From 13badc18c01fec8dedef62d17707ddb06dabf6d7 Mon Sep 17 00:00:00 2001 From: Arthur Paulino Date: Fri, 9 Oct 2026 12:50:27 +0000 Subject: [PATCH 01/31] feat: compress aggregate Ix certificates through FRI and recursive KZG Add a reusable compression pipeline for proofs of aggregated Ix root certificates, preserving the original CheckEnv claim through: aggregate root -> normalized Aiur root -> FRI -> KZG -> recursive KZG Constrain the FRI transcript, Merkle openings, quotient algebra and batch policy over Goldilocks, translate verification to the BLS12-381 scalar field, then recursively verify KZG commitments and openings. Check the final outer proof and both external pairing equations, including degree bounds. Encode the final proof under a fixed verifier layout, retaining the public pairing operands and certificate claim. Derive the verifier locally from the compiled aggregate backend, SRS and explicit bounds, without any certificate or proof witness. Normalize a root by recursively wrapping it into one all-active Aiur trace shard. Give each function and memory trace its own u8 log-height; retain native byte-table heights and constrain memory closure endpoints. Reject layouts that exceed bounds before allocating traces. The root may aggregate any number of certificate shards whose verification fits those bounds. Expose `ix compression layout`, `ix compression setup`, `ix compress` and compressed-proof verification through `ix verify`. The prover supplies only a proof address. Keep trusted settings and verifier keys outside the proof store, bind backend/SRS/layout/partition limits into configuration identity, and reject unknown configurations. Verification requires only the local settings and verifier key, not the proving SRS. Import SRS data with checked point/subgroup decoding and powers/degree-key validation; known-trapdoor development setup is an explicit option. Use bounded, versioned, canonical proof and verifier encodings. Make FRI, intermediate KZG and final KZG partition limits independent. Use lazy lowering, commit KZG partitions separately, reconstruct lookups from committed columns, and release prior witnesses and fixed evaluations as soon as possible. Store constant polynomials as a value and domain length while retaining their full degree binding. Decode SRS powers in parallel chunks and bound consistency-check MSM scratch. Pin multi-stark to the backend-independent proving and exposed Plonkish interfaces these stages require. Keep one crate README with the CLI, bounds, library entry points and a reproducible sub-3 KB recipe. Include the tested Init layout as an explicit example, not a built-in policy. Keep measurements, SRS files, binaries and proof artifacts out of the patch. Reproduction from an existing full Init root (16 aggregated shards): lake build ix export RAYON_NUM_THREADS=32 export IX_COMPRESSION_HOME="$PWD/compression" ix=.lake/build/bin/ix root=ROOT_ADDRESS "$ix" compression setup --dev-srs \ --root-layout crates/proof-compression/examples/init16-root-layout.json \ --fri-log-height 23 --log-height 25 --recursive-log-height 28 compressed=$("$ix" compress "$root") "$ix" verify "$compressed" "$ix" verify --aggregate --ixe Init.ixe --ixes Init-16.ixes "$compressed" The final 2^28 partition admits one arithmetic trace plus a 2^17 lookup table, avoiding the extra commitments/openings of a partitioned final arithmetic circuit. The SRS covers 2^28 G1 powers and all degree keys. The development SRS exercises the whole chain but is not production-secure; production requires independently trusted parameters in the same format. Other root layouts may need different bounds and can produce larger proofs. Measured full Init run on 2026-10-15, Xeon 6975P-C, 32 Rayon threads, 512 GiB machine, CPU proving, using development parameters: Native root: 5,738,532 bytes Complete Ixon.Proof: 2,707 bytes Compact KZG proof: 2,053 bytes Pairing operands: 576 bytes (12 compressed G1 points) Compression envelope: 40 bytes Ixon wrapper and claim: 38 bytes Local setup: 5,934.484 s; peak RSS 327.965 GiB Compression: 12,254.842 s; peak RSS 359.891 GiB Startup/SRS loading: 826.944 s Root normalization: 50.529 s Root -> FRI: 420.146 s FRI -> KZG: 6,412.656 s KZG -> recursive KZG: 4,544.567 s Standalone verification: 1.499 s (CLI process) Full environment audit: 1.679 s (CLI process) The audit covers all 56,810 Init constants exactly once with zero undischarged assumptions. Verification also passes without the SRS file. Stage timings include preparation and verification, not only prover work. Peak RSS values are process high-water marks. Setup is reusable. Root: 608307acecf8cef1d9d188fb571343e45baeb3f907ff954c9feaaf01eecc5391 Proof: 8adee051c53cb06dce626d7625cf8ef55948a6340a7ae5b2fe0ff3844e3ab90a Validation: - Release Rust suites for ix-proof-compression and ix-ffi: 133 passed, zero failed, one opt-in full-chain test ignored. - Aiur normalization bounds and fixed-table behavior. - Clippy with warnings denied, serial-feature build, rustfmt and diff checks. - Lean CLI/test build and CLI integration tests. - Saved full Init proof verified by the final CLI, including the environment audit and verification using only local settings/key with no SRS. - Generated fixtures cover native/constrained verifier agreement, malformed inputs, statement binding, pairing/degree checks, and dense/streamed proof equivalence. The expensive synthetic full-chain test remains opt-in; the full production Init relation was exercised by the measured run. --- Cargo.lock | 317 ++++- Cargo.toml | 3 +- Ix/Cli/CompressCmd.lean | 39 + Ix/Cli/Compression.lean | 42 + Ix/Cli/CompressionSetupCmd.lean | 87 ++ Ix/Cli/VerifyCmd.lean | 70 +- Main.lean | 4 + Tests/Cli.lean | 66 + crates/aiur/src/synthesis.rs | 87 +- crates/aiur/src/synthesis/normalization.rs | 109 ++ crates/aiur/src/vk_codec.rs | 22 +- crates/ffi/Cargo.toml | 3 +- crates/ffi/src/aiur/aggregate.rs | 2 +- crates/ffi/src/aiur/aggregate/protocol.rs | 4 +- crates/ffi/src/compression.rs | 361 +++++ crates/ffi/src/compression/layout.rs | 90 ++ crates/ffi/src/compression/local.rs | 337 +++++ crates/ffi/src/lib.rs | 1 + crates/proof-compression/Cargo.toml | 36 + crates/proof-compression/README.md | 148 ++ .../examples/init16-root-layout.json | 23 + .../proof-compression/src/circuit_metadata.rs | 107 ++ crates/proof-compression/src/foreign.rs | 934 +++++++++++++ crates/proof-compression/src/fri/algebra.rs | 427 ++++++ crates/proof-compression/src/fri/batch.rs | 375 +++++ crates/proof-compression/src/fri/key_codec.rs | 194 +++ crates/proof-compression/src/fri/mod.rs | 44 + crates/proof-compression/src/fri/pcs.rs | 526 +++++++ .../proof-compression/src/fri/pcs_witness.rs | 590 ++++++++ crates/proof-compression/src/fri/plan.rs | 751 ++++++++++ crates/proof-compression/src/fri/quadratic.rs | 145 ++ .../proof-compression/src/fri/transcript.rs | 431 ++++++ .../src/fri/transcript_tests.rs | 229 ++++ .../proof-compression/src/kzg/coefficients.rs | 185 +++ crates/proof-compression/src/kzg/compact.rs | 398 ++++++ crates/proof-compression/src/kzg/config.rs | 566 ++++++++ crates/proof-compression/src/kzg/domain.rs | 179 +++ crates/proof-compression/src/kzg/fft_batch.rs | 87 ++ crates/proof-compression/src/kzg/field.rs | 305 +++++ crates/proof-compression/src/kzg/mod.rs | 24 + crates/proof-compression/src/kzg/pcs.rs | 1216 +++++++++++++++++ crates/proof-compression/src/kzg/quotient.rs | 123 ++ .../src/kzg/quotient_plan.rs | 190 +++ crates/proof-compression/src/kzg/srs.rs | 348 +++++ .../proof-compression/src/kzg/transcript.rs | 125 ++ .../src/kzg/verifier_params.rs | 108 ++ crates/proof-compression/src/lib.rs | 7 + .../proof-compression/src/pipeline/codec.rs | 131 ++ .../proof-compression/src/pipeline/fixed.rs | 469 +++++++ crates/proof-compression/src/pipeline/mod.rs | 413 ++++++ crates/proof-compression/src/pipeline/root.rs | 103 ++ .../proof-compression/src/pipeline/setup.rs | 114 ++ .../proof-compression/src/pipeline/tests.rs | 191 +++ crates/proof-compression/src/pipeline/wrap.rs | 357 +++++ crates/proof-compression/src/recursive/mod.rs | 6 + .../src/recursive/native_curve.rs | 258 ++++ .../src/recursive/native_field.rs | 490 +++++++ .../src/recursive/native_msm.rs | 173 +++ .../src/recursive/native_transcript.rs | 242 ++++ .../src/recursive/native_verifier.rs | 718 ++++++++++ .../proof-compression/tests/kzg_sharding.rs | 95 ++ .../proof-compression/tests/kzg_streaming.rs | 223 +++ .../proof-compression/tests/parity_fixture.rs | 175 +++ .../proof-compression/tests/pipeline_root.rs | 175 +++ .../proof-compression/tests/plonkish_batch.rs | 342 +++++ .../proof-compression/tests/plonkish_hash.rs | 226 +++ .../proof-compression/tests/plonkish_pcs.rs | 143 ++ .../proof-compression/tests/plonkish_plan.rs | 678 +++++++++ .../tests/plonkish_transcript.rs | 95 ++ .../tests/plonkish_verifier_algebra.rs | 501 +++++++ .../tests/plonkish_verifier_security.rs | 197 +++ .../proof-compression/tests/root_profile.rs | 266 ++++ .../proof-compression/tests/support/parity.rs | 127 ++ .../tests/support/parity_algebra.rs | 179 +++ .../tests/support/root_profile.rs | 404 ++++++ .../tests/support/verifier_audit.rs | 93 ++ 76 files changed, 17977 insertions(+), 72 deletions(-) create mode 100644 Ix/Cli/CompressCmd.lean create mode 100644 Ix/Cli/Compression.lean create mode 100644 Ix/Cli/CompressionSetupCmd.lean create mode 100644 crates/aiur/src/synthesis/normalization.rs create mode 100644 crates/ffi/src/compression.rs create mode 100644 crates/ffi/src/compression/layout.rs create mode 100644 crates/ffi/src/compression/local.rs create mode 100644 crates/proof-compression/Cargo.toml create mode 100644 crates/proof-compression/README.md create mode 100644 crates/proof-compression/examples/init16-root-layout.json create mode 100644 crates/proof-compression/src/circuit_metadata.rs create mode 100644 crates/proof-compression/src/foreign.rs create mode 100644 crates/proof-compression/src/fri/algebra.rs create mode 100644 crates/proof-compression/src/fri/batch.rs create mode 100644 crates/proof-compression/src/fri/key_codec.rs create mode 100644 crates/proof-compression/src/fri/mod.rs create mode 100644 crates/proof-compression/src/fri/pcs.rs create mode 100644 crates/proof-compression/src/fri/pcs_witness.rs create mode 100644 crates/proof-compression/src/fri/plan.rs create mode 100644 crates/proof-compression/src/fri/quadratic.rs create mode 100644 crates/proof-compression/src/fri/transcript.rs create mode 100644 crates/proof-compression/src/fri/transcript_tests.rs create mode 100644 crates/proof-compression/src/kzg/coefficients.rs create mode 100644 crates/proof-compression/src/kzg/compact.rs create mode 100644 crates/proof-compression/src/kzg/config.rs create mode 100644 crates/proof-compression/src/kzg/domain.rs create mode 100644 crates/proof-compression/src/kzg/fft_batch.rs create mode 100644 crates/proof-compression/src/kzg/field.rs create mode 100644 crates/proof-compression/src/kzg/mod.rs create mode 100644 crates/proof-compression/src/kzg/pcs.rs create mode 100644 crates/proof-compression/src/kzg/quotient.rs create mode 100644 crates/proof-compression/src/kzg/quotient_plan.rs create mode 100644 crates/proof-compression/src/kzg/srs.rs create mode 100644 crates/proof-compression/src/kzg/transcript.rs create mode 100644 crates/proof-compression/src/kzg/verifier_params.rs create mode 100644 crates/proof-compression/src/lib.rs create mode 100644 crates/proof-compression/src/pipeline/codec.rs create mode 100644 crates/proof-compression/src/pipeline/fixed.rs create mode 100644 crates/proof-compression/src/pipeline/mod.rs create mode 100644 crates/proof-compression/src/pipeline/root.rs create mode 100644 crates/proof-compression/src/pipeline/setup.rs create mode 100644 crates/proof-compression/src/pipeline/tests.rs create mode 100644 crates/proof-compression/src/pipeline/wrap.rs create mode 100644 crates/proof-compression/src/recursive/mod.rs create mode 100644 crates/proof-compression/src/recursive/native_curve.rs create mode 100644 crates/proof-compression/src/recursive/native_field.rs create mode 100644 crates/proof-compression/src/recursive/native_msm.rs create mode 100644 crates/proof-compression/src/recursive/native_transcript.rs create mode 100644 crates/proof-compression/src/recursive/native_verifier.rs create mode 100644 crates/proof-compression/tests/kzg_sharding.rs create mode 100644 crates/proof-compression/tests/kzg_streaming.rs create mode 100644 crates/proof-compression/tests/parity_fixture.rs create mode 100644 crates/proof-compression/tests/pipeline_root.rs create mode 100644 crates/proof-compression/tests/plonkish_batch.rs create mode 100644 crates/proof-compression/tests/plonkish_hash.rs create mode 100644 crates/proof-compression/tests/plonkish_pcs.rs create mode 100644 crates/proof-compression/tests/plonkish_plan.rs create mode 100644 crates/proof-compression/tests/plonkish_transcript.rs create mode 100644 crates/proof-compression/tests/plonkish_verifier_algebra.rs create mode 100644 crates/proof-compression/tests/plonkish_verifier_security.rs create mode 100644 crates/proof-compression/tests/root_profile.rs create mode 100644 crates/proof-compression/tests/support/parity.rs create mode 100644 crates/proof-compression/tests/support/parity_algebra.rs create mode 100644 crates/proof-compression/tests/support/root_profile.rs create mode 100644 crates/proof-compression/tests/support/verifier_audit.rs diff --git a/Cargo.lock b/Cargo.lock index 587b08f59..57de1edd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -97,6 +109,136 @@ dependencies = [ "object", ] +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-traits", + "paste", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "rayon", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec", + "digest 0.10.7", + "num-bigint 0.4.6", + "rayon", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.8", + "rayon", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -117,7 +259,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -238,7 +380,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.117", ] [[package]] @@ -545,7 +687,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -569,7 +711,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -580,7 +722,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -642,7 +784,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -652,7 +794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -674,7 +816,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -725,7 +867,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -775,6 +917,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" @@ -802,7 +956,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -813,7 +967,27 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -991,7 +1165,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1693,7 +1867,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1835,6 +2009,7 @@ dependencies = [ "ix-common", "ix-compile", "ix-kernel", + "ix-proof-compression", "ixon", "ixvm-codegen", "lean-ffi", @@ -1875,6 +2050,33 @@ dependencies = [ "smallvec", ] +[[package]] +name = "ix-proof-compression" +version = "0.1.0" +dependencies = [ + "aiur", + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "bincode", + "blake3", + "multi-stark", + "num-bigint 0.4.6", + "p3-blake3", + "p3-challenger", + "p3-commit", + "p3-field", + "p3-goldilocks", + "p3-matrix", + "p3-maybe-rayon", + "p3-symmetric", + "p3-util", + "serde", + "tracing", +] + [[package]] name = "ixon" version = "0.1.0" @@ -2118,7 +2320,7 @@ dependencies = [ [[package]] name = "multi-stark" version = "0.1.0" -source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=acfc370a2e74affcb7e8ee218fbbaf80b9da1eef#acfc370a2e74affcb7e8ee218fbbaf80b9da1eef" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=63ecc52b0b54d765b886d7d9ebc98cd90005496b#63ecc52b0b54d765b886d7d9ebc98cd90005496b" dependencies = [ "bincode", "itertools 0.14.0", @@ -2160,7 +2362,7 @@ checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2459,7 +2661,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2885,7 +3087,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3009,7 +3211,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3043,7 +3245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -3102,7 +3304,7 @@ checksum = "a9a28b8493dd664c8b171dd944da82d933f7d456b829bfb236738e1fe06c5ba4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3181,13 +3383,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -3202,6 +3414,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3212,6 +3434,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -3384,7 +3612,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3512,7 +3740,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3650,7 +3878,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3739,7 +3967,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3759,6 +3987,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3776,7 +4015,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3812,7 +4051,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3916,7 +4155,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4072,7 +4311,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4349,7 +4588,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4531,7 +4770,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4542,7 +4781,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4735,7 +4974,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4751,7 +4990,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4867,7 +5106,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4894,7 +5133,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4914,7 +5153,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4935,7 +5174,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4968,7 +5207,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fb42bb09..512ac549d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/bench", "crates/common", "crates/compile", + "crates/proof-compression", "crates/ffi", "crates/ixvm-codegen", "crates/ixon", @@ -52,7 +53,7 @@ log = "0.4" memmap2 = "0.9" # Bundle v3.5.1 to fix large-arena failures (microsoft/mimalloc#1309). mimalloc = { git = "https://github.com/argumentcomputer/mimalloc_rust.git", rev = "616cc1f130d9ea2a1111a46b4e98c140e078f882", default-features = false } -multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "acfc370a2e74affcb7e8ee218fbbaf80b9da1eef" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "63ecc52b0b54d765b886d7d9ebc98cd90005496b" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean new file mode 100644 index 000000000..cc79b419e --- /dev/null +++ b/Ix/Cli/CompressCmd.lean @@ -0,0 +1,39 @@ +module +public import Cli +public import Ix.Cli.Compression + +public section +namespace Ix.Cli.CompressCmd + +def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do + try + let hex := p.positionalArg! "proof" |>.as! String + let some addr := Address.fromString hex + | throw <| IO.userError "expected a 64-character proof address" + let wrapper ← Compression.readProof addr + let .checkEnv _ _ := wrapper.claim + | throw <| IO.userError "compression requires an aggregate CheckEnv proof" + if Compression.isCompressed wrapper.proof then + throw <| IO.userError "proof is already compressed" + let _ ← IO.ofExcept (Aiur.Proof.ofBytesChecked wrapper.proof) + let dir ← Compression.requireSetup + let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) + let proof ← Compression.compress backend.system backend.allowed backend.aggrIdx + (Ix.Claim.ser wrapper.claim) wrapper.proof dir.toString + let compressed : Ixon.Proof := { claim := wrapper.claim, proof } + let result ← StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) + IO.println (toString result) + return 0 + catch e => + IO.eprintln s!"compress: {e}" + return 1 + +end Ix.Cli.CompressCmd +open Ix.Cli.CompressCmd in +def compressCmd : Cli.Cmd := `[Cli| + compress VIA runCompressCmd; + "Compress an aggregate root with the active local configuration; preserve its claim and print the new store address. Run ix compression setup first." + ARGS: + proof : String; "Store address of a root produced by ix aggregate with the current default parameters." +] +end diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean new file mode 100644 index 000000000..eda752f15 --- /dev/null +++ b/Ix/Cli/Compression.lean @@ -0,0 +1,42 @@ +module +public import Ix.Aggr.Backend +public import Ix.Aggr.Verification +public import Ix.Store + +public section +namespace Ix.Cli.Compression + +@[extern "rs_compression_setup"] +opaque setup (system : @& Aiur.AiurSystem) (dir srs : @& String) + (devSrs : Bool) (rootLayout : @& String) (rootLogHeight friLogHeight kzgLogHeight recursiveLogHeight claimLen : UInt32) : IO String + +@[extern "rs_compression_layout"] +opaque layout (system : @& Aiur.AiurSystem) (logHeight : UInt32) : IO String + +@[extern "rs_compression_run"] +opaque compress (system : @& Aiur.AiurSystem) (allowed : @& ByteArray) + (aggrIdx : @& Nat) (certificate proof : @& ByteArray) (dir : @& String) : IO ByteArray + +@[extern "rs_compression_verify"] +opaque verify (rootVk proof : @& ByteArray) (claim : @& Array Aiur.G) + (dir : @& String) : IO Unit + +def directory : IO System.FilePath := do + if let some path ← IO.getEnv "IX_COMPRESSION_HOME" then return path + return (← StoreIO.toIO Store.getHomeDir) / ".ix/compression" + +def requireSetup : IO System.FilePath := do + let dir ← directory + unless ← (dir / "default").pathExists do + throw <| IO.userError "no active compression configuration; run `ix compression setup`" + return dir + +def isCompressed (proof : ByteArray) : Bool := + proof.extract 0 5 == "IXCMP".toUTF8 + +def readProof (addr : Address) : IO Ixon.Proof := do + let bytes ← StoreIO.toIO (Store.read addr) + IO.ofExcept (Aggr.decodeAggregateWrapperAt addr bytes) + +end Ix.Cli.Compression +end diff --git a/Ix/Cli/CompressionSetupCmd.lean b/Ix/Cli/CompressionSetupCmd.lean new file mode 100644 index 000000000..9d14b160b --- /dev/null +++ b/Ix/Cli/CompressionSetupCmd.lean @@ -0,0 +1,87 @@ +module +public import Cli +public import Ix.Cli.Compression + +public section +namespace Ix.Cli.CompressionSetupCmd + +def runSetup (p : Cli.Parsed) : IO UInt32 := do + try + let srs := (p.flag? "srs").map (·.as! String) + let dev := p.hasFlag "dev-srs" + unless srs.isSome != dev do + throw <| IO.userError "specify exactly one of --srs FILE or --dev-srs" + let rootLog := ((p.flag? "root-log-height").map (·.as! Nat)).getD 20 + let layoutPath := (p.flag? "root-layout").map (·.as! String) + if layoutPath.isSome && p.hasFlag "root-log-height" then + throw <| IO.userError "specify either --root-layout or --root-log-height" + let rootLayout ← match layoutPath with + | some path => IO.FS.readFile path + | none => pure "" + if layoutPath.isSome && rootLayout.trimAscii.toString.isEmpty then + throw <| IO.userError "root layout file is empty" + let friLog := ((p.flag? "fri-log-height").map (·.as! Nat)).getD 23 + let kzgLog := ((p.flag? "log-height").map (·.as! Nat)).getD 24 + let recursiveLog := ((p.flag? "recursive-log-height").map (·.as! Nat)).getD kzgLog + unless 1 ≤ rootLog && rootLog ≤ 26 && 16 ≤ friLog && friLog ≤ 26 && 16 ≤ kzgLog && kzgLog ≤ 26 && 16 ≤ recursiveLog && recursiveLog ≤ 28 do + throw <| IO.userError "--root-log-height must be 1–26; --fri-log-height and --log-height must be 16–26; --recursive-log-height must be 16–28" + if let some path := srs then + unless ← (System.FilePath.mk path).pathExists do + throw <| IO.userError s!"SRS file does not exist: {path}" + if dev then + IO.eprintln "Using a known-trapdoor development SRS; proofs are not production-secure." + let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) + let claim := Aiur.buildClaim backend.aggrIdx (Aggr.pubInput backend.allowed ByteArray.empty) #[] + let dir ← Compression.directory + let id ← Compression.setup backend.system dir.toString (srs.getD "") dev + rootLayout rootLog.toUInt32 friLog.toUInt32 kzgLog.toUInt32 recursiveLog.toUInt32 claim.size.toUInt32 + IO.eprintln s!"Local compression configuration ready in {dir}." + IO.println id + return 0 + catch e => + IO.eprintln s!"compression setup: {e}" + return 1 + +def runLayout (p : Cli.Parsed) : IO UInt32 := do + try + let log := ((p.flag? "root-log-height").map (·.as! Nat)).getD 20 + unless 1 ≤ log && log ≤ 26 do + throw <| IO.userError "--root-log-height must be 1–26" + let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) + IO.println (← Compression.layout backend.system log.toUInt32) + return 0 + catch e => + IO.eprintln s!"compression layout: {e}" + return 1 + +end Ix.Cli.CompressionSetupCmd +open Ix.Cli.CompressionSetupCmd in +def compressionSetupCmd : Cli.Cmd := `[Cli| + setup VIA runSetup; + "Derive and activate a fixed compression verifier locally, without a certificate or proof." + FLAGS: + "srs" : String; "Trusted SRS file in IXKZGS01 format." + "dev-srs"; "Explicitly use a known-trapdoor development setup; not production-secure." + "root-layout" : String; "JSON per-trace bounds from `ix compression layout`; edit before setup." + "root-log-height" : Nat; "Uniform fallback function/memory log-height, 1–26 (default 20); exclusive with --root-layout." + "fri-log-height" : Nat; "FRI partition log-height, 16–26 (default 23); must fit indivisible hash traces." + "log-height" : Nat; "Intermediate KZG partition log-height, 16–26 (default 24)." + "recursive-log-height" : Nat; "Final KZG partition log-height, 16–28 (default: --log-height); SRS must cover both KZG stages." +] + +open Ix.Cli.CompressionSetupCmd in +def compressionLayoutCmd : Cli.Cmd := `[Cli| + layout VIA runLayout; + "Print a JSON trace-bound template for the local aggregate backend. Edit heights before setup; no proof or SRS needed." + FLAGS: + "root-log-height" : Nat; "Initial function/memory log-height, 1–26 (default 20)." +] + +def compressionCmd : Cli.Cmd := `[Cli| + compression NOOP; + "Configure proof compression locally." + SUBCOMMANDS: + compressionSetupCmd; + compressionLayoutCmd +] +end diff --git a/Ix/Cli/VerifyCmd.lean b/Ix/Cli/VerifyCmd.lean index 6390e3447..865e0fedb 100644 --- a/Ix/Cli/VerifyCmd.lean +++ b/Ix/Cli/VerifyCmd.lean @@ -25,6 +25,7 @@ public import Ix.Aggr.Backend public import Ix.Aggr.Verification public import Ix.Shard.Environment public import Ix.Cli.ShardProofIndex +public import Ix.Cli.Compression public section @@ -108,27 +109,33 @@ private def verifyAggregateProof (backend : Aggr.VerificationBackend) IO.eprintln s!"error: aggregate claim {wrapper.claim} does not match expected {expected.claim}" return 1 | none => pure () - let proof ← match Aiur.Proof.ofBytesChecked wrapper.proof with - | .ok proof => pure proof + let outerClaim := Aggr.aggregateOuterClaim backend.allowed backend.aggrIdx wrapper.claim + let verifyStarted ← IO.monoMsNow + if Compression.isCompressed wrapper.proof then + try + Compression.verify backend.system.vkBytes wrapper.proof outerClaim (← Compression.directory).toString + catch e => + IO.eprintln s!"error: compressed verification failed: {e}" + return 1 + else + let proof ← match Aiur.Proof.ofBytesChecked wrapper.proof with + | .ok proof => pure proof + | .error e => + IO.eprintln s!"error: aggregate proof {proofAddr} does not decode: {e}" + return 1 + match backend.system.verify outerClaim proof with + | .ok () => pure () | .error e => - IO.eprintln s!"error: aggregate proof {proofAddr} does not decode: {e}" + IO.eprintln s!"error: aggregate verification failed: {e}" return 1 - let outerClaim := Aggr.aggregateOuterClaim - backend.allowed backend.aggrIdx wrapper.claim - let verifyStarted ← IO.monoMsNow - match backend.system.verify outerClaim proof with - | .ok () => - IO.println s!"[verify] aggregate Aiur proof: {(← IO.monoMsNow) - verifyStarted}ms" - IO.println s!"ok: aggregate proof {proofAddr} verifies {wrapper.claim}" - if let some expected := expected? then - IO.println s!"[verify] aggregate coverage: {expected.constantCount}/\ - {expected.constantCount} environment constants committed exactly once" - IO.println s!"[verify] all {expected.constantCount} included constants \ - certified well-typed; 0 undischarged assumptions" - return 0 - | .error e => - IO.eprintln s!"error: aggregate verification failed: {e}" - return 1 + IO.println s!"[verify] aggregate proof: {(← IO.monoMsNow) - verifyStarted}ms" + IO.println s!"ok: aggregate proof {proofAddr} verifies {wrapper.claim}" + if let some expected := expected? then + IO.println s!"[verify] aggregate coverage: {expected.constantCount}/\ + {expected.constantCount} environment constants committed exactly once" + IO.println s!"[verify] all {expected.constantCount} included constants \ + certified well-typed; 0 undischarged assumptions" + return 0 /-- Shard-aware verification (parity with `check`/`prove`): - `--shard K`, no proof: print shard K's reconstructed `CheckEnv` claim @@ -287,13 +294,28 @@ def runVerifyCmdWith (recursionParameters : Aggr.RecursionParameters) if proofs.isEmpty then p.printError "error: must specify ... (or --ixe + --ixes for a shard partition)" return 1 - let (aiurSystem, compiled) ← match (← buildBackend) with - | .error e => IO.eprintln e; return 1 - | .ok b => pure b + let mut ordinary : Option (Aiur.AiurSystem × Aiur.CompiledToplevel) := none + let mut aggregate : Option Aggr.VerificationBackend := none let mut rc : UInt32 := 0 for hex in proofs do let proofAddr ← addrOfHex! "proof" hex - if (← verifyOneProof aiurSystem compiled proofAddr) != 0 then rc := 1 + let wrapper ← Compression.readProof proofAddr + if Compression.isCompressed wrapper.proof then + let backend ← match aggregate with + | some backend => pure backend + | none => + let backend ← IO.ofExcept (← Aggr.buildVerificationBackend recursionParameters) + aggregate := some backend + pure backend + if (← verifyAggregateProof backend none proofAddr) != 0 then rc := 1 + else + let (aiurSystem, compiled) ← match ordinary with + | some backend => pure backend + | none => + let backend ← IO.ofExcept (← buildBackend) + ordinary := some backend + pure backend + if (← verifyOneProof aiurSystem compiled proofAddr) != 0 then rc := 1 return rc def runVerifyCmd (p : Cli.Parsed) : IO UInt32 := @@ -304,7 +326,7 @@ end Ix.Cli.VerifyCmd open Ix.Cli.VerifyCmd in def verifyCmd : Cli.Cmd := `[Cli| verify VIA runVerifyCmd; - "Verify STARK proof(s) against their bundled claims, or a `.ixes` shard partition" + "Verify native or compressed proof(s) against their bundled claims, or a `.ixes` shard partition" FLAGS: "ixe" : String; "Path to a serialized `.ixe` env (with --ixes). With no proof args and no --shard: verify the partition off-circuit (every constant owned by exactly one shard)." diff --git a/Main.lean b/Main.lean index 6963f1ad3..1153878e5 100644 --- a/Main.lean +++ b/Main.lean @@ -11,6 +11,8 @@ import Ix.Cli.ValidateLeanCmd import Ix.Cli.ClaimCmd import Ix.Cli.CatalogCmd import Ix.Cli.CompileCmd +import Ix.Cli.CompressCmd +import Ix.Cli.CompressionSetupCmd import Ix.Cli.DecompileCmd import Ix.Cli.DiffCmd import Ix.Cli.IngressCmd @@ -39,6 +41,8 @@ def ixCmd : Cli.Cmd := `[Cli| aggregateCmd; catalogCmd; compileCmd; + compressCmd; + compressionCmd; decompileCmd; mergeCmd; checkCmd; diff --git a/Tests/Cli.lean b/Tests/Cli.lean index 9f632f5d5..bde7a82e8 100644 --- a/Tests/Cli.lean +++ b/Tests/Cli.lean @@ -1,6 +1,7 @@ module public import Ix.Ixon +public import Ix.Cli.Compression /- Integration tests for the Ix CLI -/ @@ -164,11 +165,76 @@ private def Tests.Cli.testCompileSharingLimits : IO Unit := do finally IO.FS.removeDirAll dir +/-- Compression CLI contracts use generated inputs and temporary directories; + no external certificates, setup files, or expensive proving is required. -/ +public def Tests.Cli.testCompression : IO Unit := do + let ix ← IO.FS.realPath ".lake/build/bin/ix" + let dir ← IO.FS.createTempDir + let run := fun args => IO.Process.output { + cmd := ix.toString, args, env := #[("IX_COMPRESSION_HOME", some dir.toString)] } + let reject := fun args message => do + let result ← run args + unless result.exitCode != 0 && (result.stderr.splitOn message).length > 1 do + throw <| IO.userError s!"unexpected compression CLI result: {result.stdout}\n{result.stderr}" + try + for args in [#["compress", "--help"], #["verify", "--help"], + #["compression", "setup", "--help"], #["compression", "layout", "--help"]] do + let help ← run args + unless help.exitCode == 0 do + throw <| IO.userError s!"compression help failed: {help.stderr}" + let args := #["compression", "setup"] + reject args "specify exactly one" + reject (args ++ #["--dev-srs", "--srs", "missing"]) "specify exactly one" + for flag in ["--root-log-height", "--fri-log-height", "--log-height"] do + for value in ["0", "27", "4294967296"] do + reject (args ++ #["--dev-srs", flag, value]) "must be" + for value in ["0", "29", "4294967296"] do + reject (args ++ #["--dev-srs", "--recursive-log-height", value]) "must be" + reject (args ++ #["--dev-srs", "--root-layout", "missing", "--root-log-height", "20"]) + "specify either" + reject #["compression", "layout", "--root-log-height", "256"] "must be" + reject (args ++ #["--srs", (dir / "missing").toString]) "SRS file does not exist" + reject (args ++ #["--srs", (dir / "missing").toString, + "--log-height", "25", "--recursive-log-height", "28"]) "SRS file does not exist" + let template ← run #["compression", "layout", "--root-log-height", "12"] + unless template.exitCode == 0 do + throw <| IO.userError s!"compression layout failed: {template.stderr}" + let json ← IO.ofExcept (Lean.Json.parse template.stdout) + let functions ← IO.ofExcept ((json.getObjVal? "functions").bind Lean.Json.getArr?) + unless !functions.isEmpty && functions.all (fun v => v.getNat?.toOption == some 12) do + throw <| IO.userError "compression layout has incorrect function heights" + let layoutPath := dir / "layout.json" + IO.FS.writeFile layoutPath "{}" + reject (args ++ #["--dev-srs", "--root-layout", layoutPath.toString]) + "root layout requires exactly" + IO.FS.removeFile layoutPath + reject #["compress", "invalid-address"] "expected a 64-character proof address" + -- Exercise the Lean/Rust boundary, including the Array G argument. + let rejected ← try + Ix.Cli.Compression.verify ByteArray.empty ByteArray.empty #[1, 2] dir.toString + pure false + catch _ => pure true + unless rejected do throw <| IO.userError "compression FFI accepted an invalid proof" + -- A structurally valid envelope cannot supply a verifier or select an + -- unconfigured local key. + let envelope := "IXCMP002".toUTF8 ++ ByteArray.mk (Array.replicate 33 0) + let rejected ← try + Ix.Cli.Compression.verify ByteArray.empty envelope #[1, 2] dir.toString + pure false + catch _ => pure true + unless rejected do throw <| IO.userError "compression FFI accepted an unknown configuration" + unless (← dir.readDir).isEmpty do + throw <| IO.userError "invalid compression inputs created local configuration files" + IO.println "compression CLI: setup validation and proof-only FFI rejection checks passed" + finally + IO.FS.removeDirAll dir + public def Tests.Cli.suite : IO UInt32 := do Tests.Cli.run "lake" (#["exe", "ix", "--help"]) none Tests.Cli.testCompileNoBuild Tests.Cli.testCompileContracts Tests.Cli.testCompileSharingLimits + Tests.Cli.testCompression --Tests.Cli.run "ix" (#["store", "ix_test/IxTest.lean"]) none --Tests.Cli.run "ix" (#["prove", "ix_test/IxTest.lean", "one"]) none return 0 diff --git a/crates/aiur/src/synthesis.rs b/crates/aiur/src/synthesis.rs index a3f0a2ef1..c0e66bb74 100644 --- a/crates/aiur/src/synthesis.rs +++ b/crates/aiur/src/synthesis.rs @@ -13,6 +13,8 @@ use multi_stark::{ verifier::VerificationError, }; +mod normalization; + use crate::{ G, bytecode::{FunIdx, Toplevel}, @@ -67,7 +69,18 @@ pub(crate) fn verify_against( claim: &[G], proof: &AiurProof, ) -> Result<(), AiurVerificationError> { - for header in &proof.preamble.headers { + validate_preamble(system, commitment_parameters, claim, &proof.preamble)?; + system.verify_batch(proof)?; + Ok(()) +} + +pub(crate) fn validate_preamble( + system: &System, + commitment_parameters: CommitmentParameters, + claim: &[G], + preamble: &multi_stark::batch::BatchPreamble, +) -> Result<(), AiurVerificationError> { + for header in &preamble.headers { let well_shaped = fixed_trace_heights( system.circuits.iter().map(|circuit| circuit.preprocessed_height), &header.active, @@ -88,9 +101,8 @@ pub(crate) fn verify_against( )); } } - check_batch_policy(system.circuits.len(), claim, &proof.preamble) + check_batch_policy(system.circuits.len(), claim, preamble) .map_err(AiurVerificationError::Policy)?; - system.verify_batch(proof)?; Ok(()) } @@ -1571,6 +1583,75 @@ mod tests { IOBuffer { data: FxHashMap::default(), map: FxHashMap::default() } } + #[test] + fn fixed_normalization_pads_unused_tables_and_checks_bounds() { + let (cp, fp) = test_parameters(); + let mut toplevel = call_and_memory_toplevel(); + toplevel.memory_sizes.push(2); // An unused memory table must also be active. + let system = AiurSystem::build(toplevel, cp, fp); + let mut heights = vec![4; system.system.circuits.len()]; + let n = heights.len(); + heights[n - 3] = 1; // Unused memory has its own small bound. + heights[n - 2] = 8; + heights[n - 1] = 16; + let mut previous = None; + for input in [ + vec![G::from_u64(3), G::from_u64(5)], + vec![G::from_u64(7), G::from_u64(11)], + ] { + let mut io = empty_io_buffer(); + let (record, output) = + system.toplevel().execute(0, input.clone(), &mut io).unwrap(); + let (claim, proof) = system + .prove_from_execution_normalized( + 0, &input, &io, record, &output, &heights, + ) + .unwrap(); + system.verify(&claim, &proof).unwrap(); + assert!(proof.preamble.headers[0].active.iter().all(|&v| v)); + let logs = &proof.preamble.headers[0].log_degrees; + assert_eq!(logs, &heights); + if let Some(old) = &previous { + assert_eq!(old, logs); + } + previous = Some(logs.clone()); + assert_eq!( + proof.preamble.messages.len(), + 2 * system.toplevel().memory_sizes.len() + ); + let mut invalid = + vec![vec![], vec![0; heights.len()], vec![27; heights.len()]]; + let mut wrong_fixed = heights.clone(); + wrong_fixed[n - 1] = 15; + invalid.push(wrong_fixed); + for wrong in invalid { + let (record, output) = + system.toplevel().execute(0, input.clone(), &mut io).unwrap(); + assert!( + system + .prove_from_execution_normalized( + 0, &input, &io, record, &output, &wrong + ) + .is_err() + ); + } + } + let mut io = empty_io_buffer(); + let mut record = QueryRecord::new(system.toplevel()); + for i in 0..3 { + system + .toplevel() + .execute_in(0, vec![G::from_usize(i), G::ONE], &mut io, &mut record) + .unwrap(); + } + heights[0] = 1; + let err = system + .prove_from_execution_normalized(0, &[], &io, record, &[], &heights) + .err() + .expect("oversized execution"); + assert!(err.contains("needs 3 rows; configured bound is 2"), "{err}"); + } + /// Hand-build the toplevel for a single constrained function `f(a, b) = a*b`. /// /// Body: `Mul(0, 1)` (multiplies the two inputs, which live at value indices diff --git a/crates/aiur/src/synthesis/normalization.rs b/crates/aiur/src/synthesis/normalization.rs new file mode 100644 index 000000000..feb7b66c9 --- /dev/null +++ b/crates/aiur/src/synthesis/normalization.rs @@ -0,0 +1,109 @@ +//! Fixed-layout proving of an already executed invocation. Used after a +//! recursive wrap to normalize arbitrary child proofs for compression. +use super::*; +use multi_stark::{batch::ShardInput, system::SystemWitness}; + +impl AiurSystem { + /// Prove one invocation in a fixed, all-active trace layout. Function and + /// memory tables have their configured power-of-two heights; fixed byte + /// tables retain their native heights. Empty tables are zero-padded. Memory + /// closures have one pair per statically declared width, including empty intervals. + /// Heights are in system order: function circuits, memories, then byte tables. + /// Returns an error before trace allocation if any execution table exceeds + /// its bound. The caller supplies the matching execution record and output. + pub fn prove_from_execution_normalized( + &self, + fun_idx: FunIdx, + input: &[G], + io: &IOBuffer, + record: QueryRecord, + output: &[G], + log_heights: &[u8], + ) -> Result<(Vec, AiurProof), String> { + if record.pointer_base != 0 { + return Err("normalization requires pointer base zero".into()); + } + if log_heights.len() != self.system.circuits.len() + || log_heights.iter().any(|log| !(1..=26).contains(log)) + { + return Err( + "normalization requires one log-height in 1–26 per circuit".into(), + ); + } + for (i, (circuit, &log)) in + self.system.circuits.iter().zip(log_heights).enumerate() + { + if circuit.preprocessed_height != 0 + && circuit.preprocessed_height != 1usize << log + { + return Err(format!( + "normalization circuit {i} must retain its preprocessed height" + )); + } + } + let mut plan = self.single_shard_plan(&record); + for (i, (rows, &log)) in + plan.shards[0].rows.iter().zip(log_heights).enumerate() + { + let target = 1usize << log; + if rows.len() > target { + return Err(format!( + "normalization circuit {i} needs {} rows; configured bound is {target}", + rows.len() + )); + } + } + let active = vec![true; log_heights.len()]; + if lookup_query_bound( + self.system.circuits.iter().map(|c| c.num_lookups), + &active, + log_heights, + ) + .is_none() + || !trace_cap_coverage( + self.commitment_parameters.log_blowup, + self.commitment_parameters.cap_height, + log_heights, + ) + { + return Err("normalized layout exceeds the Aiur proof bounds".into()); + } + plan.memory_totals = self + .toplevel + .memory_sizes + .iter() + .map(|&width| { + ( + width, + 0, + record.memory_queries.get(&width).map_or(0, |rows| rows.len()), + ) + }) + .collect(); + plan.memory_totals.sort_unstable(); + let messages = Self::boundary_messages(&plan); + let index = self.row_index(&record, &plan); + let mut traces = self.shard_witness(&record, io, &plan, &index, 0).traces; + drop(record); + for (trace, &log) in traces.iter_mut().zip(log_heights) { + let size = trace + .width + .checked_mul(1usize << log) + .ok_or("normalized trace size overflow")?; + trace.values.resize(size, G::ZERO); + } + let witness = SystemWitness::from_stage_1(traces, &self.system); + let mut claim = vec![function_channel(), G::from_usize(fun_idx)]; + claim.extend(input); + claim.extend(output); + let proof = self.system.prove_batch( + &self.key, + vec![ShardInput { claims: vec![claim.clone()], witness }], + messages, + ); + self + .verify(&claim, &proof) + .map_err(|e| format!("normalized proof: {e:?}"))?; + Ok((claim, proof)) + } +} diff --git a/crates/aiur/src/vk_codec.rs b/crates/aiur/src/vk_codec.rs index 73ca8081e..3ed529a6a 100644 --- a/crates/aiur/src/vk_codec.rs +++ b/crates/aiur/src/vk_codec.rs @@ -196,7 +196,7 @@ fn encode_circuit(buf: &mut Vec, circuit: &Circuit) { /// skipped — see the module docs). The config's construction parameters are /// passed alongside because [`AiurConfig`] doesn't expose them back; they are /// written first so the decoder can rebuild the config. -pub(crate) fn to_bytes( +pub fn to_bytes( system: &System, commitment_parameters: CommitmentParameters, fri_parameters: FriParameters, @@ -523,6 +523,26 @@ impl AiurVerifyingKey { self.fri_parameters } + /// Constraint system and fixed commitments for recursive verifier construction. + pub fn system(&self) -> &System { + &self.system + } + + /// Check application policy and trace bounds without a proof. Intended for + /// deterministic construction of a verifier circuit, not proof verification. + pub fn validate_preamble( + &self, + claim: &[Val], + preamble: &multi_stark::batch::BatchPreamble, + ) -> Result<(), crate::synthesis::AiurVerificationError> { + crate::synthesis::validate_preamble( + &self.system, + self.commitment_parameters, + claim, + preamble, + ) + } + pub fn num_circuits(&self) -> usize { self.system.circuits.len() } diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 28bd56b5d..a3e1933e2 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -21,6 +21,7 @@ ix-common = { workspace = true } ix-compile = { workspace = true } ixon = { workspace = true } ix-kernel = { workspace = true } +ix-proof-compression = { path = "../proof-compression", default-features = false } lean-ffi = { workspace = true } memmap2 = { workspace = true } multi-stark = { workspace = true } @@ -47,7 +48,7 @@ serde = { version = "1.0.219", features = ["derive"], optional = true } [features] default = [] -parallel = ["aiur/parallel"] +parallel = ["aiur/parallel", "ix-proof-compression/parallel"] cuda = ["aiur/cuda"] cuda-trace-codegen = ["cuda", "aiur/cuda-trace-codegen"] test-ffi = [] diff --git a/crates/ffi/src/aiur/aggregate.rs b/crates/ffi/src/aiur/aggregate.rs index 5011fd5c1..f74226459 100644 --- a/crates/ffi/src/aiur/aggregate.rs +++ b/crates/ffi/src/aiur/aggregate.rs @@ -38,7 +38,7 @@ mod ffi; pub mod lanes; mod plan; mod prepare; -mod protocol; +pub(crate) mod protocol; mod prove; mod scheduler; mod statement; diff --git a/crates/ffi/src/aiur/aggregate/protocol.rs b/crates/ffi/src/aiur/aggregate/protocol.rs index 460e10400..e03a6e980 100644 --- a/crates/ffi/src/aiur/aggregate/protocol.rs +++ b/crates/ffi/src/aiur/aggregate/protocol.rs @@ -47,7 +47,7 @@ pub(super) fn build_claim(fun_idx: usize, input: &[G], output: &[G]) -> Vec { claim } -pub(super) fn serialize_claims(claims: &[&[G]]) -> Vec { +pub(crate) fn serialize_claims(claims: &[&[G]]) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&(claims.len() as u64).to_le_bytes()); for claim in claims { @@ -63,7 +63,7 @@ pub(super) fn inner_claim(verify_idx: usize, claim_bytes: &[u8]) -> Vec { build_claim(verify_idx, &packed_digest(claim_bytes), &[]) } -pub(super) fn aggregate_outer_claim( +pub(crate) fn aggregate_outer_claim( aggr_idx: usize, allowed: &[u8], claim_bytes: &[u8], diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs new file mode 100644 index 000000000..f624f99e1 --- /dev/null +++ b/crates/ffi/src/compression.rs @@ -0,0 +1,361 @@ +//! Locally generated fixed compression configurations. No verifier import. +mod layout; +mod local; +use crate::aiur::aggregate::protocol::{ + aggregate_outer_claim, serialize_claims, +}; +use aiur::{ + synthesis::{AiurProof, AiurSystem}, + vk_codec::{AiurVerifyingKey, aiur_system_to_bytes}, +}; +use ix_proof_compression::{ + kzg::{Srs, VerifierParams}, + pipeline::{self, FixedRoot, RecursiveVerifier}, +}; +use ixvm_codegen::aiur_ix_aggr_runner::{ + AggrAdvice, aggr_io_buffer, execute_ix_aggr, +}; +use lean_ffi::object::{ + LeanArray, LeanBorrowed, LeanByteArray, LeanExternal, LeanIOResult, LeanNat, + LeanOwned, LeanString, +}; +use multi_stark::types::{CommitmentParameters, GoldilocksBlake3Config}; +use std::{fs::File, io::BufReader, path::Path, sync::Arc}; + +const MAGIC: &[u8; 8] = b"IXCMP002"; + +fn configuration( + system: &AiurSystem, + root_logs: Vec, + claim_len: usize, +) -> pipeline::Result { + if root_logs.len() + != system.toplevel().circuits.len() + + system.toplevel().memory_sizes.len() + + 2 + || !root_logs.ends_with(&[8, 16]) + { + return Err("root layout does not match the aggregate backend".into()); + } + let mut memories: Vec<_> = system + .toplevel() + .memory_sizes + .iter() + .enumerate() + .map(|(i, &width)| (width, system.toplevel().circuits.len() + i)) + .collect(); + memories.sort_unstable(); + FixedRoot::new(root_logs, memories, claim_len) +} + +fn fri_config(key: &AiurVerifyingKey) -> GoldilocksBlake3Config { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + key.fri_parameters(), + ) +} + +fn load_srs(path: &Path) -> pipeline::Result> { + Ok(Arc::new(Srs::from_reader(BufReader::new(File::open(path)?))?)) +} + +fn setup( + system: &AiurSystem, + dir: &Path, + srs_path: &Path, + dev: bool, + root_layout: &str, + root_log: u32, + fri_log: u32, + kzg_log: u32, + recursive_log: u32, + claim_len: usize, +) -> pipeline::Result { + if !(1..=26).contains(&root_log) + || !(16..=26).contains(&fri_log) + || !(16..=26).contains(&kzg_log) + || !(16..=28).contains(&recursive_log) + { + return Err( + "root log-height must be 1–26; FRI and intermediate KZG log-heights must be 16–26; recursive log-height must be 16–28" + .into(), + ); + } + if dev == !srs_path.as_os_str().is_empty() { + return Err("specify exactly one of --srs FILE or --dev-srs".into()); + } + let vk = aiur_system_to_bytes(system)?; + let key = AiurVerifyingKey::from_bytes(&vk)?; + let root_logs = + layout::resolve(system, root_layout, u8::try_from(root_log)?)?; + let root = configuration(system, root_logs.clone(), claim_len)?; + eprintln!("[compression setup] loading KZG parameters"); + let srs = if dev { + let srs = Srs::unsafe_dev_setup( + 1usize << kzg_log.max(recursive_log), + b"ix-compression-cli-dev-v1", + ); + srs.validate()?; + Arc::new(srs) + } else { + load_srs(srs_path)? + }; + if srs.max_len() < 1usize << kzg_log.max(recursive_log) { + return Err("SRS is smaller than the KZG partition height".into()); + } + let params = VerifierParams::from_srs(&srs).to_bytes()?; + let settings = local::Settings::new( + &vk, + ¶ms, + root_logs, + fri_log, + kzg_log, + recursive_log, + claim_len, + ); + let id = settings.id(); + if dir.join(&id).exists() { + let entry = local::load(dir, &id, &vk)?; + RecursiveVerifier::from_bytes(&entry.key_bytes)?; + local::reuse(dir, &id, srs_path, dev.then_some(srs.as_ref()))?; + return Ok(id); + } + eprintln!( + "[compression setup] deriving fixed verifier (no certificate or proof input)" + ); + let verifier = pipeline::setup( + &key, + &root, + srs.clone(), + fri_config(&key), + 1usize << fri_log, + 1usize << kzg_log, + 1usize << recursive_log, + )?; + let bytes = verifier.to_bytes()?; + RecursiveVerifier::from_bytes(&bytes)?; + local::publish( + dir, + &settings, + &bytes, + srs_path, + dev.then_some(srs.as_ref()), + )?; + Ok(id) +} + +fn compress( + system: &AiurSystem, + allowed: &[u8], + aggr_idx: usize, + certificate: &[u8], + bytes: &[u8], + dir: &Path, +) -> pipeline::Result> { + let vk = aiur_system_to_bytes(system)?; + let entry = local::load(dir, &local::active(dir)?, &vk)?; + let expected = aggregate_outer_claim(aggr_idx, allowed, certificate); + if expected.len() != entry.settings.claim_len { + return Err("compression claim shape changed; run setup again".into()); + } + let key = AiurVerifyingKey::from_bytes(&vk)?; + let fixed = + configuration(system, entry.settings.root_logs.clone(), expected.len())?; + let proof = AiurProof::from_bytes(bytes)?; + let advice = system.proof_to_advice_bytes(&expected, &proof)?; + let srs = load_srs(&entry.srs_path)?; + if blake3::hash(&VerifierParams::from_srs(&srs).to_bytes()?).to_hex().as_str() + != entry.settings.params_hash + { + return Err( + "SRS no longer matches the local compression configuration".into(), + ); + } + drop(proof); + let claims = serialize_claims(&[&expected]); + let mut io = aggr_io_buffer(&AggrAdvice { + shape: 1, + proof_advice: [&advice, &[]], + ixvm_vk: &[], + self_vk: &vk, + child_claims: [&claims, &[]], + output_claim: certificate, + allowed, + preimages: &[], + trees: &[], + paths: &[], + }); + eprintln!( + "[compress] normalizing the aggregate root to the configured trace bounds" + ); + // The aggregate call is [channel, entrypoint, inputs…], with no outputs. + let input = &expected[2..]; + let (record, output) = + execute_ix_aggr(system.toplevel(), aggr_idx, input.to_vec(), &mut io)?; + if !output.is_empty() { + return Err("aggregate normalizer returned unexpected outputs".into()); + } + drop(advice); + let (claim, proof) = system.prove_from_execution_normalized( + aggr_idx, + input, + &io, + record, + &output, + &entry.settings.root_logs, + )?; + if claim != expected { + return Err("normalizer changed the certificate claim".into()); + } + drop(io); + eprintln!("[compress] normalized root → FRI"); + let prepared = fixed.prepare(&key, &proof, &expected, Default::default())?; + drop(proof); + let (fri, proof) = + prepared.prove_fri(fri_config(&key), 1usize << entry.settings.fri_log)?; + eprintln!("[compress] FRI → KZG"); + let prepared = + pipeline::prepare_kzg(&fri, &proof, &expected, Default::default())?; + drop(fri); + drop(proof); + let height = 1usize << entry.settings.kzg_log; + let (kzg, proof) = prepared.prove_kzg(srs.clone(), height)?; + eprintln!("[compress] KZG → recursive KZG"); + let prepared = pipeline::prepare_recursive(&kzg, &proof, &expected)?; + drop(kzg); + drop(proof); + let (verifier, proof) = + prepared.prove(srs, 1usize << entry.settings.recursive_log)?; + if verifier.to_bytes()? != entry.key_bytes { + return Err( + "prover circuit differs from the locally configured verifier".into(), + ); + } + let bytes = verifier.encode_proof(&proof)?; + RecursiveVerifier::from_bytes(&entry.key_bytes)? + .verify_bytes(&bytes, &expected)?; + let mut result = MAGIC.to_vec(); + result.extend(local::decode_id(&entry.settings.id())?); + result.extend(bytes); + Ok(result) +} + +#[unsafe(no_mangle)] +extern "C" fn rs_compression_layout( + system: LeanExternal>, + log: u32, +) -> LeanIOResult { + let result = (|| -> pipeline::Result { + if !(1..=26).contains(&log) { + return Err("root log-height must be 1–26".into()); + } + Ok(serde_json::to_string_pretty(&layout::template( + system.get(), + u8::try_from(log)?, + ))?) + })(); + match result { + Ok(json) => LeanIOResult::ok(LeanString::new(&json)), + Err(e) => LeanIOResult::error_string(&e.to_string()), + } +} + +#[unsafe(no_mangle)] +extern "C" fn rs_compression_setup( + system: LeanExternal>, + dir: LeanString>, + srs: LeanString>, + dev: bool, + root_layout: LeanString>, + root_log: u32, + fri_log: u32, + kzg_log: u32, + recursive_log: u32, + claim_len: u32, +) -> LeanIOResult { + match setup( + system.get(), + Path::new(dir.as_str()), + Path::new(srs.as_str()), + dev, + root_layout.as_str(), + root_log, + fri_log, + kzg_log, + recursive_log, + claim_len as usize, + ) { + Ok(id) => LeanIOResult::ok(LeanString::new(&id)), + Err(e) => LeanIOResult::error_string(&e.to_string()), + } +} + +#[unsafe(no_mangle)] +extern "C" fn rs_compression_run( + system: LeanExternal>, + allowed: LeanByteArray>, + idx: LeanNat>, + certificate: LeanByteArray>, + proof: LeanByteArray>, + dir: LeanString>, +) -> LeanIOResult { + match compress( + system.get(), + allowed.as_bytes(), + crate::aiur::lean_unbox_nat_as_usize(idx.inner()), + certificate.as_bytes(), + proof.as_bytes(), + Path::new(dir.as_str()), + ) { + Ok(bytes) => LeanIOResult::ok(LeanByteArray::from_bytes(&bytes)), + Err(e) => LeanIOResult::error_string(&e.to_string()), + } +} + +#[unsafe(no_mangle)] +extern "C" fn rs_compression_verify( + vk: LeanByteArray>, + proof: LeanByteArray>, + claim: LeanArray>, + dir: LeanString>, +) -> LeanIOResult { + let result = (|| -> pipeline::Result<()> { + let (id, body) = envelope(proof.as_bytes())?; + let entry = local::load(Path::new(dir.as_str()), &id, vk.as_bytes())?; + let expected = claim.map(|x| crate::aiur::lean_unbox_g(&x)); + RecursiveVerifier::from_bytes(&entry.key_bytes)? + .verify_bytes(body, &expected) + })(); + match result { + Ok(()) => LeanIOResult::ok(LeanOwned::box_usize(0)), + Err(e) => LeanIOResult::error_string(&e.to_string()), + } +} + +fn envelope(bytes: &[u8]) -> pipeline::Result<(String, &[u8])> { + if bytes.len() <= 40 || !bytes.starts_with(MAGIC) { + return Err("invalid compressed certificate envelope".into()); + } + let id: [u8; 32] = bytes[8..40].try_into()?; + Ok((blake3::Hash::from(id).to_hex().to_string(), &bytes[40..])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_has_only_version_configuration_and_proof() { + let mut bytes = MAGIC.to_vec(); + bytes.extend([7; 32]); + bytes.push(42); + let (id, proof) = envelope(&bytes).unwrap(); + assert_eq!(local::decode_id(&id).unwrap(), [7; 32]); + assert_eq!(proof, &[42]); + for len in 0..=40 { + assert!(envelope(&bytes[..len]).is_err()); + } + bytes[7] = b'1'; + assert!(envelope(&bytes).is_err()); + } +} diff --git a/crates/ffi/src/compression/layout.rs b/crates/ffi/src/compression/layout.rs new file mode 100644 index 000000000..bb79e7439 --- /dev/null +++ b/crates/ffi/src/compression/layout.rs @@ -0,0 +1,90 @@ +//! User-facing bounds, resolved to the aggregate backend's circuit order. +use super::*; +use serde_json::{Value, json}; + +pub(super) fn template(system: &AiurSystem, log: u8) -> Value { + json!({ + "functions": vec![log; system.toplevel().circuits.len()], + "memory": system.toplevel().memory_sizes.iter().map(|w| (w.to_string(), json!(log))).collect::>() + }) +} + +pub(super) fn resolve( + system: &AiurSystem, + input: &str, + default: u8, +) -> pipeline::Result> { + let value = if input.is_empty() { + template(system, default) + } else { + serde_json::from_str(input)? + }; + resolve_for( + &value, + system.toplevel().circuits.len(), + &system.toplevel().memory_sizes, + ) +} + +fn resolve_for( + value: &Value, + functions: usize, + widths: &[usize], +) -> pipeline::Result> { + let object = value.as_object().ok_or("root layout must be a JSON object")?; + if object.len() != 2 + || !object.contains_key("functions") + || !object.contains_key("memory") + { + return Err("root layout requires exactly 'functions' and 'memory'".into()); + } + let logs = value["functions"] + .as_array() + .ok_or("root layout functions must be an array")?; + let memories = value["memory"] + .as_object() + .ok_or("root layout memory must map widths to log-heights")?; + if logs.len() != functions || memories.len() != widths.len() { + return Err(format!("root layout requires {functions} function circuits and {} memory widths; use `ix compression layout` for a template", widths.len()).into()); + } + let log = |v: &Value| -> pipeline::Result { + match v.as_u64() { + Some(n @ 1..=26) => Ok(u8::try_from(n)?), + _ => Err("root layout log-heights must be integers in 1–26".into()), + } + }; + let mut result = + logs.iter().map(log).collect::>>()?; + for width in widths { + let v = memories + .get(&width.to_string()) + .ok_or_else(|| format!("root layout missing memory width {width}"))?; + result.push(log(v)?); + } + // Native preprocessed byte tables are never resized. + result.extend([8, 16]); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_resolves_memory_widths_in_backend_order() -> pipeline::Result<()> { + let value = json!({"functions": [12, 6], "memory": {"1": 3, "8": 7}}); + assert_eq!(resolve_for(&value, 2, &[8, 1])?, [12, 6, 7, 3, 8, 16]); + for bad in [ + json!({"functions": [12], "memory": {"1": 3, "8": 7}}), + json!({"functions": [12, 6], "memory": {"2": 3, "8": 7}}), + json!({"functions": [12, 6], "memory": {"1": 0, "8": 7}}), + json!({"functions": [27, 6], "memory": {"1": 3, "8": 7}}), + json!({"functions": [256, 6], "memory": {"1": 3, "8": 7}}), + json!({"functions": [1.5, 6], "memory": {"1": 3, "8": 7}}), + json!({"functions": [12, 6], "memory": {"1": 3, "8": 7}, "extra": 1}), + ] { + assert!(resolve_for(&bad, 2, &[8, 1]).is_err(), "{bad}"); + } + Ok(()) + } +} diff --git a/crates/ffi/src/compression/local.rs b/crates/ffi/src/compression/local.rs new file mode 100644 index 000000000..aa4ef0abc --- /dev/null +++ b/crates/ffi/src/compression/local.rs @@ -0,0 +1,337 @@ +//! Locally generated verifier configurations, separate from proof-store hints. +use super::*; +use std::{ + fs, + io::Write, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +const VERSION: u32 = 4; + +pub(super) struct Settings { + pub root_hash: String, + pub params_hash: String, + pub root_logs: Vec, + pub fri_log: u32, + pub kzg_log: u32, + pub recursive_log: u32, + pub claim_len: usize, +} + +impl Settings { + pub(super) fn new( + vk: &[u8], + params: &[u8], + root_logs: Vec, + fri_log: u32, + kzg_log: u32, + recursive_log: u32, + claim_len: usize, + ) -> Self { + Self { + root_hash: blake3::hash(vk).to_hex().to_string(), + params_hash: blake3::hash(params).to_hex().to_string(), + root_logs, + fri_log, + kzg_log, + recursive_log, + claim_len, + } + } + + pub(super) fn id(&self) -> String { + let mut h = blake3::Hasher::new(); + // Bump whenever circuit construction, normalization or security settings change. + h.update(b"ix/fixed-compression/v4"); + h.update(self.root_hash.as_bytes()); + h.update(self.params_hash.as_bytes()); + h.update(&(self.root_logs.len() as u64).to_le_bytes()); + h.update(&self.root_logs); + h.update(&self.fri_log.to_le_bytes()); + h.update(&self.kzg_log.to_le_bytes()); + h.update(&self.recursive_log.to_le_bytes()); + h.update(&(self.claim_len as u64).to_le_bytes()); + h.finalize().to_hex().to_string() + } +} + +pub(super) struct Entry { + pub settings: Settings, + pub srs_path: PathBuf, + pub key_bytes: Vec, +} + +pub(super) fn decode_id(id: &str) -> pipeline::Result<[u8; 32]> { + if id.len() != 64 + || !id.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + { + return Err("invalid compression configuration identifier".into()); + } + let mut bytes = [0; 32]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&id[i * 2..i * 2 + 2], 16)?; + } + Ok(bytes) +} + +pub(super) fn active(dir: &Path) -> pipeline::Result { + let id = fs::read_to_string(dir.join("default")).map_err(|e| { + format!( + "no active compression configuration; run `ix compression setup`: {e}" + ) + })?; + decode_id(id.trim())?; + Ok(id.trim().to_string()) +} + +fn temporary(dir: &Path) -> pipeline::Result { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + Ok(dir.join(format!(".setup-{}-{stamp}", std::process::id()))) +} + +fn write_atomic( + path: &Path, + write: impl FnOnce(&mut File) -> pipeline::Result<()>, +) -> pipeline::Result<()> { + let tmp = temporary(path.parent().ok_or("missing configuration directory")?)?; + let mut f = File::options().write(true).create_new(true).open(&tmp)?; + let result = (|| -> pipeline::Result<()> { + write(&mut f)?; + f.sync_all()?; + drop(f); + fs::rename(&tmp, path)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(tmp); + } + result +} + +pub(super) fn activate(dir: &Path, id: &str) -> pipeline::Result<()> { + decode_id(id)?; + write_atomic(&dir.join("default"), |f| Ok(f.write_all(id.as_bytes())?)) +} + +fn srs_location(srs: &Path, dev: bool) -> pipeline::Result { + if dev { + return Ok(String::new()); + } + Ok( + fs::canonicalize(srs)?.to_str().ok_or("SRS path is not UTF-8")?.to_string(), + ) +} + +/// The SRS path is not part of circuit identity. Re-running setup with the +/// same parameters at a new path must update the proving source as well. +/// Called only after the existing verifier and the supplied SRS are validated. +pub(super) fn reuse( + dir: &Path, + id: &str, + srs: &Path, + dev: Option<&Srs>, +) -> pipeline::Result<()> { + decode_id(id)?; + let base = dir.join(id); + let path = base.join("settings.json"); + let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path)?)?; + value["srs"] = srs_location(srs, dev.is_some())?.into(); + value["dev_srs"] = dev.is_some().into(); + if let Some(srs) = dev { + write_atomic(&base.join("development.srs"), |f| Ok(srs.write(f)?))?; + } + let bytes = serde_json::to_vec_pretty(&value)?; + write_atomic(&path, |f| Ok(f.write_all(&bytes)?))?; + activate(dir, id) +} + +pub(super) fn load(dir: &Path, id: &str, vk: &[u8]) -> pipeline::Result { + decode_id(id)?; + let base = dir.join(id); + let bytes = fs::read(base.join("settings.json")).map_err(|e| format!("compression configuration {id} is not available locally; run matching `ix compression setup`: {e}"))?; + let value: serde_json::Value = serde_json::from_slice(&bytes)?; + let string = |name| -> pipeline::Result { + Ok( + value + .get(name) + .and_then(|v| v.as_str()) + .ok_or("invalid local compression settings")? + .to_string(), + ) + }; + let number = |name| -> pipeline::Result { + Ok(u32::try_from( + value + .get(name) + .and_then(|v| v.as_u64()) + .ok_or("invalid local compression settings")?, + )?) + }; + if number("version")? != VERSION { + return Err("unsupported local compression settings".into()); + } + let settings = Settings { + root_hash: string("root")?, + params_hash: string("parameters")?, + root_logs: serde_json::from_value( + value + .get("root_log_heights") + .ok_or("missing root trace heights")? + .clone(), + )?, + fri_log: number("fri_log_height")?, + kzg_log: number("kzg_log_height")?, + recursive_log: number("recursive_log_height")?, + claim_len: number("claim_len")? as usize, + }; + if settings.id() != id + || settings.root_hash != blake3::hash(vk).to_hex().as_str() + || settings.root_logs.is_empty() + || settings.root_logs.iter().any(|log| !(1..=26).contains(log)) + || !(16..=26).contains(&settings.fri_log) + || !(16..=26).contains(&settings.kzg_log) + || !(16..=28).contains(&settings.recursive_log) + { + return Err( + "local compression configuration does not match this aggregate backend" + .into(), + ); + } + let key_bytes = fs::read(base.join("verifier.bin"))?; + if blake3::hash(&key_bytes).to_hex().as_str() != string("verifier")? { + return Err( + "local compression verifier is corrupt; remove this configuration directory and rerun setup".into(), + ); + } + let srs_path = if value.get("dev_srs").and_then(|v| v.as_bool()) == Some(true) + { + base.join("development.srs") + } else { + PathBuf::from(string("srs")?) + }; + Ok(Entry { settings, srs_path, key_bytes }) +} + +pub(super) fn publish( + dir: &Path, + settings: &Settings, + key: &[u8], + srs: &Path, + dev: Option<&Srs>, +) -> pipeline::Result<()> { + fs::create_dir_all(dir)?; + let tmp = temporary(dir)?; + fs::create_dir(&tmp)?; + let result = (|| -> pipeline::Result<()> { + let id = settings.id(); + let path = srs_location(srs, dev.is_some())?; + if let Some(srs) = dev { + let mut file = File::options() + .write(true) + .create_new(true) + .open(tmp.join("development.srs"))?; + srs.write(&mut file)?; + file.sync_all()?; + } + let record = serde_json::json!({ + "version": VERSION, + "root": settings.root_hash, + "parameters": settings.params_hash, + "root_log_heights": settings.root_logs, + "fri_log_height": settings.fri_log, + "kzg_log_height": settings.kzg_log, + "recursive_log_height": settings.recursive_log, + "claim_len": settings.claim_len, + "verifier": blake3::hash(key).to_hex().as_str(), + "srs": path, + "dev_srs": dev.is_some(), + }); + for (name, bytes) in [ + ("verifier.bin", key.to_vec()), + ("settings.json", serde_json::to_vec_pretty(&record)?), + ] { + let mut file = + File::options().write(true).create_new(true).open(tmp.join(name))?; + file.write_all(&bytes)?; + file.sync_all()?; + } + fs::rename(&tmp, dir.join(&id))?; + activate(dir, &id) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(tmp); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_configuration_identity_and_atomic_publication() + -> pipeline::Result<()> { + let settings = + || Settings::new(b"root", b"params", vec![20, 8, 16], 23, 24, 24, 18); + let id = settings().id(); + for other in [ + Settings::new(b"root", b"params", vec![20, 8, 16], 23, 24, 28, 18), + Settings::new(b"root", b"params", vec![20, 8, 16], 24, 24, 24, 18), + Settings::new(b"other", b"params", vec![20, 8, 16], 23, 24, 24, 18), + Settings::new(b"root", b"other", vec![20, 8, 16], 23, 24, 24, 18), + Settings::new(b"root", b"params", vec![21, 8, 16], 23, 24, 24, 18), + Settings::new(b"root", b"params", vec![20, 7, 16], 23, 24, 24, 18), + Settings::new(b"root", b"params", vec![8, 20, 16], 23, 24, 24, 18), + Settings::new(b"root", b"params", vec![20, 8, 16], 23, 25, 24, 18), + Settings::new(b"root", b"params", vec![20, 8, 16], 23, 24, 24, 19), + ] { + assert_ne!(id, other.id()); + } + let dir = temporary(&std::env::temp_dir())?; + fs::create_dir(&dir)?; + let result = (|| -> pipeline::Result<()> { + assert!(active(&dir).is_err()); + assert!(load(&dir, "../default", b"root").is_err()); + assert!(load(&dir, &id, b"root").is_err()); + let srs = dir.join("test.srs"); + fs::write(&srs, b"storage test only")?; + // Storage/checksum checks do not deserialize a cryptographic key. + publish(&dir, &settings(), b"key", &srs, None)?; + assert_eq!(active(&dir)?, id); + let entry = load(&dir, &id, b"root")?; + assert_eq!(entry.key_bytes, b"key"); + assert_eq!(entry.srs_path, srs); + assert!(load(&dir, &id, b"wrong backend").is_err()); + assert!(publish(&dir, &settings(), b"replacement", &srs, None).is_err()); + assert_eq!(load(&dir, &id, b"root")?.key_bytes, b"key"); + let changed = + Settings::new(b"root", b"params", vec![21, 8, 16], 23, 24, 24, 18); + assert!( + publish(&dir, &changed, b"key", &dir.join("missing"), None).is_err() + ); + assert_eq!(active(&dir)?, id); + let moved = dir.join("moved.srs"); + fs::rename(&srs, &moved)?; + reuse(&dir, &id, &moved, None)?; + assert_eq!(load(&dir, &id, b"root")?.srs_path, moved); + assert_eq!(load(&dir, &id, b"root")?.key_bytes, b"key"); + fs::remove_file(&moved)?; + // Verification can load the key after the proving SRS is removed. + assert!(load(&dir, &id, b"root").is_ok()); + fs::write(dir.join(&id).join("verifier.bin"), b"corrupt")?; + assert!(load(&dir, &id, b"root").is_err()); + fs::write(dir.join(&id).join("verifier.bin"), b"key")?; + let path = dir.join(&id).join("settings.json"); + let mut record: serde_json::Value = + serde_json::from_slice(&fs::read(&path)?)?; + record["version"] = (VERSION + 1).into(); + fs::write(path, serde_json::to_vec(&record)?)?; + assert!(load(&dir, &id, b"root").is_err()); + assert_eq!(fs::read_dir(&dir)?.count(), 2); // configuration + default + Ok(()) + })(); + fs::remove_dir_all(dir)?; + result + } +} diff --git a/crates/ffi/src/lib.rs b/crates/ffi/src/lib.rs index 93157799b..d0e9fe096 100644 --- a/crates/ffi/src/lib.rs +++ b/crates/ffi/src/lib.rs @@ -26,6 +26,7 @@ pub mod unsigned; pub mod builder; pub mod catalog; pub mod compile; +mod compression; pub mod graph; pub mod ix; pub mod kernel; diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml new file mode 100644 index 000000000..12eb0f38e --- /dev/null +++ b/crates/proof-compression/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "ix-proof-compression" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +aiur = { workspace = true } +multi-stark = { workspace = true } +ark-bls12-381 = { version = "0.5", features = ["curve"] } +ark-ec = "0.5" +ark-ff = { version = "0.5", features = ["asm"] } +ark-poly = "0.5" +ark-serialize = { version = "0.5", features = ["std"] } +bincode = { version = "2", features = ["serde"] } +blake3 = { workspace = true } +num-bigint = { workspace = true } +serde = { version = "1", features = ["derive"] } +tracing = { workspace = true } +p3-blake3 = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-challenger = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-commit = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-field = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-goldilocks = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-matrix = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-maybe-rayon = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-symmetric = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } + +[features] +default = ["parallel"] +parallel = ["multi-stark/parallel", "aiur/parallel", "p3-maybe-rayon/parallel", "ark-ec/parallel", "ark-ff/parallel", "ark-poly/parallel"] + +[lints] +workspace = true diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md new file mode 100644 index 000000000..2882cc9a5 --- /dev/null +++ b/crates/proof-compression/README.md @@ -0,0 +1,148 @@ +# Proof compression + +Compress an aggregated Ix certificate through three stages: +root Aiur proof → FRI proof → KZG proof → recursive KZG proof. +The certificate claim remains unchanged. The root can aggregate any number of +shards, provided its recursive verification fits the configured trace bounds. + +## Local setup and CLI + +Build with `lake build ix`, then run `.lake/build/bin/ix` (called `ix` below): + +```sh +ix compression setup --srs /path/to/trusted.srs +ix compress ROOT_ADDRESS +ix verify COMPRESSED_ADDRESS +``` + +The prover and verifier each run setup with the same SRS, compiled aggregate +backend, and bounds. Setup derives a fixed verifier locally without a certificate +or proof. The prover supplies only the proof; no verifier key is accepted from it. +`compress` accepts a root from `ix aggregate` using the current default aggregate +parameters, checks it, normalizes it, runs all three stages, and verifies the +result against the local key before saving it. It prints the new store address +to stdout and progress to stderr. + +Configurations live in `~/.ix/compression//`; set +`IX_COMPRESSION_HOME` to use another directory. Each contains `settings.json` and +`verifier.bin`. The `default` file selects the proving configuration. Proofs carry +an identifier for an already configured local verifier. Unknown configurations +are rejected. Setup records the absolute SRS path for proving; verification needs +only settings and the verifier key. Repeated setup with identical parameters +reuses the key. Treat the configuration directory as trusted local data. + +Use `--dev-srs` instead of `--srs FILE` only for development. It generates +known-trapdoor parameters in `development.srs`; those proofs are not +production-secure. Imported SRS files use `Srs::write` / `Srs::from_reader` format: +`IXKZGS01`, a little-endian u32 log-length, `2^log-length` G1 powers, +G2 and tau-G2, then log-length + 1 degree keys, all compressed Ark points. +Import checks point encodings, subgroups, and consistency; provenance must be +established independently. + +## Trace bounds + +The normalizer wraps the root through the aggregate program into one all-active +Aiur trace shard. Each function and memory trace has its own `u8` log-height; +byte tables retain their native heights. Setup fixes the complete layout before +any proof is provided. Oversized executions are rejected before trace allocation. + +```sh +ix compression layout > root-layout.json +# Edit the bounds for the executions you intend to support. +ix compression setup --srs /path/to/trusted.srs --root-layout root-layout.json +``` + +The JSON contains `functions`, an array in compiled function-circuit order, and +`memory`, an object mapping every memory width to its log-height. For example, +`{"functions": [18, 12], "memory": {"1": 16, "8": 10}}` describes a backend with +two function circuits and two memory widths. Heights must be integers in 1–26; +missing entries and unknown fields are rejected. Templates start at 20; use +`compression layout --root-log-height N` to change that starting value. +Without `--root-layout`, setup uses uniform `--root-log-height N` (default 20). +The two setup flags are mutually exclusive. + +These bounds cover verification of the input aggregate proof, including memory +closures; they do not describe the original shards or the proof's byte size. +Changing any bound requires matching setup on both sides. + +The compression stages have independent partition limits: + +| Setup flag | Log-height range | Default | +| --- | --- | --- | +| `--fri-log-height` | 16–26 | 23 | +| `--log-height` (intermediate KZG) | 16–26 | 24 | +| `--recursive-log-height` (final KZG) | 16–28 | `--log-height` | + +The SRS must cover the larger KZG limit. The FRI stage uses the aggregate +backend's query and proof-of-work parameters with blowup four; its partition +must fit indivisible hash traces. Partition limits are part of the configuration +identity, not total memory limits or automatic RAM tuning. Setup and proving can +require substantial resources and do not resume automatically. + +## Reproduce the sub-3 KB Init proof + +Start with the stored root from aggregating all 16 shards of Init, and retain +`Init.ixe` and `Init-16.ixes` for the environment audit. The supplied +[Init layout](examples/init16-root-layout.json) fixes the 88 function-circuit +and 11 memory bounds used for this run. It is an example for this compiled +backend and workload, not a universal layout for every aggregate root. + +From the repository root, in Bash: + +```sh +lake build ix +export RAYON_NUM_THREADS=32 +export IX_COMPRESSION_HOME="$PWD/compression" +ix=.lake/build/bin/ix +root=ROOT_ADDRESS # Replace with your stored 16-shard aggregate root address. + +"$ix" compression setup --dev-srs \ + --root-layout crates/proof-compression/examples/init16-root-layout.json \ + --fri-log-height 23 --log-height 25 --recursive-log-height 28 +compressed=$("$ix" compress "$root") +"$ix" verify "$compressed" +"$ix" verify --aggregate --ixe Init.ixe --ixes Init-16.ixes "$compressed" + +# Copy and measure the complete serialized Ixon.Proof, including its claim. +cp "$HOME/.ix/store/${compressed:0:2}/${compressed:2:2}/${compressed:4:2}/${compressed:6}" compressed.proof +wc -c < compressed.proof +test "$(wc -c < compressed.proof)" -lt 3000 +``` + +This configuration produced a 2,707-byte complete proof. The larger final +partition permits one arithmetic trace plus the lookup table, avoiding the +commitments and openings of multiple final arithmetic partitions. A smaller +final height can still prove the statement but produces a larger proof. The +size target depends on the layout and compiled circuits; it is not guaranteed +for arbitrary bounds. The development recipe exercises the full pipeline; +for production, replace `--dev-srs` with `--srs /path/to/trusted.srs` covering +`2^28` G1 powers, with the required degree keys. + +## Library and tests + +`pipeline::FixedRoot` and `pipeline::setup` derive the fixed verifier without a +witness. `pipeline::prepare_root` also supports variable root layouts with an +independently trusted aggregate key and expected outer claim. `Prepared::prove_fri`, +`prepare_kzg` / `Prepared::prove_kzg`, and `prepare_recursive` / +`RecursiveStage::prove` expose the three stages separately. The final verifier +checks the expected claim, outer proof, and both opening and degree pairing +equations. Verifier codecs contain no proving powers; authenticate keys before +loading them outside local setup. + +The FRI verifier supports Goldilocks with quadratic challenges, cap-height zero, +binary FRI, a constant final polynomial, active preprocessed tables, and bounded +field-sampling retries. Unsupported profiles fail during preparation. + +```sh +cargo test --release -p ix-proof-compression +``` + +Tests generate their fixtures and cover native/constrained verifier agreement, +statement binding, malformed inputs, degree bounds, and streaming equivalence. +The expensive complete-chain test independently derives a fixed key and proves +a synthetic relation with development parameters and low FRI query counts: + +```sh +cargo test --release -p ix-proof-compression --test pipeline_root \ + complete_compression_chain -- --ignored --nocapture +``` diff --git a/crates/proof-compression/examples/init16-root-layout.json b/crates/proof-compression/examples/init16-root-layout.json new file mode 100644 index 000000000..47c8a246f --- /dev/null +++ b/crates/proof-compression/examples/init16-root-layout.json @@ -0,0 +1,23 @@ +{ + "functions": [ + 17, 3, 5, 12, 14, 15, 17, 14, 16, 20, 16, 15, 15, 16, 1, 1, + 5, 1, 19, 14, 20, 18, 16, 17, 7, 16, 15, 19, 17, 19, 16, 17, + 20, 19, 16, 16, 15, 16, 5, 1, 1, 1, 1, 1, 5, 1, 1, 8, + 5, 1, 1, 7, 5, 5, 7, 7, 1, 5, 6, 5, 4, 1, 1, 1, + 1, 6, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 17, 1, 19, 16 + ], + "memory": { + "3": 20, + "4": 15, + "5": 5, + "6": 17, + "7": 19, + "8": 8, + "10": 19, + "11": 8, + "26": 1, + "32": 17, + "34": 14 + } +} diff --git a/crates/proof-compression/src/circuit_metadata.rs b/crates/proof-compression/src/circuit_metadata.rs new file mode 100644 index 000000000..534afa67e --- /dev/null +++ b/crates/proof-compression/src/circuit_metadata.rs @@ -0,0 +1,107 @@ +//! Structural validation shared by the FRI and KZG verifier-key codecs. +use multi_stark::expr::Source; +use multi_stark::graph::Node; +use multi_stark::lookup::{ + MAX_LOOKUP_GROUP, logup_constraint_count, stage2_width, +}; +use multi_stark::system::Circuit; +use multi_stark::traits::Field; + +pub(crate) fn validate_circuit( + c: &Circuit, + dimension: usize, +) -> Result<(), &'static str> { + if !(1..=MAX_LOOKUP_GROUP).contains(&c.lookup_group_size) { + return Err("lookup group size"); + } + if c.num_lookups != c.graph.lookups.len() + || c.stage_2_width + != stage2_width(c.num_lookups, c.lookup_group_size, dimension) + || c.num_publics != 4 * dimension + { + return Err("lookup/public widths"); + } + let g = &c.graph; + if g.nodes.len() != g.degrees.len() || g.lookup_prefix_len > g.nodes.len() { + return Err("graph dimensions"); + } + for (i, n) in g.nodes.iter().enumerate() { + let degree = |id: multi_stark::graph::NodeId| { + g.degrees + .get(id.index()) + .filter(|_| id.index() < i) + .copied() + .ok_or("graph topology") + }; + let d = match *n { + Node::Const(_) | Node::IsTransition => 0, + Node::Public(p) => { + if p as usize >= c.num_publics { + return Err("public index"); + } + 0 + }, + Node::IsFirstRow | Node::IsLastRow => 1, + Node::Var(col) => { + let width = match col.source { + Source::Main => c.main_width, + Source::Stage2 => c.stage_2_width, + Source::Preprocessed => c.preprocessed_width, + }; + if col.index as usize >= width { + return Err("column index"); + } + 1 + }, + Node::Add(a, b) | Node::Sub(a, b) => degree(a)?.max(degree(b)?), + Node::Mul(a, b) => { + degree(a)?.checked_add(degree(b)?).ok_or("degree overflow")? + }, + Node::Neg(a) => degree(a)?, + }; + if g.degrees[i] != d { + return Err("node degree"); + } + } + if g.zeros.iter().any(|n| n.index() >= g.nodes.len()) + || g.lookups.iter().any(|l| { + l.args + .iter() + .chain(std::iter::once(&l.multiplicity)) + .any(|n| n.index() >= g.lookup_prefix_len) + }) + { + return Err("graph roots"); + } + let mut lookup_degree = 1u64; + for group in g.lookups.chunks(c.lookup_group_size) { + let degrees: Vec<_> = group + .iter() + .map(|l| { + l.args + .iter() + .map(|n| u64::from(g.degrees[n.index()])) + .max() + .unwrap_or(0) + }) + .collect(); + let sum: u64 = degrees.iter().sum(); + lookup_degree = lookup_degree.max(sum + 1); + for (l, °ree) in group.iter().zip(°rees) { + lookup_degree = lookup_degree + .max(u64::from(g.degrees[l.multiplicity.index()]) + sum - degree); + } + } + let lookup_degree = u32::try_from(lookup_degree) + .map_err(|_overflow| "lookup degree overflow")?; + let max = g.zeros.iter().map(|n| g.degrees[n.index()]).max().unwrap_or(0); + if g.max_constraint_degree != max + || c.max_constraint_degree != max.max(lookup_degree) as usize + || c.constraint_count + != g.zeros.len() + + logup_constraint_count(c.num_lookups, c.lookup_group_size, dimension) + { + return Err("constraint metadata"); + } + Ok(()) +} diff --git a/crates/proof-compression/src/foreign.rs b/crates/proof-compression/src/foreign.rs new file mode 100644 index 000000000..dce15a670 --- /dev/null +++ b/crates/proof-compression/src/foreign.rs @@ -0,0 +1,934 @@ +//! Goldilocks constraints over the BLS12-381 scalar field. +//! Each modular relation has a bounded quotient; small integer relations lift directly. + +use num_bigint::{BigInt, BigUint, Sign}; +use p3_field::PrimeField64; + +use crate::kzg::field::Scalar; +use multi_stark::plonkish::Assignment; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::CircuitId; +use multi_stark::plonkish::CircuitStats; +use multi_stark::plonkish::Gate; +use multi_stark::plonkish::LoweringError; +use multi_stark::plonkish::MultiStarkCircuit; +use multi_stark::plonkish::Table; +use multi_stark::plonkish::Value; +use multi_stark::plonkish::ValueSource as Recipe; +use multi_stark::plonkish::Witness; +use multi_stark::plonkish::WitnessError; +use multi_stark::system::CircuitInputs; +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; +use multi_stark::types::Val; +use p3_matrix::Matrix; + +const P: u64 = ::ORDER_U64; + +pub trait KzgCircuitExt { + fn kzg_circuit_inputs( + &self, + srs_len: usize, + quotient_budget: usize, + ) -> Result>, LoweringError>; + fn kzg_circuit_input( + &self, + index: usize, + srs_len: usize, + quotient_budget: usize, + ) -> Result>, LoweringError>; +} +impl KzgCircuitExt for MultiStarkCircuit { + /// Minimize KZG commitment/evaluation bytes by choosing lookup groups per + /// trace, within the supplied quotient budget. Include the actual SRS length + /// because smaller traces also need shifted degree commitments. + fn kzg_circuit_inputs( + &self, + srs_len: usize, + quotient_budget: usize, + ) -> Result>, LoweringError> { + let mut inputs = self.circuit_inputs(); + for input in &mut inputs { + tune_kzg_input(input, srs_len, quotient_budget)?; + } + Ok(inputs) + } + + /// Generate and tune one circuit's preprocessing, without materializing the others. + fn kzg_circuit_input( + &self, + index: usize, + srs_len: usize, + quotient_budget: usize, + ) -> Result>, LoweringError> { + let Some(mut input) = self.circuit_input(index) else { + return Ok(None); + }; + tune_kzg_input(&mut input, srs_len, quotient_budget)?; + Ok(Some(input)) + } +} + +fn tune_kzg_input( + input: &mut CircuitInputs, + srs_len: usize, + quotient_budget: usize, +) -> Result<(), LoweringError> { + use multi_stark::graph::ExtensionParams; + use multi_stark::lookup::MAX_LOOKUP_GROUP; + use multi_stark::lookup::logup_max_degree; + use multi_stark::lookup::stage2_width; + let fixed = input.preprocessed.as_ref().expect("lowering has fixed columns"); + if fixed.height() > srs_len { + return Err(LoweringError::InvalidTraceHeight); + } + let graph = input + .compile_graph(&ExtensionParams { + degree: 1, + w: Scalar::ZERO, + karatsuba: false, + }) + .expect("valid lowering"); + let commitment_bytes = if fixed.height() == srs_len { 48 } else { 96 }; + let best = (1..=MAX_LOOKUP_GROUP) + .filter_map(|group| { + let degree = graph + .max_constraint_degree + .max(logup_max_degree(&graph, group)) as usize; + let quotient = (degree.max(2) - 1).next_power_of_two(); + if quotient > quotient_budget { + return None; + } + let width = stage2_width(input.lookups.len(), group, 1); + let bytes = + width * (commitment_bytes + 64) + quotient * (commitment_bytes + 32); + Some(((bytes, quotient, group), group)) + }) + .min_by_key(|(cost, _)| *cost) + .ok_or(LoweringError::QuotientBudgetTooSmall)?; + input.lookup_group_size = best.1; + Ok(()) +} +/// A fixed translation. Public values retain their original order and canonical encoding. +pub struct GoldilocksCircuit { + pub circuit: Circuit, + pub inputs: GoldilocksInputs, +} + +/// Retain this mapping when consuming the circuit for lowering, to reuse its key. +pub struct GoldilocksInputs { + source_owner: CircuitId, + wires: Vec, + is_input: Vec, +} + +fn signed(value: Val) -> i128 { + let n = value.as_canonical_u64(); + if n > P / 2 { i128::from(n) - i128::from(P) } else { i128::from(n) } +} + +fn scalar(n: &BigInt) -> Scalar { + let (sign, limbs) = n.to_u64_digits(); + let radix = Scalar::from_u64(u64::MAX) + Scalar::ONE; + let v = limbs + .iter() + .rev() + .fold(Scalar::ZERO, |a, &b| a * radix + Scalar::from_u64(b)); + if sign == Sign::Minus { -v } else { v } +} + +fn integer(value: Scalar) -> BigInt { + let bytes: Vec<_> = + value.canonical_limbs_le().into_iter().flat_map(u64::to_le_bytes).collect(); + BigInt::from_biguint(Sign::Plus, BigUint::from_bytes_le(&bytes)) +} + +fn interval(coefficients: [i128; 5], bounds: [u64; 3]) -> (BigInt, BigInt) { + let [a, b, c] = bounds.map(BigInt::from); + let maxima = [&a * &b, a, b, c]; + let mut low = BigInt::from(coefficients[4]); + let mut high = low.clone(); + for (coefficient, maximum) in coefficients.into_iter().zip(maxima) { + let term = coefficient * maximum; + if coefficient < 0 { + low += term; + } else { + high += term; + } + } + (low, high) +} + +fn small_interval( + coefficients: [i128; 5], + bounds: [u64; 3], +) -> Option<(i128, i128)> { + let [a, b, c] = bounds.map(i128::from); + let maxima = + [if coefficients[0] == 0 { 0 } else { a.checked_mul(b)? }, a, b, c]; + let (mut low, mut high) = (coefficients[4], coefficients[4]); + for (coefficient, maximum) in coefficients.into_iter().zip(maxima) { + let term = coefficient.checked_mul(maximum)?; + if coefficient < 0 { + low = low.checked_add(term)?; + } else { + high = high.checked_add(term)?; + } + } + Some((low, high)) +} + +fn boolean_gate(gate: &Gate) -> bool { + gate.wires[0] == gate.wires[1] + && gate.coefficients + == [Val::ONE, Val::NEG_ONE, Val::ZERO, Val::ZERO, Val::ZERO] +} + +fn signed_scalar(n: i128) -> Scalar { + let value = Scalar::from_u64( + u64::try_from(n.unsigned_abs()).expect("signed Goldilocks coefficient"), + ); + if n < 0 { -value } else { value } +} + +fn arithmetic_range( + gate: &Gate, + bounds: [u64; 3], +) -> Option<(i128, i128)> { + let mut coefficients = gate.coefficients.map(signed); + coefficients[3] = 0; + if coefficients == [-1, 1, 0, 0, 0] && bounds[1] <= 1 { + Some((0, i128::from(bounds[0]))) + } else { + small_interval(coefficients, bounds) + } +} + +fn selection_range( + source: &Circuit, + gate: &Gate, + bounds: &[u64], +) -> Option<(i128, i128)> { + if gate.coefficients + != [Val::ZERO, Val::ONE, Val::ONE, Val::NEG_ONE, Val::ZERO] + { + return None; + } + let Recipe::Arithmetic(yes) = source.value_source(gate.wires[0]).unwrap() + else { + return None; + }; + let Recipe::Arithmetic(no) = source.value_source(gate.wires[1]).unwrap() + else { + return None; + }; + let yes = &source.gates()[yes]; + let no = &source.gates()[no]; + if yes.coefficients + != [Val::ONE, Val::ZERO, Val::ZERO, Val::NEG_ONE, Val::ZERO] + || no.coefficients + != [Val::NEG_ONE, Val::ONE, Val::ZERO, Val::NEG_ONE, Val::ZERO] + || yes.wires[0] != no.wires[1] + || bounds[yes.wires[0].index()] > 1 + { + return None; + } + // bit*a + (1-bit)*b lies in [0,max(a,b)], not [0,a+b]. + Some(( + 0, + i128::from(bounds[yes.wires[1].index()].max(bounds[no.wires[0].index()])), + )) +} + +fn root(parents: &mut [usize], mut i: usize) -> usize { + while parents[i] != i { + parents[i] = parents[parents[i]]; + i = parents[i]; + } + i +} + +/// Propagate only independently enforced bounds through equality and acyclic +/// integer arithmetic. An unbounded equality cycle cannot justify its own range. +fn propagate_bounds( + source: &Circuit, + bounds: &mut [u64], + checked: &mut [bool], + integer_gates: &mut [bool], +) { + let mut parents: Vec<_> = (0..source.num_values()).collect(); + for gate in source.gates() { + if gate.coefficients + == [Val::ZERO, Val::ONE, Val::NEG_ONE, Val::ZERO, Val::ZERO] + { + let a = root(&mut parents, gate.wires[0].index()); + let b = root(&mut parents, gate.wires[1].index()); + parents[a.max(b)] = a.min(b); + } + } + for (i, recipe) in source.value_sources().enumerate() { + if let Recipe::Constant(value) = recipe { + bounds[i] = value.as_canonical_u64(); + checked[i] = true; + } + let r = root(&mut parents, i); + parents[i] = r; + if checked[i] { + bounds[r] = bounds[r].min(bounds[i]); + checked[r] = true; + } + } + for (i, recipe) in source.value_sources().enumerate() { + let Recipe::Arithmetic(index) = recipe else { + continue; + }; + let gate = &source.gates()[index]; + let [a, b, c] = gate.wires.map(|w| parents[w.index()]); + let [qm, qa, qb, _, _] = gate.coefficients; + if (qm != Val::ZERO || qa != Val::ZERO) && !checked[a] + || (qm != Val::ZERO || qb != Val::ZERO) && !checked[b] + { + continue; + } + if let Some((low, high)) = + arithmetic_range(gate, [bounds[a], bounds[b], bounds[c]]) + && low >= 0 + && high < i128::from(P) + { + let r = parents[i]; + bounds[r] = bounds[r].min(u64::try_from(high).unwrap()); + checked[r] = true; + integer_gates[index] = true; + } + } + // Roots have the smallest index in their component, so this in-place + // expansion never overwrites another component's root. + for (i, &r) in parents.iter().enumerate() { + bounds[i] = bounds[r]; + checked[i] = checked[r]; + } +} + +/// Range checks use one shared 16-bit table. Rounding up the bit count remains +/// sound: even the largest gate and quotient products are below 2^209 < Fr. +fn range( + b: &mut CircuitBuilder, + table: Table, + value: Value, + bits: usize, +) { + if bits == 0 { + let zero = b.constant(Scalar::ZERO); + b.assert_equal(value, zero); + return; + } + if bits == 1 { + b.assert_bool(value); + return; + } + let count = bits.div_ceil(16); + let mut terms = Vec::with_capacity(count); + let radix = Scalar::from_u32(1 << 16); + let mut coefficient = Scalar::ONE; + for i in 0..count { + let limb = b.hint("integer limb", &[value], move |v| { + let limbs = v[0].canonical_limbs_le(); + Ok(Scalar::from_u64((limbs[i / 4] >> (16 * (i % 4))) & 0xffff)) + }); + b.lookup(table, &[limb]); + terms.push((coefficient, limb)); + coefficient *= radix; + } + let packed = b.linear_combination(&terms, Scalar::ZERO); + b.assert_equal(value, packed); +} + +fn canonical(b: &mut CircuitBuilder, table: Table, value: Value) { + let hi = b.hint("Goldilocks high word", &[value], |v| { + Ok(Scalar::from_u64(v[0].canonical_low_u64() >> 32)) + }); + let lo = b.hint("Goldilocks low word", &[value], |v| { + Ok(Scalar::from_u32( + u32::try_from(v[0].canonical_low_u64() & u64::from(u32::MAX)).unwrap(), + )) + }); + range(b, table, hi, 32); + range(b, table, lo, 32); + let packed = + b.affine([hi, lo], [Scalar::from_u64(1 << 32), Scalar::ONE], Scalar::ZERO); + b.assert_equal(value, packed); + // x < 0xffffffff00000001 iff the high word is not 0xffffffff or lo = 0. + let delta = b.affine( + [hi, hi], + [Scalar::ONE, Scalar::ZERO], + -Scalar::from_u32(u32::MAX), + ); + let max_hi = b.is_zero(delta); + let invalid = b.mul(max_hi.value(), lo); + let zero = b.constant(Scalar::ZERO); + b.assert_equal(invalid, zero); +} + +impl GoldilocksCircuit { + pub fn new(source: &Circuit) -> Self { + let mut b = CircuitBuilder::::new(); + let inputs = Self::translate(source, &mut b, false); + Self { circuit: b.finish(), inputs } + } + + /// Count first, then reserve the exact IR lengths to avoid geometric growth + /// during large translations. This runs the translation twice. + pub fn new_preallocated(source: &Circuit) -> Self { + let stats = Self::estimate(source); + let mut b = CircuitBuilder::::new(); + b.reserve(stats); + let inputs = Self::translate(source, &mut b, false); + debug_assert_eq!(b.stats(), stats); + Self { circuit: b.finish(), inputs } + } + + /// Exact translated frontend counts without storing the expanded circuit. + /// Retains a wire mapping and range bounds proportional to source values. + pub fn estimate(source: &Circuit) -> CircuitStats { + let mut b = CircuitBuilder::::counting(); + Self::translate(source, &mut b, false); + b.stats() + } + + /// Count the scalar translation with generic hash gates, without storing it. + pub fn estimate_with_expanded_hashes(source: &Circuit) -> CircuitStats { + let mut b = CircuitBuilder::::counting(); + Self::translate(source, &mut b, true); + b.stats() + } + + /// Expand compact hash calls only after translating the surrounding + /// Goldilocks arithmetic. The original hash bytes remain constrained. + pub fn new_with_expanded_hashes(source: &Circuit) -> Self { + let mut b = CircuitBuilder::::new(); + let inputs = Self::translate(source, &mut b, true); + Self { circuit: b.finish(), inputs } + } + + fn translate( + source: &Circuit, + b: &mut CircuitBuilder, + expand_hashes: bool, + ) -> GoldilocksInputs { + let range_table = b.fixed_table( + "u16", + (0..=u16::MAX).map(|v| vec![Scalar::from_u16(v)]).collect(), + ); + let mut bounds = vec![P - 1; source.num_values()]; + let mut checked = vec![false; source.num_values()]; + let mut integer_gates = vec![false; source.gates().len()]; + // x²-x=0 has exactly the same roots in both fields. These gates are + // lifted directly below, independently enforcing this bound. + for gate in source.gates() { + if boolean_gate(gate) { + bounds[gate.wires[0].index()] = 1; + checked[gate.wires[0].index()] = true; + } + } + let table_bounds: Vec> = source + .tables() + .iter() + .map(|table| { + (0..table.width()) + .map(|column| { + table + .rows() + .iter() + .map(|row| row[column].as_canonical_u64()) + .max() + .unwrap() + }) + .collect() + }) + .collect(); + // Membership checks give independently enforced bounds. + for lookup in source.lookups() { + for (column, value) in lookup.values.iter().enumerate() { + let maximum = table_bounds[lookup.table.index()][column]; + bounds[value.index()] = bounds[value.index()].min(maximum); + checked[value.index()] = true; + } + } + { + for (hash_input, hash_output) in source.blake3_calls() { + for value in hash_input.iter().chain(hash_output) { + bounds[value.index()] = bounds[value.index()].min(255); + checked[value.index()] = true; + } + } + } + propagate_bounds(source, &mut bounds, &mut checked, &mut integer_gates); + let mut wires = Vec::with_capacity(source.num_values()); + let mut inputs = Vec::with_capacity(source.num_values()); + for (i, recipe) in source.value_sources().enumerate() { + if let Recipe::Constant(value) = recipe { + let n = value.as_canonical_u64(); + bounds[i] = n; + checked[i] = true; + wires.push(b.constant(Scalar::from_u64(n))); + inputs.push(false); + } else { + // Acyclic arithmetic with no modular wrap derives a range from + // earlier wires. The translated gate enforces that equality. + if let Recipe::Arithmetic(gate) = recipe { + let gate_index = gate; + let gate = &source.gates()[gate_index]; + let output_range = + selection_range(source, gate, &bounds).or_else(|| { + arithmetic_range(gate, gate.wires.map(|w| bounds[w.index()])) + }); + if let Some((low, high)) = output_range + && low >= 0 + && high < i128::from(P) + { + bounds[i] = bounds[i].min(u64::try_from(high).unwrap()); + checked[i] = true; + integer_gates[gate_index] = true; + } + } + let value = b.input(format!("goldilocks[{i}]")); + if !checked[i] { + canonical(b, range_table, value); + } + wires.push(value); + inputs.push(true); + } + } + let tables: Vec<_> = source + .tables() + .iter() + .map(|table| { + b.fixed_table( + table.name(), + table + .rows() + .iter() + .map(|row| { + row + .iter() + .map(|v| Scalar::from_u64(v.as_canonical_u64())) + .collect() + }) + .collect(), + ) + }) + .collect(); + for lookup in source.lookups() { + b.lookup( + tables[lookup.table.index()], + &lookup.values.iter().map(|v| wires[v.index()]).collect::>(), + ); + } + { + if expand_hashes { + use multi_stark::plonkish::gadgets::ByteGadgets; + use multi_stark::plonkish::gadgets::blake3; + let bytes = ByteGadgets::new(b); + for (hash_input, hash_output) in source.blake3_calls() { + let input: Vec<_> = hash_input + .iter() + .map(|v| bytes.constrain_byte(b, wires[v.index()])) + .collect(); + let digest = blake3(b, &bytes, &input); + for (actual, expected) in digest.iter().zip(*hash_output) { + b.assert_equal(actual.value(), wires[expected.index()]); + } + } + } else { + b.enable_compact_blake3(); + for (hash_input, hash_output) in source.blake3_calls() { + b.constrain_blake3( + hash_input.iter().map(|v| wires[v.index()]).collect(), + &(*hash_output).map(|v| wires[v.index()]), + ); + } + } + } + for (gate, integer) in source.gates().iter().zip(integer_gates) { + constrain_gate(b, range_table, gate, &wires, &bounds, integer); + } + for value in source.public_values() { + b.expose_public(wires[value.index()]); + } + GoldilocksInputs { source_owner: source.id(), wires, is_input: inputs } + } + + pub fn assign( + &self, + source: &Assignment, + witness: &mut Witness<'_, Scalar>, + ) -> Result<(), WitnessError> { + self.inputs.assign(source, witness) + } +} + +impl GoldilocksInputs { + pub fn assign( + &self, + source: &Assignment, + witness: &mut Witness<'_, Scalar>, + ) -> Result<(), WitnessError> { + let values = source.values(self.source_owner)?; + for (i, &input) in self.is_input.iter().enumerate() { + if input { + witness + .set(self.wires[i], Scalar::from_u64(values[i].as_canonical_u64()))?; + } + } + Ok(()) + } +} + +fn constrain_gate( + b: &mut CircuitBuilder, + table: Table, + gate: &Gate, + wires: &[Value], + bounds: &[u64], + integer_relation: bool, +) { + let coefficients = gate.coefficients.map(signed); + let values = gate.wires.map(|v| wires[v.index()]); + if integer_relation + || boolean_gate(gate) + || small_interval(coefficients, gate.wires.map(|v| bounds[v.index()])) + .is_some_and(|(low, high)| low > -i128::from(P) && high < i128::from(P)) + { + b.constrain_gate(values, coefficients.map(signed_scalar)); + return; + } + let (low, high) = + interval(coefficients, gate.wires.map(|v| bounds[v.index()])); + let p = BigInt::from(P); + if low > -&p && high < p { + b.constrain_gate(values, coefficients.map(|c| scalar(&BigInt::from(c)))); + return; + } + let shift = + if low < BigInt::from(0) { (-low + &p - 1) / &p } else { BigInt::from(0) }; + let offset = &shift * &p; + let max_q = (high + &offset) / &p; + let bits = usize::try_from(max_q.bits()).expect("quotient fits usize"); + assert!(bits <= 130, "Goldilocks gate quotient bound"); + let hint_offset = offset.clone(); + let quotient = b.hint("Goldilocks quotient", &values, move |v| { + let [a, c, d] = [integer(v[0]), integer(v[1]), integer(v[2])]; + let [qm, qa, qb, qc, k] = coefficients; + let sum = qm * &a * &c + qa * a + qb * c + qc * d + k + &hint_offset; + if &sum % P != BigInt::from(0) { + return Err("invalid Goldilocks gate".into()); + } + Ok(scalar(&(sum / P))) + }); + range(b, table, quotient, bits); + let [qm, qa, qb, qc, k] = coefficients.map(|c| scalar(&BigInt::from(c))); + let product = b.mul(values[0], values[1]); + let residual = b.linear_combination( + &[ + (qm, product), + (qa, values[0]), + (qb, values[1]), + (qc, values[2]), + (-Scalar::from_u64(P), quotient), + ], + k + scalar(&offset), + ); + let zero = b.constant(Scalar::ZERO); + b.assert_equal(residual, zero); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn census_matches_materialized_translation() { + for compact in [false, true] { + let mut b = CircuitBuilder::::new(); + if compact { + b.enable_compact_blake3(); + } + let x = b.input("x"); + let y = b.input("y"); + let bit = b.is_zero(x); + let product = b.mul(x, y); + let selected = b.select(bit, product, y); + let five = b.constant(Val::from_u8(5)); + let folded = b.add(five, five); + let output = b.add(selected, folded); + let table = b.fixed_table( + "byte", + (0..=255).map(|i| vec![Val::from_u16(i)]).collect(), + ); + b.lookup(table, &[y]); + if compact { + for len in [0, 1, 64, 65, 1024, 1025, 2049] { + b.constrain_blake3(vec![y; len], &[y; 32]); + } + } + b.expose_public(output); + let source = b.finish(); + let regular = GoldilocksCircuit::new(&source); + let reserved = GoldilocksCircuit::new_preallocated(&source); + assert_eq!(GoldilocksCircuit::estimate(&source), regular.circuit.stats()); + assert_eq!(regular.circuit.stats(), reserved.circuit.stats()); + assert_eq!( + GoldilocksCircuit::estimate_with_expanded_hashes(&source), + GoldilocksCircuit::new_with_expanded_hashes(&source).circuit.stats() + ); + for (a, b) in regular.circuit.gates().iter().zip(reserved.circuit.gates()) + { + assert_eq!(a.coefficients, b.coefficients); + assert_eq!(a.wires.map(|v| v.index()), b.wires.map(|v| v.index())); + } + } + } + + #[test] + fn selection_preserves_integer_bounds_and_rejects_aliases() { + for maximum in [255, P - 1] { + let mut b = CircuitBuilder::::new(); + let bit = b.input("bit"); + let yes = b.input("yes"); + let no = b.input("no"); + if maximum == 255 { + let table = b.fixed_table( + "byte", + (0..=255).map(|v| vec![Val::from_u16(v)]).collect(), + ); + b.lookup(table, &[yes]); + b.lookup(table, &[no]); + } + let bit_handle = b.assert_bool(bit); + let out = b.select(bit_handle, yes, no); + b.expose_public(out); + let source = b.finish(); + let mapped = GoldilocksCircuit::new(&source); + if maximum == 255 { + assert_eq!(mapped.circuit.stats().lookups, 2); + assert_eq!(mapped.circuit.stats().gates, source.stats().gates + 1); + } else { + // Only the two branches need canonical checks; selection + // intermediates and output inherit their enforced ranges. + assert_eq!(mapped.circuit.stats().lookups, 8); + } + for choice in [0, 1] { + for (a, c) in [(0, maximum), (maximum, 0), (maximum, maximum - 1)] { + let mut witness = source.witness(); + witness.set(bit, Val::from_u8(choice)).unwrap(); + witness.set(yes, Val::from_u64(a)).unwrap(); + witness.set(no, Val::from_u64(c)).unwrap(); + let assignment = witness.generate().unwrap(); + let mut witness = mapped.circuit.witness(); + mapped.assign(&assignment, &mut witness).unwrap(); + let assignment = witness.generate().unwrap(); + assert_eq!( + assignment.public_values(), + [Scalar::from_u64(if choice == 1 { a } else { c })] + ); + // Alter each source wire by p, preserving its Goldilocks + // residue. Scalar constraints must still reject it. + for (i, &is_input) in mapped.inputs.is_input.iter().enumerate() { + if is_input { + let mut forged = + assignment.values(mapped.circuit.id()).unwrap().to_vec(); + forged[mapped.inputs.wires[i].index()] += Scalar::from_u64(P); + assert!(mapped.circuit.check_values(&forged).is_err()); + } + } + } + } + } + } + + #[test] + fn boolean_bounds_are_enforced_without_integer_range_gadgets() { + let mut b = CircuitBuilder::::new(); + let x = b.input("bit"); + b.assert_bool(x); + let y = b.affine([x, x], [Val::from_u8(15), Val::ZERO], Val::from_u8(3)); + b.expose_public(y); + let source = b.finish(); + let mapped = GoldilocksCircuit::new(&source); + assert_eq!(mapped.circuit.stats().lookups, 0); + for bit in [0, 1] { + let mut witness = source.witness(); + witness.set(x, Val::from_u8(bit)).unwrap(); + let assignment = witness.generate().unwrap(); + let mut witness = mapped.circuit.witness(); + mapped.assign(&assignment, &mut witness).unwrap(); + let output = witness.generate().unwrap(); + assert_eq!(output.public_values(), [Scalar::from_u8(bit * 15 + 3)]); + } + for invalid in [2, P, P + 1, u64::MAX] { + let mut witness = mapped.circuit.witness(); + for (i, &is_input) in mapped.inputs.is_input.iter().enumerate() { + if is_input { + witness + .set( + mapped.inputs.wires[i], + Scalar::from_u64(if i == x.index() { invalid } else { 3 }), + ) + .unwrap(); + } + } + assert!(witness.generate().is_err()); + } + } + + #[test] + fn equality_ranges_need_an_independent_bound() { + for bounded in [false, true] { + let mut b = CircuitBuilder::::new(); + let x = b.input("x"); + let bit = b.input("bit"); + b.assert_bool(bit); + let y = b.mul(x, bit); + b.assert_equal(x, y); // At bit=1 this cycle implies no range. + if bounded { + let byte = b.input("byte"); + let table = b.fixed_table( + "byte", + (0..=255).map(|v| vec![Val::from_u16(v)]).collect(), + ); + b.lookup(table, &[byte]); + b.assert_equal(y, byte); + } + let source = b.finish(); + let mapped = GoldilocksCircuit::new(&source); + if bounded { + assert_eq!(mapped.circuit.stats().lookups, 1); + } + for n in [7, P, P + 7] { + let mut witness = mapped.circuit.witness(); + for (i, &is_input) in mapped.inputs.is_input.iter().enumerate() { + if is_input { + witness + .set( + mapped.inputs.wires[i], + Scalar::from_u64(if i == bit.index() { 1 } else { n }), + ) + .unwrap(); + } + } + assert_eq!(witness.generate().is_ok(), n == 7); + } + } + } + + #[test] + fn modular_arithmetic_and_canonical_boundaries() { + let mut b = CircuitBuilder::::new(); + let x = b.input("x"); + let y = b.input("y"); + let product = b.mul(x, y); + let result = b.sub(product, x); + b.expose_public(result); + let source = b.finish(); + let mapped = GoldilocksCircuit::new(&source); + for (a, c) in + [(0, 0), (1, 2), (P - 1, P - 1), (P - 1, 2), (1 << 63, 1 << 63)] + { + let mut witness = source.witness(); + witness.set(x, Val::from_u64(a)).unwrap(); + witness.set(y, Val::from_u64(c)).unwrap(); + let assignment = witness.generate().unwrap(); + let mut witness = mapped.circuit.witness(); + mapped.assign(&assignment, &mut witness).unwrap(); + let output = witness.generate().unwrap(); + assert_eq!( + output.public_values(), + &[Scalar::from_u64(assignment.public_values()[0].as_canonical_u64())] + ); + let quotient = mapped + .circuit + .gates() + .iter() + .find_map(|gate| { + gate.wires.iter().zip(&gate.coefficients[1..4]).find_map( + |(&wire, &coefficient)| { + (coefficient == -Scalar::from_u64(P) + && matches!( + mapped.circuit.value_source(wire), + Ok(Recipe::Hint) + )) + .then_some(wire) + }, + ) + }) + .expect("constrained modular quotient"); + let mut forged = output.values(mapped.circuit.id()).unwrap().to_vec(); + forged[quotient.index()] += Scalar::ONE; + assert!(mapped.circuit.check_values(&forged).is_err()); + } + // Bypass the source witness API: noncanonical scalar inputs must fail + // the actual translated constraints, not just a conversion helper. + for invalid in [P, u64::MAX] { + let mut witness = mapped.circuit.witness(); + for (i, &is_input) in mapped.inputs.is_input.iter().enumerate() { + if is_input { + let value = if i == x.index() { + Scalar::from_u64(invalid) + } else { + Scalar::ZERO + }; + witness.set(mapped.inputs.wires[i], value).unwrap(); + } + } + assert!(witness.generate().is_err()); + } + } + + #[test] + fn arbitrary_gate_coefficients_and_lookup_bounds() { + let mut b = CircuitBuilder::::new(); + let x = b.input("x"); + let y = b.input("y"); + let table = + b.fixed_table("small", (0..16).map(|i| vec![Val::from_u8(i)]).collect()); + b.lookup(table, &[x]); + b.lookup(table, &[y]); + let product = b.mul(x, y); + let coefficient = Val::from_u64(P / 2); + b.constrain_gate( + [x, y, product], + [coefficient, Val::ZERO, Val::ZERO, -coefficient, Val::ZERO], + ); + b.expose_public(product); + let source = b.finish(); + let mapped = GoldilocksCircuit::new(&source); + let mut witness = source.witness(); + witness.set(x, Val::from_u8(15)).unwrap(); + witness.set(y, Val::from_u8(13)).unwrap(); + let assignment = witness.generate().unwrap(); + let mut witness = mapped.circuit.witness(); + mapped.assign(&assignment, &mut witness).unwrap(); + let output = witness.generate().unwrap(); + assert_eq!(output.public_values(), &[Scalar::from_u8(195)]); + let mut forged = output.values(mapped.circuit.id()).unwrap().to_vec(); + forged[mapped.inputs.wires[product.index()].index()] += Scalar::ONE; + assert!(mapped.circuit.check_values(&forged).is_err()); + } + + #[test] + fn lookup_tuning_respects_the_quotient_budget() { + let mut b = CircuitBuilder::::new(); + let x = b.input("x"); + let y = b.mul(x, x); + b.expose_public(y); + let compiled = b.finish().lower_to_multi_stark(Scalar::from_u8(1)).unwrap(); + let height = compiled.main_height(); + let tuned = compiled.kzg_circuit_inputs(height, 4).unwrap(); + assert_eq!(tuned[0].lookup_group_size, 4); + assert!(matches!( + compiled.kzg_circuit_inputs(height, 1), + Err(LoweringError::QuotientBudgetTooSmall) + )); + } +} diff --git a/crates/proof-compression/src/fri/algebra.rs b/crates/proof-compression/src/fri/algebra.rs new file mode 100644 index 000000000..04f583f30 --- /dev/null +++ b/crates/proof-compression/src/fri/algebra.rs @@ -0,0 +1,427 @@ +use std::borrow::Borrow; + +use p3_field::extension::BinomiallyExtendable; +use p3_field::{Field, TwoAdicField}; + +use super::QuadraticValue as Q; +use multi_stark::eval::VarValues; +use multi_stark::expr::RowOffset; +use multi_stark::expr::Source; +use multi_stark::graph::ConstraintGraph; +use multi_stark::graph::Node; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; +use multi_stark::system::Circuit; + +/// Boundary wires bound by the transcript gadget. None of these +/// values is authenticated by the algebraic checks themselves. +#[derive(Clone, Copy, Debug)] +pub struct AlgebraicChallenges { + pub beta: Q, + pub gamma: Q, + pub alpha: Q, + pub zeta: Q, +} + +/// Openings of one active circuit. Every entry is the evaluation of a +/// **base column** in the challenge field, including the stage-2 columns. +#[derive(Clone, Debug)] +pub struct CircuitOpenings { + pub preprocessed: [Vec; 2], + pub main: [Vec; 2], + pub stage2: [Vec; 2], + pub quotient: Vec, + pub accumulator: Q, +} + +/// An explicitly unauthenticated algebraic-verifier input interface. +#[derive(Clone, Debug)] +pub struct AlgebraicInputs { + pub claims: Vec>, + pub challenges: AlgebraicChallenges, + /// Active-circuit order (a fixed subset of canonical circuit order). + pub openings: Vec, +} + +impl AlgebraicInputs { + /// Allocate inputs using only fixed circuit metadata and claim lengths. + /// Claims are public. Openings and challenges are private boundary wires + /// which a complete verifier MUST bind to its PCS and transcript gadgets. + pub fn allocate( + builder: &mut CircuitBuilder, + circuits: &[impl Borrow>], + claim_lengths: &[usize], + ) -> Self { + let claims = claim_lengths + .iter() + .enumerate() + .map(|(i, &len)| { + (0..len) + .map(|j| builder.public_input(format!("claim[{i}][{j}]"))) + .collect() + }) + .collect(); + Self::with_claims(builder, circuits, claims) + } + + /// Compose verification with caller-owned statement wires. Public exposure + /// is the caller's choice; claims may be derived by other gadgets. + pub fn with_claims( + builder: &mut CircuitBuilder, + circuits: &[impl Borrow>], + claims: Vec>, + ) -> Self { + let challenges = AlgebraicChallenges { + beta: Q::input(builder, "beta"), + gamma: Q::input(builder, "gamma"), + alpha: Q::input(builder, "alpha"), + zeta: Q::input(builder, "zeta"), + }; + let openings = circuits + .iter() + .map(Borrow::borrow) + .enumerate() + .map(|(i, circuit)| { + let mut row = |name, len| { + (0..len) + .map(|j| Q::input(builder, &format!("circuit[{i}].{name}[{j}]"))) + .collect() + }; + CircuitOpenings { + preprocessed: [ + row("preprocessed", circuit.preprocessed_width), + row("preprocessed_next", circuit.preprocessed_width), + ], + main: [ + row("main", circuit.main_width), + row("main_next", circuit.main_width), + ], + stage2: [ + row("stage2", circuit.stage_2_width), + row("stage2_next", circuit.stage_2_width), + ], + quotient: row("quotient", circuit.quotient_degree() * 2), + accumulator: Q::input(builder, &format!("circuit[{i}].accumulator")), + } + }) + .collect(); + Self { claims, challenges, openings } + } +} + +/// Unnormalized selectors, matching the native two-adic subgroup verifier. +#[derive(Clone, Copy, Debug)] +pub struct Selectors { + pub is_first_row: Q, + pub is_last_row: Q, + pub is_transition: Q, + pub inv_vanishing: Q, +} + +/// Intermediate wires for differential tests and subsequent PCS integration. +pub struct CircuitEvaluation { + pub selectors: Selectors, + pub zeta_next: Q, + pub nodes: Vec, + pub constraints: Vec, + pub composition: Q, + pub quotient: Q, +} + +pub struct AlgebraicOutputs { + pub claims_accumulator: Q, + pub circuits: Vec, +} + +fn sweep + multi_stark::traits::Field>( + builder: &mut CircuitBuilder, + graph: &ConstraintGraph, + view: &VarValues<'_, Q>, +) -> Vec { + let mut values: Vec = Vec::with_capacity(graph.nodes.len()); + for node in &graph.nodes { + let value = match *node { + Node::Const(c) => Q::constant( + builder, + [c, ::ZERO], + ), + Node::Var(col) => { + let rows = match col.source { + Source::Preprocessed => view.preprocessed, + Source::Main => view.main, + Source::Stage2 => view.stage2, + }; + let row = match col.offset { + RowOffset::Current => 0, + RowOffset::Next => 1, + }; + rows[row][col.index as usize] + }, + Node::Public(i) => view.publics[i as usize], + Node::IsFirstRow => view.is_first_row, + Node::IsLastRow => view.is_last_row, + Node::IsTransition => view.is_transition, + Node::Add(a, b) => values[a.index()].add(builder, values[b.index()]), + Node::Sub(a, b) => values[a.index()].sub(builder, values[b.index()]), + Node::Mul(a, b) => values[a.index()].mul(builder, values[b.index()]), + Node::Neg(a) => values[a.index()].neg(builder), + }; + values.push(value); + } + values +} + +/// Multiply pairs of coordinate-polynomial evaluations. Each coordinate is +/// itself in the OOD challenge field. This is NOT ordinary multiplication +/// after recombining the two stage-2 columns into one extension value. +fn coordinate_mul + multi_stark::traits::Field>( + builder: &mut CircuitBuilder, + a: [Q; 2], + b: [Q; 2], +) -> [Q; 2] { + let ac = a[0].mul(builder, b[0]); + let bd = a[1].mul(builder, b[1]); + let a_sum = a[0].add(builder, a[1]); + let b_sum = b[0].add(builder, b[1]); + let cross = a_sum.mul(builder, b_sum); + let cross = cross.sub(builder, ac); + let cross = cross.sub(builder, bd); + let w_bd = bd.scale(builder, F::W); + [ac.add(builder, w_bd), cross] +} + +/// Constrain the claim balance and all OOD/quotient identities. +/// +/// Supported scope: quadratic binomial challenge field, two-adic *subgroup* +/// trace domains, fixed active circuits/heights, and grouped lookups. +/// Unsupported metadata or mismatched wire shapes are construction errors +/// and panic. No proof/witness values affect circuit construction. +/// +/// This is NOT a proof verifier: callers must additionally constrain exact +/// Fiat-Shamir replay and PCS verification before making that claim. +pub fn constrain_algebraic_checks< + F: TwoAdicField + BinomiallyExtendable<2> + multi_stark::traits::Field, +>( + builder: &mut CircuitBuilder, + circuits: &[impl Borrow>], + log_degrees: &[u8], + inputs: &AlgebraicInputs, +) -> AlgebraicOutputs { + let zero = + Q::constant(builder, [::ZERO; 2]); + constrain_algebraic_checks_with_residual( + builder, + circuits, + log_degrees, + inputs, + zero, + ) +} + +/// Batch shards can end in a nonzero residual; the batch caller must constrain +/// its value against the verifier-side messages (and any other shard residuals). +pub(super) fn constrain_algebraic_checks_with_residual< + F: TwoAdicField + BinomiallyExtendable<2> + multi_stark::traits::Field, +>( + builder: &mut CircuitBuilder, + circuits: &[impl Borrow>], + log_degrees: &[u8], + inputs: &AlgebraicInputs, + residual: Q, +) -> AlgebraicOutputs { + let circuits: Vec<&Circuit> = + circuits.iter().map(Borrow::borrow).collect(); + assert!(!circuits.is_empty(), "at least one active circuit is required"); + assert_eq!(circuits.len(), log_degrees.len()); + assert_eq!(circuits.len(), inputs.openings.len()); + for ((circuit, &log_degree), opening) in + circuits.iter().zip(log_degrees).zip(&inputs.openings) + { + assert_eq!(circuit.num_publics, 8, "quadratic challenge field required"); + assert!( + (1..=multi_stark::lookup::MAX_LOOKUP_GROUP) + .contains(&circuit.lookup_group_size) + ); + assert!(usize::from(log_degree) <= F::TWO_ADICITY); + let height = + 1usize.checked_shl(u32::from(log_degree)).expect("height overflow"); + if circuit.preprocessed_height != 0 { + assert_eq!(circuit.preprocessed_height, height); + } + for row in 0..2 { + assert_eq!(opening.preprocessed[row].len(), circuit.preprocessed_width); + assert_eq!(opening.main[row].len(), circuit.main_width); + assert_eq!(opening.stage2[row].len(), circuit.stage_2_width); + } + assert_eq!(opening.quotient.len(), circuit.quotient_degree() * 2); + } + let zero = + Q::constant(builder, [::ZERO; 2]); + let one = Q::constant( + builder, + [ + ::ONE, + ::ZERO, + ], + ); + let AlgebraicChallenges { beta, gamma, alpha, zeta } = inputs.challenges; + let mut acc = zero; + for claim in &inputs.claims { + let mut fingerprint = zero; + for &arg in claim.iter().rev() { + let arg = Q::from_base(builder, arg); + fingerprint = fingerprint.mul(builder, gamma); + fingerprint = fingerprint.add(builder, arg); + } + let message = beta.add(builder, fingerprint); + let inverse = message.inverse(builder); + acc = acc.add(builder, inverse); + } + let claims_accumulator = acc; + inputs.openings.last().unwrap().accumulator.assert_equal(builder, residual); + let mut evaluations = Vec::with_capacity(circuits.len()); + for ((circuit, &log_degree), opening) in + circuits.iter().zip(log_degrees).zip(&inputs.openings) + { + let log_degree = usize::from(log_degree); + let g = F::two_adic_generator(log_degree); + let last = Q::constant( + builder, + [Field::inverse(&g), ::ZERO], + ); + let zeta_pow_n = zeta.exp_power_of_2(builder, log_degree); + let vanishing = zeta_pow_n.sub(builder, one); + let inv_vanishing = vanishing.inverse(builder); + let first_denominator = zeta.sub(builder, one); + let first_inverse = first_denominator.inverse(builder); + let is_transition = zeta.sub(builder, last); + let last_inverse = is_transition.inverse(builder); + let selectors = Selectors { + is_first_row: vanishing.mul(builder, first_inverse), + is_last_row: vanishing.mul(builder, last_inverse), + is_transition, + inv_vanishing, + }; + let zeta_next = zeta.scale(builder, g); + let next_acc = opening.accumulator; + // The protocol's four extension publics are split into base + // coordinates, each embedded separately into the OOD field. + let publics: Vec<_> = [beta, gamma, acc, next_acc] + .into_iter() + .flat_map(|value| value.0) + .map(|value| Q::from_base(builder, value)) + .collect(); + let view = VarValues { + preprocessed: [&opening.preprocessed[0], &opening.preprocessed[1]], + main: [&opening.main[0], &opening.main[1]], + stage2: [&opening.stage2[0], &opening.stage2[1]], + publics: &publics, + is_first_row: selectors.is_first_row, + is_last_row: selectors.is_last_row, + is_transition, + }; + let nodes = sweep(builder, &circuit.graph, &view); + let mut constraints: Vec<_> = + circuit.graph.zeros.iter().map(|id| nodes[id.index()]).collect(); + let injection_norm = Field::inverse( + &(::from_usize(1 << log_degree) * g), + ); + let injection: [Q; 2] = std::array::from_fn(|k| { + let delta = publics[6 + k].sub(builder, publics[4 + k]); + let delta = delta.scale(builder, injection_norm); + selectors.is_last_row.mul(builder, delta) + }); + if circuit.graph.lookups.is_empty() { + for (k, inj) in injection.into_iter().enumerate() { + let delta = opening.stage2[1][k].sub(builder, opening.stage2[0][k]); + constraints.push(delta.add(builder, inj)); + } + } else { + let groups = circuit.graph.lookups.chunks(circuit.lookup_group_size); + let count = groups.len(); + for (i, group) in groups.enumerate() { + let messages: Vec<_> = group + .iter() + .map(|lookup| { + let mut message = [zero; 2]; + for arg in lookup.args.iter().rev() { + message = + coordinate_mul(builder, message, [publics[2], publics[3]]); + message[0] = message[0].add(builder, nodes[arg.index()]); + } + message[0] = message[0].add(builder, publics[0]); + message[1] = message[1].add(builder, publics[1]); + message + }) + .collect(); + let mut prefix = vec![[one, zero]]; + for &message in &messages { + prefix.push(coordinate_mul( + builder, + *prefix.last().unwrap(), + message, + )); + } + let mut suffix = vec![[one, zero]; group.len() + 1]; + for j in (0..group.len()).rev() { + suffix[j] = coordinate_mul(builder, messages[j], suffix[j + 1]); + } + let delta = std::array::from_fn(|k| { + let target = if i + 1 < count { + opening.stage2[0][2 * (i + 1) + k] + } else { + opening.stage2[1][k].add(builder, injection[k]) + }; + target.sub(builder, opening.stage2[0][2 * i + k]) + }); + let mut constraint = + coordinate_mul(builder, *prefix.last().unwrap(), delta); + for (j, lookup) in group.iter().enumerate() { + let others = coordinate_mul(builder, prefix[j], suffix[j + 1]); + for k in 0..2 { + let term = + others[k].mul(builder, nodes[lookup.multiplicity.index()]); + constraint[k] = constraint[k].sub(builder, term); + } + } + constraints.extend(constraint); + } + } + assert_eq!(constraints.len(), circuit.constraint_count()); + let mut composition = zero; + for &constraint in &constraints { + composition = composition.mul(builder, alpha); + composition = composition.add(builder, constraint); + } + // Unlike stage 2, quotient coefficient slices MUST be recombined + // with the extension basis before summing powers of zeta^n. + let basis = Q::constant( + builder, + [ + ::ZERO, + ::ONE, + ], + ); + let mut quotient = zero; + let mut power = one; + for chunk in opening.quotient.as_chunks::<2>().0 { + let imag = chunk[1].mul(builder, basis); + let chunk = chunk[0].add(builder, imag); + let term = power.mul(builder, chunk); + quotient = quotient.add(builder, term); + power = power.mul(builder, zeta_pow_n); + } + let divided_composition = composition.mul(builder, inv_vanishing); + divided_composition.assert_equal(builder, quotient); + evaluations.push(CircuitEvaluation { + selectors, + zeta_next, + nodes, + constraints, + composition, + quotient, + }); + acc = next_acc; + } + AlgebraicOutputs { claims_accumulator, circuits: evaluations } +} diff --git a/crates/proof-compression/src/fri/batch.rs b/crates/proof-compression/src/fri/batch.rs new file mode 100644 index 000000000..ebec3478d --- /dev/null +++ b/crates/proof-compression/src/fri/batch.rs @@ -0,0 +1,375 @@ +//! Shared batch transcript and residual balance, distinct from the +//! ordinary-proof transcript. Compatibility helpers specialize messages to +//! constants; the validated plan uses the same implementation with bound wires. +//! Application policy belongs to the caller in both cases. +use super::AlgebraicInputs; +use super::Blake3Challenger; +use super::ByteGadgets; +use super::ExpandedPcsWitness; +use super::FixedPcsShape; +use super::FixedVerifierInputs; +use super::PcsInputs; +use super::QuadraticValue as Q; +use super::TranscriptCommitments; +use super::algebra::constrain_algebraic_checks_with_residual; +use super::pcs::constrain_pcs; +use super::pcs_witness::expand_after_lookup; +use multi_stark::batch::BatchMessage; +use multi_stark::batch::BatchProof; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; +use multi_stark::system::System; +use multi_stark::types::GoldilocksBlake3Config as Config; +use multi_stark::types::Val; +use p3_challenger::CanObserve; + +pub fn build_single_batch_verifier( + system: &System, + shape: &FixedPcsShape, + claim_lengths: &[usize], + messages: &[BatchMessage], +) -> (Circuit, FixedVerifierInputs) { + let mut b = CircuitBuilder::new(); + let claims = claim_lengths + .iter() + .enumerate() + .map(|(i, &n)| { + (0..n).map(|j| b.public_input(format!("claim[{i}][{j}]"))).collect() + }) + .collect(); + let bytes = ByteGadgets::new(&mut b); + let inputs = constrain_single_batch_verifier( + &mut b, &bytes, system, shape, claims, messages, + ); + (b.finish(), inputs) +} + +/// All claims remain caller-owned wires. Every message is fixed into the +/// transcript and residual-balance constraints, never trusted witness advice. +pub fn constrain_single_batch_verifier( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shape: &FixedPcsShape, + claims: Vec>, + messages: &[BatchMessage], +) -> FixedVerifierInputs { + let messages: Vec<_> = messages + .iter() + .map(|m| super::plan::Message { + args: m.args.iter().map(|&v| b.constant(v)).collect(), + multiplicity: b.constant(m.multiplicity), + }) + .collect(); + constrain_bound_batch_verifier(b, bytes, system, shape, claims, &messages) +} + +pub(super) fn constrain_bound_batch_verifier( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shape: &FixedPcsShape, + claims: Vec>, + messages: &[super::plan::Message], +) -> FixedVerifierInputs { + constrain_bound_batch_queries( + b, + bytes, + system, + shape, + claims, + messages, + 0..system.config.fri_parameters().num_queries, + true, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn constrain_bound_batch_queries( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shape: &FixedPcsShape, + claims: Vec>, + messages: &[super::plan::Message], + queries: std::ops::Range, + check_algebra: bool, +) -> FixedVerifierInputs { + constrain_batch_queries( + b, + bytes, + system, + std::slice::from_ref(shape), + vec![claims], + messages, + queries, + check_algebra, + ) + .remove(0) +} + +/// Verify every shard under a shared preamble, then constrain the sum of +/// their residuals against the messages. Shapes and message policy must be +/// validated by the caller; statements are caller-owned wires. +pub fn constrain_batch_verifier( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shapes: &[FixedPcsShape], + claims: Vec>>, + messages: &[super::plan::Message], +) -> Vec { + constrain_batch_queries( + b, + bytes, + system, + shapes, + claims, + messages, + 0..system.config.fri_parameters().num_queries, + true, + ) +} + +#[allow(clippy::too_many_arguments)] +fn constrain_batch_queries( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shapes: &[FixedPcsShape], + claims: Vec>>, + messages: &[super::plan::Message], + queries: std::ops::Range, + check_algebra: bool, +) -> Vec { + assert!(!shapes.is_empty()); + assert_eq!(shapes.len(), claims.len()); + let shapes: Vec<_> = shapes + .iter() + .map(|shape| { + let mut checked = + FixedPcsShape::from_profile(system, &shape.active, &shape.log_degrees); + checked.max_field_retries = shape.max_field_retries; + checked + }) + .collect(); + // A shared challenger has one retry policy across the whole batch. + assert!( + shapes.iter().all(|s| s.max_field_retries == shapes[0].max_field_retries) + ); + let algebra: Vec<_> = shapes + .iter() + .zip(claims) + .map(|(shape, claims)| { + let circuits: Vec<_> = system + .circuits + .iter() + .zip(&shape.active) + .filter_map(|(c, &active)| active.then_some(c)) + .collect(); + AlgebraicInputs::with_claims(b, &circuits, claims) + }) + .collect(); + let commitments: Vec<_> = + shapes.iter().map(|_| TranscriptCommitments::allocate(b, bytes)).collect(); + let mut ch = Blake3Challenger::with_retry_limit( + b, + bytes, + system.config.challenger_seed(), + shapes[0].max_field_retries, + ); + ch.observe_constant(b, bytes, system.circuits.len()); + for c in &system.circuits { + for value in [ + c.constraint_count(), + c.max_constraint_degree(), + c.preprocessed_height, + c.preprocessed_width, + c.main_width, + c.stage_2_width, + c.lookup_group_size, + ] { + ch.observe_constant(b, bytes, value); + } + } + if let Some(commit) = &system.preprocessed_commit { + assert_eq!(commit.roots().len(), 1); + ch.observe_bytes(&commit.roots()[0].map(|v| bytes.constant(b, v))); + } + ch.observe_constant(b, bytes, shapes.len()); + for ((shape, algebra), commitments) in + shapes.iter().zip(&algebra).zip(&commitments) + { + for &active in &shape.active { + ch.observe_constant(b, bytes, usize::from(active)); + } + ch.observe_bytes(&commitments.stage1); + ch.observe_constant(b, bytes, shape.log_degrees.len()); + for &log in &shape.log_degrees { + ch.observe_constant(b, bytes, usize::from(log)); + } + ch.observe_constant(b, bytes, algebra.claims.len()); + for claim in &algebra.claims { + ch.observe_constant(b, bytes, claim.len()); + for &value in claim { + ch.observe_field(b, bytes, value); + } + } + } + ch.observe_constant(b, bytes, messages.len()); + for message in messages { + ch.observe_constant(b, bytes, message.args.len()); + for &value in + message.args.iter().chain(std::iter::once(&message.multiplicity)) + { + ch.observe_field(b, bytes, value); + } + } + let beta = ch.sample_extension(b, bytes); + ch.observe_extension(b, bytes, beta); + let gamma = ch.sample_extension(b, bytes); + ch.observe_extension(b, bytes, gamma); + let zero = Q::constant(b, [Val::ZERO; 2]); + let mut total = zero; + if check_algebra { + for message in messages { + let mut fingerprint = zero; + for &value in message.args.iter().rev() { + fingerprint = fingerprint.mul(b, gamma); + let arg = Q::from_base(b, value); + fingerprint = fingerprint.add(b, arg); + } + let term = + beta.add(b, fingerprint).inverse(b).mul_base(b, message.multiplicity); + total = total.add(b, term); + } + } + let inputs = shapes + .into_iter() + .zip(algebra) + .zip(commitments) + .enumerate() + .map(|(index, ((shape, algebra), commitments))| { + beta.assert_equal(b, algebra.challenges.beta); + gamma.assert_equal(b, algebra.challenges.gamma); + let mut shard_ch = ch.clone(); + shard_ch.observe_constant(b, bytes, index); + shard_ch.observe_bytes(&commitments.stage2); + for opening in &algebra.openings { + shard_ch.observe_extension(b, bytes, opening.accumulator); + } + shard_ch + .sample_extension(b, bytes) + .assert_equal(b, algebra.challenges.alpha); + shard_ch.observe_bytes(&commitments.quotient); + shard_ch + .sample_extension(b, bytes) + .assert_equal(b, algebra.challenges.zeta); + if check_algebra { + let circuits: Vec<_> = system + .circuits + .iter() + .zip(&shape.active) + .filter_map(|(c, &active)| active.then_some(c)) + .collect(); + let residual = algebra + .openings + .last() + .expect("nonempty active circuits") + .accumulator; + constrain_algebraic_checks_with_residual( + b, + &circuits, + &shape.log_degrees, + &algebra, + residual, + ); + total = total.add(b, residual); + } + let pcs = PcsInputs::allocate_queries(b, bytes, &shape, queries.clone()); + let query_bits = constrain_pcs( + b, + bytes, + system, + &shape, + &algebra, + &commitments, + &pcs, + shard_ch, + ); + FixedVerifierInputs { shape, algebra, commitments, pcs, query_bits } + }) + .collect(); + if check_algebra { + total.assert_equal(b, zero); + } + inputs +} + +/// Untrusted normalization. Native verification is not used as an oracle. +/// The circuit replays every challenge and constrains all openings and paths. +pub fn expand_single_batch_witness( + system: &System, + shape: &FixedPcsShape, + batch: &BatchProof, + messages: &[BatchMessage], +) -> Result { + if batch.preamble.headers.len() != 1 || batch.proofs.len() != 1 { + return Err("expected exactly one batch shard".into()); + } + if batch.preamble.messages.len() != messages.len() + || batch + .preamble + .messages + .iter() + .zip(messages) + .any(|(a, b)| a.args != b.args || a.multiplicity != b.multiplicity) + { + return Err("batch messages differ from the circuit profile".into()); + } + Ok( + expand_batch_witness(system, std::slice::from_ref(shape), batch)?.remove(0), + ) +} + +/// Normalize all shards without pre-verifying them. Assignment and circuit +/// constraints, rather than native verification, authenticate the result. +pub fn expand_batch_witness( + system: &System, + shapes: &[FixedPcsShape], + batch: &BatchProof, +) -> Result, String> { + if shapes.is_empty() + || shapes.len() != batch.proofs.len() + || shapes.len() != batch.preamble.headers.len() + { + return Err("batch shard count differs from profile".into()); + } + for ((shape, header), proof) in + shapes.iter().zip(&batch.preamble.headers).zip(&batch.proofs) + { + if header.active != shape.active + || header.log_degrees != shape.log_degrees + || header.active != proof.active + || header.log_degrees != proof.log_degrees + || header.stage_1_trace != proof.commitments.stage_1_trace + { + return Err("batch header differs from proof or profile".into()); + } + } + let (challenger, beta, gamma) = system.batch_challenger(&batch.preamble); + shapes + .iter() + .zip(&batch.preamble.headers) + .zip(&batch.proofs) + .enumerate() + .map(|(index, ((shape, header), proof))| { + let mut ch = challenger.clone(); + ch.observe(Val::from_usize(index)); + let claims: Vec<_> = header.claims.iter().map(Vec::as_slice).collect(); + expand_after_lookup(system, shape, proof, &claims, ch, beta, gamma) + }) + .collect() +} diff --git a/crates/proof-compression/src/fri/key_codec.rs b/crates/proof-compression/src/fri/key_codec.rs new file mode 100644 index 000000000..1152140ad --- /dev/null +++ b/crates/proof-compression/src/fri/key_codec.rs @@ -0,0 +1,194 @@ +//! Versioned verifier-only transport; no prover matrices are serialized. +use p3_blake3::Blake3; +use p3_symmetric::CryptographicHasher; +use serde::{Deserialize, Serialize}; + +use super::plan::VerifierError; +use super::plan::VerifierKey; +use super::plan::validate_circuit; +use multi_stark::graph::ConstraintGraph; +use multi_stark::system::Circuit; +use multi_stark::system::System; +use multi_stark::types::Commitment; +use multi_stark::types::CommitmentParameters; +use multi_stark::types::FriParameters; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; + +#[derive(Serialize, Deserialize)] +struct CircuitData { + graph: ConstraintGraph, + metadata: [usize; 8], +} +#[derive(Serialize, Deserialize)] +struct KeyData { + version: u32, + commitment: [usize; 2], + fri: [usize; 5], + circuits: Vec, + preprocessing: Option, + indices: Vec>, +} + +impl VerifierKey { + /// Canonical versioned encoding for trusted key transport and fingerprints. + pub fn to_bytes(&self) -> Result, VerifierError> { + let s = self.system(); + let fp = s.config.fri_parameters(); + use multi_stark::config::StarkGenericConfig; + let data = KeyData { + version: 1, + commitment: [s.config.log_blowup(), s.config.cap_height()], + fri: [ + fp.log_final_poly_len, + fp.max_log_arity, + fp.num_queries, + fp.commit_proof_of_work_bits, + fp.query_proof_of_work_bits, + ], + circuits: s + .circuits + .iter() + .map(|c| CircuitData { + graph: c.graph.clone(), + metadata: [ + c.main_width, + c.preprocessed_width, + c.preprocessed_height, + c.num_lookups, + c.stage_2_width, + c.num_publics, + c.lookup_group_size, + c.max_constraint_degree, + ], + }) + .collect(), + preprocessing: s.preprocessed_commit.clone(), + indices: s.preprocessed_indices.clone(), + }; + bincode::serde::encode_to_vec(data, bincode::config::standard()) + .map_err(|e| VerifierError::Encoding(e.to_string())) + } + + /// Decode a trusted key, with a 256 MiB decoding budget. Trust/authentication + /// remains the caller's responsibility. Profile validation is still required. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (data, used): (KeyData, usize) = bincode::serde::decode_from_slice( + bytes, + bincode::config::standard().with_limit::<268435456>(), + ) + .map_err(|e| VerifierError::Encoding(e.to_string()))?; + if used != bytes.len() || data.version != 1 { + return Err(VerifierError::Profile("key version/trailing bytes")); + } + let commitment = CommitmentParameters { + log_blowup: data.commitment[0], + cap_height: data.commitment[1], + }; + let fri = FriParameters { + log_final_poly_len: data.fri[0], + max_log_arity: data.fri[1], + num_queries: data.fri[2], + commit_proof_of_work_bits: data.fri[3], + query_proof_of_work_bits: data.fri[4], + }; + super::validate_parameters(commitment, fri)?; + let mut circuits = Vec::with_capacity(data.circuits.len()); + for (index, c) in data.circuits.into_iter().enumerate() { + let [ + main_width, + preprocessed_width, + preprocessed_height, + num_lookups, + stage_2_width, + num_publics, + lookup_group_size, + max_constraint_degree, + ] = c.metadata; + if !(1..=multi_stark::lookup::MAX_LOOKUP_GROUP) + .contains(&lookup_group_size) + { + return Err(VerifierError::Circuit { + index, + field: "lookup group size", + }); + } + let count = num_lookups + .max(1) + .div_ceil(lookup_group_size) + .checked_mul(2) + .and_then(|n| c.graph.zeros.len().checked_add(n)) + .ok_or(VerifierError::Profile("constraint count overflow"))?; + let circuit = Circuit { + graph: c.graph, + main_width, + preprocessed: None, + preprocessed_width, + preprocessed_height, + num_lookups, + stage_2_width, + num_publics, + lookup_group_size, + constraint_count: count, + max_constraint_degree, + }; + validate_circuit(&circuit, index)?; + circuits.push(circuit); + } + let key = Self::new(System { + config: GoldilocksBlake3Config::new(commitment, fri), + circuits, + preprocessed_commit: data.preprocessing, + preprocessed_indices: data.indices, + }); + let system = key.system(); + if system.preprocessed_indices.len() != system.circuits.len() { + return Err(VerifierError::Profile("preprocessing map length")); + } + let mut slot = 0; + for (index, c) in system.circuits.iter().enumerate() { + let expected = if c.preprocessed_width == 0 { + if c.preprocessed_height != 0 { + return Err(VerifierError::Circuit { + index, + field: "unexpected preprocessing height", + }); + } + None + } else { + if !c.preprocessed_height.is_power_of_two() { + return Err(VerifierError::Circuit { + index, + field: "preprocessing height", + }); + } + let current = slot; + slot += 1; + Some(current) + }; + if system.preprocessed_indices[index] != expected { + return Err(VerifierError::Circuit { + index, + field: "preprocessing matrix order", + }); + } + } + if (slot == 0 && system.preprocessed_commit.is_some()) + || (slot != 0 + && system.preprocessed_commit.as_ref().map(|c| c.roots().len()) + != Some(1)) + { + return Err(VerifierError::Profile("preprocessing commitment")); + } + if key.to_bytes()? != bytes { + return Err(VerifierError::Profile("noncanonical key encoding")); + } + Ok(key) + } + + pub fn fingerprint(&self) -> Result<[u8; 32], VerifierError> { + Ok(Blake3.hash_iter( + b"multi-stark/verifier-key/v1".iter().copied().chain(self.to_bytes()?), + )) + } +} diff --git a/crates/proof-compression/src/fri/mod.rs b/crates/proof-compression/src/fri/mod.rs new file mode 100644 index 000000000..af7b7fba2 --- /dev/null +++ b/crates/proof-compression/src/fri/mod.rs @@ -0,0 +1,44 @@ +//! Fixed-profile multi-stark verification with transcript, algebra, PCS and FRI checks. +//! [`VerifierPlan`] validates a trusted key and profile; callers bind the statement. +//! Proof assignment never overwrites statement wires. +//! +//! Standalone algebraic and transcript helpers do not verify a complete proof. + +mod algebra; +mod batch; +pub use batch::{ + build_single_batch_verifier, constrain_batch_verifier, + constrain_single_batch_verifier, expand_batch_witness, + expand_single_batch_witness, +}; +mod pcs; +mod pcs_witness; +mod quadratic; +mod transcript; + +pub use algebra::{ + AlgebraicChallenges, AlgebraicInputs, AlgebraicOutputs, CircuitEvaluation, + CircuitOpenings, Selectors, constrain_algebraic_checks, +}; +pub use multi_stark::plonkish::gadgets::ByteGadgets; +pub use multi_stark::plonkish::gadgets::ByteValue; +pub use multi_stark::plonkish::gadgets::blake3; +pub use pcs::{ + FixedPcsShape, FixedVerifierInputs, PcsInputs, PcsQuery, + build_fixed_verifier, build_profile_verifier, constrain_fixed_verifier, +}; +pub use pcs_witness::{ExpandedPcsWitness, expand_pcs_witness}; +pub use quadratic::QuadraticValue; +pub use transcript::{ + Blake3Challenger, DEFAULT_FIELD_RETRIES, TranscriptCommitments, + constrain_transcript, +}; + +mod key_codec; +mod plan; +pub(crate) use plan::validate_parameters; +pub use plan::{ + Envelope, ImplementationOptions, Message, PreparedProof, ProofEnvelope, + ProofProfile, ResourceEstimate, Statement, StatementBinding, StatementSlot, + VerifierError, VerifierInputs, VerifierKey, VerifierLimits, VerifierPlan, +}; diff --git a/crates/proof-compression/src/fri/pcs.rs b/crates/proof-compression/src/fri/pcs.rs new file mode 100644 index 000000000..7422d5e78 --- /dev/null +++ b/crates/proof-compression/src/fri/pcs.rs @@ -0,0 +1,526 @@ +use std::collections::BTreeMap; + +use p3_field::{Field, PrimeCharacteristicRing, TwoAdicField}; + +use super::AlgebraicInputs; +use super::Blake3Challenger; +use super::ByteGadgets; +use super::ByteValue; +use super::QuadraticValue as Q; +use super::TranscriptCommitments; +use super::blake3; +use super::constrain_algebraic_checks; +use super::transcript::DEFAULT_FIELD_RETRIES; +use super::transcript::constrain_active_transcript; +use multi_stark::config::StarkGenericConfig; +use multi_stark::plonkish::Bool; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; +use multi_stark::system::System; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; + +pub(super) type Digest = [ByteValue; 32]; + +/// Fixed active bitmap and heterogeneous trace heights. Preprocessed tables +/// must be active. Requires some preprocessing, cap zero, binary FRI and a +/// constant final polynomial. Both grinding phases are supported. +#[derive(Clone, Debug)] +pub struct FixedPcsShape { + pub log_trace: u8, + pub log_lde: usize, + pub queries: usize, + pub active: Vec, + pub log_degrees: Vec, + /// Maximum rejected candidates per field sample; exhaustion is rejected. + pub max_field_retries: usize, + /// Active opening index for each preprocessed matrix. + pub preprocessed: Vec, + /// LDE log heights, in each commitment's original matrix order. + pub heights: [Vec; 4], + /// Canonical batches: main, stage 2, quotient, preprocessing. + pub widths: [Vec; 4], +} + +impl FixedPcsShape { + pub fn new(system: &System, log_trace: u8) -> Self { + Self::from_profile( + system, + &vec![true; system.circuits.len()], + &vec![log_trace; system.circuits.len()], + ) + } + + /// Heights are supplied in active-circuit order, not canonical order. + pub fn from_profile( + system: &System, + active: &[bool], + logs: &[u8], + ) -> Self { + let fri = system.config.fri_parameters(); + assert_eq!(system.config.cap_height(), 0); + assert_eq!(fri.max_log_arity, 1, "binary FRI required"); + assert_eq!(fri.log_final_poly_len, 0, "constant final polynomial required"); + assert!( + fri.commit_proof_of_work_bits < 64 && fri.query_proof_of_work_bits < 64 + ); + assert!(fri.num_queries > 0); + assert_eq!(active.len(), system.circuits.len()); + let circuits: Vec<_> = system + .circuits + .iter() + .zip(active) + .filter_map(|(c, &a)| a.then_some(c)) + .collect(); + assert_eq!(circuits.len(), logs.len()); + let log_trace = *logs.iter().max().expect("at least one active circuit"); + let log_lde = usize::from(log_trace) + system.config.log_blowup(); + assert!(log_lde <= Val::TWO_ADICITY && log_lde < usize::BITS as usize); + for (c, &enabled) in system.circuits.iter().zip(active) { + assert!( + c.preprocessed_width == 0 || enabled, + "preprocessed tables must be active" + ); + } + let mut preprocessed = vec![]; + for (i, (c, &log)) in circuits.iter().zip(logs).enumerate() { + assert!( + c.quotient_degree() <= system.config.max_quotient_degree() + && usize::from(log) <= system.config.max_log_degree() + && usize::from(log) + c.quotient_degree().ilog2() as usize + <= system.config.max_log_quotient_domain(), + "trace/quotient domain exceeds PCS limits" + ); + if c.preprocessed_width != 0 { + assert_eq!(c.preprocessed_height, 1usize << log); + preprocessed.push(i); + } + } + assert!(!preprocessed.is_empty(), "this profile requires preprocessing"); + let heights: Vec<_> = logs + .iter() + .map(|&h| usize::from(h) + system.config.log_blowup()) + .collect(); + let prep_heights = preprocessed.iter().map(|&i| heights[i]).collect(); + let prep_widths = + preprocessed.iter().map(|&i| circuits[i].preprocessed_width).collect(); + Self { + log_trace, + log_lde, + queries: fri.num_queries, + active: active.to_vec(), + log_degrees: logs.to_vec(), + max_field_retries: DEFAULT_FIELD_RETRIES, + preprocessed, + heights: [heights.clone(), heights.clone(), heights, prep_heights], + widths: [ + circuits.iter().map(|c| c.main_width).collect(), + circuits.iter().map(|c| c.stage_2_width).collect(), + circuits.iter().map(|c| 2 * c.quotient_degree()).collect(), + prep_widths, + ], + } + } +} + +pub struct PcsQuery { + pub input_rows: [Vec>; 4], + pub input_paths: [Vec; 4], + pub fri_siblings: Vec, + pub fri_paths: Vec>, +} + +pub struct PcsInputs { + pub roots: Vec, + pub commit_pow: Vec, + pub query_pow: Value, + pub final_poly: Q, + pub queries: Vec, + pub(super) query_start: usize, +} + +impl PcsInputs { + pub(super) fn allocate_queries( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + shape: &FixedPcsShape, + queries: std::ops::Range, + ) -> Self { + assert!(queries.start <= queries.end && queries.end <= shape.queries); + fn digest(b: &mut CircuitBuilder, bytes: &ByteGadgets) -> Digest { + std::array::from_fn(|_| bytes.input(b, "Merkle digest byte")) + } + let rounds = usize::from(shape.log_trace); + Self { + roots: (0..rounds).map(|_| digest(b, bytes)).collect(), + commit_pow: (0..rounds) + .map(|_| b.input("commit grinding witness")) + .collect(), + query_pow: b.input("query grinding witness"), + final_poly: Q::input(b, "FRI final constant"), + query_start: queries.start, + queries: queries + .map(|_| PcsQuery { + input_rows: std::array::from_fn(|batch| { + shape.widths[batch] + .iter() + .map(|&width| { + (0..width).map(|_| b.input("queried column")).collect() + }) + .collect() + }), + input_paths: std::array::from_fn(|batch| { + (0..*shape.heights[batch].iter().max().unwrap()) + .map(|_| digest(b, bytes)) + .collect() + }), + fri_siblings: (0..rounds) + .map(|_| Q::input(b, "FRI sibling")) + .collect(), + fri_paths: (0..rounds) + .map(|round| { + (0..shape.log_lde - round - 1).map(|_| digest(b, bytes)).collect() + }) + .collect(), + }) + .collect(), + } + } +} + +/// Private proof wires and the caller's claim interface. Shape and verifying +/// key are circuit constants; the enclosing builder controls public exposure. +pub struct FixedVerifierInputs { + pub shape: FixedPcsShape, + pub algebra: AlgebraicInputs, + pub commitments: TranscriptCommitments, + pub pcs: PcsInputs, + /// Derived query bits, useful for differential diagnostics, not inputs. + pub query_bits: Vec>, +} + +/// Complete fixed-profile verifier, including Fiat-Shamir, algebra, Merkle +/// authentication, opening reduction, and every binary FRI fold. The +/// transcript rejects exhaustion of its fixed field-sampling retry budget. +pub fn build_fixed_verifier( + system: &System, + log_trace: u8, + claim_lengths: &[usize], +) -> (Circuit, FixedVerifierInputs) { + let shape = FixedPcsShape::new(system, log_trace); + build_profile_verifier(system, &shape, claim_lengths) +} + +pub fn build_profile_verifier( + system: &System, + shape: &FixedPcsShape, + claim_lengths: &[usize], +) -> (Circuit, FixedVerifierInputs) { + let mut b = CircuitBuilder::new(); + let claims = claim_lengths + .iter() + .enumerate() + .map(|(i, &len)| { + (0..len).map(|j| b.public_input(format!("claim[{i}][{j}]"))).collect() + }) + .collect(); + let bytes = ByteGadgets::new(&mut b); + let inputs = constrain_fixed_verifier(&mut b, &bytes, system, shape, claims); + (b.finish(), inputs) +} + +/// Append verification using caller-owned claim wires and reusable byte tables. +pub fn constrain_fixed_verifier( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shape: &FixedPcsShape, + claims: Vec>, +) -> FixedVerifierInputs { + // The caller's redundant shape fields must never reduce authentication. + let queries = system.config.fri_parameters().num_queries; + constrain_fixed_queries(b, bytes, system, shape, claims, 0..queries, true) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn constrain_fixed_queries( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shape: &FixedPcsShape, + claims: Vec>, + queries: std::ops::Range, + check_algebra: bool, +) -> FixedVerifierInputs { + // Re-derive dimensions: callers cannot smuggle inconsistent public fields. + let mut checked_shape = + FixedPcsShape::from_profile(system, &shape.active, &shape.log_degrees); + checked_shape.max_field_retries = shape.max_field_retries; + let shape = checked_shape; + let circuits: Vec<_> = system + .circuits + .iter() + .zip(&shape.active) + .filter_map(|(c, &a)| a.then_some(c)) + .collect(); + let algebra = AlgebraicInputs::with_claims(b, &circuits, claims); + let commitments = TranscriptCommitments::allocate(b, bytes); + let challenger = constrain_active_transcript( + b, + bytes, + system, + &shape.active, + &shape.log_degrees, + &algebra, + &commitments, + shape.max_field_retries, + ); + if check_algebra { + constrain_algebraic_checks(b, &circuits, &shape.log_degrees, &algebra); + } + let pcs = PcsInputs::allocate_queries(b, bytes, &shape, queries); + let query_bits = constrain_pcs( + b, + bytes, + system, + &shape, + &algebra, + &commitments, + &pcs, + challenger, + ); + FixedVerifierInputs { shape, algebra, commitments, pcs, query_bits } +} + +fn opening_rows<'a>( + algebra: &'a AlgebraicInputs, + shape: &FixedPcsShape, + batch: usize, + matrix: usize, +) -> Vec<&'a [Q]> { + let matrix = if batch == 3 { shape.preprocessed[matrix] } else { matrix }; + let o = &algebra.openings[matrix]; + match batch { + 0 => o.main.iter().map(Vec::as_slice).collect(), + 1 => o.stage2.iter().map(Vec::as_slice).collect(), + 2 => vec![&o.quotient], + 3 => o.preprocessed.iter().map(Vec::as_slice).collect(), + _ => unreachable!(), + } +} + +fn hash_fields( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + values: impl IntoIterator, +) -> Digest { + let encoded: Vec<_> = + values.into_iter().flat_map(|v| bytes.encode_field64(b, v)).collect(); + blake3(b, bytes, &encoded) +} + +#[allow(clippy::too_many_arguments)] +fn authenticate( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + root: &Digest, + mut digest: Digest, + bits: &[Bool], + path: &[Digest], + injections: &BTreeMap, +) { + assert_eq!(bits.len(), path.len()); + for (level, (&bit, sibling)) in bits.iter().zip(path).enumerate() { + let block: [_; 64] = std::array::from_fn(|i| { + let (left, right) = if i < 32 { + (sibling[i], digest[i]) + } else { + (digest[i - 32], sibling[i - 32]) + }; + bytes.select(b, bit, left, right) + }); + digest = blake3(b, bytes, &block); + if let Some(row_hash) = injections.get(&(bits.len() - level - 1)) { + let block: Vec<_> = digest.into_iter().chain(*row_hash).collect(); + digest = blake3(b, bytes, &block); + } + } + for (actual, expected) in digest.into_iter().zip(root) { + b.assert_equal(actual.value(), expected.value()); + } +} + +/// g raised to the bit-reversal of a little-endian bit vector. +fn reversed_power(b: &mut CircuitBuilder, g: Val, bits: &[Bool]) -> Value { + let one = b.constant(Val::ONE); + let mut power = one; + for (i, &bit) in bits.iter().enumerate() { + let factor = b.constant(g.exp_power_of_2(bits.len() - 1 - i)); + let selected = b.select(bit, factor, one); + power = b.mul(power, selected); + } + power +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn constrain_pcs( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + shape: &FixedPcsShape, + algebra: &AlgebraicInputs, + commitments: &TranscriptCommitments, + pcs: &PcsInputs, + mut challenger: Blake3Challenger, +) -> Vec> { + // PCS observes OOD evaluations in batch, matrix, point, column order. + for batch in 0..4 { + for matrix in 0..shape.widths[batch].len() { + for row in opening_rows(algebra, shape, batch, matrix) { + for &value in row { + challenger.observe_extension(b, bytes, value); + } + } + } + } + let alpha = challenger.sample_extension(b, bytes); + let betas: Vec<_> = pcs + .roots + .iter() + .zip(&pcs.commit_pow) + .map(|(root, &pow)| { + challenger.observe_bytes(root); + challenger.check_witness( + b, + bytes, + system.config.fri_parameters().commit_proof_of_work_bits, + pow, + ); + challenger.sample_extension(b, bytes) + }) + .collect(); + challenger.observe_extension(b, bytes, pcs.final_poly); + for _ in &betas { + challenger.observe_constant(b, bytes, 1); + } + challenger.check_witness( + b, + bytes, + system.config.fri_parameters().query_proof_of_work_bits, + pcs.query_pow, + ); + let queries: Vec<_> = (0..shape.queries) + .map(|_| challenger.sample_bits(b, bytes, shape.log_lde)) + .collect(); + let preprocessed = system.preprocessed_commit.as_ref().unwrap().roots()[0] + .map(|x| bytes.constant(b, x)); + let roots = [ + commitments.stage1, + commitments.stage2, + commitments.quotient, + preprocessed, + ]; + let zeta = algebra.challenges.zeta; + let zero = Q::constant(b, [Val::ZERO; 2]); + let one = Q::constant(b, [Val::ONE, Val::ZERO]); + for (offset, query) in pcs.queries.iter().enumerate() { + let query_index = pcs.query_start + offset; + let bits = &queries[query_index]; + let mut reductions = BTreeMap::new(); + let mut denominators = BTreeMap::new(); + for &height in &shape.heights[0] { + denominators.entry(height).or_insert_with(|| { + let point = reversed_power( + b, + Val::two_adic_generator(height), + &bits[shape.log_lde - height..], + ); + let point = b.scale(point, Val::GENERATOR); + let x = Q::from_base(b, point); + let next = zeta.scale( + b, + Val::two_adic_generator(height - system.config.log_blowup()), + ); + [zeta, next].map(|z| z.sub(b, x).inverse(b)) + }); + } + for (batch, root) in roots.iter().enumerate() { + let mut rows: BTreeMap> = BTreeMap::new(); + for (&height, row) in + shape.heights[batch].iter().zip(&query.input_rows[batch]) + { + rows.entry(height).or_default().extend(row); + } + let mut hashes: BTreeMap<_, _> = rows + .into_iter() + .map(|(h, row)| (h, hash_fields(b, bytes, row))) + .collect(); + let (height, leaf) = hashes.pop_last().unwrap(); + authenticate( + b, + bytes, + root, + leaf, + &bits[shape.log_lde - height..], + &query.input_paths[batch], + &hashes, + ); + for (matrix, opened) in query.input_rows[batch].iter().enumerate() { + let height = shape.heights[batch][matrix]; + let (alpha_power, reduced) = + reductions.entry(height).or_insert((one, zero)); + for (point, row) in + opening_rows(algebra, shape, batch, matrix).iter().enumerate() + { + for (&at_x, &at_z) in opened.iter().zip(*row) { + let at_x = Q::from_base(b, at_x); + let difference = at_z.sub(b, at_x); + let term = difference.mul(b, denominators[&height][point]); + let term = alpha_power.mul(b, term); + *reduced = reduced.add(b, term); + *alpha_power = alpha_power.mul(b, alpha); + } + } + } + } + if let Some((_, constant)) = reductions.get(&system.config.log_blowup()) { + constant.assert_equal(b, zero); + } + let mut folded = reductions[&shape.log_lde].1; + for (round, &beta) in betas.iter().enumerate() { + let sibling = query.fri_siblings[round]; + let lo = Q::select(b, bits[round], sibling, folded); + let hi = Q::select(b, bits[round], folded, sibling); + let leaf = hash_fields(b, bytes, lo.0.into_iter().chain(hi.0)); + authenticate( + b, + bytes, + &pcs.roots[round], + leaf, + &bits[round + 1..], + &query.fri_paths[round], + &BTreeMap::new(), + ); + let subgroup_x = reversed_power( + b, + Val::two_adic_generator(shape.log_lde - round), + &bits[round + 1..], + ); + let inv_x = b.inverse(subgroup_x); + let sum = lo.add(b, hi).scale(b, Val::ONE.halve()); + let difference = + lo.sub(b, hi).mul_base(b, inv_x).scale(b, Val::ONE.halve()); + let odd = difference.mul(b, beta); + folded = sum.add(b, odd); + if let Some((_, reduced)) = reductions.get(&(shape.log_lde - round - 1)) { + let beta_squared = beta.mul(b, beta); + let injected = beta_squared.mul(b, *reduced); + folded = folded.add(b, injected); + } + } + folded.assert_equal(b, pcs.final_poly); + tracing::info!(query = query_index + 1, total = shape.queries, stats = ?b.stats(), "Plonkish PCS query built"); + } + queries +} diff --git a/crates/proof-compression/src/fri/pcs_witness.rs b/crates/proof-compression/src/fri/pcs_witness.rs new file mode 100644 index 000000000..d6550e9d7 --- /dev/null +++ b/crates/proof-compression/src/fri/pcs_witness.rs @@ -0,0 +1,590 @@ +//! Untrusted witness preparation for the fixed verifier. Decompression and +//! native arithmetic here supply hints only; all openings, paths, challenges, +//! and folds are independently constrained by the verifier circuit. + +use std::collections::{BTreeMap, BTreeSet}; + +use p3_blake3::Blake3; +use p3_challenger::{ + CanObserve, CanSampleBits, FieldChallenger, GrindingChallenger, +}; +use p3_field::{ + BasedVectorSpace, Field, PrimeCharacteristicRing, PrimeField64, TwoAdicField, +}; +use p3_symmetric::CryptographicHasher; +use p3_util::reverse_bits_len; + +use super::FixedPcsShape; +use super::FixedVerifierInputs; +use super::QuadraticValue; +use multi_stark::config::StarkGenericConfig; +use multi_stark::plonkish::Witness; +use multi_stark::prover::Proof; +use multi_stark::prover::observe_claims; +use multi_stark::prover::sample_lookup_challenges; +use multi_stark::system::System; +use multi_stark::types::ExtVal; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; + +type Digest = [u8; 32]; +type Path = Vec; + +#[derive(Clone)] +pub struct ExpandedPcsWitness { + pub proof: Proof, + pub claims: Vec>, + pub challenges: [ExtVal; 4], + /// Native diagnostic values only; the circuit derives its own PCS + /// challenges and indices, and `assign` does not assign these fields. + pub pcs_alpha: ExtVal, + pub fri_betas: Vec, + pub query_indices: Vec, + /// Query-major, then the four input batches. + pub input_paths: Vec<[Path; 4]>, + /// Query-major, then FRI round. + pub fri_paths: Vec>, +} + +fn validate( + shape: &FixedPcsShape, + proof: &Proof, +) -> Result<(), String> { + let matrices = shape.widths[0].len(); + let rounds = usize::from(shape.log_trace); + let p = &proof.opening_proof; + if proof.active != shape.active + || proof.log_degrees != shape.log_degrees + || proof.intermediate_accumulators.len() != matrices + || p.commit_phase_commits.len() != rounds + || p.commit_phase_openings.len() != rounds + || p.commit_pow_witnesses.len() != rounds + || p.final_poly.len() != 1 + || p.input_openings.len() != 4 + || proof.stage_1_opened_values.len() != matrices + || proof.stage_2_opened_values.len() != matrices + || proof.quotient_opened_values.len() != matrices + || proof.preprocessed_opened_values.as_ref().map(Vec::len) + != Some(shape.preprocessed.len()) + { + return Err("proof does not match fixed verifier shape".into()); + } + for root in [ + &proof.commitments.stage_1_trace, + &proof.commitments.stage_2_trace, + &proof.commitments.quotient_chunks, + ] + .into_iter() + .chain(&p.commit_phase_commits) + { + if root.roots().len() != 1 { + return Err("cap height must be zero".into()); + } + } + for batch in 0..4 { + let openings = &p.input_openings[batch].opened_values; + if openings.len() != shape.queries { + return Err("query count mismatch".into()); + } + for query in openings { + if query.len() != shape.widths[batch].len() + || query + .iter() + .zip(&shape.widths[batch]) + .any(|(row, &width)| row.len() != width) + { + return Err("input row shape mismatch".into()); + } + } + for matrix in 0..shape.widths[batch].len() { + let rows = + native_rows(proof, batch, matrix).ok_or("OOD matrix shape mismatch")?; + if rows.len() != if batch == 2 { 1 } else { 2 } + || rows.iter().any(|row| row.len() != shape.widths[batch][matrix]) + { + return Err("OOD row shape mismatch".into()); + } + } + } + for opening in &p.commit_phase_openings { + if opening.log_arity != 1 + || opening.sibling_values.len() != shape.queries + || opening.sibling_values.iter().any(|row| row.len() != 1) + { + return Err("binary FRI opening shape mismatch".into()); + } + } + Ok(()) +} + +fn native_rows( + proof: &Proof, + batch: usize, + matrix: usize, +) -> Option<&[Vec]> { + let matrices = match batch { + 0 => &proof.stage_1_opened_values, + 1 => &proof.stage_2_opened_values, + 2 => &proof.quotient_opened_values, + 3 => proof.preprocessed_opened_values.as_ref()?, + _ => return None, + }; + matrices.get(matrix).map(Vec::as_slice) +} + +fn hash_fields(values: impl IntoIterator) -> Digest { + Blake3.hash_iter( + values.into_iter().flat_map(|v| v.as_canonical_u64().to_le_bytes()), + ) +} + +/// Expand the native binary frontier wire order. Shared children are +/// recomputed from queried leaves; every resulting full path is later +/// checked in-circuit. No serialized proof identity is claimed. +fn expand_paths( + indices: &[usize], + leaves: &[Digest], + log_height: usize, + supplied: &[Digest], + injections: &BTreeMap>, +) -> Result, String> { + let mut current = BTreeMap::new(); + for (&index, &leaf) in indices.iter().zip(leaves) { + if index >= 1 << log_height { + return Err("query index out of range".into()); + } + if let Some(previous) = current.insert(index, leaf) + && previous != leaf + { + return Err("inconsistent duplicate query".into()); + } + } + let mut levels = Vec::with_capacity(log_height); + let mut cursor = 0; + for level in 0..log_height { + let parents: BTreeSet<_> = current.keys().map(|i| i >> 1).collect(); + let mut next = BTreeMap::new(); + for parent in parents { + for child in [2 * parent, 2 * parent + 1] { + if let std::collections::btree_map::Entry::Vacant(entry) = + current.entry(child) + { + entry + .insert(*supplied.get(cursor).ok_or("truncated Merkle frontier")?); + cursor += 1; + } + } + let left = current[&(2 * parent)]; + let right = current[&(2 * parent + 1)]; + next.insert(parent, Blake3.hash_iter(left.into_iter().chain(right))); + } + if let Some(rows) = injections.get(&(log_height - level - 1)) { + let mut seen = BTreeMap::new(); + for (&index, &row) in indices.iter().zip(rows) { + let parent = index >> (level + 1); + if let Some(previous) = seen.insert(parent, row) { + if previous != row { + return Err("inconsistent injected row for shared query".into()); + } + } else { + let digest = next.get_mut(&parent).unwrap(); + *digest = Blake3.hash_iter(digest.iter().copied().chain(row)); + } + } + } + levels.push(current); + current = next; + } + if cursor != supplied.len() { + return Err("extra Merkle frontier digests".into()); + } + Ok( + indices + .iter() + .map(|index| { + levels + .iter() + .enumerate() + .map(|(level, nodes)| nodes[&((index >> level) ^ 1)]) + .collect() + }) + .collect(), + ) +} + +/// Normalize an existing native proof without trusting native acceptance. +/// Invalid algebra/Merkle data may still produce an assignment; the circuit +/// must reject it. Malformed dimensions/frontiers return an error here. +pub fn expand_pcs_witness( + system: &System, + shape: &FixedPcsShape, + proof: &Proof, + claims: &[&[Val]], +) -> Result { + system + .verify_shape(proof) + .map_err(|e| format!("invalid native proof shape: {e:?}"))?; + validate(shape, proof)?; + let mut challenger = system.config.initialise_challenger(); + system.observe_shape(&mut challenger); + for &active in &proof.active { + challenger.observe(Val::from_bool(active)); + } + if let Some(root) = &system.preprocessed_commit { + challenger.observe(root.clone()); + } + challenger.observe(proof.commitments.stage_1_trace.clone()); + for &height in &proof.log_degrees { + challenger.observe(Val::from_u8(height)); + } + observe_claims::(&mut challenger, claims); + let (beta, gamma) = + sample_lookup_challenges::(&mut challenger); + expand_after_lookup(system, shape, proof, claims, challenger, beta, gamma) +} + +/// Shared untrusted PCS normalization after the caller replays its protocol's +/// lookup transcript. Both single-proof and batch transcripts use this path. +pub(super) fn expand_after_lookup( + system: &System, + shape: &FixedPcsShape, + proof: &Proof, + claims: &[&[Val]], + mut challenger: multi_stark::types::Challenger, + beta: ExtVal, + gamma: ExtVal, +) -> Result { + system + .verify_shape(proof) + .map_err(|e| format!("invalid proof shape: {e:?}"))?; + validate(shape, proof)?; + challenger.observe(proof.commitments.stage_2_trace.clone()); + for &acc in &proof.intermediate_accumulators { + challenger.observe_algebra_element(acc); + } + let alpha = challenger.sample_algebra_element(); + challenger.observe(proof.commitments.quotient_chunks.clone()); + let zeta: ExtVal = challenger.sample_algebra_element(); + for batch in 0..4 { + for matrix in 0..shape.widths[batch].len() { + for row in native_rows(proof, batch, matrix).unwrap() { + challenger.observe_algebra_slice(row); + } + } + } + let pcs_alpha: ExtVal = challenger.sample_algebra_element(); + let p = &proof.opening_proof; + let fri_betas: Vec = p + .commit_phase_commits + .iter() + .zip(&p.commit_pow_witnesses) + .map(|(root, &pow)| { + challenger.observe(root.clone()); + // Ignore acceptance here: these computations are untrusted hints. + let _ = challenger.check_witness( + system.config.fri_parameters().commit_proof_of_work_bits, + pow, + ); + challenger.sample_algebra_element() + }) + .collect(); + challenger.observe_algebra_slice(&p.final_poly); + for _ in &fri_betas { + challenger.observe(Val::ONE); + } + let _ = challenger.check_witness( + system.config.fri_parameters().query_proof_of_work_bits, + p.query_pow_witness, + ); + let query_indices: Vec<_> = + (0..shape.queries).map(|_| challenger.sample_bits(shape.log_lde)).collect(); + let mut input_paths: Vec<[Path; 4]> = + (0..shape.queries).map(|_| std::array::from_fn(|_| vec![])).collect(); + for (batch, opening) in p.input_openings.iter().enumerate() { + let mut hashes: BTreeMap> = BTreeMap::new(); + for matrices in &opening.opened_values { + let mut rows: BTreeMap> = BTreeMap::new(); + for (&height, row) in shape.heights[batch].iter().zip(matrices) { + rows.entry(height).or_default().extend(row); + } + for (height, row) in rows { + hashes.entry(height).or_default().push(hash_fields(row)); + } + } + let (height, leaves) = hashes.pop_last().unwrap(); + let indices: Vec<_> = + query_indices.iter().map(|i| i >> (shape.log_lde - height)).collect(); + let paths = expand_paths( + &indices, + &leaves, + height, + &opening.opening_proof.sibling_hashes, + &hashes, + )?; + for (query, path) in paths.into_iter().enumerate() { + input_paths[query][batch] = path; + } + } + let rounds = usize::from(shape.log_trace); + let mut fri_paths: Vec> = vec![vec![vec![]; rounds]; shape.queries]; + let mut folded = Vec::new(); + let mut reductions = Vec::new(); + for (query, &index) in query_indices.iter().enumerate() { + let mut reduced_by_height = BTreeMap::new(); + for batch in 0..4 { + for (matrix, row) in + p.input_openings[batch].opened_values[query].iter().enumerate() + { + let height = shape.heights[batch][matrix]; + let x = Val::GENERATOR + * Val::two_adic_generator(height).exp_u64( + u64::try_from(reverse_bits_len( + index >> (shape.log_lde - height), + height, + )) + .unwrap(), + ); + let next = + zeta * Val::two_adic_generator(height - system.config.log_blowup()); + let denominators: Vec<_> = [zeta, next] + .into_iter() + .map(|z| { + (z - x).try_inverse().ok_or("opening point equals query point") + }) + .collect::>()?; + let (power, reduced) = reduced_by_height + .entry(height) + .or_insert((ExtVal::ONE, ExtVal::ZERO)); + for (point, opened) in + native_rows(proof, batch, matrix).unwrap().iter().enumerate() + { + for (&at_x, &at_z) in row.iter().zip(opened) { + *reduced += *power * (at_z - at_x) * denominators[point]; + *power *= pcs_alpha; + } + } + } + } + folded.push(reduced_by_height[&shape.log_lde].1); + reductions.push(reduced_by_height); + } + for (round, &beta) in fri_betas.iter().enumerate() { + let log_height = shape.log_lde - round - 1; + let indices: Vec<_> = + query_indices.iter().map(|i| i >> (round + 1)).collect(); + let mut leaves = Vec::new(); + for query in 0..shape.queries { + let sibling = p.commit_phase_openings[round].sibling_values[query][0]; + let pair = if (query_indices[query] >> round) & 1 == 0 { + [folded[query], sibling] + } else { + [sibling, folded[query]] + }; + leaves.push(hash_fields( + pair + .iter() + .flat_map( + >::as_basis_coefficients_slice, + ) + .copied(), + )); + let x = Val::two_adic_generator(log_height + 1).exp_u64( + u64::try_from(reverse_bits_len(indices[query], log_height)).unwrap(), + ); + folded[query] = (pair[0] + pair[1]).halve() + + (pair[0] - pair[1]) * beta * x.inverse().halve(); + if let Some((_, reduced)) = reductions[query].get(&log_height) { + folded[query] += beta.square() * *reduced; + } + } + let paths = expand_paths( + &indices, + &leaves, + log_height, + &p.commit_phase_openings[round].opening_proof.sibling_hashes, + &BTreeMap::new(), + )?; + for (query, path) in paths.into_iter().enumerate() { + fri_paths[query][round] = path; + } + } + Ok(ExpandedPcsWitness { + proof: proof.clone(), + claims: claims.iter().map(|c| c.to_vec()).collect(), + challenges: [beta, gamma, alpha, zeta], + pcs_alpha, + fri_betas, + query_indices, + input_paths, + fri_paths, + }) +} + +impl ExpandedPcsWitness { + pub fn assign( + &self, + witness: &mut Witness<'_, Val>, + inputs: &FixedVerifierInputs, + ) -> Result<(), String> { + self.assign_inner(witness, inputs, true) + } + + /// Assign only proof wires. Caller-owned/derived claim wires must be + /// supplied by the enclosing statement gadget, not by the adapter. + pub fn assign_proof( + &self, + witness: &mut Witness<'_, Val>, + inputs: &FixedVerifierInputs, + ) -> Result<(), String> { + self.assign_inner(witness, inputs, false) + } + + fn assign_inner( + &self, + witness: &mut Witness<'_, Val>, + inputs: &FixedVerifierInputs, + assign_claims: bool, + ) -> Result<(), String> { + validate(&inputs.shape, &self.proof)?; + let mut set = + |wire, value| witness.set(wire, value).map_err(|e| e.to_string()); + fn extension( + set: &mut impl FnMut(multi_stark::plonkish::Value, Val) -> Result<(), String>, + wire: QuadraticValue, + value: ExtVal, + ) -> Result<(), String> { + for (wire, &value) in wire.0.into_iter().zip( + >::as_basis_coefficients_slice(&value), + ) { + set(wire, value)?; + } + Ok(()) + } + fn digest( + set: &mut impl FnMut(multi_stark::plonkish::Value, Val) -> Result<(), String>, + wires: &super::pcs::Digest, + values: Digest, + ) -> Result<(), String> { + for (wire, value) in wires.iter().zip(values) { + set(wire.value(), Val::from_u8(value))?; + } + Ok(()) + } + if inputs.algebra.claims.len() != self.claims.len() { + return Err("claim count mismatch".into()); + } + for (wires, claim) in inputs.algebra.claims.iter().zip(&self.claims) { + if wires.len() != claim.len() { + return Err("claim length mismatch".into()); + } + if assign_claims { + for (&wire, &value) in wires.iter().zip(claim) { + set(wire, value)?; + } + } + } + let c = inputs.algebra.challenges; + for (wire, value) in + [c.beta, c.gamma, c.alpha, c.zeta].into_iter().zip(self.challenges) + { + extension(&mut set, wire, value)?; + } + for (matrix, o) in inputs.algebra.openings.iter().enumerate() { + for (batch, wires) in [ + o.main.as_slice(), + o.stage2.as_slice(), + std::slice::from_ref(&o.quotient), + o.preprocessed.as_slice(), + ] + .into_iter() + .enumerate() + { + let matrix = if batch == 3 { + match inputs.shape.preprocessed.iter().position(|&i| i == matrix) { + Some(slot) => slot, + None => continue, + } + } else { + matrix + }; + for (wires, values) in + wires.iter().zip(native_rows(&self.proof, batch, matrix).unwrap()) + { + for (&wire, &value) in wires.iter().zip(values) { + extension(&mut set, wire, value)?; + } + } + } + extension( + &mut set, + o.accumulator, + self.proof.intermediate_accumulators[matrix], + )?; + } + for (wires, root) in [ + (&inputs.commitments.stage1, &self.proof.commitments.stage_1_trace), + (&inputs.commitments.stage2, &self.proof.commitments.stage_2_trace), + (&inputs.commitments.quotient, &self.proof.commitments.quotient_chunks), + ] { + digest(&mut set, wires, root.roots()[0])?; + } + let p = &self.proof.opening_proof; + for (&wire, &value) in + inputs.pcs.commit_pow.iter().zip(&p.commit_pow_witnesses) + { + set(wire, value)?; + } + set(inputs.pcs.query_pow, p.query_pow_witness)?; + for (wires, root) in inputs.pcs.roots.iter().zip(&p.commit_phase_commits) { + digest(&mut set, wires, root.roots()[0])?; + } + extension(&mut set, inputs.pcs.final_poly, p.final_poly[0])?; + if self.input_paths.len() != inputs.shape.queries + || self.fri_paths.len() != inputs.shape.queries + { + return Err("path query count mismatch".into()); + } + for (offset, wires) in inputs.pcs.queries.iter().enumerate() { + let query = inputs.pcs.query_start + offset; + for batch in 0..4 { + for (wires, values) in wires.input_rows[batch] + .iter() + .zip(&p.input_openings[batch].opened_values[query]) + { + for (&wire, &value) in wires.iter().zip(values) { + set(wire, value)?; + } + } + if self.input_paths[query][batch].len() + != wires.input_paths[batch].len() + { + return Err("input path length mismatch".into()); + } + for (wires, &value) in + wires.input_paths[batch].iter().zip(&self.input_paths[query][batch]) + { + digest(&mut set, wires, value)?; + } + } + if self.fri_paths[query].len() != wires.fri_paths.len() { + return Err("FRI path round count mismatch".into()); + } + for round in 0..wires.fri_siblings.len() { + extension( + &mut set, + wires.fri_siblings[round], + p.commit_phase_openings[round].sibling_values[query][0], + )?; + if self.fri_paths[query][round].len() != wires.fri_paths[round].len() { + return Err("FRI path length mismatch".into()); + } + for (wires, &value) in + wires.fri_paths[round].iter().zip(&self.fri_paths[query][round]) + { + digest(&mut set, wires, value)?; + } + } + } + Ok(()) + } +} diff --git a/crates/proof-compression/src/fri/plan.rs b/crates/proof-compression/src/fri/plan.rs new file mode 100644 index 000000000..75e63291d --- /dev/null +++ b/crates/proof-compression/src/fri/plan.rs @@ -0,0 +1,751 @@ +//! Validated verifier generation. Keys and profiles are trusted construction +//! inputs; proof normalization is untrusted and never assigns the statement. +use std::{ + fmt, + sync::atomic::{AtomicU64, Ordering}, +}; + +use p3_field::TwoAdicField; + +use super::ByteGadgets; +use super::ExpandedPcsWitness; +use super::FixedPcsShape; +use super::FixedVerifierInputs; +use multi_stark::batch::BatchMessage; +use multi_stark::batch::BatchProof; +use multi_stark::config::StarkGenericConfig; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; +use multi_stark::plonkish::Witness; +use multi_stark::prover::Proof; +use multi_stark::system::Circuit as StarkCircuit; +use multi_stark::system::System; +use multi_stark::types::CommitmentParameters; +use multi_stark::types::GoldilocksBlake3Config as Config; +use multi_stark::types::Val; + +/// A verifier-only key. No preprocessed matrices or PCS prover data are retained. +/// The caller must obtain this key from a trusted source; proof-supplied keys +/// are not authenticated merely by constructing this object. +pub struct VerifierKey { + system: System, +} + +impl VerifierKey { + /// Consume a verifier-side System (its optional matrices may already be None). + pub fn new(mut system: System) -> Self { + for c in &mut system.circuits { + c.preprocessed = None; + } + Self { system } + } + + /// Copy metadata and commitments without copying any preprocessed matrices. + pub fn from_system(system: &System) -> Self { + let circuits = system + .circuits + .iter() + .map(|c| StarkCircuit { + graph: c.graph.clone(), + main_width: c.main_width, + preprocessed: None, + preprocessed_width: c.preprocessed_width, + preprocessed_height: c.preprocessed_height, + num_lookups: c.num_lookups, + stage_2_width: c.stage_2_width, + num_publics: c.num_publics, + lookup_group_size: c.lookup_group_size, + constraint_count: c.constraint_count, + max_constraint_degree: c.max_constraint_degree, + }) + .collect(); + Self::new(System { + config: Config::new( + CommitmentParameters { + log_blowup: system.config.log_blowup(), + cap_height: system.config.cap_height(), + }, + system.config.fri_parameters(), + ), + circuits, + preprocessed_commit: system.preprocessed_commit.clone(), + preprocessed_indices: system.preprocessed_indices.clone(), + }) + } + + pub fn system(&self) -> &System { + &self.system + } +} + +#[derive(serde::Serialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum Envelope { + Ordinary, + SingleBatch, +} + +/// Log heights use active-circuit order; activation uses canonical key order. +/// Statement dimensions are fixed, but values need not be. +#[derive(serde::Serialize, Clone, Debug, PartialEq, Eq)] +pub struct ProofProfile { + pub envelope: Envelope, + pub active: Vec, + pub log_degrees: Vec, + pub claim_lengths: Vec, + pub message_lengths: Vec, + pub max_field_retries: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VerifierError { + Unsupported(&'static str), + Profile(&'static str), + Circuit { index: usize, field: &'static str }, + Limit { resource: &'static str, required: usize, limit: usize }, + Statement(&'static str), + Encoding(String), + Proof(String), + Assignment(String), +} +impl fmt::Display for VerifierError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} +impl std::error::Error for VerifierError {} + +/// Explicit pre-allocation limits. These bound structural work, not peak RSS. +#[derive(Clone, Copy, Debug)] +pub struct VerifierLimits { + pub max_circuits: usize, + pub max_graph_nodes: usize, + pub max_queries: usize, + pub max_field_retries: usize, + pub max_statement_values: usize, + pub max_proof_values: usize, +} +impl Default for VerifierLimits { + fn default() -> Self { + Self { + max_circuits: 4096, + max_graph_nodes: 10_000_000, + max_queries: 256, + max_field_retries: 16, + max_statement_values: 1_000_000, + max_proof_values: 100_000_000, + } + } +} + +/// Exact number of external proof field values, including digest bytes and +/// auxiliary challenge coordinates, before hash hints and arithmetic gates. +/// Actual lowering costs are reported by CircuitStats and MultiStarkLayout. +#[derive(Clone, Copy, Debug)] +pub struct ResourceEstimate { + pub proof_values: usize, + pub statement_values: usize, + pub graph_nodes: usize, +} + +#[derive(Clone, Copy, Debug)] +pub struct ImplementationOptions { + pub compact_blake3: bool, +} +impl Default for ImplementationOptions { + fn default() -> Self { + Self { compact_blake3: true } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Message { + pub args: Vec, + pub multiplicity: T, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Statement { + pub claims: Vec>, + pub messages: Vec>, +} + +/// Wire bindings are explicitly caller-owned. The enclosing circuit must expose +/// or constrain them; unconstrained private wires prove an existential claim. +#[derive(Clone, Copy, Debug)] +pub enum StatementBinding { + Constant(Val), + Wire(Value), +} +#[derive(Clone, Copy, Debug)] +pub enum StatementSlot { + Constant(Val), + Public, +} + +/// Proof data never participates in construction. For ordinary proofs the +/// statement owner supplies the claims used for transcript normalization. +pub enum ProofEnvelope<'a> { + Ordinary { proof: &'a Proof, claims: &'a [Vec] }, + SingleBatch(&'a BatchProof), +} + +pub struct VerifierPlan<'a> { + key: &'a VerifierKey, + profile: ProofProfile, + shape: FixedPcsShape, + estimate: ResourceEstimate, + id: u64, +} + +pub struct VerifierInputs { + proof: FixedVerifierInputs, + statement: Statement, + plan_id: u64, +} +impl VerifierInputs { + pub fn proof(&self) -> &FixedVerifierInputs { + &self.proof + } + pub fn statement(&self) -> &Statement { + &self.statement + } + + /// Convenience for standalone inputs. Derived caller-owned wires must be + /// assigned by their owner instead. Constants are checked, never overwritten. + pub fn assign_statement( + &self, + witness: &mut Witness<'_, Val>, + values: &Statement, + ) -> Result<(), VerifierError> { + check_statement_pair(&self.statement, values)?; + for (&binding, &value) in + statement_values(&self.statement).zip(statement_values(values)) + { + match binding { + StatementBinding::Constant(expected) if expected != value => { + return Err(VerifierError::Statement("constant binding differs")); + }, + StatementBinding::Constant(_) => {}, + StatementBinding::Wire(wire) => witness + .set(wire, value) + .map_err(|e| VerifierError::Assignment(e.to_string()))?, + } + } + Ok(()) + } +} + +/// Private normalized data prevents callers from mixing malformed path shapes +/// with another plan. Assignment supplies proof wires only, never statements. +pub struct PreparedProof { + expanded: ExpandedPcsWitness, + plan_id: u64, +} +impl PreparedProof { + pub fn query_indices(&self) -> &[usize] { + &self.expanded.query_indices + } + pub fn assign_proof( + &self, + witness: &mut Witness<'_, Val>, + inputs: &VerifierInputs, + ) -> Result<(), VerifierError> { + if self.plan_id != inputs.plan_id { + return Err(VerifierError::Proof("different verifier plan".into())); + } + self + .expanded + .assign_proof(witness, &inputs.proof) + .map_err(VerifierError::Assignment) + } +} + +fn statement_values(s: &Statement) -> impl Iterator { + s.claims.iter().flatten().chain( + s.messages + .iter() + .flat_map(|m| m.args.iter().chain(std::iter::once(&m.multiplicity))), + ) +} +fn check_statement_pair( + a: &Statement, + b: &Statement, +) -> Result<(), VerifierError> { + if a.claims.len() != b.claims.len() + || a.messages.len() != b.messages.len() + || a.claims.iter().zip(&b.claims).any(|(a, b)| a.len() != b.len()) + || a + .messages + .iter() + .zip(&b.messages) + .any(|(a, b)| a.args.len() != b.args.len()) + { + return Err(VerifierError::Statement("statement dimensions differ")); + } + Ok(()) +} +fn map_statement( + s: Statement, + mut f: impl FnMut(T) -> U, +) -> Statement { + Statement { + claims: s + .claims + .into_iter() + .map(|c| c.into_iter().map(&mut f).collect()) + .collect(), + messages: s + .messages + .into_iter() + .map(|m| Message { + args: m.args.into_iter().map(&mut f).collect(), + multiplicity: f(m.multiplicity), + }) + .collect(), + } +} +fn limit( + resource: &'static str, + required: usize, + bound: usize, +) -> Result<(), VerifierError> { + if required > bound { + Err(VerifierError::Limit { resource, required, limit: bound }) + } else { + Ok(()) + } +} +fn sum( + values: impl IntoIterator, +) -> Result { + values.into_iter().try_fold(0usize, |a, b| { + a.checked_add(b).ok_or(VerifierError::Profile("dimension overflow")) + }) +} + +pub(crate) fn validate_parameters( + commitment: CommitmentParameters, + fri: multi_stark::types::FriParameters, +) -> Result<(), VerifierError> { + if commitment.log_blowup > 31 { + return Err(VerifierError::Unsupported("blowup exceeds Goldilocks domain")); + } + if commitment.cap_height != 0 { + return Err(VerifierError::Unsupported("nonzero commitment cap")); + } + if fri.max_log_arity != 1 || fri.log_final_poly_len != 0 { + return Err(VerifierError::Unsupported( + "requires binary FRI and a constant final polynomial", + )); + } + if fri.commit_proof_of_work_bits >= 64 || fri.query_proof_of_work_bits >= 64 { + return Err(VerifierError::Unsupported("grinding bits >= 64")); + } + if fri.num_queries == 0 { + return Err(VerifierError::Profile("zero FRI queries")); + } + Ok(()) +} + +impl<'a> VerifierPlan<'a> { + pub fn validate( + key: &'a VerifierKey, + profile: ProofProfile, + limits: VerifierLimits, + ) -> Result { + let system = key.system(); + let fri = system.config.fri_parameters(); + validate_parameters( + CommitmentParameters { + log_blowup: system.config.log_blowup(), + cap_height: system.config.cap_height(), + }, + fri, + )?; + limit("queries", fri.num_queries, limits.max_queries)?; + if profile.max_field_retries.checked_add(1).is_none() { + return Err(VerifierError::Profile("retry budget overflow")); + } + limit( + "field retries", + profile.max_field_retries, + limits.max_field_retries, + )?; + limit("circuits", system.circuits.len(), limits.max_circuits)?; + if profile.active.len() != system.circuits.len() + || profile.active.iter().filter(|&&a| a).count() + != profile.log_degrees.len() + { + return Err(VerifierError::Profile("activation/height dimensions")); + } + let max_log = profile + .log_degrees + .iter() + .max() + .copied() + .ok_or(VerifierError::Profile("no active circuits"))?; + let lde_log = usize::from(max_log) + .checked_add(system.config.log_blowup()) + .ok_or(VerifierError::Profile("LDE overflow"))?; + if lde_log > Val::TWO_ADICITY || lde_log >= usize::BITS as usize { + return Err(VerifierError::Profile("LDE exceeds field domain")); + } + if profile.envelope == Envelope::Ordinary + && !profile.message_lengths.is_empty() + { + return Err(VerifierError::Profile( + "ordinary proofs have no batch messages", + )); + } + limit( + "statement records", + sum([profile.claim_lengths.len(), profile.message_lengths.len()])?, + limits.max_statement_values, + )?; + let statement_values = sum( + profile + .claim_lengths + .iter() + .copied() + .chain(profile.message_lengths.iter().copied()) + .chain([profile.message_lengths.len()]), + )?; + limit("statement values", statement_values, limits.max_statement_values)?; + let graph_nodes = sum(system.circuits.iter().map(|c| c.graph.nodes.len()))?; + limit("graph nodes", graph_nodes, limits.max_graph_nodes)?; + if system.preprocessed_indices.len() != system.circuits.len() { + return Err(VerifierError::Profile("preprocessing map length")); + } + if system.preprocessed_commit.as_ref().map(|c| c.roots().len()) != Some(1) { + return Err(VerifierError::Unsupported( + "requires a root-only preprocessed commitment", + )); + } + let mut prep_index = 0; + let mut logs = profile.log_degrees.iter(); + for (i, c) in system.circuits.iter().enumerate() { + validate_circuit(c, i)?; + let log = if profile.active[i] { logs.next().copied() } else { None }; + if c.quotient_degree() > system.config.max_quotient_degree() { + return Err(VerifierError::Circuit { + index: i, + field: "quotient degree exceeds PCS", + }); + } + if let Some(log) = log + && (usize::from(log) > system.config.max_log_degree() + || usize::from(log) + c.quotient_degree().ilog2() as usize + > system.config.max_log_quotient_domain()) + { + return Err(VerifierError::Circuit { + index: i, + field: "trace/quotient domain exceeds PCS", + }); + } + if c.preprocessed_width > 0 { + if log.is_none() + || Some(c.preprocessed_height) + != log.and_then(|l| 1usize.checked_shl(u32::from(l))) + { + return Err(VerifierError::Circuit { + index: i, + field: "preprocessing activation/height", + }); + } + if system.preprocessed_indices[i] != Some(prep_index) { + return Err(VerifierError::Circuit { + index: i, + field: "preprocessing matrix order", + }); + } + prep_index += 1; + } else if c.preprocessed_height != 0 + || system.preprocessed_indices[i].is_some() + { + return Err(VerifierError::Circuit { + index: i, + field: "unexpected preprocessing", + }); + } + } + if prep_index == 0 { + return Err(VerifierError::Unsupported("absent preprocessing")); + } + let mut shape = FixedPcsShape::from_profile( + system, + &profile.active, + &profile.log_degrees, + ); + shape.max_field_retries = profile.max_field_retries; + // Calculate external wire counts with checked arithmetic before allocating a circuit. + let widths = sum(shape.widths.iter().flatten().copied())?; + let ood = sum(shape.widths.iter().enumerate().flat_map(|(batch, ws)| { + ws.iter().map(move |&w| w.saturating_mul(if batch == 2 { 2 } else { 4 })) + }))?; + let rounds = usize::from(max_log); + let path_bytes = sum( + shape + .heights + .iter() + .map(|h| h.iter().max().copied().unwrap_or(0)) + .chain((0..rounds).map(|r| lde_log - r - 1)), + )? + .checked_mul(32) + .ok_or(VerifierError::Profile("path overflow"))?; + let per_query = sum([widths, path_bytes, rounds * 2])?; + let proof_values = sum([ + 8, + 96, + ood, + shape.log_degrees.len().saturating_mul(2), + rounds * 33, + 3, + per_query + .checked_mul(fri.num_queries) + .ok_or(VerifierError::Profile("query overflow"))?, + ])?; + limit("proof values", proof_values, limits.max_proof_values)?; + static NEXT_PLAN: AtomicU64 = AtomicU64::new(1); + Ok(Self { + key, + profile, + shape, + estimate: ResourceEstimate { + proof_values, + statement_values, + graph_nodes, + }, + id: NEXT_PLAN.fetch_add(1, Ordering::Relaxed), + }) + } + /// Protocol identity including key, shape and constant statement bindings. + /// Public values are intentionally absent so one circuit can be reused. + pub fn identity( + &self, + schema: &Statement, + ) -> Result<[u8; 32], VerifierError> { + use p3_field::PrimeField64; + use p3_symmetric::CryptographicHasher; + self.check_statement(schema)?; + let constants: Vec<_> = statement_values(schema) + .map(|s| match s { + StatementSlot::Constant(v) => Some(v.as_canonical_u64()), + StatementSlot::Public => None, + }) + .collect(); + let encoded = bincode::serde::encode_to_vec( + ( + "multi-stark/verifier-plan/v1", + self.key.fingerprint()?, + &self.profile, + constants, + ), + bincode::config::standard(), + ) + .map_err(|e| VerifierError::Encoding(e.to_string()))?; + Ok(p3_blake3::Blake3.hash_iter(encoded)) + } + + /// Cache identity additionally binds compiler and lowering provenance and + /// layout options. Callers must supply their actual version/options strings. + pub fn build_identity( + &self, + schema: &Statement, + options: ImplementationOptions, + compiler: &str, + lowering_version: &str, + layout_options: &str, + ) -> Result<[u8; 32], VerifierError> { + use p3_symmetric::CryptographicHasher; + let encoded = bincode::serde::encode_to_vec( + ( + self.identity(schema)?, + "plonkish-gadgets/v1", + env!("CARGO_PKG_VERSION"), + options.compact_blake3, + compiler, + lowering_version, + layout_options, + ), + bincode::config::standard(), + ) + .map_err(|e| VerifierError::Encoding(e.to_string()))?; + Ok(p3_blake3::Blake3.hash_iter(encoded)) + } + + pub fn profile(&self) -> &ProofProfile { + &self.profile + } + pub fn shape(&self) -> &FixedPcsShape { + &self.shape + } + pub fn resources(&self) -> ResourceEstimate { + self.estimate + } + + fn check_statement(&self, s: &Statement) -> Result<(), VerifierError> { + if s + .claims + .iter() + .map(Vec::len) + .ne(self.profile.claim_lengths.iter().copied()) + || s + .messages + .iter() + .map(|m| m.args.len()) + .ne(self.profile.message_lengths.iter().copied()) + { + return Err(VerifierError::Statement("statement does not match profile")); + } + Ok(()) + } + + /// Compose a complete verifier with explicitly caller-owned bindings. + pub fn constrain( + &self, + b: &mut CircuitBuilder, + statement: Statement, + options: ImplementationOptions, + ) -> Result { + self.check_statement(&statement)?; + if options.compact_blake3 { + b.enable_compact_blake3(); + } else if b.compact_blake3_enabled() { + return Err(VerifierError::Unsupported( + "builder already uses compact hashes", + )); + } + let wires = map_statement(statement.clone(), |v| match v { + StatementBinding::Constant(c) => b.constant(c), + StatementBinding::Wire(w) => w, + }); + let bytes = ByteGadgets::new(b); + let proof = match self.profile.envelope { + Envelope::Ordinary => super::constrain_fixed_verifier( + b, + &bytes, + self.key.system(), + &self.shape, + wires.claims, + ), + Envelope::SingleBatch => super::batch::constrain_bound_batch_verifier( + b, + &bytes, + self.key.system(), + &self.shape, + wires.claims, + &wires.messages, + ), + }; + Ok(VerifierInputs { proof, statement, plan_id: self.id }) + } + + /// Standalone public order: claims in order, then each message's arguments + /// followed by its multiplicity. Constant slots are omitted from publics. + pub fn build( + &self, + schema: Statement, + options: ImplementationOptions, + ) -> Result<(Circuit, VerifierInputs), VerifierError> { + self.check_statement(&schema)?; + let mut b = CircuitBuilder::new(); + let mut index = 0; + let bindings = map_statement(schema, |s| match s { + StatementSlot::Constant(c) => StatementBinding::Constant(c), + StatementSlot::Public => { + let w = b.public_input(format!("statement[{index}]")); + index += 1; + StatementBinding::Wire(w) + }, + }); + let inputs = self.constrain(&mut b, bindings, options)?; + Ok((b.finish(), inputs)) + } + + pub fn expand_witness( + &self, + proof: ProofEnvelope<'_>, + ) -> Result { + let expanded = match (self.profile.envelope, proof) { + (Envelope::Ordinary, ProofEnvelope::Ordinary { proof, claims }) => { + if claims + .iter() + .map(Vec::len) + .ne(self.profile.claim_lengths.iter().copied()) + { + return Err(VerifierError::Statement("claim dimensions")); + } + super::expand_pcs_witness( + self.key.system(), + &self.shape, + proof, + &claims.iter().map(Vec::as_slice).collect::>(), + ) + }, + (Envelope::SingleBatch, ProofEnvelope::SingleBatch(batch)) => { + if batch.preamble.headers.len() != 1 || batch.proofs.len() != 1 { + return Err(VerifierError::Proof( + "expected exactly one shard".into(), + )); + } + let statement = Statement { + claims: batch.preamble.headers[0].claims.clone(), + messages: batch + .preamble + .messages + .iter() + .map(|m| Message { + args: m.args.clone(), + multiplicity: m.multiplicity, + }) + .collect(), + }; + self.check_statement(&statement)?; + let messages: Vec<_> = statement + .messages + .into_iter() + .map(|m| BatchMessage { args: m.args, multiplicity: m.multiplicity }) + .collect(); + super::expand_single_batch_witness( + self.key.system(), + &self.shape, + batch, + &messages, + ) + }, + _ => return Err(VerifierError::Proof("wrong proof envelope".into())), + } + .map_err(VerifierError::Proof)?; + Ok(PreparedProof { expanded, plan_id: self.id }) + } + + /// Count frontend operations without retaining their constraints or recipes. + /// This is not an estimate of foreign-field or R1CS expansion. + pub fn estimate( + &self, + schema: Statement, + options: ImplementationOptions, + ) -> Result { + self.check_statement(&schema)?; + let mut b = CircuitBuilder::counting(); + let mut index = 0; + let bindings = map_statement(schema, |s| match s { + StatementSlot::Constant(c) => StatementBinding::Constant(c), + StatementSlot::Public => { + let w = b.public_input(format!("statement[{index}]")); + index += 1; + StatementBinding::Wire(w) + }, + }); + self.constrain(&mut b, bindings, options)?; + Ok(b.stats()) + } +} + +pub(super) fn validate_circuit( + circuit: &StarkCircuit, + index: usize, +) -> Result<(), VerifierError> { + crate::circuit_metadata::validate_circuit(circuit, 2) + .map_err(|field| VerifierError::Circuit { index, field }) +} diff --git a/crates/proof-compression/src/fri/quadratic.rs b/crates/proof-compression/src/fri/quadratic.rs new file mode 100644 index 000000000..d97d15306 --- /dev/null +++ b/crates/proof-compression/src/fri/quadratic.rs @@ -0,0 +1,145 @@ +use p3_field::Field; +use p3_field::extension::BinomiallyExtendable; + +use multi_stark::plonkish::Bool; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; + +/// Coordinates `a + b*X` in the configured quadratic extension, `X² = F::W`. +/// Arithmetic uses native base-field constraints, not foreign-field emulation. +#[derive(Clone, Copy, Debug)] +pub struct QuadraticValue(pub [Value; 2]); + +impl QuadraticValue { + pub fn select( + b: &mut CircuitBuilder, + bit: Bool, + yes: Self, + no: Self, + ) -> Self { + Self(std::array::from_fn(|i| b.select(bit, yes.0[i], no.0[i]))) + } + + pub fn mul_base( + self, + b: &mut CircuitBuilder, + value: Value, + ) -> Self { + Self(self.0.map(|coordinate| b.mul(coordinate, value))) + } + + pub fn input( + builder: &mut CircuitBuilder, + name: &str, + ) -> Self { + Self(std::array::from_fn(|i| builder.input(format!("{name}[{i}]")))) + } + + pub fn constant( + builder: &mut CircuitBuilder, + coordinates: [F; 2], + ) -> Self { + Self(coordinates.map(|c| builder.constant(c))) + } + + pub fn from_base( + builder: &mut CircuitBuilder, + value: Value, + ) -> Self { + Self([ + value, + builder.constant(::ZERO), + ]) + } + + pub fn add( + self, + builder: &mut CircuitBuilder, + rhs: Self, + ) -> Self { + Self(std::array::from_fn(|i| builder.add(self.0[i], rhs.0[i]))) + } + + pub fn sub( + self, + builder: &mut CircuitBuilder, + rhs: Self, + ) -> Self { + Self(std::array::from_fn(|i| builder.sub(self.0[i], rhs.0[i]))) + } + + pub fn neg( + self, + builder: &mut CircuitBuilder, + ) -> Self { + let zero = builder.constant(::ZERO); + Self(self.0.map(|v| builder.sub(zero, v))) + } + + pub fn scale( + self, + builder: &mut CircuitBuilder, + scalar: F, + ) -> Self { + let scalar = builder.constant(scalar); + Self(self.0.map(|v| builder.mul(v, scalar))) + } + + pub fn mul + multi_stark::traits::Field>( + self, + builder: &mut CircuitBuilder, + rhs: Self, + ) -> Self { + let ac = builder.mul(self.0[0], rhs.0[0]); + let bd = builder.mul(self.0[1], rhs.0[1]); + let a_plus_b = builder.add(self.0[0], self.0[1]); + let c_plus_d = builder.add(rhs.0[0], rhs.0[1]); + let cross = builder.mul(a_plus_b, c_plus_d); + let cross = builder.sub(cross, ac); + let cross = builder.sub(cross, bd); + let w = builder.constant(F::W); + let w_bd = builder.mul(w, bd); + Self([builder.add(ac, w_bd), cross]) + } + + /// Constrained inverse through the norm: `(a - bX)/(a² - W*b²)`. + /// The base inverse relation rejects zero, including malicious hints. + pub fn inverse + multi_stark::traits::Field>( + self, + builder: &mut CircuitBuilder, + ) -> Self { + let aa = builder.mul(self.0[0], self.0[0]); + let bb = builder.mul(self.0[1], self.0[1]); + let w = builder.constant(F::W); + let w_bb = builder.mul(w, bb); + let norm = builder.sub(aa, w_bb); + let inv_norm = builder.inverse(norm); + let real = builder.mul(self.0[0], inv_norm); + let imag = builder.mul(self.0[1], inv_norm); + let zero = builder.constant(::ZERO); + Self([real, builder.sub(zero, imag)]) + } + + pub fn exp_power_of_2< + F: BinomiallyExtendable<2> + multi_stark::traits::Field, + >( + mut self, + builder: &mut CircuitBuilder, + log_power: usize, + ) -> Self { + for _ in 0..log_power { + self = self.mul(builder, self); + } + self + } + + pub fn assert_equal( + self, + builder: &mut CircuitBuilder, + rhs: Self, + ) { + for (a, b) in self.0.into_iter().zip(rhs.0) { + builder.assert_equal(a, b); + } + } +} diff --git a/crates/proof-compression/src/fri/transcript.rs b/crates/proof-compression/src/fri/transcript.rs new file mode 100644 index 000000000..b12a58639 --- /dev/null +++ b/crates/proof-compression/src/fri/transcript.rs @@ -0,0 +1,431 @@ +use std::collections::HashMap; + +use p3_field::{PrimeCharacteristicRing, PrimeField64}; +use p3_goldilocks::Goldilocks; + +use super::AlgebraicInputs; +use super::ByteGadgets; +use super::ByteValue; +use super::QuadraticValue; +use super::blake3; +use multi_stark::plonkish::Bool; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; +use multi_stark::system::System; +use multi_stark::types::GoldilocksBlake3Config; + +#[cfg(test)] +#[path = "transcript_tests.rs"] +mod tests; + +/// Constrained reference Goldilocks/BLAKE3 challenger. Observation invalidates +/// buffered output; each flush chains its digest; sampling pops bytes from +/// the END of that digest, matching p3's HashChallenger exactly. +/// +/// Field sampling selects the FIRST canonical candidate, with a fixed retry +/// budget. Exhaustion is rejected; no modular reduction or skipped valid +/// candidates are allowed. Conditional buffer updates are circuit relations. +#[derive(Clone)] +pub struct Blake3Challenger { + input: Vec, + output: Vec, + encodings: HashMap, + dynamic: Option, + max_field_retries: usize, +} + +pub const DEFAULT_FIELD_RETRIES: usize = 2; + +/// Bytes in consumption order, and a one-hot remaining-byte count. This +/// representation lets retries consume a witness-dependent number of bytes +/// without changing the circuit's layout or trusting a host-selected cursor. +#[derive(Clone)] +struct BufferedOutput { + queue: [ByteValue; 32], + remaining: [Bool; 33], +} + +impl Blake3Challenger { + /// Exact reference grinding check; zero bits do not observe the witness. + pub fn check_witness( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + bits: usize, + witness: Value, + ) { + if bits != 0 { + self.observe_field(b, bytes, witness); + for bit in self.sample_bits(b, bytes, bits) { + b.assert_zero(bit.value()); + } + } + } + /// Low bits of the next little-endian u64. Native sample_bits always + /// consumes eight bytes, even for zero bits, and does not reject >= p. + pub fn sample_bits( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + count: usize, + ) -> Vec { + assert!(count < 64 && (1u64 << count) < Goldilocks::ORDER_U64); + let sampled = self.sample_chunk::<8>(b, bytes); + sampled[..count.div_ceil(8)] + .iter() + .flat_map(|&byte| bytes.bits(b, byte)) + .take(count) + .collect() + } + + pub fn new( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + seed: &[u8], + ) -> Self { + Self::with_retry_limit(b, bytes, seed, DEFAULT_FIELD_RETRIES) + } + + /// The retry limit is a circuit constant, never prover advice. Zero + /// retains the original no-retry subset. Larger limits cost more gates. + pub fn with_retry_limit( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + seed: &[u8], + max_field_retries: usize, + ) -> Self { + Self { + input: seed.iter().map(|&x| bytes.constant(b, x)).collect(), + output: vec![], + encodings: HashMap::new(), + dynamic: None, + max_field_retries, + } + } + + pub fn observe_bytes(&mut self, values: &[ByteValue]) { + // Native observe_slice([]) performs no byte observations, so it + // must not discard partially consumed output. + if !values.is_empty() { + self.output.clear(); + self.dynamic = None; + } + self.input.extend_from_slice(values); + } + + pub fn observe_field( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + value: Value, + ) { + let encoding = *self + .encodings + .entry(value) + .or_insert_with(|| bytes.encode_field64(b, value)); + self.observe_bytes(&encoding); + } + + pub fn observe_extension( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + value: QuadraticValue, + ) { + for coordinate in value.0 { + self.observe_field(b, bytes, coordinate); + } + } + + pub fn sample_byte( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + ) -> ByteValue { + self.sample_chunk::<1>(b, bytes)[0] + } + + fn sample_chunk( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + ) -> [ByteValue; N] { + assert!((1..=32).contains(&N)); + let Some(old) = self.dynamic.take() else { + return std::array::from_fn(|_| { + if self.output.is_empty() { + let digest = blake3(b, bytes, &self.input); + self.input = digest.to_vec(); + self.output = digest.to_vec(); + } + self.output.pop().unwrap() + }); + }; + assert_eq!(self.input.len(), 32); + // At most one digest boundary can be crossed by a <=32-byte read. + // Hashing speculatively is safe: state advances only when required. + let digest = blake3(b, bytes, &self.input); + let fresh: [_; 32] = std::array::from_fn(|i| digest[31 - i]); + let terms: Vec<_> = old.remaining[..N] + .iter() + .map(|bit| (Goldilocks::ONE, bit.value())) + .collect(); + let refill = b.linear_combination(&terms, Goldilocks::ZERO); + let refill = b.assert_bool(refill); + let sampled = std::array::from_fn(|i| { + let mut value = old.queue[i]; + for r in 0..=i { + value = bytes.select(b, old.remaining[r], fresh[i - r], value); + } + value + }); + let zero = bytes.constant(b, 0); + let queue = std::array::from_fn(|i| { + let mut value = old.queue.get(i + N).copied().unwrap_or(zero); + for r in 0..N { + let candidate = fresh.get(i + N - r).copied().unwrap_or(zero); + value = bytes.select(b, old.remaining[r], candidate, value); + } + value + }); + let remaining = std::array::from_fn(|count| { + let terms: Vec<_> = (0..=32) + .filter(|&r| (if r >= N { r - N } else { 32 - N + r }) == count) + .map(|r| (Goldilocks::ONE, old.remaining[r].value())) + .collect(); + let value = b.linear_combination(&terms, Goldilocks::ZERO); + b.assert_bool(value) + }); + for (value, fresh) in self.input.iter_mut().zip(digest) { + *value = bytes.select(b, refill, fresh, *value); + } + self.dynamic = Some(BufferedOutput { queue, remaining }); + sampled + } + + fn buffer( + &self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + ) -> BufferedOutput { + self.dynamic.clone().unwrap_or_else(|| { + let zero = bytes.constant(b, 0); + BufferedOutput { + queue: std::array::from_fn(|i| { + self.output.iter().rev().nth(i).copied().unwrap_or(zero) + }), + remaining: std::array::from_fn(|i| { + let value = b.constant(Goldilocks::from_bool(i == self.output.len())); + b.assert_bool(value) + }), + } + }) + } + + fn retain_state_if( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + retain: Bool, + input: &[ByteValue], + before: &BufferedOutput, + ) { + assert_eq!(self.input.len(), 32); + assert_eq!(input.len(), 32); + let after = self.buffer(b, bytes); + let queue = std::array::from_fn(|i| { + bytes.select(b, retain, before.queue[i], after.queue[i]) + }); + let remaining = std::array::from_fn(|i| { + let value = b.select( + retain, + before.remaining[i].value(), + after.remaining[i].value(), + ); + b.assert_bool(value) + }); + for (value, &old) in self.input.iter_mut().zip(input) { + *value = bytes.select(b, retain, old, *value); + } + self.output.clear(); + self.dynamic = Some(BufferedOutput { queue, remaining }); + } + + pub fn sample_field( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + ) -> Value { + let mut candidate = self.sample_chunk::<8>(b, bytes); + let mut accepted = + bytes.is_at_most_u64(b, candidate, Goldilocks::ORDER_U64 - 1); + let one = b.constant(Goldilocks::ONE); + for _ in 0..self.max_field_retries { + let before = self.buffer(b, bytes); + let input = self.input.clone(); + let next = self.sample_chunk::<8>(b, bytes); + self.retain_state_if(b, bytes, accepted, &input, &before); + candidate = std::array::from_fn(|i| { + bytes.select(b, accepted, candidate[i], next[i]) + }); + let valid = bytes.is_at_most_u64(b, next, Goldilocks::ORDER_U64 - 1); + let done = b.select(accepted, one, valid.value()); + accepted = b.assert_bool(done); + } + b.assert_equal(accepted.value(), one); + let value = bytes.pack_u64(b, candidate); + self.encodings.insert(value, candidate); + value + } + + pub fn sample_extension( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + ) -> QuadraticValue { + QuadraticValue(std::array::from_fn(|_| self.sample_field(b, bytes))) + } + + /// Observe a fixed integer through the same canonical field encoding. + pub fn observe_constant( + &mut self, + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + value: usize, + ) { + let value = b.constant(Goldilocks::from_usize(value)); + self.observe_field(b, bytes, value); + } +} + +/// Single-root commitments (cap height zero) supplied as constrained bytes. +/// The PCS gadget authenticates its openings against these wires. +pub struct TranscriptCommitments { + pub stage1: [ByteValue; 32], + pub stage2: [ByteValue; 32], + pub quotient: [ByteValue; 32], +} + +impl TranscriptCommitments { + pub fn allocate( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + ) -> Self { + let mut root = + |label| std::array::from_fn(|i| bytes.input(b, &format!("{label}[{i}]"))); + Self { + stage1: root("stage1 root"), + stage2: root("stage2 root"), + quotient: root("quotient root"), + } + } +} + +/// Constrain the reference multi-stark transcript through OOD challenge zeta +/// and bind all four challenge wires consumed by the algebraic verifier. +/// +/// Scope: all circuits active, fixed heights, cap height zero, Goldilocks, +/// quadratic challenges, and bounded rejection-sampling retries. System metadata, +/// preprocessing commitment, and protocol seed are trusted circuit constants. +/// PCS/Merkle/FRI authentication is NOT performed by this function. +/// Returns the constrained challenger state for the PCS continuation. +pub fn constrain_transcript( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + log_degrees: &[u8], + inputs: &AlgebraicInputs, + commitments: &TranscriptCommitments, +) -> Blake3Challenger { + constrain_active_transcript( + b, + bytes, + system, + &vec![true; system.circuits.len()], + log_degrees, + inputs, + commitments, + DEFAULT_FIELD_RETRIES, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn constrain_active_transcript( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + system: &System, + active: &[bool], + log_degrees: &[u8], + inputs: &AlgebraicInputs, + commitments: &TranscriptCommitments, + max_field_retries: usize, +) -> Blake3Challenger { + assert_eq!( + system.config.cap_height(), + 0, + "only cap height zero is supported" + ); + assert!(!system.circuits.is_empty()); + assert_eq!(system.circuits.len(), active.len()); + assert_eq!(active.iter().filter(|&&a| a).count(), log_degrees.len()); + assert_eq!(log_degrees.len(), inputs.openings.len()); + let mut challenger = Blake3Challenger::with_retry_limit( + b, + bytes, + system.config.challenger_seed(), + max_field_retries, + ); + challenger.observe_constant(b, bytes, system.circuits.len()); + for circuit in &system.circuits { + for item in [ + circuit.constraint_count(), + circuit.max_constraint_degree(), + circuit.preprocessed_height, + circuit.preprocessed_width, + circuit.main_width, + circuit.stage_2_width, + circuit.lookup_group_size, + ] { + challenger.observe_constant(b, bytes, item); + } + } + for &enabled in active { + challenger.observe_constant(b, bytes, usize::from(enabled)); + } + if let Some(commitment) = &system.preprocessed_commit { + assert_eq!( + commitment.roots().len(), + 1, + "only cap height zero is supported" + ); + let root = commitment.roots()[0].map(|x| bytes.constant(b, x)); + challenger.observe_bytes(&root); + } + challenger.observe_bytes(&commitments.stage1); + for &log_degree in log_degrees { + challenger.observe_constant(b, bytes, usize::from(log_degree)); + } + challenger.observe_constant(b, bytes, inputs.claims.len()); + for claim in &inputs.claims { + challenger.observe_constant(b, bytes, claim.len()); + for &value in claim { + challenger.observe_field(b, bytes, value); + } + } + let beta = challenger.sample_extension(b, bytes); + beta.assert_equal(b, inputs.challenges.beta); + challenger.observe_extension(b, bytes, beta); + let gamma = challenger.sample_extension(b, bytes); + gamma.assert_equal(b, inputs.challenges.gamma); + challenger.observe_extension(b, bytes, gamma); + challenger.observe_bytes(&commitments.stage2); + for opening in &inputs.openings { + challenger.observe_extension(b, bytes, opening.accumulator); + } + let alpha = challenger.sample_extension(b, bytes); + alpha.assert_equal(b, inputs.challenges.alpha); + challenger.observe_bytes(&commitments.quotient); + let zeta = challenger.sample_extension(b, bytes); + zeta.assert_equal(b, inputs.challenges.zeta); + challenger +} diff --git a/crates/proof-compression/src/fri/transcript_tests.rs b/crates/proof-compression/src/fri/transcript_tests.rs new file mode 100644 index 000000000..e275ccac7 --- /dev/null +++ b/crates/proof-compression/src/fri/transcript_tests.rs @@ -0,0 +1,229 @@ +use p3_blake3::Blake3; +use p3_challenger::{ + CanObserve, CanSample, CanSampleBits, HashChallenger, SerializingChallenger64, +}; +use p3_symmetric::CryptographicHasher; + +use super::*; + +/// Supply a chosen FIRST digest to exercise rare retries deterministically. +/// Later digest chaining is real BLAKE3. The circuit starts just after that +/// first flush, with private constrained bytes (no production escape hatch). +#[derive(Clone)] +struct FirstDigest([u8; 32]); + +impl CryptographicHasher for FirstDigest { + fn hash_iter>(&self, input: I) -> [u8; 32] { + let input: Vec<_> = input.into_iter().collect(); + if input == b"initial" { self.0 } else { Blake3.hash_iter(input) } + } +} + +fn digest(words: [u64; 4]) -> [u8; 32] { + let mut bytes: Vec<_> = + words.into_iter().flat_map(u64::to_le_bytes).collect(); + bytes.reverse(); + bytes.try_into().unwrap() +} + +fn buffered( + b: &mut CircuitBuilder, + bytes: &ByteGadgets, + retries: usize, +) -> (Blake3Challenger, [ByteValue; 32]) { + let input = std::array::from_fn(|_| bytes.input(b, "forced first digest")); + let mut challenger = + Blake3Challenger::with_retry_limit(b, bytes, &[], retries); + challenger.input = input.to_vec(); + challenger.output = input.to_vec(); + (challenger, input) +} + +#[test] +fn retries_select_first_valid_candidate_and_preserve_native_transcript_state() { + let mut b = CircuitBuilder::new(); + let bytes = ByteGadgets::new(&mut b); + let (mut challenger, input) = buffered(&mut b, &bytes, 2); + let first = challenger.sample_field(&mut b, &bytes); + let second = challenger.sample_field(&mut b, &bytes); + let bits = challenger.sample_bits(&mut b, &bytes, 17); + challenger.observe_bytes(&[]); // Must not discard a partially used digest. + let third = challenger.sample_field(&mut b, &bytes); + let observed = b.constant(Goldilocks::from_u8(42)); + challenger.observe_field(&mut b, &bytes, observed); + let fourth = challenger.sample_field(&mut b, &bytes); + let circuit = b.finish(); + let p = Goldilocks::ORDER_U64; + for words in [ + [0, 17, 19, 23], + [p - 1, p, 19, 23], + [p, 17, 19, 23], + [p, u64::MAX, 19, 23], + ] { + let digest = digest(words); + let mut native = SerializingChallenger64::::from_hasher( + b"initial".to_vec(), + FirstDigest(digest), + ); + let expected_first: Goldilocks = native.sample(); + let expected_second: Goldilocks = native.sample(); + let expected_bits = native.sample_bits(17); + let expected_third: Goldilocks = native.sample(); + native.observe(Goldilocks::from_u8(42)); + let expected_fourth: Goldilocks = native.sample(); + let mut witness = circuit.witness(); + for (wire, value) in input.iter().zip(digest) { + witness.set(wire.value(), Goldilocks::from_u8(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + for (wire, expected) in [first, second, third, fourth].into_iter().zip([ + expected_first, + expected_second, + expected_third, + expected_fourth, + ]) { + assert_eq!(assignment.value(wire).unwrap(), expected); + } + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, bit)| { + assignment.value(bit.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + assert_eq!(actual, u64::try_from(expected_bits).unwrap()); + } + // A third rejection exceeds this circuit's budget, even though native + // sampling would eventually find a valid field element. + let mut witness = circuit.witness(); + for (wire, value) in input.iter().zip(digest([p, p + 1, u64::MAX, 7])) { + witness.set(wire.value(), Goldilocks::from_u8(value)).unwrap(); + } + assert!(witness.generate().is_err()); +} + +#[test] +fn byte_reads_after_conditional_retries_cross_digest_boundaries_correctly() { + let mut b = CircuitBuilder::new(); + let bytes = ByteGadgets::new(&mut b); + let (mut challenger, input) = buffered(&mut b, &bytes, 3); + let field = challenger.sample_field(&mut b, &bytes); + let sampled: Vec<_> = + (0..39).map(|_| challenger.sample_byte(&mut b, &bytes)).collect(); + let next_field = challenger.sample_field(&mut b, &bytes); // unaligned read + let circuit = b.finish(); + for rejects in 0..=3 { + let mut words = [17, 19, 23, 29]; + words[..rejects].fill(Goldilocks::ORDER_U64); + let digest = digest(words); + let mut native = HashChallenger::::new( + b"initial".to_vec(), + FirstDigest(digest), + ); + let sample_field = |native: &mut HashChallenger| loop { + let value = u64::from_le_bytes(std::array::from_fn(|_| native.sample())); + if value < Goldilocks::ORDER_U64 { + break Goldilocks::from_u64(value); + } + }; + let expected = sample_field(&mut native); + let tail: Vec = (0..39).map(|_| native.sample()).collect(); + let expected_next = sample_field(&mut native); + let mut witness = circuit.witness(); + for (wire, value) in input.iter().zip(digest) { + witness.set(wire.value(), Goldilocks::from_u8(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + assert_eq!(assignment.value(field).unwrap(), expected); + assert_eq!(assignment.value(next_field).unwrap(), expected_next); + for (wire, expected) in sampled.iter().zip(tail) { + assert_eq!( + assignment.value(wire.value()).unwrap(), + Goldilocks::from_u8(expected) + ); + } + } +} + +#[test] +fn conditional_retry_circuit_proves_multiple_paths_under_one_outer_key() { + use multi_stark::system::System; + use multi_stark::system::SystemWitness; + use multi_stark::types::CommitmentParameters; + use multi_stark::types::FriParameters; + use multi_stark::types::GoldilocksBlake3Config; + + let mut b = CircuitBuilder::new(); + let bytes = ByteGadgets::new(&mut b); + let (mut challenger, input) = buffered(&mut b, &bytes, 2); + for byte in input { + b.expose_public(byte.value()); + } + let field = challenger.sample_field(&mut b, &bytes); + b.expose_public(field); + let buffer = challenger.buffer(&mut b, &bytes); + let circuit = b.finish(); + let compiled = + circuit.lower_to_multi_stark(Goldilocks::from_u8(105)).unwrap(); + let config = GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 1, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 4, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ); + let (system, key) = System::new(config, compiled.circuit_inputs()); + for rejects in 0..=2 { + let mut words = [17, 19, 23, 29]; + words[..rejects].fill(Goldilocks::ORDER_U64); + let digest = digest(words); + let expected = Goldilocks::from_u64(words[rejects]); + let mut witness = compiled.witness(); + for (wire, value) in input.iter().zip(digest) { + witness.set(wire.value(), Goldilocks::from_u8(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + let public: Vec<_> = + digest.into_iter().map(Goldilocks::from_u8).chain([expected]).collect(); + let claims = compiled.claims(&public).unwrap(); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1( + compiled.traces(&assignment).unwrap(), + &system, + ), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + + let mut wrong_public = public; + *wrong_public.last_mut().unwrap() += Goldilocks::ONE; + let wrong_claims = compiled.claims(&wrong_public).unwrap(); + assert!( + system + .verify_multiple_claims( + &wrong_claims.iter().map(Vec::as_slice).collect::>(), + &proof + ) + .is_err() + ); + + // Bypass witness recipes: a forged cursor/result still violates the + // actual relation. The cursor is never implicitly trusted hint data. + for wire in [ + field, + buffer.remaining[8].value(), + buffer.remaining[16].value(), + buffer.remaining[24].value(), + ] { + let mut forged = + assignment.values(compiled.circuit().id()).unwrap().to_vec(); + forged[wire.index()] += Goldilocks::ONE; + assert!(compiled.circuit().check_values(&forged).is_err()); + } + } +} diff --git a/crates/proof-compression/src/kzg/coefficients.rs b/crates/proof-compression/src/kzg/coefficients.rs new file mode 100644 index 000000000..05af55b08 --- /dev/null +++ b/crates/proof-compression/src/kzg/coefficients.rs @@ -0,0 +1,185 @@ +//! Resident or checksummed, trusted-local coefficient storage. +use std::{ + borrow::Cow, + fs::File, + io::{Read, Seek, SeekFrom}, + ops::Range, + sync::{Arc, Mutex}, +}; + +use ark_bls12_381::Fr; +use ark_ff::Zero; +use ark_serialize::{CanonicalDeserialize, SerializationError}; +use p3_maybe_rayon::prelude::*; + +const CHUNK: usize = 1 << 16; + +pub(super) enum Column { + /// A constant polynomial, still bound to its full trace domain. + Constant { + value: Fr, + len: usize, + }, + Resident { + values: Arc>, + range: Range, + }, + Disk { + file: Arc>, + offset: u64, + len: usize, + digest: blake3::Hash, + }, +} + +impl From> for Column { + fn from(values: Vec) -> Self { + if values.iter().skip(1).all(Zero::is_zero) { + return Self::Constant { + value: values.first().copied().unwrap_or_default(), + len: values.len(), + }; + } + let range = 0..values.len(); + Self::Resident { values: Arc::new(values), range } + } +} + +impl Column { + pub(super) fn len(&self) -> usize { + match self { + Self::Resident { range, .. } => range.len(), + Self::Constant { len, .. } | Self::Disk { len, .. } => *len, + } + } + + pub(super) fn slice(&self, range: Range) -> Self { + assert!(range.start <= range.end && range.end <= self.len()); + match self { + Self::Constant { value, .. } => Self::Constant { + value: if range.start == 0 && !range.is_empty() { + *value + } else { + Fr::default() + }, + len: range.len(), + }, + Self::Resident { values, range: parent } => Self::Resident { + values: values.clone(), + range: parent.start + range.start..parent.start + range.end, + }, + Self::Disk { .. } => { + unreachable!("quotient slicing precedes checkpointing") + }, + } + } + + pub(super) fn index( + reader: &mut (impl Read + Seek), + file: Arc>, + len: usize, + ) -> Result { + let offset = reader.stream_position()?; + let mut remaining = + len.checked_mul(32).ok_or(SerializationError::InvalidData)?; + let mut bytes = vec![0u8; (CHUNK * 32).min(remaining)]; + let mut hash = blake3::Hasher::new(); + while remaining != 0 { + let n = remaining.min(bytes.len()); + reader.read_exact(&mut bytes[..n])?; + hash.update(&bytes[..n]); + remaining -= n; + } + Ok(Self::Disk { file, offset, len, digest: hash.finalize() }) + } + + /// Check the bytes on every access; changing/truncating a checkpoint fails + /// closed. The file must already be trusted when first indexed. + pub(super) fn visit( + &self, + mut visit: impl FnMut(usize, &[Fr]), + ) -> Result<(), SerializationError> { + match self { + Self::Constant { value, len } => { + let mut values = vec![Fr::default(); CHUNK.min(*len)]; + for start in (0..*len).step_by(CHUNK) { + values[0] = if start == 0 { *value } else { Fr::default() }; + visit(start, &values[..CHUNK.min(len - start)]); + } + }, + Self::Resident { values, range } => visit(0, &values[range.clone()]), + Self::Disk { file, offset, len, digest } => { + let mut file = + file.lock().map_err(|_error| SerializationError::InvalidData)?; + file.seek(SeekFrom::Start(*offset))?; + let mut bytes = vec![0u8; CHUNK.min(*len) * 32]; + let mut values = vec![Fr::default(); CHUNK.min(*len)]; + let mut hash = blake3::Hasher::new(); + for start in (0..*len).step_by(CHUNK) { + let count = CHUNK.min(len - start); + let bytes = &mut bytes[..count * 32]; + file.read_exact(bytes)?; + hash.update(bytes); + values[..count] + .par_iter_mut() + .zip(bytes.par_chunks(32)) + .try_for_each(|(dst, src)| { + *dst = Fr::deserialize_compressed(src)?; + Ok::<_, SerializationError>(()) + })?; + visit(start, &values[..count]); + } + if hash.finalize() != *digest { + return Err(SerializationError::InvalidData); + } + }, + } + Ok(()) + } + + pub(super) fn load(&self) -> Result, SerializationError> { + if let Self::Resident { values, range } = self { + return Ok(Cow::Borrowed(&values[range.clone()])); + } + let mut values = Vec::with_capacity(self.len()); + self.visit(|_, chunk| values.extend_from_slice(chunk))?; + Ok(Cow::Owned(values)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_polynomials_preserve_length_slices_and_chunk_offsets() { + for len in [0, 1, CHUNK - 1, CHUNK, CHUNK + 3] { + let mut expected = vec![Fr::default(); len]; + if len != 0 { + expected[0] = Fr::from(7u8); + } + let column = Column::from(expected.clone()); + assert!(matches!(column, Column::Constant { .. })); + assert_eq!(column.len(), len); + assert_eq!(column.load().unwrap().as_ref(), expected); + let mut visited = vec![]; + column + .visit(|start, values| { + assert_eq!(start, visited.len()); + visited.extend_from_slice(values); + }) + .unwrap(); + assert_eq!(visited, expected); + for range in [0..len, 0..0, len / 2..len, len..len] { + assert_eq!( + column.slice(range.clone()).load().unwrap().as_ref(), + &expected[range] + ); + } + } + assert!(matches!( + Column::from(vec![Fr::default(), Fr::from(1u8)]), + Column::Resident { .. } + )); + } +} diff --git a/crates/proof-compression/src/kzg/compact.rs b/crates/proof-compression/src/kzg/compact.rs new file mode 100644 index 000000000..73454770d --- /dev/null +++ b/crates/proof-compression/src/kzg/compact.rs @@ -0,0 +1,398 @@ +//! Fixed-profile proof transport. The verifier supplies the system and heights; +//! this codec omits their redundant shape metadata, not cryptographic checks. + +#[cfg(test)] +use crate::foreign::KzgCircuitExt; + +use ark_bls12_381::G1Affine; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; + +use super::KzgCommitment; +use super::KzgConfig; +use super::KzgProof; +use super::Scalar; +use multi_stark::config::ProofConfig; +use multi_stark::prover::Commitments; +use multi_stark::prover::Proof; +use multi_stark::system::System; +use multi_stark::traits::Algebra; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompactProofError; + +impl std::fmt::Display for CompactProofError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid compact KZG proof or profile") + } +} +impl std::error::Error for CompactProofError {} + +/// Balanced ordinary proofs with all circuits active. Authenticate the system/profile separately and +/// run normal proof verification after decoding; decoding is not verification. +pub struct FixedProofCodec { + template: Proof, + max_openings: usize, + body_bytes: usize, +} + +impl FixedProofCodec { + /// Shape-only input for deterministic verifier construction. This is not + /// a valid proof and must never be used as a verification shortcut. + pub(crate) fn template(&self) -> Proof { + let mut proof = self.template.clone(); + let mut next = proof.log_degrees.clone(); + next.sort_unstable(); + next.dedup(); + next.retain(|&log| log != 0); + proof.opening_proof.0.resize(1 + next.len(), G1Affine::default()); + proof + } + + pub fn new( + system: &System, + log_degrees: &[u8], + ) -> Result { + if log_degrees.len() != system.circuits.len() || log_degrees.is_empty() { + return Err(CompactProofError); + } + let max_log = system.config.max_log_degree(); + let mut commitments = [ + KzgCommitment(vec![], vec![]), + KzgCommitment(vec![], vec![]), + KzgCommitment(vec![], vec![]), + ]; + let mut rounds = [vec![], vec![], vec![], vec![]]; + let mut heights = Vec::new(); + for (ci, (c, &log)) in system.circuits.iter().zip(log_degrees).enumerate() { + let height = + 1usize.checked_shl(u32::from(log)).ok_or(CompactProofError)?; + if usize::from(log) > max_log + || (c.preprocessed_width != 0 && c.preprocessed_height != height) + { + return Err(CompactProofError); + } + if !heights.contains(&log) { + heights.push(log); + } + for (i, width) in [c.main_width, c.stage_2_width, c.quotient_degree()] + .into_iter() + .enumerate() + { + commitments[i].0.push(vec![G1Affine::default(); width]); + commitments[i].1.push(vec![ + G1Affine::default(); + if usize::from(log) < max_log { + width + } else { + 0 + } + ]); + let count = match i { + 0 => { + 1 + usize::from( + system.opens_next_row(ci, multi_stark::expr::Source::Main), + ) + }, + 1 => 2, + _ => 1, + }; + rounds[i].push(vec![vec![Scalar::ZERO; width]; count]); + } + if c.preprocessed_width != 0 { + rounds[3].push(vec![ + vec![Scalar::ZERO; c.preprocessed_width]; + 1 + usize::from(system.opens_next_row( + ci, + multi_stark::expr::Source::Preprocessed + )) + ]); + } + } + let [stage_1_trace, stage_2_trace, quotient_chunks] = commitments; + let [ + stage_1_opened_values, + stage_2_opened_values, + quotient_opened_values, + prep, + ] = rounds; + let template = Proof { + active: vec![true; log_degrees.len()], + log_degrees: log_degrees.to_vec(), + commitments: Commitments { + stage_1_trace, + stage_2_trace, + quotient_chunks, + }, + intermediate_accumulators: vec![Scalar::ZERO; log_degrees.len()], + opening_proof: KzgProof(vec![]), + stage_1_opened_values, + stage_2_opened_values, + quotient_opened_values, + preprocessed_opened_values: system + .preprocessed_commit + .as_ref() + .map(|_| prep), + }; + let body_bytes = + points(&template).count() * 48 + fields(&template).count() * 32; + Ok(Self { template, max_openings: heights.len() + 1, body_bytes }) + } + + pub fn encode( + &self, + proof: &Proof, + ) -> Result, CompactProofError> { + if proof.active != self.template.active + || proof.log_degrees != self.template.log_degrees + || proof.intermediate_accumulators.last() != Some(&Scalar::ZERO) + || shape(proof) != shape(&self.template) + || proof.opening_proof.0.is_empty() + || proof.opening_proof.0.len() > self.max_openings + { + return Err(CompactProofError); + } + let mut bytes = b"KQP1".to_vec(); + bytes.push( + u8::try_from(proof.opening_proof.0.len()) + .map_err(|_error| CompactProofError)?, + ); + for point in points(proof) { + point + .serialize_compressed(&mut bytes) + .map_err(|_error| CompactProofError)?; + } + for field in fields(proof) { + field + .0 + .serialize_compressed(&mut bytes) + .map_err(|_error| CompactProofError)?; + } + Ok(bytes) + } + + pub fn decode( + &self, + bytes: &[u8], + ) -> Result, CompactProofError> { + if bytes.len() < 5 || &bytes[..4] != b"KQP1" { + return Err(CompactProofError); + } + let openings = usize::from(bytes[4]); + if openings == 0 + || openings > self.max_openings + || bytes.len() != 5 + self.body_bytes + 48 * openings + { + return Err(CompactProofError); + } + let mut proof = self.template.clone(); + proof.opening_proof.0.resize(openings, G1Affine::default()); + let mut input = &bytes[5..]; + for commitment in [ + &mut proof.commitments.stage_1_trace, + &mut proof.commitments.stage_2_trace, + &mut proof.commitments.quotient_chunks, + ] { + for point in commitment.0.iter_mut().chain(&mut commitment.1).flatten() { + *point = G1Affine::deserialize_compressed(&mut input) + .map_err(|_error| CompactProofError)?; + } + } + for point in &mut proof.opening_proof.0 { + *point = G1Affine::deserialize_compressed(&mut input) + .map_err(|_error| CompactProofError)?; + } + let accumulator_count = proof.intermediate_accumulators.len() - 1; + for value in + proof.intermediate_accumulators.iter_mut().take(accumulator_count).chain( + [ + &mut proof.stage_1_opened_values, + &mut proof.stage_2_opened_values, + &mut proof.quotient_opened_values, + ] + .into_iter() + .chain(proof.preprocessed_opened_values.as_mut()) + .flatten() + .flatten() + .flatten(), + ) + { + value.0 = ark_bls12_381::Fr::deserialize_compressed(&mut input) + .map_err(|_error| CompactProofError)?; + } + if !input.is_empty() { + return Err(CompactProofError); + } + Ok(proof) + } +} + +fn points(proof: &Proof) -> impl Iterator { + [ + &proof.commitments.stage_1_trace, + &proof.commitments.stage_2_trace, + &proof.commitments.quotient_chunks, + ] + .into_iter() + .flat_map(|c| c.0.iter().chain(&c.1).flatten()) + .chain(&proof.opening_proof.0) +} + +fn fields(proof: &Proof) -> impl Iterator { + proof + .intermediate_accumulators + .iter() + .take(proof.intermediate_accumulators.len().saturating_sub(1)) + .chain( + [ + &proof.stage_1_opened_values, + &proof.stage_2_opened_values, + &proof.quotient_opened_values, + ] + .into_iter() + .chain(proof.preprocessed_opened_values.as_ref()) + .flatten() + .flatten() + .flatten(), + ) +} + +fn shape(proof: &Proof) -> Vec { + let mut shape = vec![ + proof.intermediate_accumulators.len(), + usize::from(proof.preprocessed_opened_values.is_some()), + ]; + for c in [ + &proof.commitments.stage_1_trace, + &proof.commitments.stage_2_trace, + &proof.commitments.quotient_chunks, + ] { + for matrices in [&c.0, &c.1] { + shape.push(matrices.len()); + shape.extend(matrices.iter().map(Vec::len)); + } + } + for round in [ + &proof.stage_1_opened_values, + &proof.stage_2_opened_values, + &proof.quotient_opened_values, + ] + .into_iter() + .chain(proof.preprocessed_opened_values.as_ref()) + { + shape.push(round.len()); + for matrix in round { + shape.push(matrix.len()); + shape.extend(matrix.iter().map(Vec::len)); + } + } + shape +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::expr::Expr; + use multi_stark::system::CircuitInputs; + use multi_stark::system::SystemWitness; + use multi_stark::traits::Field; + use p3_matrix::dense::RowMajorMatrix; + use std::sync::Arc; + + #[test] + fn merged_tables_do_not_allow_cross_table_substitution() { + use multi_stark::plonkish::CircuitBuilder; + let mut b = CircuitBuilder::::new(); + let a = b.fixed_table("a", vec![vec![Scalar::ONE]]); + b.fixed_table("b", vec![vec![Scalar::ONE]]); + let one = b.constant(Scalar::ONE); + b.lookup(a, &[one]); + let compiled = b + .finish() + .lower_to_multi_stark(Scalar::from_u8(93)) + .unwrap() + .merge_table_traces(16) + .unwrap(); + let assignment = compiled.witness().generate().unwrap(); + let (system, key) = System::new( + KzgConfig::new( + Arc::new(super::super::Srs::unsafe_dev_setup(16, b"table-ids")), + 4, + ), + compiled.kzg_circuit_inputs(16, 4).unwrap(), + ); + let claims = compiled.claims(&[]).unwrap(); + let claims: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let mut traces = compiled.traces(&assignment).unwrap(); + let proof = system.prove_multiple_claims( + &key, + &claims, + SystemWitness::from_stage_1(traces.clone(), &system), + ); + system.verify_multiple_claims(&claims, &proof).unwrap(); + assert_eq!(traces[1].values, [Scalar::ONE, Scalar::ZERO]); + // The tuple is identical, but paying through the other table is invalid. + traces[1].values.swap(0, 1); + let forged = system.prove_multiple_claims( + &key, + &claims, + SystemWitness::from_stage_1(traces, &system), + ); + assert!(system.verify_multiple_claims(&claims, &forged).is_err()); + } + + #[test] + fn roundtrip_and_reject_malformed_transport() { + let f = Scalar::from_u8; + let (system, key) = System::new( + KzgConfig::new( + Arc::new(super::super::Srs::unsafe_dev_setup(8, b"codec")), + 4, + ), + [CircuitInputs { + main_width: 1, + constraints: vec![Expr::main(0) - Expr::constant(f(7))], + ..Default::default() + }], + ); + let proof = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new_col(vec![f(7); 4])], + &system, + ), + ); + let codec = FixedProofCodec::new(&system, &[2]).unwrap(); + let bytes = codec.encode(&proof).unwrap(); + let decoded = codec.decode(&bytes).unwrap(); + assert_eq!(decoded.to_bytes().unwrap(), proof.to_bytes().unwrap()); + system.verify_multiple_claims(&[], &decoded).unwrap(); + assert!(bytes.len() < proof.to_bytes().unwrap().len()); + for end in 0..bytes.len() { + assert!(codec.decode(&bytes[..end]).is_err()); + } + let mut bad = bytes.clone(); + bad.push(0); + assert!(codec.decode(&bad).is_err()); + bad = bytes.clone(); + bad[5..53].fill(0xff); + assert!(codec.decode(&bad).is_err()); + bad = bytes.clone(); + let n = bad.len(); + bad[n - 32..].fill(0xff); + assert!(codec.decode(&bad).is_err()); + let mut bad_proof = proof.clone(); + bad_proof.stage_1_opened_values[0][0].clear(); + assert!(codec.encode(&bad_proof).is_err()); + bad_proof = proof.clone(); + bad_proof.active[0] = false; + assert!(codec.encode(&bad_proof).is_err()); + bad_proof = proof.clone(); + bad_proof.intermediate_accumulators[0] = Scalar::ONE; + assert!(codec.encode(&bad_proof).is_err()); + bad_proof = proof.clone(); + bad_proof.stage_1_opened_values[0][0][0] += Scalar::ONE; + let forged = codec.decode(&codec.encode(&bad_proof).unwrap()).unwrap(); + assert!(system.verify_multiple_claims(&[], &forged).is_err()); + } +} diff --git a/crates/proof-compression/src/kzg/config.rs b/crates/proof-compression/src/kzg/config.rs new file mode 100644 index 000000000..651dc739e --- /dev/null +++ b/crates/proof-compression/src/kzg/config.rs @@ -0,0 +1,566 @@ +//! [`ProofConfig`] instantiation for the KZG backend: BLS12-381 +//! scalar field (its own challenge field, `D = 1`), Blake3 transcript, +//! monomial KZG commitments. + +use std::sync::Arc; + +use ark_serialize::CanonicalSerialize; +use p3_maybe_rayon::prelude::*; + +use multi_stark::config::ProofConfig; +use multi_stark::traits::Pcs; + +use super::field::Scalar; +use super::pcs::KzgPcs; +use super::srs::Srs; +use super::transcript::Blake3Transcript; + +#[derive(Clone)] +pub struct KzgConfig { + pcs: KzgPcs, + /// Bytes observed into every fresh challenger: a domain tag plus a + /// digest of the protocol parameters INCLUDING the SRS (see the + /// transcript contract on [`ProofConfig::initialise_challenger`]). + transcript_seed: Vec, + max_log_degree: usize, + stream_lookups: bool, + stream_quotient: bool, +} + +impl KzgConfig { + /// Public parameters are caller-supplied and taken on trust here: + /// call [`Srs::validate`] first on parameters you did not generate. + /// + /// # Panics + /// Panics if the SRS length is not a power of two (trace domains + /// are, and `max_log_degree` is read off the SRS). + pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { + let verifier = super::VerifierParams::from_srs(&srs); + let mut config = Self::verifier_only(verifier, max_quotient_degree); + config.pcs = KzgPcs::new(srs, max_quotient_degree); + config + } + + /// Construct a verifier without retaining the proving powers. No proving operation is available. + pub fn verifier_only( + params: super::VerifierParams, + max_quotient_degree: usize, + ) -> Self { + let max_log_degree = p3_util::log2_strict_usize(params.max_len()); + let mut transcript_seed = b"multi-stark/kzg/v3".to_vec(); + for parameter in [max_log_degree, max_quotient_degree] { + transcript_seed.extend(u64::try_from(parameter).unwrap().to_le_bytes()); + } + // Preserve the proving configuration's transcript exactly. + params.g1[0] + .serialize_compressed(&mut transcript_seed) + .expect("Vec serialization"); + params + .g2 + .serialize_compressed(&mut transcript_seed) + .expect("Vec serialization"); + params.g1[1] + .serialize_compressed(&mut transcript_seed) + .expect("Vec serialization"); + params + .tau_g2 + .serialize_compressed(&mut transcript_seed) + .expect("Vec serialization"); + Self { + pcs: KzgPcs::verifier_only(params, max_quotient_degree), + transcript_seed, + max_log_degree, + stream_lookups: false, + stream_quotient: false, + } + } + + /// Reconstruct and commit one lookup trace at a time from committed columns. + /// This changes memory use, not the proof or its transcript. + pub fn with_streaming_lookups(mut self) -> Self { + self.stream_lookups = true; + self + } + + /// Evaluate each quotient on trace-sized cosets, bounding temporary matrices. + pub fn with_streaming_quotient(mut self) -> Self { + self.stream_quotient = true; + self + } +} + +impl ProofConfig for KzgConfig { + fn accelerated_lookup_commit( + &self, + inputs: &[multi_stark::config::LookupCommitInput<'_, Self>], + beta: Scalar, + gamma: Scalar, + mut acc: Scalar, + ) -> Option> { + if !self.stream_lookups { + return None; + } + let mut parts = Vec::new(); + let mut accumulators = Vec::new(); + for (i, input) in inputs.iter().enumerate() { + let start = std::time::Instant::now(); + let domain = input.stage_1.0.matrices[input.stage_1.1].domain; + let (selected, graph) = + super::quotient_plan::lookup_graph(&input.circuit.graph); + tracing::info!( + circuit = i, + fixed_columns = selected.len(), + "KZG lookup fixed columns" + ); + let fixed = input.preprocessed.filter(|_| !selected.is_empty()).map( + |(data, slot)| self.pcs.evaluate_columns(data, slot, domain, &selected), + ); + let main = self.pcs.get_evaluations_on_domain( + input.stage_1.0, + input.stage_1.1, + domain, + ); + use multi_stark::traits::Algebra; + use multi_stark::traits::EvaluationDomain; + const CHUNK: usize = 1 << 12; + let width = input.circuit.stage_2_width; + let mut trace = vec![Scalar::ZERO; domain.size() * width]; + // Every chunk starts at zero. Its total determines the prefix + // offset applied in a second bounded, parallel pass. + let totals: Vec<_> = trace + .par_chunks_mut(CHUNK * width) + .enumerate() + .map(|(i, dst)| { + let start = i * CHUNK; + let values = multi_stark::system::compute_lookup_values_range( + &graph, + &main, + fixed.as_ref(), + start..start + dst.len() / width, + ); + let (mut chunks, next) = + multi_stark::lookup::LookupValues::stage_2_traces( + &[values], + &[input.circuit.lookup_group_size], + beta, + &gamma, + Scalar::ZERO, + ); + dst.copy_from_slice(&chunks.remove(0).values); + next[0] + }) + .collect(); + let mut local = Scalar::ZERO; + let offsets: Vec<_> = totals + .into_iter() + .map(|value| { + let offset = local; + local += value; + offset + }) + .collect(); + trace.par_chunks_mut(CHUNK * width).zip(offsets).for_each( + |(dst, offset)| { + if offset != Scalar::ZERO { + for value in dst { + *value += offset; + } + } + }, + ); + drop(main); + drop(fixed); + acc += local; + accumulators.push(acc); + let trace = p3_matrix::dense::RowMajorMatrix::new( + trace, + input.circuit.stage_2_width, + ); + let (_, data) = self.pcs.commit(vec![(domain, trace)]); + parts.push(data); + tracing::info!( + circuit = i, + seconds = start.elapsed().as_secs_f64(), + "KZG lookup committed" + ); + } + let (commitment, data) = super::pcs::KzgProverData::concatenate(parts); + Some((commitment, data, accumulators)) + } + + fn accelerated_quotient_commit( + &self, + inputs: &[multi_stark::config::QuotientCommitInput<'_, Self>], + alpha: Scalar, + ) -> Option<(super::pcs::KzgCommitment, super::pcs::KzgProverData)> { + if !self.stream_quotient { + return None; + } + use multi_stark::traits::Algebra; + use multi_stark::traits::EvaluationDomain; + use multi_stark::traits::TwoAdicField; + let mut parts = Vec::with_capacity(inputs.len()); + for (i, input) in inputs.iter().enumerate() { + let start = std::time::Instant::now(); + let n = input.trace_domain.size(); + let ratio = input.quotient_domain.size() / n; + let generator = + Scalar::two_adic_generator(input.quotient_domain.log_size); + let mut shift = input.quotient_domain.shift; + let mut values = vec![Scalar::ZERO; input.quotient_domain.size()]; + let passes = super::quotient_plan::plan( + &input.circuit.graph, + input.circuit.lookup_group_size.max(1), + ); + tracing::info!(circuit = i, fixed_columns = ?passes.iter().map(|p| p.fixed.len()).collect::>(), "KZG quotient fixed-column passes"); + for coset in 0..ratio { + let domain = super::domain::Radix2Coset { + log_size: input.trace_domain.log_size, + shift, + }; + let main = self.pcs.get_evaluations_on_domain( + input.stage_1.0, + input.stage_1.1, + domain, + ); + let stage2 = self.pcs.get_evaluations_on_domain( + input.stage_2.0, + input.stage_2.1, + domain, + ); + for pass in &passes { + let fixed = input + .preprocessed + .filter(|_| !pass.fixed.is_empty()) + .map(|(data, slot)| { + self.pcs.evaluate_columns(data, slot, domain, &pass.fixed) + }); + super::quotient::evaluate_coset( + input, + pass, + domain, + fixed.as_ref(), + &main, + &stage2, + alpha, + coset, + ratio, + &mut values, + ); + } + shift *= generator; + tracing::info!( + circuit = i, + coset, + seconds = start.elapsed().as_secs_f64(), + "KZG quotient coset evaluated" + ); + } + let (_, data) = self.pcs.commit_quotient(vec![( + input.quotient_domain, + p3_matrix::dense::RowMajorMatrix::new_col(values), + ratio, + )]); + parts.push(data); + tracing::info!( + circuit = i, + seconds = start.elapsed().as_secs_f64(), + "KZG quotient committed" + ); + } + Some(super::pcs::KzgProverData::concatenate(parts)) + } + + fn omit_inactive_preprocessed_openings(&self) -> bool { + true + } + fn omit_unused_next_row_openings(&self) -> bool { + true + } + + type Pcs = KzgPcs; + type Challenge = Scalar; + type Challenger = Blake3Transcript; + + fn pcs(&self) -> &KzgPcs { + &self.pcs + } + + fn initialise_challenger(&self) -> Blake3Transcript { + let mut challenger = Blake3Transcript::new(); + challenger.observe_bytes(&self.transcript_seed); + challenger + } + + fn max_log_degree(&self) -> usize { + self.max_log_degree + } + + fn max_log_quotient_domain(&self) -> usize { + ::TWO_ADICITY + } + + fn max_quotient_degree(&self) -> usize { + self.pcs.max_quotient_degree() + } + + fn log_blowup(&self) -> usize { + // KZG commits polynomials, not evaluation blowups. + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::expr::Expr; + use multi_stark::lookup::Lookup; + use multi_stark::prover::Proof; + use multi_stark::system::CircuitInputs; + use multi_stark::system::System; + use multi_stark::system::SystemWitness; + use multi_stark::traits::Algebra; + use multi_stark::traits::Field; + use p3_matrix::dense::RowMajorMatrix; + + /// `a·b = c` per row, with a self-canceling push/pull lookup pair to + /// exercise the stage-2 machinery — the KZG twin of the BabyBear + /// config's smoke test. + fn mul_circuit() -> CircuitInputs { + let m = Expr::main; + CircuitInputs { + main_width: 3, + constraints: vec![m(0) * m(1) - m(2)], + lookups: vec![ + Lookup::push(Expr::constant(Scalar::from_u32(1)), vec![m(0), m(2)]), + Lookup::pull(Expr::constant(Scalar::from_u32(1)), vec![m(0), m(2)]), + ], + ..Default::default() + } + } + + fn kzg_system() + -> (System, multi_stark::system::ProverKey) { + let srs = Arc::new(Srs::unsafe_dev_setup(1 << 8, b"test")); + let config = KzgConfig::new(srs, 8); + System::new(config, [mul_circuit()]) + } + + fn witness(system: &System) -> SystemWitness { + let f = Scalar::from_u32; + let trace = RowMajorMatrix::new( + [2, 3, 6, 4, 5, 20, 7, 8, 56, 1, 1, 1].map(f).to_vec(), + 3, + ); + SystemWitness::from_stage_1(vec![trace], system) + } + + #[test] + fn kzg_prove_verify() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); + system + .verify_multiple_claims(no_claims, &proof) + .expect("KZG proof failed to verify"); + } + + #[test] + fn opens_next_rows_only_when_the_graph_reads_them() { + let f = Scalar::from_u8; + let (system, key) = System::new( + KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(8, b"next-rows")), 4), + [ + CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![f(7); 4])), + constraints: vec![Expr::main_next(0) - Expr::preprocessed_next(0)], + ..Default::default() + }, + CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![f(9); 2])), + constraints: vec![Expr::main(0) - Expr::preprocessed(0)], + ..Default::default() + }, + ], + ); + let proof = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1( + vec![ + RowMajorMatrix::new_col(vec![f(7); 4]), + RowMajorMatrix::new_col(vec![f(9); 2]), + ], + &system, + ), + ); + system.verify_multiple_claims(&[], &proof).unwrap(); + assert_eq!( + proof.stage_1_opened_values.iter().map(Vec::len).collect::>(), + [2, 1] + ); + assert_eq!( + proof + .preprocessed_opened_values + .as_ref() + .unwrap() + .iter() + .map(Vec::len) + .collect::>(), + [2, 1] + ); + let codec = + super::super::compact::FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap(); + let decoded = codec.decode(&codec.encode(&proof).unwrap()).unwrap(); + system.verify_multiple_claims(&[], &decoded).unwrap(); + let mut bad = proof.clone(); + bad.stage_1_opened_values[0].pop(); + assert!(system.verify_multiple_claims(&[], &bad).is_err()); + let mut bad = proof.clone(); + bad.preprocessed_opened_values.as_mut().unwrap()[0][1][0] += Scalar::ONE; + assert!(system.verify_multiple_claims(&[], &bad).is_err()); + let mut bad = proof.clone(); + bad.stage_1_opened_values[1].push(vec![Scalar::ZERO]); + assert!(system.verify_multiple_claims(&[], &bad).is_err()); + // KZG commits columns separately, so inactive fixed matrices need no openings. + let partial = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1( + vec![ + RowMajorMatrix::new(vec![], 1), + RowMajorMatrix::new_col(vec![f(9); 2]), + ], + &system, + ), + ); + system.verify_multiple_claims(&[], &partial).unwrap(); + assert_eq!(partial.active, [false, true]); + assert_eq!( + partial.preprocessed_opened_values.as_ref().unwrap()[0].len(), + 0 + ); + let mut bad = partial.clone(); + bad.preprocessed_opened_values.as_mut().unwrap()[1].clear(); + assert!(system.verify_multiple_claims(&[], &bad).is_err()); + let mut bad = partial; + bad.preprocessed_opened_values.as_mut().unwrap()[0].push(vec![f(7)]); + assert!(system.verify_multiple_claims(&[], &bad).is_err()); + } + + #[test] + fn quotient_slices_fit_an_srs_exactly_as_large_as_the_trace() { + let config = + KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(4, b"exact-srs")), 4); + let (system, key) = System::new(config, [mul_circuit()]); + let proof = system.prove_multiple_claims(&key, &[], witness(&system)); + system.verify_multiple_claims(&[], &proof).unwrap(); + } + + #[test] + fn batch_messages_work_with_scalar_challenges() { + use multi_stark::batch::BatchMessage; + use multi_stark::batch::ShardInput; + let config = + KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(2, b"batch")), 4); + let (system, key) = System::new( + config, + [CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![ + Scalar::ONE, + Scalar::ZERO, + ])), + lookups: vec![Lookup::pull(Expr::preprocessed(0), vec![Expr::main(0)])], + ..Default::default() + }], + ); + let value = Scalar::from_u8(9); + let witness = SystemWitness::from_stage_1( + vec![RowMajorMatrix::new_col(vec![value; 2])], + &system, + ); + let mut proof = system.prove_batch( + &key, + vec![ShardInput { claims: vec![], witness }], + vec![BatchMessage { args: vec![value], multiplicity: Scalar::ONE }], + ); + system.verify_batch(&proof).unwrap(); + proof.preamble.messages[0].args[0] += Scalar::ONE; + assert!(system.verify_batch(&proof).is_err()); + } + + #[test] + fn kzg_tampering_rejected() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let prove = + || system.prove_multiple_claims(&key, no_claims, witness(&system)); + + let mut tampered = prove(); + tampered.intermediate_accumulators[0] += >::ONE; + assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); + + let mut tampered = prove(); + tampered.stage_1_opened_values[0][0][0] += >::ONE; + assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); + + let mut tampered = prove(); + tampered.quotient_opened_values[0][0][0] += + >::ONE; + assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); + } + + #[test] + fn kzg_wrong_claim_rejected() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); + let claim = [Scalar::from_u32(42)]; + assert!(system.verify(&claim, &proof).is_err()); + } + + #[test] + fn kzg_serialization_round_trip() { + let (system, key) = kzg_system(); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); + let bytes = proof.to_bytes().expect("serialize"); + let proof2 = Proof::::from_bytes(&bytes).expect("deserialize"); + system.verify_multiple_claims(no_claims, &proof2).unwrap(); + } + + /// Two circuits at different trace heights: ζ·g differs per height, + /// so the opening carries three distinct points and the per-point + /// witness batching (and cross-point pairing batch) is exercised. + #[test] + fn kzg_two_circuits_two_heights() { + let srs = Arc::new(Srs::unsafe_dev_setup(1 << 8, b"test")); + let config = KzgConfig::new(srs, 8); + let (system, key) = System::new(config, [mul_circuit(), mul_circuit()]); + let f = Scalar::from_u32; + let small = RowMajorMatrix::new( + [2, 3, 6, 4, 5, 20, 7, 8, 56, 1, 1, 1].map(f).to_vec(), + 3, + ); + let mut long = small.values.clone(); + for _ in 0..2 { + long.extend(long.clone()); + } + let witness = SystemWitness::from_stage_1( + vec![small, RowMajorMatrix::new(long, 3)], + &system, + ); + let no_claims: &[&[Scalar]] = &[]; + let proof = system.prove_multiple_claims(&key, no_claims, witness); + let bytes = proof.to_bytes().expect("serialize"); + println!( + "KZG proof: {} bytes (two circuits, heights 4 and 16)", + bytes.len() + ); + system.verify_multiple_claims(no_claims, &proof).unwrap(); + } +} diff --git a/crates/proof-compression/src/kzg/domain.rs b/crates/proof-compression/src/kzg/domain.rs new file mode 100644 index 000000000..746a2305c --- /dev/null +++ b/crates/proof-compression/src/kzg/domain.rs @@ -0,0 +1,179 @@ +//! The crate evaluation domain over [`Scalar`]: a multiplicative coset +//! `shift · H` of the order-`2^log_size` subgroup `H`. +//! +//! The selector formulas reproduce the p3 coset semantics the core's +//! constraint math was written against (unnormalized selectors; the +//! logUp boundary injection pre-absorbs the `n·g` normalization): +//! with `s` the shift, `g` the subgroup generator, and `u = X/s`, +//! +//! - vanishing: `Z(X) = u^n − 1` +//! - first row: `Z(X)/(u − 1)` +//! - last row: `Z(X)/(u − g⁻¹)` +//! - transition: `u − g⁻¹` + +use multi_stark::traits::Algebra; +use multi_stark::traits::EvaluationDomain; +use multi_stark::traits::Field; +use multi_stark::traits::LagrangeSelectors; +use multi_stark::traits::TwoAdicField; +use multi_stark::traits::batch_inverse; + +use super::field::Scalar; + +const ONE: Scalar = >::ONE; + +/// A coset `shift · H`, `|H| = 2^log_size`, over the BLS12-381 scalar +/// field. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Radix2Coset { + pub log_size: usize, + pub shift: Scalar, +} + +impl Radix2Coset { + /// The subgroup generator `g`. + #[inline] + pub fn generator(&self) -> Scalar { + Scalar::two_adic_generator(self.log_size) + } + + /// The coset's points in natural order: `shift · g^i`. + pub fn points(&self) -> Vec { + self + .generator() + .powers() + .take(self.size()) + .map(|x| x * self.shift) + .collect() + } +} + +impl EvaluationDomain for Radix2Coset { + type F = Scalar; + type Challenge = Scalar; + + #[inline] + fn size(&self) -> usize { + 1 << self.log_size + } + + #[inline] + fn first_point(&self) -> Scalar { + self.shift + } + + #[inline] + fn next_point(&self, x: Scalar) -> Scalar { + x * self.generator() + } + + fn create_disjoint_domain(&self, min_size: usize) -> Self { + // Multiplying the shift by the field's multiplicative generator + // leaves the subgroup (and every subgroup coset reachable by + // repeated application), exactly as in p3. + Self { + log_size: p3_util::log2_ceil_usize(min_size), + shift: self.shift + * Scalar(::GENERATOR), + } + } + + fn selectors_at_point(&self, point: Scalar) -> LagrangeSelectors { + let unshifted = point * self.shift.inverse(); + let z_h = unshifted.exp_power_of_2(self.log_size) - ONE; + let g_inv = self.generator().inverse(); + LagrangeSelectors { + is_first_row: z_h * (unshifted - ONE).inverse(), + is_last_row: z_h * (unshifted - g_inv).inverse(), + is_transition: unshifted - g_inv, + inv_vanishing: z_h.inverse(), + } + } + + fn selectors_on_coset(&self, coset: Self) -> LagrangeSelectors> { + assert_eq!(self.shift, ONE, "selectors_on_coset needs the group itself"); + assert_ne!(coset.shift, ONE, "coset must be disjoint from the group"); + assert!(coset.log_size >= self.log_size); + let rate_bits = coset.log_size - self.log_size; + + // Z_H(X) = X^n − 1 is periodic over the coset with period + // 2^rate_bits: (s·w^j·h)^n = s^n·w^{jn} and h^n = 1. + let s_pow_n = coset.shift.exp_power_of_2(self.log_size); + let vanishing: Vec = Scalar::two_adic_generator(rate_bits) + .powers() + .take(1 << rate_bits) + .map(|x| s_pow_n * x - ONE) + .collect(); + + let xs = coset.points(); + let single_point_selector = |i: u64| { + let subgroup_point = self.generator().exp_u64(i); + let denoms: Vec = + xs.iter().map(|&x| x - subgroup_point).collect(); + let inverses = batch_inverse(&denoms); + vanishing + .iter() + .cycle() + .zip(inverses) + .map(|(&z_h, inv)| z_h * inv) + .collect() + }; + + let subgroup_last = self.generator().inverse(); + LagrangeSelectors { + is_first_row: single_point_selector(0), + is_last_row: single_point_selector(self.size() as u64 - 1), + is_transition: xs.into_iter().map(|x| x - subgroup_last).collect(), + inv_vanishing: batch_inverse(&vanishing) + .into_iter() + .cycle() + .take(coset.size()) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `selectors_on_coset` must agree pointwise with `selectors_at_point` + /// evaluated at each coset point. + #[test] + fn coset_selectors_match_pointwise() { + for log_size in [0usize, 1, 3] { + for extra_bits in [0usize, 1, 2] { + let trace = Radix2Coset { log_size, shift: ONE }; + let coset = trace.create_disjoint_domain(1 << (log_size + extra_bits)); + let on_coset = trace.selectors_on_coset(coset); + for (i, x) in coset.points().into_iter().enumerate() { + let at_point = trace.selectors_at_point(x); + assert_eq!(on_coset.is_first_row[i], at_point.is_first_row); + assert_eq!(on_coset.is_last_row[i], at_point.is_last_row); + assert_eq!(on_coset.is_transition[i], at_point.is_transition); + assert_eq!(on_coset.inv_vanishing[i], at_point.inv_vanishing); + } + } + } + } + + /// The disjoint domain must be disjoint: the vanishing polynomial of + /// the trace domain is nonzero on every coset point. + #[test] + fn disjoint_domain_is_disjoint() { + let trace = Radix2Coset { log_size: 4, shift: ONE }; + let coset = trace.create_disjoint_domain(1 << 6); + for x in coset.points() { + assert!(!(x.exp_power_of_2(4) - ONE).is_zero()); + } + } + + #[test] + fn next_point_walks_the_domain() { + let domain = Radix2Coset { log_size: 3, shift: ONE }; + let points = domain.points(); + for i in 0..points.len() - 1 { + assert_eq!(domain.next_point(points[i]), points[i + 1]); + } + } +} diff --git a/crates/proof-compression/src/kzg/fft_batch.rs b/crates/proof-compression/src/kzg/fft_batch.rs new file mode 100644 index 000000000..aae143fe9 --- /dev/null +++ b/crates/proof-compression/src/kzg/fft_batch.rs @@ -0,0 +1,87 @@ +use super::coefficients::Column; +use ark_bls12_381::Fr; +use ark_ff::{AdditiveGroup, Zero}; +use ark_poly::{EvaluationDomain, Radix2EvaluationDomain}; +use p3_maybe_rayon::prelude::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Pair(pub(super) [Fr; 2]); +impl std::ops::Add for Pair { + type Output = Self; + fn add(mut self, rhs: Self) -> Self { + self += rhs; + self + } +} +impl std::ops::Sub for Pair { + type Output = Self; + fn sub(mut self, rhs: Self) -> Self { + self -= rhs; + self + } +} +impl std::ops::AddAssign for Pair { + fn add_assign(&mut self, rhs: Self) { + for (a, b) in self.0.iter_mut().zip(rhs.0) { + *a += b; + } + } +} +impl std::ops::SubAssign for Pair { + fn sub_assign(&mut self, rhs: Self) { + for (a, b) in self.0.iter_mut().zip(rhs.0) { + *a -= b; + } + } +} +impl std::ops::MulAssign for Pair { + fn mul_assign(&mut self, rhs: Fr) { + for a in &mut self.0 { + *a *= rhs; + } + } +} +impl Zero for Pair { + fn zero() -> Self { + Self([Fr::ZERO; 2]) + } + fn is_zero(&self) -> bool { + self.0.iter().all(Zero::is_zero) + } +} + +// Two columns share FFT roots and permutation passes, using one extra column +// of scratch. The underlying transform remains arkworks' FFT. +pub(super) fn evaluate( + columns: &[&Column], + domain: Radix2EvaluationDomain, +) -> Vec { + assert_eq!(columns.len(), 2); + assert_eq!(columns[0].len(), columns[1].len()); + if let ( + Column::Constant { value: a, .. }, + Column::Constant { value: b, .. }, + ) = (columns[0], columns[1]) + { + return vec![Pair([*a, *b]); domain.size()]; + } + let mut rows = vec![Pair::zero(); columns[0].len()]; + for (index, column) in columns.iter().enumerate() { + column + .visit(|start, chunk| { + rows[start..start + chunk.len()] + .par_iter_mut() + .zip(chunk) + .for_each(|(row, value)| row.0[index] = *value); + }) + .expect("coefficient checkpoint"); + } + if rows.iter().skip(1).all(Zero::is_zero) { + let value = rows.first().copied().unwrap_or_else(Pair::zero); + rows.resize(domain.size(), value); + rows.fill(value); + } else { + domain.fft_in_place(&mut rows); + } + rows +} diff --git a/crates/proof-compression/src/kzg/field.rs b/crates/proof-compression/src/kzg/field.rs new file mode 100644 index 000000000..bd414eb08 --- /dev/null +++ b/crates/proof-compression/src/kzg/field.rs @@ -0,0 +1,305 @@ +//! Crate field traits for the BLS12-381 scalar field. +//! +//! [`Scalar`] is a `repr(transparent)` newtype over `ark_bls12_381::Fr`. +//! A newtype (unlike the p3 fields, which implement the crate traits +//! directly) because the traits require serde and arkworks types only +//! speak `CanonicalSerialize`; the wrapper carries a canonical 32-byte +//! little-endian serde encoding and every crate trait. +//! +//! The field is its own challenge field: `ExtensionOf` with +//! `D = 1` (|Fr| ~ 2^255 dwarfs every Schwartz-Zippel term), and its own +//! packing with `WIDTH = 1` (the sweep runs scalar; MSM dominates the +//! prover here, not constraint evaluation). + +use core::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; + +use ark_bls12_381::Fr; +use ark_ff::{AdditiveGroup, FftField, Field as ArkField}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_matrix::Matrix; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use multi_stark::traits::Algebra; +use multi_stark::traits::ExtensionOf; +use multi_stark::traits::Field; +use multi_stark::traits::Packed; +use multi_stark::traits::PackedExtension; +use multi_stark::traits::TwoAdicField; + +/// The BLS12-381 scalar field as a crate [`Field`]. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] +#[repr(transparent)] +pub struct Scalar(pub Fr); + +impl Serialize for Scalar { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = [0u8; 32]; + self + .0 + .serialize_compressed(&mut bytes[..]) + .map_err(serde::ser::Error::custom)?; + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Scalar { + fn deserialize>( + deserializer: D, + ) -> Result { + let bytes = <[u8; 32]>::deserialize(deserializer)?; + Fr::deserialize_compressed(&bytes[..]) + .map(Self) + .map_err(serde::de::Error::custom) + } +} + +impl Add for Scalar { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self(self.0 + rhs.0) + } +} +impl Sub for Scalar { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self(self.0 - rhs.0) + } +} +impl Mul for Scalar { + type Output = Self; + #[inline] + fn mul(self, rhs: Self) -> Self { + Self(self.0 * rhs.0) + } +} +impl Neg for Scalar { + type Output = Self; + #[inline] + fn neg(self) -> Self { + Self(-self.0) + } +} +impl AddAssign for Scalar { + #[inline] + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + } +} +impl SubAssign for Scalar { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + self.0 -= rhs.0; + } +} +impl MulAssign for Scalar { + #[inline] + fn mul_assign(&mut self, rhs: Self) { + self.0 *= rhs.0; + } +} + +impl core::fmt::Display for Scalar { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Scalar { + /// Low 64 bits of the canonical value — exact for values < 2^64. + /// For Aiur-style consumers that only extract bytes/pointers/counters + /// (values small by construction). + pub fn canonical_low_u64(&self) -> u64 { + use ark_ff::PrimeField; + self.0.into_bigint().0[0] + } + + /// The canonical value as four little-endian 64-bit limbs. + pub fn canonical_limbs_le(&self) -> [u64; 4] { + use ark_ff::PrimeField; + self.0.into_bigint().0 + } + + /// The element with the given canonical little-endian limbs. + /// + /// # Panics + /// Panics if the limbs are not below the modulus. + pub fn from_limbs_le(limbs: [u64; 4]) -> Self { + use ark_ff::{BigInt, PrimeField}; + Self( + Fr::from_bigint(BigInt(limbs)) + .expect("limbs must be below the scalar modulus"), + ) + } +} + +impl Algebra for Scalar { + const ZERO: Self = Self(::ZERO); + const ONE: Self = Self(::ONE); +} + +impl Field for Scalar { + const TWO: Self = Self(ark_ff::MontFp!("2")); + const NEG_ONE: Self = Self(ark_ff::MontFp!("-1")); + fn prime_order_exceeds(_: usize) -> bool { + true + } + + type Packing = Self; + + #[inline] + fn inverse(&self) -> Self { + // `inverse(0)` is free per the trait contract; zero keeps it total. + self.0.inverse().map_or(>::ZERO, Self) + } + + #[inline] + fn exp_u64(&self, exp: u64) -> Self { + Self(self.0.pow([exp])) + } + + #[inline] + fn from_bool(b: bool) -> Self { + Self(Fr::from(b)) + } + + #[inline] + fn from_u8(x: u8) -> Self { + Self(Fr::from(x)) + } + + #[inline] + fn from_u32(x: u32) -> Self { + Self(Fr::from(x)) + } + + #[inline] + fn from_u64(x: u64) -> Self { + Self(Fr::from(x)) + } + + #[inline] + fn from_usize(x: usize) -> Self { + Self(Fr::from(u64::try_from(x).expect("usize fits u64"))) + } +} + +impl TwoAdicField for Scalar { + // Fr has a 2^32 root of unity; pinned by a test below. + const TWO_ADICITY: usize = 32; + + #[inline] + fn two_adic_generator(bits: usize) -> Self { + assert!(bits <= Self::TWO_ADICITY); + Self(Fr::get_root_of_unity(1u64 << bits).expect("two-adic subgroup exists")) + } +} + +impl Packed for Scalar { + const WIDTH: usize = 1; + + #[inline] + fn from_slice(slice: &[Self]) -> &Self { + &slice[0] + } + + #[inline] + fn as_slice(&self) -> &[Self] { + core::slice::from_ref(self) + } + + #[inline] + fn packed_row_pair>( + matrix: &M, + i: usize, + step: usize, + ) -> Vec { + let height = matrix.height(); + let width = matrix.width(); + let mut out = Vec::with_capacity(2 * width); + for r in [i % height, (i + step) % height] { + out.extend_from_slice(&matrix.row_slice(r).expect("row in range")); + } + out + } +} + +impl ExtensionOf for Scalar { + const D: usize = 1; + // Unused at D = 1 (the basis is the identity). + const W: Self = >::ZERO; + type ExtPacking = Self; + + #[inline] + fn as_basis_coefficients_slice(&self) -> &[Self] { + core::slice::from_ref(self) + } + + #[inline] + fn from_basis_coefficients_fn(mut f: impl FnMut(usize) -> Self) -> Self { + f(0) + } +} + +impl PackedExtension for Scalar { + #[inline] + fn from_basis_coefficients_fn(mut f: impl FnMut(usize) -> Self) -> Self { + f(0) + } + + #[inline] + fn as_basis_coefficients_slice(&self) -> &[Self] { + core::slice::from_ref(self) + } +} + +impl multi_stark::traits::PrimeField for Scalar { + fn canonical_u64(self) -> Option { + let [lo, a, b, c] = self.canonical_limbs_le(); + (a == 0 && b == 0 && c == 0).then_some(lo) + } + fn modulus_u64() -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_adicity_matches_ark() { + assert_eq!( + u32::try_from(Scalar::TWO_ADICITY).unwrap(), + ::TWO_ADICITY + ); + // The order-2^k generators nest: g_k^2 == g_{k-1}. + for bits in 1..=Scalar::TWO_ADICITY { + let g = Scalar::two_adic_generator(bits); + assert_eq!(g * g, Scalar::two_adic_generator(bits - 1)); + } + assert_eq!(Scalar::two_adic_generator(0), >::ONE); + } + + #[test] + fn serde_round_trip() { + let x = Scalar::from_u64(0xdead_beef_cafe_f00d); + let bytes = + bincode::serde::encode_to_vec(x, bincode::config::standard()).unwrap(); + let (y, _): (Scalar, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) + .unwrap(); + assert_eq!(x, y); + } + + #[test] + fn inverse_and_powers() { + let x = Scalar::from_u32(12345); + assert_eq!(x * x.inverse(), >::ONE); + let cube: Vec = x.powers().take(4).collect(); + assert_eq!(cube[3], x * x * x); + assert_eq!(x.exp_u64(3), cube[3]); + assert_eq!(x.exp_power_of_2(3), x.exp_u64(8)); + } +} diff --git a/crates/proof-compression/src/kzg/mod.rs b/crates/proof-compression/src/kzg/mod.rs new file mode 100644 index 000000000..af1ad2992 --- /dev/null +++ b/crates/proof-compression/src/kzg/mod.rs @@ -0,0 +1,24 @@ +//! BLS12-381 KZG backend for multi-stark, with a BLAKE3 transcript. +//! Commitments contain one G1 point per column; openings are batched by point. + +mod coefficients; +pub mod compact; +pub mod config; +pub mod domain; +mod fft_batch; +pub mod field; +pub mod pcs; +mod quotient; +mod quotient_plan; +pub mod srs; +pub mod transcript; + +pub use config::KzgConfig; +pub use domain::Radix2Coset; +pub use field::Scalar; +pub use pcs::{KzgCommitment, KzgPcs, KzgProof}; +pub use srs::Srs; +pub use transcript::Blake3Transcript; + +pub mod verifier_params; +pub use verifier_params::VerifierParams; diff --git a/crates/proof-compression/src/kzg/pcs.rs b/crates/proof-compression/src/kzg/pcs.rs new file mode 100644 index 000000000..503d721d2 --- /dev/null +++ b/crates/proof-compression/src/kzg/pcs.rs @@ -0,0 +1,1216 @@ +//! Monomial-basis KZG over BLS12-381 implementing [`Pcs`]. +//! +//! Each column has a G1 commitment; shorter polynomials also carry a shifted +//! commitment for degree checks. Openings at each point are folded with a +//! transcript challenge, producing one witness commitment per distinct point. +//! A second challenge batches their pairing equations. +//! +//! Quotients use coefficient slices: `Q(X) = Σₖ X^{k·n}·cₖ(X)`. +//! Coset FFTs evaluate stored coefficients on the quotient domain; +//! [`Pcs::max_quotient_degree`] bounds this evaluation budget. + +use std::sync::Arc; + +use ark_bls12_381::{Bls12_381, Fr, G1Affine, G1Projective}; +use ark_ec::{CurveGroup, VariableBaseMSM, pairing::Pairing}; +use ark_ff::{AdditiveGroup, Field as ArkField, Zero}; +use ark_poly::{ + EvaluationDomain as ArkEvaluationDomain, Radix2EvaluationDomain, +}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_matrix::Matrix; +use p3_matrix::dense::RowMajorMatrix; +use p3_maybe_rayon::prelude::*; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use multi_stark::traits::EvaluationDomain; +use multi_stark::traits::OpenedValues; +use multi_stark::traits::OpeningRounds; +use multi_stark::traits::Pcs; +use multi_stark::traits::Transcript; +use multi_stark::traits::VerifyRounds; + +use super::coefficients::Column; +use super::domain::Radix2Coset; +use super::field::Scalar; +use super::srs::Srs; +use super::transcript::Blake3Transcript; + +/// Per-matrix column commitments and optional shifted degree commitments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KzgCommitment(pub Vec>, pub Vec>); + +/// One opening proof: one witness point per distinct opening point, in +/// transcript (first-appearance) order. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KzgProof(pub Vec); + +// Bound Arkworks' length prefixes against the remaining byte slice before +// allocating. Bincode's outer byte budget cannot bound nested Arkworks Vecs. +fn read_count( + input: &mut &[u8], + minimum_bytes: usize, +) -> Result { + let count = usize::try_from(u64::deserialize_compressed(&mut *input)?) + .map_err(|_overflow| ark_serialize::SerializationError::InvalidData)?; + if count > input.len() / minimum_bytes { + return Err(ark_serialize::SerializationError::InvalidData); + } + Ok(count) +} + +fn read_points( + input: &mut &[u8], +) -> Result, ark_serialize::SerializationError> { + let count = read_count(input, 48)?; + (0..count).map(|_| G1Affine::deserialize_compressed(&mut *input)).collect() +} + +fn read_point_rows( + input: &mut &[u8], +) -> Result>, ark_serialize::SerializationError> { + let count = read_count(input, 8)?; + (0..count).map(|_| read_points(input)).collect() +} + +impl Serialize for KzgProof { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = Vec::new(); + self + .0 + .serialize_compressed(&mut bytes) + .map_err(serde::ser::Error::custom)?; + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for KzgProof { + fn deserialize>( + deserializer: D, + ) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + let mut input = bytes.as_slice(); + let points = read_points(&mut input).map_err(serde::de::Error::custom)?; + if !input.is_empty() { + return Err(serde::de::Error::custom("trailing proof bytes")); + } + Ok(Self(points)) + } +} + +impl Serialize for KzgCommitment { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = Vec::new(); + self + .0 + .serialize_compressed(&mut bytes) + .map_err(serde::ser::Error::custom)?; + self + .1 + .serialize_compressed(&mut bytes) + .map_err(serde::ser::Error::custom)?; + bytes.serialize(serializer) + } +} +impl<'de> Deserialize<'de> for KzgCommitment { + fn deserialize>( + deserializer: D, + ) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + let mut input = bytes.as_slice(); + let main = read_point_rows(&mut input).map_err(serde::de::Error::custom)?; + let shifted = + read_point_rows(&mut input).map_err(serde::de::Error::custom)?; + if !input.is_empty() { + return Err(serde::de::Error::custom("trailing commitment bytes")); + } + Ok(Self(main, shifted)) + } +} + +/// A committed matrix on the prover side: its domain and each column +/// polynomial in coefficient form (length = domain size). +pub struct CommittedMatrix { + pub(crate) domain: Radix2Coset, + columns: Vec, +} + +/// Prover-side retained data for one commitment round. +pub struct KzgProverData { + commitment: KzgCommitment, + pub matrices: Vec, +} + +impl KzgProverData { + /// Save a local prover checkpoint. This is not a verifier-key format. + pub fn write_checkpoint( + &self, + mut out: impl std::io::Write, + ) -> Result<(), ark_serialize::SerializationError> { + self.commitment.0.serialize_compressed(&mut out)?; + self.commitment.1.serialize_compressed(&mut out)?; + (self.matrices.len() as u64).serialize_compressed(&mut out)?; + for matrix in &self.matrices { + (matrix.domain.log_size as u64).serialize_compressed(&mut out)?; + matrix.domain.shift.0.serialize_compressed(&mut out)?; + (matrix.columns.len() as u64).serialize_compressed(&mut out)?; + for column in &matrix.columns { + (column.len() as u64).serialize_compressed(&mut out)?; + let mut result = Ok(()); + let mut bytes = vec![0u8; column.len().min(1 << 16) * 32]; + column.visit(|_, values| { + if result.is_err() { + return; + } + result = values.chunks(1 << 16).try_for_each(|chunk| { + let bytes = &mut bytes[..chunk.len() * 32]; + bytes + .par_chunks_mut(32) + .zip(chunk.par_iter()) + .try_for_each(|(dst, value)| value.serialize_compressed(dst))?; + out.write_all(bytes)?; + Ok::<_, ark_serialize::SerializationError>(()) + }); + })?; + result?; + } + } + Ok(()) + } + + /// Load a trusted, locally produced prover checkpoint. + pub fn read_checkpoint( + mut input: impl std::io::Read, + ) -> Result { + let commitment = KzgCommitment( + Vec::deserialize_compressed(&mut input)?, + Vec::deserialize_compressed(&mut input)?, + ); + let count = usize::try_from(u64::deserialize_compressed(&mut input)?) + .map_err(|_error| ark_serialize::SerializationError::InvalidData)?; + if count != commitment.0.len() || count != commitment.1.len() { + return Err(ark_serialize::SerializationError::InvalidData); + } + let mut matrices = Vec::with_capacity(count); + for i in 0..count { + let log_size = usize::try_from(u64::deserialize_compressed(&mut input)?) + .map_err(|_error| ark_serialize::SerializationError::InvalidData)?; + if log_size > 32 { + return Err(ark_serialize::SerializationError::InvalidData); + } + let shift = Scalar(Fr::deserialize_compressed(&mut input)?); + let columns: Vec> = Vec::deserialize_compressed(&mut input)?; + if columns.len() != commitment.0[i].len() + || columns.iter().any(|c| c.len() != 1usize << log_size) + { + return Err(ark_serialize::SerializationError::InvalidData); + } + matrices.push(CommittedMatrix { + domain: Radix2Coset { log_size, shift }, + columns: columns.into_iter().map(Column::from).collect(), + }); + } + let mut tail = [0]; + if input.read(&mut tail)? != 0 { + return Err(ark_serialize::SerializationError::InvalidData); + } + Ok(Self { commitment, matrices }) + } + + /// Index an existing trusted local checkpoint without retaining its columns. + /// Each later read checks canonical scalars and the digest recorded here. + pub fn read_checkpoint_file( + path: impl AsRef, + ) -> Result { + use ark_serialize::SerializationError; + use std::{ + fs::File, + io::{BufReader, Read}, + sync::Mutex, + }; + let file = Arc::new(Mutex::new(File::open(path)?)); + let mut guard = + file.lock().map_err(|_error| SerializationError::InvalidData)?; + let mut reader = BufReader::with_capacity(1 << 20, &mut *guard); + let commitment = KzgCommitment( + Vec::deserialize_compressed(&mut reader)?, + Vec::deserialize_compressed(&mut reader)?, + ); + let count = usize::try_from(u64::deserialize_compressed(&mut reader)?) + .map_err(|_error| SerializationError::InvalidData)?; + if count != commitment.0.len() || count != commitment.1.len() { + return Err(SerializationError::InvalidData); + } + let mut matrices = Vec::with_capacity(count); + for i in 0..count { + let log = u64::deserialize_compressed(&mut reader)?; + if log > 32 { + return Err(SerializationError::InvalidData); + } + let log_size = usize::try_from(log) + .map_err(|_error| SerializationError::InvalidData)?; + let shift = Scalar(Fr::deserialize_compressed(&mut reader)?); + let width = u64::deserialize_compressed(&mut reader)?; + if width != commitment.0[i].len() as u64 { + return Err(SerializationError::InvalidData); + } + let mut columns = Vec::with_capacity(commitment.0[i].len()); + for _ in 0..width { + let len = u64::deserialize_compressed(&mut reader)?; + if len != 1u64 << log { + return Err(SerializationError::InvalidData); + } + columns.push(Column::index( + &mut reader, + file.clone(), + 1usize << log_size, + )?); + } + matrices.push(CommittedMatrix { + domain: Radix2Coset { log_size, shift }, + columns, + }); + } + let mut tail = [0]; + if reader.read(&mut tail)? != 0 { + return Err(SerializationError::InvalidData); + } + Ok(Self { commitment, matrices }) + } + + /// Combine independently committed matrices in canonical circuit order. + pub fn concatenate( + parts: impl IntoIterator, + ) -> (KzgCommitment, Self) { + let mut commitment = KzgCommitment(vec![], vec![]); + let mut matrices = Vec::new(); + for mut part in parts { + commitment.0.append(&mut part.commitment.0); + commitment.1.append(&mut part.commitment.1); + matrices.append(&mut part.matrices); + } + (commitment.clone(), Self { commitment, matrices }) + } +} + +#[derive(Debug)] +pub enum KzgError { + /// Commitment/opened-value dimensions disagree with the rounds. + ShapeMismatch, + /// The batched pairing equation does not hold. + PairingCheckFailed, + DegreeBoundFailed, +} + +/// See the module docs. +#[derive(Clone)] +pub struct KzgPcs { + srs: Option>, + verifier: super::VerifierParams, + max_quotient_degree: usize, +} + +impl KzgPcs { + pub(super) fn evaluate_columns( + &self, + data: &KzgProverData, + idx: usize, + domain: Radix2Coset, + selected: &[usize], + ) -> RowMajorMatrix { + let matrix = &data.matrices[idx]; + assert!( + domain.size() <= matrix.domain.size() * self.max_quotient_degree, + "requested domain ({}) exceeds the coset-FFT budget ({}x trace); \ + raise max_quotient_degree if this cost is intended", + domain.size(), + self.max_quotient_degree + ); + let ark = Self::ark_domain(domain); + let width = selected.len(); + let height = domain.size(); + let mut values = vec![Scalar(Fr::ZERO); width * height]; + let pairs = width / 2; + for i in 0..pairs { + let columns = super::fft_batch::evaluate( + &[ + &matrix.columns[selected[2 * i]], + &matrix.columns[selected[2 * i + 1]], + ], + ark, + ); + values.par_chunks_mut(width).zip(columns).for_each(|(row, value)| { + row[2 * i] = Scalar(value.0[0]); + row[2 * i + 1] = Scalar(value.0[1]); + }); + } + if let Some(coefficients) = + selected.get(pairs * 2).map(|&i| &matrix.columns[i]) + { + let column = match coefficients { + Column::Constant { value, .. } => vec![*value; height], + _ => { + let coefficients = + coefficients.load().expect("coefficient checkpoint"); + if coefficients.iter().skip(1).all(Zero::is_zero) { + vec![coefficients.first().copied().unwrap_or(Fr::ZERO); height] + } else { + let mut values = coefficients.into_owned(); + ark.fft_in_place(&mut values); + values + } + }, + }; + values + .par_chunks_mut(width) + .zip(column) + .for_each(|(row, value)| row[pairs * 2] = Scalar(value)); + } + RowMajorMatrix::new(values, width) + } + + pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { + let verifier = super::VerifierParams::from_srs(&srs); + Self { srs: Some(srs), verifier, max_quotient_degree } + } + + pub fn verifier_only( + verifier: super::VerifierParams, + max_quotient_degree: usize, + ) -> Self { + Self { srs: None, verifier, max_quotient_degree } + } + + fn prover_srs(&self) -> &Srs { + self.srs.as_deref().expect("proving requires the full SRS") + } + + /// The arkworks FFT domain realizing one of ours. + fn ark_domain(domain: Radix2Coset) -> Radix2EvaluationDomain { + let base = Radix2EvaluationDomain::new(domain.size()) + .expect("size within Fr two-adicity"); + if domain.shift == multi_stark::traits::Algebra::::ONE { + base + } else { + base.get_coset(domain.shift.0).expect("nonzero coset shift") + } + } + + fn commit_columns(&self, columns: &[Column]) -> Vec { + let commits: Vec = + columns.iter().map(|c| self.commit_column(c, 0)).collect(); + G1Projective::normalize_batch(&commits) + } + + fn commit_column(&self, column: &Column, shift: usize) -> G1Projective { + assert!(column.len() <= self.verifier.max_len().saturating_sub(shift)); + match column { + Column::Constant { value, .. } => self.prover_srs().g1[shift] * value, + _ => self.msm_at(&column.load().expect("coefficient checkpoint"), shift), + } + } + + fn msm(&self, coeffs: &[Fr]) -> G1Projective { + assert!( + coeffs.len() <= self.verifier.max_len(), + "polynomial length {} exceeds the SRS ({})", + coeffs.len(), + self.verifier.max_len() + ); + if coeffs.is_empty() { + return G1Projective::zero(); + } + self.msm_at(coeffs, 0) + } + + fn msm_at(&self, coeffs: &[Fr], shift: usize) -> G1Projective { + if coeffs.iter().skip(1).all(Zero::is_zero) { + return self.prover_srs().g1[shift] + * coeffs.first().copied().unwrap_or(Fr::ZERO); + } + // Bound digit/bucket scratch and expose point-range parallelism in + // addition to the MSM implementation's small number of scalar windows. + const CHUNK: usize = 1 << 18; + let concurrency = current_num_threads().div_ceil(8).min(4); + let mut result = G1Projective::zero(); + for (batch, values) in coeffs.chunks(CHUNK * concurrency).enumerate() { + let partial: G1Projective = values + .par_chunks(CHUNK) + .enumerate() + .map(|(i, scalars)| { + let start = shift + batch * CHUNK * concurrency + i * CHUNK; + G1Projective::msm( + &self.prover_srs().g1[start..start + scalars.len()], + scalars, + ) + .expect("equal lengths") + }) + .sum(); + result += partial; + } + result + } + + /// Interpolate each column of `matrix` over `domain`. + fn interpolate_columns( + domain: Radix2Coset, + matrix: RowMajorMatrix, + ) -> Vec { + assert_eq!(matrix.height(), domain.size(), "matrix height != domain size"); + let width = matrix.width(); + let evals = if width == 1 { + // The consuming map reuses the transparent scalar vector's allocation. + vec![matrix.values.into_iter().map(|v| v.0).collect()] + } else { + let mut evals: Vec> = + vec![Vec::with_capacity(matrix.height()); width]; + for (i, value) in matrix.values.into_iter().enumerate() { + evals[i % width].push(value.0); + } + evals + }; + let ark = Self::ark_domain(domain); + evals + .into_iter() + .map(|mut col| { + let first = col[0]; + if col.iter().all(|&v| v == first) { + Column::Constant { value: first, len: col.len() } + } else { + ark.ifft_in_place(&mut col); + col.into() + } + }) + .collect() + } +} + +impl Pcs for KzgPcs { + type F = Scalar; + type Challenge = Scalar; + type Domain = Radix2Coset; + type Challenger = Blake3Transcript; + type Commitment = KzgCommitment; + type ProverData = KzgProverData; + type Proof = KzgProof; + type Error = KzgError; + type Evaluations<'a> = RowMajorMatrix; + + fn natural_domain_for_degree(&self, degree: usize) -> Radix2Coset { + Radix2Coset { + log_size: p3_util::log2_strict_usize(degree), + shift: multi_stark::traits::Algebra::::ONE, + } + } + + fn max_quotient_degree(&self) -> usize { + self.max_quotient_degree + } + + fn commit( + &self, + evaluations: Vec<(Radix2Coset, RowMajorMatrix)>, + ) -> (KzgCommitment, KzgProverData) { + let matrices: Vec = evaluations + .into_iter() + .map(|(domain, matrix)| CommittedMatrix { + domain, + columns: Self::interpolate_columns(domain, matrix), + }) + .collect(); + let commitment = KzgCommitment( + matrices.iter().map(|m| self.commit_columns(&m.columns)).collect(), + matrices + .iter() + .map(|m| { + let shift = self.verifier.max_len() - m.domain.size(); + if shift == 0 { + return vec![]; + } + let points: Vec<_> = + m.columns.iter().map(|c| self.commit_column(c, shift)).collect(); + G1Projective::normalize_batch(&points) + }) + .collect(), + ); + (commitment.clone(), KzgProverData { matrices, commitment }) + } + + fn commit_quotient( + &self, + quotients: Vec<(Radix2Coset, RowMajorMatrix, usize)>, + ) -> (KzgCommitment, KzgProverData) { + let matrices: Vec = quotients + .into_iter() + .map(|(quotient_domain, evaluations, quotient_degree)| { + let big = quotient_domain.size(); + debug_assert_eq!(big % quotient_degree, 0); + let n = big / quotient_degree; + let coefficient_columns: Vec = + Self::interpolate_columns(quotient_domain, evaluations); + // Slice `Q(X) = Σₖ X^{k·n}·cₖ(X)`: slice k of coordinate d + // is coefficient range [k·n, (k+1)·n), laid out as column + // `k·D + d` — the order the verifier's ζ-recombination + // reads. + let columns: Vec = (0..quotient_degree) + .flat_map(|k| { + coefficient_columns.iter().map(move |c| c.slice(k * n..(k + 1) * n)) + }) + .collect(); + CommittedMatrix { + domain: Radix2Coset { + log_size: p3_util::log2_strict_usize(n), + shift: multi_stark::traits::Algebra::::ONE, + }, + columns, + } + }) + .collect(); + let commitment = KzgCommitment( + matrices.iter().map(|m| self.commit_columns(&m.columns)).collect(), + matrices + .iter() + .map(|m| { + let shift = self.verifier.max_len() - m.domain.size(); + if shift == 0 { + return vec![]; + } + let points: Vec<_> = + m.columns.iter().map(|c| self.commit_column(c, shift)).collect(); + G1Projective::normalize_batch(&points) + }) + .collect(), + ); + (commitment.clone(), KzgProverData { matrices, commitment }) + } + + fn get_evaluations_on_domain( + &self, + data: &KzgProverData, + idx: usize, + domain: Radix2Coset, + ) -> RowMajorMatrix { + self.evaluate_columns( + data, + idx, + domain, + &(0..data.matrices[idx].columns.len()).collect::>(), + ) + } + + fn open( + &self, + rounds: OpeningRounds<'_, KzgProverData, Scalar>, + challenger: &mut Blake3Transcript, + ) -> (OpenedValues, KzgProof) { + for (data, _) in &rounds { + challenger.observe_commitment(data.commitment.clone()); + for m in &data.matrices { + challenger.observe_canonical(&(m.domain.log_size as u64)); + } + } + let _degree_challenge = challenger.sample_challenge(); + // Pass 1: evaluate everything, observing values in traversal + // order, and batch (polynomial, value) pairs per distinct point. + struct PointBatch<'a> { + z: Fr, + entries: Vec<(&'a Column, Fr)>, + } + let mut batches: Vec> = Vec::new(); + let mut opened: OpenedValues = Vec::new(); + for (data, points_per_matrix) in &rounds { + debug_assert_eq!(data.matrices.len(), points_per_matrix.len()); + let mut round_values = Vec::new(); + for (index, (matrix, points)) in + data.matrices.iter().zip(points_per_matrix).enumerate() + { + if !points.is_empty() { + assert_eq!( + matrix.columns.len(), + data.commitment.0[index].len(), + "missing polynomial data for requested openings" + ); + } + let mut matrix_values = + vec![vec![Scalar(Fr::ZERO); matrix.columns.len()]; points.len()]; + // Decode a disk column once for all requested points. + if !points.is_empty() { + for (column_index, column) in matrix.columns.iter().enumerate() { + if let Column::Constant { value, .. } = column { + for row in &mut matrix_values { + row[column_index] = Scalar(*value); + } + continue; + } + column + .visit(|start, chunk| { + for (&z, row) in points.iter().zip(&mut matrix_values) { + row[column_index].0 += + eval_poly(chunk, z.0) * z.0.pow([start as u64]); + } + }) + .expect("coefficient checkpoint"); + } + } + for (&z, row) in points.iter().zip(&matrix_values) { + for &value in row { + challenger.observe_challenge(value); + } + let batch = match batches.iter().position(|b| b.z == z.0) { + Some(i) => &mut batches[i], + None => { + batches.push(PointBatch { z: z.0, entries: Vec::new() }); + batches.last_mut().expect("just pushed") + }, + }; + for (column, value) in matrix.columns.iter().zip(row) { + batch.entries.push((column, value.0)); + } + } + round_values.push(matrix_values); + } + opened.push(round_values); + } + + let v = challenger.sample_challenge().0; + + // Fold two points per scan, bounding scratch to two polynomials. + let mut witnesses = Vec::with_capacity(batches.len()); + for group in batches.chunks(2) { + let mut combined: Vec> = group + .iter() + .map(|batch| { + vec![ + Fr::ZERO; + batch.entries.iter().map(|(c, _)| c.len()).max().unwrap_or(0) + ] + }) + .collect(); + let mut columns: Vec<(&Column, [Fr; 2])> = Vec::new(); + let mut indices = std::collections::HashMap::new(); + for (batch_index, batch) in group.iter().enumerate() { + let mut power = Fr::ONE; + for (column, _) in &batch.entries { + let index = + *indices.entry(std::ptr::from_ref(*column)).or_insert_with(|| { + columns.push((column, [Fr::ZERO; 2])); + columns.len() - 1 + }); + columns[index].1[batch_index] += power; + power *= v; + } + } + for (column, weights) in columns { + if let Column::Constant { value, .. } = column { + for (dst, weight) in combined.iter_mut().zip(weights) { + if let Some(first) = dst.first_mut() { + *first += *value * weight; + } + } + continue; + } + column + .visit(|start, chunk| { + if let [a, b] = combined.as_mut_slice() + && weights[0] != Fr::ZERO + && weights[1] != Fr::ZERO + { + a[start..start + chunk.len()] + .par_iter_mut() + .zip(&mut b[start..start + chunk.len()]) + .zip(chunk) + .for_each(|((a, b), c)| { + *a += weights[0] * c; + *b += weights[1] * c; + }); + } else { + for (dst, weight) in combined.iter_mut().zip(weights) { + if weight != Fr::ZERO { + dst[start..start + chunk.len()] + .par_iter_mut() + .zip(chunk) + .for_each(|(a, c)| *a += weight * c); + } + } + } + }) + .expect("coefficient checkpoint"); + } + for (batch, values) in group.iter().zip(combined) { + // Subtracting claimed values only changes the remainder. + witnesses.push(self.msm(÷_by_linear(values, batch.z))); + } + } + let witnesses = G1Projective::normalize_batch(&witnesses); + for w in &witnesses { + challenger.observe_canonical(w); + } + // Mirror the verifier's cross-point batching sample to keep the + // transcripts in lockstep (the prover has no use for r). + let _r = challenger.sample_challenge(); + + (opened, KzgProof(witnesses)) + } + + fn verify( + &self, + rounds: VerifyRounds, + proof: &KzgProof, + challenger: &mut Blake3Transcript, + ) -> Result<(), KzgError> { + for (commitment, matrices) in &rounds { + challenger.observe_commitment(commitment.clone()); + for (domain, _) in matrices { + challenger.observe_canonical(&(domain.log_size as u64)); + } + } + let degree_challenge = challenger.sample_challenge().0; + let mut weight = Fr::ONE; + let mut shifted_sum = G1Projective::zero(); + let mut by_degree = + vec![G1Projective::zero(); self.verifier.degree_keys.len()]; + for (commitment, matrices) in &rounds { + if commitment.0.len() != matrices.len() + || commitment.1.len() != matrices.len() + { + return Err(KzgError::ShapeMismatch); + } + for ((columns, shifted), (domain, _)) in + commitment.0.iter().zip(&commitment.1).zip(matrices) + { + let log = domain.log_size; + if log >= by_degree.len() { + return Err(KzgError::ShapeMismatch); + } + if domain.size() == self.verifier.max_len() { + if !shifted.is_empty() { + return Err(KzgError::ShapeMismatch); + } + } else { + if shifted.len() != columns.len() { + return Err(KzgError::ShapeMismatch); + } + for (&c, &s) in columns.iter().zip(shifted) { + by_degree[log] += c * weight; + shifted_sum += s * weight; + weight *= degree_challenge; + } + } + } + } + let mut g1 = vec![shifted_sum.into_affine()]; + let mut g2 = vec![self.verifier.g2]; + for (sum, key) in by_degree.into_iter().zip(&self.verifier.degree_keys) { + if !sum.is_zero() { + g1.push((-sum).into_affine()); + g2.push(*key); + } + } + if !Bls12_381::multi_pairing(g1, g2).is_zero() { + return Err(KzgError::DegreeBoundFailed); + } + // Mirror `open`'s traversal exactly: observe claimed values and + // batch (commitment, value) pairs per distinct point. + struct PointBatch { + z: Fr, + commitments: Vec, + values: Vec, + } + let mut batches: Vec = Vec::new(); + for (commitment, matrices) in &rounds { + if commitment.0.len() != matrices.len() { + return Err(KzgError::ShapeMismatch); + } + for (column_commits, (_domain, openings)) in + commitment.0.iter().zip(matrices) + { + for (z, values) in openings { + if values.len() != column_commits.len() { + return Err(KzgError::ShapeMismatch); + } + for &value in values { + challenger.observe_challenge(value); + } + let batch = match batches.iter().position(|b| b.z == z.0) { + Some(i) => &mut batches[i], + None => { + batches.push(PointBatch { + z: z.0, + commitments: Vec::new(), + values: Vec::new(), + }); + batches.last_mut().expect("just pushed") + }, + }; + batch.commitments.extend_from_slice(column_commits); + batch.values.extend(values.iter().map(|value| value.0)); + } + } + } + if proof.0.len() != batches.len() { + return Err(KzgError::ShapeMismatch); + } + + let v = challenger.sample_challenge().0; + for w in &proof.0 { + challenger.observe_canonical(w); + } + let r = challenger.sample_challenge().0; + + // Per point z (with witness W and v-powers u): + // e(C_z − y_z·G + z·W, H) = e(W, τH) + // where C_z = Σ uᵢ·Cᵢ and y_z = Σ uᵢ·yᵢ. Cross-batched over + // points with powers of r into one 2-pairing product. + let g = G1Projective::from(self.verifier.g1[0]); + let mut lhs = G1Projective::zero(); + let mut rhs = G1Projective::zero(); + let mut r_power = Fr::ONE; + for (batch, &witness) in batches.iter().zip(&proof.0) { + let mut v_powers = Vec::with_capacity(batch.values.len()); + let mut power = Fr::ONE; + let mut y = Fr::ZERO; + for &value in &batch.values { + v_powers.push(power); + y += power * value; + power *= v; + } + let c = G1Projective::msm(&batch.commitments, &v_powers) + .expect("equal lengths"); + lhs += (c - g * y + witness * batch.z) * r_power; + rhs += witness * r_power; + r_power *= r; + } + let check = Bls12_381::multi_pairing( + [lhs.into_affine(), (-rhs).into_affine()], + [self.verifier.g2, self.verifier.tau_g2], + ); + if check.is_zero() { Ok(()) } else { Err(KzgError::PairingCheckFailed) } + } +} + +const POLYNOMIAL_BLOCK: usize = 1 << 12; + +fn eval_poly_serial(coeffs: &[Fr], z: Fr) -> Fr { + coeffs.iter().rev().fold(Fr::ZERO, |acc, c| acc * z + c) +} + +/// Evaluate coefficient blocks independently, then fold at z^block_size. +fn eval_poly(coeffs: &[Fr], z: Fr) -> Fr { + if coeffs.len() <= POLYNOMIAL_BLOCK { + return eval_poly_serial(coeffs, z); + } + let blocks: Vec<_> = coeffs + .par_chunks(POLYNOMIAL_BLOCK) + .map(|block| eval_poly_serial(block, z)) + .collect(); + eval_poly_serial(&blocks, z.pow([POLYNOMIAL_BLOCK as u64])) +} + +/// Synthetic division, reusing the input allocation. Block boundary carries +/// are suffix evaluations; the second pass runs independently in each block. +fn divide_by_linear(mut p: Vec, z: Fr) -> Vec { + if p.len() <= 1 { + return Vec::new(); + } + let n = p.len() - 1; + if n <= POLYNOMIAL_BLOCK { + let mut carry = Fr::ZERO; + for value in p[1..].iter_mut().rev() { + carry = *value + z * carry; + *value = carry; + } + } else { + let mut boundaries: Vec<_> = p[1..] + .par_chunks(POLYNOMIAL_BLOCK) + .map(|block| eval_poly_serial(block, z)) + .collect(); + let full_power = z.pow([POLYNOMIAL_BLOCK as u64]); + let mut carry = Fr::ZERO; + for (i, boundary) in boundaries.iter_mut().enumerate().rev() { + let size = (n - i * POLYNOMIAL_BLOCK).min(POLYNOMIAL_BLOCK); + let power = if size == POLYNOMIAL_BLOCK { + full_power + } else { + z.pow([size as u64]) + }; + let local = *boundary; + *boundary = carry; + carry = local + power * carry; + } + p[1..].par_chunks_mut(POLYNOMIAL_BLOCK).zip(boundaries).for_each( + |(block, mut carry)| { + for value in block.iter_mut().rev() { + carry = *value + z * carry; + *value = carry; + } + }, + ); + } + p.copy_within(1.., 0); + p.truncate(n); + p +} + +#[cfg(test)] +mod degree_tests { + use super::*; + + #[test] + fn serialized_point_counts_are_bounded_before_allocation() { + let config = bincode::config::standard(); + for bytes in [ + u64::MAX.to_le_bytes().to_vec(), + [1u64.to_le_bytes(), u64::MAX.to_le_bytes()].concat(), + ] { + let encoded = bincode::serde::encode_to_vec(bytes, config).unwrap(); + assert!( + bincode::serde::decode_from_slice::(&encoded, config) + .is_err() + ); + assert!( + bincode::serde::decode_from_slice::(&encoded, config) + .is_err() + ); + } + } + + #[test] + fn constant_coefficients_match_dense_commitments_evaluations_and_openings() { + use multi_stark::traits::Algebra; + let pcs = + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(32, b"constant-columns")), 2); + let domain = pcs.natural_domain_for_degree(16); + let (commitment, compact) = pcs.commit(vec![( + domain, + RowMajorMatrix::new( + (0..16) + .flat_map(|_| [Scalar::ZERO, Scalar::from_u8(7), Scalar::ONE]) + .collect(), + 3, + ), + )]); + assert!( + compact.matrices[0] + .columns + .iter() + .all(|c| matches!(c, Column::Constant { .. })) + ); + let columns: Vec = compact.matrices[0] + .columns + .iter() + .map(|c| Column::Resident { + values: Arc::new(c.load().unwrap().into_owned()), + range: 0..c.len(), + }) + .collect(); + assert_eq!(pcs.commit_columns(&columns), commitment.0[0]); + let shifted: Vec<_> = + columns.iter().map(|c| pcs.commit_column(c, 16)).collect(); + assert_eq!(G1Projective::normalize_batch(&shifted), commitment.1[0]); + let dense = KzgProverData { + commitment, + matrices: vec![CommittedMatrix { domain, columns }], + }; + for evaluation_domain in [domain, domain.create_disjoint_domain(32)] { + assert_eq!( + pcs.get_evaluations_on_domain(&compact, 0, evaluation_domain), + pcs.get_evaluations_on_domain(&dense, 0, evaluation_domain) + ); + } + let points = vec![vec![Scalar::from_u8(13), Scalar::from_u8(19)]]; + assert_eq!( + pcs.open(vec![(&compact, points.clone())], &mut Blake3Transcript::new()), + pcs.open(vec![(&dense, points)], &mut Blake3Transcript::new()) + ); + } + + #[test] + fn disk_coefficients_match_resident_and_detect_changes() { + use std::io::{Seek, SeekFrom, Write}; + let path = std::env::temp_dir().join(format!( + "kzg-columns-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut file = std::fs::File::create_new(&path).unwrap(); + let pcs = + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(1 << 17, b"disk-columns")), 2); + let domain = pcs.natural_domain_for_degree(1 << 17); + let (_, resident) = pcs.commit(vec![( + domain, + RowMajorMatrix::new_col( + (0..domain.size()).map(Scalar::from_usize).collect(), + ), + )]); + let mut bytes = Vec::new(); + resident.write_checkpoint(&mut bytes).unwrap(); + file.write_all(&bytes).unwrap(); + let disk = KzgProverData::read_checkpoint_file(&path).unwrap(); + let mut roundtrip = Vec::new(); + disk.write_checkpoint(&mut roundtrip).unwrap(); + assert_eq!(bytes, roundtrip); + for shift in [Scalar::ONE, Scalar::from_u8(7)] { + let coset = Radix2Coset { shift, ..domain }; + assert_eq!( + pcs.get_evaluations_on_domain(&disk, 0, coset), + pcs.get_evaluations_on_domain(&resident, 0, coset) + ); + } + let points = vec![vec![ + Scalar::from_u8(13), + Scalar::from_u8(19), + Scalar::from_u8(13), + Scalar::from_u8(23), + Scalar::from_u8(29), + ]]; + let expected = + pcs.open(vec![(&resident, points.clone())], &mut Blake3Transcript::new()); + let actual = + pcs.open(vec![(&disk, points.clone())], &mut Blake3Transcript::new()); + pcs + .verify( + vec![( + resident.commitment.clone(), + vec![( + domain, + points[0].iter().copied().zip(actual.0[0][0].clone()).collect(), + )], + )], + &actual.1, + &mut Blake3Transcript::new(), + ) + .unwrap(); + assert_eq!(actual, expected); + file.seek(SeekFrom::End(-32)).unwrap(); + file.write_all(&[0; 32]).unwrap(); + assert!(disk.matrices[0].columns[0].load().is_err()); + file.set_len(0).unwrap(); + assert!(disk.matrices[0].columns[0].load().is_err()); + assert!(KzgProverData::read_checkpoint_file(&path).is_err()); + drop(file); + drop(disk); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn blocked_opening_arithmetic_matches_horner_and_synthetic_division() { + for n in [ + 0, + 1, + 2, + POLYNOMIAL_BLOCK, + POLYNOMIAL_BLOCK + 1, + 2 * POLYNOMIAL_BLOCK + 3, + ] { + let p: Vec<_> = (0..n).map(|i| Fr::from((i * i + 7) as u64)).collect(); + for z in [Fr::ZERO, Fr::ONE, -Fr::ONE, Fr::from(13u64)] { + assert_eq!(eval_poly(&p, z), eval_poly_serial(&p, z)); + let mut expected = vec![Fr::ZERO; n.saturating_sub(1)]; + let mut carry = Fr::ZERO; + for j in (1..n).rev() { + carry = p[j] + z * carry; + expected[j - 1] = carry; + } + assert_eq!(divide_by_linear(p.clone(), z), expected); + } + } + } + + use multi_stark::traits::Algebra; + use multi_stark::traits::Field; + + #[test] + fn chunked_commitment_matches_native_msm_with_shift_and_tail() { + let pcs = + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(1 << 18, b"chunked-msm")), 4); + let mut value = Fr::from(17u64); + let coefficients: Vec<_> = (0..(2 * (1 << 16) + 3)) + .map(|_| { + value = value.square() + Fr::ONE; + value + }) + .collect(); + for shift in [0, 7] { + let expected = G1Projective::msm( + &pcs.prover_srs().g1[shift..shift + coefficients.len()], + &coefficients, + ) + .unwrap(); + assert_eq!(pcs.msm_at(&coefficients, shift), expected); + } + } + + #[test] + fn mixed_degree_openings_enforce_every_bound() { + let pcs = + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(16, b"degree-test")), 4); + let domains = + [pcs.natural_domain_for_degree(2), pcs.natural_domain_for_degree(16)]; + let (commitment, data) = pcs.commit( + domains + .into_iter() + .map(|d| { + (d, RowMajorMatrix::new_col(vec![Scalar::from_u8(7); d.size()])) + }) + .collect(), + ); + let z = Scalar::from_u8(19); + let (values, proof) = pcs.open( + vec![(&data, vec![vec![z], vec![z]])], + &mut Blake3Transcript::new(), + ); + let rounds = vec![( + commitment.clone(), + domains + .into_iter() + .zip(values[0].iter()) + .map(|(d, v)| (d, vec![(z, v[0].clone())])) + .collect(), + )]; + pcs.verify(rounds.clone(), &proof, &mut Blake3Transcript::new()).unwrap(); + let verifier = KzgPcs::verifier_only(pcs.verifier.clone(), 4); + assert!(verifier.srs.is_none()); + verifier + .verify(rounds.clone(), &proof, &mut Blake3Transcript::new()) + .unwrap(); + let mut missing = rounds.clone(); + missing[0].0.1[0].clear(); + assert!( + verifier.verify(missing, &proof, &mut Blake3Transcript::new()).is_err() + ); + let mut corrupt = rounds; + corrupt[0].0.1[0][0] = pcs.prover_srs().g1[0]; + assert!(matches!( + verifier.verify(corrupt, &proof, &mut Blake3Transcript::new()), + Err(KzgError::DegreeBoundFailed) + )); + + // A valid KZG opening for a polynomial too large for its declared + // two-row domain must be rejected, even though it fits the global SRS. + let columns = vec![Column::from(vec![Fr::ZERO, Fr::ZERO, Fr::ONE])]; + let commitment = KzgCommitment( + pcs.commit_columns(&columns).into_iter().map(|c| vec![c]).collect(), + vec![vec![pcs.prover_srs().g1[0]]], + ); + let data = KzgProverData { + commitment: commitment.clone(), + matrices: vec![CommittedMatrix { domain: domains[0], columns }], + }; + let (values, proof) = + pcs.open(vec![(&data, vec![vec![z]])], &mut Blake3Transcript::new()); + assert!(matches!( + verifier.verify( + vec![( + commitment, + vec![(domains[0], vec![(z, values[0][0][0].clone())])] + )], + &proof, + &mut Blake3Transcript::new() + ), + Err(KzgError::DegreeBoundFailed) + )); + } +} diff --git a/crates/proof-compression/src/kzg/quotient.rs b/crates/proof-compression/src/kzg/quotient.rs new file mode 100644 index 000000000..bc8dca44e --- /dev/null +++ b/crates/proof-compression/src/kzg/quotient.rs @@ -0,0 +1,123 @@ +//! Scalar quotient evaluation with bounded selectors and reusable row scratch. +use p3_matrix::dense::RowMajorMatrix; +use p3_maybe_rayon::prelude::*; + +use super::KzgConfig; +use super::Radix2Coset; +use super::Scalar; +use multi_stark::config::QuotientCommitInput; +use multi_stark::eval::VarValues; +use multi_stark::traits::Algebra; +use multi_stark::traits::EvaluationDomain; +use multi_stark::traits::Field; +use multi_stark::traits::batch_inverse; + +#[allow(clippy::too_many_arguments)] +pub(super) fn evaluate_coset( + input: &QuotientCommitInput<'_, KzgConfig>, + pass: &super::quotient_plan::Pass, + domain: Radix2Coset, + fixed: Option<&RowMajorMatrix>, + main: &RowMajorMatrix, + stage2: &RowMajorMatrix, + alpha: Scalar, + coset: usize, + ratio: usize, + output: &mut [Scalar], +) { + let n = input.trace_domain.size(); + assert_eq!(input.trace_domain.shift, Scalar::ONE); + assert_eq!(domain.size(), n); + assert_eq!(output.len(), n * ratio); + assert!(coset < ratio); + let g = domain.generator(); + let last = input.trace_domain.generator().inverse(); + let vanishing = + domain.shift.exp_power_of_2(input.trace_domain.log_size) - Scalar::ONE; + let inv_vanishing = vanishing.inverse(); + let delta = (input.lookup_publics[3] - input.lookup_publics[2]) + * (Scalar::from_usize(n) * input.trace_domain.generator()).inverse(); + let mut powers: Vec<_> = + alpha.powers().take(input.constraint_count).collect(); + powers.reverse(); + let circuit = input.circuit; + let ext = multi_stark::system::extension_params::(); + const BLOCK: usize = 1 << 12; + output.par_chunks_mut(BLOCK * ratio).enumerate().for_each(|(block, out)| { + let start = block * BLOCK; + let rows = out.len() / ratio; + let x0 = domain.shift * g.exp_u64(start as u64); + let first_denoms: Vec<_> = + g.powers().take(rows).map(|p| x0 * p - Scalar::ONE).collect(); + let first_inv = batch_inverse(&first_denoms); + drop(first_denoms); + let transition: Vec<_> = + g.powers().take(rows).map(|p| x0 * p - last).collect(); + let last_inv = batch_inverse(&transition); + let mut graph = Vec::with_capacity(pass.graph.nodes.len()); + let mut constraints = Vec::with_capacity(input.constraint_count); + for local in 0..rows { + let row = start + local; + let next = (row + 1) % n; + let row_values = |matrix: &RowMajorMatrix, r| r * matrix.width; + let main_at = row_values(main, row); + let main_next = row_values(main, next); + let stage2_at = row_values(stage2, row); + let stage2_next = row_values(stage2, next); + let preprocessed = fixed.map_or([&[][..], &[][..]], |m| { + [ + &m.values[row * m.width..(row + 1) * m.width], + &m.values[next * m.width..(next + 1) * m.width], + ] + }); + let is_last_row = vanishing * last_inv[local]; + let view = VarValues { + preprocessed, + main: [ + &main.values[main_at..main_at + main.width], + &main.values[main_next..main_next + main.width], + ], + stage2: [ + &stage2.values[stage2_at..stage2_at + stage2.width], + &stage2.values[stage2_next..stage2_next + stage2.width], + ], + publics: &input.lookup_publics, + is_first_row: vanishing * first_inv[local], + is_last_row, + is_transition: transition[local], + }; + pass.graph.sweep(&view, &mut graph); + constraints.clear(); + let mut value = pass + .graph + .zeros + .iter() + .zip(&pass.weights) + .fold(Scalar::ZERO, |acc, (id, &weight)| { + acc + graph[id.index()] * powers[weight] + }); + for (group, lookups) in &pass.groups { + let last = *group + 1 == stage2.width; + let target = + if last { view.stage2[1][0] } else { view.stage2[0][group + 1] }; + constraints.clear(); + multi_stark::lookup::logup_constraint_values( + lookups, + &graph, + &[view.stage2[0][*group]], + &[target], + &input.lookup_publics, + &[if last { delta } else { Scalar::ZERO }], + is_last_row, + ext.w, + ext.degree, + circuit.lookup_group_size.max(1), + &mut constraints, + ); + debug_assert_eq!(constraints.len(), 1); + value += constraints[0] * powers[circuit.graph.zeros.len() + group]; + } + out[local * ratio + coset] += value * inv_vanishing; + } + }); +} diff --git a/crates/proof-compression/src/kzg/quotient_plan.rs b/crates/proof-compression/src/kzg/quotient_plan.rs new file mode 100644 index 000000000..15dd5238e --- /dev/null +++ b/crates/proof-compression/src/kzg/quotient_plan.rs @@ -0,0 +1,190 @@ +//! Split quotient constraints by their fixed-column dependencies. +use std::collections::BTreeSet; + +use super::Scalar; +use multi_stark::expr::Source; +use multi_stark::graph::ConstraintGraph; +use multi_stark::graph::Node; +use multi_stark::graph::NodeId; +use multi_stark::lookup::Lookup; + +pub(super) struct Pass { + pub fixed: Vec, + pub graph: ConstraintGraph, + pub weights: Vec, + pub groups: Vec<(usize, Vec>)>, +} + +fn mark(graph: &ConstraintGraph, roots: &[NodeId]) -> Vec { + let mut used = vec![false; graph.nodes.len()]; + for root in roots { + used[root.index()] = true; + } + for i in (0..used.len()).rev() { + if used[i] { + match graph.nodes[i] { + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { + used[a.index()] = true; + used[b.index()] = true; + }, + Node::Neg(a) => used[a.index()] = true, + _ => {}, + } + } + } + used +} + +pub(super) fn plan( + graph: &ConstraintGraph, + group_size: usize, +) -> Vec { + let groups = graph.lookups.len().div_ceil(group_size).max(1); + let roots: Vec> = graph + .zeros + .iter() + .map(|id| vec![*id]) + .chain((0..groups).map(|g| { + graph + .lookups + .iter() + .skip(g * group_size) + .take(group_size) + .flat_map(|l| { + std::iter::once(l.multiplicity).chain(l.args.iter().copied()) + }) + .collect() + })) + .collect(); + let mut batches: Vec<(BTreeSet, Vec)> = Vec::new(); + for (index, root) in roots.iter().enumerate() { + let used = mark(graph, root); + let fixed: BTreeSet<_> = graph + .nodes + .iter() + .zip(used) + .filter_map(|(node, used)| match node { + Node::Var(col) if used && col.source == Source::Preprocessed => { + Some(col.index as usize) + }, + _ => None, + }) + .collect(); + // A single constraint may exceed the budget; it is never split or omitted. + if let Some((columns, indices)) = + batches.iter_mut().find(|(columns, _)| columns.union(&fixed).count() <= 8) + { + columns.extend(fixed); + indices.push(index); + } else { + batches.push((fixed, vec![index])); + } + } + batches + .into_iter() + .map(|(fixed, indices)| { + let fixed: Vec<_> = fixed.into_iter().collect(); + let used = mark( + graph, + &indices + .iter() + .flat_map(|i| roots[*i].iter().copied()) + .collect::>(), + ); + let mut remap = vec![NodeId(u32::MAX); graph.nodes.len()]; + let mut nodes = Vec::new(); + let mut degrees = Vec::new(); + for (i, node) in graph.nodes.iter().copied().enumerate() { + if !used[i] { + continue; + } + let id = |id: NodeId| remap[id.index()]; + let node = match node { + Node::Add(a, b) => Node::Add(id(a), id(b)), + Node::Sub(a, b) => Node::Sub(id(a), id(b)), + Node::Mul(a, b) => Node::Mul(id(a), id(b)), + Node::Neg(a) => Node::Neg(id(a)), + Node::Var(mut col) if col.source == Source::Preprocessed => { + col.index = fixed + .binary_search(&(col.index as usize)) + .unwrap() + .try_into() + .unwrap(); + Node::Var(col) + }, + other => other, + }; + remap[i] = NodeId(nodes.len().try_into().unwrap()); + nodes.push(node); + degrees.push(graph.degrees[i]); + } + let mut zeros = Vec::new(); + let mut weights = Vec::new(); + let mut selected_groups = Vec::new(); + for index in indices { + if index < graph.zeros.len() { + zeros.push(remap[graph.zeros[index].index()]); + weights.push(index); + } else { + let g = index - graph.zeros.len(); + selected_groups.push(( + g, + graph + .lookups + .iter() + .skip(g * group_size) + .take(group_size) + .map(|l| Lookup { + multiplicity: remap[l.multiplicity.index()], + args: l.args.iter().map(|id| remap[id.index()]).collect(), + }) + .collect(), + )); + } + } + Pass { + fixed, + weights, + groups: selected_groups, + graph: ConstraintGraph { + lookup_prefix_len: nodes.len(), + nodes, + degrees, + zeros, + lookups: vec![], + max_constraint_degree: graph.max_constraint_degree, + }, + } + }) + .collect() +} + +pub(super) fn lookup_graph( + graph: &ConstraintGraph, +) -> (Vec, ConstraintGraph) { + let mut graph = graph.clone(); + graph.nodes.truncate(graph.lookup_prefix_len); + graph.degrees.truncate(graph.lookup_prefix_len); + graph.zeros.clear(); + let fixed: Vec<_> = graph + .nodes + .iter() + .filter_map(|node| match node { + Node::Var(col) if col.source == Source::Preprocessed => { + Some(col.index as usize) + }, + _ => None, + }) + .collect::>() + .into_iter() + .collect(); + for node in &mut graph.nodes { + if let Node::Var(col) = node + && col.source == Source::Preprocessed + { + col.index = + fixed.binary_search(&(col.index as usize)).unwrap().try_into().unwrap(); + } + } + (fixed, graph) +} diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs new file mode 100644 index 000000000..f4778fff2 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs.rs @@ -0,0 +1,348 @@ +//! KZG parameters: G1 powers, G2 anchors and G2 powers for degree checks. +//! Imported parameters need validated point decoding, [`Srs::validate`] and +//! trusted provenance. Consistency does not establish an unknown trapdoor. +//! The full available G1 degree range and every required G2 degree key must +//! be represented; truncating public parameters does not reduce that range. +//! [`Srs::unsafe_dev_setup`] reveals its trapdoor and is only for tests. + +use ark_bls12_381::{ + Bls12_381, Fr, G1Affine, G1Projective, G2Affine, G2Projective, +}; +use ark_ec::{ + AffineRepr, CurveGroup, PrimeGroup, VariableBaseMSM, pairing::Pairing, +}; +use ark_ff::{Field, PrimeField, Zero}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_maybe_rayon::prelude::*; +use std::io::{self, Read, Write}; + +/// `[G, τG, τ²G, …]` in G1 and `[H, τH]` in G2. +pub struct Srs { + pub g1: Vec, + pub g2: G2Affine, + pub tau_g2: G2Affine, + /// Entry k is τ^(max_len - 2^k) H, for degree-bound checks. + pub degree_keys: Vec, +} + +impl Srs { + /// Read IXKZGS01: magic, little-endian u32 log-length, G1 powers, + /// G2, tau-G2, and log-length + 1 degree keys (compressed Ark points). + /// Validates point encodings, subgroups and the complete powers relation. + /// The caller must independently authenticate the source of these parameters. + pub fn from_reader(mut reader: impl Read) -> io::Result { + let invalid = |e| io::Error::new(io::ErrorKind::InvalidData, e); + let mut magic = [0; 8]; + reader.read_exact(&mut magic)?; + if &magic != b"IXKZGS01" { + return Err(invalid("unsupported KZG SRS format")); + } + let mut log = [0; 4]; + reader.read_exact(&mut log)?; + let log = u32::from_le_bytes(log); + // BLS12-381's scalar field has two-adicity 32. Leave room for the + // quotient domain and reject oversized headers before allocating. + if !(1..=30).contains(&log) { + return Err(invalid("unsupported KZG SRS log-length")); + } + let decode_error = |e| io::Error::new(io::ErrorKind::InvalidData, e); + // Grow only as points are read, so a truncated header cannot force a + // large allocation. Deserialization performs subgroup checks. + let mut g1 = Vec::new(); + const CHUNK: usize = 1 << 16; + let len = 1usize << log; + let mut bytes = vec![0; 48 * CHUNK.min(len)]; + while g1.len() < len { + let count = CHUNK.min(len - g1.len()); + reader.read_exact(&mut bytes[..48 * count])?; + let points = bytes[..48 * count] + .par_chunks_exact(48) + .map(G1Affine::deserialize_compressed) + .collect::, _>>() + .map_err(decode_error)?; + g1.extend(points); + } + let g2 = + G2Affine::deserialize_compressed(&mut reader).map_err(decode_error)?; + let tau_g2 = + G2Affine::deserialize_compressed(&mut reader).map_err(decode_error)?; + let mut degree_keys = Vec::new(); + for _ in 0..=log { + degree_keys.push( + G2Affine::deserialize_compressed(&mut reader).map_err(decode_error)?, + ); + } + if reader.read(&mut [0])? != 0 { + return Err(invalid("trailing KZG SRS data")); + } + let srs = Self { g1, g2, tau_g2, degree_keys }; + srs.validate().map_err(invalid)?; + Ok(srs) + } + + /// Export parameters in the format consumed by [`Self::from_reader`]. + pub fn write(&self, mut writer: impl Write) -> io::Result<()> { + if self.max_len() < 2 + || !self.max_len().is_power_of_two() + || self.max_len().ilog2() > 30 + || self.degree_keys.len() != self.max_len().ilog2() as usize + 1 + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid SRS dimensions", + )); + } + writer.write_all(b"IXKZGS01")?; + writer.write_all(&self.max_len().ilog2().to_le_bytes())?; + let encode_error = |e| io::Error::new(io::ErrorKind::InvalidData, e); + for point in &self.g1 { + point.serialize_compressed(&mut writer).map_err(encode_error)?; + } + self.g2.serialize_compressed(&mut writer).map_err(encode_error)?; + self.tau_g2.serialize_compressed(&mut writer).map_err(encode_error)?; + for point in &self.degree_keys { + point.serialize_compressed(&mut writer).map_err(encode_error)?; + } + Ok(()) + } + + /// The largest polynomial length (degree + 1) this SRS can commit. + #[inline] + pub fn max_len(&self) -> usize { + self.g1.len() + } + + /// Check nonzero anchors, degree keys and the powers relation + /// `e(g1[i+1], H) = e(g1[i], τH)`, batched with a challenge derived + /// from the SRS bytes. Points must already have passed validated + /// deserialization, including subgroup checks. This does not establish + /// trusted provenance or an unknown trapdoor. + pub fn validate(&self) -> Result<(), &'static str> { + if self.g1.len() < 2 || !self.g1.len().is_power_of_two() { + return Err("SRS length must be a power of two >= 2"); + } + if self.g1[0].is_zero() + || self.g1[1].is_zero() + || self.g2.is_zero() + || self.tau_g2.is_zero() + { + return Err("SRS anchor is the identity"); + } + let logs = p3_util::log2_strict_usize(self.max_len()); + if self.degree_keys.len() != logs + 1 { + return Err("missing degree keys"); + } + for (k, key) in self.degree_keys.iter().enumerate() { + let shift = self.max_len() - (1 << k); + if !Bls12_381::multi_pairing( + [self.g1[shift], (-G1Projective::from(self.g1[0])).into_affine()], + [self.g2, *key], + ) + .is_zero() + { + return Err("inconsistent degree key"); + } + } + + // Preserve the validation challenge's exact encoding without retaining a + // second, serialized copy of all powers (12 GiB for a 2^28 SRS). + struct HashWriter(blake3::Hasher); + impl Write for HashWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.update(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = HashWriter(blake3::Hasher::new()); + writer.0.update(b"multi-stark/kzg/srs-validate"); + self.g1.serialize_compressed(&mut writer).expect("infallible hash writer"); + self.g2.serialize_compressed(&mut writer).expect("infallible hash writer"); + self + .tau_g2 + .serialize_compressed(&mut writer) + .expect("infallible hash writer"); + let mut wide = [0u8; 64]; + writer.0.finalize_xof().fill(&mut wide); + let r = Fr::from_le_bytes_mod_order(&wide); + + // Bound both powers and MSM digit/bucket scratch independently of SRS + // length. All adjacent pairs still participate with their original weight. + const CHUNK: usize = 1 << 16; + let concurrency = current_num_threads().div_ceil(8).min(4); + let mut low = G1Projective::zero(); + let mut high = G1Projective::zero(); + for start in (0..self.g1.len() - 1).step_by(CHUNK * concurrency) { + let end = (start + CHUNK * concurrency).min(self.g1.len() - 1); + let parts: Vec<_> = self.g1[start..end] + .par_chunks(CHUNK) + .enumerate() + .map(|(index, points)| { + let offset = start + index * CHUNK; + let mut acc = r.pow([offset as u64]); + let powers: Vec<_> = (0..points.len()) + .map(|_| { + let value = acc; + acc *= r; + value + }) + .collect(); + ( + G1Projective::msm(points, &powers).expect("equal lengths"), + G1Projective::msm( + &self.g1[offset + 1..offset + 1 + points.len()], + &powers, + ) + .expect("equal lengths"), + ) + }) + .collect(); + for (a, b) in parts { + low += a; + high += b; + } + } + // e(high, H) = e(low, τH) ⇔ e(high, H)·e(−low, τH) = 1. + let check = Bls12_381::multi_pairing( + [high.into_affine(), (-low).into_affine()], + [self.g2, self.tau_g2], + ); + if check.is_zero() { + Ok(()) + } else { + Err("G1 powers are not one τ-progression against the G2 pair") + } + } + + /// A deterministic SRS with τ derived from `seed`. TESTS AND + /// DEVELOPMENT ONLY: τ is recoverable, so commitments under this + /// SRS are not binding against anyone who knows the seed. + pub fn unsafe_dev_setup(max_len: usize, seed: &[u8]) -> Self { + assert!(max_len >= 2 && max_len.is_power_of_two()); + let mut wide = [0u8; 64]; + blake3::Hasher::new() + .update(b"multi-stark/kzg/dev-srs") + .update(seed) + .finalize_xof() + .fill(&mut wide); + let tau = Fr::from_le_bytes_mod_order(&wide); + + let g1_gen = G1Projective::generator(); + // τ-powers in independent chunks (each chunk seeds itself with + // τ^start), so the scalar multiplications parallelize under the + // `parallel` feature; serial otherwise. + let chunk = 1usize << 14; + let mut g1 = vec![G1Affine::identity(); max_len]; + g1.par_chunks_mut(chunk).enumerate().for_each(|(i, dst)| { + let mut acc = tau.pow([(i * chunk) as u64]); + let powers: Vec<_> = (0..dst.len()) + .map(|_| { + let point = g1_gen * acc; + acc *= tau; + point + }) + .collect(); + dst.copy_from_slice(&G1Projective::normalize_batch(&powers)); + }); + let g2_gen = G2Projective::generator(); + Self { + g1, + g2: g2_gen.into_affine(), + tau_g2: (g2_gen * tau).into_affine(), + degree_keys: (0..=p3_util::log2_strict_usize(max_len)) + .map(|k| { + (g2_gen * tau.pow([(max_len - (1 << k)) as u64])).into_affine() + }) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srs_transport_rejects_malformed_and_inconsistent_parameters() { + let mut srs = Srs::unsafe_dev_setup(32, b"srs-transport"); + let mut bytes = Vec::new(); + srs.write(&mut bytes).unwrap(); + let decoded = Srs::from_reader(bytes.as_slice()).unwrap(); + assert_eq!(decoded.g1, srs.g1); + assert_eq!(decoded.degree_keys, srs.degree_keys); + assert!(Srs::from_reader(&bytes[..bytes.len() - 1]).is_err()); + let mut trailing = bytes.clone(); + trailing.push(0); + assert!(Srs::from_reader(trailing.as_slice()).is_err()); + bytes[8..12].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(Srs::from_reader(bytes.as_slice()).is_err()); + bytes[8..12].copy_from_slice(&30u32.to_le_bytes()); + assert!(Srs::from_reader(&bytes[..12]).is_err()); + srs.g1[7] = srs.g1[8]; + bytes.clear(); + srs.write(&mut bytes).unwrap(); + assert!(Srs::from_reader(bytes.as_slice()).is_err()); + } + + #[test] + fn development_powers_match_naive_at_chunk_boundaries() { + let seed = b"chunk-powers"; + let srs = Srs::unsafe_dev_setup(1 << 16, seed); + let mut wide = [0u8; 64]; + blake3::Hasher::new() + .update(b"multi-stark/kzg/dev-srs") + .update(seed) + .finalize_xof() + .fill(&mut wide); + let tau = Fr::from_le_bytes_mod_order(&wide); + for i in [0, 1, (1 << 14) - 1, 1 << 14, (1 << 15) - 1, (1 << 16) - 1] { + assert_eq!( + srs.g1[i], + (G1Projective::generator() * tau.pow([i as u64])).into_affine() + ); + } + } + + #[test] + fn dev_setup_validates() { + Srs::unsafe_dev_setup(1 << 5, b"validate-test").validate().unwrap(); + } + + #[test] + fn validation_and_transport_cover_chunk_boundaries() { + let mut srs = Srs::unsafe_dev_setup(1 << 17, b"validation-chunks"); + let mut bytes = Vec::new(); + srs.write(&mut bytes).unwrap(); + let decoded = Srs::from_reader(bytes.as_slice()).unwrap(); + assert_eq!(decoded.g1, srs.g1); + for index in [(1 << 16) - 1, 1 << 16, (1 << 16) + 1, (1 << 17) - 2] { + let saved = srs.g1[index]; + srs.g1[index] = srs.g1[index + 1]; + assert!(srs.validate().is_err(), "accepted changed power {index}"); + srs.g1[index] = saved; + } + let mut malformed = bytes.clone(); + let point = 12 + 48 * (1 << 16); + malformed[point..point + 48].fill(0xff); + assert!(Srs::from_reader(malformed.as_slice()).is_err()); + assert!(Srs::from_reader(&bytes[..point + 47]).is_err()); + } + + #[test] + fn corrupted_power_rejected() { + let mut srs = Srs::unsafe_dev_setup(1 << 5, b"validate-test"); + srs.g1[7] = + (G1Projective::from(srs.g1[7]) + G1Projective::generator()).into_affine(); + assert!(srs.validate().is_err()); + } + + #[test] + fn mismatched_tau_g2_rejected() { + let mut srs = Srs::unsafe_dev_setup(1 << 5, b"validate-test"); + srs.tau_g2 = (G2Projective::from(srs.tau_g2) + G2Projective::generator()) + .into_affine(); + assert!(srs.validate().is_err()); + } +} diff --git a/crates/proof-compression/src/kzg/transcript.rs b/crates/proof-compression/src/kzg/transcript.rs new file mode 100644 index 000000000..09c783b96 --- /dev/null +++ b/crates/proof-compression/src/kzg/transcript.rs @@ -0,0 +1,125 @@ +//! BLAKE3 Fiat-Shamir transcript. Each sample hashes the chain state and +//! buffered canonical observations, reduces 64 XOF bytes into the scalar +//! field, takes 32 more bytes as the next state, and clears the buffer. + +use ark_bls12_381::Fr; +use ark_ff::PrimeField; +use ark_serialize::CanonicalSerialize; + +use multi_stark::traits::Transcript; + +use super::field::Scalar; +use super::pcs::KzgCommitment; + +/// See the module docs. +#[derive(Clone)] +pub struct Blake3Transcript { + state: [u8; 32], + buffer: Vec, +} + +impl Blake3Transcript { + /// A fresh transcript. Domain separation and parameter binding are + /// the configuration's job (it observes a seed right away). + pub fn new() -> Self { + Self { state: [0; 32], buffer: Vec::new() } + } + + pub fn observe_bytes(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); + } + + /// Absorb a canonically-serializable arkworks value (compressed). + pub fn observe_canonical(&mut self, value: &impl CanonicalSerialize) { + value + .serialize_compressed(&mut self.buffer) + .expect("serialization into a Vec cannot fail"); + } + + fn squeeze(&mut self) -> Fr { + let mut hasher = blake3::Hasher::new(); + hasher.update(&self.state); + hasher.update(&self.buffer); + self.buffer.clear(); + let mut output = hasher.finalize_xof(); + let mut wide = [0u8; 64]; + output.fill(&mut wide); + output.fill(&mut self.state); + Fr::from_le_bytes_mod_order(&wide) + } +} + +impl Default for Blake3Transcript { + fn default() -> Self { + Self::new() + } +} + +impl Transcript for Blake3Transcript { + type F = Scalar; + type Challenge = Scalar; + type Commitment = KzgCommitment; + + fn observe_field(&mut self, x: Scalar) { + self.observe_canonical(&x.0); + } + + fn observe_field_slice(&mut self, xs: &[Scalar]) { + for x in xs { + self.observe_field(*x); + } + } + + fn observe_challenge(&mut self, x: Scalar) { + self.observe_field(x); + } + + fn observe_commitment(&mut self, c: KzgCommitment) { + self.observe_canonical(&c.0); + self.observe_canonical(&c.1); + } + + fn sample_challenge(&mut self) -> Scalar { + Scalar(self.squeeze()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::traits::Field; + + #[test] + fn deterministic_and_sensitive() { + let run = |values: &[u64]| { + let mut t = Blake3Transcript::new(); + for &v in values { + t.observe_field(Scalar::from_u64(v)); + } + t.sample_challenge() + }; + assert_eq!(run(&[1, 2, 3]), run(&[1, 2, 3])); + assert_ne!(run(&[1, 2, 3]), run(&[1, 2, 4])); + assert_ne!(run(&[1, 2, 3]), run(&[1, 2])); + } + + #[test] + fn consecutive_samples_differ() { + let mut t = Blake3Transcript::new(); + t.observe_field(Scalar::from_u64(7)); + assert_ne!(t.sample_challenge(), t.sample_challenge()); + } + + /// Slice observation must equal element-wise observation (trait + /// contract). + #[test] + fn slice_observation_flat() { + let xs = [Scalar::from_u64(5), Scalar::from_u64(6)]; + let mut a = Blake3Transcript::new(); + a.observe_field_slice(&xs); + let mut b = Blake3Transcript::new(); + b.observe_field(xs[0]); + b.observe_field(xs[1]); + assert_eq!(a.sample_challenge(), b.sample_challenge()); + } +} diff --git a/crates/proof-compression/src/kzg/verifier_params.rs b/crates/proof-compression/src/kzg/verifier_params.rs new file mode 100644 index 000000000..11de892cc --- /dev/null +++ b/crates/proof-compression/src/kzg/verifier_params.rs @@ -0,0 +1,108 @@ +//! Public verifier parameters, with the original SRS degree range preserved. +use super::Srs; +use ark_bls12_381::{G1Affine, G2Affine}; +use ark_ec::{AffineRepr, pairing::Pairing}; +use ark_ff::Zero; +use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, SerializationError, +}; + +#[derive(Clone)] +pub struct VerifierParams { + pub(crate) max_len: usize, + pub(crate) g1: [G1Affine; 2], + pub(crate) g2: G2Affine, + pub(crate) tau_g2: G2Affine, + pub(crate) degree_keys: Vec, +} + +impl VerifierParams { + pub fn from_srs(srs: &Srs) -> Self { + assert!(srs.max_len() >= 2 && srs.max_len().is_power_of_two()); + assert_eq!(srs.degree_keys.len(), srs.max_len().ilog2() as usize + 1); + Self { + max_len: srs.max_len(), + g1: [srs.g1[0], srs.g1[1]], + g2: srs.g2, + tau_g2: srs.tau_g2, + degree_keys: srs.degree_keys.clone(), + } + } + + pub fn max_len(&self) -> usize { + self.max_len + } + + pub fn to_bytes(&self) -> Result, SerializationError> { + let mut out = vec![]; + 1u64.serialize_compressed(&mut out)?; + (self.max_len as u64).serialize_compressed(&mut out)?; + for point in self.g1 { + point.serialize_compressed(&mut out)?; + } + self.g2.serialize_compressed(&mut out)?; + self.tau_g2.serialize_compressed(&mut out)?; + for key in &self.degree_keys { + key.serialize_compressed(&mut out)?; + } + Ok(out) + } + + /// Validated point decoding and shape checks. Provenance must be established independently. + pub fn from_bytes(mut input: &[u8]) -> Result { + if u64::deserialize_compressed(&mut input)? != 1 { + return Err(SerializationError::InvalidData); + } + let max_len = usize::try_from(u64::deserialize_compressed(&mut input)?) + .map_err(|_error| SerializationError::InvalidData)?; + if max_len < 2 || !max_len.is_power_of_two() || max_len.ilog2() > 32 { + return Err(SerializationError::InvalidData); + } + let g1 = [ + G1Affine::deserialize_compressed(&mut input)?, + G1Affine::deserialize_compressed(&mut input)?, + ]; + let g2 = G2Affine::deserialize_compressed(&mut input)?; + let tau_g2 = G2Affine::deserialize_compressed(&mut input)?; + let degree_keys = (0..=max_len.ilog2()) + .map(|_| G2Affine::deserialize_compressed(&mut input)) + .collect::, _>>()?; + if !input.is_empty() + || g1.iter().any(AffineRepr::is_zero) + || g2.is_zero() + || tau_g2.is_zero() + || degree_keys.iter().any(AffineRepr::is_zero) + || degree_keys.last() != Some(&g2) + || !ark_bls12_381::Bls12_381::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]) + .is_zero() + { + return Err(SerializationError::InvalidData); + } + Ok(Self { max_len, g1, g2, tau_g2, degree_keys }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn encoding_preserves_degree_bound_and_rejects_malformed_keys() { + let srs = Srs::unsafe_dev_setup(64, b"verifier-params"); + let params = VerifierParams::from_srs(&srs); + let encoded = params.to_bytes().unwrap(); + let decoded = VerifierParams::from_bytes(&encoded).unwrap(); + assert_eq!(decoded.max_len(), 64); + assert_eq!(decoded.g1.len(), 2); + assert_eq!(decoded.degree_keys, srs.degree_keys); + assert!(VerifierParams::from_bytes(&encoded[..encoded.len() - 1]).is_err()); + let mut extra = encoded.clone(); + extra.push(0); + assert!(VerifierParams::from_bytes(&extra).is_err()); + let mut bad = params.clone(); + bad.max_len = 32; + assert!(VerifierParams::from_bytes(&bad.to_bytes().unwrap()).is_err()); + let mut bad = params; + bad.g1[1] = bad.g1[0]; + assert!(VerifierParams::from_bytes(&bad.to_bytes().unwrap()).is_err()); + } +} diff --git a/crates/proof-compression/src/lib.rs b/crates/proof-compression/src/lib.rs new file mode 100644 index 000000000..ed7f2479b --- /dev/null +++ b/crates/proof-compression/src/lib.rs @@ -0,0 +1,7 @@ +//! FRI and KZG proof compression for Ix. +mod circuit_metadata; +pub mod foreign; +pub mod fri; +pub mod kzg; +pub mod pipeline; +pub mod recursive; diff --git a/crates/proof-compression/src/pipeline/codec.rs b/crates/proof-compression/src/pipeline/codec.rs new file mode 100644 index 000000000..f55a0a338 --- /dev/null +++ b/crates/proof-compression/src/pipeline/codec.rs @@ -0,0 +1,131 @@ +//! Verifier-only transport. These bytes must be authenticated by the caller; +//! decoding a key supplied alongside a proof does not establish trust. +use super::{FriVerifier, KzgVerifier, Result, Statement}; +use crate::fri::VerifierKey; +use crate::kzg::{KzgCommitment, KzgConfig, Scalar, VerifierParams}; +use multi_stark::system::{Circuit, System}; +use multi_stark::traits::Field; +use multi_stark::types::Val; +use serde::{Serialize, de::DeserializeOwned}; + +pub(super) fn encode(tag: &[u8; 8], value: impl Serialize) -> Result> { + let mut bytes = tag.to_vec(); + bytes + .extend(bincode::serde::encode_to_vec(value, bincode::config::standard())?); + Ok(bytes) +} + +pub(super) fn decode( + tag: &[u8; 8], + bytes: &[u8], +) -> Result { + if !bytes.starts_with(tag) || bytes.len() > 64 * 1024 * 1024 { + return Err("invalid compression key tag or size".into()); + } + let (value, used): (T, usize) = bincode::serde::decode_from_slice( + &bytes[8..], + bincode::config::standard().with_limit::<67108864>(), + )?; + if used != bytes.len() - 8 || encode(tag, &value)? != bytes { + return Err("noncanonical compression key".into()); + } + Ok(value) +} + +impl Statement { + fn validate(&self) -> Result<()> { + let count = self.locations.len(); + if self.template.len() <= count + || self.template[..=count].iter().any(|row| row.len() != 4) + || self.locations.iter().copied().ne((1..=count).map(|i| (i, 3))) + || self + .locations + .iter() + .any(|&(row, col)| self.template[row][col] != F::ZERO) + { + return Err("invalid compression statement schema".into()); + } + Ok(()) + } +} + +impl FriVerifier { + /// Encode the verifier, including its public statement schema. No prover + /// matrices are included. Authenticate the complete encoding on transport. + pub fn to_bytes(&self) -> Result> { + encode(b"IXFRIV01", (self.key.to_bytes()?, &self.statement)) + } + + /// Decode an independently trusted verifier with a 64 MiB decoding budget. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (key, statement): (Vec, Statement) = + decode(b"IXFRIV01", bytes)?; + statement.validate()?; + Ok(Self { key: VerifierKey::from_bytes(&key)?, statement }) + } +} + +impl KzgVerifier { + /// Encode verifier parameters, compiled constraints, fixed commitments and + /// the public statement schema. Proving powers and matrices are excluded. + pub fn to_bytes(&self) -> Result> { + encode( + b"IXKZGV01", + ( + self.params.to_bytes()?, + &self.system.circuits, + &self.system.preprocessed_commit, + &self.statement, + ), + ) + } + + /// Decode an independently trusted verifier with a 64 MiB decoding budget. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (params, circuits, preprocessing, statement): ( + Vec, + Vec>, + Option, + Statement, + ) = decode(b"IXKZGV01", bytes)?; + let params = VerifierParams::from_bytes(¶ms)?; + statement.validate()?; + let commitment = + preprocessing.as_ref().ok_or("missing fixed commitment")?; + if circuits.is_empty() + || commitment.0.len() != circuits.len() + || commitment.1.len() != circuits.len() + { + return Err("invalid fixed commitment dimensions".into()); + } + for (i, c) in circuits.iter().enumerate() { + crate::circuit_metadata::validate_circuit(c, 1)?; + if c.preprocessed.is_some() + || c.preprocessed_width == 0 + || !c.preprocessed_height.is_power_of_two() + || c.preprocessed_height > params.max_len() + || c.quotient_degree() > 2 + || commitment.0[i].len() != c.preprocessed_width + || commitment.1[i].len() + != if c.preprocessed_height < params.max_len() { + c.preprocessed_width + } else { + 0 + } + { + return Err("invalid KZG compression circuit metadata".into()); + } + } + let indices = (0..circuits.len()).map(Some).collect(); + Ok(Self { + system: System { + config: KzgConfig::verifier_only(params.clone(), 2), + circuits, + preprocessed_commit: preprocessing, + preprocessed_indices: indices, + }, + params, + statement, + }) + } +} diff --git a/crates/proof-compression/src/pipeline/fixed.rs b/crates/proof-compression/src/pipeline/fixed.rs new file mode 100644 index 000000000..ca0a1f265 --- /dev/null +++ b/crates/proof-compression/src/pipeline/fixed.rs @@ -0,0 +1,469 @@ +//! A fixed verifier for the normalizer's all-active, one-shard output. +//! Only the claim, proof and memory interval ends vary between invocations. +use super::{Prepared, Result}; +use crate::fri::*; +use aiur::{synthesis::AiurProof, vk_codec::AiurVerifyingKey}; +use multi_stark::{ + batch::{BatchMessage, BatchPreamble, ShardHeader}, + plonkish::{Circuit, CircuitBuilder, Value}, + types::Val, +}; +use p3_field::{PrimeCharacteristicRing, PrimeField64}; + +#[derive(Clone)] +pub struct FixedRoot { + log_heights: Vec, + // (memory width, circuit index), sorted by width. + memory_traces: Vec<(usize, usize)>, + claim_len: usize, +} + +struct Built { + circuit: Circuit, + public: Vec, + ends: Vec, + inputs: FixedVerifierInputs, + shape: FixedPcsShape, +} + +impl FixedRoot { + /// Heights follow system circuit order, including fixed preprocessed tables. + /// Memory (width, circuit index) pairs and claim length come from the trusted backend. + pub fn new( + log_heights: Vec, + memory_traces: Vec<(usize, usize)>, + claim_len: usize, + ) -> Result { + if log_heights.is_empty() + || log_heights.iter().any(|log| !(1..=26).contains(log)) + || claim_len == 0 + || claim_len > 1024 + || memory_traces.len() > 4096 + || memory_traces.iter().any(|&(w, i)| { + w == 0 || w > u32::MAX as usize || i >= log_heights.len() + }) + || memory_traces.windows(2).any(|w| w[0].0 >= w[1].0) + || memory_traces + .iter() + .map(|&(_, i)| i) + .collect::>() + .len() + != memory_traces.len() + { + return Err("invalid fixed root configuration".into()); + } + Ok(Self { log_heights, memory_traces, claim_len }) + } + + fn preamble( + &self, + key: &AiurVerifyingKey, + expected: &[Val], + ends: &[Val], + ) -> Result> { + if self.log_heights.len() != key.num_circuits() + || key.system().circuits.iter().zip(&self.log_heights).any(|(c, &log)| { + c.preprocessed_height != 0 && c.preprocessed_height != 1usize << log + }) + { + return Err( + "fixed root heights do not match the aggregate backend".into(), + ); + } + let mut messages = vec![]; + for (&(width, _), &end) in self.memory_traces.iter().zip(ends) { + messages.push(BatchMessage::pull( + aiur::memory::Memory::memseg_args(Val::from_usize(width), Val::ZERO) + .to_vec(), + )); + messages.push(BatchMessage::push( + aiur::memory::Memory::memseg_args(Val::from_usize(width), end).to_vec(), + )); + } + Ok(BatchPreamble { + headers: vec![ShardHeader { + active: vec![true; key.num_circuits()], + log_degrees: self.log_heights.clone(), + // The policy check does not read commitments; use the trusted fixed + // commitment as a placeholder, never any prover-supplied data. + stage_1_trace: key + .system() + .preprocessed_commit + .clone() + .ok_or("missing Aiur fixed tables")?, + claims: vec![expected.to_vec()], + }], + messages, + }) + } + + fn build( + &self, + key: &AiurVerifyingKey, + limits: VerifierLimits, + ) -> Result { + let preamble = self.preamble( + key, + &vec![Val::ZERO; self.claim_len], + &vec![Val::ZERO; self.memory_traces.len()], + )?; + key + .validate_preamble(&preamble.headers[0].claims[0], &preamble) + .map_err(|e| format!("fixed root policy: {e:?}"))?; + let verifier = VerifierKey::from_system(key.system()); + let header = &preamble.headers[0]; + let plan = VerifierPlan::validate( + &verifier, + ProofProfile { + envelope: Envelope::SingleBatch, + active: header.active.clone(), + log_degrees: header.log_degrees.clone(), + claim_lengths: vec![self.claim_len], + message_lengths: vec![3; preamble.messages.len()], + max_field_retries: DEFAULT_FIELD_RETRIES, + }, + limits, + )?; + let shape = plan.shape().clone(); + let mut b = CircuitBuilder::new(); + b.enable_compact_blake3(); + let public: Vec<_> = (0..self.claim_len) + .map(|i| b.public_input(format!("claim[{i}]"))) + .collect(); + let ends: Vec<_> = (0..self.memory_traces.len()) + .map(|i| b.input(format!("memory_end[{i}]"))) + .collect(); + let mut messages = vec![]; + for (&(width, index), &end) in self.memory_traces.iter().zip(&ends) { + let log = self.log_heights[index]; + // 0 <= end <= 2^log_height: low bits must vanish if the top bit is 1. + // This also establishes canonical integer ordering for Aiur's policy. + let mut low = b.constant(Val::ZERO); + for bit in 0..log { + let v = b.hint("memory end bit", &[end], move |v| { + Ok(Val::from_u64((v[0].as_canonical_u64() >> bit) & 1)) + }); + b.assert_bool(v); + low = + b.affine([low, v], [Val::ONE, Val::from_u64(1u64 << bit)], Val::ZERO); + } + let top = b.hint("memory end top", &[end], move |v| { + Ok(Val::from_u64((v[0].as_canonical_u64() >> log) & 1)) + }); + b.assert_bool(top); + let value = + b.affine([low, top], [Val::ONE, Val::from_u64(1u64 << log)], Val::ZERO); + b.assert_equal(value, end); + let overflow = b.mul(low, top); + b.assert_zero(overflow); + let channel = b.constant(aiur::shard::memseg_channel_value()); + let width = b.constant(Val::from_usize(width)); + let zero = b.constant(Val::ZERO); + let minus = b.constant(Val::NEG_ONE); + let one = b.constant(Val::ONE); + messages.push(Message { + args: vec![channel, width, zero], + multiplicity: minus, + }); + messages + .push(Message { args: vec![channel, width, end], multiplicity: one }); + } + let bytes = ByteGadgets::new(&mut b); + let mut inputs = constrain_batch_verifier( + &mut b, + &bytes, + key.system(), + std::slice::from_ref(&shape), + vec![vec![public.clone()]], + &messages, + ); + Ok(Built { + circuit: b.finish(), + public, + ends, + inputs: inputs.remove(0), + shape, + }) + } + + /// Construct the verifier without any certificate or proof witness. + pub fn circuit( + &self, + key: &AiurVerifyingKey, + limits: VerifierLimits, + ) -> Result> { + Ok(self.build(key, limits)?.circuit) + } + + pub fn prepare( + &self, + key: &AiurVerifyingKey, + proof: &AiurProof, + expected: &[Val], + limits: VerifierLimits, + ) -> Result> { + key + .verify(expected, proof) + .map_err(|e| format!("normalized root: {e:?}"))?; + if expected.len() != self.claim_len + || proof.preamble.headers.len() != 1 + || proof.preamble.messages.len() != 2 * self.memory_traces.len() + { + return Err( + "root does not match the fixed normalization configuration".into(), + ); + } + let ends: Vec<_> = proof + .preamble + .messages + .iter() + .skip(1) + .step_by(2) + .map(|m| m.args[2]) + .collect(); + if ends + .iter() + .zip(&self.memory_traces) + .any(|(e, &(_, i))| e.as_canonical_u64() > 1u64 << self.log_heights[i]) + { + return Err("memory closure exceeds normalization bound".into()); + } + let mut preamble = self.preamble(key, expected, &ends)?; + preamble.headers[0].stage_1_trace = + proof.preamble.headers[0].stage_1_trace.clone(); + if preamble.headers != proof.preamble.headers + || preamble + .messages + .iter() + .zip(&proof.preamble.messages) + .any(|(a, b)| a.args != b.args || a.multiplicity != b.multiplicity) + { + return Err( + "root does not match the fixed normalization configuration".into(), + ); + } + let built = self.build(key, limits)?; + let expanded = expand_batch_witness( + key.system(), + std::slice::from_ref(&built.shape), + proof, + )?; + let mut witness = built.circuit.witness(); + for (&wire, &value) in + built.public.iter().zip(expected).chain(built.ends.iter().zip(&ends)) + { + witness.set(wire, value)?; + } + expanded[0].assign_proof(&mut witness, &built.inputs)?; + let assignment = witness.generate()?; + Ok(Prepared { circuit: built.circuit, assignment }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::setup; + use multi_stark::{ + batch::ShardInput, + expr::Expr, + lookup::Lookup, + system::{CircuitInputs, System, SystemWitness}, + types::{CommitmentParameters, FriParameters, GoldilocksBlake3Config}, + }; + use p3_matrix::dense::RowMajorMatrix; + + #[test] + fn normalized_aiur_execution_matches_the_fixed_verifier() -> Result<()> { + use aiur::{bytecode::*, execute::IOBuffer, synthesis::AiurSystem}; + let cp = CommitmentParameters { log_blowup: 2, cap_height: 0 }; + let fp = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }; + let layout = FunctionLayout { + input_size: 2, + selectors: 1, + auxiliaries: 2, + lookups: 1, + }; + let top = Toplevel { + functions: vec![Function { + body: Block { + ops: vec![Op::Mul(0, 1)], + ctrl: Ctrl::Return(0, vec![2]), + }, + layout, + entry: true, + constrained: true, + }], + memory_sizes: vec![1], + circuits: vec![Circuit { members: vec![0], layout }], + }; + let system = AiurSystem::build(top, cp, fp); + let key = AiurVerifyingKey::from_bytes( + &aiur::vk_codec::aiur_system_to_bytes(&system)?, + )?; + let fixed = FixedRoot::new(vec![4, 1, 8, 16], vec![(1, 1)], 5)?; + for logs in [vec![4, 1, 8], vec![4, 1, 8, 15]] { + let invalid = FixedRoot::new(logs, vec![(1, 1)], 5)?; + assert!(invalid.circuit(&key, Default::default()).is_err()); + } + for memories in [vec![(1, 4)], vec![(1, 1), (2, 1)], vec![(2, 1), (1, 0)]] { + assert!(FixedRoot::new(vec![4, 1, 8, 16], memories, 5).is_err()); + } + let other = FixedRoot::new(vec![4, 2, 8, 16], vec![(1, 1)], 5)?; + let config = || GoldilocksBlake3Config::new(cp, fp); + let local = + setup::fri(fixed.circuit(&key, Default::default())?, config(), 1 << 16)?; + for value in [3, 7] { + let input = vec![Val::from_u32(value), Val::TWO]; + let mut io = + IOBuffer { data: Default::default(), map: Default::default() }; + let (record, output) = + system.toplevel().execute(0, input.clone(), &mut io)?; + let (claim, proof) = system.prove_from_execution_normalized( + 0, + &input, + &io, + record, + &output, + &[4, 1, 8, 16], + )?; + assert!(other.prepare(&key, &proof, &claim, Default::default()).is_err()); + let prepared = fixed.prepare(&key, &proof, &claim, Default::default())?; + let (verifier, proof) = prepared.prove_fri(config(), 1 << 16)?; + assert_eq!(verifier.to_bytes()?, local.to_bytes()?); + local.verify(&proof, &claim)?; + } + Ok(()) + } + + #[test] + fn fixed_key_binds_claims_and_enforces_memory_bounds() -> Result<()> { + let cp = CommitmentParameters { log_blowup: 1, cap_height: 0 }; + let fp = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }; + let channel = aiur::shard::memseg_channel_value(); + let (system, pk) = System::new( + GoldilocksBlake3Config::new(cp, fp), + [(8, 8), (1, 2)].into_iter().map(|(width, height)| CircuitInputs { + main_width: 4, + preprocessed: Some(RowMajorMatrix::new_col(vec![Val::ZERO; height])), + lookups: vec![ + Lookup::pull( + if width == 8 { Expr::main(2) } else { Val::ZERO.into() }, + vec![Expr::main(0), Expr::main(1)], + ), + Lookup::push( + Expr::main(2), + vec![ + channel.into(), + Val::from_usize(width).into(), + Val::ZERO.into(), + ], + ), + Lookup::pull( + Expr::main(2), + vec![channel.into(), Val::from_usize(width).into(), Expr::main(3)], + ), + ], + ..Default::default() + }), + ); + let key = + AiurVerifyingKey::from_bytes(&aiur::vk_codec::to_bytes(&system, cp, fp))?; + let fixed = FixedRoot::new(vec![3, 1], vec![(1, 1), (8, 0)], 2)?; + let config = || { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + fp, + ) + }; + // Generated before, and independently of, every certificate and witness. + let local = + setup::fri(fixed.circuit(&key, Default::default())?, config(), 1 << 16)?; + for (small_end, large_end) in [(0, 0), (2, 8), (3, 1), (1, 9)] { + let expected = + vec![Val::from_u32(17 + small_end), Val::from_u32(31 + large_end)]; + let ends = [small_end, large_end]; + let messages = [1, 8] + .into_iter() + .zip(ends) + .flat_map(|(width, end)| { + [ + BatchMessage::pull(vec![channel, Val::from_u32(width), Val::ZERO]), + BatchMessage::push(vec![ + channel, + Val::from_u32(width), + Val::from_u32(end), + ]), + ] + }) + .collect(); + let traces = [(large_end, 8), (small_end, 2)] + .into_iter() + .map(|(end, height)| { + let mut values = vec![Val::ZERO; 4 * height]; + values[..4].copy_from_slice(&[ + expected[0], + expected[1], + Val::ONE, + Val::from_u32(end), + ]); + RowMajorMatrix::new(values, 4) + }) + .collect(); + let proof = system.prove_batch( + &pk, + vec![ShardInput { + claims: vec![expected.clone()], + witness: SystemWitness::from_stage_1(traces, &system), + }], + messages, + ); + key.verify(&expected, &proof).unwrap(); + if small_end <= 2 && large_end <= 8 { + let stage = + fixed.prepare(&key, &proof, &expected, Default::default())?; + let (verifier, compressed) = stage.prove_fri(config(), 1 << 16)?; + assert_eq!(verifier.to_bytes()?, local.to_bytes()?); + local.verify(&compressed, &expected)?; + assert!( + local + .verify(&compressed, &[expected[0] + Val::ONE, expected[1]]) + .is_err() + ); + } else { + assert!( + fixed.prepare(&key, &proof, &expected, Default::default()).is_err() + ); + // Bypass the host bound check: the circuit must reject the witness too. + let built = fixed.build(&key, Default::default())?; + let expanded = expand_batch_witness( + &system, + std::slice::from_ref(&built.shape), + &proof, + )?; + let mut witness = built.circuit.witness(); + for (&wire, &value) in built.public.iter().zip(&expected) { + witness.set(wire, value)?; + } + for (&wire, end) in built.ends.iter().zip(ends) { + witness.set(wire, Val::from_u32(end))?; + } + expanded[0].assign_proof(&mut witness, &built.inputs)?; + assert!(witness.generate().is_err()); + } + } + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs new file mode 100644 index 000000000..ed59bd03d --- /dev/null +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -0,0 +1,413 @@ +//! Compression stages for aggregated Ix certificates. +//! +//! The caller supplies the trusted aggregate key, expected outer claim and +//! proving parameters. No certificate, filesystem layout or development SRS +//! is built into these stages. Verifiers returned by local construction must +//! be authenticated before use elsewhere; a prover-supplied key is not trusted. +mod codec; +mod fixed; +mod root; +mod setup; +#[cfg(test)] +mod tests; +mod wrap; + +pub use fixed::FixedRoot; +pub use root::prepare_root; +pub use setup::setup; +pub use wrap::{ + RecursiveProof, RecursiveStage, RecursiveVerifier, prepare_recursive, +}; + +use crate::foreign::{GoldilocksCircuit, KzgCircuitExt}; +use crate::fri::{self, VerifierKey}; +use crate::kzg::{KzgConfig, Scalar, Srs, VerifierParams}; +use multi_stark::config::ProofConfig; +use multi_stark::lookup::LookupValues; +use multi_stark::plonkish::{Assignment, Circuit, MultiStarkCircuit}; +use multi_stark::prover::{Proof, Stage1}; +use multi_stark::system::{ProverKey, System, SystemWitness}; +use multi_stark::traits::{Algebra, Field, Pcs}; +use multi_stark::types::{GoldilocksBlake3Config, Val}; +use p3_field::PrimeField64; +use p3_matrix::Matrix; +use std::sync::Arc; + +pub type Result = std::result::Result>; + +// System::new asserts these conditions. Check caller-selected parameters +// before committing matrices so an unsupported configuration is an error. +fn validate_config( + config: &SC, + definitions: &[multi_stark::system::CircuitInputs< + multi_stark::config::Val, + >], +) -> Result<()> { + let params = multi_stark::system::extension_params::(); + for definition in definitions { + let graph = definition + .compile_graph(¶ms) + .map_err(|e| format!("compression circuit: {e:?}"))?; + let degree = + graph.max_constraint_degree.max(multi_stark::lookup::logup_max_degree( + &graph, + definition.lookup_group_size, + )) as usize; + let quotient = (degree.max(2) - 1).next_power_of_two(); + if quotient > config.max_quotient_degree() { + return Err(format!("compression circuit requires quotient degree {quotient}; increase the PCS degree budget").into()); + } + if let Some(fixed) = &definition.preprocessed { + let height = fixed.height(); + if !height.is_power_of_two() + || height.ilog2() as usize > config.max_log_degree() + || height.ilog2() as usize + quotient.ilog2() as usize + > config.max_log_quotient_domain() + { + return Err("compression trace exceeds the PCS domain".into()); + } + } + } + Ok(()) +} + +/// A checked circuit and its witness, ready for lowering and proving. +/// Keeping preparation separate lets callers inspect costs and choose an SRS +/// and partition height before allocating prover data. +pub struct Prepared { + circuit: Circuit, + assignment: Assignment, +} + +impl Prepared { + pub fn circuit(&self) -> &Circuit { + &self.circuit + } + + pub fn public_values(&self) -> &[F] { + self.assignment.public_values() + } +} + +/// The lowering's complete claim template, including all activation anchors. +/// Only public-value cells may be replaced during verification. +#[derive(serde::Serialize, serde::Deserialize)] +struct Statement { + template: Vec>, + locations: Vec<(usize, usize)>, +} + +impl Statement { + fn from_lowered( + circuit: &MultiStarkCircuit, + count: usize, + ) -> Result { + let template = circuit.claims(&vec![F::ZERO; count])?; + // MultiStarkCircuit::claims places the anchor first, then public cells. + // Obtain every other claim (partition/hash activation) from the lowering. + let locations: Vec<_> = (1..=count).map(|i| (i, 3)).collect(); + if template.len() <= count + || template[..=count].iter().any(|row| row.len() != 4) + { + return Err("unsupported lowering claim schema".into()); + } + Ok(Self { template, locations }) + } + + fn claims(&self, public: &[F]) -> Result>> { + if public.len() != self.locations.len() { + return Err("unexpected public statement length".into()); + } + let mut claims = self.template.clone(); + for (&(row, col), &value) in self.locations.iter().zip(public) { + claims[row][col] = value; + } + Ok(claims) + } +} + +/// Trusted verifier for the first, FRI-compressed stage. +pub struct FriVerifier { + key: VerifierKey, + statement: Statement, +} + +impl FriVerifier { + fn schema(&self) -> fri::Statement { + let mut schema = fri::Statement { + claims: self + .statement + .template + .iter() + .map(|row| { + row + .iter() + .copied() + .map(fri::StatementSlot::Constant) + .collect::>() + }) + .collect::>(), + messages: vec![], + }; + for &(row, col) in &self.statement.locations { + schema.claims[row][col] = fri::StatementSlot::Public; + } + schema + } + + pub fn verify( + &self, + proof: &Proof, + expected: &[Val], + ) -> Result<()> { + let claims = self.statement.claims(expected)?; + self + .key + .system() + .verify_multiple_claims( + &claims.iter().map(Vec::as_slice).collect::>(), + proof, + ) + .map_err(|e| format!("FRI compression proof: {e:?}"))?; + Ok(()) + } +} + +impl Prepared { + /// Prove the root-verifier circuit using caller-selected FRI parameters. + pub fn prove_fri( + self, + config: GoldilocksBlake3Config, + max_height: usize, + ) -> Result<(FriVerifier, Proof)> { + use multi_stark::config::StarkGenericConfig; + fri::validate_parameters( + multi_stark::types::CommitmentParameters { + log_blowup: config.log_blowup(), + cap_height: config.cap_height(), + }, + config.fri_parameters(), + )?; + let compiled = + self.circuit.lower_to_multi_stark_sharded(Val::ONE, max_height)?; + let statement = Statement::from_lowered( + &compiled, + self.assignment.public_values().len(), + )?; + let claims = statement.claims(self.assignment.public_values())?; + let definitions = compiled.circuit_inputs(); + validate_config(&config, &definitions)?; + let traces = compiled.traces(&self.assignment)?; + drop(compiled); + drop(self.assignment); + let (mut system, key) = System::new(config, definitions); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let witness = SystemWitness::from_stage_1(traces, &system); + // Lookup messages now own everything needed from the fixed evaluations. + // Quotients/openings use PCS data; the returned verifier needs neither. + for circuit in &mut system.circuits { + circuit.preprocessed = None; + } + let proof = system.prove_multiple_claims(&key, &refs, witness); + drop(key); + system + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("new FRI proof: {e:?}"))?; + Ok((FriVerifier { key: VerifierKey::new(system), statement }, proof)) + } +} + +/// Prepare a FRI verifier over the BLS12-381 scalar field. The statement and +/// all activation anchors come from the trusted previous stage's verifier. +pub fn prepare_kzg( + verifier: &FriVerifier, + proof: &Proof, + expected: &[Val], + limits: fri::VerifierLimits, +) -> Result> { + verifier.verify(proof, expected)?; + let claims = verifier.statement.claims(expected)?; + let profile = fri::ProofProfile { + envelope: fri::Envelope::Ordinary, + active: proof.active.clone(), + log_degrees: proof.log_degrees.clone(), + claim_lengths: claims.iter().map(Vec::len).collect(), + message_lengths: vec![], + max_field_retries: fri::DEFAULT_FIELD_RETRIES, + }; + let plan = fri::VerifierPlan::validate(&verifier.key, profile, limits)?; + let schema = verifier.schema(); + let (circuit, inputs) = plan.build(schema, Default::default())?; + let expanded = plan + .expand_witness(fri::ProofEnvelope::Ordinary { proof, claims: &claims })?; + let mut witness = circuit.witness(); + inputs.assign_statement( + &mut witness, + &fri::Statement { claims, messages: vec![] }, + )?; + expanded.assign_proof(&mut witness, &inputs)?; + drop(expanded); + drop(inputs); + drop(plan); + let assignment = witness.generate()?; + let GoldilocksCircuit { circuit: foreign, inputs } = + GoldilocksCircuit::new_preallocated(&circuit); + drop(circuit); + let mut witness = foreign.witness(); + inputs.assign(&assignment, &mut witness)?; + drop(inputs); + drop(assignment); + let assignment = witness.generate()?; + Ok(Prepared { circuit: foreign, assignment }) +} + +/// Trusted verifier for a scalar-field compression stage. It retains only +/// verification parameters, never the SRS proving powers or fixed matrices. +pub struct KzgVerifier { + system: System, + params: VerifierParams, + statement: Statement, +} + +impl KzgVerifier { + fn codec(&self) -> Result { + let logs = self + .system + .circuits + .iter() + .map(|c| { + u8::try_from(c.preprocessed_height.ilog2()) + .expect("validated trace height") + }) + .collect::>(); + Ok(crate::kzg::compact::FixedProofCodec::new(&self.system, &logs)?) + } + + /// Fixed-shape transport under this trusted key; encoding is not verification. + pub fn encode_proof(&self, proof: &Proof) -> Result> { + Ok(self.codec()?.encode(proof)?) + } + + pub fn verify_bytes(&self, bytes: &[u8], expected: &[Scalar]) -> Result<()> { + self.verify(&self.codec()?.decode(bytes)?, expected) + } + + pub fn verify( + &self, + proof: &Proof, + expected: &[Scalar], + ) -> Result<()> { + let claims = self.statement.claims(expected)?; + self + .system + .verify_multiple_claims( + &claims.iter().map(Vec::as_slice).collect::>(), + proof, + ) + .map_err(|e| format!("KZG compression proof: {e:?}"))?; + Ok(()) + } +} + +impl Prepared { + /// Parameters must have trusted provenance and pass Srs::validate before + /// use. Partitioning is bounded by both max_height and the supplied SRS. + pub fn prove_kzg( + self, + srs: Arc, + max_height: usize, + ) -> Result<(KzgVerifier, Proof)> { + if srs.max_len() < 2 + || !srs.max_len().is_power_of_two() + || srs.degree_keys.len() != srs.max_len().ilog2() as usize + 1 + { + return Err("invalid SRS shape".into()); + } + if max_height < 2 + || !max_height.is_power_of_two() + || max_height > srs.max_len() + { + return Err( + "partition height must be a power of two within the SRS range".into(), + ); + } + let compiled = self + .circuit + .lower_to_multi_stark_sharded(Scalar::ONE, max_height)? + .merge_table_traces(max_height)?; + let statement = Statement::from_lowered( + &compiled, + self.assignment.public_values().len(), + )?; + let claims = statement.claims(self.assignment.public_values())?; + let params = VerifierParams::from_srs(&srs); + let config = + KzgConfig::new(srs, 2).with_streaming_lookups().with_streaming_quotient(); + // Commit one partition at a time. Lookup traces are reconstructed from + // committed columns, so neither fixed evaluations nor expanded lookup + // messages need to remain resident alongside all prover coefficients. + let source = compiled.trace_shards(&self.assignment)?; + let count = compiled.num_circuits(); + let mut circuits = Vec::with_capacity(count); + let mut fixed = Vec::with_capacity(count); + let mut main = Vec::with_capacity(count); + let mut logs = Vec::with_capacity(count); + let mut lookups = Vec::with_capacity(count); + for i in 0..count { + let definition = compiled + .kzg_circuit_input(i, params.max_len(), 2)? + .ok_or("missing compression circuit")?; + validate_config(&config, std::slice::from_ref(&definition))?; + let (mut local, key) = + System::new_without_preprocessed(config.clone(), [definition]); + let circuit = local.circuits.remove(0); + fixed.push(key.preprocessed_data.ok_or("missing fixed columns")?); + let trace = source.trace(i)?; + let height = trace.height(); + logs.push(height.ilog2() as usize); + lookups.push(LookupValues::shape_only( + height, + &circuit.graph.lookups.iter().map(|l| l.args.len()).collect::>(), + )); + let (_, data) = config + .pcs() + .commit(vec![(config.pcs().natural_domain_for_degree(height), trace)]); + main.push(data); + circuits.push(circuit); + } + drop(compiled); + drop(self.assignment); + let (fixed_commitment, fixed_data) = + crate::kzg::pcs::KzgProverData::concatenate(fixed); + let (stage_1_trace_commit, stage_1_trace_data) = + crate::kzg::pcs::KzgProverData::concatenate(main); + let mut system = System { + config, + circuits, + preprocessed_commit: Some(fixed_commitment), + preprocessed_indices: (0..count).map(Some).collect(), + }; + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = system.prove_committed( + &ProverKey { preprocessed_data: Some(fixed_data) }, + &refs, + Stage1 { + active: vec![true; count], + active_indices: (0..count).collect(), + log_degrees: logs, + stage_1_trace_commit, + stage_1_trace_data, + lookups, + }, + ); + system + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("new KZG proof: {e:?}"))?; + system.config = KzgConfig::verifier_only(params.clone(), 2); + Ok((KzgVerifier { system, params, statement }, proof)) + } +} + +fn scalar_statement(expected: &[Val]) -> Vec { + expected.iter().map(|v| Scalar::from_u64(v.as_canonical_u64())).collect() +} diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs new file mode 100644 index 000000000..4e3f521b7 --- /dev/null +++ b/crates/proof-compression/src/pipeline/root.rs @@ -0,0 +1,103 @@ +use super::{Prepared, Result}; +use crate::fri::*; +use aiur::{synthesis::AiurProof, vk_codec::AiurVerifyingKey}; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::types::Val; + +/// Prepare compression of an aggregate root's Aiur proof, including every +/// trace shard. `expected` is the outer call claim reconstructed by the host +/// from its trusted aggregate entrypoint, allowed keys and certificate claim. +/// +/// Aiur policy is checked before construction. Activation, heights, claim +/// placement and memory-closure messages are then fixed into the circuit, so +/// its witness cannot bypass that policy. The claim values remain public. +pub fn prepare_root( + key: &AiurVerifyingKey, + proof: &AiurProof, + expected: &[Val], + limits: VerifierLimits, +) -> Result> { + key.verify(expected, proof).map_err(|e| format!("aggregate root: {e:?}"))?; + prepare_batch( + &VerifierKey::from_system(key.system()), + proof, + expected, + limits, + ) +} + +fn prepare_batch( + key: &VerifierKey, + proof: &AiurProof, + expected: &[Val], + limits: VerifierLimits, +) -> Result> { + let mut shapes = Vec::new(); + let mut proof_values = 0usize; + for header in &proof.preamble.headers { + let plan = VerifierPlan::validate( + key, + ProofProfile { + envelope: Envelope::SingleBatch, + active: header.active.clone(), + log_degrees: header.log_degrees.clone(), + claim_lengths: header.claims.iter().map(Vec::len).collect(), + message_lengths: proof + .preamble + .messages + .iter() + .map(|m| m.args.len()) + .collect(), + max_field_retries: DEFAULT_FIELD_RETRIES, + }, + limits, + )?; + proof_values = proof_values + .checked_add(plan.resources().proof_values) + .ok_or("batch proof size overflow")?; + if proof_values > limits.max_proof_values { + return Err("batch exceeds proof value limit".into()); + } + shapes.push(plan.shape().clone()); + } + let expanded = expand_batch_witness(key.system(), &shapes, proof)?; + let mut b = CircuitBuilder::new(); + b.enable_compact_blake3(); + let public: Vec<_> = (0..expected.len()) + .map(|i| b.public_input(format!("claim[{i}]"))) + .collect(); + let claims: Vec<_> = proof + .preamble + .headers + .iter() + .map(|header| header.claims.iter().map(|_| public.clone()).collect()) + .collect(); + let bytes = ByteGadgets::new(&mut b); + let messages: Vec<_> = proof + .preamble + .messages + .iter() + .map(|m| Message { + args: m.args.iter().map(|&v| b.constant(v)).collect(), + multiplicity: b.constant(m.multiplicity), + }) + .collect(); + let inputs = constrain_batch_verifier( + &mut b, + &bytes, + key.system(), + &shapes, + claims, + &messages, + ); + let circuit = b.finish(); + let mut witness = circuit.witness(); + for (&wire, &value) in public.iter().zip(expected) { + witness.set(wire, value)?; + } + for (expanded, inputs) in expanded.iter().zip(&inputs) { + expanded.assign_proof(&mut witness, inputs)?; + } + let assignment = witness.generate()?; + Ok(Prepared { circuit, assignment }) +} diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs new file mode 100644 index 000000000..00a341b70 --- /dev/null +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -0,0 +1,114 @@ +//! Deterministic verifier construction. No proof or witness is an input. +use super::*; +use crate::kzg::KzgCommitment; +use aiur::vk_codec::AiurVerifyingKey; + +/// Derive a complete fixed compression verifier from locally trusted inputs. +/// This performs circuit construction and preprocessing commitments, but no +/// proof generation. The supplied SRS must have been authenticated/validated. +pub fn setup( + root_key: &AiurVerifyingKey, + root: &FixedRoot, + srs: Arc, + fri_config: GoldilocksBlake3Config, + fri_height: usize, + kzg_height: usize, + recursive_height: usize, +) -> Result { + let circuit = root.circuit(root_key, Default::default())?; + let fri = fri(circuit, fri_config, fri_height)?; + let circuit = foreign(&fri)?; + let kzg = kzg(circuit, srs.clone(), kzg_height)?; + wrap::setup_recursive(&kzg, srs, recursive_height) +} + +pub(super) fn fri( + circuit: Circuit, + config: GoldilocksBlake3Config, + height: usize, +) -> Result { + use multi_stark::config::StarkGenericConfig; + fri::validate_parameters( + multi_stark::types::CommitmentParameters { + log_blowup: config.log_blowup(), + cap_height: config.cap_height(), + }, + config.fri_parameters(), + )?; + let public_count = circuit.public_values().len(); + let compiled = circuit.lower_to_multi_stark_sharded(Val::ONE, height)?; + let statement = Statement::from_lowered(&compiled, public_count)?; + let definitions = compiled.circuit_inputs(); + drop(compiled); + validate_config(&config, &definitions)?; + let (system, _) = System::new_without_preprocessed(config, definitions); + Ok(FriVerifier { key: VerifierKey::new(system), statement }) +} + +pub(super) fn foreign(verifier: &FriVerifier) -> Result> { + let system = verifier.key.system(); + let profile = fri::ProofProfile { + envelope: fri::Envelope::Ordinary, + active: vec![true; system.circuits.len()], + log_degrees: system + .circuits + .iter() + .map(|c| { + u8::try_from(c.preprocessed_height.ilog2()).expect("usize log fits u8") + }) + .collect(), + claim_lengths: verifier.statement.template.iter().map(Vec::len).collect(), + message_lengths: vec![], + max_field_retries: fri::DEFAULT_FIELD_RETRIES, + }; + let plan = + fri::VerifierPlan::validate(&verifier.key, profile, Default::default())?; + let (circuit, _) = plan.build(verifier.schema(), Default::default())?; + Ok(GoldilocksCircuit::new_preallocated(&circuit).circuit) +} + +pub(super) fn kzg( + circuit: Circuit, + srs: Arc, + height: usize, +) -> Result { + if height < 2 || !height.is_power_of_two() || height > srs.max_len() { + return Err( + "partition height must be a power of two within the SRS range".into(), + ); + } + let public_count = circuit.public_values().len(); + let compiled = circuit + .lower_to_multi_stark_sharded(Scalar::ONE, height)? + .merge_table_traces(height)?; + let statement = Statement::from_lowered(&compiled, public_count)?; + let params = VerifierParams::from_srs(&srs); + let config = + KzgConfig::new(srs, 2).with_streaming_lookups().with_streaming_quotient(); + let count = compiled.num_circuits(); + let mut circuits = Vec::with_capacity(count); + let mut fixed = KzgCommitment(vec![], vec![]); + for i in 0..count { + let definition = compiled + .kzg_circuit_input(i, params.max_len(), 2)? + .ok_or("missing compression circuit")?; + validate_config(&config, std::slice::from_ref(&definition))?; + let (mut local, _) = + System::new_without_preprocessed(config.clone(), [definition]); + let commit = + local.preprocessed_commit.take().ok_or("missing fixed commitment")?; + fixed.0.extend(commit.0); + fixed.1.extend(commit.1); + circuits.push(local.circuits.remove(0)); + } + Ok(KzgVerifier { + system: System { + config: KzgConfig::verifier_only(params.clone(), 2), + circuits, + preprocessed_commit: Some(fixed), + preprocessed_indices: (0..count).map(Some).collect(), + }, + params, + statement, + }) +} diff --git a/crates/proof-compression/src/pipeline/tests.rs b/crates/proof-compression/src/pipeline/tests.rs new file mode 100644 index 000000000..c4a5176ae --- /dev/null +++ b/crates/proof-compression/src/pipeline/tests.rs @@ -0,0 +1,191 @@ +use super::*; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::types::{CommitmentParameters, FriParameters}; + +#[test] +fn unsupported_fri_profiles_fail_before_proving() { + for (cap, arity, final_log, queries) in + [(1, 1, 0, 2), (0, 2, 0, 2), (0, 1, 1, 2), (0, 1, 0, 0)] + { + let config = || { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: cap }, + FriParameters { + log_final_poly_len: final_log, + max_log_arity: arity, + num_queries: queries, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) + }; + assert!(arithmetic(Val::ONE, 2).prove_fri(config(), 32).is_err()); + } +} + +fn arithmetic(value: F, repetitions: usize) -> Prepared { + let mut b = CircuitBuilder::new(); + let table = b.fixed_table("small", vec![vec![F::ONE], vec![F::TWO]]); + let x = b.public_input("x"); + for _ in 0..repetitions { + b.lookup(table, &[x]); + } + let square = b.mul(x, x); + b.expose_public(square); + let circuit = b.finish(); + let mut witness = circuit.witness(); + witness.set(x, value).unwrap(); + let assignment = witness.generate().unwrap(); + Prepared { circuit, assignment } +} + +#[test] +fn fri_stage_preserves_publics_through_foreign_translation() -> Result<()> { + for value in [Val::ONE, Val::TWO] { + let expected = [value, value * value]; + let prepared = arithmetic(value, 2); + let config = || { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 1, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) + }; + let local = setup::fri(arithmetic(value, 2).circuit, config(), 32)?; + let (verifier, proof) = prepared.prove_fri(config(), 32)?; + assert_eq!(local.to_bytes()?, verifier.to_bytes()?); + let key_bytes = verifier.to_bytes()?; + let verifier = FriVerifier::from_bytes(&key_bytes)?; + assert_eq!(verifier.to_bytes()?, key_bytes); + assert!( + FriVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]).is_err() + ); + let mut extra_key = key_bytes; + extra_key.push(0); + assert!(FriVerifier::from_bytes(&extra_key).is_err()); + let mut bad = FriVerifier::from_bytes(&verifier.to_bytes()?)?; + bad.statement.locations[0] = (usize::MAX, 3); + assert!(FriVerifier::from_bytes(&bad.to_bytes()?).is_err()); + assert!(KzgVerifier::from_bytes(&verifier.to_bytes()?).is_err()); + verifier.verify(&proof, &expected)?; + assert!( + verifier.verify(&proof, &[value, value * value + Val::ONE]).is_err() + ); + assert!(verifier.verify(&proof, &expected[..1]).is_err()); + let translated = + prepare_kzg(&verifier, &proof, &expected, Default::default())?; + assert_eq!(translated.public_values(), scalar_statement(&expected)); + assert!( + prepare_kzg( + &verifier, + &proof, + &[value + Val::ONE, value * value], + Default::default() + ) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn fri_memory_reduction_preserves_the_dense_proof() -> Result<()> { + let config = || { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 1, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) + }; + // Include multiple partitions and a lookup table. Build the reference using + // dense lowering and retained fixed evaluations, as before the optimization. + let prepared = arithmetic(Val::TWO, 50); + let compiled = + prepared.circuit.lower_to_multi_stark_with_max_height(Val::ONE, 32)?; + let claims = compiled.claims(prepared.assignment.public_values())?; + let (system, key) = System::new(config(), compiled.circuit_inputs()); + let reference = system.prove_multiple_claims( + &key, + &claims.iter().map(Vec::as_slice).collect::>(), + SystemWitness::from_stage_1( + compiled.traces(&prepared.assignment)?, + &system, + ), + ); + let (verifier, proof) = arithmetic(Val::TWO, 50).prove_fri(config(), 32)?; + assert_eq!(proof.to_bytes()?, reference.to_bytes()?); + assert!( + verifier.key.system().circuits.iter().all(|c| c.preprocessed.is_none()) + ); + verifier.verify(&proof, &[Val::TWO, Val::from_u8(4)])?; + Ok(()) +} + +#[test] +fn kzg_stages_derive_claims_for_different_layouts_and_parameters() -> Result<()> +{ + for (height, value) in [(32, Scalar::ONE), (128, Scalar::TWO)] { + let stage = arithmetic(value, 50); + let expected = [value, value * value]; + let srs = Arc::new(Srs::unsafe_dev_setup(height, &height.to_le_bytes())); + srs.validate()?; + let local = setup::kzg(arithmetic(value, 50).circuit, srs.clone(), height)?; + let (verifier, proof) = stage.prove_kzg(srs.clone(), height)?; + assert_eq!(local.to_bytes()?, verifier.to_bytes()?); + drop(local); + let key_bytes = verifier.to_bytes()?; + let verifier = KzgVerifier::from_bytes(&key_bytes)?; + assert_eq!(verifier.to_bytes()?, key_bytes); + assert!( + KzgVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]).is_err() + ); + let mut extra_key = key_bytes; + extra_key.push(0); + assert!(KzgVerifier::from_bytes(&extra_key).is_err()); + let mut bad = KzgVerifier::from_bytes(&verifier.to_bytes()?)?; + bad.system.circuits[0].graph.max_constraint_degree += 1; + assert!(KzgVerifier::from_bytes(&bad.to_bytes()?).is_err()); + let mut bad = KzgVerifier::from_bytes(&verifier.to_bytes()?)?; + bad.system.circuits[0].preprocessed_height = 0; + assert!(KzgVerifier::from_bytes(&bad.to_bytes()?).is_err()); + let mut other = KzgVerifier::from_bytes(&verifier.to_bytes()?)?; + other.params = VerifierParams::from_srs(&Srs::unsafe_dev_setup( + height, + b"other-parameters", + )); + let other = KzgVerifier::from_bytes(&other.to_bytes()?)?; + assert!(other.verify(&proof, &expected).is_err()); + verifier.verify(&proof, &expected)?; + assert!( + verifier.verify(&proof, &[value, value * value + Scalar::ONE]).is_err() + ); + assert!(verifier.verify(&proof, &expected[..1]).is_err()); + assert!(verifier.system.circuits.iter().all(|c| c.preprocessed.is_none())); + // The verifier does not retain the proving powers. + assert_eq!(Arc::strong_count(&srs), 1); + if height == 128 { + let claim: Vec<_> = expected + .iter() + .map(|v| Val::from_u64(v.canonical_limbs_le()[0])) + .collect(); + let recursive = prepare_recursive(&verifier, &proof, &claim)?; + assert!(recursive.circuit().stats().publics > claim.len()); + let mut wrong = claim; + wrong[0] += Val::ONE; + assert!(prepare_recursive(&verifier, &proof, &wrong).is_err()); + } + } + let srs = Arc::new(Srs::unsafe_dev_setup(8, b"too-small")); + assert!(arithmetic(Scalar::ONE, 2).prove_kzg(srs, 16).is_err()); + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/wrap.rs b/crates/proof-compression/src/pipeline/wrap.rs new file mode 100644 index 000000000..0a753fe2b --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap.rs @@ -0,0 +1,357 @@ +use super::{KzgVerifier, Prepared, Result, scalar_statement}; +use crate::kzg::{KzgConfig, Scalar, Srs}; +use crate::recursive::native_verifier; +use ark_bls12_381::{Bls12_381, G1Affine, G2Affine}; +use ark_ec::{AffineRepr, pairing::Pairing}; +use ark_ff::Zero; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use multi_stark::prover::Proof; +use multi_stark::traits::Algebra; +use multi_stark::types::Val; +use std::sync::Arc; + +/// Recursive proof and its public pairing operands. Certificate claim values +/// are supplied independently to RecursiveVerifier::verify. +pub struct RecursiveProof { + pub proof: Proof, + pub pairing_values: Vec, +} + +struct Pairings { + degree_count: usize, + keys: Vec, +} + +impl Pairings { + fn encode(&self, values: &[Scalar]) -> Result> { + if values.len() != 2 * self.keys.len() + || self.keys.len() != self.degree_count + 2 + { + return Err("incorrect pairing operand count".into()); + } + let mut bytes = Vec::with_capacity(values.len() * 24); + for value in values { + let limbs = value.canonical_limbs_le(); + if limbs[3] != 0 { + return Err("noncanonical packed pairing operand".into()); + } + for limb in &limbs[..3] { + bytes.extend(limb.to_le_bytes()); + } + } + Ok(bytes) + } + + fn verify(&self, values: &[Scalar]) -> Result<()> { + let bytes = self.encode(values)?; + let points = bytes + .as_chunks::<48>() + .0 + .iter() + .map(|bytes| G1Affine::deserialize_compressed(bytes.as_slice())) + .collect::, _>>()?; + for range in [0..self.degree_count, self.degree_count..self.keys.len()] { + if !Bls12_381::multi_pairing( + points[range.clone()].to_vec(), + self.keys[range].to_vec(), + ) + .is_zero() + { + return Err("external pairing equation failed".into()); + } + } + Ok(()) + } +} + +pub struct RecursiveStage { + prepared: Prepared, + pairings: Pairings, + claim_len: usize, +} + +/// Build the recursive KZG verifier. The original public statement remains +/// public; degree-check and opening-check operands are additional publics. +pub fn prepare_recursive( + verifier: &KzgVerifier, + proof: &Proof, + expected: &[Val], +) -> Result { + let public = scalar_statement(expected); + verifier.verify(proof, &public)?; + let claims = verifier.statement.claims(&public)?; + let built = native_verifier::build( + &verifier.system, + &verifier.params, + proof, + &claims, + &verifier.statement.locations, + )?; + let assignment = built.assign()?; + let pairings = Pairings { + degree_count: built.degree_output_count, + keys: built.pairing_keys, + }; + pairings.verify(&assignment.public_values()[public.len()..])?; + Ok(RecursiveStage { + prepared: Prepared { circuit: built.circuit, assignment }, + pairings, + claim_len: public.len(), + }) +} + +impl RecursiveStage { + pub fn circuit(&self) -> &multi_stark::plonkish::Circuit { + self.prepared.circuit() + } + + pub fn prove( + self, + srs: Arc, + max_height: usize, + ) -> Result<(RecursiveVerifier, RecursiveProof)> { + let pairing_values = + self.prepared.public_values()[self.claim_len..].to_vec(); + let (verifier, proof) = self.prepared.prove_kzg(srs, max_height)?; + Ok(( + RecursiveVerifier { + verifier, + pairings: self.pairings, + claim_len: self.claim_len, + }, + RecursiveProof { proof, pairing_values }, + )) + } +} + +pub struct RecursiveVerifier { + verifier: KzgVerifier, + pairings: Pairings, + claim_len: usize, +} + +pub(super) fn setup_recursive( + verifier: &KzgVerifier, + srs: Arc, + height: usize, +) -> Result { + let claim_len = verifier.statement.locations.len(); + let claims = verifier.statement.claims(&vec![Scalar::ZERO; claim_len])?; + let template = verifier.codec()?.template(); + let built = native_verifier::build( + &verifier.system, + &verifier.params, + &template, + &claims, + &verifier.statement.locations, + )?; + let outer = super::setup::kzg(built.circuit, srs, height)?; + Ok(RecursiveVerifier { + verifier: outer, + pairings: Pairings { + degree_count: built.degree_output_count, + keys: built.pairing_keys, + }, + claim_len, + }) +} + +impl RecursiveVerifier { + /// Encode all verification data, including both stages' public parameters + /// and the pairing equations. Authenticate the complete encoding on transport. + pub fn to_bytes(&self) -> Result> { + let mut keys = vec![]; + for key in &self.pairings.keys { + key.serialize_compressed(&mut keys)?; + } + super::codec::encode( + b"IXRECV01", + ( + self.verifier.to_bytes()?, + self.claim_len, + self.pairings.degree_count, + keys, + ), + ) + } + + /// Decode an independently trusted verifier with a 64 MiB decoding budget. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (verifier, claim_len, degree_count, keys): ( + Vec, + usize, + usize, + Vec, + ) = super::codec::decode(b"IXRECV01", bytes)?; + let verifier = KzgVerifier::from_bytes(&verifier)?; + let count = degree_count.checked_add(2).ok_or("pairing count overflow")?; + if count.checked_mul(96) != Some(keys.len()) + || count.checked_mul(2).and_then(|n| n.checked_add(claim_len)) + != Some(verifier.statement.locations.len()) + { + return Err("invalid recursive public statement dimensions".into()); + } + let keys = keys + .as_chunks::<96>() + .0 + .iter() + .map(|bytes| G2Affine::deserialize_compressed(bytes.as_slice())) + .collect::, _>>()?; + if keys.iter().any(|p| p.is_zero()) { + return Err("identity pairing key".into()); + } + Ok(Self { verifier, pairings: Pairings { degree_count, keys }, claim_len }) + } + + /// Pack the public pairing operands followed by the fixed-shape KZG proof. + /// The expected certificate claim and trusted verifier are supplied separately. + pub fn encode_proof(&self, proof: &RecursiveProof) -> Result> { + let mut bytes = self.pairings.encode(&proof.pairing_values)?; + bytes.extend(self.verifier.encode_proof(&proof.proof)?); + Ok(bytes) + } + + pub fn verify_bytes(&self, bytes: &[u8], expected: &[Val]) -> Result<()> { + let offset = self.pairings.keys.len() * 48; + if bytes.len() < offset { + return Err("truncated pairing operands".into()); + } + let pairing_values = bytes[..offset] + .as_chunks::<24>() + .0 + .iter() + .map(|chunk| { + let mut limbs = [0; 4]; + for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) { + *limb = u64::from_le_bytes(*bytes); + } + Scalar::from_limbs_le(limbs) + }) + .collect(); + let proof = self.verifier.codec()?.decode(&bytes[offset..])?; + self.verify(&RecursiveProof { proof, pairing_values }, expected) + } + + /// Verify the recursive proof, expected certificate statement, and both + /// external pairing equations. No proving SRS is needed. + pub fn verify(&self, proof: &RecursiveProof, expected: &[Val]) -> Result<()> { + if expected.len() != self.claim_len { + return Err("unexpected certificate claim length".into()); + } + let mut public = scalar_statement(expected); + public.extend_from_slice(&proof.pairing_values); + self.verifier.verify(&proof.proof, &public)?; + self.pairings.verify(&proof.pairing_values) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ec::CurveGroup; + use ark_serialize::CanonicalSerialize; + use multi_stark::plonkish::CircuitBuilder; + use multi_stark::traits::{Algebra, Field}; + + #[test] + fn final_verifier_requires_claim_outer_proof_and_both_pairings() -> Result<()> + { + let inner = Srs::unsafe_dev_setup(8, b"inner-test"); + let outer = Arc::new(Srs::unsafe_dev_setup(128, b"outer-test")); + // Exercise the final verification boundary with small generated outer + // circuits. Actual recursive constraints are tested by prepare_recursive + // and the native_verifier tests, without a large recursive proving run. + for (claim_len, degree_count) in [(2, 0), (5, 2)] { + for invalid_pairing in [None, Some(0), Some(1)] { + if degree_count == 0 && invalid_pairing == Some(0) { + continue; + } + let mut points = vec![]; + let mut keys = vec![]; + if degree_count != 0 { + points.extend([inner.g1[0], -inner.g1[0]]); + keys.extend([inner.g2, inner.g2]); + } + points.extend([inner.g1[1], -inner.g1[0]]); + keys.extend([inner.g2, inner.tau_g2]); + if let Some(equation) = invalid_pairing { + let index = if equation == 0 { 0 } else { degree_count }; + points[index] = + (points[index] * ark_bls12_381::Fr::from(2u8)).into_affine(); + } + let mut packed = vec![]; + for point in points { + let mut bytes = vec![]; + point.serialize_compressed(&mut bytes)?; + for chunk in bytes.as_chunks::<24>().0 { + let mut limbs = [0; 4]; + for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) + { + *limb = u64::from_le_bytes(*bytes); + } + packed.push(Scalar::from_limbs_le(limbs)); + } + } + let expected: Vec<_> = + (0..claim_len).map(|i| Val::from_usize(7 + i)).collect(); + let mut public = scalar_statement(&expected); + public.extend_from_slice(&packed); + let mut b = CircuitBuilder::new(); + let wires: Vec<_> = (0..public.len()) + .map(|i| b.public_input(format!("public{i}"))) + .collect(); + let circuit = b.finish(); + let mut witness = circuit.witness(); + for (&wire, &value) in wires.iter().zip(&public) { + witness.set(wire, value)?; + } + let assignment = witness.generate()?; + let stage = RecursiveStage { + prepared: Prepared { circuit, assignment }, + pairings: Pairings { degree_count, keys }, + claim_len, + }; + let (verifier, mut proof) = stage.prove(outer.clone(), 128)?; + assert_eq!( + verifier.verify(&proof, &expected).is_err(), + invalid_pairing.is_some() + ); + if invalid_pairing.is_none() { + let key_bytes = verifier.to_bytes()?; + let verifier = RecursiveVerifier::from_bytes(&key_bytes)?; + assert_eq!(verifier.to_bytes()?, key_bytes); + assert!( + RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) + .is_err() + ); + let mut extra_key = key_bytes.clone(); + extra_key.push(0); + assert!(RecursiveVerifier::from_bytes(&extra_key).is_err()); + let bytes = verifier.encode_proof(&proof)?; + verifier.verify_bytes(&bytes, &expected)?; + assert!( + verifier + .verify_bytes(&bytes[..bytes.len() - 1], &expected) + .is_err() + ); + let mut extra = bytes.clone(); + extra.push(0); + assert!(verifier.verify_bytes(&extra, &expected).is_err()); + let mut malformed = bytes.clone(); + malformed[..48].fill(0xff); + assert!(verifier.verify_bytes(&malformed, &expected).is_err()); + let mut wrong = expected.clone(); + wrong[0] += Val::ONE; + assert!(verifier.verify(&proof, &wrong).is_err()); + assert!(verifier.verify(&proof, &expected[..claim_len - 1]).is_err()); + proof.pairing_values[0] += Scalar::ONE; + assert!(verifier.verify(&proof, &expected).is_err()); + proof.pairing_values[0] -= Scalar::ONE; + proof.proof.stage_1_opened_values[0][0][0] += Scalar::ONE; + assert!(verifier.verify(&proof, &expected).is_err()); + } + } + } + Ok(()) + } +} diff --git a/crates/proof-compression/src/recursive/mod.rs b/crates/proof-compression/src/recursive/mod.rs new file mode 100644 index 000000000..0f8be92f9 --- /dev/null +++ b/crates/proof-compression/src/recursive/mod.rs @@ -0,0 +1,6 @@ +//! Scalar-field circuit for KZG verification with publicly bound pairing operands. +pub mod native_curve; +pub mod native_field; +pub mod native_msm; +pub mod native_transcript; +pub mod native_verifier; diff --git a/crates/proof-compression/src/recursive/native_curve.rs b/crates/proof-compression/src/recursive/native_curve.rs new file mode 100644 index 000000000..fc3eb33ec --- /dev/null +++ b/crates/proof-compression/src/recursive/native_curve.rs @@ -0,0 +1,258 @@ +//! Affine arithmetic with constrained denominators; exceptional sums reject. +use crate::kzg::Scalar; +use crate::recursive::native_field::Builder; +use crate::recursive::native_field::FqGadget; +use crate::recursive::native_field::FqVar; +use ark_bls12_381::{Fq, G1Affine}; +use ark_ec::{AdditiveGroup, AffineRepr}; +use ark_ff::Field; +use multi_stark::plonkish::Bool; +use multi_stark::plonkish::Value; +use multi_stark::traits::Field as NativeField; + +#[derive(Clone, Copy)] +pub struct Affine { + pub x: FqVar, + pub y: FqVar, +} +#[derive(Clone, Copy)] +pub struct PointInput { + pub point: Affine, + pub infinity: Bool, +} + +impl Affine { + pub fn constant(b: &mut Builder, f: &FqGadget, p: G1Affine) -> Self { + assert!(!p.infinity); + Self { x: f.constant(b, p.x), y: f.constant(b, p.y) } + } + pub fn select(b: &mut Builder, bit: Bool, yes: Self, no: Self) -> Self { + Self { + x: FqVar(std::array::from_fn(|i| b.select(bit, yes.x.0[i], no.x.0[i]))), + y: FqVar(std::array::from_fn(|i| b.select(bit, yes.y.0[i], no.y.0[i]))), + } + } + pub fn on_curve(self, b: &mut Builder, f: &FqGadget) { + let xx = f.mul(b, self.x, self.x); + f.relation(b, &[(1, xx, self.x), (-1, self.y, self.y)], &[], 4); + } + pub fn double(self, b: &mut Builder, f: &FqGadget) -> Self { + let slope = f.hint(b, "doubling slope", &[self.x, self.y], |v| { + Ok( + v[0].square() + * Fq::from(3u8) + * (v[1].double().inverse().ok_or("zero doubling denominator")?), + ) + }); + f.relation(b, &[(2, self.y, slope), (-3, self.x, self.x)], &[], 0); + // For an on-curve input y=0 implies x != 0, so the slope equation + // itself rejects the only possible zero denominator. + let x = f.hint(b, "double x", &[slope, self.x], |v| { + Ok(v[0].square() - v[1].double()) + }); + f.relation(b, &[(1, slope, slope)], &[(-2, self.x), (-1, x)], 0); + let y = f.hint(b, "double y", &[slope, self.x, x, self.y], |v| { + Ok(v[0] * (v[1] - v[2]) - v[3]) + }); + f.relation( + b, + &[(1, slope, self.x), (-1, slope, x)], + &[(-1, self.y), (-1, y)], + 0, + ); + Self { x, y } + } + pub fn add(self, b: &mut Builder, f: &FqGadget, rhs: Self) -> Self { + let inv = f.hint(b, "addition inverse", &[rhs.x, self.x], |v| { + (v[0] - v[1]).inverse().ok_or("exceptional affine addition".into()) + }); + f.relation(b, &[(1, rhs.x, inv), (-1, self.x, inv)], &[], -1); + let slope = f.hint(b, "addition slope", &[rhs.y, self.y, inv], |v| { + Ok((v[0] - v[1]) * v[2]) + }); + f.relation( + b, + &[(1, slope, rhs.x), (-1, slope, self.x)], + &[(-1, rhs.y), (1, self.y)], + 0, + ); + let x = f.hint(b, "sum x", &[slope, self.x, rhs.x], |v| { + Ok(v[0].square() - v[1] - v[2]) + }); + f.relation( + b, + &[(1, slope, slope)], + &[(-1, self.x), (-1, rhs.x), (-1, x)], + 0, + ); + let y = f.hint(b, "sum y", &[slope, self.x, x, self.y], |v| { + Ok(v[0] * (v[1] - v[2]) - v[3]) + }); + f.relation( + b, + &[(1, slope, self.x), (-1, slope, x)], + &[(-1, self.y), (-1, y)], + 0, + ); + Self { x, y } + } + pub fn constant_mul(self, b: &mut Builder, f: &FqGadget, n: u64) -> Self { + assert!(n > 0); + let mut acc = self; + for i in (0..63 - n.leading_zeros()).rev() { + acc = acc.double(b, f); + if n & (1u64 << i) != 0 { + acc = acc.add(b, f, self); + } + } + acc + } + pub fn equal(self, b: &mut Builder, f: &FqGadget, rhs: Self) { + f.equal(b, self.x, rhs.x); + f.equal(b, self.y, rhs.y); + } + pub fn subgroup(self, b: &mut Builder, f: &FqGadget) { + // Same endomorphism criterion as ark-bls12-381's native checker. + let xp = self.constant_mul(b, f, 0xd201000000010000); + let inv = + f.hint(b, "reject exceptional subgroup point", &[xp.x, self.x], |v| { + (v[0] - v[1]).inverse().ok_or("exceptional subgroup point".into()) + }); + f.relation(b, &[(1, xp.x, inv), (-1, self.x, inv)], &[], -1); + let x2p = xp.constant_mul(b, f, 0xd201000000010000); + let beta = f.constant(b, ark_bls12_381::g1::BETA); + f.relation(b, &[(1, beta, self.x)], &[(-1, x2p.x)], 0); + f.relation(b, &[], &[(1, self.y), (1, x2p.y)], 0); + } +} +impl PointInput { + pub fn new(b: &mut Builder, f: &FqGadget, name: &str) -> Self { + let point = Affine { + x: f.input(b, &format!("{name}.x")), + y: f.input(b, &format!("{name}.y")), + }; + let flag = b.input(format!("{name}.infinity")); + let infinity = b.assert_bool(flag); + for v in point.x.0.into_iter().chain(point.y.0) { + let zero = b.mul(v, flag); + b.assert_zero(zero); + } + let result = Self { point, infinity }; + let p = result.nonzero(b, f); + p.on_curve(b, f); + p.subgroup(b, f); + result + } + pub fn nonzero(self, b: &mut Builder, f: &FqGadget) -> Affine { + let g = Affine::constant(b, f, G1Affine::generator()); + Affine::select(b, self.infinity, g, self.point) + } + pub fn inputs(self, p: G1Affine) -> Vec<(Value, Scalar)> { + let (x, y) = + if p.infinity { (Fq::from(0u8), Fq::from(0u8)) } else { (p.x, p.y) }; + self + .point + .x + .0 + .into_iter() + .zip(FqGadget::witness_values(x)) + .chain(self.point.y.0.into_iter().zip(FqGadget::witness_values(y))) + .chain([(self.infinity.value(), Scalar::from_u8(u8::from(p.infinity)))]) + .collect() + } +} + +#[cfg(test)] +fn check_operation(operation: &str) -> Result<(), Box> { + use ark_ec::CurveGroup; + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let p = PointInput::new(&mut b, &f, "point"); + let g = G1Affine::generator(); + let a = p.nonzero(&mut b, &f); + match operation { + "native-subgroup" => {}, + "native-double" => { + let out = a.double(&mut b, &f); + let expected = Affine::constant( + &mut b, + &f, + (g * ark_bls12_381::Fr::from(2u8)).into_affine(), + ); + out.equal(&mut b, &f, expected); + }, + "native-add" => { + let rhs = Affine::constant( + &mut b, + &f, + (g * ark_bls12_381::Fr::from(7u8)).into_affine(), + ); + let out = a.add(&mut b, &f, rhs); + let expected = Affine::constant( + &mut b, + &f, + (g * ark_bls12_381::Fr::from(8u8)).into_affine(), + ); + out.equal(&mut b, &f, expected); + }, + _ => return Err("unknown native operation".into()), + } + let c = b.finish(); + let mut w = c.witness(); + for (v, n) in p.inputs(g) { + w.set(v, n)?; + } + let _a = w.generate()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ec::CurveGroup; + #[test] + fn subgroup_infinity_and_reject_wrong_subgroup() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let p = PointInput::new(&mut b, &f, "p"); + let c = b.finish(); + let run = |point| { + let mut w = c.witness(); + for (v, n) in p.inputs(point) { + w.set(v, n).unwrap(); + } + w.generate() + }; + for n in [0u64, 1, 2, 123456789, u64::MAX] { + run((G1Affine::generator() * ark_bls12_381::Fr::from(n)).into_affine()) + .unwrap(); + } + let torsion = G1Affine::new_unchecked(Fq::from(0u8), Fq::from(2u8)); + assert!(torsion.is_on_curve()); + assert!(!torsion.is_in_correct_subgroup_assuming_on_curve()); + assert!(run(torsion).is_err()); + let mut checked = 0; + for x in 1..100u64 { + if let Some(point) = + G1Affine::get_point_from_x_unchecked(Fq::from(x), false) + { + assert!(!point.is_in_correct_subgroup_assuming_on_curve()); + assert!(run(point).is_err()); + checked += 1; + if checked == 8 { + break; + } + } + } + assert_eq!(checked, 8); + assert!( + run(G1Affine::new_unchecked(Fq::from(7u8), Fq::from(9u8))).is_err() + ); + } + #[test] + fn affine_operations() { + for operation in ["native-add", "native-double"] { + check_operation(operation).unwrap(); + } + } +} diff --git a/crates/proof-compression/src/recursive/native_field.rs b/crates/proof-compression/src/recursive/native_field.rs new file mode 100644 index 000000000..9d3e4d355 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -0,0 +1,490 @@ +//! Bounded Fq arithmetic in a native Fr Plonkish circuit. +use crate::kzg::Scalar; +use ark_bls12_381::Fq; +use ark_ff::{BigInteger, PrimeField}; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Table; +use multi_stark::plonkish::Value; +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; +use num_bigint::{BigInt, BigUint, Sign}; +use std::collections::BTreeMap; + +pub type Builder = CircuitBuilder; +const W: usize = 80; +const L: usize = 5; +const CARRY_BITS: usize = 89; + +#[derive(Clone, Copy, Debug)] +pub struct FqVar(pub [Value; L]); + +pub struct FqGadget { + range: Table, +} + +pub fn integer(v: Scalar) -> BigUint { + BigUint::from_bytes_le( + &v.canonical_limbs_le() + .into_iter() + .flat_map(u64::to_le_bytes) + .collect::>(), + ) +} +pub fn scalar(v: &BigUint) -> Scalar { + let digits = v.to_u64_digits(); + let mut limbs = [0; 4]; + assert!(digits.len() <= 4); + limbs[..digits.len()].copy_from_slice(&digits); + Scalar::from_limbs_le(limbs) +} +fn signed(v: &BigInt) -> Scalar { + let (sign, n) = v.clone().into_parts(); + let f = scalar(&n); + if sign == Sign::Minus { -f } else { f } +} +pub fn modulus() -> BigUint { + BigUint::from_bytes_le(&Fq::MODULUS.to_bytes_le()) +} +fn digits(v: &BigUint) -> [BigUint; L] { + let mask = (BigUint::from(1u8) << W) - 1u8; + std::array::from_fn(|i| (v >> (W * i)) & &mask) +} +pub fn fq_value(values: &[Scalar]) -> Fq { + let n = values + .iter() + .rev() + .fold(BigUint::from(0u8), |acc, &v| (acc << W) + integer(v)); + Fq::from_le_bytes_mod_order(&n.to_bytes_le()) +} + +impl FqGadget { + pub fn new(b: &mut Builder) -> Self { + Self { + range: b.fixed_table( + "Fq u16", + (0..=u16::MAX).map(|v| vec![Scalar::from_u16(v)]).collect(), + ), + } + } + pub fn range(&self, b: &mut Builder, value: Value, bits: usize) { + match bits.div_ceil(16) { + 1 => self.range_n::<1>(b, value, bits), + 2 => self.range_n::<2>(b, value, bits), + 3 => self.range_n::<3>(b, value, bits), + 4 => self.range_n::<4>(b, value, bits), + 5 => self.range_n::<5>(b, value, bits), + 6 => self.range_n::<6>(b, value, bits), + _ => panic!("unsupported range width"), + } + } + fn range_n( + &self, + b: &mut Builder, + value: Value, + bits: usize, + ) { + assert!(bits > 0 && bits <= N * 16); + let limbs = b.hint_many::("bounded integer", &[value], |v| { + let n = integer(v[0]); + Ok(std::array::from_fn(|i| { + scalar(&((&n >> (16 * i)) & BigUint::from(65535u32))) + })) + }); + for &limb in &limbs { + b.lookup(self.range, &[limb]); + } + if !bits.is_multiple_of(16) { + let top = b.scale(limbs[N - 1], Scalar::from_u64(1 << (16 - bits % 16))); + b.lookup(self.range, &[top]); + } + let terms: Vec<_> = limbs + .iter() + .enumerate() + .map(|(i, &v)| (scalar(&(BigUint::from(1u8) << (16 * i))), v)) + .collect(); + let packed = b.linear_combination(&terms, Scalar::ZERO); + b.assert_equal(packed, value); + } + pub fn constant(&self, b: &mut Builder, value: Fq) -> FqVar { + FqVar( + digits(&BigUint::from_bytes_le(&value.into_bigint().to_bytes_le())) + .map(|n| b.constant(scalar(&n))), + ) + } + pub fn input(&self, b: &mut Builder, name: &str) -> FqVar { + let result = FqVar(std::array::from_fn(|i| b.input(format!("{name}.{i}")))); + self.bound(b, result); + self.canonical(b, result); + result + } + pub fn witness_values(value: Fq) -> [Scalar; L] { + digits(&BigUint::from_bytes_le(&value.into_bigint().to_bytes_le())) + .map(|n| scalar(&n)) + } + pub fn hint( + &self, + b: &mut Builder, + name: &str, + inputs: &[FqVar], + f: impl Fn(&[Fq]) -> Result + Send + Sync + 'static, + ) -> FqVar { + let deps: Vec<_> = inputs.iter().flat_map(|v| v.0).collect(); + let result = FqVar(b.hint_many(name, &deps, move |v| { + let args: Vec<_> = + v.as_chunks::().0.iter().map(|v| fq_value(v)).collect(); + Ok(Self::witness_values(f(&args)?)) + })); + self.bound(b, result); + result + } + pub fn bound(&self, b: &mut Builder, v: FqVar) { + for (i, &limb) in v.0.iter().enumerate() { + self.range(b, limb, if i == L - 1 { 61 } else { W }); + } + } + pub fn canonical(&self, b: &mut Builder, v: FqVar) { + let p = digits(&(modulus() - 1u8)); + let p_hint = p.clone(); + let advice = + b.hint_many::<10>("Fq canonical subtraction", &v.0, move |values| { + let mut borrow = BigInt::from(0u8); + let radix = BigInt::from(1u8) << W; + let mut out = [Scalar::ZERO; 10]; + for i in 0..L { + let mut d = BigInt::from(p_hint[i].clone()) + - BigInt::from(integer(values[i])) + - &borrow; + borrow = BigInt::from(u8::from(d.sign() == Sign::Minus)); + if d.sign() == Sign::Minus { + d += &radix; + } + out[i] = signed(&d); + out[L + i] = signed(&borrow); + } + Ok(out) + }); + let radix = scalar(&(BigUint::from(1u8) << W)); + let mut borrow = b.constant(Scalar::ZERO); + for i in 0..L { + self.range(b, advice[i], W); + b.assert_bool(advice[L + i]); + let eq = b.linear_combination( + &[ + (Scalar::NEG_ONE, v.0[i]), + (Scalar::NEG_ONE, borrow), + (radix, advice[L + i]), + (Scalar::NEG_ONE, advice[i]), + ], + scalar(&p[i]), + ); + b.assert_zero(eq); + borrow = advice[L + i]; + } + b.assert_zero(borrow); + } + /// Sum of bounded quadratic and linear terms is zero modulo Fq. + /// Each side has at most three unit-weight products. Limbs are <2^80, + /// field representatives <2^381, quotient+2^383 <2^384. Four signed + /// carries are biased by 2^88 and range-checked to 89 bits. Pairing two + /// radix-2^80 coefficients keeps every integer equality below 2^251 < Fr. + pub fn relation( + &self, + b: &mut Builder, + products: &[(i8, FqVar, FqVar)], + linear: &[(i8, FqVar)], + constant: i8, + ) { + for sign in [-1i8, 1] { + assert!( + products + .iter() + .filter(|(c, _, _)| c.signum() == sign) + .map(|(c, _, _)| usize::from(c.unsigned_abs())) + .sum::() + <= 3 + ); + assert!( + linear + .iter() + .filter(|(c, _)| c.signum() == sign) + .map(|(c, _)| usize::from(c.unsigned_abs())) + .sum::() + <= 3 + ); + } + assert!(constant.unsigned_abs() <= 4); + let deps: Vec<_> = products + .iter() + .flat_map(|(_, a, c)| a.0.into_iter().chain(c.0)) + .chain(linear.iter().flat_map(|(_, a)| a.0)) + .collect(); + let pc: Vec<_> = products.iter().map(|(c, _, _)| *c).collect(); + let lc: Vec<_> = linear.iter().map(|(c, _)| *c).collect(); + let p = digits(&modulus()); + let p_hint = p.clone(); + let advice = + b.hint_many::<9>("Fq quotient and carries", &deps, move |values| { + let mut t = vec![BigInt::from(0u8); 10]; + let mut at = 0; + for &c in &pc { + for i in 0..L { + for j in 0..L { + t[i + j] += BigInt::from(c) + * BigInt::from(integer(values[at + i])) + * BigInt::from(integer(values[at + L + j])); + } + } + at += 2 * L; + } + for &c in &lc { + for i in 0..L { + t[i] += BigInt::from(c) * BigInt::from(integer(values[at + i])); + } + at += L; + } + t[0] += constant; + let total = t.iter().rev().fold(BigInt::from(0u8), |a, c| (a << W) + c); + let prime = BigInt::from(modulus()); + if &total % &prime != BigInt::from(0u8) { + return Err("nonzero Fq relation".into()); + } + let q: BigInt = total / prime + (BigInt::from(1u8) << 383usize); + let q = q.to_biguint().ok_or("negative biased quotient")?; + if q.bits() > 384 { + return Err("Fq quotient overflow".into()); + } + let q = digits(&q); + let bias = digits(&(BigUint::from(1u8) << 383)); + for i in 0..L { + for j in 0..L { + t[i + j] -= BigInt::from(p_hint[i].clone()) + * (BigInt::from(q[j].clone()) - BigInt::from(bias[j].clone())); + } + } + let mut out = [Scalar::ZERO; 9]; + for i in 0..L { + out[i] = scalar(&q[i]); + } + let radix = BigInt::from(1u8) << W; + let block_radix = &radix * &radix; + let mut carry = BigInt::from(0u8); + for j in 0..5 { + carry += &t[2 * j] + &radix * &t[2 * j + 1]; + if &carry % &block_radix != BigInt::from(0u8) { + return Err("nonintegral Fq carry".into()); + } + carry /= &block_radix; + if j < 4 { + let biased: BigInt = &carry + (BigInt::from(1u8) << 88usize); + let v = biased.to_biguint().ok_or("negative biased carry")?; + if v.bits() > CARRY_BITS as u64 { + return Err("Fq carry overflow".into()); + } + out[L + j] = scalar(&v); + } + } + if carry != BigInt::from(0u8) { + return Err("nonzero final Fq carry".into()); + } + Ok(out) + }); + for (i, &v) in advice[..L].iter().enumerate() { + self.range(b, v, if i == L - 1 { 64 } else { W }); + } + for &v in &advice[L..] { + self.range(b, v, CARRY_BITS); + } + let mut terms: Vec> = vec![vec![]; 10]; + let mut cached = BTreeMap::new(); + for &(c, a, d) in products { + for i in 0..L { + for j in 0..L { + let key = ( + a.0[i].index().min(d.0[j].index()), + a.0[i].index().max(d.0[j].index()), + ); + let v = *cached.entry(key).or_insert_with(|| b.mul(a.0[i], d.0[j])); + terms[i + j].push((signed(&BigInt::from(c)), v)); + } + } + } + for &(c, a) in linear { + for (terms, v) in terms.iter_mut().zip(a.0) { + terms.push((signed(&BigInt::from(c)), v)); + } + } + let bias = digits(&(BigUint::from(1u8) << 383)); + let mut constants = vec![BigInt::from(0u8); 10]; + constants[0] += constant; + for i in 0..L { + for j in 0..L { + terms[i + j].push((-scalar(&p[i]), advice[j])); + constants[i + j] += BigInt::from(&p[i] * &bias[j]); + } + } + let radix = scalar(&(BigUint::from(1u8) << W)); + let block = radix * radix; + let carry_bias = scalar(&(BigUint::from(1u8) << 88)); + for j in 0..5 { + let mut equation = terms[2 * j].clone(); + equation.extend(terms[2 * j + 1].iter().map(|&(c, v)| (c * radix, v))); + let mut constant = + signed(&constants[2 * j]) + radix * signed(&constants[2 * j + 1]); + if j > 0 { + equation.push((Scalar::ONE, advice[L + j - 1])); + constant -= carry_bias; + } + if j < 4 { + equation.push((-block, advice[L + j])); + constant += block * carry_bias; + } + let value = b.linear_combination(&equation, constant); + b.assert_zero(value); + } + } + pub fn mul(&self, b: &mut Builder, x: FqVar, y: FqVar) -> FqVar { + let result = self.hint(b, "Fq product", &[x, y], |v| Ok(v[0] * v[1])); + self.relation(b, &[(1, x, y)], &[(-1, result)], 0); + result + } + pub fn equal(&self, b: &mut Builder, x: FqVar, y: FqVar) { + self.relation(b, &[], &[(1, x), (-1, y)], 0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::plonkish::Assignment; + use multi_stark::plonkish::Circuit; + use std::collections::{HashMap, HashSet}; + + pub(super) fn check_mutations(c: &Circuit, a: &Assignment) { + let handles: HashMap<_, _> = c + .gates() + .iter() + .flat_map(|g| g.wires) + .chain(c.lookups().iter().flat_map(|l| l.values.iter().copied())) + .map(|v| (v.index(), v)) + .collect(); + let tables: Vec> = + c.tables().iter().map(|t| t.rows().iter().cloned().collect()).collect(); + let satisfied = |changed: Option| { + let value = |v: Value| { + a.value(v).unwrap() + + if changed == Some(v.index()) { Scalar::ONE } else { Scalar::ZERO } + }; + c.gates().iter().all(|g| { + let [a, b, c] = g.wires.map(value); + let [qm, qa, qb, qc, k] = g.coefficients; + qm * a * b + qa * a + qb * b + qc * c + k == Scalar::ZERO + }) && c.lookups().iter().all(|l| { + tables[l.table.index()] + .contains(&l.values.iter().copied().map(value).collect::>()) + }) + }; + assert!(satisfied(None)); + for &index in handles.keys() { + assert!(!satisfied(Some(index)), "unconstrained value {index}"); + } + } + + #[test] + fn products_and_adversarial_assignments() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let x = f.input(&mut b, "x"); + let y = f.input(&mut b, "y"); + let out = f.mul(&mut b, x, y); + let c = b.finish(); + let cases = [ + (Fq::from(0u8), Fq::from(1u8)), + (-Fq::from(1u8), -Fq::from(1u8)), + (Fq::from(42u8), Fq::from(999u64)), + ]; + for (xv, yv) in cases { + let mut w = c.witness(); + for (v, n) in x + .0 + .into_iter() + .zip(FqGadget::witness_values(xv)) + .chain(y.0.into_iter().zip(FqGadget::witness_values(yv))) + { + w.set(v, n).unwrap(); + } + let a = w.generate().unwrap(); + assert_eq!(fq_value(&out.0.map(|v| a.value(v).unwrap())), xv * yv); + check_mutations(&c, &a); + } + } + + #[test] + fn rejects_noncanonical_and_out_of_range() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let x = f.input(&mut b, "x"); + let c = b.finish(); + for n in [modulus(), modulus() + 1u8, (BigUint::from(1u8) << 381) - 1u8] { + let mut w = c.witness(); + for (v, n) in x.0.into_iter().zip(digits(&n)) { + w.set(v, scalar(&n)).unwrap(); + } + assert!(w.generate().is_err()); + } + for i in 0..L { + let mut w = c.witness(); + for (j, v) in x.0.into_iter().enumerate() { + w.set( + v, + if i == j { + scalar(&(BigUint::from(1u8) << if i == 4 { 61 } else { 80 })) + } else { + Scalar::ZERO + }, + ) + .unwrap(); + } + assert!(w.generate().is_err()); + } + } + + #[test] + fn bounded_relations_accept_redundant_representatives() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let x = FqVar(std::array::from_fn(|i| b.input(format!("x{i}")))); + f.bound(&mut b, x); + let z = f.constant(&mut b, Fq::from(0u8)); + f.equal(&mut b, x, z); + // Exercise the maximum permitted quadratic weight and negative quotient. + f.relation(&mut b, &[(-3, x, x)], &[(3, x)], 0); + let c = b.finish(); + let mut w = c.witness(); + for (v, n) in x.0.into_iter().zip(digits(&modulus())) { + w.set(v, scalar(&n)).unwrap(); + } + check_mutations(&c, &w.generate().unwrap()); + } + + #[test] + fn integer_bounds_do_not_wrap_fr() { + use ark_bls12_381::Fr; + let one = BigUint::from(1u8); + let radix = &one << 80; + let max = (&one << 381) - 1u8; + let max_expression = 3u8 * &max * &max + 3u8 * &max + 4u8; + assert!(max_expression / modulus() < (&one << 383)); + // Up to 15 products on either side of a coefficient, plus five + // quotient*modulus terms, three linear limbs, and a constant. + let coefficient_bound = 20u8 * &radix * &radix + 3u8 * &radix + 4u8; + let carry_bound = &one << 88; + let equation_bound: BigUint = (&radix + 1u8) * &coefficient_bound + + (&radix * &radix + 1u8) * &carry_bound; + let fr = BigUint::from_bytes_le(&Fr::MODULUS.to_bytes_le()); + assert!(equation_bound < fr); + assert!( + ((&radix + 1u8) * coefficient_bound + &carry_bound) / (&radix * &radix) + < carry_bound + ); + } +} diff --git a/crates/proof-compression/src/recursive/native_msm.rs b/crates/proof-compression/src/recursive/native_msm.rs new file mode 100644 index 000000000..12744bc1b --- /dev/null +++ b/crates/proof-compression/src/recursive/native_msm.rs @@ -0,0 +1,173 @@ +use crate::kzg::Scalar; +use crate::recursive::native_curve::Affine; +use crate::recursive::native_curve::PointInput; +use crate::recursive::native_field::Builder; +use crate::recursive::native_field::FqGadget; +use crate::recursive::native_transcript::scalar_bytes; +use ark_bls12_381::{Fr, G1Affine}; +use ark_ec::{AffineRepr, CurveGroup}; +use ark_ff::{BigInteger, PrimeField}; +use multi_stark::plonkish::Bool; +use multi_stark::plonkish::Value; +use multi_stark::plonkish::gadgets::ByteGadgets; +use multi_stark::traits::Algebra; + +pub struct Base { + pub point: PointInput, + pub fixed: Option, + table: Option<[Affine; 16]>, +} +impl Base { + pub fn new(point: PointInput, fixed: Option) -> Self { + Self { point, fixed, table: None } + } + fn table(&mut self, b: &mut Builder, f: &FqGadget) -> [Affine; 16] { + if let Some(table) = self.table { + return table; + } + let p = self.point.nonzero(b, f); + let mut table = [p; 16]; + if let Some(point) = self.fixed { + let point = if point.infinity { G1Affine::generator() } else { point }; + table = std::array::from_fn(|i| { + Affine::constant(b, f, (point * Fr::from((i + 1) as u64)).into_affine()) + }); + } else { + for n in 2..=16 { + table[n - 1] = if n % 2 == 0 { + table[n / 2 - 1].double(b, f) + } else { + table[n - 2].add(b, f, p) + }; + } + } + self.table = Some(table); + table + } +} +fn select(b: &mut Builder, table: &[Affine; 16], bits: &[Bool]) -> Affine { + let mut layer = table.to_vec(); + for &bit in bits { + layer = layer + .as_chunks::<2>() + .0 + .iter() + .map(|pair| Affine::select(b, bit, pair[1], pair[0])) + .collect(); + } + layer[0] +} +fn constant_mul(b: &mut Builder, f: &FqGadget, p: Affine, n: Fr) -> Affine { + let bits = n.into_bigint().to_bits_be(); + let first = bits.iter().position(|&v| v).expect("nonzero multiplier"); + let mut acc = p; + for &bit in &bits[first + 1..] { + acc = acc.double(b, f); + if bit { + acc = acc.add(b, f, p); + } + } + acc +} +pub fn negate(b: &mut Builder, f: &FqGadget, p: Affine) -> Affine { + let y = f.hint(b, "negative y", &[p.y], |v| Ok(-v[0])); + f.relation(b, &[], &[(1, p.y), (1, y)], 0); + Affine { x: p.x, y } +} + +/// Fixed-shape four-bit Straus MSM. Offsets avoid zero digits; constrained +/// inverses reject exceptional intermediate sums, never bypass them. +pub fn msm( + b: &mut Builder, + f: &FqGadget, + bytes: &ByteGadgets, + bases: &mut [Base], + terms: &[(usize, Value)], + tag: &str, +) -> Affine { + assert!(!terms.is_empty()); + let mut wide = [0; 64]; + blake3::Hasher::new() + .update(b"kzg-wrap/msm-offset/v1") + .update(tag.as_bytes()) + .finalize_xof() + .fill(&mut wide); + let h = + (G1Affine::generator() * Fr::from_le_bytes_mod_order(&wide)).into_affine(); + let mut sum = Affine::constant(b, f, h); + let mut tables = Vec::new(); + let mut digits = Vec::new(); + for &(id, scalar) in terms { + let base = &mut bases[id]; + let zero = b.constant(Scalar::ZERO); + let scalar = b.select(base.point.infinity, zero, scalar); + let enc = scalar_bytes(b, bytes, scalar); + digits + .push(enc.into_iter().flat_map(|v| bytes.bits(b, v)).collect::>()); + let table = base.table(b, f); + sum = sum.add(b, f, table[0]); + tables.push(table); + } + let mut acc = Affine::constant(b, f, h); + for window in (0..64).rev() { + for _ in 0..4 { + acc = acc.double(b, f); + } + for (table, bits) in tables.iter().zip(&digits) { + let p = select(b, table, &bits[4 * window..4 * window + 4]); + acc = acc.add(b, f, p); + } + } + let mut k = Fr::from(0u8); + let mut power = Fr::from(1u8); + for _ in 0..64 { + k += power; + power *= Fr::from(16u8); + } + let ks = constant_mul(b, f, sum, k); + let correction = Affine::constant(b, f, (h * (power - k)).into_affine()); + let correction = ks.add(b, f, correction); + let neg = negate(b, f, correction); + acc.add(b, f, neg) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::recursive::native_field::fq_value; + #[test] + fn msm_matches_native_with_zero_scalars_and_identity() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + let points: Vec<_> = + (0..3).map(|i| PointInput::new(&mut b, &f, &format!("p{i}"))).collect(); + let mut bases: Vec<_> = + points.iter().map(|&p| Base::new(p, None)).collect(); + let scalars: Vec<_> = (0..3).map(|i| b.input(format!("s{i}"))).collect(); + let terms: Vec<_> = + scalars.iter().enumerate().map(|(i, &v)| (i, v)).collect(); + let result = msm(&mut b, &f, &bytes, &mut bases, &terms, "test"); + let c = b.finish(); + for (ns, ss) in [([2u64, 3, 7], [11u64, 0, 17]), ([0, 9, 11], [17, 91, 0])] + { + let mut w = c.witness(); + let mut expected = ark_bls12_381::G1Projective::default(); + for i in 0..3 { + let p = (G1Affine::generator() * Fr::from(ns[i])).into_affine(); + let s = Fr::from(ss[i]); + expected += p * s; + for (v, n) in points[i].inputs(p) { + w.set(v, n).unwrap(); + } + w.set(scalars[i], Scalar(s)).unwrap(); + } + let a = w.generate().unwrap(); + let actual = G1Affine::new_unchecked( + fq_value(&result.x.0.map(|v| a.value(v).unwrap())), + fq_value(&result.y.0.map(|v| a.value(v).unwrap())), + ); + assert_eq!(actual, expected.into_affine()); + } + } +} diff --git a/crates/proof-compression/src/recursive/native_transcript.rs b/crates/proof-compression/src/recursive/native_transcript.rs new file mode 100644 index 000000000..82f43d75e --- /dev/null +++ b/crates/proof-compression/src/recursive/native_transcript.rs @@ -0,0 +1,242 @@ +use crate::kzg::Scalar; +use crate::recursive::native_curve::PointInput; +use crate::recursive::native_field::Builder; +use crate::recursive::native_field::FqVar; +use crate::recursive::native_field::integer; +use crate::recursive::native_field::modulus; +use ark_bls12_381::Fr; +use ark_ff::{BigInteger, PrimeField}; +use multi_stark::plonkish::Bool; +use multi_stark::plonkish::Value; +use multi_stark::plonkish::gadgets::ByteGadgets; +use multi_stark::plonkish::gadgets::ByteValue; +use multi_stark::plonkish::gadgets::blake3_xof; +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; +use num_bigint::BigUint; + +/// Final borrow of bound - bytes (both unsigned little endian). +fn greater_than( + b: &mut Builder, + bytes: &ByteGadgets, + input: &[ByteValue], + bound: &[u8], +) -> Bool { + assert_eq!(input.len(), bound.len()); + let mut borrow = b.constant(Scalar::ZERO); + for (&value, &limit) in input.iter().zip(bound) { + let out = b.hint_many::<2>( + "byte subtraction", + &[value.value(), borrow], + move |v| { + let x = i64::try_from(v[0].canonical_limbs_le()[0]) + .map_err(|e| e.to_string())?; + let d = i64::from(limit) + - x + - i64::try_from(v[1].canonical_limbs_le()[0]) + .map_err(|e| e.to_string())?; + Ok([ + Scalar::from_u64(d.rem_euclid(256) as u64), + Scalar::from_u8(u8::from(d < 0)), + ]) + }, + ); + let diff = bytes.constrain_byte(b, out[0]); + b.assert_bool(out[1]); + let eq = b.linear_combination( + &[ + (Scalar::NEG_ONE, value.value()), + (Scalar::NEG_ONE, borrow), + (Scalar::from_u16(256), out[1]), + (Scalar::NEG_ONE, diff.value()), + ], + Scalar::from_u8(limit), + ); + b.assert_zero(eq); + borrow = out[1]; + } + b.assert_bool(borrow) +} + +pub fn scalar_bytes( + b: &mut Builder, + bytes: &ByteGadgets, + v: Value, +) -> [ByteValue; 32] { + let raw = b.hint_many::<32>("scalar bytes", &[v], |v| { + let data: Vec<_> = v[0] + .canonical_limbs_le() + .into_iter() + .flat_map(u64::to_le_bytes) + .collect(); + Ok(std::array::from_fn(|i| Scalar::from_u8(data[i]))) + }); + let result = raw.map(|v| bytes.constrain_byte(b, v)); + let mut coeff = Scalar::ONE; + let terms: Vec<_> = raw + .into_iter() + .map(|v| { + let term = (coeff, v); + coeff *= Scalar::from_u16(256); + term + }) + .collect(); + let packed = b.linear_combination(&terms, Scalar::ZERO); + b.assert_equal(packed, v); + let modulus = BigUint::from_bytes_le(&Fr::MODULUS.to_bytes_le()) - 1u8; + let mut bound = modulus.to_bytes_le(); + bound.resize(32, 0); + let invalid = greater_than(b, bytes, &result, &bound); + b.assert_zero(invalid.value()); + result +} + +pub fn fq_bytes( + b: &mut Builder, + bytes: &ByteGadgets, + v: FqVar, +) -> [ByteValue; 48] { + let mut result = Vec::new(); + for (i, &limb) in v.0.iter().enumerate() { + let raw = b.hint_many::<10>("Fq limb bytes", &[limb], |v| { + let mut data = integer(v[0]).to_bytes_le(); + data.resize(10, 0); + Ok(std::array::from_fn(|i| Scalar::from_u8(data[i]))) + }); + let values = raw.map(|v| bytes.constrain_byte(b, v)); + let mut coeff = Scalar::ONE; + let terms: Vec<_> = raw + .into_iter() + .map(|v| { + let term = (coeff, v); + coeff *= Scalar::from_u16(256); + term + }) + .collect(); + let packed = b.linear_combination(&terms, Scalar::ZERO); + b.assert_equal(packed, limb); + if i == 4 { + b.assert_zero(raw[8]); + b.assert_zero(raw[9]); + result.extend_from_slice(&values[..8]); + } else { + result.extend(values); + } + } + result.try_into().unwrap() +} + +/// The point must have canonical coordinates, with zero coordinates at infinity. +pub fn point_bytes( + b: &mut Builder, + bytes: &ByteGadgets, + p: PointInput, +) -> [ByteValue; 48] { + let x = fq_bytes(b, bytes, p.point.x); + let y = fq_bytes(b, bytes, p.point.y); + let mut half = ((modulus() - 1u8) / 2u8).to_bytes_le(); + half.resize(48, 0); + let sign = greater_than(b, bytes, &y, &half); + let flagged = b.linear_combination( + &[ + (Scalar::ONE, x[47].value()), + (Scalar::from_u8(64), p.infinity.value()), + (Scalar::from_u8(32), sign.value()), + ], + Scalar::from_u8(128), + ); + let first = bytes.constrain_byte(b, flagged); + std::array::from_fn(|i| if i == 0 { first } else { x[47 - i] }) +} + +pub struct Transcript { + state: [ByteValue; 32], + buffer: Vec, +} +impl Transcript { + pub fn new(b: &mut Builder, bytes: &ByteGadgets, seed: &[u8]) -> Self { + Self { + state: [bytes.constant(b, 0); 32], + buffer: seed.iter().map(|&v| bytes.constant(b, v)).collect(), + } + } + pub fn observe(&mut self, values: &[ByteValue]) { + self.buffer.extend_from_slice(values); + } + pub fn constant( + &mut self, + b: &mut Builder, + bytes: &ByteGadgets, + data: &[u8], + ) { + self.buffer.extend(data.iter().map(|&v| bytes.constant(b, v))); + } + #[cfg(test)] + pub fn scalar(&mut self, b: &mut Builder, bytes: &ByteGadgets, v: Value) { + self.observe(&scalar_bytes(b, bytes, v)); + } + pub fn sample(&mut self, b: &mut Builder, bytes: &ByteGadgets) -> Value { + let mut input = self.state.to_vec(); + input.append(&mut self.buffer); + let out = blake3_xof(b, bytes, &input, 96); + self.state.copy_from_slice(&out[64..]); + let mut coeff = Scalar::ONE; + let terms: Vec<_> = out[..64] + .iter() + .map(|v| { + let term = (coeff, v.value()); + coeff *= Scalar::from_u16(256); + term + }) + .collect(); + b.linear_combination(&terms, Scalar::ZERO) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::Blake3Transcript; + use crate::recursive::native_curve::PointInput; + use crate::recursive::native_field::FqGadget; + use ark_bls12_381::G1Affine; + use ark_ec::{AffineRepr, CurveGroup}; + use ark_serialize::CanonicalSerialize; + use multi_stark::traits::Transcript as _; + #[test] + fn transcript_and_point_encoding_match_native() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + let p = PointInput::new(&mut b, &f, "point"); + let encoded = point_bytes(&mut b, &bytes, p); + let v = b.input("scalar"); + let mut t = Transcript::new(&mut b, &bytes, b"test"); + t.observe(&encoded); + t.scalar(&mut b, &bytes, v); + let first = t.sample(&mut b, &bytes); + let second = t.sample(&mut b, &bytes); + let c = b.finish(); + for n in [0u64, 1, 2, 17] { + let pval = (G1Affine::generator() * Fr::from(n)).into_affine(); + let scalar = Scalar(-Fr::from(n + 1)); + let mut w = c.witness(); + for (v, n) in p.inputs(pval) { + w.set(v, n).unwrap(); + } + w.set(v, scalar).unwrap(); + let a = w.generate().unwrap(); + let mut expected = vec![]; + pval.serialize_compressed(&mut expected).unwrap(); + for (v, &n) in encoded.iter().zip(&expected) { + assert_eq!(a.value(v.value()).unwrap(), Scalar::from_u8(n)); + } + let mut t = Blake3Transcript::new(); + t.observe_bytes(b"test"); + t.observe_bytes(&expected); + t.observe_field(scalar); + assert_eq!(a.value(first).unwrap(), t.sample_challenge()); + assert_eq!(a.value(second).unwrap(), t.sample_challenge()); + } + } +} diff --git a/crates/proof-compression/src/recursive/native_verifier.rs b/crates/proof-compression/src/recursive/native_verifier.rs new file mode 100644 index 000000000..01917b7c5 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier.rs @@ -0,0 +1,718 @@ +//! Fixed-profile ordinary KZG verification with publicly bound pairing inputs. +#[cfg(test)] +use crate::foreign::KzgCircuitExt; + +use crate::kzg::KzgCommitment; +use crate::kzg::KzgConfig; +use crate::kzg::Scalar; +#[cfg(test)] +use crate::kzg::Srs; +use crate::kzg::VerifierParams; +use crate::recursive::native_curve::Affine; +use crate::recursive::native_curve::PointInput; +use crate::recursive::native_field::Builder; +use crate::recursive::native_field::FqGadget; +use crate::recursive::native_msm; +use crate::recursive::native_msm::Base; +use crate::recursive::native_transcript; +use crate::recursive::native_transcript::Transcript; +use ark_bls12_381::{G1Affine, G2Affine}; +use ark_serialize::CanonicalSerialize; +use multi_stark::config::ProofConfig; +use multi_stark::expr::RowOffset; +use multi_stark::expr::Source; +use multi_stark::graph::Node; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::Value; +use multi_stark::plonkish::gadgets::ByteGadgets; +use multi_stark::plonkish::gadgets::ByteValue; +use multi_stark::prover::Proof; +use multi_stark::system::System; +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; +use multi_stark::traits::Pcs; +use multi_stark::traits::TwoAdicField; +use std::collections::BTreeMap; + +type Commitment = (Vec>, Vec>); +type Openings = Vec>>; + +pub struct Built { + pub circuit: Circuit, + pub inputs: Vec<(Value, Scalar)>, + pub pairing_keys: Vec, + pub degree_output_count: usize, +} +struct Context { + b: Builder, + f: FqGadget, + bytes: ByteGadgets, + inputs: Vec<(Value, Scalar)>, + bases: Vec, + encodings: Vec<[ByteValue; 48]>, + scalar_encodings: BTreeMap, +} +impl Context { + fn new() -> Self { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + Self { + b, + f, + bytes, + inputs: vec![], + bases: vec![], + encodings: vec![], + scalar_encodings: BTreeMap::new(), + } + } + fn scalar(&mut self, name: &str, value: Scalar) -> Value { + let v = self.b.input(name); + self.inputs.push((v, value)); + v + } + fn point(&mut self, value: G1Affine, fixed: bool) -> usize { + let id = self.bases.len(); + let p = if fixed { + let point = Affine { + x: self.f.constant(&mut self.b, value.x), + y: self.f.constant( + &mut self.b, + if value.infinity { ark_bls12_381::Fq::from(0u8) } else { value.y }, + ), + }; + let flag = self.b.constant(Scalar::from_bool(value.infinity)); + let infinity = self.b.assert_bool(flag); + PointInput { point, infinity } + } else { + let p = PointInput::new(&mut self.b, &self.f, &format!("point{id}")); + self.inputs.extend(p.inputs(value)); + p + }; + let encoded = if fixed { + let mut encoded = vec![]; + value.serialize_compressed(&mut encoded).unwrap(); + std::array::from_fn(|i| self.bytes.constant(&mut self.b, encoded[i])) + } else { + native_transcript::point_bytes(&mut self.b, &self.bytes, p) + }; + self.bases.push(Base::new(p, fixed.then_some(value))); + self.encodings.push(encoded); + if id % 64 == 63 { + tracing::debug!( + "Allocated {} curve points: {:?}", + id + 1, + self.b.stats() + ); + } + id + } + fn commitment(&mut self, c: &KzgCommitment, fixed: bool) -> Commitment { + ( + c.0 + .iter() + .map(|row| row.iter().map(|&p| self.point(p, fixed)).collect()) + .collect(), + c.1 + .iter() + .map(|row| row.iter().map(|&p| self.point(p, fixed)).collect()) + .collect(), + ) + } + fn openings(&mut self, name: &str, values: &[Vec>]) -> Openings { + values + .iter() + .enumerate() + .map(|(i, rows)| { + rows + .iter() + .enumerate() + .map(|(j, row)| { + row + .iter() + .enumerate() + .map(|(k, &v)| self.scalar(&format!("{name}.{i}.{j}.{k}"), v)) + .collect() + }) + .collect() + }) + .collect() + } + fn observe_scalar(&mut self, t: &mut Transcript, v: Value) { + let bytes = *self.scalar_encodings.entry(v.index()).or_insert_with(|| { + native_transcript::scalar_bytes(&mut self.b, &self.bytes, v) + }); + t.observe(&bytes); + } + fn observe_constant(&mut self, t: &mut Transcript, v: usize) { + let v = self.b.constant(Scalar::from_usize(v)); + self.observe_scalar(t, v); + } + fn observe_commitment(&mut self, t: &mut Transcript, c: &Commitment) { + for rows in [&c.0, &c.1] { + t.constant(&mut self.b, &self.bytes, &(rows.len() as u64).to_le_bytes()); + for row in rows { + t.constant(&mut self.b, &self.bytes, &(row.len() as u64).to_le_bytes()); + for &id in row { + t.observe(&self.encodings[id]); + } + } + } + } + fn sample(&mut self, t: &mut Transcript) -> Value { + t.sample(&mut self.b, &self.bytes) + } +} +fn fp( + b: &mut Builder, + gamma: Value, + args: impl DoubleEndedIterator, +) -> Value { + let mut acc = b.constant(Scalar::ZERO); + for arg in args.rev() { + acc = b.mul_add(acc, gamma, arg); + } + acc +} +fn combine(b: &mut Builder, terms: Vec<(usize, Value)>) -> Vec<(usize, Value)> { + let mut unique = BTreeMap::::new(); + for (id, v) in terms { + if let Some(old) = unique.get_mut(&id) { + *old = b.add(*old, v); + } else { + unique.insert(id, v); + } + } + unique.into_iter().collect() +} + +pub fn build( + system: &System, + srs: &VerifierParams, + proof: &Proof, + claims: &[Vec], + public_locations: &[(usize, usize)], +) -> Result { + let quotients = + system.verify_shape(proof).map_err(|e| format!("shape: {e:?}"))?; + if !proof.active.iter().all(|&v| v) + || system + .preprocessed_indices + .iter() + .enumerate() + .any(|(i, &s)| s != Some(i)) + { + return Err( + "wrapper requires every circuit active and one fixed matrix per circuit" + .into(), + ); + } + let logs = &proof.log_degrees; + let mut c = Context::new(); + let fixed = c.commitment( + system.preprocessed_commit.as_ref().ok_or("missing fixed commitment")?, + true, + ); + let main = c.commitment(&proof.commitments.stage_1_trace, false); + let stage2 = c.commitment(&proof.commitments.stage_2_trace, false); + let quotient = c.commitment(&proof.commitments.quotient_chunks, false); + let witnesses: Vec<_> = + proof.opening_proof.0.iter().map(|&p| c.point(p, false)).collect(); + let generator = c.point(srs.g1[0], true); + tracing::debug!("Curve inputs complete: {:?}", c.b.stats()); + let accs: Vec<_> = proof + .intermediate_accumulators + .iter() + .enumerate() + .map(|(i, &v)| c.scalar(&format!("acc{i}"), v)) + .collect(); + c.b.assert_zero(*accs.last().ok_or("empty accumulators")?); + let main_open = c.openings("main", &proof.stage_1_opened_values); + let stage2_open = c.openings("stage2", &proof.stage_2_opened_values); + let quotient_open = c.openings("quotient", &proof.quotient_opened_values); + let fixed_open = c.openings( + "fixed", + proof + .preprocessed_opened_values + .as_ref() + .ok_or("missing fixed openings")?, + ); + let mut public_claims = BTreeMap::new(); + for &(i, j) in public_locations { + let value = claims + .get(i) + .and_then(|row| row.get(j)) + .ok_or("public claim location out of bounds")?; + if public_claims.contains_key(&(i, j)) { + return Err("duplicate public claim location".into()); + } + let v = c.scalar(&format!("claim.{i}.{j}"), *value); + c.f.range(&mut c.b, v, 64); + c.b.expose_public(v); + public_claims.insert((i, j), v); + } + let claim_vars: Vec> = claims + .iter() + .enumerate() + .map(|(i, row)| { + row + .iter() + .enumerate() + .map(|(j, &value)| { + public_claims + .get(&(i, j)) + .copied() + .unwrap_or_else(|| c.b.constant(value)) + }) + .collect() + }) + .collect(); + let mut seed = b"multi-stark/kzg/v3".to_vec(); + seed.extend((system.config.max_log_degree() as u64).to_le_bytes()); + seed.extend((system.config.pcs().max_quotient_degree() as u64).to_le_bytes()); + srs.g1[0].serialize_compressed(&mut seed).unwrap(); + srs.g2.serialize_compressed(&mut seed).unwrap(); + srs.g1[1].serialize_compressed(&mut seed).unwrap(); + srs.tau_g2.serialize_compressed(&mut seed).unwrap(); + let mut t = Transcript::new(&mut c.b, &c.bytes, &seed); + c.observe_constant(&mut t, system.circuits.len()); + for circuit in &system.circuits { + for v in [ + circuit.constraint_count(), + circuit.max_constraint_degree(), + circuit.preprocessed_height, + circuit.preprocessed_width, + circuit.main_width, + circuit.stage_2_width, + circuit.lookup_group_size, + ] { + c.observe_constant(&mut t, v); + } + } + for _ in logs { + c.observe_constant(&mut t, 1); + } + c.observe_commitment(&mut t, &fixed); + c.observe_commitment(&mut t, &main); + for &log in logs { + c.observe_constant(&mut t, usize::from(log)); + } + c.observe_constant(&mut t, claims.len()); + for row in &claim_vars { + c.observe_constant(&mut t, row.len()); + for &v in row { + c.observe_scalar(&mut t, v); + } + } + let beta = c.sample(&mut t); + c.observe_scalar(&mut t, beta); + let gamma = c.sample(&mut t); + c.observe_scalar(&mut t, gamma); + let mut initial_acc = c.b.constant(Scalar::ZERO); + for row in &claim_vars { + let hash = fp(&mut c.b, gamma, row.iter().copied()); + let message = c.b.add(beta, hash); + let inv = c.b.inverse(message); + initial_acc = c.b.add(initial_acc, inv); + } + c.observe_commitment(&mut t, &stage2); + for &v in &accs { + c.observe_scalar(&mut t, v); + } + let alpha = c.sample(&mut t); + c.observe_commitment(&mut t, "ient); + let zeta = c.sample(&mut t); + c.b.inverse(zeta); // fixes distinct-point batch shape (all generators differ). + let rounds = [ + (&main, &main_open), + (&stage2, &stage2_open), + ("ient, "ient_open), + (&fixed, &fixed_open), + ]; + for (commit, _) in rounds { + c.observe_commitment(&mut t, commit); + for &log in logs { + t.constant(&mut c.b, &c.bytes, &u64::from(log).to_le_bytes()); + } + } + let degree_challenge = c.sample(&mut t); + let mut degree_groups = BTreeMap::>::new(); + let mut shifted = vec![]; + let mut weight = c.b.constant(Scalar::ONE); + for (commit, _) in rounds { + for (i, &log) in logs.iter().enumerate() { + if usize::from(log) < system.config.max_log_degree() { + if commit.0[i].len() != commit.1[i].len() { + return Err("shifted commitment width".into()); + } + for (&p, &s) in commit.0[i].iter().zip(&commit.1[i]) { + degree_groups.entry(log).or_default().push((p, weight)); + shifted.push((s, weight)); + weight = c.b.mul(weight, degree_challenge); + } + } else if !commit.1[i].is_empty() { + return Err("unexpected shifted commitments".into()); + } + } + } + let mut batches: Vec<(Scalar, Vec, Vec)> = vec![]; + for (commit, openings) in rounds { + for (i, rows) in openings.iter().enumerate() { + for (row_index, row) in rows.iter().enumerate() { + for &v in row { + c.observe_scalar(&mut t, v); + } + let g = if row_index == 0 { + Scalar::ONE + } else { + Scalar::two_adic_generator(usize::from(logs[i])) + }; + let index = if let Some(index) = + batches.iter().position(|(key, _, _)| *key == g) + { + index + } else { + batches.push((g, vec![], vec![])); + batches.len() - 1 + }; + batches[index].1.extend_from_slice(&commit.0[i]); + batches[index].2.extend_from_slice(row); + } + } + } + if witnesses.len() != batches.len() { + return Err("opening witness count".into()); + } + let v = c.sample(&mut t); + for &id in &witnesses { + t.observe(&c.encodings[id]); + } + let r = c.sample(&mut t); + tracing::debug!("Transcript complete: {:?}", c.b.stats()); + let mut lhs = vec![]; + let mut rhs = vec![]; + let mut r_power = c.b.constant(Scalar::ONE); + for ((g, points, values), &witness) in batches.iter().zip(&witnesses) { + let mut v_power = c.b.constant(Scalar::ONE); + let mut y = c.b.constant(Scalar::ZERO); + for (&id, &value) in points.iter().zip(values) { + let coefficient = c.b.mul(v_power, r_power); + lhs.push((id, coefficient)); + y = c.b.mul_add(v_power, value, y); + v_power = c.b.mul(v_power, v); + } + let weighted_y = c.b.mul(y, r_power); + let minus_y = c.b.scale(weighted_y, Scalar::NEG_ONE); + lhs.push((generator, minus_y)); + let z = c.b.scale(zeta, *g); + let coeff = c.b.mul(z, r_power); + lhs.push((witness, coeff)); + rhs.push((witness, r_power)); + r_power = c.b.mul(r_power, r); + } + // All AIR roots and logUp constraints, in the native folding order. + let mut acc = initial_acc; + for (i, circuit) in system.circuits.iter().enumerate() { + let one = c.b.constant(Scalar::ONE); + let mut z_n = zeta; + for _ in 0..logs[i] { + z_n = c.b.mul(z_n, z_n); + } + let vanishing = c.b.sub(z_n, one); + let inv_vanishing = c.b.inverse(vanishing); + let g = Scalar::two_adic_generator(usize::from(logs[i])); + let transition = + c.b.affine([zeta, zeta], [Scalar::ONE, Scalar::ZERO], -g.inverse()); + let first_denom = c.b.sub(zeta, one); + let inv_first = c.b.inverse(first_denom); + let first = c.b.mul(vanishing, inv_first); + let inv_last = c.b.inverse(transition); + let last = c.b.mul(vanishing, inv_last); + let publics = [beta, gamma, acc, accs[i]]; + let mut values = Vec::with_capacity(circuit.graph.nodes.len()); + for node in &circuit.graph.nodes { + let value = match *node { + Node::Const(v) => c.b.constant(v), + Node::Var(col) => { + let rows = match col.source { + Source::Main => &main_open[i], + Source::Preprocessed => &fixed_open[i], + Source::Stage2 => &stage2_open[i], + }; + rows[usize::from(col.offset == RowOffset::Next)][col.index as usize] + }, + Node::Public(j) => publics[j as usize], + Node::IsFirstRow => first, + Node::IsLastRow => last, + Node::IsTransition => transition, + Node::Add(a, b) => c.b.add(values[a.index()], values[b.index()]), + Node::Sub(a, b) => c.b.sub(values[a.index()], values[b.index()]), + Node::Mul(a, b) => c.b.mul(values[a.index()], values[b.index()]), + Node::Neg(a) => c.b.scale(values[a.index()], Scalar::NEG_ONE), + }; + values.push(value); + } + let mut constraints: Vec<_> = + circuit.graph.zeros.iter().map(|id| values[id.index()]).collect(); + let delta = c.b.sub(accs[i], acc); + let delta = + c.b.scale(delta, (Scalar::from_usize(1usize << logs[i]) * g).inverse()); + let injection = c.b.mul(last, delta); + let groups: Vec<_> = + circuit.graph.lookups.chunks(circuit.lookup_group_size.max(1)).collect(); + if groups.is_empty() { + let difference = c.b.sub(stage2_open[i][1][0], stage2_open[i][0][0]); + constraints.push(c.b.add(difference, injection)); + } + for (j, group) in groups.iter().enumerate() { + let target = if j + 1 < groups.len() { + stage2_open[i][0][j + 1] + } else { + c.b.add(stage2_open[i][1][0], injection) + }; + let difference = c.b.sub(target, stage2_open[i][0][j]); + let messages: Vec<_> = group + .iter() + .map(|lookup| { + let fingerprint = fp( + &mut c.b, + gamma, + lookup.args.iter().map(|id| values[id.index()]), + ); + c.b.add(beta, fingerprint) + }) + .collect(); + let mut product = one; + for &message in &messages { + product = c.b.mul(product, message); + } + let mut numerator = c.b.constant(Scalar::ZERO); + for (k, lookup) in group.iter().enumerate() { + let mut term = values[lookup.multiplicity.index()]; + for (l, &message) in messages.iter().enumerate() { + if k != l { + term = c.b.mul(term, message); + } + } + numerator = c.b.add(numerator, term); + } + let left = c.b.mul(product, difference); + constraints.push(c.b.sub(left, numerator)); + } + if constraints.len() != circuit.constraint_count() { + return Err("constraint count mismatch".into()); + } + let mut composition = c.b.constant(Scalar::ZERO); + for value in constraints { + composition = c.b.mul_add(composition, alpha, value); + } + let mut quotient = c.b.constant(Scalar::ZERO); + if quotient_open[i][0].len() != quotients[i] { + return Err("quotient width mismatch".into()); + } + for &value in quotient_open[i][0].iter().rev() { + quotient = c.b.mul_add(quotient, z_n, value); + } + let composition = c.b.mul(composition, inv_vanishing); + c.b.assert_equal(composition, quotient); + acc = accs[i]; + } + tracing::debug!("AIR checks complete: {:?}", c.b.stats()); + let mut terms = vec![]; + let mut keys = vec![]; + if !shifted.is_empty() { + terms.push(combine(&mut c.b, shifted)); + keys.push(srs.g2); + } + for (log, group) in degree_groups { + let group = group + .into_iter() + .map(|(id, v)| (id, c.b.scale(v, Scalar::NEG_ONE))) + .collect(); + terms.push(combine(&mut c.b, group)); + keys.push(srs.degree_keys[usize::from(log)]); + } + let degree_output_count = terms.len(); + terms.push(combine(&mut c.b, lhs)); + keys.push(srs.g2); + let rhs = rhs + .into_iter() + .map(|(id, v)| (id, c.b.scale(v, Scalar::NEG_ONE))) + .collect(); + terms.push(combine(&mut c.b, rhs)); + keys.push(srs.tau_g2); + for (i, terms) in terms.iter().enumerate() { + tracing::debug!("MSM {i}/{}: {} bases", keys.len(), terms.len()); + let point = native_msm::msm( + &mut c.b, + &c.f, + &c.bytes, + &mut c.bases, + terms, + &format!("pairing{i}"), + ); + // Canonicalize before exposing the compressed pairing input. + let x = c.f.hint(&mut c.b, "output x", &[point.x], |v| Ok(v[0])); + c.f.equal(&mut c.b, x, point.x); + c.f.canonical(&mut c.b, x); + let y = c.f.hint(&mut c.b, "output y", &[point.y], |v| Ok(v[0])); + c.f.equal(&mut c.b, y, point.y); + c.f.canonical(&mut c.b, y); + let point = Affine { x, y }; + let zero = c.b.constant(Scalar::ZERO); + let infinity = c.b.assert_bool(zero); + let encoded = native_transcript::point_bytes( + &mut c.b, + &c.bytes, + PointInput { point, infinity }, + ); + for chunk in encoded.as_chunks::<24>().0 { + let mut coefficient = Scalar::ONE; + let terms: Vec<_> = chunk + .iter() + .map(|v| { + let term = (coefficient, v.value()); + coefficient *= Scalar::from_u16(256); + term + }) + .collect(); + let packed = c.b.linear_combination(&terms, Scalar::ZERO); + c.b.expose_public(packed); + } + tracing::debug!("MSM {i} complete: {:?}", c.b.stats()); + } + Ok(Built { + circuit: c.b.finish(), + inputs: c.inputs, + pairing_keys: keys, + degree_output_count, + }) +} + +impl Built { + /// Assign and check circuit relations. This does not check the external + /// pairing equations; callers must verify the public operands separately. + pub fn assign( + &self, + ) -> Result< + multi_stark::plonkish::Assignment, + Box, + > { + let mut w = self.circuit.witness(); + for &(v, n) in &self.inputs { + w.set(v, n)?; + } + Ok(w.generate()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::system::SystemWitness; + use std::sync::Arc; + #[test] + fn complete_wrapper_fixture_and_changed_claim() { + let mut b = Builder::new(); + let table = + b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let x = b.input("x"); + for _ in 0..30 { + b.lookup(table, &[x]); + } + let square = b.mul(x, x); + b.expose_public(square); + let circuit = b.finish(); + let mut w = circuit.witness(); + w.set(x, Scalar::TWO).unwrap(); + let a = w.generate().unwrap(); + let lowered = + circuit.lower_to_multi_stark_sharded(Scalar::from_u8(93), 32).unwrap(); + let claims = lowered.claims(a.public_values()).unwrap(); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let srs = Arc::new(Srs::unsafe_dev_setup(32, b"native-wrapper-test")); + let (system, key) = System::new( + KzgConfig::new(srs.clone(), 2), + lowered.kzg_circuit_inputs(32, 2).unwrap(), + ); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(lowered.traces(&a).unwrap(), &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + let mut built = build( + &system, + &VerifierParams::from_srs(&srs), + &proof, + &claims, + &[(1, 3)], + ) + .unwrap(); + built.assign().unwrap(); + let template = + crate::kzg::compact::FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .template(); + let local = build( + &system, + &VerifierParams::from_srs(&srs), + &template, + &claims, + &[(1, 3)], + ) + .unwrap(); + assert!( + built + .circuit + .gates() + .iter() + .map(|g| (g.wires.map(Value::index), g.coefficients)) + .eq( + local + .circuit + .gates() + .iter() + .map(|g| (g.wires.map(Value::index), g.coefficients)) + ) + ); + assert_eq!(built.pairing_keys, local.pairing_keys); + drop(local); + // Public claim values must not specialize the verifier's constraints, + // even through unused constant gates left behind during construction. + let mut changed = claims.clone(); + changed[1][3] = Scalar::from_u64(0x123456789); + let other = build( + &system, + &VerifierParams::from_srs(&srs), + &proof, + &changed, + &[(1, 3)], + ) + .unwrap(); + assert!( + built + .circuit + .gates() + .iter() + .map(|g| (g.wires.map(Value::index), g.coefficients)) + .eq( + other + .circuit + .gates() + .iter() + .map(|g| (g.wires.map(Value::index), g.coefficients)) + ) + ); + drop(other); + let public = built.circuit.public_values()[0]; + let slot = built.inputs.iter().position(|&(v, _)| v == public).unwrap(); + built.inputs[slot].1 += Scalar::ONE; + assert!(built.assign().is_err()); + built.inputs[slot].1 -= Scalar::ONE; + let slot = built.inputs.len() - 2; + built.inputs[slot].1 += Scalar::ONE; + assert!(built.assign().is_err()); + } +} diff --git a/crates/proof-compression/tests/kzg_sharding.rs b/crates/proof-compression/tests/kzg_sharding.rs new file mode 100644 index 000000000..2faa088c2 --- /dev/null +++ b/crates/proof-compression/tests/kzg_sharding.rs @@ -0,0 +1,95 @@ +use ix_proof_compression::foreign::KzgCircuitExt; + +use std::sync::Arc; + +use ix_proof_compression::kzg::KzgConfig; +use ix_proof_compression::kzg::Scalar; +use ix_proof_compression::kzg::Srs; +use multi_stark::batch::Retention; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::WitnessError; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; + +#[test] +fn regenerated_kzg_shards_preserve_global_copy_constraints() { + let f = Scalar::from_u8; + let mut b = CircuitBuilder::new(); + let x = b.input("x"); + let double = b.add(x, x); + b.mul(x, x); + b.expose_public(double); + let compiled = b.finish().lower_to_multi_stark_sharded(f(81), 2).unwrap(); + assert_eq!(compiled.main_heights(), [2, 2, 2]); + let mut witness = compiled.witness(); + witness.set(x, f(5)).unwrap(); + let assignment = witness.generate().unwrap(); + let shards = compiled.trace_shards(&assignment).unwrap(); + assert_eq!(shards.len(), 3); + assert!(matches!(shards.traces(3), Err(WitnessError::ShardIndex { .. }))); + let claims = vec![compiled.claims(&[f(10)]).unwrap(), vec![], vec![]]; + let (system, key) = System::new( + KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(2, b"shards")), 8), + compiled.kzg_circuit_inputs(2, 8).unwrap(), + ); + let mut calls = [0; 3]; + let proof = system.prove_batch_with( + &key, + &claims, + vec![], + Retention::Regenerate, + |shard| { + calls[shard] += 1; + SystemWitness::from_stage_1(shards.traces(shard).unwrap(), &system) + }, + ); + assert_eq!(calls, [2; 3]); + system.verify_batch(&proof).unwrap(); + assert!( + proof + .proofs + .iter() + .any(|p| p.intermediate_accumulators.last() != Some(&Scalar::ZERO)) + ); + let retained = system.prove_batch_with( + &key, + &claims, + vec![], + Retention::Retain, + |shard| SystemWitness::from_stage_1(shards.traces(shard).unwrap(), &system), + ); + assert_eq!(retained.to_bytes().unwrap(), proof.to_bytes().unwrap()); + + let forged = system.prove_batch_with( + &key, + &claims, + vec![], + Retention::Regenerate, + |shard| { + let mut traces = shards.traces(shard).unwrap(); + if shard == 1 { + // 7*7=49 satisfies this shard's arithmetic, but x=5 elsewhere. + assert_eq!(&traces[1].values[..3], &[f(5), f(5), f(25)]); + traces[1].values[..3].copy_from_slice(&[f(7), f(7), f(49)]); + } + SystemWitness::from_stage_1(traces, &system) + }, + ); + assert!(system.verify_batch(&forged).is_err()); + + // Reprove with a disconnected partition omitted, rather than merely + // corrupting an existing proof. The required activation claim rejects it. + let omitted = system.prove_batch_with( + &key, + &claims[..2], + vec![], + Retention::Regenerate, + |shard| SystemWitness::from_stage_1(shards.traces(shard).unwrap(), &system), + ); + assert!(system.verify_batch(&omitted).is_err()); + let mut wrong = proof.clone(); + wrong.preamble.headers[0].claims[1][3] += Scalar::ONE; + assert!(system.verify_batch(&wrong).is_err()); +} diff --git a/crates/proof-compression/tests/kzg_streaming.rs b/crates/proof-compression/tests/kzg_streaming.rs new file mode 100644 index 000000000..52d316a43 --- /dev/null +++ b/crates/proof-compression/tests/kzg_streaming.rs @@ -0,0 +1,223 @@ +use ix_proof_compression::foreign::KzgCircuitExt; + +use ix_proof_compression::kzg::KzgConfig; +use ix_proof_compression::kzg::Scalar; +use ix_proof_compression::kzg::Srs; +use ix_proof_compression::kzg::pcs::KzgProverData; +use multi_stark::config::ProofConfig; +use multi_stark::lookup::LookupValues; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::prover::Stage1; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; +use multi_stark::traits::Pcs; +use p3_matrix::Matrix; +use std::sync::Arc; + +#[test] +fn streamed_lookup_proof_matches_dense_and_checkpoint_roundtrip() { + let mut builder = CircuitBuilder::::new(); + let table = + builder.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let x = builder.input("x"); + for _ in 0..30 { + builder.lookup(table, &[x]); + } + let square = builder.mul(x, x); + builder.expose_public(square); + let circuit = builder.finish(); + let mut witness = circuit.witness(); + witness.set(x, Scalar::TWO).unwrap(); + let assignment = witness.generate().unwrap(); + let lowered = + circuit.lower_to_multi_stark_sharded(Scalar::from_u8(93), 16).unwrap(); + let claims = lowered.claims(assignment.public_values()).unwrap(); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(16, b"streamed-lookup-test")), + 2, + ); + let (mut system, key) = + System::new(config, lowered.kzg_circuit_inputs(16, 2).unwrap()); + let dense = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(lowered.traces(&assignment).unwrap(), &system), + ); + system.verify_multiple_claims(&refs, &dense).unwrap(); + let (consumed, key) = System::new_without_preprocessed( + system.config.clone(), + lowered.kzg_circuit_inputs(16, 2).unwrap(), + ); + assert_eq!(consumed.preprocessed_commit, system.preprocessed_commit); + assert!(consumed.circuits.iter().all(|c| c.preprocessed.is_none())); + system = consumed; + let mut parts = Vec::new(); + let mut logs = Vec::new(); + let mut lookups = Vec::new(); + for (trace, circuit) in + lowered.traces(&assignment).unwrap().into_iter().zip(&mut system.circuits) + { + let h = trace.height(); + logs.push(h.ilog2() as usize); + lookups.push(LookupValues::shape_only( + h, + &circuit.graph.lookups.iter().map(|l| l.args.len()).collect::>(), + )); + let (_, data) = system + .config + .pcs() + .commit(vec![(system.config.pcs().natural_domain_for_degree(h), trace)]); + let mut bytes = Vec::new(); + data.write_checkpoint(&mut bytes).unwrap(); + let roundtrip = KzgProverData::read_checkpoint(bytes.as_slice()).unwrap(); + assert!(KzgProverData::read_checkpoint(&bytes[..bytes.len() - 1]).is_err()); + bytes.push(0); + assert!(KzgProverData::read_checkpoint(bytes.as_slice()).is_err()); + parts.push(roundtrip); + circuit.preprocessed = None; + } + system.config = + system.config.with_streaming_lookups().with_streaming_quotient(); + let count = system.circuits.len(); + let (commitment, data) = KzgProverData::concatenate(parts); + let stage = Stage1 { + active: vec![true; count], + active_indices: (0..count).collect(), + log_degrees: logs, + stage_1_trace_commit: commitment, + stage_1_trace_data: data, + lookups, + }; + let streamed = system.prove_committed(&key, &refs, stage); + system.verify_multiple_claims(&refs, &streamed).unwrap(); + assert_eq!(streamed.to_bytes().unwrap(), dense.to_bytes().unwrap()); + let mut wrong = claims; + wrong[1][3] += Scalar::ONE; + assert!( + system + .verify_multiple_claims( + &wrong.iter().map(Vec::as_slice).collect::>(), + &streamed + ) + .is_err() + ); +} + +#[test] +fn streamed_chunks_preserve_global_rows_and_quotient_cosets() { + use multi_stark::expr::Expr; + use multi_stark::lookup::Lookup; + use multi_stark::system::CircuitInputs; + let x = Expr::main(0); + let next = Expr::main_next(0); + let message = vec![x.clone(), next.clone(), Expr::preprocessed_next(0)]; + let mult = Expr::IsFirstRow + Expr::IsLastRow; + let circuit = CircuitInputs { + main_width: 1, + preprocessed: Some(p3_matrix::dense::RowMajorMatrix::new_col( + (0..1 << 17).map(|i| Scalar::from_usize(i % 97)).collect(), + )), + constraints: vec![ + x.clone() + * x.clone() + * x.clone() + * (x.clone() * x.clone() - Expr::constant(Scalar::ONE)), + x.clone() + next, + ], + lookups: vec![ + Lookup::push(mult.clone(), message.clone()), + Lookup::pull(mult, message), + ], + lookup_group_size: 2, + ..Default::default() + }; + let empty = CircuitInputs { + main_width: 1, + constraints: vec![Expr::main(0)], + ..Default::default() + }; + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(1 << 17, b"chunk-and-coset-test")), + 4, + ); + let (mut system, key) = System::new(config, [circuit, empty]); + let traces = vec![ + p3_matrix::dense::RowMajorMatrix::new_col( + (0..1 << 17) + .map(|i| if i % 2 == 0 { Scalar::ONE } else { Scalar::NEG_ONE }) + .collect(), + ), + p3_matrix::dense::RowMajorMatrix::new_col(vec![Scalar::ZERO; 8]), + ]; + let dense = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(traces.clone(), &system), + ); + system.config = + system.config.with_streaming_lookups().with_streaming_quotient(); + let streamed = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(traces, &system), + ); + assert_eq!(streamed.to_bytes().unwrap(), dense.to_bytes().unwrap()); + system.verify_multiple_claims(&[], &streamed).unwrap(); +} + +#[test] +fn fixed_column_passes_preserve_weights_wide_constraints_and_lookup_carry() { + use multi_stark::expr::Expr; + use multi_stark::lookup::Lookup; + use multi_stark::system::CircuitInputs; + use p3_matrix::dense::RowMajorMatrix; + let mut constraints: Vec<_> = + (0..20).map(|i| Expr::preprocessed_next(i) - Expr::main_next(i)).collect(); + // One constraint exceeds the per-pass column budget. + constraints.push((0..12).fold(Expr::constant(Scalar::ZERO), |sum, i| { + sum + Expr::preprocessed(i) - Expr::main(i) + })); + let lookups: Vec<_> = (0..5) + .map(|i| { + Lookup::push(Expr::constant(Scalar::ONE), vec![Expr::preprocessed(i * 4)]) + }) + .collect(); + let values: Vec<_> = + (0..16 * 20).map(|i| Scalar::from_usize(i % 31)).collect(); + let first = CircuitInputs { + main_width: 20, + preprocessed: Some(RowMajorMatrix::new(values.clone(), 20)), + constraints: constraints.clone(), + lookups: lookups.clone(), + lookup_group_size: 2, + ..Default::default() + }; + let second = CircuitInputs { + lookups: lookups + .into_iter() + .map(|l| Lookup::pull(l.multiplicity, l.args)) + .collect(), + ..first.clone() + }; + let config = + KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(16, b"fixed-passes")), 4); + let (mut system, key) = System::new(config, [first, second]); + let traces = vec![RowMajorMatrix::new(values, 20); 2]; + let dense = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(traces.clone(), &system), + ); + system.config = + system.config.with_streaming_lookups().with_streaming_quotient(); + let streamed = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(traces, &system), + ); + assert_eq!(streamed.to_bytes().unwrap(), dense.to_bytes().unwrap()); + system.verify_multiple_claims(&[], &streamed).unwrap(); +} diff --git a/crates/proof-compression/tests/parity_fixture.rs b/crates/proof-compression/tests/parity_fixture.rs new file mode 100644 index 000000000..96375a630 --- /dev/null +++ b/crates/proof-compression/tests/parity_fixture.rs @@ -0,0 +1,175 @@ +//! Pin the inner statement and fixed shape before building its circuit verifier. + +#[path = "support/parity.rs"] +mod parity; + +use std::collections::HashMap; + +use multi_stark::eval::VarValues; +use multi_stark::eval::eval_expr; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_matrix::Matrix; +use multi_stark::p3_matrix::dense::RowMajorMatrix; +use multi_stark::system::CircuitInputs; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::Val; +use parity::{Function, HEIGHT, LOG_HEIGHT, NUM_QUERIES}; + +/// Check the actual constraints and exact message multiset, independently +/// of the honest witness generator and the probabilistic proof protocol. +fn relations_hold( + inputs: &[CircuitInputs], + traces: &[RowMajorMatrix], + claim: &[Val], +) -> bool { + if inputs.len() != 2 || traces.len() != 2 { + return false; + } + let mut balance = HashMap::from([(claim.to_vec(), Val::ONE)]); + for (input, trace) in inputs.iter().zip(traces) { + if trace.height() != HEIGHT || trace.width() != input.main_width { + return false; + } + let prep = input.preprocessed.as_ref().unwrap(); + for row in 0..HEIGHT { + let main = trace.row_slice(row).unwrap(); + let fixed = prep.row_slice(row).unwrap(); + let view = VarValues { + main: [&main, &main], + preprocessed: [&fixed, &fixed], + stage2: [&[], &[]], + publics: &[], + is_first_row: Val::from_bool(row == 0), + is_last_row: Val::from_bool(row + 1 == HEIGHT), + is_transition: Val::from_bool(row + 1 < HEIGHT), + }; + if input.constraints.iter().any(|e| eval_expr(e, &view) != Val::ZERO) { + return false; + } + for lookup in &input.lookups { + let args = lookup.args.iter().map(|e| eval_expr(e, &view)).collect(); + *balance.entry(args).or_insert(Val::ZERO) += + eval_expr(&lookup.multiplicity, &view); + } + } + } + balance.values().all(|&value| value == Val::ZERO) +} + +#[test] +fn parity_fixture_all_inputs_and_dummy_rows() { + let inputs = parity::circuit_inputs(); + for function in Function::ALL { + for n in 0..HEIGHT { + let traces = parity::traces(function, n); + let claim = parity::claim(function, n, function.result(n)); + assert!(relations_hold(&inputs, &traces, &claim), "{function:?}({n})"); + let live_rows = traces + .iter() + .flat_map(|trace| trace.values.as_chunks::<2>().0) + .filter(|row| row[0] == Val::ONE) + .count(); + assert_eq!(live_rows, n + 1); + let wrong = parity::claim(function, n, !function.result(n)); + assert!(!relations_hold(&inputs, &traces, &wrong)); + } + } +} + +#[test] +fn parity_fixture_binds_every_cell_including_dummies() { + let inputs = parity::circuit_inputs(); + let claim = parity::claim(Function::Odd, 17, true); + let mut traces = parity::traces(Function::Odd, 17); + for circuit in 0..2 { + for cell in 0..HEIGHT * 2 { + let original = traces[circuit].values[cell]; + // Flip boolean cells so rejection must involve more than just + // the boolean constraint (e.g. missing calls or dummy results). + traces[circuit].values[cell] = Val::ONE - original; + assert!(!relations_hold(&inputs, &traces, &claim), "{circuit}:{cell}"); + traces[circuit].values[cell] = original; + } + } + // Flip the result along the WHOLE chain and the public result too: + // the call messages still cancel, but the terminal base case rejects. + for trace in &mut traces { + for row in trace.values.as_chunks_mut::<2>().0 { + if row[0] == Val::ONE { + row[1] = Val::ONE - row[1]; + } + } + } + assert!(!relations_hold( + &inputs, + &traces, + &parity::claim(Function::Odd, 17, false) + )); +} + +#[test] +fn parity_fixture_proofs_share_one_key_and_shape() { + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + assert!(system.preprocessed_commit.is_some()); + for function in Function::ALL { + for n in [0, 1, 17, 100, 127] { + let claim = parity::claim(function, n, function.result(n)); + let witness = + SystemWitness::from_stage_1(parity::traces(function, n), &system); + let proof = system.prove(&key, &claim, witness); + // Even n=0 keeps the all-dummy other circuit active at 128 rows. + assert_eq!(proof.active, vec![true; 2]); + assert_eq!(proof.log_degrees, vec![LOG_HEIGHT; 2]); + let fri = &proof.opening_proof; + assert_eq!(fri.commit_phase_commits.len(), usize::from(LOG_HEIGHT)); + assert_eq!(fri.commit_pow_witnesses.len(), usize::from(LOG_HEIGHT)); + assert_eq!(fri.commit_phase_openings.len(), usize::from(LOG_HEIGHT)); + assert_eq!(fri.final_poly.len(), 1); + // Main, stage 2, quotient, and preprocessing: each input batch + // opens both matrices at each query, with fixed column widths. + assert_eq!(fri.input_openings.len(), 4); + for (opening, width) in fri.input_openings.iter().zip([2, 4, 2, 2]) { + assert_eq!(opening.opened_values.len(), NUM_QUERIES); + for query in &opening.opened_values { + assert_eq!(query.len(), 2); + assert!(query.iter().all(|row| row.len() == width)); + } + } + for opening in &fri.commit_phase_openings { + assert_eq!(opening.log_arity, 1); + assert_eq!(opening.sibling_values.len(), NUM_QUERIES); + assert!(opening.sibling_values.iter().all(|row| row.len() == 1)); + } + // Pruned sibling digest counts vary with query overlap. They + // are deliberately not treated as a fixed wire-format shape. + system.verify(&claim, &proof).unwrap(); + let wrong = parity::claim(function, n, !function.result(n)); + assert!(system.verify(&wrong, &proof).is_err()); + let mut resized = proof.clone(); + resized.log_degrees[0] -= 1; + assert!(system.verify(&claim, &resized).is_err()); + } + } +} + +#[test] +fn parity_fixture_rejects_missing_recursive_call() { + let inputs = parity::circuit_inputs(); + let claim = parity::claim(Function::Even, 18, true); + let mut traces = parity::traces(Function::Even, 18); + // Erase odd(17), leaving the rows individually valid, but an unmatched + // call from even(18) and an unmatched return from even(16). + traces[Function::Odd.index()].values[17 * 2..18 * 2].fill(Val::ZERO); + assert!(!relations_hold(&inputs, &traces, &claim)); + let (system, key) = System::new(parity::config(), inputs); + let proof = + system.prove(&key, &claim, SystemWitness::from_stage_1(traces, &system)); + assert!(system.verify(&claim, &proof).is_err()); +} + +#[test] +#[should_panic(expected = "parity fixture supports arguments 0..128")] +fn parity_fixture_rejects_out_of_range_arguments() { + parity::traces(Function::Even, HEIGHT); +} diff --git a/crates/proof-compression/tests/pipeline_root.rs b/crates/proof-compression/tests/pipeline_root.rs new file mode 100644 index 000000000..3b4cfee2b --- /dev/null +++ b/crates/proof-compression/tests/pipeline_root.rs @@ -0,0 +1,175 @@ +//! Generated aggregate-shaped statements, including multi-shard Aiur batches. +use aiur::vk_codec::{AiurVerifyingKey, to_bytes}; +use ix_proof_compression::kzg::Srs; +use ix_proof_compression::pipeline::{ + FixedRoot, FriVerifier, KzgVerifier, RecursiveVerifier, prepare_kzg, + prepare_recursive, prepare_root, setup, +}; +use multi_stark::batch::ShardInput; +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::system::{CircuitInputs, System, SystemWitness}; +use multi_stark::types::{ + CommitmentParameters, FriParameters, GoldilocksBlake3Config, Val, +}; +use p3_field::PrimeCharacteristicRing; +use p3_matrix::dense::RowMajorMatrix; +use std::sync::Arc; + +fn root_system() -> ( + System, + multi_stark::system::ProverKey, + CommitmentParameters, + FriParameters, +) { + let cp = CommitmentParameters { log_blowup: 1, cap_height: 0 }; + let fp = FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }; + // A small relation carrying the same 18-word shape as an aggregate call. + // Its trusted key accepts any values; the caller selects the expected claim. + let (system, pk) = System::new( + GoldilocksBlake3Config::new(cp, fp), + [CircuitInputs { + main_width: 19, + preprocessed: Some(RowMajorMatrix::new_col(vec![Val::ZERO; 2])), + lookups: vec![Lookup::pull( + Expr::main(18), + (0..18).map(Expr::main).collect(), + )], + ..Default::default() + }], + ); + (system, pk, cp, fp) +} + +#[test] +fn different_root_statements_and_trace_shard_counts() { + let (system, pk, cp, fp) = root_system(); + let key = AiurVerifyingKey::from_bytes(&to_bytes(&system, cp, fp)).unwrap(); + for (seed, shards) in [(7, 1), (31, 2)] { + let expected: Vec<_> = (0..18).map(|i| Val::from_u32(seed + i)).collect(); + let inputs = || { + (0..shards) + .map(|i| { + let mut row = expected.clone(); + row.push(Val::from_bool(i == shards - 1)); + row.extend(vec![Val::ZERO; 19]); + ShardInput { + claims: if i == 0 { vec![expected.clone()] } else { vec![] }, + witness: SystemWitness::from_stage_1( + vec![RowMajorMatrix::new(row, 19)], + &system, + ), + } + }) + .collect() + }; + let proof = system.prove_batch(&pk, inputs(), vec![]); + let stage = + prepare_root(&key, &proof, &expected, Default::default()).unwrap(); + assert_eq!(stage.public_values(), expected); + let mut wrong = expected.clone(); + wrong[17] += Val::ONE; + assert!(prepare_root(&key, &proof, &wrong, Default::default()).is_err()); + let mut bad = proof.clone(); + bad.proofs.pop(); + assert!(prepare_root(&key, &bad, &expected, Default::default()).is_err()); + // A balanced pair can satisfy the generic batch protocol but violate + // Aiur's memory-closure policy. The public root API must reject it. + let messages = vec![ + multi_stark::batch::BatchMessage::push(vec![Val::ONE]), + multi_stark::batch::BatchMessage::pull(vec![Val::ONE]), + ]; + let bad = system.prove_batch(&pk, inputs(), messages); + system.verify_batch(&bad).unwrap(); + assert!(prepare_root(&key, &bad, &expected, Default::default()).is_err()); + let insufficient = + prepare_root(&key, &proof, &expected, Default::default()).unwrap(); + assert!( + insufficient + .prove_fri(GoldilocksBlake3Config::new(cp, fp), 1 << 16) + .is_err() + ); + let (verifier, compressed) = stage + .prove_fri( + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + fp, + ), + 1 << 16, + ) + .unwrap(); + verifier.verify(&compressed, &expected).unwrap(); + assert!(verifier.verify(&compressed, &wrong).is_err()); + } +} + +/// Runs all real compression circuits; expensive because it includes two KZG provers. +#[test] +#[ignore = "complete compression chain, including recursive KZG proving"] +fn complete_compression_chain() -> Result<(), Box> { + let (system, pk, cp, fp) = root_system(); + let key = AiurVerifyingKey::from_bytes(&to_bytes(&system, cp, fp))?; + let height = 1 << 22; + let srs = + Arc::new(Srs::unsafe_dev_setup(height, b"complete-compression-test")); + srs.validate()?; + let fixed = FixedRoot::new(vec![1], vec![], 18)?; + let config = || { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + FriParameters { num_queries: 1, ..fp }, + ) + }; + eprintln!("deriving the local verifier without a proof"); + let local = + setup(&key, &fixed, srs.clone(), config(), 1 << 20, height, height)?; + let local_bytes = local.to_bytes()?; + drop(local); + eprintln!("local verifier ready; generating the root proof"); + let expected: Vec<_> = (0..18).map(|i| Val::from_u32(71 + i)).collect(); + let mut row = expected.clone(); + row.push(Val::ONE); + row.extend(vec![Val::ZERO; 19]); + let root = system.prove_batch( + &pk, + vec![ShardInput { + claims: vec![expected.clone()], + witness: SystemWitness::from_stage_1( + vec![RowMajorMatrix::new(row, 19)], + &system, + ), + }], + vec![], + ); + let prepared = fixed.prepare(&key, &root, &expected, Default::default())?; + eprintln!("root verifier: {:?}", prepared.circuit().stats()); + let (fri, proof) = prepared.prove_fri(config(), 1 << 20)?; + let fri = FriVerifier::from_bytes(&fri.to_bytes()?)?; + let prepared = prepare_kzg(&fri, &proof, &expected, Default::default())?; + eprintln!("foreign verifier: {:?}", prepared.circuit().stats()); + let (kzg, proof) = prepared.prove_kzg(srs.clone(), height)?; + let kzg = KzgVerifier::from_bytes(&kzg.to_bytes()?)?; + eprintln!("foreign proof verified"); + let recursive = prepare_recursive(&kzg, &proof, &expected)?; + eprintln!("recursive verifier: {:?}", recursive.circuit().stats()); + let (verifier, proof) = recursive.prove(srs, height)?; + assert_eq!(verifier.to_bytes()?, local_bytes); + let verifier = RecursiveVerifier::from_bytes(&local_bytes)?; + verifier.verify(&proof, &expected)?; + let bytes = verifier.encode_proof(&proof)?; + verifier.verify_bytes(&bytes, &expected)?; + let mut wrong = expected.clone(); + wrong[17] += Val::ONE; + assert!(verifier.verify_bytes(&bytes, &wrong).is_err()); + let mut corrupt = bytes; + let last = corrupt.len() - 1; + corrupt[last] ^= 1; + assert!(verifier.verify_bytes(&corrupt, &expected).is_err()); + Ok(()) +} diff --git a/crates/proof-compression/tests/plonkish_batch.rs b/crates/proof-compression/tests/plonkish_batch.rs new file mode 100644 index 000000000..39065cc46 --- /dev/null +++ b/crates/proof-compression/tests/plonkish_batch.rs @@ -0,0 +1,342 @@ +#[path = "support/parity.rs"] +mod fixture; +use ix_proof_compression::fri::ExpandedPcsWitness; +use ix_proof_compression::fri::FixedPcsShape; +use ix_proof_compression::fri::FixedVerifierInputs; +use ix_proof_compression::fri::build_single_batch_verifier; +use ix_proof_compression::fri::expand_pcs_witness; +use ix_proof_compression::fri::expand_single_batch_witness; +use multi_stark::batch::BatchMessage; +use multi_stark::batch::ShardInput; +use multi_stark::plonkish::Assignment; +use multi_stark::plonkish::Circuit; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::ExtVal; +use multi_stark::types::Val; +use p3_field::{PrimeCharacteristicRing, PrimeField64}; + +#[test] +fn multiple_shards_bind_the_shared_transcript_and_global_balance() { + use ix_proof_compression::fri::{ + ByteGadgets, Message, constrain_batch_verifier, expand_batch_witness, + }; + use multi_stark::plonkish::CircuitBuilder; + let (system, key) = System::new(fixture::config(), fixture::circuit_inputs()); + let claim = fixture::claim(fixture::Function::Even, 17, false); + let split = |unbalanced: bool| { + let traces = fixture::traces(fixture::Function::Even, 17); + (0..2) + .map(|shard| { + let mut part = traces.clone(); + for trace in &mut part { + for (row, values) in + trace.values.as_chunks_mut::<2>().0.iter_mut().enumerate() + { + if usize::from(row >= 8) != shard { + values.fill(Val::ZERO); + } + } + } + ShardInput { + claims: if shard == 0 && !unbalanced { + vec![claim.clone()] + } else { + vec![] + }, + witness: SystemWitness::from_stage_1(part, &system), + } + }) + .collect() + }; + for unbalanced in [false, true] { + let batch = system.prove_batch(&key, split(unbalanced), vec![]); + assert_eq!(system.verify_batch(&batch).is_err(), unbalanced); + let shapes: Vec<_> = batch + .preamble + .headers + .iter() + .map(|h| FixedPcsShape::from_profile(&system, &h.active, &h.log_degrees)) + .collect(); + if !unbalanced { + assert_ne!( + *batch.proofs[0].intermediate_accumulators.last().unwrap(), + ExtVal::ZERO + ); + } + let mut b = CircuitBuilder::new(); + b.enable_compact_blake3(); + let claims = batch + .preamble + .headers + .iter() + .map(|h| { + h.claims + .iter() + .map(|row| row.iter().map(|&v| b.constant(v)).collect()) + .collect() + }) + .collect(); + let bytes = ByteGadgets::new(&mut b); + let inputs = constrain_batch_verifier( + &mut b, + &bytes, + &system, + &shapes, + claims, + &Vec::>::new(), + ); + let circuit = b.finish(); + let expanded = expand_batch_witness(&system, &shapes, &batch).unwrap(); + let check = |expanded: &[ExpandedPcsWitness]| { + let mut w = circuit.witness(); + for (e, i) in expanded.iter().zip(&inputs) { + e.assign_proof(&mut w, i)?; + } + w.generate().map_err(|e| e.to_string()) + }; + assert_eq!(check(&expanded).is_err(), unbalanced); + if !unbalanced { + for shard in 0..2 { + let mut bad = expanded.clone(); + bad[shard].proof.intermediate_accumulators[0] += ExtVal::ONE; + assert!(check(&bad).is_err()); + } + let mut swapped = expanded.clone(); + swapped.swap(0, 1); + assert!(check(&swapped).is_err()); + let mut missing = batch.clone(); + missing.proofs.pop(); + assert!(expand_batch_witness(&system, &shapes, &missing).is_err()); + } + } +} + +fn assign( + c: &Circuit, + i: &FixedVerifierInputs, + e: &ExpandedPcsWitness, +) -> Result, String> { + let mut w = c.witness(); + e.assign(&mut w, i)?; + w.generate().map_err(|e| e.to_string()) +} +#[test] +fn single_batch_binds_transcript_claims_messages_and_pcs() { + let (system, key) = System::new(fixture::config(), fixture::circuit_inputs()); + let messages = vec![ + BatchMessage::pull(vec![Val::from_u8(77)]), + BatchMessage::push(vec![Val::from_u8(77)]), + ]; + let shape = FixedPcsShape::new(&system, fixture::LOG_HEIGHT); + let (c, inputs) = + build_single_batch_verifier(&system, &shape, &[3], &messages); + for n in [0, 17] { + let claim = fixture::claim(fixture::Function::Even, n, n % 2 == 0); + let batch = system.prove_batch( + &key, + vec![ShardInput { + claims: vec![claim.clone()], + witness: SystemWitness::from_stage_1( + fixture::traces(fixture::Function::Even, n), + &system, + ), + }], + messages.clone(), + ); + system.verify_batch(&batch).unwrap(); + let expanded = + expand_single_batch_witness(&system, &shape, &batch, &messages).unwrap(); + let a = assign(&c, &inputs, &expanded).unwrap(); + assert_eq!(a.public_values(), claim); + for (bits, expected) in + inputs.query_bits.iter().zip(&expanded.query_indices) + { + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, b)| a.value(b.value()).unwrap().as_canonical_u64() << i) + .sum(); + assert_eq!(actual, *expected as u64); + } + drop(a); + if n == 0 { + continue; + } + for attack in 0..8 { + let mut bad = expanded.clone(); + match attack { + 0 => bad.claims[0][1] += Val::ONE, + 1 => bad.challenges[0] += ExtVal::ONE, + 2 => bad.proof.intermediate_accumulators[0] += ExtVal::ONE, + 3 => bad.proof.stage_1_opened_values[0][0][0] += ExtVal::ONE, + 4 => bad.input_paths[0][0][0][0] ^= 1, + 5 => { + bad.proof.opening_proof.commit_phase_openings[0].sibling_values[0] + [0] += ExtVal::ONE + }, + 6 => bad.proof.opening_proof.final_poly[0] += ExtVal::ONE, + _ => bad.fri_paths[0][0][0][0] ^= 1, + } + assert!(assign(&c, &inputs, &bad).is_err(), "attack {attack}"); + } + let changed_messages = vec![ + BatchMessage::pull(vec![Val::from_u8(78)]), + BatchMessage::push(vec![Val::from_u8(78)]), + ]; + let wrong = system.prove_batch( + &key, + vec![ShardInput { + claims: vec![claim.clone()], + witness: SystemWitness::from_stage_1( + fixture::traces(fixture::Function::Even, n), + &system, + ), + }], + changed_messages.clone(), + ); + system.verify_batch(&wrong).unwrap(); + assert!( + expand_single_batch_witness(&system, &shape, &wrong, &messages).is_err() + ); + let wrong_expanded = + expand_single_batch_witness(&system, &shape, &wrong, &changed_messages) + .unwrap(); + assert!( + assign(&c, &inputs, &wrong_expanded).is_err(), + "valid proof for different fixed messages" + ); + let single = system.prove( + &key, + &claim, + SystemWitness::from_stage_1( + fixture::traces(fixture::Function::Even, n), + &system, + ), + ); + system.verify(&claim, &single).unwrap(); + let single_expanded = + expand_pcs_witness(&system, &shape, &single, &[&claim]).unwrap(); + assert!( + assign(&c, &inputs, &single_expanded).is_err(), + "non-batch transcript accepted" + ); + } +} +#[test] +fn single_batch_constrains_nonzero_residual_against_messages() { + let (system, key) = System::new(fixture::config(), fixture::circuit_inputs()); + let claim = fixture::claim(fixture::Function::Odd, 19, true); + let messages = vec![BatchMessage::push(claim)]; + let batch = system.prove_batch( + &key, + vec![ShardInput { + claims: vec![], + witness: SystemWitness::from_stage_1( + fixture::traces(fixture::Function::Odd, 19), + &system, + ), + }], + messages.clone(), + ); + system.verify_batch(&batch).unwrap(); + assert_ne!( + *batch.proofs[0].intermediate_accumulators.last().unwrap(), + ExtVal::ZERO + ); + let shape = FixedPcsShape::new(&system, fixture::LOG_HEIGHT); + let (c, i) = build_single_batch_verifier(&system, &shape, &[], &messages); + let e = + expand_single_batch_witness(&system, &shape, &batch, &messages).unwrap(); + assign(&c, &i, &e).unwrap(); + let mut bad = e; + *bad.proof.intermediate_accumulators.last_mut().unwrap() = ExtVal::ZERO; + assert!(assign(&c, &i, &bad).is_err()); +} + +#[test] +fn compact_hash_batch_verification_can_be_proved() { + use ix_proof_compression::fri::ByteGadgets; + use ix_proof_compression::fri::constrain_single_batch_verifier; + use multi_stark::plonkish::CircuitBuilder; + use multi_stark::types::CommitmentParameters; + use multi_stark::types::FriParameters; + use multi_stark::types::GoldilocksBlake3Config; + let (inner, key) = System::new(fixture::config(), fixture::circuit_inputs()); + let claim = fixture::claim(fixture::Function::Odd, 19, true); + let batch = inner.prove_batch( + &key, + vec![ShardInput { + claims: vec![claim.clone()], + witness: SystemWitness::from_stage_1( + fixture::traces(fixture::Function::Odd, 19), + &inner, + ), + }], + vec![], + ); + inner.verify_batch(&batch).unwrap(); + let shape = FixedPcsShape::new(&inner, fixture::LOG_HEIGHT); + let mut b = CircuitBuilder::new(); + b.enable_compact_blake3(); + let wires = + (0..claim.len()).map(|i| b.public_input(format!("claim{i}"))).collect(); + let bytes = ByteGadgets::new(&mut b); + let inputs = constrain_single_batch_verifier( + &mut b, + &bytes, + &inner, + &shape, + vec![wires], + &[], + ); + let circuit = b.finish(); + let expanded = + expand_single_batch_witness(&inner, &shape, &batch, &[]).unwrap(); + let a = assign(&circuit, &inputs, &expanded).unwrap(); + for (bits, &expected) in inputs.query_bits.iter().zip(&expanded.query_indices) + { + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, bit)| a.value(bit.value()).unwrap().as_canonical_u64() << i) + .sum(); + assert_eq!(actual, expected as u64); + } + let compiled = circuit.lower_to_multi_stark(Val::from_u8(99)).unwrap(); + let claims = compiled.claims(&claim).unwrap(); + let traces = compiled.traces(&a).unwrap(); + let mut defs = compiled.circuit_inputs(); + for d in &mut defs { + d.lookup_group_size = 3; + } + let cfg = GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 32, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ); + let (outer, key) = System::new(cfg, defs); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = outer.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &outer), + ); + outer.verify_multiple_claims(&refs, &proof).unwrap(); + let mut bad = claim; + bad[1] += Val::ONE; + let bad = compiled.claims(&bad).unwrap(); + assert!( + outer + .verify_multiple_claims( + &bad.iter().map(Vec::as_slice).collect::>(), + &proof + ) + .is_err() + ); +} diff --git a/crates/proof-compression/tests/plonkish_hash.rs b/crates/proof-compression/tests/plonkish_hash.rs new file mode 100644 index 000000000..584d605fe --- /dev/null +++ b/crates/proof-compression/tests/plonkish_hash.rs @@ -0,0 +1,226 @@ +use ix_proof_compression::fri::Blake3Challenger; +use ix_proof_compression::fri::ByteGadgets; +use ix_proof_compression::fri::blake3; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_field::PrimeField64; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::WitnessError; +use multi_stark::types::Challenger; +use multi_stark::types::Val; +use p3_blake3::Blake3; +use p3_challenger::{ + CanObserve, CanSample, CanSampleBits, GrindingChallenger, HashChallenger, +}; +use p3_symmetric::CryptographicHasher; + +#[test] +fn blake3_matches_native_at_block_and_tree_boundaries() { + for len in [0, 1, 63, 64, 65, 1023, 1024, 1025, 2048, 2049] { + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let input: Vec<_> = (0..len) + .map(|i| bytes.input(&mut builder, &format!("byte {i}"))) + .collect(); + let output = blake3(&mut builder, &bytes, &input); + let circuit = builder.finish(); + for seed in [0u8, 197] { + let message: Vec<_> = (0..len) + .map(|i| u8::try_from(i % 251).unwrap().wrapping_add(seed)) + .collect(); + let expected: [u8; 32] = Blake3.hash_iter(message.iter().copied()); + let mut witness = circuit.witness(); + for (&wire, &value) in input.iter().zip(&message) { + witness.set(wire.value(), Val::from_u8(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + let actual = output.map(|byte| { + u8::try_from(assignment.value(byte.value()).unwrap().as_canonical_u64()) + .unwrap() + }); + assert_eq!(actual, expected, "length {len}, seed {seed}"); + } + } +} + +#[test] +fn byte_range_and_field_encoding_are_canonical() { + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let input = bytes.input(&mut builder, "byte"); + let circuit = builder.finish(); + for x in [256, 511, 65536] { + let mut witness = circuit.witness(); + witness.set(input.value(), Val::from_u32(x)).unwrap(); + assert!(witness.generate().is_err()); + } + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let input = builder.input("field element"); + let encoded = bytes.encode_field64(&mut builder, input); + let circuit = builder.finish(); + for x in [0, 1, 0xffff_ffff, 1 << 32, Val::ORDER_U64 - 1] { + let mut witness = circuit.witness(); + witness.set(input, Val::from_u64(x)).unwrap(); + let assignment = witness.generate().unwrap(); + let actual = encoded.map(|v| { + u8::try_from(assignment.value(v.value()).unwrap().as_canonical_u64()) + .unwrap() + }); + assert_eq!(actual, x.to_le_bytes()); + } + // Model sampled hash bytes, including candidates whose reduction would + // alias 0 or 1. They must fail, not get accepted modulo Goldilocks p. + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let input = + std::array::from_fn(|i| bytes.input(&mut builder, &format!("sample[{i}]"))); + bytes.assert_at_most_u64(&mut builder, input, Val::ORDER_U64 - 1); + let circuit = builder.finish(); + for x in [0, Val::ORDER_U64 - 1, Val::ORDER_U64, Val::ORDER_U64 + 1, u64::MAX] + { + let mut witness = circuit.witness(); + for (wire, value) in input.iter().zip(x.to_le_bytes()) { + witness.set(wire.value(), Val::from_u8(value)).unwrap(); + } + assert_eq!(witness.generate().is_ok(), x < Val::ORDER_U64, "candidate {x}"); + } +} + +#[test] +fn challenger_matches_digest_pop_order_chaining_and_observation_reset() { + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let mut challenger = + Blake3Challenger::new(&mut builder, &bytes, b"transcript test"); + let observed = bytes.input(&mut builder, "observe after partial sample"); + let mut wires: Vec<_> = + (0..7).map(|_| challenger.sample_byte(&mut builder, &bytes)).collect(); + challenger.observe_bytes(&[]); + wires.extend((0..3).map(|_| challenger.sample_byte(&mut builder, &bytes))); + challenger.observe_bytes(&[observed]); + wires.extend((0..70).map(|_| challenger.sample_byte(&mut builder, &bytes))); + let circuit = builder.finish(); + for value in [0u8, 255] { + let mut native = HashChallenger::::new( + b"transcript test".to_vec(), + Blake3, + ); + let mut expected: Vec = (0..7).map(|_| native.sample()).collect(); + CanObserve::::observe_slice(&mut native, &[]); + expected.extend((0..3).map(|_| native.sample())); + native.observe(value); + expected.extend((0..70).map(|_| native.sample())); + let mut witness = circuit.witness(); + witness.set(observed.value(), Val::from_u8(value)).unwrap(); + let assignment = witness.generate().unwrap(); + for (wire, expected) in wires.iter().zip(expected) { + assert_eq!( + assignment.value(wire.value()).unwrap(), + Val::from_u8(expected) + ); + } + } +} + +#[test] +fn challenger_bit_sampling_matches_native_across_byte_and_digest_boundaries() { + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let seed = b"bit sampling"; + let mut challenger = Blake3Challenger::new(&mut builder, &bytes, seed); + let mut native = Challenger::from_hasher(seed.to_vec(), Blake3); + let mut samples = Vec::new(); + for count in [0, 1, 7, 8, 9, 31, 32, 63, 0, 8] { + samples.push(( + challenger.sample_bits(&mut builder, &bytes, count), + native.sample_bits(count), + )); + } + let field = builder.constant(Val::from_u32(123)); + challenger.observe_field(&mut builder, &bytes, field); + native.observe(Val::from_u32(123)); + samples.push(( + challenger.sample_bits(&mut builder, &bytes, 17), + native.sample_bits(17), + )); + let circuit = builder.finish(); + let assignment = circuit.witness().generate().unwrap(); + for (bits, expected) in samples { + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, bit)| { + assignment.value(bit.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + assert_eq!(actual, u64::try_from(expected).unwrap()); + } +} + +#[test] +fn grinding_matches_native_including_zero_bit_noop_and_twenty_bits() { + for bits in [0, 3, 20] { + let seed = b"root profile grinding"; + let initial = Challenger::from_hasher(seed.to_vec(), Blake3); + let mut native = initial.clone(); + let valid = native.grind(bits); + let expected = native.sample_bits(17); + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let mut challenger = Blake3Challenger::new(&mut builder, &bytes, seed); + let pow = builder.input("grinding witness"); + challenger.check_witness(&mut builder, &bytes, bits, pow); + let next = challenger.sample_bits(&mut builder, &bytes, 17); + let circuit = builder.finish(); + let mut witness = circuit.witness(); + witness.set(pow, valid).unwrap(); + let assignment = witness.generate().unwrap(); + let actual: u64 = next + .iter() + .enumerate() + .map(|(i, bit)| { + assignment.value(bit.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + assert_eq!(actual, u64::try_from(expected).unwrap()); + + let mut wrong = valid + Val::ONE; + if bits != 0 { + // Select a genuinely invalid nonce rather than assuming +1 fails. + while initial.clone().check_witness(bits, wrong) { + wrong += Val::ONE; + } + } + let mut witness = circuit.witness(); + witness.set(pow, wrong).unwrap(); + assert_eq!(witness.generate().is_ok(), bits == 0); + } +} + +#[test] +fn wrong_digest_is_rejected_by_relations_not_just_host_hashing() { + let mut builder = CircuitBuilder::::new(); + let bytes = ByteGadgets::new(&mut builder); + let input = bytes.input(&mut builder, "byte"); + let output = blake3(&mut builder, &bytes, &[input]); + let expected: [_; 32] = + std::array::from_fn(|i| builder.public_input(format!("digest[{i}]"))); + for (byte, expected) in output.iter().zip(expected) { + builder.assert_equal(byte.value(), expected); + } + let circuit = builder.finish(); + let digest: [u8; 32] = Blake3.hash_iter([42]); + for bad_index in 0..32 { + let mut witness = circuit.witness(); + witness.set(input.value(), Val::from_u8(42)).unwrap(); + for (i, &wire) in expected.iter().enumerate() { + witness + .set(wire, Val::from_u8(digest[i] ^ u8::from(i == bad_index))) + .unwrap(); + } + assert!(matches!( + witness.generate(), + Err(WitnessError::UnsatisfiedGate { .. }) + )); + } +} diff --git a/crates/proof-compression/tests/plonkish_pcs.rs b/crates/proof-compression/tests/plonkish_pcs.rs new file mode 100644 index 000000000..48281cf25 --- /dev/null +++ b/crates/proof-compression/tests/plonkish_pcs.rs @@ -0,0 +1,143 @@ +#[path = "support/parity.rs"] +mod parity; + +use ix_proof_compression::fri::FixedPcsShape; +use ix_proof_compression::fri::build_fixed_verifier; +use ix_proof_compression::fri::expand_pcs_witness; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_field::PrimeField64; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::ExtVal; +use multi_stark::types::Val; +use p3_symmetric::MerkleCap; + +#[test] +fn full_fixed_verifier_accepts_native_proofs_and_rejects_corruption() { + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + let (circuit, inputs) = + build_fixed_verifier(&system, parity::LOG_HEIGHT, &[3]); + println!( + "Full verifier: {} gates, {} lookups, {} values", + circuit.gates().len(), + circuit.lookups().len(), + circuit.num_values() + ); + assert_eq!(circuit.public_values().len(), 3); + for (function, n) in [ + (parity::Function::Even, 100), + (parity::Function::Odd, 127), + (parity::Function::Odd, 0), + ] { + let claim = parity::claim(function, n, function.result(n)); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1(parity::traces(function, n), &system), + ); + system.verify(&claim, &proof).unwrap(); + let expanded = + expand_pcs_witness(&system, &inputs.shape, &proof, &[&claim]).unwrap(); + let mut witness = circuit.witness(); + expanded.assign(&mut witness, &inputs).unwrap(); + let assignment = witness.generate().unwrap(); + assert_eq!(assignment.public_values(), claim); + for (bits, &native) in inputs.query_bits.iter().zip(&expanded.query_indices) + { + let derived: u64 = bits + .iter() + .enumerate() + .map(|(i, bit)| { + assignment.value(bit.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + assert_eq!(derived, u64::try_from(native).unwrap()); + } + if n != 100 { + continue; + } + for attack in 0..13 { + let mut wrong = expanded.clone(); + match attack { + 0 => wrong.claims[0][2] += Val::ONE, + 1 => wrong.proof.stage_1_opened_values[0][0][0] += ExtVal::ONE, + 2 => { + wrong.proof.opening_proof.input_openings[0].opened_values[0][0][0] += + Val::ONE + }, + 3 => wrong.input_paths[0][0][0][0] ^= 1, + 4 => wrong.fri_paths[0][0][0][0] ^= 1, + 5 => { + wrong.proof.opening_proof.commit_phase_openings[0].sibling_values + [0][0] += ExtVal::ONE + }, + 6 => wrong.proof.opening_proof.final_poly[0] += ExtVal::ONE, + 7 => wrong.proof.intermediate_accumulators[0] += ExtVal::ONE, + 8 => { + wrong.proof.opening_proof.input_openings[3].opened_values[0][0][0] += + Val::ONE + }, + 9 => wrong.challenges[3] += ExtVal::ONE, + 10 => { + let root = &mut wrong.proof.opening_proof.commit_phase_commits[0]; + let mut digest = root.roots()[0]; + digest[0] ^= 1; + *root = MerkleCap::new(vec![digest]); + }, + 11 => { + wrong + .proof + .opening_proof + .commit_phase_openings + .last_mut() + .unwrap() + .sibling_values[3][0] += ExtVal::ONE; + }, + _ => wrong.fri_paths[3].last_mut().unwrap()[0][0] ^= 1, + } + let mut witness = circuit.witness(); + wrong.assign(&mut witness, &inputs).unwrap(); + assert!(witness.generate().is_err(), "accepted attack {attack}"); + } + } +} + +#[test] +fn full_verifier_adapter_rejects_malformed_fixed_shapes() { + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + let shape = FixedPcsShape::new(&system, parity::LOG_HEIGHT); + let claim = parity::claim(parity::Function::Odd, 17, true); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1( + parity::traces(parity::Function::Odd, 17), + &system, + ), + ); + for attack in 0..6 { + let mut wrong = proof.clone(); + match attack { + 0 => wrong.log_degrees[0] -= 1, + 1 => { + wrong.opening_proof.input_openings[0].opened_values.pop(); + }, + 2 => wrong.opening_proof.commit_phase_openings[0].log_arity = 2, + 3 => wrong.opening_proof.final_poly.push(ExtVal::ZERO), + 4 => { + wrong.opening_proof.input_openings[0] + .opening_proof + .sibling_hashes + .pop(); + }, + _ => wrong.opening_proof.input_openings[0] + .opening_proof + .sibling_hashes + .push([0; 32]), + } + assert!( + expand_pcs_witness(&system, &shape, &wrong, &[&claim]).is_err(), + "malformed shape {attack}" + ); + } +} diff --git a/crates/proof-compression/tests/plonkish_plan.rs b/crates/proof-compression/tests/plonkish_plan.rs new file mode 100644 index 000000000..e1a50a2c7 --- /dev/null +++ b/crates/proof-compression/tests/plonkish_plan.rs @@ -0,0 +1,678 @@ +//! Tests the generic generator using a small lookup system. +use ix_proof_compression::fri::*; +use multi_stark::batch::BatchMessage; +use multi_stark::batch::ShardInput; +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::system::CircuitInputs; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::CommitmentParameters; +use multi_stark::types::FriParameters; +use multi_stark::types::GoldilocksBlake3Config as Config; +use multi_stark::types::Val; +use p3_matrix::dense::RowMajorMatrix; + +fn config() -> Config { + Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: 2, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) +} +fn setup() -> (System, multi_stark::system::ProverKey) { + System::new( + config(), + [CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new(vec![Val::ONE, Val::ZERO], 1)), + constraints: vec![], + ext_constraints: vec![], + lookups: vec![Lookup { + multiplicity: -Expr::preprocessed(0), + args: vec![Expr::main(0)], + }], + lookup_group_size: 1, + }], + ) +} +fn trace(system: &System, value: Val) -> SystemWitness { + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new(vec![value; 2], 1)], + system, + ) +} + +fn profile(envelope: Envelope) -> ProofProfile { + ProofProfile { + envelope, + active: vec![true], + log_degrees: vec![1], + claim_lengths: if envelope == Envelope::Ordinary { + vec![1] + } else { + vec![] + }, + message_lengths: if envelope == Envelope::SingleBatch { + vec![1] + } else { + vec![] + }, + max_field_retries: 2, + } +} + +#[test] +fn dynamic_batch_statements_reuse_circuit_and_outer_key() { + let (system, pk) = setup(); + let key = VerifierKey::from_system(&system); + assert!(key.system().circuits.iter().all(|c| c.preprocessed.is_none())); + let mut batch_profile = profile(Envelope::SingleBatch); + batch_profile.message_lengths.push(1); + let plan = + VerifierPlan::validate(&key, batch_profile, VerifierLimits::default()) + .unwrap(); + let schema = Statement { + claims: vec![], + messages: vec![ + Message { + args: vec![StatementSlot::Public], + multiplicity: StatementSlot::Public, + }; + 2 + ], + }; + let (circuit, inputs) = + plan.build(schema, ImplementationOptions::default()).unwrap(); + assert_eq!( + circuit.stats().inputs, + plan.resources().proof_values + plan.resources().statement_values + ); + let compiled = circuit.lower_to_multi_stark(Val::from_u8(91)).unwrap(); + let (outer, outer_pk) = System::new(config(), compiled.circuit_inputs()); + for (value, first) in + [(Val::from_u8(13), Val::ONE), (Val::from_u8(91), Val::from_u8(3))] + { + let second = Val::ONE - first; + let batch = system.prove_batch( + &pk, + vec![ShardInput { claims: vec![], witness: trace(&system, value) }], + vec![ + BatchMessage { args: vec![value], multiplicity: first }, + BatchMessage { args: vec![value], multiplicity: second }, + ], + ); + system.verify_batch(&batch).unwrap(); + let prepared = + plan.expand_witness(ProofEnvelope::SingleBatch(&batch)).unwrap(); + let statement = Statement { + claims: vec![], + messages: vec![ + Message { args: vec![value], multiplicity: first }, + Message { args: vec![value], multiplicity: second }, + ], + }; + let mut w = compiled.witness(); + inputs.assign_statement(&mut w, &statement).unwrap(); + prepared.assign_proof(&mut w, &inputs).unwrap(); + let a = w.generate().unwrap(); + let public = [value, first, value, second]; + assert_eq!(a.public_values(), &public); + let claims = compiled.claims(&public).unwrap(); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = outer.prove_multiple_claims( + &outer_pk, + &refs, + SystemWitness::from_stage_1(compiled.traces(&a).unwrap(), &outer), + ); + outer.verify_multiple_claims(&refs, &proof).unwrap(); + let wrong_claims = + compiled.claims(&[value + Val::ONE, first, value, second]).unwrap(); + assert!( + outer + .verify_multiple_claims( + &wrong_claims.iter().map(Vec::as_slice).collect::>(), + &proof + ) + .is_err() + ); + for change_multiplicity in [false, true] { + let mut bad = statement.clone(); + if change_multiplicity { + bad.messages[0].multiplicity += Val::ONE; + } else { + bad.messages[0].args[0] += Val::ONE; + } + let mut w = compiled.witness(); + inputs.assign_statement(&mut w, &bad).unwrap(); + prepared.assign_proof(&mut w, &inputs).unwrap(); + assert!(w.generate().is_err()); + } + let mut malformed = batch.clone(); + malformed.preamble.headers[0].log_degrees[0] = 2; + assert!( + plan.expand_witness(ProofEnvelope::SingleBatch(&malformed)).is_err() + ); + } +} + +#[test] +fn ordinary_composition_proof_assignment_preserves_statement_owner() { + let (system, pk) = setup(); + let key = VerifierKey::from_system(&system); + let plan = VerifierPlan::validate( + &key, + profile(Envelope::Ordinary), + VerifierLimits::default(), + ) + .unwrap(); + let mut b = CircuitBuilder::new(); + let public = b.public_input("subject"); + let one = b.constant(Val::ONE); + let derived = b.add(public, one); + let inputs = plan + .constrain( + &mut b, + Statement { + claims: vec![vec![StatementBinding::Wire(derived)]], + messages: vec![], + }, + ImplementationOptions::default(), + ) + .unwrap(); + let c = b.finish(); + for value in [Val::from_u8(4), Val::from_u8(19)] { + let claims = vec![vec![value]]; + let proof = system.prove(&pk, &claims[0], trace(&system, value)); + system.verify(&claims[0], &proof).unwrap(); + let prepared = plan + .expand_witness(ProofEnvelope::Ordinary { + proof: &proof, + claims: &claims, + }) + .unwrap(); + let mut w = c.witness(); + w.set(public, value - Val::ONE).unwrap(); + prepared.assign_proof(&mut w, &inputs).unwrap(); + assert_eq!(w.generate().unwrap().public_values(), &[value - Val::ONE]); + let mut w = c.witness(); + prepared.assign_proof(&mut w, &inputs).unwrap(); + assert!( + w.generate().is_err(), + "proof assignment must not assign the public subject" + ); + let mut w = c.witness(); + w.set(public, value).unwrap(); + prepared.assign_proof(&mut w, &inputs).unwrap(); + assert!(w.generate().is_err()); + let mut corrupt = proof.clone(); + corrupt.opening_proof.final_poly[0] += multi_stark::types::ExtVal::ONE; + if let Ok(p) = plan.expand_witness(ProofEnvelope::Ordinary { + proof: &corrupt, + claims: &claims, + }) { + let mut w = c.witness(); + w.set(public, value - Val::ONE).unwrap(); + p.assign_proof(&mut w, &inputs).unwrap(); + assert!(w.generate().is_err()); + } + } +} + +#[test] +fn malformed_profiles_are_rejected_before_construction() { + let (system, _) = setup(); + let key = VerifierKey::from_system(&system); + for mutation in 0..7 { + let mut p = profile(Envelope::Ordinary); + match mutation { + 0 => p.active.clear(), + 1 => p.log_degrees.clear(), + 2 => p.log_degrees[0] = u8::MAX, + 3 => p.log_degrees[0] = 2, + 4 => p.message_lengths.push(1), + 5 => p.max_field_retries = usize::MAX, + _ => p.claim_lengths = vec![usize::MAX, 1], + } + assert!( + VerifierPlan::validate(&key, p, VerifierLimits::default()).is_err() + ); + } + let limits = + VerifierLimits { max_proof_values: 1, ..VerifierLimits::default() }; + assert!(matches!( + VerifierPlan::validate(&key, profile(Envelope::Ordinary), limits), + Err(VerifierError::Limit { resource: "proof values", .. }) + )); +} + +#[test] +fn key_transport_constants_and_implementation_equivalence() { + let (system, pk) = setup(); + let key = VerifierKey::from_system(&system); + let encoded = key.to_bytes().unwrap(); + let decoded = VerifierKey::from_bytes(&encoded).unwrap(); + assert_eq!(key.fingerprint().unwrap(), decoded.fingerprint().unwrap()); + assert_eq!(decoded.to_bytes().unwrap(), encoded); + for mut bad in [encoded[..encoded.len() / 2].to_vec(), encoded.clone()] { + bad.push(0xff); + assert!(VerifierKey::from_bytes(&bad).is_err()); + } + let plan = VerifierPlan::validate( + &decoded, + profile(Envelope::Ordinary), + VerifierLimits::default(), + ) + .unwrap(); + let value = Val::from_u8(31); + let claims = vec![vec![value]]; + let proof = system.prove(&pk, &claims[0], trace(&system, value)); + let schema = Statement { + claims: vec![vec![StatementSlot::Constant(value)]], + messages: vec![], + }; + let id = plan.identity(&schema).unwrap(); + let different = Statement { + claims: vec![vec![StatementSlot::Constant(value + Val::ONE)]], + messages: vec![], + }; + assert_ne!(id, plan.identity(&different).unwrap()); + for compact_blake3 in [false, true] { + let (c, i) = plan + .build(schema.clone(), ImplementationOptions { compact_blake3 }) + .unwrap(); + let expanded = plan + .expand_witness(ProofEnvelope::Ordinary { + proof: &proof, + claims: &claims, + }) + .unwrap(); + let mut w = c.witness(); + i.assign_statement( + &mut w, + &Statement { claims: claims.clone(), messages: vec![] }, + ) + .unwrap(); + expanded.assign_proof(&mut w, &i).unwrap(); + assert!(w.generate().unwrap().public_values().is_empty()); + let bad = + system.prove(&pk, &[value + Val::ONE], trace(&system, value + Val::ONE)); + let bad_claims = vec![vec![value + Val::ONE]]; + let bad_expanded = plan + .expand_witness(ProofEnvelope::Ordinary { + proof: &bad, + claims: &bad_claims, + }) + .unwrap(); + let mut w = c.witness(); + bad_expanded.assign_proof(&mut w, &i).unwrap(); + assert!(w.generate().is_err()); + } + let mut altered = setup().0; + altered.circuits[0].graph.nodes.push(multi_stark::graph::Node::Public(8)); + altered.circuits[0].graph.degrees.push(0); + let key = VerifierKey::new(altered); + assert!(matches!( + VerifierPlan::validate( + &key, + profile(Envelope::Ordinary), + VerifierLimits::default() + ), + Err(VerifierError::Circuit { field: "public index", .. }) + )); +} + +#[allow(dead_code)] +#[path = "support/root_profile.rs"] +mod root; +#[test] +fn generic_plan_handles_sparse_mixed_heights_and_grinding() { + let profile = root::Profile::Smoke; + let (system, key) = + System::new(profile.config(), root::circuit_inputs(profile)); + let vk = VerifierKey::from_system(&system); + let plan = VerifierPlan::validate( + &vk, + ProofProfile { + envelope: Envelope::Ordinary, + active: root::ACTIVE.to_vec(), + log_degrees: profile.logs(), + claim_lengths: vec![18], + message_lengths: vec![], + max_field_retries: 2, + }, + VerifierLimits::default(), + ) + .unwrap(); + let (c, inputs) = plan + .build( + Statement { + claims: vec![vec![StatementSlot::Public; 18]], + messages: vec![], + }, + ImplementationOptions::default(), + ) + .unwrap(); + for subject in [[0x5a; 32], [0xa5; 32]] { + let claim = root::claim(subject); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1(root::traces(profile, subject), &system), + ); + system.verify(&claim, &proof).unwrap(); + let statement = Statement { claims: vec![claim], messages: vec![] }; + let p = plan + .expand_witness(ProofEnvelope::Ordinary { + proof: &proof, + claims: &statement.claims, + }) + .unwrap(); + let mut w = c.witness(); + inputs.assign_statement(&mut w, &statement).unwrap(); + p.assign_proof(&mut w, &inputs).unwrap(); + w.generate().unwrap(); + let mut wrong = proof.clone(); + wrong.active[1] = true; + assert!( + plan + .expand_witness(ProofEnvelope::Ordinary { + proof: &wrong, + claims: &statement.claims + }) + .is_err() + ); + } +} + +#[test] +fn statement_modes_and_keys_are_bound() { + let (system, pk) = setup(); + let value = Val::from_u8(13); + let claim = vec![value]; + let ordinary = system.prove(&pk, &claim, trace(&system, value)); + let batch = system.prove_batch( + &pk, + vec![ShardInput { claims: vec![], witness: trace(&system, value) }], + vec![BatchMessage::push(claim.clone())], + ); + let key = VerifierKey::from_system(&system); + let plan = VerifierPlan::validate( + &key, + profile(Envelope::SingleBatch), + VerifierLimits::default(), + ) + .unwrap(); + assert!( + plan + .expand_witness(ProofEnvelope::Ordinary { + proof: &ordinary, + claims: std::slice::from_ref(&claim) + }) + .is_err() + ); + for dynamic in [false, true] { + let slot = |v| { + if dynamic { StatementSlot::Public } else { StatementSlot::Constant(v) } + }; + let schema = Statement { + claims: vec![], + messages: vec![Message { + args: vec![slot(value)], + multiplicity: slot(Val::ONE), + }], + }; + let (c, i) = plan.build(schema, ImplementationOptions::default()).unwrap(); + println!("Batch binding dynamic={dynamic}: {:?}", c.stats()); + let prepared = + plan.expand_witness(ProofEnvelope::SingleBatch(&batch)).unwrap(); + let mut w = c.witness(); + i.assign_statement( + &mut w, + &Statement { + claims: vec![], + messages: vec![Message { args: vec![value], multiplicity: Val::ONE }], + }, + ) + .unwrap(); + prepared.assign_proof(&mut w, &i).unwrap(); + w.generate().unwrap(); + } + let plan = VerifierPlan::validate( + &key, + profile(Envelope::Ordinary), + VerifierLimits::default(), + ) + .unwrap(); + assert!(plan.expand_witness(ProofEnvelope::SingleBatch(&batch)).is_err()); + let schema = + Statement { claims: vec![vec![StatementSlot::Public]], messages: vec![] }; + let (c, i) = + plan.build(schema.clone(), ImplementationOptions::default()).unwrap(); + let mut other = setup().0; + other.preprocessed_commit = + Some(multi_stark::types::Commitment::from(vec![[0u8; 32]])); + let other = VerifierKey::new(other); + let wrong_plan = VerifierPlan::validate( + &other, + profile(Envelope::Ordinary), + VerifierLimits::default(), + ) + .unwrap(); + assert_ne!( + plan.identity(&schema).unwrap(), + wrong_plan.identity(&schema).unwrap() + ); + if let Ok(p) = wrong_plan.expand_witness(ProofEnvelope::Ordinary { + proof: &ordinary, + claims: std::slice::from_ref(&claim), + }) { + assert!( + p.assign_proof(&mut c.witness(), &i).is_err(), + "cannot mix plan assignment" + ); + let (wrong_c, wrong_i) = + wrong_plan.build(schema, ImplementationOptions::default()).unwrap(); + let mut w = wrong_c.witness(); + wrong_i + .assign_statement( + &mut w, + &Statement { claims: vec![claim], messages: vec![] }, + ) + .unwrap(); + p.assign_proof(&mut w, &wrong_i).unwrap(); + assert!( + w.generate().is_err(), + "native proof must not validate under another key" + ); + } +} + +#[test] +fn height_one_only_profile_checks_zero_round_fri() { + let (system, pk) = System::new( + config(), + [CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new(vec![Val::ONE], 1)), + lookups: vec![Lookup { + multiplicity: -Expr::preprocessed(0), + args: vec![Expr::main(0)], + }], + ..CircuitInputs::default() + }], + ); + let value = Val::from_u8(7); + let claims = vec![vec![value]]; + let proof = system.prove( + &pk, + &claims[0], + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new(vec![value], 1)], + &system, + ), + ); + system.verify(&claims[0], &proof).unwrap(); + assert!(proof.opening_proof.commit_phase_commits.is_empty()); + let key = VerifierKey::from_system(&system); + let mut p = profile(Envelope::Ordinary); + p.log_degrees[0] = 0; + let plan = + VerifierPlan::validate(&key, p, VerifierLimits::default()).unwrap(); + let (c, i) = plan + .build( + Statement { claims: vec![vec![StatementSlot::Public]], messages: vec![] }, + ImplementationOptions::default(), + ) + .unwrap(); + let prepared = plan + .expand_witness(ProofEnvelope::Ordinary { proof: &proof, claims: &claims }) + .unwrap(); + let mut w = c.witness(); + i.assign_statement( + &mut w, + &Statement { claims: claims.clone(), messages: vec![] }, + ) + .unwrap(); + prepared.assign_proof(&mut w, &i).unwrap(); + w.generate().unwrap(); + let mut bad = proof; + bad.opening_proof.final_poly[0] += multi_stark::types::ExtVal::ONE; + if let Ok(prepared) = plan + .expand_witness(ProofEnvelope::Ordinary { proof: &bad, claims: &claims }) + { + let mut w = c.witness(); + i.assign_statement(&mut w, &Statement { claims, messages: vec![] }) + .unwrap(); + prepared.assign_proof(&mut w, &i).unwrap(); + assert!(w.generate().is_err()); + } +} + +#[test] +fn complete_helpers_cannot_drop_queries_through_modified_shape() { + let (system, pk) = setup(); + let value = Val::from_u8(13); + let claims = vec![vec![value]]; + let ordinary = system.prove(&pk, &claims[0], trace(&system, value)); + let batch = system.prove_batch( + &pk, + vec![ShardInput { claims: claims.clone(), witness: trace(&system, value) }], + vec![], + ); + for envelope in [Envelope::Ordinary, Envelope::SingleBatch] { + let mut shape = FixedPcsShape::new(&system, 1); + // Public shape fields are redundant metadata, never permission to + // weaken the protocol selected by the trusted system configuration. + shape.queries = 0; + let mut builder = CircuitBuilder::new(); + builder.enable_compact_blake3(); + let claim = builder.public_input("claim"); + let bytes = ByteGadgets::new(&mut builder); + let inputs = match envelope { + Envelope::Ordinary => constrain_fixed_verifier( + &mut builder, + &bytes, + &system, + &shape, + vec![vec![claim]], + ), + Envelope::SingleBatch => constrain_single_batch_verifier( + &mut builder, + &bytes, + &system, + &shape, + vec![vec![claim]], + &[], + ), + }; + let circuit = builder.finish(); + let expanded = match envelope { + Envelope::Ordinary => { + expand_pcs_witness(&system, &inputs.shape, &ordinary, &[&claims[0]]) + }, + Envelope::SingleBatch => { + expand_single_batch_witness(&system, &inputs.shape, &batch, &[]) + }, + } + .unwrap(); + let mut witness = circuit.witness(); + expanded.assign(&mut witness, &inputs).unwrap(); + witness.generate().unwrap(); + for query in 0..system.config.fri_parameters().num_queries { + let mut bad = expanded.clone(); + bad.proof.opening_proof.input_openings[0].opened_values[query][0][0] += + Val::ONE; + let mut witness = circuit.witness(); + bad.assign(&mut witness, &inputs).unwrap(); + assert!( + witness.generate().is_err(), + "{envelope:?}: omitted query {query}" + ); + } + assert_eq!( + inputs.pcs.queries.len(), + system.config.fri_parameters().num_queries + ); + } +} + +#[test] +fn profile_enforces_native_quotient_domain_bound() { + let x = Expr::main(0); + let (system, pk) = System::new( + config(), + [ + CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![Val::ZERO; 2])), + ..Default::default() + }, + CircuitInputs { + main_width: 1, + constraints: vec![x.clone() * x.clone() * x.clone() - x], + ..Default::default() + }, + ], + ); + assert_eq!(system.circuits[1].quotient_degree(), 2); + let mut proof = system.prove_multiple_claims( + &pk, + &[], + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new_col(vec![Val::ZERO; 2]); 2], + &system, + ), + ); + system.verify_multiple_claims(&[], &proof).unwrap(); + proof.log_degrees[1] = 30; + assert!(matches!( + system.verify_shape(&proof), + Err(multi_stark::verifier::VerificationError::InvalidProofShape) + )); + let key = VerifierKey::from_system(&system); + let profile = ProofProfile { + envelope: Envelope::Ordinary, + active: vec![true, true], + log_degrees: vec![1, 30], // LDE fits 2^32, but quotient exceeds native limit 2^30. + claim_lengths: vec![], + message_lengths: vec![], + max_field_retries: 0, + }; + assert!( + VerifierPlan::validate(&key, profile, VerifierLimits::default()).is_err() + ); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + FixedPcsShape::from_profile(&system, &[true, true], &[1, 30]) + })) + .is_err() + ); +} diff --git a/crates/proof-compression/tests/plonkish_transcript.rs b/crates/proof-compression/tests/plonkish_transcript.rs new file mode 100644 index 000000000..8679f2cda --- /dev/null +++ b/crates/proof-compression/tests/plonkish_transcript.rs @@ -0,0 +1,95 @@ +#[path = "support/parity_algebra.rs"] +mod adapter; +#[path = "support/parity.rs"] +mod parity; + +use ix_proof_compression::fri::AlgebraicInputs; +use ix_proof_compression::fri::ByteGadgets; +use ix_proof_compression::fri::TranscriptCommitments; +use ix_proof_compression::fri::constrain_algebraic_checks; +use ix_proof_compression::fri::constrain_transcript; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::Val; + +#[test] +fn parity_transcript_and_algebra_match_native_and_reject_changed_roots() { + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + let mut builder = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[3]); + let bytes = ByteGadgets::new(&mut builder); + let roots = TranscriptCommitments::allocate(&mut builder, &bytes); + constrain_transcript( + &mut builder, + &bytes, + &system, + &[parity::LOG_HEIGHT; 2], + &inputs, + &roots, + ); + constrain_algebraic_checks( + &mut builder, + &system.circuits, + &[parity::LOG_HEIGHT; 2], + &inputs, + ); + // This intermediate test deliberately omits PCS verification. + adapter::expose_boundary(&mut builder, &inputs); + for byte in roots.stage1.iter().chain(&roots.stage2).chain(&roots.quotient) { + builder.expose_public(byte.value()); + } + let circuit = builder.finish(); + println!( + "Transcript + algebra: {} gates, {} lookups, {} values", + circuit.gates().len(), + circuit.lookups().len(), + circuit.num_values() + ); + for (function, n) in [ + (parity::Function::Even, 0), + (parity::Function::Odd, 17), + (parity::Function::Even, 100), + (parity::Function::Odd, 127), + ] { + let claim = parity::claim(function, n, function.result(n)); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1(parity::traces(function, n), &system), + ); + system.verify(&claim, &proof).unwrap(); + let challenges = adapter::challenges(&system, &proof, &[&claim]); + let digests = [ + proof.commitments.stage_1_trace.roots()[0], + proof.commitments.stage_2_trace.roots()[0], + proof.commitments.quotient_chunks.roots()[0], + ]; + for altered_root in [None, Some(0), Some(1), Some(2)] { + let mut witness = circuit.witness(); + adapter::assign(&mut witness, &inputs, &proof, &[&claim], challenges) + .unwrap(); + for (i, (wires, digest)) in + [&roots.stage1, &roots.stage2, &roots.quotient] + .into_iter() + .zip(digests) + .enumerate() + { + for (j, (wire, value)) in wires.iter().zip(digest).enumerate() { + let value = value ^ u8::from(altered_root == Some(i) && j == 0); + witness.set(wire.value(), Val::from_u8(value)).unwrap(); + } + } + let result = witness.generate(); + if altered_root.is_some() { + assert!(result.is_err(), "altered root {altered_root:?}"); + } else { + let assignment = result.unwrap(); + let mut expected = adapter::statement(&proof, &[&claim], challenges); + expected.extend(digests.into_iter().flatten().map(Val::from_u8)); + assert_eq!(assignment.public_values(), expected); + } + } + } +} diff --git a/crates/proof-compression/tests/plonkish_verifier_algebra.rs b/crates/proof-compression/tests/plonkish_verifier_algebra.rs new file mode 100644 index 000000000..7730606a5 --- /dev/null +++ b/crates/proof-compression/tests/plonkish_verifier_algebra.rs @@ -0,0 +1,501 @@ +#[path = "support/parity_algebra.rs"] +mod adapter; +#[path = "support/parity.rs"] +mod parity; + +use ix_proof_compression::fri::AlgebraicInputs; +use ix_proof_compression::fri::AlgebraicOutputs; +use ix_proof_compression::fri::QuadraticValue as Q; +use ix_proof_compression::fri::constrain_algebraic_checks; +use multi_stark::config::StarkGenericConfig; +use multi_stark::eval::VarValues; +use multi_stark::p3_field::BasedVectorSpace; +use multi_stark::p3_field::Field; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_field::TwoAdicField; +use multi_stark::p3_field::extension::BinomiallyExtendable; +use multi_stark::plonkish::Assignment; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::WitnessError; +use multi_stark::prover::Proof; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::traits::Pcs; +use multi_stark::types::ExtVal; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; +use p3_commit::PolynomialSpace; + +fn value(assignment: &Assignment, wire: Q) -> ExtVal { + ExtVal::from_basis_coefficients_fn(|i| assignment.value(wire.0[i]).unwrap()) +} + +fn assignment( + circuit: &Circuit, + inputs: &AlgebraicInputs, + proof: &Proof, + claim: &[Val], + challenges: [ExtVal; 4], +) -> Result, WitnessError> { + let mut witness = circuit.witness(); + adapter::assign(&mut witness, inputs, proof, &[claim], challenges)?; + witness.generate() +} + +/// Native oracle using the same primitives as System::verify, independent +/// of the gadget implementation. Compare every graph node and constraint. +fn check_intermediates( + system: &System, + proof: &Proof, + claims: &[&[Val]], + challenges: [ExtVal; 4], + outputs: &AlgebraicOutputs, + assignment: &Assignment, +) { + let [beta, gamma, alpha, zeta] = challenges; + let mut acc = claims + .iter() + .map(|claim| { + let fingerprint = + claim.iter().rev().fold(ExtVal::ZERO, |acc, &v| acc * gamma + v); + (beta + fingerprint).inverse() + }) + .sum(); + assert_eq!(value(assignment, outputs.claims_accumulator), acc); + for (i, circuit) in system.circuits.iter().enumerate() { + let output = &outputs.circuits[i]; + let domain = <_ as Pcs>::natural_domain_for_degree( + system.config.pcs(), + parity::HEIGHT, + ); + let sels = domain.selectors_at_point(zeta); + assert_eq!( + value(assignment, output.selectors.is_first_row), + sels.is_first_row + ); + assert_eq!( + value(assignment, output.selectors.is_last_row), + sels.is_last_row + ); + assert_eq!( + value(assignment, output.selectors.is_transition), + sels.is_transition + ); + assert_eq!( + value(assignment, output.selectors.inv_vanishing), + sels.inv_vanishing + ); + assert_eq!( + value(assignment, output.zeta_next), + domain.next_point(zeta).unwrap() + ); + let next_acc = proof.intermediate_accumulators[i]; + let publics: Vec = [beta, gamma, acc, next_acc] + .iter() + .flat_map(|v| v.as_basis_coefficients_slice()) + .map(|&v: &Val| ExtVal::from(v)) + .collect(); + let empty = [vec![], vec![]]; + let preprocessed = system.preprocessed_indices[i] + .map_or(empty.as_slice(), |slot| { + &proof.preprocessed_opened_values.as_ref().unwrap()[slot] + }); + let view = VarValues { + preprocessed: [&preprocessed[0], &preprocessed[1]], + main: [ + &proof.stage_1_opened_values[i][0], + &proof.stage_1_opened_values[i][1], + ], + stage2: [ + &proof.stage_2_opened_values[i][0], + &proof.stage_2_opened_values[i][1], + ], + publics: &publics, + is_first_row: sels.is_first_row, + is_last_row: sels.is_last_row, + is_transition: sels.is_transition, + }; + let mut nodes = Vec::new(); + circuit.graph.sweep(&view, &mut nodes); + assert_eq!(output.nodes.len(), nodes.len()); + for (&wire, &native) in output.nodes.iter().zip(&nodes) { + assert_eq!(value(assignment, wire), native); + } + let g = Val::two_adic_generator(usize::from(parity::LOG_HEIGHT)); + let norm = (Val::from_usize(parity::HEIGHT) * g).inverse(); + let delta = std::array::from_fn::<_, 2, _>(|k| { + (publics[6 + k] - publics[4 + k]) * norm + }); + let mut constraints = circuit.graph.constraint_values(&nodes); + multi_stark::lookup::logup_constraint_values( + &circuit.graph.lookups, + &nodes, + view.stage2[0], + view.stage2[1], + &publics, + &delta, + sels.is_last_row, + >::W, + 2, + circuit.lookup_group_size, + &mut constraints, + ); + assert_eq!(output.constraints.len(), constraints.len()); + for (&wire, &native) in output.constraints.iter().zip(&constraints) { + assert_eq!(value(assignment, wire), native); + } + let composition = + constraints.into_iter().fold(ExtVal::ZERO, |acc, c| acc * alpha + c); + assert_eq!(value(assignment, output.composition), composition); + let basis = + >::ith_basis_element(1).unwrap(); + let power = zeta.exp_power_of_2(usize::from(parity::LOG_HEIGHT)); + let quotient: ExtVal = proof.quotient_opened_values[i][0] + .as_chunks::<2>() + .0 + .iter() + .zip(power.powers()) + .map(|(chunk, power)| (chunk[0] + basis * chunk[1]) * power) + .sum(); + assert_eq!(value(assignment, output.quotient), quotient); + assert_eq!(composition * sels.inv_vanishing, quotient); + acc = next_acc; + } +} + +#[test] +fn quadratic_gadgets_match_native_arithmetic_and_reject_zero_inverse() { + let mut builder = CircuitBuilder::::new(); + let a = Q::input(&mut builder, "a"); + let b = Q::input(&mut builder, "b"); + let sum = a.add(&mut builder, b); + let difference = a.sub(&mut builder, b); + let product = a.mul(&mut builder, b); + let inverse = a.inverse(&mut builder); + let power = a.exp_power_of_2(&mut builder, 7); + let circuit = builder.finish(); + for i in 0..32 { + let a_value = + ExtVal::from_basis_coefficients_slice(&[Val::from_u32(i), Val::ONE]) + .unwrap(); + let b_value = + ExtVal::from_basis_coefficients_slice(&[Val::from_u32(2 * i), -Val::ONE]) + .unwrap(); + let mut witness = circuit.witness(); + adapter::set_extension(&mut witness, a, a_value).unwrap(); + adapter::set_extension(&mut witness, b, b_value).unwrap(); + let assignment = witness.generate().unwrap(); + assert_eq!(value(&assignment, sum), a_value + b_value); + assert_eq!(value(&assignment, difference), a_value - b_value); + assert_eq!(value(&assignment, product), a_value * b_value); + assert_eq!(value(&assignment, inverse), a_value.inverse()); + assert_eq!(value(&assignment, power), a_value.exp_power_of_2(7)); + } + let mut witness = circuit.witness(); + adapter::set_extension(&mut witness, a, ExtVal::ZERO).unwrap(); + adapter::set_extension(&mut witness, b, ExtVal::ONE).unwrap(); + assert!(matches!(witness.generate(), Err(WitnessError::HintFailed { .. }))); +} + +#[test] +fn parity_algebra_matches_native_for_multiple_proofs_with_one_circuit() { + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + let mut builder = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[3]); + let outputs = constrain_algebraic_checks( + &mut builder, + &system.circuits, + &[parity::LOG_HEIGHT; 2], + &inputs, + ); + let circuit = builder.finish(); + println!( + "Parity algebra: {} values, {} gates", + circuit.num_values(), + circuit.gates().len() + ); + for function in parity::Function::ALL { + for n in [0, 1, 17, 100, 127] { + let claim = parity::claim(function, n, function.result(n)); + let witness = + SystemWitness::from_stage_1(parity::traces(function, n), &system); + let proof = system.prove(&key, &claim, witness); + system.verify(&claim, &proof).unwrap(); + let challenges = adapter::challenges(&system, &proof, &[&claim]); + let assignment = + assignment(&circuit, &inputs, &proof, &claim, challenges).unwrap(); + assert_eq!(assignment.public_values(), claim); + check_intermediates( + &system, + &proof, + &[&claim], + challenges, + &outputs, + &assignment, + ); + } + } +} + +#[test] +fn parity_algebra_rejects_tampered_claim_openings_accumulators_and_challenges() +{ + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + let claim = parity::claim(parity::Function::Even, 100, true); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1( + parity::traces(parity::Function::Even, 100), + &system, + ), + ); + let challenges = adapter::challenges(&system, &proof, &[&claim]); + let mut builder = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[3]); + constrain_algebraic_checks( + &mut builder, + &system.circuits, + &[parity::LOG_HEIGHT; 2], + &inputs, + ); + let circuit = builder.finish(); + assert!(assignment(&circuit, &inputs, &proof, &claim, challenges).is_ok()); + for i in 0..claim.len() { + let mut wrong = claim.clone(); + wrong[i] += Val::ONE; + assert!(assignment(&circuit, &inputs, &proof, &wrong, challenges).is_err()); + } + for i in 0..2 { + let mut wrong = proof.clone(); + wrong.intermediate_accumulators[i] += ExtVal::ONE; + assert!(assignment(&circuit, &inputs, &wrong, &claim, challenges).is_err()); + for kind in 0..4 { + let mut wrong = proof.clone(); + let row = match kind { + 0 => &mut wrong.stage_1_opened_values[i][0], + 1 => &mut wrong.stage_2_opened_values[i][0], + 2 => &mut wrong.preprocessed_opened_values.as_mut().unwrap()[i][0], + _ => &mut wrong.quotient_opened_values[i][0], + }; + row[0] += ExtVal::ONE; + assert!( + assignment(&circuit, &inputs, &wrong, &claim, challenges).is_err(), + "circuit {i}, kind {kind}" + ); + } + } + for i in 0..4 { + let mut wrong = challenges; + wrong[i] += ExtVal::ONE; + assert!(assignment(&circuit, &inputs, &proof, &claim, wrong).is_err()); + } + // The vanishing denominator must be nonzero, not an unconstrained hint. + let mut on_domain = challenges; + on_domain[3] = ExtVal::ONE; + assert!(assignment(&circuit, &inputs, &proof, &claim, on_domain).is_err()); + // Explicitly pin this milestone's security boundary: corrupted FRI + // data is invisible to the algebraic gadget and needs the PCS + // constraints. Native verification does reject the same corruption. + let mut bad_fri = proof.clone(); + bad_fri.opening_proof.final_poly[0] += ExtVal::ONE; + assert!(system.verify(&claim, &bad_fri).is_err()); + assert!(assignment(&circuit, &inputs, &bad_fri, &claim, challenges).is_ok()); +} + +#[test] +fn algebraic_relations_can_be_proved_independently() { + let (system, key) = System::new(parity::config(), parity::circuit_inputs()); + let claim = parity::claim(parity::Function::Odd, 17, true); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1( + parity::traces(parity::Function::Odd, 17), + &system, + ), + ); + let challenges = adapter::challenges(&system, &proof, &[&claim]); + let mut builder = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[3]); + constrain_algebraic_checks( + &mut builder, + &system.circuits, + &[parity::LOG_HEIGHT; 2], + &inputs, + ); + // Bind every supplied boundary value publicly for this algebra-only + // proof. Otherwise private, unauthenticated openings are freely chosen. + adapter::expose_boundary(&mut builder, &inputs); + let compiled = + builder.finish().lower_to_multi_stark(Val::from_u32(101)).unwrap(); + let assignment = + assignment(compiled.circuit(), &inputs, &proof, &claim, challenges) + .unwrap(); + // The outer statement is explicitly the complete algebraic instance, + // not merely the parity result and not an authenticated inner proof. + let expected = adapter::statement(&proof, &[&claim], challenges); + assert_eq!(assignment.public_values(), expected); + let outer_claims = compiled.claims(&expected).unwrap(); + let refs: Vec<_> = outer_claims.iter().map(Vec::as_slice).collect(); + let (outer, outer_key) = + System::new(parity::config(), compiled.circuit_inputs()); + let outer_proof = outer.prove_multiple_claims( + &outer_key, + &refs, + SystemWitness::from_stage_1(compiled.traces(&assignment).unwrap(), &outer), + ); + outer.verify_multiple_claims(&refs, &outer_proof).unwrap(); + let mut wrong = outer_claims.clone(); + wrong[1][3] += Val::ONE; + assert!( + outer + .verify_multiple_claims( + &wrong.iter().map(Vec::as_slice).collect::>(), + &outer_proof + ) + .is_err() + ); +} + +#[test] +fn algebra_handles_selectors_next_rows_no_lookups_and_multiple_quotient_slices() +{ + use multi_stark::expr::Expr; + use multi_stark::p3_matrix::dense::RowMajorMatrix; + use multi_stark::system::CircuitInputs; + + let x = Expr::main(0); + let square = Expr::main(1); + let cube = Expr::main(2); + let square_error = square.clone() + -(x.clone() * x.clone()); + let input = CircuitInputs { + main_width: 3, + constraints: vec![ + square_error.clone(), + x.clone() * x.clone() * x.clone() - cube, + Expr::IsFirstRow * x.clone(), + Expr::IsLastRow + * (x.clone() - Expr::constant(Val::from_usize(parity::HEIGHT - 1))), + Expr::IsTransition * (Expr::main_next(0) - x - Expr::constant(Val::ONE)), + Expr::public(0) * square_error, + ], + ..Default::default() + }; + let (system, key) = System::new(parity::config(), [input]); + assert_eq!(system.circuits[0].quotient_degree(), 2); + let trace = RowMajorMatrix::new( + (0..parity::HEIGHT) + .flat_map(|n| { + let x = Val::from_usize(n); + [x, x * x, x * x * x] + }) + .collect(), + 3, + ); + let proof = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(vec![trace], &system), + ); + system.verify_multiple_claims(&[], &proof).unwrap(); + let challenges = adapter::challenges(&system, &proof, &[]); + let mut builder = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[]); + let outputs = constrain_algebraic_checks( + &mut builder, + &system.circuits, + &[parity::LOG_HEIGHT], + &inputs, + ); + let circuit = builder.finish(); + let mut witness = circuit.witness(); + adapter::assign(&mut witness, &inputs, &proof, &[], challenges).unwrap(); + let assignment = witness.generate().unwrap(); + check_intermediates(&system, &proof, &[], challenges, &outputs, &assignment); + // Exercise the non-leading quotient slice independently of the first. + let mut wrong = proof.clone(); + wrong.quotient_opened_values[0][0][2] += ExtVal::ONE; + let mut witness = circuit.witness(); + adapter::assign(&mut witness, &inputs, &wrong, &[], challenges).unwrap(); + assert!(witness.generate().is_err()); +} + +#[test] +fn grouped_logup_coordinates_match_native_for_full_and_partial_groups() { + use multi_stark::expr::Expr; + use multi_stark::lookup::Lookup; + use multi_stark::system::CircuitInputs; + use multi_stark::types::CommitmentParameters; + use multi_stark::types::FriParameters; + use p3_matrix::dense::RowMajorMatrix; + let claim = vec![Val::from_u8(17)]; + for group in [1, 2, 3, 8] { + let x = Expr::main(0); + let pair = vec![x.clone(), x.clone() * x.clone()]; + let (system, key) = System::new( + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 4, cap_height: 0 }, + FriParameters { + num_queries: 2, + log_final_poly_len: 0, + max_log_arity: 1, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ), + [CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col( + (0..parity::HEIGHT).map(|i| Val::from_bool(i == 0)).collect(), + )), + lookups: vec![ + Lookup::pull(Expr::preprocessed(0), vec![x.clone()]), + Lookup::push(x.clone(), vec![x.clone()]), + Lookup::pull(x, vec![Expr::main(0)]), + Lookup::push(Expr::constant(Val::ONE), pair.clone()), + Lookup::pull(Expr::constant(Val::ONE), pair), + ], + lookup_group_size: group, + ..Default::default() + }], + ); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new_col(vec![claim[0]; parity::HEIGHT])], + &system, + ), + ); + system.verify(&claim, &proof).unwrap(); + let challenges = adapter::challenges(&system, &proof, &[&claim]); + let mut b = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut b, &system.circuits, &[1]); + let outputs = constrain_algebraic_checks( + &mut b, + &system.circuits, + &[parity::LOG_HEIGHT], + &inputs, + ); + let circuit = b.finish(); + let a = assignment(&circuit, &inputs, &proof, &claim, challenges).unwrap(); + check_intermediates(&system, &proof, &[&claim], challenges, &outputs, &a); + for point in 0..2 { + for column in 0..system.circuits[0].stage_2_width { + // Only the first accumulator carries across rows. Other + // next-row columns are authenticated by PCS, not logUp. + if point == 1 && column >= 2 { + continue; + } + let mut forged = proof.clone(); + forged.stage_2_opened_values[0][point][column] += ExtVal::ONE; + assert!( + assignment(&circuit, &inputs, &forged, &claim, challenges).is_err(), + "group {group}, point {point}, column {column}" + ); + } + } + } +} diff --git a/crates/proof-compression/tests/plonkish_verifier_security.rs b/crates/proof-compression/tests/plonkish_verifier_security.rs new file mode 100644 index 000000000..060529fef --- /dev/null +++ b/crates/proof-compression/tests/plonkish_verifier_security.rs @@ -0,0 +1,197 @@ +#[path = "support/verifier_audit.rs"] +mod fixture; + +use ix_proof_compression::fri::*; +use multi_stark::batch::ShardInput; +use multi_stark::p3_field::BasedVectorSpace; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::types::ExtVal; +use multi_stark::types::Val; +use p3_symmetric::MerkleCap; + +#[test] +fn every_verifier_boundary_rejects_tampering_without_native_prevalidation() { + let (system, pk) = fixture::setup(); + let claims = fixture::claims(); + for envelope in [Envelope::Ordinary, Envelope::SingleBatch] { + let ordinary = system.prove_multiple_claims( + &pk, + &[&claims[0]], + fixture::witness(&system), + ); + let batch = system.prove_batch( + &pk, + vec![ShardInput { + claims: claims.clone(), + witness: fixture::witness(&system), + }], + vec![], + ); + system.verify_multiple_claims(&[&claims[0]], &ordinary).unwrap(); + system.verify_batch(&batch).unwrap(); + let shape = + FixedPcsShape::from_profile(&system, &fixture::ACTIVE, &fixture::LOGS); + let mut b = CircuitBuilder::new(); + b.enable_compact_blake3(); + let bytes = ByteGadgets::new(&mut b); + let claim = vec![vec![b.public_input("tag"), b.public_input("value")]]; + let inputs = match envelope { + Envelope::Ordinary => { + constrain_fixed_verifier(&mut b, &bytes, &system, &shape, claim) + }, + Envelope::SingleBatch => constrain_single_batch_verifier( + &mut b, + &bytes, + &system, + &shape, + claim, + &[], + ), + }; + let circuit = b.finish(); + let good = match envelope { + Envelope::Ordinary => { + expand_pcs_witness(&system, &shape, &ordinary, &[&claims[0]]) + }, + Envelope::SingleBatch => { + expand_single_batch_witness(&system, &shape, &batch, &[]) + }, + } + .unwrap(); + let mut w = circuit.witness(); + good.assign(&mut w, &inputs).unwrap(); + w.generate().unwrap(); + let mut count = 0; + let mut reject = + |name: &str, bad: ExpandedPcsWitness, native_changed: bool| { + if native_changed { + let rejected = match envelope { + Envelope::Ordinary => system + .verify_multiple_claims(&[&bad.claims[0]], &bad.proof) + .is_err(), + Envelope::SingleBatch => { + let mut forged = batch.clone(); + forged.proofs[0] = bad.proof.clone(); + forged.preamble.headers[0].claims = bad.claims.clone(); + system.verify_batch(&forged).is_err() + }, + }; + assert!(rejected, "native accepted {envelope:?} {name}"); + } + // Mutate after normalization. Assignment only checks dimensions; + // it never calls native cryptographic verification. + let mut w = circuit.witness(); + bad.assign(&mut w, &inputs).unwrap(); + assert!(w.generate().is_err(), "circuit accepted {envelope:?} {name}"); + count += 1; + }; + for column in 0..2 { + let mut bad = good.clone(); + bad.claims[0][column] += Val::ONE; + reject("public claim", bad, true); + } + for c in 0..4 { + for coordinate in 0..2 { + let unit = ExtVal::from_basis_coefficients_fn(|i| { + if i == coordinate { Val::ONE } else { Val::ZERO } + }); + let mut bad = good.clone(); + bad.challenges[c] += unit; + reject("transcript challenge", bad, false); + } + } + for matrix in 0..good.proof.intermediate_accumulators.len() { + let mut bad = good.clone(); + bad.proof.intermediate_accumulators[matrix] += ExtVal::ONE; + reject("accumulator chain and final balance", bad, true); + } + for batch_index in 0..4 { + let openings = match batch_index { + 0 => &good.proof.stage_1_opened_values, + 1 => &good.proof.stage_2_opened_values, + 2 => &good.proof.quotient_opened_values, + _ => good.proof.preprocessed_opened_values.as_ref().unwrap(), + }; + for (matrix, rows) in openings.iter().enumerate() { + for (point, row) in rows.iter().enumerate() { + for column in 0..row.len() { + for coordinate in 0..2 { + let mut bad = good.clone(); + let openings = match batch_index { + 0 => &mut bad.proof.stage_1_opened_values, + 1 => &mut bad.proof.stage_2_opened_values, + 2 => &mut bad.proof.quotient_opened_values, + _ => bad.proof.preprocessed_opened_values.as_mut().unwrap(), + }; + openings[matrix][point][column] += + ExtVal::from_basis_coefficients_fn(|i| { + if i == coordinate { Val::ONE } else { Val::ZERO } + }); + reject("OOD opening coordinate", bad, true); + } + } + } + } + } + for i in 0..3 { + let mut bad = good.clone(); + let root = match i { + 0 => &mut bad.proof.commitments.stage_1_trace, + 1 => &mut bad.proof.commitments.stage_2_trace, + _ => &mut bad.proof.commitments.quotient_chunks, + }; + let mut bytes = root.roots()[0]; + bytes[0] ^= 1; + *root = MerkleCap::new(vec![bytes]); + reject("trace commitment", bad, true); + } + for query in 0..shape.queries { + for batch_index in 0..4 { + for matrix in 0..shape.widths[batch_index].len() { + for column in 0..shape.widths[batch_index][matrix] { + let mut bad = good.clone(); + bad.proof.opening_proof.input_openings[batch_index] + .opened_values[query][matrix][column] += Val::ONE; + reject("queried input column", bad, true); + } + } + for depth in 0..good.input_paths[query][batch_index].len() { + let mut bad = good.clone(); + bad.input_paths[query][batch_index][depth][0] ^= 1; + reject("input Merkle path", bad, false); + } + } + for round in 0..usize::from(shape.log_trace) { + let mut bad = good.clone(); + bad.proof.opening_proof.commit_phase_openings[round].sibling_values + [query][0] += ExtVal::ONE; + reject("FRI sibling and fold", bad, true); + for depth in 0..good.fri_paths[query][round].len() { + let mut bad = good.clone(); + bad.fri_paths[query][round][depth][0] ^= 1; + reject("FRI Merkle path", bad, false); + } + } + } + for round in 0..usize::from(shape.log_trace) { + let mut bad = good.clone(); + let mut bytes = + bad.proof.opening_proof.commit_phase_commits[round].roots()[0]; + bytes[0] ^= 1; + bad.proof.opening_proof.commit_phase_commits[round] = + MerkleCap::new(vec![bytes]); + reject("FRI commitment", bad, true); + let mut bad = good.clone(); + bad.proof.opening_proof.commit_pow_witnesses[round] += Val::ONE; + reject("commit grinding", bad, true); + } + let mut bad = good.clone(); + bad.proof.opening_proof.query_pow_witness += Val::ONE; + reject("query grinding", bad, true); + let mut bad = good.clone(); + bad.proof.opening_proof.final_poly[0] += ExtVal::ONE; + reject("final polynomial", bad, true); + println!("{envelope:?}: {count} adversarial assignments rejected"); + } +} diff --git a/crates/proof-compression/tests/root_profile.rs b/crates/proof-compression/tests/root_profile.rs new file mode 100644 index 000000000..455d24c61 --- /dev/null +++ b/crates/proof-compression/tests/root_profile.rs @@ -0,0 +1,266 @@ +#[path = "support/root_profile.rs"] +mod fixture; + +use ix_proof_compression::fri::ExpandedPcsWitness; +use ix_proof_compression::fri::expand_pcs_witness; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_field::PrimeField64; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::ExtVal; +use multi_stark::types::Val; +use p3_blake3::Blake3; +use p3_symmetric::CryptographicHasher; + +fn accepts( + verifier: &fixture::StatementVerifier, + expanded: &ExpandedPcsWitness, + subject: [u8; 32], +) -> bool { + let mut witness = verifier.circuit.witness(); + if expanded.assign_proof(&mut witness, &verifier.inputs).is_err() { + return false; + } + for (wire, value) in verifier.subject.iter().zip(subject) { + witness.set(wire.value(), Val::from_u8(value)).unwrap(); + } + witness.generate().is_ok() +} + +#[test] +fn root_profile_constrains_sparse_mixed_height_grouped_proof_and_statement() { + let profile = fixture::Profile::Smoke; + let (system, key) = + System::new(profile.config(), fixture::circuit_inputs(profile)); + let verifier = fixture::build_statement_verifier(&system, profile); + assert_eq!(verifier.circuit.public_values().len(), 32); + assert_eq!( + verifier.circuit.stats().tables, + 3, + "statement and verifier share byte tables" + ); + assert_eq!(verifier.inputs.shape.preprocessed, [2, 4]); + assert_eq!(verifier.inputs.shape.widths[3], [11, 11]); + assert_eq!(system.circuits[0].quotient_degree(), 4); + assert_eq!(system.circuits[0].stage_2_width, 4); + assert_eq!(system.circuits[2].stage_2_width, 2); + println!("Root-profile circuit: {:?}", verifier.circuit.stats()); + for subject in [[0x5a; 32], [0xa5; 32]] { + let claim = fixture::claim(subject); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1(fixture::traces(profile, subject), &system), + ); + system.verify(&claim, &proof).unwrap(); + assert_eq!(proof.active, fixture::ACTIVE); + assert_eq!(proof.log_degrees, profile.logs()); + let expanded = + expand_pcs_witness(&system, &verifier.inputs.shape, &proof, &[&claim]) + .unwrap(); + let mut witness = verifier.circuit.witness(); + expanded.assign_proof(&mut witness, &verifier.inputs).unwrap(); + for (wire, value) in verifier.subject.iter().zip(subject) { + witness.set(wire.value(), Val::from_u8(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + assert_eq!(assignment.public_values(), subject.map(Val::from_u8)); + for (&wire, &value) in verifier.inputs.algebra.claims[0].iter().zip(&claim) + { + assert_eq!(assignment.value(wire).unwrap(), value); + } + for (bits, &index) in + verifier.inputs.query_bits.iter().zip(&expanded.query_indices) + { + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, b)| { + assignment.value(b.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + assert_eq!(actual, u64::try_from(index).unwrap()); + } + let mut wrong_subject = subject; + wrong_subject[0] ^= 1; + assert!(!accepts(&verifier, &expanded, wrong_subject)); + if subject[0] != 0x5a { + continue; + } + for attack in 0..18 { + let mut bad = expanded.clone(); + match attack { + 0 => bad.proof.active.swap(1, 2), + 1 => bad.proof.log_degrees[2] += 1, + 2 => bad.proof.stage_2_opened_values[0][0][3] += ExtVal::ONE, + 3 => bad.proof.quotient_opened_values[0][0][7] += ExtVal::ONE, + 4 => { + bad.proof.preprocessed_opened_values.as_mut().unwrap()[1][1][10] += + ExtVal::ONE + }, + 5 => { + bad.proof.opening_proof.input_openings[0].opened_values[0][2][0] += + Val::ONE + }, + 6 => { + bad.proof.opening_proof.input_openings[3].opened_values[0][0][0] += + Val::ONE + }, + 7 => { + bad.proof.opening_proof.input_openings[3].opened_values[0][1][10] += + Val::ONE + }, + 8 => bad.input_paths[0][0][0][0] ^= 1, + 9 => bad.input_paths[0][3][0][0] ^= 1, + 10 => bad.proof.opening_proof.commit_pow_witnesses[0] += Val::ONE, + 11 => bad.proof.opening_proof.query_pow_witness += Val::ONE, + 12 => { + bad.proof.opening_proof.commit_phase_openings[2].sibling_values[0] + [0] += ExtVal::ONE + }, + 13 => { + bad.proof.opening_proof.commit_phase_openings[5].sibling_values[0] + [0] += ExtVal::ONE + }, + 14 => { + bad.proof.opening_proof.input_openings[0].opened_values[0][6][0] += + Val::ONE + }, + 15 => bad.proof.intermediate_accumulators[2] += ExtVal::ONE, + 16 => bad.proof.stage_1_opened_values[1][1][2] += ExtVal::ONE, + _ => bad.proof.opening_proof.final_poly[0] += ExtVal::ONE, + } + assert!(!accepts(&verifier, &bad, subject), "accepted attack {attack}"); + } + // The enclosing circuit, not the host adapter, supplies the claim. + let mut ignored_claim = expanded.clone(); + ignored_claim.claims[0][1] += Val::ONE; + assert!(accepts(&verifier, &ignored_claim, subject)); + + // These are VALID native fixture proofs, but receipts for the wrong + // statement type/scope/assumptions. Rejection must come from the + // composed circuit, not native acceptance in the witness adapter. + for attack in 0..5 { + let mut encoded = fixture::receipt_bytes(subject); + match attack { + 0 => encoded[0] = 0xe4, // wrong claim tag + 1 => encoded[1] = 2, // old object format + 2 => encoded[2] = 0, // wrong validator identifier + 3 => { + encoded[35] = 1; + encoded.extend([0x33; 32]); + }, // conditional + _ => encoded.push(0), // noncanonical trailing byte + } + let digest: [u8; 32] = Blake3.hash_iter(encoded); + let mut wrong_claim = claim.clone(); + for (word, chunk) in + wrong_claim[10..].iter_mut().zip(digest.as_chunks::<4>().0) + { + *word = Val::from_u32(u32::from_le_bytes(*chunk)); + } + let mut traces = fixture::traces(profile, subject); + traces[0].values[1..9].copy_from_slice(&wrong_claim[10..]); + let wrong_proof = system.prove( + &key, + &wrong_claim, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify(&wrong_claim, &wrong_proof).unwrap(); + let wrong = expand_pcs_witness( + &system, + &verifier.inputs.shape, + &wrong_proof, + &[&wrong_claim], + ) + .unwrap(); + assert!( + !accepts(&verifier, &wrong, subject), + "accepted wrong statement codec {attack}" + ); + } + + // Same dimensions/active map, different trusted preprocessing OR AIR. + for attack in 0..2 { + let mut definitions = fixture::circuit_inputs(profile); + let mut traces = fixture::traces(profile, subject); + if attack == 0 { + definitions[3].preprocessed.as_mut().unwrap().values[10] += Val::ONE; + } else { + use multi_stark::expr::Expr; + definitions[8].constraints = + vec![Expr::main(0) - Expr::constant(Val::from_u8(18))]; + traces[8].values[0] = Val::from_u8(18); + } + let (other_system, other_key) = + System::new(profile.config(), definitions); + let other_proof = other_system.prove( + &other_key, + &claim, + SystemWitness::from_stage_1(traces, &other_system), + ); + other_system.verify(&claim, &other_proof).unwrap(); + let other = expand_pcs_witness( + &other_system, + &profile.shape(&other_system), + &other_proof, + &[&claim], + ) + .unwrap(); + assert!( + !accepts(&verifier, &other, subject), + "accepted another key {attack}" + ); + } + } +} + +#[test] +fn root_statement_codec_and_unit_consumer_policy_are_explicit() { + let subject = std::array::from_fn(|i| u8::try_from(i).unwrap()); + let encoded = fixture::receipt_bytes(subject); + // Golden fixture wire layout, written explicitly rather than using a shared helper + // used to construct the circuit. The unconditional receipt is 36 bytes. + assert_eq!(encoded.len(), 36); + assert_eq!(&encoded[..3], &[0xe5, 3, 1]); + assert_eq!(&encoded[3..35], &subject); + assert_eq!(encoded[35], 0); + let blob = fixture::allowed_blob(); + assert_eq!(blob.len(), 80); + assert_eq!(&blob[32..40], &23u64.to_le_bytes()); + assert_eq!(&blob[72..80], &fixture::AGGR_INDEX.to_le_bytes()); + let bound = |slots: &[usize], active: &[bool], logs: &[u8], claims| { + fixture::unit_lookup_bound(slots.iter().copied(), active, logs, claims) + }; + assert_eq!(bound(&[2, 999, 3], &[true, false, true], &[7, 0], 1), Some(260)); + assert_eq!( + bound(&[1], &[true], &[0], Val::ORDER_U64 - 2), + Some(Val::ORDER_U64 - 1) + ); + assert_eq!(bound(&[1], &[true], &[0], Val::ORDER_U64 - 1), None); + assert_eq!(bound(&[3], &[true], &[63], 1), None); + assert_eq!(bound(&[1], &[true], &[64], 1), None); + assert_eq!(bound(&[1], &[false], &[], 1), None); + assert_eq!(bound(&[1], &[true], &[], 1), None); + assert_eq!(bound(&[1], &[true], &[0, 0], 1), None); + assert_eq!(bound(&[1], &[true, false], &[0], 1), None); +} + +#[test] +fn root_profile_full_size_native_proof() { + let profile = fixture::Profile::FullSize; + let (system, key) = + System::new(profile.config(), fixture::circuit_inputs(profile)); + let subject = [0x5a; 32]; + let claim = fixture::claim(subject); + let proof = system.prove( + &key, + &claim, + SystemWitness::from_stage_1(fixture::traces(profile, subject), &system), + ); + system.verify(&claim, &proof).unwrap(); + let shape = profile.shape(&system); + assert_eq!(shape.queries, 100); + assert_eq!(shape.log_trace, 16); + expand_pcs_witness(&system, &shape, &proof, &[&claim]).unwrap(); +} diff --git a/crates/proof-compression/tests/support/parity.rs b/crates/proof-compression/tests/support/parity.rs new file mode 100644 index 000000000..dd7638c5e --- /dev/null +++ b/crates/proof-compression/tests/support/parity.rs @@ -0,0 +1,127 @@ +//! Generated inner proof fixture for the Plonkish verifier. +//! +//! Each function has 128 slots, with argument `n` fixed in slot `n` by +//! preprocessing. A live row consumes its call and (unless `n == 0`) emits +//! a call to the other function at `n - 1`, with the same return value. +//! Unused slots contain `[live = 0, result = 0]` and emit no messages. +//! This deliberately supports one public root call, not arbitrary batches. + +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_matrix::dense::RowMajorMatrix; +use multi_stark::system::CircuitInputs; +use multi_stark::types::CommitmentParameters; +use multi_stark::types::FriParameters; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; + +pub(crate) const HEIGHT: usize = 128; +pub(crate) const LOG_HEIGHT: u8 = 7; +pub(crate) const NUM_QUERIES: usize = 4; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Function { + Even, + Odd, +} + +impl Function { + pub(crate) const ALL: [Self; 2] = [Self::Even, Self::Odd]; + + pub(crate) fn index(self) -> usize { + match self { + Self::Even => 0, + Self::Odd => 1, + } + } + + fn other(self) -> Self { + match self { + Self::Even => Self::Odd, + Self::Odd => Self::Even, + } + } + + pub(crate) fn result(self, n: usize) -> bool { + n % 2 == self.index() + } +} + +/// Tiny test parameters, NOT a production security configuration. +pub(crate) fn config() -> GoldilocksBlake3Config { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 1, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: NUM_QUERIES, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) +} + +pub(crate) fn circuit_inputs() -> Vec> { + Function::ALL + .into_iter() + .map(|function| { + let one = Expr::constant(Val::ONE); + let live = Expr::main(0); + let result = Expr::main(1); + let n = Expr::preprocessed(0); + let base = Expr::preprocessed(1); + let tag = Expr::constant(Val::from_usize(function.index())); + let other_tag = Expr::constant(Val::from_usize(function.other().index())); + let base_result = Expr::constant(Val::from_bool(function.result(0))); + CircuitInputs { + main_width: 2, + preprocessed: Some(RowMajorMatrix::new( + (0..HEIGHT) + .flat_map(|n| [Val::from_usize(n), Val::from_bool(n == 0)]) + .collect(), + 2, + )), + constraints: vec![ + live.clone() * (live.clone() - one.clone()), + result.clone() * (result.clone() - one.clone()), + (one.clone() - live.clone()) * result.clone(), + base.clone() * (result.clone() - base_result * live.clone()), + ], + lookups: vec![ + Lookup::pull(live.clone(), vec![tag, n.clone(), result.clone()]), + Lookup::push( + live * (one.clone() - base), + vec![other_tag, n - one, result], + ), + ], + ..Default::default() + } + }) + .collect() +} + +/// The verifier supplies the expected result independently of the witness. +pub(crate) fn claim(function: Function, n: usize, expected: bool) -> Vec { + assert!(n < HEIGHT, "parity fixture supports arguments 0..128"); + vec![ + Val::from_usize(function.index()), + Val::from_usize(n), + Val::from_bool(expected), + ] +} + +pub(crate) fn traces(function: Function, n: usize) -> Vec> { + assert!(n < HEIGHT, "parity fixture supports arguments 0..128"); + let mut traces: Vec<_> = Function::ALL + .iter() + .map(|_| RowMajorMatrix::new(vec![Val::ZERO; HEIGHT * 2], 2)) + .collect(); + let mut callee = function; + for argument in (0..=n).rev() { + let row = &mut traces[callee.index()].values[argument * 2..][..2]; + row.copy_from_slice(&[Val::ONE, Val::from_bool(callee.result(argument))]); + callee = callee.other(); + } + traces +} diff --git a/crates/proof-compression/tests/support/parity_algebra.rs b/crates/proof-compression/tests/support/parity_algebra.rs new file mode 100644 index 000000000..c9edb5518 --- /dev/null +++ b/crates/proof-compression/tests/support/parity_algebra.rs @@ -0,0 +1,179 @@ +//! Host-side test adapter, NOT trusted circuit verification. Transcript +//! replay supplies witness values and differential-test oracle challenges; +//! transcript tests constrain these values independently. + +use ix_proof_compression::fri::AlgebraicInputs; +use ix_proof_compression::fri::QuadraticValue; +use multi_stark::config::StarkGenericConfig; +use multi_stark::p3_field::BasedVectorSpace; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Witness; +use multi_stark::plonkish::WitnessError; +use multi_stark::prover::Proof; +use multi_stark::system::System; +use multi_stark::types::ExtVal; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; +use p3_challenger::{CanObserve, FieldChallenger}; + +/// Native transcript replay through zeta, in the order used by System::verify. +pub(crate) fn challenges( + system: &System, + proof: &Proof, + claims: &[&[Val]], +) -> [ExtVal; 4] { + let mut challenger = system.config.initialise_challenger(); + system.observe_shape(&mut challenger); + for &active in &proof.active { + challenger.observe(Val::from_bool(active)); + } + if let Some(commitment) = &system.preprocessed_commit { + challenger.observe(commitment.clone()); + } + challenger.observe(proof.commitments.stage_1_trace.clone()); + for &height in &proof.log_degrees { + challenger.observe(Val::from_u8(height)); + } + challenger.observe(Val::from_usize(claims.len())); + for claim in claims { + challenger.observe(Val::from_usize(claim.len())); + challenger.observe_slice(claim); + } + let beta: ExtVal = challenger.sample_algebra_element(); + challenger.observe_algebra_element(beta); + let gamma: ExtVal = challenger.sample_algebra_element(); + challenger.observe_algebra_element(gamma); + challenger.observe(proof.commitments.stage_2_trace.clone()); + for &accumulator in &proof.intermediate_accumulators { + challenger.observe_algebra_element(accumulator); + } + let alpha = challenger.sample_algebra_element(); + challenger.observe(proof.commitments.quotient_chunks.clone()); + let zeta = challenger.sample_algebra_element(); + [beta, gamma, alpha, zeta] +} + +pub(crate) fn set_extension( + witness: &mut Witness<'_, Val>, + wire: QuadraticValue, + value: ExtVal, +) -> Result<(), WitnessError> { + for (wire, &value) in wire.0.iter().zip(value.as_basis_coefficients_slice()) { + witness.set(*wire, value)?; + } + Ok(()) +} + +pub(crate) fn assign( + witness: &mut Witness<'_, Val>, + inputs: &AlgebraicInputs, + proof: &Proof, + claims: &[&[Val]], + challenges: [ExtVal; 4], +) -> Result<(), WitnessError> { + assert_eq!(claims.len(), inputs.claims.len()); + for (wires, claim) in inputs.claims.iter().zip(claims) { + assert_eq!(wires.len(), claim.len()); + for (&wire, &value) in wires.iter().zip(*claim) { + witness.set(wire, value)?; + } + } + let c = inputs.challenges; + for (wire, value) in + [c.beta, c.gamma, c.alpha, c.zeta].into_iter().zip(challenges) + { + set_extension(witness, wire, value)?; + } + assert_eq!(proof.active, vec![true; inputs.openings.len()]); + let mut preprocessed_slot = 0; + for (i, opening) in inputs.openings.iter().enumerate() { + let mut windows = vec![ + (&opening.main, &proof.stage_1_opened_values[i]), + (&opening.stage2, &proof.stage_2_opened_values[i]), + ]; + if !opening.preprocessed[0].is_empty() { + windows.push(( + &opening.preprocessed, + &proof.preprocessed_opened_values.as_ref().unwrap()[preprocessed_slot], + )); + preprocessed_slot += 1; + } + for (wires, values) in windows { + assert_eq!(values.len(), 2); + for row in 0..2 { + assert_eq!(wires[row].len(), values[row].len()); + for (&wire, &value) in wires[row].iter().zip(&values[row]) { + set_extension(witness, wire, value)?; + } + } + } + assert_eq!(proof.quotient_opened_values[i].len(), 1); + assert_eq!( + opening.quotient.len(), + proof.quotient_opened_values[i][0].len() + ); + for (&wire, &value) in + opening.quotient.iter().zip(&proof.quotient_opened_values[i][0]) + { + set_extension(witness, wire, value)?; + } + set_extension( + witness, + opening.accumulator, + proof.intermediate_accumulators[i], + )?; + } + Ok(()) +} + +/// Publicly bind the complete algebraic instance for isolated algebra tests. +/// The full verifier authenticates these wires with transcript/PCS +/// constraints and exposes only the inner claims. +pub(crate) fn expose_boundary( + builder: &mut CircuitBuilder, + inputs: &AlgebraicInputs, +) { + let c = inputs.challenges; + let mut boundary = vec![c.beta, c.gamma, c.alpha, c.zeta]; + for o in &inputs.openings { + boundary.extend(o.preprocessed.iter().flatten().copied()); + boundary.extend(o.main.iter().flatten().copied()); + boundary.extend(o.stage2.iter().flatten().copied()); + boundary.extend(o.quotient.iter().copied()); + boundary.push(o.accumulator); + } + for wire in boundary { + for coordinate in wire.0 { + builder.expose_public(coordinate); + } + } +} + +/// Expected public instance obtained from the native inputs, independently +/// of the Plonkish witness assignment. Ordering matches expose_boundary(). +pub(crate) fn statement( + proof: &Proof, + claims: &[&[Val]], + challenges: [ExtVal; 4], +) -> Vec { + let mut public: Vec = + claims.iter().flat_map(|claim| claim.iter().copied()).collect(); + let mut extension_values = challenges.to_vec(); + // The example has preprocessing for both circuits; this helper is not + // the general sparse-system/optional-preprocessing proof adapter. + let preprocessed = proof.preprocessed_opened_values.as_ref().unwrap(); + for (i, preprocessed) in preprocessed.iter().enumerate() { + extension_values.extend(preprocessed.iter().flatten().copied()); + extension_values + .extend(proof.stage_1_opened_values[i].iter().flatten().copied()); + extension_values + .extend(proof.stage_2_opened_values[i].iter().flatten().copied()); + extension_values.extend(proof.quotient_opened_values[i][0].iter().copied()); + extension_values.push(proof.intermediate_accumulators[i]); + } + for value in extension_values { + public.extend_from_slice(value.as_basis_coefficients_slice()); + } + public +} diff --git a/crates/proof-compression/tests/support/root_profile.rs b/crates/proof-compression/tests/support/root_profile.rs new file mode 100644 index 000000000..62aec9f34 --- /dev/null +++ b/crates/proof-compression/tests/support/root_profile.rs @@ -0,0 +1,404 @@ +//! Synthetic aggregation-root profile for exercising the Plonkish builder. +//! The root consumes an 18-word receipt and calls mutually recursive parity, +//! two fixed tables, and a small memory-like counter circuit. Inactive functions +//! are interspersed so canonical, active, and preprocessed indices all differ. +//! The receipt binds a fixed tag, format, validator, subject and assumptions marker. +//! Keys and circuit semantics are synthetic, not an application implementation. + +use ix_proof_compression::fri::ByteGadgets; +use ix_proof_compression::fri::ByteValue; +use ix_proof_compression::fri::FixedPcsShape; +use ix_proof_compression::fri::FixedVerifierInputs; +use ix_proof_compression::fri::blake3; +use ix_proof_compression::fri::constrain_fixed_verifier; +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::p3_field::PrimeField64; +use multi_stark::p3_matrix::dense::RowMajorMatrix; +use multi_stark::plonkish::Circuit; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::plonkish::Value; +use multi_stark::system::CircuitInputs; +use multi_stark::system::System; +use multi_stark::types::CommitmentParameters; +use multi_stark::types::FriParameters; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; +use p3_blake3::Blake3; +use p3_symmetric::CryptographicHasher; + +pub(crate) const ACTIVE: [bool; 9] = + [true, false, true, true, true, false, true, true, true]; +pub(crate) const AGGR_INDEX: u64 = 17; +// Fixture receipt: claim tag = e5, format = 3, validator identifier = 1. +const RECEIPT_PREFIX: [u8; 3] = [0xe5, 3, 1]; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Profile { + /// Four queries, small grinding and small fixed tables; same algorithms. + Smoke, + /// Larger fixed tables, 100 queries and 20-bit query grinding. + FullSize, +} + +impl Profile { + pub(crate) fn logs(self) -> Vec { + let tables = match self { + Self::Smoke => [4, 5], + Self::FullSize => [8, 16], + }; + vec![7, 7, tables[0], 7, tables[1], 6, 0] + } + + pub(crate) fn config(self) -> GoldilocksBlake3Config { + let (queries, commit_pow, query_pow) = match self { + Self::Smoke => (4, 3, 4), + Self::FullSize => (100, 0, 20), + }; + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + FriParameters { + log_final_poly_len: 0, + max_log_arity: 1, + num_queries: queries, + commit_proof_of_work_bits: commit_pow, + query_proof_of_work_bits: query_pow, + }, + ) + } + + pub(crate) fn shape( + self, + system: &System, + ) -> FixedPcsShape { + // This fixture has unit consumers. Count ALL slots (even + // providers) conservatively; fixed metadata makes this a build-time + // policy check, not a prover-supplied claim about multiplicities. + assert!( + unit_lookup_bound( + system.circuits.iter().map(|c| c.graph.lookups.len()), + &ACTIVE, + &self.logs(), + 1 + ) + .is_some(), + "unit lookup count reaches the field characteristic" + ); + FixedPcsShape::from_profile(system, &ACTIVE, &self.logs()) + } +} + +/// Conservatively bounds the lookup budget. This assumption is specific to unit +/// consumers; it must NOT be applied to arbitrary user lookup multiplicities. +pub(crate) fn unit_lookup_bound( + slots: impl ExactSizeIterator, + active: &[bool], + logs: &[u8], + claims: u64, +) -> Option { + if slots.len() != active.len() + || !active.contains(&true) + || claims >= Val::ORDER_U64 + { + return None; + } + let mut logs = logs.iter(); + let mut total = claims; + for (slots, &enabled) in slots.zip(active) { + if enabled { + let height = 1u64.checked_shl(u32::from(*logs.next()?))?; + total = + total.checked_add(height.checked_mul(u64::try_from(slots).ok()?)?)?; + if total >= Val::ORDER_U64 { + return None; + } + } + } + logs.next().is_none().then_some(total) +} + +fn words(digest: [u8; 32]) -> [Val; 8] { + std::array::from_fn(|i| { + Val::from_u32(u32::from_le_bytes( + digest[4 * i..4 * i + 4].try_into().unwrap(), + )) + }) +} + +/// An 80-byte allow-list blob of two synthetic VK digests and entrypoint indices. +pub(crate) fn allowed_blob() -> Vec { + [0x11; 32] + .into_iter() + .chain(23u64.to_le_bytes()) + .chain([0x22; 32]) + .chain(AGGR_INDEX.to_le_bytes()) + .collect() +} + +pub(crate) fn allowed_digest() -> [Val; 8] { + words(Blake3.hash_iter(allowed_blob())) +} + +pub(crate) fn claim(subject: [u8; 32]) -> Vec { + let output = words(Blake3.hash_iter(receipt_bytes(subject))); + [Val::ZERO, Val::from_u64(AGGR_INDEX)] + .into_iter() + .chain(allowed_digest()) + .chain(output) + .collect() +} + +pub(crate) fn receipt_bytes(subject: [u8; 32]) -> Vec { + RECEIPT_PREFIX.into_iter().chain(subject).chain([0]).collect() +} + +fn c(n: usize) -> Expr { + Expr::constant(Val::from_usize(n)) +} + +fn inactive() -> CircuitInputs { + CircuitInputs { + main_width: 1, + constraints: vec![Expr::main(0)], + ..Default::default() + } +} + +fn parity(function: usize) -> CircuitInputs { + // Unlike the original parity fixture, functions have NO preprocessing. + // Main columns: live, result, n, is_base. All 128 rows retain their n. + let live = Expr::main(0); + let result = Expr::main(1); + let n = Expr::main(2); + let base = Expr::main(3); + CircuitInputs { + main_width: 4, + constraints: vec![ + live.clone() * (live.clone() - c(1)), + result.clone() * (result.clone() - c(1)), + base.clone() * (base.clone() - c(1)), + (c(1) - live.clone()) * result.clone(), + base.clone() * (result.clone() - c(1 - function) * live.clone()), + n.clone() * base.clone(), + Expr::IsFirstRow * n.clone(), + Expr::IsFirstRow * (base.clone() - c(1)), + Expr::IsTransition * (Expr::main_next(2) - n.clone() - c(1)), + Expr::IsTransition * Expr::main_next(3), + Expr::IsLastRow * (n.clone() - c(127)), + ], + lookups: vec![ + Lookup::pull(live.clone(), vec![c(function), n.clone(), result.clone()]), + Lookup::push( + live * (c(1) - base), + vec![c(1 - function), n - c(1), result], + ), + ], + lookup_group_size: 2, + ..Default::default() + } +} + +fn table(height: usize, tag: usize) -> CircuitInputs { + // Eleven preprocessed columns stress wide fixed tables. This toy relation + // uses all columns, with two message widths and next-row preprocessing. + let preprocessed = RowMajorMatrix::new( + (0..height) + .flat_map(|row| (0..11).map(move |col| Val::from_usize(row + col))) + .collect(), + 11, + ); + let mut args = vec![c(tag)]; + args.extend((0..if tag == 3 { 1 } else { 11 }).map(Expr::preprocessed)); + CircuitInputs { + main_width: 1, + preprocessed: Some(preprocessed), + constraints: vec![ + Expr::main(0) * (Expr::main(0) - c(1)), + Expr::IsTransition + * (Expr::preprocessed_next(0) - Expr::preprocessed(0) - c(1)), + ], + lookups: vec![Lookup::pull(Expr::main(0), args)], + lookup_group_size: 2, + ..Default::default() + } +} + +pub(crate) fn circuit_inputs(profile: Profile) -> Vec> { + let live = Expr::main(0); + let mut root_claim = vec![c(0), Expr::constant(Val::from_u64(AGGR_INDEX))]; + root_claim.extend(allowed_digest().map(Expr::constant)); + root_claim.extend((1..=8).map(Expr::main)); + let mut root_constraints = vec![ + live.clone() * (live.clone() - c(1)), + Expr::IsFirstRow * (live.clone() - c(1)), + Expr::IsTransition * Expr::main_next(0), + ]; + root_constraints + .extend((1..=8).map(|i| (c(1) - live.clone()) * Expr::main(i))); + root_constraints.extend( + [(9, 100), (10, 7), (11, 9)] + .map(|(i, value)| Expr::main(i) - live.clone() * c(value)), + ); + let root = CircuitInputs { + main_width: 12, + constraints: root_constraints, + lookups: vec![ + Lookup::pull(live.clone(), root_claim), + Lookup::push(live.clone(), vec![c(0), Expr::main(9), c(1)]), + Lookup::push(live.clone(), vec![c(3), Expr::main(10)]), + Lookup::push( + live.clone(), + std::iter::once(c(6)) + .chain((0..11).map(|i| Expr::main(11) + c(i))) + .collect(), + ), + Lookup::push(live, vec![c(7), c(42)]), + ], + // Five messages: one full group plus a partial final group. + lookup_group_size: 3, + ..Default::default() + }; + let memory = CircuitInputs { + main_width: 2, + constraints: vec![ + Expr::IsFirstRow * Expr::main(0), + Expr::IsTransition * (Expr::main_next(0) - Expr::main(0) - c(1)), + Expr::IsLastRow * (Expr::main(0) - c(63)), + Expr::IsFirstRow * (Expr::main(1) - c(1)), + Expr::IsTransition * Expr::main_next(1), + ], + lookups: vec![Lookup::pull(Expr::main(1), vec![c(7), c(42)])], + ..Default::default() + }; + let singleton = CircuitInputs { + main_width: 1, + constraints: vec![Expr::main(0) - c(17)], + ..Default::default() + }; + let logs = profile.logs(); + vec![ + root, + inactive(), + parity(0), + table(1 << logs[2], 3), + parity(1), + inactive(), + table(1 << logs[4], 6), + memory, + singleton, + ] +} + +pub(crate) fn traces( + profile: Profile, + subject: [u8; 32], +) -> Vec> { + let mut root = RowMajorMatrix::new(vec![Val::ZERO; 128 * 12], 12); + root.values[0] = Val::ONE; + root.values[1..9].copy_from_slice(&claim(subject)[10..18]); + root.values[9..12].copy_from_slice(&[ + Val::from_u8(100), + Val::from_u8(7), + Val::from_u8(9), + ]); + let mut functions: Vec<_> = (0..2) + .map(|_| { + RowMajorMatrix::new( + (0..128) + .flat_map(|n| { + [Val::ZERO, Val::ZERO, Val::from_usize(n), Val::from_bool(n == 0)] + }) + .collect(), + 4, + ) + }) + .collect(); + for n in 0..=100 { + let row = &mut functions[n % 2].values[n * 4..][..4]; + row[0] = Val::ONE; + row[1] = Val::ONE; + } + let logs = profile.logs(); + let mut small = RowMajorMatrix::new(vec![Val::ZERO; 1 << logs[2]], 1); + small.values[7] = Val::ONE; + let mut large = RowMajorMatrix::new(vec![Val::ZERO; 1 << logs[4]], 1); + large.values[9] = Val::ONE; + let memory = RowMajorMatrix::new( + (0..64) + .flat_map(|n| [Val::from_usize(n), Val::from_bool(n == 0)]) + .collect(), + 2, + ); + let odd = functions.pop().unwrap(); + let even = functions.pop().unwrap(); + vec![ + root, + RowMajorMatrix::new(vec![], 1), + even, + small, + odd, + RowMajorMatrix::new(vec![], 1), + large, + memory, + RowMajorMatrix::new(vec![Val::from_u8(17)], 1), + ] +} + +pub(crate) struct StatementVerifier { + pub circuit: Circuit, + pub inputs: FixedVerifierInputs, + pub subject: [ByteValue; 32], +} + +fn pack_words( + b: &mut CircuitBuilder, + digest: &[ByteValue; 32], +) -> Vec { + digest + .as_chunks::<4>() + .0 + .iter() + .map(|chunk| { + let terms: Vec<_> = chunk + .iter() + .enumerate() + .map(|(i, byte)| (Val::from_u64(1u64 << (8 * i)), byte.value())) + .collect(); + b.linear_combination(&terms, Val::ZERO) + }) + .collect() +} + +pub(crate) fn build_statement_verifier( + system: &System, + profile: Profile, +) -> StatementVerifier { + let mut b = CircuitBuilder::new(); + let bytes = ByteGadgets::new(&mut b); + let subject = std::array::from_fn(|_| { + let value = bytes.input(&mut b, "public subject digest byte"); + b.expose_public(value.value()); + value + }); + let mut encoded: Vec<_> = + RECEIPT_PREFIX.iter().map(|&v| bytes.constant(&mut b, v)).collect(); + encoded.extend(subject); + encoded.push(bytes.constant(&mut b, 0)); + let output = blake3(&mut b, &bytes, &encoded); + let allowed: Vec<_> = + allowed_blob().into_iter().map(|v| bytes.constant(&mut b, v)).collect(); + let allowed = blake3(&mut b, &bytes, &allowed); + let mut claim = + vec![b.constant(Val::ZERO), b.constant(Val::from_u64(AGGR_INDEX))]; + claim.extend(pack_words(&mut b, &allowed)); + claim.extend(pack_words(&mut b, &output)); + let inputs = constrain_fixed_verifier( + &mut b, + &bytes, + system, + &profile.shape(system), + vec![claim], + ); + StatementVerifier { circuit: b.finish(), inputs, subject } +} diff --git a/crates/proof-compression/tests/support/verifier_audit.rs b/crates/proof-compression/tests/support/verifier_audit.rs new file mode 100644 index 000000000..d4d87ca09 --- /dev/null +++ b/crates/proof-compression/tests/support/verifier_audit.rs @@ -0,0 +1,93 @@ +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::p3_field::PrimeCharacteristicRing; +use multi_stark::system::CircuitInputs; +use multi_stark::system::ProverKey; +use multi_stark::system::System; +use multi_stark::system::SystemWitness; +use multi_stark::types::CommitmentParameters; +use multi_stark::types::FriParameters; +use multi_stark::types::GoldilocksBlake3Config as Config; +use multi_stark::types::Val; +use p3_matrix::dense::RowMajorMatrix; + +pub(crate) const ACTIVE: [bool; 4] = [false, true, true, true]; +pub(crate) const LOGS: [u8; 3] = [2, 3, 0]; + +pub(crate) fn setup() -> (System, ProverKey) { + let x = Expr::main(0); + System::new( + Config::new( + CommitmentParameters { log_blowup: 2, cap_height: 0 }, + FriParameters { + num_queries: 2, + log_final_poly_len: 0, + max_log_arity: 1, + commit_proof_of_work_bits: 3, + query_proof_of_work_bits: 4, + }, + ), + [ + CircuitInputs { main_width: 1, ..Default::default() }, + CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![ + Val::ONE, + Val::ZERO, + Val::ZERO, + Val::ZERO, + ])), + // Three lookups exercise a full group and a partial group. + lookups: vec![ + Lookup::pull( + Expr::preprocessed(0), + vec![Expr::constant(Val::from_u8(13)), x.clone()], + ), + Lookup::push(x.clone(), vec![x.clone()]), + Lookup::pull(x.clone(), vec![x.clone()]), + ], + lookup_group_size: 2, + ..Default::default() + }, + CircuitInputs { + main_width: 2, + constraints: vec![ + Expr::IsFirstRow * x.clone(), + Expr::IsLastRow * (x.clone() - Expr::constant(Val::from_u8(7))), + Expr::IsTransition + * (Expr::main_next(0) - x.clone() - Expr::constant(Val::ONE)), + x.clone() * x.clone() * x - Expr::main(1), + ], + ..Default::default() + }, + CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![Val::from_u8(9)])), + constraints: vec![Expr::main(0) - Expr::preprocessed(0)], + ..Default::default() + }, + ], + ) +} +pub(crate) fn claims() -> Vec> { + vec![vec![Val::from_u8(13), Val::from_u8(42)]] +} +pub(crate) fn witness(system: &System) -> SystemWitness { + SystemWitness::from_stage_1( + vec![ + RowMajorMatrix::new_col(vec![]), + RowMajorMatrix::new_col(vec![Val::from_u8(42); 4]), + RowMajorMatrix::new( + (0..8) + .flat_map(|i| { + let x = Val::from_u8(i); + [x, x * x * x] + }) + .collect(), + 2, + ), + RowMajorMatrix::new_col(vec![Val::from_u8(9)]), + ], + system, + ) +} From 647fb8fe3e085b768e4b0a0775cf3d68ae2d006b Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:40:57 +0000 Subject: [PATCH 02/31] feat(compression)!: replace local setup with bundled verifying keys Compress through a separate ix-compression-worker executable with authenticated Filecoin parameters, public-degree KZG and integrity-checked preprocessing caches. Verification uses exactly two application-bundled verifier profiles, Filecoin and development, with claim-bound fixtures; development parameters require --dev-srs on both compression and verification, which still check the proof, claim and pairings. Remove the local setup flow: `ix compression setup/layout`, the locally registered verifier directory, the rs_compression_layout extern and the setup-time root layout file. The bundled trust policy replaces them. Share circuit construction, lowering, statement schemas and prepared verification between the worker and the library stage APIs. Release frontend data before KZG proving, pass setup, cache and prefetch configuration explicitly to workers, and trace phases through IX_TEXRAY. The library keeps the general stage APIs, the normalized FixedRoot, multi-shard batch verification and witness-free verifier construction. Carry the improvements of the squashed FRI and recursive KZG compression into the restructured pipeline: - FixedRoot takes one log-height per system circuit and (width, circuit) memory pairs, matching the per-trace normalization in aiur. - stages::setup takes independent FRI, intermediate KZG and recursive KZG heights. - KZG keeps constant columns compact: interpolation, checkpoint loading and quotient slicing produce Column::Constant, which commits as a multiple of the first power and evaluates and opens without materializing coefficients. - SRS validation hashes through a streaming writer and bounds the MSM scratch by chunking adjacent-power pairs. - The FRI stage drops fixed evaluations, keys and witness-expansion data as soon as proving no longer needs them. Pin multi-stark to 2ed23ca (Plonkish proving improvements) for the witness and builder interfaces the stages use. CUDA proving lands separately. --- Benchmarks/Typecheck.lean | 2 +- Cargo.lock | 29 +- Cargo.toml | 2 +- Ix/Cli/AggregateCmd.lean | 2 +- Ix/Cli/CompressCmd.lean | 40 +- Ix/Cli/Compression.lean | 55 +- Ix/Cli/CompressionSetupCmd.lean | 87 -- Ix/Cli/ProveCmd.lean | 2 +- Ix/Cli/VerifyCmd.lean | 99 +- Ix/TracingTexray.lean | 4 + Main.lean | 6 +- Tests/Cli.lean | 68 +- Tests/CompressionCli.lean | 139 +++ Tests/CompressionCliMain.lean | 5 + crates/ffi/src/compression.rs | 371 +----- crates/ffi/src/compression/layout.rs | 90 -- crates/ffi/src/compression/local.rs | 337 ------ crates/proof-compression/Cargo.toml | 11 +- crates/proof-compression/README.md | 427 +++++-- .../examples/init16-root-layout.json | 23 - .../examples/kzg_filecoin_import.rs | 69 ++ .../profiles/aggregate-development-v5.bin | Bin 0 -> 2008 bytes .../profiles/aggregate-development-v5.json | 47 + .../profiles/aggregate-filecoin-v5.bin | Bin 0 -> 2008 bytes .../profiles/aggregate-filecoin-v5.json | 47 + .../src/bin/ix_compression_worker.rs | 48 + crates/proof-compression/src/foreign.rs | 216 +++- .../proof-compression/src/foreign/interval.rs | 68 ++ .../proof-compression/src/foreign/quotient.rs | 104 ++ .../src/foreign/tests/independent_witness.rs | 241 ++++ .../src/foreign/tests/indexed_names.rs | 262 ++++ .../src/foreign/tests/quotient_hints.rs | 207 ++++ .../src/foreign/tests/range_hints.rs | 171 +++ .../foreign/tests/translation_intervals.rs | 242 ++++ crates/proof-compression/src/fri/algebra.rs | 6 +- crates/proof-compression/src/kzg/buffer.rs | 22 + .../proof-compression/src/kzg/coefficients.rs | 12 +- crates/proof-compression/src/kzg/compact.rs | 34 +- crates/proof-compression/src/kzg/config.rs | 488 +++++--- crates/proof-compression/src/kzg/encoding.rs | 128 ++ crates/proof-compression/src/kzg/mod.rs | 9 +- crates/proof-compression/src/kzg/pcs.rs | 687 ++++++----- .../src/kzg/pcs/public_degree_tests.rs | 100 ++ crates/proof-compression/src/kzg/srs.rs | 254 ++-- .../proof-compression/src/kzg/srs/filecoin.rs | 1008 ++++++++++++++++ crates/proof-compression/src/kzg/srs_cache.rs | 343 ++++++ .../proof-compression/src/kzg/transcript.rs | 312 ++++- .../src/kzg/verifier_params.rs | 106 +- crates/proof-compression/src/lib.rs | 1 + .../proof-compression/src/pipeline/first.rs | 473 ++++++++ .../src/pipeline/fixed_cache.rs | 179 +++ .../src/pipeline/init_claim.rs | 6 + .../src/pipeline/init_fri.rs | 92 ++ crates/proof-compression/src/pipeline/mod.rs | 623 ++++------ .../proof-compression/src/pipeline/prover.rs | 456 +++++++ crates/proof-compression/src/pipeline/root.rs | 277 +++-- .../proof-compression/src/pipeline/setup.rs | 634 ++++++++-- .../src/pipeline/{ => stages}/codec.rs | 80 +- .../src/pipeline/stages/construct.rs | 239 ++++ .../src/pipeline/{ => stages}/fixed.rs | 2 +- .../src/pipeline/stages/mod.rs | 300 +++++ .../src/pipeline/stages/root.rs | 225 ++++ .../src/pipeline/stages/setup.rs | 93 ++ .../src/pipeline/stages/statement.rs | 214 ++++ .../src/pipeline/{ => stages}/tests.rs | 47 +- .../src/pipeline/stages/verification.rs | 217 ++++ .../src/pipeline/stages/wrap.rs | 242 ++++ .../src/pipeline/statement.rs | 152 +++ .../proof-compression/src/pipeline/storage.rs | 121 ++ .../src/pipeline/verifier.rs | 252 ++++ crates/proof-compression/src/pipeline/wrap.rs | 357 ------ .../src/pipeline/wrap/mod.rs | 54 + .../src/pipeline/wrap/outer.rs | 1065 +++++++++++++++++ .../src/pipeline/wrap/saved.rs | 426 +++++++ .../src/recursive/native_curve.rs | 126 +- .../src/recursive/native_field.rs | 129 +- .../src/recursive/native_field/advice.rs | 126 ++ .../recursive/native_field/advice/tests.rs | 424 +++++++ .../src/recursive/native_msm.rs | 340 +++++- .../src/recursive/native_transcript.rs | 30 +- .../src/recursive/native_verifier.rs | 697 ++++++----- .../src/recursive/native_verifier/bindings.rs | 213 ++++ .../native_verifier/parallel_prefix_tests.rs | 311 +++++ .../src/recursive/native_verifier/plan.rs | 361 ++++++ .../recursive/native_verifier/reuse_tests.rs | 427 +++++++ .../src/recursive/native_verifier/shape.rs | 283 +++++ .../aggregate-kzg/development-v5-packet.bin | Bin 0 -> 2181 bytes .../fixtures/aggregate-kzg/filecoin-claim.bin | Bin 0 -> 36 bytes .../aggregate-kzg/filecoin-v5-packet.bin | Bin 0 -> 2181 bytes .../tests/fixtures/filecoin-prefix-2p8.bin | Bin 0 -> 25342 bytes .../tests/fixtures/init-fri/outer-claims.bin | Bin 0 -> 1232 bytes .../tests/fixtures/init-fri/outer-proof.bin | Bin 0 -> 1425255 bytes .../tests/fixtures/init-fri/outer-vk.bin | Bin 0 -> 6180 bytes .../proof-compression/tests/kzg_sharding.rs | 28 +- .../proof-compression/tests/kzg_streaming.rs | 10 +- .../tests/kzg_transcripts.rs | 232 ++++ .../proof-compression/tests/pipeline_root.rs | 199 ++- .../proof-compression/tests/plonkish_plan.rs | 6 +- .../tests/plonkish_verifier_algebra.rs | 53 + .../proof-compression/tests/worker_tracing.rs | 55 + docs/aiur-gpu-proving.md | 24 +- docs/benchmarking.md | 12 +- flake.nix | 15 +- lakefile.lean | 143 ++- 104 files changed, 14725 insertions(+), 3376 deletions(-) delete mode 100644 Ix/Cli/CompressionSetupCmd.lean create mode 100644 Tests/CompressionCli.lean create mode 100644 Tests/CompressionCliMain.lean delete mode 100644 crates/ffi/src/compression/layout.rs delete mode 100644 crates/ffi/src/compression/local.rs delete mode 100644 crates/proof-compression/examples/init16-root-layout.json create mode 100644 crates/proof-compression/examples/kzg_filecoin_import.rs create mode 100644 crates/proof-compression/profiles/aggregate-development-v5.bin create mode 100644 crates/proof-compression/profiles/aggregate-development-v5.json create mode 100644 crates/proof-compression/profiles/aggregate-filecoin-v5.bin create mode 100644 crates/proof-compression/profiles/aggregate-filecoin-v5.json create mode 100644 crates/proof-compression/src/bin/ix_compression_worker.rs create mode 100644 crates/proof-compression/src/foreign/interval.rs create mode 100644 crates/proof-compression/src/foreign/quotient.rs create mode 100644 crates/proof-compression/src/foreign/tests/independent_witness.rs create mode 100644 crates/proof-compression/src/foreign/tests/indexed_names.rs create mode 100644 crates/proof-compression/src/foreign/tests/quotient_hints.rs create mode 100644 crates/proof-compression/src/foreign/tests/range_hints.rs create mode 100644 crates/proof-compression/src/foreign/tests/translation_intervals.rs create mode 100644 crates/proof-compression/src/kzg/buffer.rs create mode 100644 crates/proof-compression/src/kzg/encoding.rs create mode 100644 crates/proof-compression/src/kzg/pcs/public_degree_tests.rs create mode 100644 crates/proof-compression/src/kzg/srs/filecoin.rs create mode 100644 crates/proof-compression/src/kzg/srs_cache.rs create mode 100644 crates/proof-compression/src/pipeline/first.rs create mode 100644 crates/proof-compression/src/pipeline/fixed_cache.rs create mode 100644 crates/proof-compression/src/pipeline/init_claim.rs create mode 100644 crates/proof-compression/src/pipeline/init_fri.rs create mode 100644 crates/proof-compression/src/pipeline/prover.rs rename crates/proof-compression/src/pipeline/{ => stages}/codec.rs (59%) create mode 100644 crates/proof-compression/src/pipeline/stages/construct.rs rename crates/proof-compression/src/pipeline/{ => stages}/fixed.rs (99%) create mode 100644 crates/proof-compression/src/pipeline/stages/mod.rs create mode 100644 crates/proof-compression/src/pipeline/stages/root.rs create mode 100644 crates/proof-compression/src/pipeline/stages/setup.rs create mode 100644 crates/proof-compression/src/pipeline/stages/statement.rs rename crates/proof-compression/src/pipeline/{ => stages}/tests.rs (80%) create mode 100644 crates/proof-compression/src/pipeline/stages/verification.rs create mode 100644 crates/proof-compression/src/pipeline/stages/wrap.rs create mode 100644 crates/proof-compression/src/pipeline/statement.rs create mode 100644 crates/proof-compression/src/pipeline/storage.rs create mode 100644 crates/proof-compression/src/pipeline/verifier.rs delete mode 100644 crates/proof-compression/src/pipeline/wrap.rs create mode 100644 crates/proof-compression/src/pipeline/wrap/mod.rs create mode 100644 crates/proof-compression/src/pipeline/wrap/outer.rs create mode 100644 crates/proof-compression/src/pipeline/wrap/saved.rs create mode 100644 crates/proof-compression/src/recursive/native_field/advice.rs create mode 100644 crates/proof-compression/src/recursive/native_field/advice/tests.rs create mode 100644 crates/proof-compression/src/recursive/native_verifier/bindings.rs create mode 100644 crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs create mode 100644 crates/proof-compression/src/recursive/native_verifier/plan.rs create mode 100644 crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs create mode 100644 crates/proof-compression/src/recursive/native_verifier/shape.rs create mode 100644 crates/proof-compression/tests/fixtures/aggregate-kzg/development-v5-packet.bin create mode 100644 crates/proof-compression/tests/fixtures/aggregate-kzg/filecoin-claim.bin create mode 100644 crates/proof-compression/tests/fixtures/aggregate-kzg/filecoin-v5-packet.bin create mode 100644 crates/proof-compression/tests/fixtures/filecoin-prefix-2p8.bin create mode 100644 crates/proof-compression/tests/fixtures/init-fri/outer-claims.bin create mode 100644 crates/proof-compression/tests/fixtures/init-fri/outer-proof.bin create mode 100644 crates/proof-compression/tests/fixtures/init-fri/outer-vk.bin create mode 100644 crates/proof-compression/tests/kzg_transcripts.rs create mode 100644 crates/proof-compression/tests/worker_tracing.rs diff --git a/Benchmarks/Typecheck.lean b/Benchmarks/Typecheck.lean index 42f1ec801..5037162b9 100644 --- a/Benchmarks/Typecheck.lean +++ b/Benchmarks/Typecheck.lean @@ -430,7 +430,7 @@ def runTypecheckCmd (p : Cli.Parsed) : IO UInt32 := do IO.eprintln "error: --join requires singleton CheckEnv shards; drop --skip-deps" return Ix.Benchmark.Results.exitUsage -- Off by default; CI passes --texray explicitly. - let useTexray := p.hasFlag "texray" + let useTexray ← TracingTexray.enabled (p.hasFlag "texray") let useInterp := p.hasFlag "interp" if join && useInterp then IO.eprintln "error: --join requires the native singleton-shard prover; drop --interp" diff --git a/Cargo.lock b/Cargo.lock index 57de1edd3..78ffe0792 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,6 +389,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + [[package]] name = "blake3" version = "1.8.5" @@ -401,6 +411,8 @@ dependencies = [ "cfg-if", "constant_time_eq", "cpufeatures 0.3.0", + "memmap2", + "rayon-core", ] [[package]] @@ -1023,7 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2061,6 +2073,7 @@ dependencies = [ "ark-poly", "ark-serialize", "bincode", + "blake2b_simd", "blake3", "multi-stark", "num-bigint 0.4.6", @@ -2074,7 +2087,11 @@ dependencies = [ "p3-symmetric", "p3-util", "serde", + "serde_json", + "tiny-keccak", "tracing", + "tracing-subscriber", + "tracing-texray", ] [[package]] @@ -2320,7 +2337,7 @@ dependencies = [ [[package]] name = "multi-stark" version = "0.1.0" -source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=63ecc52b0b54d765b886d7d9ebc98cd90005496b#63ecc52b0b54d765b886d7d9ebc98cd90005496b" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=2ed23ca070951461c4ba83a80d1a931850570484#2ed23ca070951461c4ba83a80d1a931850570484" dependencies = [ "bincode", "itertools 0.14.0", @@ -2595,7 +2612,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3682,7 +3699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3918,7 +3935,7 @@ dependencies = [ [[package]] name = "sppark" version = "0.1.15" -source = "git+https://github.com/argumentcomputer/sppark?rev=e10e107673aa22861f0f8b9758fc62169ab919ae#e10e107673aa22861f0f8b9758fc62169ab919ae" +source = "git+https://github.com/argumentcomputer/sppark?rev=114feb69ebfe029e2caa5e6c2d372c695e5d4da8#114feb69ebfe029e2caa5e6c2d372c695e5d4da8" dependencies = [ "cc", "which", @@ -4031,7 +4048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 512ac549d..a500cfaec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" memmap2 = "0.9" # Bundle v3.5.1 to fix large-arena failures (microsoft/mimalloc#1309). mimalloc = { git = "https://github.com/argumentcomputer/mimalloc_rust.git", rev = "616cc1f130d9ea2a1111a46b4e98c140e078f882", default-features = false } -multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "63ecc52b0b54d765b886d7d9ebc98cd90005496b" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "2ed23ca070951461c4ba83a80d1a931850570484" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" diff --git a/Ix/Cli/AggregateCmd.lean b/Ix/Cli/AggregateCmd.lean index ec7e0e235..50678354a 100644 --- a/Ix/Cli/AggregateCmd.lean +++ b/Ix/Cli/AggregateCmd.lean @@ -138,7 +138,7 @@ def runAggregateCmdWith (recursionParameters : Aggr.RecursionParameters) def runAggregateCmd (p : Cli.Parsed) : IO UInt32 := do -- Streamed `[texray]` span lines on stderr: the per-node wall and RSS -- breakdown of every slot's execution, witness and STARK phases. - if p.hasFlag "texray" then TracingTexray.init {} + if ← TracingTexray.enabled (p.hasFlag "texray") then TracingTexray.init {} runAggregateCmdWith Aggr.defaultRecursionParameters p end Ix.Cli.AggregateCmd diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index cc79b419e..3e3ea8226 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -1,25 +1,40 @@ module public import Cli public import Ix.Cli.Compression +public import Ix.Aggr.Backend public section namespace Ix.Cli.CompressCmd def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do try + let setup ← Compression.describeSetup (p.hasFlag "dev-srs") let hex := p.positionalArg! "proof" |>.as! String - let some addr := Address.fromString hex - | throw <| IO.userError "expected a 64-character proof address" + let some addr := Address.fromString hex | throw <| IO.userError "expected a 64-character proof address" let wrapper ← Compression.readProof addr - let .checkEnv _ _ := wrapper.claim - | throw <| IO.userError "compression requires an aggregate CheckEnv proof" if Compression.isCompressed wrapper.proof then throw <| IO.userError "proof is already compressed" - let _ ← IO.ofExcept (Aiur.Proof.ofBytesChecked wrapper.proof) - let dir ← Compression.requireSetup + let .checkEnv _ _ := wrapper.claim | throw <| IO.userError "compression requires an aggregate CheckEnv proof" + let height := ((p.flag? "log-height").map (·.as! Nat)).getD 24 + unless height == 24 || height == 25 do + throw <| IO.userError "--log-height must be 24 or 25" + -- The statement comes from the same backend `ix verify --aggregate` uses; + -- a trusted root key only replaces the verifying key the FRI stage checks. let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) - let proof ← Compression.compress backend.system backend.allowed backend.aggrIdx - (Ix.Claim.ser wrapper.claim) wrapper.proof dir.toString + let statement := Compression.statementBytes backend wrapper.claim + Compression.checkClaim (Ix.Claim.ser wrapper.claim) statement + let out ← match (p.flag? "out").map (·.as! String) with + | some path => pure path + | none => + let dir := (← StoreIO.toIO Store.getHomeDir) / ".ix/compression" + pure (dir / toString addr / toString height).toString + let vk ← match (p.flag? "root-vk").map (·.as! String) with + | some path => IO.FS.readBinFile path + | none => pure backend.system.vkBytes + IO.eprintln s!"Compressing aggregate root {addr} with {setup}." + let proof ← Compression.compress vk wrapper.proof (Ix.Claim.ser wrapper.claim) statement + out (← Compression.cacheDir).toString + (p.hasFlag "dev-srs") height.toUInt32 let compressed : Ixon.Proof := { claim := wrapper.claim, proof } let result ← StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) IO.println (toString result) @@ -32,8 +47,13 @@ end Ix.Cli.CompressCmd open Ix.Cli.CompressCmd in def compressCmd : Cli.Cmd := `[Cli| compress VIA runCompressCmd; - "Compress an aggregate root with the active local configuration; preserve its claim and print the new store address. Run ix compression setup first." + "Compress an aggregate CheckEnv proof; preserve its claim and print the new proof address." + FLAGS: + "out" : String; "Empty output directory for the stage proofs; defaults to a new directory under ~/.ix/compression." + "log-height" : Nat; "First KZG partition height: 24 (default) or 25." + "root-vk" : String; "Trusted root verifier key; omit to use the current aggregate backend." + "dev-srs"; "Use known-trapdoor development parameters; results are not production-secure." ARGS: - proof : String; "Store address of a root produced by ix aggregate with the current default parameters." + proof : String; "Address of an aggregate Ixon.Proof in the ix store." ] end diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index eda752f15..bbe30f86a 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -1,36 +1,38 @@ module -public import Ix.Aggr.Backend public import Ix.Aggr.Verification +public import Ix.Aggr.Backend public import Ix.Store public section namespace Ix.Cli.Compression -@[extern "rs_compression_setup"] -opaque setup (system : @& Aiur.AiurSystem) (dir srs : @& String) - (devSrs : Bool) (rootLayout : @& String) (rootLogHeight friLogHeight kzgLogHeight recursiveLogHeight claimLen : UInt32) : IO String +@[extern "rs_compression_check_claim"] +opaque checkClaim (claim statement : @& ByteArray) : IO Unit -@[extern "rs_compression_layout"] -opaque layout (system : @& Aiur.AiurSystem) (logHeight : UInt32) : IO String +/-- Select development parameters when `devSrs` is set; otherwise require +the configured Filecoin cache. -/ +@[extern "rs_compression_setup"] +opaque describeSetup (devSrs : Bool) : IO String +/-- Run every stage into the empty directory `dir`, reusing the fixed +preprocessing and development SRS caches under `cacheRoot`. -/ @[extern "rs_compression_run"] -opaque compress (system : @& Aiur.AiurSystem) (allowed : @& ByteArray) - (aggrIdx : @& Nat) (certificate proof : @& ByteArray) (dir : @& String) : IO ByteArray +opaque compress (vk proof claim statement : @& ByteArray) (dir cacheRoot : @& String) + (devSrs : Bool) (logHeight : UInt32) : IO ByteArray +/-- Verify with the profile bundled under the packet's id, whose pinned +aggregation program fixes the statement for `claim`, and return the setup +class: `"filecoin"` or `"development"`. Development proofs need `devSrs`. +Needs neither the aggregation backend nor a GPU. -/ @[extern "rs_compression_verify"] -opaque verify (rootVk proof : @& ByteArray) (claim : @& Array Aiur.G) - (dir : @& String) : IO Unit - -def directory : IO System.FilePath := do - if let some path ← IO.getEnv "IX_COMPRESSION_HOME" then return path - return (← StoreIO.toIO Store.getHomeDir) / ".ix/compression" +opaque verify (proof claim : @& ByteArray) (devSrs : Bool) : IO String -def requireSetup : IO System.FilePath := do - let dir ← directory - unless ← (dir / "default").pathExists do - throw <| IO.userError "no active compression configuration; run `ix compression setup`" - return dir +/-- Caches shared by every compression run: fixed preprocessing keyed by the +executable and profile, and development SRS files. -/ +def cacheDir : IO System.FilePath := do + return (← StoreIO.toIO Store.getHomeDir) / ".ix/compression/cache" +-- Reserve the family prefix, including unknown versions, so they fail closed. def isCompressed (proof : ByteArray) : Bool := proof.extract 0 5 == "IXCMP".toUTF8 @@ -38,5 +40,20 @@ def readProof (addr : Address) : IO Ixon.Proof := do let bytes ← StoreIO.toIO (Store.read addr) IO.ofExcept (Aggr.decodeAggregateWrapperAt addr bytes) +/-- The root statement a compressed proof of `claim` must expose: the public +claim of the backend's `ix_aggr` entrypoint, serialized as one batch claim. -/ +def statementBytes (backend : Aggr.VerificationBackend) (claim : Ix.Claim) : ByteArray := + MultiStark.serializeClaims + #[Aggr.aggregateOuterClaim backend.allowed backend.aggrIdx claim] + +def verifyProof (addr : Address) (wrapper : Ixon.Proof) (devSrs : Bool) : IO UInt32 := do + let claim := Ix.Claim.ser wrapper.claim + let setup ← verify wrapper.proof claim devSrs + if setup == "development" then + IO.println s!"ok: development proof {addr} verifies claim {Address.blake3 claim} (not production-secure)" + else + IO.println s!"ok: proof {addr} verifies claim {Address.blake3 claim} under the Filecoin powers of tau" + return 0 + end Ix.Cli.Compression end diff --git a/Ix/Cli/CompressionSetupCmd.lean b/Ix/Cli/CompressionSetupCmd.lean deleted file mode 100644 index 9d14b160b..000000000 --- a/Ix/Cli/CompressionSetupCmd.lean +++ /dev/null @@ -1,87 +0,0 @@ -module -public import Cli -public import Ix.Cli.Compression - -public section -namespace Ix.Cli.CompressionSetupCmd - -def runSetup (p : Cli.Parsed) : IO UInt32 := do - try - let srs := (p.flag? "srs").map (·.as! String) - let dev := p.hasFlag "dev-srs" - unless srs.isSome != dev do - throw <| IO.userError "specify exactly one of --srs FILE or --dev-srs" - let rootLog := ((p.flag? "root-log-height").map (·.as! Nat)).getD 20 - let layoutPath := (p.flag? "root-layout").map (·.as! String) - if layoutPath.isSome && p.hasFlag "root-log-height" then - throw <| IO.userError "specify either --root-layout or --root-log-height" - let rootLayout ← match layoutPath with - | some path => IO.FS.readFile path - | none => pure "" - if layoutPath.isSome && rootLayout.trimAscii.toString.isEmpty then - throw <| IO.userError "root layout file is empty" - let friLog := ((p.flag? "fri-log-height").map (·.as! Nat)).getD 23 - let kzgLog := ((p.flag? "log-height").map (·.as! Nat)).getD 24 - let recursiveLog := ((p.flag? "recursive-log-height").map (·.as! Nat)).getD kzgLog - unless 1 ≤ rootLog && rootLog ≤ 26 && 16 ≤ friLog && friLog ≤ 26 && 16 ≤ kzgLog && kzgLog ≤ 26 && 16 ≤ recursiveLog && recursiveLog ≤ 28 do - throw <| IO.userError "--root-log-height must be 1–26; --fri-log-height and --log-height must be 16–26; --recursive-log-height must be 16–28" - if let some path := srs then - unless ← (System.FilePath.mk path).pathExists do - throw <| IO.userError s!"SRS file does not exist: {path}" - if dev then - IO.eprintln "Using a known-trapdoor development SRS; proofs are not production-secure." - let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) - let claim := Aiur.buildClaim backend.aggrIdx (Aggr.pubInput backend.allowed ByteArray.empty) #[] - let dir ← Compression.directory - let id ← Compression.setup backend.system dir.toString (srs.getD "") dev - rootLayout rootLog.toUInt32 friLog.toUInt32 kzgLog.toUInt32 recursiveLog.toUInt32 claim.size.toUInt32 - IO.eprintln s!"Local compression configuration ready in {dir}." - IO.println id - return 0 - catch e => - IO.eprintln s!"compression setup: {e}" - return 1 - -def runLayout (p : Cli.Parsed) : IO UInt32 := do - try - let log := ((p.flag? "root-log-height").map (·.as! Nat)).getD 20 - unless 1 ≤ log && log ≤ 26 do - throw <| IO.userError "--root-log-height must be 1–26" - let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) - IO.println (← Compression.layout backend.system log.toUInt32) - return 0 - catch e => - IO.eprintln s!"compression layout: {e}" - return 1 - -end Ix.Cli.CompressionSetupCmd -open Ix.Cli.CompressionSetupCmd in -def compressionSetupCmd : Cli.Cmd := `[Cli| - setup VIA runSetup; - "Derive and activate a fixed compression verifier locally, without a certificate or proof." - FLAGS: - "srs" : String; "Trusted SRS file in IXKZGS01 format." - "dev-srs"; "Explicitly use a known-trapdoor development setup; not production-secure." - "root-layout" : String; "JSON per-trace bounds from `ix compression layout`; edit before setup." - "root-log-height" : Nat; "Uniform fallback function/memory log-height, 1–26 (default 20); exclusive with --root-layout." - "fri-log-height" : Nat; "FRI partition log-height, 16–26 (default 23); must fit indivisible hash traces." - "log-height" : Nat; "Intermediate KZG partition log-height, 16–26 (default 24)." - "recursive-log-height" : Nat; "Final KZG partition log-height, 16–28 (default: --log-height); SRS must cover both KZG stages." -] - -open Ix.Cli.CompressionSetupCmd in -def compressionLayoutCmd : Cli.Cmd := `[Cli| - layout VIA runLayout; - "Print a JSON trace-bound template for the local aggregate backend. Edit heights before setup; no proof or SRS needed." - FLAGS: - "root-log-height" : Nat; "Initial function/memory log-height, 1–26 (default 20)." -] - -def compressionCmd : Cli.Cmd := `[Cli| - compression NOOP; - "Configure proof compression locally." - SUBCOMMANDS: - compressionSetupCmd; - compressionLayoutCmd -] -end diff --git a/Ix/Cli/ProveCmd.lean b/Ix/Cli/ProveCmd.lean index 77f67edf4..ec8a651ae 100644 --- a/Ix/Cli/ProveCmd.lean +++ b/Ix/Cli/ProveCmd.lean @@ -252,7 +252,7 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do -- Streamed `[texray] : ── RAM Δ/peak` lines on stderr as -- each `aiur/` / `stark/` span closes: the per-phase wall + RSS -- breakdown (execute vs witness vs STARK) of every prove in the run. - if p.hasFlag "texray" then TracingTexray.init {} + if ← TracingTexray.enabled (p.hasFlag "texray") then TracingTexray.init {} let keepGoing := p.hasFlag "keep-going" -- Same units as `ix shard --max-ram`: the per-shard prover budget the -- partition was sized against, re-checked here against each shard's diff --git a/Ix/Cli/VerifyCmd.lean b/Ix/Cli/VerifyCmd.lean index 865e0fedb..5940d4fa6 100644 --- a/Ix/Cli/VerifyCmd.lean +++ b/Ix/Cli/VerifyCmd.lean @@ -10,6 +10,7 @@ -/ module public import Cli +public import Ix.Cli.Compression public import Ix.Address public import Ix.Aiur.Compiler public import Ix.Aiur.Protocol @@ -25,7 +26,6 @@ public import Ix.Aggr.Backend public import Ix.Aggr.Verification public import Ix.Shard.Environment public import Ix.Cli.ShardProofIndex -public import Ix.Cli.Compression public section @@ -94,8 +94,9 @@ structure ExpectedAggregate where claim : Ix.Claim constantCount : Nat -private def verifyAggregateProof (backend : Aggr.VerificationBackend) - (expected? : Option ExpectedAggregate) (proofAddr : Address) : IO UInt32 := do +private def verifyAggregateProof (getBackend : IO (Except String Aggr.VerificationBackend)) + (expected? : Option ExpectedAggregate) (proofAddr : Address) + (devSrs : Bool := false) : IO UInt32 := do let bytes ← StoreIO.toIO (Store.read proofAddr) let wrapper ← match decodeAggregateWrapperAt proofAddr bytes with | .ok wrapper => pure wrapper @@ -109,33 +110,32 @@ private def verifyAggregateProof (backend : Aggr.VerificationBackend) IO.eprintln s!"error: aggregate claim {wrapper.claim} does not match expected {expected.claim}" return 1 | none => pure () - let outerClaim := Aggr.aggregateOuterClaim backend.allowed backend.aggrIdx wrapper.claim - let verifyStarted ← IO.monoMsNow if Compression.isCompressed wrapper.proof then - try - Compression.verify backend.system.vkBytes wrapper.proof outerClaim (← Compression.directory).toString - catch e => - IO.eprintln s!"error: compressed verification failed: {e}" - return 1 - else - let proof ← match Aiur.Proof.ofBytesChecked wrapper.proof with - | .ok proof => pure proof - | .error e => - IO.eprintln s!"error: aggregate proof {proofAddr} does not decode: {e}" - return 1 - match backend.system.verify outerClaim proof with - | .ok () => pure () + return ← Compression.verifyProof proofAddr wrapper devSrs + let proof ← match Aiur.Proof.ofBytesChecked wrapper.proof with + | .ok proof => pure proof | .error e => - IO.eprintln s!"error: aggregate verification failed: {e}" + IO.eprintln s!"error: aggregate proof {proofAddr} does not decode: {e}" return 1 - IO.println s!"[verify] aggregate proof: {(← IO.monoMsNow) - verifyStarted}ms" - IO.println s!"ok: aggregate proof {proofAddr} verifies {wrapper.claim}" - if let some expected := expected? then - IO.println s!"[verify] aggregate coverage: {expected.constantCount}/\ - {expected.constantCount} environment constants committed exactly once" - IO.println s!"[verify] all {expected.constantCount} included constants \ - certified well-typed; 0 undischarged assumptions" - return 0 + let backend ← match ← getBackend with + | .error e => IO.eprintln e; return 1 + | .ok backend => pure backend + let outerClaim := Aggr.aggregateOuterClaim + backend.allowed backend.aggrIdx wrapper.claim + let verifyStarted ← IO.monoMsNow + match backend.system.verify outerClaim proof with + | .ok () => + IO.println s!"[verify] aggregate Aiur proof: {(← IO.monoMsNow) - verifyStarted}ms" + IO.println s!"ok: aggregate proof {proofAddr} verifies {wrapper.claim}" + if let some expected := expected? then + IO.println s!"[verify] aggregate coverage: {expected.constantCount}/\ + {expected.constantCount} environment constants committed exactly once" + IO.println s!"[verify] all {expected.constantCount} included constants \ + certified well-typed; 0 undischarged assumptions" + return 0 + | .error e => + IO.eprintln s!"error: aggregate verification failed: {e}" + return 1 /-- Shard-aware verification (parity with `check`/`prove`): - `--shard K`, no proof: print shard K's reconstructed `CheckEnv` claim @@ -274,16 +274,22 @@ def runVerifyCmdWith (recursionParameters : Aggr.RecursionParameters) {(← IO.monoMsNow) - started}ms" pure (some { claim, constantCount := native.constantCount }) | none, some _ => unreachable! - let backendStarted ← IO.monoMsNow - let backend ← match ← Aggr.buildVerificationBackend recursionParameters with - | .error e => IO.eprintln e; return 1 - | .ok backend => pure backend - IO.println s!"[verify] aggregate backend setup: \ - {(← IO.monoMsNow) - backendStarted}ms" + let backendRef ← IO.mkRef (none : Option (Except String Aggr.VerificationBackend)) + let getBackend : IO (Except String Aggr.VerificationBackend) := do + if let some result ← backendRef.get then + return result + let backendStarted ← IO.monoMsNow + let result ← Aggr.buildVerificationBackend recursionParameters + backendRef.set (some result) + if let .ok _ := result then + IO.println s!"[verify] aggregate backend setup: \ + {(← IO.monoMsNow) - backendStarted}ms" + return result let mut rc : UInt32 := 0 for hex in proofs do let proofAddr ← addrOfHex! "aggregate proof" hex - if (← verifyAggregateProof backend expected? proofAddr) != 0 then + if (← verifyAggregateProof getBackend expected? proofAddr + (p.hasFlag "dev-srs")) != 0 then rc := 1 return rc match (p.flag? "ixe").map (·.as! String), (p.flag? "ixes").map (·.as! String) with @@ -294,27 +300,21 @@ def runVerifyCmdWith (recursionParameters : Aggr.RecursionParameters) if proofs.isEmpty then p.printError "error: must specify ... (or --ixe + --ixes for a shard partition)" return 1 - let mut ordinary : Option (Aiur.AiurSystem × Aiur.CompiledToplevel) := none - let mut aggregate : Option Aggr.VerificationBackend := none + let mut backend? := none let mut rc : UInt32 := 0 for hex in proofs do let proofAddr ← addrOfHex! "proof" hex let wrapper ← Compression.readProof proofAddr if Compression.isCompressed wrapper.proof then - let backend ← match aggregate with - | some backend => pure backend - | none => - let backend ← IO.ofExcept (← Aggr.buildVerificationBackend recursionParameters) - aggregate := some backend - pure backend - if (← verifyAggregateProof backend none proofAddr) != 0 then rc := 1 + if (← Compression.verifyProof proofAddr wrapper (p.hasFlag "dev-srs")) != 0 then + rc := 1 else - let (aiurSystem, compiled) ← match ordinary with - | some backend => pure backend + let (aiurSystem, compiled) ← match backend? with + | some b => pure b | none => - let backend ← IO.ofExcept (← buildBackend) - ordinary := some backend - pure backend + let b ← IO.ofExcept (← buildBackend) + backend? := some b + pure b if (← verifyOneProof aiurSystem compiled proofAddr) != 0 then rc := 1 return rc @@ -326,9 +326,10 @@ end Ix.Cli.VerifyCmd open Ix.Cli.VerifyCmd in def verifyCmd : Cli.Cmd := `[Cli| verify VIA runVerifyCmd; - "Verify native or compressed proof(s) against their bundled claims, or a `.ixes` shard partition" + "Verify proof(s) against their bundled claims, or a `.ixes` shard partition" FLAGS: + "dev-srs"; "Allow development compressed proofs; these are not production-secure." "ixe" : String; "Path to a serialized `.ixe` env (with --ixes). With no proof args and no --shard: verify the partition off-circuit (every constant owned by exactly one shard)." "ixes" : String; "Path to a `.ixes` shard manifest (with --ixe). For aggregate roots, reproduces the manifest-relative hybrid structural root." "shard" : Nat; "0-based shard index K (with --ixe + --ixes). No proof: print shard K's reconstructed CheckEnv claim digest. With proof(s): bind each to shard K and verify." diff --git a/Ix/TracingTexray.lean b/Ix/TracingTexray.lean index 8e37be078..5ca100f3a 100644 --- a/Ix/TracingTexray.lean +++ b/Ix/TracingTexray.lean @@ -18,6 +18,10 @@ public section namespace TracingTexray +/-- Enable TeXRay through a command-line flag or the process-wide switch. -/ +def enabled (flag : Bool := false) : IO Bool := do + return flag || (← IO.getEnv "IX_TEXRAY") == some "1" + /-- Configuration for the tracing-texray subscriber. -/ structure Settings where /-- Comma-separated span-name prefixes to keep diff --git a/Main.lean b/Main.lean index 1153878e5..22d7f26f0 100644 --- a/Main.lean +++ b/Main.lean @@ -11,8 +11,6 @@ import Ix.Cli.ValidateLeanCmd import Ix.Cli.ClaimCmd import Ix.Cli.CatalogCmd import Ix.Cli.CompileCmd -import Ix.Cli.CompressCmd -import Ix.Cli.CompressionSetupCmd import Ix.Cli.DecompileCmd import Ix.Cli.DiffCmd import Ix.Cli.IngressCmd @@ -20,6 +18,7 @@ import Ix.Cli.MergeCmd import Ix.Cli.NameOfCmd import Ix.Cli.PackCmd import Ix.Cli.ProfileCmd +import Ix.Cli.CompressCmd import Ix.Cli.ProveCmd import Ix.Cli.ShardCmd import Ix.Cli.TreeCmd @@ -41,8 +40,6 @@ def ixCmd : Cli.Cmd := `[Cli| aggregateCmd; catalogCmd; compileCmd; - compressCmd; - compressionCmd; decompileCmd; mergeCmd; checkCmd; @@ -56,6 +53,7 @@ def ixCmd : Cli.Cmd := `[Cli| treeCmd; profileCmd; proveCmd; + compressCmd; shardCmd; codegenCmd; verifyCmd; diff --git a/Tests/Cli.lean b/Tests/Cli.lean index bde7a82e8..da72a27e0 100644 --- a/Tests/Cli.lean +++ b/Tests/Cli.lean @@ -1,7 +1,7 @@ module public import Ix.Ixon -public import Ix.Cli.Compression +public import Tests.CompressionCli /- Integration tests for the Ix CLI -/ @@ -165,76 +165,12 @@ private def Tests.Cli.testCompileSharingLimits : IO Unit := do finally IO.FS.removeDirAll dir -/-- Compression CLI contracts use generated inputs and temporary directories; - no external certificates, setup files, or expensive proving is required. -/ -public def Tests.Cli.testCompression : IO Unit := do - let ix ← IO.FS.realPath ".lake/build/bin/ix" - let dir ← IO.FS.createTempDir - let run := fun args => IO.Process.output { - cmd := ix.toString, args, env := #[("IX_COMPRESSION_HOME", some dir.toString)] } - let reject := fun args message => do - let result ← run args - unless result.exitCode != 0 && (result.stderr.splitOn message).length > 1 do - throw <| IO.userError s!"unexpected compression CLI result: {result.stdout}\n{result.stderr}" - try - for args in [#["compress", "--help"], #["verify", "--help"], - #["compression", "setup", "--help"], #["compression", "layout", "--help"]] do - let help ← run args - unless help.exitCode == 0 do - throw <| IO.userError s!"compression help failed: {help.stderr}" - let args := #["compression", "setup"] - reject args "specify exactly one" - reject (args ++ #["--dev-srs", "--srs", "missing"]) "specify exactly one" - for flag in ["--root-log-height", "--fri-log-height", "--log-height"] do - for value in ["0", "27", "4294967296"] do - reject (args ++ #["--dev-srs", flag, value]) "must be" - for value in ["0", "29", "4294967296"] do - reject (args ++ #["--dev-srs", "--recursive-log-height", value]) "must be" - reject (args ++ #["--dev-srs", "--root-layout", "missing", "--root-log-height", "20"]) - "specify either" - reject #["compression", "layout", "--root-log-height", "256"] "must be" - reject (args ++ #["--srs", (dir / "missing").toString]) "SRS file does not exist" - reject (args ++ #["--srs", (dir / "missing").toString, - "--log-height", "25", "--recursive-log-height", "28"]) "SRS file does not exist" - let template ← run #["compression", "layout", "--root-log-height", "12"] - unless template.exitCode == 0 do - throw <| IO.userError s!"compression layout failed: {template.stderr}" - let json ← IO.ofExcept (Lean.Json.parse template.stdout) - let functions ← IO.ofExcept ((json.getObjVal? "functions").bind Lean.Json.getArr?) - unless !functions.isEmpty && functions.all (fun v => v.getNat?.toOption == some 12) do - throw <| IO.userError "compression layout has incorrect function heights" - let layoutPath := dir / "layout.json" - IO.FS.writeFile layoutPath "{}" - reject (args ++ #["--dev-srs", "--root-layout", layoutPath.toString]) - "root layout requires exactly" - IO.FS.removeFile layoutPath - reject #["compress", "invalid-address"] "expected a 64-character proof address" - -- Exercise the Lean/Rust boundary, including the Array G argument. - let rejected ← try - Ix.Cli.Compression.verify ByteArray.empty ByteArray.empty #[1, 2] dir.toString - pure false - catch _ => pure true - unless rejected do throw <| IO.userError "compression FFI accepted an invalid proof" - -- A structurally valid envelope cannot supply a verifier or select an - -- unconfigured local key. - let envelope := "IXCMP002".toUTF8 ++ ByteArray.mk (Array.replicate 33 0) - let rejected ← try - Ix.Cli.Compression.verify ByteArray.empty envelope #[1, 2] dir.toString - pure false - catch _ => pure true - unless rejected do throw <| IO.userError "compression FFI accepted an unknown configuration" - unless (← dir.readDir).isEmpty do - throw <| IO.userError "invalid compression inputs created local configuration files" - IO.println "compression CLI: setup validation and proof-only FFI rejection checks passed" - finally - IO.FS.removeDirAll dir - public def Tests.Cli.suite : IO UInt32 := do Tests.Cli.run "lake" (#["exe", "ix", "--help"]) none + Tests.CompressionCli.run Tests.Cli.testCompileNoBuild Tests.Cli.testCompileContracts Tests.Cli.testCompileSharingLimits - Tests.Cli.testCompression --Tests.Cli.run "ix" (#["store", "ix_test/IxTest.lean"]) none --Tests.Cli.run "ix" (#["prove", "ix_test/IxTest.lean", "one"]) none return 0 diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean new file mode 100644 index 000000000..497387c8c --- /dev/null +++ b/Tests/CompressionCli.lean @@ -0,0 +1,139 @@ +module +public import Ix.Cli.Compression + +public section +namespace Tests.CompressionCli + +private def putProof (home : System.FilePath) (bytes : ByteArray) : IO Address := do + let addr := Address.blake3 bytes + let hex := toString addr + let path := home / ".ix/store" / (hex.take 2).toString / + ((hex.drop 2).take 2).toString / ((hex.drop 4).take 2).toString + IO.FS.createDirAll path + IO.FS.writeBinFile (path / (hex.drop 6).toString) bytes + return addr + +private def invoke (home : System.FilePath) (args : Array String) + (filecoinCache : Option String := none) : IO IO.Process.Output := do + let ix ← IO.FS.realPath ".lake/build/bin/ix" + IO.Process.output { cmd := ix.toString, args, env := #[("HOME", some home.toString), + ("IX_KZG_FILECOIN_CACHE", filecoinCache), ("IX_KZG_FILECOIN_MANIFEST", none)] } + +private def rejects (home : System.FilePath) (args : Array String) (message : String) + (filecoinCache : Option String := none) : IO Unit := do + let out ← invoke home args filecoinCache + unless out.exitCode != 0 && (out.stderr.splitOn message).length > 1 do + throw <| IO.userError s!"expected rejection ({message}): {args}\n{out.stdout}\n{out.stderr}" + +public def run : IO Unit := do + let home ← IO.FS.createTempDir + try + let help ← invoke home #["compress", "--help"] + unless help.exitCode == 0 && (help.stdout.splitOn "--root-vk").length > 1 do + throw <| IO.userError s!"compress help failed: {help.stdout} {help.stderr}" + let claim := Ix.Claim.checkEnv (Address.blake3 "test".toUTF8) none + let native : Ixon.Proof := { claim, proof := "native".toUTF8 } + let compressed : Ixon.Proof := { claim, proof := "IXCMP".toUTF8 } + let other : Ixon.Proof := + { claim := Ix.Claim.check (Address.blake3 "test".toUTF8) none, proof := "native".toUTF8 } + let old ← putProof home (Ixon.Proof.ser native) + let addr ← putProof home (Ixon.Proof.ser compressed) + let otherAddr ← putProof home (Ixon.Proof.ser other) + unless old != addr && native.claim == compressed.claim do + throw <| IO.userError "compression must preserve claim identity and change proof identity" + rejects home #["compress", toString old] "--dev-srs" + rejects home #["compress", toString otherAddr, "--dev-srs", "--out", (home / "run").toString] + "aggregate CheckEnv proof" + unless !(← (home / "run").pathExists) do + throw <| IO.userError "unsupported input created output" + rejects home #["compress", toString addr, "--dev-srs"] "already compressed" + let missingCache := some (home / "missing-filecoin-cache").toString + rejects home #["compress", toString addr] "does not name a file" missingCache + rejects home #["compress", toString addr, "--dev-srs"] "already compressed" missingCache + rejects home #["verify", toString addr] "unsupported compressed proof version" + rejects home #["verify", toString addr, "--dev-srs"] "unsupported compressed proof version" + let removed ← invoke home #["verifier", "import", "/missing"] + unless removed.exitCode != 0 do + throw <| IO.userError "external verifier import remains available" + + let fixtures : System.FilePath := "crates/proof-compression/tests/fixtures/aggregate-kzg" + let expected ← IO.ofExcept (Ix.Claim.de (← IO.FS.readBinFile (fixtures / "filecoin-claim.bin"))) + let packet ← IO.FS.readBinFile (fixtures / "filecoin-v5-packet.bin") + let tag := ByteArray.mk #[73, 88, 67, 77, 80, 0, 0, 4] + let proof := tag ++ packet + let wrapped : Ixon.Proof := { claim := expected, proof } + let compressedAddr ← putProof home (Ixon.Proof.ser wrapped) + let metadata ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile + "crates/proof-compression/profiles/aggregate-filecoin-v5.json")) + let expectedAddr ← IO.ofExcept (metadata.getObjValAs? String "compressed_proof_address") + unless toString compressedAddr == expectedAddr do + throw <| IO.userError "bundled proof address differs from its metadata" + let args := #["verify", toString compressedAddr] + let out ← invoke home args + unless out.exitCode == 0 && (out.stdout.splitOn "Filecoin").length > 1 do + throw <| IO.userError s!"bundled Filecoin proof failed: {out.stdout} {out.stderr}" + let aggregateArgs := #["verify", "--aggregate", toString compressedAddr] + let aggregate ← invoke home aggregateArgs + unless aggregate.exitCode == 0 && (aggregate.stdout.splitOn "Filecoin").length > 1 do + throw <| IO.userError s!"bundled aggregate proof failed: {aggregate.stdout} {aggregate.stderr}" + unless (aggregate.stdout.splitOn "aggregate backend setup").length == 1 do + throw <| IO.userError "compressed aggregate verification constructed the native backend" + rejects home (aggregateArgs ++ #["--ixe", "Tests/Fixtures/ixon-v4/handoff/accepted.ixe"]) + "does not match expected" + unless !(← (home / ".ix/verifiers").pathExists) do + throw <| IO.userError "verification created a filesystem verifier registry" + + -- A local registry cannot replace a verifier carried by the application. + let alias := toString (Address.mk (packet.extract 0 32)) + let profiles := home / ".ix/verifiers/filecoin/profiles" + IO.FS.createDirAll profiles + IO.FS.writeBinFile (profiles / alias) "invalid registry entry".toUTF8 + let independent ← invoke home args + unless independent.exitCode == 0 do + throw <| IO.userError s!"verification read an external registry: {independent.stderr}" + IO.FS.removeDirAll (home / ".ix/verifiers") + + let badClaim ← putProof home (Ixon.Proof.ser { wrapped with claim }) + rejects home (args.set! 1 (toString badClaim)) "packet statement differs" + let unknown := proof.set! 8 ((proof.get! 8) ^^^ 1) + let unknownAddr ← putProof home (Ixon.Proof.ser { wrapped with proof := unknown }) + rejects home (args.set! 1 (toString unknownAddr)) "not bundled with this application" + for bad in [proof.set! 7 2, proof.set! (proof.size - 1) ((proof.get! (proof.size - 1)) ^^^ 1)] do + let badAddr ← putProof home (Ixon.Proof.ser { wrapped with proof := bad }) + let result ← invoke home (args.set! 1 (toString badAddr)) + unless result.exitCode != 0 do throw <| IO.userError "modified compact proof accepted" + + let devPacket ← IO.FS.readBinFile (fixtures / "development-v5-packet.bin") + let devProof := tag ++ devPacket + let devWrapped : Ixon.Proof := { claim := expected, proof := devProof } + let devAddr ← putProof home (Ixon.Proof.ser devWrapped) + let devMetadata ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile + "crates/proof-compression/profiles/aggregate-development-v5.json")) + let expectedDevAddr ← IO.ofExcept (devMetadata.getObjValAs? String "compressed_proof_address") + unless toString devAddr == expectedDevAddr do + throw <| IO.userError "bundled development proof address differs from its metadata" + for aggregateFlag in [#[], #["--aggregate"]] do + let devArgs := #["verify", toString devAddr] ++ aggregateFlag + rejects home devArgs "--dev-srs" + let accepted ← invoke home (devArgs.push "--dev-srs") + unless accepted.exitCode == 0 && (accepted.stdout.splitOn "development proof").length > 1 do + throw <| IO.userError s!"development proof failed: {accepted.stdout} {accepted.stderr}" + unless (accepted.stdout.splitOn "aggregate backend setup").length == 1 do + throw <| IO.userError "development verification constructed the native backend" + let badDevClaim ← putProof home (Ixon.Proof.ser { devWrapped with claim }) + rejects home #["verify", toString badDevClaim, "--dev-srs"] "packet statement differs" + let badDevPrefix ← putProof home (Ixon.Proof.ser + { devWrapped with proof := devProof.set! 40 ((devProof.get! 40) ^^^ 1) }) + rejects home #["verify", toString badDevPrefix, "--dev-srs"] "packet statement differs" + for index in [8 + 32 + 18 * 8, devProof.size - 1] do + let bad ← putProof home (Ixon.Proof.ser + { devWrapped with proof := devProof.set! index ((devProof.get! index) ^^^ 1) }) + let result ← invoke home #["verify", toString bad, "--dev-srs"] + unless result.exitCode != 0 do + throw <| IO.userError "development opt-in accepted a modified proof" + IO.println "compression CLI: bundled proofs, claim binding and development opt-in passed" + finally + IO.FS.removeDirAll home + +end Tests.CompressionCli +end diff --git a/Tests/CompressionCliMain.lean b/Tests/CompressionCliMain.lean new file mode 100644 index 000000000..faa7c9b40 --- /dev/null +++ b/Tests/CompressionCliMain.lean @@ -0,0 +1,5 @@ +import Tests.CompressionCli + +def main : IO UInt32 := do + Tests.CompressionCli.run + return 0 diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index f624f99e1..f57ec708b 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -1,361 +1,64 @@ -//! Locally generated fixed compression configurations. No verifier import. -mod layout; -mod local; -use crate::aiur::aggregate::protocol::{ - aggregate_outer_claim, serialize_claims, -}; -use aiur::{ - synthesis::{AiurProof, AiurSystem}, - vk_codec::{AiurVerifyingKey, aiur_system_to_bytes}, -}; -use ix_proof_compression::{ - kzg::{Srs, VerifierParams}, - pipeline::{self, FixedRoot, RecursiveVerifier}, -}; -use ixvm_codegen::aiur_ix_aggr_runner::{ - AggrAdvice, aggr_io_buffer, execute_ix_aggr, -}; +//! Main CLI bridge; compression is linked into the ix executable. +use ix_proof_compression::pipeline; use lean_ffi::object::{ - LeanArray, LeanBorrowed, LeanByteArray, LeanExternal, LeanIOResult, LeanNat, - LeanOwned, LeanString, + LeanBorrowed, LeanByteArray, LeanIOResult, LeanOwned, LeanString, }; -use multi_stark::types::{CommitmentParameters, GoldilocksBlake3Config}; -use std::{fs::File, io::BufReader, path::Path, sync::Arc}; - -const MAGIC: &[u8; 8] = b"IXCMP002"; - -fn configuration( - system: &AiurSystem, - root_logs: Vec, - claim_len: usize, -) -> pipeline::Result { - if root_logs.len() - != system.toplevel().circuits.len() - + system.toplevel().memory_sizes.len() - + 2 - || !root_logs.ends_with(&[8, 16]) - { - return Err("root layout does not match the aggregate backend".into()); - } - let mut memories: Vec<_> = system - .toplevel() - .memory_sizes - .iter() - .enumerate() - .map(|(i, &width)| (width, system.toplevel().circuits.len() + i)) - .collect(); - memories.sort_unstable(); - FixedRoot::new(root_logs, memories, claim_len) -} - -fn fri_config(key: &AiurVerifyingKey) -> GoldilocksBlake3Config { - GoldilocksBlake3Config::new( - CommitmentParameters { log_blowup: 2, cap_height: 0 }, - key.fri_parameters(), - ) -} - -fn load_srs(path: &Path) -> pipeline::Result> { - Ok(Arc::new(Srs::from_reader(BufReader::new(File::open(path)?))?)) -} - -fn setup( - system: &AiurSystem, - dir: &Path, - srs_path: &Path, - dev: bool, - root_layout: &str, - root_log: u32, - fri_log: u32, - kzg_log: u32, - recursive_log: u32, - claim_len: usize, -) -> pipeline::Result { - if !(1..=26).contains(&root_log) - || !(16..=26).contains(&fri_log) - || !(16..=26).contains(&kzg_log) - || !(16..=28).contains(&recursive_log) - { - return Err( - "root log-height must be 1–26; FRI and intermediate KZG log-heights must be 16–26; recursive log-height must be 16–28" - .into(), - ); - } - if dev == !srs_path.as_os_str().is_empty() { - return Err("specify exactly one of --srs FILE or --dev-srs".into()); - } - let vk = aiur_system_to_bytes(system)?; - let key = AiurVerifyingKey::from_bytes(&vk)?; - let root_logs = - layout::resolve(system, root_layout, u8::try_from(root_log)?)?; - let root = configuration(system, root_logs.clone(), claim_len)?; - eprintln!("[compression setup] loading KZG parameters"); - let srs = if dev { - let srs = Srs::unsafe_dev_setup( - 1usize << kzg_log.max(recursive_log), - b"ix-compression-cli-dev-v1", - ); - srs.validate()?; - Arc::new(srs) - } else { - load_srs(srs_path)? - }; - if srs.max_len() < 1usize << kzg_log.max(recursive_log) { - return Err("SRS is smaller than the KZG partition height".into()); - } - let params = VerifierParams::from_srs(&srs).to_bytes()?; - let settings = local::Settings::new( - &vk, - ¶ms, - root_logs, - fri_log, - kzg_log, - recursive_log, - claim_len, - ); - let id = settings.id(); - if dir.join(&id).exists() { - let entry = local::load(dir, &id, &vk)?; - RecursiveVerifier::from_bytes(&entry.key_bytes)?; - local::reuse(dir, &id, srs_path, dev.then_some(srs.as_ref()))?; - return Ok(id); - } - eprintln!( - "[compression setup] deriving fixed verifier (no certificate or proof input)" - ); - let verifier = pipeline::setup( - &key, - &root, - srs.clone(), - fri_config(&key), - 1usize << fri_log, - 1usize << kzg_log, - 1usize << recursive_log, - )?; - let bytes = verifier.to_bytes()?; - RecursiveVerifier::from_bytes(&bytes)?; - local::publish( - dir, - &settings, - &bytes, - srs_path, - dev.then_some(srs.as_ref()), - )?; - Ok(id) -} - -fn compress( - system: &AiurSystem, - allowed: &[u8], - aggr_idx: usize, - certificate: &[u8], - bytes: &[u8], - dir: &Path, -) -> pipeline::Result> { - let vk = aiur_system_to_bytes(system)?; - let entry = local::load(dir, &local::active(dir)?, &vk)?; - let expected = aggregate_outer_claim(aggr_idx, allowed, certificate); - if expected.len() != entry.settings.claim_len { - return Err("compression claim shape changed; run setup again".into()); - } - let key = AiurVerifyingKey::from_bytes(&vk)?; - let fixed = - configuration(system, entry.settings.root_logs.clone(), expected.len())?; - let proof = AiurProof::from_bytes(bytes)?; - let advice = system.proof_to_advice_bytes(&expected, &proof)?; - let srs = load_srs(&entry.srs_path)?; - if blake3::hash(&VerifierParams::from_srs(&srs).to_bytes()?).to_hex().as_str() - != entry.settings.params_hash - { - return Err( - "SRS no longer matches the local compression configuration".into(), - ); - } - drop(proof); - let claims = serialize_claims(&[&expected]); - let mut io = aggr_io_buffer(&AggrAdvice { - shape: 1, - proof_advice: [&advice, &[]], - ixvm_vk: &[], - self_vk: &vk, - child_claims: [&claims, &[]], - output_claim: certificate, - allowed, - preimages: &[], - trees: &[], - paths: &[], - }); - eprintln!( - "[compress] normalizing the aggregate root to the configured trace bounds" - ); - // The aggregate call is [channel, entrypoint, inputs…], with no outputs. - let input = &expected[2..]; - let (record, output) = - execute_ix_aggr(system.toplevel(), aggr_idx, input.to_vec(), &mut io)?; - if !output.is_empty() { - return Err("aggregate normalizer returned unexpected outputs".into()); - } - drop(advice); - let (claim, proof) = system.prove_from_execution_normalized( - aggr_idx, - input, - &io, - record, - &output, - &entry.settings.root_logs, - )?; - if claim != expected { - return Err("normalizer changed the certificate claim".into()); - } - drop(io); - eprintln!("[compress] normalized root → FRI"); - let prepared = fixed.prepare(&key, &proof, &expected, Default::default())?; - drop(proof); - let (fri, proof) = - prepared.prove_fri(fri_config(&key), 1usize << entry.settings.fri_log)?; - eprintln!("[compress] FRI → KZG"); - let prepared = - pipeline::prepare_kzg(&fri, &proof, &expected, Default::default())?; - drop(fri); - drop(proof); - let height = 1usize << entry.settings.kzg_log; - let (kzg, proof) = prepared.prove_kzg(srs.clone(), height)?; - eprintln!("[compress] KZG → recursive KZG"); - let prepared = pipeline::prepare_recursive(&kzg, &proof, &expected)?; - drop(kzg); - drop(proof); - let (verifier, proof) = - prepared.prove(srs, 1usize << entry.settings.recursive_log)?; - if verifier.to_bytes()? != entry.key_bytes { - return Err( - "prover circuit differs from the locally configured verifier".into(), - ); - } - let bytes = verifier.encode_proof(&proof)?; - RecursiveVerifier::from_bytes(&entry.key_bytes)? - .verify_bytes(&bytes, &expected)?; - let mut result = MAGIC.to_vec(); - result.extend(local::decode_id(&entry.settings.id())?); - result.extend(bytes); - Ok(result) -} - -#[unsafe(no_mangle)] -extern "C" fn rs_compression_layout( - system: LeanExternal>, - log: u32, -) -> LeanIOResult { - let result = (|| -> pipeline::Result { - if !(1..=26).contains(&log) { - return Err("root log-height must be 1–26".into()); - } - Ok(serde_json::to_string_pretty(&layout::template( - system.get(), - u8::try_from(log)?, - ))?) - })(); - match result { - Ok(json) => LeanIOResult::ok(LeanString::new(&json)), - Err(e) => LeanIOResult::error_string(&e.to_string()), - } -} - -#[unsafe(no_mangle)] -extern "C" fn rs_compression_setup( - system: LeanExternal>, - dir: LeanString>, - srs: LeanString>, - dev: bool, - root_layout: LeanString>, - root_log: u32, - fri_log: u32, - kzg_log: u32, - recursive_log: u32, - claim_len: u32, -) -> LeanIOResult { - match setup( - system.get(), - Path::new(dir.as_str()), - Path::new(srs.as_str()), - dev, - root_layout.as_str(), - root_log, - fri_log, - kzg_log, - recursive_log, - claim_len as usize, - ) { - Ok(id) => LeanIOResult::ok(LeanString::new(&id)), - Err(e) => LeanIOResult::error_string(&e.to_string()), - } -} #[unsafe(no_mangle)] extern "C" fn rs_compression_run( - system: LeanExternal>, - allowed: LeanByteArray>, - idx: LeanNat>, - certificate: LeanByteArray>, + vk: LeanByteArray>, proof: LeanByteArray>, + claim: LeanByteArray>, + statement: LeanByteArray>, dir: LeanString>, + cache_root: LeanString>, + dev_srs: bool, + log_height: u32, ) -> LeanIOResult { - match compress( - system.get(), - allowed.as_bytes(), - crate::aiur::lean_unbox_nat_as_usize(idx.inner()), - certificate.as_bytes(), + let dir = dir.to_string(); + let cache_root = cache_root.to_string(); + match pipeline::compress( + vk.as_bytes(), proof.as_bytes(), - Path::new(dir.as_str()), + claim.as_bytes(), + statement.as_bytes(), + std::path::Path::new(&dir), + std::path::Path::new(&cache_root), + dev_srs, + log_height, ) { - Ok(bytes) => LeanIOResult::ok(LeanByteArray::from_bytes(&bytes)), + Ok(bytes) => LeanIOResult::ok(LeanByteArray::from_bytes(bytes.as_slice())), Err(e) => LeanIOResult::error_string(&e.to_string()), } } #[unsafe(no_mangle)] extern "C" fn rs_compression_verify( - vk: LeanByteArray>, proof: LeanByteArray>, - claim: LeanArray>, - dir: LeanString>, + claim: LeanByteArray>, + dev_srs: bool, ) -> LeanIOResult { - let result = (|| -> pipeline::Result<()> { - let (id, body) = envelope(proof.as_bytes())?; - let entry = local::load(Path::new(dir.as_str()), &id, vk.as_bytes())?; - let expected = claim.map(|x| crate::aiur::lean_unbox_g(&x)); - RecursiveVerifier::from_bytes(&entry.key_bytes)? - .verify_bytes(body, &expected) - })(); - match result { - Ok(()) => LeanIOResult::ok(LeanOwned::box_usize(0)), + match pipeline::verify(proof.as_bytes(), claim.as_bytes(), dev_srs) { + Ok(class) => LeanIOResult::ok(LeanString::new(class.label())), Err(e) => LeanIOResult::error_string(&e.to_string()), } } -fn envelope(bytes: &[u8]) -> pipeline::Result<(String, &[u8])> { - if bytes.len() <= 40 || !bytes.starts_with(MAGIC) { - return Err("invalid compressed certificate envelope".into()); +#[unsafe(no_mangle)] +extern "C" fn rs_compression_setup(dev_srs: bool) -> LeanIOResult { + match pipeline::setup_description(dev_srs) { + Ok(description) => LeanIOResult::ok(LeanString::new(description)), + Err(e) => LeanIOResult::error_string(&e.to_string()), } - let id: [u8; 32] = bytes[8..40].try_into()?; - Ok((blake3::Hash::from(id).to_hex().to_string(), &bytes[40..])) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn envelope_has_only_version_configuration_and_proof() { - let mut bytes = MAGIC.to_vec(); - bytes.extend([7; 32]); - bytes.push(42); - let (id, proof) = envelope(&bytes).unwrap(); - assert_eq!(local::decode_id(&id).unwrap(), [7; 32]); - assert_eq!(proof, &[42]); - for len in 0..=40 { - assert!(envelope(&bytes[..len]).is_err()); - } - bytes[7] = b'1'; - assert!(envelope(&bytes).is_err()); +#[unsafe(no_mangle)] +extern "C" fn rs_compression_check_claim( + claim: LeanByteArray>, + statement: LeanByteArray>, +) -> LeanIOResult { + match pipeline::check_claim(claim.as_bytes(), statement.as_bytes()) { + Ok(_statement) => LeanIOResult::ok(LeanOwned::box_usize(0)), + Err(e) => LeanIOResult::error_string(&e.to_string()), } } diff --git a/crates/ffi/src/compression/layout.rs b/crates/ffi/src/compression/layout.rs deleted file mode 100644 index bb79e7439..000000000 --- a/crates/ffi/src/compression/layout.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! User-facing bounds, resolved to the aggregate backend's circuit order. -use super::*; -use serde_json::{Value, json}; - -pub(super) fn template(system: &AiurSystem, log: u8) -> Value { - json!({ - "functions": vec![log; system.toplevel().circuits.len()], - "memory": system.toplevel().memory_sizes.iter().map(|w| (w.to_string(), json!(log))).collect::>() - }) -} - -pub(super) fn resolve( - system: &AiurSystem, - input: &str, - default: u8, -) -> pipeline::Result> { - let value = if input.is_empty() { - template(system, default) - } else { - serde_json::from_str(input)? - }; - resolve_for( - &value, - system.toplevel().circuits.len(), - &system.toplevel().memory_sizes, - ) -} - -fn resolve_for( - value: &Value, - functions: usize, - widths: &[usize], -) -> pipeline::Result> { - let object = value.as_object().ok_or("root layout must be a JSON object")?; - if object.len() != 2 - || !object.contains_key("functions") - || !object.contains_key("memory") - { - return Err("root layout requires exactly 'functions' and 'memory'".into()); - } - let logs = value["functions"] - .as_array() - .ok_or("root layout functions must be an array")?; - let memories = value["memory"] - .as_object() - .ok_or("root layout memory must map widths to log-heights")?; - if logs.len() != functions || memories.len() != widths.len() { - return Err(format!("root layout requires {functions} function circuits and {} memory widths; use `ix compression layout` for a template", widths.len()).into()); - } - let log = |v: &Value| -> pipeline::Result { - match v.as_u64() { - Some(n @ 1..=26) => Ok(u8::try_from(n)?), - _ => Err("root layout log-heights must be integers in 1–26".into()), - } - }; - let mut result = - logs.iter().map(log).collect::>>()?; - for width in widths { - let v = memories - .get(&width.to_string()) - .ok_or_else(|| format!("root layout missing memory width {width}"))?; - result.push(log(v)?); - } - // Native preprocessed byte tables are never resized. - result.extend([8, 16]); - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn layout_resolves_memory_widths_in_backend_order() -> pipeline::Result<()> { - let value = json!({"functions": [12, 6], "memory": {"1": 3, "8": 7}}); - assert_eq!(resolve_for(&value, 2, &[8, 1])?, [12, 6, 7, 3, 8, 16]); - for bad in [ - json!({"functions": [12], "memory": {"1": 3, "8": 7}}), - json!({"functions": [12, 6], "memory": {"2": 3, "8": 7}}), - json!({"functions": [12, 6], "memory": {"1": 0, "8": 7}}), - json!({"functions": [27, 6], "memory": {"1": 3, "8": 7}}), - json!({"functions": [256, 6], "memory": {"1": 3, "8": 7}}), - json!({"functions": [1.5, 6], "memory": {"1": 3, "8": 7}}), - json!({"functions": [12, 6], "memory": {"1": 3, "8": 7}, "extra": 1}), - ] { - assert!(resolve_for(&bad, 2, &[8, 1]).is_err(), "{bad}"); - } - Ok(()) - } -} diff --git a/crates/ffi/src/compression/local.rs b/crates/ffi/src/compression/local.rs deleted file mode 100644 index aa4ef0abc..000000000 --- a/crates/ffi/src/compression/local.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Locally generated verifier configurations, separate from proof-store hints. -use super::*; -use std::{ - fs, - io::Write, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, -}; - -const VERSION: u32 = 4; - -pub(super) struct Settings { - pub root_hash: String, - pub params_hash: String, - pub root_logs: Vec, - pub fri_log: u32, - pub kzg_log: u32, - pub recursive_log: u32, - pub claim_len: usize, -} - -impl Settings { - pub(super) fn new( - vk: &[u8], - params: &[u8], - root_logs: Vec, - fri_log: u32, - kzg_log: u32, - recursive_log: u32, - claim_len: usize, - ) -> Self { - Self { - root_hash: blake3::hash(vk).to_hex().to_string(), - params_hash: blake3::hash(params).to_hex().to_string(), - root_logs, - fri_log, - kzg_log, - recursive_log, - claim_len, - } - } - - pub(super) fn id(&self) -> String { - let mut h = blake3::Hasher::new(); - // Bump whenever circuit construction, normalization or security settings change. - h.update(b"ix/fixed-compression/v4"); - h.update(self.root_hash.as_bytes()); - h.update(self.params_hash.as_bytes()); - h.update(&(self.root_logs.len() as u64).to_le_bytes()); - h.update(&self.root_logs); - h.update(&self.fri_log.to_le_bytes()); - h.update(&self.kzg_log.to_le_bytes()); - h.update(&self.recursive_log.to_le_bytes()); - h.update(&(self.claim_len as u64).to_le_bytes()); - h.finalize().to_hex().to_string() - } -} - -pub(super) struct Entry { - pub settings: Settings, - pub srs_path: PathBuf, - pub key_bytes: Vec, -} - -pub(super) fn decode_id(id: &str) -> pipeline::Result<[u8; 32]> { - if id.len() != 64 - || !id.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) - { - return Err("invalid compression configuration identifier".into()); - } - let mut bytes = [0; 32]; - for (i, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&id[i * 2..i * 2 + 2], 16)?; - } - Ok(bytes) -} - -pub(super) fn active(dir: &Path) -> pipeline::Result { - let id = fs::read_to_string(dir.join("default")).map_err(|e| { - format!( - "no active compression configuration; run `ix compression setup`: {e}" - ) - })?; - decode_id(id.trim())?; - Ok(id.trim().to_string()) -} - -fn temporary(dir: &Path) -> pipeline::Result { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - Ok(dir.join(format!(".setup-{}-{stamp}", std::process::id()))) -} - -fn write_atomic( - path: &Path, - write: impl FnOnce(&mut File) -> pipeline::Result<()>, -) -> pipeline::Result<()> { - let tmp = temporary(path.parent().ok_or("missing configuration directory")?)?; - let mut f = File::options().write(true).create_new(true).open(&tmp)?; - let result = (|| -> pipeline::Result<()> { - write(&mut f)?; - f.sync_all()?; - drop(f); - fs::rename(&tmp, path)?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(tmp); - } - result -} - -pub(super) fn activate(dir: &Path, id: &str) -> pipeline::Result<()> { - decode_id(id)?; - write_atomic(&dir.join("default"), |f| Ok(f.write_all(id.as_bytes())?)) -} - -fn srs_location(srs: &Path, dev: bool) -> pipeline::Result { - if dev { - return Ok(String::new()); - } - Ok( - fs::canonicalize(srs)?.to_str().ok_or("SRS path is not UTF-8")?.to_string(), - ) -} - -/// The SRS path is not part of circuit identity. Re-running setup with the -/// same parameters at a new path must update the proving source as well. -/// Called only after the existing verifier and the supplied SRS are validated. -pub(super) fn reuse( - dir: &Path, - id: &str, - srs: &Path, - dev: Option<&Srs>, -) -> pipeline::Result<()> { - decode_id(id)?; - let base = dir.join(id); - let path = base.join("settings.json"); - let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path)?)?; - value["srs"] = srs_location(srs, dev.is_some())?.into(); - value["dev_srs"] = dev.is_some().into(); - if let Some(srs) = dev { - write_atomic(&base.join("development.srs"), |f| Ok(srs.write(f)?))?; - } - let bytes = serde_json::to_vec_pretty(&value)?; - write_atomic(&path, |f| Ok(f.write_all(&bytes)?))?; - activate(dir, id) -} - -pub(super) fn load(dir: &Path, id: &str, vk: &[u8]) -> pipeline::Result { - decode_id(id)?; - let base = dir.join(id); - let bytes = fs::read(base.join("settings.json")).map_err(|e| format!("compression configuration {id} is not available locally; run matching `ix compression setup`: {e}"))?; - let value: serde_json::Value = serde_json::from_slice(&bytes)?; - let string = |name| -> pipeline::Result { - Ok( - value - .get(name) - .and_then(|v| v.as_str()) - .ok_or("invalid local compression settings")? - .to_string(), - ) - }; - let number = |name| -> pipeline::Result { - Ok(u32::try_from( - value - .get(name) - .and_then(|v| v.as_u64()) - .ok_or("invalid local compression settings")?, - )?) - }; - if number("version")? != VERSION { - return Err("unsupported local compression settings".into()); - } - let settings = Settings { - root_hash: string("root")?, - params_hash: string("parameters")?, - root_logs: serde_json::from_value( - value - .get("root_log_heights") - .ok_or("missing root trace heights")? - .clone(), - )?, - fri_log: number("fri_log_height")?, - kzg_log: number("kzg_log_height")?, - recursive_log: number("recursive_log_height")?, - claim_len: number("claim_len")? as usize, - }; - if settings.id() != id - || settings.root_hash != blake3::hash(vk).to_hex().as_str() - || settings.root_logs.is_empty() - || settings.root_logs.iter().any(|log| !(1..=26).contains(log)) - || !(16..=26).contains(&settings.fri_log) - || !(16..=26).contains(&settings.kzg_log) - || !(16..=28).contains(&settings.recursive_log) - { - return Err( - "local compression configuration does not match this aggregate backend" - .into(), - ); - } - let key_bytes = fs::read(base.join("verifier.bin"))?; - if blake3::hash(&key_bytes).to_hex().as_str() != string("verifier")? { - return Err( - "local compression verifier is corrupt; remove this configuration directory and rerun setup".into(), - ); - } - let srs_path = if value.get("dev_srs").and_then(|v| v.as_bool()) == Some(true) - { - base.join("development.srs") - } else { - PathBuf::from(string("srs")?) - }; - Ok(Entry { settings, srs_path, key_bytes }) -} - -pub(super) fn publish( - dir: &Path, - settings: &Settings, - key: &[u8], - srs: &Path, - dev: Option<&Srs>, -) -> pipeline::Result<()> { - fs::create_dir_all(dir)?; - let tmp = temporary(dir)?; - fs::create_dir(&tmp)?; - let result = (|| -> pipeline::Result<()> { - let id = settings.id(); - let path = srs_location(srs, dev.is_some())?; - if let Some(srs) = dev { - let mut file = File::options() - .write(true) - .create_new(true) - .open(tmp.join("development.srs"))?; - srs.write(&mut file)?; - file.sync_all()?; - } - let record = serde_json::json!({ - "version": VERSION, - "root": settings.root_hash, - "parameters": settings.params_hash, - "root_log_heights": settings.root_logs, - "fri_log_height": settings.fri_log, - "kzg_log_height": settings.kzg_log, - "recursive_log_height": settings.recursive_log, - "claim_len": settings.claim_len, - "verifier": blake3::hash(key).to_hex().as_str(), - "srs": path, - "dev_srs": dev.is_some(), - }); - for (name, bytes) in [ - ("verifier.bin", key.to_vec()), - ("settings.json", serde_json::to_vec_pretty(&record)?), - ] { - let mut file = - File::options().write(true).create_new(true).open(tmp.join(name))?; - file.write_all(&bytes)?; - file.sync_all()?; - } - fs::rename(&tmp, dir.join(&id))?; - activate(dir, &id) - })(); - if result.is_err() { - let _ = fs::remove_dir_all(tmp); - } - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn local_configuration_identity_and_atomic_publication() - -> pipeline::Result<()> { - let settings = - || Settings::new(b"root", b"params", vec![20, 8, 16], 23, 24, 24, 18); - let id = settings().id(); - for other in [ - Settings::new(b"root", b"params", vec![20, 8, 16], 23, 24, 28, 18), - Settings::new(b"root", b"params", vec![20, 8, 16], 24, 24, 24, 18), - Settings::new(b"other", b"params", vec![20, 8, 16], 23, 24, 24, 18), - Settings::new(b"root", b"other", vec![20, 8, 16], 23, 24, 24, 18), - Settings::new(b"root", b"params", vec![21, 8, 16], 23, 24, 24, 18), - Settings::new(b"root", b"params", vec![20, 7, 16], 23, 24, 24, 18), - Settings::new(b"root", b"params", vec![8, 20, 16], 23, 24, 24, 18), - Settings::new(b"root", b"params", vec![20, 8, 16], 23, 25, 24, 18), - Settings::new(b"root", b"params", vec![20, 8, 16], 23, 24, 24, 19), - ] { - assert_ne!(id, other.id()); - } - let dir = temporary(&std::env::temp_dir())?; - fs::create_dir(&dir)?; - let result = (|| -> pipeline::Result<()> { - assert!(active(&dir).is_err()); - assert!(load(&dir, "../default", b"root").is_err()); - assert!(load(&dir, &id, b"root").is_err()); - let srs = dir.join("test.srs"); - fs::write(&srs, b"storage test only")?; - // Storage/checksum checks do not deserialize a cryptographic key. - publish(&dir, &settings(), b"key", &srs, None)?; - assert_eq!(active(&dir)?, id); - let entry = load(&dir, &id, b"root")?; - assert_eq!(entry.key_bytes, b"key"); - assert_eq!(entry.srs_path, srs); - assert!(load(&dir, &id, b"wrong backend").is_err()); - assert!(publish(&dir, &settings(), b"replacement", &srs, None).is_err()); - assert_eq!(load(&dir, &id, b"root")?.key_bytes, b"key"); - let changed = - Settings::new(b"root", b"params", vec![21, 8, 16], 23, 24, 24, 18); - assert!( - publish(&dir, &changed, b"key", &dir.join("missing"), None).is_err() - ); - assert_eq!(active(&dir)?, id); - let moved = dir.join("moved.srs"); - fs::rename(&srs, &moved)?; - reuse(&dir, &id, &moved, None)?; - assert_eq!(load(&dir, &id, b"root")?.srs_path, moved); - assert_eq!(load(&dir, &id, b"root")?.key_bytes, b"key"); - fs::remove_file(&moved)?; - // Verification can load the key after the proving SRS is removed. - assert!(load(&dir, &id, b"root").is_ok()); - fs::write(dir.join(&id).join("verifier.bin"), b"corrupt")?; - assert!(load(&dir, &id, b"root").is_err()); - fs::write(dir.join(&id).join("verifier.bin"), b"key")?; - let path = dir.join(&id).join("settings.json"); - let mut record: serde_json::Value = - serde_json::from_slice(&fs::read(&path)?)?; - record["version"] = (VERSION + 1).into(); - fs::write(path, serde_json::to_vec(&record)?)?; - assert!(load(&dir, &id, b"root").is_err()); - assert_eq!(fs::read_dir(&dir)?.count(), 2); // configuration + default - Ok(()) - })(); - fs::remove_dir_all(dir)?; - result - } -} diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index 12eb0f38e..f15ef4f4e 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true publish = false +[[bin]] +name = "ix-compression-worker" +path = "src/bin/ix_compression_worker.rs" + [dependencies] aiur = { workspace = true } multi-stark = { workspace = true } @@ -14,10 +18,15 @@ ark-ff = { version = "0.5", features = ["asm"] } ark-poly = "0.5" ark-serialize = { version = "0.5", features = ["std"] } bincode = { version = "2", features = ["serde"] } -blake3 = { workspace = true } +blake3 = { workspace = true, features = ["mmap", "rayon"] } +tiny-keccak = { workspace = true } +blake2b_simd = "1" num-bigint = { workspace = true } serde = { version = "1", features = ["derive"] } +serde_json = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-texray = { workspace = true } p3-blake3 = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } p3-challenger = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } p3-commit = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 2882cc9a5..c50fd71d4 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -1,148 +1,341 @@ # Proof compression -Compress an aggregated Ix certificate through three stages: -root Aiur proof → FRI proof → KZG proof → recursive KZG proof. -The certificate claim remains unchanged. The root can aggregate any number of -shards, provided its recursive verification fits the configured trace bounds. +Compress an aggregate `CheckEnv` root proof through FRI → KZG → recursive +KZG. The statement the compressed proof exposes is the root's public claim +row, derived from the aggregation backend as `ix verify --aggregate` derives +it and bound to the claim by its packed digest; nothing in the pipeline or +its caches depends on which environment was proved. -## Local setup and CLI +The KZG stages prove under the Filecoin phase-one powers of tau when +`IX_KZG_FILECOIN_CACHE` names a prefix cache produced by the authenticated +import in `kzg::srs::filecoin` (`examples/kzg_filecoin_import.rs` drives it +from the 72 GiB `challenge_19`). The cache is pinned by the manifest receipt +its import printed: the receipt of the complete `2^27` cache is built in, and +`IX_KZG_FILECOIN_MANIFEST` names the receipt of a cache with another prefix. +The recursive stage needs the `2^27` prefix, about 12 GiB. -Build with `lake build ix`, then run `.lake/build/bin/ix` (called `ix` below): +The provisioned parameters live in the S3 bucket +`argument-kzg-params-063002298335-us-east-1-an` in `us-east-1`, under their +file names: the `2^27` prover cache `filecoin-prefix-2p27.bin` and the +complete 72 GiB `challenge_19` it was imported from. Fetch the cache onto +fast local storage (see [Disk placement](#disk-placement)) and point +the pipeline at it; `challenge_19` is only needed to re-run the import. ```sh -ix compression setup --srs /path/to/trusted.srs -ix compress ROOT_ADDRESS -ix verify COMPRESSED_ADDRESS +IX_COMPRESSION_DATA=/path/to/local-nvme/ix-compression +mkdir -p "$IX_COMPRESSION_DATA/parameters" "$IX_COMPRESSION_DATA/runs" +export IX_KZG_FILECOIN_CACHE="$IX_COMPRESSION_DATA/parameters/filecoin-prefix-2p27.bin" +export IX_KZG_FIXED_CACHE="$IX_COMPRESSION_DATA/cache/fixed-preprocessing" +export IX_KZG_SRS_CACHE="$IX_COMPRESSION_DATA/cache/development-srs" +aws s3 cp s3://argument-kzg-params-063002298335-us-east-1-an/filecoin-prefix-2p27.bin \ + "$IX_KZG_FILECOIN_CACHE" +ix compress ROOT_PROOF_ADDRESS --out "$IX_COMPRESSION_DATA/runs/run-001" +ix verify COMPRESSED_PROOF_ADDRESS ``` -The prover and verifier each run setup with the same SRS, compiled aggregate -backend, and bounds. Setup derives a fixed verifier locally without a certificate -or proof. The prover supplies only the proof; no verifier key is accepted from it. -`compress` accepts a root from `ix aggregate` using the current default aggregate -parameters, checks it, normalizes it, runs all three stages, and verifies the -result against the local key before saving it. It prints the new store address -to stdout and progress to stderr. - -Configurations live in `~/.ix/compression//`; set -`IX_COMPRESSION_HOME` to use another directory. Each contains `settings.json` and -`verifier.bin`. The `default` file selects the proving configuration. Proofs carry -an identifier for an already configured local verifier. Unknown configurations -are rejected. Setup records the absolute SRS path for proving; verification needs -only settings and the verifier key. Repeated setup with identical parameters -reuses the key. Treat the configuration directory as trusted local data. - -Use `--dev-srs` instead of `--srs FILE` only for development. It generates -known-trapdoor parameters in `development.srs`; those proofs are not -production-secure. Imported SRS files use `Srs::write` / `Srs::from_reader` format: -`IXKZGS01`, a little-endian u32 log-length, `2^log-length` G1 powers, -G2 and tau-G2, then log-length + 1 degree keys, all compressed Ark points. -Import checks point encodings, subgroups, and consistency; provenance must be -established independently. - -## Trace bounds - -The normalizer wraps the root through the aggregate program into one all-active -Aiur trace shard. Each function and memory trace has its own `u8` log-height; -byte tables retain their native heights. Setup fixes the complete layout before -any proof is provided. Oversized executions are rejected before trace allocation. +Choose an existing local NVMe mount for `IX_COMPRESSION_DATA`; these commands +create directories on its filesystem. `--out` changes the run directory only. +The explicit cache variables keep preprocessing off the default home volume. +`IX_KZG_SRS_CACHE` is used only for development parameters. See +[KZG benchmarking](#kzg-benchmarking) for cold/warm runs and timing boundaries. + +Neither download needs to be trusted: the importer checks the +source against the ceremony's published BLAKE2b-512 digest, and loading +checks the cache against its built-in receipt and the pinned tau anchors. A +256-power cache from the same import is checked in at +`tests/fixtures/filecoin-prefix-2p8.bin` for tests of the real format. + +| Artifact | Size | Pin | +| --- | ---: | --- | +| `challenge_19` | 77,309,411,488 bytes | BLAKE2b-512 `5a26015b…4df76b`, `FILECOIN_CHALLENGE_19_BLAKE2B` | +| `filecoin-prefix-2p27.bin` | 12,884,919,006 bytes | BLAKE3 receipt `14423860…37f10a`, `FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3` | +| `tests/fixtures/filecoin-prefix-2p8.bin` | 25,342 bytes | BLAKE3 receipt `185c556e…a0507d`, `FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3` | + +`--dev-srs` selects a **known-trapdoor development SRS**, including when a +Filecoin cache is configured. Its proofs are **not production-secure**. +Verification requires the corresponding bundled development profile and +explicit `--dev-srs` opt-in; proof, claim and pairing checks still apply. ```sh -ix compression layout > root-layout.json -# Edit the bounds for the executions you intend to support. -ix compression setup --srs /path/to/trusted.srs --root-layout root-layout.json +ix compress ROOT_PROOF_ADDRESS --dev-srs --out "$IX_COMPRESSION_DATA/runs/dev-001" +ix verify COMPRESSED_PROOF_ADDRESS --dev-srs ``` -The JSON contains `functions`, an array in compiled function-circuit order, and -`memory`, an object mapping every memory width to its log-height. For example, -`{"functions": [18, 12], "memory": {"1": 16, "8": 10}}` describes a backend with -two function circuits and two memory widths. Heights must be integers in 1–26; -missing entries and unknown fields are rejected. Templates start at 20; use -`compression layout --root-log-height N` to change that starting value. -Without `--root-layout`, setup uses uniform `--root-log-height N` (default 20). -The two setup flags are mutually exclusive. +Compression preserves the claim address and prints a new proof address. +It runs the three stages in sequence, each as a child process, into an empty +directory, `~/.ix/compression/PROOF_ADDRESS/HEIGHT` unless `--out DIR` names +one. Each stage writes its proof and verifying metadata; a rerun starts over. +Fixed preprocessing and development SRS files are cached across runs under +`~/.ix/compression/cache`. Use `--log-height 25` for fewer partitions +(default: 24) and `--root-vk PATH` for a trusted root key instead of the +current aggregate key. Full proving needs hundreds of GiB of RAM. + +Verification uses the profiles compiled into the application from `profiles/`. +`ix verify` selects a profile by the packet's ID and derives the expected +statement from that profile's pinned aggregation-program prefix and the hash +of the caller's serialized claim. It checks the compact proof and both +pairing obligations without a verifier file, registry, proving SRS or GPU. + +External verifier imports are not supported, and local files under +`~/.ix/verifiers` have no effect. Unknown profile IDs are rejected; adding an +accepted profile requires an application update. The bundled profiles are: + +| Profile | Setup | First-stage log-height | +| --- | --- | --- | +| `aggregate-filecoin-v5.bin` | Filecoin | 24 | +| `aggregate-development-v5.bin` | Development, requires `--dev-srs` | 24 | + +The adjacent JSON files record the setup, root key and artifact identities. +The verifier catalog pins each profile and its aggregation-program prefix. +The public claim is supplied by the caller rather than copied from the packet, so a bundled profile verifies +any aggregate root proved by the same recursive circuit. `ix compress` derives +the statement from the aggregation backend instead and fails if a bundled +profile pins a different program than the build it runs in, so a stale +bundle is caught where the backend is available. + +Compression does not require a profile to be bundled before proving. It +constructs its circuit, verifies its output and writes `final-kzg/verifier.bin` +as an output of the run. That file can be reviewed and bundled in a later +application release; generating it does not register it for verification. + +The packet carries the 18-word public claim, a profile ID, two compressed G1 +opening-pairing operands and the compact `KQP4` KZG proof. Generic Serde +serialization of complete KZG proofs is unsupported; callers use +`FixedProofCodec` with the expected profile. Internal commitment metadata +retains its canonical encoding and checks nested counts before allocating. + +Every setup declares its complete public polynomial degree. Loading a shorter +proving prefix or only verifier anchors preserves that degree and the setup +identity. The Filecoin identity derives from the ceremony digest and its +`2^28 - 2` public degree, so every prefix cache of it binds the same +checkpoints and verifier profiles. Development identities derive from the +seed and declared degree; the trapdoor remains known. -These bounds cover verification of the input aggregate proof, including memory -closures; they do not describe the original shards or the proof's byte size. -Changing any bound requires matching setup on both sides. +## Final proof transcript -The compression stages have independent partition limits: +New recursive KZG proofs use Keccak-256 in portable verifier profile v5. +The FRI proof, first KZG proof and the recursive circuit's verification of +that first proof retain BLAKE3. The compact proof layout and SRS are unchanged. +The profile version selects the transcript without trying alternatives; +application verification also requires that exact profile to be bundled. +Setup bindings include the transcript choice. Metadata checksums and profile +identities remain BLAKE3. -| Setup flag | Log-height range | Default | +The Keccak transcript starts with a zero 32-byte state. For counters 0, 1 and +2 it computes Keccak-256 over +`"multi-stark/kzg/keccak256/v1" || counter:u8 || state:bytes32 || observations`. +The first two digests concatenate into a 512-bit **big-endian** integer reduced +modulo the BLS12-381 scalar field; the third digest becomes the next state. +All three hashes use the old state, then the observation buffer is cleared. +Observations keep the existing canonical field/point and vector encodings. +The parameter seed has its own Keccak protocol domain and binds the same +public setup anchors and degree limits as the BLAKE3 configuration. + +This profile makes a custom Ethereum verifier's transcript use native Keccak; +it does not change the proof into a standard PLONK format or implement a +Solidity verifier. + +## Profiling + +The global `IX_TEXRAY=1` switch enables TeXRay for proving, aggregation, +compression, and the Typecheck benchmark. On `ix compress` or a direct +worker invocation it reports coarse phase timings and RSS summaries on +stderr. Each worker installs TeXRay before loading inputs and examines a `compression/fri`, +`compression/kzg`, or `compression/wrap` root. The tree covers circuit +construction, witness generation, lowering, setup, preprocessing caches, +commitments, proving, verification, and output. The inherited `stark/*` +lookup, quotient, and opening phases are included; `stark/fri_open` also +names KZG openings in the generic prover. Prefetched trace generation can +overlap commitment, so child durations should not be summed as wall time. + +## Disk placement + +Use local NVMe where possible. Other filesystems are supported, but +parameter loading, checkpoint writes, integrity scans, and cache publication +are part of proving wall time. Put stage output and fixed preprocessing on +the **same filesystem** so cache publication and restoration can hard-link +the files. Across filesystems the implementation copies them, adding I/O and +duplicate storage even if both filesystems are on NVMe. + +| Data | Location | Storage behavior | | --- | --- | --- | -| `--fri-log-height` | 16–26 | 23 | -| `--log-height` (intermediate KZG) | 16–26 | 24 | -| `--recursive-log-height` (final KZG) | 16–28 | `--log-height` | +| Filecoin parameters | `IX_KZG_FILECOIN_CACHE` | Each KZG worker reads and authenticates its required prefix from the provisioned file. | +| Fixed coefficients and circuit metadata | `/first-kzg/kzg/` and `/final-kzg/kzg/`; reused through `IX_KZG_FIXED_CACHE` | Cold runs write and sync checkpoints, then hash and publish them after proof verification. Warm runs hash the cached files before linking or copying them into the new run. | +| Development parameters | `IX_KZG_SRS_CACHE` | Generated and cached on a miss; loaded and checked on reuse. Unused with Filecoin. | +| Stage proofs, claims, verifier metadata, and reports | `--out` | Retained between worker processes and after the run. | +| Stored `Ixon.Proof` containing the final proof and claim | `~/.ix/store` | Written by `ix compress`; `--out` does not relocate the store. | + +Witness columns stay in memory. Workers index fixed checkpoints and stream +their columns during proving. OS page-cache hits can reduce physical reads, so a warm +preprocessing cache does not imply zero storage work. + +### Capacity and retention + +The 12 GiB Filecoin file is only one part of the footprint. Fixed checkpoint +payloads use 32 bytes per coefficient: + +```text +fixed coefficient bytes = 32 × sum(preprocessed_width × trace_height) +``` -The SRS must cover the larger KZG limit. The FRI stage uses the aggregate -backend's query and proof-of-work parameters with blowup four; its partition -must fit indivisible hash traces. Partition limits are part of the configuration -identity, not total memory limits or automatic RAM tuning. Setup and proving can -require substantial resources and do not resume automatically. +For the bundled v5 recursive profiles, the computation trace has 15 fixed +columns at `2^27` rows and the table trace has four at `2^17` rows. Their +combined coefficient payload is **60.015625 GiB**, excluding checkpoint +metadata. Together with the Filecoin file, this already exceeds 72 GiB before +first-stage preprocessing, intermediate artifacts, and filesystem headroom. +This is a calculation from those profiles, not a measured disk high-water mark +for the current code. First-stage size depends on the layout; record the +actual checkpoint sizes when provisioning a new profile. -## Reproduce the sub-3 KB Init proof +On one filesystem, cache entries and run checkpoints share storage through +hard links. Deleting one directory does not free those bytes while another +link remains. Cache keys include the worker executable digest, plan, and setup; +a changed executable creates a new cache lineage. There is no automatic +eviction of old runs or cache entries. Retire them explicitly once no worker +uses them. Use `df -h` on the selected mount to check available space. -Start with the stored root from aggregating all 16 shards of Init, and retain -`Init.ixe` and `Init-16.ixes` for the environment audit. The supplied -[Init layout](examples/init16-root-layout.json) fixes the 88 function-circuit -and 11 memory bounds used for this run. It is an example for this compiled -backend and workload, not a universal layout for every aggregate root. +Before discarding ephemeral storage, preserve the stored proof with its claim +and `final-kzg/verifier.bin` on durable storage. Keep intermediate proofs and +caches when they are useful for later runs. The 72 GiB `challenge_19` source +is needed only for parameter import; normal proving uses the prefix cache. -From the repository root, in Bash: +## Stage entries and caches + +`ix compress` invokes `ix-compression-worker ` for each stage. +Each invocation runs one stage and exits, releasing its host memory. +Reuse between invocations comes from the disk caches below. +The worker must be installed beside `ix`, or named by `IX_COMPRESSION_WORKER`. +Maintainers can invoke a stage directly when diagnosing a saved input: ```sh -lake build ix -export RAYON_NUM_THREADS=32 -export IX_COMPRESSION_HOME="$PWD/compression" -ix=.lake/build/bin/ix -root=ROOT_ADDRESS # Replace with your stored 16-shard aggregate root address. - -"$ix" compression setup --dev-srs \ - --root-layout crates/proof-compression/examples/init16-root-layout.json \ - --fri-log-height 23 --log-height 25 --recursive-log-height 28 -compressed=$("$ix" compress "$root") -"$ix" verify "$compressed" -"$ix" verify --aggregate --ixe Init.ixe --ixes Init-16.ixes "$compressed" - -# Copy and measure the complete serialized Ixon.Proof, including its claim. -cp "$HOME/.ix/store/${compressed:0:2}/${compressed:2:2}/${compressed:4:2}/${compressed:6}" compressed.proof -wc -c < compressed.proof -test "$(wc -c < compressed.proof)" -lt 3000 +ix-compression-worker fri root/ fri/ --prove-outer +ix-compression-worker kzg run fri/ first-kzg/ 24 +ix-compression-worker wrap stage-and-prove first-kzg/ final-kzg/ ``` -This configuration produced a 2,707-byte complete proof. The larger final -partition permits one arithmetic trace plus the lookup table, avoiding the -commitments and openings of multiple final arithmetic partitions. A smaller -final height can still prove the statement but produces a larger proof. The -size target depends on the layout and compiled circuits; it is not guaranteed -for arbitrary bounds. The development recipe exercises the full pipeline; -for production, replace `--dev-srs` with `--srs /path/to/trusted.srs` covering -`2^28` G1 powers, with the required degree keys. - -## Library and tests - -`pipeline::FixedRoot` and `pipeline::setup` derive the fixed verifier without a -witness. `pipeline::prepare_root` also supports variable root layouts with an -independently trusted aggregate key and expected outer claim. `Prepared::prove_fri`, -`prepare_kzg` / `Prepared::prove_kzg`, and `prepare_recursive` / -`RecursiveStage::prove` expose the three stages separately. The final verifier -checks the expected claim, outer proof, and both opening and degree pairing -equations. Verifier codecs contain no proving powers; authenticate keys before -loading them outside local setup. - -The FRI verifier supports Goldilocks with quadratic challenges, cap-height zero, -binary FRI, a constant final polynomial, active preprocessed tables, and bounded -field-sampling retries. Unsupported profiles fail during preparation. +The KZG stages generate traces directly into commitment and proving. +Every KZG stage requires `--dev-srs` when no Filecoin cache is configured. + +`ix compress` sets the fixed-preprocessing and development SRS cache +directories under `~/.ix/compression/cache` unless the environment already +names them. Direct stage invocations use the defaults below. + +| Variable | Meaning | Default | +| --- | --- | --- | +| `IX_KZG_FIXED_CACHE` | directory of fixed-preprocessing checkpoints, bound to the executable digest, plan and setup identity | `/kzg/fixed-cache` | +| `IX_KZG_FILECOIN_CACHE` | receipt-pinned Filecoin prefix cache, used unless `--dev-srs` selects development parameters | unset: `--dev-srs` required | +| `IX_KZG_FILECOIN_MANIFEST` | BLAKE3 manifest receipt of that cache | the built-in receipt of the complete `2^27` cache | +| `IX_KZG_SRS_CACHE` | directory holding block-checksummed development SRS files shared across stages | unset: regenerated per stage | +| `IX_KZG_PREFETCH_GIB` | bound on the next partition's witness generated while the current one is committed | `32` | +| `IX_ROOT_GENERIC_HASHES` | set to build the FRI stage's circuit with generic rather than compact BLAKE3; `ix compress` rejects it | unset | + +## KZG benchmarking + +State the input boundary with every timing: + +| Scope | Command boundary | Included work | +| --- | --- | --- | +| Aggregate root → compressed packet | Whole `ix compress` process | Backend setup, root-to-FRI compression, both KZG workers, verification, cache publication, and final store write. Initial proving and aggregation are excluded. | +| Saved compressed FRI → KZG packet | Both sequential KZG worker processes | First KZG stage and recursive KZG stage, including parameter loading, verification, output, and cache publication. Root-to-FRI compression is excluded. | + +Use whole-process wall time for either scope. Internal +`kzg/prove-report.json.total_seconds` starts inside the proving phase and is +captured before fixed-cache publication; it excludes some preparation and +storage work. `IX_TEXRAY=1` provides the phase breakdown described under +[Profiling](#profiling). Do not sum overlapping child spans. + +For a Filecoin cold/warm pair, use the NVMe layout and provisioned parameters +from the quickstart. `IX_FRI_INPUT` must contain `outer-vk.bin`, +`outer-proof.bin`, and `outer-claims.bin` from the saved FRI stage. Keep the +input, binary, features, setup, and runtime settings identical between runs: ```sh -cargo test --release -p ix-proof-compression +set -eu +IX_FRI_INPUT=/path/to/saved-fri +IX_KZG_WORKER=.lake/build/bin/ix-compression-worker +IX_KZG_BENCH=$(mktemp -d "$IX_COMPRESSION_DATA/runs/kzg-bench.XXXXXX") +export IX_KZG_FIXED_CACHE="$IX_KZG_BENCH/fixed-preprocessing" +: "${IX_KZG_FILECOIN_CACHE:?set the provisioned Filecoin cache path}" +sha256sum "$IX_KZG_WORKER" "$IX_FRI_INPUT/outer-vk.bin" \ + "$IX_FRI_INPUT/outer-proof.bin" "$IX_FRI_INPUT/outer-claims.bin" \ + > "$IX_KZG_BENCH/inputs.sha256" +findmnt -T "$IX_KZG_BENCH" > "$IX_KZG_BENCH/storage.txt" +df -h "$IX_KZG_BENCH" >> "$IX_KZG_BENCH/storage.txt" +for mode in cold warm; do + run="$IX_KZG_BENCH/$mode" + mkdir -p "$run" + /usr/bin/time -v -o "$run/first-kzg.time" \ + "$IX_KZG_WORKER" kzg run "$IX_FRI_INPUT" "$run/first-kzg" 24 \ + > "$run/first-kzg.log" 2>&1 + /usr/bin/time -v -o "$run/recursive-kzg.time" \ + "$IX_KZG_WORKER" wrap stage-and-prove "$run/first-kzg" "$run/final-kzg" \ + > "$run/recursive-kzg.log" 2>&1 +done ``` -Tests generate their fixtures and cover native/constrained verifier agreement, -statement binding, malformed inputs, degree bounds, and streaming equivalence. -The expensive complete-chain test independently derives a fixed key and proves -a synthetic relation with development parameters and low FRI query counts: +The first run populates the initially empty fixed cache; the second reuses it +with fresh output directories and fresh worker processes. Sum the two elapsed +worker times for each saved-FRI-to-packet result. Parameter download and import +happen before timing. For development runs, report the development SRS cache +state separately: generating parameters changes the measured workload. + +Record the revisions, hardware and driver, environment settings, parameter +receipt, filesystem/device placement, checkpoint sizes, and each process's +exit status alongside these logs. Report OS page-cache state separately from +preprocessing-cache state; if uncontrolled, say so. Rebuilding the worker +between the pair invalidates preprocessing reuse. For full compression from +an aggregate root, time `ix compress` with a fresh `--out` directory and the +same explicit cache placement instead. + +## Shared stage construction + +The public stage API and the worker use the same production circuit builders, +statement schemas, lowering namespaces, and recursive Keccak transcript. +The file adapters load artifacts, select setup and cache locations, and report +results. Multi-shard roots and the normalized `FixedRoot` remain supported; +`setup` constructs its verifier without a proof or witness. + +KZG commitment consumes the lowered circuit and assignment before proving. +The remaining phase owns committed columns and verifier metadata, allowing +frontend memory to be released before lookup and quotient allocation. +Bundled and library recursive verification share validation and pairing checks. +A bundled profile retains its prepared system, pairing keys and compact codec. + +The library's `IXKZGV02` key encoding binds the transcript explicitly. +The bundled v5 profile and packet encodings are unchanged. + +## Code and tests + +The library also exposes `pipeline::prepare_root` for aggregate roots with +multiple Aiur trace shards, `FixedRoot` for a bounded normalized root, and +`pipeline::setup` to construct a verifier from trusted parameters without a +proof witness. `Prepared::prove_fri`, `prepare_kzg`, `Prepared::prove_kzg` and +`prepare_recursive` compose the stages with caller-selected bounds and SRS. +These APIs return verifier-only metadata and keep the expected claim separate +from the proof. They do not register a verifier in the application's bundled +catalog. The CLI uses the worker and trusted profiles described above. + +`fri` constrains FRI verification, `foreign` translates Goldilocks arithmetic +into scalar-field constraints, `kzg` implements the commitment backend, and +`recursive` constrains KZG verification with public pairing operands. +`pipeline` runs the stages through the Plonkish API, exposed by +`ix-compression-worker`. ```sh -cargo test --release -p ix-proof-compression --test pipeline_root \ - complete_compression_chain -- --ignored --nocapture +cargo test --release -p ix-proof-compression +lake build ix compression-cli-tests +.lake/build/bin/compression-cli-tests ``` + +The lakefile compiles Cargo's C units as C17 so they stay compatible with +Lean's bundled libc on hosts whose C compiler defaults to C23, and tracks +that flag and the embedded profile bytes when deciding whether to rebuild +the Rust archive. + +Tests cover small proving, tampering, CLI validation and proof identity. +The saved Init FRI fixture checks native verification and verifier planning. +The CLI test verifies both checked-in packets in a fresh home without a proving +SRS or verifier import. Development proofs require `--dev-srs`; that flag also +selects development parameters when a Filecoin cache is configured. Tests reject +modified claims, proofs and pairing data even with development opt-in, and +confirm that a local verifier registry cannot override the application's profiles. diff --git a/crates/proof-compression/examples/init16-root-layout.json b/crates/proof-compression/examples/init16-root-layout.json deleted file mode 100644 index 47c8a246f..000000000 --- a/crates/proof-compression/examples/init16-root-layout.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "functions": [ - 17, 3, 5, 12, 14, 15, 17, 14, 16, 20, 16, 15, 15, 16, 1, 1, - 5, 1, 19, 14, 20, 18, 16, 17, 7, 16, 15, 19, 17, 19, 16, 17, - 20, 19, 16, 16, 15, 16, 5, 1, 1, 1, 1, 1, 5, 1, 1, 8, - 5, 1, 1, 7, 5, 5, 7, 7, 1, 5, 6, 5, 4, 1, 1, 1, - 1, 6, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 17, 1, 19, 16 - ], - "memory": { - "3": 20, - "4": 15, - "5": 5, - "6": 17, - "7": 19, - "8": 8, - "10": 19, - "11": 8, - "26": 1, - "32": 17, - "34": 14 - } -} diff --git a/crates/proof-compression/examples/kzg_filecoin_import.rs b/crates/proof-compression/examples/kzg_filecoin_import.rs new file mode 100644 index 000000000..3ffdc9984 --- /dev/null +++ b/crates/proof-compression/examples/kzg_filecoin_import.rs @@ -0,0 +1,69 @@ +//! Provision an authenticated cache from stock Filecoin `challenge_19`. + +use ix_proof_compression::kzg::srs::filecoin::{ + FILECOIN_CHALLENGE_19_BLAKE2B, FILECOIN_CHALLENGE_19_MIRROR_URL, + FILECOIN_CHALLENGE_19_URL, FILECOIN_MAX_TRACE_LEN, + FILECOIN_PUBLIC_MAX_DEGREE, filecoin_setup_id, import_filecoin_challenge, + load_filecoin_cache, +}; +use std::{path::Path, time::Instant}; + +fn main() -> Result<(), Box> { + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() + } + + fn parse_digest(value: &str) -> Result<[u8; 32], Box> { + if value.len() != 64 || !value.is_ascii() { + return Err("manifest digest must have 64 hexadecimal characters".into()); + } + let mut digest = [0; 32]; + for (i, byte) in digest.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[2 * i..2 * i + 2], 16)?; + } + Ok(digest) + } + + fn prefix(log: &str) -> Result> { + let log: u32 = log.parse()?; + if !(1..=27).contains(&log) { + return Err("prefix log must be in 1..=27".into()); + } + Ok(1usize << log) + } + + let args: Vec<_> = std::env::args().skip(1).collect(); + let start = Instant::now(); + match args.iter().map(String::as_str).collect::>().as_slice() { + ["info"] => { + println!("source_url={FILECOIN_CHALLENGE_19_URL}"); + println!("mirror_url={FILECOIN_CHALLENGE_19_MIRROR_URL}"); + println!("source_blake2b={}", hex(&FILECOIN_CHALLENGE_19_BLAKE2B)); + println!("public_max_degree={FILECOIN_PUBLIC_MAX_DEGREE}"); + println!("max_trace_len={FILECOIN_MAX_TRACE_LEN}"); + println!("setup_id={}", hex(&filecoin_setup_id())); + } + ["import", source, cache, log] => { + let prefix_len = prefix(log)?; + eprintln!("Reading the complete 72 GiB source and validating {prefix_len} retained G1 powers; this is one-time provisioning."); + let receipt = import_filecoin_challenge(Path::new(source), Path::new(cache), prefix_len)?; + println!("prefix_len={}", receipt.prefix_len); + println!("manifest_blake3={}", hex(&receipt.digest)); + println!("setup_id={}", hex(&filecoin_setup_id())); + eprintln!("Import completed in {:.3}s. Pin manifest_blake3 in trusted configuration; a digest read from an untrusted sidecar is not authentication.", start.elapsed().as_secs_f64()); + } + ["verify", cache, digest, log] => { + let srs = load_filecoin_cache(Path::new(cache), parse_digest(digest)?, prefix(log)?)?; + println!("loaded_prefix_len={}", srs.g1.len()); + println!("public_max_degree={FILECOIN_PUBLIC_MAX_DEGREE}"); + println!("setup_id={}", hex(&filecoin_setup_id())); + eprintln!("Authenticated prefix loaded in {:.3}s", start.elapsed().as_secs_f64()); + } + _ => return Err(concat!( + "usage: kzg_filecoin_import info\n", + " kzg_filecoin_import import \n", + " kzg_filecoin_import verify ", + ).into()), + } + Ok(()) +} diff --git a/crates/proof-compression/profiles/aggregate-development-v5.bin b/crates/proof-compression/profiles/aggregate-development-v5.bin new file mode 100644 index 0000000000000000000000000000000000000000..39621fec89a7176e56367042e9f3f5baa1cbd377 GIT binary patch literal 2008 zcmcIlc~ld35Z+C8lO>2jt{fzhGst08pq^C_wE|U6MQnryxkMurftn(y-~lKtL5?Vt zBNV&{3KSuTu_%&4O9dq$mqkHR5HNut0@2b9ueSf${?mCoGvDm*dpo-`Z)V}Q001DU z!2l{>$OHh<@Pp$2!PEb6h-QGn_i5%)`GfVxJf>Le4R;ANkgj!h&hdGc-dDz>P!57B z4kfGk8mQt?fI&S0RZR^o)Gk6*OG6ud{SOODTZ}4zKzazGk0KC(NH_yBiZBR5#Ze4V zghe#zI2r>*5N@fF2})nc4gM`?jKThMVL@XAfY2C%QHz7r7>}s2#=?DqrW!BOTnvE# z5n6%+b#x&_QxA&;hzn&$AP56vRnOF)v>?R4eA#aUqmM5`_M)C-dh|#tr$Xx#R`rze zGq-m9$w&ctck z0iWhayL3~Me9v1@no{1#YU0WQ*@oqA)lBlHgZ}$#?zGs^N&d5`M8oQHF-)ODN2LXE zaJ~Qngzdb#+YZ{HMKUQ*t#0i2oeUI;He?VnkP=k z_OSK6pOmj|P39=?&R`z7t*ELRKmLL?B;E04F?ZcA>Y;(dY)bByiq*H^{(4#tKZ7K2 z*XB3`rug+~w>_0+OU&)PsK4%}e=C{jTlOMp<*nx(!x2A?@7BU3etYhf-J9OK8|KQE z1uZSL@A<-|M1vmT=4e}K;8a%4W=-ka@GpAwNV$ooYxL)yDL`Q+y>9AzjlFNE}an)sU+$GoCLi*9HNH{?2cu<8YxAwJj;NBIL*%fCS z$BN|Q>Fx3Tg4Fxp7mmjKq&Hibi(7KqX_^=IN~#FG(`Q?OS9;PU5$i1&Tg7IyC)DZW zq`dg@;q#shhDs}W!u9JC$rxcq?zBI4#gQ||<}$ABcz4f7v9pMT+OV(gjv@i4pBPOPi+2)$3o zZE*VWkSyXxL+qR@(%i7Q#^p;zKz>0yFm6LfE)+P=*4fKt%~ip2-ll%oaNiiK{)T}3 zZhuCgd2DN@!(?W0`{yQW4}0M!SbDj4m#m^t2~&;_^Wrc*;YPW#NZ*kozj&94UKd}_ z$*YB}Vp61M9K(wkgOZOzm1!PcQj&hhRE`8*ZlJuP0C^JX<)E3AI~m3&YMWk9#}HF< zdH&5!?~9c2%$n^LS-_4LwihN_xCie&)Yf*%sG54KC!ntHgolgWjtL#zOus!t!g$yZ zvObP+Evi(c5z?6mY_Ysi+f(u)Ryyan0aN9v85BGC=%}U;%e)HL$)zWKn(i5kMf|o( zK_zD;De4;{%3)qL{j7eGo%Cmi?&&VjJJkF7{jBGcvOxS<#I5>_oU&^*46ZoR4Jx7l zQVV-y>w}8zMS2|4zsaTxoW+F&}$;8yD*QtMpzCJI$) z{4(Homm;jp+M)TusRPU(V{ZB+j*hwi{(qOYJiscUC+u4Pc+#)6L$g~(oj%Em9=rJ5 zFYMGiZIAUPr(b@tJ#D9Uyx!;g6-JS!0+F7msw<#{&TIV0>ZwlTK++D@zK6FN5Bx?N a;P5q@h^{Zayk;2gBHrljK|Zh3+5S%ibmI&F literal 0 HcmV?d00001 diff --git a/crates/proof-compression/profiles/aggregate-development-v5.json b/crates/proof-compression/profiles/aggregate-development-v5.json new file mode 100644 index 000000000..199bf2713 --- /dev/null +++ b/crates/proof-compression/profiles/aggregate-development-v5.json @@ -0,0 +1,47 @@ +{ + "setup": "development", + "known_trapdoor": true, + "profile_version": 5, + "first_stage_log_height": 24, + "profile_id": "304340325a2fff6bbfbca27e0b58b335cd9f0731b7698ef8e9d9f2f23fe408a1", + "verifier_blake3": "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", + "packet_blake3": "6162de2ddcf6b5ac6cc783f653ead5815f8504683c4f83d2b301fd61b3cafe47", + "source_fri_verifier_blake3": "5f7db8e94c52f404df1c2658d4c686b7e1ccb5d95b473d47c0efda96141f89cf", + "source_fri_proof_blake3": "c4c19524f0f633eccab88164cb1dd08e2fc877467ec7d0b45821f0c6791a11a6", + "source_fri_fixture": "tests/fixtures/init-fri", + "producer_ix_revision": "45cb4b7a4120f1d44f94248369045c8be56431ff", + "producer_multi_stark_revision": "8b6c3fbdd8575bfeb818a32acf06509bac81ff3b", + "statement_prefix": [ + 0, + 293, + 3008464260, + 3936129970, + 1088962797, + 1141056675, + 2347219926, + 1176003176, + 683533119, + 3085659920 + ], + "scope": "Aggregate CheckEnv recursive circuit for the current CLI FRI verifier. The caller supplies the claim; its digest occupies the last eight public words. Development parameters require --dev-srs.", + "root_verifier_blake3": "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc", + "claim": { + "kind": "CheckEnv", + "root": "f8912c57f41e1b38ebd63e53cf05b0775cff02b42185084a014c07b18d0e2b0c", + "assumptions": null, + "blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80" + }, + "claim_recovery": { + "import": "Init", + "lean_toolchain": "4.34.1", + "environment_root": "3a3b14ba432d397260d10436147314fe1de01614e7c3906dea2fa5b0623744dd", + "constants": 56810, + "partition": "ix shard --shards 16, default static min-cut and balance", + "structural_above": 4096, + "aggregate_proof_address": "779713f2027576d9f8bbbb5573e237a057e0bdc18b8599571feff20fcadb814f", + "aggregate_proof_source": "multi-stark sb/kzg:experiments/kzg-cuda-validation/resume-20261009/root-artifacts/root-proof.bin", + "aggregate_verification_passed": true + }, + "compressed_proof_address": "b6084ba2941da8a991afc978946ef63326148378addb6c1cd9d929a0e4a692c3", + "producer_worker_sha256": "ab7403a0f222b1098c51622614693c0e3ff7a243ec6cd3323a65bf7febcc5626" +} diff --git a/crates/proof-compression/profiles/aggregate-filecoin-v5.bin b/crates/proof-compression/profiles/aggregate-filecoin-v5.bin new file mode 100644 index 0000000000000000000000000000000000000000..57802a7f184d344a0f6821ac2fb778666f4f5c85 GIT binary patch literal 2008 zcmdT^dpuNm6rabPnaj{f9z{bFMnWlKJx7z2XwcN=QKA$+*c2@zuY}b2urp<61_B7k%R-8kk%z1tr1CNf(C2?&ptMzx!azU?1eGBK2LV;I%325^Ku`_6P8~u> z5Q|5z*MJa!*4(fWN?)lB|3{!0f&BMD!U-S@fMNgzSu`NU7*L93R?cOyQY?pE1;AlA zU^N=9xCQ{RN=PIOx6*bH01zmoWLWx3D}wv#(M z++p6oXbDfNYn!Bss;V=O*f59W2ku_3E4Gh4E((fXd?`z^M~|zS-tZm`ex_!%>!Wfq zVtvE0t0{JWPHn0+++%J0WqVs|YN=)T?_te(>TaxiKFoX!tN40^j_i?N&7v_G7cY|; z6WLzPB?}&==99Mdp-Ro0IH`HDnbbF1oYz(i++Gw1*B~wLyFSio?Pe zpO3F?2_REGS}1EtuBNZTc2cxpDLsw}xFxY38fCuo+B#b__RzDfN|NJZoiQ9o z?24m`6Q?aL%)Ca%#$2_^=N3revCCRwX#wHN0b88YwS7K(r6op2k(>C7zxDFq^S`9Pr`-gEYJHWbxwDe=twNi z=Q@?HGt+M#eBZ!8G4~F66ML}c@w)iaAxmHIiTVqU(X?0Lg;V%3{BAQt-QPUs?b$(A zfr%7liaVg@5uUL3l)hViP3C5g^;bLt-L(%2L#DMN%3d)&iRDpsg04%&H)ia}$$C+G zb@>W*b73~fsJx8x7zLWKAENjjAM1kG_gL!&r$g36JVu|_`ACvKZlz*&M7N=Q7 z-S9ZW#%@GIVO4!v+r2vVF1y!wa>UN8@n>EJ z&l8BfURs{I!i!sHmLxlYDB0FZsJaX5E!zov6Os3k>RqYB4xA2soBZY5Vc zwE+j2bd$@X0mkYPJ?5V34bOe}_IFFl+jie zp$b>Vx%iSOqUj#fOY9uGF5ehX{|h$ot@G3+r@d}|W)+8$A89D;l(@ma4F#GFD4oyJ z^76_O+*@xiBl7Ajdy*r;J7qHl*D4nRrMz-_p~r_M_-?IS)@)zNHrBv3V)}^`=Q1u% z;Gg2m3Ts5=Q!GfT>Rh*=Lt~`BBGslP7BjNXHnulalD2E;7f_6J$b}P&2{EbhxUBqI j4#%=mM+rxu+HIq#f6g+!V_@C=cK?L&fTq>|y|sS>YiaBV literal 0 HcmV?d00001 diff --git a/crates/proof-compression/profiles/aggregate-filecoin-v5.json b/crates/proof-compression/profiles/aggregate-filecoin-v5.json new file mode 100644 index 000000000..4686dcc72 --- /dev/null +++ b/crates/proof-compression/profiles/aggregate-filecoin-v5.json @@ -0,0 +1,47 @@ +{ + "setup": "filecoin", + "known_trapdoor": false, + "profile_version": 5, + "first_stage_log_height": 24, + "profile_id": "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", + "verifier_blake3": "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", + "packet_blake3": "80d136cbc7496a3436a2d9d291497025742a5573ffcd565a76bf1f21024c0b4c", + "source_fri_verifier_blake3": "5f7db8e94c52f404df1c2658d4c686b7e1ccb5d95b473d47c0efda96141f89cf", + "source_fri_proof_blake3": "c4c19524f0f633eccab88164cb1dd08e2fc877467ec7d0b45821f0c6791a11a6", + "source_fri_fixture": "tests/fixtures/init-fri", + "producer_ix_revision": "45cb4b7a4120f1d44f94248369045c8be56431ff", + "producer_multi_stark_revision": "8b6c3fbdd8575bfeb818a32acf06509bac81ff3b", + "statement_prefix": [ + 0, + 293, + 3008464260, + 3936129970, + 1088962797, + 1141056675, + 2347219926, + 1176003176, + 683533119, + 3085659920 + ], + "scope": "Aggregate CheckEnv recursive circuit for the current CLI FRI verifier. The caller supplies the claim; its digest occupies the last eight public words.", + "root_verifier_blake3": "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc", + "claim": { + "kind": "CheckEnv", + "root": "f8912c57f41e1b38ebd63e53cf05b0775cff02b42185084a014c07b18d0e2b0c", + "assumptions": null, + "blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80" + }, + "claim_recovery": { + "import": "Init", + "lean_toolchain": "4.34.1", + "environment_root": "3a3b14ba432d397260d10436147314fe1de01614e7c3906dea2fa5b0623744dd", + "constants": 56810, + "partition": "ix shard --shards 16, default static min-cut and balance", + "structural_above": 4096, + "aggregate_proof_address": "779713f2027576d9f8bbbb5573e237a057e0bdc18b8599571feff20fcadb814f", + "aggregate_proof_source": "multi-stark sb/kzg:experiments/kzg-cuda-validation/resume-20261009/root-artifacts/root-proof.bin", + "aggregate_verification_passed": true + }, + "compressed_proof_address": "14cb1679032f43cba067ea52e9b8899e2e57f5a1cf8581bc4309dc07f30afa22", + "producer_worker_sha256": "ab7403a0f222b1098c51622614693c0e3ff7a243ec6cd3323a65bf7febcc5626" +} diff --git a/crates/proof-compression/src/bin/ix_compression_worker.rs b/crates/proof-compression/src/bin/ix_compression_worker.rs new file mode 100644 index 000000000..df002a5cf --- /dev/null +++ b/crates/proof-compression/src/bin/ix_compression_worker.rs @@ -0,0 +1,48 @@ +//! The stage worker `ix compress` spawns: `ix-compression-worker +//! ` runs one FRI, first KZG or recursive KZG stage. Stages run in +//! their own executable so the Lean executable links no KZG proving code, +//! and so each heavy phase exits before the next starts. +use tracing_subscriber::{ + Layer, filter::FilterFn, layer::SubscriberExt, util::SubscriberInitExt, +}; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let Some((stage, rest)) = args.split_first() else { + return Err("usage: ix-compression-worker ".into()); + }; + let texray = std::env::var("IX_TEXRAY").as_deref() == Ok("1"); + let timing = texray.then(|| { + tracing_texray::TeXRayLayer::new().streaming().track_ram().with_filter( + FilterFn::new(|metadata| { + metadata.is_span() + && (metadata.name().starts_with("compression/") + || matches!( + metadata.name(), + "stark/stage1_commit" + | "stark/lookup_construction" + | "stark/stage2_commit" + | "stark/quotient" + | "stark/fri_open" + )) + }), + ) + }); + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_ansi(false) + .with_filter(tracing_subscriber::filter::LevelFilter::INFO), + ) + .with(timing) + .try_init()?; + let span = match stage.as_str() { + "fri" => tracing::info_span!("compression/fri"), + "kzg" => tracing::info_span!("compression/kzg"), + "wrap" => tracing::info_span!("compression/wrap"), + _ => return Err("unknown compression stage".into()), + }; + let span = if texray { tracing_texray::examine(span) } else { span }; + span.in_scope(|| ix_proof_compression::pipeline::command(stage, rest)) +} diff --git a/crates/proof-compression/src/foreign.rs b/crates/proof-compression/src/foreign.rs index dce15a670..eba28cdb4 100644 --- a/crates/proof-compression/src/foreign.rs +++ b/crates/proof-compression/src/foreign.rs @@ -26,31 +26,33 @@ use p3_matrix::Matrix; const P: u64 = ::ORDER_U64; +mod interval; +mod quotient; + pub trait KzgCircuitExt { fn kzg_circuit_inputs( &self, - srs_len: usize, + max_trace_len: usize, quotient_budget: usize, ) -> Result>, LoweringError>; fn kzg_circuit_input( &self, index: usize, - srs_len: usize, + max_trace_len: usize, quotient_budget: usize, ) -> Result>, LoweringError>; } impl KzgCircuitExt for MultiStarkCircuit { /// Minimize KZG commitment/evaluation bytes by choosing lookup groups per - /// trace, within the supplied quotient budget. Include the actual SRS length - /// because smaller traces also need shifted degree commitments. + /// trace, within the supplied trace cap and quotient budget. fn kzg_circuit_inputs( &self, - srs_len: usize, + max_trace_len: usize, quotient_budget: usize, ) -> Result>, LoweringError> { let mut inputs = self.circuit_inputs(); for input in &mut inputs { - tune_kzg_input(input, srs_len, quotient_budget)?; + tune_kzg_input(input, max_trace_len, quotient_budget)?; } Ok(inputs) } @@ -59,20 +61,20 @@ impl KzgCircuitExt for MultiStarkCircuit { fn kzg_circuit_input( &self, index: usize, - srs_len: usize, + max_trace_len: usize, quotient_budget: usize, ) -> Result>, LoweringError> { let Some(mut input) = self.circuit_input(index) else { return Ok(None); }; - tune_kzg_input(&mut input, srs_len, quotient_budget)?; + tune_kzg_input(&mut input, max_trace_len, quotient_budget)?; Ok(Some(input)) } } fn tune_kzg_input( input: &mut CircuitInputs, - srs_len: usize, + max_trace_len: usize, quotient_budget: usize, ) -> Result<(), LoweringError> { use multi_stark::graph::ExtensionParams; @@ -80,7 +82,7 @@ fn tune_kzg_input( use multi_stark::lookup::logup_max_degree; use multi_stark::lookup::stage2_width; let fixed = input.preprocessed.as_ref().expect("lowering has fixed columns"); - if fixed.height() > srs_len { + if fixed.height() > max_trace_len { return Err(LoweringError::InvalidTraceHeight); } let graph = input @@ -90,7 +92,7 @@ fn tune_kzg_input( karatsuba: false, }) .expect("valid lowering"); - let commitment_bytes = if fixed.height() == srs_len { 48 } else { 96 }; + let commitment_bytes = 48; let best = (1..=MAX_LOOKUP_GROUP) .filter_map(|group| { let degree = graph @@ -121,6 +123,15 @@ pub struct GoldilocksInputs { source_owner: CircuitId, wires: Vec, is_input: Vec, + scheduled: bool, +} + +impl GoldilocksInputs { + /// Whether the translated circuit accepted a parallel witness schedule. + /// False for counting builders, serial translations and expanded hashes. + pub fn scheduled(&self) -> bool { + self.scheduled + } } fn signed(value: Val) -> i128 { @@ -144,6 +155,7 @@ fn integer(value: Scalar) -> BigInt { BigInt::from_biguint(Sign::Plus, BigUint::from_bytes_le(&bytes)) } +#[cfg(test)] fn interval(coefficients: [i128; 5], bounds: [u64; 3]) -> (BigInt, BigInt) { let [a, b, c] = bounds.map(BigInt::from); let maxima = [&a * &b, a, b, c]; @@ -327,15 +339,35 @@ fn range( b.assert_bool(value); return; } - let count = bits.div_ceil(16); - let mut terms = Vec::with_capacity(count); + match bits.div_ceil(16) { + 1 => range_limbs::<1>(b, table, value), + 2 => range_limbs::<2>(b, table, value), + 3 => range_limbs::<3>(b, table, value), + 4 => range_limbs::<4>(b, table, value), + 5 => range_limbs::<5>(b, table, value), + 6 => range_limbs::<6>(b, table, value), + 7 => range_limbs::<7>(b, table, value), + 8 => range_limbs::<8>(b, table, value), + 9 => range_limbs::<9>(b, table, value), + _ => panic!("Goldilocks range exceeds quotient bound"), + } +} + +fn range_limbs( + b: &mut CircuitBuilder, + table: Table, + value: Value, +) { + let limbs = b.hint_many_pure::("integer limbs", &[value], |v| { + let words = v[0].canonical_limbs_le(); + Ok(std::array::from_fn(|i| { + Scalar::from_u64((words[i / 4] >> (16 * (i % 4))) & 0xffff) + })) + }); + let mut terms = Vec::with_capacity(N); let radix = Scalar::from_u32(1 << 16); let mut coefficient = Scalar::ONE; - for i in 0..count { - let limb = b.hint("integer limb", &[value], move |v| { - let limbs = v[0].canonical_limbs_le(); - Ok(Scalar::from_u64((limbs[i / 4] >> (16 * (i % 4))) & 0xffff)) - }); + for limb in limbs { b.lookup(table, &[limb]); terms.push((coefficient, limb)); coefficient *= radix; @@ -344,11 +376,30 @@ fn range( b.assert_equal(value, packed); } +/// The builder's zero test on a pure hint, so the translation stays eligible +/// for a parallel witness schedule. Returns a constrained bit, including at +/// zero. +fn is_zero(b: &mut CircuitBuilder, value: Value) -> Value { + let inverse = b.hint_pure("zero-test inverse", &[value], |values| { + Ok(values[0].try_inverse().unwrap_or(Scalar::ZERO)) + }); + let product = b.mul(value, inverse); + let one = b.constant(Scalar::ONE); + let result = b.sub(one, product); + b.assert_bool(result); + let zero = b.constant(Scalar::ZERO); + b.constrain_gate( + [value, result, zero], + [Scalar::ONE, Scalar::ZERO, Scalar::ZERO, Scalar::ZERO, Scalar::ZERO], + ); + result +} + fn canonical(b: &mut CircuitBuilder, table: Table, value: Value) { - let hi = b.hint("Goldilocks high word", &[value], |v| { + let hi = b.hint_pure("Goldilocks high word", &[value], |v| { Ok(Scalar::from_u64(v[0].canonical_low_u64() >> 32)) }); - let lo = b.hint("Goldilocks low word", &[value], |v| { + let lo = b.hint_pure("Goldilocks low word", &[value], |v| { Ok(Scalar::from_u32( u32::try_from(v[0].canonical_low_u64() & u64::from(u32::MAX)).unwrap(), )) @@ -364,8 +415,8 @@ fn canonical(b: &mut CircuitBuilder, table: Table, value: Value) { [Scalar::ONE, Scalar::ZERO], -Scalar::from_u32(u32::MAX), ); - let max_hi = b.is_zero(delta); - let invalid = b.mul(max_hi.value(), lo); + let max_hi = is_zero(b, delta); + let invalid = b.mul(max_hi, lo); let zero = b.constant(Scalar::ZERO); b.assert_equal(invalid, zero); } @@ -416,6 +467,49 @@ impl GoldilocksCircuit { b: &mut CircuitBuilder, expand_hashes: bool, ) -> GoldilocksInputs { + Self::translate_with_chunk_size(source, b, expand_hashes, 1 << 16) + } + + pub(super) fn translate_with_chunk_size( + source: &Circuit, + b: &mut CircuitBuilder, + expand_hashes: bool, + chunk_size: usize, + ) -> GoldilocksInputs { + Self::translate_inner(source, b, expand_hashes, chunk_size, |b, index| { + b.input_indexed("goldilocks", index) + }) + } + + #[cfg(test)] + fn translate_with_inputs( + source: &Circuit, + b: &mut CircuitBuilder, + expand_hashes: bool, + input: impl FnMut(&mut CircuitBuilder, usize) -> Value, + ) -> GoldilocksInputs { + Self::translate_inner(source, b, expand_hashes, 0, input) + } + + fn translate_inner( + source: &Circuit, + b: &mut CircuitBuilder, + expand_hashes: bool, + chunk_size: usize, + mut input: impl FnMut(&mut CircuitBuilder, usize) -> Value, + ) -> GoldilocksInputs { + let mut chunks = + (cfg!(feature = "parallel") && chunk_size > 0 && !expand_hashes) + .then(Vec::new); + let record_chunk = + |b: &CircuitBuilder, chunks: &mut Option>| { + if let Some(ends) = chunks { + let end = b.stats().values; + if end - ends.last().copied().unwrap_or(0) >= chunk_size { + ends.push(end); + } + } + }; let range_table = b.fixed_table( "u16", (0..=u16::MAX).map(|v| vec![Scalar::from_u16(v)]).collect(), @@ -492,13 +586,14 @@ impl GoldilocksCircuit { integer_gates[gate_index] = true; } } - let value = b.input(format!("goldilocks[{i}]")); + let value = input(b, i); if !checked[i] { canonical(b, range_table, value); } wires.push(value); inputs.push(true); } + record_chunk(b, &mut chunks); } let tables: Vec<_> = source .tables() @@ -552,11 +647,33 @@ impl GoldilocksCircuit { } for (gate, integer) in source.gates().iter().zip(integer_gates) { constrain_gate(b, range_table, gate, &wires, &bounds, integer); + record_chunk(b, &mut chunks); } for value in source.public_values() { b.expose_public(wires[value.index()]); } - GoldilocksInputs { source_owner: source.id(), wires, is_input: inputs } + let mut scheduled = false; + if let Some(mut ends) = chunks { + if ends.last() != Some(&b.stats().values) { + ends.push(b.stats().values); + } + let chunk_count = ends.len(); + let started = std::time::Instant::now(); + let certified = b.try_parallel_witness_prefix(ends); + tracing::info!( + chunks = chunk_count, + certified, + certificate_seconds = started.elapsed().as_secs_f64(), + "Goldilocks witness schedule checked" + ); + scheduled = certified; + } + GoldilocksInputs { + source_owner: source.id(), + wires, + is_input: inputs, + scheduled, + } } pub fn assign( @@ -603,31 +720,18 @@ fn constrain_gate( b.constrain_gate(values, coefficients.map(signed_scalar)); return; } - let (low, high) = - interval(coefficients, gate.wires.map(|v| bounds[v.index()])); - let p = BigInt::from(P); - if low > -&p && high < p { - b.constrain_gate(values, coefficients.map(|c| scalar(&BigInt::from(c)))); + let interval::GatePlan::Quotient { offset, bits } = interval::GatePlan::new( + coefficients, + gate.wires.map(|v| bounds[v.index()]), + ) else { + b.constrain_gate(values, coefficients.map(signed_scalar)); return; - } - let shift = - if low < BigInt::from(0) { (-low + &p - 1) / &p } else { BigInt::from(0) }; - let offset = &shift * &p; - let max_q = (high + &offset) / &p; - let bits = usize::try_from(max_q.bits()).expect("quotient fits usize"); - assert!(bits <= 130, "Goldilocks gate quotient bound"); - let hint_offset = offset.clone(); - let quotient = b.hint("Goldilocks quotient", &values, move |v| { - let [a, c, d] = [integer(v[0]), integer(v[1]), integer(v[2])]; - let [qm, qa, qb, qc, k] = coefficients; - let sum = qm * &a * &c + qa * a + qb * c + qc * d + k + &hint_offset; - if &sum % P != BigInt::from(0) { - return Err("invalid Goldilocks gate".into()); - } - Ok(scalar(&(sum / P))) - }); + }; + let hint = quotient::QuotientHint::from_limbs(coefficients, offset); + let quotient = + b.hint_pure("Goldilocks quotient", &values, move |v| hint.evaluate(v)); range(b, table, quotient, bits); - let [qm, qa, qb, qc, k] = coefficients.map(|c| scalar(&BigInt::from(c))); + let [qm, qa, qb, qc, k] = coefficients.map(signed_scalar); let product = b.mul(values[0], values[1]); let residual = b.linear_combination( &[ @@ -637,12 +741,28 @@ fn constrain_gate( (qc, values[2]), (-Scalar::from_u64(P), quotient), ], - k + scalar(&offset), + k + Scalar::from_limbs_le(offset.0), ); let zero = b.constant(Scalar::ZERO); b.assert_equal(residual, zero); } +#[cfg(test)] +#[path = "foreign/tests/independent_witness.rs"] +mod independent_witness_tests; +#[cfg(test)] +#[path = "foreign/tests/indexed_names.rs"] +mod indexed_names_tests; +#[cfg(test)] +#[path = "foreign/tests/translation_intervals.rs"] +mod interval_tests; +#[cfg(test)] +#[path = "foreign/tests/quotient_hints.rs"] +mod quotient_tests; +#[cfg(test)] +#[path = "foreign/tests/range_hints.rs"] +mod range_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/proof-compression/src/foreign/interval.rs b/crates/proof-compression/src/foreign/interval.rs new file mode 100644 index 000000000..73035d83a --- /dev/null +++ b/crates/proof-compression/src/foreign/interval.rs @@ -0,0 +1,68 @@ +use ark_ff::{BigInt as Limbs, BigInteger}; + +use super::{P, quotient}; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum GatePlan { + Direct, + Quotient { offset: Limbs<4>, bits: usize }, +} + +impl GatePlan { + pub(super) fn new(coefficients: [i128; 5], bounds: [u64; 3]) -> Self { + assert!(coefficients.iter().all(|c| c.unsigned_abs() <= u128::from(P / 2))); + let [a, b, c] = bounds.map(u128::from); + let k = coefficients[4]; + let constant = Limbs::from(u64::try_from(k.unsigned_abs()).unwrap()); + let low_positive = if k < 0 { Limbs::zero() } else { constant }; + let mut low_negative = if k < 0 { constant } else { Limbs::zero() }; + let mut high_positive = low_positive; + let high_negative = low_negative; + // Each coefficient has magnitude below 2^63 and each bound below + // 2^64. All interval magnitudes and their width fit below 2^192. + for (coefficient, maximum) in coefficients.into_iter().zip([a * b, a, b, c]) + { + let term = quotient::product( + maximum, + u64::try_from(coefficient.unsigned_abs()).unwrap(), + ); + let accumulator = + if coefficient < 0 { &mut low_negative } else { &mut high_positive }; + assert!(!accumulator.add_with_carry(&term)); + } + let (low_negative, low) = difference(low_positive, low_negative); + let (high_negative, high) = difference(high_positive, high_negative); + let modulus = Limbs::from(P); + if (!low_negative || low < modulus) && (high_negative || high < modulus) { + return Self::Direct; + } + let mut offset = if low_negative { low } else { Limbs::zero() }; + if low_negative { + let (_, remainder) = quotient::divide(offset); + if remainder != 0 { + assert!(!offset.add_with_carry(&Limbs::from(P - remainder))); + } + } + // offset >= -low and high >= low, even if both endpoints are negative. + let mut numerator = offset; + if high_negative { + assert!(!numerator.sub_with_borrow(&high)); + } else { + assert!(!numerator.add_with_carry(&high)); + } + let (max_q, _) = quotient::divide(numerator); + let bits = usize::try_from(max_q.num_bits()).unwrap(); + assert!(bits <= 130, "Goldilocks gate quotient bound"); + Self::Quotient { offset, bits } + } +} + +fn difference(positive: Limbs<4>, negative: Limbs<4>) -> (bool, Limbs<4>) { + let (is_negative, mut magnitude, smaller) = if positive < negative { + (true, negative, positive) + } else { + (false, positive, negative) + }; + assert!(!magnitude.sub_with_borrow(&smaller)); + (is_negative, magnitude) +} diff --git a/crates/proof-compression/src/foreign/quotient.rs b/crates/proof-compression/src/foreign/quotient.rs new file mode 100644 index 000000000..41a04bd86 --- /dev/null +++ b/crates/proof-compression/src/foreign/quotient.rs @@ -0,0 +1,104 @@ +use ark_ff::{ + BigInt as Limbs, BigInteger, biginteger::arithmetic::mac_with_carry, +}; +use num_bigint::BigInt; +#[cfg(test)] +use num_bigint::Sign; + +use super::{P, Scalar, integer, scalar}; + +pub(super) struct QuotientHint { + coefficients: [i64; 5], + offset: Limbs<4>, +} + +impl QuotientHint { + #[cfg(test)] + pub(super) fn new(coefficients: [i128; 5], offset: &BigInt) -> Self { + let (sign, words) = offset.to_u64_digits(); + assert_ne!(sign, Sign::Minus); + assert!(offset.bits() <= 193, "Goldilocks quotient offset bound"); + let mut offset = Limbs::zero(); + offset.0[..words.len()].copy_from_slice(&words); + Self::from_limbs(coefficients, offset) + } + + pub(super) fn from_limbs(coefficients: [i128; 5], offset: Limbs<4>) -> Self { + assert!(offset.num_bits() <= 193, "Goldilocks quotient offset bound"); + Self { + coefficients: coefficients.map(|c| { + assert!(c.unsigned_abs() <= u128::from(P / 2)); + i64::try_from(c).expect("signed Goldilocks coefficient") + }), + offset, + } + } + + pub(super) fn evaluate(&self, values: &[Scalar]) -> Result { + let words = + [values[0], values[1], values[2]].map(|v| v.canonical_limbs_le()); + if words.iter().any(|v| v[1..] != [0; 3]) { + return self.evaluate_large(values); + } + let [a, b, c] = words.map(|v| u128::from(v[0])); + let mut positive = self.offset; + let mut negative = Limbs::zero(); + // |coefficient| < 2^63, a,b,c < 2^64, and offset < 2^193. + // Each signed accumulator is below 2^194, including all five terms. + for (coefficient, value) in + self.coefficients.into_iter().zip([a * b, a, b, c, 1]) + { + if coefficient == 0 { + continue; + } + let term = product(value, coefficient.unsigned_abs()); + let accumulator = + if coefficient < 0 { &mut negative } else { &mut positive }; + assert!(!accumulator.add_with_carry(&term)); + } + let (mut magnitude, smaller, is_negative) = if positive < negative { + (negative, positive, true) + } else { + (positive, negative, false) + }; + assert!(!magnitude.sub_with_borrow(&smaller)); + let (quotient, remainder) = divide(magnitude); + if remainder != 0 { + return Err("invalid Goldilocks gate".into()); + } + let quotient = Scalar::from_limbs_le(quotient.0); + Ok(if is_negative { -quotient } else { quotient }) + } + + fn evaluate_large(&self, values: &[Scalar]) -> Result { + let [a, b, c] = + [integer(values[0]), integer(values[1]), integer(values[2])]; + let [qm, qa, qb, qc, k] = self.coefficients; + let sum = + qm * &a * &b + qa * a + qb * b + qc * c + k + BigInt::from(self.offset); + if &sum % P != BigInt::from(0) { + return Err("invalid Goldilocks gate".into()); + } + Ok(scalar(&(sum / P))) + } +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn product(value: u128, coefficient: u64) -> Limbs<4> { + let mut carry = 0; + let low = mac_with_carry(0, value as u64, coefficient, &mut carry); + let high = mac_with_carry(0, (value >> 64) as u64, coefficient, &mut carry); + Limbs([low, high, carry, 0]) +} + +pub(super) fn divide(value: Limbs<4>) -> (Limbs<4>, u64) { + let mut quotient = Limbs::zero(); + let mut remainder = 0u128; + for (word, output) in value.0.into_iter().zip(&mut quotient.0).rev() { + let dividend = (remainder << 64) | u128::from(word); + *output = + u64::try_from(dividend / u128::from(P)).expect("single-limb quotient"); + remainder = dividend % u128::from(P); + } + (quotient, u64::try_from(remainder).unwrap()) +} diff --git a/crates/proof-compression/src/foreign/tests/independent_witness.rs b/crates/proof-compression/src/foreign/tests/independent_witness.rs new file mode 100644 index 000000000..999beac03 --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/independent_witness.rs @@ -0,0 +1,241 @@ +use super::*; + +struct Fixture { + circuit: Circuit, + assignment: Assignment, +} + +impl Fixture { + fn new(rounds: usize) -> Self { + let mut b = CircuitBuilder::::new(); + let bytes = b + .fixed_table("byte", (0..=255).map(|i| vec![Val::from_u16(i)]).collect()); + let mut inputs = Vec::new(); + let boundary = [0, 1, P - 1, u64::from(u32::MAX), 1 << 32, P - (1 << 32)]; + for i in 0..rounds { + let constant = b.constant(Val::from_usize(19 + i % 31)); + let wide = b.input("wide"); + let factor = b.input("factor"); + let byte = b.input("byte"); + let bit = b.input("bit"); + inputs.extend([ + (wide, Val::from_u64(boundary[i % boundary.len()])), + (factor, Val::from_u64(P - 3 - u64::try_from(i % 127).unwrap())), + (byte, Val::from_usize(i % 256)), + (bit, Val::from_usize(i % 2)), + ]); + b.lookup(bytes, &[byte]); + let bit = b.assert_bool(bit); + let product = b.mul(wide, factor); + let shifted = b.add(product, constant); + let chosen = b.select(bit, shifted, byte); + let copies = [ + b.hint("copy", &[chosen], |values| Ok(values[0])), + b.hint("copy", &[chosen], |values| Ok(values[0])), + ]; + for copy in copies { + b.assert_equal(copy, chosen); + } + b.constant(Val::from_usize(333 + i % 37)); + if i + 1 == rounds { + b.expose_public(copies[0]); + } + } + let circuit = b.finish(); + let mut witness = circuit.witness(); + for (input, value) in inputs { + witness.set(input, value).unwrap(); + } + let assignment = witness.generate().unwrap(); + Self { circuit, assignment } + } + + fn translate(&self, chunk_size: usize) -> GoldilocksCircuit { + let mut b = CircuitBuilder::new(); + let inputs = GoldilocksCircuit::translate_with_chunk_size( + &self.circuit, + &mut b, + false, + chunk_size, + ); + GoldilocksCircuit { circuit: b.finish(), inputs } + } + + fn witness<'a>( + &self, + translated: &'a GoldilocksCircuit, + ) -> Witness<'a, Scalar> { + let mut witness = translated.circuit.witness(); + translated.assign(&self.assignment, &mut witness).unwrap(); + witness + } +} + +fn assert_relation(left: &Circuit, right: &Circuit) { + assert_eq!(left.stats(), right.stats()); + for (left, right) in left.value_sources().zip(right.value_sources()) { + match (left, right) { + (Recipe::Input, Recipe::Input) | (Recipe::Hint, Recipe::Hint) => {}, + (Recipe::Arithmetic(a), Recipe::Arithmetic(b)) => assert_eq!(a, b), + (Recipe::Constant(a), Recipe::Constant(b)) => assert_eq!(a, b), + _ => panic!("different recipe kind"), + } + } + for (left, right) in left.gates().iter().zip(right.gates()) { + assert_eq!(left.wires.map(Value::index), right.wires.map(Value::index)); + assert_eq!(left.coefficients, right.coefficients); + } + for (left, right) in left.tables().iter().zip(right.tables()) { + assert_eq!(left.name(), right.name()); + assert_eq!(left.rows(), right.rows()); + } + for (left, right) in left.lookups().iter().zip(right.lookups()) { + assert_eq!(left.table.index(), right.table.index()); + assert_eq!( + left.values.iter().map(|v| v.index()).collect::>(), + right.values.iter().map(|v| v.index()).collect::>() + ); + } + assert_eq!( + left.public_values().iter().map(|v| v.index()).collect::>(), + right.public_values().iter().map(|v| v.index()).collect::>() + ); +} + +#[test] +fn scheduled_translation_preserves_assignments_and_errors() { + let fixture = Fixture::new(24); + let serial = fixture.translate(0); + let scheduled = fixture.translate(64); + assert_relation(&serial.circuit, &scheduled.circuit); + assert_eq!( + GoldilocksCircuit::estimate(&fixture.circuit), + scheduled.circuit.stats() + ); + let expected = fixture.witness(&serial).generate().unwrap(); + let actual = fixture.witness(&scheduled).generate().unwrap(); + assert_eq!( + expected.values(serial.circuit.id()).unwrap(), + actual.values(scheduled.circuit.id()).unwrap() + ); + assert_eq!(expected.public_values(), actual.public_values()); + let values = fixture.assignment.values(fixture.circuit.id()).unwrap(); + let indices: Vec<_> = scheduled + .inputs + .is_input + .iter() + .enumerate() + .filter_map(|(index, &input)| input.then_some(index)) + .collect(); + for missing in + [indices[0], indices[indices.len() / 2], *indices.last().unwrap()] + { + let mut errors = Vec::new(); + for translated in [&serial, &scheduled] { + let mut witness = translated.circuit.witness(); + for &index in &indices { + if index != missing { + witness + .set( + translated.inputs.wires[index], + Scalar::from_u64(values[index].as_canonical_u64()), + ) + .unwrap(); + } + } + errors.push(witness.generate().err()); + } + assert_eq!(errors[0], errors[1]); + assert_eq!( + errors[0], + Some(WitnessError::MissingInput { + name: format!("goldilocks[{missing}]") + }) + ); + } + // A noncanonical Goldilocks alias must be rejected on both schedules. + let mut errors = Vec::new(); + for translated in [&serial, &scheduled] { + let mut witness = translated.circuit.witness(); + for &index in &indices { + let value = + if index == indices[0] { P } else { values[index].as_canonical_u64() }; + witness + .set(translated.inputs.wires[index], Scalar::from_u64(value)) + .unwrap(); + } + errors.push(witness.generate().err()); + } + assert!(errors[0].is_some()); + assert_eq!(errors[0], errors[1]); +} + +#[test] +fn public_translation_activates_parallel_schedule() { + // Large enough for several default-size chunks on the public path. + let fixture = Fixture::new(2048); + let translated = GoldilocksCircuit::new_preallocated(&fixture.circuit); + assert!(translated.circuit.stats().values > 2 << 16); + assert_eq!(translated.inputs.scheduled(), cfg!(feature = "parallel")); + assert!(!fixture.translate(0).inputs.scheduled()); + assert!( + !GoldilocksCircuit::new_with_expanded_hashes(&fixture.circuit) + .inputs + .scheduled() + ); + let mut census = CircuitBuilder::counting(); + let counted = GoldilocksCircuit::translate_with_chunk_size( + &fixture.circuit, + &mut census, + false, + 1, + ); + assert!(!counted.scheduled()); + let serial = fixture.translate(0); + let expected = fixture.witness(&serial).generate().unwrap(); + let actual = fixture.witness(&translated).generate().unwrap(); + assert_eq!( + expected.values(serial.circuit.id()).unwrap(), + actual.values(translated.circuit.id()).unwrap() + ); +} + +#[test] +fn scheduled_translation_preserves_verified_kzg_bytes() { + use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; + use multi_stark::system::{System, SystemWitness}; + use std::sync::Arc; + let fixture = Fixture::new(2); + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(1 << 16, b"independent-witness-advice")), + 4, + ); + let mut proofs = Vec::new(); + for chunk_size in [0, 16] { + let translated = fixture.translate(chunk_size); + assert_eq!( + translated.inputs.scheduled(), + chunk_size > 0 && cfg!(feature = "parallel") + ); + let assignment = fixture.witness(&translated).generate().unwrap(); + let compiled = + translated.circuit.lower_to_multi_stark(Scalar::from_u8(89)).unwrap(); + let claims = compiled.claims(assignment.public_values()).unwrap(); + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); + let traces = compiled.traces(&assignment).unwrap(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + proofs.push( + FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .unwrap(), + ); + } + assert_eq!(proofs[0], proofs[1]); +} diff --git a/crates/proof-compression/src/foreign/tests/indexed_names.rs b/crates/proof-compression/src/foreign/tests/indexed_names.rs new file mode 100644 index 000000000..8ee8a8bee --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/indexed_names.rs @@ -0,0 +1,262 @@ +use super::*; + +struct Fixture { + circuit: Circuit, + inputs: Vec<(Value, Val)>, +} + +impl Fixture { + fn new(rounds: usize) -> Self { + let mut builder = CircuitBuilder::::new(); + let bytes = builder.fixed_table( + "bytes", + (0..=255).map(|i| vec![Val::from_u16(i)]).collect(), + ); + let mut inputs = Vec::new(); + for i in 0..rounds { + let constant = builder.constant(Val::from_usize(17 + i % 31)); + let bit = builder.input("choice"); + inputs.push((bit, Val::from_usize(i % 2))); + let bit = builder.assert_bool(bit); + let byte = builder.input("byte"); + inputs.push((byte, Val::from_usize(7 + i % 249))); + builder.lookup(bytes, &[byte]); + let wide = builder.input("wide"); + inputs + .push((wide, Val::from_u64(P - 1 - u64::try_from(i % 257).unwrap()))); + let product = builder.mul(wide, byte); + let shifted = builder.add(product, constant); + let selected = builder.select(bit, shifted, byte); + let copied = builder.hint("copy", &[selected], |values| Ok(values[0])); + builder.assert_equal(copied, selected); + builder.constant(Val::from_usize(333 + i % 37)); + if i + 1 == rounds { + builder.expose_public(copied); + } + } + Self { circuit: builder.finish(), inputs } + } + + fn assignment(&self) -> Assignment { + let mut witness = self.circuit.witness(); + for &(wire, value) in &self.inputs { + witness.set(wire, value).unwrap(); + } + witness.generate().unwrap() + } +} + +fn translate( + source: &Circuit, + builder: &mut CircuitBuilder, +) -> GoldilocksInputs { + if INDEXED { + GoldilocksCircuit::translate(source, builder, false) + } else { + GoldilocksCircuit::translate_with_inputs( + source, + builder, + false, + |builder, i| builder.input(format!("goldilocks[{i}]")), + ) + } +} + +fn preallocated( + source: &Circuit, +) -> GoldilocksCircuit { + let stats = { + let mut builder = CircuitBuilder::counting(); + translate::(source, &mut builder); + builder.stats() + }; + let mut builder = CircuitBuilder::new(); + builder.reserve(stats); + let inputs = translate::(source, &mut builder); + assert_eq!(builder.stats(), stats); + GoldilocksCircuit { circuit: builder.finish(), inputs } +} + +fn assert_layout(expected: &GoldilocksCircuit, actual: &GoldilocksCircuit) { + assert_eq!(expected.circuit.stats(), actual.circuit.stats()); + assert_eq!(expected.inputs.is_input, actual.inputs.is_input); + assert_eq!( + expected.inputs.wires.iter().map(|v| v.index()).collect::>(), + actual.inputs.wires.iter().map(|v| v.index()).collect::>() + ); + for (left, right) in + expected.circuit.value_sources().zip(actual.circuit.value_sources()) + { + match (left, right) { + (Recipe::Input, Recipe::Input) | (Recipe::Hint, Recipe::Hint) => {}, + (Recipe::Arithmetic(a), Recipe::Arithmetic(b)) => assert_eq!(a, b), + (Recipe::Constant(a), Recipe::Constant(b)) => assert_eq!(a, b), + _ => panic!("different recipe kind"), + } + } + for (left, right) in + expected.circuit.gates().iter().zip(actual.circuit.gates()) + { + assert_eq!(left.wires.map(|v| v.index()), right.wires.map(|v| v.index())); + assert_eq!(left.coefficients, right.coefficients); + } + for (left, right) in + expected.circuit.tables().iter().zip(actual.circuit.tables()) + { + assert_eq!(left.name(), right.name()); + assert_eq!(left.rows(), right.rows()); + } + for (left, right) in + expected.circuit.lookups().iter().zip(actual.circuit.lookups()) + { + assert_eq!(left.table.index(), right.table.index()); + assert_eq!( + left.values.iter().map(|v| v.index()).collect::>(), + right.values.iter().map(|v| v.index()).collect::>() + ); + } + assert_eq!( + expected + .circuit + .public_values() + .iter() + .map(|v| v.index()) + .collect::>(), + actual + .circuit + .public_values() + .iter() + .map(|v| v.index()) + .collect::>() + ); +} + +fn assign( + translated: &GoldilocksCircuit, + source: &Assignment, +) -> Assignment { + let mut witness = translated.circuit.witness(); + translated.assign(source, &mut witness).unwrap(); + witness.generate().unwrap() +} + +#[test] +fn indexed_names_preserve_translation_layout_values_and_missing_inputs() { + let fixture = Fixture::new(5); + let source = fixture.assignment(); + let expected = preallocated::(&fixture.circuit); + let actual = GoldilocksCircuit::new_preallocated(&fixture.circuit); + assert_layout(&expected, &actual); + let old_assignment = assign(&expected, &source); + let new_assignment = assign(&actual, &source); + assert_eq!( + old_assignment.values(expected.circuit.id()).unwrap(), + new_assignment.values(actual.circuit.id()).unwrap() + ); + assert_eq!(old_assignment.public_values(), new_assignment.public_values()); + let indices: Vec<_> = actual + .inputs + .is_input + .iter() + .enumerate() + .filter_map(|(index, &input)| input.then_some(index)) + .collect(); + assert!(indices.windows(2).any(|pair| pair[1] != pair[0] + 1)); + let values = source.values(fixture.circuit.id()).unwrap(); + for missing in + [indices[0], indices[indices.len() / 2], *indices.last().unwrap()] + { + for translated in [&expected, &actual] { + let mut witness = translated.circuit.witness(); + for &index in &indices { + if index != missing { + witness + .set( + translated.inputs.wires[index], + Scalar::from_u64(values[index].as_canonical_u64()), + ) + .unwrap(); + } + } + assert_eq!( + witness.generate().err(), + Some(WitnessError::MissingInput { + name: format!("goldilocks[{missing}]") + }) + ); + } + } +} + +#[test] +fn indexed_names_preserve_owned_names_and_lazy_census() { + struct UnusedName; + // The conversion must never run; counting builders keep names lazy. + #[allow(clippy::fallible_impl_from)] + impl From for String { + fn from(_: UnusedName) -> Self { + panic!("counting must not materialize an input name") + } + } + let mut census = CircuitBuilder::::counting(); + census.input(UnusedName); + census.input_indexed("goldilocks", usize::MAX); + assert_eq!(census.stats().inputs, 2); + for name in ["", "input α[17]"] { + let mut builder = CircuitBuilder::::new(); + builder.input(name); + assert_eq!( + builder.finish().witness().generate().err(), + Some(WitnessError::MissingInput { name: name.into() }) + ); + } + let mut builder = CircuitBuilder::::new(); + builder.input_indexed("goldilocks", usize::MAX); + assert_eq!( + builder.finish().witness().generate().err(), + Some(WitnessError::MissingInput { + name: format!("goldilocks[{}]", usize::MAX) + }) + ); +} + +#[test] +fn indexed_names_preserve_verified_kzg_bytes() { + use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; + use multi_stark::system::{System, SystemWitness}; + use std::sync::Arc; + let fixture = Fixture::new(2); + let source = fixture.assignment(); + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(1 << 16, b"indexed-input-names")), + 4, + ); + let mut proofs = Vec::new(); + for indexed in [false, true] { + let translated = if indexed { + GoldilocksCircuit::new_preallocated(&fixture.circuit) + } else { + preallocated::(&fixture.circuit) + }; + let assignment = assign(&translated, &source); + let compiled = + translated.circuit.lower_to_multi_stark(Scalar::from_u8(88)).unwrap(); + let claims = compiled.claims(assignment.public_values()).unwrap(); + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let traces = compiled.traces(&assignment).unwrap(); + let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + proofs.push( + FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .unwrap(), + ); + } + assert_eq!(proofs[0], proofs[1]); +} diff --git a/crates/proof-compression/src/foreign/tests/quotient_hints.rs b/crates/proof-compression/src/foreign/tests/quotient_hints.rs new file mode 100644 index 000000000..1af5f68c2 --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/quotient_hints.rs @@ -0,0 +1,207 @@ +use super::*; +use quotient::QuotientHint; + +fn reference( + coefficients: [i128; 5], + offset: &BigInt, + values: &[Scalar], +) -> Result { + let [a, b, c] = [integer(values[0]), integer(values[1]), integer(values[2])]; + let [qm, qa, qb, qc, k] = coefficients; + let sum = qm * &a * &b + qa * a + qb * b + qc * c + k + offset; + if &sum % P != BigInt::from(0) { + return Err("invalid Goldilocks gate".into()); + } + Ok(scalar(&(sum / P))) +} + +fn offset(coefficients: [i128; 5]) -> BigInt { + let (low, _) = interval(coefficients, [P - 1; 3]); + if low < BigInt::from(0) { ((-low + P - 1) / P) * P } else { BigInt::from(0) } +} + +fn goldilocks(value: i128) -> Val { + let magnitude = Val::from_u64(u64::try_from(value.unsigned_abs()).unwrap()); + if value < 0 { -magnitude } else { magnitude } +} + +fn cases(count: usize) -> Vec<([i128; 5], [Scalar; 3])> { + let mut state = 0xda45_83f7_91a2_b603u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + state + }; + (0..count) + .map(|i| { + let mut coefficients = + std::array::from_fn(|_| signed(Val::from_u64(next()))); + coefficients[3] = -1; + if i < 8 { + coefficients = [ + [-i128::from(P / 2), -1, 0, 1, i128::from(P / 2)][i % 5], + i128::from(P / 2), + -i128::from(P / 2), + -1, + i128::from(P / 2), + ]; + } + let [a, b] = if i < 8 { + [Val::from_u64([0, 1, P - 1, 1 << 63][i % 4]), Val::from_u64(P - 1)] + } else { + [Val::from_u64(next()), Val::from_u64(next())] + }; + let [qm, qa, qb, _, k] = coefficients.map(goldilocks); + let c = qm * a * b + qa * a + qb * b + k; + (coefficients, [a, b, c].map(|v| Scalar::from_u64(v.as_canonical_u64()))) + }) + .collect() +} + +#[test] +fn bounded_quotient_hints_match_integer_division_and_reject_residuals() { + for (coefficients, values) in cases(2048) { + let offset = offset(coefficients); + let hint = QuotientHint::new(coefficients, &offset); + let expected = reference(coefficients, &offset, &values).unwrap(); + assert_eq!(hint.evaluate(&values), Ok(expected)); + let mut invalid = values; + invalid[2] += Scalar::ONE; + assert!(reference(coefficients, &offset, &invalid).is_err()); + assert_eq!( + hint.evaluate(&invalid), + reference(coefficients, &offset, &invalid) + ); + } +} + +#[test] +fn bounded_quotient_hints_preserve_negative_and_large_input_behavior() { + let inputs = [ + [Scalar::from_u64(P), Scalar::ONE, Scalar::ZERO], + [Scalar::from_u64(u64::MAX); 3], + [Scalar::from_limbs_le([0, 1, 0, 0]), Scalar::from_u64(P), Scalar::ONE], + [ + Scalar::from_limbs_le([0, 0, 0, 1 << 6]), + Scalar::from_u64(P), + Scalar::ZERO, + ], + [Scalar::NEG_ONE, Scalar::from_u64(P), Scalar::ZERO], + ]; + for coefficients in [ + [-1, 0, 0, 0, 0], + [1, 0, 0, -1, 0], + [0, -1, 0, 0, 0], + [i128::from(P / 2), -i128::from(P / 2), i128::from(P / 2), -1, 7], + ] { + for offset in [BigInt::from(0), offset(coefficients)] { + let hint = QuotientHint::new(coefficients, &offset); + for values in inputs { + assert_eq!( + hint.evaluate(&values), + reference(coefficients, &offset, &values) + ); + } + } + } + let negative = QuotientHint::new([-1, 0, 0, 0, 0], &BigInt::from(0)); + assert_eq!(negative.evaluate(&inputs[0]), Ok(Scalar::NEG_ONE)); + + let offset = (((BigInt::from(1u8) << 193usize) - 1u8) / P) * P; + for coefficient in [-i128::from(P / 2), i128::from(P / 2)] { + let coefficients = [coefficient; 5]; + let coefficients = + [coefficients[0], coefficients[1], coefficients[2], coefficients[3], 0]; + let hint = QuotientHint::new(coefficients, &offset); + let divisible = [Scalar::from_u64(P); 3]; + assert_eq!( + hint.evaluate(&divisible), + Ok(reference(coefficients, &offset, &divisible).unwrap()) + ); + assert_eq!( + hint.evaluate(&inputs[1]), + reference(coefficients, &offset, &inputs[1]) + ); + } +} + +fn proof_fixture(bounded: bool) -> (Circuit, Assignment) { + let mut b = CircuitBuilder::::new(); + let table = + b.fixed_table("limbs", (0..16).map(|n| vec![Scalar::from_u8(n)]).collect()); + let inputs = [b.input("a"), b.input("b"), b.input("c")]; + let coefficients = [1, 0, 0, -1, 0]; + let offset = BigInt::from(P); + let quotient = if bounded { + let hint = QuotientHint::new(coefficients, &offset); + b.hint("quotient", &inputs, move |v| hint.evaluate(v)) + } else { + b.hint("quotient", &inputs, move |v| reference(coefficients, &offset, v)) + }; + range(&mut b, table, quotient, 64); + let product = b.mul(inputs[0], inputs[1]); + let residual = b.linear_combination( + &[ + (Scalar::ONE, product), + (-Scalar::ONE, inputs[2]), + (-Scalar::from_u64(P), quotient), + ], + Scalar::from_u64(P), + ); + let zero = b.constant(Scalar::ZERO); + b.assert_equal(residual, zero); + b.expose_public(inputs[2]); + let circuit = b.finish(); + let mut witness = circuit.witness(); + for (input, value) in inputs.into_iter().zip([P - 1, 2, P - 2]) { + witness.set(input, Scalar::from_u64(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + (circuit, assignment) +} + +#[test] +fn bounded_quotient_hints_preserve_assignment_constraints_and_kzg_bytes() { + use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; + use multi_stark::system::{System, SystemWitness}; + use std::sync::Arc; + let fixtures = [proof_fixture(false), proof_fixture(true)]; + assert_eq!(fixtures[0].0.stats(), fixtures[1].0.stats()); + assert_eq!( + fixtures[0].1.values(fixtures[0].0.id()).unwrap(), + fixtures[1].1.values(fixtures[1].0.id()).unwrap() + ); + for (reference, bounded) in + fixtures[0].0.gates().iter().zip(fixtures[1].0.gates()) + { + assert_eq!( + reference.wires.map(|v| v.index()), + bounded.wires.map(|v| v.index()) + ); + assert_eq!(reference.coefficients, bounded.coefficients); + } + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(128, b"quotient-hint-parity")), + 4, + ); + let mut bytes = Vec::new(); + for (circuit, assignment) in fixtures { + let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(78)).unwrap(); + let traces = compiled.traces(&assignment).unwrap(); + let claims = compiled.claims(assignment.public_values()).unwrap(); + let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + bytes.push( + FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .unwrap(), + ); + } + assert_eq!(bytes[0], bytes[1]); +} diff --git a/crates/proof-compression/src/foreign/tests/range_hints.rs b/crates/proof-compression/src/foreign/tests/range_hints.rs new file mode 100644 index 000000000..690936787 --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/range_hints.rs @@ -0,0 +1,171 @@ +use super::*; + +fn single_limb_range( + b: &mut CircuitBuilder, + table: Table, + value: Value, + bits: usize, +) { + if bits == 0 { + let zero = b.constant(Scalar::ZERO); + b.assert_equal(value, zero); + return; + } + if bits == 1 { + b.assert_bool(value); + return; + } + let mut terms = Vec::new(); + let mut coefficient = Scalar::ONE; + for i in 0..bits.div_ceil(16) { + let limb = b.hint("integer limb", &[value], move |v| { + let words = v[0].canonical_limbs_le(); + Ok(Scalar::from_u64((words[i / 4] >> (16 * (i % 4))) & 0xffff)) + }); + b.lookup(table, &[limb]); + terms.push((coefficient, limb)); + coefficient *= Scalar::from_u32(1 << 16); + } + let packed = b.linear_combination(&terms, Scalar::ZERO); + b.assert_equal(value, packed); +} + +fn fixture( + bits: &[usize], + grouped: bool, + table_size: u32, +) -> (Circuit, Vec) { + let mut b = CircuitBuilder::::new(); + let table = b.fixed_table( + "limbs", + (0..table_size).map(|v| vec![Scalar::from_u32(v)]).collect(), + ); + let inputs = bits.iter().map(|_| b.input("integer")).collect::>(); + for (&bits, &value) in bits.iter().zip(&inputs) { + if grouped { + range(&mut b, table, value, bits); + } else { + single_limb_range(&mut b, table, value, bits); + } + b.expose_public(value); + } + (b.finish(), inputs) +} + +fn assign( + circuit: &Circuit, + inputs: &[Value], + values: &[Scalar], +) -> Assignment { + let mut witness = circuit.witness(); + for (&input, &value) in inputs.iter().zip(values) { + witness.set(input, value).unwrap(); + } + witness.generate().unwrap() +} + +#[test] +fn grouped_range_hints_preserve_wires_constraints_and_boundary_values() { + let bits = [0, 1, 2, 16, 17, 32, 33, 48, 64, 65, 80, 96, 112, 128, 129, 130]; + let (single, old_inputs) = fixture(&bits, false, 1 << 16); + let (grouped, new_inputs) = fixture(&bits, true, 1 << 16); + assert_eq!( + old_inputs.iter().map(|v| v.index()).collect::>(), + new_inputs.iter().map(|v| v.index()).collect::>() + ); + let mut expected = single.stats(); + assert!(grouped.stats().hint_calls < expected.hint_calls); + expected.hint_calls = grouped.stats().hint_calls; + assert_eq!(grouped.stats(), expected); + for (old, new) in single.gates().iter().zip(grouped.gates()) { + assert_eq!(old.wires.map(|v| v.index()), new.wires.map(|v| v.index())); + assert_eq!(old.coefficients, new.coefficients); + } + for (old, new) in single.lookups().iter().zip(grouped.lookups()) { + assert_eq!(old.table.index(), new.table.index()); + assert_eq!( + old.values.iter().map(|v| v.index()).collect::>(), + new.values.iter().map(|v| v.index()).collect::>() + ); + } + for digit in [0u64, 1, 0x5555, 0xffff] { + let values = bits.map(|bits| { + if bits <= 1 { + return Scalar::from_u64(digit & u64::try_from(bits).unwrap()); + } + (0..bits.div_ceil(16)).fold(Scalar::ZERO, |value, _| { + value * Scalar::from_u32(1 << 16) + Scalar::from_u64(digit) + }) + }); + let old = assign(&single, &old_inputs, &values); + let new = assign(&grouped, &new_inputs, &values); + assert_eq!( + old.values(single.id()).unwrap(), + new.values(grouped.id()).unwrap() + ); + assert_eq!(old.public_values(), new.public_values()); + } +} + +#[test] +fn grouped_range_hints_still_reject_non_limbs_with_valid_packing() { + let (circuit, inputs) = fixture(&[32], true, 1 << 16); + let assignment = assign(&circuit, &inputs, &[Scalar::ZERO]); + let mut forged = assignment.values(circuit.id()).unwrap().to_vec(); + // The two limb hints directly follow the input; both limbs pack to zero + // while neither is a table entry. + let limbs: Vec<_> = circuit + .value_sources() + .enumerate() + .filter_map(|(index, source)| { + matches!(source, Recipe::Hint).then_some(index) + }) + .take(2) + .collect(); + assert_eq!(limbs.len(), 2); + assert_eq!(limbs[1], limbs[0] + 1); + forged[limbs[0]] = Scalar::from_u32(1 << 16); + forged[limbs[1]] = Scalar::NEG_ONE; + assert_eq!( + circuit.check_values(&forged), + Err(WitnessError::LookupMissing { table: "limbs".into() }) + ); +} + +#[test] +fn grouped_range_hints_preserve_kzg_proof_bytes() { + use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; + use multi_stark::system::{System, SystemWitness}; + use std::sync::Arc; + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(64, b"range-hint-parity")), + 4, + ); + let mut proofs = Vec::new(); + for grouped in [false, true] { + let (circuit, inputs) = fixture(&[17, 32], grouped, 16); + let assignment = assign( + &circuit, + &inputs, + &[Scalar::from_u32((1 << 16) + 9), Scalar::from_u32((7 << 16) + 3)], + ); + let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(77)).unwrap(); + let traces = compiled.traces(&assignment).unwrap(); + let claims = compiled.claims(assignment.public_values()).unwrap(); + let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + proofs.push( + FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .unwrap(), + ); + } + assert_eq!(proofs[0], proofs[1]); +} diff --git a/crates/proof-compression/src/foreign/tests/translation_intervals.rs b/crates/proof-compression/src/foreign/tests/translation_intervals.rs new file mode 100644 index 000000000..0473315fa --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/translation_intervals.rs @@ -0,0 +1,242 @@ +use super::*; +use ark_ff::BigInt as Limbs; +use interval::GatePlan; + +fn reference_plan(coefficients: [i128; 5], bounds: [u64; 3]) -> GatePlan { + let (low, high) = interval(coefficients, bounds); + let p = BigInt::from(P); + if low > -&p && high < p { + return GatePlan::Direct; + } + let shift = + if low < BigInt::from(0) { (-low + &p - 1) / &p } else { BigInt::from(0) }; + let offset = &shift * &p; + let max_q: BigInt = (high + &offset) / &p; + let bits = usize::try_from(max_q.bits()).unwrap(); + let (sign, words) = offset.to_u64_digits(); + assert_ne!(sign, Sign::Minus); + let mut offset = Limbs::zero(); + offset.0[..words.len()].copy_from_slice(&words); + GatePlan::Quotient { offset, bits } +} + +#[test] +fn fixed_intervals_match_signed_boundaries_and_quotient_bits() { + let half = i128::from(P / 2); + for coefficients in [ + [0; 5], + [0, -1, 0, 0, 0], + [0, -1, 0, 0, -1], + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + [1, 0, 0, 0, 0], + [-1, 0, 0, 0, -1], + [-half, -half, -half, -half, -half], + [half; 5], + [half, -half, half, -half, -half], + ] { + for bounds in [ + [0; 3], + [1; 3], + [P - 2, 1, 0], + [P - 1; 3], + [P; 3], + [P + 1; 3], + [u64::MAX; 3], + ] { + assert_eq!( + GatePlan::new(coefficients, bounds), + reference_plan(coefficients, bounds), + "coefficients={coefficients:?}, bounds={bounds:?}" + ); + } + } + assert_eq!( + GatePlan::new([0, -1, 0, 0, -1], [P - 1, 0, 0]), + GatePlan::Quotient { offset: Limbs::from(P), bits: 0 } + ); + for bit in 1..64 { + for maximum in [(1u64 << bit) - 1, 1u64 << bit, (1u64 << bit) + 1] { + let coefficients = [1, 0, 0, 0, 0]; + let bounds = [P, maximum, 0]; + assert_eq!( + GatePlan::new(coefficients, bounds), + reference_plan(coefficients, bounds) + ); + } + } + let mut state = 0x5fcd_06a9_8372_b14du64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + state + }; + for _ in 0..16_384 { + let coefficients = std::array::from_fn(|_| signed(Val::from_u64(next()))); + let bounds = std::array::from_fn(|_| next()); + assert_eq!( + GatePlan::new(coefficients, bounds), + reference_plan(coefficients, bounds) + ); + for coefficient in coefficients { + assert_eq!( + signed_scalar(coefficient), + scalar(&BigInt::from(coefficient)) + ); + } + } +} + +fn fixture(reference: bool, count: usize) -> (Circuit, [Value; 3]) { + let mut source = CircuitBuilder::::new(); + let source_wires = [source.input("a"), source.input("b"), source.input("c")]; + let mut builder = CircuitBuilder::::new(); + let table = builder + .fixed_table("limbs", (0..16).map(|i| vec![Scalar::from_u8(i)]).collect()); + let zero = builder.constant(Scalar::ZERO); + let values = [builder.input("a"), builder.input("b"), builder.input("c")]; + let wires = [zero, values[0], values[1], values[2]]; + for i in 0..count { + let coefficients = match i % 6 { + 0 => [1, 0, 0, -1, 0], + 1 => [-1, 1, 0, -1, 0], + 2 => [0, 1, -1, -1, 0], + 3 => [0, 1, 1, -1, 0], + 4 => [i128::from(P / 2), -7, 13, -1, -1], + _ => [-i128::from(P / 2), 19, -31, -1, 7], + }; + let coefficients = coefficients.map(|v| { + let magnitude = Val::from_u64(u64::try_from(v.unsigned_abs()).unwrap()); + if v < 0 { -magnitude } else { magnitude } + }); + let gate = Gate { wires: source_wires, coefficients }; + if reference { + constrain_reference( + &mut builder, + table, + &gate, + &wires, + &[P - 1; 4], + false, + ); + } else { + constrain_gate(&mut builder, table, &gate, &wires, &[P - 1; 4], false); + } + } + builder.expose_public(values[2]); + (builder.finish(), values) +} + +#[test] +fn fixed_intervals_preserve_constraint_layout() { + let (expected, _) = fixture(true, 24); + let (actual, _) = fixture(false, 24); + assert_eq!(expected.stats(), actual.stats()); + for (a, b) in expected.gates().iter().zip(actual.gates()) { + assert_eq!(a.wires.map(|v| v.index()), b.wires.map(|v| v.index())); + assert_eq!(a.coefficients, b.coefficients); + } + for (a, b) in expected.lookups().iter().zip(actual.lookups()) { + assert_eq!(a.table.index(), b.table.index()); + assert_eq!( + a.values.iter().map(|v| v.index()).collect::>(), + b.values.iter().map(|v| v.index()).collect::>() + ); + } + assert!(expected.value_sources().zip(actual.value_sources()).all( + |(a, b)| { std::mem::discriminant(&a) == std::mem::discriminant(&b) } + )); +} + +#[test] +fn fixed_intervals_preserve_assignment_and_kzg_bytes() { + use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; + use multi_stark::system::{System, SystemWitness}; + use std::sync::Arc; + let mut assignments = Vec::new(); + let mut proofs = Vec::new(); + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(128, b"interval-plan-parity")), + 4, + ); + for reference in [true, false] { + let (circuit, inputs) = fixture(reference, 1); + let mut witness = circuit.witness(); + for (wire, value) in inputs.into_iter().zip([P - 1, 2, P - 2]) { + witness.set(wire, Scalar::from_u64(value)).unwrap(); + } + let assignment = witness.generate().unwrap(); + assignments.push(assignment.values(circuit.id()).unwrap().to_vec()); + let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(87)).unwrap(); + let traces = compiled.traces(&assignment).unwrap(); + let claims = compiled.claims(assignment.public_values()).unwrap(); + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + proofs.push( + FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .unwrap(), + ); + } + assert_eq!(assignments[0], assignments[1]); + assert_eq!(proofs[0], proofs[1]); +} + +/// The modular gate construction before fixed-width interval planning, +/// kept as the reference for the plan's offsets and quotient bounds. +fn constrain_reference( + b: &mut CircuitBuilder, + table: Table, + gate: &Gate, + wires: &[Value], + bounds: &[u64], + integer_relation: bool, +) { + let coefficients = gate.coefficients.map(signed); + let values = gate.wires.map(|v| wires[v.index()]); + if integer_relation + || boolean_gate(gate) + || small_interval(coefficients, gate.wires.map(|v| bounds[v.index()])) + .is_some_and(|(low, high)| low > -i128::from(P) && high < i128::from(P)) + { + b.constrain_gate(values, coefficients.map(signed_scalar)); + return; + } + let (low, high) = + interval(coefficients, gate.wires.map(|v| bounds[v.index()])); + let p = BigInt::from(P); + if low > -&p && high < p { + b.constrain_gate(values, coefficients.map(|c| scalar(&BigInt::from(c)))); + return; + } + let shift = + if low < BigInt::from(0) { (-low + &p - 1) / &p } else { BigInt::from(0) }; + let offset = &shift * &p; + let max_q = (high + &offset) / &p; + let bits = usize::try_from(max_q.bits()).expect("quotient fits usize"); + assert!(bits <= 130, "Goldilocks gate quotient bound"); + let hint = quotient::QuotientHint::new(coefficients, &offset); + let quotient = + b.hint("Goldilocks quotient", &values, move |v| hint.evaluate(v)); + range(b, table, quotient, bits); + let [qm, qa, qb, qc, k] = coefficients.map(|c| scalar(&BigInt::from(c))); + let product = b.mul(values[0], values[1]); + let residual = b.linear_combination( + &[ + (qm, product), + (qa, values[0]), + (qb, values[1]), + (qc, values[2]), + (-Scalar::from_u64(P), quotient), + ], + k + scalar(&offset), + ); + let zero = b.constant(Scalar::ZERO); + b.assert_equal(residual, zero); +} diff --git a/crates/proof-compression/src/fri/algebra.rs b/crates/proof-compression/src/fri/algebra.rs index 04f583f30..68d561123 100644 --- a/crates/proof-compression/src/fri/algebra.rs +++ b/crates/proof-compression/src/fri/algebra.rs @@ -13,8 +13,8 @@ use multi_stark::plonkish::CircuitBuilder; use multi_stark::plonkish::Value; use multi_stark::system::Circuit; -/// Boundary wires bound by the transcript gadget. None of these -/// values is authenticated by the algebraic checks themselves. +/// Boundary wires that the transcript gadget binds. None of these values +/// is authenticated by the algebraic checks themselves. #[derive(Clone, Copy, Debug)] pub struct AlgebraicChallenges { pub beta: Q, @@ -265,6 +265,8 @@ pub(super) fn constrain_algebraic_checks_with_residual< ], ); let AlgebraicChallenges { beta, gamma, alpha, zeta } = inputs.challenges; + // Zero collapses current-row and next-row opening points. + zeta.inverse(builder); let mut acc = zero; for claim in &inputs.claims { let mut fingerprint = zero; diff --git a/crates/proof-compression/src/kzg/buffer.rs b/crates/proof-compression/src/kzg/buffer.rs new file mode 100644 index 000000000..2cd03da4b --- /dev/null +++ b/crates/proof-compression/src/kzg/buffer.rs @@ -0,0 +1,22 @@ +//! Parallel first-touch initialization of large field buffers. + +use p3_maybe_rayon::prelude::*; + +pub(super) fn generate( + len: usize, + value: impl Fn(usize) -> T + Sync, +) -> Vec { + let mut output: Vec = Vec::with_capacity(len); + output.spare_capacity_mut()[..len] + .par_chunks_mut(1 << 14) + .enumerate() + .for_each(|(tile, chunk)| { + for (offset, slot) in chunk.iter_mut().enumerate() { + slot.write(value((tile << 14) + offset)); + } + }); + // Every slot is initialized after the parallel iterator joins. T is Copy, + // so a panic during initialization cannot leak partially constructed values. + unsafe { output.set_len(len) }; + output +} diff --git a/crates/proof-compression/src/kzg/coefficients.rs b/crates/proof-compression/src/kzg/coefficients.rs index 05af55b08..d231817df 100644 --- a/crates/proof-compression/src/kzg/coefficients.rs +++ b/crates/proof-compression/src/kzg/coefficients.rs @@ -1,4 +1,4 @@ -//! Resident or checksummed, trusted-local coefficient storage. +//! Compact constant, resident or checksummed, trusted-local coefficient storage. use std::{ borrow::Cow, fs::File, @@ -46,6 +46,16 @@ impl From> for Column { } impl Column { + /// Host coefficients of a resident column; compact constant and + /// disk-backed columns return `None` and are read through [`Self::visit`] + /// or [`Self::load`]. + pub(super) fn resident(&self) -> Option<&[Fr]> { + match self { + Self::Resident { values, range } => Some(&values[range.clone()]), + Self::Constant { .. } | Self::Disk { .. } => None, + } + } + pub(super) fn len(&self) -> usize { match self { Self::Resident { range, .. } => range.len(), diff --git a/crates/proof-compression/src/kzg/compact.rs b/crates/proof-compression/src/kzg/compact.rs index 73454770d..60b250e1c 100644 --- a/crates/proof-compression/src/kzg/compact.rs +++ b/crates/proof-compression/src/kzg/compact.rs @@ -56,11 +56,8 @@ impl FixedProofCodec { return Err(CompactProofError); } let max_log = system.config.max_log_degree(); - let mut commitments = [ - KzgCommitment(vec![], vec![]), - KzgCommitment(vec![], vec![]), - KzgCommitment(vec![], vec![]), - ]; + let mut commitments = + [KzgCommitment(vec![]), KzgCommitment(vec![]), KzgCommitment(vec![])]; let mut rounds = [vec![], vec![], vec![], vec![]]; let mut heights = Vec::new(); for (ci, (c, &log)) in system.circuits.iter().zip(log_degrees).enumerate() { @@ -79,14 +76,6 @@ impl FixedProofCodec { .enumerate() { commitments[i].0.push(vec![G1Affine::default(); width]); - commitments[i].1.push(vec![ - G1Affine::default(); - if usize::from(log) < max_log { - width - } else { - 0 - } - ]); let count = match i { 0 => { 1 + usize::from( @@ -151,7 +140,7 @@ impl FixedProofCodec { { return Err(CompactProofError); } - let mut bytes = b"KQP1".to_vec(); + let mut bytes = b"KQP4".to_vec(); bytes.push( u8::try_from(proof.opening_proof.0.len()) .map_err(|_error| CompactProofError)?, @@ -174,7 +163,7 @@ impl FixedProofCodec { &self, bytes: &[u8], ) -> Result, CompactProofError> { - if bytes.len() < 5 || &bytes[..4] != b"KQP1" { + if bytes.len() < 5 || &bytes[..4] != b"KQP4" { return Err(CompactProofError); } let openings = usize::from(bytes[4]); @@ -192,7 +181,7 @@ impl FixedProofCodec { &mut proof.commitments.stage_2_trace, &mut proof.commitments.quotient_chunks, ] { - for point in commitment.0.iter_mut().chain(&mut commitment.1).flatten() { + for point in commitment.0.iter_mut().flatten() { *point = G1Affine::deserialize_compressed(&mut input) .map_err(|_error| CompactProofError)?; } @@ -233,7 +222,7 @@ fn points(proof: &Proof) -> impl Iterator { &proof.commitments.quotient_chunks, ] .into_iter() - .flat_map(|c| c.0.iter().chain(&c.1).flatten()) + .flat_map(|c| c.0.iter().flatten()) .chain(&proof.opening_proof.0) } @@ -266,10 +255,8 @@ fn shape(proof: &Proof) -> Vec { &proof.commitments.stage_2_trace, &proof.commitments.quotient_chunks, ] { - for matrices in [&c.0, &c.1] { - shape.push(matrices.len()); - shape.extend(matrices.iter().map(Vec::len)); - } + shape.push(c.0.len()); + shape.extend(c.0.iter().map(Vec::len)); } for round in [ &proof.stage_1_opened_values, @@ -365,9 +352,10 @@ mod tests { let codec = FixedProofCodec::new(&system, &[2]).unwrap(); let bytes = codec.encode(&proof).unwrap(); let decoded = codec.decode(&bytes).unwrap(); - assert_eq!(decoded.to_bytes().unwrap(), proof.to_bytes().unwrap()); + assert_eq!(codec.encode(&decoded).unwrap(), bytes); system.verify_multiple_claims(&[], &decoded).unwrap(); - assert!(bytes.len() < proof.to_bytes().unwrap().len()); + assert!(proof.to_bytes().is_err()); + assert!(Proof::::from_bytes(&bytes).is_err()); for end in 0..bytes.len() { assert!(codec.decode(&bytes[..end]).is_err()); } diff --git a/crates/proof-compression/src/kzg/config.rs b/crates/proof-compression/src/kzg/config.rs index 651dc739e..cca7c95b7 100644 --- a/crates/proof-compression/src/kzg/config.rs +++ b/crates/proof-compression/src/kzg/config.rs @@ -1,5 +1,5 @@ //! [`ProofConfig`] instantiation for the KZG backend: BLS12-381 -//! scalar field (its own challenge field, `D = 1`), Blake3 transcript, +//! scalar field (its own challenge field, `D = 1`), a selected transcript, //! monomial KZG commitments. use std::sync::Arc; @@ -11,17 +11,18 @@ use multi_stark::config::ProofConfig; use multi_stark::traits::Pcs; use super::field::Scalar; -use super::pcs::KzgPcs; +use super::pcs::{KzgCommitment, KzgPcs, KzgProverData}; use super::srs::Srs; -use super::transcript::Blake3Transcript; +use super::transcript::{KzgTranscript, TranscriptKind}; #[derive(Clone)] pub struct KzgConfig { pcs: KzgPcs, - /// Bytes observed into every fresh challenger: a domain tag plus a - /// digest of the protocol parameters INCLUDING the SRS (see the - /// transcript contract on [`ProofConfig::initialise_challenger`]). + /// Bytes observed into every fresh challenger: a domain tag, the trace + /// cap and quotient budget, the public setup and the four anchor points + /// (see the transcript contract on [`ProofConfig::initialise_challenger`]). transcript_seed: Vec, + transcript_kind: TranscriptKind, max_log_degree: usize, stream_lookups: bool, stream_quotient: bool, @@ -35,46 +36,88 @@ impl KzgConfig { /// Panics if the SRS length is not a power of two (trace domains /// are, and `max_log_degree` is read off the SRS). pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { - let verifier = super::VerifierParams::from_srs(&srs); + let max_trace_len = srs.max_len(); + Self::with_max_trace_len(srs, max_trace_len, max_quotient_degree) + } + + /// Bind the admitted trace cap independently of the loaded public-power prefix. + pub fn with_max_trace_len( + srs: Arc, + max_trace_len: usize, + max_quotient_degree: usize, + ) -> Self { + let verifier = + super::VerifierParams::with_max_trace_len(&srs, max_trace_len); let mut config = Self::verifier_only(verifier, max_quotient_degree); - config.pcs = KzgPcs::new(srs, max_quotient_degree); + config.pcs = + KzgPcs::with_max_trace_len(srs, max_trace_len, max_quotient_degree); config } + pub fn transcript_seed(&self) -> &[u8] { + &self.transcript_seed + } + /// Construct a verifier without retaining the proving powers. No proving operation is available. pub fn verifier_only( params: super::VerifierParams, max_quotient_degree: usize, ) -> Self { - let max_log_degree = p3_util::log2_strict_usize(params.max_len()); - let mut transcript_seed = b"multi-stark/kzg/v3".to_vec(); - for parameter in [max_log_degree, max_quotient_degree] { - transcript_seed.extend(u64::try_from(parameter).unwrap().to_le_bytes()); - } - // Preserve the proving configuration's transcript exactly. - params.g1[0] - .serialize_compressed(&mut transcript_seed) - .expect("Vec serialization"); - params - .g2 - .serialize_compressed(&mut transcript_seed) - .expect("Vec serialization"); - params.g1[1] - .serialize_compressed(&mut transcript_seed) - .expect("Vec serialization"); - params - .tau_g2 - .serialize_compressed(&mut transcript_seed) - .expect("Vec serialization"); + let max_log_degree = p3_util::log2_strict_usize(params.max_trace_len()); + assert!(max_quotient_degree.is_power_of_two()); + let transcript_kind = TranscriptKind::Blake3; + let transcript_seed = + Self::make_transcript_seed(¶ms, max_quotient_degree, transcript_kind); Self { pcs: KzgPcs::verifier_only(params, max_quotient_degree), transcript_seed, + transcript_kind, max_log_degree, stream_lookups: false, stream_quotient: false, } } + /// Select the Fiat-Shamir protocol and its parameter-binding domain. + pub fn with_transcript(mut self, kind: TranscriptKind) -> Self { + self.transcript_seed = Self::make_transcript_seed( + self.pcs.verifier_params(), + self.pcs.max_quotient_degree(), + kind, + ); + self.transcript_kind = kind; + self + } + + pub fn transcript_kind(&self) -> TranscriptKind { + self.transcript_kind + } + + fn make_transcript_seed( + params: &super::VerifierParams, + max_quotient_degree: usize, + kind: TranscriptKind, + ) -> Vec { + let domain: &[u8] = match kind { + TranscriptKind::Blake3 => b"multi-stark/kzg/v4", + TranscriptKind::Keccak256 => b"multi-stark/kzg/keccak256/v1", + }; + let mut seed = domain.to_vec(); + for parameter in + [p3_util::log2_strict_usize(params.max_trace_len()), max_quotient_degree] + { + seed.extend(u64::try_from(parameter).unwrap().to_le_bytes()); + } + seed.extend((params.public_setup.max_degree as u64).to_le_bytes()); + seed.extend(params.public_setup.id); + // Proving and portable verification bind the same public anchors. + params.g1[0].serialize_compressed(&mut seed).expect("Vec serialization"); + params.g2.serialize_compressed(&mut seed).expect("Vec serialization"); + params.g1[1].serialize_compressed(&mut seed).expect("Vec serialization"); + params.tau_g2.serialize_compressed(&mut seed).expect("Vec serialization"); + seed + } + /// Reconstruct and commit one lookup trace at a time from committed columns. /// This changes memory use, not the proof or its transcript. pub fn with_streaming_lookups(mut self) -> Self { @@ -87,6 +130,170 @@ impl KzgConfig { self.stream_quotient = true; self } + + /// One circuit's lookup trace, constructed on the host from committed + /// columns, with its local accumulator total. + fn host_lookup_part( + &self, + i: usize, + input: &multi_stark::config::LookupCommitInput<'_, Self>, + beta: Scalar, + gamma: Scalar, + ) -> (KzgProverData, Scalar) { + use multi_stark::traits::Algebra; + use multi_stark::traits::EvaluationDomain; + let start = std::time::Instant::now(); + let domain = input.stage_1.0.matrices[input.stage_1.1].domain; + let (selected, graph) = + super::quotient_plan::lookup_graph(&input.circuit.graph); + tracing::info!( + circuit = i, + fixed_columns = selected.len(), + "KZG lookup fixed columns" + ); + let fixed = input.preprocessed.filter(|_| !selected.is_empty()).map( + |(data, slot)| self.pcs.evaluate_columns(data, slot, domain, &selected), + ); + let main = self.pcs.get_evaluations_on_domain( + input.stage_1.0, + input.stage_1.1, + domain, + ); + const CHUNK: usize = 1 << 12; + let width = input.circuit.stage_2_width; + let mut trace = vec![Scalar::ZERO; domain.size() * width]; + // Every chunk starts at zero. Its total determines the prefix + // offset applied in a second bounded, parallel pass. + let totals: Vec<_> = trace + .par_chunks_mut(CHUNK * width) + .enumerate() + .map(|(i, dst)| { + let start = i * CHUNK; + let values = multi_stark::system::compute_lookup_values_range( + &graph, + &main, + fixed.as_ref(), + start..start + dst.len() / width, + ); + let (mut chunks, next) = + multi_stark::lookup::LookupValues::stage_2_traces( + &[values], + &[input.circuit.lookup_group_size], + beta, + &gamma, + Scalar::ZERO, + ); + dst.copy_from_slice(&chunks.remove(0).values); + next[0] + }) + .collect(); + let mut local = Scalar::ZERO; + let offsets: Vec<_> = totals + .into_iter() + .map(|value| { + let offset = local; + local += value; + offset + }) + .collect(); + trace.par_chunks_mut(CHUNK * width).zip(offsets).for_each( + |(dst, offset)| { + if offset != Scalar::ZERO { + for value in dst { + *value += offset; + } + } + }, + ); + drop(main); + drop(fixed); + let trace = + p3_matrix::dense::RowMajorMatrix::new(trace, input.circuit.stage_2_width); + let (_, data) = self.pcs.commit(vec![(domain, trace)]); + tracing::info!( + circuit = i, + seconds = start.elapsed().as_secs_f64(), + "KZG lookup committed" + ); + (data, local) + } + + /// One circuit's quotient, evaluated on the host one trace-sized coset at + /// a time with fixed-column passes. + fn host_quotient_part( + &self, + i: usize, + input: &multi_stark::config::QuotientCommitInput<'_, Self>, + alpha: Scalar, + ) -> KzgProverData { + use multi_stark::traits::Algebra; + use multi_stark::traits::EvaluationDomain; + use multi_stark::traits::TwoAdicField; + let start = std::time::Instant::now(); + let n = input.trace_domain.size(); + let ratio = input.quotient_domain.size() / n; + let generator = Scalar::two_adic_generator(input.quotient_domain.log_size); + let mut shift = input.quotient_domain.shift; + let mut values = vec![Scalar::ZERO; input.quotient_domain.size()]; + let passes = super::quotient_plan::plan( + &input.circuit.graph, + input.circuit.lookup_group_size.max(1), + ); + tracing::info!(circuit = i, fixed_columns = ?passes.iter().map(|p| p.fixed.len()).collect::>(), "KZG quotient fixed-column passes"); + for coset in 0..ratio { + let domain = super::domain::Radix2Coset { + log_size: input.trace_domain.log_size, + shift, + }; + let main = self.pcs.get_evaluations_on_domain( + input.stage_1.0, + input.stage_1.1, + domain, + ); + let stage2 = self.pcs.get_evaluations_on_domain( + input.stage_2.0, + input.stage_2.1, + domain, + ); + for pass in &passes { + let fixed = input.preprocessed.filter(|_| !pass.fixed.is_empty()).map( + |(data, slot)| { + self.pcs.evaluate_columns(data, slot, domain, &pass.fixed) + }, + ); + super::quotient::evaluate_coset( + input, + pass, + domain, + fixed.as_ref(), + &main, + &stage2, + alpha, + coset, + ratio, + &mut values, + ); + } + shift *= generator; + tracing::info!( + circuit = i, + coset, + seconds = start.elapsed().as_secs_f64(), + "KZG quotient coset evaluated" + ); + } + let (_, data) = self.pcs.commit_quotient(vec![( + input.quotient_domain, + p3_matrix::dense::RowMajorMatrix::new_col(values), + ratio, + )]); + tracing::info!( + circuit = i, + seconds = start.elapsed().as_secs_f64(), + "KZG quotient committed" + ); + data + } } impl ProofConfig for KzgConfig { @@ -100,91 +307,21 @@ impl ProofConfig for KzgConfig { if !self.stream_lookups { return None; } - let mut parts = Vec::new(); - let mut accumulators = Vec::new(); - for (i, input) in inputs.iter().enumerate() { - let start = std::time::Instant::now(); - let domain = input.stage_1.0.matrices[input.stage_1.1].domain; - let (selected, graph) = - super::quotient_plan::lookup_graph(&input.circuit.graph); - tracing::info!( - circuit = i, - fixed_columns = selected.len(), - "KZG lookup fixed columns" - ); - let fixed = input.preprocessed.filter(|_| !selected.is_empty()).map( - |(data, slot)| self.pcs.evaluate_columns(data, slot, domain, &selected), - ); - let main = self.pcs.get_evaluations_on_domain( - input.stage_1.0, - input.stage_1.1, - domain, - ); - use multi_stark::traits::Algebra; - use multi_stark::traits::EvaluationDomain; - const CHUNK: usize = 1 << 12; - let width = input.circuit.stage_2_width; - let mut trace = vec![Scalar::ZERO; domain.size() * width]; - // Every chunk starts at zero. Its total determines the prefix - // offset applied in a second bounded, parallel pass. - let totals: Vec<_> = trace - .par_chunks_mut(CHUNK * width) - .enumerate() - .map(|(i, dst)| { - let start = i * CHUNK; - let values = multi_stark::system::compute_lookup_values_range( - &graph, - &main, - fixed.as_ref(), - start..start + dst.len() / width, - ); - let (mut chunks, next) = - multi_stark::lookup::LookupValues::stage_2_traces( - &[values], - &[input.circuit.lookup_group_size], - beta, - &gamma, - Scalar::ZERO, - ); - dst.copy_from_slice(&chunks.remove(0).values); - next[0] - }) - .collect(); - let mut local = Scalar::ZERO; - let offsets: Vec<_> = totals - .into_iter() - .map(|value| { - let offset = local; - local += value; - offset - }) - .collect(); - trace.par_chunks_mut(CHUNK * width).zip(offsets).for_each( - |(dst, offset)| { - if offset != Scalar::ZERO { - for value in dst { - *value += offset; - } - } - }, - ); - drop(main); - drop(fixed); + let results: Vec<(KzgProverData, Scalar)> = inputs + .iter() + .enumerate() + .map(|(i, input)| self.host_lookup_part(i, input, beta, gamma)) + .collect(); + let mut parts = Vec::with_capacity(results.len()); + let mut accumulators = Vec::with_capacity(results.len()); + // Local traces start at zero; only the ordered public totals depend + // on the preceding circuit's accumulator. + for (data, local) in results { + parts.push(data); acc += local; accumulators.push(acc); - let trace = p3_matrix::dense::RowMajorMatrix::new( - trace, - input.circuit.stage_2_width, - ); - let (_, data) = self.pcs.commit(vec![(domain, trace)]); - parts.push(data); - tracing::info!( - circuit = i, - seconds = start.elapsed().as_secs_f64(), - "KZG lookup committed" - ); } - let (commitment, data) = super::pcs::KzgProverData::concatenate(parts); + let (commitment, data) = KzgProverData::concatenate(parts); Some((commitment, data, accumulators)) } @@ -192,83 +329,18 @@ impl ProofConfig for KzgConfig { &self, inputs: &[multi_stark::config::QuotientCommitInput<'_, Self>], alpha: Scalar, - ) -> Option<(super::pcs::KzgCommitment, super::pcs::KzgProverData)> { + ) -> Option<(KzgCommitment, KzgProverData)> { if !self.stream_quotient { return None; } - use multi_stark::traits::Algebra; - use multi_stark::traits::EvaluationDomain; - use multi_stark::traits::TwoAdicField; - let mut parts = Vec::with_capacity(inputs.len()); - for (i, input) in inputs.iter().enumerate() { - let start = std::time::Instant::now(); - let n = input.trace_domain.size(); - let ratio = input.quotient_domain.size() / n; - let generator = - Scalar::two_adic_generator(input.quotient_domain.log_size); - let mut shift = input.quotient_domain.shift; - let mut values = vec![Scalar::ZERO; input.quotient_domain.size()]; - let passes = super::quotient_plan::plan( - &input.circuit.graph, - input.circuit.lookup_group_size.max(1), - ); - tracing::info!(circuit = i, fixed_columns = ?passes.iter().map(|p| p.fixed.len()).collect::>(), "KZG quotient fixed-column passes"); - for coset in 0..ratio { - let domain = super::domain::Radix2Coset { - log_size: input.trace_domain.log_size, - shift, - }; - let main = self.pcs.get_evaluations_on_domain( - input.stage_1.0, - input.stage_1.1, - domain, - ); - let stage2 = self.pcs.get_evaluations_on_domain( - input.stage_2.0, - input.stage_2.1, - domain, - ); - for pass in &passes { - let fixed = input - .preprocessed - .filter(|_| !pass.fixed.is_empty()) - .map(|(data, slot)| { - self.pcs.evaluate_columns(data, slot, domain, &pass.fixed) - }); - super::quotient::evaluate_coset( - input, - pass, - domain, - fixed.as_ref(), - &main, - &stage2, - alpha, - coset, - ratio, - &mut values, - ); - } - shift *= generator; - tracing::info!( - circuit = i, - coset, - seconds = start.elapsed().as_secs_f64(), - "KZG quotient coset evaluated" - ); - } - let (_, data) = self.pcs.commit_quotient(vec![( - input.quotient_domain, - p3_matrix::dense::RowMajorMatrix::new_col(values), - ratio, - )]); - parts.push(data); - tracing::info!( - circuit = i, - seconds = start.elapsed().as_secs_f64(), - "KZG quotient committed" - ); + { + Some(KzgProverData::concatenate( + inputs + .iter() + .enumerate() + .map(|(i, input)| self.host_quotient_part(i, input, alpha)), + )) } - Some(super::pcs::KzgProverData::concatenate(parts)) } fn omit_inactive_preprocessed_openings(&self) -> bool { @@ -280,14 +352,14 @@ impl ProofConfig for KzgConfig { type Pcs = KzgPcs; type Challenge = Scalar; - type Challenger = Blake3Transcript; + type Challenger = KzgTranscript; fn pcs(&self) -> &KzgPcs { &self.pcs } - fn initialise_challenger(&self) -> Blake3Transcript { - let mut challenger = Blake3Transcript::new(); + fn initialise_challenger(&self) -> KzgTranscript { + let mut challenger = KzgTranscript::new(self.transcript_kind); challenger.observe_bytes(&self.transcript_seed); challenger } @@ -309,13 +381,11 @@ impl ProofConfig for KzgConfig { 0 } } - #[cfg(test)] mod tests { use super::*; use multi_stark::expr::Expr; use multi_stark::lookup::Lookup; - use multi_stark::prover::Proof; use multi_stark::system::CircuitInputs; use multi_stark::system::System; use multi_stark::system::SystemWitness; @@ -365,6 +435,33 @@ mod tests { .expect("KZG proof failed to verify"); } + #[test] + fn proof_binds_public_identity_degree_and_trace_cap() { + let srs = Arc::new(Srs::unsafe_dev_setup(16, b"public-parameters")); + let params = super::super::VerifierParams::from_srs(&srs); + let (mut system, key) = + System::new(KzgConfig::new(srs.clone(), 8), [mul_circuit()]); + let proof = system.prove_multiple_claims(&key, &[], witness(&system)); + let anchors = Srs::unsafe_dev_setup_prefix(2, 15, b"public-parameters"); + system.config = KzgConfig::verifier_only( + super::super::VerifierParams::with_max_trace_len(&anchors, 16), + 8, + ); + system.verify_multiple_claims(&[], &proof).unwrap(); + let mut identity = params.clone(); + identity.public_setup.id[0] ^= 1; + let mut degree = params; + degree.public_setup.max_degree = 31; + for changed in [ + identity, + degree, + super::super::VerifierParams::with_max_trace_len(&srs, 8), + ] { + system.config = KzgConfig::verifier_only(changed, 8); + assert!(system.verify_multiple_claims(&[], &proof).is_err()); + } + } + #[test] fn opens_next_rows_only_when_the_graph_reads_them() { let f = Scalar::from_u8; @@ -524,12 +621,15 @@ mod tests { } #[test] - fn kzg_serialization_round_trip() { + fn kzg_compact_round_trip() { let (system, key) = kzg_system(); let no_claims: &[&[Scalar]] = &[]; let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); - let bytes = proof.to_bytes().expect("serialize"); - let proof2 = Proof::::from_bytes(&bytes).expect("deserialize"); + let codec = + super::super::compact::FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap(); + let bytes = codec.encode(&proof).expect("serialize"); + let proof2 = codec.decode(&bytes).expect("deserialize"); system.verify_multiple_claims(no_claims, &proof2).unwrap(); } @@ -556,7 +656,11 @@ mod tests { ); let no_claims: &[&[Scalar]] = &[]; let proof = system.prove_multiple_claims(&key, no_claims, witness); - let bytes = proof.to_bytes().expect("serialize"); + let bytes = + super::super::compact::FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .expect("serialize"); println!( "KZG proof: {} bytes (two circuits, heights 4 and 16)", bytes.len() diff --git a/crates/proof-compression/src/kzg/encoding.rs b/crates/proof-compression/src/kzg/encoding.rs new file mode 100644 index 000000000..75884eeb0 --- /dev/null +++ b/crates/proof-compression/src/kzg/encoding.rs @@ -0,0 +1,128 @@ +//! Bounded parallel conversion of canonical scalar-field streams. + +use std::io::{self, Read, Write}; + +use ark_bls12_381::Fr; +use ark_ff::{BigInt, PrimeField}; +use p3_maybe_rayon::prelude::*; + +use super::Scalar; + +const BLOCK_FIELDS: usize = 1 << 20; +const FIELD_BYTES: usize = 32; + +pub(crate) fn write_fields( + writer: &mut impl Write, + values: &[T], + field: impl Fn(&T) -> Fr + Sync, +) -> io::Result<()> { + let mut bytes = vec![0; values.len().min(BLOCK_FIELDS) * FIELD_BYTES]; + for values in values.chunks(BLOCK_FIELDS) { + let bytes = &mut bytes[..values.len() * FIELD_BYTES]; + bytes + .par_chunks_mut(FIELD_BYTES * 2048) + .zip(values.par_chunks(2048)) + .for_each(|(bytes, values)| { + for (bytes, value) in + bytes.as_chunks_mut::().0.iter_mut().zip(values) + { + for (out, limb) in bytes + .as_chunks_mut::<8>() + .0 + .iter_mut() + .zip(field(value).into_bigint().0) + { + *out = limb.to_le_bytes(); + } + } + }); + writer.write_all(bytes)?; + } + Ok(()) +} + +pub(crate) fn read_fields( + reader: &mut impl Read, + count: usize, + convert: impl Fn(Fr) -> T + Sync + Send, +) -> io::Result> { + let mut values: Vec = Vec::new(); + values.try_reserve_exact(count).map_err(io::Error::other)?; + let mut bytes = vec![0; count.min(BLOCK_FIELDS) * FIELD_BYTES]; + for start in (0..count).step_by(BLOCK_FIELDS) { + let block = (count - start).min(BLOCK_FIELDS); + let bytes = &mut bytes[..block * FIELD_BYTES]; + reader.read_exact(bytes)?; + values.spare_capacity_mut()[start..start + block] + .par_chunks_mut(2048) + .zip(bytes.par_chunks(FIELD_BYTES * 2048)) + .try_for_each(|(output, bytes)| { + for (slot, bytes) in + output.iter_mut().zip(bytes.as_chunks::().0) + { + let limbs: [u64; 4] = std::array::from_fn(|i| { + u64::from_le_bytes(bytes.as_chunks::<8>().0[i]) + }); + let value = Fr::from_bigint(BigInt(limbs)).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "noncanonical scalar field element", + ) + })?; + slot.write(convert(value)); + } + Ok::<(), io::Error>(()) + })?; + } + // All reads and parallel decoders completed successfully. On any error the + // vector still has length zero, and partially initialized Copy values need + // no destructors. + unsafe { values.set_len(count) }; + Ok(values) +} + +/// Write canonical 32-byte little-endian scalars without a length prefix. +pub fn write_scalars( + writer: &mut impl Write, + values: &[Scalar], +) -> io::Result<()> { + write_fields(writer, values, |value| value.0) +} + +/// Read exactly `count` canonical scalars, rejecting values at least the modulus. +pub fn read_scalars( + reader: &mut impl Read, + count: usize, +) -> io::Result> { + read_fields(reader, count, Scalar) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ff::Field; + use ark_serialize::CanonicalSerialize; + + #[test] + fn field_stream_matches_arkworks_across_blocks() { + let values: Vec<_> = + (0..BLOCK_FIELDS + 17).map(|i| Fr::from(i as u64).square()).collect(); + let mut expected = Vec::new(); + for value in &values { + value.serialize_compressed(&mut expected).unwrap(); + } + let mut actual = Vec::new(); + write_fields(&mut actual, &values, |value| *value).unwrap(); + assert_eq!(actual, expected); + let decoded = + read_fields(&mut actual.as_slice(), values.len(), |value| value).unwrap(); + assert_eq!(decoded, values); + } + + #[test] + fn scalar_stream_rejects_invalid_and_truncated_fields() { + assert!(read_scalars(&mut [255; 32].as_slice(), 1).is_err()); + assert!(read_scalars(&mut [0; 31].as_slice(), 1).is_err()); + assert!(read_scalars(&mut [].as_slice(), 0).unwrap().is_empty()); + } +} diff --git a/crates/proof-compression/src/kzg/mod.rs b/crates/proof-compression/src/kzg/mod.rs index af1ad2992..7d5d600ab 100644 --- a/crates/proof-compression/src/kzg/mod.rs +++ b/crates/proof-compression/src/kzg/mod.rs @@ -1,24 +1,27 @@ -//! BLS12-381 KZG backend for multi-stark, with a BLAKE3 transcript. +//! BLS12-381 KZG backend with BLAKE3 and Keccak-256 transcripts. //! Commitments contain one G1 point per column; openings are batched by point. +mod buffer; mod coefficients; pub mod compact; pub mod config; pub mod domain; +pub mod encoding; mod fft_batch; pub mod field; pub mod pcs; mod quotient; mod quotient_plan; pub mod srs; +mod srs_cache; pub mod transcript; pub use config::KzgConfig; pub use domain::Radix2Coset; pub use field::Scalar; pub use pcs::{KzgCommitment, KzgPcs, KzgProof}; -pub use srs::Srs; -pub use transcript::Blake3Transcript; +pub use srs::{PublicSetup, Srs}; +pub use transcript::{Blake3Transcript, KzgTranscript, TranscriptKind}; pub mod verifier_params; pub use verifier_params::VerifierParams; diff --git a/crates/proof-compression/src/kzg/pcs.rs b/crates/proof-compression/src/kzg/pcs.rs index 503d721d2..818d86038 100644 --- a/crates/proof-compression/src/kzg/pcs.rs +++ b/crates/proof-compression/src/kzg/pcs.rs @@ -1,13 +1,17 @@ //! Monomial-basis KZG over BLS12-381 implementing [`Pcs`]. //! -//! Each column has a G1 commitment; shorter polynomials also carry a shifted -//! commitment for degree checks. Openings at each point are folded with a +//! Each column has one G1 commitment under the declared full public degree. +//! Domain sizes describe trace geometry; the public setup supplies the global +//! polynomial degree allowance used by the enclosing constraint argument. +//! Openings at each point are folded with a //! transcript challenge, producing one witness commitment per distinct point. //! A second challenge batches their pairing equations. //! //! Quotients use coefficient slices: `Q(X) = Σₖ X^{k·n}·cₖ(X)`. //! Coset FFTs evaluate stored coefficients on the quotient domain; -//! [`Pcs::max_quotient_degree`] bounds this evaluation budget. +//! [`Pcs::max_quotient_degree`] bounds this evaluation budget. Requested +//! evaluation domains must be at least as large as the committed trace; +//! smaller-domain coefficient folding is unsupported. use std::sync::Arc; @@ -34,46 +38,18 @@ use super::coefficients::Column; use super::domain::Radix2Coset; use super::field::Scalar; use super::srs::Srs; -use super::transcript::Blake3Transcript; +use super::transcript::KzgTranscript; -/// Per-matrix column commitments and optional shifted degree commitments. +/// Per-matrix column commitments. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct KzgCommitment(pub Vec>, pub Vec>); +pub struct KzgCommitment(pub Vec>); /// One opening proof: one witness point per distinct opening point, in /// transcript (first-appearance) order. #[derive(Clone, Debug, PartialEq, Eq)] pub struct KzgProof(pub Vec); -// Bound Arkworks' length prefixes against the remaining byte slice before -// allocating. Bincode's outer byte budget cannot bound nested Arkworks Vecs. -fn read_count( - input: &mut &[u8], - minimum_bytes: usize, -) -> Result { - let count = usize::try_from(u64::deserialize_compressed(&mut *input)?) - .map_err(|_overflow| ark_serialize::SerializationError::InvalidData)?; - if count > input.len() / minimum_bytes { - return Err(ark_serialize::SerializationError::InvalidData); - } - Ok(count) -} - -fn read_points( - input: &mut &[u8], -) -> Result, ark_serialize::SerializationError> { - let count = read_count(input, 48)?; - (0..count).map(|_| G1Affine::deserialize_compressed(&mut *input)).collect() -} - -fn read_point_rows( - input: &mut &[u8], -) -> Result>, ark_serialize::SerializationError> { - let count = read_count(input, 8)?; - (0..count).map(|_| read_points(input)).collect() -} - -impl Serialize for KzgProof { +impl Serialize for KzgCommitment { fn serialize(&self, serializer: S) -> Result { let mut bytes = Vec::new(); self @@ -84,47 +60,62 @@ impl Serialize for KzgProof { } } -impl<'de> Deserialize<'de> for KzgProof { +impl<'de> Deserialize<'de> for KzgCommitment { fn deserialize>( deserializer: D, ) -> Result { let bytes = Vec::::deserialize(deserializer)?; - let mut input = bytes.as_slice(); - let points = read_points(&mut input).map_err(serde::de::Error::custom)?; - if !input.is_empty() { - return Err(serde::de::Error::custom("trailing proof bytes")); + decode_commitment(&bytes).map_err(serde::de::Error::custom) + } +} + +fn decode_commitment( + mut input: &[u8], +) -> Result { + use ark_serialize::SerializationError; + fn count( + input: &mut &[u8], + element_bytes: usize, + ) -> Result { + let count = usize::try_from(u64::deserialize_compressed(&mut *input)?) + .map_err(|_error| SerializationError::InvalidData)?; + if count > input.len() / element_bytes { + return Err(SerializationError::InvalidData); } - Ok(Self(points)) + Ok(count) } + let rows = count(&mut input, 8)?; + let mut matrices = Vec::with_capacity(rows); + for _ in 0..rows { + let width = count(&mut input, 48)?; + let mut points = Vec::with_capacity(width); + for _ in 0..width { + points.push(G1Affine::deserialize_compressed(&mut input)?); + } + matrices.push(points); + } + if !input.is_empty() { + return Err(SerializationError::InvalidData); + } + Ok(KzgCommitment(matrices)) } -impl Serialize for KzgCommitment { - fn serialize(&self, serializer: S) -> Result { - let mut bytes = Vec::new(); - self - .0 - .serialize_compressed(&mut bytes) - .map_err(serde::ser::Error::custom)?; - self - .1 - .serialize_compressed(&mut bytes) - .map_err(serde::ser::Error::custom)?; - bytes.serialize(serializer) +// The shared PCS interface requires serde, but KZG proof transport requires +// the authenticated shape supplied to FixedProofCodec. +impl Serialize for KzgProof { + fn serialize( + &self, + _serializer: S, + ) -> Result { + Err(serde::ser::Error::custom("KZG proofs require FixedProofCodec")) } } -impl<'de> Deserialize<'de> for KzgCommitment { + +impl<'de> Deserialize<'de> for KzgProof { fn deserialize>( - deserializer: D, + _deserializer: D, ) -> Result { - let bytes = Vec::::deserialize(deserializer)?; - let mut input = bytes.as_slice(); - let main = read_point_rows(&mut input).map_err(serde::de::Error::custom)?; - let shifted = - read_point_rows(&mut input).map_err(serde::de::Error::custom)?; - if !input.is_empty() { - return Err(serde::de::Error::custom("trailing commitment bytes")); - } - Ok(Self(main, shifted)) + Err(serde::de::Error::custom("KZG proofs require FixedProofCodec")) } } @@ -133,6 +124,39 @@ impl<'de> Deserialize<'de> for KzgCommitment { pub struct CommittedMatrix { pub(crate) domain: Radix2Coset, columns: Vec, + /// Compact constant columns and resident columns whose coefficients above + /// the constant term are zero. Disk-backed columns are never flagged; the + /// flag is an optimization. + constants: Vec, +} + +impl CommittedMatrix { + /// Evaluation domain represented by the stored coefficient columns. + pub fn domain(&self) -> Radix2Coset { + self.domain + } + + /// Number of polynomial columns. + pub fn width(&self) -> usize { + self.columns.len() + } + + fn new(domain: Radix2Coset, columns: Vec) -> Self { + let constants = columns + .par_iter() + .map(|column| { + matches!(column, Column::Constant { .. }) + || column + .resident() + .is_some_and(|values| values.iter().skip(1).all(Zero::is_zero)) + }) + .collect(); + Self { domain, columns, constants } + } + + fn constant_columns(&self) -> &[bool] { + &self.constants + } } /// Prover-side retained data for one commitment round. @@ -147,8 +171,8 @@ impl KzgProverData { &self, mut out: impl std::io::Write, ) -> Result<(), ark_serialize::SerializationError> { + out.write_all(b"IXKZGCP4")?; self.commitment.0.serialize_compressed(&mut out)?; - self.commitment.1.serialize_compressed(&mut out)?; (self.matrices.len() as u64).serialize_compressed(&mut out)?; for matrix in &self.matrices { (matrix.domain.log_size as u64).serialize_compressed(&mut out)?; @@ -157,20 +181,13 @@ impl KzgProverData { for column in &matrix.columns { (column.len() as u64).serialize_compressed(&mut out)?; let mut result = Ok(()); - let mut bytes = vec![0u8; column.len().min(1 << 16) * 32]; column.visit(|_, values| { if result.is_err() { return; } - result = values.chunks(1 << 16).try_for_each(|chunk| { - let bytes = &mut bytes[..chunk.len() * 32]; - bytes - .par_chunks_mut(32) - .zip(chunk.par_iter()) - .try_for_each(|(dst, value)| value.serialize_compressed(dst))?; - out.write_all(bytes)?; - Ok::<_, ark_serialize::SerializationError>(()) - }); + result = + super::encoding::write_fields(&mut out, values, |value| *value) + .map_err(ark_serialize::SerializationError::from); })?; result?; } @@ -182,13 +199,15 @@ impl KzgProverData { pub fn read_checkpoint( mut input: impl std::io::Read, ) -> Result { - let commitment = KzgCommitment( - Vec::deserialize_compressed(&mut input)?, - Vec::deserialize_compressed(&mut input)?, - ); + let mut magic = [0; 8]; + input.read_exact(&mut magic)?; + if &magic != b"IXKZGCP4" { + return Err(ark_serialize::SerializationError::InvalidData); + } + let commitment = KzgCommitment(Vec::deserialize_compressed(&mut input)?); let count = usize::try_from(u64::deserialize_compressed(&mut input)?) .map_err(|_error| ark_serialize::SerializationError::InvalidData)?; - if count != commitment.0.len() || count != commitment.1.len() { + if count != commitment.0.len() { return Err(ark_serialize::SerializationError::InvalidData); } let mut matrices = Vec::with_capacity(count); @@ -199,16 +218,26 @@ impl KzgProverData { return Err(ark_serialize::SerializationError::InvalidData); } let shift = Scalar(Fr::deserialize_compressed(&mut input)?); - let columns: Vec> = Vec::deserialize_compressed(&mut input)?; - if columns.len() != commitment.0[i].len() - || columns.iter().any(|c| c.len() != 1usize << log_size) - { + let width = usize::try_from(u64::deserialize_compressed(&mut input)?) + .map_err(|_error| ark_serialize::SerializationError::InvalidData)?; + if width != commitment.0[i].len() { return Err(ark_serialize::SerializationError::InvalidData); } - matrices.push(CommittedMatrix { - domain: Radix2Coset { log_size, shift }, - columns: columns.into_iter().map(Column::from).collect(), - }); + let mut columns = Vec::with_capacity(width); + for _ in 0..width { + let len = u64::deserialize_compressed(&mut input)?; + if len != 1u64 << log_size { + return Err(ark_serialize::SerializationError::InvalidData); + } + columns.push( + super::encoding::read_fields(&mut input, 1usize << log_size, |v| v) + .map_err(ark_serialize::SerializationError::from)?, + ); + } + matrices.push(CommittedMatrix::new( + Radix2Coset { log_size, shift }, + columns.into_iter().map(Column::from).collect(), + )); } let mut tail = [0]; if input.read(&mut tail)? != 0 { @@ -232,13 +261,15 @@ impl KzgProverData { let mut guard = file.lock().map_err(|_error| SerializationError::InvalidData)?; let mut reader = BufReader::with_capacity(1 << 20, &mut *guard); - let commitment = KzgCommitment( - Vec::deserialize_compressed(&mut reader)?, - Vec::deserialize_compressed(&mut reader)?, - ); + let mut magic = [0; 8]; + reader.read_exact(&mut magic)?; + if &magic != b"IXKZGCP4" { + return Err(SerializationError::InvalidData); + } + let commitment = KzgCommitment(Vec::deserialize_compressed(&mut reader)?); let count = usize::try_from(u64::deserialize_compressed(&mut reader)?) .map_err(|_error| SerializationError::InvalidData)?; - if count != commitment.0.len() || count != commitment.1.len() { + if count != commitment.0.len() { return Err(SerializationError::InvalidData); } let mut matrices = Vec::with_capacity(count); @@ -266,10 +297,8 @@ impl KzgProverData { 1usize << log_size, )?); } - matrices.push(CommittedMatrix { - domain: Radix2Coset { log_size, shift }, - columns, - }); + matrices + .push(CommittedMatrix::new(Radix2Coset { log_size, shift }, columns)); } let mut tail = [0]; if reader.read(&mut tail)? != 0 { @@ -282,11 +311,10 @@ impl KzgProverData { pub fn concatenate( parts: impl IntoIterator, ) -> (KzgCommitment, Self) { - let mut commitment = KzgCommitment(vec![], vec![]); + let mut commitment = KzgCommitment(vec![]); let mut matrices = Vec::new(); for mut part in parts { commitment.0.append(&mut part.commitment.0); - commitment.1.append(&mut part.commitment.1); matrices.append(&mut part.matrices); } (commitment.clone(), Self { commitment, matrices }) @@ -299,7 +327,8 @@ pub enum KzgError { ShapeMismatch, /// The batched pairing equation does not hold. PairingCheckFailed, - DegreeBoundFailed, + /// A commitment or witness point is off the curve or outside the subgroup. + InvalidPoint, } /// See the module docs. @@ -311,6 +340,16 @@ pub struct KzgPcs { } impl KzgPcs { + /// The public anchors every proof of this configuration is bound to. + pub fn verifier_params(&self) -> &super::VerifierParams { + &self.verifier + } + + /// The public setup these parameters commit to. + pub fn public_setup(&self) -> super::PublicSetup { + self.verifier.public_setup() + } + pub(super) fn evaluate_columns( &self, data: &KzgProverData, @@ -319,6 +358,12 @@ impl KzgPcs { selected: &[usize], ) -> RowMajorMatrix { let matrix = &data.matrices[idx]; + assert!( + domain.size() >= matrix.domain.size(), + "evaluation domain ({}) is smaller than the committed trace ({})", + domain.size(), + matrix.domain.size() + ); assert!( domain.size() <= matrix.domain.size() * self.max_quotient_degree, "requested domain ({}) exceeds the coset-FFT budget ({}x trace); \ @@ -370,7 +415,17 @@ impl KzgPcs { } pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { - let verifier = super::VerifierParams::from_srs(&srs); + let max_trace_len = srs.max_len(); + Self::with_max_trace_len(srs, max_trace_len, max_quotient_degree) + } + + pub fn with_max_trace_len( + srs: Arc, + max_trace_len: usize, + max_quotient_degree: usize, + ) -> Self { + let verifier = + super::VerifierParams::with_max_trace_len(&srs, max_trace_len); Self { srs: Some(srs), verifier, max_quotient_degree } } @@ -396,36 +451,44 @@ impl KzgPcs { } } - fn commit_columns(&self, columns: &[Column]) -> Vec { + fn commit_matrix_columns(&self, matrix: &CommittedMatrix) -> Vec { let commits: Vec = - columns.iter().map(|c| self.commit_column(c, 0)).collect(); + matrix.columns.iter().map(|c| self.commit_column(c)).collect(); G1Projective::normalize_batch(&commits) } - fn commit_column(&self, column: &Column, shift: usize) -> G1Projective { - assert!(column.len() <= self.verifier.max_len().saturating_sub(shift)); + /// Commit one column. A constant column is a multiple of the first power, + /// so its coefficients are never materialized. + fn commit_column(&self, column: &Column) -> G1Projective { match column { - Column::Constant { value, .. } => self.prover_srs().g1[shift] * value, - _ => self.msm_at(&column.load().expect("coefficient checkpoint"), shift), + Column::Constant { value, len } => { + assert!( + *len <= self.verifier.max_trace_len(), + "polynomial length {len} exceeds the trace cap ({})", + self.verifier.max_trace_len() + ); + self.prover_srs().g1[0] * value + }, + _ => self.msm(&column.load().expect("coefficient checkpoint")), } } fn msm(&self, coeffs: &[Fr]) -> G1Projective { assert!( - coeffs.len() <= self.verifier.max_len(), - "polynomial length {} exceeds the SRS ({})", + coeffs.len() <= self.verifier.max_trace_len(), + "polynomial length {} exceeds the trace cap ({})", coeffs.len(), - self.verifier.max_len() + self.verifier.max_trace_len() ); if coeffs.is_empty() { return G1Projective::zero(); } - self.msm_at(coeffs, 0) - } - - fn msm_at(&self, coeffs: &[Fr], shift: usize) -> G1Projective { + assert!( + coeffs.len() <= self.prover_srs().max_len(), + "proving requires the complete coefficient prefix" + ); if coeffs.iter().skip(1).all(Zero::is_zero) { - return self.prover_srs().g1[shift] + return self.prover_srs().g1[0] * coeffs.first().copied().unwrap_or(Fr::ZERO); } // Bound digit/bucket scratch and expose point-range parallelism in @@ -438,7 +501,7 @@ impl KzgPcs { .par_chunks(CHUNK) .enumerate() .map(|(i, scalars)| { - let start = shift + batch * CHUNK * concurrency + i * CHUNK; + let start = batch * CHUNK * concurrency + i * CHUNK; G1Projective::msm( &self.prover_srs().g1[start..start + scalars.len()], scalars, @@ -455,31 +518,60 @@ impl KzgPcs { fn interpolate_columns( domain: Radix2Coset, matrix: RowMajorMatrix, - ) -> Vec { + ) -> CommittedMatrix { assert_eq!(matrix.height(), domain.size(), "matrix height != domain size"); let width = matrix.width(); - let evals = if width == 1 { + let evals: Vec> = if width == 1 { // The consuming map reuses the transparent scalar vector's allocation. vec![matrix.values.into_iter().map(|v| v.0).collect()] } else { - let mut evals: Vec> = - vec![Vec::with_capacity(matrix.height()); width]; - for (i, value) in matrix.values.into_iter().enumerate() { - evals[i % width].push(value.0); - } - evals + (0..width) + .into_par_iter() + .map(|column| { + super::buffer::generate(matrix.height(), |row| { + matrix.values[row * width + column].0 + }) + }) + .collect() }; + let mut constants = vec![false; width]; + let mut output: Vec> = (0..width).map(|_| None).collect(); + let mut active = Vec::new(); + let mut inputs = Vec::new(); + for (i, col) in evals.into_iter().enumerate() { + let first = col[0]; + if col.iter().all(|&v| v == first) { + constants[i] = true; + output[i] = Some(Column::Constant { value: first, len: col.len() }); + } else { + active.push(i); + inputs.push(col); + } + } + let interpolated = Self::interpolate_on_host(domain, inputs); + for (i, column) in active.into_iter().zip(interpolated) { + output[i] = Some(Column::from(column)); + } + CommittedMatrix { + domain, + columns: output + .into_iter() + .map(|column| column.expect("every column is interpolated")) + .collect(), + constants, + } + } + + fn interpolate_on_host( + domain: Radix2Coset, + columns: Vec>, + ) -> Vec> { let ark = Self::ark_domain(domain); - evals - .into_iter() + columns + .into_par_iter() .map(|mut col| { - let first = col[0]; - if col.iter().all(|&v| v == first) { - Column::Constant { value: first, len: col.len() } - } else { - ark.ifft_in_place(&mut col); - col.into() - } + ark.ifft_in_place(&mut col); + col }) .collect() } @@ -489,7 +581,7 @@ impl Pcs for KzgPcs { type F = Scalar; type Challenge = Scalar; type Domain = Radix2Coset; - type Challenger = Blake3Transcript; + type Challenger = KzgTranscript; type Commitment = KzgCommitment; type ProverData = KzgProverData; type Proof = KzgProof; @@ -513,25 +605,10 @@ impl Pcs for KzgPcs { ) -> (KzgCommitment, KzgProverData) { let matrices: Vec = evaluations .into_iter() - .map(|(domain, matrix)| CommittedMatrix { - domain, - columns: Self::interpolate_columns(domain, matrix), - }) + .map(|(domain, matrix)| Self::interpolate_columns(domain, matrix)) .collect(); let commitment = KzgCommitment( - matrices.iter().map(|m| self.commit_columns(&m.columns)).collect(), - matrices - .iter() - .map(|m| { - let shift = self.verifier.max_len() - m.domain.size(); - if shift == 0 { - return vec![]; - } - let points: Vec<_> = - m.columns.iter().map(|c| self.commit_column(c, shift)).collect(); - G1Projective::normalize_batch(&points) - }) - .collect(), + matrices.iter().map(|m| self.commit_matrix_columns(m)).collect(), ); (commitment.clone(), KzgProverData { matrices, commitment }) } @@ -546,8 +623,8 @@ impl Pcs for KzgPcs { let big = quotient_domain.size(); debug_assert_eq!(big % quotient_degree, 0); let n = big / quotient_degree; - let coefficient_columns: Vec = - Self::interpolate_columns(quotient_domain, evaluations); + let coefficient_columns = + Self::interpolate_columns(quotient_domain, evaluations).columns; // Slice `Q(X) = Σₖ X^{k·n}·cₖ(X)`: slice k of coordinate d // is coefficient range [k·n, (k+1)·n), laid out as column // `k·D + d` — the order the verifier's ζ-recombination @@ -557,29 +634,17 @@ impl Pcs for KzgPcs { coefficient_columns.iter().map(move |c| c.slice(k * n..(k + 1) * n)) }) .collect(); - CommittedMatrix { - domain: Radix2Coset { + CommittedMatrix::new( + Radix2Coset { log_size: p3_util::log2_strict_usize(n), shift: multi_stark::traits::Algebra::::ONE, }, columns, - } + ) }) .collect(); let commitment = KzgCommitment( - matrices.iter().map(|m| self.commit_columns(&m.columns)).collect(), - matrices - .iter() - .map(|m| { - let shift = self.verifier.max_len() - m.domain.size(); - if shift == 0 { - return vec![]; - } - let points: Vec<_> = - m.columns.iter().map(|c| self.commit_column(c, shift)).collect(); - G1Projective::normalize_batch(&points) - }) - .collect(), + matrices.iter().map(|m| self.commit_matrix_columns(m)).collect(), ); (commitment.clone(), KzgProverData { matrices, commitment }) } @@ -601,7 +666,7 @@ impl Pcs for KzgPcs { fn open( &self, rounds: OpeningRounds<'_, KzgProverData, Scalar>, - challenger: &mut Blake3Transcript, + challenger: &mut KzgTranscript, ) -> (OpenedValues, KzgProof) { for (data, _) in &rounds { challenger.observe_commitment(data.commitment.clone()); @@ -609,7 +674,6 @@ impl Pcs for KzgPcs { challenger.observe_canonical(&(m.domain.log_size as u64)); } } - let _degree_challenge = challenger.sample_challenge(); // Pass 1: evaluate everything, observing values in traversal // order, and batch (polynomial, value) pairs per distinct point. struct PointBatch<'a> { @@ -633,20 +697,29 @@ impl Pcs for KzgPcs { } let mut matrix_values = vec![vec![Scalar(Fr::ZERO); matrix.columns.len()]; points.len()]; - // Decode a disk column once for all requested points. if !points.is_empty() { + let zs: Vec<_> = points.iter().map(|z| z.0).collect(); + let constants = matrix.constant_columns(); for (column_index, column) in matrix.columns.iter().enumerate() { - if let Column::Constant { value, .. } = column { + if constants[column_index] { + let value = match column { + Column::Constant { value, .. } => *value, + _ => column + .resident() + .and_then(|values| values.first().copied()) + .unwrap_or(Fr::ZERO), + }; for row in &mut matrix_values { - row[column_index] = Scalar(*value); + row[column_index] = Scalar(value); } continue; } + // Decode a disk column once for all requested points. column .visit(|start, chunk| { - for (&z, row) in points.iter().zip(&mut matrix_values) { + for (&z, row) in zs.iter().zip(&mut matrix_values) { row[column_index].0 += - eval_poly(chunk, z.0) * z.0.pow([start as u64]); + eval_poly(chunk, z) * z.pow([start as u64]); } }) .expect("coefficient checkpoint"); @@ -738,7 +811,7 @@ impl Pcs for KzgPcs { } for (batch, values) in group.iter().zip(combined) { // Subtracting claimed values only changes the remainder. - witnesses.push(self.msm(÷_by_linear(values, batch.z))); + witnesses.push(self.witness(values, batch.z)); } } let witnesses = G1Projective::normalize_batch(&witnesses); @@ -756,58 +829,33 @@ impl Pcs for KzgPcs { &self, rounds: VerifyRounds, proof: &KzgProof, - challenger: &mut Blake3Transcript, + challenger: &mut KzgTranscript, ) -> Result<(), KzgError> { + // Serde-decoded points were validated; in-memory callers may not be. + let points: Vec<_> = rounds + .iter() + .flat_map(|(commitment, _)| commitment.0.iter().flatten()) + .chain(&proof.0) + .collect(); + if points.par_iter().any(|point| { + !point.is_on_curve() || !point.is_in_correct_subgroup_assuming_on_curve() + }) { + return Err(KzgError::InvalidPoint); + } for (commitment, matrices) in &rounds { challenger.observe_commitment(commitment.clone()); for (domain, _) in matrices { challenger.observe_canonical(&(domain.log_size as u64)); } } - let degree_challenge = challenger.sample_challenge().0; - let mut weight = Fr::ONE; - let mut shifted_sum = G1Projective::zero(); - let mut by_degree = - vec![G1Projective::zero(); self.verifier.degree_keys.len()]; for (commitment, matrices) in &rounds { if commitment.0.len() != matrices.len() - || commitment.1.len() != matrices.len() + || matrices.iter().any(|(domain, _)| { + domain.log_size > self.verifier.max_trace_len().ilog2() as usize + }) { return Err(KzgError::ShapeMismatch); } - for ((columns, shifted), (domain, _)) in - commitment.0.iter().zip(&commitment.1).zip(matrices) - { - let log = domain.log_size; - if log >= by_degree.len() { - return Err(KzgError::ShapeMismatch); - } - if domain.size() == self.verifier.max_len() { - if !shifted.is_empty() { - return Err(KzgError::ShapeMismatch); - } - } else { - if shifted.len() != columns.len() { - return Err(KzgError::ShapeMismatch); - } - for (&c, &s) in columns.iter().zip(shifted) { - by_degree[log] += c * weight; - shifted_sum += s * weight; - weight *= degree_challenge; - } - } - } - } - let mut g1 = vec![shifted_sum.into_affine()]; - let mut g2 = vec![self.verifier.g2]; - for (sum, key) in by_degree.into_iter().zip(&self.verifier.degree_keys) { - if !sum.is_zero() { - g1.push((-sum).into_affine()); - g2.push(*key); - } - } - if !Bls12_381::multi_pairing(g1, g2).is_zero() { - return Err(KzgError::DegreeBoundFailed); } // Mirror `open`'s traversal exactly: observe claimed values and // batch (commitment, value) pairs per distinct point. @@ -888,6 +936,14 @@ impl Pcs for KzgPcs { } } +impl KzgPcs { + /// The witness commitment `[(p(X) − p(z)) / (X − z)]·G` of one folded + /// opening polynomial. + fn witness(&self, combined: Vec, z: Fr) -> G1Projective { + self.msm(÷_by_linear(combined, z)) + } +} + const POLYNOMIAL_BLOCK: usize = 1 << 12; fn eval_poly_serial(coeffs: &[Fr], z: Fr) -> Fr { @@ -952,31 +1008,76 @@ fn divide_by_linear(mut p: Vec, z: Fr) -> Vec { } #[cfg(test)] -mod degree_tests { +mod tests { use super::*; #[test] - fn serialized_point_counts_are_bounded_before_allocation() { + fn commitment_metadata_rejects_impossible_counts() { + assert!(decode_commitment(&[255; 8]).is_err()); + let mut nested = 1u64.to_le_bytes().to_vec(); + nested.extend([255; 8]); + assert!(decode_commitment(&nested).is_err()); + let commitment = KzgCommitment(vec![vec![G1Affine::default()], vec![]]); + let mut bytes = Vec::new(); + commitment.0.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(decode_commitment(&bytes).unwrap(), commitment); + for end in 0..bytes.len() { + assert!(decode_commitment(&bytes[..end]).is_err()); + } + bytes.push(0); + assert!(decode_commitment(&bytes).is_err()); + } + + #[test] + fn opening_proofs_have_no_generic_serde_encoding() { let config = bincode::config::standard(); - for bytes in [ - u64::MAX.to_le_bytes().to_vec(), - [1u64.to_le_bytes(), u64::MAX.to_le_bytes()].concat(), - ] { - let encoded = bincode::serde::encode_to_vec(bytes, config).unwrap(); - assert!( - bincode::serde::decode_from_slice::(&encoded, config) - .is_err() - ); - assert!( - bincode::serde::decode_from_slice::(&encoded, config) - .is_err() - ); + assert!(bincode::serde::encode_to_vec(KzgProof(vec![]), config).is_err()); + assert!( + bincode::serde::decode_from_slice::(&[255; 8], config) + .is_err() + ); + } + + #[test] + fn evaluations_reject_domains_smaller_than_the_committed_trace() { + let pcs = KzgPcs::new( + Arc::new(Srs::unsafe_dev_setup(4, b"evaluation-domain-bound")), + 2, + ); + let committed = pcs.natural_domain_for_degree(4); + let smaller = pcs.natural_domain_for_degree(2); + for width in [1, 2] { + let evaluations = |domain: Radix2Coset| { + RowMajorMatrix::new( + domain + .points() + .into_iter() + .flat_map(|x| { + (0..width).map(move |column| x * x + Scalar::from_usize(column)) + }) + .collect(), + width, + ) + }; + let (_, data) = pcs.commit(vec![(committed, evaluations(committed))]); + for domain in + [committed, Radix2Coset { log_size: 3, shift: Scalar::from_u8(7) }] + { + assert_eq!( + pcs.get_evaluations_on_domain(&data, 0, domain), + evaluations(domain) + ); + } + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + pcs.get_evaluations_on_domain(&data, 0, smaller) + })); + assert!(result.is_err(), "width {width} accepted a shrinking domain"); } } #[test] fn constant_coefficients_match_dense_commitments_evaluations_and_openings() { - use multi_stark::traits::Algebra; let pcs = KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(32, b"constant-columns")), 2); let domain = pcs.natural_domain_for_degree(16); @@ -995,6 +1096,7 @@ mod degree_tests { .iter() .all(|c| matches!(c, Column::Constant { .. })) ); + assert_eq!(compact.matrices[0].constant_columns(), [true; 3]); let columns: Vec = compact.matrices[0] .columns .iter() @@ -1003,15 +1105,14 @@ mod degree_tests { range: 0..c.len(), }) .collect(); - assert_eq!(pcs.commit_columns(&columns), commitment.0[0]); - let shifted: Vec<_> = - columns.iter().map(|c| pcs.commit_column(c, 16)).collect(); - assert_eq!(G1Projective::normalize_batch(&shifted), commitment.1[0]); let dense = KzgProverData { - commitment, - matrices: vec![CommittedMatrix { domain, columns }], + commitment: commitment.clone(), + matrices: vec![CommittedMatrix::new(domain, columns)], }; - for evaluation_domain in [domain, domain.create_disjoint_domain(32)] { + assert_eq!(pcs.commit_matrix_columns(&dense.matrices[0]), commitment.0[0]); + for evaluation_domain in + [domain, Radix2Coset { log_size: 5, shift: Scalar::from_u8(7) }] + { assert_eq!( pcs.get_evaluations_on_domain(&compact, 0, evaluation_domain), pcs.get_evaluations_on_domain(&dense, 0, evaluation_domain) @@ -1019,8 +1120,8 @@ mod degree_tests { } let points = vec![vec![Scalar::from_u8(13), Scalar::from_u8(19)]]; assert_eq!( - pcs.open(vec![(&compact, points.clone())], &mut Blake3Transcript::new()), - pcs.open(vec![(&dense, points)], &mut Blake3Transcript::new()) + pcs.open(vec![(&compact, points.clone())], &mut KzgTranscript::default()), + pcs.open(vec![(&dense, points)], &mut KzgTranscript::default()) ); } @@ -1066,10 +1167,10 @@ mod degree_tests { Scalar::from_u8(23), Scalar::from_u8(29), ]]; - let expected = - pcs.open(vec![(&resident, points.clone())], &mut Blake3Transcript::new()); + let expected = pcs + .open(vec![(&resident, points.clone())], &mut KzgTranscript::default()); let actual = - pcs.open(vec![(&disk, points.clone())], &mut Blake3Transcript::new()); + pcs.open(vec![(&disk, points.clone())], &mut KzgTranscript::default()); pcs .verify( vec![( @@ -1080,7 +1181,7 @@ mod degree_tests { )], )], &actual.1, - &mut Blake3Transcript::new(), + &mut KzgTranscript::default(), ) .unwrap(); assert_eq!(actual, expected); @@ -1123,7 +1224,7 @@ mod degree_tests { use multi_stark::traits::Field; #[test] - fn chunked_commitment_matches_native_msm_with_shift_and_tail() { + fn chunked_commitment_matches_native_msm_with_tail() { let pcs = KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(1 << 18, b"chunked-msm")), 4); let mut value = Fr::from(17u64); @@ -1133,20 +1234,39 @@ mod degree_tests { value }) .collect(); - for shift in [0, 7] { - let expected = G1Projective::msm( - &pcs.prover_srs().g1[shift..shift + coefficients.len()], - &coefficients, - ) - .unwrap(); - assert_eq!(pcs.msm_at(&coefficients, shift), expected); - } + let expected = G1Projective::msm( + &pcs.prover_srs().g1[..coefficients.len()], + &coefficients, + ) + .unwrap(); + assert_eq!(pcs.msm(&coefficients), expected); } #[test] - fn mixed_degree_openings_enforce_every_bound() { + fn constant_columns_are_detected_on_commit_and_checkpoint_load() { let pcs = - KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(16, b"degree-test")), 4); + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(16, b"constant-columns")), 4); + let domain = pcs.natural_domain_for_degree(16); + let matrix = RowMajorMatrix::new( + (0..16) + .flat_map(|row| [Scalar::from_u8(7), Scalar::from_usize(row)]) + .collect(), + 2, + ); + let (_, data) = pcs.commit(vec![(domain, matrix)]); + assert_eq!(data.matrices[0].constant_columns(), [true, false]); + let mut bytes = Vec::new(); + data.write_checkpoint(&mut bytes).unwrap(); + let loaded = KzgProverData::read_checkpoint(bytes.as_slice()).unwrap(); + assert_eq!(loaded.matrices[0].constant_columns(), [true, false]); + } + + #[test] + fn mixed_height_openings_verify_without_proving_powers() { + let pcs = KzgPcs::new( + Arc::new(Srs::unsafe_dev_setup(16, b"public-degree-test")), + 4, + ); let domains = [pcs.natural_domain_for_degree(2), pcs.natural_domain_for_degree(16)]; let (commitment, data) = pcs.commit( @@ -1157,60 +1277,39 @@ mod degree_tests { }) .collect(), ); + assert_eq!(commitment.0.iter().map(Vec::len).collect::>(), [1, 1]); let z = Scalar::from_u8(19); let (values, proof) = pcs.open( vec![(&data, vec![vec![z], vec![z]])], - &mut Blake3Transcript::new(), + &mut KzgTranscript::default(), ); let rounds = vec![( - commitment.clone(), + commitment, domains .into_iter() - .zip(values[0].iter()) + .zip(&values[0]) .map(|(d, v)| (d, vec![(z, v[0].clone())])) .collect(), )]; - pcs.verify(rounds.clone(), &proof, &mut Blake3Transcript::new()).unwrap(); let verifier = KzgPcs::verifier_only(pcs.verifier.clone(), 4); assert!(verifier.srs.is_none()); verifier - .verify(rounds.clone(), &proof, &mut Blake3Transcript::new()) + .verify(rounds.clone(), &proof, &mut KzgTranscript::default()) .unwrap(); - let mut missing = rounds.clone(); - missing[0].0.1[0].clear(); + let mut corrupt = rounds.clone(); + corrupt[0].1[0].1[0].1[0] += Scalar::ONE; assert!( - verifier.verify(missing, &proof, &mut Blake3Transcript::new()).is_err() - ); - let mut corrupt = rounds; - corrupt[0].0.1[0][0] = pcs.prover_srs().g1[0]; - assert!(matches!( - verifier.verify(corrupt, &proof, &mut Blake3Transcript::new()), - Err(KzgError::DegreeBoundFailed) - )); - - // A valid KZG opening for a polynomial too large for its declared - // two-row domain must be rejected, even though it fits the global SRS. - let columns = vec![Column::from(vec![Fr::ZERO, Fr::ZERO, Fr::ONE])]; - let commitment = KzgCommitment( - pcs.commit_columns(&columns).into_iter().map(|c| vec![c]).collect(), - vec![vec![pcs.prover_srs().g1[0]]], + verifier.verify(corrupt, &proof, &mut KzgTranscript::default()).is_err() ); - let data = KzgProverData { - commitment: commitment.clone(), - matrices: vec![CommittedMatrix { domain: domains[0], columns }], - }; - let (values, proof) = - pcs.open(vec![(&data, vec![vec![z]])], &mut Blake3Transcript::new()); + let mut oversized = rounds; + oversized[0].1[0].0.log_size = 5; assert!(matches!( - verifier.verify( - vec![( - commitment, - vec![(domains[0], vec![(z, values[0][0][0].clone())])] - )], - &proof, - &mut Blake3Transcript::new() - ), - Err(KzgError::DegreeBoundFailed) + verifier.verify(oversized, &proof, &mut KzgTranscript::default()), + Err(KzgError::ShapeMismatch) )); } } + +#[cfg(test)] +#[path = "pcs/public_degree_tests.rs"] +mod public_degree_tests; diff --git a/crates/proof-compression/src/kzg/pcs/public_degree_tests.rs b/crates/proof-compression/src/kzg/pcs/public_degree_tests.rs new file mode 100644 index 000000000..04d6c3fa8 --- /dev/null +++ b/crates/proof-compression/src/kzg/pcs/public_degree_tests.rs @@ -0,0 +1,100 @@ +use super::KzgPcs; +use crate::kzg::{KzgConfig, Scalar, Srs}; +use multi_stark::{ + config::ProofConfig, + expr::Expr, + prover::{Commitments, Proof, observe_claims, sample_lookup_challenges}, + system::{CircuitInputs, System}, + traits::{Algebra, EvaluationDomain, Field, Pcs, Transcript, TwoAdicField}, + verifier::VerificationError, +}; +use p3_matrix::dense::RowMajorMatrix; +use std::sync::Arc; + +fn high_degree_air_proof( + constant: Scalar, +) -> (System, Proof) { + let srs = Arc::new(Srs::unsafe_dev_setup(8, b"high-degree-air")); + let pcs = KzgPcs::new(srs.clone(), 1); + let (system, _) = System::new( + KzgConfig::with_max_trace_len(srs, 2, 1), + [CircuitInputs { + main_width: 1, + constraints: vec![Expr::main(0)], + ..Default::default() + }], + ); + assert_eq!(system.circuits[0].constraint_count(), 2); + let trace_domain = pcs.natural_domain_for_degree(2); + let polynomial_domain = pcs.natural_domain_for_degree(4); + let (main, mut main_data) = pcs.commit(vec![( + polynomial_domain, + RowMajorMatrix::new_col( + (0..4) + .map(|i| Scalar::two_adic_generator(2).exp_u64(i)) + .map(|x| x * x + constant) + .collect(), + ), + )]); + // A malicious committer can use public powers beyond the trace height. + main_data.matrices[0].domain = trace_domain; + let (stage2, stage2_data) = pcs.commit(vec![( + trace_domain, + RowMajorMatrix::new_col(vec![Scalar::ZERO; 2]), + )]); + + let mut transcript = system.config.initialise_challenger(); + system.observe_shape(&mut transcript); + transcript.observe_field(Scalar::ONE); + transcript.observe_commitment(main.clone()); + transcript.observe_field(Scalar::ONE); + observe_claims::(&mut transcript, &[]); + sample_lookup_challenges::(&mut transcript); + transcript.observe_commitment(stage2.clone()); + transcript.observe_challenge(Scalar::ZERO); + let alpha = transcript.sample_challenge(); + let (quotient, quotient_data) = + pcs.commit(vec![(trace_domain, RowMajorMatrix::new_col(vec![alpha; 2]))]); + transcript.observe_commitment(quotient.clone()); + let zeta = transcript.sample_challenge(); + let (opened, opening_proof) = pcs.open( + vec![ + (&main_data, vec![vec![zeta]]), + (&stage2_data, vec![vec![zeta, trace_domain.next_point(zeta)]]), + ("ient_data, vec![vec![zeta]]), + ], + &mut transcript, + ); + let mut opened = opened.into_iter(); + let proof = Proof { + active: vec![true], + commitments: Commitments { + stage_1_trace: main, + stage_2_trace: stage2, + quotient_chunks: quotient, + }, + intermediate_accumulators: vec![Scalar::ZERO], + log_degrees: vec![1], + opening_proof, + stage_1_opened_values: opened.next().unwrap(), + stage_2_opened_values: opened.next().unwrap(), + quotient_opened_values: opened.next().unwrap(), + preprocessed_opened_values: None, + }; + (system, proof) +} + +#[test] +fn public_degree_air_accepts_a_high_degree_domain_vanishing_polynomial() { + let (system, proof) = high_degree_air_proof(Scalar::NEG_ONE); + system.verify_multiple_claims(&[], &proof).unwrap(); +} + +#[test] +fn public_degree_air_rejects_invalid_rows_with_valid_high_degree_openings() { + let (system, proof) = high_degree_air_proof(Scalar::ZERO); + assert!(matches!( + system.verify_multiple_claims(&[], &proof), + Err(VerificationError::OodEvaluationMismatch) + )); +} diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs index f4778fff2..fef34990c 100644 --- a/crates/proof-compression/src/kzg/srs.rs +++ b/crates/proof-compression/src/kzg/srs.rs @@ -1,8 +1,7 @@ -//! KZG parameters: G1 powers, G2 anchors and G2 powers for degree checks. +//! KZG public parameters: a G1 prefix, G2 anchors and authenticated degree metadata. //! Imported parameters need validated point decoding, [`Srs::validate`] and //! trusted provenance. Consistency does not establish an unknown trapdoor. -//! The full available G1 degree range and every required G2 degree key must -//! be represented; truncating public parameters does not reduce that range. +//! The declared public degree is independent of the locally loaded prefix. //! [`Srs::unsafe_dev_setup`] reveals its trapdoor and is only for tests. use ark_bls12_381::{ @@ -12,107 +11,93 @@ use ark_ec::{ AffineRepr, CurveGroup, PrimeGroup, VariableBaseMSM, pairing::Pairing, }; use ark_ff::{Field, PrimeField, Zero}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use ark_serialize::CanonicalSerialize; use p3_maybe_rayon::prelude::*; -use std::io::{self, Read, Write}; +use std::io::{self, Write}; + +pub mod filecoin; + +/// Identity and maximum polynomial degree of the complete public parameter set. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PublicSetup { + pub max_degree: usize, + pub id: [u8; 32], +} + +impl PublicSetup { + pub fn known_trapdoor(max_degree: usize, seed: &[u8]) -> Self { + assert!(max_degree >= 1 && max_degree.checked_add(1).is_some()); + let mut hash = blake3::Hasher::new(); + hash.update(b"multi-stark/kzg/known-trapdoor-public-degree/v1"); + hash.update(&(max_degree as u64).to_le_bytes()); + hash.update(blake3::hash(seed).as_bytes()); + Self { max_degree, id: *hash.finalize().as_bytes() } + } +} + +/// The development trapdoor of `seed`. +pub(super) fn dev_tau(seed: &[u8]) -> Fr { + let mut wide = [0u8; 64]; + blake3::Hasher::new() + .update(b"multi-stark/kzg/dev-srs") + .update(seed) + .finalize_xof() + .fill(&mut wide); + Fr::from_le_bytes_mod_order(&wide) +} /// `[G, τG, τ²G, …]` in G1 and `[H, τH]` in G2. pub struct Srs { pub g1: Vec, pub g2: G2Affine, pub tau_g2: G2Affine, - /// Entry k is τ^(max_len - 2^k) H, for degree-bound checks. - pub degree_keys: Vec, + pub public_setup: PublicSetup, } impl Srs { - /// Read IXKZGS01: magic, little-endian u32 log-length, G1 powers, - /// G2, tau-G2, and log-length + 1 degree keys (compressed Ark points). - /// Validates point encodings, subgroups and the complete powers relation. - /// The caller must independently authenticate the source of these parameters. - pub fn from_reader(mut reader: impl Read) -> io::Result { - let invalid = |e| io::Error::new(io::ErrorKind::InvalidData, e); - let mut magic = [0; 8]; - reader.read_exact(&mut magic)?; - if &magic != b"IXKZGS01" { - return Err(invalid("unsupported KZG SRS format")); - } - let mut log = [0; 4]; - reader.read_exact(&mut log)?; - let log = u32::from_le_bytes(log); - // BLS12-381's scalar field has two-adicity 32. Leave room for the - // quotient domain and reject oversized headers before allocating. - if !(1..=30).contains(&log) { - return Err(invalid("unsupported KZG SRS log-length")); - } - let decode_error = |e| io::Error::new(io::ErrorKind::InvalidData, e); - // Grow only as points are read, so a truncated header cannot force a - // large allocation. Deserialization performs subgroup checks. - let mut g1 = Vec::new(); - const CHUNK: usize = 1 << 16; - let len = 1usize << log; - let mut bytes = vec![0; 48 * CHUNK.min(len)]; - while g1.len() < len { - let count = CHUNK.min(len - g1.len()); - reader.read_exact(&mut bytes[..48 * count])?; - let points = bytes[..48 * count] - .par_chunks_exact(48) - .map(G1Affine::deserialize_compressed) - .collect::, _>>() - .map_err(decode_error)?; - g1.extend(points); - } - let g2 = - G2Affine::deserialize_compressed(&mut reader).map_err(decode_error)?; - let tau_g2 = - G2Affine::deserialize_compressed(&mut reader).map_err(decode_error)?; - let mut degree_keys = Vec::new(); - for _ in 0..=log { - degree_keys.push( - G2Affine::deserialize_compressed(&mut reader).map_err(decode_error)?, - ); - } - if reader.read(&mut [0])? != 0 { - return Err(invalid("trailing KZG SRS data")); - } - let srs = Self { g1, g2, tau_g2, degree_keys }; - srs.validate().map_err(invalid)?; - Ok(srs) + /// Reuse or populate a shared cache directory holding one file per + /// prefix length and seed, so stages of different heights share one + /// trusted-local location. + pub fn unsafe_dev_setup_prefix_in_cache_directory( + max_len: usize, + public_max_degree: usize, + seed: &[u8], + directory: impl AsRef, + ) -> std::io::Result { + super::srs_cache::cached_in_directory( + max_len, + public_max_degree, + seed, + directory.as_ref(), + ) } - /// Export parameters in the format consumed by [`Self::from_reader`]. - pub fn write(&self, mut writer: impl Write) -> io::Result<()> { - if self.max_len() < 2 - || !self.max_len().is_power_of_two() - || self.max_len().ilog2() > 30 - || self.degree_keys.len() != self.max_len().ilog2() as usize + 1 - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "invalid SRS dimensions", - )); - } - writer.write_all(b"IXKZGS01")?; - writer.write_all(&self.max_len().ilog2().to_le_bytes())?; - let encode_error = |e| io::Error::new(io::ErrorKind::InvalidData, e); - for point in &self.g1 { - point.serialize_compressed(&mut writer).map_err(encode_error)?; + /// Authenticate setup metadata independently before importing public powers. + pub fn from_public_powers( + g1: Vec, + g2: G2Affine, + tau_g2: G2Affine, + public_setup: PublicSetup, + ) -> Result { + if g1.par_iter().any(|p| { + !p.is_on_curve() || !p.is_in_correct_subgroup_assuming_on_curve() + }) || [&g2, &tau_g2].into_iter().any(|p| { + !p.is_on_curve() || !p.is_in_correct_subgroup_assuming_on_curve() + }) { + return Err("invalid public parameter point"); } - self.g2.serialize_compressed(&mut writer).map_err(encode_error)?; - self.tau_g2.serialize_compressed(&mut writer).map_err(encode_error)?; - for point in &self.degree_keys { - point.serialize_compressed(&mut writer).map_err(encode_error)?; - } - Ok(()) + let srs = Self { g1, g2, tau_g2, public_setup }; + srs.validate()?; + Ok(srs) } - /// The largest polynomial length (degree + 1) this SRS can commit. + /// Number of locally loaded powers, independent of the full public degree. #[inline] pub fn max_len(&self) -> usize { self.g1.len() } - /// Check nonzero anchors, degree keys and the powers relation + /// Check nonzero anchors, public degree metadata and the powers relation /// `e(g1[i+1], H) = e(g1[i], τH)`, batched with a challenge derived /// from the SRS bytes. Points must already have passed validated /// deserialization, including subgroup checks. This does not establish @@ -128,20 +113,10 @@ impl Srs { { return Err("SRS anchor is the identity"); } - let logs = p3_util::log2_strict_usize(self.max_len()); - if self.degree_keys.len() != logs + 1 { - return Err("missing degree keys"); - } - for (k, key) in self.degree_keys.iter().enumerate() { - let shift = self.max_len() - (1 << k); - if !Bls12_381::multi_pairing( - [self.g1[shift], (-G1Projective::from(self.g1[0])).into_affine()], - [self.g2, *key], - ) - .is_zero() - { - return Err("inconsistent degree key"); - } + if self.public_setup.max_degree < self.max_len() - 1 + || self.public_setup.max_degree.checked_add(1).is_none() + { + return Err("invalid public degree allowance"); } // Preserve the validation challenge's exact encoding without retaining a @@ -157,7 +132,9 @@ impl Srs { } } let mut writer = HashWriter(blake3::Hasher::new()); - writer.0.update(b"multi-stark/kzg/srs-validate"); + writer.0.update(b"multi-stark/kzg/srs-validate/v4"); + writer.0.update(&self.public_setup.id); + writer.0.update(&(self.public_setup.max_degree as u64).to_le_bytes()); self.g1.serialize_compressed(&mut writer).expect("infallible hash writer"); self.g2.serialize_compressed(&mut writer).expect("infallible hash writer"); self @@ -167,6 +144,9 @@ impl Srs { let mut wide = [0u8; 64]; writer.0.finalize_xof().fill(&mut wide); let r = Fr::from_le_bytes_mod_order(&wide); + if r.is_zero() { + return Err("zero parameter-validation challenge"); + } // Bound both powers and MSM digit/bucket scratch independently of SRS // length. All adjacent pairs still participate with their original weight. @@ -221,41 +201,49 @@ impl Srs { /// SRS are not binding against anyone who knows the seed. pub fn unsafe_dev_setup(max_len: usize, seed: &[u8]) -> Self { assert!(max_len >= 2 && max_len.is_power_of_two()); - let mut wide = [0u8; 64]; - blake3::Hasher::new() - .update(b"multi-stark/kzg/dev-srs") - .update(seed) - .finalize_xof() - .fill(&mut wide); - let tau = Fr::from_le_bytes_mod_order(&wide); + Self::unsafe_dev_setup_prefix(max_len, max_len - 1, seed) + } + + /// Known-trapdoor powers with an identity independent of the loaded prefix. + pub fn unsafe_dev_setup_prefix( + max_len: usize, + public_max_degree: usize, + seed: &[u8], + ) -> Self { + assert!(max_len >= 2 && max_len.is_power_of_two()); + assert!(public_max_degree >= max_len - 1); + let public_setup = PublicSetup::known_trapdoor(public_max_degree, seed); + let tau = dev_tau(seed); let g1_gen = G1Projective::generator(); // τ-powers in independent chunks (each chunk seeds itself with // τ^start), so the scalar multiplications parallelize under the - // `parallel` feature; serial otherwise. + // `parallel` feature; serial otherwise. A bounded fixed-base table + // amortizes the generator multiplication across powers without a table + // whose size grows with the SRS degree. let chunk = 1usize << 14; + let table = ark_ec::scalar_mul::BatchMulPreprocessing::new( + g1_gen, + max_len.min(1 << 12), + ); let mut g1 = vec![G1Affine::identity(); max_len]; g1.par_chunks_mut(chunk).enumerate().for_each(|(i, dst)| { let mut acc = tau.pow([(i * chunk) as u64]); - let powers: Vec<_> = (0..dst.len()) + let scalars: Vec<_> = (0..dst.len()) .map(|_| { - let point = g1_gen * acc; + let value = acc; acc *= tau; - point + value }) .collect(); - dst.copy_from_slice(&G1Projective::normalize_batch(&powers)); + dst.copy_from_slice(&table.batch_mul(&scalars)); }); let g2_gen = G2Projective::generator(); Self { g1, g2: g2_gen.into_affine(), tau_g2: (g2_gen * tau).into_affine(), - degree_keys: (0..=p3_util::log2_strict_usize(max_len)) - .map(|k| { - (g2_gen * tau.pow([(max_len - (1 << k)) as u64])).into_affine() - }) - .collect(), + public_setup, } } } @@ -264,28 +252,6 @@ impl Srs { mod tests { use super::*; - #[test] - fn srs_transport_rejects_malformed_and_inconsistent_parameters() { - let mut srs = Srs::unsafe_dev_setup(32, b"srs-transport"); - let mut bytes = Vec::new(); - srs.write(&mut bytes).unwrap(); - let decoded = Srs::from_reader(bytes.as_slice()).unwrap(); - assert_eq!(decoded.g1, srs.g1); - assert_eq!(decoded.degree_keys, srs.degree_keys); - assert!(Srs::from_reader(&bytes[..bytes.len() - 1]).is_err()); - let mut trailing = bytes.clone(); - trailing.push(0); - assert!(Srs::from_reader(trailing.as_slice()).is_err()); - bytes[8..12].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(Srs::from_reader(bytes.as_slice()).is_err()); - bytes[8..12].copy_from_slice(&30u32.to_le_bytes()); - assert!(Srs::from_reader(&bytes[..12]).is_err()); - srs.g1[7] = srs.g1[8]; - bytes.clear(); - srs.write(&mut bytes).unwrap(); - assert!(Srs::from_reader(bytes.as_slice()).is_err()); - } - #[test] fn development_powers_match_naive_at_chunk_boundaries() { let seed = b"chunk-powers"; @@ -311,23 +277,15 @@ mod tests { } #[test] - fn validation_and_transport_cover_chunk_boundaries() { + fn validation_covers_chunk_boundaries() { let mut srs = Srs::unsafe_dev_setup(1 << 17, b"validation-chunks"); - let mut bytes = Vec::new(); - srs.write(&mut bytes).unwrap(); - let decoded = Srs::from_reader(bytes.as_slice()).unwrap(); - assert_eq!(decoded.g1, srs.g1); + srs.validate().unwrap(); for index in [(1 << 16) - 1, 1 << 16, (1 << 16) + 1, (1 << 17) - 2] { let saved = srs.g1[index]; srs.g1[index] = srs.g1[index + 1]; assert!(srs.validate().is_err(), "accepted changed power {index}"); srs.g1[index] = saved; } - let mut malformed = bytes.clone(); - let point = 12 + 48 * (1 << 16); - malformed[point..point + 48].fill(0xff); - assert!(Srs::from_reader(malformed.as_slice()).is_err()); - assert!(Srs::from_reader(&bytes[..point + 47]).is_err()); } #[test] diff --git a/crates/proof-compression/src/kzg/srs/filecoin.rs b/crates/proof-compression/src/kzg/srs/filecoin.rs new file mode 100644 index 000000000..8a4c268cc --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/filecoin.rs @@ -0,0 +1,1008 @@ +//! Authenticated import of Filecoin's stock phase-one `challenge_19`. +//! +//! The source pin is the final Golem participant's published BLAKE2b-512 digest: +//! . +//! Filecoin identifies this artifact, without an additional randomness beacon, at +//! . +//! +//! Import verifies the entire source digest, and checks canonical coordinates, +//! curve membership, subgroup membership and the power progression of the retained +//! G1 prefix against the G2 anchors. It does not repeat the ceremony transcript. +//! The normalized cache has an independently pinned manifest and authenticated +//! chunks, so subsequent loads need read only the requested prefix. Its manifest +//! digest must come from the successful import through trusted configuration; +//! reading a digest supplied alongside an untrusted cache would not authenticate it. + +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use ark_bls12_381::{Bls12_381, Fr, G1Affine, G1Projective, G2Affine}; +use ark_ec::{AffineRepr, CurveGroup, VariableBaseMSM, pairing::Pairing}; +use ark_ff::{Field, PrimeField, Zero}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_maybe_rayon::prelude::*; + +use super::{PublicSetup, Srs}; + +/// Official uncompressed phase-one output, approximately 72 GiB. +pub const FILECOIN_CHALLENGE_19_URL: &str = + "https://trusted-setup.filecoin.io/phase1/challenge_19"; +/// Avail's complete copy of the same file. Any mirror is acceptable: the import +/// authenticates the bytes against the published digest. +pub const FILECOIN_CHALLENGE_19_MIRROR_URL: &str = + "https://srs.availproject.org/challenge_19"; +/// Maximum supported honest polynomial length; this is not a public degree bound. +pub const FILECOIN_MAX_TRACE_LEN: usize = 1 << 27; +/// The ceremony publishes G1 powers with exponents `0..=2^28-2`. +pub const FILECOIN_PUBLIC_MAX_DEGREE: usize = (1 << 28) - 2; +/// BLAKE2b-512 of the complete artifact, including its 64-byte transcript header. +pub const FILECOIN_CHALLENGE_19_BLAKE2B: [u8; 64] = [ + 0x5a, 0x26, 0x01, 0x5b, 0xa2, 0x7d, 0x81, 0x64, 0x15, 0x24, 0x07, 0xda, 0x8f, + 0x9b, 0x87, 0xe4, 0x75, 0x93, 0xf1, 0x7a, 0xe4, 0xc2, 0x60, 0xe4, 0x67, 0xba, + 0xc2, 0xba, 0x9d, 0xda, 0x6f, 0x66, 0xc1, 0x5f, 0xa3, 0x52, 0x48, 0x76, 0x04, + 0xd1, 0x35, 0x0e, 0xf3, 0x3a, 0x3b, 0xfe, 0xdb, 0x0d, 0x99, 0xe3, 0x7b, 0x61, + 0x91, 0x61, 0xe2, 0x75, 0x45, 0x01, 0x73, 0x66, 0x27, 0x4d, 0xf7, 0x6b, +]; + +/// Manifest receipt of the complete `2^27` prefix cache that +/// [`import_filecoin_challenge`] produces from the authenticated source. The +/// import is deterministic, so an independent import reproduces this digest. +pub const FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3: [u8; 32] = [ + 0x14, 0x42, 0x38, 0x60, 0x05, 0xc4, 0xe4, 0xa2, 0xeb, 0xc2, 0x3c, 0x10, 0x96, + 0x94, 0xeb, 0x9b, 0xb1, 0x6f, 0xe5, 0x4b, 0xc5, 0x70, 0x8f, 0x03, 0x80, 0x65, + 0x8a, 0xc9, 0x9a, 0x37, 0xf1, 0x0a, +]; +/// Manifest receipt of the checked-in 256-power prefix cache at +/// [`FILECOIN_PREFIX_2P8_FIXTURE`]. +pub const FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3: [u8; 32] = [ + 0x18, 0x5c, 0x55, 0x6e, 0x5f, 0x5f, 0x0c, 0x67, 0xdd, 0xc0, 0xc8, 0xac, 0x21, + 0x0c, 0x8b, 0x11, 0xd1, 0xa4, 0x85, 0x74, 0xde, 0xfb, 0xc4, 0x42, 0x99, 0xdc, + 0x71, 0x27, 0x47, 0xa0, 0x50, 0x7d, +]; +/// The 256-power prefix cache imported from the authenticated source, enough +/// for tests of the real cache format without the 12 GiB `2^27` cache. +pub const FILECOIN_PREFIX_2P8_FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/filecoin-prefix-2p8.bin" +); + +/// `τ·G` of the ceremony, big-endian uncompressed `x || y`, as read from the +/// authenticated source. Avail's independent extraction publishes the same +/// x-coordinate. +pub const FILECOIN_TAU_G1: [u8; 96] = [ + 0x0e, 0x3f, 0x32, 0x1e, 0x2b, 0xa2, 0x4e, 0x30, 0x25, 0x42, 0xa3, 0xec, 0xf8, + 0x84, 0x7e, 0x8e, 0x82, 0x0f, 0x9c, 0xa1, 0xb3, 0xa6, 0xa6, 0x3b, 0xae, 0x25, + 0x14, 0x0f, 0x21, 0x53, 0x44, 0x31, 0x4e, 0x1b, 0xfc, 0x9c, 0x36, 0xbf, 0x2c, + 0x3d, 0xcb, 0xe9, 0x4d, 0xec, 0x33, 0xd7, 0x1f, 0x3c, 0x16, 0x3a, 0x59, 0xba, + 0x33, 0xf7, 0x4f, 0xe5, 0x3c, 0xe0, 0x12, 0x18, 0x1a, 0xe8, 0xb9, 0xf2, 0x03, + 0x83, 0xeb, 0xd2, 0x89, 0xfb, 0x12, 0x72, 0x31, 0x49, 0x2e, 0x94, 0x9d, 0x3f, + 0xd7, 0x64, 0x0a, 0x2b, 0x49, 0xd0, 0x82, 0x24, 0xf6, 0x25, 0xec, 0x38, 0x35, + 0xa5, 0xff, 0x8f, 0xc1, 0x0d, +]; +/// `τ·H` of the ceremony, big-endian uncompressed `x || y` with each +/// coordinate as `c1 || c0`, as read from the authenticated source. Avail's +/// independent extraction publishes the same x-coordinate. +pub const FILECOIN_TAU_G2: [u8; 192] = [ + 0x11, 0x60, 0x10, 0xf1, 0xb3, 0xd3, 0x3a, 0xa5, 0xf3, 0xf6, 0x08, 0xc3, 0x85, + 0x40, 0x09, 0x79, 0xed, 0x36, 0xdc, 0x3f, 0xab, 0x53, 0xd3, 0x62, 0x6f, 0x51, + 0x9c, 0x74, 0xd7, 0xe1, 0xeb, 0xef, 0x62, 0xc9, 0xad, 0x35, 0x79, 0x64, 0x04, + 0xce, 0x2f, 0xc3, 0xeb, 0xb4, 0x21, 0x67, 0xb5, 0xa1, 0x05, 0x0a, 0xc2, 0x7c, + 0x35, 0x27, 0x07, 0x2b, 0x81, 0xaf, 0x0f, 0xa9, 0xa6, 0x83, 0xe2, 0x26, 0xda, + 0x79, 0x27, 0x5f, 0x29, 0x85, 0xc9, 0xe8, 0x9a, 0x0d, 0xb9, 0x69, 0xb7, 0x6e, + 0x0b, 0x6b, 0xe9, 0x4f, 0xee, 0x87, 0x4c, 0x48, 0x58, 0x67, 0x37, 0xad, 0x55, + 0x88, 0xcf, 0x1e, 0x10, 0x41, 0x19, 0x93, 0xb9, 0xf2, 0x9f, 0x67, 0xc4, 0x8d, + 0x2c, 0xbc, 0xb7, 0x6a, 0x6e, 0x31, 0xc3, 0x41, 0xc9, 0xda, 0x84, 0x6f, 0x9f, + 0xc9, 0x9c, 0xa4, 0x13, 0x0e, 0x2b, 0xea, 0x5f, 0x95, 0x80, 0x5b, 0x4b, 0x42, + 0xa4, 0x04, 0xa9, 0x9b, 0x9e, 0x3b, 0x08, 0x35, 0xa7, 0x25, 0xbd, 0x90, 0xae, + 0x66, 0x04, 0x4e, 0xda, 0x01, 0x71, 0x4f, 0x35, 0x7c, 0xb8, 0xc4, 0x1b, 0x01, + 0xf8, 0x9b, 0x03, 0x98, 0x13, 0x00, 0x96, 0xfb, 0x65, 0x4a, 0x9d, 0x23, 0xb0, + 0x35, 0x28, 0xba, 0x2d, 0x79, 0xa2, 0xe3, 0x76, 0x03, 0x8a, 0xa7, 0x0d, 0xbf, + 0xc9, 0x79, 0xb0, 0x81, 0x49, 0x72, 0x6e, 0xb1, 0x84, 0x1d, +]; + +const MAGIC: &[u8] = b"multi-stark/bls12-381/filecoin-srs/v1\0"; +const G1_BYTES: usize = 96; +const G2_BYTES: usize = 192; +const BLOCK_POINTS: usize = 1 << 18; +const HEADER_BYTES: usize = + MAGIC.len() + 64 + 3 * 8 + 2 * G1_BYTES + 2 * G2_BYTES; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy)] +struct ChallengeSpec { + power: u32, + digest: [u8; 64], + block_points: usize, + /// Whether the tau anchors must equal the pinned ceremony values. + pinned_anchors: bool, +} + +const FILECOIN: ChallengeSpec = ChallengeSpec { + power: 27, + digest: FILECOIN_CHALLENGE_19_BLAKE2B, + block_points: BLOCK_POINTS, + pinned_anchors: true, +}; + +impl ChallengeSpec { + fn max_len(self) -> usize { + 1usize << self.power + } + + fn max_degree(self) -> usize { + 2 * self.max_len() - 2 + } + + fn g2_offset(self) -> u64 { + 64 + (self.max_degree() as u64 + 1) * G1_BYTES as u64 + } + + fn source_bytes(self) -> u64 { + // Header, tau G1, tau G2, alpha G1, beta G1, and beta G2. + 64 + (self.max_degree() as u64 + 1) * G1_BYTES as u64 + + self.max_len() as u64 * (G2_BYTES + 2 * G1_BYTES) as u64 + + G2_BYTES as u64 + } + + fn setup_id(self) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"multi-stark/kzg/filecoin-challenge19/v1"); + hasher.update(&self.digest); + hasher.update(&(self.max_degree() as u64).to_le_bytes()); + *hasher.finalize().as_bytes() + } +} + +/// Stable ceremony identity, independent of retained prefix or cache layout. +pub fn filecoin_setup_id() -> [u8; 32] { + FILECOIN.setup_id() +} + +/// The verifier's view of the ceremony: `[G, τG]`, `H`, `τH` and the public +/// setup, built from the pinned constants without any cache file. Prover +/// prefixes loaded from a cache must agree with these anchors. +pub fn filecoin_anchors() -> Srs { + let tau_g1 = decode_g1(&FILECOIN_TAU_G1, true).expect("pinned tau G1"); + let tau_g2 = decode_g2(&FILECOIN_TAU_G2).expect("pinned tau G2"); + Srs { + g1: vec![G1Affine::generator(), tau_g1], + g2: G2Affine::generator(), + tau_g2, + public_setup: PublicSetup { + max_degree: FILECOIN.max_degree(), + id: FILECOIN.setup_id(), + }, + } +} + +/// A receipt for a successfully authenticated import. +/// +/// Pin `digest` in trusted prover/verifier configuration. It authenticates the +/// manifest, including the anchors and every G1 chunk digest, not merely a file +/// checksum declared by the cache itself. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FilecoinCacheIdentity { + pub prefix_len: usize, + pub digest: [u8; 32], +} + +/// Validate stock `challenge_19` and atomically create a normalized prefix cache. +/// +/// This is one-time provisioning: it reads all 72 GiB of the source and performs +/// subgroup and batched progression checks for `prefix_len` G1 powers. Working +/// memory is bounded by a chunk, independent of the retained prefix length. The +/// destination must not exist. A failed import never publishes a usable cache. +pub fn import_filecoin_challenge( + source: &Path, + cache: &Path, + prefix_len: usize, +) -> io::Result { + import_challenge(source, cache, prefix_len, FILECOIN) +} + +/// Load a requested prefix using an externally pinned import receipt. +/// +/// `prefix_len == 2` reads only the small authenticated manifest. Larger prefixes +/// authenticate their chunks before decoding. Unrequested chunks are not read. +/// Subgroup and progression checks are inherited from the authenticated import; +/// reloads check canonical coordinates and curve membership, plus all four +/// manifest anchors. This avoids repeating an expensive provisioning operation. +pub fn load_filecoin_cache( + cache: &Path, + expected_manifest_digest: [u8; 32], + prefix_len: usize, +) -> io::Result { + load_cache(cache, expected_manifest_digest, prefix_len, FILECOIN) +} + +fn invalid( + message: impl Into>, +) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn validate_prefix(prefix_len: usize, spec: ChallengeSpec) -> io::Result<()> { + if prefix_len < 2 + || !prefix_len.is_power_of_two() + || prefix_len > spec.max_len() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Filecoin prefix must be a power of two in 2..={}", + spec.max_len() + ), + )); + } + Ok(()) +} + +fn decode_g1(bytes: &[u8], subgroup: bool) -> io::Result { + // Both Filecoin's pairing 0.16.2 and arkworks 0.5 use big-endian x || y, + // with the standard compression/infinity/sign flags in the first byte. + let point = + G1Affine::deserialize_uncompressed_unchecked(bytes).map_err(invalid)?; + if point.is_zero() + || !point.is_on_curve() + || (subgroup && !point.is_in_correct_subgroup_assuming_on_curve()) + { + return Err(invalid("invalid Filecoin G1 point")); + } + Ok(point) +} + +fn decode_g2(bytes: &[u8]) -> io::Result { + // The component order is x.c1 || x.c0 || y.c1 || y.c0, not Fq2 serde order. + let point = + G2Affine::deserialize_uncompressed_unchecked(bytes).map_err(invalid)?; + if point.is_zero() + || !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err(invalid("invalid Filecoin G2 point")); + } + Ok(point) +} + +struct Anchors { + g1: [G1Affine; 2], + g2: [G2Affine; 2], +} + +impl Anchors { + fn read(source: &mut File, spec: ChallengeSpec) -> io::Result { + let mut g1 = [0; 2 * G1_BYTES]; + source.seek(SeekFrom::Start(64))?; + source.read_exact(&mut g1)?; + let mut g2 = [0; 2 * G2_BYTES]; + source.seek(SeekFrom::Start(spec.g2_offset()))?; + source.read_exact(&mut g2)?; + Self::decode(&g1, &g2, spec) + } + + fn decode(g1: &[u8], g2: &[u8], spec: ChallengeSpec) -> io::Result { + let anchors = Self { + g1: [ + decode_g1(&g1[..G1_BYTES], true)?, + decode_g1(&g1[G1_BYTES..], true)?, + ], + g2: [decode_g2(&g2[..G2_BYTES])?, decode_g2(&g2[G2_BYTES..])?], + }; + if anchors.g1[0] != G1Affine::generator() + || anchors.g2[0] != G2Affine::generator() + { + return Err(invalid("Filecoin generators do not match BLS12-381")); + } + if spec.pinned_anchors { + let pinned = filecoin_anchors(); + if anchors.g1[1] != pinned.g1[1] || anchors.g2[1] != pinned.tau_g2 { + return Err(invalid( + "Filecoin tau anchors differ from the pinned ceremony values", + )); + } + } + if !Bls12_381::multi_pairing( + [anchors.g1[1], -anchors.g1[0]], + [anchors.g2[0], anchors.g2[1]], + ) + .is_zero() + { + return Err(invalid("Filecoin G1/G2 tau anchors disagree")); + } + Ok(anchors) + } +} + +fn encode_header( + spec: ChallengeSpec, + prefix_len: usize, + anchors: &Anchors, +) -> io::Result> { + let mut header = Vec::with_capacity(HEADER_BYTES); + header.extend_from_slice(MAGIC); + header.extend_from_slice(&spec.digest); + header.extend_from_slice(&(spec.max_degree() as u64).to_le_bytes()); + header.extend_from_slice(&(prefix_len as u64).to_le_bytes()); + header.extend_from_slice(&(spec.block_points as u64).to_le_bytes()); + for point in &anchors.g1 { + point.serialize_uncompressed(&mut header).map_err(invalid)?; + } + for point in &anchors.g2 { + point.serialize_uncompressed(&mut header).map_err(invalid)?; + } + Ok(header) +} + +fn block_digest(index: usize, bytes: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"multi-stark/kzg/filecoin-cache-block/v1"); + hasher.update(&(index as u64).to_le_bytes()); + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + *hasher.finalize().as_bytes() +} + +fn manifest_digest(header: &[u8], index: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"multi-stark/kzg/filecoin-cache-manifest/v1"); + hasher.update(header); + hasher.update(index); + *hasher.finalize().as_bytes() +} + +struct ProgressionCheck { + r: Fr, + next_power: Fr, + previous: Option, + low: G1Projective, + high: G1Projective, +} + +impl ProgressionCheck { + fn new(spec: ChallengeSpec, prefix_len: usize) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"multi-stark/kzg/filecoin-prefix-progression/v1"); + hasher.update(&spec.digest); + hasher.update(&(prefix_len as u64).to_le_bytes()); + let mut reader = hasher.finalize_xof(); + let r = loop { + let mut wide = [0; 64]; + reader.fill(&mut wide); + let r = Fr::from_le_bytes_mod_order(&wide); + if !r.is_zero() { + break r; + } + }; + Self { + r, + next_power: Fr::ONE, + previous: None, + low: G1Projective::zero(), + high: G1Projective::zero(), + } + } + + fn absorb(&mut self, points: &[G1Affine]) { + if let Some(previous) = self.previous { + // This pair crosses chunk boundaries and cannot be omitted. + self.low += previous * self.next_power; + self.high += points[0] * self.next_power; + self.next_power *= self.r; + } + let scalars: Vec<_> = (0..points.len() - 1) + .map(|_| { + let scalar = self.next_power; + self.next_power *= self.r; + scalar + }) + .collect(); + self.low += G1Projective::msm(&points[..points.len() - 1], &scalars) + .expect("equal lengths"); + self.high += + G1Projective::msm(&points[1..], &scalars).expect("equal lengths"); + self.previous = points.last().copied(); + } + + fn verify(self, anchors: &Anchors) -> io::Result<()> { + if !Bls12_381::multi_pairing( + [self.high.into_affine(), (-self.low).into_affine()], + anchors.g2, + ) + .is_zero() + { + return Err(invalid("Filecoin G1 prefix is not one tau progression")); + } + Ok(()) + } +} + +struct Temporary(PathBuf); + +impl Temporary { + fn create(destination: &Path) -> io::Result<(Self, File)> { + if destination.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "Filecoin cache already exists", + )); + } + loop { + let path = destination.with_extension(format!( + "{}.{}.partial", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => return Ok((Self(path), file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, + Err(error) => return Err(error), + } + } + } + + fn publish(&self, destination: &Path) -> io::Result<()> { + // A hard link is atomic and refuses to overwrite an existing receipt's file. + fs::hard_link(&self.0, destination) + } +} + +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +fn hash_exact( + source: &mut File, + hasher: &mut blake2b_simd::State, + mut remaining: u64, + buffer: &mut [u8], +) -> io::Result<()> { + while remaining != 0 { + let count = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("read length is bounded by the buffer"); + source.read_exact(&mut buffer[..count])?; + hasher.update(&buffer[..count]); + remaining -= count as u64; + } + Ok(()) +} + +fn import_challenge( + source: &Path, + cache: &Path, + prefix_len: usize, + spec: ChallengeSpec, +) -> io::Result { + validate_prefix(prefix_len, spec)?; + let mut source = File::open(source)?; + if source.metadata()?.len() != spec.source_bytes() { + return Err(invalid("Filecoin challenge has the wrong length")); + } + let anchors = Anchors::read(&mut source, spec)?; + let header = encode_header(spec, prefix_len, &anchors)?; + let (temporary, mut output) = Temporary::create(cache)?; + output.write_all(&header)?; + let mut index = + Vec::with_capacity(prefix_len.div_ceil(spec.block_points) * 32); + let mut hasher = blake2b_simd::State::new(); + let mut previous_digest = [0; 64]; + source.seek(SeekFrom::Start(0))?; + source.read_exact(&mut previous_digest)?; + hasher.update(&previous_digest); + let mut buffer = + vec![0; (prefix_len.min(spec.block_points) * G1_BYTES).max(1 << 20)]; + let mut progression = ProgressionCheck::new(spec, prefix_len); + for (block_index, start) in + (0..prefix_len).step_by(spec.block_points).enumerate() + { + let count = (prefix_len - start).min(spec.block_points); + let bytes = &mut buffer[..count * G1_BYTES]; + source.read_exact(bytes)?; + hasher.update(bytes); + let points = bytes + .par_chunks_exact(G1_BYTES) + .map(|bytes| decode_g1(bytes, true)) + .collect::>>()?; + if start == 0 && points[..2] != anchors.g1 { + return Err(invalid("Filecoin source anchors changed during import")); + } + progression.absorb(&points); + index.extend_from_slice(&block_digest(block_index, bytes)); + output.write_all(bytes)?; + } + let remaining_g1_bytes = + (spec.max_degree() + 1 - prefix_len) as u64 * G1_BYTES as u64; + hash_exact(&mut source, &mut hasher, remaining_g1_bytes, &mut buffer)?; + let mut g2_bytes = [0; 2 * G2_BYTES]; + source.read_exact(&mut g2_bytes)?; + hasher.update(&g2_bytes); + if g2_bytes != header[HEADER_BYTES - 2 * G2_BYTES..] { + return Err(invalid("Filecoin source G2 anchors changed during import")); + } + let remaining = + spec.source_bytes() - spec.g2_offset() - g2_bytes.len() as u64; + hash_exact(&mut source, &mut hasher, remaining, &mut buffer)?; + if source.read(&mut [0])? != 0 { + return Err(invalid("trailing bytes in Filecoin challenge")); + } + if hasher.finalize().as_bytes() != spec.digest { + return Err(invalid( + "Filecoin challenge does not match the published BLAKE2b digest", + )); + } + progression.verify(&anchors)?; + let digest = manifest_digest(&header, &index); + output.write_all(&index)?; + output.write_all(&digest)?; + output.sync_all()?; + drop(output); + temporary.publish(cache)?; + Ok(FilecoinCacheIdentity { prefix_len, digest }) +} + +fn load_cache( + cache: &Path, + expected_manifest_digest: [u8; 32], + prefix_len: usize, + spec: ChallengeSpec, +) -> io::Result { + validate_prefix(prefix_len, spec)?; + let mut file = File::open(cache)?; + let mut header = vec![0; HEADER_BYTES]; + file.read_exact(&mut header)?; + if !header.starts_with(MAGIC) + || header[MAGIC.len()..MAGIC.len() + 64] != spec.digest + { + return Err(invalid("Filecoin cache format or ceremony identity mismatch")); + } + let numbers = MAGIC.len() + 64; + let number = |offset: usize| { + u64::from_le_bytes( + header[numbers + offset..numbers + offset + 8].try_into().unwrap(), + ) + }; + if number(0) != spec.max_degree() as u64 + || number(16) != spec.block_points as u64 + { + return Err(invalid("Filecoin cache degree range or block size mismatch")); + } + let stored_len = usize::try_from(number(8)).map_err(invalid)?; + validate_prefix(stored_len, spec).map_err(invalid)?; + if prefix_len > stored_len { + return Err(invalid("Filecoin cache prefix is smaller than requested")); + } + let index_bytes = stored_len.div_ceil(spec.block_points) * 32; + let index_offset = HEADER_BYTES as u64 + stored_len as u64 * G1_BYTES as u64; + if file.metadata()?.len() != index_offset + index_bytes as u64 + 32 { + return Err(invalid("Filecoin cache has the wrong length")); + } + file.seek(SeekFrom::Start(index_offset))?; + let mut index = vec![0; index_bytes]; + file.read_exact(&mut index)?; + let mut declared_digest = [0; 32]; + file.read_exact(&mut declared_digest)?; + if declared_digest != expected_manifest_digest + || manifest_digest(&header, &index) != expected_manifest_digest + { + return Err(invalid( + "Filecoin cache does not match the pinned manifest digest", + )); + } + let g1_offset = numbers + 24; + let g2_offset = g1_offset + 2 * G1_BYTES; + let anchors = + Anchors::decode(&header[g1_offset..g2_offset], &header[g2_offset..], spec)?; + let mut g1 = Vec::new(); + g1.try_reserve_exact(prefix_len).map_err(io::Error::other)?; + if prefix_len == 2 { + g1.extend_from_slice(&anchors.g1); + } else { + file.seek(SeekFrom::Start(HEADER_BYTES as u64))?; + let mut buffer = vec![0; stored_len.min(spec.block_points) * G1_BYTES]; + for block_index in 0..prefix_len.div_ceil(spec.block_points) { + let start = block_index * spec.block_points; + let count = (stored_len - start).min(spec.block_points); + let bytes = &mut buffer[..count * G1_BYTES]; + file.read_exact(bytes)?; + if block_digest(block_index, bytes) + != index[block_index * 32..(block_index + 1) * 32] + { + return Err(invalid(format!( + "Filecoin cache chunk {block_index} digest mismatch" + ))); + } + let needed = (prefix_len - start).min(count); + let points = bytes[..needed * G1_BYTES] + .par_chunks_exact(G1_BYTES) + .map(|bytes| decode_g1(bytes, false)) + .collect::>>()?; + g1.extend(points); + } + if g1[..2] != anchors.g1 { + return Err(invalid( + "Filecoin cache G1 anchors disagree with its prefix", + )); + } + } + Ok(Srs { + g1, + g2: anchors.g2[0], + tau_g2: anchors.g2[1], + public_setup: PublicSetup { + max_degree: spec.max_degree(), + id: spec.setup_id(), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_bls12_381::{Fq, Fq2, G2Projective}; + use ark_ec::PrimeGroup; + use ark_ff::BigInteger; + + struct Fixture { + directory: PathBuf, + source: PathBuf, + cache: PathBuf, + spec: ChallengeSpec, + points: Vec, + } + + impl Fixture { + fn new() -> Self { + let directory = std::env::temp_dir().join(format!( + "ix-filecoin-import-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&directory).unwrap(); + let mut spec = ChallengeSpec { + power: 3, + digest: [0; 64], + block_points: 4, + pinned_anchors: false, + }; + let source = directory.join("challenge"); + let cache = directory.join("cache"); + let tau = Fr::from(7u64); + let mut bytes = vec![42; 64]; + let points: Vec<_> = (0..=spec.max_degree()) + .map(|i| { + (G1Projective::generator() * tau.pow([i as u64])).into_affine() + }) + .collect(); + for point in &points { + point.serialize_uncompressed(&mut bytes).unwrap(); + } + for i in 0..spec.max_len() { + (G2Projective::generator() * tau.pow([i as u64])) + .into_affine() + .serialize_uncompressed(&mut bytes) + .unwrap(); + } + for scale in [Fr::from(11u64), Fr::from(13u64)] { + for point in points.iter().take(spec.max_len()) { + (*point * scale) + .into_affine() + .serialize_uncompressed(&mut bytes) + .unwrap(); + } + } + (G2Projective::generator() * Fr::from(13u64)) + .into_affine() + .serialize_uncompressed(&mut bytes) + .unwrap(); + assert_eq!(bytes.len() as u64, spec.source_bytes()); + spec.digest = *blake2b_simd::blake2b(&bytes).as_array(); + fs::write(&source, bytes).unwrap(); + Self { directory, source, cache, spec, points } + } + + fn repin(&mut self) { + self.spec.digest = + *blake2b_simd::blake2b(&fs::read(&self.source).unwrap()).as_array(); + } + + fn import(&self) -> io::Result { + import_challenge( + &self.source, + &self.cache, + self.spec.max_len(), + self.spec, + ) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.directory).unwrap(); + } + } + + fn hex(input: &str) -> [u8; N] { + assert_eq!(input.len(), N * 2); + std::array::from_fn(|i| { + u8::from_str_radix(&input[2 * i..2 * i + 2], 16).unwrap() + }) + } + + #[test] + fn checked_in_prefix_cache_matches_its_pinned_receipt() { + let path = Path::new(FILECOIN_PREFIX_2P8_FIXTURE); + let srs = + load_filecoin_cache(path, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, 256) + .unwrap(); + let anchors = filecoin_anchors(); + assert_eq!(srs.max_len(), 256); + assert_eq!(srs.g1[..2], anchors.g1[..]); + assert_eq!(srs.g2, anchors.g2); + assert_eq!(srs.tau_g2, anchors.tau_g2); + assert_eq!(srs.public_setup, anchors.public_setup); + srs.validate().unwrap(); + let two = load_filecoin_cache(path, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, 2) + .unwrap(); + assert_eq!(two.g1, anchors.g1); + assert!( + load_filecoin_cache(path, FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3, 256) + .is_err() + ); + assert!( + load_filecoin_cache(path, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, 512) + .is_err() + ); + } + + #[test] + fn pinned_tau_anchors_are_one_tau_progression() { + let anchors = filecoin_anchors(); + assert_eq!(anchors.max_len(), 2); + assert!( + Bls12_381::multi_pairing( + [anchors.g1[1], -anchors.g1[0]], + [anchors.g2, anchors.tau_g2], + ) + .is_zero() + ); + assert_eq!(anchors.public_setup.id, filecoin_setup_id()); + assert_eq!(anchors.public_setup.max_degree, FILECOIN_PUBLIC_MAX_DEGREE); + let mut altered = FILECOIN_TAU_G1; + altered[95] ^= 1; + assert!(decode_g1(&altered, true).is_err()); + } + + #[test] + fn pinned_filecoin_encoding_matches_pairing_generator_vectors() { + let g1 = hex::<96>(concat!( + "17f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb", + "08b3f481e3aaa0f1a09e30ed741d8ae4fcf5e095d5d00af600db18cb2c04b3edd03cc744a2888ae40caa232946c5e7e1", + )); + let g2 = hex::<192>(concat!( + "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e", + "024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8", + "0606c4a02ea734cc32acd2b02bc28b99cb3e287e85a763af267492ab572e99ab3f370d275cec1da1aaa9075ff05f79be", + "0ce5d527727d6e118cc9cdc6da2e351aadfd9baa8cbdd3a76d429a695160d12c923ac9cc3baca289e193548608b82801", + )); + assert_eq!(decode_g1(&g1, true).unwrap(), G1Affine::generator()); + assert_eq!(decode_g2(&g2).unwrap(), G2Affine::generator()); + let mut serialized = Vec::new(); + G1Affine::generator().serialize_uncompressed(&mut serialized).unwrap(); + assert_eq!(serialized, g1); + serialized.clear(); + G2Affine::generator().serialize_uncompressed(&mut serialized).unwrap(); + assert_eq!(serialized, g2); + let mut swapped = g2; + swapped[..48].copy_from_slice(&g2[48..96]); + swapped[48..96].copy_from_slice(&g2[..48]); + assert!(decode_g2(&swapped).is_err()); + } + + #[test] + fn source_pin_and_public_range_are_exact() { + assert_eq!( + FILECOIN_CHALLENGE_19_BLAKE2B, + hex::<64>(concat!( + "5a26015ba27d8164152407da8f9b87e47593f17ae4c260e467bac2ba9dda6f66c", + "15fa352487604d1350ef33a3bfedb0d99e37b619161e27545017366274df76b", + )) + ); + assert_eq!(FILECOIN.max_degree(), FILECOIN_PUBLIC_MAX_DEGREE); + assert_eq!(FILECOIN.source_bytes(), 77_309_411_488); + assert_eq!(FILECOIN.max_len(), FILECOIN_MAX_TRACE_LEN); + } + + #[test] + fn authenticated_prefix_and_manifest_only_loads_preserve_public_degree() { + let fixture = Fixture::new(); + let receipt = fixture.import().unwrap(); + for prefix_len in [2, 4, 8] { + let srs = + load_cache(&fixture.cache, receipt.digest, prefix_len, fixture.spec) + .unwrap(); + assert_eq!(srs.g1, fixture.points[..prefix_len]); + let setup = srs.public_setup; + assert_eq!(setup.max_degree, 14); + assert_eq!(setup.id, fixture.spec.setup_id()); + } + assert!(fixture.import().is_err()); + assert!(load_filecoin_cache(&fixture.cache, receipt.digest, 2).is_err()); + } + + #[test] + fn incorrect_source_pin_and_changed_unused_tail_never_publish_cache() { + let fixture = Fixture::new(); + let mut bytes = fs::read(&fixture.source).unwrap(); + *bytes.last_mut().unwrap() ^= 1; + fs::write(&fixture.source, bytes).unwrap(); + assert!(fixture.import().unwrap_err().to_string().contains("BLAKE2b")); + assert!(!fixture.cache.exists()); + assert_eq!(fs::read_dir(&fixture.directory).unwrap().count(), 1); + } + + #[test] + fn malformed_source_length_is_rejected_before_allocation() { + let fixture = Fixture::new(); + let original = fs::read(&fixture.source).unwrap(); + for bad in [ + original[..original.len() - 1].to_vec(), + [original.as_slice(), &[0]].concat(), + ] { + fs::write(&fixture.source, bad).unwrap(); + assert!( + fixture.import().unwrap_err().to_string().contains("wrong length") + ); + assert!(!fixture.cache.exists()); + } + for prefix in [0, 1, 3, 16, usize::MAX] { + assert!( + import_challenge(&fixture.source, &fixture.cache, prefix, fixture.spec) + .is_err() + ); + } + } + + #[test] + fn checked_decoding_rejects_flags_noncanonical_coordinates_curve_and_subgroup_errors() + { + let mut generator = Vec::new(); + G1Affine::generator().serialize_uncompressed(&mut generator).unwrap(); + for flag in [0x20, 0x40, 0x80] { + let mut bytes = generator.clone(); + bytes[0] |= flag; + assert!(decode_g1(&bytes, true).is_err()); + } + let mut noncanonical = generator; + noncanonical[..48].copy_from_slice(&Fq::MODULUS.to_bytes_be()); + assert!(decode_g1(&noncanonical, true).is_err()); + let mut identity = [0; G1_BYTES]; + identity[0] = 0x40; + assert!(decode_g1(&identity, true).is_err()); + let mut invalid_curve = [0; G1_BYTES]; + invalid_curve[47] = 1; + invalid_curve[95] = 1; + assert!(decode_g1(&invalid_curve, true).is_err()); + let torsion = G1Affine::new_unchecked(Fq::zero(), Fq::from(2u64)); + assert!(torsion.is_on_curve()); + assert!(!torsion.is_in_correct_subgroup_assuming_on_curve()); + let mut bytes = Vec::new(); + torsion.serialize_uncompressed(&mut bytes).unwrap(); + assert!(decode_g1(&bytes, true).is_err()); + assert_eq!(decode_g1(&bytes, false).unwrap(), torsion); + } + + #[test] + fn source_interior_subgroup_failure_never_publishes_cache() { + let mut fixture = Fixture::new(); + let mut bytes = fs::read(&fixture.source).unwrap(); + let mut encoded = Vec::new(); + G1Affine::new_unchecked(Fq::zero(), Fq::from(2u64)) + .serialize_uncompressed(&mut encoded) + .unwrap(); + bytes[64 + 3 * G1_BYTES..64 + 4 * G1_BYTES].copy_from_slice(&encoded); + fs::write(&fixture.source, bytes).unwrap(); + fixture.repin(); + assert!(fixture.import().unwrap_err().to_string().contains("G1 point")); + assert!(!fixture.cache.exists()); + } + + #[test] + fn g2_decoding_rejects_identity_flags_noncanonical_and_wrong_subgroup() { + let mut bytes = Vec::new(); + G2Affine::generator().serialize_uncompressed(&mut bytes).unwrap(); + for flag in [0x20, 0x40, 0x80] { + let mut invalid = bytes.clone(); + invalid[0] |= flag; + assert!(decode_g2(&invalid).is_err()); + } + bytes[..48].copy_from_slice(&Fq::MODULUS.to_bytes_be()); + assert!(decode_g2(&bytes).is_err()); + let mut identity = [0; G2_BYTES]; + identity[0] = 0x40; + assert!(decode_g2(&identity).is_err()); + let outside_subgroup = (0u64..) + .filter_map(|x| G2Affine::get_point_from_x_unchecked(Fq2::from(x), false)) + .find(|point| !point.is_in_correct_subgroup_assuming_on_curve()) + .unwrap(); + assert!(outside_subgroup.is_on_curve()); + bytes.clear(); + outside_subgroup.serialize_uncompressed(&mut bytes).unwrap(); + assert!(decode_g2(&bytes).is_err()); + } + + #[test] + fn progression_includes_chunk_boundaries() { + let mut fixture = Fixture::new(); + let mut bytes = fs::read(&fixture.source).unwrap(); + for i in 4..8 { + let mut encoded = Vec::new(); + (fixture.points[i] * Fr::from(2u64)) + .into_affine() + .serialize_uncompressed(&mut encoded) + .unwrap(); + bytes[64 + i * G1_BYTES..64 + (i + 1) * G1_BYTES] + .copy_from_slice(&encoded); + } + fs::write(&fixture.source, bytes).unwrap(); + fixture.repin(); + assert!(fixture.import().unwrap_err().to_string().contains("progression")); + assert!(!fixture.cache.exists()); + } + + #[test] + fn cache_rejects_wrong_receipt_header_index_and_requested_chunk() { + let fixture = Fixture::new(); + let receipt = fixture.import().unwrap(); + assert!(load_cache(&fixture.cache, [0; 32], 8, fixture.spec).is_err()); + let original = fs::read(&fixture.cache).unwrap(); + for offset in [ + 0, + MAGIC.len(), + MAGIC.len() + 64, + HEADER_BYTES - 1, + HEADER_BYTES, + HEADER_BYTES + 8 * G1_BYTES, + original.len() - 1, + ] { + let mut bytes = original.clone(); + bytes[offset] ^= 1; + fs::write(&fixture.cache, bytes).unwrap(); + assert!( + load_cache(&fixture.cache, receipt.digest, 8, fixture.spec).is_err(), + "offset {offset}" + ); + } + for bad in [ + original[..original.len() - 1].to_vec(), + [original.as_slice(), &[0]].concat(), + ] { + fs::write(&fixture.cache, bad).unwrap(); + assert!( + load_cache(&fixture.cache, receipt.digest, 8, fixture.spec).is_err() + ); + } + } + + #[test] + fn prefix_reads_authenticate_needed_chunks_without_reading_unused_tail() { + let fixture = Fixture::new(); + let receipt = fixture.import().unwrap(); + let mut bytes = fs::read(&fixture.cache).unwrap(); + bytes[HEADER_BYTES + 4 * G1_BYTES] ^= 1; + fs::write(&fixture.cache, bytes).unwrap(); + assert!( + load_cache(&fixture.cache, receipt.digest, 2, fixture.spec).is_ok() + ); + assert!( + load_cache(&fixture.cache, receipt.digest, 4, fixture.spec).is_ok() + ); + assert!( + load_cache(&fixture.cache, receipt.digest, 8, fixture.spec).is_err() + ); + } +} diff --git a/crates/proof-compression/src/kzg/srs_cache.rs b/crates/proof-compression/src/kzg/srs_cache.rs new file mode 100644 index 000000000..7bb7629ec --- /dev/null +++ b/crates/proof-compression/src/kzg/srs_cache.rs @@ -0,0 +1,343 @@ +//! Bounded I/O for the trusted-local development SRS cache: a shared +//! directory with one file per prefix length and seed. Files store +//! uncompressed points behind a BLAKE3 checksum; loading checks canonical +//! coordinates, curve membership and the seed-dependent anchors, not the +//! full subgroup and progression validation of an import. +use super::srs::dev_tau; +use super::{PublicSetup, Srs}; +use ark_bls12_381::{G1Affine, G1Projective, G2Affine, G2Projective}; +use ark_ec::{AffineRepr, CurveGroup, PrimeGroup}; +use ark_ff::Field; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use p3_maybe_rayon::prelude::*; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::Instant, +}; + +const MAGIC: &[u8] = b"ix/bls12-381/dev-srs/v1\0"; +const G1_BYTES: usize = 96; +const G2_BYTES: usize = 192; +const BLOCK_POINTS: usize = 1 << 18; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +fn header(max_len: usize, seed: &[u8]) -> Vec { + let mut bytes = MAGIC.to_vec(); + bytes.extend_from_slice(&(max_len as u64).to_le_bytes()); + bytes.extend_from_slice(blake3::hash(seed).as_bytes()); + bytes +} + +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +/// Reuse a cached prefix from `directory`, or generate and publish it. A +/// damaged existing file is an error, never a silent regeneration; writes +/// become visible atomically, so concurrent writers publish complete files. +pub(super) fn cached_in_directory( + max_len: usize, + public_max_degree: usize, + seed: &[u8], + directory: &Path, +) -> io::Result { + let invalid_input = + |message| io::Error::new(io::ErrorKind::InvalidInput, message); + if max_len < 2 || !max_len.is_power_of_two() { + return Err(invalid_input("SRS length must be a power of two >= 2")); + } + if public_max_degree < max_len - 1 + || public_max_degree.checked_add(1).is_none() + { + return Err(invalid_input("public degree is smaller than the G1 prefix")); + } + let setup = PublicSetup::known_trapdoor(public_max_degree, seed); + let header = header(max_len, seed); + let path = + directory.join(format!("{}.dev-srs", blake3::hash(&header).to_hex())); + let start = Instant::now(); + match File::open(&path) { + Ok(file) => { + let srs = + read_cache(file, &header, max_len, seed, setup).map_err(|error| { + io::Error::new(error.kind(), format!("{}: {error}", path.display())) + })?; + eprintln!( + "Loaded development SRS cache {} in {:?}", + path.display(), + start.elapsed() + ); + Ok(srs) + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(directory)?; + let srs = Srs::unsafe_dev_setup_prefix(max_len, public_max_degree, seed); + write_cache(&path, &header, &srs)?; + eprintln!( + "Generated and cached development SRS {} in {:?}", + path.display(), + start.elapsed() + ); + Ok(srs) + }, + Err(error) => Err(error), + } +} + +fn read_cache( + mut file: File, + expected_header: &[u8], + max_len: usize, + seed: &[u8], + public_setup: PublicSetup, +) -> io::Result { + let g2_len = 2 * G2_BYTES; + let expected_size = (max_len as u64) + .checked_mul(G1_BYTES as u64) + .and_then(|n| n.checked_add((expected_header.len() + g2_len + 32) as u64)) + .ok_or_else(|| invalid("SRS cache length overflow"))?; + if file.metadata()?.len() != expected_size { + return Err(invalid("SRS cache has the wrong length")); + } + let mut actual_header = vec![0; expected_header.len()]; + file.read_exact(&mut actual_header)?; + if actual_header != expected_header { + return Err(invalid("SRS cache seed, size or format mismatch")); + } + let mut hasher = blake3::Hasher::new(); + hasher.update(&actual_header); + let mut bytes = vec![0; g2_len]; + file.read_exact(&mut bytes)?; + hasher.update(&bytes); + let mut g2_points = bytes + .as_chunks::() + .0 + .iter() + .map(|point| { + G2Affine::deserialize_uncompressed(point.as_slice()) + .map_err(|e| invalid(&e.to_string())) + }) + .collect::>>()? + .into_iter(); + let g2 = g2_points.next().expect("two anchors"); + let tau_g2 = g2_points.next().expect("two anchors"); + let tau = dev_tau(seed); + let generator = G2Projective::generator(); + if g2 != generator.into_affine() || tau_g2 != (generator * tau).into_affine() + { + return Err(invalid( + "SRS cache G2 anchors differ from the development seed", + )); + } + let mut g1 = Vec::new(); + g1.try_reserve_exact(max_len).map_err(io::Error::other)?; + bytes.resize(max_len.min(BLOCK_POINTS) * G1_BYTES, 0); + while g1.len() < max_len { + let count = (max_len - g1.len()).min(BLOCK_POINTS); + let block = &mut bytes[..count * G1_BYTES]; + file.read_exact(block)?; + hasher.update(block); + let points = block + .par_chunks_exact(G1_BYTES) + .map(|bytes| { + let point = G1Affine::deserialize_uncompressed_unchecked(bytes) + .map_err(|e| invalid(&e.to_string()))?; + if point.is_zero() || !point.is_on_curve() { + return Err(invalid("invalid G1 point in development SRS cache")); + } + Ok(point) + }) + .collect::>>()?; + g1.extend(points); + } + let mut checksum = [0; 32]; + file.read_exact(&mut checksum)?; + if hasher.finalize().as_bytes() != &checksum { + return Err(invalid("SRS cache checksum mismatch")); + } + if file.read(&mut [0])? != 0 { + return Err(invalid("trailing bytes in SRS cache")); + } + let generator = G1Projective::generator(); + if g1[0] != generator.into_affine() + || g1[1] != (generator * tau).into_affine() + || g1[max_len - 1] + != (generator * tau.pow([(max_len - 1) as u64])).into_affine() + { + return Err(invalid( + "SRS cache G1 anchors differ from the development seed", + )); + } + Ok(Srs { g1, g2, tau_g2, public_setup }) +} + +struct Temporary(PathBuf); + +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +fn write_cache(path: &Path, header: &[u8], srs: &Srs) -> io::Result<()> { + let (temporary, mut file) = loop { + let temporary = path.with_extension(format!( + "{}.{}.partial", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + match OpenOptions::new().write(true).create_new(true).open(&temporary) { + Ok(file) => break (Temporary(temporary), file), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, + Err(error) => return Err(error), + } + }; + let mut hasher = blake3::Hasher::new(); + file.write_all(header)?; + hasher.update(header); + let mut bytes = Vec::new(); + for point in [&srs.g2, &srs.tau_g2] { + point + .serialize_uncompressed(&mut bytes) + .map_err(|e| invalid(&e.to_string()))?; + } + file.write_all(&bytes)?; + hasher.update(&bytes); + bytes.resize(srs.g1.len().min(BLOCK_POINTS) * G1_BYTES, 0); + for points in srs.g1.chunks(BLOCK_POINTS) { + let block = &mut bytes[..points.len() * G1_BYTES]; + block.par_chunks_exact_mut(G1_BYTES).zip(points.par_iter()).try_for_each( + |(bytes, point)| { + point.serialize_uncompressed(bytes).map_err(|e| invalid(&e.to_string())) + }, + )?; + file.write_all(block)?; + hasher.update(block); + } + file.write_all(hasher.finalize().as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&temporary.0, path)?; + Ok(()) +} + +#[cfg(test)] +mod directory_tests { + use super::*; + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "ix-dev-srs-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn path(&self, max_len: usize, seed: &[u8]) -> PathBuf { + self.0.join(format!( + "{}.dev-srs", + blake3::hash(&header(max_len, seed)).to_hex() + )) + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + #[test] + fn cache_round_trip_and_key_separation() { + let directory = TestDirectory::new(); + for (size, seed) in [ + (2, b"first".as_slice()), + (16, b"first"), + (32, b"first"), + (16, b"second"), + ] { + let fresh = cached_in_directory(size, 63, seed, &directory.0).unwrap(); + let cached = cached_in_directory(size, 63, seed, &directory.0).unwrap(); + let expected = Srs::unsafe_dev_setup_prefix(size, 63, seed); + for actual in [fresh, cached] { + assert_eq!(actual.g1, expected.g1); + assert_eq!(actual.g2, expected.g2); + assert_eq!(actual.tau_g2, expected.tau_g2); + assert_eq!(actual.public_setup, expected.public_setup); + actual.validate().unwrap(); + } + } + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 4); + assert!(cached_in_directory(16, 14, b"first", &directory.0).is_err()); + } + + #[test] + fn damaged_cache_fails_without_regeneration() { + let directory = TestDirectory::new(); + let seed = b"damage"; + cached_in_directory(16, 15, seed, &directory.0).unwrap(); + let path = directory.path(16, seed); + let valid = fs::read(&path).unwrap(); + let mut variants = vec![ + valid[..valid.len() - 1].to_vec(), + [valid.as_slice(), &[0]].concat(), + ]; + for offset in [0, MAGIC.len(), valid.len() - 1] { + let mut damaged = valid.clone(); + damaged[offset] ^= 1; + variants.push(damaged); + } + // A valid but different interior point must still fail the checksum. + let mut damaged = valid.clone(); + let start = header(16, seed).len() + 2 * G2_BYTES; + let point = damaged[start + G1_BYTES..start + 2 * G1_BYTES].to_vec(); + damaged[start + 7 * G1_BYTES..start + 8 * G1_BYTES].copy_from_slice(&point); + variants.push(damaged); + for damaged in variants { + fs::write(&path, &damaged).unwrap(); + assert!(cached_in_directory(16, 15, seed, &directory.0).is_err()); + assert_eq!(fs::read(&path).unwrap(), damaged); + } + } + + #[test] + fn cache_rejects_a_different_seed_even_with_valid_checksum() { + let directory = TestDirectory::new(); + let expected_header = header(16, b"expected"); + let path = directory.path(16, b"expected"); + write_cache( + &path, + &expected_header, + &Srs::unsafe_dev_setup_prefix(16, 15, b"different"), + ) + .unwrap(); + assert!(cached_in_directory(16, 15, b"expected", &directory.0).is_err()); + } + + #[test] + fn concurrent_cache_writers_publish_complete_files() { + let directory = TestDirectory::new(); + std::thread::scope(|scope| { + for _ in 0..4 { + scope.spawn(|| { + cached_in_directory(32, 31, b"concurrent", &directory.0).unwrap(); + }); + } + }); + let actual = + cached_in_directory(32, 31, b"concurrent", &directory.0).unwrap(); + assert_eq!( + actual.g1, + Srs::unsafe_dev_setup_prefix(32, 31, b"concurrent").g1 + ); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } +} diff --git a/crates/proof-compression/src/kzg/transcript.rs b/crates/proof-compression/src/kzg/transcript.rs index 09c783b96..c97350d4f 100644 --- a/crates/proof-compression/src/kzg/transcript.rs +++ b/crates/proof-compression/src/kzg/transcript.rs @@ -1,16 +1,102 @@ -//! BLAKE3 Fiat-Shamir transcript. Each sample hashes the chain state and -//! buffered canonical observations, reduces 64 XOF bytes into the scalar -//! field, takes 32 more bytes as the next state, and clears the buffer. +//! Fiat-Shamir transcripts over the BLS12-381 scalar field. +//! +//! BLAKE3 samples hash the chain state and buffered canonical observations, +//! reduce 64 XOF bytes little-endian into the scalar field, take 32 more +//! bytes as the next state, and clear the buffer. Keccak-256 uses separate +//! digest domains for its wide challenge and next state. use ark_bls12_381::Fr; use ark_ff::PrimeField; use ark_serialize::CanonicalSerialize; +use tiny_keccak::{Hasher, Keccak}; use multi_stark::traits::Transcript; use super::field::Scalar; use super::pcs::KzgCommitment; +/// The hash and challenge expansion used by the native KZG verifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TranscriptKind { + Blake3, + Keccak256, +} + +/// A native transcript whose hash is selected by the verifier configuration. +#[derive(Clone)] +pub enum KzgTranscript { + Blake3(Blake3Transcript), + Keccak256(Keccak256Transcript), +} + +impl KzgTranscript { + pub fn new(kind: TranscriptKind) -> Self { + match kind { + TranscriptKind::Blake3 => Self::Blake3(Blake3Transcript::new()), + TranscriptKind::Keccak256 => Self::Keccak256(Keccak256Transcript::new()), + } + } + + pub fn kind(&self) -> TranscriptKind { + match self { + Self::Blake3(_) => TranscriptKind::Blake3, + Self::Keccak256(_) => TranscriptKind::Keccak256, + } + } + + pub fn observe_bytes(&mut self, bytes: &[u8]) { + match self { + Self::Blake3(t) => t.observe_bytes(bytes), + Self::Keccak256(t) => t.observe_bytes(bytes), + } + } + + /// Absorb a canonically-serializable arkworks value (compressed). + pub fn observe_canonical(&mut self, value: &impl CanonicalSerialize) { + match self { + Self::Blake3(t) => t.observe_canonical(value), + Self::Keccak256(t) => t.observe_canonical(value), + } + } +} + +impl Default for KzgTranscript { + fn default() -> Self { + Self::new(TranscriptKind::Blake3) + } +} + +impl Transcript for KzgTranscript { + type F = Scalar; + type Challenge = Scalar; + type Commitment = KzgCommitment; + + fn observe_field(&mut self, x: Scalar) { + self.observe_canonical(&x.0); + } + + fn observe_field_slice(&mut self, xs: &[Scalar]) { + for x in xs { + self.observe_field(*x); + } + } + + fn observe_challenge(&mut self, x: Scalar) { + self.observe_field(x); + } + + fn observe_commitment(&mut self, c: KzgCommitment) { + self.observe_canonical(&c.0); + } + + fn sample_challenge(&mut self) -> Scalar { + match self { + Self::Blake3(t) => t.sample_challenge(), + Self::Keccak256(t) => t.sample_challenge(), + } + } +} + /// See the module docs. #[derive(Clone)] pub struct Blake3Transcript { @@ -76,7 +162,96 @@ impl Transcript for Blake3Transcript { fn observe_commitment(&mut self, c: KzgCommitment) { self.observe_canonical(&c.0); - self.observe_canonical(&c.1); + } + + fn sample_challenge(&mut self) -> Scalar { + Scalar(self.squeeze()) + } +} + +const KECCAK256_DOMAIN: &[u8] = b"multi-stark/kzg/keccak256/v1"; + +fn keccak256(parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = Keccak::v256(); + for part in parts { + hasher.update(part); + } + let mut digest = [0u8; 32]; + hasher.finalize(&mut digest); + digest +} + +/// Keccak-256 transcript with 512-bit challenge reduction. +/// +/// For counters 0, 1, and 2, hash `domain || counter:u8 || state:bytes32 || +/// observations`. The first two digests form a 64-byte big-endian integer, +/// reduced modulo Fr; the third becomes the next state. Every digest uses +/// the old state and the same observations, which are cleared afterwards. +/// Canonical observations retain arkworks' compressed byte encoding. +#[derive(Clone)] +pub struct Keccak256Transcript { + state: [u8; 32], + buffer: Vec, +} + +impl Keccak256Transcript { + /// A fresh transcript. The configuration must observe its parameter seed. + pub fn new() -> Self { + Self { state: [0; 32], buffer: Vec::new() } + } + + pub fn observe_bytes(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); + } + + /// Absorb a canonically-serializable arkworks value (compressed). + pub fn observe_canonical(&mut self, value: &impl CanonicalSerialize) { + value + .serialize_compressed(&mut self.buffer) + .expect("serialization into a Vec cannot fail"); + } + + fn digest(&self, counter: u8) -> [u8; 32] { + keccak256(&[KECCAK256_DOMAIN, &[counter], &self.state, &self.buffer]) + } + + fn squeeze(&mut self) -> Fr { + let mut wide = [0u8; 64]; + wide[..32].copy_from_slice(&self.digest(0)); + wide[32..].copy_from_slice(&self.digest(1)); + self.state = self.digest(2); + self.buffer.clear(); + Fr::from_be_bytes_mod_order(&wide) + } +} + +impl Default for Keccak256Transcript { + fn default() -> Self { + Self::new() + } +} + +impl Transcript for Keccak256Transcript { + type F = Scalar; + type Challenge = Scalar; + type Commitment = KzgCommitment; + + fn observe_field(&mut self, x: Scalar) { + self.observe_canonical(&x.0); + } + + fn observe_field_slice(&mut self, xs: &[Scalar]) { + for x in xs { + self.observe_field(*x); + } + } + + fn observe_challenge(&mut self, x: Scalar) { + self.observe_field(x); + } + + fn observe_commitment(&mut self, c: KzgCommitment) { + self.observe_canonical(&c.0); } fn sample_challenge(&mut self) -> Scalar { @@ -87,6 +262,9 @@ impl Transcript for Blake3Transcript { #[cfg(test)] mod tests { use super::*; + use ark_bls12_381::G1Affine; + use ark_ec::AffineRepr; + use ark_ff::BigInteger; use multi_stark::traits::Field; #[test] @@ -122,4 +300,130 @@ mod tests { b.observe_field(xs[1]); assert_eq!(a.sample_challenge(), b.sample_challenge()); } + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() + } + + #[test] + fn keccak256_known_answers() { + assert_eq!( + hex(&keccak256(&[b""])), + "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + ); + assert_eq!( + hex(&keccak256(&[b"abc"])), + "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45" + ); + } + + #[test] + fn keccak_transcript_matches_independent_vectors() { + // PyCryptodome 3.23.0 Crypto.Hash.keccak, digest_bits=256, independently + // generates each digest from the documented concatenation. Challenges + // are int.from_bytes(digest0 + digest1, "big") % Fr::MODULUS; the field + // observations 7 and 42 are 32-byte little-endian canonical encodings. + let mut t = Keccak256Transcript::new(); + t.observe_bytes(b"kzg-vector-v1"); + t.observe_field(Scalar::from_u64(7)); + t.observe_bytes(&(0u8..160).collect::>()); + + let vectors = [ + ( + "2e3429e71d42335d9ea681622711b7ef2468c1db8c0245dd805deed360bbcf25", + "c400066185add659881d303d8b0cd914429b2b1ba9e0addc4a50dacaf049d608", + ), + ( + "1f261b89afb3b219e30be06c7518ccb5038337b3168ed83bdba29a36699b5c27", + "510f0c6412d976b7224d04cac918bd5fcbef2ca2dfe08f7aa371be709e9a71e5", + ), + ( + "67e1777e4d2a526c9c905e84eaf3cc6b6e0764c42dc7bcff1c8e6dee350e6fdc", + "64c9ce0b0dc5ff29a58487ebd39827848fae8c0e46e7850c64e2e2f5cbbd2475", + ), + ]; + for (round, (challenge, state)) in vectors.iter().enumerate() { + if round == 2 { + t.observe_bytes(b"after-squeeze"); + t.observe_canonical(&Fr::from(42u64)); + } + assert_eq!( + hex(&t.sample_challenge().0.into_bigint().to_bytes_be()), + *challenge, + "round {round} challenge" + ); + assert_eq!(hex(&t.state), *state, "round {round} state"); + assert!(t.buffer.is_empty()); + } + } + + #[test] + fn hash_kinds_and_expansion_domains_are_distinct() { + let mut blake3 = KzgTranscript::new(TranscriptKind::Blake3); + let mut keccak = KzgTranscript::new(TranscriptKind::Keccak256); + assert_eq!(blake3.kind(), TranscriptKind::Blake3); + assert_eq!(keccak.kind(), TranscriptKind::Keccak256); + blake3.observe_bytes(b"same observations"); + keccak.observe_bytes(b"same observations"); + for _ in 0..3 { + assert_ne!(blake3.sample_challenge(), keccak.sample_challenge()); + } + + let t = Keccak256Transcript::new(); + assert_ne!(t.digest(0), t.digest(1)); + assert_ne!(t.digest(0), t.digest(2)); + assert_ne!(t.digest(1), t.digest(2)); + assert_ne!(t.digest(0), keccak256(&[&[0], &t.state])); + } + + #[test] + fn blake3_enum_preserves_legacy_transcript() { + let mut legacy = Blake3Transcript::new(); + let mut selected = KzgTranscript::default(); + assert_eq!(selected.kind(), TranscriptKind::Blake3); + assert_eq!(legacy.sample_challenge(), selected.sample_challenge()); + + let xs = [Scalar::from_u64(5), Scalar::from_u64(6)]; + let commitment = KzgCommitment(vec![ + vec![G1Affine::generator(), G1Affine::default()], + vec![], + ]); + legacy.observe_bytes(b"parameter seed"); + selected.observe_bytes(b"parameter seed"); + legacy.observe_canonical(&Fr::from(123u64)); + selected.observe_canonical(&Fr::from(123u64)); + legacy.observe_field_slice(&xs); + selected.observe_field_slice(&xs); + legacy.observe_commitment(commitment.clone()); + selected.observe_commitment(commitment); + for _ in 0..3 { + let challenge = legacy.sample_challenge(); + assert_eq!(challenge, selected.sample_challenge()); + legacy.observe_challenge(challenge); + selected.observe_challenge(challenge); + legacy.observe_field(xs[0]); + selected.observe_field(xs[0]); + } + } + + #[test] + fn keccak_incremental_observations_are_equivalent() { + let bytes: Vec<_> = (0u8..200).collect(); + let xs = [Scalar::from_u64(7), Scalar::from_u64(9)]; + let mut whole = Keccak256Transcript::new(); + let mut incremental = KzgTranscript::new(TranscriptKind::Keccak256); + whole.observe_bytes(&bytes); + for chunk in bytes.chunks(13) { + incremental.observe_bytes(chunk); + } + whole.observe_field_slice(&xs); + incremental.observe_field(xs[0]); + incremental.observe_canonical(&xs[1].0); + assert_eq!(whole.sample_challenge(), incremental.sample_challenge()); + assert_eq!(whole.sample_challenge(), incremental.sample_challenge()); + whole.observe_challenge(xs[0]); + incremental.observe_bytes(&7u64.to_le_bytes()); + incremental.observe_bytes(&[0u8; 24]); + assert_eq!(whole.sample_challenge(), incremental.sample_challenge()); + } } diff --git a/crates/proof-compression/src/kzg/verifier_params.rs b/crates/proof-compression/src/kzg/verifier_params.rs index 11de892cc..cae1725fe 100644 --- a/crates/proof-compression/src/kzg/verifier_params.rs +++ b/crates/proof-compression/src/kzg/verifier_params.rs @@ -1,5 +1,5 @@ -//! Public verifier parameters, with the original SRS degree range preserved. -use super::Srs; +//! Portable KZG anchors and the complete public degree allowance. +use super::{PublicSetup, Srs}; use ark_bls12_381::{G1Affine, G2Affine}; use ark_ec::{AffineRepr, pairing::Pairing}; use ark_ff::Zero; @@ -9,53 +9,79 @@ use ark_serialize::{ #[derive(Clone)] pub struct VerifierParams { - pub(crate) max_len: usize, + max_trace_len: usize, + pub(crate) public_setup: PublicSetup, pub(crate) g1: [G1Affine; 2], pub(crate) g2: G2Affine, pub(crate) tau_g2: G2Affine, - pub(crate) degree_keys: Vec, +} + +fn valid_cap(cap: usize, setup: PublicSetup) -> bool { + cap >= 2 + && cap.is_power_of_two() + && cap.ilog2() <= 32 + && setup.max_degree >= cap - 1 + && setup.max_degree.checked_add(1).is_some() } impl VerifierParams { pub fn from_srs(srs: &Srs) -> Self { + Self::with_max_trace_len(srs, srs.max_len()) + } + + pub fn with_max_trace_len(srs: &Srs, max_trace_len: usize) -> Self { assert!(srs.max_len() >= 2 && srs.max_len().is_power_of_two()); - assert_eq!(srs.degree_keys.len(), srs.max_len().ilog2() as usize + 1); + assert!(srs.public_setup.max_degree >= srs.max_len() - 1); + assert!( + valid_cap(max_trace_len, srs.public_setup), + "invalid public degree or trace cap" + ); Self { - max_len: srs.max_len(), + max_trace_len, + public_setup: srs.public_setup, g1: [srs.g1[0], srs.g1[1]], g2: srs.g2, tau_g2: srs.tau_g2, - degree_keys: srs.degree_keys.clone(), } } - pub fn max_len(&self) -> usize { - self.max_len + pub fn max_trace_len(&self) -> usize { + self.max_trace_len + } + + pub fn public_setup(&self) -> PublicSetup { + self.public_setup } pub fn to_bytes(&self) -> Result, SerializationError> { let mut out = vec![]; - 1u64.serialize_compressed(&mut out)?; - (self.max_len as u64).serialize_compressed(&mut out)?; + 4u64.serialize_compressed(&mut out)?; + (self.max_trace_len as u64).serialize_compressed(&mut out)?; + (self.public_setup.max_degree as u64).serialize_compressed(&mut out)?; + self.public_setup.id.serialize_compressed(&mut out)?; for point in self.g1 { point.serialize_compressed(&mut out)?; } self.g2.serialize_compressed(&mut out)?; self.tau_g2.serialize_compressed(&mut out)?; - for key in &self.degree_keys { - key.serialize_compressed(&mut out)?; - } Ok(out) } - /// Validated point decoding and shape checks. Provenance must be established independently. + /// Validated point decoding and shape checks; provenance is authenticated separately. pub fn from_bytes(mut input: &[u8]) -> Result { - if u64::deserialize_compressed(&mut input)? != 1 { + if u64::deserialize_compressed(&mut input)? != 4 { return Err(SerializationError::InvalidData); } - let max_len = usize::try_from(u64::deserialize_compressed(&mut input)?) + let max_trace_len = + usize::try_from(u64::deserialize_compressed(&mut input)?) + .map_err(|_error| SerializationError::InvalidData)?; + let max_degree = usize::try_from(u64::deserialize_compressed(&mut input)?) .map_err(|_error| SerializationError::InvalidData)?; - if max_len < 2 || !max_len.is_power_of_two() || max_len.ilog2() > 32 { + let public_setup = PublicSetup { + max_degree, + id: <[u8; 32]>::deserialize_compressed(&mut input)?, + }; + if !valid_cap(max_trace_len, public_setup) { return Err(SerializationError::InvalidData); } let g1 = [ @@ -64,42 +90,58 @@ impl VerifierParams { ]; let g2 = G2Affine::deserialize_compressed(&mut input)?; let tau_g2 = G2Affine::deserialize_compressed(&mut input)?; - let degree_keys = (0..=max_len.ilog2()) - .map(|_| G2Affine::deserialize_compressed(&mut input)) - .collect::, _>>()?; if !input.is_empty() || g1.iter().any(AffineRepr::is_zero) || g2.is_zero() || tau_g2.is_zero() - || degree_keys.iter().any(AffineRepr::is_zero) - || degree_keys.last() != Some(&g2) || !ark_bls12_381::Bls12_381::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]) .is_zero() { return Err(SerializationError::InvalidData); } - Ok(Self { max_len, g1, g2, tau_g2, degree_keys }) + Ok(Self { max_trace_len, public_setup, g1, g2, tau_g2 }) } } #[cfg(test)] mod tests { use super::*; + + #[test] + fn public_identity_and_degree_are_independent_of_loaded_prefix() { + let prover = Srs::unsafe_dev_setup(64, b"verifier-params"); + let anchors = Srs::unsafe_dev_setup_prefix(2, 63, b"verifier-params"); + let params = VerifierParams::from_srs(&prover); + let portable = VerifierParams::with_max_trace_len(&anchors, 64); + assert_eq!(params.to_bytes().unwrap(), portable.to_bytes().unwrap()); + let decoded = + VerifierParams::from_bytes(¶ms.to_bytes().unwrap()).unwrap(); + assert_eq!(decoded.max_trace_len(), 64); + assert_eq!(decoded.public_setup(), prover.public_setup); + assert_ne!( + prover.public_setup, + Srs::unsafe_dev_setup(32, b"verifier-params").public_setup + ); + assert_ne!( + prover.public_setup, + Srs::unsafe_dev_setup(64, b"other").public_setup + ); + } + #[test] - fn encoding_preserves_degree_bound_and_rejects_malformed_keys() { - let srs = Srs::unsafe_dev_setup(64, b"verifier-params"); - let params = VerifierParams::from_srs(&srs); + fn rejects_malformed_parameters() { + let params = + VerifierParams::from_srs(&Srs::unsafe_dev_setup(64, b"verifier-params")); let encoded = params.to_bytes().unwrap(); - let decoded = VerifierParams::from_bytes(&encoded).unwrap(); - assert_eq!(decoded.max_len(), 64); - assert_eq!(decoded.g1.len(), 2); - assert_eq!(decoded.degree_keys, srs.degree_keys); assert!(VerifierParams::from_bytes(&encoded[..encoded.len() - 1]).is_err()); let mut extra = encoded.clone(); extra.push(0); assert!(VerifierParams::from_bytes(&extra).is_err()); + let mut unsupported_version = encoded; + unsupported_version[..8].copy_from_slice(&0u64.to_le_bytes()); + assert!(VerifierParams::from_bytes(&unsupported_version).is_err()); let mut bad = params.clone(); - bad.max_len = 32; + bad.public_setup.max_degree = 31; assert!(VerifierParams::from_bytes(&bad.to_bytes().unwrap()).is_err()); let mut bad = params; bad.g1[1] = bad.g1[0]; diff --git a/crates/proof-compression/src/lib.rs b/crates/proof-compression/src/lib.rs index ed7f2479b..80b19414c 100644 --- a/crates/proof-compression/src/lib.rs +++ b/crates/proof-compression/src/lib.rs @@ -1,4 +1,5 @@ //! FRI and KZG proof compression for Ix. +extern crate self as ix_proof_compression; mod circuit_metadata; pub mod foreign; pub mod fri; diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs new file mode 100644 index 000000000..b0ad37c59 --- /dev/null +++ b/crates/proof-compression/src/pipeline/first.rs @@ -0,0 +1,473 @@ +//! KZG compression of the saved recursive FRI proof. +//! +//! `run` stages, proves and verifies in one process: the translated traces +//! feed commitment directly, and the loaded SRS, fixed preprocessing and +//! device residency are shared between the two halves. The stage writes its +//! proof and the metadata the recursive stage verifies it with, plus the +//! fixed-preprocessing checkpoints that populate the cross-run disk cache. +use crate::fri::{VerifierLimits, VerifierPlan}; +#[cfg(test)] +use crate::kzg::Scalar; +use multi_stark::config::ProofConfig; +#[cfg(test)] +use multi_stark::plonkish::{Assignment, MultiStarkCircuit}; +#[cfg(test)] +use multi_stark::traits::Field; +use std::{fs, path::Path, time::Instant}; + +use super::stages::construct::{self, FriConstruction, Lowered, max_height}; +use super::{ + fixed_cache::FixedCache, + setup::{FIRST_SEED, SetupClass}, + storage, +}; +#[cfg(test)] +use super::{init_claim, setup::SetupSource}; +use super::{init_fri, prover}; + +/// Partitions of the supported Init profile: ten carry the public words and +/// the rest are tables, which only exist above eleven partitions. +pub(super) const MIN_PARTITIONS: usize = 11; +pub(super) use super::stages::statement::first_claims as expected_claims; + +fn log_height_arg(arg: Option<&String>) -> storage::Result { + let log_height = arg.map(|s| s.parse::()).transpose()?.unwrap_or(24); + if !(24..=28).contains(&log_height) { + return Err("log-height must be 24..28".into()); + } + Ok(log_height) +} + +pub(super) fn run( + args: &[String], + config: &super::WorkerConfig, +) -> storage::Result<()> { + const USAGE: &str = "usage: ix-compression-worker kzg run [log-height: 24..28]"; + let Some(command) = args.first() else { + return Err(USAGE.into()); + }; + let rest = &args[1..]; + match command.as_str() { + "run" if (2..=3).contains(&rest.len()) => { + let log_height = log_height_arg(rest.get(2))?; + stage_and_prove( + Path::new(&rest[0]), + Path::new(&rest[1]), + log_height, + config, + ) + }, + _ => Err(USAGE.into()), + } +} + +fn stage_and_prove( + fri: &Path, + out: &Path, + log_height: usize, + config: &super::WorkerConfig, +) -> storage::Result<()> { + let trace_height = 1usize << log_height; + fs::create_dir_all(out)?; + let fixture = init_fri::load(fri)?; + let plan = VerifierPlan::validate( + &fixture.key, + fixture.profile, + VerifierLimits::default(), + )?; + let plan_id = plan.identity(&fixture.schema)?; + fs::write(out.join("plan-id.bin"), plan_id)?; + fs::write(out.join("inner-vk.bin"), fixture.key.to_bytes()?)?; + let start = Instant::now(); + let prepared = FriConstruction::build(&plan, fixture.schema)?.assign( + &plan, + &fixture.proof, + fixture.claims, + )?; + assert_eq!(prepared.public_values(), fixture.public); + let public = super::stages::scalar_statement(&fixture.public); + let lowered = + construct::prepare_first(prepared, trace_height, construct::TABLE_HEIGHT)?; + assert_eq!(lowered.assignment.public_values(), public); + println!( + "Scalar translation and lowering of {} circuits: {:?}; {:?}", + lowered.compiled.num_circuits(), + start.elapsed(), + lowered.compiled.circuit().stats() + ); + let height = max_height(&lowered.compiled)?; + let setup = config.setup.for_stage(FIRST_SEED, height)?; + setup.bind_stage(out, height, 2)?; + let setup_id = setup.identity(height, 2)?; + let cache = config.fixed_cache(out, &[plan_id, setup_id].concat())?; + let cached = cache.restore(out)?; + let manifest = storage::Manifest::for_compiled( + &lowered.compiled, + lowered.compiled.claims(&public)?, + cached.as_ref(), + )?; + storage::save(&out.join("manifest.bin"), &manifest)?; + println!("Generating fixed and witness traces directly into commitment"); + prove(out, lowered, &cache, config)?; + println!("RUN COMPLETE: {:?}", start.elapsed()); + Ok(()) +} + +/// Commit the fixed and witness traces of every partition, prove them +/// together, verify the proof and write it with its packet. Fixed +/// preprocessing comes from the cache when it holds this profile and is +/// published to it after the proof verifies. +fn prove( + dir: &Path, + lowered: Lowered, + cache: &FixedCache, + config: &super::WorkerConfig, +) -> storage::Result<()> { + let manifest: storage::Manifest = storage::load(&dir.join("manifest.bin"))?; + let count = manifest.heights.len(); + if count < MIN_PARTITIONS + || manifest.widths.len() != count + || manifest.widths.contains(&0) + { + return Err("unexpected trace layout".into()); + } + let height = *manifest.heights.iter().max().ok_or("empty manifest")?; + if height > 1 << 28 + || manifest.heights.iter().any(|h| *h < 2 || !h.is_power_of_two()) + { + return Err("unsupported trace height".into()); + } + let compiled = &lowered.compiled; + if compiled.num_circuits() != count + || (0..count).any(|i| { + compiled.trace_dimensions(i) + != Some((manifest.heights[i], manifest.widths[i])) + }) + { + return Err( + "generated trace dimensions differ from the staged manifest".into(), + ); + } + let statement = super::statement::from_rows(&manifest.claims)?; + if manifest.claims != expected_claims(count, &statement) { + return Err( + "staged claims differ from the statement and activation anchors".into(), + ); + } + let setup = config.setup.for_stage(FIRST_SEED, height)?; + setup.check_binding(dir, height, 2)?; + let setup_id = setup.identity(height, 2)?; + let plan_id: [u8; 32] = fs::read(dir.join("plan-id.bin"))? + .try_into() + .map_err(|bytes: Vec| { + format!("invalid verifier plan identity: {} bytes", bytes.len()) + })?; + let out = dir.join("kzg"); + fs::create_dir_all(&out)?; + fs::write(out.join("SECURITY.txt"), setup.security_description())?; + let start = Instant::now(); + let prefetch_bytes = config.prefetch_bytes; + let config = setup + .config(height, 2, false)? + .with_streaming_lookups() + .with_streaming_quotient(); + println!("KZG parameters ready: {:?}", start.elapsed()); + println!("Partition prefetch budget: {} GiB", prefetch_bytes >> 30); + let prover::Proved { system, bytes, verify_seconds, .. } = lowered + .commit(config, Some(&out), prefetch_bytes)? + .prove(&manifest.claims)?; + let output_span = tracing::info_span!("compression/write_output").entered(); + // The label is part of every published profile id and predates the + // Filecoin setup; the setup class is bound through the transcript seed. + let mut profile = blake3::Hasher::new(); + profile.update(b"init-fri-kzg-ordinary-v1/development-srs/quotient2"); + profile.update(system.config.transcript_seed()); + profile.update(&plan_id); + profile.update(&fs::read(dir.join("manifest.bin"))?); + for i in 0..count { + profile.update(&fs::read(out.join(format!("setup-{i}.bin")))?); + } + let profile = *profile.finalize().as_bytes(); + let mut packet = profile.to_vec(); + for claim in &manifest.claims[1..19] { + packet.extend_from_slice(&claim[3].canonical_limbs_le()[0].to_le_bytes()); + } + packet.extend_from_slice(&bytes); + storage::write_atomic(&out.join("proof.compact.bin"), &bytes)?; + storage::write_atomic(&out.join("profile-id.bin"), &profile)?; + storage::write_atomic(&out.join("packet.bin"), &packet)?; + let development = setup.class() == SetupClass::Development; + let public_setup = system.config.pcs().public_setup(); + let evidence = serde_json::json!({ + "proof_bytes": bytes.len(), + "packet_bytes": packet.len(), + "trace_heights": manifest.heights, + "verify_seconds": verify_seconds, + "total_seconds": start.elapsed().as_secs_f64(), + "native_verification_passed": true, + "development_srs": development, + "known_trapdoor": development, + "setup": setup.class().label(), + "setup_id": blake3::Hash::from_bytes(setup_id).to_string(), + "public_setup_id": blake3::Hash::from_bytes(public_setup.id).to_string(), + "public_max_degree": public_setup.max_degree, + }); + fs::write(out.join("prove-report.json"), format!("{evidence}\n"))?; + drop(output_span); + cache.publish(dir)?; + println!( + "VERIFIED: proof_bytes={} packet_bytes={} verify_seconds={verify_seconds} total_seconds={} development_srs={development}", + bytes.len(), + packet.len(), + start.elapsed().as_secs_f64() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::plonkish::gadgets::ByteGadgets; + use multi_stark::plonkish::gadgets::blake3; + use multi_stark::plonkish::{CircuitBuilder, Value}; + + const OPTIONS: super::super::WorkerConfig = super::super::WorkerConfig { + prefetch_bytes: 1 << 20, + setup: super::super::setup::SetupConfig::Development { + cache_directory: None, + }, + fixed_cache_root: None, + }; + + fn artifacts(directory: &Path) -> storage::Result>> { + Ok( + ["proof.compact.bin", "packet.bin", "profile-id.bin"] + .into_iter() + .map(|name| fs::read(directory.join("kzg").join(name))) + .collect::>()?, + ) + } + + struct Fixture { + compiled: MultiStarkCircuit, + public: Vec, + input: Value, + private: Value, + } + + impl Fixture { + fn assign(&self, private_byte: u8) -> storage::Result> { + let mut witness = self.compiled.witness(); + witness.set(self.input, Scalar::from_u8(7))?; + witness.set(self.private, Scalar::from_u8(private_byte))?; + Ok(witness.generate()?) + } + } + + /// A small circuit whose lowering has the Init stage's partition layout. + fn fixture(log_height: usize) -> storage::Result { + let mut builder = CircuitBuilder::::new(); + builder.enable_compact_blake3(); + let bytes = ByteGadgets::new(&mut builder); + let input = bytes.input(&mut builder, "byte"); + let private = bytes.input(&mut builder, "private byte"); + let _digest = blake3(&mut builder, &bytes, &[input, private]); + let seven = builder.constant(Scalar::from_u8(7)); + for _ in 0..65540 { + builder.assert_equal(input.value(), seven); + } + let public: Vec<_> = + init_claim::INIT_PUBLIC_WORDS.into_iter().map(Scalar::from_u64).collect(); + for &word in &public { + let value = builder.constant(word); + builder.expose_public(value); + } + let compiled = builder + .finish() + .lower_to_multi_stark_sharded(Scalar::from_u8(93), 1 << log_height)? + .merge_table_traces(1 << 22)?; + Ok(Fixture { + compiled, + public, + input: input.value(), + private: private.value(), + }) + } + + /// Write the metadata `stage_and_prove` produces before proving. + fn stage( + dir: &Path, + compiled: &MultiStarkCircuit, + public: &[Scalar], + cache_root: &Path, + ) -> storage::Result { + fs::create_dir_all(dir)?; + let height = max_height(compiled)?; + SetupSource::development(FIRST_SEED, height - 1)? + .bind_stage(dir, height, 2)?; + fs::write(dir.join("plan-id.bin"), [0u8; 32])?; + let cache = FixedCache::from_profile(cache_root, b"fixture-v1")?; + let cached = cache.restore(dir)?; + let manifest = storage::Manifest::for_compiled( + compiled, + compiled.claims(public)?, + cached.as_ref(), + )?; + storage::save(&dir.join("manifest.bin"), &manifest)?; + Ok(cache) + } + + #[test] + fn fused_fixture_proves_and_reuses_fixed_preprocessing() -> storage::Result<()> + { + let partitioned = prove_fixture(16)?; + let single = prove_fixture(17)?; + assert!(single < partitioned, "fewer partitions should reduce this proof"); + Ok(()) + } + + fn prove_fixture(log_height: usize) -> storage::Result { + let root = std::env::temp_dir().join(format!( + "init-fri-kzg-fixture-{}-{log_height}", + std::process::id() + )); + fs::create_dir(&root)?; + let fixture = fixture(log_height)?; + let (compiled, public) = (&fixture.compiled, &fixture.public); + assert_eq!(compiled.num_circuits(), if log_height == 16 { 12 } else { 11 }); + let prove = + |dir: &Path, cache: &FixedCache, private: u8| -> storage::Result<()> { + let fixture = self::fixture(log_height)?; + let assignment = fixture.assign(private)?; + prove( + dir, + Lowered { compiled: fixture.compiled, assignment }, + cache, + &OPTIONS, + ) + }; + let cache_root = root.join("cache"); + + // A cold run commits the fixed traces, proves and publishes the cache. + let dir = root.join("cold"); + let cache = stage(&dir, compiled, public, &cache_root)?; + prove(&dir, &cache, 9)?; + let first = artifacts(&dir)?; + let manifest: storage::Manifest = storage::load(&dir.join("manifest.bin"))?; + for i in 0..manifest.heights.len() { + assert!(dir.join(format!("kzg/fixed-{i}.bin")).exists()); + assert!(dir.join(format!("kzg/setup-{i}.bin")).exists()); + assert!(!dir.join(format!("kzg/main-{i}.bin")).exists()); + } + let report: serde_json::Value = + serde_json::from_slice(&fs::read(dir.join("kzg/prove-report.json"))?)?; + assert_eq!(report["setup"], "development"); + assert_eq!(report["development_srs"], true); + + // A warm run restores the fixed preprocessing from the cache and + // produces identical artifacts. + let warm = root.join("warm"); + let cache = stage(&warm, compiled, public, &cache_root)?; + for i in 0..manifest.heights.len() { + assert!(warm.join(format!("kzg/fixed-{i}.bin")).exists()); + } + prove(&warm, &cache, 9)?; + assert_eq!(first, artifacts(&warm)?); + let different = + FixedCache::from_profile(&cache_root, b"different-profile")?; + assert!(different.restore(&root.join("miss"))?.is_none()); + + // A different witness changes the proof but not the profile. + let changed = root.join("changed"); + let cache = stage(&changed, compiled, public, &cache_root)?; + prove(&changed, &cache, 231)?; + let second = artifacts(&changed)?; + assert_ne!(first[0], second[0]); + assert_eq!(first[2], second[2]); + + // Manifests that disagree with the generated traces are rejected + // before any parameters load. + for case in 0..3 { + let mut shape = storage::Manifest { + widths: manifest.widths.clone(), + heights: manifest.heights.clone(), + claims: manifest.claims.clone(), + }; + match case { + 0 => shape.widths[0] += 1, + 1 => shape.heights[0] *= 2, + _ => { + shape.widths.push(1); + shape.heights.push(2); + }, + } + let malformed = root.join(format!("invalid-shape-{case}")); + let cache = stage(&malformed, compiled, public, &cache_root)?; + storage::save(&malformed.join("manifest.bin"), &shape)?; + let error = prove(&malformed, &cache, 9).unwrap_err(); + assert!(error.to_string().contains("trace dimensions differ")); + assert!(!malformed.join("kzg/proof.compact.bin").exists()); + } + let malformed = root.join("invalid-height"); + let cache = stage(&malformed, compiled, public, &cache_root)?; + let mut shape = storage::Manifest { + widths: manifest.widths.clone(), + heights: manifest.heights.clone(), + claims: manifest.claims.clone(), + }; + shape.heights[1] = 3; + storage::save(&malformed.join("manifest.bin"), &shape)?; + let error = prove(&malformed, &cache, 9).unwrap_err(); + assert!(error.to_string().contains("trace height")); + shape.heights[1] = manifest.heights[1]; + shape.widths[1] = 0; + storage::save(&malformed.join("manifest.bin"), &shape)?; + let error = prove(&malformed, &cache, 9).unwrap_err(); + assert!(error.to_string().contains("unexpected trace layout")); + + // Removing a claim invalidates the staged claims. + let mut fewer = storage::Manifest { + widths: manifest.widths.clone(), + heights: manifest.heights.clone(), + claims: manifest.claims.clone(), + }; + fewer.claims.pop(); + storage::save(&malformed.join("manifest.bin"), &fewer)?; + let error = prove(&malformed, &cache, 9).unwrap_err(); + assert!(error.to_string().contains("staged claims differ")); + + // A damaged cache entry is rejected before anything is linked into a + // stage, whether it is metadata or a fixed checkpoint whose interior + // bytes still decode. Files are replaced rather than edited so the + // hard links into the proved stages keep their contents. + let entries = fs::read_dir(&cache_root)? + .map(|entry| Ok(entry?.path())) + .collect::>>()?; + let [entry] = entries.as_slice() else { + panic!("expected one published entry, found {entries:?}"); + }; + let cache = FixedCache::from_profile(&cache_root, b"fixture-v1")?; + let damaged = root.join("damaged"); + for name in ["kzg/fixed-0.bin", "kzg/setup-0.bin"] { + let published = entry.join(name); + let original = fs::read(&published)?; + let mut bytes = original.clone(); + let middle = bytes.len() / 2; + bytes[middle] ^= 1; + fs::remove_file(&published)?; + fs::write(&published, bytes)?; + fs::create_dir(&damaged)?; + let error = + cache.restore(&damaged).err().ok_or("damaged cache was restored")?; + assert!(error.to_string().contains("incomplete or damaged")); + assert!(!damaged.join("kzg").exists()); + fs::remove_dir_all(&damaged)?; + fs::remove_file(&published)?; + fs::write(&published, original)?; + } + fs::create_dir(&damaged)?; + assert!(cache.restore(&damaged)?.is_some()); + fs::remove_dir_all(root)?; + Ok(first[0].len()) + } +} diff --git a/crates/proof-compression/src/pipeline/fixed_cache.rs b/crates/proof-compression/src/pipeline/fixed_cache.rs new file mode 100644 index 000000000..2b9eb2cd0 --- /dev/null +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -0,0 +1,179 @@ +//! Trusted local preprocessing, independent of each proof's witness and +//! openings. Entries are keyed by the executable digest and the stage +//! profile, so a rebuilt binary never reuses another build's preprocessing. +//! The root is `IX_KZG_FIXED_CACHE`, or `/kzg/fixed-cache`. +use super::storage::{self, Manifest, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +#[derive(Serialize, Deserialize)] +pub(crate) struct Shape { + pub(crate) widths: Vec, + pub(crate) heights: Vec, +} + +#[derive(Serialize, Deserialize)] +struct CachedFile { + name: String, + size: u64, + /// BLAKE3 of the file at publication, checked on every restore. Checkpoint + /// readers only detect changes made after they index a file, so a + /// checkpoint damaged in the cache would otherwise be proved against. + digest: [u8; 32], +} + +#[derive(Serialize, Deserialize)] +struct Entry { + identity: [u8; 32], + shape: Shape, + files: Vec, +} + +pub(crate) struct FixedCache { + identity: [u8; 32], + directory: PathBuf, +} + +pub(crate) fn executable_digest() -> Result<[u8; 32]> { + static DIGEST: OnceLock<[u8; 32]> = OnceLock::new(); + if let Some(digest) = DIGEST.get() { + return Ok(*digest); + } + let digest = file_digest(&std::env::current_exe()?)?; + Ok(*DIGEST.get_or_init(|| digest)) +} + +fn file_digest(path: &Path) -> io::Result<[u8; 32]> { + let mut hasher = blake3::Hasher::new(); + hasher.update_mmap_rayon(path)?; + Ok(*hasher.finalize().as_bytes()) +} + +impl FixedCache { + /// The executable digest conservatively binds the compiler, lowering, + /// dependency versions, SRS recipe, and all circuit implementation choices. + pub(crate) fn from_profile(root: &Path, profile: &[u8]) -> Result { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"ix/kzg-fixed-cache/v5"); + hasher.update(&executable_digest()?); + hasher.update(profile); + let identity = *hasher.finalize().as_bytes(); + Ok(Self::new(root, identity)) + } + + fn new(root: &Path, identity: [u8; 32]) -> Self { + Self { + identity, + directory: root + .join(blake3::Hash::from_bytes(identity).to_hex().as_str()), + } + } + + #[tracing::instrument( + level = "info", + skip_all, + name = "compression/cache_restore" + )] + pub(crate) fn restore(&self, staged: &Path) -> Result> { + let ready = self.directory.join("ready.bin"); + if !ready.try_exists()? { + eprintln!("Fixed preprocessing cache miss: {}", self.directory.display()); + return Ok(None); + } + let entry: Entry = storage::load(&ready)?; + if entry.identity != self.identity + || entry.shape.widths.is_empty() + || entry.shape.widths.len() != entry.shape.heights.len() + || entry.files.len() != entry.shape.widths.len() * 2 + { + return Err("fixed cache identity or shape mismatch".into()); + } + let expected = file_names(entry.shape.widths.len()); + for (file, expected) in entry.files.iter().zip(expected) { + let path = self.directory.join(&file.name); + if file.name != expected + || fs::metadata(&path)?.len() != file.size + || file_digest(&path)? != file.digest + { + return Err("fixed cache entry is incomplete or damaged".into()); + } + } + fs::create_dir_all(staged.join("kzg"))?; + for CachedFile { name, .. } in &entry.files { + link_or_copy(&self.directory.join(name), &staged.join(name))?; + } + eprintln!("Reused fixed preprocessing: {}", self.directory.display()); + Ok(Some(entry.shape)) + } + + /// Publish only after the proof using these parameters has verified. + /// The cache directory must be trusted like the development SRS cache; + /// metadata and field decoding detect truncation, not a malicious producer. + #[tracing::instrument( + level = "info", + skip_all, + name = "compression/cache_publish" + )] + pub(crate) fn publish(&self, staged: &Path) -> Result<()> { + if self.directory.join("ready.bin").try_exists()? { + return Ok(()); + } + let manifest: Manifest = storage::load(&staged.join("manifest.bin"))?; + let temporary = storage::temporary_path(&self.directory); + fs::create_dir_all(temporary.join("kzg"))?; + let mut files = Vec::new(); + for name in file_names(manifest.widths.len()) { + let source = staged.join(&name); + let size = fs::metadata(&source)?.len(); + let digest = file_digest(&source)?; + let published = temporary.join(&name); + link_or_copy(&source, &published)?; + File::open(published)?.sync_all()?; + files.push(CachedFile { name, size, digest }); + } + storage::save( + &temporary.join("ready.bin"), + &Entry { + identity: self.identity, + shape: Shape { widths: manifest.widths, heights: manifest.heights }, + files, + }, + )?; + storage::sync_directory(&temporary.join("kzg"))?; + match storage::persist(&temporary, &self.directory) { + Ok(()) => (), + Err(_) if self.directory.join("ready.bin").try_exists()? => { + fs::remove_dir_all(temporary)?; + }, + Err(error) => return Err(error.into()), + } + eprintln!("Published fixed preprocessing: {}", self.directory.display()); + Ok(()) + } +} + +fn file_names(count: usize) -> impl Iterator { + (0..count) + .flat_map(|i| [format!("kzg/setup-{i}.bin"), format!("kzg/fixed-{i}.bin")]) +} + +fn link_or_copy(source: &Path, destination: &Path) -> Result<()> { + if destination.try_exists()? { + return Err( + format!("refusing to replace {}", destination.display()).into(), + ); + } + match fs::hard_link(source, destination) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::CrossesDevices => { + fs::copy(source, destination)?; + Ok(()) + }, + Err(error) => Err(error.into()), + } +} diff --git a/crates/proof-compression/src/pipeline/init_claim.rs b/crates/proof-compression/src/pipeline/init_claim.rs new file mode 100644 index 000000000..8a30d3f60 --- /dev/null +++ b/crates/proof-compression/src/pipeline/init_claim.rs @@ -0,0 +1,6 @@ +//! Independently expected public values of the Init proof fixture. +pub(crate) const INIT_PUBLIC_WORDS: [u64; 18] = [ + 0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, + 1176003176, 683533119, 3085659920, 1422858688, 3165160492, 1899567894, + 784981652, 376864732, 1915641123, 2034217541, 2163067803, +]; diff --git a/crates/proof-compression/src/pipeline/init_fri.rs b/crates/proof-compression/src/pipeline/init_fri.rs new file mode 100644 index 000000000..c7d6fc5a1 --- /dev/null +++ b/crates/proof-compression/src/pipeline/init_fri.rs @@ -0,0 +1,92 @@ +//! The saved intermediate FRI proof and the statement it exposes. +use super::stages::statement::decode_claims; +use ix_proof_compression::fri::*; +use multi_stark::prover::Proof; +use multi_stark::types::GoldilocksBlake3Config; +use multi_stark::types::Val; +use p3_field::PrimeCharacteristicRing; +use std::{fs, path::Path}; + +pub(crate) struct Fixture { + pub key: VerifierKey, + pub proof: Proof, + pub public: [Val; 18], + pub claims: Vec>, + pub profile: ProofProfile, + pub schema: Statement, +} + +#[tracing::instrument( + level = "info", + skip_all, + name = "compression/load_input" +)] +pub(crate) fn load(dir: &Path) -> Result> { + let key = VerifierKey::from_bytes(&fs::read(dir.join("outer-vk.bin"))?)?; + let bytes = fs::read(dir.join("outer-proof.bin"))?; + let proof = Proof::::from_bytes(&bytes)?; + if proof.to_bytes()? != bytes { + return Err("noncanonical proof".into()); + } + let claims = decode_claims(&fs::read(dir.join("outer-claims.bin"))?)?; + let statement = + super::stages::statement::Statement::from_claims(&claims, 18)?; + let public: [Val; 18] = statement + .public_values(&claims)? + .try_into() + .map_err(|_words| "compressed proof does not expose 18 statement words")?; + if statement.template[0][0] + != Val::from_u8(super::stages::construct::FRI_NAMESPACE) + { + return Err("compressed proof does not expose the statement rows".into()); + } + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + key + .system() + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("saved recursive proof: {e:?}"))?; + println!("Saved {}-byte recursive FRI proof verified", bytes.len()); + let profile = super::stages::construct::fri_profile(&key, &claims)?; + if proof.active != profile.active || proof.log_degrees != profile.log_degrees + { + return Err("compressed proof profile differs from the key".into()); + } + let schema = statement.schema(); + Ok(Fixture { key, proof, public, claims, profile, schema }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saved_init_fri_proof_and_public_statement_verify() { + let fixture = load( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/init-fri"), + ) + .unwrap(); + assert_eq!( + fixture.public, + super::super::init_claim::INIT_PUBLIC_WORDS.map(Val::from_u64) + ); + for i in 0..fixture.public.len() { + let mut wrong = fixture.claims.clone(); + wrong[i + 1][3] += Val::ONE; + assert!( + fixture + .key + .system() + .verify_multiple_claims( + &wrong.iter().map(Vec::as_slice).collect::>(), + &fixture.proof, + ) + .is_err(), + "altered public word {i} was accepted", + ); + } + let plan = + VerifierPlan::validate(&fixture.key, fixture.profile, Default::default()) + .unwrap(); + plan.identity(&fixture.schema).unwrap(); + } +} diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index ed59bd03d..62dafec15 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -1,413 +1,302 @@ -//! Compression stages for aggregated Ix certificates. -//! -//! The caller supplies the trusted aggregate key, expected outer claim and -//! proving parameters. No certificate, filesystem layout or development SRS -//! is built into these stages. Verifiers returned by local construction must -//! be authenticated before use elsewhere; a prover-supplied key is not trusted. -mod codec; -mod fixed; +//! Compression and verification of aggregate root proofs. +mod stages; +pub use stages::{ + FixedRoot, FriVerifier, KzgVerifier, Prepared, RecursiveProof, + RecursiveStage, RecursiveVerifier, prepare_kzg, prepare_recursive, + prepare_root, setup, +}; + +mod first; +mod fixed_cache; +#[cfg(test)] +mod init_claim; +mod init_fri; +mod prover; mod root; mod setup; -#[cfg(test)] -mod tests; +mod statement; +mod storage; +mod verifier; mod wrap; -pub use fixed::FixedRoot; -pub use root::prepare_root; -pub use setup::setup; -pub use wrap::{ - RecursiveProof, RecursiveStage, RecursiveVerifier, prepare_recursive, +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, }; -use crate::foreign::{GoldilocksCircuit, KzgCircuitExt}; -use crate::fri::{self, VerifierKey}; -use crate::kzg::{KzgConfig, Scalar, Srs, VerifierParams}; -use multi_stark::config::ProofConfig; -use multi_stark::lookup::LookupValues; -use multi_stark::plonkish::{Assignment, Circuit, MultiStarkCircuit}; -use multi_stark::prover::{Proof, Stage1}; -use multi_stark::system::{ProverKey, System, SystemWitness}; -use multi_stark::traits::{Algebra, Field, Pcs}; -use multi_stark::types::{GoldilocksBlake3Config, Val}; -use p3_field::PrimeField64; -use p3_matrix::Matrix; -use std::sync::Arc; - +pub const PROOF_TAG: &[u8; 8] = b"IXCMP\0\0\x04"; pub type Result = std::result::Result>; -// System::new asserts these conditions. Check caller-selected parameters -// before committing matrices so an unsupported configuration is an error. -fn validate_config( - config: &SC, - definitions: &[multi_stark::system::CircuitInputs< - multi_stark::config::Val, - >], -) -> Result<()> { - let params = multi_stark::system::extension_params::(); - for definition in definitions { - let graph = definition - .compile_graph(¶ms) - .map_err(|e| format!("compression circuit: {e:?}"))?; - let degree = - graph.max_constraint_degree.max(multi_stark::lookup::logup_max_degree( - &graph, - definition.lookup_group_size, - )) as usize; - let quotient = (degree.max(2) - 1).next_power_of_two(); - if quotient > config.max_quotient_degree() { - return Err(format!("compression circuit requires quotient degree {quotient}; increase the PCS degree budget").into()); - } - if let Some(fixed) = &definition.preprocessed { - let height = fixed.height(); - if !height.is_power_of_two() - || height.ilog2() as usize > config.max_log_degree() - || height.ilog2() as usize + quotient.ilog2() as usize - > config.max_log_quotient_domain() - { - return Err("compression trace exceeds the PCS domain".into()); +pub fn command(stage: &str, args: &[String]) -> Result<()> { + configure_backend(stage)?; + match stage { + "fri" => root::run(args), + "kzg" | "wrap" => { + let config = WorkerConfig::from_env( + stage, + args.iter().any(|arg| arg == "--dev-srs"), + )?; + let args: Vec<_> = + args.iter().filter(|arg| *arg != "--dev-srs").cloned().collect(); + if stage == "kzg" { + first::run(&args, &config) + } else { + wrap::run(&args, &config) } - } + }, + _ => Err("unknown compression stage".into()), } - Ok(()) } -/// A checked circuit and its witness, ready for lowering and proving. -/// Keeping preparation separate lets callers inspect costs and choose an SRS -/// and partition height before allocating prover data. -pub struct Prepared { - circuit: Circuit, - assignment: Assignment, +pub(crate) struct WorkerConfig { + setup: setup::SetupConfig, + fixed_cache_root: Option, + prefetch_bytes: usize, } -impl Prepared { - pub fn circuit(&self) -> &Circuit { - &self.circuit - } - - pub fn public_values(&self) -> &[F] { - self.assignment.public_values() +impl WorkerConfig { + fn from_env(stage: &str, development: bool) -> Result { + let setup = setup::SetupConfig::from_env(development)?; + let prefetch_gib = if stage == "kzg" { + match std::env::var("IX_KZG_PREFETCH_GIB") { + Ok(value) => value.parse::()?, + Err(std::env::VarError::NotPresent) => 32, + Err(error) => return Err(error.into()), + } + } else { + 0 + }; + Ok(Self { + setup, + fixed_cache_root: std::env::var_os("IX_KZG_FIXED_CACHE") + .map(PathBuf::from), + prefetch_bytes: prefetch_gib + .checked_mul(1 << 30) + .ok_or("prefetch budget overflow")?, + }) } -} -/// The lowering's complete claim template, including all activation anchors. -/// Only public-value cells may be replaced during verification. -#[derive(serde::Serialize, serde::Deserialize)] -struct Statement { - template: Vec>, - locations: Vec<(usize, usize)>, -} - -impl Statement { - fn from_lowered( - circuit: &MultiStarkCircuit, - count: usize, - ) -> Result { - let template = circuit.claims(&vec![F::ZERO; count])?; - // MultiStarkCircuit::claims places the anchor first, then public cells. - // Obtain every other claim (partition/hash activation) from the lowering. - let locations: Vec<_> = (1..=count).map(|i| (i, 3)).collect(); - if template.len() <= count - || template[..=count].iter().any(|row| row.len() != 4) - { - return Err("unsupported lowering claim schema".into()); - } - Ok(Self { template, locations }) + fn fixed_cache( + &self, + stage: &Path, + profile: &[u8], + ) -> Result { + let root = self + .fixed_cache_root + .clone() + .unwrap_or_else(|| stage.join("kzg/fixed-cache")); + fixed_cache::FixedCache::from_profile(&root, profile) } - fn claims(&self, public: &[F]) -> Result>> { - if public.len() != self.locations.len() { - return Err("unexpected public statement length".into()); - } - let mut claims = self.template.clone(); - for (&(row, col), &value) in self.locations.iter().zip(public) { - claims[row][col] = value; + #[cfg(test)] + const fn development() -> Self { + Self { + setup: setup::SetupConfig::Development { cache_directory: None }, + fixed_cache_root: None, + prefetch_bytes: 0, } - Ok(claims) } } -/// Trusted verifier for the first, FRI-compressed stage. -pub struct FriVerifier { - key: VerifierKey, - statement: Statement, +#[allow(clippy::unnecessary_wraps)] +fn configure_backend(_stage: &str) -> Result<()> { + Ok(()) } -impl FriVerifier { - fn schema(&self) -> fri::Statement { - let mut schema = fri::Statement { - claims: self - .statement - .template - .iter() - .map(|row| { - row - .iter() - .copied() - .map(fri::StatementSlot::Constant) - .collect::>() - }) - .collect::>(), - messages: vec![], - }; - for &(row, col) in &self.statement.locations { - schema.claims[row][col] = fri::StatementSlot::Public; - } - schema - } +/// The stage worker executable installed beside `ix`. +pub const WORKER_EXECUTABLE: &str = "ix-compression-worker"; - pub fn verify( - &self, - proof: &Proof, - expected: &[Val], - ) -> Result<()> { - let claims = self.statement.claims(expected)?; - self - .key - .system() - .verify_multiple_claims( - &claims.iter().map(Vec::as_slice).collect::>(), - proof, - ) - .map_err(|e| format!("FRI compression proof: {e:?}"))?; - Ok(()) +/// The stage worker: `IX_COMPRESSION_WORKER` when set, otherwise the +/// executable of that name beside the running one. Stages run there rather +/// than in this process so the Lean executable links no KZG proving code. +fn worker_executable() -> Result { + if let Some(path) = std::env::var_os("IX_COMPRESSION_WORKER") { + return Ok(PathBuf::from(path)); } + let current = std::env::current_exe()?; + let sibling = current.with_file_name(WORKER_EXECUTABLE); + if sibling.is_file() { + return Ok(sibling); + } + Err( + format!( + "{WORKER_EXECUTABLE} not found beside {}; build it with the ix \ + executable or name it in IX_COMPRESSION_WORKER", + current.display() + ) + .into(), + ) } -impl Prepared { - /// Prove the root-verifier circuit using caller-selected FRI parameters. - pub fn prove_fri( - self, - config: GoldilocksBlake3Config, - max_height: usize, - ) -> Result<(FriVerifier, Proof)> { - use multi_stark::config::StarkGenericConfig; - fri::validate_parameters( - multi_stark::types::CommitmentParameters { - log_blowup: config.log_blowup(), - cap_height: config.cap_height(), - }, - config.fri_parameters(), - )?; - let compiled = - self.circuit.lower_to_multi_stark_sharded(Val::ONE, max_height)?; - let statement = Statement::from_lowered( - &compiled, - self.assignment.public_values().len(), - )?; - let claims = statement.claims(self.assignment.public_values())?; - let definitions = compiled.circuit_inputs(); - validate_config(&config, &definitions)?; - let traces = compiled.traces(&self.assignment)?; - drop(compiled); - drop(self.assignment); - let (mut system, key) = System::new(config, definitions); - let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); - let witness = SystemWitness::from_stage_1(traces, &system); - // Lookup messages now own everything needed from the fixed evaluations. - // Quotients/openings use PCS data; the returned verifier needs neither. - for circuit in &mut system.circuits { - circuit.preprocessed = None; - } - let proof = system.prove_multiple_claims(&key, &refs, witness); - drop(key); - system - .verify_multiple_claims(&refs, &proof) - .map_err(|e| format!("new FRI proof: {e:?}"))?; - Ok((FriVerifier { key: VerifierKey::new(system), statement }, proof)) +fn child(stage: &str, args: &[&str], caches: &Caches) -> Result<()> { + let status = Command::new(worker_executable()?) + .arg(stage) + .args(args) + .env("IX_KZG_FIXED_CACHE", &caches.fixed) + .env("IX_KZG_SRS_CACHE", &caches.development_srs) + .stdout(Stdio::from(std::io::stderr())) + .stderr(Stdio::inherit()) + .status()?; + if !status.success() { + return Err( + format!("compression {stage} failed ({status}); checkpoints retained") + .into(), + ); } + Ok(()) } -/// Prepare a FRI verifier over the BLS12-381 scalar field. The statement and -/// all activation anchors come from the trusted previous stage's verifier. -pub fn prepare_kzg( - verifier: &FriVerifier, - proof: &Proof, - expected: &[Val], - limits: fri::VerifierLimits, -) -> Result> { - verifier.verify(proof, expected)?; - let claims = verifier.statement.claims(expected)?; - let profile = fri::ProofProfile { - envelope: fri::Envelope::Ordinary, - active: proof.active.clone(), - log_degrees: proof.log_degrees.clone(), - claim_lengths: claims.iter().map(Vec::len).collect(), - message_lengths: vec![], - max_field_retries: fri::DEFAULT_FIELD_RETRIES, - }; - let plan = fri::VerifierPlan::validate(&verifier.key, profile, limits)?; - let schema = verifier.schema(); - let (circuit, inputs) = plan.build(schema, Default::default())?; - let expanded = plan - .expand_witness(fri::ProofEnvelope::Ordinary { proof, claims: &claims })?; - let mut witness = circuit.witness(); - inputs.assign_statement( - &mut witness, - &fri::Statement { claims, messages: vec![] }, - )?; - expanded.assign_proof(&mut witness, &inputs)?; - drop(expanded); - drop(inputs); - drop(plan); - let assignment = witness.generate()?; - let GoldilocksCircuit { circuit: foreign, inputs } = - GoldilocksCircuit::new_preallocated(&circuit); - drop(circuit); - let mut witness = foreign.witness(); - inputs.assign(&assignment, &mut witness)?; - drop(inputs); - drop(assignment); - let assignment = witness.generate()?; - Ok(Prepared { circuit: foreign, assignment }) +/// The statement a compressed proof of `claim` exposes, as the caller +/// derived it from its aggregation backend, checked to end in the packed +/// digest of the claim itself. +pub fn check_claim( + claim: &[u8], + statement: &[u8], +) -> Result { + let words = statement::from_claims_bytes(statement)?; + statement::bind_claim(claim, &words)?; + Ok(words) } -/// Trusted verifier for a scalar-field compression stage. It retains only -/// verification parameters, never the SRS proving powers or fixed matrices. -pub struct KzgVerifier { - system: System, - params: VerifierParams, - statement: Statement, +/// Describe the setup a new run would prove under, or why none is allowed. +pub fn setup_description(development: bool) -> Result<&'static str> { + Ok(setup::SetupConfig::from_env(development)?.class().description()) } -impl KzgVerifier { - fn codec(&self) -> Result { - let logs = self - .system - .circuits - .iter() - .map(|c| { - u8::try_from(c.preprocessed_height.ilog2()) - .expect("validated trace height") - }) - .collect::>(); - Ok(crate::kzg::compact::FixedProofCodec::new(&self.system, &logs)?) - } - - /// Fixed-shape transport under this trusted key; encoding is not verification. - pub fn encode_proof(&self, proof: &Proof) -> Result> { - Ok(self.codec()?.encode(proof)?) - } +/// Verify a compressed proof of `claim` with the profile bundled under the +/// packet's id, needing neither the proving backend nor any file. +pub fn verify( + proof: &[u8], + claim: &[u8], + allow_development: bool, +) -> Result { + let packet = proof + .strip_prefix(PROOF_TAG) + .ok_or("unsupported compressed proof version")?; + verifier::verify(packet, claim, allow_development) +} - pub fn verify_bytes(&self, bytes: &[u8], expected: &[Scalar]) -> Result<()> { - self.verify(&self.codec()?.decode(bytes)?, expected) - } +/// Cross-run caches the stages share: fixed preprocessing keyed by the +/// executable and profile, and development SRS files. Variables already set +/// in the environment take precedence over the directory given to `compress`. +struct Caches { + fixed: PathBuf, + development_srs: PathBuf, +} - pub fn verify( - &self, - proof: &Proof, - expected: &[Scalar], - ) -> Result<()> { - let claims = self.statement.claims(expected)?; - self - .system - .verify_multiple_claims( - &claims.iter().map(Vec::as_slice).collect::>(), - proof, - ) - .map_err(|e| format!("KZG compression proof: {e:?}"))?; - Ok(()) +impl Caches { + fn under(root: &Path) -> Self { + let named = |name: &str, default: &str| { + std::env::var_os(name).map_or_else(|| root.join(default), PathBuf::from) + }; + Self { + fixed: named("IX_KZG_FIXED_CACHE", "fixed-preprocessing"), + development_srs: named("IX_KZG_SRS_CACHE", "development-srs"), + } } } -impl Prepared { - /// Parameters must have trusted provenance and pass Srs::validate before - /// use. Partitioning is bounded by both max_height and the supplied SRS. - pub fn prove_kzg( - self, - srs: Arc, - max_height: usize, - ) -> Result<(KzgVerifier, Proof)> { - if srs.max_len() < 2 - || !srs.max_len().is_power_of_two() - || srs.degree_keys.len() != srs.max_len().ilog2() as usize + 1 - { - return Err("invalid SRS shape".into()); - } - if max_height < 2 - || !max_height.is_power_of_two() - || max_height > srs.max_len() - { - return Err( - "partition height must be a power of two within the SRS range".into(), - ); - } - let compiled = self - .circuit - .lower_to_multi_stark_sharded(Scalar::ONE, max_height)? - .merge_table_traces(max_height)?; - let statement = Statement::from_lowered( - &compiled, - self.assignment.public_values().len(), - )?; - let claims = statement.claims(self.assignment.public_values())?; - let params = VerifierParams::from_srs(&srs); - let config = - KzgConfig::new(srs, 2).with_streaming_lookups().with_streaming_quotient(); - // Commit one partition at a time. Lookup traces are reconstructed from - // committed columns, so neither fixed evaluations nor expanded lookup - // messages need to remain resident alongside all prover coefficients. - let source = compiled.trace_shards(&self.assignment)?; - let count = compiled.num_circuits(); - let mut circuits = Vec::with_capacity(count); - let mut fixed = Vec::with_capacity(count); - let mut main = Vec::with_capacity(count); - let mut logs = Vec::with_capacity(count); - let mut lookups = Vec::with_capacity(count); - for i in 0..count { - let definition = compiled - .kzg_circuit_input(i, params.max_len(), 2)? - .ok_or("missing compression circuit")?; - validate_config(&config, std::slice::from_ref(&definition))?; - let (mut local, key) = - System::new_without_preprocessed(config.clone(), [definition]); - let circuit = local.circuits.remove(0); - fixed.push(key.preprocessed_data.ok_or("missing fixed columns")?); - let trace = source.trace(i)?; - let height = trace.height(); - logs.push(height.ilog2() as usize); - lookups.push(LookupValues::shape_only( - height, - &circuit.graph.lookups.iter().map(|l| l.args.len()).collect::>(), - )); - let (_, data) = config - .pcs() - .commit(vec![(config.pcs().natural_domain_for_degree(height), trace)]); - main.push(data); - circuits.push(circuit); +/// Run the three stages into an empty `out` directory, each as a child +/// process so every heavy phase releases its memory and device state before +/// the next starts. Only each stage's proof and verifying metadata are +/// written; a rerun starts over, reusing the caches under `cache_root`. +pub fn compress( + vk: &[u8], + proof: &[u8], + claim: &[u8], + statement: &[u8], + out: &Path, + cache_root: &Path, + development: bool, + log_height: u32, +) -> Result> { + let words = check_claim(claim, statement)?; + if !matches!(log_height, 24 | 25) { + return Err( + "--log-height must be 24 or 25 for the supported recursive profiles" + .into(), + ); + } + // Child stages re-read the same environment; the policy is enforced once + // here so an unconfigured run fails before any checkpoint is written. + setup::SetupConfig::from_env(development)?; + if std::env::var_os("IX_ROOT_GENERIC_HASHES").is_some() { + return Err( + "unset IX_ROOT_GENERIC_HASHES for the supported compression profile" + .into(), + ); + } + fs::create_dir_all(out)?; + if fs::read_dir(out)?.next().is_some() { + return Err( + "output directory must be empty; use a new --out directory".into(), + ); + } + let caches = Caches::under(cache_root); + let root = out.join("root"); + fs::create_dir_all(&root)?; + fs::write(root.join("root-vk.bin"), vk)?; + fs::write(root.join("root-proof.bin"), proof)?; + fs::write(root.join("root-claims.bin"), statement::to_claims_bytes(&words))?; + let fri = out.join("fri"); + let first = out.join("first-kzg"); + let final_dir = out.join("final-kzg"); + let path = |p: &Path| -> Result { + Ok(p.to_str().ok_or("non-UTF8 output path")?.to_owned()) + }; + let (root_s, fri_s, first_s, final_s) = + (path(&root)?, path(&fri)?, path(&first)?, path(&final_dir)?); + // Each KZG worker selects its setup from the forwarded development flag + // and cache configuration. + let kzg = |stage, args: &[&str]| { + let mut args = args.to_vec(); + if development { + args.push("--dev-srs"); } - drop(compiled); - drop(self.assignment); - let (fixed_commitment, fixed_data) = - crate::kzg::pcs::KzgProverData::concatenate(fixed); - let (stage_1_trace_commit, stage_1_trace_data) = - crate::kzg::pcs::KzgProverData::concatenate(main); - let mut system = System { - config, - circuits, - preprocessed_commit: Some(fixed_commitment), - preprocessed_indices: (0..count).map(Some).collect(), - }; - let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); - let proof = system.prove_committed( - &ProverKey { preprocessed_data: Some(fixed_data) }, - &refs, - Stage1 { - active: vec![true; count], - active_indices: (0..count).collect(), - log_degrees: logs, - stage_1_trace_commit, - stage_1_trace_data, - lookups, - }, + child(stage, &args, &caches) + }; + child("fri", &[&root_s, &fri_s, "--prove-outer"], &caches)?; + let height = log_height.to_string(); + kzg("kzg", &["run", &fri_s, &first_s, &height])?; + kzg("wrap", &["stage-and-prove", &first_s, &final_s])?; + let mut result = PROOF_TAG.to_vec(); + result.extend(fs::read(final_dir.join("kzg/packet.bin"))?); + let packet = &result[PROOF_TAG.len()..]; + wrap::verify(&final_dir, packet, Some(&words))?; + // A bundled profile pins the aggregation program its proofs attest; the + // backend this run proved under must be that program, or the bundle is + // stale and verification elsewhere would accept the wrong statement. + if let Some(prefix) = verifier::bundled_prefix(packet[..32].try_into()?) + && *prefix != words[..10] + { + return Err( + "the bundled verifier profile pins a different aggregation program \ + than this build's backend; update the bundle" + .into(), ); - system - .verify_multiple_claims(&refs, &proof) - .map_err(|e| format!("new KZG proof: {e:?}"))?; - system.config = KzgConfig::verifier_only(params.clone(), 2); - Ok((KzgVerifier { system, params, statement }, proof)) } + Ok(result) } -fn scalar_statement(expected: &[Val]) -> Vec { - expected.iter().map(|v| Scalar::from_u64(v.as_canonical_u64())).collect() +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unsupported_claim_is_rejected_before_io() { + assert!( + compress( + b"", + b"", + b"wrong", + &statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"wrong")), + Path::new("/does-not-exist"), + Path::new("/does-not-exist"), + true, + 24 + ) + .is_err() + ); + assert!(verify(PROOF_TAG, b"wrong", true).is_err()); + } } diff --git a/crates/proof-compression/src/pipeline/prover.rs b/crates/proof-compression/src/pipeline/prover.rs new file mode 100644 index 000000000..2a04888a8 --- /dev/null +++ b/crates/proof-compression/src/pipeline/prover.rs @@ -0,0 +1,456 @@ +//! Partitioned KZG commitment, proving and verification. +use super::{Result, storage}; +use crate::{ + foreign::KzgCircuitExt, + kzg::{ + KzgCommitment, KzgConfig, Scalar, compact::FixedProofCodec, + pcs::KzgProverData, + }, +}; +use multi_stark::{ + config::ProofConfig, + lookup::LookupValues, + plonkish::{MultiStarkCircuit, TraceShards}, + prover::{Proof, Stage1}, + system::{Circuit, ProverKey, System}, + traits::Pcs, +}; +use p3_matrix::Matrix; +use std::{ + fs::{self, File}, + io::{BufWriter, Write}, + path::Path, + time::Instant, +}; + +/// Committed columns own their data, so the circuit and assignment can be +/// released before allocating lookup and quotient working sets. +pub(super) struct Committed { + system: System, + key: ProverKey, + stage: Stage1, +} + +pub(super) struct Proved { + pub system: System, + pub proof: Proof, + pub codec: FixedProofCodec, + pub bytes: Vec, + pub verify_seconds: f64, +} + +impl Committed { + /// Commit one partition at a time. A trusted preprocessing directory holds + /// only fixed columns and circuit metadata; witness columns stay in memory. + /// Prefetch overlaps the next witness with the current partition's commits + /// only when that witness fits within the byte budget. + #[tracing::instrument(level = "info", skip_all, name = "compression/prepare")] + pub(super) fn prepare( + config: KzgConfig, + traces: &TraceShards<'_, Scalar>, + preprocessing: Option<&Path>, + prefetch_bytes: usize, + ) -> Result { + let compiled = traces.compiled(); + let count = compiled.num_circuits(); + let dimensions = (0..count) + .map(|i| compiled.trace_dimensions(i).ok_or("missing compiled trace")) + .collect::, _>>()?; + if let Some(directory) = preprocessing { + fs::create_dir_all(directory)?; + } + let mut circuits = Vec::with_capacity(count); + let mut fixed = Vec::with_capacity(count); + let mut main = Vec::with_capacity(count); + let parent = tracing::Span::current(); + let dispatch = tracing::dispatcher::get_default(Clone::clone); + let generate_witness = |i: usize| -> std::result::Result<_, String> { + let _span = + tracing::info_span!(parent: &parent, "compression/trace", circuit = i) + .entered(); + let started = Instant::now(); + let trace = traces.trace(i).map_err(|error| error.to_string())?; + if (trace.height(), trace.width()) != dimensions[i] { + return Err( + "generated trace dimensions differ from the compiled circuit".into(), + ); + } + tracing::info!( + circuit = i, + seconds = started.elapsed().as_secs_f64(), + "KZG witness generated" + ); + Ok(trace) + }; + let mut prefetched = None; + for i in 0..count { + // Without prefetch, generate fixed columns before the witness so their + // full evaluation matrices never need to coexist in memory. + let witness = if prefetch_bytes == 0 { + None + } else { + Some(prefetched.take().unwrap_or_else(|| generate_witness(i))?) + }; + let prepare = || -> Result<()> { + let started = Instant::now(); + let (circuit, data) = tracing::info_span!(parent: &parent, "compression/fixed", circuit = i) + .in_scope(|| fixed_partition(&config, compiled, i, preprocessing))?; + circuits.push(circuit); + fixed.push(data); + tracing::info!( + circuit = i, + seconds = started.elapsed().as_secs_f64(), + "KZG fixed prepared" + ); + let witness = witness.map_or_else(|| generate_witness(i), Ok)?; + let started = Instant::now(); + let (_, data) = tracing::info_span!(parent: &parent, "compression/main_commit", circuit = i) + .in_scope(|| config.pcs().commit(vec![( + config.pcs().natural_domain_for_degree(dimensions[i].0), witness, + )])); + main.push(data); + tracing::info!( + circuit = i, + seconds = started.elapsed().as_secs_f64(), + "KZG main committed" + ); + Ok(()) + }; + let can_prefetch = + dimensions.get(i + 1).is_some_and(|&(height, width)| { + height + .checked_mul(width) + .and_then(|values| values.checked_mul(size_of::())) + .is_some_and(|bytes| bytes <= prefetch_bytes) + }); + if can_prefetch { + let (prepared, next) = p3_maybe_rayon::prelude::join( + || { + tracing::dispatcher::with_default(&dispatch, || { + prepare().map_err(|error| error.to_string()) + }) + }, + || { + tracing::dispatcher::with_default(&dispatch, || { + generate_witness(i + 1) + }) + }, + ); + prepared?; + prefetched = Some(next); + } else { + prepare()?; + } + } + let lookups = circuits + .iter() + .zip(&dimensions) + .map(|(c, &(height, _))| { + LookupValues::shape_only( + height, + &c.graph.lookups.iter().map(|l| l.args.len()).collect::>(), + ) + }) + .collect(); + let (fixed_commitment, fixed_data) = KzgProverData::concatenate(fixed); + let (stage_1_trace_commit, stage_1_trace_data) = + KzgProverData::concatenate(main); + Ok(Self { + system: System { + config, + circuits, + preprocessed_commit: Some(fixed_commitment), + preprocessed_indices: (0..count).map(Some).collect(), + }, + key: ProverKey { preprocessed_data: Some(fixed_data) }, + stage: Stage1 { + active: vec![true; count], + active_indices: (0..count).collect(), + log_degrees: dimensions + .iter() + .map(|&(height, _)| height.ilog2() as usize) + .collect(), + stage_1_trace_commit, + stage_1_trace_data, + lookups, + }, + }) + } + + pub(super) fn prove(self, claims: &[Vec]) -> Result { + let Self { system, key, stage } = self; + let logs = stage + .log_degrees + .iter() + .map(|&log| u8::try_from(log)) + .collect::, _>>()?; + let codec = FixedProofCodec::new(&system, &logs)?; + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let started = Instant::now(); + let proof = tracing::info_span!("compression/prove") + .in_scope(|| system.prove_committed(&key, &refs, stage)); + tracing::info!( + seconds = started.elapsed().as_secs_f64(), + "KZG proof computed" + ); + let bytes = codec.encode(&proof)?; + let proof = codec.decode(&bytes)?; + let started = Instant::now(); + tracing::info_span!("compression/verify") + .in_scope(|| system.verify_multiple_claims(&refs, &proof)) + .map_err(|error| format!("encoded KZG proof: {error:?}"))?; + Ok(Proved { + system, + proof, + codec, + bytes, + verify_seconds: started.elapsed().as_secs_f64(), + }) + } +} + +/// Commit each fixed partition without allocating a witness. +pub(super) fn setup( + config: KzgConfig, + compiled: &MultiStarkCircuit, +) -> Result> { + let count = compiled.num_circuits(); + let mut circuits = Vec::with_capacity(count); + let mut fixed = KzgCommitment(vec![]); + for i in 0..count { + let (circuit, data) = fixed_partition(&config, compiled, i, None)?; + let (commitment, _data) = KzgProverData::concatenate([data]); + fixed.0.extend(commitment.0); + circuits.push(circuit); + } + Ok(System { + config, + circuits, + preprocessed_commit: Some(fixed), + preprocessed_indices: (0..count).map(Some).collect(), + }) +} + +fn fixed_partition( + config: &KzgConfig, + compiled: &MultiStarkCircuit, + index: usize, + preprocessing: Option<&Path>, +) -> Result<(Circuit, KzgProverData)> { + let paths = preprocessing.map(|dir| { + ( + dir.join(format!("setup-{index}.bin")), + dir.join(format!("fixed-{index}.bin")), + ) + }); + if let Some((meta, fixed)) = &paths + && meta.exists() + && fixed.exists() + { + let (circuit, commitment): (Circuit, KzgCommitment) = + storage::load(meta)?; + if compiled.trace_dimensions(index) + != Some((circuit.preprocessed_height, circuit.main_width)) + { + return Err( + "setup checkpoint dimensions differ from the compiled circuit".into(), + ); + } + let data = read_checkpoint(fixed)?; + let (actual, data) = KzgProverData::concatenate([data]); + if actual != commitment { + return Err( + "fixed checkpoint commitment differs from the setup metadata".into(), + ); + } + return Ok((circuit, data)); + } + let definition = compiled + .kzg_circuit_input( + index, + config.pcs().verifier_params().max_trace_len(), + config.max_quotient_degree(), + )? + .ok_or("missing compiled fixed trace")?; + validate_config(config, std::slice::from_ref(&definition))?; + let (mut local, key) = + System::new_without_preprocessed(config.clone(), [definition]); + let mut circuit = local.circuits.remove(0); + circuit.preprocessed = None; + let mut data = key.preprocessed_data.ok_or("missing fixed columns")?; + if let Some((meta, fixed)) = paths { + let temp = storage::temporary_path(&fixed); + let mut file = BufWriter::with_capacity(1 << 20, File::create(&temp)?); + data.write_checkpoint(&mut file)?; + file.flush()?; + drop(file); + storage::persist(&temp, &fixed)?; + drop(data); + data = read_checkpoint(&fixed)?; + storage::save( + &meta, + &(&circuit, local.preprocessed_commit.ok_or("missing fixed commitment")?), + )?; + } + Ok((circuit, data)) +} + +/// CPU proving streams the fixed coefficients from disk. +fn read_checkpoint(path: &Path) -> Result { + Ok(KzgProverData::read_checkpoint_file(path)?) +} + +// System::new asserts these conditions. Check caller-selected parameters +// before committing matrices so an unsupported configuration is an error. +pub(super) fn validate_config( + config: &SC, + definitions: &[multi_stark::system::CircuitInputs< + multi_stark::config::Val, + >], +) -> Result<()> { + let params = multi_stark::system::extension_params::(); + for definition in definitions { + let graph = definition + .compile_graph(¶ms) + .map_err(|e| format!("compression circuit: {e:?}"))?; + let degree = + graph.max_constraint_degree.max(multi_stark::lookup::logup_max_degree( + &graph, + definition.lookup_group_size, + )) as usize; + let quotient = (degree.max(2) - 1).next_power_of_two(); + if quotient > config.max_quotient_degree() { + return Err(format!("compression circuit requires quotient degree {quotient}; increase the PCS degree budget").into()); + } + if let Some(fixed) = &definition.preprocessed { + let height = fixed.height(); + if !height.is_power_of_two() + || height.ilog2() as usize > config.max_log_degree() + || height.ilog2() as usize + quotient.ilog2() as usize + > config.max_log_quotient_domain() + { + return Err("compression trace exceeds the PCS domain".into()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::{Srs, TranscriptKind}; + use multi_stark::{ + plonkish::CircuitBuilder, + system::SystemWitness, + traits::{Algebra, Field}, + }; + use std::sync::Arc; + + #[derive(Clone, Default)] + struct TraceOutput(Arc>>); + + impl Write for TraceOutput { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn partitioned_proving_preserves_bytes_with_prefetch_and_checkpoints() + -> Result<()> { + use tracing_subscriber::layer::SubscriberExt; + let output = TraceOutput::default(); + let subscriber = tracing_subscriber::registry().with( + tracing_texray::TeXRayLayer::new().streaming().writer(output.clone()), + ); + tracing::subscriber::set_global_default(subscriber)?; + let partitions = + tracing_texray::examine(tracing::info_span!("compression/test")) + .in_scope(parity_fixture)?; + let text = String::from_utf8(output.0.lock().unwrap().clone())?; + for name in + ["compression/trace", "compression/fixed", "compression/main_commit"] + { + assert_eq!( + text.matches(&format!("[texray] {name}:")).count(), + 10 * partitions, + "{text}" + ); + } + for name in [ + "compression/prove", + "compression/verify", + "stark/lookup_construction", + "stark/quotient", + "stark/fri_open", + ] { + assert!(text.contains(&format!("[texray] {name}:")), "{text}"); + } + Ok(()) + } + + fn parity_fixture() -> Result { + let mut builder = CircuitBuilder::new(); + let table = + builder.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let input = builder.public_input("input"); + for _ in 0..50 { + builder.lookup(table, &[input]); + } + let square = builder.mul(input, input); + builder.expose_public(square); + let compiled = builder + .finish() + .lower_to_multi_stark_sharded(Scalar::ONE, 32)? + .merge_table_traces(32)?; + assert!(compiled.num_circuits() > 2); + let mut witness = compiled.witness(); + witness.set(input, Scalar::TWO)?; + let assignment = witness.generate()?; + let claims = compiled.claims(assignment.public_values())?; + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let srs = Arc::new(Srs::unsafe_dev_setup(64, b"partitioned-prover")); + for kind in [TranscriptKind::Blake3, TranscriptKind::Keccak256] { + let config = KzgConfig::new(srs.clone(), 2).with_transcript(kind); + let (reference, key) = + System::new(config.clone(), compiled.kzg_circuit_inputs(64, 2)?); + let proof = reference.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(compiled.traces(&assignment)?, &reference), + ); + let expected = + FixedProofCodec::new(&reference, &proof.log_degrees)?.encode(&proof)?; + let directory = + storage::temporary_path(&std::env::temp_dir().join("ix-kzg-prover")); + let traces = compiled.trace_shards(&assignment)?; + for (preprocessing, prefetch) in [ + (None, 0), + (None, 1), + (None, usize::MAX), + (Some(directory.as_path()), 0), + (Some(directory.as_path()), usize::MAX), + ] { + let result = Committed::prepare( + config.clone().with_streaming_lookups().with_streaming_quotient(), + &traces, + preprocessing, + prefetch, + )? + .prove(&claims)?; + assert_eq!(result.bytes, expected); + assert_eq!(result.system.config.transcript_kind(), kind); + reference + .verify_multiple_claims(&refs, &result.proof) + .map_err(|error| format!("reference verification: {error:?}"))?; + } + fs::remove_dir_all(directory)?; + } + Ok(compiled.num_circuits()) + } +} diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index 4e3f521b7..ad780b864 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -1,103 +1,196 @@ -use super::{Prepared, Result}; -use crate::fri::*; -use aiur::{synthesis::AiurProof, vk_codec::AiurVerifyingKey}; -use multi_stark::plonkish::CircuitBuilder; +//! FRI compression of an exported aggregate root proof. +use super::stages::statement::decode_claims; +use crate::fri::ImplementationOptions; +use crate::fri::VerifierKey; +use crate::fri::VerifierLimits; +use multi_stark::batch::BatchProof; +use multi_stark::types::GoldilocksBlake3Config; use multi_stark::types::Val; +use p3_blake3::Blake3; +use p3_field::TwoAdicField; +use p3_symmetric::CryptographicHasher; +use std::{fs, path::PathBuf, time::Instant}; -/// Prepare compression of an aggregate root's Aiur proof, including every -/// trace shard. `expected` is the outer call claim reconstructed by the host -/// from its trusted aggregate entrypoint, allowed keys and certificate claim. -/// -/// Aiur policy is checked before construction. Activation, heights, claim -/// placement and memory-closure messages are then fixed into the circuit, so -/// its witness cannot bypass that policy. The claim values remain public. -pub fn prepare_root( - key: &AiurVerifyingKey, - proof: &AiurProof, - expected: &[Val], - limits: VerifierLimits, -) -> Result> { - key.verify(expected, proof).map_err(|e| format!("aggregate root: {e:?}"))?; - prepare_batch( - &VerifierKey::from_system(key.system()), - proof, - expected, - limits, - ) +/// Compiled worker features for deployment checks. +fn print_capabilities() { + println!( + concat!( + "{{\"format\":\"ix-compression-capabilities/v1\",\"binary\":\"ix-compression-worker\",", + "\"parallel\":{}}}" + ), + cfg!(feature = "parallel"), + ); } -fn prepare_batch( - key: &VerifierKey, - proof: &AiurProof, - expected: &[Val], - limits: VerifierLimits, -) -> Result> { - let mut shapes = Vec::new(); - let mut proof_values = 0usize; - for header in &proof.preamble.headers { - let plan = VerifierPlan::validate( - key, - ProofProfile { - envelope: Envelope::SingleBatch, - active: header.active.clone(), - log_degrees: header.log_degrees.clone(), - claim_lengths: header.claims.iter().map(Vec::len).collect(), - message_lengths: proof - .preamble - .messages - .iter() - .map(|m| m.args.len()) - .collect(), - max_field_retries: DEFAULT_FIELD_RETRIES, - }, - limits, - )?; - proof_values = proof_values - .checked_add(plan.resources().proof_values) - .ok_or("batch proof size overflow")?; - if proof_values > limits.max_proof_values { - return Err("batch exceeds proof value limit".into()); +/// Report the process's resident and virtual size every 30 seconds so a +/// long stage's memory profile is visible in its log. +fn spawn_memory_monitor() { + std::thread::spawn(|| { + loop { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(status) = fs::read_to_string("/proc/self/status") { + eprintln!( + "{}", + status + .lines() + .filter(|l| l.starts_with("VmRSS:") || l.starts_with("VmSize:")) + .collect::>() + .join(" ") + ); + } } - shapes.push(plan.shape().clone()); + }); +} + +pub(super) fn run(args: &[String]) -> Result<(), Box> { + if args.len() == 1 && args[0] == "capabilities" { + print_capabilities(); + return Ok(()); } - let expanded = expand_batch_witness(key.system(), &shapes, proof)?; - let mut b = CircuitBuilder::new(); - b.enable_compact_blake3(); - let public: Vec<_> = (0..expected.len()) - .map(|i| b.public_input(format!("claim[{i}]"))) - .collect(); - let claims: Vec<_> = proof - .preamble - .headers - .iter() - .map(|header| header.claims.iter().map(|_| public.clone()).collect()) - .collect(); - let bytes = ByteGadgets::new(&mut b); - let messages: Vec<_> = proof - .preamble - .messages + if args.len() < 2 { + return Err("usage: ix-compression-worker fri [--native-only | --check-only | --prove-outer]".into()); + } + let dir = PathBuf::from(&args[0]); + let out = PathBuf::from(&args[1]); + let mode = args.get(2).map_or("--check-only", String::as_str); + if args.len() > 3 + || !matches!(mode, "--native-only" | "--check-only" | "--prove-outer") + { + return Err("invalid mode".into()); + } + fs::create_dir_all(&out)?; + spawn_memory_monitor(); + let input_span = tracing::info_span!("compression/load_input").entered(); + let vk_bytes = fs::read(dir.join("root-vk.bin"))?; + let native_key = aiur::vk_codec::AiurVerifyingKey::from_bytes(&vk_bytes)?; + let system = native_key.system(); + let cp = native_key.commitment_parameters(); + let fp = native_key.fri_parameters(); + let bytes = fs::read(dir.join("root-proof.bin"))?; + let proof = BatchProof::::from_bytes(&bytes)?; + if proof.to_bytes()? != bytes { + return Err("noncanonical proof transport".into()); + } + let claims = decode_claims(&fs::read(dir.join("root-claims.bin"))?)?; + if claims.len() != 1 || claims[0].len() != super::statement::WORDS { + return Err("root claims must hold one statement of 18 words".into()); + } + drop(input_span); + let start = Instant::now(); + tracing::info_span!("compression/verify_input") + .in_scope(|| native_key.verify(&claims[0], &proof)) + .map_err(|e| format!("Aiur policy: {e:?}"))?; + println!( + "Native Ix root batch verified in {:?}: {} bytes, {} circuits, {} shards", + start.elapsed(), + bytes.len(), + system.circuits.len(), + proof.proofs.len() + ); + println!( + "log_blowup={} cap={} final_log={} arity={} queries={} commit_pow={} query_pow={}", + cp.log_blowup, + cp.cap_height, + fp.log_final_poly_len, + fp.max_log_arity, + fp.num_queries, + fp.commit_proof_of_work_bits, + fp.query_proof_of_work_bits + ); + println!("Trusted VK BLAKE3: {:02x?}", Blake3.hash_iter(vk_bytes)); + println!("Expected public claims: {claims:?}"); + println!( + "Fixed messages (validated Aiur memory-closure policy): {}", + proof.preamble.messages.len() + ); + if mode == "--native-only" { + return Ok(()); + } + let verifier_key = VerifierKey::from_system(system); + let plan = super::stages::root::RootPlan::new( + &verifier_key, + &proof, + VerifierLimits::default(), + )?; + let compact = std::env::var_os("IX_ROOT_GENERIC_HASHES").is_none(); + let options = ImplementationOptions { compact_blake3: compact }; + fs::write(out.join("inner-vk.bin"), verifier_key.to_bytes()?)?; + fs::write(out.join("plan-id.bin"), plan.identity()?)?; + fs::write(out.join("build-id.bin"), plan.build_identity(options)?)?; + fs::write(out.join("statement-schema.txt"), plan.description())?; + let start = Instant::now(); + let prepared = plan.prepare(&proof, &claims[0], options)?; + let max_trace_height = 1usize << 24; + let layout = + prepared.circuit().multi_stark_layout_with_max_height(max_trace_height)?; + println!( + "Circuit preparation: {:?}; {:?}", + start.elapsed(), + prepared.circuit().stats() + ); + fs::write( + out.join("circuit-cost.txt"), + format!("Stats: {:?}\nLayout: {layout:?}\n", prepared.circuit().stats()), + )?; + fs::write( + out.join("CHECKED"), + "all constraints satisfied; expected claims and query indices matched\n", + )?; + let largest_trace = *layout + .main_heights .iter() - .map(|m| Message { - args: m.args.iter().map(|&v| b.constant(v)).collect(), - multiplicity: b.constant(m.multiplicity), - }) - .collect(); - let inputs = constrain_batch_verifier( - &mut b, - &bytes, - key.system(), - &shapes, - claims, - &messages, + .chain(&layout.table_heights) + .chain(layout.custom_traces.iter().map(|(height, _, _)| height)) + .max() + .unwrap(); + let log_lde = largest_trace.ilog2() as usize + cp.log_blowup; + println!( + "Largest outer LDE: 2^{log_lde}; {} computation traces", + layout.main_heights.len() ); - let circuit = b.finish(); - let mut witness = circuit.witness(); - for (&wire, &value) in public.iter().zip(expected) { - witness.set(wire, value)?; + if log_lde > Val::TWO_ADICITY { + return Err(format!("outer LDE requires 2^{log_lde} points; Goldilocks supports at most 2^{}; a smaller/partitioned layout is required", Val::TWO_ADICITY).into()); + } + if mode == "--check-only" { + return Ok(()); } - for (expanded, inputs) in expanded.iter().zip(&inputs) { - expanded.assign_proof(&mut witness, inputs)?; + // This estimate excludes stage 2, quotients and scratch; enforce a separate + // process memory limit when running the prover. + let base_lde_bytes = layout + .trace_field_bytes + .checked_mul(1usize << cp.log_blowup) + .ok_or("LDE size overflow")?; + println!( + "Advice + fixed LDE bytes (not total prover memory): {base_lde_bytes}" + ); + if base_lde_bytes > (192usize << 30) { + return Err( + "advice/fixed LDE arrays alone exceed 192 GiB; refusing unsafe prover allocation" + .into(), + ); } - let assignment = witness.generate()?; - Ok(Prepared { circuit, assignment }) + drop(proof); + drop(plan); + drop(verifier_key); + let public = prepared.public_values().to_vec(); + let start = Instant::now(); + let (verifier, outer_proof) = prepared + .prove_fri(GoldilocksBlake3Config::new(cp, fp), max_trace_height)?; + println!("FRI proving and verification: {:?}", start.elapsed()); + let _output_span = tracing::info_span!("compression/write_output").entered(); + let bytes = outer_proof.to_bytes()?; + println!("Outer proof: {} bytes", bytes.len()); + fs::write(out.join("outer-proof.bin"), bytes)?; + fs::write(out.join("outer-vk.bin"), verifier.key.to_bytes()?)?; + fs::write( + out.join("outer-claims.bin"), + super::stages::statement::encode_claims( + &verifier.statement.claims(&public)?, + ), + )?; + fs::write( + out.join("statement-values.bin"), + bincode::serde::encode_to_vec(&public, bincode::config::standard())?, + )?; + Ok(()) } diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 00a341b70..126020e07 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -1,114 +1,536 @@ -//! Deterministic verifier construction. No proof or witness is an input. -use super::*; -use crate::kzg::KzgCommitment; -use aiur::vk_codec::AiurVerifyingKey; - -/// Derive a complete fixed compression verifier from locally trusted inputs. -/// This performs circuit construction and preprocessing commitments, but no -/// proof generation. The supplied SRS must have been authenticated/validated. -pub fn setup( - root_key: &AiurVerifyingKey, - root: &FixedRoot, - srs: Arc, - fri_config: GoldilocksBlake3Config, - fri_height: usize, - kzg_height: usize, - recursive_height: usize, -) -> Result { - let circuit = root.circuit(root_key, Default::default())?; - let fri = fri(circuit, fri_config, fri_height)?; - let circuit = foreign(&fri)?; - let kzg = kzg(circuit, srs.clone(), kzg_height)?; - wrap::setup_recursive(&kzg, srs, recursive_height) +//! Setup selection and binding for compression checkpoints. +//! +//! Every setup declares a public identity and full public degree. The +//! Filecoin policy loads an authenticated prefix of the phase-one powers of +//! tau from the cache named by `IX_KZG_FILECOIN_CACHE`, pinned by the manifest +//! receipt in `IX_KZG_FILECOIN_MANIFEST` or, when that is unset, the built-in +//! receipt of the complete `2^27` cache. The development policy derives a +//! known trapdoor from the stage seed, with parameters from a per-stage cache +//! file or from the shared directory named by `IX_KZG_SRS_CACHE`. +use super::storage; +use crate::kzg::{ + KzgConfig, PublicSetup, Srs, TranscriptKind, VerifierParams, + srs::filecoin::{ + FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3, filecoin_anchors, load_filecoin_cache, + }, +}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; + +pub(crate) const FIRST_SEED: &[u8] = b"init-fri-kzg-ordinary-v1"; +pub(crate) const BINDING_FILE: &str = "kzg-setup-id.bin"; +pub(crate) const FILECOIN_CACHE_VAR: &str = "IX_KZG_FILECOIN_CACHE"; +pub(crate) const FILECOIN_MANIFEST_VAR: &str = "IX_KZG_FILECOIN_MANIFEST"; + +/// The trust class of verifier parameters. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SetupClass { + /// Filecoin's phase-one powers of tau. The trapdoor is unknown unless + /// every ceremony participant colluded. + Filecoin, + /// A trapdoor derived from a seed; anyone can forge proofs. + Development, } -pub(super) fn fri( - circuit: Circuit, - config: GoldilocksBlake3Config, - height: usize, -) -> Result { - use multi_stark::config::StarkGenericConfig; - fri::validate_parameters( - multi_stark::types::CommitmentParameters { - log_blowup: config.log_blowup(), - cap_height: config.cap_height(), - }, - config.fri_parameters(), - )?; - let public_count = circuit.public_values().len(); - let compiled = circuit.lower_to_multi_stark_sharded(Val::ONE, height)?; - let statement = Statement::from_lowered(&compiled, public_count)?; - let definitions = compiled.circuit_inputs(); - drop(compiled); - validate_config(&config, &definitions)?; - let (system, _) = System::new_without_preprocessed(config, definitions); - Ok(FriVerifier { key: VerifierKey::new(system), statement }) +impl SetupClass { + /// Filecoin only when the identity, public degree and all four anchor + /// points equal the pinned ceremony values; an identity alone is just + /// bytes any profile could carry. + pub fn of(params: &VerifierParams) -> Self { + let pinned = filecoin_anchors(); + if params.public_setup == pinned.public_setup + && params.g1 == [pinned.g1[0], pinned.g1[1]] + && params.g2 == pinned.g2 + && params.tau_g2 == pinned.tau_g2 + { + Self::Filecoin + } else { + Self::Development + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Filecoin => "filecoin", + Self::Development => "development", + } + } + + pub fn description(self) -> &'static str { + match self { + Self::Filecoin => "the Filecoin phase-one powers of tau", + Self::Development => { + "a known-trapdoor development SRS (not production-secure)" + }, + } + } } -pub(super) fn foreign(verifier: &FriVerifier) -> Result> { - let system = verifier.key.system(); - let profile = fri::ProofProfile { - envelope: fri::Envelope::Ordinary, - active: vec![true; system.circuits.len()], - log_degrees: system - .circuits - .iter() - .map(|c| { - u8::try_from(c.preprocessed_height.ilog2()).expect("usize log fits u8") - }) - .collect(), - claim_lengths: verifier.statement.template.iter().map(Vec::len).collect(), - message_lengths: vec![], - max_field_retries: fri::DEFAULT_FIELD_RETRIES, - }; - let plan = - fri::VerifierPlan::validate(&verifier.key, profile, Default::default())?; - let (circuit, _) = plan.build(verifier.schema(), Default::default())?; - Ok(GoldilocksCircuit::new_preallocated(&circuit).circuit) +fn development_refused() -> storage::Result { + Err( + format!( + "no Filecoin SRS cache is configured; set {FILECOIN_CACHE_VAR} to an \ + authenticated prefix cache, or pass --dev-srs to use the \ + known-trapdoor development setup (not production-secure)" + ) + .into(), + ) +} + +/// Setup policy captured for one worker invocation. +#[derive(Clone)] +pub(crate) enum SetupConfig { + Filecoin { cache: PathBuf, manifest: [u8; 32] }, + Development { cache_directory: Option }, +} + +impl SetupConfig { + pub(crate) fn from_env(development: bool) -> storage::Result { + if development { + return Ok(Self::Development { + cache_directory: std::env::var_os("IX_KZG_SRS_CACHE") + .map(PathBuf::from), + }); + } + if let Some(cache) = std::env::var_os(FILECOIN_CACHE_VAR) { + let cache = PathBuf::from(cache); + if !cache.is_file() { + return Err( + format!( + "{FILECOIN_CACHE_VAR} does not name a file: {}", + cache.display() + ) + .into(), + ); + } + let manifest = match std::env::var(FILECOIN_MANIFEST_VAR) { + Ok(text) => parse_digest(text.trim())?, + Err(std::env::VarError::NotPresent) => { + FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3 + }, + Err(error) => return Err(error.into()), + }; + return Ok(Self::Filecoin { cache, manifest }); + } + development_refused() + } + + pub(crate) fn class(&self) -> SetupClass { + match self { + Self::Filecoin { .. } => SetupClass::Filecoin, + Self::Development { .. } => SetupClass::Development, + } + } + + pub(crate) fn for_stage( + &self, + seed: &[u8], + height: usize, + ) -> storage::Result { + if height < 2 || !height.is_power_of_two() { + return Err("invalid trace height".into()); + } + match self { + Self::Filecoin { cache, manifest } => { + Ok(SetupSource::filecoin(cache.clone(), *manifest)) + }, + Self::Development { cache_directory } => { + let mut source = SetupSource::development(seed, height - 1)?; + if let Some(directory) = cache_directory { + source = source.with_cache_directory(directory.clone()); + } + Ok(source) + }, + } + } +} + +#[derive(Clone)] +enum Policy { + Development { seed: Vec, cache_directory: Option }, + Filecoin { cache: PathBuf, manifest: [u8; 32] }, +} + +#[derive(Clone)] +pub(crate) struct SetupSource { + policy: Policy, + public_setup: PublicSetup, + transcript_kind: TranscriptKind, } -pub(super) fn kzg( - circuit: Circuit, - srs: Arc, - height: usize, -) -> Result { - if height < 2 || !height.is_power_of_two() || height > srs.max_len() { +fn parse_digest(text: &str) -> storage::Result<[u8; 32]> { + if text.len() != 64 || !text.is_ascii() { return Err( - "partition height must be a power of two within the SRS range".into(), - ); - } - let public_count = circuit.public_values().len(); - let compiled = circuit - .lower_to_multi_stark_sharded(Scalar::ONE, height)? - .merge_table_traces(height)?; - let statement = Statement::from_lowered(&compiled, public_count)?; - let params = VerifierParams::from_srs(&srs); - let config = - KzgConfig::new(srs, 2).with_streaming_lookups().with_streaming_quotient(); - let count = compiled.num_circuits(); - let mut circuits = Vec::with_capacity(count); - let mut fixed = KzgCommitment(vec![], vec![]); - for i in 0..count { - let definition = compiled - .kzg_circuit_input(i, params.max_len(), 2)? - .ok_or("missing compression circuit")?; - validate_config(&config, std::slice::from_ref(&definition))?; - let (mut local, _) = - System::new_without_preprocessed(config.clone(), [definition]); - let commit = - local.preprocessed_commit.take().ok_or("missing fixed commitment")?; - fixed.0.extend(commit.0); - fixed.1.extend(commit.1); - circuits.push(local.circuits.remove(0)); - } - Ok(KzgVerifier { - system: System { - config: KzgConfig::verifier_only(params.clone(), 2), - circuits, - preprocessed_commit: Some(fixed), - preprocessed_indices: (0..count).map(Some).collect(), - }, - params, - statement, - }) + format!("{FILECOIN_MANIFEST_VAR} must be a 64-character BLAKE3 digest") + .into(), + ); + } + let mut digest = [0; 32]; + for (i, byte) in digest.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; + } + Ok(digest) +} + +impl SetupSource { + pub(crate) fn development( + seed: &[u8], + public_max_degree: usize, + ) -> storage::Result { + if public_max_degree < 1 || public_max_degree.checked_add(1).is_none() { + return Err("invalid public degree allowance".into()); + } + Ok(Self { + policy: Policy::Development { + seed: seed.to_vec(), + cache_directory: None, + }, + public_setup: PublicSetup::known_trapdoor(public_max_degree, seed), + transcript_kind: TranscriptKind::Blake3, + }) + } + + /// Filecoin parameters: pinned anchors for verification, and proving + /// prefixes from an authenticated cache pinned by its import receipt. + pub(crate) fn filecoin(cache: PathBuf, manifest: [u8; 32]) -> Self { + Self { + policy: Policy::Filecoin { cache, manifest }, + public_setup: filecoin_anchors().public_setup, + transcript_kind: TranscriptKind::Blake3, + } + } + + /// Bind checkpoint identity to the selected Fiat-Shamir protocol. + pub(crate) fn with_transcript(mut self, kind: TranscriptKind) -> Self { + self.transcript_kind = kind; + self + } + + /// Serve development parameters from a shared directory of cached + /// files, keyed by prefix length and seed, instead of regenerating them. + pub(crate) fn with_cache_directory(mut self, directory: PathBuf) -> Self { + if let Policy::Development { cache_directory, .. } = &mut self.policy { + *cache_directory = Some(directory); + } + self + } + + pub(crate) fn class(&self) -> SetupClass { + match self.policy { + Policy::Development { .. } => SetupClass::Development, + Policy::Filecoin { .. } => SetupClass::Filecoin, + } + } + + pub(crate) fn security_description(&self) -> &'static str { + match self.class() { + SetupClass::Filecoin => { + "Filecoin phase-one powers of tau (challenge_19), authenticated against the published BLAKE2b-512 digest and loaded from a receipt-pinned prefix cache. Public ceremony parameters; the trapdoor is unknown unless every participant colluded.\n" + }, + SetupClass::Development => { + "Known-trapdoor development public-degree KZG. Not ceremony parameters. For development and testing only.\n" + }, + } + } + + fn check_height(&self, height: usize) -> storage::Result<()> { + if height < 2 + || !height.is_power_of_two() + || height.ilog2() > 32 + || height - 1 > self.public_setup.max_degree + { + return Err("trace height exceeds public setup allowance".into()); + } + Ok(()) + } + + #[tracing::instrument(level = "info", skip_all, name = "compression/setup")] + pub(crate) fn srs( + &self, + height: usize, + verify_only: bool, + ) -> storage::Result { + self.check_height(height)?; + let length = if verify_only { 2 } else { height }; + match &self.policy { + Policy::Filecoin { .. } if verify_only => Ok(filecoin_anchors()), + Policy::Filecoin { cache, manifest } => { + Ok(load_filecoin_cache(cache, *manifest, length)?) + }, + Policy::Development { seed, cache_directory } => { + if !verify_only && let Some(directory) = cache_directory { + return Ok(Srs::unsafe_dev_setup_prefix_in_cache_directory( + length, + self.public_setup.max_degree, + seed, + directory, + )?); + } + Ok(Srs::unsafe_dev_setup_prefix( + length, + self.public_setup.max_degree, + seed, + )) + }, + } + } + + pub(crate) fn verifier_params( + &self, + height: usize, + ) -> storage::Result { + Ok(VerifierParams::with_max_trace_len(&self.srs(height, true)?, height)) + } + + pub(crate) fn config( + &self, + height: usize, + quotient: usize, + verify_only: bool, + ) -> storage::Result { + if !quotient.is_power_of_two() { + return Err("invalid quotient budget".into()); + } + Ok( + (if verify_only { + KzgConfig::verifier_only(self.verifier_params(height)?, quotient) + } else { + KzgConfig::with_max_trace_len( + Arc::new(self.srs(height, false)?), + height, + quotient, + ) + }) + .with_transcript(self.transcript_kind), + ) + } + + /// The setup identity bound into checkpoints and fixed-cache profiles. + pub(crate) fn identity( + &self, + height: usize, + quotient: usize, + ) -> storage::Result<[u8; 32]> { + let config = self.config(height, quotient, true)?; + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/compression/setup/v4"); + hash.update(config.transcript_seed()); + Ok(*hash.finalize().as_bytes()) + } + + pub(crate) fn bind_stage( + &self, + dir: &Path, + height: usize, + quotient: usize, + ) -> storage::Result<()> { + storage::write_atomic( + &dir.join(BINDING_FILE), + &self.identity(height, quotient)?, + )?; + Ok(()) + } + + pub(crate) fn check_binding( + &self, + dir: &Path, + height: usize, + quotient: usize, + ) -> storage::Result<()> { + if fs::read(dir.join(BINDING_FILE))? != self.identity(height, quotient)? { + return Err( + "checkpoint setup, transcript, public degree or trace cap mismatch" + .into(), + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::srs::filecoin::{ + FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, + }; + + #[test] + fn setup_binding_preserves_public_identity_and_rejects_mismatch() + -> storage::Result<()> { + let dir = std::env::temp_dir() + .join(format!("ix-setup-binding-{}", std::process::id())); + fs::create_dir(&dir)?; + let setup = SetupSource::development(b"setup-binding", 63)?; + let prover = setup.srs(64, false)?; + let verifier = setup.srs(64, true)?; + assert_eq!(prover.public_setup, verifier.public_setup); + assert_eq!(verifier.max_len(), 2); + assert_eq!( + setup.config(64, 2, false)?.transcript_seed(), + setup.config(64, 2, true)?.transcript_seed() + ); + setup.bind_stage(&dir, 64, 2)?; + setup.check_binding(&dir, 64, 2)?; + let keccak = setup.clone().with_transcript(TranscriptKind::Keccak256); + assert_eq!( + setup.verifier_params(64)?.to_bytes()?, + keccak.verifier_params(64)?.to_bytes()? + ); + assert_ne!(setup.identity(64, 2)?, keccak.identity(64, 2)?); + assert_eq!( + keccak.config(64, 2, false)?.transcript_seed(), + keccak.config(64, 2, true)?.transcript_seed() + ); + assert!(keccak.check_binding(&dir, 64, 2).is_err()); + assert!(setup.check_binding(&dir, 32, 2).is_err()); + assert!(setup.check_binding(&dir, 64, 4).is_err()); + assert!( + SetupSource::development(b"other", 63)? + .check_binding(&dir, 64, 2) + .is_err() + ); + assert!( + SetupSource::development(b"setup-binding", 127)? + .check_binding(&dir, 64, 2) + .is_err() + ); + assert!(setup.config(128, 2, true).is_err()); + fs::remove_dir_all(dir)?; + Ok(()) + } + + #[test] + fn cache_directory_yields_the_generated_parameters() -> storage::Result<()> { + let dir = std::env::temp_dir() + .join(format!("ix-setup-cache-dir-{}", std::process::id())); + fs::create_dir(&dir)?; + let generated = SetupSource::development(b"setup-cache", 63)?; + let directory = SetupSource::development(b"setup-cache", 63)? + .with_cache_directory(dir.join("shared")); + let a = generated.srs(64, false)?; + let b = directory.srs(64, false)?; + let c = directory.srs(64, false)?; + assert_eq!(a.g1, b.g1); + assert_eq!(b.g1, c.g1); + assert_eq!(a.public_setup, c.public_setup); + assert_eq!(fs::read_dir(dir.join("shared"))?.count(), 1); + assert_eq!( + generated.config(64, 2, false)?.transcript_seed(), + directory.config(64, 2, false)?.transcript_seed() + ); + fs::remove_dir_all(dir)?; + Ok(()) + } + + #[test] + fn setup_class_requires_the_pinned_anchors_and_identity() { + let pinned = filecoin_anchors(); + assert_eq!( + SetupClass::of(&VerifierParams::from_srs(&pinned)), + SetupClass::Filecoin + ); + let development = Srs::unsafe_dev_setup(8, FIRST_SEED); + assert_eq!( + SetupClass::of(&VerifierParams::from_srs(&development)), + SetupClass::Development + ); + // The Filecoin identity over development anchors is not Filecoin. + let forged = Srs { + g1: development.g1[..2].to_vec(), + g2: development.g2, + tau_g2: development.tau_g2, + public_setup: pinned.public_setup, + }; + assert_eq!( + SetupClass::of(&VerifierParams::from_srs(&forged)), + SetupClass::Development + ); + // The pinned anchors under another identity are not Filecoin either. + let relabeled = Srs { + g1: pinned.g1.clone(), + g2: pinned.g2, + tau_g2: pinned.tau_g2, + public_setup: PublicSetup { + max_degree: pinned.public_setup.max_degree - 1, + id: pinned.public_setup.id, + }, + }; + assert_eq!( + SetupClass::of(&VerifierParams::from_srs(&relabeled)), + SetupClass::Development + ); + let filecoin = SetupSource::filecoin(PathBuf::from("/missing"), [0; 32]); + assert_eq!(filecoin.class(), SetupClass::Filecoin); + assert_eq!(filecoin.public_setup, pinned.public_setup); + // Verification needs no cache file; proving does. + assert_eq!(filecoin.srs(1 << 27, true).unwrap().g1, pinned.g1); + assert!(filecoin.srs(1 << 28, true).is_err()); + assert!(filecoin.srs(1 << 27, false).is_err()); + assert_eq!( + filecoin.identity(1 << 27, 2).unwrap(), + SetupSource::filecoin(PathBuf::from("/other"), [1; 32]) + .identity(1 << 27, 2) + .unwrap() + ); + assert!(parse_digest(&"0".repeat(63)).is_err()); + assert!(parse_digest(&"g".repeat(64)).is_err()); + assert_eq!(parse_digest(&"00".repeat(32)).unwrap(), [0; 32]); + } + + #[test] + fn development_flag_selects_development_parameters() -> storage::Result<()> { + let config = SetupConfig::from_env(true)?; + assert_eq!(config.class(), SetupClass::Development); + assert_eq!( + config.for_stage(FIRST_SEED, 64)?.identity(64, 2)?, + SetupSource::development(FIRST_SEED, 63)?.identity(64, 2)? + ); + if std::env::var_os(FILECOIN_CACHE_VAR).is_none() { + assert!( + SetupConfig::from_env(false) + .err() + .unwrap() + .to_string() + .contains("--dev-srs") + ); + } + Ok(()) + } + + #[test] + fn checked_in_filecoin_cache_proves_and_binds_under_its_identity() + -> storage::Result<()> { + let setup = SetupSource::filecoin( + PathBuf::from(FILECOIN_PREFIX_2P8_FIXTURE), + FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, + ); + assert!(setup.srs(512, false).is_err()); + let verifier = setup.srs(256, true)?; + assert_eq!(verifier.max_len(), 2); + assert_eq!( + SetupClass::of(&VerifierParams::from_srs(&verifier)), + SetupClass::Filecoin + ); + let prover = setup.srs(256, false)?; + assert_eq!(prover.max_len(), 256); + assert_eq!(prover.public_setup, verifier.public_setup); + assert_eq!(prover.g1[..2], verifier.g1[..]); + assert_eq!(prover.tau_g2, verifier.tau_g2); + assert_eq!( + SetupClass::of(&VerifierParams::with_max_trace_len(&prover, 256)), + SetupClass::Filecoin + ); + assert_eq!( + setup.config(256, 2, false)?.transcript_seed(), + setup.config(256, 2, true)?.transcript_seed() + ); + assert_ne!( + setup.identity(256, 2)?, + SetupSource::development(FIRST_SEED, 255)?.identity(256, 2)? + ); + Ok(()) + } } diff --git a/crates/proof-compression/src/pipeline/codec.rs b/crates/proof-compression/src/pipeline/stages/codec.rs similarity index 59% rename from crates/proof-compression/src/pipeline/codec.rs rename to crates/proof-compression/src/pipeline/stages/codec.rs index f55a0a338..242c787c6 100644 --- a/crates/proof-compression/src/pipeline/codec.rs +++ b/crates/proof-compression/src/pipeline/stages/codec.rs @@ -2,9 +2,10 @@ //! decoding a key supplied alongside a proof does not establish trust. use super::{FriVerifier, KzgVerifier, Result, Statement}; use crate::fri::VerifierKey; -use crate::kzg::{KzgCommitment, KzgConfig, Scalar, VerifierParams}; +use crate::kzg::{ + KzgCommitment, KzgConfig, Scalar, TranscriptKind, VerifierParams, +}; use multi_stark::system::{Circuit, System}; -use multi_stark::traits::Field; use multi_stark::types::Val; use serde::{Serialize, de::DeserializeOwned}; @@ -32,23 +33,6 @@ pub(super) fn decode( Ok(value) } -impl Statement { - fn validate(&self) -> Result<()> { - let count = self.locations.len(); - if self.template.len() <= count - || self.template[..=count].iter().any(|row| row.len() != 4) - || self.locations.iter().copied().ne((1..=count).map(|i| (i, 3))) - || self - .locations - .iter() - .any(|&(row, col)| self.template[row][col] != F::ZERO) - { - return Err("invalid compression statement schema".into()); - } - Ok(()) - } -} - impl FriVerifier { /// Encode the verifier, including its public statement schema. No prover /// matrices are included. Authenticate the complete encoding on transport. @@ -70,9 +54,13 @@ impl KzgVerifier { /// the public statement schema. Proving powers and matrices are excluded. pub fn to_bytes(&self) -> Result> { encode( - b"IXKZGV01", + b"IXKZGV02", ( self.params.to_bytes()?, + match self.system.config.transcript_kind() { + TranscriptKind::Blake3 => 0u8, + TranscriptKind::Keccak256 => 1u8, + }, &self.system.circuits, &self.system.preprocessed_commit, &self.statement, @@ -82,50 +70,34 @@ impl KzgVerifier { /// Decode an independently trusted verifier with a 64 MiB decoding budget. pub fn from_bytes(bytes: &[u8]) -> Result { - let (params, circuits, preprocessing, statement): ( + let (params, transcript, circuits, preprocessing, statement): ( Vec, + u8, Vec>, Option, Statement, - ) = decode(b"IXKZGV01", bytes)?; + ) = if bytes.starts_with(b"IXKZGV01") { + let (params, circuits, preprocessing, statement) = + decode(b"IXKZGV01", bytes)?; + (params, 0, circuits, preprocessing, statement) + } else { + decode(b"IXKZGV02", bytes)? + }; let params = VerifierParams::from_bytes(¶ms)?; - statement.validate()?; - let commitment = - preprocessing.as_ref().ok_or("missing fixed commitment")?; - if circuits.is_empty() - || commitment.0.len() != circuits.len() - || commitment.1.len() != circuits.len() - { - return Err("invalid fixed commitment dimensions".into()); - } - for (i, c) in circuits.iter().enumerate() { - crate::circuit_metadata::validate_circuit(c, 1)?; - if c.preprocessed.is_some() - || c.preprocessed_width == 0 - || !c.preprocessed_height.is_power_of_two() - || c.preprocessed_height > params.max_len() - || c.quotient_degree() > 2 - || commitment.0[i].len() != c.preprocessed_width - || commitment.1[i].len() - != if c.preprocessed_height < params.max_len() { - c.preprocessed_width - } else { - 0 - } - { - return Err("invalid KZG compression circuit metadata".into()); - } - } + let transcript = match transcript { + 0 => TranscriptKind::Blake3, + 1 => TranscriptKind::Keccak256, + _ => return Err("unsupported KZG transcript".into()), + }; let indices = (0..circuits.len()).map(Some).collect(); - Ok(Self { - system: System { - config: KzgConfig::verifier_only(params.clone(), 2), + Self::new( + System { + config: KzgConfig::verifier_only(params, 2).with_transcript(transcript), circuits, preprocessed_commit: preprocessing, preprocessed_indices: indices, }, - params, statement, - }) + ) } } diff --git a/crates/proof-compression/src/pipeline/stages/construct.rs b/crates/proof-compression/src/pipeline/stages/construct.rs new file mode 100644 index 000000000..b77f35f08 --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/construct.rs @@ -0,0 +1,239 @@ +//! Production circuit layouts and ownership of lowered witnesses. +use super::{Prepared, Result}; +use crate::{ + foreign::{GoldilocksCircuit, GoldilocksInputs}, + fri::{self, VerifierInputs, VerifierKey, VerifierPlan}, + kzg::{KzgConfig, Scalar}, +}; +use multi_stark::{ + plonkish::{Assignment, Circuit, MultiStarkCircuit}, + system::CircuitInputs, + traits::Field, + types::Val, +}; +use std::path::Path; + +pub(in crate::pipeline) const FRI_NAMESPACE: u8 = 107; +pub(in crate::pipeline) const FIRST_NAMESPACE: u8 = 93; +pub(in crate::pipeline) const RECURSIVE_NAMESPACE: u8 = 94; +pub(in crate::pipeline) const TABLE_HEIGHT: usize = 1 << 22; +pub(in crate::pipeline) const RECURSIVE_HEIGHT: usize = 1 << 27; + +#[tracing::instrument(level = "info", skip_all, name = "compression/lower")] +pub(in crate::pipeline) fn lower_fri( + circuit: Circuit, + height: usize, +) -> Result> { + Ok( + circuit + .lower_to_multi_stark_sharded(Val::from_u8(FRI_NAMESPACE), height)?, + ) +} + +pub(in crate::pipeline) fn fri_definitions( + compiled: &MultiStarkCircuit, +) -> Vec> { + let mut definitions = compiled.circuit_inputs(); + for definition in &mut definitions { + definition.lookup_group_size = 3; + } + definitions +} + +#[tracing::instrument(level = "info", skip_all, name = "compression/lower")] +pub(in crate::pipeline) fn lower_first( + circuit: Circuit, + height: usize, + table_height: usize, +) -> Result> { + Ok( + circuit + .lower_to_multi_stark_sharded(Scalar::from_u8(FIRST_NAMESPACE), height)? + .merge_table_traces(table_height)?, + ) +} + +#[tracing::instrument(level = "info", skip_all, name = "compression/lower")] +pub(in crate::pipeline) fn lower_recursive( + circuit: Circuit, + max_height: usize, +) -> Result> { + let height = circuit.multi_stark_layout()?.main_height; + if height > max_height.min(RECURSIVE_HEIGHT) { + return Err( + "recursive computation exceeds its single-trace height cap".into(), + ); + } + let compiled = circuit + .lower_to_multi_stark_sharded(Scalar::from_u8(RECURSIVE_NAMESPACE), height)? + .merge_table_traces(height)?; + if compiled.num_circuits() != 2 || compiled.main_heights() != [height] { + return Err("expected one computation trace and one merged table".into()); + } + Ok(compiled) +} + +pub(in crate::pipeline) fn max_height( + compiled: &MultiStarkCircuit, +) -> Result { + (0..compiled.num_circuits()) + .map(|i| compiled.trace_dimensions(i).map(|(height, _)| height)) + .max() + .flatten() + .ok_or_else(|| "empty compiled circuit".into()) +} + +/// Owns the frontend allocations until commitment has copied their columns. +pub(in crate::pipeline) struct Lowered { + pub(in crate::pipeline) compiled: MultiStarkCircuit, + pub(in crate::pipeline) assignment: Assignment, +} + +impl Lowered { + pub(in crate::pipeline) fn commit( + self, + config: KzgConfig, + preprocessing: Option<&Path>, + prefetch_bytes: usize, + ) -> Result { + let committed = super::super::prover::Committed::prepare( + config, + &self.compiled.trace_shards(&self.assignment)?, + preprocessing, + prefetch_bytes, + )?; + drop(self); + Ok(committed) + } +} + +/// The FRI verifier circuit and assignment recipe, independent of a witness. +pub(in crate::pipeline) struct FriConstruction { + pub(in crate::pipeline) circuit: Circuit, + inputs: VerifierInputs, +} + +impl FriConstruction { + #[tracing::instrument( + level = "info", + skip_all, + name = "compression/build_circuit" + )] + pub(in crate::pipeline) fn build( + plan: &VerifierPlan<'_>, + schema: fri::Statement, + ) -> Result { + let (circuit, inputs) = plan.build(schema, Default::default())?; + Ok(Self { circuit, inputs }) + } + + #[tracing::instrument( + level = "info", + skip_all, + name = "compression/source_witness" + )] + pub(in crate::pipeline) fn assign( + self, + plan: &VerifierPlan<'_>, + proof: &multi_stark::prover::Proof< + multi_stark::types::GoldilocksBlake3Config, + >, + claims: Vec>, + ) -> Result> { + let expanded = plan.expand_witness(fri::ProofEnvelope::Ordinary { + proof, + claims: &claims, + })?; + let mut witness = self.circuit.witness(); + self.inputs.assign_statement( + &mut witness, + &fri::Statement { claims, messages: vec![] }, + )?; + expanded.assign_proof(&mut witness, &self.inputs)?; + drop(expanded); + drop(self.inputs); + let assignment = witness.generate()?; + Ok(Prepared { circuit: self.circuit, assignment }) + } +} + +pub(in crate::pipeline) fn fri_profile( + key: &VerifierKey, + claims: &[Vec], +) -> Result { + Ok(fri::ProofProfile { + envelope: fri::Envelope::Ordinary, + active: vec![true; key.system().circuits.len()], + log_degrees: key + .system() + .circuits + .iter() + .map(|c| { + Ok(u8::try_from( + c.preprocessed_height + .checked_ilog2() + .ok_or("missing trace height")?, + )?) + }) + .collect::>()?, + claim_lengths: claims.iter().map(Vec::len).collect(), + message_lengths: vec![], + max_field_retries: fri::DEFAULT_FIELD_RETRIES, + }) +} + +#[tracing::instrument(level = "info", skip_all, name = "compression/translate")] +pub(in crate::pipeline) fn translate( + source: &Circuit, +) -> GoldilocksCircuit { + GoldilocksCircuit::new_preallocated(source) +} + +#[tracing::instrument(level = "info", skip_all, name = "compression/witness")] +pub(in crate::pipeline) fn assign_foreign( + inputs: &GoldilocksInputs, + circuit: &Circuit, + assignment: &Assignment, +) -> Result> { + let mut witness = circuit.witness(); + inputs.assign(assignment, &mut witness)?; + Ok(witness.generate()?) +} + +pub(in crate::pipeline) fn prepare_first( + prepared: Prepared, + height: usize, + table_height: usize, +) -> Result { + let translated = translate(&prepared.circuit); + drop(prepared.circuit); + let compiled = lower_first(translated.circuit, height, table_height)?; + let assignment = assign_foreign( + &translated.inputs, + compiled.circuit(), + &prepared.assignment, + )?; + Ok(Lowered { compiled, assignment }) +} + +pub(in crate::pipeline) fn recursive_layout() +-> crate::recursive::native_verifier::LayoutPolicy { + crate::recursive::native_verifier::LayoutPolicy { + namespace: Scalar::from_u8(RECURSIVE_NAMESPACE), + max_computation_height: RECURSIVE_HEIGHT, + max_table_height: RECURSIVE_HEIGHT, + } +} + +pub(in crate::pipeline) fn recursive_plan<'a>( + system: &'a multi_stark::system::System, + logs: &[u8], + schema: crate::recursive::native_verifier::ClaimSchema, +) -> Result> { + Ok(crate::recursive::native_verifier::Plan::new( + system, + logs, + schema, + recursive_layout(), + )?) +} diff --git a/crates/proof-compression/src/pipeline/fixed.rs b/crates/proof-compression/src/pipeline/stages/fixed.rs similarity index 99% rename from crates/proof-compression/src/pipeline/fixed.rs rename to crates/proof-compression/src/pipeline/stages/fixed.rs index ca0a1f265..e441b6c0a 100644 --- a/crates/proof-compression/src/pipeline/fixed.rs +++ b/crates/proof-compression/src/pipeline/stages/fixed.rs @@ -262,8 +262,8 @@ impl FixedRoot { #[cfg(test)] mod tests { + use super::super::setup; use super::*; - use crate::pipeline::setup; use multi_stark::{ batch::ShardInput, expr::Expr, diff --git a/crates/proof-compression/src/pipeline/stages/mod.rs b/crates/proof-compression/src/pipeline/stages/mod.rs new file mode 100644 index 000000000..9ff5648d8 --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/mod.rs @@ -0,0 +1,300 @@ +//! Compression stages for aggregated Ix certificates. +//! +//! The caller supplies the trusted aggregate key, expected outer claim and +//! proving parameters. No certificate, filesystem layout or development SRS +//! is built into these stages. Verifiers returned by local construction must +//! be authenticated before use elsewhere; a prover-supplied key is not trusted. +mod codec; +pub(in crate::pipeline) mod construct; +pub(in crate::pipeline) mod statement; +use statement::Statement; +mod fixed; +pub(in crate::pipeline) mod root; +mod setup; +#[cfg(test)] +mod tests; +pub(in crate::pipeline) mod verification; +mod wrap; +pub use verification::RecursiveVerifier; + +pub use fixed::FixedRoot; +pub use root::prepare_root; +pub use setup::setup; +pub use wrap::{RecursiveProof, RecursiveStage, prepare_recursive}; + +use crate::fri::{self, VerifierKey}; +use crate::kzg::{KzgConfig, Scalar, Srs, VerifierParams}; +use multi_stark::plonkish::{Assignment, Circuit}; +use multi_stark::prover::Proof; +use multi_stark::system::{System, SystemWitness}; +#[cfg(test)] +use multi_stark::traits::Algebra; +use multi_stark::traits::Field; +use multi_stark::types::{GoldilocksBlake3Config, Val}; +use p3_field::PrimeField64; +use std::sync::Arc; + +use super::{ + Result, + prover::{self, validate_config}, +}; + +/// A checked circuit and its witness, ready for lowering and proving. +/// Keeping preparation separate lets callers inspect costs and choose an SRS +/// and partition height before allocating prover data. +pub struct Prepared { + pub(in crate::pipeline) circuit: Circuit, + pub(in crate::pipeline) assignment: Assignment, +} + +impl Prepared { + pub fn circuit(&self) -> &Circuit { + &self.circuit + } + + pub fn public_values(&self) -> &[F] { + self.assignment.public_values() + } +} + +/// Trusted verifier for the first, FRI-compressed stage. +pub struct FriVerifier { + pub(in crate::pipeline) key: VerifierKey, + pub(in crate::pipeline) statement: Statement, +} + +impl FriVerifier { + pub fn verify( + &self, + proof: &Proof, + expected: &[Val], + ) -> Result<()> { + let claims = self.statement.claims(expected)?; + self + .key + .system() + .verify_multiple_claims( + &claims.iter().map(Vec::as_slice).collect::>(), + proof, + ) + .map_err(|e| format!("FRI compression proof: {e:?}"))?; + Ok(()) + } +} + +impl Prepared { + /// Prove the root-verifier circuit using caller-selected FRI parameters. + pub fn prove_fri( + self, + config: GoldilocksBlake3Config, + max_height: usize, + ) -> Result<(FriVerifier, Proof)> { + use multi_stark::config::StarkGenericConfig; + fri::validate_parameters( + multi_stark::types::CommitmentParameters { + log_blowup: config.log_blowup(), + cap_height: config.cap_height(), + }, + config.fri_parameters(), + )?; + let compiled = construct::lower_fri(self.circuit, max_height)?; + let statement = Statement::from_lowered( + &compiled, + self.assignment.public_values().len(), + )?; + let claims = statement.claims(self.assignment.public_values())?; + let trace_span = tracing::info_span!("compression/trace").entered(); + let definitions = construct::fri_definitions(&compiled); + validate_config(&config, &definitions)?; + let traces = compiled.traces(&self.assignment)?; + drop(trace_span); + drop(compiled); + drop(self.assignment); + let (mut system, key) = tracing::info_span!("compression/setup") + .in_scope(|| System::new(config, definitions)); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let witness = SystemWitness::from_stage_1(traces, &system); + // Lookup messages now own everything needed from the fixed evaluations. + // Quotients/openings use PCS data; the returned verifier needs neither. + for circuit in &mut system.circuits { + circuit.preprocessed = None; + } + let proof = tracing::info_span!("compression/prove") + .in_scope(|| system.prove_multiple_claims(&key, &refs, witness)); + drop(key); + tracing::info_span!("compression/verify") + .in_scope(|| system.verify_multiple_claims(&refs, &proof)) + .map_err(|e| format!("new FRI proof: {e:?}"))?; + Ok((FriVerifier { key: VerifierKey::new(system), statement }, proof)) + } +} + +/// Prepare a FRI verifier over the BLS12-381 scalar field. The statement and +/// all activation anchors come from the trusted previous stage's verifier. +pub fn prepare_kzg( + verifier: &FriVerifier, + proof: &Proof, + expected: &[Val], + limits: fri::VerifierLimits, +) -> Result> { + verifier.verify(proof, expected)?; + let claims = verifier.statement.claims(expected)?; + let profile = construct::fri_profile(&verifier.key, &claims)?; + if proof.active != profile.active || proof.log_degrees != profile.log_degrees + { + return Err("compressed proof profile differs from the key".into()); + } + let plan = fri::VerifierPlan::validate(&verifier.key, profile, limits)?; + let source = + construct::FriConstruction::build(&plan, verifier.statement.schema())? + .assign(&plan, proof, claims)?; + let foreign = construct::translate(&source.circuit); + drop(source.circuit); + drop(plan); + let assignment = construct::assign_foreign( + &foreign.inputs, + &foreign.circuit, + &source.assignment, + )?; + drop(source.assignment); + drop(foreign.inputs); + Ok(Prepared { circuit: foreign.circuit, assignment }) +} + +/// Trusted verifier for a scalar-field compression stage. It retains only +/// verification parameters, never the SRS proving powers or fixed matrices. +pub struct KzgVerifier { + system: System, + params: VerifierParams, + statement: Statement, + codec: crate::kzg::compact::FixedProofCodec, +} + +impl KzgVerifier { + pub(in crate::pipeline) fn new( + system: System, + statement: Statement, + ) -> Result { + use multi_stark::config::ProofConfig; + let params = system.config.pcs().verifier_params().clone(); + statement.validate()?; + let commitment = + system.preprocessed_commit.as_ref().ok_or("missing fixed commitment")?; + if system.circuits.is_empty() + || commitment.0.len() != system.circuits.len() + || system + .preprocessed_indices + .iter() + .copied() + .ne((0..system.circuits.len()).map(Some)) + { + return Err("invalid fixed commitment dimensions".into()); + } + for (i, c) in system.circuits.iter().enumerate() { + crate::circuit_metadata::validate_circuit(c, 1)?; + if c.preprocessed.is_some() + || c.preprocessed_width == 0 + || !c.preprocessed_height.is_power_of_two() + || c.preprocessed_height > params.max_trace_len() + || c.quotient_degree() > 2 + || commitment.0[i].len() != c.preprocessed_width + { + return Err("invalid KZG compression circuit metadata".into()); + } + } + let logs = system + .circuits + .iter() + .map(|c| u8::try_from(c.preprocessed_height.ilog2())) + .collect::, _>>()?; + let codec = crate::kzg::compact::FixedProofCodec::new(&system, &logs)?; + Ok(Self { system, params, statement, codec }) + } + + /// Fixed-shape transport under this trusted key; encoding is not verification. + pub fn encode_proof(&self, proof: &Proof) -> Result> { + Ok(self.codec.encode(proof)?) + } + + pub fn verify_bytes(&self, bytes: &[u8], expected: &[Scalar]) -> Result<()> { + self.verify(&self.codec.decode(bytes)?, expected) + } + + pub fn verify( + &self, + proof: &Proof, + expected: &[Scalar], + ) -> Result<()> { + let claims = self.statement.claims(expected)?; + self + .system + .verify_multiple_claims( + &claims.iter().map(Vec::as_slice).collect::>(), + proof, + ) + .map_err(|e| format!("KZG compression proof: {e:?}"))?; + Ok(()) + } +} + +impl Prepared { + /// Parameters must have trusted provenance and pass Srs::validate before + /// use. Partitioning is bounded by both max_height and the supplied SRS. + pub fn prove_kzg( + self, + srs: Arc, + max_height: usize, + ) -> Result<(KzgVerifier, Proof)> { + if srs.max_len() < 2 + || !srs.max_len().is_power_of_two() + || srs.public_setup.max_degree < srs.max_len() - 1 + || srs.public_setup.max_degree.checked_add(1).is_none() + { + return Err("invalid SRS shape".into()); + } + if max_height < 2 + || !max_height.is_power_of_two() + || max_height > srs.max_len() + { + return Err( + "partition height must be a power of two within the SRS range".into(), + ); + } + let compiled = + construct::lower_first(self.circuit, max_height, max_height)?; + let height = construct::max_height(&compiled)?; + if height > srs.max_len() { + return Err("compression trace exceeds the SRS range".into()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_streaming_lookups() + .with_streaming_quotient(); + prove_lowered( + construct::Lowered { compiled, assignment: self.assignment }, + config, + ) + } +} + +pub(in crate::pipeline) fn scalar_statement(expected: &[Val]) -> Vec { + expected.iter().map(|v| Scalar::from_u64(v.as_canonical_u64())).collect() +} + +fn prove_lowered( + lowered: construct::Lowered, + config: KzgConfig, +) -> Result<(KzgVerifier, Proof)> { + use multi_stark::config::ProofConfig; + let statement = Statement::from_lowered( + &lowered.compiled, + lowered.assignment.public_values().len(), + )?; + let claims = statement.claims(lowered.assignment.public_values())?; + let params = config.pcs().verifier_params().clone(); + let transcript = config.transcript_kind(); + let committed = lowered.commit(config, None, 0)?; + let prover::Proved { mut system, proof, .. } = committed.prove(&claims)?; + system.config = + KzgConfig::verifier_only(params, 2).with_transcript(transcript); + Ok((KzgVerifier::new(system, statement)?, proof)) +} diff --git a/crates/proof-compression/src/pipeline/stages/root.rs b/crates/proof-compression/src/pipeline/stages/root.rs new file mode 100644 index 000000000..7b10446a4 --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/root.rs @@ -0,0 +1,225 @@ +//! Aggregate-root construction shared by file and in-memory callers. +use super::{Prepared, Result}; +use crate::fri::*; +use aiur::{synthesis::AiurProof, vk_codec::AiurVerifyingKey}; +use multi_stark::plonkish::CircuitBuilder; +use multi_stark::types::Val; +use p3_field::PrimeField64; + +/// Prepare every trace shard under the trusted Aiur policy. Claim values +/// remain public; activation, heights and memory-closure messages fix the +/// circuit's shape. +pub fn prepare_root( + key: &AiurVerifyingKey, + proof: &AiurProof, + expected: &[Val], + limits: VerifierLimits, +) -> Result> { + key.verify(expected, proof).map_err(|e| format!("aggregate root: {e:?}"))?; + let verifier = VerifierKey::from_system(key.system()); + RootPlan::new(&verifier, proof, limits)?.prepare( + proof, + expected, + Default::default(), + ) +} + +pub(in crate::pipeline) struct RootPlan<'a> { + key: &'a VerifierKey, + plans: Vec>, + schemas: Vec>, +} + +impl<'a> RootPlan<'a> { + pub(in crate::pipeline) fn new( + key: &'a VerifierKey, + proof: &AiurProof, + limits: VerifierLimits, + ) -> Result { + if proof.preamble.headers.is_empty() { + return Err("empty aggregate batch".into()); + } + let mut plans = Vec::new(); + let mut schemas = Vec::new(); + let mut proof_values = 0usize; + for header in &proof.preamble.headers { + let plan = VerifierPlan::validate( + key, + ProofProfile { + envelope: Envelope::SingleBatch, + active: header.active.clone(), + log_degrees: header.log_degrees.clone(), + claim_lengths: header.claims.iter().map(Vec::len).collect(), + message_lengths: proof + .preamble + .messages + .iter() + .map(|m| m.args.len()) + .collect(), + max_field_retries: DEFAULT_FIELD_RETRIES, + }, + limits, + )?; + proof_values = proof_values + .checked_add(plan.resources().proof_values) + .ok_or("batch proof size overflow")?; + if proof_values > limits.max_proof_values { + return Err("batch exceeds proof value limit".into()); + } + schemas.push(Statement { + claims: header + .claims + .iter() + .map(|c| vec![StatementSlot::Public; c.len()]) + .collect(), + messages: proof + .preamble + .messages + .iter() + .map(|m| Message { + args: m.args.iter().copied().map(StatementSlot::Constant).collect(), + multiplicity: StatementSlot::Constant(m.multiplicity), + }) + .collect(), + }); + plans.push(plan); + } + Ok(Self { key, plans, schemas }) + } + + pub(in crate::pipeline) fn identity(&self) -> Result<[u8; 32]> { + let ids = self + .plans + .iter() + .zip(&self.schemas) + .map(|(p, s)| p.identity(s)) + .collect::, _>>()?; + if let [id] = ids.as_slice() { + return Ok(*id); + } + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/aggregate-batch-plan/v1"); + for id in ids { + hash.update(&id); + } + Ok(*hash.finalize().as_bytes()) + } + + pub(in crate::pipeline) fn build_identity( + &self, + options: ImplementationOptions, + ) -> Result<[u8; 32]> { + let ids = self + .plans + .iter() + .zip(&self.schemas) + .map(|(p, s)| { + p.build_identity( + s, + options, + concat!("ix-proof-compression/", env!("CARGO_PKG_VERSION")), + "plonkish-multi-stark/v1", + "max-height=16777216;lookup-group=3", + ) + }) + .collect::, _>>()?; + if let [id] = ids.as_slice() { + return Ok(*id); + } + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/aggregate-batch-build/v1"); + for id in ids { + hash.update(&id); + } + Ok(*hash.finalize().as_bytes()) + } + + pub(in crate::pipeline) fn description(&self) -> String { + format!( + "Profiles: {:?}\nSchemas: {:?}\n", + self.plans.iter().map(VerifierPlan::profile).collect::>(), + self.schemas + ) + } + + pub(in crate::pipeline) fn prepare( + &self, + proof: &AiurProof, + expected: &[Val], + options: ImplementationOptions, + ) -> Result> { + let shapes: Vec<_> = self.plans.iter().map(|p| p.shape().clone()).collect(); + let expanded = tracing::info_span!("compression/expand_witness") + .in_scope(|| expand_batch_witness(self.key.system(), &shapes, proof))?; + let build_span = tracing::info_span!("compression/build_circuit").entered(); + let mut builder = CircuitBuilder::new(); + if options.compact_blake3 { + builder.enable_compact_blake3(); + } + let public: Vec<_> = (0..expected.len()) + .map(|i| builder.public_input(format!("claim[0][{i}]"))) + .collect(); + let claims = self + .schemas + .iter() + .map(|s| { + s.claims + .iter() + .map(|claim| { + if claim.len() != public.len() { + return Err("root claim dimensions differ"); + } + Ok(public.clone()) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + // Constant binding precedes the byte gadgets in the production layout. + let messages = proof + .preamble + .messages + .iter() + .map(|m| Message { + args: m.args.iter().map(|&v| builder.constant(v)).collect(), + multiplicity: builder.constant(m.multiplicity), + }) + .collect::>(); + let bytes = ByteGadgets::new(&mut builder); + let inputs = constrain_batch_verifier( + &mut builder, + &bytes, + self.key.system(), + &shapes, + claims, + &messages, + ); + let circuit = builder.finish(); + drop(build_span); + let _witness_span = tracing::info_span!("compression/witness").entered(); + let mut witness = circuit.witness(); + for (&wire, &value) in public.iter().zip(expected) { + witness.set(wire, value)?; + } + for (expanded, inputs) in expanded.iter().zip(&inputs) { + expanded.assign_proof(&mut witness, inputs)?; + } + let assignment = witness.generate()?; + for (expanded, inputs) in expanded.iter().zip(&inputs) { + for (bits, &expected) in + inputs.query_bits.iter().zip(&expanded.query_indices) + { + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, b)| { + assignment.value(b.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + if actual != expected as u64 { + return Err("query index differs from native verification".into()); + } + } + } + Ok(Prepared { circuit, assignment }) + } +} diff --git a/crates/proof-compression/src/pipeline/stages/setup.rs b/crates/proof-compression/src/pipeline/stages/setup.rs new file mode 100644 index 000000000..f003af445 --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/setup.rs @@ -0,0 +1,93 @@ +//! Deterministic verifier construction. No proof or witness is an input. +use super::*; +use aiur::vk_codec::AiurVerifyingKey; + +/// Derive a complete fixed compression verifier from locally trusted inputs. +/// This performs circuit construction and preprocessing commitments, but no +/// proof generation. The supplied SRS must have been authenticated/validated. +pub fn setup( + root_key: &AiurVerifyingKey, + root: &FixedRoot, + srs: Arc, + fri_config: GoldilocksBlake3Config, + fri_height: usize, + kzg_height: usize, + recursive_height: usize, +) -> Result { + let circuit = root.circuit(root_key, Default::default())?; + let fri = fri(circuit, fri_config, fri_height)?; + let circuit = foreign(&fri)?; + let kzg = kzg(circuit, srs.clone(), kzg_height)?; + wrap::setup_recursive(&kzg, srs, recursive_height) +} + +pub(super) fn fri( + circuit: Circuit, + config: GoldilocksBlake3Config, + height: usize, +) -> Result { + use multi_stark::config::StarkGenericConfig; + fri::validate_parameters( + multi_stark::types::CommitmentParameters { + log_blowup: config.log_blowup(), + cap_height: config.cap_height(), + }, + config.fri_parameters(), + )?; + let public_count = circuit.public_values().len(); + let compiled = construct::lower_fri(circuit, height)?; + let statement = Statement::from_lowered(&compiled, public_count)?; + let definitions = construct::fri_definitions(&compiled); + drop(compiled); + validate_config(&config, &definitions)?; + let (system, _) = System::new_without_preprocessed(config, definitions); + Ok(FriVerifier { key: VerifierKey::new(system), statement }) +} + +pub(super) fn foreign(verifier: &FriVerifier) -> Result> { + let profile = + construct::fri_profile(&verifier.key, &verifier.statement.template)?; + let plan = + fri::VerifierPlan::validate(&verifier.key, profile, Default::default())?; + let built = + construct::FriConstruction::build(&plan, verifier.statement.schema())?; + Ok(construct::translate(&built.circuit).circuit) +} + +pub(super) fn kzg( + circuit: Circuit, + srs: Arc, + height: usize, +) -> Result { + if height < 2 || !height.is_power_of_two() || height > srs.max_len() { + return Err( + "partition height must be a power of two within the SRS range".into(), + ); + } + let public_count = circuit.public_values().len(); + let compiled = construct::lower_first(circuit, height, height)?; + let height = construct::max_height(&compiled)?; + if height > srs.max_len() { + return Err("compression trace exceeds the SRS range".into()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_streaming_lookups() + .with_streaming_quotient(); + kzg_lowered(compiled, config, public_count) +} + +pub(super) fn kzg_lowered( + compiled: multi_stark::plonkish::MultiStarkCircuit, + config: KzgConfig, + public_count: usize, +) -> Result { + use multi_stark::config::ProofConfig; + let statement = Statement::from_lowered(&compiled, public_count)?; + let params = config.pcs().verifier_params().clone(); + let transcript = config.transcript_kind(); + let mut system = prover::setup(config, &compiled)?; + drop(compiled); + system.config = + KzgConfig::verifier_only(params, 2).with_transcript(transcript); + KzgVerifier::new(system, statement) +} diff --git a/crates/proof-compression/src/pipeline/stages/statement.rs b/crates/proof-compression/src/pipeline/stages/statement.rs new file mode 100644 index 000000000..2284fa9de --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/statement.rs @@ -0,0 +1,214 @@ +//! Public cells and fixed activation anchors of a lowered statement. +use super::Result; +use crate::kzg::Scalar; +use multi_stark::{ + plonkish::MultiStarkCircuit, + traits::{Algebra, Field}, + types::Val, +}; +use p3_field::PrimeField64; + +/// The lowering's complete claim template, including all activation anchors. +/// Only public-value cells may be replaced during verification. +#[derive(serde::Serialize, serde::Deserialize)] +pub(in crate::pipeline) struct Statement { + pub(in crate::pipeline) template: Vec>, + pub(in crate::pipeline) locations: Vec<(usize, usize)>, +} + +impl Statement { + pub(in crate::pipeline) fn from_lowered( + circuit: &MultiStarkCircuit, + count: usize, + ) -> Result { + Self::from_claims(&circuit.claims(&vec![F::ZERO; count])?, count) + } + + pub(in crate::pipeline) fn claims( + &self, + public: &[F], + ) -> Result>> { + if public.len() != self.locations.len() { + return Err("unexpected public statement length".into()); + } + let mut claims = self.template.clone(); + for (&(row, col), &value) in self.locations.iter().zip(public) { + claims[row][col] = value; + } + Ok(claims) + } +} + +impl Statement { + pub(super) fn validate(&self) -> Result<()> { + let normalized = Self::from_claims(&self.template, self.locations.len())?; + if self.locations != normalized.locations + || self.template != normalized.template + { + return Err("invalid compression statement schema".into()); + } + Ok(()) + } +} + +impl Statement { + pub(in crate::pipeline) fn from_claims( + claims: &[Vec], + count: usize, + ) -> Result { + if claims.len() <= count + || claims[..=count].iter().any(|row| row.len() != 4) + { + return Err("unsupported lowering claim schema".into()); + } + let namespace = claims[0][0]; + if claims[0][3] != F::ZERO + || claims[..=count].iter().enumerate().any(|(i, row)| { + row[0] != namespace || row[1] != F::ONE || row[2] != F::from_usize(i) + }) + { + return Err("invalid lowering claim schema".into()); + } + let mut template = claims.to_vec(); + let locations: Vec<_> = (1..=count).map(|i| (i, 3)).collect(); + for &(row, col) in &locations { + template[row][col] = F::ZERO; + } + Ok(Self { template, locations }) + } + + pub(in crate::pipeline) fn public_values( + &self, + claims: &[Vec], + ) -> Result> { + let public = self + .locations + .iter() + .map(|&(row, col)| { + claims + .get(row) + .and_then(|r| r.get(col)) + .copied() + .ok_or("invalid statement dimensions") + }) + .collect::, _>>()?; + if self.claims(&public)? != claims { + return Err("claim rows differ from the statement and anchors".into()); + } + Ok(public) + } +} + +impl Statement { + pub(in crate::pipeline) fn schema( + &self, + ) -> crate::fri::Statement { + use crate::fri::{Statement, StatementSlot}; + let mut schema = Statement { + claims: self + .template + .iter() + .map(|row| row.iter().copied().map(StatementSlot::Constant).collect()) + .collect(), + messages: vec![], + }; + for &(row, col) in &self.locations { + schema.claims[row][col] = StatementSlot::Public; + } + schema + } +} + +impl Statement { + pub(in crate::pipeline) fn recursive(count: usize) -> Self { + Self { + template: (0..=count) + .map(|i| { + vec![ + Scalar::from_u8(super::construct::RECURSIVE_NAMESPACE), + Scalar::ONE, + Scalar::from_usize(i), + Scalar::ZERO, + ] + }) + .collect(), + locations: (1..=count).map(|i| (i, 3)).collect(), + } + } +} + +pub(in crate::pipeline) fn encode_claims(claims: &[Vec]) -> Vec { + let mut bytes = (claims.len() as u64).to_le_bytes().to_vec(); + for claim in claims { + bytes.extend((claim.len() as u64).to_le_bytes()); + for value in claim { + bytes.extend(value.as_canonical_u64().to_le_bytes()); + } + } + bytes +} + +pub(in crate::pipeline) fn decode_claims( + bytes: &[u8], +) -> std::result::Result>, String> { + let (words, remainder) = bytes.as_chunks::<8>(); + if !remainder.is_empty() { + return Err("unaligned claims".into()); + } + let mut words = words.iter(); + let n = u64::from_le_bytes(*words.next().ok_or("missing claims count")?); + let mut claims = Vec::new(); + for _ in 0..n { + let len = u64::from_le_bytes(*words.next().ok_or("missing claim length")?); + let mut claim = Vec::new(); + for _ in 0..len { + let v = u64::from_le_bytes(*words.next().ok_or("missing claim value")?); + if v >= Val::ORDER_U64 { + return Err("noncanonical claim".into()); + } + claim.push(Val::from_u64(v)); + } + claims.push(claim); + } + if words.next().is_some() { + return Err("trailing claims".into()); + } + Ok(claims) +} + +/// The claims a first-stage proof of `count` partitions must expose, as the +/// lowering derives them: the statement words, one activation anchor per table +/// partition and the six fixed namespace anchors. +pub(in crate::pipeline) fn first_claims( + count: usize, + statement: &super::super::statement::Statement, +) -> Vec> { + let namespace = Scalar::from_u8(super::construct::FIRST_NAMESPACE); + let mut expected: Vec<_> = std::iter::once(0) + .chain(statement.iter().copied()) + .enumerate() + .map(|(i, word)| { + vec![ + namespace, + Scalar::ONE, + Scalar::from_usize(i), + Scalar::from_u64(word), + ] + }) + .collect(); + if count > 11 { + expected.extend( + (0..count - 10) + .map(|i| vec![namespace, Scalar::from_u8(3), Scalar::from_usize(i)]), + ); + } + expected.extend((0..6).map(|i| { + vec![ + namespace, + Scalar::from_u8(4), + Scalar::from_u8(3), + Scalar::from_usize(i), + ] + })); + expected +} diff --git a/crates/proof-compression/src/pipeline/tests.rs b/crates/proof-compression/src/pipeline/stages/tests.rs similarity index 80% rename from crates/proof-compression/src/pipeline/tests.rs rename to crates/proof-compression/src/pipeline/stages/tests.rs index c4a5176ae..3c8be9db4 100644 --- a/crates/proof-compression/src/pipeline/tests.rs +++ b/crates/proof-compression/src/pipeline/stages/tests.rs @@ -46,7 +46,7 @@ fn fri_stage_preserves_publics_through_foreign_translation() -> Result<()> { let prepared = arithmetic(value, 2); let config = || { GoldilocksBlake3Config::new( - CommitmentParameters { log_blowup: 1, cap_height: 0 }, + CommitmentParameters { log_blowup: 2, cap_height: 0 }, FriParameters { log_final_poly_len: 0, max_log_arity: 1, @@ -107,13 +107,16 @@ fn fri_memory_reduction_preserves_the_dense_proof() -> Result<()> { }, ) }; - // Include multiple partitions and a lookup table. Build the reference using - // dense lowering and retained fixed evaluations, as before the optimization. + // Include multiple partitions and a lookup table. The reference uses dense + // lowering and keeps the fixed evaluations through proving. let prepared = arithmetic(Val::TWO, 50); - let compiled = - prepared.circuit.lower_to_multi_stark_with_max_height(Val::ONE, 32)?; + let compiled = prepared.circuit.lower_to_multi_stark_with_max_height( + Val::from_u8(construct::FRI_NAMESPACE), + 32, + )?; let claims = compiled.claims(prepared.assignment.public_values())?; - let (system, key) = System::new(config(), compiled.circuit_inputs()); + let (system, key) = + System::new(config(), construct::fri_definitions(&compiled)); let reference = system.prove_multiple_claims( &key, &claims.iter().map(Vec::as_slice).collect::>(), @@ -189,3 +192,35 @@ fn kzg_stages_derive_claims_for_different_layouts_and_parameters() -> Result<()> assert!(arithmetic(Scalar::ONE, 2).prove_kzg(srs, 16).is_err()); Ok(()) } + +#[test] +fn kzg_table_merging_respects_the_callers_height_bound() -> Result<()> { + fn tables() -> Prepared { + let mut builder = CircuitBuilder::new(); + let x = builder.public_input("x"); + for name in ["first", "second"] { + let table = builder.fixed_table( + name, + (0..64).map(|i| vec![Scalar::from_u64(i)]).collect(), + ); + builder.lookup(table, &[x]); + } + let circuit = builder.finish(); + let mut witness = circuit.witness(); + witness.set(x, Scalar::from_u8(7)).unwrap(); + let assignment = witness.generate().unwrap(); + Prepared { circuit, assignment } + } + + let srs = Arc::new(Srs::unsafe_dev_setup(256, b"bounded-table-merge")); + assert!(tables().prove_kzg(srs.clone(), 64).is_err()); + assert!(setup::kzg(tables().circuit, srs.clone(), 64).is_err()); + let local = setup::kzg(tables().circuit, srs.clone(), 128)?; + let (verifier, proof) = tables().prove_kzg(srs, 128)?; + assert_eq!(local.to_bytes()?, verifier.to_bytes()?); + assert!( + verifier.system.circuits.iter().all(|c| c.preprocessed_height <= 128) + ); + verifier.verify(&proof, &[Scalar::from_u8(7)])?; + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/stages/verification.rs b/crates/proof-compression/src/pipeline/stages/verification.rs new file mode 100644 index 000000000..c4b1fa54c --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/verification.rs @@ -0,0 +1,217 @@ +//! Prepared recursive verification shared by local and bundled profiles. +use super::{ + KzgVerifier, Result, scalar_statement, statement::Statement, + wrap::RecursiveProof, +}; +use crate::kzg::{KzgConfig, Scalar}; +use ark_bls12_381::{Bls12_381, G1Affine, G2Affine}; +use ark_ec::{AffineRepr, pairing::Pairing}; +use ark_ff::Zero; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use multi_stark::{system::System, types::Val}; + +pub(in crate::pipeline) struct Pairings { + pub(in crate::pipeline) keys: [G2Affine; 2], +} + +impl Pairings { + pub(in crate::pipeline) fn encode( + &self, + values: &[Scalar], + ) -> Result> { + pack_pairings(values) + } + + pub(in crate::pipeline) fn verify(&self, values: &[Scalar]) -> Result<()> { + self.verify_bytes(&self.encode(values)?) + } +} + +pub struct RecursiveVerifier { + pub(super) verifier: KzgVerifier, + pub(super) pairings: Pairings, + pub(super) claim_len: usize, +} + +impl RecursiveVerifier { + /// Encode all verification data, including both stages' public parameters + /// and the pairing equations. Authenticate the complete encoding on transport. + pub fn to_bytes(&self) -> Result> { + let mut keys = vec![]; + for key in &self.pairings.keys { + key.serialize_compressed(&mut keys)?; + } + super::codec::encode( + b"IXRECV02", + (self.verifier.to_bytes()?, self.claim_len, keys), + ) + } + + /// Decode an independently trusted verifier with a 64 MiB decoding budget. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (verifier, claim_len, keys): (Vec, usize, Vec) = + super::codec::decode(b"IXRECV02", bytes)?; + let verifier = KzgVerifier::from_bytes(&verifier)?; + if keys.len() != 2 * 96 + || claim_len.checked_add(4) != Some(verifier.statement.locations.len()) + { + return Err("invalid recursive public statement dimensions".into()); + } + let keys = keys + .as_chunks::<96>() + .0 + .iter() + .map(|bytes| G2Affine::deserialize_compressed(bytes.as_slice())) + .collect::, _>>()?; + Self::from_parts(verifier.system, verifier.statement, &keys, claim_len) + } + + /// Pack the public pairing operands followed by the fixed-shape KZG proof. + /// The expected certificate claim and trusted verifier are supplied separately. + pub fn encode_proof(&self, proof: &RecursiveProof) -> Result> { + let mut bytes = self.pairings.encode(&proof.pairing_values)?; + bytes.extend(self.verifier.encode_proof(&proof.proof)?); + Ok(bytes) + } + + pub fn verify_bytes(&self, bytes: &[u8], expected: &[Val]) -> Result<()> { + self.verify_scalar_bytes(bytes, &scalar_statement(expected)) + } + + /// Verify the recursive proof, expected certificate statement, and the + /// deferred inner opening pairing. No proving SRS is needed. + pub fn verify(&self, proof: &RecursiveProof, expected: &[Val]) -> Result<()> { + if expected.len() != self.claim_len { + return Err("unexpected certificate claim length".into()); + } + let mut public = scalar_statement(expected); + public.extend_from_slice(&proof.pairing_values); + self.verifier.verify(&proof.proof, &public)?; + self.pairings.verify(&proof.pairing_values) + } +} + +impl RecursiveVerifier { + pub(in crate::pipeline) fn from_parts( + system: System, + statement: Statement, + keys: &[G2Affine], + claim_len: usize, + ) -> Result { + let keys: [G2Affine; 2] = + keys.try_into().map_err(|_keys| "expected two pairing keys")?; + if keys.iter().any(|p| { + p.is_zero() + || !p.is_on_curve() + || !p.is_in_correct_subgroup_assuming_on_curve() + }) || statement.locations.len() + != claim_len.checked_add(4).ok_or("public count overflow")? + { + return Err("invalid recursive verifier parameters".into()); + } + Ok(Self { + verifier: KzgVerifier::new(system, statement)?, + pairings: Pairings { keys }, + claim_len, + }) + } + + pub(in crate::pipeline) fn verify_scalar_bytes( + &self, + bytes: &[u8], + expected: &[Scalar], + ) -> Result<()> { + if expected.len() != self.claim_len { + return Err("unexpected certificate claim length".into()); + } + verify_recursive_bytes( + &self.verifier.system, + &self.verifier.codec, + &self.verifier.statement, + &self.pairings, + bytes, + expected, + ) + } +} + +impl Pairings { + pub(in crate::pipeline) fn verify_bytes(&self, bytes: &[u8]) -> Result<()> { + if bytes.len() != 96 { + return Err("pairing point count".into()); + } + let points = bytes + .as_chunks::<48>() + .0 + .iter() + .map(|bytes| G1Affine::deserialize_compressed(bytes.as_slice())) + .collect::, _>>()?; + if !Bls12_381::multi_pairing(points, self.keys).is_zero() { + return Err("external opening pairing equation failed".into()); + } + Ok(()) + } +} + +pub(in crate::pipeline) fn unpack_pairings( + bytes: &[u8], +) -> Result> { + if bytes.len() != 96 { + return Err("pairing point count".into()); + } + Ok( + bytes + .as_chunks::<24>() + .0 + .iter() + .map(|chunk| { + let mut limbs = [0; 4]; + for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) { + *limb = u64::from_le_bytes(*bytes); + } + Scalar::from_limbs_le(limbs) + }) + .collect(), + ) +} + +pub(in crate::pipeline) fn pack_pairings(values: &[Scalar]) -> Result> { + if values.len() != 4 { + return Err("incorrect pairing operand count".into()); + } + let mut bytes = Vec::with_capacity(values.len() * 24); + for value in values { + let limbs = value.canonical_limbs_le(); + if limbs[3] != 0 { + return Err("noncanonical packed pairing operand".into()); + } + for limb in &limbs[..3] { + bytes.extend(limb.to_le_bytes()); + } + } + Ok(bytes) +} + +pub(in crate::pipeline) fn verify_recursive_bytes( + system: &System, + codec: &crate::kzg::compact::FixedProofCodec, + statement: &Statement, + pairings: &Pairings, + bytes: &[u8], + expected: &[Scalar], +) -> Result<()> { + if bytes.len() < 96 { + return Err("truncated pairing operands".into()); + } + let mut public = expected.to_vec(); + public.extend(unpack_pairings(&bytes[..96])?); + let claims = statement.claims(&public)?; + let proof = codec.decode(&bytes[96..])?; + system + .verify_multiple_claims( + &claims.iter().map(Vec::as_slice).collect::>(), + &proof, + ) + .map_err(|e| format!("outer KZG proof: {e:?}"))?; + pairings.verify_bytes(&bytes[..96]) +} diff --git a/crates/proof-compression/src/pipeline/stages/wrap.rs b/crates/proof-compression/src/pipeline/stages/wrap.rs new file mode 100644 index 000000000..5158f88ee --- /dev/null +++ b/crates/proof-compression/src/pipeline/stages/wrap.rs @@ -0,0 +1,242 @@ +use super::verification::{Pairings, RecursiveVerifier}; +use super::{KzgVerifier, Prepared, Result, scalar_statement}; +use crate::kzg::{KzgConfig, Scalar, Srs}; +use crate::recursive::native_verifier; +use multi_stark::prover::Proof; +use multi_stark::traits::Algebra; +use multi_stark::types::Val; +use std::sync::Arc; + +/// Recursive proof and its public pairing operands. Certificate claim values +/// are supplied independently to RecursiveVerifier::verify. +pub struct RecursiveProof { + pub proof: Proof, + pub pairing_values: Vec, +} + +fn build_recursive( + verifier: &KzgVerifier, + proof: &Proof, + claims: &[Vec], +) -> Result { + let plan = super::construct::recursive_plan( + &verifier.system, + &proof.log_degrees, + native_verifier::ClaimSchema::from_claims( + claims, + &verifier.statement.locations, + )?, + )?; + Ok(plan.build(proof, claims)?) +} + +pub struct RecursiveStage { + prepared: Prepared, + pairings: Pairings, + claim_len: usize, +} + +/// Build the recursive KZG verifier. The original public statement remains +/// public; the opening-check operands are additional publics. +pub fn prepare_recursive( + verifier: &KzgVerifier, + proof: &Proof, + expected: &[Val], +) -> Result { + let public = scalar_statement(expected); + verifier.verify(proof, &public)?; + let claims = verifier.statement.claims(&public)?; + let built = build_recursive(verifier, proof, &claims)?; + let assignment = built.assign()?; + let pairings = Pairings { + keys: built + .pairing_keys + .try_into() + .map_err(|_keys| "expected two pairing keys")?, + }; + pairings.verify(&assignment.public_values()[public.len()..])?; + Ok(RecursiveStage { + prepared: Prepared { circuit: built.circuit, assignment }, + pairings, + claim_len: public.len(), + }) +} + +impl RecursiveStage { + pub fn circuit(&self) -> &multi_stark::plonkish::Circuit { + self.prepared.circuit() + } + + pub fn prove( + self, + srs: Arc, + max_height: usize, + ) -> Result<(RecursiveVerifier, RecursiveProof)> { + let pairing_values = + self.prepared.public_values()[self.claim_len..].to_vec(); + let compiled = + super::construct::lower_recursive(self.prepared.circuit, max_height)?; + let height = super::construct::max_height(&compiled)?; + if height > srs.max_len() { + return Err("recursive trace exceeds the SRS range".into()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_transcript(crate::kzg::TranscriptKind::Keccak256) + .with_streaming_lookups() + .with_streaming_quotient(); + let (verifier, proof) = super::prove_lowered( + super::construct::Lowered { + compiled, + assignment: self.prepared.assignment, + }, + config, + )?; + Ok(( + RecursiveVerifier { + verifier, + pairings: self.pairings, + claim_len: self.claim_len, + }, + RecursiveProof { proof, pairing_values }, + )) + } +} + +pub(super) fn setup_recursive( + verifier: &KzgVerifier, + srs: Arc, + height: usize, +) -> Result { + let claim_len = verifier.statement.locations.len(); + let claims = verifier.statement.claims(&vec![Scalar::ZERO; claim_len])?; + let template = verifier.codec.template(); + let built = build_recursive(verifier, &template, &claims)?; + let compiled = super::construct::lower_recursive(built.circuit, height)?; + let height = super::construct::max_height(&compiled)?; + if height > srs.max_len() { + return Err("recursive trace exceeds the SRS range".into()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_transcript(crate::kzg::TranscriptKind::Keccak256) + .with_streaming_lookups() + .with_streaming_quotient(); + let outer = super::setup::kzg_lowered(compiled, config, claim_len + 4)?; + Ok(RecursiveVerifier { + verifier: outer, + pairings: Pairings { + keys: built + .pairing_keys + .try_into() + .map_err(|_keys| "expected two pairing keys")?, + }, + claim_len, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ec::CurveGroup; + use ark_serialize::CanonicalSerialize; + use multi_stark::plonkish::CircuitBuilder; + use multi_stark::traits::Algebra; + use multi_stark::traits::Field; + + #[test] + fn final_verifier_requires_claim_outer_proof_and_both_pairings() -> Result<()> + { + let inner = Srs::unsafe_dev_setup(8, b"inner-test"); + let outer = Arc::new(Srs::unsafe_dev_setup(128, b"outer-test")); + // Exercise the final verification boundary with small generated outer + // circuits. Actual recursive constraints are tested by prepare_recursive + // and the native_verifier tests, without a large recursive proving run. + for claim_len in [2, 5] { + for invalid_pairing in [false, true] { + let mut points = [inner.g1[1], -inner.g1[0]]; + let keys = [inner.g2, inner.tau_g2]; + if invalid_pairing { + points[0] = (points[0] * ark_bls12_381::Fr::from(2u8)).into_affine(); + } + let mut packed = vec![]; + for point in points { + let mut bytes = vec![]; + point.serialize_compressed(&mut bytes)?; + for chunk in bytes.as_chunks::<24>().0 { + let mut limbs = [0; 4]; + for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) + { + *limb = u64::from_le_bytes(*bytes); + } + packed.push(Scalar::from_limbs_le(limbs)); + } + } + let expected: Vec<_> = + (0..claim_len).map(|i| Val::from_usize(7 + i)).collect(); + let mut public = scalar_statement(&expected); + public.extend_from_slice(&packed); + let mut b = CircuitBuilder::new(); + let table = + b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let one = b.constant(Scalar::ONE); + b.lookup(table, &[one]); + let wires: Vec<_> = (0..public.len()) + .map(|i| b.public_input(format!("public{i}"))) + .collect(); + let circuit = b.finish(); + let mut witness = circuit.witness(); + for (&wire, &value) in wires.iter().zip(&public) { + witness.set(wire, value)?; + } + let assignment = witness.generate()?; + let stage = RecursiveStage { + prepared: Prepared { circuit, assignment }, + pairings: Pairings { keys }, + claim_len, + }; + let (verifier, mut proof) = stage.prove(outer.clone(), 128)?; + assert_eq!( + verifier.verify(&proof, &expected).is_err(), + invalid_pairing + ); + if !invalid_pairing { + let key_bytes = verifier.to_bytes()?; + let verifier = RecursiveVerifier::from_bytes(&key_bytes)?; + assert_eq!(verifier.to_bytes()?, key_bytes); + let mut old_key = key_bytes.clone(); + old_key[..8].copy_from_slice(b"IXRECV01"); + assert!(RecursiveVerifier::from_bytes(&old_key).is_err()); + assert!( + RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) + .is_err() + ); + let mut extra_key = key_bytes.clone(); + extra_key.push(0); + assert!(RecursiveVerifier::from_bytes(&extra_key).is_err()); + let bytes = verifier.encode_proof(&proof)?; + verifier.verify_bytes(&bytes, &expected)?; + assert!( + verifier + .verify_bytes(&bytes[..bytes.len() - 1], &expected) + .is_err() + ); + let mut extra = bytes.clone(); + extra.push(0); + assert!(verifier.verify_bytes(&extra, &expected).is_err()); + let mut malformed = bytes.clone(); + malformed[..48].fill(0xff); + assert!(verifier.verify_bytes(&malformed, &expected).is_err()); + let mut wrong = expected.clone(); + wrong[0] += Val::ONE; + assert!(verifier.verify(&proof, &wrong).is_err()); + assert!(verifier.verify(&proof, &expected[..claim_len - 1]).is_err()); + proof.pairing_values[0] += Scalar::ONE; + assert!(verifier.verify(&proof, &expected).is_err()); + proof.pairing_values[0] -= Scalar::ONE; + proof.proof.stage_1_opened_values[0][0][0] += Scalar::ONE; + assert!(verifier.verify(&proof, &expected).is_err()); + } + } + } + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/statement.rs b/crates/proof-compression/src/pipeline/statement.rs new file mode 100644 index 000000000..738e2e2f5 --- /dev/null +++ b/crates/proof-compression/src/pipeline/statement.rs @@ -0,0 +1,152 @@ +//! The public statement of an aggregate root proof: eighteen Goldilocks +//! words, the Aiur claim row of the `ix_aggr` entrypoint. The caller derives +//! it from its aggregation backend exactly as `ix verify --aggregate` does; +//! here it is carried from the root claims through every stage's claim rows +//! into the final packet, and bound to the claim it belongs to by its last +//! eight words, the packed BLAKE3 digest of the claim bytes. + +use super::Result; +use crate::kzg::Scalar; +use ark_ff::PrimeField; +use p3_field::PrimeField64; + +pub(crate) const WORDS: usize = 18; +pub(crate) type Statement = [u64; WORDS]; + +/// Where the packed claim digest sits in the statement. +const CLAIM_DIGEST: std::ops::Range = 10..18; + +/// The packed form of a 32-byte digest: eight little-endian `u32` words. +fn packed(digest: &[u8; 32]) -> [u64; 8] { + let mut words = [0; 8]; + for (word, bytes) in words.iter_mut().zip(digest.as_chunks::<4>().0) { + *word = u64::from(u32::from_le_bytes(*bytes)); + } + words +} + +/// Parse a statement in the batch claims layout, one claim of 18 words, +/// as written to `root-claims.bin` and produced by `MultiStark.serializeClaims`. +pub(crate) fn from_claims_bytes(bytes: &[u8]) -> Result { + let (words, remainder) = bytes.as_chunks::<8>(); + if !remainder.is_empty() || words.len() != 2 + WORDS { + return Err("statement must hold one claim of 18 words".into()); + } + let words: Vec<_> = words.iter().map(|w| u64::from_le_bytes(*w)).collect(); + if words[0] != 1 || words[1] != usize_u64(WORDS) { + return Err("statement must hold one claim of 18 words".into()); + } + let mut statement = [0; WORDS]; + for (slot, &word) in statement.iter_mut().zip(&words[2..]) { + if word >= multi_stark::types::Val::ORDER_U64 { + return Err("statement word is not a canonical field element".into()); + } + *slot = word; + } + Ok(statement) +} + +pub(crate) fn to_claims_bytes(statement: &Statement) -> Vec { + let mut bytes = Vec::with_capacity(8 * (2 + WORDS)); + for word in [1, usize_u64(WORDS)].into_iter().chain(statement.iter().copied()) + { + bytes.extend(word.to_le_bytes()); + } + bytes +} + +/// A statement belongs to `claim` only if it ends in the claim's packed +/// digest; the leading words name the backend and entrypoint it was proved by. +pub(crate) fn bind_claim(claim: &[u8], statement: &Statement) -> Result<()> { + if statement[CLAIM_DIGEST] != packed(blake3::hash(claim).as_bytes()) { + return Err("statement does not end in the digest of this claim".into()); + } + Ok(()) +} + +fn usize_u64(value: usize) -> u64 { + u64::try_from(value).expect("statement length fits in u64") +} + +fn scalar_word(value: Scalar) -> Result { + let limbs = value.0.into_bigint().0; + if limbs[1..].iter().any(|&limb| limb != 0) + || limbs[0] >= multi_stark::types::Val::ORDER_U64 + { + return Err("statement word is not a canonical field element".into()); + } + Ok(limbs[0]) +} + +/// The statement carried by `values`, eighteen scalar words. +pub(crate) fn from_scalars(values: &[Scalar]) -> Result { + if values.len() != WORDS { + return Err("statement must hold 18 words".into()); + } + let mut statement = [0; WORDS]; + for (slot, &value) in statement.iter_mut().zip(values) { + *slot = scalar_word(value)?; + } + Ok(statement) +} + +/// The statement carried by a stage's claim rows: rows `1..=18` hold one +/// word each in their last column, behind the zero anchor of row `0`. +pub(crate) fn from_rows(claims: &[Vec]) -> Result { + let schema = super::stages::statement::Statement::from_claims(claims, WORDS)?; + from_scalars(&schema.public_values(claims)?) +} + +/// A statement for `claim` behind the given backend and entrypoint words. +pub(crate) fn with_claim(prefix: &[u64; 10], claim: &[u8]) -> Statement { + let mut statement = [0; WORDS]; + statement[..10].copy_from_slice(prefix); + statement[CLAIM_DIGEST] + .copy_from_slice(&packed(blake3::hash(claim).as_bytes())); + statement +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::traits::{Algebra, Field}; + + #[test] + fn claims_layout_round_trips_and_binds_the_claim() { + let statement = with_claim(&[0, 293, 1, 2, 3, 4, 5, 6, 7, 8], b"claim"); + let bytes = to_claims_bytes(&statement); + assert_eq!(bytes.len(), 8 * 20); + assert_eq!(from_claims_bytes(&bytes).unwrap(), statement); + bind_claim(b"claim", &statement).unwrap(); + assert!(bind_claim(b"other", &statement).is_err()); + assert!(from_claims_bytes(&bytes[..bytes.len() - 8]).is_err()); + let mut wrong_count = bytes.clone(); + wrong_count[0] = 2; + assert!(from_claims_bytes(&wrong_count).is_err()); + let mut noncanonical = bytes.clone(); + noncanonical[16..24].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(from_claims_bytes(&noncanonical).is_err()); + } + + #[test] + fn claim_rows_expose_the_statement_in_their_last_column() { + let statement = with_claim(&[0, 293, 1, 2, 3, 4, 5, 6, 7, 8], b"claim"); + let rows: Vec> = std::iter::once(0) + .chain(statement) + .enumerate() + .map(|(i, word)| { + vec![ + Scalar::from_u8(93), + Scalar::ONE, + Scalar::from_usize(i), + Scalar::from_u64(word), + ] + }) + .collect(); + assert_eq!(from_rows(&rows).unwrap(), statement); + assert!(from_rows(&rows[..18]).is_err()); + let mut wide = rows.clone(); + wide[3][3] = Scalar::from_u64(u64::MAX); + assert!(from_rows(&wide).is_err()); + } +} diff --git a/crates/proof-compression/src/pipeline/storage.rs b/crates/proof-compression/src/pipeline/storage.rs new file mode 100644 index 000000000..e5e5ed893 --- /dev/null +++ b/crates/proof-compression/src/pipeline/storage.rs @@ -0,0 +1,121 @@ +//! Stage output files: bincode metadata and atomic file publication. +use super::fixed_cache::Shape; +use crate::kzg::Scalar; +use multi_stark::plonkish::MultiStarkCircuit; +use serde::{Serialize, de::DeserializeOwned}; +use std::{ + fs::{self, File}, + io::{self, BufReader, BufWriter, Read, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +pub(crate) type Result = std::result::Result>; + +/// The shape and public claims of a stage's proof, which the next stage +/// needs to rebuild the verifier. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct Manifest { + pub(crate) widths: Vec, + pub(crate) heights: Vec, + pub(crate) claims: Vec>, +} + +impl Manifest { + /// The manifest of a compiled circuit, checked against the shape of a + /// restored fixed-preprocessing cache entry when there is one. + pub(crate) fn for_compiled( + compiled: &MultiStarkCircuit, + claims: Vec>, + cached: Option<&Shape>, + ) -> Result { + let mut manifest = Self { widths: vec![], heights: vec![], claims }; + if cached.is_some_and(|shape| { + shape.widths.len() != compiled.num_circuits() + || shape.heights.len() != compiled.num_circuits() + }) { + return Err( + "cached trace count differs from the compiled circuit".into(), + ); + } + for i in 0..compiled.num_circuits() { + let (height, width) = + compiled.trace_dimensions(i).ok_or("missing compiled trace")?; + if let Some(shape) = cached + && (shape.widths[i] != width || shape.heights[i] != height) + { + return Err( + "cached fixed trace dimensions differ from the compiled circuit" + .into(), + ); + } + manifest.widths.push(width); + manifest.heights.push(height); + } + Ok(manifest) + } +} + +/// A staging path beside `path` that no other writer, in this process or +/// another, stages to. The suffix follows the whole file name, so a partial +/// file left behind by a crash never carries the name or extension of a +/// finished one. +pub(crate) fn temporary_path(path: &Path) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + let mut name = + path.file_name().map_or_else(Default::default, ToOwned::to_owned); + name.push(format!( + ".partial-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + path.with_file_name(name) +} + +/// Move a completely written `temporary` file to `path` so that both its +/// contents and the rename survive a crash: the temporary is synced before +/// the rename and the containing directory after it. +pub(crate) fn persist(temporary: &Path, path: &Path) -> io::Result<()> { + File::open(temporary)?.sync_all()?; + fs::rename(temporary, path)?; + sync_directory(path.parent().unwrap_or_else(|| Path::new("."))) +} + +pub(crate) fn sync_directory(directory: &Path) -> io::Result<()> { + let directory = + if directory.as_os_str().is_empty() { Path::new(".") } else { directory }; + File::open(directory)?.sync_all() +} + +/// Write `bytes` to `path` through a staged file, so a crash leaves either +/// the previous file or the complete new one. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let temp = temporary_path(path); + fs::write(&temp, bytes)?; + persist(&temp, path) +} + +pub(crate) fn save(path: &Path, value: &T) -> Result<()> { + let temp = temporary_path(path); + let mut f = BufWriter::new(File::create(&temp)?); + bincode::serde::encode_into_std_write( + value, + &mut f, + bincode::config::standard(), + )?; + f.flush()?; + drop(f); + persist(&temp, path)?; + Ok(()) +} + +pub(crate) fn load(path: &Path) -> Result { + let mut f = BufReader::new(File::open(path)?); + let value = + bincode::serde::decode_from_std_read(&mut f, bincode::config::standard())?; + let mut tail = [0]; + if f.read(&mut tail)? != 0 { + return Err("trailing metadata".into()); + } + Ok(value) +} diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs new file mode 100644 index 000000000..b3825ae26 --- /dev/null +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -0,0 +1,252 @@ +//! Verifier profiles authenticated by the application release. A packet +//! names its profile; the profile pins the aggregation program its proofs +//! attest, so the statement for a claim follows without the proving backend. +use super::{ + Result, + setup::SetupClass, + statement, + wrap::{PreparedVerifierProfile, VerifierProfile}, +}; +use std::sync::OnceLock; + +struct BundledVerifier { + id: &'static str, + digest: &'static str, + /// Backend and entrypoint words of the aggregation program whose roots + /// this profile compresses. + statement_prefix: [u64; 10], + setup: SetupClass, + bytes: &'static [u8], + decoded: OnceLock>, +} + +const AGGREGATE_PREFIX: [u64; 10] = [ + 0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, + 1176003176, 683533119, 3085659920, +]; + +static VERIFIERS: [BundledVerifier; 2] = [ + BundledVerifier { + id: "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", + digest: "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", + statement_prefix: AGGREGATE_PREFIX, + setup: SetupClass::Filecoin, + bytes: include_bytes!("../../profiles/aggregate-filecoin-v5.bin"), + decoded: OnceLock::new(), + }, + BundledVerifier { + id: "304340325a2fff6bbfbca27e0b58b335cd9f0731b7698ef8e9d9f2f23fe408a1", + digest: "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", + statement_prefix: AGGREGATE_PREFIX, + setup: SetupClass::Development, + bytes: include_bytes!("../../profiles/aggregate-development-v5.bin"), + decoded: OnceLock::new(), + }, +]; + +impl BundledVerifier { + fn profile(&self) -> Result<&PreparedVerifierProfile> { + self + .decoded + .get_or_init(|| { + if blake3::hash(self.bytes).to_hex().as_str() != self.digest { + return Err("bundled compression verifier digest differs".into()); + } + let profile = VerifierProfile::from_bytes(self.bytes) + .map_err(|error| error.to_string())?; + profile + .check_setup_class(self.setup) + .map_err(|error| error.to_string())?; + if blake3::Hash::from_bytes(profile.id()).to_hex().as_str() != self.id { + return Err("bundled compression verifier metadata differs".into()); + } + profile.prepare().map_err(|error| error.to_string()) + }) + .as_ref() + .map_err(|error| error.clone().into()) + } +} + +fn bundled(id: &[u8; 32]) -> Option<&'static BundledVerifier> { + let id = blake3::Hash::from_bytes(*id).to_hex(); + VERIFIERS.iter().find(|verifier| verifier.id == id.as_str()) +} + +/// The statement prefix a bundled profile pins, if `id` names one. +pub(super) fn bundled_prefix(id: &[u8; 32]) -> Option<&'static [u64; 10]> { + bundled(id).map(|verifier| &verifier.statement_prefix) +} + +pub(super) fn verify( + packet: &[u8], + claim: &[u8], + allow_development: bool, +) -> Result { + let id: [u8; 32] = + packet.get(..32).ok_or("truncated compressed proof")?.try_into()?; + let verifier = bundled(&id).ok_or( + "compressed proof uses a verifier profile not bundled with this application", + )?; + if verifier.setup == SetupClass::Development && !allow_development { + return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); + } + let statement = statement::with_claim(&verifier.statement_prefix, claim); + verifier.profile()?.verify(packet, &statement)?; + Ok(verifier.setup) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::{self, PROOF_TAG}; + const PACKET: &[u8] = + include_bytes!("../../tests/fixtures/aggregate-kzg/filecoin-v5-packet.bin"); + const DEVELOPMENT_PACKET: &[u8] = include_bytes!( + "../../tests/fixtures/aggregate-kzg/development-v5-packet.bin" + ); + const CLAIM: &[u8] = + include_bytes!("../../tests/fixtures/aggregate-kzg/filecoin-claim.bin"); + + fn fixtures() -> [(&'static [u8], SetupClass, &'static str); 2] { + [ + ( + PACKET, + SetupClass::Filecoin, + include_str!("../../profiles/aggregate-filecoin-v5.json"), + ), + ( + DEVELOPMENT_PACKET, + SetupClass::Development, + include_str!("../../profiles/aggregate-development-v5.json"), + ), + ] + } + + fn envelope(packet: &[u8]) -> Vec { + [PROOF_TAG.as_slice(), packet].concat() + } + + fn verify( + proof: &[u8], + claim: &[u8], + development: bool, + ) -> Result { + pipeline::verify(proof, claim, development) + } + + #[test] + fn bundled_packets_verify_without_a_registry_or_srs() { + for (packet, setup, _) in fixtures() { + assert_eq!( + verify(&envelope(packet), CLAIM, setup == SetupClass::Development) + .unwrap(), + setup + ); + } + } + + #[test] + fn development_profile_requires_explicit_opt_in() { + assert!( + verify(&envelope(DEVELOPMENT_PACKET), CLAIM, false) + .unwrap_err() + .to_string() + .contains("--dev-srs") + ); + assert_eq!( + verify(&envelope(DEVELOPMENT_PACKET), CLAIM, true).unwrap(), + SetupClass::Development + ); + } + + #[test] + fn bundled_metadata_matches_artifacts() { + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/init-fri"); + for (packet, setup, json) in fixtures() { + let metadata: serde_json::Value = serde_json::from_str(json).unwrap(); + let id = packet[..32].try_into().unwrap(); + let verifier = bundled(&id).unwrap(); + assert_eq!(verifier.setup, setup); + assert_eq!(metadata["setup"], setup.label()); + assert_eq!(metadata["known_trapdoor"], setup == SetupClass::Development); + assert_eq!(metadata["profile_id"], verifier.id); + assert_eq!(metadata["verifier_blake3"], verifier.digest); + assert_eq!( + metadata["packet_blake3"], + blake3::hash(packet).to_hex().as_str() + ); + assert_eq!( + metadata["claim"]["blake3"], + blake3::hash(CLAIM).to_hex().as_str() + ); + assert_eq!( + metadata["statement_prefix"], + serde_json::json!(verifier.statement_prefix) + ); + for (file, key) in [ + ("outer-vk.bin", "source_fri_verifier_blake3"), + ("outer-proof.bin", "source_fri_proof_blake3"), + ] { + let bytes = std::fs::read(source.join(file)).unwrap(); + assert_eq!(metadata[key], blake3::hash(&bytes).to_hex().as_str()); + } + } + } + + #[test] + fn bundled_packets_bind_all_statement_words() { + for (packet, _, _) in fixtures() { + let id = packet[..32].try_into().unwrap(); + let verifier = bundled(&id).unwrap(); + let statement = statement::with_claim(&verifier.statement_prefix, CLAIM); + let profile = verifier.profile().unwrap(); + profile.verify(packet, &statement).unwrap(); + for index in 0..statement.len() { + let mut wrong = statement; + wrong[index] ^= 1; + assert!(profile.verify(packet, &wrong).is_err()); + } + for index in [0, 32 + 18 * 8, packet.len() - 1] { + let mut packet = packet.to_vec(); + packet[index] ^= 1; + assert!(profile.verify(&packet, &statement).is_err()); + } + } + } + + #[test] + fn claim_is_supplied_independently_of_the_packet() { + for (packet, setup, _) in fixtures() { + let development = setup == SetupClass::Development; + let mut wrong = CLAIM.to_vec(); + wrong[3] ^= 1; + let error = + verify(&envelope(packet), &wrong, development).unwrap_err().to_string(); + assert!(error.contains("packet statement differs"), "{error}"); + let mut wrong_prefix = packet.to_vec(); + wrong_prefix[32] ^= 1; + assert!(verify(&envelope(&wrong_prefix), CLAIM, development).is_err()); + } + } + + #[test] + fn changed_profiles_points_and_proof_lengths_are_rejected() { + for (packet, _, _) in fixtures() { + for index in [0, 32 + 10 * 8, 32 + 18 * 8, packet.len() - 1] { + let mut packet = packet.to_vec(); + packet[index] ^= 1; + assert!(verify(&envelope(&packet), CLAIM, true).is_err()); + } + for end in [0, 31, 32, 32 + 18 * 8, packet.len() - 1] { + assert!(verify(&envelope(&packet[..end]), CLAIM, true).is_err()); + } + let mut extra = packet.to_vec(); + extra.push(0); + assert!(verify(&envelope(&extra), CLAIM, true).is_err()); + let mut wrong_version = envelope(packet); + wrong_version[7] ^= 1; + assert!(verify(&wrong_version, CLAIM, true).is_err()); + } + } +} diff --git a/crates/proof-compression/src/pipeline/wrap.rs b/crates/proof-compression/src/pipeline/wrap.rs deleted file mode 100644 index 0a753fe2b..000000000 --- a/crates/proof-compression/src/pipeline/wrap.rs +++ /dev/null @@ -1,357 +0,0 @@ -use super::{KzgVerifier, Prepared, Result, scalar_statement}; -use crate::kzg::{KzgConfig, Scalar, Srs}; -use crate::recursive::native_verifier; -use ark_bls12_381::{Bls12_381, G1Affine, G2Affine}; -use ark_ec::{AffineRepr, pairing::Pairing}; -use ark_ff::Zero; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use multi_stark::prover::Proof; -use multi_stark::traits::Algebra; -use multi_stark::types::Val; -use std::sync::Arc; - -/// Recursive proof and its public pairing operands. Certificate claim values -/// are supplied independently to RecursiveVerifier::verify. -pub struct RecursiveProof { - pub proof: Proof, - pub pairing_values: Vec, -} - -struct Pairings { - degree_count: usize, - keys: Vec, -} - -impl Pairings { - fn encode(&self, values: &[Scalar]) -> Result> { - if values.len() != 2 * self.keys.len() - || self.keys.len() != self.degree_count + 2 - { - return Err("incorrect pairing operand count".into()); - } - let mut bytes = Vec::with_capacity(values.len() * 24); - for value in values { - let limbs = value.canonical_limbs_le(); - if limbs[3] != 0 { - return Err("noncanonical packed pairing operand".into()); - } - for limb in &limbs[..3] { - bytes.extend(limb.to_le_bytes()); - } - } - Ok(bytes) - } - - fn verify(&self, values: &[Scalar]) -> Result<()> { - let bytes = self.encode(values)?; - let points = bytes - .as_chunks::<48>() - .0 - .iter() - .map(|bytes| G1Affine::deserialize_compressed(bytes.as_slice())) - .collect::, _>>()?; - for range in [0..self.degree_count, self.degree_count..self.keys.len()] { - if !Bls12_381::multi_pairing( - points[range.clone()].to_vec(), - self.keys[range].to_vec(), - ) - .is_zero() - { - return Err("external pairing equation failed".into()); - } - } - Ok(()) - } -} - -pub struct RecursiveStage { - prepared: Prepared, - pairings: Pairings, - claim_len: usize, -} - -/// Build the recursive KZG verifier. The original public statement remains -/// public; degree-check and opening-check operands are additional publics. -pub fn prepare_recursive( - verifier: &KzgVerifier, - proof: &Proof, - expected: &[Val], -) -> Result { - let public = scalar_statement(expected); - verifier.verify(proof, &public)?; - let claims = verifier.statement.claims(&public)?; - let built = native_verifier::build( - &verifier.system, - &verifier.params, - proof, - &claims, - &verifier.statement.locations, - )?; - let assignment = built.assign()?; - let pairings = Pairings { - degree_count: built.degree_output_count, - keys: built.pairing_keys, - }; - pairings.verify(&assignment.public_values()[public.len()..])?; - Ok(RecursiveStage { - prepared: Prepared { circuit: built.circuit, assignment }, - pairings, - claim_len: public.len(), - }) -} - -impl RecursiveStage { - pub fn circuit(&self) -> &multi_stark::plonkish::Circuit { - self.prepared.circuit() - } - - pub fn prove( - self, - srs: Arc, - max_height: usize, - ) -> Result<(RecursiveVerifier, RecursiveProof)> { - let pairing_values = - self.prepared.public_values()[self.claim_len..].to_vec(); - let (verifier, proof) = self.prepared.prove_kzg(srs, max_height)?; - Ok(( - RecursiveVerifier { - verifier, - pairings: self.pairings, - claim_len: self.claim_len, - }, - RecursiveProof { proof, pairing_values }, - )) - } -} - -pub struct RecursiveVerifier { - verifier: KzgVerifier, - pairings: Pairings, - claim_len: usize, -} - -pub(super) fn setup_recursive( - verifier: &KzgVerifier, - srs: Arc, - height: usize, -) -> Result { - let claim_len = verifier.statement.locations.len(); - let claims = verifier.statement.claims(&vec![Scalar::ZERO; claim_len])?; - let template = verifier.codec()?.template(); - let built = native_verifier::build( - &verifier.system, - &verifier.params, - &template, - &claims, - &verifier.statement.locations, - )?; - let outer = super::setup::kzg(built.circuit, srs, height)?; - Ok(RecursiveVerifier { - verifier: outer, - pairings: Pairings { - degree_count: built.degree_output_count, - keys: built.pairing_keys, - }, - claim_len, - }) -} - -impl RecursiveVerifier { - /// Encode all verification data, including both stages' public parameters - /// and the pairing equations. Authenticate the complete encoding on transport. - pub fn to_bytes(&self) -> Result> { - let mut keys = vec![]; - for key in &self.pairings.keys { - key.serialize_compressed(&mut keys)?; - } - super::codec::encode( - b"IXRECV01", - ( - self.verifier.to_bytes()?, - self.claim_len, - self.pairings.degree_count, - keys, - ), - ) - } - - /// Decode an independently trusted verifier with a 64 MiB decoding budget. - pub fn from_bytes(bytes: &[u8]) -> Result { - let (verifier, claim_len, degree_count, keys): ( - Vec, - usize, - usize, - Vec, - ) = super::codec::decode(b"IXRECV01", bytes)?; - let verifier = KzgVerifier::from_bytes(&verifier)?; - let count = degree_count.checked_add(2).ok_or("pairing count overflow")?; - if count.checked_mul(96) != Some(keys.len()) - || count.checked_mul(2).and_then(|n| n.checked_add(claim_len)) - != Some(verifier.statement.locations.len()) - { - return Err("invalid recursive public statement dimensions".into()); - } - let keys = keys - .as_chunks::<96>() - .0 - .iter() - .map(|bytes| G2Affine::deserialize_compressed(bytes.as_slice())) - .collect::, _>>()?; - if keys.iter().any(|p| p.is_zero()) { - return Err("identity pairing key".into()); - } - Ok(Self { verifier, pairings: Pairings { degree_count, keys }, claim_len }) - } - - /// Pack the public pairing operands followed by the fixed-shape KZG proof. - /// The expected certificate claim and trusted verifier are supplied separately. - pub fn encode_proof(&self, proof: &RecursiveProof) -> Result> { - let mut bytes = self.pairings.encode(&proof.pairing_values)?; - bytes.extend(self.verifier.encode_proof(&proof.proof)?); - Ok(bytes) - } - - pub fn verify_bytes(&self, bytes: &[u8], expected: &[Val]) -> Result<()> { - let offset = self.pairings.keys.len() * 48; - if bytes.len() < offset { - return Err("truncated pairing operands".into()); - } - let pairing_values = bytes[..offset] - .as_chunks::<24>() - .0 - .iter() - .map(|chunk| { - let mut limbs = [0; 4]; - for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) { - *limb = u64::from_le_bytes(*bytes); - } - Scalar::from_limbs_le(limbs) - }) - .collect(); - let proof = self.verifier.codec()?.decode(&bytes[offset..])?; - self.verify(&RecursiveProof { proof, pairing_values }, expected) - } - - /// Verify the recursive proof, expected certificate statement, and both - /// external pairing equations. No proving SRS is needed. - pub fn verify(&self, proof: &RecursiveProof, expected: &[Val]) -> Result<()> { - if expected.len() != self.claim_len { - return Err("unexpected certificate claim length".into()); - } - let mut public = scalar_statement(expected); - public.extend_from_slice(&proof.pairing_values); - self.verifier.verify(&proof.proof, &public)?; - self.pairings.verify(&proof.pairing_values) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ark_ec::CurveGroup; - use ark_serialize::CanonicalSerialize; - use multi_stark::plonkish::CircuitBuilder; - use multi_stark::traits::{Algebra, Field}; - - #[test] - fn final_verifier_requires_claim_outer_proof_and_both_pairings() -> Result<()> - { - let inner = Srs::unsafe_dev_setup(8, b"inner-test"); - let outer = Arc::new(Srs::unsafe_dev_setup(128, b"outer-test")); - // Exercise the final verification boundary with small generated outer - // circuits. Actual recursive constraints are tested by prepare_recursive - // and the native_verifier tests, without a large recursive proving run. - for (claim_len, degree_count) in [(2, 0), (5, 2)] { - for invalid_pairing in [None, Some(0), Some(1)] { - if degree_count == 0 && invalid_pairing == Some(0) { - continue; - } - let mut points = vec![]; - let mut keys = vec![]; - if degree_count != 0 { - points.extend([inner.g1[0], -inner.g1[0]]); - keys.extend([inner.g2, inner.g2]); - } - points.extend([inner.g1[1], -inner.g1[0]]); - keys.extend([inner.g2, inner.tau_g2]); - if let Some(equation) = invalid_pairing { - let index = if equation == 0 { 0 } else { degree_count }; - points[index] = - (points[index] * ark_bls12_381::Fr::from(2u8)).into_affine(); - } - let mut packed = vec![]; - for point in points { - let mut bytes = vec![]; - point.serialize_compressed(&mut bytes)?; - for chunk in bytes.as_chunks::<24>().0 { - let mut limbs = [0; 4]; - for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) - { - *limb = u64::from_le_bytes(*bytes); - } - packed.push(Scalar::from_limbs_le(limbs)); - } - } - let expected: Vec<_> = - (0..claim_len).map(|i| Val::from_usize(7 + i)).collect(); - let mut public = scalar_statement(&expected); - public.extend_from_slice(&packed); - let mut b = CircuitBuilder::new(); - let wires: Vec<_> = (0..public.len()) - .map(|i| b.public_input(format!("public{i}"))) - .collect(); - let circuit = b.finish(); - let mut witness = circuit.witness(); - for (&wire, &value) in wires.iter().zip(&public) { - witness.set(wire, value)?; - } - let assignment = witness.generate()?; - let stage = RecursiveStage { - prepared: Prepared { circuit, assignment }, - pairings: Pairings { degree_count, keys }, - claim_len, - }; - let (verifier, mut proof) = stage.prove(outer.clone(), 128)?; - assert_eq!( - verifier.verify(&proof, &expected).is_err(), - invalid_pairing.is_some() - ); - if invalid_pairing.is_none() { - let key_bytes = verifier.to_bytes()?; - let verifier = RecursiveVerifier::from_bytes(&key_bytes)?; - assert_eq!(verifier.to_bytes()?, key_bytes); - assert!( - RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) - .is_err() - ); - let mut extra_key = key_bytes.clone(); - extra_key.push(0); - assert!(RecursiveVerifier::from_bytes(&extra_key).is_err()); - let bytes = verifier.encode_proof(&proof)?; - verifier.verify_bytes(&bytes, &expected)?; - assert!( - verifier - .verify_bytes(&bytes[..bytes.len() - 1], &expected) - .is_err() - ); - let mut extra = bytes.clone(); - extra.push(0); - assert!(verifier.verify_bytes(&extra, &expected).is_err()); - let mut malformed = bytes.clone(); - malformed[..48].fill(0xff); - assert!(verifier.verify_bytes(&malformed, &expected).is_err()); - let mut wrong = expected.clone(); - wrong[0] += Val::ONE; - assert!(verifier.verify(&proof, &wrong).is_err()); - assert!(verifier.verify(&proof, &expected[..claim_len - 1]).is_err()); - proof.pairing_values[0] += Scalar::ONE; - assert!(verifier.verify(&proof, &expected).is_err()); - proof.pairing_values[0] -= Scalar::ONE; - proof.proof.stage_1_opened_values[0][0][0] += Scalar::ONE; - assert!(verifier.verify(&proof, &expected).is_err()); - } - } - } - Ok(()) - } -} diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs new file mode 100644 index 000000000..507d995c5 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -0,0 +1,54 @@ +mod outer; +mod saved; + +pub(super) fn run( + args: &[String], + config: &super::WorkerConfig, +) -> Result<(), Box> { + if args + .first() + .is_some_and(|s| matches!(s.as_str(), "native-verify" | "stage-and-prove")) + { + if args.len() != 3 { + return Err("usage: ix-compression-worker wrap ".into()); + } + let dir = std::path::Path::new(&args[2]); + if args[0] == "stage-and-prove" { + spawn_memory_monitor(); + std::fs::create_dir_all(dir)?; + return saved::check( + std::path::Path::new(&args[1]), + &dir.join("circuit-report.json"), + Some(dir), + config, + ); + } + return saved::check(std::path::Path::new(&args[1]), dir, None, config); + } + Err( + "usage: ix-compression-worker wrap ..." + .into(), + ) +} + +fn spawn_memory_monitor() { + std::thread::spawn(|| { + loop { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + eprintln!( + "{}", + status + .lines() + .filter(|l| l.starts_with("VmRSS:") || l.starts_with("VmHWM:")) + .collect::>() + .join(" ") + ); + } + } + }); +} + +pub(super) use outer::verify_saved as verify; + +pub(super) use outer::{PreparedVerifierProfile, VerifierProfile}; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs new file mode 100644 index 000000000..43271b027 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -0,0 +1,1065 @@ +//! Recursive KZG wrapper: staging and proving in one process, and the +//! portable verifier its packets are checked with. +use super::super::stages::{ + construct::{self, Lowered}, + statement::Statement as StageStatement, + verification, +}; +use super::{ + super::Result as PipelineResult, + super::{ + fixed_cache, prover, + setup::{SetupClass, SetupSource}, + statement::{self, Statement}, + storage, + }, +}; +use crate::{ + kzg::{ + KzgCommitment, KzgConfig, Scalar, TranscriptKind, compact::FixedProofCodec, + }, + recursive::native_verifier::Built, +}; +use multi_stark::{ + plonkish::{Assignment, MultiStarkCircuit}, + traits::Field, +}; +use std::{fs, path::Path}; + +pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-v1"; +const OUTER_PROFILE_VERSION: u32 = 5; +/// Largest admitted recursive computation trace. The development public +/// degree of a staged profile covers exactly its own height. +pub(super) const MAX_TRACE_LEN: usize = construct::RECURSIVE_HEIGHT; + +#[derive(serde::Serialize, serde::Deserialize)] +struct Pairings { + keys: Vec, +} + +pub(crate) fn stage( + built: Built, + assignment: Assignment, + dir: &Path, + profile: &[u8], + statement: &Statement, + config: &super::super::WorkerConfig, +) -> storage::Result<()> { + let frontend = Frontend::from_built(built, profile)?; + frontend.stage(assignment, dir, statement, config) +} + +/// A recursive circuit lowered into one computation trace and one merged +/// table, ready to stage any number of requests of its profile. +pub(crate) struct Frontend { + compiled: MultiStarkCircuit, + pairings: Pairings, + profile: Vec, +} + +impl Frontend { + fn from_built(built: Built, profile: &[u8]) -> storage::Result { + Self::new(built.circuit, &built.pairing_keys, profile, MAX_TRACE_LEN) + } + + pub(crate) fn new( + circuit: multi_stark::plonkish::Circuit, + keys: &[ark_bls12_381::G2Affine], + profile: &[u8], + max_height: usize, + ) -> storage::Result { + use ark_serialize::CanonicalSerialize; + if keys.len() != 2 { + return Err("invalid recursive pairing profile".into()); + } + let mut encoded = vec![]; + keys.serialize_compressed(&mut encoded)?; + let compiled = construct::lower_recursive(circuit, max_height)?; + Ok(Self { + compiled, + pairings: Pairings { keys: encoded }, + profile: profile.to_vec(), + }) + } + + /// Stage and prove one request, keeping the witness traces in memory. + /// Fixed preprocessing comes from the cache when it already holds this + /// profile and is published to it after the proof verifies. + pub(crate) fn stage( + self, + assignment: Assignment, + dir: &Path, + statement: &Statement, + config: &super::super::WorkerConfig, + ) -> storage::Result<()> { + if assignment.public_values().len() != 18 + 2 * 2 + || assignment.public_values()[..18] + .iter() + .zip(statement) + .any(|(&value, &word)| value != Scalar::from_u64(word)) + { + return Err( + "recursive assignment differs from the first stage's statement".into(), + ); + } + let start = std::time::Instant::now(); + let height = self.compiled.main_height(); + let setup = config + .setup + .for_stage(OUTER_SEED, height)? + .with_transcript(TranscriptKind::Keccak256); + let setup_id = setup.identity(height, 2)?; + fs::create_dir_all(dir)?; + setup.bind_stage(dir, height, 2)?; + storage::save(&dir.join("pairings.bin"), &self.pairings)?; + let cache_profile = [self.profile.as_slice(), &setup_id].concat(); + let cache = config.fixed_cache(dir, &cache_profile)?; + let cached = cache.restore(dir)?; + let manifest = storage::Manifest::for_compiled( + &self.compiled, + self.compiled.claims(assignment.public_values())?, + cached.as_ref(), + )?; + storage::save(&dir.join("manifest.bin"), &manifest)?; + fs::write(dir.join("SECURITY.txt"), setup.security_description())?; + eprintln!("Recursive staging complete: {:?}", start.elapsed()); + prove( + dir, + Lowered { compiled: self.compiled, assignment }, + &cache, + &setup, + )?; + eprintln!("Recursive stage-and-prove complete: {:?}", start.elapsed()); + Ok(()) + } +} + +fn pairing_keys(dir: &Path) -> storage::Result> { + let profile: Pairings = storage::load(&dir.join("pairings.bin"))?; + decode_pairing_keys(&profile) +} + +fn decode_pairing_keys( + profile: &Pairings, +) -> storage::Result> { + use ark_ec::AffineRepr; + use ark_serialize::CanonicalDeserialize; + if profile.keys.len() != 8 + 2 * 96 + || profile.keys.get(..8) != Some(&2u64.to_le_bytes()) + { + return Err("invalid pairing profile".into()); + } + let mut input = profile.keys.as_slice(); + let keys = + Vec::::deserialize_compressed(&mut input)?; + if !input.is_empty() + || keys.len() != 2 + || keys.iter().any(AffineRepr::is_zero) + { + return Err("invalid pairing profile".into()); + } + Ok(keys) +} + +fn pairing_bytes(publics: &[Scalar]) -> storage::Result> { + verification::pack_pairings(publics) +} +fn check_pairings( + bytes: &[u8], + keys: &[ark_bls12_381::G2Affine], +) -> storage::Result<()> { + verification::Pairings { + keys: keys.try_into().map_err(|_keys| "pairing point count")?, + } + .verify_bytes(bytes) +} +fn packet_body<'a>( + packet: &'a [u8], + profile: &[u8; 32], + expected: &Statement, +) -> storage::Result<&'a [u8]> { + let offset = 32 + statement::WORDS * 8; + if packet.len() < offset + 96 || packet[..32] != *profile { + return Err("packet profile mismatch".into()); + } + for (bytes, &word) in + packet[32..offset].as_chunks::<8>().0.iter().zip(expected) + { + if u64::from_le_bytes(*bytes) != word { + return Err("packet statement differs from the claim's".into()); + } + } + Ok(&packet[offset..]) +} + +#[cfg(test)] +fn packet_claims( + packet: &[u8], + profile: &[u8; 32], + pairing_count: usize, + expected: &Statement, +) -> storage::Result<(Vec>, usize)> { + if pairing_count != 2 { + return Err("pairing point count".into()); + } + let body = packet_body(packet, profile, expected)?; + let mut public: Vec<_> = + expected.iter().copied().map(Scalar::from_u64).collect(); + public.extend(verification::unpack_pairings(&body[..96])?); + let claims = StageStatement::recursive(public.len()).claims(&public)?; + Ok((claims, 32 + statement::WORDS * 8 + 96)) +} +fn verify_packet( + system: &multi_stark::system::System, + codec: &FixedProofCodec, + packet: &[u8], + profile: &[u8; 32], + keys: &[ark_bls12_381::G2Affine], + expected: &Statement, +) -> storage::Result<()> { + let body = packet_body(packet, profile, expected)?; + let pairings = verification::Pairings { + keys: keys.try_into().map_err(|_keys| "pairing point count")?, + }; + verification::verify_recursive_bytes( + system, + codec, + &StageStatement::recursive(22), + &pairings, + body, + &expected.iter().copied().map(Scalar::from_u64).collect::>(), + ) +} + +fn staged_publics( + claims: &[Vec], + pairing_count: usize, +) -> storage::Result> { + let count = pairing_count + .checked_mul(2) + .and_then(|n| n.checked_add(18)) + .ok_or("public count overflow")?; + StageStatement::recursive(count).public_values(claims) +} + +/// Commit the fixed and witness traces from memory, prove, verify the +/// packet and write it with the portable verifier. Fixed columns are +/// checkpointed because every proof of the profile shares them through the +/// cache; witness columns never touch disk. +fn prove( + dir: &Path, + lowered: Lowered, + cache: &fixed_cache::FixedCache, + setup: &SetupSource, +) -> storage::Result<()> { + use multi_stark::config::ProofConfig; + use std::time::Instant; + let start = Instant::now(); + let manifest: storage::Manifest = storage::load(&dir.join("manifest.bin"))?; + if manifest.heights.len() != 2 + || manifest.widths.len() != 2 + || manifest.widths[0] != 3 + || manifest.widths[1] != 1 + || manifest + .heights + .iter() + .any(|h| *h < 2 || !h.is_power_of_two() || *h > 1 << 28) + || manifest.heights[1] > manifest.heights[0] + { + return Err("expected one computation trace and one merged table".into()); + } + let count = manifest.heights.len(); + let height = manifest.heights[0]; + let compiled = &lowered.compiled; + if compiled.num_circuits() != count + || compiled.main_heights() != [height] + || (0..count).any(|i| { + compiled.trace_dimensions(i) + != Some((manifest.heights[i], manifest.widths[i])) + }) + { + return Err( + "compiled trace dimensions differ from the staged manifest".into(), + ); + } + let keys = pairing_keys(dir)?; + let publics = staged_publics(&manifest.claims, keys.len())?; + let point_bytes = pairing_bytes(&publics[18..])?; + check_pairings(&point_bytes, &keys)?; + let statement = statement::from_scalars(&publics[..18])?; + let out = dir.join("kzg"); + fs::create_dir_all(&out)?; + eprintln!("Preparing KZG parameters for 2^{} rows", height.ilog2()); + setup.check_binding(dir, height, 2)?; + fs::write(out.join("SECURITY.txt"), setup.security_description())?; + let setup_id = setup.identity(height, 2)?; + let verifier_params = setup.verifier_params(height)?; + let config_started = Instant::now(); + let config = setup + .config(height, 2, false)? + .with_streaming_lookups() + .with_streaming_quotient(); + let srs_config_load_seconds = config_started.elapsed().as_secs_f64(); + eprintln!("Outer KZG parameters ready: {:?}", start.elapsed()); + let prover::Proved { system, codec, bytes, .. } = + lowered.commit(config, Some(&out), 0)?.prove(&manifest.claims)?; + let output_span = tracing::info_span!("compression/write_output").entered(); + let verifier = VerifierProfile { + version: OUTER_PROFILE_VERSION, + heights: [manifest.heights[0], manifest.heights[1]], + setup: [ + fs::read(out.join("setup-0.bin"))?, + fs::read(out.join("setup-1.bin"))?, + ], + pairings: fs::read(dir.join("pairings.bin"))?, + parameters: verifier_params.to_bytes()?, + }; + let profile = verifier.id(); + let mut packet = profile.to_vec(); + for word in statement { + packet.extend(word.to_le_bytes()); + } + packet.extend(point_bytes); + packet.extend(&bytes); + if packet.len() >= 3_000 { + return Err( + "final public-degree proof packet must be smaller than 3000 bytes".into(), + ); + } + let verify_start = Instant::now(); + tracing::info_span!("compression/verify_packet").in_scope(|| { + verify_packet(&system, &codec, &packet, &profile, &keys, &statement) + })?; + let verify_seconds = verify_start.elapsed().as_secs_f64(); + let encoded = + bincode::serde::encode_to_vec(&verifier, bincode::config::standard())?; + storage::write_atomic(&out.join("proof.compact.bin"), &bytes)?; + storage::write_atomic(&dir.join("verifier.bin"), &encoded)?; + storage::write_atomic(&out.join("profile-id.bin"), &profile)?; + storage::write_atomic(&out.join("packet.bin"), &packet)?; + let public_setup = system.config.pcs().verifier_params().public_setup(); + let development = setup.class() == SetupClass::Development; + let report = serde_json::json!({ + "proof_bytes": bytes.len(), + "packet_bytes": packet.len(), + "claim_bytes": 144, + "pairing_bytes": keys.len() * 48, + "profile_bytes": 32, + "profile_version": OUTER_PROFILE_VERSION, + "transcript": "keccak256", + "total_seconds": start.elapsed().as_secs_f64(), + "verify_seconds": verify_seconds, + "srs_config_load_seconds": srs_config_load_seconds, + "native_verification_passed": true, + "external_pairings_pass": true, + "development_srs": development, + "known_trapdoor": development, + "setup": setup.class().label(), + "public_setup_id": blake3::Hash::from_bytes(public_setup.id).to_hex().to_string(), + "public_max_degree": public_setup.max_degree, + "setup_id": blake3::Hash::from_bytes(setup_id).to_hex().to_string(), + "profile_id": blake3::Hash::from_bytes(profile).to_hex().to_string(), + "trace_heights": manifest.heights, + }); + fs::write( + out.join("prove-report.json"), + serde_json::to_vec_pretty(&report)?, + )?; + println!("{}", serde_json::to_string_pretty(&report)?); + drop(output_span); + cache.publish(dir)?; + Ok(()) +} + +/// Recursive verifier metadata. Application verification accepts only bundled profiles. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct VerifierProfile { + version: u32, + heights: [usize; 2], + setup: [Vec; 2], + pairings: Vec, + parameters: Vec, +} + +fn decode(bytes: &[u8]) -> storage::Result { + if bytes.len() > 1 << 20 { + return Err("verifier profile too large".into()); + } + let (value, consumed) = bincode::serde::decode_from_slice( + bytes, + bincode::config::standard().with_limit::<{ 1 << 20 }>(), + )?; + if consumed != bytes.len() { + return Err("trailing verifier profile bytes".into()); + } + Ok(value) +} + +impl VerifierProfile { + pub(crate) fn from_bytes(bytes: &[u8]) -> PipelineResult { + let profile: Self = decode(bytes)?; + profile.prepare()?; + Ok(profile) + } + + pub(crate) fn id(&self) -> [u8; 32] { + let mut hash = blake3::Hasher::new(); + hash.update( + format!("init-kzg-recursive-profile/v{}", self.version).as_bytes(), + ); + hash.update(OUTER_SEED); + hash.update(&self.parameters); + hash.update(&(self.heights[0] as u64).to_le_bytes()); + for setup in &self.setup { + hash.update(setup); + } + hash.update(&self.pairings); + *hash.finalize().as_bytes() + } + + fn keys(&self) -> storage::Result> { + decode_pairing_keys(&decode(&self.pairings)?) + } + + /// The verifier parameters the profile carries. + pub(crate) fn verifier_params( + &self, + ) -> storage::Result { + Ok(crate::kzg::VerifierParams::from_bytes(&self.parameters)?) + } + + /// Filecoin classification covers both the outer proof and the deferred + /// inner opening equation. Development stages may use different seeds. + pub(crate) fn check_setup_class( + &self, + expected: SetupClass, + ) -> PipelineResult<()> { + let parameters = self.verifier_params()?; + if SetupClass::of(¶meters) != expected { + return Err("bundled verifier setup class differs".into()); + } + if expected == SetupClass::Filecoin + && self.keys()? != [parameters.g2, parameters.tau_g2] + { + return Err( + "Filecoin verifier has different inner pairing anchors".into(), + ); + } + Ok(()) + } + + fn system(&self) -> storage::Result> { + use crate::kzg::VerifierParams; + use multi_stark::system::{Circuit, System}; + if self.version != OUTER_PROFILE_VERSION { + return Err("unsupported recursive verifier profile".into()); + } + if self + .heights + .iter() + .any(|h| *h < 2 || !h.is_power_of_two() || *h > 1 << 28) + || self.heights[1] > self.heights[0] + { + return Err("unsupported recursive verifier profile".into()); + } + let params = VerifierParams::from_bytes(&self.parameters)?; + if params.max_trace_len() != self.heights[0] { + return Err("verifier SRS range mismatch".into()); + } + let mut circuits = vec![]; + let mut fixed = KzgCommitment(vec![]); + for (i, bytes) in self.setup.iter().enumerate() { + let (circuit, mut commit): (Circuit, KzgCommitment) = + decode(bytes)?; + if circuit.main_width != [3, 1][i] + || circuit.preprocessed_height != self.heights[i] + { + return Err("setup shape mismatch".into()); + } + circuits.push(circuit); + fixed.0.append(&mut commit.0); + } + Ok(System { + config: KzgConfig::verifier_only(params, 2) + .with_transcript(TranscriptKind::Keccak256), + circuits, + preprocessed_commit: Some(fixed), + preprocessed_indices: vec![Some(0), Some(1)], + }) + } + + pub(crate) fn prepare(&self) -> PipelineResult { + Ok(PreparedVerifierProfile { + id: self.id(), + verifier: super::super::stages::RecursiveVerifier::from_parts( + self.system()?, + StageStatement::recursive(22), + &self.keys()?, + 18, + )?, + }) + } + + pub(crate) fn verify( + &self, + packet: &[u8], + expected: &Statement, + ) -> PipelineResult<()> { + self.prepare()?.verify(packet, expected) + } +} + +pub(crate) struct PreparedVerifierProfile { + id: [u8; 32], + verifier: super::super::stages::RecursiveVerifier, +} + +impl PreparedVerifierProfile { + pub(crate) fn verify( + &self, + packet: &[u8], + expected: &Statement, + ) -> PipelineResult<()> { + let body = packet_body(packet, &self.id, expected)?; + self.verifier.verify_scalar_bytes( + body, + &expected.iter().copied().map(Scalar::from_u64).collect::>(), + ) + } +} + +/// Verify a packet against the stage's portable verifier. Without an +/// independently derived statement, the one the stage's own claim rows +/// carry is used, so the check covers the proof but not the claim binding. +pub(crate) fn verify_saved( + dir: &Path, + packet: &[u8], + expected: Option<&Statement>, +) -> PipelineResult<()> { + let staged; + let expected = match expected { + Some(expected) => expected, + None => { + let manifest: storage::Manifest = + storage::load(&dir.join("manifest.bin"))?; + staged = statement::from_rows(&manifest.claims)?; + &staged + }, + }; + VerifierProfile::from_bytes(&fs::read(dir.join("verifier.bin"))?)? + .verify(packet, expected) +} + +#[cfg(test)] +mod tests { + use super::super::super::init_claim; + use super::*; + use crate::{foreign::KzgCircuitExt, kzg::Srs}; + use ark_ec::CurveGroup; + use ark_serialize::CanonicalSerialize; + use multi_stark::{ + plonkish::CircuitBuilder, + system::{System, SystemWitness}, + traits::Algebra, + }; + use std::sync::Arc; + + #[test] + fn bundled_and_public_recursive_verifiers_share_transport_validation() + -> storage::Result<()> { + let wire = VerifierProfile::from_bytes(include_bytes!( + "../../../profiles/aggregate-filecoin-v5.bin" + ))?; + let prepared = wire.prepare()?; + let public = super::super::super::stages::RecursiveVerifier::from_bytes( + &prepared.verifier.to_bytes()?, + )?; + let packet = include_bytes!( + "../../../tests/fixtures/aggregate-kzg/filecoin-v5-packet.bin" + ); + let words = init_claim::INIT_PUBLIC_WORDS; + let expected = words.map(Scalar::from_u64); + prepared.verify(packet, &words)?; + public.verify_scalar_bytes(&packet[32 + 18 * 8..], &expected)?; + let mut wrong = expected; + wrong[0] += Scalar::ONE; + assert!( + public.verify_scalar_bytes(&packet[32 + 18 * 8..], &wrong).is_err() + ); + let mut corrupt = packet.to_vec(); + let last = corrupt.len() - 1; + corrupt[last] ^= 1; + assert!(prepared.verify(&corrupt, &words).is_err()); + assert!( + public.verify_scalar_bytes(&corrupt[32 + 18 * 8..], &expected).is_err() + ); + assert!( + public + .verify_scalar_bytes(&packet[32 + 18 * 8..packet.len() - 1], &expected) + .is_err() + ); + Ok(()) + } + + #[test] + fn filecoin_bundle_requires_filecoin_inner_pairing_anchors() { + use ark_serialize::CanonicalSerialize; + let mut profile = VerifierProfile::from_bytes(include_bytes!( + "../../../profiles/aggregate-filecoin-v5.bin" + )) + .unwrap(); + profile.check_setup_class(SetupClass::Filecoin).unwrap(); + assert!(profile.check_setup_class(SetupClass::Development).is_err()); + let development = Srs::unsafe_dev_setup(2, b"mixed-pairing-setup"); + let mut keys = Vec::new(); + vec![development.g2, development.tau_g2] + .serialize_compressed(&mut keys) + .unwrap(); + profile.pairings = bincode::serde::encode_to_vec( + Pairings { keys }, + bincode::config::standard(), + ) + .unwrap(); + assert!(profile.check_setup_class(SetupClass::Filecoin).is_err()); + } + + #[test] + fn packet_requires_outer_proof_claim_and_external_pairings() { + let inner = Srs::unsafe_dev_setup(8, b"packet-inner"); + let keys = vec![inner.g2, inner.tau_g2]; + let profile = [17u8; 32]; + let packet_prefix = |wrong: bool| { + let mut packet = profile.to_vec(); + for v in init_claim::INIT_PUBLIC_WORDS { + packet.extend(v.to_le_bytes()); + } + let lhs = if wrong { + (inner.g1[1] * ark_bls12_381::Fr::from(2u8)).into_affine() + } else { + inner.g1[1] + }; + lhs.serialize_compressed(&mut packet).unwrap(); + (-inner.g1[0]).serialize_compressed(&mut packet).unwrap(); + packet + }; + let (claims, _) = packet_claims( + &packet_prefix(false), + &profile, + 2, + &init_claim::INIT_PUBLIC_WORDS, + ) + .unwrap(); + let mut b = CircuitBuilder::new(); + let inputs: Vec<_> = (1..claims.len()) + .map(|i| { + let v = b.input(format!("public{i}")); + b.expose_public(v); + v + }) + .collect(); + let circuit = b.finish(); + let lowered = circuit.lower_to_multi_stark(Scalar::from_u8(94)).unwrap(); + let config = + KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(64, b"packet-outer")), 2) + .with_transcript(TranscriptKind::Keccak256); + let (system, key) = + System::new(config, lowered.kzg_circuit_inputs(64, 2).unwrap()); + let codec = FixedProofCodec::new( + &system, + &[u8::try_from(system.circuits[0].preprocessed_height.ilog2()).unwrap()], + ) + .unwrap(); + for wrong in [false, true] { + let mut packet = packet_prefix(wrong); + let (claims, _) = + packet_claims(&packet, &profile, 2, &init_claim::INIT_PUBLIC_WORDS) + .unwrap(); + let mut w = lowered.witness(); + for (&input, claim) in inputs.iter().zip(&claims[1..]) { + w.set(input, claim[3]).unwrap(); + } + let a = w.generate().unwrap(); + let proof = system.prove_multiple_claims( + &key, + &claims.iter().map(Vec::as_slice).collect::>(), + SystemWitness::from_stage_1(lowered.traces(&a).unwrap(), &system), + ); + packet.extend(codec.encode(&proof).unwrap()); + assert_eq!( + verify_packet( + &system, + &codec, + &packet, + &profile, + &keys, + &init_claim::INIT_PUBLIC_WORDS + ) + .is_ok(), + !wrong + ); + if !wrong { + let mut changed = packet.clone(); + changed[32 + 8] ^= 1; + assert!( + verify_packet( + &system, + &codec, + &changed, + &profile, + &keys, + &init_claim::INIT_PUBLIC_WORDS + ) + .is_err() + ); + let mut changed_claims = claims.clone(); + changed_claims[2][3] += Scalar::ONE; + assert!( + system + .verify_multiple_claims( + &changed_claims.iter().map(Vec::as_slice).collect::>(), + &proof + ) + .is_err() + ); + assert!( + verify_packet( + &system, + &codec, + &packet[..packet.len() - 1], + &profile, + &keys, + &init_claim::INIT_PUBLIC_WORDS + ) + .is_err() + ); + packet.push(0); + assert!( + verify_packet( + &system, + &codec, + &packet, + &profile, + &keys, + &init_claim::INIT_PUBLIC_WORDS + ) + .is_err() + ); + } + } + } + + fn init_packet_claims(inner: &Srs) -> Vec> { + let mut prefix = [0u8; 32].to_vec(); + for word in init_claim::INIT_PUBLIC_WORDS { + prefix.extend(word.to_le_bytes()); + } + inner.g1[1].serialize_compressed(&mut prefix).unwrap(); + (-inner.g1[0]).serialize_compressed(&mut prefix).unwrap(); + packet_claims(&prefix, &[0u8; 32], 2, &init_claim::INIT_PUBLIC_WORDS) + .unwrap() + .0 + } + + /// Stage a compiled fixture's metadata the way `Frontend::stage` does. + fn stage_fixture( + dir: &Path, + cache_root: &Path, + compiled: &MultiStarkCircuit, + assignment: &Assignment, + keys: &[ark_bls12_381::G2Affine], + ) -> storage::Result { + fs::create_dir_all(dir)?; + let height = compiled.main_height(); + SetupSource::development(OUTER_SEED, height - 1)? + .with_transcript(TranscriptKind::Keccak256) + .bind_stage(dir, height, 2)?; + let mut encoded = vec![]; + keys.serialize_compressed(&mut encoded)?; + storage::save(&dir.join("pairings.bin"), &Pairings { keys: encoded })?; + let cache = fixed_cache::FixedCache::from_profile(cache_root, b"outer")?; + let cached = cache.restore(dir)?; + let manifest = storage::Manifest::for_compiled( + compiled, + compiled.claims(assignment.public_values())?, + cached.as_ref(), + )?; + storage::save(&dir.join("manifest.bin"), &manifest)?; + Ok(cache) + } + + #[test] + fn fused_profiles_prove_and_portable_verifiers_round_trip() + -> storage::Result<()> { + let dir = std::env::temp_dir() + .join(format!("outer-staged-{}-v5", std::process::id())); + fs::create_dir(&dir)?; + let inner = Srs::unsafe_dev_setup(8, b"staged-inner"); + let keys = vec![inner.g2, inner.tau_g2]; + let claims = init_packet_claims(&inner); + let make = || -> storage::Result { + let mut b = CircuitBuilder::new(); + let table = + b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let one = b.constant(Scalar::ONE); + b.lookup(table, &[one]); + for claim in &claims[1..] { + let value = b.constant(claim[3]); + b.expose_public(value); + } + let compiled = b + .finish() + .lower_to_multi_stark_sharded(Scalar::from_u8(94), 128)? + .merge_table_traces(128)?; + let assignment = compiled.witness().generate()?; + Ok(Lowered { compiled, assignment }) + }; + let Lowered { compiled, assignment } = make()?; + let height = compiled.main_height(); + let first = dir.join("first"); + let cache_root = dir.join("cache"); + let cache = + stage_fixture(&first, &cache_root, &compiled, &assignment, &keys)?; + let manifest: storage::Manifest = + storage::load(&first.join("manifest.bin"))?; + assert_eq!(manifest.claims, claims); + let setup = SetupSource::development(OUTER_SEED, height - 1)? + .with_transcript(TranscriptKind::Keccak256); + prove(&first, Lowered { compiled, assignment }, &cache, &setup)?; + assert!(first.join("kzg/fixed-0.bin").exists()); + assert!(!first.join("kzg/main-0.bin").exists()); + let packet = fs::read(first.join("kzg/packet.bin"))?; + verify_saved(&first, &packet, None)?; + for offset in [0, 32, packet.len() - 1] { + let mut bad = packet.clone(); + bad[offset] ^= 1; + assert!(verify_saved(&first, &bad, None).is_err()); + } + assert!(verify_saved(&first, &packet[..packet.len() - 1], None).is_err()); + let mut extra = packet.clone(); + extra.push(0); + assert!(verify_saved(&first, &extra, None).is_err()); + // A second run of the profile restores the fixed preprocessing from + // the cache and produces the same packet. + let second = dir.join("second"); + let Lowered { compiled, assignment } = make()?; + let cache = + stage_fixture(&second, &cache_root, &compiled, &assignment, &keys)?; + assert!(second.join("kzg/fixed-0.bin").exists()); + prove(&second, Lowered { compiled, assignment }, &cache, &setup)?; + assert_eq!(packet, fs::read(second.join("kzg/packet.bin"))?); + for (row, col) in [(0, 3), (1, 0), (1, 1), (1, 2)] { + let mut bad = claims.clone(); + bad[row][col] += Scalar::ONE; + assert!(staged_publics(&bad, keys.len()).is_err()); + } + let mut bad = claims.clone(); + bad[1].pop(); + assert!(staged_publics(&bad, keys.len()).is_err()); + assert!(staged_publics(&claims[..claims.len() - 1], keys.len()).is_err()); + let portable = fs::read(first.join("verifier.bin"))?; + let profile = VerifierProfile::from_bytes(&portable)?; + assert_eq!(profile.version, OUTER_PROFILE_VERSION); + assert_eq!( + profile.system()?.config.transcript_kind(), + TranscriptKind::Keccak256 + ); + + for version in [0, 4, 6] { + let mut unsupported: VerifierProfile = decode(&portable)?; + unsupported.version = version; + let unsupported = bincode::serde::encode_to_vec( + &unsupported, + bincode::config::standard(), + )?; + assert!(VerifierProfile::from_bytes(&unsupported).is_err()); + } + + // Verify using only the portable object after deleting the SRS and all checkpoints. + fs::remove_dir_all(&dir)?; + profile.verify(&packet, &init_claim::INIT_PUBLIC_WORDS)?; + let mut wrong_range: VerifierProfile = decode(&portable)?; + wrong_range.heights[0] *= 2; + assert!(wrong_range.system().is_err()); + Ok(()) + } + + #[test] + fn fused_recursive_traces_prove_and_report() -> storage::Result<()> { + let inner = Srs::unsafe_dev_setup(8, b"fused-recursive-inner"); + let claims = init_packet_claims(&inner); + let make = |private| -> storage::Result<(Built, Assignment)> { + let mut builder = CircuitBuilder::new(); + let input = builder.input("private"); + let table = builder.fixed_table( + "small", + (0..16).map(|v| vec![Scalar::from_u8(v)]).collect(), + ); + builder.lookup(table, &[input]); + for _ in 0..8 { + builder.mul(input, input); + } + for claim in &claims[1..] { + let wire = builder.constant(claim[3]); + builder.expose_public(wire); + } + let circuit = builder.finish(); + let mut witness = circuit.witness(); + witness.set(input, Scalar::from_u8(private))?; + let assignment = witness.generate()?; + Ok(( + Built { + circuit, + inputs: vec![], + points: vec![], + pairing_outputs: vec![], + pairing_keys: vec![inner.g2, inner.tau_g2], + msm_terms: vec![], + terms: vec![], + }, + assignment, + )) + }; + let root = std::env::temp_dir() + .join(format!("kzg-recursive-fused-{}", std::process::id())); + fs::create_dir(&root)?; + let mut outputs = vec![]; + for (name, private) in [("first", 7), ("same", 7), ("other", 9)] { + let dir = root.join(name); + fs::create_dir(&dir)?; + let (built, assignment) = make(private)?; + stage( + built, + assignment, + &dir, + b"recursive-fixture", + &init_claim::INIT_PUBLIC_WORDS, + &crate::pipeline::WorkerConfig::development(), + )?; + let report: serde_json::Value = + serde_json::from_slice(&fs::read(dir.join("kzg/prove-report.json"))?)?; + assert_eq!(report["setup"], "development"); + assert_eq!(report["development_srs"], true); + assert_eq!(report["native_verification_passed"], true); + assert!(report["public_max_degree"].is_u64()); + assert_eq!(report["trace_heights"].as_array().unwrap().len(), 2); + let manifest: storage::Manifest = + storage::load(&dir.join("manifest.bin"))?; + for i in 0..manifest.heights.len() { + assert!(!dir.join(format!("kzg/main-{i}.bin")).exists()); + assert!(dir.join(format!("kzg/fixed-{i}.bin")).exists()); + } + let packet = fs::read(dir.join("kzg/packet.bin"))?; + verify_saved(&dir, &packet, None)?; + outputs.push( + ["proof.compact.bin", "packet.bin", "profile-id.bin"] + .map(|name| fs::read(dir.join("kzg").join(name)).unwrap()), + ); + } + assert_eq!(outputs[0], outputs[1]); + assert_ne!(outputs[1][0], outputs[2][0]); + assert_eq!(outputs[1][2], outputs[2][2]); + fs::remove_dir_all(root)?; + Ok(()) + } + + #[test] + #[ignore = "eight-minute CPU outer proof over an identity-commitment wrapper"] + fn constant_column_first_stage_proof_wraps_proves_and_verifies() + -> storage::Result<()> { + use crate::recursive::native_verifier::Plan; + use multi_stark::{expr::Expr, lookup::Lookup, system::CircuitInputs}; + use p3_matrix::dense::RowMajorMatrix; + let words = init_claim::INIT_PUBLIC_WORDS; + let srs = Arc::new(Srs::unsafe_dev_setup(8, b"constant-column-inner")); + let (system, key) = System::new( + KzgConfig::new(srs.clone(), 2), + [CircuitInputs { + main_width: 18, + preprocessed: Some(RowMajorMatrix::new_col(vec![ + Scalar::ONE, + Scalar::ZERO, + Scalar::ZERO, + Scalar::ZERO, + ])), + constraints: (0..18) + .map(|i| Expr::main_next(i) - Expr::main(i)) + .collect(), + lookups: (0..19) + .map(|index| { + Lookup::pull( + Expr::preprocessed(0), + vec![ + Expr::constant(Scalar::from_u8(93)), + Expr::constant(Scalar::ONE), + Expr::constant(Scalar::from_usize(index)), + if index == 0 { + Expr::constant(Scalar::ZERO) + } else { + Expr::main(u32::try_from(index - 1).unwrap()) + }, + ], + ) + }) + .collect(), + ..Default::default() + }], + ); + let claims: Vec<_> = std::iter::once(0) + .chain(words) + .enumerate() + .map(|(index, value)| { + vec![ + Scalar::from_u8(93), + Scalar::ONE, + Scalar::from_usize(index), + Scalar::from_u64(value), + ] + }) + .collect(); + // Every main column is constant and the first is zero, so the proof + // carries an identity commitment and generator multiples the affine + // wrapper arithmetic must handle without exceptional additions. + let main = RowMajorMatrix::new( + words.into_iter().map(Scalar::from_u64).collect::>().repeat(4), + 18, + ); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(vec![main], &system), + ); + system + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("inner proof: {e:?}"))?; + assert!( + proof.commitments.stage_1_trace.0.iter().flatten().any(|p| p.infinity) + ); + let plan = Plan::new( + &system, + &[2], + super::super::saved::claim_schema(&claims)?, + super::super::saved::layout(), + )?; + let built = plan.build(&proof, &claims)?; + let assignment = built.check()?; + let dir = std::env::temp_dir() + .join(format!("kzg-recursive-constant-{}", std::process::id())); + fs::create_dir(&dir)?; + stage( + built, + assignment, + &dir, + &plan.identity(), + &init_claim::INIT_PUBLIC_WORDS, + &crate::pipeline::WorkerConfig::development(), + )?; + let packet = fs::read(dir.join("kzg/packet.bin"))?; + verify_saved(&dir, &packet, None)?; + let mut bad = packet.clone(); + bad[32 + 18 * 8 + 1] ^= 1; + assert!(verify_saved(&dir, &bad, None).is_err()); + fs::remove_dir_all(dir)?; + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs new file mode 100644 index 000000000..e8ccbb032 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -0,0 +1,426 @@ +use super::super::{ + setup::{BINDING_FILE, FIRST_SEED, SetupClass}, + statement, +}; +use crate::{ + kzg::{KzgCommitment, KzgConfig, Scalar, compact::FixedProofCodec}, + recursive::native_verifier::{ClaimSchema, Plan}, +}; +#[cfg(test)] +use multi_stark::traits::Field; +use multi_stark::{ + prover::Proof, + system::{Circuit, System}, +}; +use serde::de::DeserializeOwned; +use std::{collections::BTreeMap, fs, path::Path, time::Instant}; + +type Result = std::result::Result>; + +#[derive(serde::Deserialize)] +struct Manifest { + widths: Vec, + heights: Vec, + claims: Vec>, +} + +fn read_recorded( + dir: &Path, + name: &str, + hashes: &mut BTreeMap, +) -> Result> { + let bytes = fs::read(dir.join(name))?; + hashes.insert(name.into(), blake3::hash(&bytes).to_hex().to_string()); + Ok(bytes) +} + +fn load( + dir: &Path, + name: &str, + hashes: &mut BTreeMap, +) -> Result { + let bytes = read_recorded(dir, name, hashes)?; + let (result, used) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard())?; + if used != bytes.len() { + return Err("trailing metadata".into()); + } + Ok(result) +} + +#[cfg(test)] +pub(super) fn layout() -> crate::recursive::native_verifier::LayoutPolicy { + super::super::stages::construct::recursive_layout() +} + +pub(super) fn claim_schema(claims: &[Vec]) -> Result { + let public = + super::super::stages::statement::Statement::from_claims(claims, 18)? + .locations; + Ok(ClaimSchema::from_claims(claims, &public)?) +} + +struct Input { + system: System, + proof: Proof, + claims: Vec>, + logs: Vec, + proof_bytes: Vec, + input_blake3: BTreeMap, + setup_identity: [u8; 32], + development_srs: bool, + load_seconds: f64, +} + +impl Input { + fn plan(&self) -> Result> { + super::super::stages::construct::recursive_plan( + &self.system, + &self.logs, + claim_schema(&self.claims)?, + ) + } + + #[tracing::instrument( + level = "info", + skip_all, + name = "compression/load_input" + )] + fn load(dir: &Path, config: &super::super::WorkerConfig) -> Result { + let start = Instant::now(); + let mut input_blake3 = BTreeMap::new(); + let manifest: Manifest = load(dir, "manifest.bin", &mut input_blake3)?; + let count = manifest.heights.len(); + let height = + *manifest.heights.iter().max().ok_or("empty recursive profile")?; + // The partition count follows the outer FRI proof's layout; the trace + // height is the supported profile's. Profile identity is bound separately + // through the profile id and the application's bundled verifier. + if ![1 << 24, 1 << 25].contains(&height) + || count < super::super::first::MIN_PARTITIONS + || manifest.widths.len() != count + || manifest.widths.contains(&0) + || manifest.heights.iter().any(|h| *h < 2 || !h.is_power_of_two()) + { + return Err("unsupported partition profile".into()); + } + let statement = statement::from_rows(&manifest.claims)?; + if manifest.claims + != super::super::first::expected_claims(count, &statement) + { + return Err("claim rows differ from the statement and anchors".into()); + } + eprintln!("Loading authenticated verifier parameters and saved proof"); + let proof_dir = if dir.join("kzg").is_dir() { + dir.join("kzg") + } else { + dir.to_path_buf() + }; + let setup = config.setup.for_stage(FIRST_SEED, height)?; + setup.check_binding(dir, height, 2)?; + let setup_id = setup.identity(height, 2)?; + if read_recorded(dir, BINDING_FILE, &mut input_blake3)? != setup_id { + return Err("setup binding changed while loading saved metadata".into()); + } + let config = setup.config(height, 2, true)?; + let mut circuits = vec![]; + let mut commitment = KzgCommitment(vec![]); + for i in 0..count { + let (circuit, mut part): (Circuit, KzgCommitment) = + load(&proof_dir, &format!("setup-{i}.bin"), &mut input_blake3)?; + if circuit.preprocessed_height != manifest.heights[i] + || circuit.main_width != manifest.widths[i] + || circuit.preprocessed.is_some() + || part.0.len() != 1 + { + return Err("inconsistent setup metadata".into()); + } + circuits.push(circuit); + commitment.0.append(&mut part.0); + } + let system = System { + config, + circuits, + preprocessed_commit: Some(commitment), + preprocessed_indices: (0..count).map(Some).collect(), + }; + let logs: Vec<_> = manifest + .heights + .iter() + .map(|h| u8::try_from(h.ilog2()).unwrap()) + .collect(); + let plan = super::super::stages::construct::recursive_plan( + &system, + &logs, + claim_schema(&manifest.claims)?, + )?; + let proof_bytes = + read_recorded(&proof_dir, "proof.compact.bin", &mut input_blake3)?; + plan.validate_compact_len(proof_bytes.len())?; + let proof = FixedProofCodec::new(&system, &logs)?.decode(&proof_bytes)?; + plan.validate_request(&proof, &manifest.claims)?; + let refs: Vec<_> = manifest.claims.iter().map(Vec::as_slice).collect(); + system + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("native verification: {e:?}"))?; + eprintln!( + "Saved {}-byte proof verifies natively: {:?}", + proof_bytes.len(), + start.elapsed() + ); + Ok(Self { + system, + proof, + claims: manifest.claims, + logs, + proof_bytes, + input_blake3, + setup_identity: setup_id, + development_srs: setup.class() == SetupClass::Development, + load_seconds: start.elapsed().as_secs_f64(), + }) + } +} + +pub(crate) fn check( + dir: &Path, + report: &Path, + stage: Option<&Path>, + config: &super::super::WorkerConfig, +) -> Result<()> { + let start = Instant::now(); + let input = Input::load(dir, config)?; + let build_start = Instant::now(); + let plan = input.plan()?; + let fixed_profile = plan.identity(); + let built = tracing::info_span!("compression/build_circuit") + .in_scope(|| plan.build(&input.proof, &input.claims))?; + let build_seconds = build_start.elapsed().as_secs_f64(); + let witness_start = Instant::now(); + let assignment = + tracing::info_span!("compression/witness").in_scope(|| built.assign())?; + let witness_seconds = witness_start.elapsed().as_secs_f64(); + let stats = built.circuit.stats(); + let result = serde_json::json!({ + "gates": stats.gates, + "lookups": stats.lookups, + "values": stats.values, + "rows": stats.gates + stats.lookups + stats.publics + 1, + "public_values": stats.publics, + "pairing_points": built.pairing_keys.len(), + "msm_terms": built.msm_terms, + "witness_seconds": witness_seconds, + "circuit_satisfied": true, + "external_pairings_pass": true, + "outer_proof_generated": false, + "input_proof_bytes": input.proof_bytes.len(), + "input_proof_blake3": blake3::hash(&input.proof_bytes).to_hex().to_string(), + "input_blake3": input.input_blake3, + "setup_id": blake3::Hash::from_bytes(input.setup_identity).to_hex().to_string(), + "frontend_identity": blake3::Hash::from_bytes(fixed_profile).to_hex().to_string(), + "load_and_native_verify_seconds": input.load_seconds, + "build_seconds": build_seconds, + "total_seconds": start.elapsed().as_secs_f64(), + "development_srs": input.development_srs, + "scope": "Full saved first-stage KZG proof checked by recursive constraints and external pairings; no outer proof yet", + }); + fs::write(report, serde_json::to_vec_pretty(&result)?)?; + println!("{}", serde_json::to_string_pretty(&result)?); + if let Some(stage) = stage { + let statement = statement::from_rows(&input.claims)?; + super::outer::stage( + built, + assignment, + stage, + &fixed_profile, + &statement, + config, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::Srs; + use crate::recursive::native_verifier::LayoutPolicy; + use crate::pipeline::init_claim; + use crate::pipeline::setup::SetupSource; + use multi_stark::traits::Algebra; + use multi_stark::{ + expr::Expr, + lookup::Lookup, + system::{CircuitInputs, ProverKey, SystemWitness}, + }; + use p3_matrix::dense::RowMajorMatrix; + use std::sync::Arc; + + fn input( + system: System, + key: &ProverKey, + words: [u64; 18], + ) -> Input { + let claims: Vec<_> = std::iter::once(0) + .chain(words) + .enumerate() + .map(|(index, value)| { + vec![ + Scalar::from_u8(93), + Scalar::ONE, + Scalar::from_usize(index), + Scalar::from_u64(value), + ] + }) + .collect(); + let main = RowMajorMatrix::new( + words.into_iter().map(Scalar::from_u64).collect::>().repeat(4), + 18, + ); + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = system.prove_multiple_claims( + key, + &refs, + SystemWitness::from_stage_1(vec![main], &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + let proof_bytes = + FixedProofCodec::new(&system, &[2]).unwrap().encode(&proof).unwrap(); + Input { + system, + proof, + claims, + logs: vec![2], + proof_bytes, + input_blake3: BTreeMap::new(), + setup_identity: [0; 32], + development_srs: true, + load_seconds: 0.0, + } + } + + #[test] + fn saved_plan_excludes_public_words_and_binds_constants_and_layout() { + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(8, b"saved-plan-fixture")), + 2, + ); + let definition = CircuitInputs { + main_width: 18, + preprocessed: Some(RowMajorMatrix::new_col(vec![ + Scalar::ONE, + Scalar::ZERO, + Scalar::ZERO, + Scalar::ZERO, + ])), + constraints: (0..18) + .map(|i| Expr::main_next(i) - Expr::main(i)) + .collect(), + lookups: (0..19) + .map(|index| { + Lookup::pull( + Expr::preprocessed(0), + vec![ + Expr::constant(Scalar::from_u8(93)), + Expr::constant(Scalar::ONE), + Expr::constant(Scalar::from_usize(index)), + if index == 0 { + Expr::constant(Scalar::ZERO) + } else { + Expr::main(u32::try_from(index - 1).unwrap()) + }, + ], + ) + }) + .collect(), + ..Default::default() + }; + let (first_system, first_key) = + System::new(config.clone(), [definition.clone()]); + let (second_system, second_key) = System::new(config, [definition]); + let first = input(first_system, &first_key, [0; 18]); + let mut second = + input(second_system, &second_key, std::array::from_fn(|i| i as u64)); + assert_ne!(first.proof_bytes, second.proof_bytes); + let identity = first.plan().unwrap().identity(); + assert_eq!(identity, second.plan().unwrap().identity()); + let other = LayoutPolicy { max_table_height: 1 << 20, ..layout() }; + assert_ne!( + identity, + Plan::new( + &first.system, + &first.logs, + claim_schema(&first.claims).unwrap(), + other + ) + .unwrap() + .identity() + ); + for claim in &mut second.claims { + claim[0] += Scalar::ONE; + } + assert_ne!(identity, second.plan().unwrap().identity()); + for claim in &mut second.claims { + claim[0] -= Scalar::ONE; + } + second.claims[0][3] = Scalar::ONE; + assert!(second.plan().is_err()); + second.claims[0][3] = Scalar::ZERO; + second.claims[18][3] = Scalar::from_limbs_le([0, 1, 0, 0]); + assert!(second.plan().is_err()); + } + + #[test] + fn saved_loader_requires_binding_before_setup_metadata() -> Result<()> { + let root = std::env::temp_dir() + .join(format!("ix-saved-binding-{}", std::process::id())); + fs::create_dir(&root)?; + let ns = Scalar::from_u8(93); + let mut claims: Vec<_> = std::iter::once(0) + .chain(init_claim::INIT_PUBLIC_WORDS) + .enumerate() + .map(|(index, word)| { + vec![ns, Scalar::ONE, Scalar::from_usize(index), Scalar::from_u64(word)] + }) + .collect(); + claims.extend( + (0..11).map(|i| vec![ns, Scalar::from_u8(3), Scalar::from_usize(i)]), + ); + claims.extend((0..6).map(|i| { + vec![ns, Scalar::from_u8(4), Scalar::from_u8(3), Scalar::from_usize(i)] + })); + let manifest = (vec![1usize; 21], vec![1usize << 24; 21], claims); + fs::write( + root.join("manifest.bin"), + bincode::serde::encode_to_vec(&manifest, bincode::config::standard())?, + )?; + fs::write(root.join("setup-0.bin"), [0xff])?; + let missing = + Input::load(&root, &crate::pipeline::WorkerConfig::development()) + .err() + .unwrap(); + assert_eq!( + missing.downcast_ref::().unwrap().kind(), + std::io::ErrorKind::NotFound + ); + fs::write(root.join(BINDING_FILE), [0; 32])?; + let wrong = + Input::load(&root, &crate::pipeline::WorkerConfig::development()) + .err() + .unwrap(); + assert!(wrong.to_string().contains("mismatch")); + fs::remove_file(root.join(BINDING_FILE))?; + fs::write( + root.join(BINDING_FILE), + SetupSource::development(FIRST_SEED, (1 << 24) - 1)? + .identity(1 << 24, 2)?, + )?; + let malformed = + Input::load(&root, &crate::pipeline::WorkerConfig::development()) + .err() + .unwrap(); + assert!(malformed.downcast_ref::().is_some()); + fs::remove_dir_all(root)?; + Ok(()) + } +} diff --git a/crates/proof-compression/src/recursive/native_curve.rs b/crates/proof-compression/src/recursive/native_curve.rs index fc3eb33ec..e0e9322bd 100644 --- a/crates/proof-compression/src/recursive/native_curve.rs +++ b/crates/proof-compression/src/recursive/native_curve.rs @@ -1,14 +1,13 @@ -//! Affine arithmetic with constrained denominators; exceptional sums reject. +//! Affine arithmetic with constrained denominators and a complete final addition. use crate::kzg::Scalar; -use crate::recursive::native_field::Builder; -use crate::recursive::native_field::FqGadget; -use crate::recursive::native_field::FqVar; +use crate::recursive::native_field::{Builder, FqGadget, FqVar, fq_value}; use ark_bls12_381::{Fq, G1Affine}; use ark_ec::{AdditiveGroup, AffineRepr}; use ark_ff::Field; -use multi_stark::plonkish::Bool; -use multi_stark::plonkish::Value; -use multi_stark::traits::Field as NativeField; +use multi_stark::{ + plonkish::{Bool, Value}, + traits::{Algebra, Field as NativeField}, +}; #[derive(Clone, Copy)] pub struct Affine { @@ -37,7 +36,7 @@ impl Affine { f.relation(b, &[(1, xx, self.x), (-1, self.y, self.y)], &[], 4); } pub fn double(self, b: &mut Builder, f: &FqGadget) -> Self { - let slope = f.hint(b, "doubling slope", &[self.x, self.y], |v| { + let slope = f.hint_pure(b, "doubling slope", &[self.x, self.y], |v| { Ok( v[0].square() * Fq::from(3u8) @@ -47,11 +46,11 @@ impl Affine { f.relation(b, &[(2, self.y, slope), (-3, self.x, self.x)], &[], 0); // For an on-curve input y=0 implies x != 0, so the slope equation // itself rejects the only possible zero denominator. - let x = f.hint(b, "double x", &[slope, self.x], |v| { + let x = f.hint_pure(b, "double x", &[slope, self.x], |v| { Ok(v[0].square() - v[1].double()) }); f.relation(b, &[(1, slope, slope)], &[(-2, self.x), (-1, x)], 0); - let y = f.hint(b, "double y", &[slope, self.x, x, self.y], |v| { + let y = f.hint_pure(b, "double y", &[slope, self.x, x, self.y], |v| { Ok(v[0] * (v[1] - v[2]) - v[3]) }); f.relation( @@ -63,11 +62,11 @@ impl Affine { Self { x, y } } pub fn add(self, b: &mut Builder, f: &FqGadget, rhs: Self) -> Self { - let inv = f.hint(b, "addition inverse", &[rhs.x, self.x], |v| { + let inv = f.hint_pure(b, "addition inverse", &[rhs.x, self.x], |v| { (v[0] - v[1]).inverse().ok_or("exceptional affine addition".into()) }); f.relation(b, &[(1, rhs.x, inv), (-1, self.x, inv)], &[], -1); - let slope = f.hint(b, "addition slope", &[rhs.y, self.y, inv], |v| { + let slope = f.hint_pure(b, "addition slope", &[rhs.y, self.y, inv], |v| { Ok((v[0] - v[1]) * v[2]) }); f.relation( @@ -76,7 +75,7 @@ impl Affine { &[(-1, rhs.y), (1, self.y)], 0, ); - let x = f.hint(b, "sum x", &[slope, self.x, rhs.x], |v| { + let x = f.hint_pure(b, "sum x", &[slope, self.x, rhs.x], |v| { Ok(v[0].square() - v[1] - v[2]) }); f.relation( @@ -85,7 +84,7 @@ impl Affine { &[(-1, self.x), (-1, rhs.x), (-1, x)], 0, ); - let y = f.hint(b, "sum y", &[slope, self.x, x, self.y], |v| { + let y = f.hint_pure(b, "sum y", &[slope, self.x, x, self.y], |v| { Ok(v[0] * (v[1] - v[2]) - v[3]) }); f.relation( @@ -96,6 +95,37 @@ impl Affine { ); Self { x, y } } + + /// Complete addition of nonidentity prime-order inputs. The ordinary affine + /// branch receives fixed, distinct points when its denominator would vanish. + /// Equal inputs use doubling; opposite inputs have canonical zero coordinates. + pub fn add_complete( + self, + b: &mut Builder, + f: &FqGadget, + rhs: Self, + ) -> PointInput { + let same_x = equal_flag(b, f, self.x, rhs.x); + let same_y = equal_flag(b, f, self.y, rhs.y); + let g = G1Affine::generator(); + let fallback_left = Self::constant(b, f, g); + let fallback_right = Self::constant(b, f, (g + g).into()); + let left = Self::select(b, same_x, fallback_left, self); + let right = Self::select(b, same_x, fallback_right, rhs); + let ordinary = left.add(b, f, right); + let doubled = self.double(b, f); + let zero = f.constant(b, Fq::from(0u8)); + let exceptional = + Self::select(b, same_y, doubled, Self { x: zero, y: zero }); + let point = Self::select(b, same_x, exceptional, ordinary); + let different_y = b.affine( + [same_y.value(), same_y.value()], + [Scalar::NEG_ONE, Scalar::ZERO], + Scalar::ONE, + ); + let infinity = b.mul(same_x.value(), different_y); + PointInput { point, infinity: b.assert_bool(infinity) } + } pub fn constant_mul(self, b: &mut Builder, f: &FqGadget, n: u64) -> Self { assert!(n > 0); let mut acc = self; @@ -114,10 +144,12 @@ impl Affine { pub fn subgroup(self, b: &mut Builder, f: &FqGadget) { // Same endomorphism criterion as ark-bls12-381's native checker. let xp = self.constant_mul(b, f, 0xd201000000010000); - let inv = - f.hint(b, "reject exceptional subgroup point", &[xp.x, self.x], |v| { - (v[0] - v[1]).inverse().ok_or("exceptional subgroup point".into()) - }); + let inv = f.hint_pure( + b, + "reject exceptional subgroup point", + &[xp.x, self.x], + |v| (v[0] - v[1]).inverse().ok_or("exceptional subgroup point".into()), + ); f.relation(b, &[(1, xp.x, inv), (-1, self.x, inv)], &[], -1); let x2p = xp.constant_mul(b, f, 0xd201000000010000); let beta = f.constant(b, ark_bls12_381::g1::BETA); @@ -125,6 +157,24 @@ impl Affine { f.relation(b, &[], &[(1, self.y), (1, x2p.y)], 0); } } + +fn equal_flag(b: &mut Builder, f: &FqGadget, lhs: FqVar, rhs: FqVar) -> Bool { + let deps: Vec<_> = lhs.0.into_iter().chain(rhs.0).collect(); + let equal = b.hint("Fq equality", &deps, |v| { + Ok(Scalar::from_bool(fq_value(&v[..5]) == fq_value(&v[5..]))) + }); + let flag = b.assert_bool(equal); + let zero = b.constant(Scalar::ZERO); + let flag_fq = FqVar([equal, zero, zero, zero, zero]); + let inverse = f.hint_pure(b, "Fq equality inverse", &[lhs, rhs], |v| { + Ok((v[0] - v[1]).inverse().unwrap_or(Fq::from(0u8))) + }); + f.relation(b, &[(1, lhs, inverse), (-1, rhs, inverse)], &[(1, flag_fq)], -1); + f.relation(b, &[(1, lhs, flag_fq), (-1, rhs, flag_fq)], &[], 0); + f.relation(b, &[(1, inverse, flag_fq)], &[], 0); + flag +} + impl PointInput { pub fn new(b: &mut Builder, f: &FqGadget, name: &str) -> Self { let point = Affine { @@ -147,6 +197,15 @@ impl PointInput { let g = Affine::constant(b, f, G1Affine::generator()); Affine::select(b, self.infinity, g, self.point) } + pub fn canonical(self, b: &mut Builder, f: &FqGadget) -> Self { + let x = f.hint_pure(b, "output x", &[self.point.x], |v| Ok(v[0])); + f.equal(b, x, self.point.x); + f.canonical(b, x); + let y = f.hint_pure(b, "output y", &[self.point.y], |v| Ok(v[0])); + f.equal(b, y, self.point.y); + f.canonical(b, y); + Self { point: Affine { x, y }, infinity: self.infinity } + } pub fn inputs(self, p: G1Affine) -> Vec<(Value, Scalar)> { let (x, y) = if p.infinity { (Fq::from(0u8), Fq::from(0u8)) } else { (p.x, p.y) }; @@ -255,4 +314,35 @@ mod tests { check_operation(operation).unwrap(); } } + + #[test] + fn complete_add_equal_opposite_and_distinct_points() { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let left = PointInput::new(&mut b, &f, "left"); + let right = PointInput::new(&mut b, &f, "right"); + let lhs = left.nonzero(&mut b, &f); + let rhs = right.nonzero(&mut b, &f); + let result = lhs.add_complete(&mut b, &f, rhs); + let result = result.canonical(&mut b, &f); + let c = b.finish(); + let p = G1Affine::generator(); + for q in [p, -p, (p * ark_bls12_381::Fr::from(7u8)).into_affine()] { + let mut w = c.witness(); + for (v, n) in left.inputs(p).into_iter().chain(right.inputs(q)) { + w.set(v, n).unwrap(); + } + let a = w.generate().unwrap(); + let x = fq_value(&result.point.x.0.map(|v| a.value(v).unwrap())); + let y = fq_value(&result.point.y.0.map(|v| a.value(v).unwrap())); + let actual = if a.value(result.infinity.value()).unwrap() == Scalar::ONE { + assert_eq!(x, Fq::from(0u8)); + assert_eq!(y, Fq::from(0u8)); + G1Affine::identity() + } else { + G1Affine::new_unchecked(x, y) + }; + assert_eq!(actual, (p + q).into_affine()); + } + } } diff --git a/crates/proof-compression/src/recursive/native_field.rs b/crates/proof-compression/src/recursive/native_field.rs index 9d3e4d355..27f6d18fa 100644 --- a/crates/proof-compression/src/recursive/native_field.rs +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -2,11 +2,10 @@ use crate::kzg::Scalar; use ark_bls12_381::Fq; use ark_ff::{BigInteger, PrimeField}; -use multi_stark::plonkish::CircuitBuilder; -use multi_stark::plonkish::Table; -use multi_stark::plonkish::Value; -use multi_stark::traits::Algebra; -use multi_stark::traits::Field; +use multi_stark::{ + plonkish::{CircuitBuilder, Table, Value}, + traits::{Algebra, Field}, +}; use num_bigint::{BigInt, BigUint, Sign}; use std::collections::BTreeMap; @@ -15,20 +14,25 @@ const W: usize = 80; const L: usize = 5; const CARRY_BITS: usize = 89; +mod advice; + #[derive(Clone, Copy, Debug)] pub struct FqVar(pub [Value; L]); pub struct FqGadget { range: Table, + #[cfg(test)] + reference_advice: bool, } pub fn integer(v: Scalar) -> BigUint { - BigUint::from_bytes_le( - &v.canonical_limbs_le() - .into_iter() - .flat_map(u64::to_le_bytes) - .collect::>(), - ) + let mut bytes = [0; 32]; + for (output, word) in + bytes.as_chunks_mut::<8>().0.iter_mut().zip(v.canonical_limbs_le()) + { + *output = word.to_le_bytes(); + } + BigUint::from_bytes_le(&bytes) } pub fn scalar(v: &BigUint) -> Scalar { let digits = v.to_u64_digits(); @@ -64,6 +68,8 @@ impl FqGadget { "Fq u16", (0..=u16::MAX).map(|v| vec![Scalar::from_u16(v)]).collect(), ), + #[cfg(test)] + reference_advice: false, } } pub fn range(&self, b: &mut Builder, value: Value, bits: usize) { @@ -84,11 +90,14 @@ impl FqGadget { bits: usize, ) { assert!(bits > 0 && bits <= N * 16); - let limbs = b.hint_many::("bounded integer", &[value], |v| { - let n = integer(v[0]); - Ok(std::array::from_fn(|i| { - scalar(&((&n >> (16 * i)) & BigUint::from(65535u32))) - })) + #[cfg(test)] + let reference_advice = self.reference_advice; + let limbs = b.hint_many_pure::("bounded integer", &[value], move |v| { + #[cfg(test)] + if reference_advice { + return Ok(advice::range_reference(v[0])); + } + Ok(advice::range_limbs(v[0])) }); for &limb in &limbs { b.lookup(self.range, &[limb]); @@ -121,7 +130,11 @@ impl FqGadget { digits(&BigUint::from_bytes_le(&value.into_bigint().to_bytes_le())) .map(|n| scalar(&n)) } - pub fn hint( + /// The callback's values and errors must depend only on its arguments and + /// immutable captures, with no observable effects from execution order. + /// Invalid arguments must return an error rather than panic. Independent + /// callbacks may execute even when an earlier point block fails. + pub fn hint_pure( &self, b: &mut Builder, name: &str, @@ -129,7 +142,7 @@ impl FqGadget { f: impl Fn(&[Fq]) -> Result + Send + Sync + 'static, ) -> FqVar { let deps: Vec<_> = inputs.iter().flat_map(|v| v.0).collect(); - let result = FqVar(b.hint_many(name, &deps, move |v| { + let result = FqVar(b.hint_many_pure(name, &deps, move |v| { let args: Vec<_> = v.as_chunks::().0.iter().map(|v| fq_value(v)).collect(); Ok(Self::witness_values(f(&args)?)) @@ -146,7 +159,7 @@ impl FqGadget { let p = digits(&(modulus() - 1u8)); let p_hint = p.clone(); let advice = - b.hint_many::<10>("Fq canonical subtraction", &v.0, move |values| { + b.hint_many_pure::<10>("Fq canonical subtraction", &v.0, move |values| { let mut borrow = BigInt::from(0u8); let radix = BigInt::from(1u8) << W; let mut out = [Scalar::ZERO; 10]; @@ -220,73 +233,16 @@ impl FqGadget { .collect(); let pc: Vec<_> = products.iter().map(|(c, _, _)| *c).collect(); let lc: Vec<_> = linear.iter().map(|(c, _)| *c).collect(); - let p = digits(&modulus()); - let p_hint = p.clone(); + let p = &advice::constants().modulus_digits; + #[cfg(test)] + let reference_advice = self.reference_advice; let advice = - b.hint_many::<9>("Fq quotient and carries", &deps, move |values| { - let mut t = vec![BigInt::from(0u8); 10]; - let mut at = 0; - for &c in &pc { - for i in 0..L { - for j in 0..L { - t[i + j] += BigInt::from(c) - * BigInt::from(integer(values[at + i])) - * BigInt::from(integer(values[at + L + j])); - } - } - at += 2 * L; - } - for &c in &lc { - for i in 0..L { - t[i] += BigInt::from(c) * BigInt::from(integer(values[at + i])); - } - at += L; - } - t[0] += constant; - let total = t.iter().rev().fold(BigInt::from(0u8), |a, c| (a << W) + c); - let prime = BigInt::from(modulus()); - if &total % &prime != BigInt::from(0u8) { - return Err("nonzero Fq relation".into()); - } - let q: BigInt = total / prime + (BigInt::from(1u8) << 383usize); - let q = q.to_biguint().ok_or("negative biased quotient")?; - if q.bits() > 384 { - return Err("Fq quotient overflow".into()); - } - let q = digits(&q); - let bias = digits(&(BigUint::from(1u8) << 383)); - for i in 0..L { - for j in 0..L { - t[i + j] -= BigInt::from(p_hint[i].clone()) - * (BigInt::from(q[j].clone()) - BigInt::from(bias[j].clone())); - } - } - let mut out = [Scalar::ZERO; 9]; - for i in 0..L { - out[i] = scalar(&q[i]); - } - let radix = BigInt::from(1u8) << W; - let block_radix = &radix * &radix; - let mut carry = BigInt::from(0u8); - for j in 0..5 { - carry += &t[2 * j] + &radix * &t[2 * j + 1]; - if &carry % &block_radix != BigInt::from(0u8) { - return Err("nonintegral Fq carry".into()); - } - carry /= &block_radix; - if j < 4 { - let biased: BigInt = &carry + (BigInt::from(1u8) << 88usize); - let v = biased.to_biguint().ok_or("negative biased carry")?; - if v.bits() > CARRY_BITS as u64 { - return Err("Fq carry overflow".into()); - } - out[L + j] = scalar(&v); - } + b.hint_many_pure::<9>("Fq quotient and carries", &deps, move |values| { + #[cfg(test)] + if reference_advice { + return advice::relation_reference(values, &pc, &lc, constant, p); } - if carry != BigInt::from(0u8) { - return Err("nonzero final Fq carry".into()); - } - Ok(out) + advice::relation(values, &pc, &lc, constant) }); for (i, &v) in advice[..L].iter().enumerate() { self.range(b, v, if i == L - 1 { 64 } else { W }); @@ -343,7 +299,7 @@ impl FqGadget { } } pub fn mul(&self, b: &mut Builder, x: FqVar, y: FqVar) -> FqVar { - let result = self.hint(b, "Fq product", &[x, y], |v| Ok(v[0] * v[1])); + let result = self.hint_pure(b, "Fq product", &[x, y], |v| Ok(v[0] * v[1])); self.relation(b, &[(1, x, y)], &[(-1, result)], 0); result } @@ -355,8 +311,7 @@ impl FqGadget { #[cfg(test)] mod tests { use super::*; - use multi_stark::plonkish::Assignment; - use multi_stark::plonkish::Circuit; + use multi_stark::plonkish::{Assignment, Circuit}; use std::collections::{HashMap, HashSet}; pub(super) fn check_mutations(c: &Circuit, a: &Assignment) { diff --git a/crates/proof-compression/src/recursive/native_field/advice.rs b/crates/proof-compression/src/recursive/native_field/advice.rs new file mode 100644 index 000000000..4fe58aeb8 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_field/advice.rs @@ -0,0 +1,126 @@ +use std::sync::OnceLock; + +use super::{ + BigInt, BigUint, CARRY_BITS, L, Scalar, W, digits, integer, modulus, scalar, +}; +use multi_stark::traits::{Algebra, Field}; + +pub(super) struct Constants { + pub(super) modulus_digits: [BigUint; L], + modulus_signed_digits: [BigInt; L], + prime: BigInt, + quotient_bias: BigInt, + bias_digits: [BigInt; L], + carry_bias: BigInt, +} + +pub(super) fn constants() -> &'static Constants { + static CONSTANTS: OnceLock = OnceLock::new(); + CONSTANTS.get_or_init(|| { + let prime = modulus(); + let modulus_digits = digits(&prime); + let quotient_bias = BigUint::from(1u8) << 383; + Constants { + modulus_signed_digits: modulus_digits.clone().map(BigInt::from), + modulus_digits, + prime: BigInt::from(prime), + bias_digits: digits("ient_bias).map(BigInt::from), + quotient_bias: BigInt::from(quotient_bias), + carry_bias: BigInt::from(1u8) << 88usize, + } + }) +} + +pub(super) fn range_limbs(value: Scalar) -> [Scalar; N] { + let words = value.canonical_limbs_le(); + std::array::from_fn(|i| { + Scalar::from_u64((words[i / 4] >> (16 * (i % 4))) & 0xffff) + }) +} + +pub(super) fn relation( + values: &[Scalar], + products: &[i8], + linear: &[i8], + constant: i8, +) -> Result<[Scalar; 9], String> { + let constants = constants(); + let values: Vec<_> = + values.iter().map(|&v| BigInt::from(integer(v))).collect(); + let mut terms: [BigInt; 10] = std::array::from_fn(|_| BigInt::from(0u8)); + let mut at = 0; + for &coefficient in products { + let scaled: [BigInt; L] = + std::array::from_fn(|i| coefficient * &values[at + i]); + for i in 0..L { + for j in 0..L { + terms[i + j] += &scaled[i] * &values[at + L + j]; + } + } + at += 2 * L; + } + for &coefficient in linear { + for i in 0..L { + terms[i] += coefficient * &values[at + i]; + } + at += L; + } + terms[0] += constant; + let total = terms.iter().rev().fold(BigInt::from(0u8), |a, c| (a << W) + c); + let quotient = &total / &constants.prime; + let remainder = &total % &constants.prime; + if remainder != BigInt::from(0u8) { + return Err("nonzero Fq relation".into()); + } + let quotient = quotient + &constants.quotient_bias; + let quotient = quotient.to_biguint().ok_or("negative biased quotient")?; + if quotient.bits() > 384 { + return Err("Fq quotient overflow".into()); + } + let quotient = digits("ient); + let unbiased: [BigInt; L] = std::array::from_fn(|i| { + BigInt::from(quotient[i].clone()) - &constants.bias_digits[i] + }); + for i in 0..L { + for j in 0..L { + terms[i + j] -= &constants.modulus_signed_digits[i] * &unbiased[j]; + } + } + let mut out = [Scalar::ZERO; 9]; + for i in 0..L { + out[i] = scalar("ient[i]); + } + let mut carry = BigInt::from(0u8); + for j in 0..5 { + carry += &terms[2 * j] + (&terms[2 * j + 1] << W); + divide_carry(&mut carry)?; + if j < 4 { + let biased = &carry + &constants.carry_bias; + let value = biased.to_biguint().ok_or("negative biased carry")?; + if value.bits() > CARRY_BITS as u64 { + return Err("Fq carry overflow".into()); + } + out[L + j] = scalar(&value); + } + } + if carry != BigInt::from(0u8) { + return Err("nonzero final Fq carry".into()); + } + Ok(out) +} + +fn divide_carry(carry: &mut BigInt) -> Result<(), String> { + // Exactness makes arithmetic right shift agree with division for negative + // carries too. Zero has no trailing-zero count and is exactly divisible. + if carry.trailing_zeros().is_some_and(|bits| bits < (2 * W) as u64) { + return Err("nonintegral Fq carry".into()); + } + *carry >>= 2 * W; + Ok(()) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(super) use tests::{range_reference, relation_reference}; diff --git a/crates/proof-compression/src/recursive/native_field/advice/tests.rs b/crates/proof-compression/src/recursive/native_field/advice/tests.rs new file mode 100644 index 000000000..18d3ea6c7 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_field/advice/tests.rs @@ -0,0 +1,424 @@ +use super::*; +use crate::recursive::native_field::{Builder, FqGadget}; +use ark_bls12_381::Fq; +use ark_ff::PrimeField; + +fn integer_reference(value: Scalar) -> BigUint { + BigUint::from_bytes_le( + &value + .canonical_limbs_le() + .into_iter() + .flat_map(u64::to_le_bytes) + .collect::>(), + ) +} + +pub(in crate::recursive::native_field) fn range_reference( + value: Scalar, +) -> [Scalar; N] { + let n = integer_reference(value); + std::array::from_fn(|i| scalar(&((&n >> (16 * i)) & BigUint::from(65535u32)))) +} + +pub(in crate::recursive::native_field) fn relation_reference( + values: &[Scalar], + pc: &[i8], + lc: &[i8], + constant: i8, + p_hint: &[BigUint; L], +) -> Result<[Scalar; 9], String> { + let mut t = vec![BigInt::from(0u8); 10]; + let mut at = 0; + for &c in pc { + for i in 0..L { + for j in 0..L { + t[i + j] += BigInt::from(c) + * BigInt::from(integer_reference(values[at + i])) + * BigInt::from(integer_reference(values[at + L + j])); + } + } + at += 2 * L; + } + for &c in lc { + for i in 0..L { + t[i] += BigInt::from(c) * BigInt::from(integer_reference(values[at + i])); + } + at += L; + } + t[0] += constant; + let total = t.iter().rev().fold(BigInt::from(0u8), |a, c| (a << W) + c); + let prime = BigInt::from(modulus()); + if &total % &prime != BigInt::from(0u8) { + return Err("nonzero Fq relation".into()); + } + let q: BigInt = total / prime + (BigInt::from(1u8) << 383usize); + let q = q.to_biguint().ok_or("negative biased quotient")?; + if q.bits() > 384 { + return Err("Fq quotient overflow".into()); + } + let q = digits(&q); + let bias = digits(&(BigUint::from(1u8) << 383)); + for i in 0..L { + for j in 0..L { + t[i + j] -= BigInt::from(p_hint[i].clone()) + * (BigInt::from(q[j].clone()) - BigInt::from(bias[j].clone())); + } + } + let mut out = [Scalar::ZERO; 9]; + for i in 0..L { + out[i] = scalar(&q[i]); + } + let radix = BigInt::from(1u8) << W; + let block_radix = &radix * &radix; + let mut carry = BigInt::from(0u8); + for j in 0..5 { + carry += &t[2 * j] + &radix * &t[2 * j + 1]; + if &carry % &block_radix != BigInt::from(0u8) { + return Err("nonintegral Fq carry".into()); + } + carry /= &block_radix; + if j < 4 { + let biased: BigInt = &carry + (BigInt::from(1u8) << 88usize); + let v = biased.to_biguint().ok_or("negative biased carry")?; + if v.bits() > CARRY_BITS as u64 { + return Err("Fq carry overflow".into()); + } + out[L + j] = scalar(&v); + } + } + if carry != BigInt::from(0u8) { + return Err("nonzero final Fq carry".into()); + } + Ok(out) +} + +struct Case { + products: Vec, + linear: Vec, + constant: i8, + values: Vec, +} + +fn cases(count: usize) -> Vec { + let mut state = 0x918e_6af2_a79d_c043u64; + let mut next = || { + let mut bytes = [0; 48]; + for word in bytes.as_chunks_mut::<8>().0 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + *word = state.to_le_bytes(); + } + Fq::from_le_bytes_mod_order(&bytes) + }; + (0..count) + .map(|index| { + let products: Vec = [ + vec![1], + vec![-1], + vec![3], + vec![-3], + vec![1, 1, 1], + vec![-1, -1, -1], + vec![2, -1], + vec![], + ][index % 8] + .clone(); + let constant: i8 = [-4, 0, 4][index % 3]; + let mut total = Fq::from(constant.unsigned_abs()); + if constant < 0 { + total = -total; + } + let mut values = Vec::new(); + for &coefficient in &products { + let a = if index < 8 { -Fq::from(1u8) } else { next() }; + let b = next(); + let term = Fq::from(coefficient.unsigned_abs()) * a * b; + total += if coefficient < 0 { -term } else { term }; + values.extend(FqGadget::witness_values(a)); + values.extend(FqGadget::witness_values(b)); + } + let addend = next(); + total += addend; + values.extend(FqGadget::witness_values(addend)); + values.extend(FqGadget::witness_values(total)); + Case { products, linear: vec![1, -1], constant, values } + }) + .collect() +} + +#[test] +fn range_extraction_and_integer_encoding_match_full_width_reference() { + for value in [ + Scalar::ZERO, + Scalar::ONE, + Scalar::NEG_ONE, + Scalar::from_limbs_le([u64::MAX, u64::MAX, u64::MAX, 1]), + ] { + assert_eq!(integer(value), integer_reference(value)); + assert_eq!(range_limbs::<1>(value), range_reference::<1>(value)); + assert_eq!(range_limbs::<4>(value), range_reference::<4>(value)); + assert_eq!(range_limbs::<5>(value), range_reference::<5>(value)); + assert_eq!(range_limbs::<6>(value), range_reference::<6>(value)); + } +} + +#[test] +fn carry_division_preserves_signed_exactness_at_radix_boundaries() { + let radix = BigInt::from(1u8) << (2 * W); + for multiplier in [ + BigInt::from(0u8), + BigInt::from(1i8), + BigInt::from(-1i8), + BigInt::from(127i8), + BigInt::from(-127i8), + BigInt::from(1u8) << 383, + -(BigInt::from(1u8) << 383usize), + ] { + let exact = &multiplier * &radix; + for delta in [-1i8, 0, 1] { + let value = &exact + delta; + let (quotient, remainder) = (&value / &radix, &value % &radix); + let mut actual = value.clone(); + let result = divide_carry(&mut actual); + if remainder == BigInt::from(0u8) { + assert_eq!(result, Ok(())); + assert_eq!(actual, quotient); + } else { + assert_eq!(result, Err("nonintegral Fq carry".into())); + assert_eq!(actual, value); + } + } + } +} + +#[test] +fn zero_and_quotient_limit_errors_match_reference() { + let modulus_digits = constants().modulus_digits.clone(); + assert_eq!( + relation(&[], &[], &[], 0), + relation_reference(&[], &[], &[], 0, &modulus_digits) + ); + let mut values = vec![Scalar::ZERO; 2 * L]; + values[L - 1] = Scalar::NEG_ONE; + for (value, limb) in values[L..].iter_mut().zip(&modulus_digits) { + *value = scalar(limb); + } + for (coefficient, error) in + [(1, "Fq quotient overflow"), (-1, "negative biased quotient")] + { + let expected = Err(error.into()); + assert_eq!(relation(&values, &[coefficient], &[], 0), expected); + assert_eq!( + relation_reference(&values, &[coefficient], &[], 0, &modulus_digits), + expected + ); + } +} + +#[test] +fn quotient_carries_and_rejections_match_reference() { + let modulus_digits = constants().modulus_digits.clone(); + for mut case in cases(256) { + let expected = relation_reference( + &case.values, + &case.products, + &case.linear, + case.constant, + &modulus_digits, + ) + .unwrap(); + assert_eq!( + relation(&case.values, &case.products, &case.linear, case.constant), + Ok(expected) + ); + let last_input = case.values.len() - L; + case.values[last_input] += Scalar::ONE; + let expected = relation_reference( + &case.values, + &case.products, + &case.linear, + case.constant, + &modulus_digits, + ); + assert!(expected.is_err()); + assert_eq!( + relation(&case.values, &case.products, &case.linear, case.constant), + expected + ); + case.values[0] = Scalar::NEG_ONE; + assert_eq!( + relation(&case.values, &case.products, &case.linear, case.constant), + relation_reference( + &case.values, + &case.products, + &case.linear, + case.constant, + &modulus_digits + ) + ); + } + let prime = FqGadget::witness_values(Fq::from(0u8)); + let redundant = digits(&modulus()).map(|v| scalar(&v)); + for representative in [prime, redundant] { + let values: Vec<_> = + representative.into_iter().cycle().take(3 * L).collect(); + assert_eq!( + relation(&values, &[-3], &[3], 0), + relation_reference(&values, &[-3], &[3], 0, &modulus_digits) + ); + } +} + +fn fixture( + reference: bool, +) -> ( + multi_stark::plonkish::Circuit, + multi_stark::plonkish::Assignment, +) { + let mut builder = Builder::new(); + let mut field = FqGadget::new(&mut builder); + field.reference_advice = reference; + let x = field.input(&mut builder, "x"); + let y = field.input(&mut builder, "y"); + let product = field.mul(&mut builder, x, y); + let expected = field.constant(&mut builder, Fq::from(0u8)); + field.equal(&mut builder, product, expected); + for value in product.0 { + builder.expose_public(value); + } + let circuit = builder.finish(); + let mut witness = circuit.witness(); + for (value, assigned) in x + .0 + .into_iter() + .zip(FqGadget::witness_values(-Fq::from(1u8))) + .chain(y.0.into_iter().zip(FqGadget::witness_values(Fq::from(0u8)))) + { + witness.set(value, assigned).unwrap(); + } + let assignment = witness.generate().unwrap(); + (circuit, assignment) +} + +#[test] +fn advice_preserves_complete_frontend_assignment_and_constraints() { + let (reference, old) = fixture(true); + let (optimized, new) = fixture(false); + assert_eq!(reference.stats(), optimized.stats()); + assert_eq!(old.public_values(), new.public_values()); + for (left, right) in reference.gates().iter().zip(optimized.gates()) { + assert_eq!(left.wires.map(|v| v.index()), right.wires.map(|v| v.index())); + assert_eq!(left.coefficients, right.coefficients); + assert_eq!( + left.wires.map(|v| old.value(v).unwrap()), + right.wires.map(|v| new.value(v).unwrap()) + ); + } + for (left, right) in reference.lookups().iter().zip(optimized.lookups()) { + assert_eq!(left.table.index(), right.table.index()); + assert_eq!( + left.values.iter().map(|&v| old.value(v).unwrap()).collect::>(), + right.values.iter().map(|&v| new.value(v).unwrap()).collect::>() + ); + } +} + +#[test] +#[ignore = "two CPU KZG proofs of a native Fq primitive with its full range table"] +fn recursive_advice_kzg_byte_parity() { + use crate::{ + foreign::KzgCircuitExt, + kzg::{KzgConfig, Srs, compact::FixedProofCodec}, + }; + use multi_stark::system::{System, SystemWitness}; + use std::sync::Arc; + let config = KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(1 << 16, b"recursive-advice-parity")), + 4, + ); + let mut bytes = Vec::new(); + for reference in [true, false] { + let (circuit, assignment) = fixture(reference); + let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(79)).unwrap(); + let traces = compiled.traces(&assignment).unwrap(); + let claims = compiled.claims(assignment.public_values()).unwrap(); + let (system, key) = System::new( + config.clone(), + compiled.kzg_circuit_inputs(1 << 16, 4).unwrap(), + ); + let refs = claims.iter().map(Vec::as_slice).collect::>(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + bytes.push( + FixedProofCodec::new(&system, &proof.log_degrees) + .unwrap() + .encode(&proof) + .unwrap(), + ); + } + assert_eq!(bytes[0], bytes[1]); + println!( + "RECURSIVE_ADVICE_PROOF bytes={} blake3={}", + bytes[0].len(), + blake3::hash(&bytes[0]) + ); +} + +#[test] +#[ignore = "isolated recursive-field advice comparison"] +fn recursive_advice_benchmark() { + use std::{hint::black_box, time::Instant}; + let cases = cases(1 << 12); + let modulus_digits = constants().modulus_digits.clone(); + for case in &cases { + assert_eq!( + relation(&case.values, &case.products, &case.linear, case.constant), + relation_reference( + &case.values, + &case.products, + &case.linear, + case.constant, + &modulus_digits + ) + ); + } + for iteration in 0..5 { + let mut seconds = [0.0; 2]; + let mut outputs = [Vec::new(), Vec::new()]; + for optimized in + if iteration % 2 == 0 { [false, true] } else { [true, false] } + { + let started = Instant::now(); + outputs[usize::from(optimized)] = cases + .iter() + .map(|case| { + let case = black_box(case); + if optimized { + relation(&case.values, &case.products, &case.linear, case.constant) + .unwrap() + } else { + relation_reference( + &case.values, + &case.products, + &case.linear, + case.constant, + &modulus_digits, + ) + .unwrap() + } + }) + .collect::>(); + seconds[usize::from(optimized)] = started.elapsed().as_secs_f64(); + } + assert_eq!(outputs[0], outputs[1]); + println!( + "RECURSIVE_ADVICE_BENCH iteration={iteration} relations={} reference_seconds={:.9} optimized_seconds={:.9}", + cases.len(), + seconds[0], + seconds[1] + ); + } +} diff --git a/crates/proof-compression/src/recursive/native_msm.rs b/crates/proof-compression/src/recursive/native_msm.rs index 12744bc1b..f376f66b2 100644 --- a/crates/proof-compression/src/recursive/native_msm.rs +++ b/crates/proof-compression/src/recursive/native_msm.rs @@ -1,16 +1,16 @@ use crate::kzg::Scalar; -use crate::recursive::native_curve::Affine; -use crate::recursive::native_curve::PointInput; -use crate::recursive::native_field::Builder; -use crate::recursive::native_field::FqGadget; -use crate::recursive::native_transcript::scalar_bytes; -use ark_bls12_381::{Fr, G1Affine}; -use ark_ec::{AffineRepr, CurveGroup}; -use ark_ff::{BigInteger, PrimeField}; -use multi_stark::plonkish::Bool; -use multi_stark::plonkish::Value; -use multi_stark::plonkish::gadgets::ByteGadgets; -use multi_stark::traits::Algebra; +use crate::recursive::{ + native_curve::{Affine, PointInput}, + native_field::{Builder, FqGadget, FqVar, fq_value}, + native_transcript::scalar_bytes, +}; +use ark_bls12_381::{Fr, G1Affine, G1Projective}; +use ark_ec::{AdditiveGroup, AffineRepr, CurveGroup}; +use ark_ff::{BigInteger, Field, PrimeField, Zero}; +use multi_stark::{ + plonkish::{Bool, Value, gadgets::ByteGadgets}, + traits::Algebra, +}; pub struct Base { pub point: PointInput, @@ -70,31 +70,205 @@ fn constant_mul(b: &mut Builder, f: &FqGadget, p: Affine, n: Fr) -> Affine { acc } pub fn negate(b: &mut Builder, f: &FqGadget, p: Affine) -> Affine { - let y = f.hint(b, "negative y", &[p.y], |v| Ok(-v[0])); + let y = f.hint_pure(b, "negative y", &[p.y], |v| Ok(-v[0])); f.relation(b, &[], &[(1, p.y), (1, y)], 0); Affine { x: p.x, y } } -/// Fixed-shape four-bit Straus MSM. Offsets avoid zero digits; constrained -/// inverses reject exceptional intermediate sums, never bypass them. -pub fn msm( +fn correction_coefficients() -> (Fr, Fr) { + let mut k = Fr::from(0u8); + let mut power = Fr::from(1u8); + for _ in 0..64 { + k += power; + power *= Fr::from(16u8); + } + let c = power - k; + assert!(!k.is_zero() && !c.is_zero() && k != c && !power.is_zero()); + (k, c) +} + +fn add_without_exception(lhs: &mut G1Projective, rhs: &G1Projective) -> bool { + if lhs.is_zero() + || rhs.is_zero() + || lhs.x * rhs.z.square() == rhs.x * lhs.z.square() + { + return false; + } + *lhs += rhs; + true +} + +fn offset_is_safe( + h: G1Projective, + tables: &[[G1Projective; 16]], + digits: &[[usize; 64]], +) -> bool { + if h.is_zero() { + return false; + } + let mut sum = h; + for table in tables { + if !add_without_exception(&mut sum, &table[0]) { + return false; + } + } + let mut acc = h; + for window in (0..64).rev() { + for _ in 0..4 { + acc.double_in_place(); + } + for (table, digit) in tables.iter().zip(digits) { + if !add_without_exception(&mut acc, &table[digit[window]]) { + return false; + } + } + } + let (k, c) = correction_coefficients(); + let mut correction = sum * k; + add_without_exception(&mut correction, &(h * c)) +} + +fn choose_offset( + points: &[G1Affine], + scalars: &[Fr], + first: Fr, +) -> Result<(G1Affine, usize), String> { + let tables: Vec<_> = points + .iter() + .map(|p| { + let p = G1Projective::from(*p); + let mut table = [p; 16]; + for n in 2..=16 { + table[n - 1] = + if n % 2 == 0 { table[n / 2 - 1].double() } else { table[n - 2] + p }; + } + table + }) + .collect(); + let digits: Vec<_> = scalars + .iter() + .map(|s| { + let limbs = s.into_bigint(); + std::array::from_fn(|window| { + ((limbs.as_ref()[window / 16] >> (4 * (window % 16))) & 15) as usize + }) + }) + .collect(); + let attempts = points + .len() + .checked_mul(130) + .and_then(|n| n.checked_add(4)) + .ok_or("MSM term count overflow")?; + let generator = G1Affine::generator(); + let mut h = generator * first; + for attempt in 1..=attempts { + if offset_is_safe(h, &tables, &digits) { + return Ok((h.into_affine(), attempt)); + } + h += generator; + } + Err("no valid affine MSM offset".into()) +} + +fn witness_offset( b: &mut Builder, f: &FqGadget, - bytes: &ByteGadgets, - bases: &mut [Base], + bases: &[Base], terms: &[(usize, Value)], tag: &str, ) -> Affine { - assert!(!terms.is_empty()); let mut wide = [0; 64]; blake3::Hasher::new() .update(b"kzg-wrap/msm-offset/v1") .update(tag.as_bytes()) .finalize_xof() .fill(&mut wide); - let h = - (G1Affine::generator() * Fr::from_le_bytes_mod_order(&wide)).into_affine(); - let mut sum = Affine::constant(b, f, h); + let first = Fr::from_le_bytes_mod_order(&wide); + let deps: Vec<_> = terms + .iter() + .flat_map(|&(id, scalar)| { + let point = bases[id].point; + point + .point + .x + .0 + .into_iter() + .chain(point.point.y.0) + .chain([point.infinity.value(), scalar]) + }) + .collect(); + let limbs = b.hint_many::<10>("nonexceptional MSM offset", &deps, move |v| { + let mut points = Vec::with_capacity(v.len() / 12); + let mut scalars = Vec::with_capacity(v.len() / 12); + for values in v.as_chunks::<12>().0 { + let infinity = values[10] == Scalar::ONE; + if values[10] != Scalar::ZERO && !infinity { + return Err("invalid point infinity flag".into()); + } + let point = if infinity { + G1Affine::generator() + } else { + G1Affine::new_unchecked( + fq_value(&values[..5]), + fq_value(&values[5..10]), + ) + }; + if !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err("invalid MSM point".into()); + } + points.push(point); + scalars.push(if infinity { Fr::from(0u8) } else { values[11].0 }); + } + let (offset, _) = choose_offset(&points, &scalars, first)?; + let x = FqGadget::witness_values(offset.x); + let y = FqGadget::witness_values(offset.y); + Ok(std::array::from_fn(|i| if i < 5 { x[i] } else { y[i - 5] })) + }); + let h = Affine { + x: FqVar(limbs[..5].try_into().unwrap()), + y: FqVar(limbs[5..].try_into().unwrap()), + }; + for coordinate in [h.x, h.y] { + f.bound(b, coordinate); + f.canonical(b, coordinate); + } + h.on_curve(b, f); + h.subgroup(b, f); + h +} + +/// Fixed-shape four-bit Straus MSM with a constrained, witness-selected offset. +/// For n bases, at most 130n + 3 subgroup offsets are excluded: each of the +/// 65n affine additions against a fixed multiple excludes at most two offsets, +/// the correction addition excludes two, and the identity excludes one. Every +/// intermediate is aH+B with nonzero a, so advancing H by the generator finds +/// a valid offset within 130n + 4 attempts. Constant multiplications use scalar +/// prefixes strictly below the subgroup order and introduce no further poles. +/// All inverse constraints remain enforced; the retry hint cannot bypass them. +pub fn msm( + b: &mut Builder, + f: &FqGadget, + bytes: &ByteGadgets, + bases: &mut [Base], + terms: &[(usize, Value)], + tag: &str, +) -> PointInput { + assert!(!terms.is_empty()); + let offset = witness_offset(b, f, bases, terms, tag); + msm_with_offset(b, f, bytes, bases, terms, offset) +} + +fn msm_with_offset( + b: &mut Builder, + f: &FqGadget, + bytes: &ByteGadgets, + bases: &mut [Base], + terms: &[(usize, Value)], + h: Affine, +) -> PointInput { + let mut sum = h; let mut tables = Vec::new(); let mut digits = Vec::new(); for &(id, scalar) in terms { @@ -108,7 +282,7 @@ pub fn msm( sum = sum.add(b, f, table[0]); tables.push(table); } - let mut acc = Affine::constant(b, f, h); + let mut acc = h; for window in (0..64).rev() { for _ in 0..4 { acc = acc.double(b, f); @@ -118,25 +292,47 @@ pub fn msm( acc = acc.add(b, f, p); } } - let mut k = Fr::from(0u8); - let mut power = Fr::from(1u8); - for _ in 0..64 { - k += power; - power *= Fr::from(16u8); - } + let (k, c) = correction_coefficients(); + // Digit offsets give acc = 16^64 H + sum((s_i + k) P_i). + // Since sum = H + sum(P_i) and c = 16^64 - k, the correction + // k*sum + c*H cancels every offset for any constrained subgroup H. let ks = constant_mul(b, f, sum, k); - let correction = Affine::constant(b, f, (h * (power - k)).into_affine()); + let correction = constant_mul(b, f, h, c); let correction = ks.add(b, f, correction); let neg = negate(b, f, correction); - acc.add(b, f, neg) + acc.add_complete(b, f, neg) } #[cfg(test)] mod tests { use super::*; use crate::recursive::native_field::fq_value; + use ark_serialize::CanonicalSerialize; + use multi_stark::traits::Field as _; + + fn fr(n: i64) -> Fr { + let value = Fr::from(n.unsigned_abs()); + if n < 0 { -value } else { value } + } + + fn rejects_changed_gate( + circuit: &multi_stark::plonkish::Circuit, + assignment: &multi_stark::plonkish::Assignment, + changed: Value, + delta: Scalar, + ) -> bool { + circuit.gates().iter().any(|g| { + let [a, b, c] = g.wires.map(|v| { + assignment.value(v).unwrap() + + if v == changed { delta } else { Scalar::ZERO } + }); + let [qm, qa, qb, qc, k] = g.coefficients; + qm * a * b + qa * a + qb * b + qc * c + k != Scalar::ZERO + }) + } + #[test] - fn msm_matches_native_with_zero_scalars_and_identity() { + fn msm_matches_native_with_zero_identity_duplicate_and_negated_terms() { let mut b = Builder::new(); let f = FqGadget::new(&mut b); let bytes = ByteGadgets::new(&mut b); @@ -148,14 +344,25 @@ mod tests { let terms: Vec<_> = scalars.iter().enumerate().map(|(i, &v)| (i, v)).collect(); let result = msm(&mut b, &f, &bytes, &mut bases, &terms, "test"); + let result = result.canonical(&mut b, &f); + let encoded = + crate::recursive::native_transcript::point_bytes(&mut b, &bytes, result); let c = b.finish(); - for (ns, ss) in [([2u64, 3, 7], [11u64, 0, 17]), ([0, 9, 11], [17, 91, 0])] - { + for (ns, ss) in [ + ([2, 3, 7], [11, 0, 17]), + ([0, 9, 11], [17, 91, 0]), + ([2, 3, 7], [0, 0, 0]), + ([0, 0, 0], [1, 7, 11]), + ([2, 2, 2], [1, 0, -1]), + ([2, -2, 7], [1, 1, 0]), + ([2, 2, -2], [1, 1, 2]), + ([-2, 2, -7], [-1, 2, 3]), + ] { let mut w = c.witness(); - let mut expected = ark_bls12_381::G1Projective::default(); + let mut expected = G1Projective::default(); for i in 0..3 { - let p = (G1Affine::generator() * Fr::from(ns[i])).into_affine(); - let s = Fr::from(ss[i]); + let p = (G1Affine::generator() * fr(ns[i])).into_affine(); + let s = fr(ss[i]); expected += p * s; for (v, n) in points[i].inputs(p) { w.set(v, n).unwrap(); @@ -163,11 +370,62 @@ mod tests { w.set(scalars[i], Scalar(s)).unwrap(); } let a = w.generate().unwrap(); - let actual = G1Affine::new_unchecked( - fq_value(&result.x.0.map(|v| a.value(v).unwrap())), - fq_value(&result.y.0.map(|v| a.value(v).unwrap())), - ); + let x = fq_value(&result.point.x.0.map(|v| a.value(v).unwrap())); + let y = fq_value(&result.point.y.0.map(|v| a.value(v).unwrap())); + let infinity = a.value(result.infinity.value()).unwrap() == Scalar::ONE; + let actual = if infinity { + assert!(x.is_zero() && y.is_zero()); + G1Affine::identity() + } else { + G1Affine::new_unchecked(x, y) + }; assert_eq!(actual, expected.into_affine()); + let mut expected_bytes = vec![]; + actual.serialize_compressed(&mut expected_bytes).unwrap(); + assert_eq!( + encoded.map(|v| u8::try_from( + a.value(v.value()).unwrap().0.into_bigint().as_ref()[0] + ) + .unwrap()), + expected_bytes.as_slice(), + ); + assert!(rejects_changed_gate( + &c, + &a, + result.infinity.value(), + if infinity { Scalar::NEG_ONE } else { Scalar::ONE }, + )); + assert!(rejects_changed_gate(&c, &a, result.point.x.0[0], Scalar::ONE)); } } + + #[test] + fn offset_retry_and_constraints_reject_a_deliberate_collision() { + let p = (G1Affine::generator() * fr(2)).into_affine(); + let (good, attempts) = choose_offset(&[p], &[fr(0)], fr(-2)).unwrap(); + assert!(attempts > 1 && attempts <= 134); + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + let point = PointInput::new(&mut b, &f, "point"); + let offset = PointInput::new(&mut b, &f, "offset"); + let h = offset.nonzero(&mut b, &f); + let mut bases = [Base::new(point, None)]; + let scalar = b.constant(Scalar::ZERO); + let result = + msm_with_offset(&mut b, &f, &bytes, &mut bases, &[(0, scalar)], h); + let c = b.finish(); + let run = |h| { + let mut w = c.witness(); + for (v, n) in point.inputs(p).into_iter().chain(offset.inputs(h)) { + w.set(v, n).unwrap(); + } + w.generate() + }; + assert!(run(-p).is_err()); + let a = run(good).unwrap(); + assert_eq!(a.value(result.infinity.value()).unwrap(), Scalar::ONE); + assert!(rejects_changed_gate(&c, &a, offset.point.x.0[0], Scalar::ONE)); + assert!(rejects_changed_gate(&c, &a, result.point.y.0[0], Scalar::ONE)); + } } diff --git a/crates/proof-compression/src/recursive/native_transcript.rs b/crates/proof-compression/src/recursive/native_transcript.rs index 82f43d75e..774b2496c 100644 --- a/crates/proof-compression/src/recursive/native_transcript.rs +++ b/crates/proof-compression/src/recursive/native_transcript.rs @@ -1,18 +1,17 @@ use crate::kzg::Scalar; -use crate::recursive::native_curve::PointInput; -use crate::recursive::native_field::Builder; -use crate::recursive::native_field::FqVar; -use crate::recursive::native_field::integer; -use crate::recursive::native_field::modulus; +use crate::recursive::{ + native_curve::PointInput, + native_field::{Builder, FqVar, integer, modulus}, +}; use ark_bls12_381::Fr; use ark_ff::{BigInteger, PrimeField}; -use multi_stark::plonkish::Bool; -use multi_stark::plonkish::Value; -use multi_stark::plonkish::gadgets::ByteGadgets; -use multi_stark::plonkish::gadgets::ByteValue; -use multi_stark::plonkish::gadgets::blake3_xof; -use multi_stark::traits::Algebra; -use multi_stark::traits::Field; +use multi_stark::{ + plonkish::{ + Bool, Value, + gadgets::{ByteGadgets, ByteValue, blake3_xof}, + }, + traits::{Algebra, Field}, +}; use num_bigint::BigUint; /// Final borrow of bound - bytes (both unsigned little endian). @@ -25,7 +24,7 @@ fn greater_than( assert_eq!(input.len(), bound.len()); let mut borrow = b.constant(Scalar::ZERO); for (&value, &limit) in input.iter().zip(bound) { - let out = b.hint_many::<2>( + let out = b.hint_many_pure::<2>( "byte subtraction", &[value.value(), borrow], move |v| { @@ -98,7 +97,7 @@ pub fn fq_bytes( ) -> [ByteValue; 48] { let mut result = Vec::new(); for (i, &limb) in v.0.iter().enumerate() { - let raw = b.hint_many::<10>("Fq limb bytes", &[limb], |v| { + let raw = b.hint_many_pure::<10>("Fq limb bytes", &[limb], |v| { let mut data = integer(v[0]).to_bytes_le(); data.resize(10, 0); Ok(std::array::from_fn(|i| Scalar::from_u8(data[i]))) @@ -197,8 +196,7 @@ impl Transcript { mod tests { use super::*; use crate::kzg::Blake3Transcript; - use crate::recursive::native_curve::PointInput; - use crate::recursive::native_field::FqGadget; + use crate::recursive::{native_curve::PointInput, native_field::FqGadget}; use ark_bls12_381::G1Affine; use ark_ec::{AffineRepr, CurveGroup}; use ark_serialize::CanonicalSerialize; diff --git a/crates/proof-compression/src/recursive/native_verifier.rs b/crates/proof-compression/src/recursive/native_verifier.rs index 01917b7c5..6b539959b 100644 --- a/crates/proof-compression/src/recursive/native_verifier.rs +++ b/crates/proof-compression/src/recursive/native_verifier.rs @@ -1,60 +1,128 @@ //! Fixed-profile ordinary KZG verification with publicly bound pairing inputs. +use crate::{ + kzg::{KzgCommitment, KzgConfig, Scalar}, + recursive::{ + native_curve::{Affine, PointInput}, + native_field::{Builder, FqGadget}, + native_msm::{self, Base}, + native_transcript::{self, Transcript}, + }, +}; +use ark_bls12_381::{Bls12_381, G1Affine, G2Affine}; #[cfg(test)] -use crate::foreign::KzgCircuitExt; - -use crate::kzg::KzgCommitment; -use crate::kzg::KzgConfig; -use crate::kzg::Scalar; -#[cfg(test)] -use crate::kzg::Srs; -use crate::kzg::VerifierParams; -use crate::recursive::native_curve::Affine; -use crate::recursive::native_curve::PointInput; -use crate::recursive::native_field::Builder; -use crate::recursive::native_field::FqGadget; -use crate::recursive::native_msm; -use crate::recursive::native_msm::Base; -use crate::recursive::native_transcript; -use crate::recursive::native_transcript::Transcript; -use ark_bls12_381::{G1Affine, G2Affine}; +use ark_ec::CurveGroup; +use ark_ec::pairing::Pairing; +use ark_ff::Zero; use ark_serialize::CanonicalSerialize; -use multi_stark::config::ProofConfig; -use multi_stark::expr::RowOffset; -use multi_stark::expr::Source; -use multi_stark::graph::Node; -use multi_stark::plonkish::Circuit; -use multi_stark::plonkish::Value; -use multi_stark::plonkish::gadgets::ByteGadgets; -use multi_stark::plonkish::gadgets::ByteValue; -use multi_stark::prover::Proof; -use multi_stark::system::System; -use multi_stark::traits::Algebra; -use multi_stark::traits::Field; -use multi_stark::traits::Pcs; -use multi_stark::traits::TwoAdicField; +use multi_stark::{ + config::ProofConfig, + expr::{RowOffset, Source}, + graph::Node, + plonkish::{ + Circuit, CircuitStats, Value, + gadgets::{ByteGadgets, ByteValue}, + }, + prover::Proof, + system::System, + traits::{Algebra, Field, TwoAdicField}, +}; use std::collections::BTreeMap; -type Commitment = (Vec>, Vec>); +mod bindings; +#[cfg(test)] +mod parallel_prefix_tests; +mod plan; +#[cfg(test)] +mod reuse_tests; +mod shape; + +pub(crate) use bindings::{Bindings, Compiled}; +use bindings::{PointBinding, PointSource, ScalarSource}; +pub(crate) use plan::{ClaimSchema, ClaimSlot, LayoutPolicy, Plan}; +use shape::{MatrixShape, Round, Shape}; + +type Commitment = Vec>; type Openings = Vec>>; +/// A compiled circuit paired with one request's assignments and pairing outputs. pub struct Built { pub circuit: Circuit, pub inputs: Vec<(Value, Scalar)>, + #[cfg(test)] + pub points: Vec, + pub pairing_outputs: Vec, pub pairing_keys: Vec, - pub degree_output_count: usize, + pub msm_terms: Vec, + #[cfg(test)] + pub terms: Vec>, +} + +#[derive(Clone, Copy)] +pub struct Profile<'a> { + pub circuits: &'a [multi_stark::system::Circuit], + pub fixed: &'a KzgCommitment, + pub transcript_seed: &'a [u8], + pub transcript_kind: crate::kzg::TranscriptKind, + pub generator: G1Affine, + pub g2: G2Affine, + pub tau_g2: G2Affine, + pub max_log_degree: usize, +} + +pub struct Counts { + pub stats: CircuitStats, + pub table_dimensions: Vec<(usize, usize)>, + pub msm_terms: Vec, +} + +struct Construction { + context: Context, + pairing_outputs: Vec, + pairing_keys: Vec, + terms: Vec>, +} + +impl Construction { + fn finish(self, plan: &Plan<'_>) -> Compiled { + Compiled { + circuit: self.context.b.finish(), + bindings: Bindings { + identity: plan.identity, + shape: plan.shape.clone(), + claims: plan.claims.clone(), + scalars: self.context.inputs, + points: self.context.points, + pairing_outputs: self.pairing_outputs, + pairing_keys: self.pairing_keys, + msm_terms: self.terms.iter().map(Vec::len).collect(), + #[cfg(test)] + terms: self.terms, + }, + } + } + + fn counts(&self) -> Counts { + Counts { + stats: self.context.b.stats(), + table_dimensions: self.context.b.table_dimensions().collect(), + msm_terms: self.terms.iter().map(Vec::len).collect(), + } + } } + struct Context { b: Builder, f: FqGadget, bytes: ByteGadgets, - inputs: Vec<(Value, Scalar)>, + inputs: Vec<(Value, ScalarSource)>, bases: Vec, + points: Vec, + point_ends: Vec, encodings: Vec<[ByteValue; 48]>, scalar_encodings: BTreeMap, } impl Context { - fn new() -> Self { - let mut b = Builder::new(); + fn new(mut b: Builder) -> Self { let f = FqGadget::new(&mut b); let bytes = ByteGadgets::new(&mut b); Self { @@ -63,18 +131,28 @@ impl Context { bytes, inputs: vec![], bases: vec![], + points: vec![], + point_ends: vec![], encodings: vec![], scalar_encodings: BTreeMap::new(), } } - fn scalar(&mut self, name: &str, value: Scalar) -> Value { + fn scalar(&mut self, name: &str, source: ScalarSource) -> Value { let v = self.b.input(name); - self.inputs.push((v, value)); + self.inputs.push((v, source)); v } - fn point(&mut self, value: G1Affine, fixed: bool) -> usize { - let id = self.bases.len(); - let p = if fixed { + fn point(&mut self, source: PointSource) -> usize { + let id = self.points.len(); + let source = match source { + PointSource::Fixed(value) if value.infinity => { + PointSource::Fixed(G1Affine::identity()) + }, + other => other, + }; + let fixed = + if let PointSource::Fixed(value) = source { Some(value) } else { None }; + let p = if let Some(value) = fixed { let point = Affine { x: self.f.constant(&mut self.b, value.x), y: self.f.constant( @@ -86,53 +164,73 @@ impl Context { let infinity = self.b.assert_bool(flag); PointInput { point, infinity } } else { - let p = PointInput::new(&mut self.b, &self.f, &format!("point{id}")); - self.inputs.extend(p.inputs(value)); - p + PointInput::new(&mut self.b, &self.f, &format!("point{id}")) }; - let encoded = if fixed { + let encoded = if let Some(value) = fixed { let mut encoded = vec![]; value.serialize_compressed(&mut encoded).unwrap(); std::array::from_fn(|i| self.bytes.constant(&mut self.b, encoded[i])) } else { native_transcript::point_bytes(&mut self.b, &self.bytes, p) }; - self.bases.push(Base::new(p, fixed.then_some(value))); + self.bases.push(Base::new(p, fixed)); + self.points.push(PointBinding { source, point: p }); self.encodings.push(encoded); + let end = self.b.stats().values; + if self.point_ends.last() != Some(&end) { + self.point_ends.push(end); + } if id % 64 == 63 { - tracing::debug!( - "Allocated {} curve points: {:?}", - id + 1, - self.b.stats() - ); + eprintln!("Allocated {} curve points: {:?}", id + 1, self.b.stats()); } id } - fn commitment(&mut self, c: &KzgCommitment, fixed: bool) -> Commitment { - ( - c.0 - .iter() - .map(|row| row.iter().map(|&p| self.point(p, fixed)).collect()) - .collect(), - c.1 - .iter() - .map(|row| row.iter().map(|&p| self.point(p, fixed)).collect()) - .collect(), - ) + fn seal_point_prefix(&mut self) -> bool { + self.b.try_parallel_witness_prefix(std::mem::take(&mut self.point_ends)) } - fn openings(&mut self, name: &str, values: &[Vec>]) -> Openings { - values + fn fixed_commitment(&mut self, c: &KzgCommitment) -> Commitment { + c.0 .iter() + .map(|row| { + row.iter().map(|&p| self.point(PointSource::Fixed(p))).collect() + }) + .collect() + } + fn commitment(&mut self, round: Round, shape: &MatrixShape) -> Commitment { + shape + .widths + .iter() + .enumerate() + .map(|(matrix, &width)| { + (0..width) + .map(|column| { + self.point(PointSource::Commitment { round, matrix, column }) + }) + .collect() + }) + .collect() + } + fn openings( + &mut self, + name: &str, + round: Round, + shape: &MatrixShape, + ) -> Openings { + shape + .widths + .iter() + .zip(&shape.opening_rows) .enumerate() - .map(|(i, rows)| { - rows - .iter() - .enumerate() - .map(|(j, row)| { - row - .iter() - .enumerate() - .map(|(k, &v)| self.scalar(&format!("{name}.{i}.{j}.{k}"), v)) + .map(|(matrix, (&width, &rows))| { + (0..rows) + .map(|row| { + (0..width) + .map(|column| { + self.scalar( + &format!("{name}.{matrix}.{row}.{column}"), + ScalarSource::Opening { round, matrix, row, column }, + ) + }) .collect() }) .collect() @@ -150,13 +248,11 @@ impl Context { self.observe_scalar(t, v); } fn observe_commitment(&mut self, t: &mut Transcript, c: &Commitment) { - for rows in [&c.0, &c.1] { - t.constant(&mut self.b, &self.bytes, &(rows.len() as u64).to_le_bytes()); - for row in rows { - t.constant(&mut self.b, &self.bytes, &(row.len() as u64).to_le_bytes()); - for &id in row { - t.observe(&self.encodings[id]); - } + t.constant(&mut self.b, &self.bytes, &(c.len() as u64).to_le_bytes()); + for row in c { + t.constant(&mut self.b, &self.bytes, &(row.len() as u64).to_le_bytes()); + for &id in row { + t.observe(&self.encodings[id]); } } } @@ -187,97 +283,127 @@ fn combine(b: &mut Builder, terms: Vec<(usize, Value)>) -> Vec<(usize, Value)> { unique.into_iter().collect() } +#[cfg(test)] pub fn build( system: &System, - srs: &VerifierParams, + srs: &crate::kzg::Srs, proof: &Proof, claims: &[Vec], public_locations: &[(usize, usize)], ) -> Result { - let quotients = - system.verify_shape(proof).map_err(|e| format!("shape: {e:?}"))?; - if !proof.active.iter().all(|&v| v) - || system - .preprocessed_indices - .iter() - .enumerate() - .any(|(i, &s)| s != Some(i)) + let configured = system.config.pcs().verifier_params(); + if srs.g1.get(..2) != Some(configured.g1.as_slice()) + || srs.g2 != configured.g2 + || srs.tau_g2 != configured.tau_g2 { return Err( - "wrapper requires every circuit active and one fixed matrix per circuit" - .into(), + "wrapper SRS differs from the authenticated configuration".into(), ); } - let logs = &proof.log_degrees; - let mut c = Context::new(); - let fixed = c.commitment( - system.preprocessed_commit.as_ref().ok_or("missing fixed commitment")?, - true, + let plan = Plan::new( + system, + &proof.log_degrees, + ClaimSchema::from_claims(claims, public_locations)?, + LayoutPolicy::legacy(), + )?; + plan.build(proof, claims) +} + +/// Count the ordinary wrapper constraints without retaining an IR or generating +/// a witness. This measures a profile; it does not verify its proof or its SRS. +pub fn count( + profile: &Profile<'_>, + proof: &Proof, + claims: &[Vec], + public_locations: &[(usize, usize)], +) -> Result { + let plan = Plan::from_profile( + profile, + &proof.log_degrees, + ClaimSchema::from_claims(claims, public_locations)?, + LayoutPolicy::legacy(), + )?; + plan.validate_request(proof, claims)?; + plan.count() +} + +fn construct( + profile: &Profile<'_>, + shape: &Shape, + claims: &ClaimSchema, + builder: Builder, +) -> Result { + let circuits = profile.circuits; + let quotients: Vec<_> = + circuits.iter().map(|c| c.quotient_degree()).collect(); + let logs = &shape.logs; + let mut c = Context::new(builder); + let fixed = c.fixed_commitment(profile.fixed); + let main = c.commitment(Round::Main, &shape.rounds[Round::Main.index()]); + let stage2 = + c.commitment(Round::Stage2, &shape.rounds[Round::Stage2.index()]); + let quotient = + c.commitment(Round::Quotient, &shape.rounds[Round::Quotient.index()]); + let witnesses: Vec<_> = (0..shape.opening_generators.len()) + .map(|index| c.point(PointSource::Witness(index))) + .collect(); + let generator = c.point(PointSource::Fixed(profile.generator)); + let prefix_blocks = c.point_ends.len(); + let prefix_values = c.b.stats().values; + let parallel_prefix = c.seal_point_prefix(); + eprintln!("Curve inputs complete: {:?}", c.b.stats()); + eprintln!( + "Point witness prefix: admitted={parallel_prefix}, blocks={prefix_blocks}, values={prefix_values}" ); - let main = c.commitment(&proof.commitments.stage_1_trace, false); - let stage2 = c.commitment(&proof.commitments.stage_2_trace, false); - let quotient = c.commitment(&proof.commitments.quotient_chunks, false); - let witnesses: Vec<_> = - proof.opening_proof.0.iter().map(|&p| c.point(p, false)).collect(); - let generator = c.point(srs.g1[0], true); - tracing::debug!("Curve inputs complete: {:?}", c.b.stats()); - let accs: Vec<_> = proof - .intermediate_accumulators - .iter() - .enumerate() - .map(|(i, &v)| c.scalar(&format!("acc{i}"), v)) + let accs: Vec<_> = (0..logs.len()) + .map(|i| c.scalar(&format!("acc{i}"), ScalarSource::Accumulator(i))) .collect(); c.b.assert_zero(*accs.last().ok_or("empty accumulators")?); - let main_open = c.openings("main", &proof.stage_1_opened_values); - let stage2_open = c.openings("stage2", &proof.stage_2_opened_values); - let quotient_open = c.openings("quotient", &proof.quotient_opened_values); - let fixed_open = c.openings( - "fixed", - proof - .preprocessed_opened_values - .as_ref() - .ok_or("missing fixed openings")?, + let main_open = + c.openings("main", Round::Main, &shape.rounds[Round::Main.index()]); + let stage2_open = + c.openings("stage2", Round::Stage2, &shape.rounds[Round::Stage2.index()]); + let quotient_open = c.openings( + "quotient", + Round::Quotient, + &shape.rounds[Round::Quotient.index()], ); - let mut public_claims = BTreeMap::new(); - for &(i, j) in public_locations { - let value = claims - .get(i) - .and_then(|row| row.get(j)) - .ok_or("public claim location out of bounds")?; - if public_claims.contains_key(&(i, j)) { - return Err("duplicate public claim location".into()); - } - let v = c.scalar(&format!("claim.{i}.{j}"), *value); - c.f.range(&mut c.b, v, 64); - c.b.expose_public(v); - public_claims.insert((i, j), v); - } - let claim_vars: Vec> = claims + let fixed_open = + c.openings("fixed", Round::Fixed, &shape.rounds[Round::Fixed.index()]); + let mut claim_vars: Vec> = claims + .rows .iter() - .enumerate() - .map(|(i, row)| { + .map(|row| { row .iter() - .enumerate() - .map(|(j, &value)| { - public_claims - .get(&(i, j)) - .copied() - .unwrap_or_else(|| c.b.constant(value)) + .map(|slot| match slot { + ClaimSlot::Constant(value) => Some(c.b.constant(*value)), + ClaimSlot::PublicU64 => None, }) .collect() }) .collect(); - let mut seed = b"multi-stark/kzg/v3".to_vec(); - seed.extend((system.config.max_log_degree() as u64).to_le_bytes()); - seed.extend((system.config.pcs().max_quotient_degree() as u64).to_le_bytes()); - srs.g1[0].serialize_compressed(&mut seed).unwrap(); - srs.g2.serialize_compressed(&mut seed).unwrap(); - srs.g1[1].serialize_compressed(&mut seed).unwrap(); - srs.tau_g2.serialize_compressed(&mut seed).unwrap(); - let mut t = Transcript::new(&mut c.b, &c.bytes, &seed); - c.observe_constant(&mut t, system.circuits.len()); - for circuit in &system.circuits { + for &(i, j) in &claims.public_locations { + let v = c.scalar( + &format!("claim.{i}.{j}"), + ScalarSource::Claim { row: i, column: j }, + ); + c.f.range(&mut c.b, v, 64); + c.b.expose_public(v); + claim_vars[i][j] = Some(v); + } + let claim_vars: Vec> = claim_vars + .into_iter() + .map(|row| { + row + .into_iter() + .map(|value| value.expect("validated public claim schema")) + .collect() + }) + .collect(); + let mut t = Transcript::new(&mut c.b, &c.bytes, profile.transcript_seed); + c.observe_constant(&mut t, circuits.len()); + for circuit in circuits { for v in [ circuit.constraint_count(), circuit.max_constraint_degree(), @@ -298,7 +424,7 @@ pub fn build( for &log in logs { c.observe_constant(&mut t, usize::from(log)); } - c.observe_constant(&mut t, claims.len()); + c.observe_constant(&mut t, claims.rows.len()); for row in &claim_vars { c.observe_constant(&mut t, row.len()); for &v in row { @@ -336,26 +462,6 @@ pub fn build( t.constant(&mut c.b, &c.bytes, &u64::from(log).to_le_bytes()); } } - let degree_challenge = c.sample(&mut t); - let mut degree_groups = BTreeMap::>::new(); - let mut shifted = vec![]; - let mut weight = c.b.constant(Scalar::ONE); - for (commit, _) in rounds { - for (i, &log) in logs.iter().enumerate() { - if usize::from(log) < system.config.max_log_degree() { - if commit.0[i].len() != commit.1[i].len() { - return Err("shifted commitment width".into()); - } - for (&p, &s) in commit.0[i].iter().zip(&commit.1[i]) { - degree_groups.entry(log).or_default().push((p, weight)); - shifted.push((s, weight)); - weight = c.b.mul(weight, degree_challenge); - } - } else if !commit.1[i].is_empty() { - return Err("unexpected shifted commitments".into()); - } - } - } let mut batches: Vec<(Scalar, Vec, Vec)> = vec![]; for (commit, openings) in rounds { for (i, rows) in openings.iter().enumerate() { @@ -376,7 +482,7 @@ pub fn build( batches.push((g, vec![], vec![])); batches.len() - 1 }; - batches[index].1.extend_from_slice(&commit.0[i]); + batches[index].1.extend_from_slice(&commit[i]); batches[index].2.extend_from_slice(row); } } @@ -389,7 +495,7 @@ pub fn build( t.observe(&c.encodings[id]); } let r = c.sample(&mut t); - tracing::debug!("Transcript complete: {:?}", c.b.stats()); + eprintln!("Transcript complete: {:?}", c.b.stats()); let mut lhs = vec![]; let mut rhs = vec![]; let mut r_power = c.b.constant(Scalar::ONE); @@ -413,7 +519,7 @@ pub fn build( } // All AIR roots and logUp constraints, in the native folding order. let mut acc = initial_acc; - for (i, circuit) in system.circuits.iter().enumerate() { + for (i, circuit) in circuits.iter().enumerate() { let one = c.b.constant(Scalar::ONE); let mut z_n = zeta; for _ in 0..logs[i] { @@ -518,32 +624,20 @@ pub fn build( c.b.assert_equal(composition, quotient); acc = accs[i]; } - tracing::debug!("AIR checks complete: {:?}", c.b.stats()); + eprintln!("AIR checks complete: {:?}", c.b.stats()); let mut terms = vec![]; let mut keys = vec![]; - if !shifted.is_empty() { - terms.push(combine(&mut c.b, shifted)); - keys.push(srs.g2); - } - for (log, group) in degree_groups { - let group = group - .into_iter() - .map(|(id, v)| (id, c.b.scale(v, Scalar::NEG_ONE))) - .collect(); - terms.push(combine(&mut c.b, group)); - keys.push(srs.degree_keys[usize::from(log)]); - } - let degree_output_count = terms.len(); terms.push(combine(&mut c.b, lhs)); - keys.push(srs.g2); + keys.push(profile.g2); let rhs = rhs .into_iter() .map(|(id, v)| (id, c.b.scale(v, Scalar::NEG_ONE))) .collect(); terms.push(combine(&mut c.b, rhs)); - keys.push(srs.tau_g2); + keys.push(profile.tau_g2); + let mut outputs = vec![]; for (i, terms) in terms.iter().enumerate() { - tracing::debug!("MSM {i}/{}: {} bases", keys.len(), terms.len()); + eprintln!("MSM {i}/{}: {} bases", keys.len(), terms.len()); let point = native_msm::msm( &mut c.b, &c.f, @@ -552,21 +646,8 @@ pub fn build( terms, &format!("pairing{i}"), ); - // Canonicalize before exposing the compressed pairing input. - let x = c.f.hint(&mut c.b, "output x", &[point.x], |v| Ok(v[0])); - c.f.equal(&mut c.b, x, point.x); - c.f.canonical(&mut c.b, x); - let y = c.f.hint(&mut c.b, "output y", &[point.y], |v| Ok(v[0])); - c.f.equal(&mut c.b, y, point.y); - c.f.canonical(&mut c.b, y); - let point = Affine { x, y }; - let zero = c.b.constant(Scalar::ZERO); - let infinity = c.b.assert_bool(zero); - let encoded = native_transcript::point_bytes( - &mut c.b, - &c.bytes, - PointInput { point, infinity }, - ); + let point = point.canonical(&mut c.b, &c.f); + let encoded = native_transcript::point_bytes(&mut c.b, &c.bytes, point); for chunk in encoded.as_chunks::<24>().0 { let mut coefficient = Scalar::ONE; let terms: Vec<_> = chunk @@ -580,19 +661,36 @@ pub fn build( let packed = c.b.linear_combination(&terms, Scalar::ZERO); c.b.expose_public(packed); } - tracing::debug!("MSM {i} complete: {:?}", c.b.stats()); + outputs.push(point); + eprintln!("MSM {i} complete: {:?}", c.b.stats()); } - Ok(Built { - circuit: c.b.finish(), - inputs: c.inputs, + Ok(Construction { + context: c, + pairing_outputs: outputs, pairing_keys: keys, - degree_output_count, + terms, }) } impl Built { - /// Assign and check circuit relations. This does not check the external - /// pairing equations; callers must verify the public operands separately. + #[cfg(test)] + pub fn check( + &self, + ) -> Result< + multi_stark::plonkish::Assignment, + Box, + > { + let assignment = self.assign()?; + bindings::check_msm_outputs( + &assignment, + &self.points, + &self.pairing_outputs, + &self.terms, + )?; + Ok(assignment) + } + + /// Generate a constraint-checked witness and verify its deferred opening pairing. pub fn assign( &self, ) -> Result< @@ -603,17 +701,84 @@ impl Built { for &(v, n) in &self.inputs { w.set(v, n)?; } - Ok(w.generate()?) + let a = w.generate()?; + bindings::check_pairings(&a, &self.pairing_outputs, &self.pairing_keys)?; + Ok(a) } } #[cfg(test)] mod tests { use super::*; + use crate::{foreign::KzgCircuitExt, kzg::Srs}; use multi_stark::system::SystemWitness; use std::sync::Arc; + #[test] fn complete_wrapper_fixture_and_changed_claim() { + wrapper_fixture(false); + } + + #[test] + fn public_degree_wrapper_fixture_and_changed_claim() { + wrapper_fixture(true); + } + + #[test] + fn constant_column_proof_has_canonical_identity_pairing_outputs() { + use multi_stark::{expr::Expr, system::CircuitInputs}; + use p3_matrix::dense::RowMajorMatrix; + let srs = Arc::new(Srs::unsafe_dev_setup_prefix( + 4, + 6, + b"identity-wrapper-public-test", + )); + let trace = RowMajorMatrix::new_col(vec![Scalar::from_u8(7); 4]); + let (system, key) = System::new( + KzgConfig::new(srs.clone(), 2), + [CircuitInputs { + main_width: 1, + preprocessed: Some(trace.clone()), + constraints: vec![Expr::main(0) - Expr::preprocessed(0)], + ..Default::default() + }], + ); + let proof = system.prove_multiple_claims( + &key, + &[], + SystemWitness::from_stage_1(vec![trace], &system), + ); + system.verify_multiple_claims(&[], &proof).unwrap(); + assert!(proof.opening_proof.0.iter().all(|p| p.infinity)); + let built = build(&system, &srs, &proof, &[], &[]).unwrap(); + let assignment = built.check().unwrap(); + for point in &built.pairing_outputs { + assert_eq!( + assignment.value(point.infinity.value()).unwrap(), + Scalar::ONE + ); + assert!( + point + .point + .x + .0 + .into_iter() + .chain(point.point.y.0) + .all(|v| assignment.value(v).unwrap() == Scalar::ZERO) + ); + } + assert_eq!( + assignment.public_values(), + [ + Scalar::from_u8(0xc0), + Scalar::ZERO, + Scalar::from_u8(0xc0), + Scalar::ZERO + ] + ); + } + + fn wrapper_fixture(public_degree: bool) { let mut b = Builder::new(); let table = b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); @@ -631,7 +796,12 @@ mod tests { circuit.lower_to_multi_stark_sharded(Scalar::from_u8(93), 32).unwrap(); let claims = lowered.claims(a.public_values()).unwrap(); let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); - let srs = Arc::new(Srs::unsafe_dev_setup(32, b"native-wrapper-test")); + let srs = if public_degree { + Srs::unsafe_dev_setup_prefix(32, 62, b"native-wrapper-public-test") + } else { + Srs::unsafe_dev_setup(32, b"native-wrapper-test") + }; + let srs = Arc::new(srs); let (system, key) = System::new( KzgConfig::new(srs.clone(), 2), lowered.kzg_circuit_inputs(32, 2).unwrap(), @@ -642,77 +812,56 @@ mod tests { SystemWitness::from_stage_1(lowered.traces(&a).unwrap(), &system), ); system.verify_multiple_claims(&refs, &proof).unwrap(); - let mut built = build( - &system, - &VerifierParams::from_srs(&srs), - &proof, - &claims, - &[(1, 3)], - ) - .unwrap(); - built.assign().unwrap(); - let template = + let mut built = build(&system, &srs, &proof, &claims, &[(1, 3)]).unwrap(); + let profile = Profile { + circuits: &system.circuits, + fixed: system.preprocessed_commit.as_ref().unwrap(), + transcript_seed: system.config.transcript_seed(), + transcript_kind: system.config.transcript_kind(), + generator: srs.g1[0], + g2: srs.g2, + tau_g2: srs.tau_g2, + max_log_degree: system.config.max_log_degree(), + }; + let counted = count(&profile, &proof, &claims, &[(1, 3)]).unwrap(); + let encoded = crate::kzg::compact::FixedProofCodec::new(&system, &proof.log_degrees) .unwrap() - .template(); - let local = build( - &system, - &VerifierParams::from_srs(&srs), - &template, - &claims, - &[(1, 3)], - ) - .unwrap(); - assert!( - built - .circuit - .gates() - .iter() - .map(|g| (g.wires.map(Value::index), g.coefficients)) - .eq( - local - .circuit - .gates() - .iter() - .map(|g| (g.wires.map(Value::index), g.coefficients)) - ) + .encode(&proof) + .unwrap(); + assert_eq!( + Shape::new(&profile, &proof.log_degrees) + .unwrap() + .checked_compact_len() + .unwrap(), + encoded.len() ); - assert_eq!(built.pairing_keys, local.pairing_keys); - drop(local); - // Public claim values must not specialize the verifier's constraints, - // even through unused constant gates left behind during construction. - let mut changed = claims.clone(); - changed[1][3] = Scalar::from_u64(0x123456789); - let other = build( - &system, - &VerifierParams::from_srs(&srs), - &proof, - &changed, - &[(1, 3)], - ) - .unwrap(); - assert!( + assert_eq!(counted.stats, built.circuit.stats()); + assert_eq!( + counted.table_dimensions, built .circuit - .gates() + .tables() .iter() - .map(|g| (g.wires.map(Value::index), g.coefficients)) - .eq( - other - .circuit - .gates() - .iter() - .map(|g| (g.wires.map(Value::index), g.coefficients)) - ) + .map(|table| (table.rows().len(), table.width())) + .collect::>() + ); + assert_eq!( + counted.msm_terms, + built.terms.iter().map(Vec::len).collect::>() ); - drop(other); + built.check().unwrap(); + let key = built.pairing_keys[1]; + built.pairing_keys[1] = (key * ark_bls12_381::Fr::from(2u8)).into_affine(); + assert!(built.assign().is_err()); + built.pairing_keys[1] = key; let public = built.circuit.public_values()[0]; let slot = built.inputs.iter().position(|&(v, _)| v == public).unwrap(); built.inputs[slot].1 += Scalar::ONE; - assert!(built.assign().is_err()); + assert!(built.check().is_err()); built.inputs[slot].1 -= Scalar::ONE; let slot = built.inputs.len() - 2; built.inputs[slot].1 += Scalar::ONE; - assert!(built.assign().is_err()); + assert!(built.check().is_err()); } } diff --git a/crates/proof-compression/src/recursive/native_verifier/bindings.rs b/crates/proof-compression/src/recursive/native_verifier/bindings.rs new file mode 100644 index 000000000..e48124a0e --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/bindings.rs @@ -0,0 +1,213 @@ +use super::*; +use multi_stark::plonkish::Assignment; +#[cfg(test)] +use multi_stark::plonkish::Witness; +use shape::{Round, Shape}; + +#[derive(Clone, Copy)] +pub(super) enum PointSource { + Fixed(G1Affine), + Commitment { round: Round, matrix: usize, column: usize }, + Witness(usize), +} + +impl PointSource { + fn value(self, proof: &Proof) -> G1Affine { + match self { + Self::Fixed(point) => point, + Self::Commitment { round, matrix, column } => { + round.commitment(proof).0[matrix][column] + }, + Self::Witness(index) => proof.opening_proof.0[index], + } + } +} + +pub(super) struct PointBinding { + pub(super) source: PointSource, + pub(super) point: PointInput, +} + +pub(super) enum ScalarSource { + Accumulator(usize), + Opening { round: Round, matrix: usize, row: usize, column: usize }, + Claim { row: usize, column: usize }, +} + +impl ScalarSource { + fn value(&self, proof: &Proof, claims: &[Vec]) -> Scalar { + match *self { + Self::Accumulator(index) => proof.intermediate_accumulators[index], + Self::Opening { round, matrix, row, column } => { + round.openings(proof).unwrap()[matrix][row][column] + }, + Self::Claim { row, column } => claims[row][column], + } + } +} + +pub(crate) struct Compiled { + pub circuit: Circuit, + pub bindings: Bindings, +} + +pub(crate) struct Bindings { + pub(super) identity: [u8; 32], + pub(super) shape: Shape, + pub(super) claims: ClaimSchema, + pub(super) scalars: Vec<(Value, ScalarSource)>, + pub(super) points: Vec, + pub(super) pairing_outputs: Vec, + pub(super) pairing_keys: Vec, + pub(super) msm_terms: Vec, + #[cfg(test)] + pub(super) terms: Vec>, +} + +pub(super) struct RequestInputs { + pub(super) inputs: Vec<(Value, Scalar)>, + #[cfg(test)] + pub(super) points: Vec, +} + +impl Bindings { + pub(super) fn request_inputs( + &self, + candidate_id: [u8; 32], + proof: &Proof, + claims: &[Vec], + ) -> Result { + if candidate_id != self.identity { + return Err("request uses a different recursive frontend profile".into()); + } + self.shape.validate(proof)?; + self.claims.validate(claims)?; + let mut inputs = Vec::new(); + #[cfg(test)] + let mut points = Vec::with_capacity(self.points.len()); + for binding in &self.points { + let value = binding.source.value(proof); + if !value.is_on_curve() + || !value.is_in_correct_subgroup_assuming_on_curve() + { + return Err("invalid recursive proof point".into()); + } + if !matches!(binding.source, PointSource::Fixed(_)) { + inputs.extend(binding.point.inputs(value)); + } + #[cfg(test)] + points.push(value); + } + inputs.extend( + self + .scalars + .iter() + .map(|(handle, source)| (*handle, source.value(proof, claims))), + ); + Ok(RequestInputs { + inputs, + #[cfg(test)] + points, + }) + } + + /// Assign one request to a circuit compiled once for its profile. + #[cfg(test)] + pub(crate) fn check( + &self, + candidate_id: [u8; 32], + proof: &Proof, + claims: &[Vec], + mut witness: Witness<'_, Scalar>, + ) -> Result, Box> { + let request = self.request_inputs(candidate_id, proof, claims)?; + for (handle, value) in request.inputs { + witness.set(handle, value)?; + } + let assignment = witness.generate()?; + let expected = self.claims.public_values(claims); + if assignment.public_values().get(..expected.len()) + != Some(expected.as_slice()) + { + return Err( + "recursive assignment differs from the expected public statement" + .into(), + ); + } + check_pairings(&assignment, &self.pairing_outputs, &self.pairing_keys)?; + check_msm_outputs( + &assignment, + &request.points, + &self.pairing_outputs, + &self.terms, + )?; + Ok(assignment) + } +} + +fn pairing_points( + assignment: &Assignment, + pairing_outputs: &[PointInput], +) -> Result, Box> { + let mut outputs = vec![]; + for point in pairing_outputs { + let x = crate::recursive::native_field::fq_value( + &point.point.x.0.map(|v| assignment.value(v).unwrap()), + ); + let y = crate::recursive::native_field::fq_value( + &point.point.y.0.map(|v| assignment.value(v).unwrap()), + ); + let point = if assignment.value(point.infinity.value())? == Scalar::ONE { + if !x.is_zero() || !y.is_zero() { + return Err("noncanonical identity output".into()); + } + G1Affine::identity() + } else { + G1Affine::new_unchecked(x, y) + }; + if !point.is_on_curve() || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err("invalid recursive pairing output".into()); + } + outputs.push(point); + } + Ok(outputs) +} + +pub(super) fn check_pairings( + assignment: &Assignment, + pairing_outputs: &[PointInput], + pairing_keys: &[G2Affine], +) -> Result<(), Box> { + let outputs = pairing_points(assignment, pairing_outputs)?; + if outputs.len() != 2 + || pairing_keys.len() != 2 + || !Bls12_381::multi_pairing(outputs, pairing_keys.iter().copied()) + .is_zero() + { + return Err("external opening pairing check failed".into()); + } + Ok(()) +} + +#[cfg(test)] +pub(super) fn check_msm_outputs( + assignment: &Assignment, + points: &[G1Affine], + pairing_outputs: &[PointInput], + terms: &[Vec<(usize, Value)>], +) -> Result<(), Box> { + let outputs = pairing_points(assignment, pairing_outputs)?; + assert_eq!(outputs.len(), terms.len()); + for (point, terms) in outputs.iter().zip(terms) { + let expected = terms + .iter() + .map(|&(id, scalar)| points[id] * assignment.value(scalar).unwrap().0) + .sum::() + .into_affine(); + if *point != expected { + return Err("MSM differs from native computation".into()); + } + } + Ok(()) +} diff --git a/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs new file mode 100644 index 000000000..9b67df083 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs @@ -0,0 +1,311 @@ +use super::*; +use ark_bls12_381::Fr; +use ark_ec::AffineRepr; +use ark_ff::PrimeField; +use multi_stark::plonkish::{Assignment, WitnessError}; +use std::time::Instant; + +struct PointFixture { + circuit: Circuit, + inputs: Vec<(Value, Scalar)>, + expected_publics: Vec, + prefix_ends: Vec, + certified: bool, +} + +fn points(count: usize) -> Vec { + let g = G1Affine::generator(); + let seven = (g * Fr::from(7u8)).into_affine(); + (0..count) + .map(|i| match i % 8 { + 0 => G1Affine::identity(), + 1 | 2 => g, + 3 => -g, + 4 => seven, + 5 => -seven, + _ => { + let scalar = Fr::from_le_bytes_mod_order( + blake3::hash(&(i as u64).to_le_bytes()).as_bytes(), + ); + (g * scalar).into_affine() + }, + }) + .collect() +} + +impl PointFixture { + fn new(points: &[G1Affine], parallel: bool) -> Self { + let mut c = Context::new(Builder::new()); + let generator = G1Affine::generator(); + c.point(PointSource::Fixed(generator)); + let first_end = c.point_ends.clone(); + c.point(PointSource::Fixed(generator)); + assert_eq!(c.point_ends, first_end); + c.point(PointSource::Fixed(G1Affine::identity())); + for i in 0..points.len() { + c.point(PointSource::Witness(i)); + } + c.point(PointSource::Fixed(generator)); + let prefix_ends = c.point_ends.clone(); + assert_eq!(prefix_ends.last(), Some(&c.b.stats().values)); + assert!(prefix_ends.windows(2).all(|pair| pair[0] < pair[1])); + let certified = parallel && c.seal_point_prefix(); + assert_eq!(certified, parallel); + + let mut inputs = Vec::new(); + let mut expected_publics = Vec::new(); + for binding in &c.points { + let point = match binding.source { + PointSource::Fixed(point) => point, + PointSource::Witness(index) => { + inputs.extend(binding.point.inputs(points[index])); + points[index] + }, + PointSource::Commitment { .. } => unreachable!(), + }; + let mut encoded = Vec::new(); + point.serialize_compressed(&mut encoded).unwrap(); + expected_publics.extend(encoded.into_iter().map(Scalar::from_u8)); + } + + let dependencies: Vec<_> = + c.encodings.iter().flatten().map(|byte| byte.value()).collect(); + for &value in &dependencies { + c.b.expose_public(value); + } + // This opaque suffix hint reads derived values from every point block. + let sum = c.b.hint("point encoding sum", &dependencies, |values| { + Ok(values.iter().copied().fold(Scalar::ZERO, |sum, value| sum + value)) + }); + let terms: Vec<_> = + dependencies.iter().map(|&value| (Scalar::ONE, value)).collect(); + let constrained_sum = c.b.linear_combination(&terms, Scalar::ZERO); + c.b.assert_equal(sum, constrained_sum); + c.b.expose_public(sum); + expected_publics.push( + expected_publics + .iter() + .copied() + .fold(Scalar::ZERO, |sum, value| sum + value), + ); + Self { + circuit: c.b.finish(), + inputs, + expected_publics, + prefix_ends, + certified, + } + } + + fn generate(&self) -> Result, WitnessError> { + self.generate_changed(None, None) + } + + fn generate_changed( + &self, + missing: Option, + corrupt: Option, + ) -> Result, WitnessError> { + let mut witness = self.circuit.witness(); + for (i, &(input, mut value)) in self.inputs.iter().enumerate() { + if Some(i) == missing { + continue; + } + if Some(i) == corrupt { + value += Scalar::ONE; + } + witness.set(input, value)?; + } + witness.generate() + } +} + +fn same_relation(a: &PointFixture, b: &PointFixture) { + assert_eq!(a.circuit.stats(), b.circuit.stats()); + assert_eq!(a.prefix_ends, b.prefix_ends); + assert!( + a.inputs + .iter() + .map(|(wire, value)| (wire.index(), value)) + .eq(b.inputs.iter().map(|(wire, value)| (wire.index(), value))) + ); + for (a, b) in a.circuit.gates().iter().zip(b.circuit.gates()) { + assert_eq!(a.wires.map(Value::index), b.wires.map(Value::index)); + assert_eq!(a.coefficients, b.coefficients); + } + for (a, b) in a.circuit.lookups().iter().zip(b.circuit.lookups()) { + assert_eq!(a.table.index(), b.table.index()); + assert!( + a.values + .iter() + .map(|wire| wire.index()) + .eq(b.values.iter().map(|wire| wire.index())) + ); + } + for (a, b) in a.circuit.tables().iter().zip(b.circuit.tables()) { + assert_eq!(a.name(), b.name()); + assert_eq!(a.rows(), b.rows()); + } + assert!( + a.circuit + .public_values() + .iter() + .map(|wire| wire.index()) + .eq(b.circuit.public_values().iter().map(|wire| wire.index())) + ); + assert_eq!(a.expected_publics, b.expected_publics); +} + +fn assignment_digest( + circuit: &Circuit, + assignment: &Assignment, +) -> String { + let mut hash = blake3::Hasher::new(); + for value in assignment.values(circuit.id()).unwrap() { + for limb in value.canonical_limbs_le() { + hash.update(&limb.to_le_bytes()); + } + } + hash.finalize().to_hex().to_string() +} + +#[test] +fn point_prefix_matches_serial_inputs_values_and_encodings() { + let points = points(9); + let serial = PointFixture::new(&points, false); + let parallel = PointFixture::new(&points, true); + same_relation(&serial, ¶llel); + let expected = serial.generate().unwrap(); + let actual = parallel.generate().unwrap(); + assert!( + expected.values(serial.circuit.id()).unwrap() + == actual.values(parallel.circuit.id()).unwrap() + ); + assert_eq!(actual.public_values(), parallel.expected_publics); + assert_eq!(expected.public_values(), actual.public_values()); + assert!( + parallel.circuit.num_values() > *parallel.prefix_ends.last().unwrap() + ); + assert_eq!(parallel.circuit.stats().inputs, 11 * points.len()); +} + +#[test] +fn point_prefix_preserves_earliest_point_error() { + let points = points(4); + let serial = PointFixture::new(&points, false); + let parallel = PointFixture::new(&points, true); + for (missing, corrupt) in [ + (Some(0), None), + (Some(21), None), + (Some(43), None), + (None, Some(11)), + (Some(0), Some(11)), + (Some(33), Some(11)), + (Some(33), Some(22)), + ] { + let expected = serial.generate_changed(missing, corrupt).err().unwrap(); + let actual = parallel.generate_changed(missing, corrupt).err().unwrap(); + assert_eq!(actual, expected, "missing={missing:?}, corrupt={corrupt:?}"); + } +} + +#[test] +#[ignore = "isolated point-prefix witness comparison; default fixture has 335 dynamic points"] +fn native_point_prefix_operation_benchmark() { + let count = std::env::var("IX_KZG_POINT_PREFIX_POINTS") + .map_or(335, |value| value.parse::().expect("valid point count")); + assert!(count >= 2); + let points = points(count); + let start = Instant::now(); + let serial = PointFixture::new(&points, false); + let parallel = PointFixture::new(&points, true); + let construction_seconds = start.elapsed().as_secs_f64(); + same_relation(&serial, ¶llel); + let stats = parallel.circuit.stats(); + println!( + "NATIVE_POINT_PREFIX_SETUP {}", + serde_json::json!({ + "dynamic_points": count, + "fixed_points": 4, + "prefix_blocks": parallel.prefix_ends.len(), + "prefix_values": parallel.prefix_ends.last().unwrap(), + "values": stats.values, + "inputs": stats.inputs, + "gates": stats.gates, + "lookups": stats.lookups, + "hint_calls": stats.hint_calls, + "hint_outputs": stats.hint_outputs, + "publics": stats.publics, + "certified": parallel.certified, + "construction_seconds": construction_seconds, + "scope": "Point-input subgroup checks and encodings, then a dependent serial suffix; no recursive proof or lowering", + }) + ); + let mut reference = None::<(Vec, Vec)>; + let mut digest = String::new(); + let mut parallel_digest_checked = false; + let mut serial_seconds = Vec::new(); + let mut parallel_seconds = Vec::new(); + for sample in 0..5 { + let modes = if sample % 2 == 0 { + [("serial", &serial), ("parallel", ¶llel)] + } else { + [("parallel", ¶llel), ("serial", &serial)] + }; + for (mode, fixture) in modes { + let start = Instant::now(); + let assignment = fixture.generate().unwrap(); + let seconds = start.elapsed().as_secs_f64(); + assert_eq!(assignment.public_values(), fixture.expected_publics); + if let Some((values, publics)) = &reference { + assert!(assignment.values(fixture.circuit.id()).unwrap() == values); + assert_eq!(assignment.public_values(), publics); + } else { + digest = assignment_digest(&fixture.circuit, &assignment); + } + if fixture.certified && !parallel_digest_checked { + assert_eq!(assignment_digest(&fixture.circuit, &assignment), digest); + parallel_digest_checked = true; + } + if fixture.certified { + parallel_seconds.push(seconds); + } else { + serial_seconds.push(seconds); + } + println!( + "NATIVE_POINT_PREFIX_SAMPLE {}", + serde_json::json!({ + "sample": sample, + "mode": mode, + "seconds": seconds, + "full_assignment_equal": true, + "input_mapping_equal": true, + "public_encodings_equal": true, + "full_relation_checked": true, + "assignment_blake3": digest, + }) + ); + if reference.is_none() { + reference = Some(( + assignment.values(fixture.circuit.id()).unwrap().to_vec(), + assignment.public_values().to_vec(), + )); + } + } + } + serial_seconds.sort_by(f64::total_cmp); + parallel_seconds.sort_by(f64::total_cmp); + println!( + "NATIVE_POINT_PREFIX_RESULT {}", + serde_json::json!({ + "dynamic_points": count, + "paired_samples": 5, + "serial_median_seconds": serial_seconds[2], + "parallel_median_seconds": parallel_seconds[2], + "speedup": serial_seconds[2] / parallel_seconds[2], + "full_assignment_equal": true, + "assignment_blake3": digest, + "timing_scope": "Input assignment, recipe evaluation and complete frontend relation checks; excludes construction, comparisons, checksums and assignment destruction", + }) + ); +} diff --git a/crates/proof-compression/src/recursive/native_verifier/plan.rs b/crates/proof-compression/src/recursive/native_verifier/plan.rs new file mode 100644 index 000000000..250bd2af8 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/plan.rs @@ -0,0 +1,361 @@ +use super::*; +use shape::Shape; + +const FRONTEND_VERSION: &[u8] = b"init-kzg-recursive/frontend/v1"; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub(crate) enum ClaimSlot { + Constant(Scalar), + PublicU64, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub(crate) struct ClaimSchema { + pub(super) rows: Vec>, + pub(super) public_locations: Vec<(usize, usize)>, +} + +impl ClaimSchema { + pub(crate) fn from_claims( + claims: &[Vec], + public_locations: &[(usize, usize)], + ) -> Result { + let mut seen = std::collections::BTreeSet::new(); + for &(row, column) in public_locations { + if claims.get(row).and_then(|values| values.get(column)).is_none() { + return Err("public claim location out of bounds".into()); + } + if !seen.insert((row, column)) { + return Err("duplicate public claim location".into()); + } + } + let rows = claims + .iter() + .enumerate() + .map(|(row, values)| { + values + .iter() + .enumerate() + .map(|(column, &value)| { + if seen.contains(&(row, column)) { + ClaimSlot::PublicU64 + } else { + ClaimSlot::Constant(value) + } + }) + .collect() + }) + .collect(); + let schema = Self { rows, public_locations: public_locations.to_vec() }; + schema.validate(claims)?; + Ok(schema) + } + + pub(super) fn validate(&self, claims: &[Vec]) -> Result<(), String> { + if claims.len() != self.rows.len() + || claims + .iter() + .zip(&self.rows) + .any(|(values, slots)| values.len() != slots.len()) + { + return Err("claim dimensions differ from the statement schema".into()); + } + for (values, slots) in claims.iter().zip(&self.rows) { + for (&value, slot) in values.iter().zip(slots) { + match slot { + ClaimSlot::Constant(expected) if value != *expected => { + return Err( + "constant claim differs from the statement schema".into(), + ); + }, + ClaimSlot::PublicU64 + if value.canonical_limbs_le()[1..] + .iter() + .any(|&limb| limb != 0) => + { + return Err("public claim does not fit in u64".into()); + }, + _ => {}, + } + } + } + Ok(()) + } + + #[cfg(test)] + pub(super) fn public_values(&self, claims: &[Vec]) -> Vec { + self + .public_locations + .iter() + .map(|&(row, column)| claims[row][column]) + .collect() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub(crate) struct LayoutPolicy { + pub namespace: Scalar, + pub max_computation_height: usize, + pub max_table_height: usize, +} + +impl LayoutPolicy { + pub(super) fn legacy() -> Self { + Self { + namespace: Scalar::from_u8(94), + max_computation_height: 1 << 29, + max_table_height: 1 << 29, + } + } + + fn validate(self) -> Result<(), String> { + if [self.max_computation_height, self.max_table_height] + .iter() + .any(|&height| height < 2 || !height.is_power_of_two()) + { + return Err("layout caps must be nontrivial powers of two".into()); + } + Ok(()) + } +} + +pub(crate) struct Plan<'a> { + pub(super) profile: Profile<'a>, + pub(super) shape: Shape, + pub(super) claims: ClaimSchema, + pub(super) layout: LayoutPolicy, + pub(super) identity: [u8; 32], +} + +impl<'a> Plan<'a> { + pub(crate) fn new( + system: &'a System, + logs: &[u8], + claims: ClaimSchema, + layout: LayoutPolicy, + ) -> Result { + if system.preprocessed_indices.len() != system.circuits.len() + || system + .preprocessed_indices + .iter() + .enumerate() + .any(|(i, &index)| index != Some(i)) + { + return Err( + "wrapper requires one fixed matrix per circuit in canonical order" + .into(), + ); + } + let params = system.config.pcs().verifier_params(); + let profile = Profile { + circuits: &system.circuits, + fixed: system + .preprocessed_commit + .as_ref() + .ok_or("missing fixed commitment")?, + transcript_seed: system.config.transcript_seed(), + transcript_kind: system.config.transcript_kind(), + generator: params.g1[0], + g2: params.g2, + tau_g2: params.tau_g2, + max_log_degree: system.config.max_log_degree(), + }; + let plan = Self::from_profile(&profile, logs, claims, layout)?; + for (circuit, &log) in system.circuits.iter().zip(logs) { + let quotient = circuit.quotient_degree(); + if quotient > system.config.max_quotient_degree() + || usize::from(log) + quotient.ilog2() as usize + > system.config.max_log_quotient_domain() + { + return Err( + "circuit quotient exceeds the authenticated configuration".into(), + ); + } + } + Ok(plan) + } + + pub(super) fn from_profile( + profile: &Profile<'a>, + logs: &[u8], + claims: ClaimSchema, + layout: LayoutPolicy, + ) -> Result { + if profile.transcript_kind != crate::kzg::TranscriptKind::Blake3 { + return Err( + "recursive circuit requires a BLAKE3 inner transcript".into(), + ); + } + layout.validate()?; + let shape = Shape::new(profile, logs)?; + for point in profile + .fixed + .0 + .iter() + .flatten() + .chain(std::iter::once(&profile.generator)) + { + if !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err("invalid fixed G1 verifier point".into()); + } + } + for point in [profile.g2, profile.tau_g2] { + if !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err("invalid G2 verifier point".into()); + } + } + let identity = identity(profile, &shape, &claims, layout)?; + Ok(Self { profile: *profile, shape, claims, layout, identity }) + } + + pub(crate) fn identity(&self) -> [u8; 32] { + self.identity + } + + pub(crate) fn count(&self) -> Result { + construct(&self.profile, &self.shape, &self.claims, Builder::counting()) + .map(|construction| construction.counts()) + } + + pub(crate) fn validate_compact_len( + &self, + bytes: usize, + ) -> Result<(), String> { + if bytes != self.shape.checked_compact_len()? { + return Err( + "compact proof length differs from the verifier profile".into(), + ); + } + Ok(()) + } + + pub(crate) fn build( + &self, + proof: &Proof, + claims: &[Vec], + ) -> Result { + self.validate_request(proof, claims)?; + let Compiled { circuit, bindings } = self.compile()?; + let request = bindings.request_inputs(self.identity(), proof, claims)?; + Ok(Built { + circuit, + inputs: request.inputs, + #[cfg(test)] + points: request.points, + pairing_outputs: bindings.pairing_outputs, + pairing_keys: bindings.pairing_keys, + msm_terms: bindings.msm_terms, + #[cfg(test)] + terms: bindings.terms, + }) + } + + pub(crate) fn compile(&self) -> Result { + let construction = + construct(&self.profile, &self.shape, &self.claims, Builder::new())?; + let compiled = construction.finish(self); + let layout = compiled + .circuit + .multi_stark_layout() + .map_err(|error| error.to_string())?; + if layout.main_heights.len() != 1 + || layout.main_height > self.layout.max_computation_height + { + return Err( + "recursive computation does not fit one admitted trace".into(), + ); + } + let table_rows = + compiled.circuit.tables().iter().try_fold(0usize, |sum, table| { + sum.checked_add(table.rows().len()).ok_or("merged table size overflow") + })?; + let table_height = table_rows + .max(2) + .checked_next_power_of_two() + .ok_or("merged table height overflow")?; + if table_height > self.layout.max_table_height + || !layout.custom_traces.is_empty() + { + return Err( + "recursive tables do not fit one admitted merged table".into(), + ); + } + Ok(compiled) + } + + pub(crate) fn validate_request( + &self, + proof: &Proof, + claims: &[Vec], + ) -> Result<(), String> { + self.shape.validate(proof)?; + self.claims.validate(claims) + } +} + +fn identity( + profile: &Profile<'_>, + shape: &Shape, + claims: &ClaimSchema, + layout: LayoutPolicy, +) -> Result<[u8; 32], String> { + let mut hash = blake3::Hasher::new(); + hash.update(FRONTEND_VERSION); + hash.update(b"one-computation-and-merged-table/v1"); + let parameters = ( + profile.transcript_seed, + profile.max_log_degree, + shape, + claims, + layout, + (0..profile.circuits.len()).collect::>(), + ); + hash.update( + &bincode::serde::encode_to_vec(parameters, bincode::config::standard()) + .map_err(|error| error.to_string())?, + ); + for circuit in profile.circuits { + let metadata = ( + &circuit.graph, + circuit.main_width, + circuit.preprocessed_width, + circuit.preprocessed_height, + circuit.num_lookups, + circuit.stage_2_width, + circuit.num_publics, + circuit.lookup_group_size, + circuit.constraint_count, + circuit.max_constraint_degree, + ); + let bytes = + bincode::serde::encode_to_vec(metadata, bincode::config::standard()) + .map_err(|error| error.to_string())?; + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(&bytes); + } + let mut points = vec![]; + profile + .fixed + .0 + .serialize_compressed(&mut points) + .map_err(|error| error.to_string())?; + profile + .generator + .serialize_compressed(&mut points) + .map_err(|error| error.to_string())?; + profile + .g2 + .serialize_compressed(&mut points) + .map_err(|error| error.to_string())?; + profile + .tau_g2 + .serialize_compressed(&mut points) + .map_err(|error| error.to_string())?; + hash.update(&(points.len() as u64).to_le_bytes()); + hash.update(&points); + Ok(*hash.finalize().as_bytes()) +} diff --git a/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs new file mode 100644 index 000000000..498e7c785 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs @@ -0,0 +1,427 @@ +use super::*; +use crate::kzg::Srs; +use ark_bls12_381::Fq; +use multi_stark::{ + expr::Expr, + lookup::Lookup, + plonkish::Assignment, + system::{CircuitInputs, ProverKey, SystemWitness}, +}; +use p3_matrix::dense::RowMajorMatrix; +use std::sync::Arc; + +struct Fixture { + system: System, + key: ProverKey, + srs: Arc, +} + +impl Fixture { + fn new() -> Self { + let srs = Arc::new(Srs::unsafe_dev_setup_prefix( + 4, + 6, + b"recursive-reuse-development-only", + )); + let (system, key) = System::new( + KzgConfig::new(srs.clone(), 2), + [CircuitInputs { + main_width: 1, + preprocessed: Some(RowMajorMatrix::new_col(vec![ + Scalar::ONE, + Scalar::ZERO, + Scalar::ZERO, + Scalar::ZERO, + ])), + constraints: vec![ + Expr::main_next(0) - Expr::main(0), + Expr::preprocessed_next(0) * (Expr::main_next(0) - Expr::main(0)), + ], + lookups: vec![Lookup::pull( + Expr::preprocessed(0), + vec![ + Expr::constant(Scalar::from_u8(93)), + Expr::constant(Scalar::ONE), + Expr::constant(Scalar::ZERO), + Expr::main(0), + ], + )], + ..Default::default() + }], + ); + Self { system, key, srs } + } + + fn proof(&self, word: u64) -> (Proof, Vec>) { + let value = Scalar::from_u64(word); + let claims = + vec![vec![Scalar::from_u8(93), Scalar::ONE, Scalar::ZERO, value]]; + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = self.system.prove_multiple_claims( + &self.key, + &refs, + SystemWitness::from_stage_1( + vec![RowMajorMatrix::new_col(vec![value; 4])], + &self.system, + ), + ); + self.system.verify_multiple_claims(&refs, &proof).unwrap(); + (proof, claims) + } +} + +fn layout() -> LayoutPolicy { + LayoutPolicy { + namespace: Scalar::from_u8(94), + max_computation_height: 1 << 27, + max_table_height: 1 << 27, + } +} + +fn schema(claims: &[Vec]) -> ClaimSchema { + ClaimSchema::from_claims(claims, &[(0, 3)]).unwrap() +} + +fn relation_digest(circuit: &Circuit) -> [u8; 32] { + let mut hash = blake3::Hasher::new(); + hash.update(&(circuit.num_values() as u64).to_le_bytes()); + for gate in circuit.gates() { + for value in gate.wires { + hash.update(&(value.index() as u64).to_le_bytes()); + } + for scalar in gate.coefficients { + for limb in scalar.canonical_limbs_le() { + hash.update(&limb.to_le_bytes()); + } + } + } + for table in circuit.tables() { + for row in table.rows() { + hash.update(&(row.len() as u64).to_le_bytes()); + for value in row { + for limb in value.canonical_limbs_le() { + hash.update(&limb.to_le_bytes()); + } + } + } + } + for lookup in circuit.lookups() { + hash.update(&(lookup.table.index() as u64).to_le_bytes()); + for value in &lookup.values { + hash.update(&(value.index() as u64).to_le_bytes()); + } + } + for value in circuit.public_values() { + hash.update(&(value.index() as u64).to_le_bytes()); + } + *hash.finalize().as_bytes() +} + +fn assignment_digest( + circuit: &Circuit, + assignment: &Assignment, +) -> [u8; 32] { + let mut hash = blake3::Hasher::new(); + for value in circuit + .gates() + .iter() + .flat_map(|gate| gate.wires) + .chain( + circuit.lookups().iter().flat_map(|lookup| lookup.values.iter().copied()), + ) + .chain(circuit.public_values().iter().copied()) + { + for limb in assignment.value(value).unwrap().canonical_limbs_le() { + hash.update(&limb.to_le_bytes()); + } + } + *hash.finalize().as_bytes() +} + +#[test] +fn recursive_frontend_reuses_two_statements_and_returns_first() { + let fixture = Fixture::new(); + let (proof_a, claims_a) = fixture.proof(0x1234_5678_90ab_cdef); + let (proof_b, claims_b) = fixture.proof(0x2345_6789_01ab_cdef); + assert_ne!( + proof_a.commitments.stage_1_trace, + proof_b.commitments.stage_1_trace + ); + let plan = + Plan::new(&fixture.system, &[2], schema(&claims_a), layout()).unwrap(); + let id = plan.identity(); + let Compiled { circuit, bindings } = plan.compile().unwrap(); + let relation = relation_digest(&circuit); + let compiled = circuit + .lower_to_multi_stark_sharded( + layout().namespace, + layout().max_computation_height, + ) + .unwrap() + .merge_table_traces(layout().max_table_height) + .unwrap(); + assert_eq!(compiled.main_heights().len(), 1); + assert_eq!(compiled.num_circuits(), 2); + let mut first_digest = None; + for (index, (proof, claims)) in + [(&proof_a, &claims_a), (&proof_b, &claims_b), (&proof_a, &claims_a)] + .into_iter() + .enumerate() + { + let candidate = + Plan::new(&fixture.system, &[2], schema(claims), layout()).unwrap(); + assert_eq!(candidate.identity(), id); + let assignment = bindings + .check(candidate.identity(), proof, claims, compiled.witness()) + .unwrap(); + let digest = assignment_digest(compiled.circuit(), &assignment); + if index == 0 { + first_digest = Some(digest); + } + if index == 1 { + assert_ne!(Some(digest), first_digest); + } + if index == 2 { + assert_eq!(Some(digest), first_digest); + } + let fresh = candidate.build(proof, claims).unwrap(); + assert_eq!(relation_digest(&fresh.circuit), relation); + let fresh_assignment = fresh.check().unwrap(); + assert_eq!(assignment.public_values(), fresh_assignment.public_values()); + assert_eq!(digest, assignment_digest(&fresh.circuit, &fresh_assignment)); + } + assert!(bindings.check(id, &proof_a, &claims_b, compiled.witness()).is_err()); + let mut wrong = id; + wrong[0] ^= 1; + assert!( + bindings.check(wrong, &proof_a, &claims_a, compiled.witness()).is_err() + ); +} + +#[test] +fn recursive_schema_rejects_malformed_and_constant_claims() { + let claims = vec![vec![Scalar::from_u8(9), Scalar::from_u8(17)]]; + assert!(ClaimSchema::from_claims(&claims, &[(0, 1), (0, 1)]).is_err()); + assert!(ClaimSchema::from_claims(&claims, &[(1, 0)]).is_err()); + assert!(ClaimSchema::from_claims(&claims, &[(0, 2)]).is_err()); + let schema = ClaimSchema::from_claims(&claims, &[(0, 1)]).unwrap(); + let mut changed = claims.clone(); + changed[0][0] += Scalar::ONE; + assert!(schema.validate(&changed).is_err()); + changed = claims.clone(); + changed[0][1] = Scalar::from_limbs_le([0, 1, 0, 0]); + assert!(schema.validate(&changed).is_err()); + assert!(ClaimSchema::from_claims(&changed, &[(0, 1)]).is_err()); + changed[0].pop(); + assert!(schema.validate(&changed).is_err()); + assert!(schema.validate(&[]).is_err()); +} + +#[test] +fn recursive_compact_preflight_rejects_malformed_resource_dimensions() { + let mut fixture = Fixture::new(); + let (proof, claims) = fixture.proof(17); + let encoded = + crate::kzg::compact::FixedProofCodec::new(&fixture.system, &[2]) + .unwrap() + .encode(&proof) + .unwrap(); + let plan = + Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); + plan.validate_compact_len(encoded.len()).unwrap(); + assert!(plan.validate_compact_len(encoded.len() - 1).is_err()); + assert!(plan.validate_compact_len(encoded.len() + 1).is_err()); + let mut overflow = plan.shape.clone(); + overflow.rounds[Round::Main.index()].widths[0] = usize::MAX; + assert!(overflow.checked_compact_len().is_err()); + drop(plan); + fixture.system.circuits[0].main_width = 1 << 24; + let huge = + Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); + assert!(huge.validate_compact_len(encoded.len()).is_err()); + drop(huge); + fixture.system.circuits[0].main_width = usize::MAX; + assert!(Plan::new(&fixture.system, &[2], schema(&claims), layout()).is_err()); +} + +#[test] +fn recursive_exact_shape_rejects_every_payload_dimension() { + let fixture = Fixture::new(); + let (proof, claims) = fixture.proof(17); + let plan = + Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); + let reject = |changed: Proof| { + assert!(plan.validate_request(&changed, &claims).is_err()) + }; + let mutations: Vec)>> = vec![ + Box::new(|p| { + p.active.pop(); + }), + Box::new(|p| p.active.push(true)), + Box::new(|p| p.active[0] = false), + Box::new(|p| { + p.log_degrees.pop(); + }), + Box::new(|p| p.log_degrees.push(2)), + Box::new(|p| p.log_degrees[0] = 1), + Box::new(|p| { + p.intermediate_accumulators.pop(); + }), + Box::new(|p| p.intermediate_accumulators.push(Scalar::ZERO)), + Box::new(|p| p.intermediate_accumulators[0] = Scalar::ONE), + Box::new(|p| { + p.opening_proof.0.pop(); + }), + Box::new(|p| p.opening_proof.0.push(G1Affine::identity())), + Box::new(|p| p.preprocessed_opened_values = None), + ]; + for mutate in mutations { + let mut changed = proof.clone(); + mutate(&mut changed); + reject(changed); + } + for round in [Round::Main, Round::Stage2, Round::Quotient] { + for kind in 0..2 { + let mut changed = proof.clone(); + let commitment = match round { + Round::Main => &mut changed.commitments.stage_1_trace, + Round::Stage2 => &mut changed.commitments.stage_2_trace, + Round::Quotient => &mut changed.commitments.quotient_chunks, + Round::Fixed => unreachable!(), + }; + match kind { + 0 => { + commitment.0.pop(); + }, + _ => commitment.0[0].push(G1Affine::identity()), + } + reject(changed); + } + } + for round in Round::ALL { + for kind in 0..4 { + let mut changed = proof.clone(); + let openings = match round { + Round::Fixed => changed.preprocessed_opened_values.as_mut().unwrap(), + Round::Main => &mut changed.stage_1_opened_values, + Round::Stage2 => &mut changed.stage_2_opened_values, + Round::Quotient => &mut changed.quotient_opened_values, + }; + match kind { + 0 => { + openings.pop(); + }, + 1 => openings[0].push(vec![]), + 2 => openings[0][0].push(Scalar::ZERO), + _ => { + openings[0][0].pop(); + }, + } + reject(changed); + } + } +} + +#[test] +fn recursive_profile_identity_binds_metadata_and_schema() { + let mut fixture = Fixture::new(); + let (_, claims) = fixture.proof(17); + let id = |system: &System, schema, layout| { + Plan::new(system, &[2], schema, layout).unwrap().identity() + }; + let baseline = id(&fixture.system, schema(&claims), layout()); + let mut other_claims = claims.clone(); + other_claims[0][3] += Scalar::ONE; + assert_eq!(baseline, id(&fixture.system, schema(&other_claims), layout())); + other_claims[0][0] += Scalar::ONE; + assert_ne!(baseline, id(&fixture.system, schema(&other_claims), layout())); + let schema_a = ClaimSchema::from_claims(&claims, &[(0, 0), (0, 3)]).unwrap(); + let schema_b = ClaimSchema::from_claims(&claims, &[(0, 3), (0, 0)]).unwrap(); + assert_ne!( + id(&fixture.system, schema_a, layout()), + id(&fixture.system, schema_b, layout()) + ); + for policy in [ + LayoutPolicy { namespace: Scalar::from_u8(95), ..layout() }, + LayoutPolicy { max_computation_height: 1 << 26, ..layout() }, + LayoutPolicy { max_table_height: 1 << 26, ..layout() }, + ] { + assert_ne!(baseline, id(&fixture.system, schema(&claims), policy)); + } + let point = fixture.system.preprocessed_commit.as_ref().unwrap().0[0][0]; + fixture.system.preprocessed_commit.as_mut().unwrap().0[0][0] = + (point * ark_bls12_381::Fr::from(2u8)).into_affine(); + assert_ne!(baseline, id(&fixture.system, schema(&claims), layout())); + fixture.system.preprocessed_commit.as_mut().unwrap().0[0][0] = point; + fixture.system.circuits[0].graph.nodes.push(Node::Const(Scalar::from_u8(43))); + fixture.system.circuits[0].graph.degrees.push(0); + assert_ne!(baseline, id(&fixture.system, schema(&claims), layout())); + fixture.system.circuits[0].graph.nodes.pop(); + fixture.system.circuits[0].graph.degrees.pop(); + let srs = fixture.srs.clone(); + let altered = Srs::from_public_powers( + srs.g1.clone(), + srs.g2, + srs.tau_g2, + crate::kzg::PublicSetup { max_degree: 6, id: [73; 32] }, + ) + .unwrap(); + fixture.system.config = KzgConfig::new(Arc::new(altered), 2); + assert_ne!(baseline, id(&fixture.system, schema(&claims), layout())); + fixture.system.preprocessed_indices.clear(); + assert!(Plan::new(&fixture.system, &[2], schema(&claims), layout()).is_err()); +} + +#[test] +fn recursive_profile_rejects_invalid_graph_and_fixed_points_before_construction() + { + let mut fixture = Fixture::new(); + let (_, claims) = fixture.proof(17); + assert!(Plan::new(&fixture.system, &[1], schema(&claims), layout()).is_err()); + assert!(Plan::new(&fixture.system, &[], schema(&claims), layout()).is_err()); + fixture.system.circuits[0].graph.nodes.push(Node::Public(4)); + fixture.system.circuits[0].graph.degrees.push(0); + assert!(Plan::new(&fixture.system, &[2], schema(&claims), layout()).is_err()); + fixture.system.circuits[0].graph.nodes.pop(); + fixture.system.circuits[0].graph.degrees.pop(); + fixture.system.preprocessed_commit.as_mut().unwrap().0[0][0] = + G1Affine::new_unchecked(Fq::from(0u8), Fq::from(0u8)); + assert!(Plan::new(&fixture.system, &[2], schema(&claims), layout()).is_err()); +} + +#[test] +fn recursive_shape_deduplicates_log_zero_opening_points() { + let mut fixture = Fixture::new(); + fixture.system.circuits[0].preprocessed_height = 1; + let plan = Plan::new( + &fixture.system, + &[0], + ClaimSchema::from_claims(&[], &[]).unwrap(), + layout(), + ) + .unwrap(); + assert_eq!(plan.shape.opening_generators, [Scalar::ONE]); +} + +#[test] +fn recursive_profile_rejects_keccak_before_circuit_construction() { + let mut fixture = Fixture::new(); + let (_, claims) = fixture.proof(7); + let plan = + Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); + let mut profile = plan.profile; + profile.transcript_kind = crate::kzg::TranscriptKind::Keccak256; + assert!(matches!( + Plan::from_profile(&profile, &[2], schema(&claims), layout()), + Err(error) if error.contains("requires a BLAKE3 inner transcript") + )); + fixture.system.config = fixture + .system + .config + .clone() + .with_transcript(crate::kzg::TranscriptKind::Keccak256); + assert!(matches!( + Plan::new(&fixture.system, &[2], schema(&claims), layout()), + Err(error) if error.contains("requires a BLAKE3 inner transcript") + )); +} diff --git a/crates/proof-compression/src/recursive/native_verifier/shape.rs b/crates/proof-compression/src/recursive/native_verifier/shape.rs new file mode 100644 index 000000000..1d6911946 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/shape.rs @@ -0,0 +1,283 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub(super) enum Round { + Fixed, + Main, + Stage2, + Quotient, +} + +impl Round { + pub(super) const ALL: [Self; 4] = + [Self::Fixed, Self::Main, Self::Stage2, Self::Quotient]; + pub(super) const PCS: [Self; 4] = + [Self::Main, Self::Stage2, Self::Quotient, Self::Fixed]; + + pub(super) fn index(self) -> usize { + self as usize + } + + pub(super) fn commitment(self, proof: &Proof) -> &KzgCommitment { + match self { + Self::Fixed => { + unreachable!("fixed points belong to the authenticated profile") + }, + Self::Main => &proof.commitments.stage_1_trace, + Self::Stage2 => &proof.commitments.stage_2_trace, + Self::Quotient => &proof.commitments.quotient_chunks, + } + } + + pub(super) fn openings( + self, + proof: &Proof, + ) -> Option<&[Vec>]> { + match self { + Self::Fixed => proof.preprocessed_opened_values.as_deref(), + Self::Main => Some(&proof.stage_1_opened_values), + Self::Stage2 => Some(&proof.stage_2_opened_values), + Self::Quotient => Some(&proof.quotient_opened_values), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub(super) struct MatrixShape { + pub(super) widths: Vec, + pub(super) opening_rows: Vec, +} + +impl MatrixShape { + pub(super) fn check_commitment( + &self, + commitment: &KzgCommitment, + ) -> Result<(), String> { + if commitment.0.len() != self.widths.len() + || commitment + .0 + .iter() + .zip(&self.widths) + .any(|(row, &width)| row.len() != width) + { + return Err( + "commitment dimensions differ from the verifier profile".into(), + ); + } + Ok(()) + } + + fn check_openings( + &self, + openings: &[Vec>], + ) -> Result<(), String> { + if openings.len() != self.widths.len() + || openings.iter().zip(&self.widths).zip(&self.opening_rows).any( + |((rows, &width), &count)| { + rows.len() != count || rows.iter().any(|row| row.len() != width) + }, + ) + { + return Err("opening dimensions differ from the verifier profile".into()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub(super) struct Shape { + pub(super) logs: Vec, + pub(super) rounds: [MatrixShape; 4], + pub(super) opening_generators: Vec, +} + +impl Shape { + pub(super) fn new( + profile: &Profile<'_>, + logs: &[u8], + ) -> Result { + if logs.is_empty() || logs.len() != profile.circuits.len() { + return Err("trace logs must cover every circuit".into()); + } + if profile.max_log_degree > Scalar::TWO_ADICITY + || profile.max_log_degree >= usize::BITS as usize + { + return Err("invalid maximum trace degree".into()); + } + let mut rounds: [MatrixShape; 4] = std::array::from_fn(|_| MatrixShape { + widths: vec![], + opening_rows: vec![], + }); + for (circuit, &log) in profile.circuits.iter().zip(logs) { + let height = + 1usize.checked_shl(u32::from(log)).ok_or("trace height overflow")?; + if usize::from(log) > profile.max_log_degree + || circuit.preprocessed_height != height + || circuit.preprocessed_width == 0 + || circuit.main_width == 0 + { + return Err("invalid fixed/main trace dimensions".into()); + } + if [circuit.main_width, circuit.preprocessed_width, circuit.stage_2_width] + .iter() + .any(|&width| u32::try_from(width).is_err()) + { + return Err("trace width exceeds the column-index range".into()); + } + validate_graph(circuit)?; + let quotient = (circuit.max_constraint_degree.max(2) - 1) + .checked_next_power_of_two() + .ok_or("quotient degree overflow")?; + if usize::from(log) + quotient.ilog2() as usize > Scalar::TWO_ADICITY { + return Err("quotient domain exceeds scalar two-adicity".into()); + } + let opens_next = |source| { + circuit.graph.nodes.iter().any(|node| { + matches!(node, Node::Var(column) if column.source == source && column.offset == RowOffset::Next) + }) + }; + for (round, width, rows) in [ + ( + Round::Fixed, + circuit.preprocessed_width, + 1 + usize::from(opens_next(Source::Preprocessed)), + ), + ( + Round::Main, + circuit.main_width, + 1 + usize::from(opens_next(Source::Main)), + ), + (Round::Stage2, circuit.stage_2_width, 2), + (Round::Quotient, quotient, 1), + ] { + let shape = &mut rounds[round.index()]; + shape.widths.push(width); + shape.opening_rows.push(rows); + } + } + rounds[Round::Fixed.index()].check_commitment(profile.fixed)?; + let mut opening_generators = vec![]; + for round in Round::PCS { + for (&log, &rows) in logs.iter().zip(&rounds[round.index()].opening_rows) + { + for row in 0..rows { + let g = if row == 0 { + Scalar::ONE + } else { + Scalar::two_adic_generator(usize::from(log)) + }; + if !opening_generators.contains(&g) { + opening_generators.push(g); + } + } + } + } + Ok(Self { logs: logs.to_vec(), rounds, opening_generators }) + } + + pub(super) fn checked_compact_len(&self) -> Result { + let overflow = || "compact proof shape size overflow".to_owned(); + let mut points = self.opening_generators.len(); + let mut fields = self.logs.len().checked_sub(1).ok_or_else(overflow)?; + for round in Round::ALL { + let shape = &self.rounds[round.index()]; + if round != Round::Fixed { + for &width in &shape.widths { + points = points.checked_add(width).ok_or_else(overflow)?; + } + } + for (&width, &rows) in shape.widths.iter().zip(&shape.opening_rows) { + fields = fields + .checked_add(width.checked_mul(rows).ok_or_else(overflow)?) + .ok_or_else(overflow)?; + } + } + points + .checked_mul(48) + .and_then(|bytes| { + fields.checked_mul(32).and_then(|fields| bytes.checked_add(fields)) + }) + .and_then(|bytes| bytes.checked_add(5)) + .ok_or_else(overflow) + } + + pub(super) fn validate( + &self, + proof: &Proof, + ) -> Result<(), String> { + if proof.active.len() != self.logs.len() + || proof.active.iter().any(|&active| !active) + || proof.log_degrees != self.logs + || proof.intermediate_accumulators.len() != self.logs.len() + || proof.intermediate_accumulators.last() != Some(&Scalar::ZERO) + || proof.opening_proof.0.len() != self.opening_generators.len() + { + return Err( + "proof activation, degree, accumulator or witness shape differs from profile" + .into(), + ); + } + for round in Round::ALL { + let shape = &self.rounds[round.index()]; + if round != Round::Fixed { + shape.check_commitment(round.commitment(proof))?; + } + shape.check_openings( + round.openings(proof).ok_or("missing fixed openings")?, + )?; + } + Ok(()) + } +} + +pub(super) fn validate_graph( + circuit: &multi_stark::system::Circuit, +) -> Result<(), String> { + let graph = &circuit.graph; + if !(1..=multi_stark::lookup::MAX_LOOKUP_GROUP) + .contains(&circuit.lookup_group_size) + || circuit.num_lookups != graph.lookups.len() + || circuit.num_publics != 4 + || graph.degrees.len() != graph.nodes.len() + || graph.lookup_prefix_len > graph.nodes.len() + { + return Err("invalid circuit graph metadata".into()); + } + let groups = graph.lookups.len().div_ceil(circuit.lookup_group_size).max(1); + if circuit.stage_2_width != groups + || graph.zeros.len().checked_add(groups) != Some(circuit.constraint_count) + { + return Err("invalid lookup/constraint dimensions".into()); + } + for (index, node) in graph.nodes.iter().enumerate() { + let valid = match *node { + Node::Var(column) => { + (column.index as usize) + < match column.source { + Source::Preprocessed => circuit.preprocessed_width, + Source::Main => circuit.main_width, + Source::Stage2 => circuit.stage_2_width, + } + }, + Node::Public(index) => index < 4, + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { + a.index() < index && b.index() < index + }, + Node::Neg(a) => a.index() < index, + _ => true, + }; + if !valid { + return Err("invalid circuit graph reference".into()); + } + } + if graph.zeros.iter().any(|id| id.index() >= graph.nodes.len()) + || graph.lookups.iter().any(|lookup| { + std::iter::once(&lookup.multiplicity) + .chain(&lookup.args) + .any(|id| id.index() >= graph.lookup_prefix_len) + }) + { + return Err("invalid constraint or lookup graph root".into()); + } + Ok(()) +} diff --git a/crates/proof-compression/tests/fixtures/aggregate-kzg/development-v5-packet.bin b/crates/proof-compression/tests/fixtures/aggregate-kzg/development-v5-packet.bin new file mode 100644 index 0000000000000000000000000000000000000000..2c36925740dbd8c204c2ecf4b0ad5768aaf345f6 GIT binary patch literal 2181 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 literal 0 HcmV?d00001 diff --git a/crates/proof-compression/tests/fixtures/init-fri/outer-claims.bin b/crates/proof-compression/tests/fixtures/init-fri/outer-claims.bin new file mode 100644 index 0000000000000000000000000000000000000000..dce84daf21bbf244915d7339a7d8c4fb84d68c35 GIT binary patch literal 1232 zcmY#jfB+UKoeiZKp)@*$OC7p;Tcpfi3-PJrgQ}Zy@1!0+b^K6uck0W;@Tn7k zs#A{DE5fHv5US2~lYJ#VbwW^evroTm05M_d43_R;e5f*<^04#{Q;)76md!^B}U zx_(%?#w8C+&$#4a=@@1X%zPLhrVr*%m^dzVF#i%#2lF=}bujx0se{SGXk6}tg$DqA CMlP%X literal 0 HcmV?d00001 diff --git a/crates/proof-compression/tests/fixtures/init-fri/outer-proof.bin b/crates/proof-compression/tests/fixtures/init-fri/outer-proof.bin new file mode 100644 index 0000000000000000000000000000000000000000..ef63009fb764913afaee86de0626167214bee6eb GIT binary patch literal 1425255 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
eYB*(|3=+g4gvT4Xs&#v<`Pid|P_ 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

#Q=tyeL%ThVJWbrh?;#}>lVYk%J_ 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

Vi!h-yyKK!UyRyOb0uxei-77xs?D8LW$ z^V!ZdZB0}Fz$I*4OPN}(*91GkO>|+5NAzjbn6sp`m#FVZG@`X&wzv6$J2B%Yp2mFO zkFAjC=vsB{L?SG>hdE`(Zf`b@7WS_FV%sqh1V^j4q2pF*TC)V+jtDU2sXo3una?QR zw+8HG*O7Gfp$`jnELH{AK1xhu6F8E-7pGYwL-?ho2&$8(Z^0L+hJmu}s4+v-7m}1L zN8QiYW)_-%uy}SWmwv4k?wj5fP9dLo|G_q3+USS3YYxQVg|4?c+_Ups+8t#*)MWB}?pZvxpy5QM|#-NMCZ5AlMC3}NyRK>Hja(2bz{5u>^#LMTdOhzB* z2-Jy^hbLoc|SJLP=3*y;^zT!>a&*&W&J|%uy@* zP43-ttCZN*JXvY^_Lg#$&V(5LY55aTAK&^=LR&A~hs12IiZu}BgZ4DXY8z={EDI8@ zg@5=D`1nnzGIM{wtT44R$6(hdQxwYOH~M*yK7D}2hMrH` z3unzy3Yu!S*>xkJb{K`v+JUvXdpQ(xU>ke>C;<+ElKx}kbCvj6D@Ef9#v1h&{@Wlt zMG~Vh1@zJ~qNi&pGuHHcE-5!wB+QkEW>R`96#_1Ra|1bHCW#13acTO`;M46>jhcD{y-oNT9t=AoDR3|i0`6+~qjd4i?VbG_53KxH zCry&HFEo8dVjtRLkWi!QLJyczz1Vz0H6_8<0Rv#IGM*`#3&j^inC8i>nUnIEUl`Oj zTN|S<4DxJ+R5bciFO2bAz0@>HMvp)wvW||)fdtJo_Bk44dW(d}+5y!P`sf?;7Do1zv&oKM%2v1S=>)vUg zR(H+~?D~j&;v107ua@2yYo?*d$|7RvA+V=eYabbGQ3GMk)zB6jF{GO$ zoi*tyS1<{Mz%3FS8HM5IBAA0kLF^g|!tuYbw6C{)Iu)(s8oSTw8I2$dy?aH@gI9CB zGG$EoNu805%xRAHK>*Cyo``q-5Lr)dz01w-J@6jIh=ucxBbn@#l{ZUZin#S3a^A|x z2ymQz!INtgRG#W(mj&HXDK5i}5v0W!v_1E4v|WGg%(j12pVNV#|Bn^T@>d7|D)cO_ z3=1wQnZ!TWV3!WEM?PpWJpLre>c`IBUqNU4aI(8LLkv#xG2+M<*^TSKYrc2Q3js+) zglJt0=&MagomUZ8gCOK^ddY3Cl~AUAv8_@_!sHJL60ql^YMRAk7p@LU8k|1Y^|A?j zKi1-PyX{qSYhJ?UISdr#=&-`&l{>nYccCGV#58mLzD|Jg{)q9fjy|9iF%&SDv7bF;}-AjCc|zu7etMhr%Qqi;+Rtms6ZN{xd`F z9fVYgjuHX6+-vx7ceKn^D;7j|ZHn+SIP1;;)M0q%6E3*aX0=(ZUnDgj-TtPy)|^7+ zTGXlp1A=g&dglSlVp+LP(}U8fwn@=9+Ute3$&v%zMZ&rw3Jxw1bu+r=J|j zcdmDqGlLac0Yg~~HjiW2;p%P|EHgl0-wXW#Tl%(~IADI9E^@Ygyg2D-T(RBQI_@=`>6M84YFc2RUNvakJ z)}T(P$f6~^U!NHsgQeVN=`$Py7^l)jMMI}YzKTt-y)OYjX)o$QCP4e%OC6Van~hBrEb3O2$v_*PcJL@A+dw-q<_C#hx7mYYO(DrNCL_;=2DwX>%*Fr5TW@ZoZ z7GkwssSXW;Ja&0$rcdpbfrJF}n$$R#9;jYNF??PP>Tb7qvWiZs*&>=39s7-tO)m _AR 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

aMxqY|_QkzW^ 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%0D65`j&)-zz`zV3e^0OQk$@}$b@azu7%io? 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

D_7Zu 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�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

+{N`3DaNDkHY{M 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

~?tPMPg7I->~Ntz=z{m3f^ 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

  • 9 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

    tFs;fV1)iUPzKZ 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�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%0DAozF@uJV)ci8pAUBO$!%LvQ^wUX6vjg{d z5N1xgNQD{>_7lsOd*>PUlX;(T>sJq1;j+IrvrPR|4@PHSs{1rTq);q6No z!T6aXQZo&>)W{sBI-hNG>94K4#g+b&jt43UkM8AJomGA-&)Azn8_?23R>Z*kA@Ids zU@|tbxR+pZoQ!vnqNqat9AU;fxcFdA-|s%Gk|o|{7+Z;WV7o0bE>!15#vE~h1e?K( z!(J#uF1u{LqX^1!_|q*3vRJ^CQOEhHe%#9llu=O|q`+zF=5!{nrMI}7ahJe)SoUP0 z{PRr$ksstGML<$*5Q3c5;F0M)6JtcA8bQ@|P|?v2(2Fa&vPx6-i}6u3th*AROqocI zreaj<)sBM`Yw<0l12Q#J>lP-N=1g3-PYh91x0vLQiiTRFfILTBbA%YG6SWv2py`iW zeVzZW>z>n6y^~mVQups-7Vzr%_=;d(cJB@>S-cxr5T(d;-?d|1o8;BUfh)2~IcKYc zG-!F>ppJA81Gwz{IIOS}|Q zM}bc5lG`+-dh;V*T?)TN>Quz{jwYy&|3j;AAJv@fpUJnHdKSH|7k`79B%qQd#U{4! zd}z#z=IpjhzA}S}$e&{z4?J)hYDn>KBA(G;Yc25%A%0gy3$%}MI7=GRU4Vb_*C7%< zw|u~GLVfVRo}HLW$a{42uQ$^B(##ThQ`E#62ff{-{T6c7S0I(s6lXTive4*J*x;GI zJu(QWmU9PoyZ>EwqoympYSOtb7m11tksf3hh7^guRbWzGn?cUG^t%z=QsgZ4-y1oJHe1{GO8ykc?O*v@y*T!6J-GCf_eNwb-ms-W%atr z?J`{`8-D`WoPjYrIpZ06!aqhAHw6-HWR-U9&5S?dvBQ46(!KK z9L%sHur3uKaq5(J-^u@wIG=9nSUM(|e|tg#T$g^X@{nE$FZxYE$gI091rLi&|J@}Z z=mv1Y>Pa}cJ7mhtSFjDYN9CFmL_qFk^MXGu)|4ET(#}WhNmIlhWSvu#;7ou<+n%;< z+qN-n+qP}nwr$(CZB4tU&7Ftse;@Ysp6^s1QmLF&lJ82#3$+wtkaz3AM5e#j>mw9g zxJ(nIBJ+|Ppt<6A*LoB7IcPlvM5__T!+XTcI>KNMh_^Ey*tKI&n7E%Q^^(wwY^*go zGC^dBkONu-vI^#v_QqsS&BQ~uoRHa%t8~DvG(G1=PC^O-di|>%P)ebm5KAT+A5P%#_)mS&nYA3ECCP$d%#;m7HSvB68@e$dI954Y^pBnyI ze)0CMx4aApzAG7+ImrB56epk*Z7Nd?TVrujBF`7wlKevbRRV0Grg!G!p`_;h>v3S0 z=6*jeS(1zoN%+VZcJYBlnNL0O@$qQA^p$)e3bqcgAcZUo=l(_$OoJ3tP7_^lEj<=> zrZP~*`A$My&`GotC}Vwu({eg8~p+Tupamq_yo9+pGbI=eP;zQ zGC3JIN2e4jByINRk?Whk!9@$Ckh8-eGRjKn)vPQxM_t3e zUvyqi@ydwCq~-YBZ=#uvYvYeM9O*$Cs0nf+XxLbLMI)+(ON&o82Xq6^-n_L_rE@O4 zo7Nu9YR}mckCpHIZ)~PK3T8J15m{jJh@SjZ(oe|T>+0jy-HCdFH#Xvqced`dOww=6 zG9gkp*w>woAK#AhYmc1C5b4`MTNo}j3~u4 z*S1*C%9j`dD-!dE_Dt*>33$@-vq$%V1*cHja!|Psz?g2479?gG0#bQp`{AV5hY(8m7@Q0= z>QfKa|Dm>qxK4h4lqyga&K|T_R1jFdWMs6SQHgOSq|A*I*C$zrthDH!g za9w7OHDwzy_XI-w&A+p5d`SL_Z93tS`Ln{>e67Ug6by05-zNGn+M{1w^`F$r8L{`CB&8SQyYjodmtZc|pxv>Rl=XR{z;VucZ zG2ZE0OWc3AM`(*cJ*kbFhM@O-)3&m8YT=q&nDfmXyOcKdOYktRgnB^MU@eE=D(c{Ml)Eyg@BZ zV)4w@Li=J}eNUEMHGF$cjf2R>%gQ7{t^+1P$@dK4$@Exvs*Pu0X9u?Oi5i`=_fi9^ZuNz?m#)ZWFqlfzETih4?t0r^ z&Y#K~w48!vpi_|ttD396+Auz3*A~mVmB}fPm?h3I2VD^_wlk?82mU8MH0!Bd4oq=aKqtboX$BoH=NT zWrpO|>DBiXwTt{bECQj4up?X&+v}blJL=P#(%qH?Xotw^sFG3`4*Z{cIYa_ z^@UTkC7fa+Rn5Gd9n@-#jJtX1iMV}`Q4eSkQAiGuz@$l_-v_iUSOjZ_@5v>w(Y-aE zI(zxwLweep$Y>Hph*KaZt`nK`Q&bHBu{xKhYl)(;z1OM=as=@FUks+IM|ohetaw7; zlhf2%O6)-omYXVTEhjzDB-OP<;`Arz&fK`yKc9A3CF}-p2=B%LY2q5=XNHN%rL7_j z3XEFkS)hVX5vBhs4LbHS@^sL!t2xt-HiO-}dRdIwRCDR4x7QuVvhB=V5Mz|oI$o?F zl`NE@U_@mXIcHA!Zy3$P+c(z%y%hcku#(iJ2#D6k{3&~fh)UgP zj!54~tPD#c9%J_F=m;J6M`{)HA)P2mudMn!MRG6drLo;eJPCj#mV@W2K&LJ2 zz)%n4E;%cq$dV_>ts13qvEPZ+v1W73L=_d&vxqa&?x;vzv!`)9#yoj2O*S_q&OE*L!UEeU}3K~zAR zv3tj0+F$%eLy(Q!4ypqTH(F1|se|QCx`QZ-{#IpyW-N}O+ZjU{PyG|g!;03CEhk6c z+Bj>qp{{+AA=a8BQc(I=W`0o}yF}~%M5w@rE~ts<1LUvP3c6t;p>JY^7K(*cf6yi1 z9z;<5E|44lTvbKuZ~1)&y4*Rgm8KmTeE2#aQwh#hQRf69Iv#QC8ZzI*VIQ#IH`w&s z+WI&CX;o@QxC}~6mu&e>dJNfYroMU!H4XR|53gKeRfui*9pU=vnC{EhUw(+Ch;cWcI0T7h6>u)x z^szg?%KK?;q|P}cgcmYs6g3@^lw^@3C}*0{$;Z^_$+4JJdHIxvW})vwoU8R5mzX2` z)omA*3mh#mj<50I<)wVsULrdiW2a((NJj_;&@ooC&Mp&6A=W2ja@RA=#KHmkp3`!# z9%T}GwRj@+ErU|&!K6X;7`ogiqD3(CORHkRK+O~jWR6kXc5<$4&6e#4k#1)BjMIQlX%CbNQsKB~G_3ZxvB*#$mk*Ul5) zr#$KSq8ZZ)&Jr0D3%vSBoTPGFe;#S)d??$w6|R^y_u73B)KPs2mvvUo$Ueyzy|7U& z=~mDB+zqRhlCv3-H+7xH))6{v4Uc~l;_Hj~y||+8bisPw3um_!IswIl`sE$pH!gko zVA=7Sc#}Ubrf+zA{Qmt}hY_U#Z&A*n(R0l6i(EbA3hKR>1k0j?>b|8-#h1V5j^TxL zx!J;WOKK&$dW|RG=KAE^CC=u0=Gly||NG#R1oV5hK_ge;oMT9@(kL)4rGFDZNLELA zw?$B4WxH%bIO>eq@cT$-aqNb_9&rgFpEv7DRceF)8upp83_S)W-RT4>0X4gFJa*zw z01YP-^1S6WQU+YUIyiIT<;KDm*ke9Y5sdJMky-v~ibHKM>EjmTsjB=M|LCOZbDt&k zIi#`MVV_J?O(BIK%W%??Jhfk5<6i;h00-?9^niAWqJd{M7xUGRH^30v{fxGWrPdJ6 zwV!&v_-vZcW)<*rV5 z`ayx zO=8?R)|VMS&TaPAyq)w;OfUe(MZa`(E&EfJWbH~-?CQr!A8=Q^6F%9}g~6QESJ&LL z1hME%ZlfM@2(AXsE|}r*@z)v;yb#}o>LMYkJv5JJ^HDGTFN{NRv!vkj#j(X@dm4$jYIzZ)OJ106~#eb}=;1~mP)zz?wKZSuM zXrC5N4&0yJ9942N>VuT%<`~+^TZ4VLt|;sDHkMBn#Ytr0NaaoZUiZid8}F*zM9up_ zbbx#)P|Ogd$fIW)wsckq5+KVDcm43m$(ZI^b~F>ru33zXQ&;`66nfPnW*iiED=*N$ z%#u$+&%Md`Ku#{mCSajHm@tb4csW#}3os~KpxUI#S@Q*HT_VYol&vC{i)ciJ$ncb^ zJ^>%#;ZsO#yoTPPTztO9#hbb^n`)54Z_&2*-(%S-Bo>U+ZirVhJYQzm8IlA@cbwwy zm$N&Ocj@W;VgY6kjglsl8(3b z_JxXnlNFw5PRP;Ru2}I%d}bi$ymp5R^!%GD=n0qP^T6@cA4vy-@TZ~+jpSnW(wp#m z-xm&KYmPn32K2l-p-E#U&V`B`d%m7O4?vxp5YNO@Zwg0EhTk}J?>r&tn z&|ILCa=MWvQbrb&$YpWXNSyCE z4chs!P7zRX6kVsqD|OI~T<`c0*%1rmW3ES*J&N(s zM6hFM1e*4tebM(L{cnhjj>w6HYh$F?z`9PI_EO(w^C|fLe!SP`6#_fFEIMrQ=CWuJT2C z>nAG5K!618G7+D}cNY`pQM=7A>(AuvbJ%|P+6H%t{_3lazdjbUME|x{gAbtL-ye>> z`2%3E1U-D3(-iYNaV-k_klq%gyqBU8$y2n}!~Vg2p09?~O4rYr#Yp-wb@6C*LE83d z*Z+L~6;@D_rz#fC-RupWk#X|Yviy$nEx{ut-s=R1A7zGDs>Emls<88guqn&eeKvQU zFYbK2(d-ZnBz%Y@s~T=I4)qCiYMkT980R>1-q>k^YgtjzaBsEEC0ZvOUX`2OX38fW z!oi93Z~urBv-t3Fq1g?IjQ-FjgDv!t7u9dzC}|K@53DeR?BQD|^9jYSwfXGM2be3yiqSd%^x8xk z%>l_Xp%$#hRH1w`%Cu1A#JKO>|m*sGL=`!vY}`SgqY zzUoez!i6GlHZm8t3gc%ZQmV)2lRiQb`R(ItV|;mw8BTTE;Po|UriRmYlfW#}@xqCY zzI2_R2|`^gsfmU@sCX4jACCi3RDP#8hnhXY4lp+t^DwD%u>Q;k)7~vtNk^3nZ?yWP z#Hu6fo}{4Vxa$O+sfU04UaqYijDKfOjTpWELPoE)>sl~plF6HJ5l`7)D`bWT)7)>N z?8Zhw7kxgy+2bW_=@W*-Jzg3X?pVA00_(i}U1;XMAJxBe-YI_=n7Qllo$FR^EvgJxqk*UgAc+KM z=yiu*VEL-u^2|h=?h;2|R{7G`>z)+)a7GvXMI z1roUX8Ab!kpDuWy`IV*4h*`+C4JkaQOa}~jsw`BTcR*w_6U&0)3G#c%m+ta|xZ+O@& zNC05sQx@(Az09)Ub1`9V3PV47xP+?bIbx;_HT|2wi;={OJ_-H$J8=NcND{6I*1~bs z>JxH1$OsbB)}dE`H4S+Vy^5n^ki?ROH{2rxt*5HKx=|JjSCp-w#s4Ri;c(r~nn3Aw z?b8PpdUS31uPDAR#UOTJhQSrOdrZ^pdpGN+P<@<2Z*>JiiI)#+fer--VBEGKk{k}+3ka##v{`r#e2 z$UV&}q{3f*fa8?w(HcM8CxC;H5Sf~=!G{!J;vp^khbL+s|f9of{A=*?T(B; ze6{TBY`I^}i!5FzWhmj5_=gu!ae7q11xKfLk1B*-KEV8N6Y{8k?>h}Qyh~zJp;qG4 zet0C&MC|WBAn=S5L*|Z$_pd*Ew`YyW(_2eCG*Vr~%p!32hl^Tg6x5_dEgBzo=sqj0 zmH%+;#3#VWJDZIywW*vu%3t+A{K?}k0{4b>e}9OcK@qL3=!dJ2*$CXVIpbshfhRS; z6ZubcBF^xxmJg>pcA^#^_aB+r5E`A`6f>WN=2T3xy{>f)5EDlf=DZfPx zWRPx#%`*S+#B`p2&oWVG%WC_9jDK?eJ6}Si$rZ=TIr}2WYyBCl{Qu2=0i_EmvfVd{ zPZo*imaLb#pK(?Zt`SA~jlSp2TD&D~lk|Rg)aKzxy(*?lk$YSP0A<{Nf}7q?%SqV| zU#Ak2-(6$NpRzysVc-kMw`(RGzRjFs+DT>fA0E#lfFU4$@f9>+)Ms@yn9GM7`eS*{ZGUI zU#8wl(AF@>7P0GgHS6);bxqYtU7~Mi%WgzbVrpZH{huJia*5?+>sWmTk7oMO$V2A8 z`H!UZxBvEiEx_vqXSgZ{cjD)`oZlAe+sf73mwqB9SHT|t%{TCfs6FosJ-$m2e`koD zrTw2gh33pq&u_@(D~?`SRe!Aigg)I!>vmCzg>c|Y?_iT6-m^dX1=q*&`BkA(jRd`d zwFEEaAMSP_U&|R%17>X$WgpU}{-5|p8;$oH{3C&8cQKft;WqZa`Sy{}mz1f`+)^R! zuEDW4Df%-GlOzBuh$v-gR3gJr(%l2y54X7Fh>zjWd{0zf5=j?|Y!BNWih?IAPtr0y30!jWSI7`TzNqebcWd+x2tCRPCdHczu zQfH@*U=66R$F%b9XLYLnaHy*lpIPS$_MU+U_O`dQ&mT@n)qJP0-L7~tuF$K*Lvi%O z?_5y4h`c47ncZ}nz21D~e)wcRiiaE}tDlchv-Esdp~DaF#4r28T3cRv($D7_2~|z` z;d}-k>PcT}EB2oQuC}ZKd_VkscO~>K7{|WlijBG637G1KPd@6OGuZikx&QG6CE{fG zZ#`)-x@w2`7NB}z>5sC*1U&u8!*C?azph<#nk%DvboFz$|L{BPK%PJx%E4tOgQYJN zo$enlPI&FP@(uO)Qs1e%q`~>$yhX8~Xw=P!)mTb7fH>w!G5N`(iukY|y?CPX#6kSh zU)16H;VG1~#WqJv-H^z#Ql;68(&me}Q7z=Oy4Tjns&FUqD~`!!hT?Q}6^K+z>X;$XrO`{#&ocTcrsPT>Js? zVU`Vwzjyyz-`JByqM#+SmugIq?gIj19)HGR?o0 zuhDD4S^FjZPglh3-y(ge^5*Kb4g_Nu1mANUv?qFd&JHTmfBhh_tgT=*JPj^3)`iH- zeREmrC8x))O|AVdb8#X9`bU`-Sh(jUc6wcUDOzxo(z_a${vp;Bj^<}R!}4KLv{Vpg zvJM%xB|tCjMHB@Le0!nw1;u>Eq5@1)b2HP6BtRBo}^>50^s5C82u? z_Z0@aPEntIuB{Qe1U!XJ)nvh`8DEYuk{Ll{ud>k4jdQklkzyy|0#vUTb>6xKHEkdn z1qDtcVq@93VT{nW0=?FQLBVAcQf8{bE@F})4-Tm3vkFcOl2O=NkK^|-`?>E*$AUIO zifEL*Bm&CpJz_pX(RewX$gC}I)o>f@iLU&|=cr#1c;UTX-AWWiI{VaPpqv%Jhp8|q_ z2^{sTNQr|yg6QvDtY?_hbznoawc}>_9TLfdt4d5x{2sSMvad#;Urs+4`tDFs3TtOf zs;W2Cb%sXpUN$0ukdp2-_CRZut8_5Zbg4dy!F7q{2fz<# z^SC-M5dZ~~1`W+Uh7p>b4t$x)dPoEu^{~`-_9HKsJR{i**l@%r?|Pzf9pC(W*>OCq zXZj%~)A$T@{oslP&=VqY=?YnC6pMC=mzj}%_zbG7{2Lz2GtdU9s>7!fCVD>P@sv+) zfYu8$4OHMHclP*fqG>w+=A@*t7pIx~7 z8O$xAvhK79Dh%U3Ho84_S>fU@pO6nTyuEyIXX(pCmmae(C0y+Tz~%&%NEv~$x4dQT?APeW9Hcw6ycw%WRQ^v&lyTkOo6)(lwRghAjarRgss z?(Udni)$TPWr32z>XZo^;-5`lcI|f@--6LFC}pjBGeo)HiZyn-?=P@t1rLbEp|<>7 z8wH`om>xuQ)avAqAKZwOWCy>gnz75&CwQv>n!z*CDh1QNXy=sfi&)6OYFd+-g~u&Z zbtE`I=0j)ThB_WaH$F!-@oC~WO#)ZkQ*qavCO28K=&t%3&m_FZvDm-3(Mfk&JsGDD z+A<`*49u7D{6U!$?x$_!v|8ULt76K;g3iKV{bguXl&>tS>-52pr(6l$sz!}AiccN^ zWl&JJF!ry+)m%XYAvc%R5eBf;JMckAgjLzFY(0W zQjIwS+?35WPxB+v((>mNmtG56tpqTLJXGj$wW7y^0*@yuyxj-nuBW@|T9SntfMU^( zLoT~w>P~TUhIKrE+pElmW{^upcvTa~O7qsra43&16ee3Mo4PAHpiT0Go~MbaFa8`s zb)PKjS{uFaCjrRX(nu0oiz0u~)r##Z3So$_I##-S(OW z%YvzV4i{h~fb~Hk3kF`sF*dekf30sbNrZK5%TDGL*A2?_bR#`9{H?%R{0xaDfXx0q zNeaLYtau^SxlAB@2V)4FxL(iKtOXV#Z*8GCM2D(K71MML7n!Rd*Qx|!cV;MNAQ}T9 zp^$n?f`d~+AcSwqCK%=AfutjH3Utu3hN1N_61BC+u#`*8_r9F9$Ggn&F}6LB`JQl3L|e z>19DUBXMSqA6vF^Tn`qVp}xAPa-`UN5b5H$D)YC`Nigc=_fRBdjc85;+w@vk_xP>2 zd(y{P(@{`QKPJ=AxCs1!esSeB@VYRb{Q+SZe+5KYdWG$!-b_372S)K@6;&!&fxC(= z_~kGcANn9gsowW3ly%6W8I`M6S=?aeB+oi4%c_H`PlqLkN+=5nvOj5V_%^P*xZ(c5 zb9GCzRS)rCTP<#y?5?f<^xGqDJrwX(K^xH6B@q^G`&lq2daWWABg;Bd7s#!fbV^ky|I_N(n(a@T9coYS3;%PY6x7}PzW8gr!k5B z1-n{uxQBMvWf?;@ey(q=c`-!ht8C9s0kx;OaA1zA^LUd;)7w#iq`@tywEB4*^L&=W zE`_VS89NdYI)ug&Ua6K=#679#u*uY{M9xi8dGU+XsaC!FVM+^1#HEmPmE1#*9n zsbv_O3$^X&Gv{znK?(^j-0Zx{8TAq-+9yF2pa1z@Fr2t~@N4t{_E*&9#CIBx^J34R zO61=2y=hVk_>*q(`&p=k_i09GJ+g<<4U7xGGIbB>gH2 z8A&Qej|DO;dbRKqlNaUSG8OBWkM{lu?BstN1aOAw;wa;=%;A+$T5l*H{ad1J1r!{p z)b1dSqKyVe8Kcyl`vz>E*Z}<_Z;D4mnYP%})E$|T?a?S5=`VdIfShX>;l_EHC;dP; z1#}FkA`_|-z?4dVt-Ddxt0PaDXaKChWD`#vmnzsG$mc@C&J4i8Ziin%P4FM`6;Lzh z#OhdOt(kEIyh1Rs`}mbBNpN8L_&}#e%8^)hUR_slo$u{D4nGV6jzF(=UNBojuu~lN z;08r`dlx${LVm3nBDV6lavlk~zx~aJb`}2c!VeRR)4fDIS{dgY@bKg%N!qJ`Iso^F z3?(l)JSnA)p+lLx(5Pf1E*DwEhm(DiRvOAV`fWXNimHhEXcm-k-+BX`rJHGHKlHkC zu5Al%bAN8u$^E;B!WZ8q-P6{tYm=(sx8%7t(WMLdCB&Pesw^;B^g=KV`9e0h6Zp-M z?(TKzg`9F5{4N>fXel!`WE7oJIdKK%uu2vs9L*>b+A-s3h~n-gC7sl+|J8_@V|$X~ zC0H3sx&?sH^cV4Gs$_THA4H!>%XrNF->bbh1-RX}qgPQm!$QsDvdy1#izGbyp^p6B z+#=X$zzSF_s<}!$J3QF5(SODWZ>+n{hfkBokU;?4d!3e2MQkoNi^RvvE{Hze6a_gV z$}+~@TGaeCaXf+MclP-z5%ZZzU9MXUitD_SMBx3?Y3^;&pZs#fVwbhjoZCn*%|gyc zvwbxf<5qEO3A}uw&Ixg6&Oonl`lLkW2y}S}{hCy8r4%^k-_SkGS*dk9=m7=-2co51 zeEC%I7w#i(icwd3)k^uj4>WTNwaSUFnG{sHD()9CZcTPu6*EJN_E@U;DX_9;*ao<5 zSex{8SZ7k0!NJyXb1I4PfA1BJ_YDX%R$5ES98RVfxBW&_K)&W7LVW#by+M($PH+%) ziM;{btGsZbhu{!X+@71=b`mzr=Vq{Z)vdFnh=8T)c1t73+P=u`^f>SuF0W`4)ABRsKvAo z%UnRjE5yO6W6WM;@%1KFj(iLr;ymsjxshLWTU*MqbWNCb6v6)1`vWq&7037`{>5; z)Y+?@zg9)_#@(%8mw%yA6uJvBbXylBzG9heYrG}M$aeD)E)036c3PC|kS-69n&Juc zxQ@67Mz0u`c@D{8Z3hue=!FPAbSer{raJQaysg7-*DOmlwhsh@Sb#;A%ElSZXJLU$ zlfDqJ+oMM7dJ?&OEgelhv+&ZW3xmC;ujbRN-u>Z3@hlzw_}rze%+Nx*P0QT?_5~lG z=8}>O4viOO%y3;ZLefKUT;+=$2yQQbzsqpgf?2Nv8fg{uIkoEGk+)HBQ|Q2JfjY&D z<}XcRMAzpcQj1^eCRKQ(-XQ0K7wwLMU(dKdLs_#A?+WxS`LYAt-H|*GB*7(cF}aXYLh`nrL>s zB*E@r0NWWSNKiK?`>;|pp+^o4STqhMa80$J@a7c~ea+`{m1qufB=md{%XdXbs>1fp zt{?FB6OwIMx@{Gir)XPA8od|Xy->|QrGW3C0vq6!5htMZMPI|XR{-6ns$+EGl)`5iQD1 zM>Cq{@%_S)r~@g1l%--I;Px+58C-k>RWC7$>IvtTrer>E3V8vD8qS>A07E325FO581rE5(1U}lTJEx4}w1m6biJ@J}mXudi6|olrYIkU#SP7dZ zdY){T|GZhvAhQwb&`<-8)j2WX1Vd{PTj5jPKBz}`;_d(IhEY-`CECHKjEJw&bGKk* z1lSll64$DbL27aJ^bxY7w6jIQkH6{Zj%VO6j4^fK#m5S+@!*i_<0I;ib(o8ONyC`x zbx!XDn);iU%TFU+kW)&@F)kw5ho*1v@s}!N$kZlccTq3Qg;vgY4(PtMxX7o;We(0u zrV8&1$|iJqN`}@uH)1FtC||Y3(DRXY9^`A7(mNM@_OVtSK0BfS24r9%Rrj2My<+A1gCsVh4kFYD&_m1GR3z}en2#RUvAuj zh3vA4i}J!sG?H*A2kOcsm=t&^1|Z9KY^;uN|IJDLP#uWxDOV0Y-ejf@oXf*P@gJ5d9dmITwWOt8b-9A5J6%WxH zo%9}Ve?oD-IaUC?5uoOM{%Uqtb2>x|L}y6_|MG^|k6ppfK0RuklI#*$AmYF7X%oHW zD96b?!FKbotu8xblOzwKHN%r7dBi~S^{%55mXqXXmiT9v>&h?J%)ma=F)-1J zFXiUwNjEVf3=M6v%ry7d|9T@u>HKZYrMMZ%x2rc21

    )FQ0Z^FYV;o31t#fDMA%x z;d9*NuP%8&STy%pUg>L3$h(B9cqES5eu(sKd7yeI-K=o(ujb=f@f~!<2rG-o~w~Xh0qng*g+qgGK~8X zM@uqSxRkaY+@TK;l|!ts&%w`reXim^%Nmv!&T2`^G&!|BV0ZmiD_bKRzuxh2Jk*P2 z9U>dWvGrUO&JP1&i9G#migU6K-l;Quj51M$^lfSjt)7@H88zjv#Gd^8XqG$3sGw8gbtr}QxP0j5y5)-#?;5Je}!ld=EU@Izm89U z)Awtt8TQvxa)OPp%H~5rU1KUVSgsywKeK61^I|w1cgXZ!=qr;4&3&Pq_k%P%#?m9DHK#i?A*yC1sOvk~D0794LFArlgR4P=zp- z+_A`7Vz16YZKgZPz;UrM8k62yay|nM7erc8aVqVNII0*SP*^7zFUNgpfj~#cswxEc z;d|@s1-_Q0o!nCreUv-&y^X(Uc)2aO?G$Li?LLv}zj4ci)dOl*!4aKn&=Wf6i_Ykg zBPk2-cSQM<2W8ml-y&>o=+ zCWy!2hO0+0+Ut7&90QOcl^M}>Y|K^Ss(Hj(8--xN6`AF z?r6RM&33jezy?WmH#tUiCJS)97A*O{pQkG35pCHwZrl1xOhx+(Co@@qhqT-W1x2cL z!i^08d%Q6#H9x@0H0jSlLn}EMSx#Q4%bpM?a0IF~#{PseK$m&9H+OsT-uRKjd|@P8 zEPIy*aUsQ;%of~*sOWwi-a2DA`1Y^xk+f;&Ew}i!`dHHq)V)pTGCcraYo8@IsHO+p zUi%sgs9>`R$K*z}L3}QCFB-1igR;K2qr#6@V0%Uf2q1cj32UllFezRXRR%t#pj}c% zEt?YpwI=O=PQq=j(bnLgJu|mQN-MJli%s`rQ|!9ALREqx+lN6JRh@{-p;r}gep40W zEN>8*yCZ{<{;^zMe~cCaLUbkKR!IO23+Qm^q07CUdlhK{JbXcjmq?m)kt?XiI{vD7 zMFK}b>6SMuwH|t62G_4x@N))Jrt8qsH^UhGcz1v>@C;9g{;OMC1OuyM%ea8)Kz_8% z2(mPaDqZ}owxoBe7n1SRY2!Cy$VJyqI2I0ssS;;ixS9Hk9+o~EL7tFy0eH3fyI0Z4 z^+=5LNL2&!3MO=pt08Vt7YyXtLyg!881lgOQ?M81||rIp3B+U|5E3xyCw<2}aJE{W@}mP;o~Vs__iw$VWV%n4(a3ZoE9 zkDf|)Id6d2SWduqS?&FW5%^-zoF27f;l1mr69Z)6r_RH}Y-R7&q0ACHoGl=^r9gFn zX42xaGi(0!lcws7xUf-vr5lO9ru}@7)#B@f3P~xMf_F?k9XJQ*C<;9__E{cF zccyq*3ZuKb!*R~J_VlK|{j#ElcDFJ}E{YE7QW0jm>!F3FV=a z#UgFs6P1C_EiXk+2eWUsUBj)2b}tX!P&*i_6x{g5k(yuMU}C?#adp1$oxXG{Bf-F& zPpOp^^`e}+o`h-z4c=qyYWI{4-iA94isfm$FP8@X>Z+Bx1E+(6)al=Y(Eg#5&W|C` zA3?)TaFl%_l``Y3*%ygqs9mH9-Re%o*uHma$|p%&j(DFD->9~m*{pSOaR z;fS#7e&2TIg~YG9xfH3#Y7FG&nQ?eZYe3nn!ya?>DcemSNP?}ulh(gDgI6W>yP(km91I`ud5QMUvt=N3B4>#R^Te`GHH=?}R$`7x_)%}4ZJ;D8g zEV`uB#W(!C66=gEh~?$NKKP(^arR4OpuO>P-U&bezLt)qQb;8M8YUCGbviX&aIW0) zZK05Toe!o6eMIth=lc81ggRI*h}D7{4ANVOKS!xMt>?SOZsdtnQN+kcOnm2 z(~Mi4K2aVS)fFdycO;Ud+5bRC=z+V;cIW`JhrrRrPxzAd^7ORR4VdyD0mR64)ForO zlB3!NlihJ}j$u=Ih3yUo=D3x%!qwy=j^sT-sr{ZM=DqtTi`?83pqX<3FXGg;JQqWu zM==7lwj$v+srpsw?BwCll4|~;?CniEO51$2m&@2+Gj!=$fVqwV7HDFFtkI<|Fcd(f zv*E~xDL(@|gSWX|N@o$nuY>1nQdPP;pz~FkmcQaGamm2-v1JMmQ9dYppm|(^ek-d3%KRVFC0z@Re6STmoV?SLO$2x)>Y|>#q@n4xRl|Dv3|Yz0>a>Q zYMgoSvho`@{S&y7UmaDMVJphfiA%`j2rM<9qpBzrtym4}v@1^%bk2+@l$UzsgN`nB3QwSg+!DbGi|zJEI!fUgwd9WbyW6ExCQx0 z1|>_VVB*?ovXPL6$?n0h_ciW1fObRDpg(N8KQO}pWII{%Y)?^^5J ztIV&729u^*^XOwa?3i#C;Zav^5Yw~SlJL<*KJS&wd4~l_0VNvfUHfLHD zLULA|e<;Lk!`MfEYdR>O%``}f23%+41MQp4>GqXEEo!hTvD6Npen^(Wz&+c`lGUs{ zDvZL?mQ{5pUq8hPK3I5ls%zVxC+~+h3;IA8|Le z+SGWL=Y%l-=W-i>Q<>*0eB~NCMM!c+>^ytuH{pH9EW zUX2ya3bnWwnikuM!3|(vZy3!i1FgXrVBj~^7nhA00LeZEBSfGb^UJ#;cWX^u$$5NK08D!WN`gC66!JFDKtzps9#@eJs2T(v zX&W3}Ntp%|{GkUI=q>ZYa?xTR;6T)JiGW5CSrFnnFTA{W!@ypK^1QQO@OJKHC`EdK zEcCfb`9YUlU5#^7giN@IXK3&W$r87_IwDlyFN+XlnnEhR2OjR!Ws zyEgm1Qjp%iAh7UCbf({RF6QP6k^gCL58;@#mKhiASsRn-J;>0xHcHHQ&?ALhrz1Ba z*3hy2QpRgeoR&oiDDIBR>35& zw#{kV)3$Bfwr$(CZQHhO_p~)_zZ^$oa&DE7_=WLI3nQpPM~ z3|m9$yigGMrcF64ax=J(20dcsT~(SqZn4HSj7`3nIX67^JS8shS_)aYbM=?od~}Sd zK!|yqZxkE)u`Y&m)rT!9HGkYI{MHzLzCutpa2ghY^2=-4_`&PS@$zPOcP*^1r8V`_z(;&4LH8+SmkV5x_A7(WxQ$T@uSHkH$Pr zy9qnM8;pe^dtH`KQWd4+Ko|Yjv*8Vg{Qi`o2q7mJ2GRuP;TU&?aAx}yw)QHXn1z)& z{e*iN(*!Ts=kF~<+b8w=m-ZarM^h<4NWPoSW`KC=pcjsTAI#RVGDPW;?)XK#@fd;{ zm$Pc8!mU(M((S`Uoq0U;WyPYB< zU?gV93vs5>Z&1rveVixMx;ZDyDQUp);!D!mwItrsOgQ1ohCFU_k(4$kn+&t+CPl_jX9; zQ}|9=EGw(kJJJ;o1qU^dM5Y~SK>98I+Q)N(RPS>hpz*{UJQrWV>}B=sg)z@Y;gtfJ zH{!7i{;bJY4!Gp;qJ72WFquuuFj9gTsUs@dkMu37y$3=7Sw3jdGWAXX{0(!{4Si z#*nNTD(5W?V?{( zC`dQAoGQow(GS0z#fdLQ;i|oMmqw+-!-LBGkVb3iZ1v*b;xBP+>=lRg^yS}?JUUn# zMoZx%^WBFhc!yOi*C5`IdIc(VECQEdQw)MfbIv%#dY0ii;5b7cUuaR~4YzVZhmH)i zdrjF0Wk$M5qLr9@T0X^beG7g^To-hX_R@8JAS+*HtfzcED!0oYsD>Y6Ck5$J$e{Vd zA!JJ*6R~xVi{dU=EefeDJxY@%+89P9UW{0??+;(;{Q<}V&}TvWvg->)V>v=a>`Ue{ zjOWNrk+J)3rAXAzRnYW{;Dm$)Z}WFS6Tp~AWb%x0hm)CJPDjPVy|8IO8MW8ALW}OH zcrV2^fx`SsN6w?zPp!DIhs4&J#zzSnUfcJ8+|?d&Lu>l;7uRe^t9G)ON=S1Msn#QF z-$6TJ7tSMJLKlQnD{FQ;%}9;^G$j^cNCCy$Qoe|0A-bqNNj5%JcFqcKwPyQOUCIr}Oa@fIr*mFdNep!Qm^Rd4n;GMYVu6h!wjeU`&LU1Ld z@0)ge%F{|gf^*j#Rn0Uxwd2>8GaN<|89^7dEIZj%iS9F^E4(alm*#nO<44%vcUr~q zC+$i+0BrwNf}_%;Wv3wByIGcL3<#CKGhTY= zG3~jm^{EtAk_#!*MbTN_%Vo@L)BQflo>*xLnN5B{blgLJrQo)%5x8o*K>h%TSo&+V z+iQ7J9(P57OZRke_XRV1^vnnGo5u)&}eX!rAcsMb+gVzjb~= zpJrCvWpk2=#stZ6x3uPdMmgnji5D@;j3c)-vafyeIj$MI#QNqRlX$Z*+yVq??|OFq zay5u6gTlJn1arFjBXuE2v2>%$FI>p6kdCx>i%uXoGEFdAgNHT&T_m(FtFlUcc#xOe zx_!9udG^sVS}|!Y>XCN^ZK4r*6OnJZO)1vxAG_yND8xCX^+y>T$ zoEAA1?MRD~8ooIsH+5o@3;5d(dq*{s(a!6PpXjxk#^6m4PgKEDGQr2^@8dWw3x!i> zHP9vHqe+jYP&Qd! z+DQS9&sqAiT$8O;E`JI&JX&NTBjkhA_hZh8cDpCwHdFtby_L{pr<=QPT#f@b%ijzh z&C5i0FmwgUZC^JNWApQp#3Y(ec|68tgD%0Do82`8F8aqx+M3v4mtS?j3nC+86jOvO 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

    eYSX`Qo!l^0nIwmO79F1Z00CTcwDCg4rz7%H3fu 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 TKUb7LTiL}SHlO5Y2*vylHbF37 literal 0 HcmV?d00001 diff --git a/crates/proof-compression/tests/fixtures/init-fri/outer-vk.bin b/crates/proof-compression/tests/fixtures/init-fri/outer-vk.bin new file mode 100644 index 0000000000000000000000000000000000000000..d24caf646e01130a22076448eef2b9ec21850bcd GIT binary patch literal 6180 zcmeHL*-{%v6rI-0^u42fL7;_Q5)vQ*Vn;GI27~bq#&`qo3r?KfKj4=<+#@&^*&O zwrYO;7afF=LQ|6S4h0bK(b-QCa6o70Ng+)X^9fKQ7eK z3Y9u8)X@r++*_#92N!e0-lOUn#H_38UAnW|RrOxo)90#sPWR_U)e8fqx2pPnSug%a zSx;y7$ofZ6GX1HP?4=5Ba^Ny3ieGrDt>;pNt{Cphr&-0pd4E!ZWp8oQG_WXl;iLC-Y7+kB2JN@ z92+aHSsO)?qMf3Ha-o*hQbs2wyC}LTdMHv-Mjks<$FVvA)iO>_X^iOP49xb?W4I58 z)f&vz>4r4SkK_DFm@nh01?@z2`=YKc!I^%n&BO8vwaCEgIIf<9)iR#Hp#3r3aY@f# zhATO&t-{(mwaCK8IBr~njWTXt*MYcB-PEhM;C3Es8?dzv#Zf4Y!C(c3MjldgIy^6T zC~%vRA}Fe}l)nMxE;qX$DixeQar*4Vt2gf4y?6gXOj+{w(P!F#jz17&6k?N>D&& z|A-5^a-%Nj&W}~i-Z1&jmqsi|zViju=N;^9yu#+IbYCbXKOvg-((BAq^idQj`Y8q| zauh|16x3|2)uC3w6Z68#Q?StAVsC8`PA^frG%VY=yaLM=Ji98~Jg;jP;9?FJm*LW7 zYLS688`suht%6rKgqxds`8wRlt~*&X=&ISkF=_zrye6%0?{3uYU?`!oQG~az^ z@Z_0uSFYW@_vEuLzWy%R;=4=t5V0J>Onl}iFv$~=YhxriCV5(N8Ag(0lJ`iimyzU{ z|D(( zx$`0!rg?D$)o!j#&G{iO?c8FLbt{r|{mY{ccj))a2vy6s=VT8X`#i?WH(ezUx)2pMZLqtc9451vuT;lJEKw zES{l0(y&s+m9wx?CW9e#zo6?E;Zi>?t-$3g)FK1xRb0Oc>t!++LiFo;c`ikZOOc%C zQna`f$$2hCi%XH5=TdabrRb(h(M^}4n=VC@e6y!1SH~+8lMOU7HQhj?GqVjeHdm|f zQEk}`9_qD^HZOQ?9OSjxhacp%!Sl#jqR3koWVYVy4o#`azpOjkxC~J|=>jL(?gD?T z!v%qODr*Fh9A4B2@GpxaG@y4lyxqI%#;lXcbFjlRy@#cqgHDt_Ibswcr_GzdI?+y< zu#?ge-d;qUthN}?FQNv5(}VKsD_7b9e-34@&h_;}pojw{LcF^PKyVm`$|F+NmeFa< zhdtARHfuz939GL#SaGXui*FDs&TGBvO+vQMj6@vw~50 zA3qjT6DNLpU3le8{Py$rzvO=y*g0AM{Z*>{SN$3{=_Sj1FqNfjr4=Ytj`FL33aXF_ LtB8uKn2P@mXO%Mr literal 0 HcmV?d00001 diff --git a/crates/proof-compression/tests/kzg_sharding.rs b/crates/proof-compression/tests/kzg_sharding.rs index 2faa088c2..edf077817 100644 --- a/crates/proof-compression/tests/kzg_sharding.rs +++ b/crates/proof-compression/tests/kzg_sharding.rs @@ -60,7 +60,33 @@ fn regenerated_kzg_shards_preserve_global_copy_constraints() { Retention::Retain, |shard| SystemWitness::from_stage_1(shards.traces(shard).unwrap(), &system), ); - assert_eq!(retained.to_bytes().unwrap(), proof.to_bytes().unwrap()); + system.verify_batch(&retained).unwrap(); + assert!(retained.preamble.headers == proof.preamble.headers); + assert_eq!(retained.preamble.messages.len(), proof.preamble.messages.len()); + for (left, right) in + retained.preamble.messages.iter().zip(&proof.preamble.messages) + { + assert_eq!(left.args, right.args); + assert_eq!(left.multiplicity, right.multiplicity); + } + assert_eq!(retained.proofs.len(), proof.proofs.len()); + for (left, right) in retained.proofs.iter().zip(&proof.proofs) { + assert!(left.active == right.active); + assert!(left.commitments.stage_1_trace == right.commitments.stage_1_trace); + assert!(left.commitments.stage_2_trace == right.commitments.stage_2_trace); + assert!( + left.commitments.quotient_chunks == right.commitments.quotient_chunks + ); + assert!(left.intermediate_accumulators == right.intermediate_accumulators); + assert!(left.log_degrees == right.log_degrees); + assert!(left.opening_proof == right.opening_proof); + assert!(left.quotient_opened_values == right.quotient_opened_values); + assert!( + left.preprocessed_opened_values == right.preprocessed_opened_values + ); + assert!(left.stage_1_opened_values == right.stage_1_opened_values); + assert!(left.stage_2_opened_values == right.stage_2_opened_values); + } let forged = system.prove_batch_with( &key, diff --git a/crates/proof-compression/tests/kzg_streaming.rs b/crates/proof-compression/tests/kzg_streaming.rs index 52d316a43..50546fa40 100644 --- a/crates/proof-compression/tests/kzg_streaming.rs +++ b/crates/proof-compression/tests/kzg_streaming.rs @@ -3,6 +3,7 @@ use ix_proof_compression::foreign::KzgCircuitExt; use ix_proof_compression::kzg::KzgConfig; use ix_proof_compression::kzg::Scalar; use ix_proof_compression::kzg::Srs; +use ix_proof_compression::kzg::compact::FixedProofCodec; use ix_proof_compression::kzg::pcs::KzgProverData; use multi_stark::config::ProofConfig; use multi_stark::lookup::LookupValues; @@ -93,7 +94,8 @@ fn streamed_lookup_proof_matches_dense_and_checkpoint_roundtrip() { }; let streamed = system.prove_committed(&key, &refs, stage); system.verify_multiple_claims(&refs, &streamed).unwrap(); - assert_eq!(streamed.to_bytes().unwrap(), dense.to_bytes().unwrap()); + let codec = FixedProofCodec::new(&system, &dense.log_degrees).unwrap(); + assert_eq!(codec.encode(&streamed).unwrap(), codec.encode(&dense).unwrap()); let mut wrong = claims; wrong[1][3] += Scalar::ONE; assert!( @@ -164,7 +166,8 @@ fn streamed_chunks_preserve_global_rows_and_quotient_cosets() { &[], SystemWitness::from_stage_1(traces, &system), ); - assert_eq!(streamed.to_bytes().unwrap(), dense.to_bytes().unwrap()); + let codec = FixedProofCodec::new(&system, &dense.log_degrees).unwrap(); + assert_eq!(codec.encode(&streamed).unwrap(), codec.encode(&dense).unwrap()); system.verify_multiple_claims(&[], &streamed).unwrap(); } @@ -218,6 +221,7 @@ fn fixed_column_passes_preserve_weights_wide_constraints_and_lookup_carry() { &[], SystemWitness::from_stage_1(traces, &system), ); - assert_eq!(streamed.to_bytes().unwrap(), dense.to_bytes().unwrap()); + let codec = FixedProofCodec::new(&system, &dense.log_degrees).unwrap(); + assert_eq!(codec.encode(&streamed).unwrap(), codec.encode(&dense).unwrap()); system.verify_multiple_claims(&[], &streamed).unwrap(); } diff --git a/crates/proof-compression/tests/kzg_transcripts.rs b/crates/proof-compression/tests/kzg_transcripts.rs new file mode 100644 index 000000000..1c3cb6287 --- /dev/null +++ b/crates/proof-compression/tests/kzg_transcripts.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; + +use ix_proof_compression::kzg::compact::FixedProofCodec; +use ix_proof_compression::kzg::{KzgConfig, Scalar, Srs, TranscriptKind}; +use multi_stark::config::ProofConfig; +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::prover::Proof; +use multi_stark::system::{CircuitInputs, ProverKey, System, SystemWitness}; +use multi_stark::traits::{Algebra, Field}; +use p3_matrix::dense::RowMajorMatrix; + +const HEIGHTS: [usize; 2] = [4, 8]; +const MAX_QUOTIENT_DEGREE: usize = 4; + +fn config() -> KzgConfig { + KzgConfig::new( + Arc::new(Srs::unsafe_dev_setup(8, b"kzg-transcript-integration")), + MAX_QUOTIENT_DEGREE, + ) +} + +fn system(config: KzgConfig) -> (System, ProverKey) { + System::new( + config, + HEIGHTS.map(|height| { + let x = Expr::main(0); + let square = Expr::main(1); + CircuitInputs { + main_width: 2, + preprocessed: Some(RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + [ + Scalar::from_usize(row), + if row + 1 == height { Scalar::ONE } else { Scalar::ZERO }, + ] + }) + .collect(), + 2, + )), + constraints: vec![ + x.clone() - Expr::preprocessed(0), + square.clone() - x.clone() * x.clone(), + Expr::IsTransition + * (Expr::main_next(0) - x.clone() - Expr::constant(Scalar::ONE)), + ], + // A fixed Boolean column gives the claim multiplicity one; + // raw Lagrange boundary selectors are not normalized. + lookups: vec![Lookup::pull( + Expr::preprocessed(1), + vec![Expr::constant(Scalar::from_usize(height)), x, square], + )], + ..Default::default() + } + }), + ) +} + +fn claims() -> [[Scalar; 3]; 2] { + HEIGHTS.map(|height| { + let last = Scalar::from_usize(height - 1); + [Scalar::from_usize(height), last, last * last] + }) +} + +fn prove( + system: &System, + key: &ProverKey, +) -> Proof { + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let traces = HEIGHTS + .map(|height| { + RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + let x = Scalar::from_usize(row); + [x, x * x] + }) + .collect(), + 2, + ) + }) + .to_vec(); + system.prove_multiple_claims( + key, + &refs, + SystemWitness::from_stage_1(traces, system), + ) +} + +#[test] +fn transcript_kinds_verify_compact_proofs_and_reject_tampering() { + let base = config(); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let mut encodings = Vec::new(); + for kind in [TranscriptKind::Blake3, TranscriptKind::Keccak256] { + let (mut system, key) = system(base.clone().with_transcript(kind)); + assert_eq!(system.config.transcript_kind(), kind); + let proof = prove(&system, &key); + assert_eq!(proof.log_degrees, [2, 3]); + assert_eq!(proof.opening_proof.0.len(), 3); + system.verify_multiple_claims(&refs, &proof).unwrap(); + + let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); + let compact = codec.encode(&proof).unwrap(); + let decoded = codec.decode(&compact).unwrap(); + assert_eq!(codec.encode(&decoded).unwrap(), compact); + system.verify_multiple_claims(&refs, &decoded).unwrap(); + encodings.push(compact); + + let verifier = KzgConfig::verifier_only( + system.config.pcs().verifier_params().clone(), + MAX_QUOTIENT_DEGREE, + ) + .with_transcript(kind); + assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); + system.config = verifier.clone(); + system.verify_multiple_claims(&refs, &proof).unwrap(); + system.verify_multiple_claims(&refs, &decoded).unwrap(); + + let mut wrong_claims = claims; + wrong_claims[0][2] += Scalar::ONE; + let wrong_refs: Vec<_> = + wrong_claims.iter().map(|claim| claim.as_slice()).collect(); + assert!( + system.verify_multiple_claims(&wrong_refs, &proof).is_err(), + "{kind:?} accepted a changed public claim" + ); + + let mut wrong_evaluation = proof.clone(); + wrong_evaluation.stage_1_opened_values[0][0][0] += Scalar::ONE; + assert!( + system.verify_multiple_claims(&refs, &wrong_evaluation).is_err(), + "{kind:?} accepted a changed opened value" + ); + + let mut wrong_opening = proof.clone(); + wrong_opening.opening_proof.0[0] = Default::default(); + assert_ne!(wrong_opening.opening_proof, proof.opening_proof); + assert!( + system.verify_multiple_claims(&refs, &wrong_opening).is_err(), + "{kind:?} accepted a changed KZG opening witness" + ); + + let other = match kind { + TranscriptKind::Blake3 => TranscriptKind::Keccak256, + TranscriptKind::Keccak256 => TranscriptKind::Blake3, + }; + system.config = verifier.with_transcript(other); + assert!( + system.verify_multiple_claims(&refs, &proof).is_err(), + "{other:?} accepted a {kind:?} proof" + ); + assert!( + system.verify_multiple_claims(&refs, &decoded).is_err(), + "{other:?} accepted a compact {kind:?} proof" + ); + } + assert_ne!(encodings[0], encodings[1]); + assert_eq!(encodings[0].len(), encodings[1].len()); +} + +#[test] +fn default_transcript_preserves_explicit_blake3_proof_bytes() { + let config = config(); + assert_eq!(config.transcript_kind(), TranscriptKind::Blake3); + let (mut system, key) = system(config.clone()); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let default = prove(&system, &key); + system.verify_multiple_claims(&refs, &default).unwrap(); + let codec = FixedProofCodec::new(&system, &default.log_degrees).unwrap(); + + system.config = config.with_transcript(TranscriptKind::Blake3); + let explicit = prove(&system, &key); + system.verify_multiple_claims(&refs, &explicit).unwrap(); + assert_eq!(codec.encode(&default).unwrap(), codec.encode(&explicit).unwrap()); +} + +#[test] +fn filecoin_keccak_proof_verifies_with_portable_authenticated_parameters() { + use ix_proof_compression::kzg::VerifierParams; + use ix_proof_compression::kzg::srs::filecoin::{ + FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, + filecoin_setup_id, load_filecoin_cache, + }; + + let path = std::path::Path::new(FILECOIN_PREFIX_2P8_FIXTURE); + let receipt = FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3; + let srs = load_filecoin_cache(path, receipt, 8).unwrap(); + assert_eq!(srs.public_setup.id, filecoin_setup_id()); + let config = KzgConfig::new(Arc::new(srs), MAX_QUOTIENT_DEGREE) + .with_transcript(TranscriptKind::Keccak256); + let (mut system, key) = system(config); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let proof = prove(&system, &key); + system.verify_multiple_claims(&refs, &proof).unwrap(); + + let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); + let compact = codec.encode(&proof).unwrap(); + let decoded = codec.decode(&compact).unwrap(); + assert_eq!(codec.encode(&decoded).unwrap(), compact); + + let anchors = load_filecoin_cache(path, receipt, 2).unwrap(); + let params = VerifierParams::with_max_trace_len(&anchors, 8); + let params_bytes = params.to_bytes().unwrap(); + assert_eq!( + params_bytes, + system.config.pcs().verifier_params().to_bytes().unwrap() + ); + let verifier = KzgConfig::verifier_only( + VerifierParams::from_bytes(¶ms_bytes).unwrap(), + MAX_QUOTIENT_DEGREE, + ) + .with_transcript(TranscriptKind::Keccak256); + assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); + system.config = verifier.clone(); + system.verify_multiple_claims(&refs, &proof).unwrap(); + system.verify_multiple_claims(&refs, &decoded).unwrap(); + + system.config = verifier.with_transcript(TranscriptKind::Blake3); + assert!(system.verify_multiple_claims(&refs, &proof).is_err()); + assert!(system.verify_multiple_claims(&refs, &decoded).is_err()); + + let mut wrong_receipt = receipt; + wrong_receipt[0] ^= 1; + assert!(load_filecoin_cache(path, wrong_receipt, 8).is_err()); +} diff --git a/crates/proof-compression/tests/pipeline_root.rs b/crates/proof-compression/tests/pipeline_root.rs index 3b4cfee2b..4fcd85975 100644 --- a/crates/proof-compression/tests/pipeline_root.rs +++ b/crates/proof-compression/tests/pipeline_root.rs @@ -22,7 +22,18 @@ fn root_system() -> ( CommitmentParameters, FriParameters, ) { - let cp = CommitmentParameters { log_blowup: 1, cap_height: 0 }; + root_system_with_blowup(1) +} + +fn root_system_with_blowup( + log_blowup: usize, +) -> ( + System, + multi_stark::system::ProverKey, + CommitmentParameters, + FriParameters, +) { + let cp = CommitmentParameters { log_blowup, cap_height: 0 }; let fp = FriParameters { log_final_poly_len: 0, max_log_arity: 1, @@ -50,7 +61,8 @@ fn root_system() -> ( #[test] fn different_root_statements_and_trace_shard_counts() { let (system, pk, cp, fp) = root_system(); - let key = AiurVerifyingKey::from_bytes(&to_bytes(&system, cp, fp)).unwrap(); + let key_bytes = to_bytes(&system, cp, fp); + let key = AiurVerifyingKey::from_bytes(&key_bytes).unwrap(); for (seed, shards) in [(7, 1), (31, 2)] { let expected: Vec<_> = (0..18).map(|i| Val::from_u32(seed + i)).collect(); let inputs = || { @@ -73,6 +85,13 @@ fn different_root_statements_and_trace_shard_counts() { let stage = prepare_root(&key, &proof, &expected, Default::default()).unwrap(); assert_eq!(stage.public_values(), expected); + check_cli_root( + &key_bytes, + &proof, + &expected, + shards, + stage.circuit().stats(), + ); let mut wrong = expected.clone(); wrong[17] += Val::ONE; assert!(prepare_root(&key, &proof, &wrong, Default::default()).is_err()); @@ -109,6 +128,182 @@ fn different_root_statements_and_trace_shard_counts() { } } +#[test] +fn shared_batch_root_preserves_single_batch_verifier_and_proof_bytes() +-> Result<(), Box> { + use ix_proof_compression::fri::{ + Envelope, ImplementationOptions, ProofEnvelope, ProofProfile, Statement, + StatementBinding, VerifierKey, VerifierPlan, + }; + use multi_stark::plonkish::CircuitBuilder; + + let (system, pk, cp, fp) = root_system_with_blowup(2); + let key = AiurVerifyingKey::from_bytes(&to_bytes(&system, cp, fp))?; + let expected: Vec<_> = (0..18).map(|i| Val::from_u32(41 + i)).collect(); + let mut row = expected.clone(); + row.push(Val::ONE); + row.extend(vec![Val::ZERO; 19]); + let proof = system.prove_batch( + &pk, + vec![ShardInput { + claims: vec![expected.clone()], + witness: SystemWitness::from_stage_1( + vec![RowMajorMatrix::new(row, 19)], + &system, + ), + }], + vec![], + ); + let prepared = prepare_root(&key, &proof, &expected, Default::default())?; + + let verifier_key = VerifierKey::from_system(key.system()); + let header = &proof.preamble.headers[0]; + let plan = VerifierPlan::validate( + &verifier_key, + ProofProfile { + envelope: Envelope::SingleBatch, + active: header.active.clone(), + log_degrees: header.log_degrees.clone(), + claim_lengths: vec![expected.len()], + message_lengths: vec![], + max_field_retries: 2, + }, + Default::default(), + )?; + let expanded = plan.expand_witness(ProofEnvelope::SingleBatch(&proof))?; + let mut builder = CircuitBuilder::new(); + builder.enable_compact_blake3(); + let claims = vec![ + (0..expected.len()) + .map(|i| { + StatementBinding::Wire(builder.public_input(format!("claim[0][{i}]"))) + }) + .collect(), + ]; + let inputs = plan.constrain( + &mut builder, + Statement { claims, messages: vec![] }, + ImplementationOptions { compact_blake3: true }, + )?; + let circuit = builder.finish(); + let mut witness = circuit.witness(); + inputs.assign_statement( + &mut witness, + &Statement { claims: vec![expected.clone()], messages: vec![] }, + )?; + expanded.assign_proof(&mut witness, &inputs)?; + let assignment = witness.generate()?; + let compiled = + circuit.lower_to_multi_stark_sharded(Val::from_u8(107), 1 << 24)?; + let claims = compiled.claims(&expected)?; + let template = compiled.claims(&vec![Val::ZERO; expected.len()])?; + let traces = compiled.traces(&assignment)?; + let mut definitions = compiled.circuit_inputs(); + for definition in &mut definitions { + definition.lookup_group_size = 3; + } + drop(compiled); + drop(assignment); + let (reference, key) = + System::new(GoldilocksBlake3Config::new(cp, fp), definitions); + let proof = reference.prove_multiple_claims( + &key, + &claims.iter().map(Vec::as_slice).collect::>(), + SystemWitness::from_stage_1(traces, &reference), + ); + let reference_key = VerifierKey::from_system(&reference).to_bytes()?; + let reference_proof = proof.to_bytes()?; + drop(reference); + + let (verifier, proof) = + prepared.prove_fri(GoldilocksBlake3Config::new(cp, fp), 1 << 24)?; + type EncodedVerifier = (Vec, (Vec>, Vec<(usize, usize)>)); + let bytes = verifier.to_bytes()?; + let ((key, (actual_template, locations)), consumed): ( + EncodedVerifier, + usize, + ) = bincode::serde::decode_from_slice( + &bytes[8..], + bincode::config::standard(), + )?; + assert_eq!(consumed, bytes.len() - 8); + assert_eq!(key, reference_key); + assert_eq!(actual_template, template); + assert_eq!( + locations, + (1..=expected.len()).map(|i| (i, 3)).collect::>() + ); + assert_eq!(proof.to_bytes()?, reference_proof); + Ok(()) +} + +fn check_cli_root( + key: &[u8], + proof: &aiur::synthesis::AiurProof, + expected: &[Val], + shards: usize, + stats: multi_stark::plonkish::CircuitStats, +) { + use ix_proof_compression::fri::*; + use p3_field::PrimeField64; + use std::fs; + + let directory = std::env::temp_dir() + .join(format!("ix-cli-root-{}-{shards}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let result = (|| -> Result<(), Box> { + let input = directory.join("input"); + let output = directory.join("output"); + fs::create_dir(&input)?; + fs::write(input.join("root-vk.bin"), key)?; + fs::write(input.join("root-proof.bin"), proof.to_bytes()?)?; + let mut claims = 1u64.to_le_bytes().to_vec(); + claims.extend((expected.len() as u64).to_le_bytes()); + for value in expected { + claims.extend(value.as_canonical_u64().to_le_bytes()); + } + fs::write(input.join("root-claims.bin"), claims)?; + ix_proof_compression::pipeline::command( + "fri", + &[ + input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + "--check-only".into(), + ], + )?; + assert!(output.join("CHECKED").is_file()); + assert!( + fs::read_to_string(output.join("circuit-cost.txt"))? + .starts_with(&format!("Stats: {stats:?}\n")) + ); + if shards == 1 { + let key = AiurVerifyingKey::from_bytes(key)?; + let key = VerifierKey::from_system(key.system()); + let header = &proof.preamble.headers[0]; + let plan = VerifierPlan::validate( + &key, + ProofProfile { + envelope: Envelope::SingleBatch, + active: header.active.clone(), + log_degrees: header.log_degrees.clone(), + claim_lengths: vec![expected.len()], + message_lengths: vec![], + max_field_retries: DEFAULT_FIELD_RETRIES, + }, + Default::default(), + )?; + let identity = plan.identity(&Statement { + claims: vec![vec![StatementSlot::Public; expected.len()]], + messages: vec![], + })?; + assert_eq!(fs::read(output.join("plan-id.bin"))?, identity); + } + Ok(()) + })(); + fs::remove_dir_all(directory).unwrap(); + result.unwrap(); +} + /// Runs all real compression circuits; expensive because it includes two KZG provers. #[test] #[ignore = "complete compression chain, including recursive KZG proving"] diff --git a/crates/proof-compression/tests/plonkish_plan.rs b/crates/proof-compression/tests/plonkish_plan.rs index e1a50a2c7..b7cd2f975 100644 --- a/crates/proof-compression/tests/plonkish_plan.rs +++ b/crates/proof-compression/tests/plonkish_plan.rs @@ -630,11 +630,7 @@ fn profile_enforces_native_quotient_domain_bound() { let (system, pk) = System::new( config(), [ - CircuitInputs { - main_width: 1, - preprocessed: Some(RowMajorMatrix::new_col(vec![Val::ZERO; 2])), - ..Default::default() - }, + CircuitInputs { main_width: 1, ..Default::default() }, CircuitInputs { main_width: 1, constraints: vec![x.clone() * x.clone() * x.clone() - x], diff --git a/crates/proof-compression/tests/plonkish_verifier_algebra.rs b/crates/proof-compression/tests/plonkish_verifier_algebra.rs index 7730606a5..5eb25bbac 100644 --- a/crates/proof-compression/tests/plonkish_verifier_algebra.rs +++ b/crates/proof-compression/tests/plonkish_verifier_algebra.rs @@ -304,6 +304,59 @@ fn parity_algebra_rejects_tampered_claim_openings_accumulators_and_challenges() assert!(assignment(&circuit, &inputs, &bad_fri, &claim, challenges).is_ok()); } +#[test] +fn algebra_rejects_zero_ood_challenge_with_satisfied_openings() { + use multi_stark::system::CircuitInputs; + + let (system, _) = System::new( + parity::config(), + [CircuitInputs { main_width: 1, ..Default::default() }], + ); + let mut builder = CircuitBuilder::new(); + let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[]); + constrain_algebraic_checks(&mut builder, &system.circuits, &[1], &inputs); + let circuit = builder.finish(); + let imaginary = + ExtVal::from_basis_coefficients_slice(&[Val::ZERO, Val::ONE]).unwrap(); + for (zeta, valid) in [ + (ExtVal::ZERO, false), + (ExtVal::ONE, false), + (ExtVal::from_u8(2), true), + (imaginary, true), + ] { + let mut witness = circuit.witness(); + let c = inputs.challenges; + for (wire, value) in [ + (c.beta, ExtVal::from_u8(3)), + (c.gamma, ExtVal::from_u8(5)), + (c.alpha, ExtVal::from_u8(7)), + (c.zeta, zeta), + ] { + adapter::set_extension(&mut witness, wire, value).unwrap(); + } + // Zero openings satisfy the AIR identity even at zeta = 0. + for opening in &inputs.openings { + for &wire in opening + .preprocessed + .iter() + .flatten() + .chain(opening.main.iter().flatten()) + .chain(opening.stage2.iter().flatten()) + .chain(&opening.quotient) + .chain(std::iter::once(&opening.accumulator)) + { + adapter::set_extension(&mut witness, wire, ExtVal::ZERO).unwrap(); + } + } + let result = witness.generate(); + if valid { + result.unwrap(); + } else { + assert!(matches!(result, Err(WitnessError::HintFailed { .. }))); + } + } +} + #[test] fn algebraic_relations_can_be_proved_independently() { let (system, key) = System::new(parity::config(), parity::circuit_inputs()); diff --git a/crates/proof-compression/tests/worker_tracing.rs b/crates/proof-compression/tests/worker_tracing.rs new file mode 100644 index 000000000..431b47eb2 --- /dev/null +++ b/crates/proof-compression/tests/worker_tracing.rs @@ -0,0 +1,55 @@ +use std::{fs, process::Command}; + +fn worker(texray: bool) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_ix-compression-worker")); + command + .env("IX_TEXRAY", if texray { "1" } else { "0" }) + .env_remove("IX_KZG_FILECOIN_CACHE") + .env_remove("IX_KZG_FILECOIN_MANIFEST") + .env_remove("IX_KZG_PREFETCH_GIB"); + command +} + +#[test] +fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { + let directory = std::env::temp_dir() + .join(format!("ix-worker-tracing-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + for enabled in [false, true] { + let output = + worker(enabled).args(["fri", "capabilities"]).output().unwrap(); + assert!(output.status.success()); + let capabilities: serde_json::Value = + serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(capabilities["binary"], "ix-compression-worker"); + assert_eq!( + String::from_utf8_lossy(&output.stderr) + .contains("[texray] compression/fri:"), + enabled + ); + for (stage, args) in [ + ("fri", vec!["fri", "missing", "fri-out", "--native-only"]), + ("kzg", vec!["kzg", "run", "missing", "kzg-out", "24", "--dev-srs"]), + ( + "wrap", + vec!["wrap", "native-verify", "missing", "report.json", "--dev-srs"], + ), + ] { + let output = + worker(enabled).args(args).current_dir(&directory).output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + stderr.contains(&format!("[texray] compression/{stage}:")), + enabled, + "{stderr}" + ); + assert_eq!( + stderr.contains("[texray] compression/load_input:"), + enabled, + "{stderr}" + ); + } + } + fs::remove_dir_all(directory).unwrap(); +} diff --git a/docs/aiur-gpu-proving.md b/docs/aiur-gpu-proving.md index 7d730ae91..2c4fb0b75 100644 --- a/docs/aiur-gpu-proving.md +++ b/docs/aiur-gpu-proving.md @@ -25,15 +25,17 @@ everything else unchanged. Build on a GPU host: ```sh -IX_CUDA_TRACE_CODEGEN=1 MULTI_STARK_CUDA_ARCHS=120 CFLAGS=-std=gnu17 lake build ix +IX_CUDA_TRACE_CODEGEN=1 MULTI_STARK_CUDA_ARCHS=120 lake build ix ``` `MULTI_STARK_CUDA_ARCHS` names the SM architecture (`120` is Blackwell, -`80` the CI container's Ampere). `CFLAGS=-std=gnu17` is required with -glibc 2.43 headers: in C23 mode mimalloc's `strtol` becomes -`__isoc23_strtol`, which the sysroot of Lean's bundled `clang`/`ld.lld` -does not provide, and the final link fails on `libix_ffi_net.a`. If `nvcc` -is not on `PATH`, set `NVCC` or `CUDA_HOME`. +`80` the CI container's Ampere). + +The lakefile compiles the C units Cargo builds as C17: with glibc 2.38 or newer headers in C23 mode, mimalloc's +`strtol` would become `__isoc23_strtol`, which the link-time glibc of +Lean's bundled toolchain does not provide, and the final link would fail on +`libix_ffi_net.a`. No `CFLAGS` setting is needed. If `nvcc` is not on +`PATH`, set `NVCC` or `CUDA_HOME`. A binary linked by the Nix Rust toolchain runs on Nix glibc, whose loader never searches `/usr/lib/x86_64-linux-gnu`; the statically linked CUDA @@ -184,7 +186,7 @@ The ones a run needs, then the ones a measurement might. | `--retention retain\|regenerate\|auto` | What a batch keeps between its two rounds. Every real GPU plan runs `regenerate`: round two rebuilds each shard from its seeds, which the resident seed cache makes cheap. | | `MULTI_STARK_CUDA_MIN_FREE_BYTES` | Device headroom the backend keeps by spilling LDEs to the host; default a quarter of the card. | | `MULTI_STARK_CUDA_MEMORY_LOG=1` | Logs stage-1 placement and per-lookup-job budget and free bytes to stderr. | -| `--texray` | Per-phase wall and RSS lines on stderr, as on the CPU. | +| `--texray` or `IX_TEXRAY=1` | Per-phase wall and RSS lines on stderr, as on the CPU. The environment switch also enables compression-worker TeXRay output. | ## 6. Benchmarking a multi-GPU run @@ -193,6 +195,12 @@ command, recorded with its identities. The script that produced the measurements below does only this: write the provenance, sample the devices, run the one command under the cap, and keep the exit status. +Set `RUNS_DIR` to fast local storage for this script's outputs and lane cache. +For subsequent KZG compression, use the separate +[NVMe and cache placement instructions](../crates/proof-compression/README.md#disk-placement) +and [KZG benchmark recipe](../crates/proof-compression/README.md#kzg-benchmarking). +`RUNS_DIR` does not configure KZG cache paths. + ```sh #!/bin/bash # run-lanes4.sh | srs | wrap ".into()), + } +} + +fn export_srs(domain: usize, path: &Path) -> Result<()> { + export_parameters(domain, path, &SetupConfig::from_env(false)?) +} + +fn export_parameters( + domain: usize, + path: &Path, + config: &SetupConfig, +) -> Result<()> { + if domain < 2 || !domain.is_power_of_two() { + return Err("PLONK domain must be a power of two >= 2".into()); + } + let count = domain.checked_add(3).ok_or("PLONK SRS length overflow")?; + let prefix = + count.checked_next_power_of_two().ok_or("PLONK SRS length overflow")?; + let SetupConfig::Filecoin { manifest, .. } = config else { + return Err("PLONK requires authenticated Filecoin parameters".into()); + }; + let source = config.for_stage(b"ix/bls-plonk/srs/v1", prefix)?; + let srs = source.srs(prefix, false)?; + let temporary = storage::temporary_path(path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut out = std::io::BufWriter::new(fs::File::create(&temporary)?); + out.write_all(b"IXPSRS01")?; + out.write_all(&srs.public_setup.id)?; + out.write_all(&(srs.public_setup.max_degree as u64).to_le_bytes())?; + out.write_all(manifest)?; + out.write_all(&(count as u64).to_le_bytes())?; + srs.g2.serialize_compressed(&mut out)?; + srs.tau_g2.serialize_compressed(&mut out)?; + for point in &srs.g1[..count] { + out.write_all(&point.x.into_bigint().to_bytes_be())?; + out.write_all(&point.y.into_bigint().to_bytes_be())?; + } + out.flush()?; + drop(out); + storage::persist(&temporary, path)?; + let mut hash = blake3::Hasher::new(); + hash.update_mmap_rayon(path)?; + let receipt = serde_json::json!({ + "format": "ix-plonk-srs-export-v1", "blake3": hash.finalize().to_hex().as_str(), + "setupId": blake3::Hash::from_bytes(srs.public_setup.id).to_hex().as_str(), + "publicMaxDegree": srs.public_setup.max_degree, "powers": count, + "filecoinManifest": blake3::Hash::from_bytes(*manifest).to_hex().as_str(), + }); + storage::write_atomic( + &path.with_extension("json"), + &serde_json::to_vec_pretty(&receipt)?, + )?; + Ok(()) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ParameterReceipt { + format: String, + blake3: String, + setup_id: String, + public_max_degree: usize, + powers: usize, + filecoin_manifest: String, +} + +fn cached_parameters( + domain: usize, + root: &Path, + config: &SetupConfig, +) -> Result<(PathBuf, String)> { + let SetupConfig::Filecoin { manifest, .. } = config else { + return Err("PLONK requires Filecoin parameters".into()); + }; + let anchors = crate::kzg::srs::filecoin::filecoin_anchors(); + let mut identity = blake3::Hasher::new(); + identity.update(b"ix/plonk-parameter-export/v1"); + identity.update(&anchors.public_setup.id); + identity.update(manifest); + identity.update(&u64::try_from(domain)?.to_le_bytes()); + let path = root.join(identity.finalize().to_hex().as_str()).join("srs.bin"); + let receipt_path = path.with_extension("json"); + if !receipt_path.try_exists()? { + export_parameters(domain, &path, config)?; + } + let receipt: ParameterReceipt = load_json(&receipt_path)?; + if receipt.format != "ix-plonk-srs-export-v1" + || digest(&receipt.setup_id)? != anchors.public_setup.id + || digest(&receipt.filecoin_manifest)? != *manifest + || receipt.public_max_degree != anchors.public_setup.max_degree + || receipt.powers + != domain.checked_add(3).ok_or("PLONK SRS length overflow")? + { + return Err("cached PLONK parameter identity differs".into()); + } + let mut hash = blake3::Hasher::new(); + hash.update_mmap_rayon(&path)?; + if *hash.finalize().as_bytes() != digest(&receipt.blake3)? { + return Err("cached PLONK parameter export is damaged".into()); + } + Ok((path, receipt.blake3)) +} + +fn metadata_string<'a>( + value: &'a serde_json::Value, + field: &str, +) -> Result<&'a str> { + value + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("missing PLONK metadata field {field}").into()) +} + +fn claim_digest(expected: &statement::Statement) -> Result<[u8; 32]> { + let mut digest = [0; 32]; + for (chunk, value) in + digest.as_chunks_mut::<4>().0.iter_mut().zip(&expected[10..]) + { + *chunk = u32::try_from(*value)?.to_le_bytes(); + } + Ok(digest) +} + +fn wrap_packet( + verifier: &Path, + packet: &Path, + trusted_digest: &str, + expected: &statement::Statement, + cache_root: &Path, + out: &Path, +) -> Result<()> { + statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; + let expected_digest = claim_digest(expected)?; + let setup = SetupConfig::from_env(false)?; + let SetupConfig::Filecoin { manifest, .. } = &setup else { + return Err("PLONK requires Filecoin parameters".into()); + }; + let helper = helper_executable()?; + fs::create_dir_all(out)?; + if fs::read_dir(out)?.next().is_some() { + return Err("PLONK output directory must be empty".into()); + } + let input = out.join("input"); + wrap::plonk_export(verifier, packet, trusted_digest, expected, &input)?; + let profile_path = input.join("profile.json"); + let exported: serde_json::Value = load_json(&profile_path)?; + let mut identity = blake3::Hasher::new(); + identity.update(b"ix/bls-plonk-cache/v1"); + identity.update(&fixed_cache::executable_digest()?); + identity.update(&sha256(&helper)?); + identity.update(&sha256(&profile_path)?); + identity.update(manifest); + let cache = + cache_root.join("circuits").join(identity.finalize().to_hex().as_str()); + checked(Command::new(&helper).arg("compile").arg(&profile_path).arg(&cache))?; + let mut metadata: serde_json::Value = + load_json(&cache.join("manifest.json"))?; + let domain = usize::try_from( + metadata + .get("Domain") + .and_then(serde_json::Value::as_u64) + .ok_or("missing PLONK domain")?, + )?; + if domain < 2 || !domain.is_power_of_two() || domain > 1 << 26 { + return Err( + "PLONK domain exceeds the authenticated adapter capacity".into(), + ); + } + if metadata.get("KeysReady").and_then(serde_json::Value::as_bool) + != Some(true) + { + let (srs, receipt) = + cached_parameters(domain, &cache_root.join("parameters"), &setup)?; + checked( + Command::new(&helper) + .arg("prepare") + .arg(&cache) + .arg(srs) + .arg(receipt) + .env("IX_PLONK_LAGRANGE_CACHE", cache_root.join("lagrange")), + )?; + metadata = load_json(&cache.join("manifest.json"))?; + } + checked( + Command::new(&helper) + .arg("prove") + .arg(&cache) + .arg(input.join("witness.json")) + .arg(out), + )?; + let key_hash = + metadata_string(&metadata["Files"]["verifying-key.bin"], "SHA256")?; + let key_id = digest(key_hash)?; + let key = cache.join("verifying-key.bin"); + checked( + Command::new(&helper) + .arg("verify") + .arg(&key) + .arg(key_hash) + .arg(out.join("proof.bin")) + .arg(hex(&expected_digest)) + .arg(filecoin_anchor_encoding()?), + )?; + let proof = fs::read(out.join("proof.bin"))?; + if proof.is_empty() || proof.len() > MAX_PROOF_BYTES { + return Err("invalid native PLONK proof length".into()); + } + let mut envelope = PROOF_TAG.to_vec(); + envelope.extend(key_id); + envelope.extend(proof); + storage::write_atomic(&out.join("proof.envelope.bin"), &envelope)?; + storage::write_atomic(&out.join("verifying-key.bin"), &fs::read(&key)?)?; + storage::write_atomic( + &out.join("PlonkVerifier.sol"), + &fs::read(cache.join("PlonkVerifier.sol"))?, + )?; + let candidate = TrustedCatalog { + format: "ix-bls-plonk-verifiers-v1".into(), + keys: vec![TrustedKey { + sha256: key_hash.into(), + path: "verifying-key.bin".into(), + inner_profile_id: metadata_string(&exported, "profileId")?.into(), + statement_prefix: expected[..10].try_into()?, + circuit_sha256: metadata_string( + &metadata["Files"]["circuit.bin"], + "SHA256", + )? + .into(), + helper_sha256: metadata_string(&metadata, "ExecutableSHA256")?.into(), + setup: "filecoin".into(), + }], + }; + storage::write_atomic( + &out.join("verifier-catalog.candidate.json"), + &serde_json::to_vec_pretty(&candidate)?, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> TrustedKey { + TrustedKey { + sha256: hex(&[1; 32]), + path: "verifier.bin".into(), + inner_profile_id: hex(&[2; 32]), + statement_prefix: [0; 10], + circuit_sha256: hex(&[3; 32]), + helper_sha256: hex(&[4; 32]), + setup: "filecoin".into(), + } + } + + #[test] + fn catalog_requires_an_independently_authorized_key() -> Result<()> { + let scratch = Scratch::new()?; + let path = scratch.0.join("catalog.json"); + let mut catalog = TrustedCatalog { + format: "ix-bls-plonk-verifiers-v1".into(), + keys: vec![key()], + }; + let write = |catalog: &TrustedCatalog| { + storage::write_atomic(&path, &serde_json::to_vec(catalog).unwrap()) + }; + write(&catalog)?; + assert_eq!( + select_key(&path, &[1; 32])?.path, + scratch.0.join("verifier.bin") + ); + assert!(select_key(&path, &[5; 32]).is_err()); + catalog.keys.push(key()); + write(&catalog)?; + assert!(select_key(&path, &[1; 32]).is_err()); + catalog.keys.pop(); + catalog.keys[0].setup = "development".into(); + write(&catalog)?; + assert!(select_key(&path, &[1; 32]).is_err()); + catalog.keys[0].setup = "filecoin".into(); + catalog.keys[0].statement_prefix[0] = u64::MAX; + write(&catalog)?; + assert!(select_key(&path, &[1; 32]).is_err()); + assert!(digest("é").is_err()); + assert!(verify(&[], b"claim").is_err()); + Ok(()) + } + + #[test] + fn parameter_cache_preserves_filecoin_receipt_and_rejects_corruption() + -> Result<()> { + use crate::kzg::srs::filecoin::{ + FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, + }; + let scratch = Scratch::new()?; + let setup = SetupConfig::Filecoin { + cache: FILECOIN_PREFIX_2P8_FIXTURE.into(), + manifest: FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, + }; + let (path, receipt) = cached_parameters(8, &scratch.0, &setup)?; + assert_eq!(fs::metadata(&path)?.len(), 280 + 96 * 11); + let (again, same_receipt) = cached_parameters(8, &scratch.0, &setup)?; + assert_eq!(again, path); + assert_eq!(same_receipt, receipt); + let mut bytes = fs::read(&path)?; + *bytes.last_mut().unwrap() ^= 1; + fs::write(&path, bytes)?; + assert!(cached_parameters(8, &scratch.0, &setup).is_err()); + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/stages/wrap.rs b/crates/proof-compression/src/pipeline/stages/wrap.rs index 5158f88ee..5cc6205de 100644 --- a/crates/proof-compression/src/pipeline/stages/wrap.rs +++ b/crates/proof-compression/src/pipeline/stages/wrap.rs @@ -81,7 +81,7 @@ impl RecursiveStage { return Err("recursive trace exceeds the SRS range".into()); } let config = KzgConfig::with_max_trace_len(srs, height, 2) - .with_transcript(crate::kzg::TranscriptKind::Keccak256) + .with_transcript(crate::kzg::TranscriptKind::Blake3) .with_streaming_lookups() .with_streaming_quotient(); let (verifier, proof) = super::prove_lowered( @@ -117,7 +117,7 @@ pub(super) fn setup_recursive( return Err("recursive trace exceeds the SRS range".into()); } let config = KzgConfig::with_max_trace_len(srs, height, 2) - .with_transcript(crate::kzg::TranscriptKind::Keccak256) + .with_transcript(crate::kzg::TranscriptKind::Blake3) .with_streaming_lookups() .with_streaming_quotient(); let outer = super::setup::kzg_lowered(compiled, config, claim_len + 4)?; diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs index b3825ae26..d802c4b7d 100644 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -25,7 +25,23 @@ const AGGREGATE_PREFIX: [u64; 10] = [ 1176003176, 683533119, 3085659920, ]; -static VERIFIERS: [BundledVerifier; 2] = [ +static VERIFIERS: [BundledVerifier; 4] = [ + BundledVerifier { + id: "4224c47197ed387320dfb9b8bbf2369fa326f72c54e1cbc65907f5e6e8541ca0", + digest: "12ce28356f86ba34afb11b8e3b2c142f47ab9259e9c3f5751069bd1c41609084", + statement_prefix: AGGREGATE_PREFIX, + setup: SetupClass::Filecoin, + bytes: include_bytes!("../../profiles/aggregate-filecoin-v6.bin"), + decoded: OnceLock::new(), + }, + BundledVerifier { + id: "b0db3d47f5f9e08b3710499adaba92ad6c5bd160393d45aef1a505c5909cd580", + digest: "116c668b17db0a1bd95ea8cce9d080af14b16031db128c47a3dcd46443147678", + statement_prefix: AGGREGATE_PREFIX, + setup: SetupClass::Development, + bytes: include_bytes!("../../profiles/aggregate-development-v6.bin"), + decoded: OnceLock::new(), + }, BundledVerifier { id: "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", digest: "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", @@ -134,6 +150,20 @@ mod tests { pipeline::verify(proof, claim, development) } + #[test] + fn blake3_profiles_derive_from_authenticated_circuit_metadata() { + for (source, target) in [(2, 0), (3, 1)] { + let (bytes, id) = + VerifierProfile::reencode_blake3(VERIFIERS[source].bytes).unwrap(); + assert_eq!(bytes, VERIFIERS[target].bytes); + assert_eq!( + blake3::Hash::from_bytes(id).to_hex().as_str(), + VERIFIERS[target].id + ); + VERIFIERS[target].profile().unwrap(); + } + } + #[test] fn bundled_packets_verify_without_a_registry_or_srs() { for (packet, setup, _) in fixtures() { diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index 7882d3768..1c8cbaf8d 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -1,5 +1,7 @@ mod outer; mod saved; +#[cfg(feature = "plonk")] +pub(super) use outer::plonk_export; /// Device bytes the recursive prover keeps resident per device unless /// `IX_KZG_CUDA_RESIDENT_GIB` overrides it. Its traces are the largest diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 43271b027..787b1103b 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -1,5 +1,10 @@ //! Recursive KZG wrapper: staging and proving in one process, and the //! portable verifier its packets are checked with. +#[cfg(feature = "plonk")] +mod relation; +#[cfg(feature = "plonk")] +pub(in crate::pipeline) use relation::plonk::export as plonk_export; + use super::super::stages::{ construct::{self, Lowered}, statement::Statement as StageStatement, @@ -27,7 +32,7 @@ use multi_stark::{ use std::{fs, path::Path}; pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-v1"; -const OUTER_PROFILE_VERSION: u32 = 5; +const OUTER_PROFILE_VERSION: u32 = 6; /// Largest admitted recursive computation trace. The development public /// degree of a staged profile covers exactly its own height. pub(super) const MAX_TRACE_LEN: usize = construct::RECURSIVE_HEIGHT; @@ -107,7 +112,7 @@ impl Frontend { let setup = config .setup .for_stage(OUTER_SEED, height)? - .with_transcript(TranscriptKind::Keccak256); + .with_transcript(TranscriptKind::Blake3); let setup_id = setup.identity(height, 2)?; fs::create_dir_all(dir)?; setup.bind_stage(dir, height, 2)?; @@ -192,7 +197,7 @@ fn packet_body<'a>( Ok(&packet[offset..]) } -#[cfg(test)] +#[cfg(any(test, feature = "plonk"))] fn packet_claims( packet: &[u8], profile: &[u8; 32], @@ -346,7 +351,7 @@ fn prove( "pairing_bytes": keys.len() * 48, "profile_bytes": 32, "profile_version": OUTER_PROFILE_VERSION, - "transcript": "keccak256", + "transcript": "blake3", "total_seconds": start.elapsed().as_secs_f64(), "verify_seconds": verify_seconds, "srs_config_load_seconds": srs_config_load_seconds, @@ -396,6 +401,19 @@ fn decode(bytes: &[u8]) -> storage::Result { } impl VerifierProfile { + pub(crate) fn reencode_blake3( + bytes: &[u8], + ) -> PipelineResult<(Vec, [u8; 32])> { + let mut profile = Self::from_bytes(bytes)?; + profile.version = OUTER_PROFILE_VERSION; + profile.prepare()?; + let id = profile.id(); + Ok(( + bincode::serde::encode_to_vec(profile, bincode::config::standard())?, + id, + )) + } + pub(crate) fn from_bytes(bytes: &[u8]) -> PipelineResult { let profile: Self = decode(bytes)?; profile.prepare()?; @@ -451,7 +469,7 @@ impl VerifierProfile { fn system(&self) -> storage::Result> { use crate::kzg::VerifierParams; use multi_stark::system::{Circuit, System}; - if self.version != OUTER_PROFILE_VERSION { + if !matches!(self.version, 5 | OUTER_PROFILE_VERSION) { return Err("unsupported recursive verifier profile".into()); } if self @@ -480,8 +498,13 @@ impl VerifierProfile { fixed.0.append(&mut commit.0); } Ok(System { - config: KzgConfig::verifier_only(params, 2) - .with_transcript(TranscriptKind::Keccak256), + config: KzgConfig::verifier_only(params, 2).with_transcript( + if self.version == 5 { + TranscriptKind::Keccak256 + } else { + TranscriptKind::Blake3 + }, + ), circuits, preprocessed_commit: Some(fixed), preprocessed_indices: vec![Some(0), Some(1)], @@ -661,7 +684,7 @@ mod tests { let lowered = circuit.lower_to_multi_stark(Scalar::from_u8(94)).unwrap(); let config = KzgConfig::new(Arc::new(Srs::unsafe_dev_setup(64, b"packet-outer")), 2) - .with_transcript(TranscriptKind::Keccak256); + .with_transcript(TranscriptKind::Blake3); let (system, key) = System::new(config, lowered.kzg_circuit_inputs(64, 2).unwrap()); let codec = FixedProofCodec::new( @@ -771,7 +794,7 @@ mod tests { fs::create_dir_all(dir)?; let height = compiled.main_height(); SetupSource::development(OUTER_SEED, height - 1)? - .with_transcript(TranscriptKind::Keccak256) + .with_transcript(TranscriptKind::Blake3) .bind_stage(dir, height, 2)?; let mut encoded = vec![]; keys.serialize_compressed(&mut encoded)?; @@ -791,7 +814,7 @@ mod tests { fn fused_profiles_prove_and_portable_verifiers_round_trip() -> storage::Result<()> { let dir = std::env::temp_dir() - .join(format!("outer-staged-{}-v5", std::process::id())); + .join(format!("outer-staged-{}-v6", std::process::id())); fs::create_dir(&dir)?; let inner = Srs::unsafe_dev_setup(8, b"staged-inner"); let keys = vec![inner.g2, inner.tau_g2]; @@ -823,7 +846,7 @@ mod tests { storage::load(&first.join("manifest.bin"))?; assert_eq!(manifest.claims, claims); let setup = SetupSource::development(OUTER_SEED, height - 1)? - .with_transcript(TranscriptKind::Keccak256); + .with_transcript(TranscriptKind::Blake3); prove(&first, Lowered { compiled, assignment }, &cache, &setup)?; assert!(first.join("kzg/fixed-0.bin").exists()); assert!(!first.join("kzg/main-0.bin").exists()); @@ -861,10 +884,10 @@ mod tests { assert_eq!(profile.version, OUTER_PROFILE_VERSION); assert_eq!( profile.system()?.config.transcript_kind(), - TranscriptKind::Keccak256 + TranscriptKind::Blake3 ); - for version in [0, 4, 6] { + for version in [0, 4, 7] { let mut unsupported: VerifierProfile = decode(&portable)?; unsupported.version = version; let unsupported = bincode::serde::encode_to_vec( diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs new file mode 100644 index 000000000..f8cd4558d --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs @@ -0,0 +1,402 @@ +//! Verifier relations of authenticated recursive profiles: opening layouts, +//! validated constraint graphs and native differential vectors for terminal +//! proof exporters. + +use super::*; +use ark_bls12_381::{Fq, G1Affine, G1Projective, G2Affine}; +use ark_ec::{AffineRepr, CurveGroup}; +use ark_ff::{BigInteger, PrimeField, Zero}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use multi_stark::{ + config::ProofConfig, + eval::VarValues, + expr::{RowOffset, Source}, + graph::{ConstraintGraph, Node}, + prover::{Proof, observe_claims, sample_lookup_challenges}, + system::System, + traits::{Algebra, EvaluationDomain, Pcs, Transcript}, +}; +use serde_json::{Value as Json, json}; +use std::fmt::Write as _; + +mod codegen; +pub(in crate::pipeline) mod plonk; + +type Result = std::result::Result>; + +fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(&mut out, "{byte:02x}").unwrap(); + } + out +} + +fn hex_json(bytes: &[u8]) -> String { + format!("0x{}", hex(bytes)) +} + +fn scalar(value: Scalar) -> String { + hex_json(&value.0.into_bigint().to_bytes_be()) +} + +fn fq(value: Fq) -> Vec { + value.into_bigint().to_bytes_be() +} + +fn g1(point: G1Affine) -> Vec { + if point.is_zero() { + return vec![0; 128]; + } + [vec![0; 16], fq(point.x), vec![0; 16], fq(point.y)].concat() +} + +fn proof_points(proof: &Proof) -> Vec { + [ + &proof.commitments.stage_1_trace, + &proof.commitments.stage_2_trace, + &proof.commitments.quotient_chunks, + ] + .into_iter() + .flat_map(|c| c.0.iter().flatten().copied()) + .chain(proof.opening_proof.0.iter().copied()) + .collect() +} + +#[derive(Clone, Copy)] +struct GraphMatrix { + index: usize, + width: usize, + rows: usize, +} + +/// Opening windows indexed by the graph's three variable sources. +#[derive(Clone, Copy)] +struct GraphInputs { + main: GraphMatrix, + preprocessed: GraphMatrix, + stage2: GraphMatrix, + public_count: usize, +} + +impl GraphInputs { + fn for_circuit(system: &System, index: usize) -> Result { + if index >= 2 { + return Err("Solidity graph requires one of two circuits".into()); + } + let c = system.circuits.get(index).ok_or("missing circuit")?; + Ok(Self { + main: GraphMatrix { + index, + width: c.main_width, + rows: 1 + usize::from(system.opens_next_row(index, Source::Main)), + }, + preprocessed: GraphMatrix { + index: 6 + index, + width: c.preprocessed_width, + rows: 1 + + usize::from(system.opens_next_row(index, Source::Preprocessed)), + }, + stage2: GraphMatrix { index: 2 + index, width: c.stage_2_width, rows: 2 }, + public_count: c.num_publics, + }) + } + + fn matrix(self, source: Source) -> GraphMatrix { + match source { + Source::Main => self.main, + Source::Preprocessed => self.preprocessed, + Source::Stage2 => self.stage2, + } + } +} + +/// Validated arithmetic with variable references resolved into opening windows. +#[derive(Clone, Copy)] +enum GraphInstruction { + Constant(Scalar), + Opening { matrix: usize, offset: usize }, + Public(usize), + First, + Last, + Transition, + Add(usize, usize), + Sub(usize, usize), + Mul(usize, usize), + Neg(usize), +} + +struct GraphProgram { + instructions: Vec, +} + +impl GraphProgram { + fn compile( + graph: &ConstraintGraph, + inputs: GraphInputs, + ) -> Result { + if inputs.public_count != 4 { + return Err("Solidity graph requires four circuit public scalars".into()); + } + for m in [inputs.main, inputs.preprocessed, inputs.stage2] { + if m.index >= 8 || !(1..=2).contains(&m.rows) { + return Err("invalid Solidity graph opening window".into()); + } + } + let mut program = + Self { instructions: Vec::with_capacity(graph.nodes.len()) }; + for (index, node) in graph.nodes.iter().enumerate() { + let child = |id: multi_stark::graph::NodeId| -> Result { + let child = id.index(); + if child >= index { + return Err("graph child must precede its parent".into()); + } + Ok(child) + }; + let instruction = match *node { + Node::Const(value) => GraphInstruction::Constant(value), + Node::Var(col) => { + let matrix = inputs.matrix(col.source); + let row = usize::from(col.offset == RowOffset::Next); + let column = usize::try_from(col.index)?; + if row >= matrix.rows || column >= matrix.width { + return Err("graph column lies outside its opening window".into()); + } + let offset = row + .checked_mul(matrix.width) + .and_then(|base| base.checked_add(column)) + .ok_or("graph opening offset overflow")?; + GraphInstruction::Opening { matrix: matrix.index, offset } + }, + Node::Public(index) => { + let index = usize::try_from(index)?; + if index >= inputs.public_count { + return Err("graph public index out of range".into()); + } + GraphInstruction::Public(index) + }, + Node::IsFirstRow => GraphInstruction::First, + Node::IsLastRow => GraphInstruction::Last, + Node::IsTransition => GraphInstruction::Transition, + Node::Add(a, b) => GraphInstruction::Add(child(a)?, child(b)?), + Node::Sub(a, b) => GraphInstruction::Sub(child(a)?, child(b)?), + Node::Mul(a, b) => GraphInstruction::Mul(child(a)?, child(b)?), + Node::Neg(a) => GraphInstruction::Neg(child(a)?), + }; + program.instructions.push(instruction); + } + for root in + graph.zeros.iter().chain(graph.lookups.iter().flat_map(|lookup| { + std::iter::once(&lookup.multiplicity).chain(&lookup.args) + })) + { + if root.index() >= graph.nodes.len() { + return Err("graph root out of range".into()); + } + } + Ok(program) + } + + fn evaluate + Copy + From>( + &self, + opening: impl Fn(usize, usize) -> A, + publics: &[A], + first: A, + last: A, + transition: A, + ) -> Vec { + let mut values: Vec = Vec::with_capacity(self.instructions.len()); + for instruction in &self.instructions { + values.push(match *instruction { + GraphInstruction::Constant(value) => A::from(value), + GraphInstruction::Opening { matrix, offset } => opening(matrix, offset), + GraphInstruction::Public(index) => publics[index], + GraphInstruction::First => first, + GraphInstruction::Last => last, + GraphInstruction::Transition => transition, + GraphInstruction::Add(a, b) => values[a] + values[b], + GraphInstruction::Sub(a, b) => values[a] - values[b], + GraphInstruction::Mul(a, b) => values[a] * values[b], + GraphInstruction::Neg(a) => -values[a], + }); + } + values + } +} + +struct Batch { + z: Scalar, + commitments: Vec, + values: Vec, +} + +fn diagnostics( + system: &System, + proof: &Proof, + claims: &[Vec], +) -> Result { + let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + system + .verify_multiple_claims(&refs, proof) + .map_err(|e| format!("native proof: {e:?}"))?; + let mut t = system.config.initialise_challenger(); + system.observe_shape(&mut t); + for &active in &proof.active { + t.observe_field(Scalar::from_bool(active)); + } + t.observe_commitment( + system.preprocessed_commit.clone().ok_or("missing fixed commitment")?, + ); + t.observe_commitment(proof.commitments.stage_1_trace.clone()); + for &log in &proof.log_degrees { + t.observe_field(Scalar::from_u8(log)); + } + observe_claims::(&mut t, &refs); + let (beta, gamma) = sample_lookup_challenges::(&mut t); + let mut accumulator = claims + .iter() + .map(|row| { + (beta + + row + .iter() + .rev() + .fold(Scalar::ZERO, |acc, value| acc * gamma + *value)) + .inverse() + }) + .fold(Scalar::ZERO, |a, b| a + b); + let public_accumulator = accumulator; + t.observe_commitment(proof.commitments.stage_2_trace.clone()); + for &a in &proof.intermediate_accumulators { + t.observe_challenge(a); + } + let alpha = t.sample_challenge(); + t.observe_commitment(proof.commitments.quotient_chunks.clone()); + let zeta = t.sample_challenge(); + let rounds = [ + (&proof.commitments.stage_1_trace, &proof.stage_1_opened_values), + (&proof.commitments.stage_2_trace, &proof.stage_2_opened_values), + (&proof.commitments.quotient_chunks, &proof.quotient_opened_values), + ( + system.preprocessed_commit.as_ref().unwrap(), + proof.preprocessed_opened_values.as_ref().unwrap(), + ), + ]; + for (commit, _) in &rounds { + t.observe_commitment((*commit).clone()); + for &log in &proof.log_degrees { + t.observe_canonical(&u64::from(log)); + } + } + let mut batches: Vec = vec![]; + for (commit, values) in &rounds { + for (i, rows) in values.iter().enumerate() { + let domain = system + .config + .pcs() + .natural_domain_for_degree(1 << proof.log_degrees[i]); + for (row, values) in rows.iter().enumerate() { + let z = if row == 0 { zeta } else { domain.next_point(zeta) }; + for &v in values { + t.observe_challenge(v); + } + let index = match batches.iter().position(|b| b.z == z) { + Some(index) => index, + None => { + batches.push(Batch { z, commitments: vec![], values: vec![] }); + batches.len() - 1 + }, + }; + batches[index].commitments.extend_from_slice(&commit.0[i]); + batches[index].values.extend_from_slice(values); + } + } + } + let v = t.sample_challenge(); + for witness in &proof.opening_proof.0 { + t.observe_canonical(witness); + } + let r = t.sample_challenge(); + if batches.len() != proof.opening_proof.0.len() { + return Err("opening count mismatch".into()); + } + let mut left = G1Projective::zero(); + let mut right = G1Projective::zero(); + let mut rp = Scalar::ONE; + let generator = system.config.pcs().verifier_params().g1[0]; + for (batch, witness) in batches.iter().zip(&proof.opening_proof.0) { + let mut folded = G1Projective::zero(); + let mut y = Scalar::ZERO; + let mut vp = Scalar::ONE; + for (c, value) in batch.commitments.iter().zip(&batch.values) { + folded += *c * vp.0; + y += *value * vp; + vp *= v; + } + left += (folded - generator * y.0 + *witness * batch.z.0) * rp.0; + right += *witness * rp.0; + rp *= r; + } + let mut compositions = vec![]; + let mut quotients = vec![]; + for (i, c) in system.circuits.iter().enumerate() { + let n = 1usize << proof.log_degrees[i]; + let domain = system.config.pcs().natural_domain_for_degree(n); + let sels = domain.selectors_at_point(zeta); + let next = proof.intermediate_accumulators[i]; + let publics = [beta, gamma, accumulator, next]; + let fixed = &proof.preprocessed_opened_values.as_ref().unwrap()[i]; + let main = &proof.stage_1_opened_values[i]; + let stage2 = &proof.stage_2_opened_values[i]; + let view = VarValues { + preprocessed: [&fixed[0], fixed.get(1).map_or(&[][..], Vec::as_slice)], + main: [&main[0], main.get(1).map_or(&[][..], Vec::as_slice)], + stage2: [&stage2[0], &stage2[1]], + publics: &publics, + is_first_row: sels.is_first_row, + is_last_row: sels.is_last_row, + is_transition: sels.is_transition, + }; + let mut values = vec![]; + c.graph.sweep(&view, &mut values); + let mut constraints = c.graph.constraint_values(&values); + let ext = multi_stark::system::extension_params::(); + let delta = (next - accumulator) + * (Scalar::from_usize(n) * domain.generator()).inverse(); + multi_stark::lookup::logup_constraint_values( + &c.graph.lookups, + &values, + &stage2[0], + &stage2[1], + &publics, + &[delta], + sels.is_last_row, + ext.w, + ext.degree, + c.lookup_group_size.max(1), + &mut constraints, + ); + let composition = constraints + .into_iter() + .fold(Scalar::ZERO, |acc, value| acc * alpha + value); + let zn = zeta.exp_power_of_2(usize::from(proof.log_degrees[i])); + let quotient = proof.quotient_opened_values[i][0] + .iter() + .zip(zn.powers()) + .map(|(&value, power)| value * power) + .fold(Scalar::ZERO, |a, b| a + b); + if composition * sels.inv_vanishing != quotient { + return Err("diagnostic quotient mismatch".into()); + } + compositions.push(scalar(composition)); + quotients.push(scalar(quotient)); + accumulator = next; + } + Ok(json!({ + "challenges": ([beta, gamma, alpha, zeta, v, r].map(scalar)), + "publicAccumulator": scalar(public_accumulator), + "outerL": hex_json(&g1(left.into_affine())), + "outerR": hex_json(&g1(right.into_affine())), + "compositions": compositions, + "quotients": quotients, + })) +} diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs new file mode 100644 index 000000000..bad78f938 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs @@ -0,0 +1,342 @@ +//! Fixed opening layouts and scalar arithmetic for authenticated profiles. + +use super::{KzgConfig, Result, Scalar, System, VerifierProfile}; +use multi_stark::{ + config::ProofConfig, + expr::Source, + traits::{Algebra, Field, Pcs}, +}; + +#[derive(Clone, Copy)] +struct CircuitShape { + height: usize, + // Main, LogUp, quotient, preprocessed. + widths: [usize; 4], + main_next: bool, + preprocessed_next: bool, + lookup_group_size: usize, + omega: Scalar, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct MatrixLayout { + pub(super) width: usize, + pub(super) rows: usize, + pub(super) point_offset: usize, + pub(super) value_offset: usize, + pub(super) groups: [usize; 2], +} + +pub(super) struct OpeningLayout { + pub(super) matrices: Vec, + pub(super) factors: Vec, + pub(super) commitment_count: usize, + pub(super) point_count: usize, + pub(super) field_count: usize, + pub(super) fixed_count: usize, + pub(super) generator_index: usize, + pub(super) coefficient_count: usize, +} + +fn add(a: usize, b: usize) -> Result { + a.checked_add(b).ok_or_else(|| "Solidity opening offset overflow".into()) +} + +fn mul(a: usize, b: usize) -> Result { + a.checked_mul(b).ok_or_else(|| "Solidity opening offset overflow".into()) +} + +fn word_offset(index: usize) -> Result { + mul(add(index, 1)?, 32) +} + +fn log_degree(height: usize) -> Result { + if height < 2 || !height.is_power_of_two() { + return Err( + "Solidity trace height must be a power of two above one".into(), + ); + } + let log = usize::try_from(height.ilog2())?; + if log > 28 { + return Err("Solidity trace log degree exceeds 28".into()); + } + Ok(log) +} + +fn quotient_width(max_degree: usize) -> Result { + (max_degree.max(2) - 1) + .checked_next_power_of_two() + .ok_or_else(|| "Solidity quotient width overflow".into()) +} + +impl OpeningLayout { + pub(super) fn new( + profile: &VerifierProfile, + system: &System, + ) -> Result { + if system.circuits.len() != 2 { + return Err("Solidity opening layout requires two circuits".into()); + } + let mut shapes = Vec::with_capacity(2); + for (ci, circuit) in system.circuits.iter().enumerate() { + let height = profile.heights[ci]; + log_degree(height)?; + let domain = system.config.pcs().natural_domain_for_degree(height); + shapes.push(CircuitShape { + height, + widths: [ + circuit.main_width, + circuit.stage_2_width, + quotient_width(circuit.max_constraint_degree())?, + circuit.preprocessed_width, + ], + main_next: system.opens_next_row(ci, Source::Main), + preprocessed_next: system.opens_next_row(ci, Source::Preprocessed), + lookup_group_size: circuit.lookup_group_size, + omega: domain.generator(), + }); + } + Self::from_shapes(&shapes) + } + + fn from_shapes(shapes: &[CircuitShape]) -> Result { + if shapes.len() != 2 { + return Err("Solidity opening layout requires two circuits".into()); + } + for shape in shapes { + let log = log_degree(shape.height)?; + if shape.widths[1] == 0 + || shape.widths[3] == 0 + || !(1..=8).contains(&shape.lookup_group_size) + { + return Err("invalid Solidity circuit opening shape".into()); + } + if shape.omega.exp_power_of_2(log) != Scalar::ONE + || shape.omega.exp_power_of_2(log - 1) == Scalar::ONE + { + return Err("Solidity domain generator has incorrect order".into()); + } + } + let mut matrices = Vec::with_capacity(8); + let mut factors = Vec::with_capacity(3); + let mut commitment_count = 0; + let mut fixed_count = 0; + let mut field_count = 1; + for round in 0..4 { + for shape in shapes { + let width = shape.widths[round]; + let rows = match round { + 0 => 1 + usize::from(shape.main_next), + 1 => 2, + 2 => 1, + _ => 1 + usize::from(shape.preprocessed_next), + }; + let mut groups = [0; 2]; + // Nonzero zeta preserves equality and first-occurrence order of + // the row factors, including rows belonging to zero-width matrices. + for (row, group) in groups.iter_mut().enumerate().take(rows) { + let factor = if row == 0 { Scalar::ONE } else { shape.omega }; + *group = match factors.iter().position(|&value| value == factor) { + Some(index) => index, + None => { + let index = factors.len(); + factors.push(factor); + index + }, + }; + } + matrices.push(MatrixLayout { + width, + rows, + point_offset: if round == 3 { fixed_count } else { commitment_count }, + value_offset: field_count, + groups, + }); + field_count = add(field_count, mul(width, rows)?)?; + if round == 3 { + fixed_count = add(fixed_count, width)?; + } else { + commitment_count = add(commitment_count, width)?; + } + } + } + if !(2..=3).contains(&factors.len()) { + return Err("invalid Solidity evaluation-point count".into()); + } + let point_count = add(commitment_count, factors.len())?; + for matrix in &mut matrices[6..] { + matrix.point_offset = add(matrix.point_offset, point_count)?; + } + let generator_index = add(point_count, fixed_count)?; + let coefficient_count = add(generator_index, 1)?; + // Include the array length word and the final element's full extent. + word_offset(field_count)?; + word_offset(coefficient_count)?; + word_offset(factors.len())?; + Ok(Self { + matrices, + factors, + commitment_count, + point_count, + field_count, + fixed_count, + generator_index, + coefficient_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::traits::TwoAdicField; + + fn shapes() -> [CircuitShape; 2] { + [ + CircuitShape { + height: 32, + widths: [3, 4, 2, 15], + main_next: false, + preprocessed_next: false, + lookup_group_size: 2, + omega: Scalar::two_adic_generator(5), + }, + CircuitShape { + height: 16, + widths: [1, 1, 1, 4], + main_next: false, + preprocessed_next: false, + lookup_group_size: 1, + omega: Scalar::two_adic_generator(4), + }, + ] + } + + #[test] + fn opening_counts_offsets_and_equal_domains() -> Result<()> { + let mut shapes = shapes(); + let layout = OpeningLayout::from_shapes(&shapes)?; + assert_eq!(layout.field_count, 37); + assert_eq!(layout.commitment_count, 12); + assert_eq!(layout.point_count, 15); + assert_eq!(layout.fixed_count, 19); + assert_eq!(layout.generator_index, 34); + assert_eq!(layout.matrices[6].point_offset, 15); + assert_eq!(layout.matrices[7].value_offset, 33); + shapes[1].height = shapes[0].height; + shapes[1].omega = shapes[0].omega; + let layout = OpeningLayout::from_shapes(&shapes)?; + assert_eq!(layout.factors.len(), 2); + assert_eq!(layout.point_count, 14); + assert_eq!(layout.generator_index, 33); + assert_eq!(layout.matrices[2].groups[1], layout.matrices[3].groups[1]); + Ok(()) + } + + #[test] + fn grouping_preserves_first_occurrence_and_zero_width_rows() -> Result<()> { + let mut shapes = shapes(); + shapes[1].main_next = true; + let layout = OpeningLayout::from_shapes(&shapes)?; + assert_eq!( + layout.factors, + vec![Scalar::ONE, shapes[1].omega, shapes[0].omega] + ); + assert_eq!(layout.matrices[1].groups[1], 1); + assert_eq!(layout.matrices[2].groups[1], 2); + shapes[0].widths[0] = 0; + shapes[0].main_next = true; + let layout = OpeningLayout::from_shapes(&shapes)?; + assert_eq!( + layout.factors, + vec![Scalar::ONE, shapes[0].omega, shapes[1].omega] + ); + assert_eq!( + layout.matrices[0].value_offset, + layout.matrices[1].value_offset + ); + Ok(()) + } + + #[test] + fn grouping_matches_runtime_points_for_all_row_flags() -> Result<()> { + for equal in [false, true] { + for flags in 0..16 { + let mut shapes = shapes(); + if equal { + shapes[1].height = shapes[0].height; + shapes[1].omega = shapes[0].omega; + } + shapes[0].main_next = flags & 1 != 0; + shapes[1].main_next = flags & 2 != 0; + shapes[0].preprocessed_next = flags & 4 != 0; + shapes[1].preprocessed_next = flags & 8 != 0; + let layout = OpeningLayout::from_shapes(&shapes)?; + for zeta in [Scalar::ONE, Scalar::from_u8(7), -Scalar::ONE] { + let mut points = Vec::new(); + for (index, matrix) in layout.matrices.iter().enumerate() { + for (row, &expected_group) in + matrix.groups[..matrix.rows].iter().enumerate() + { + let point = + if row == 0 { zeta } else { zeta * shapes[index % 2].omega }; + let group = match points.iter().position(|&value| value == point) + { + Some(group) => group, + None => { + points.push(point); + points.len() - 1 + }, + }; + assert_eq!(expected_group, group); + } + } + assert_eq!( + points, + layout + .factors + .iter() + .map(|&factor| zeta * factor) + .collect::>() + ); + } + } + } + Ok(()) + } + + #[test] + fn rejects_malformed_and_overflowing_shapes() { + let valid = shapes(); + assert!(OpeningLayout::from_shapes(&valid[..1]).is_err()); + for height in [0, 1, 3, 1 << 29] { + let mut bad = valid; + bad[0].height = height; + assert!(OpeningLayout::from_shapes(&bad).is_err()); + } + for width in [1, 3] { + let mut bad = valid; + bad[0].widths[width] = 0; + assert!(OpeningLayout::from_shapes(&bad).is_err()); + } + for group in [0, 9] { + let mut bad = valid; + bad[0].lookup_group_size = group; + assert!(OpeningLayout::from_shapes(&bad).is_err()); + } + for omega in [Scalar::ZERO, Scalar::ONE, valid[1].omega] { + let mut bad = valid; + bad[0].omega = omega; + assert!(OpeningLayout::from_shapes(&bad).is_err()); + } + for width in [usize::MAX, usize::MAX / 16] { + let mut bad = valid; + bad[0].widths[0] = width; + assert!(OpeningLayout::from_shapes(&bad).is_err()); + } + assert!(quotient_width(usize::MAX).is_err()); + assert!(word_offset(usize::MAX).is_err()); + assert_eq!(quotient_width(0).unwrap(), 1); + assert_eq!(quotient_width(3).unwrap(), 2); + } +} diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk.rs new file mode 100644 index 000000000..f5a33244f --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk.rs @@ -0,0 +1,167 @@ +//! Canonical verifier programs and witnesses for the terminal BLS PLONK circuit. +use super::*; +mod ir; + +fn point(point: G1Affine) -> Json { + if point.is_zero() { + json!({"x": "0x0", "y": "0x0"}) + } else { + json!({"x": hex_json(&fq(point.x)), "y": hex_json(&fq(point.y))}) + } +} + +fn key(point: &G2Affine) -> String { + let mut bytes = vec![]; + point.serialize_compressed(&mut bytes).unwrap(); + hex_json(&bytes) +} + +pub(in crate::pipeline) fn export( + verifier: &Path, + packet: &Path, + trusted_digest: &str, + expected: &Statement, + directory: &Path, +) -> Result<()> { + let bytes = fs::read(verifier)?; + if blake3::hash(&bytes).to_hex().as_str() != trusted_digest { + return Err("trusted verifier digest differs".into()); + } + let profile = VerifierProfile::from_bytes(&bytes)?; + profile.check_setup_class(SetupClass::Filecoin)?; + write_export( + &profile, + &fs::read(packet)?, + expected, + directory, + "witness", + true, + ) +} + +pub(super) fn write_export( + profile: &VerifierProfile, + packet: &[u8], + expected: &Statement, + directory: &Path, + name: &str, + valid_pairing: bool, +) -> Result<()> { + let system = profile.system()?; + if system.config.transcript_kind() != TranscriptKind::Blake3 { + return Err("PLONK wrapping requires a BLAKE3 final KZG profile".into()); + } + profile.check_setup_class(SetupClass::Filecoin)?; + let (claims, offset) = packet_claims(packet, &profile.id(), 2, expected)?; + let logs = profile + .heights + .iter() + .map(|h| u8::try_from(h.ilog2())) + .collect::, _>>()?; + let proof = + FixedProofCodec::new(&system, &logs)?.decode(&packet[offset..])?; + let diagnostic = diagnostics(&system, &proof, &claims)?; + let deferred_bytes = &packet[32 + 18 * 8..offset]; + if check_pairings(deferred_bytes, &profile.keys()?).is_ok() != valid_pairing { + return Err("unexpected deferred pairing result".into()); + } + let layout = codegen::OpeningLayout::new(profile, &system)?; + let program = ir::build(&system, &layout)?; + let params = system.config.pcs().verifier_params(); + let fixed: Vec<_> = system + .preprocessed_commit + .as_ref() + .unwrap() + .0 + .iter() + .flatten() + .copied() + .map(point) + .collect(); + let circuits: Vec<_> = system.circuits.iter().enumerate().map(|(i, c)| { + json!({ + "logDegree": logs[i], + "shape": [c.constraint_count(), c.max_constraint_degree(), profile.heights[i], + c.preprocessed_width, c.main_width, c.stage_2_width, c.lookup_group_size], + }) + }).collect(); + let matrices: Vec<_> = layout + .matrices + .iter() + .map(|m| { + json!({ + "width": m.width, "rows": m.rows, "pointOffset": m.point_offset, + "valueOffset": m.value_offset, + }) + }) + .collect(); + let exported = json!({ + "format": "ix-bls-plonk-profile-v1", + "profileId": hex_json(&profile.id()), + "setupId": hex_json(¶ms.public_setup.id), + "publicMaxDegree": params.public_setup.max_degree, + "prefix": &expected[..10], + "seed": hex_json(system.config.transcript_seed()), + "circuits": circuits, + "matrices": matrices, + "pointCount": layout.point_count, + "fieldCount": layout.field_count, + "witnessOffset": layout.commitment_count, + "openingCount": layout.factors.len(), + "fixed": fixed, + "generator": point(params.g1[0]), + "outerKeys": [key(¶ms.g2), key(¶ms.tau_g2)], + "deferredKeys": profile.keys()?.iter().map(key).collect::>(), + "program": program, + }); + let mut values = vec![proof.intermediate_accumulators[0]]; + for round in [ + &proof.stage_1_opened_values, + &proof.stage_2_opened_values, + &proof.quotient_opened_values, + proof.preprocessed_opened_values.as_ref().unwrap(), + ] { + values.extend(round.iter().flatten().flatten().copied()); + } + if values.len() != layout.field_count { + return Err("exported scalar count differs".into()); + } + let deferred = deferred_bytes + .as_chunks::<48>() + .0 + .iter() + .map(|bytes| G1Affine::deserialize_compressed(bytes.as_slice()).map(point)) + .collect::, _>>()?; + let digest = expected[10..] + .iter() + .map(|word| u32::try_from(*word)) + .collect::, _>>()? + .into_iter() + .flat_map(u32::to_le_bytes) + .collect::>(); + let witness = json!({ + "claimDigest": hex_json(&digest), + "points": proof_points(&proof).into_iter().map(point).collect::>(), + "deferred": deferred, + "values": values.into_iter().map(scalar).collect::>(), + }); + fs::create_dir_all(directory)?; + storage::write_atomic( + &directory.join("profile.json"), + &serde_json::to_vec_pretty(&exported)?, + )?; + storage::write_atomic( + &directory.join(format!("{name}.json")), + &serde_json::to_vec_pretty(&witness)?, + )?; + storage::write_atomic( + &directory.join(format!("{name}-diagnostic.json")), + &serde_json::to_vec_pretty(&diagnostic)?, + )?; + storage::write_atomic(&directory.join(format!("{name}.bin")), packet)?; + storage::write_atomic( + &directory.join(format!("{name}-statement.json")), + &serde_json::to_vec_pretty(expected)?, + )?; + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk/ir.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk/ir.rs new file mode 100644 index 000000000..143fcac73 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk/ir.rs @@ -0,0 +1,268 @@ +use super::*; +use std::{ops, sync::Mutex}; + +#[derive(serde::Serialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub(super) enum Instruction { + Constant { value: String }, + Input { kind: &'static str, index: usize }, + Add { a: usize, b: usize }, + Sub { a: usize, b: usize }, + Mul { a: usize, b: usize }, + Inverse { a: usize }, +} + +#[derive(Default, serde::Serialize)] +pub(super) struct Program { + pub nodes: Vec, + pub assertions: Vec, + pub coefficients: Vec, + pub witnesses: Vec, +} + +#[derive(Clone, Copy)] +pub(super) enum Term<'a> { + Constant(Scalar), + Node(usize, &'a Mutex), +} + +impl<'a> Term<'a> { + fn input( + arena: &'a Mutex, + kind: &'static str, + index: usize, + ) -> Self { + Self::push(arena, Instruction::Input { kind, index }) + } + + fn push(arena: &'a Mutex, instruction: Instruction) -> Self { + let mut program = arena.lock().unwrap(); + let id = program.nodes.len(); + program.nodes.push(instruction); + Self::Node(id, arena) + } + + fn id(self, arena: &'a Mutex) -> usize { + match self { + Self::Node(id, owner) => { + assert!(std::ptr::eq(owner, arena)); + id + }, + Self::Constant(value) => { + let Self::Node(id, _) = + Self::push(arena, Instruction::Constant { value: scalar(value) }) + else { + unreachable!() + }; + id + }, + } + } + + fn binary( + self, + other: Self, + native: impl FnOnce(Scalar, Scalar) -> Scalar, + operation: impl FnOnce(usize, usize) -> Instruction, + ) -> Self { + let arena = match (self, other) { + (Self::Constant(a), Self::Constant(b)) => { + return Self::Constant(native(a, b)); + }, + (Self::Node(_, arena), _) | (_, Self::Node(_, arena)) => arena, + }; + let a = self.id(arena); + let b = other.id(arena); + Self::push(arena, operation(a, b)) + } + + fn inverse(self) -> Self { + match self { + Self::Constant(v) => { + Self::Constant(v.try_inverse().expect("zero constant denominator")) + }, + Self::Node(a, arena) => Self::push(arena, Instruction::Inverse { a }), + } + } + + fn power_of_two(self, log: usize) -> Self { + let mut value = self; + for _ in 0..log { + value *= value; + } + value + } +} + +impl From for Term<'_> { + fn from(value: Scalar) -> Self { + Self::Constant(value) + } +} +impl Algebra for Term<'_> { + const ZERO: Self = Self::Constant(Scalar::ZERO); + const ONE: Self = Self::Constant(Scalar::ONE); +} +impl ops::Add for Term<'_> { + type Output = Self; + fn add(self, rhs: Self) -> Self { + self.binary(rhs, |a, b| a + b, |a, b| Instruction::Add { a, b }) + } +} +impl ops::Sub for Term<'_> { + type Output = Self; + fn sub(self, rhs: Self) -> Self { + self.binary(rhs, |a, b| a - b, |a, b| Instruction::Sub { a, b }) + } +} +impl ops::Mul for Term<'_> { + type Output = Self; + fn mul(self, rhs: Self) -> Self { + self.binary(rhs, |a, b| a * b, |a, b| Instruction::Mul { a, b }) + } +} +impl ops::Mul for Term<'_> { + type Output = Self; + fn mul(self, rhs: Scalar) -> Self { + self * Self::Constant(rhs) + } +} +impl ops::Neg for Term<'_> { + type Output = Self; + fn neg(self) -> Self { + Self::ZERO - self + } +} +impl ops::AddAssign for Term<'_> { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} +impl ops::SubAssign for Term<'_> { + fn sub_assign(&mut self, rhs: Self) { + *self = *self - rhs; + } +} +impl ops::MulAssign for Term<'_> { + fn mul_assign(&mut self, rhs: Self) { + *self = *self * rhs; + } +} + +pub(super) fn build( + system: &System, + layout: &codegen::OpeningLayout, +) -> Result { + let arena = Mutex::new(Program::default()); + let ch: [_; 6] = std::array::from_fn(|i| Term::input(&arena, "challenge", i)); + let values: Vec<_> = + (0..layout.field_count).map(|i| Term::input(&arena, "value", i)).collect(); + let publics: Vec<_> = + (0..23).map(|i| Term::input(&arena, "public", i)).collect(); + let [beta, gamma, alpha, zeta, v, r] = ch; + let mut assertions = Vec::new(); + let mut accumulator = Term::ZERO; + for (i, value) in publics.iter().copied().enumerate() { + let row = [ + Term::Constant(Scalar::from_u8(94)), + Term::ONE, + Term::Constant(Scalar::from_usize(i)), + value, + ]; + let message = + row.into_iter().rev().fold(Term::ZERO, |acc, value| acc * gamma + value); + accumulator += (beta + message).inverse(); + } + // An inverse node enforces the forbidden-challenge rejection even if its output is unused. + let _ = zeta.inverse(); + for (i, c) in system.circuits.iter().enumerate() { + let graph = + GraphProgram::compile(&c.graph, GraphInputs::for_circuit(system, i)?)?; + let height = c.preprocessed_height; + let domain = system.config.pcs().natural_domain_for_degree(height); + let omega = domain.generator(); + let vanishing = zeta.power_of_two(height.ilog2() as usize) - Term::ONE; + let _ = vanishing.inverse(); + let first = vanishing * (zeta - Term::ONE).inverse(); + let last = vanishing * (zeta * omega - Term::ONE).inverse() * omega; + let next = if i == 0 { values[0] } else { Term::ZERO }; + let cp = [beta, gamma, accumulator, next]; + let rows = |round: usize| { + let m = &layout.matrices[round * 2 + i]; + let current = &values[m.value_offset..m.value_offset + m.width]; + let following = if m.rows == 2 { + &values[m.value_offset + m.width..m.value_offset + 2 * m.width] + } else { + &[] + }; + [current, following] + }; + let stage2 = rows(1); + let nodes = graph.evaluate( + |matrix, offset| values[layout.matrices[matrix].value_offset + offset], + &cp, + first, + last, + zeta - Term::Constant(omega.inverse()), + ); + let mut constraints = c.graph.constraint_values(&nodes); + let ext = multi_stark::system::extension_params::(); + if ext.degree != 1 { + return Err( + "PLONK scalar export requires a native-field KZG proof".into(), + ); + } + let delta = + (next - accumulator) * (Scalar::from_usize(height) * omega).inverse(); + multi_stark::lookup::logup_constraint_values( + &c.graph.lookups, + &nodes, + stage2[0], + stage2[1], + &cp, + &[delta], + last, + ext.w, + ext.degree, + c.lookup_group_size.max(1), + &mut constraints, + ); + let composition = constraints + .into_iter() + .fold(Term::ZERO, |acc, value| acc * alpha + value); + let zn = vanishing + Term::ONE; + let quotient = + rows(2)[0].iter().rev().fold(Term::ZERO, |acc, value| acc * zn + *value); + assertions.push((composition - vanishing * quotient).id(&arena)); + accumulator = next; + } + let mut coefficients = vec![Term::ZERO; layout.coefficient_count]; + let mut witnesses = vec![]; + let mut rp = Term::ONE; + for (group, factor) in layout.factors.iter().enumerate() { + let mut vp = Term::ONE; + let mut y = Term::ZERO; + for matrix in &layout.matrices { + for (row, &g) in matrix.groups[..matrix.rows].iter().enumerate() { + if g != group { + continue; + } + for column in 0..matrix.width { + coefficients[matrix.point_offset + column] += rp * vp; + y += values[matrix.value_offset + row * matrix.width + column] * vp; + vp *= v; + } + } + } + coefficients[layout.generator_index] -= rp * y; + coefficients[layout.commitment_count + group] += rp * zeta * *factor; + witnesses.push(rp.id(&arena)); + rp *= r; + } + let coefficients = coefficients.into_iter().map(|v| v.id(&arena)).collect(); + let mut program = arena.into_inner().unwrap(); + program.assertions = assertions; + program.coefficients = coefficients; + program.witnesses = witnesses; + Ok(program) +} diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index e8ccbb032..ce99c22e3 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -244,9 +244,9 @@ pub(crate) fn check( mod tests { use super::*; use crate::kzg::Srs; - use crate::recursive::native_verifier::LayoutPolicy; use crate::pipeline::init_claim; use crate::pipeline::setup::SetupSource; + use crate::recursive::native_verifier::LayoutPolicy; use multi_stark::traits::Algebra; use multi_stark::{ expr::Expr, diff --git a/flake.nix b/flake.nix index c6c982c91..0ba7c0a4a 100644 --- a/flake.nix +++ b/flake.nix @@ -97,6 +97,35 @@ sha256 = "sha256-p8h3Sl/YRByZfZTAKXdsvF6xEenXKrXSVvpphmZENH4="; }; + # gnark requires the 1.26.8 patch release; the newer pinned package + # set supplies its matching bootstrap and platform patches. + plonkGo = cudaPkgs.go_1_26.overrideAttrs (_: { + version = "1.26.8"; + src = cudaPkgs.fetchurl { + url = "https://go.dev/dl/go1.26.8.src.tar.gz"; + hash = "sha256-Tjm5jkL5RvoFrIvFtxh335fb23y7Gnd7VBZnrXEX/S4="; + }; + }); + plonkSources = pkgs.lib.fileset.fileFilter ( + file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" + || file.name == "go.mod" || file.name == "go.sum" + ) ./tools/bls-plonk-wrapper; + plonkHelper = (cudaPkgs.buildGoModule.override { go = plonkGo; }) { + pname = "ix-bls-plonk-wrapper"; + version = "0.1.0"; + src = pkgs.lib.fileset.toSource { + root = ./tools/bls-plonk-wrapper; + fileset = plonkSources; + }; + vendorHash = "sha256-bmRyQn7iMLGRATQStghs1KmlXwln70Sxzu5oSWR5pRU="; + subPackages = [ "." ]; + env.CGO_ENABLED = "0"; + env.GOTOOLCHAIN = "local"; + postInstall = '' + mv "$out/bin/bls-plonk-wrapper" "$out/bin/ix-bls-plonk-wrapper" + ''; + }; + # Rust package craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; # The Cargo files the workspace builds from. @@ -231,6 +260,7 @@ # What its Rust archive target traces, as Crane sees it. cargoSource compressionData + plonkSources ]; }; lakeManifest = builtins.fromJSON (builtins.readFile ./lake-manifest.json); @@ -261,6 +291,7 @@ mkLakeBuildArgs = rustLib: { inherit lakeDeps; src = leanSrc; + IX_PLONK_PREBUILT = "${plonkHelper}/bin/ix-bls-plonk-wrapper"; # Don't build the `ix_rs` static lib with Lake, since we build it with Crane postPatch = '' substituteInPlace lakefile.lean --replace-fail 'proc { cmd := "cargo"' '--proc { cmd := "cargo"' @@ -358,6 +389,7 @@ packages = { default = ixLib; ix = ixCLI; + plonk-wrapper = plonkHelper; # `checks` are built by `nix flake check`; exposing this derivation # as a package keeps the ignored suite available on demand without # adding it to the default check set. @@ -375,6 +407,7 @@ }; checks = { + plonk-wrapper = plonkHelper; # Lint the host workspace; warnings are errors. clippy = craneLib.cargoClippy ( craneArgs @@ -425,6 +458,7 @@ lean cargo-deny valgrind + plonkGo ]; }; } @@ -448,6 +482,7 @@ lean cargo-deny valgrind + plonkGo ]) ++ [ cudaToolkit ]; shellHook = '' diff --git a/lakefile.lean b/lakefile.lean index 4629c04f9..e60afe849 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -203,6 +203,7 @@ target ix_compression_worker pkg : FilePath := do "--bin", "ix-compression-worker"] unless features.contains "parallel" do args := args.push "--no-default-features" + args := args ++ #["--features", "plonk"] let mut before := none if usesCuda features then args := args ++ #["--features", "cuda"] @@ -211,6 +212,33 @@ target ix_compression_worker pkg : FilePath := do (pkg.dir / "target" / "release" / "ix-compression-worker") (pkg.buildDir / "bin" / "ix-compression-worker") before +/-- The terminal prover is a native Go executable with a pinned module graph. -/ +target ix_plonk_wrapper pkg : FilePath := do + let sources ← inputDir (pkg.dir / "tools/bls-plonk-wrapper") true fun path => + path.extension == some "go" || path.extension == some "json" || + path.extension == some "bin" || path.fileName == "go.mod" || path.fileName == "go.sum" + let output := pkg.buildDir / "bin" / "ix-bls-plonk-wrapper" + let prebuilt ← IO.getEnv "IX_PLONK_PREBUILT" + sources.mapM fun _ => do + let compiler ← match prebuilt with + | some path => pure path + | none => (·.stdout) <$> captureProc' { cmd := "go", args := #["version"] } + addPureTrace (prebuilt, compiler) s!"PLONK helper compiler: {compiler}" + buildUnlessUpToDate #[output] (← getTrace) (output.addExtension "trace") do + match prebuilt with + | some path => copyNativeArtifact (FilePath.mk path) output "plonk" + | none => + IO.FS.createDirAll output.parent.get! + let temporary := ((← IO.FS.realPath output.parent.get!) / "ix-bls-plonk-wrapper").addExtension "tmp" + proc { + cmd := "go" + args := #["build", "-buildvcs=false", "-trimpath", "-mod=readonly", "-o", temporary.toString, "."] + cwd := pkg.dir / "tools/bls-plonk-wrapper" + } + IO.FS.rename temporary output + setTrace (← computeTrace output) + return output + /-- Cargo feature variants wait for the default archive before building. -/ target ix_rs_test pkg : FilePath := do buildRustStatic pkg (← cargoArgs (testFfi := true)) "test" (some (← ix_rs.fetch)) @@ -244,7 +272,7 @@ lean_lib Ix where lean_exe ix where root := `Main supportInterpreter := true - needs := #[`@/ix_compression_worker] + needs := #[`@/ix_compression_worker, `@/ix_plonk_wrapper] moreLinkObjs := #[ix_rs_net] section Tests @@ -397,16 +425,20 @@ script install := do eprintln "Target directory can't be empty."; return 1 pure ⟨input⟩ - -- Copy the ix binary into the target directory - let tgtPath := binDir / "ix" - let srcBytes ← FS.readBinFile $ ".lake" / "build" / "bin" / "ix" - FS.writeBinFile tgtPath srcBytes - - -- Set access rights for the newly created file let fullAccess := { read := true, write := true, execution := true } let noWriteAccess := { fullAccess with write := false } let fileRight := { user := fullAccess, group := fullAccess, other := noWriteAccess } - setAccessRights tgtPath fileRight + FS.createDirAll binDir + -- Compression resolves its worker and terminal prover beside the CLI. + for name in ["ix", "ix-compression-worker", "ix-bls-plonk-wrapper"] do + let destination := binDir / name + FS.writeBinFile destination (← FS.readBinFile $ ".lake" / "build" / "bin" / name) + setAccessRights destination fileRight + let cudaLibrary : FilePath := ".lake" / "build" / "lib" / cudaLibraryName + if ← cudaLibrary.pathExists then + let libraryDir := binDir.parent.getD "." / "lib" + FS.createDirAll libraryDir + FS.writeBinFile (libraryDir / cudaLibraryName) (← FS.readBinFile cudaLibrary) return 0 script "get-exe-targets" := do diff --git a/tools/bls-plonk-wrapper/README.md b/tools/bls-plonk-wrapper/README.md new file mode 100644 index 000000000..301af3139 --- /dev/null +++ b/tools/bls-plonk-wrapper/README.md @@ -0,0 +1,154 @@ +# BLS12-381 PLONK wrapper + +This CPU helper proves verification of an ix BLAKE3 KZG packet, including its +outer opening equation and its deferred pairing equation. Its only public +inputs are the expected claim digest, packed as two little-endian 128-bit +limbs. The trusted circuit fixes the inner profile and aggregation-program +prefix. + +The implementation is experimental. The checked-in outer packets are small +conformance circuits, not aggregate Lean proofs. Production verification keys +need review of the complete circuit and its measurements before authorization. + +## Build and use through ix + +Go 1.26.8 or newer is required for source builds. `lake build ix` builds the +native helper beside ix and its compression worker; `lake run install` installs +all three. `nix build .#ix` packages them, and `nix build .#plonk-wrapper` builds +and tests only this helper. Runtime proving and verification do not need Go or +Cargo installed. + +```sh +export IX_KZG_FILECOIN_CACHE=/path/to/filecoin-prefix-2p27.bin +ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output +``` + +The existing ix setup configuration owns Filecoin selection and authentication. +`IX_KZG_FILECOIN_MANIFEST` selects an explicitly pinned cache receipt when using +a cache other than the standard `2^27` cache. Terminal PLONK rejects `--dev-srs`. +Key generation and Lagrange conversion are deterministic operations on these +public powers; they do not require a circuit-specific trusted ceremony. + +The output's `plonk` directory contains the native proof, Solidity proof bytes, +generated verifier, public key and a candidate verifier catalog. `ix compress` +preserves the claim and stores a new proof address. Override the helper location +with `IX_PLONK_HELPER` if it is not beside the CLI/worker. + +## Authorize verification keys independently + +Set `IX_PLONK_VERIFIER_CATALOG` to an independently trusted JSON catalog before +verifying a PLONK proof with `ix verify`. Generating a proof does not activate +its candidate catalog. Approving a key approves the circuit and fixed program +prefix represented by that key. + +The generated `verifier-catalog.candidate.json` has the catalog schema and uses +a path relative to its directory for `verifying-key.bin`. It records the key's +SHA-256, inner profile, program prefix, circuit hash and helper hash. Review and +install accepted entries as trusted application configuration. Proof envelopes +carry only a key identifier and proof bytes; they cannot supply a trusted key. + +Verification checks the selected key's digest and Filecoin G1/G2 anchors and +derives the expected public digest from the independently supplied ix claim. +It requires no proving SRS or GPU. + +## Standalone operations + +`ix-compression-worker plonk export` authenticates a portable BLAKE3 profile, +verifies its packet against an independently expected statement, and exports +the fixed scalar program and canonical witness: + +```sh +ix-compression-worker plonk export \ + verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input +ix-bls-plonk-wrapper compile input/profile.json artifacts +ix-bls-plonk-wrapper check-witness artifacts input/witness.json +``` + +`check-witness` is an assignment diagnostic, not a proof. It uses diagnostic +commitment challenges. Production `prove` uses gnark's real KZG commitment +computation and verifies the resulting PLONK proof before publishing it. + +Read the padded `Domain` from `artifacts/manifest.json`, then export the required +parameters through ix: + +```sh +ix-compression-worker plonk srs DOMAIN parameters.bin +ix-bls-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 +ix-bls-plonk-wrapper prove artifacts input/witness.json proof-output +ix-bls-plonk-wrapper verify \ + artifacts/verifying-key.bin TRUSTED_KEY_SHA256 \ + proof-output/proof.bin EXPECTED_CLAIM_DIGEST +``` + +The parameter export receipt is written beside `parameters.bin` as +`parameters.json`. Obtain `TRUSTED_EXPORT_BLAKE3` from that authenticated Rust +operation, independently of an untrusted parameter file. Gnark requires `N+3` +monomial powers and `N` Lagrange points; the adapter requests the next power of +two from ix and retains the ceremony's full public degree. Its current maximum +PLONK domain is `2^26` with the provisioned `2^27` cache. + +Circuit and key artifacts bind the profile, executable and parameter receipt. +The helper checks hashes before reuse. Lagrange points are cached separately +by setup, domain, receipt and helper identity; `IX_PLONK_LAGRANGE_CACHE` can +select the shared directory. A changed executable needs a new artifact +directory. Corrupt artifacts fail closed. + +## Tests and sizing + +```sh +cd tools/bls-plonk-wrapper +go test ./... +IX_PLONK_SIZE=1 go test -run TestComponentSizing -v +``` + +The ordinary tests cover BLAKE3 block/chunk/XOF boundaries, canonical curve +encodings, subgroup rejection, complete verifier witnesses, cached-parameter +integrity, basis conversion and small PLONK proofs using real Filecoin powers. +The small PLONK backend proof tests exercise `N=8`, separately from the full +verifier circuit. + +The measured full conformance verifier has 20,665,582 constraints, a `2^25` +domain and one extra committed polynomial. Point validation and public binding +account for about 3.21 million constraints, BLAKE3 adds 6.29 million, scalar +identities add 441, opening MSMs add 7.77 million, and the pairing checks add +about 1.70 million each. These are cumulative-compilation differences; shared +range checks can affect component accounting. + +Both pairing checks remain separate. Even eliminating the second check for +free would leave more than `2^24` constraints, so pairing batching cannot +reduce the current padded domain. The standard gnark MSM path is retained: +the tested alternative increased the full count to 25,429,821. + +The backend is native CPU gnark at `262cf453c107d147a228b6b7418c5cf791c3b79b`. +Upstream experimental WebGPU/WASM PLONK acceleration exists, but this helper +does not activate it. The packaged native ICICLE/CUDA integration is for +Groth16. + +## Full Init proof measurement + +A full Filecoin/BLAKE3 Init packet was wrapped and verified on October 16, +2026, using 48 logical CPUs of a Xeon Platinum 8559C and 499 GiB RAM. The +actual Init wrapper has 21,184,618 constraints, a `2^25` domain, two public +inputs and one extra commitment. + +With keys prepared, `plonk.Prove` took **127.17 seconds**; the fresh prove +process took **293.87 seconds**, including artifact authentication/loading, +verification and output. Peak prover RSS was **53.63 GiB**. In-memory native +verification took **2.75 ms**. Across seven fresh verifier processes, +decode/public-witness/verification took a median **4.20 ms** and complete +process wall time was **25.51 ms**. Wrong claims and a modified proof were +rejected. + +The native proof is **744 bytes**, its ix envelope **784 bytes**, and the +Solidity encoding **1,184 bytes**. Solidity transaction gas was not measured. +One-time parameter/key preparation took **20m 17s**, including **18m 59s** +for Lagrange conversion and cache publication. Those costs, compilation, +SRS export and inner KZG proving are excluded from the proving figure. + +The matching circuit, keys, parameter representations, frozen tools and proof +are archived in `s3://argument-kzg-params-063002298335-us-east-1-an/` +(`us-east-1`) under +`bls12-381/plonk/init/2p25/73d8ebbad3df955310fcf7c8b7d35cf04f6ecd5be513f55af25036b7349e5299/`. +Its `MANIFEST.json` has SHA-256 +`6be357f686599d283acca68e6eb4a9aa9f29c31d0010e7c8190f5767a04daaee`; the +archive's `verify_bundle.py --manifest-sha256` authenticates a restored copy. diff --git a/tools/bls-plonk-wrapper/artifacts.go b/tools/bls-plonk-wrapper/artifacts.go new file mode 100644 index 000000000..0897b5651 --- /dev/null +++ b/tools/bls-plonk-wrapper/artifacts.go @@ -0,0 +1,172 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +type artifact struct { + SHA256 string + Bytes int64 +} +type manifest struct { + Format, ExecutableSHA256, ProfileSHA256 string + SRSBLAKE3 string + KeysReady bool + Domain uint64 + Constraints, PublicInputs, Commitments int + Files map[string]artifact + Seconds map[string]float64 +} + +func fileHash(path string) (artifact, error) { + f, err := os.Open(path) + if err != nil { + return artifact{}, err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, f) + return artifact{SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: n}, err +} + +func atomicFile(path string, write func(io.Writer) error) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".plonk-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if err := write(f); err != nil { + return err + } + if err := f.Sync(); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(f.Name(), path); err != nil { + return err + } + d, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +func writeJSON(path string, value any) error { + return atomicFile(path, func(w io.Writer) error { enc := json.NewEncoder(w); enc.SetIndent("", " "); return enc.Encode(value) }) +} + +func saveBinary(dir, name string, value io.WriterTo, m *manifest) error { + path := filepath.Join(dir, name) + if err := atomicFile(path, func(w io.Writer) error { _, err := value.WriteTo(w); return err }); err != nil { + return err + } + entry, err := fileHash(path) + if err != nil { + return err + } + m.Files[name] = entry + return nil +} + +func (m *manifest) checkFile(dir, name string) error { + want, ok := m.Files[name] + if !ok { + return fmt.Errorf("artifact %s missing from manifest", name) + } + got, err := fileHash(filepath.Join(dir, name)) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("artifact %s checksum or size differs", name) + } + return nil +} + +func loadManifest(dir string) (*manifest, error) { + var m manifest + if err := decodeJSON(filepath.Join(dir, "manifest.json"), &m); err != nil { + return nil, err + } + if m.Format != "ix-bls-plonk-artifacts-v1" || m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil || m.Seconds == nil { + return nil, fmt.Errorf("invalid wrapper artifact manifest") + } + if m.KeysReady { + if _, err := unhex(m.SRSBLAKE3, 32); err != nil { + return nil, fmt.Errorf("invalid parameter receipt: %w", err) + } + } + executable, err := os.Executable() + if err != nil { + return nil, err + } + identity, err := fileHash(executable) + if err != nil { + return nil, err + } + if identity.SHA256 != m.ExecutableSHA256 { + return nil, fmt.Errorf("wrapper executable changed; recompile circuit and keys") + } + if err := m.checkFile(dir, "profile.json"); err != nil { + return nil, err + } + if m.Files["profile.json"].SHA256 != m.ProfileSHA256 { + return nil, fmt.Errorf("profile identity differs") + } + return &m, nil +} + +func executableHash() (string, error) { + path, err := os.Executable() + if err != nil { + return "", err + } + entry, err := fileHash(path) + return entry.SHA256, err +} + +func (m *manifest) checkKeys(dir string) error { + if !m.KeysReady { + return fmt.Errorf("PLONK keys are not prepared") + } + for _, name := range []string{"proving-key.bin", "verifying-key.bin", "PlonkVerifier.sol"} { + if err := m.checkFile(dir, name); err != nil { + return err + } + } + return nil +} + +func readBinary(path string, value io.ReaderFrom) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + n, err := value.ReadFrom(f) + if err != nil { + return err + } + stat, err := f.Stat() + if err != nil { + return err + } + if n != stat.Size() { + return fmt.Errorf("trailing binary data in %s", path) + } + return nil +} diff --git a/tools/bls-plonk-wrapper/backend.go b/tools/bls-plonk-wrapper/backend.go new file mode 100644 index 000000000..b7ae60063 --- /dev/null +++ b/tools/bls-plonk-wrapper/backend.go @@ -0,0 +1,340 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime/debug" + "strings" + "time" + + "github.com/consensys/gnark-crypto/ecc" + bls "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + plonkbls "github.com/consensys/gnark/backend/plonk/bls12-381" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/zeebo/blake3" +) + +// readSRS consumes ix's authenticated export. The expected digest comes from +// the Rust parameter loader, independently of this file's contents. +func readSRS(path, expectedDigest string, domain uint64, p *profile) (*kzg.SRS, error) { + expected, err := unhex(expectedDigest, 32) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + h := blake3.New() + if _, err := io.Copy(h, f); err != nil { + return nil, err + } + if !bytes.Equal(h.Sum(nil), expected) { + return nil, fmt.Errorf("SRS export differs from the Rust loader's receipt") + } + if _, err := f.Seek(0, 0); err != nil { + return nil, err + } + r := bufio.NewReaderSize(f, 1<<20) + var header [88]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, err + } + setup, err := unhex(p.SetupID, 32) + if err != nil { + return nil, err + } + count := binary.LittleEndian.Uint64(header[80:]) + if string(header[:8]) != "IXPSRS01" || !bytes.Equal(header[8:40], setup) || binary.LittleEndian.Uint64(header[40:48]) != p.PublicMaxDegree || count != domain+3 || domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || count-1 > p.PublicMaxDegree { + return nil, fmt.Errorf("SRS identity, degree or required power count differs") + } + stat, err := f.Stat() + if err != nil { + return nil, err + } + if stat.Size() != int64(280+96*count) { + return nil, fmt.Errorf("invalid SRS export length") + } + srs := &kzg.SRS{} + for i := range srs.Vk.G2 { + var point [96]byte + if _, err := io.ReadFull(r, point[:]); err != nil { + return nil, err + } + if _, err := srs.Vk.G2[i].SetBytes(point[:]); err != nil { + return nil, err + } + srs.Vk.Lines[i] = bls.PrecomputeLines(srs.Vk.G2[i]) + } + anchors, err := g2Keys(p.OuterKeys) + if err != nil { + return nil, err + } + if !srs.Vk.G2[0].Equal(&anchors[0]) || !srs.Vk.G2[1].Equal(&anchors[1]) { + return nil, fmt.Errorf("SRS G2 anchors differ") + } + srs.Pk.G1, err = readAffinePoints(r, count) + if err != nil { + return nil, err + } + srs.Vk.G1 = srs.Pk.G1[0] + g, err := p.Generator.native() + if err != nil { + return nil, err + } + if !srs.Vk.G1.Equal(&g) { + return nil, fmt.Errorf("SRS G1 anchor differs") + } + return srs, nil +} + +func prepare(dir, path, digest string) error { + decodedDigest, err := unhex(digest, 32) + if err != nil { + return err + } + digest = hex.EncodeToString(decodedDigest) + metadata, err := loadManifest(dir) + if err != nil { + return err + } + if metadata.KeysReady { + if metadata.SRSBLAKE3 != digest { + return fmt.Errorf("prepared keys belong to a different SRS export") + } + if err := metadata.checkKeys(dir); err != nil { + return err + } + fmt.Printf("reused_keys=true verifying_key_sha256=%s\n", metadata.Files["verifying-key.bin"].SHA256) + return nil + } + m := metadata + if err := m.checkFile(dir, "circuit.bin"); err != nil { + return err + } + p, err := loadProfile(filepath.Join(dir, "profile.json")) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "stage=srs_import domain=%d\n", m.Domain) + start := time.Now() + srs, err := readSRS(path, digest, m.Domain, p) + if err != nil { + return err + } + m.Seconds["srsImport"] = time.Since(start).Seconds() + start = time.Now() + cacheRoot := os.Getenv("IX_PLONK_LAGRANGE_CACHE") + if cacheRoot == "" { + cacheRoot = filepath.Join(filepath.Dir(dir), "lagrange") + } + fmt.Fprintln(os.Stderr, "stage=lagrange") + lagrangeSRS, reused, err := lagrangeParameters(cacheRoot, srs, m.Domain, p.SetupID, digest, m.ExecutableSHA256) + if err != nil { + return err + } + m.Seconds["lagrange"] = time.Since(start).Seconds() + // The group FFT's temporary arrays must not overlap circuit deserialization. + debug.FreeOSMemory() + fmt.Fprintln(os.Stderr, "stage=load_circuit") + _, ccs, _, err := loadCircuit(dir) + if err != nil { + return err + } + fmt.Fprintln(os.Stderr, "stage=index") + start = time.Now() + pk, vk, err := plonk.Setup(ccs, srs, lagrangeSRS) + if err != nil { + return err + } + m.Seconds["index"] = time.Since(start).Seconds() + m.Commitments = len(vk.(*plonkbls.VerifyingKey).Qcp) + debug.FreeOSMemory() + fmt.Fprintln(os.Stderr, "stage=write_keys") + if err := saveBinary(dir, "proving-key.bin", pk, m); err != nil { + return err + } + if err := saveBinary(dir, "verifying-key.bin", vk, m); err != nil { + return err + } + if err := atomicFile(filepath.Join(dir, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }); err != nil { + return err + } + m.Files["PlonkVerifier.sol"], err = fileHash(filepath.Join(dir, "PlonkVerifier.sol")) + if err != nil { + return err + } + m.SRSBLAKE3, m.KeysReady = digest, true + if err := writeJSON(filepath.Join(dir, "manifest.json"), m); err != nil { + return err + } + fmt.Printf("domain=%d extra_commitments=%d reused_lagrange=%t verifying_key_sha256=%s\n", m.Domain, m.Commitments, reused, m.Files["verifying-key.bin"].SHA256) + return nil +} + +func prove(dir, path, out string) error { + p, ccs, m, err := loadCircuit(dir) + if err != nil { + return err + } + if err := m.checkKeys(dir); err != nil { + return err + } + pk := plonk.NewProvingKey(ecc.BLS12_381) + if err := readBinary(filepath.Join(dir, "proving-key.bin"), pk); err != nil { + return err + } + vk := plonk.NewVerifyingKey(ecc.BLS12_381) + if err := readBinary(filepath.Join(dir, "verifying-key.bin"), vk); err != nil { + return err + } + assignment, err := witnessAssignment(p, path) + if err != nil { + return err + } + w, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + if err != nil { + return err + } + start := time.Now() + proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + if err != nil { + return err + } + seconds := time.Since(start).Seconds() + public, err := w.Public() + if err != nil { + return err + } + start = time.Now() + if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + return err + } + verifySeconds := time.Since(start).Seconds() + if err := atomicFile(filepath.Join(out, "proof.bin"), func(w io.Writer) error { _, err := proof.WriteTo(w); return err }); err != nil { + return err + } + solidityBytes := proof.(*plonkbls.Proof).MarshalSolidity() + if err := atomicFile(filepath.Join(out, "proof.solidity.bin"), func(w io.Writer) error { _, err := w.Write(solidityBytes); return err }); err != nil { + return err + } + report := map[string]any{"proveSeconds": seconds, "verifySeconds": verifySeconds, "solidityBytes": len(solidityBytes), "verifyingKeySHA256": m.Files["verifying-key.bin"].SHA256} + if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { + return err + } + fmt.Printf("prove_seconds=%.3f verify_seconds=%.6f solidity_bytes=%d\n", seconds, verifySeconds, len(solidityBytes)) + return nil +} + +func decodeProof(data []byte, commitments int) (*plonkbls.Proof, error) { + if commitments < 0 || commitments > 1024 || len(data) != 664+80*commitments { + return nil, fmt.Errorf("invalid PLONK proof length") + } + // Bound the two serialized vector lengths before gnark allocates them. + if binary.BigEndian.Uint32(data[384:388]) != uint32(6+commitments) || binary.BigEndian.Uint32(data[660+32*commitments:664+32*commitments]) != uint32(commitments) { + return nil, fmt.Errorf("invalid PLONK proof vector length") + } + for i := 0; i < 8; i++ { + if data[48*i]&0x80 == 0 { + return nil, fmt.Errorf("uncompressed proof point") + } + } + if data[580+32*commitments]&0x80 == 0 { + return nil, fmt.Errorf("uncompressed shifted opening") + } + for i := 0; i < commitments; i++ { + if data[664+32*commitments+48*i]&0x80 == 0 { + return nil, fmt.Errorf("uncompressed committed point") + } + } + proof := new(plonkbls.Proof) + r := bytes.NewReader(data) + if _, err := proof.ReadFrom(r); err != nil { + return nil, err + } + var canonical bytes.Buffer + if _, err := proof.WriteTo(&canonical); err != nil { + return nil, err + } + if r.Len() != 0 || !bytes.Equal(canonical.Bytes(), data) { + return nil, fmt.Errorf("noncanonical proof") + } + return proof, nil +} + +type publicClaim struct { + Claim [2]frontend.Variable `gnark:",public"` +} + +func (*publicClaim) Define(frontend.API) error { return nil } + +func verify(keyPath, trustedDigest, proofPath, expectedClaim string, expectedAnchors ...string) error { + key, err := fileHash(keyPath) + if err != nil { + return err + } + if key.SHA256 != strings.TrimPrefix(trustedDigest, "0x") { + return fmt.Errorf("trusted wrapper verification key digest differs") + } + vk := new(plonkbls.VerifyingKey) + if err := readBinary(keyPath, vk); err != nil { + return err + } + if vk.NbPublicVariables != 2 { + return fmt.Errorf("wrapper key has wrong public-input schema") + } + if len(expectedAnchors) > 1 { + return fmt.Errorf("expected one anchor encoding") + } + if len(expectedAnchors) == 1 { + expected, err := unhex(expectedAnchors[0], 240) + if err != nil { + return err + } + g := vk.Kzg.G1.Bytes() + h := vk.Kzg.G2[0].Bytes() + tauH := vk.Kzg.G2[1].Bytes() + actual := append(append(append([]byte(nil), g[:]...), h[:]...), tauH[:]...) + if !bytes.Equal(actual, expected) { + return fmt.Errorf("wrapper verification key setup anchors differ") + } + } + digest, err := unhex(expectedClaim, 32) + if err != nil { + return err + } + f, err := os.Open(proofPath) + if err != nil { + return err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, 1<<20)) + if err != nil { + return err + } + start := time.Now() + proof, err := decodeProof(data, len(vk.Qcp)) + if err != nil { + return err + } + w, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, ecc.BLS12_381.ScalarField(), frontend.PublicOnly()) + if err != nil { + return err + } + if err := plonk.Verify(proof, vk, w, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + return err + } + fmt.Printf("verified claim=%s verify_seconds=%.6f\n", hex.EncodeToString(digest), time.Since(start).Seconds()) + return nil +} diff --git a/tools/bls-plonk-wrapper/backend_test.go b/tools/bls-plonk-wrapper/backend_test.go new file mode 100644 index 000000000..0e4194a3b --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + plonkbls "github.com/consensys/gnark/backend/plonk/bls12-381" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +type backendCircuit struct { + Claim [2]frontend.Variable `gnark:",public"` + X frontend.Variable + Commit bool `gnark:"-"` +} + +func (c *backendCircuit) Define(api frontend.API) error { + api.AssertIsEqual(api.Mul(c.X, c.X), c.Claim[0]) + api.AssertIsEqual(c.Claim[1], 1) + if c.Commit { + challenge, err := api.(frontend.Committer).Commit(c.X) + if err != nil { + return err + } + api.AssertIsDifferent(challenge, 0) + } + return nil +} + +func TestFilecoinBackendAndProofCodec(t *testing.T) { + p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + if err != nil { + t.Fatal(err) + } + var receipt struct { + Format, Blake3, SetupID, FilecoinManifest string + PublicMaxDegree, Powers uint64 + } + if err := decodeJSON(filepath.Join("testdata", "srs.json"), &receipt); err != nil { + t.Fatal(err) + } + srsPath := filepath.Join("testdata", "srs.bin") + srs, err := readSRS(srsPath, receipt.Blake3, 128, p) + if err != nil { + t.Fatal(err) + } + if _, err := readSRS(srsPath, receipt.Blake3, 64, p); err == nil { + t.Fatal("accepted wrong power count") + } + if _, err := readSRS(srsPath, string(bytes.Repeat([]byte{'0'}, 64)), 128, p); err == nil { + t.Fatal("accepted wrong export receipt") + } + for _, commit := range []bool{false, true} { + ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: commit}) + if err != nil { + t.Fatal(err) + } + n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + 2)) + if n > 128 { + t.Fatalf("tiny circuit exceeded fixture SRS: %d", n) + } + lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) + if err != nil { + t.Fatal(err) + } + pk, vk, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) + if err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7}, ecc.BLS12_381.ScalarField()) + if err != nil { + t.Fatal(err) + } + proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + if err != nil { + t.Fatal(err) + } + public, _ := w.Public() + if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + proof.WriteTo(&encoded) + m := len(vk.(*plonkbls.VerifyingKey).Qcp) + decoded, err := decodeProof(encoded.Bytes(), m) + if err != nil { + t.Fatal(err) + } + if err := plonk.Verify(decoded, vk, public); err != nil { + t.Fatal(err) + } + for _, offset := range []int{0, 384, 660 + 32*m} { + bad := append([]byte(nil), encoded.Bytes()...) + bad[offset] = 0xff + if _, err := decodeProof(bad, m); err == nil { + t.Fatalf("accepted malformed proof at %d", offset) + } + } + if _, err := decodeProof(append(encoded.Bytes(), 0), m); err == nil { + t.Fatal("accepted trailing proof bytes") + } + if commit { + dir := os.Getenv("IX_PLONK_TEST_OUTPUT") + if dir == "" { + dir = t.TempDir() + } + keyPath := filepath.Join(dir, "verifying-key.bin") + if err := atomicFile(keyPath, func(w io.Writer) error { _, err := vk.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + keyHash, _ := fileHash(keyPath) + proofPath := filepath.Join(dir, "proof.bin") + if err := atomicFile(proofPath, func(w io.Writer) error { _, err := w.Write(encoded.Bytes()); return err }); err != nil { + t.Fatal(err) + } + var digest [32]byte + digest[0], digest[16] = 49, 1 + if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err != nil { + t.Fatal(err) + } + digest[31] = 1 + if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err == nil { + t.Fatal("accepted changed public digest") + } + digest[31] = 0 + if err := atomicFile(filepath.Join(dir, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }); err != nil { + t.Fatal(err) + } + payload := proof.(*plonkbls.Proof).MarshalSolidity() + if len(payload) != 1056+128*m { + t.Fatal("unexpected Solidity proof length") + } + vector := map[string]any{"scope": "tiny-backend-test-not-an-ix-wrapper", "proof": "0x" + hex.EncodeToString(payload), "digest": "0x" + hex.EncodeToString(digest[:]), "commitments": m, "domain": n} + data, _ := json.MarshalIndent(vector, "", " ") + if err := atomicFile(filepath.Join(dir, "proof.json"), func(w io.Writer) error { _, err := w.Write(data); return err }); err != nil { + t.Fatal(err) + } + } + t.Logf("Filecoin PLONK backend: N=%d commitments=%d native_bytes=%d solidity_bytes=%d", n, m, encoded.Len(), len(proof.(*plonkbls.Proof).MarshalSolidity())) + } +} diff --git a/tools/bls-plonk-wrapper/blake3.go b/tools/bls-plonk-wrapper/blake3.go new file mode 100644 index 000000000..5acddffc9 --- /dev/null +++ b/tools/bls-plonk-wrapper/blake3.go @@ -0,0 +1,156 @@ +package main + +import ( + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/std/math/uints" +) + +var blakeIV = [8]uint32{0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19} +var blakePermutation = [16]int{2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8} + +type blakeOutput struct { + cv [8]uints.U32 + block [16]uints.U32 + counter uint64 + length uint32 + flags uint32 +} + +func blakeCompress(u *uints.BinaryField[uints.U32], out blakeOutput, counter uint64, flags uint32) [16]uints.U32 { + var v [16]uints.U32 + copy(v[:8], out.cv[:]) + for i := 0; i < 4; i++ { + v[8+i] = uints.NewU32(blakeIV[i]) + } + v[12], v[13] = uints.NewU32(uint32(counter)), uints.NewU32(uint32(counter>>32)) + v[14], v[15] = uints.NewU32(out.length), uints.NewU32(flags) + m := out.block + g := func(a, b, c, d int, x, y uints.U32) { + v[a] = u.Add(v[a], v[b], x) + v[d] = u.Lrot(u.Xor(v[d], v[a]), -16) + v[c] = u.Add(v[c], v[d]) + v[b] = u.Lrot(u.Xor(v[b], v[c]), -12) + v[a] = u.Add(v[a], v[b], y) + v[d] = u.Lrot(u.Xor(v[d], v[a]), -8) + v[c] = u.Add(v[c], v[d]) + v[b] = u.Lrot(u.Xor(v[b], v[c]), -7) + } + for round := 0; round < 7; round++ { + g(0, 4, 8, 12, m[0], m[1]) + g(1, 5, 9, 13, m[2], m[3]) + g(2, 6, 10, 14, m[4], m[5]) + g(3, 7, 11, 15, m[6], m[7]) + g(0, 5, 10, 15, m[8], m[9]) + g(1, 6, 11, 12, m[10], m[11]) + g(2, 7, 8, 13, m[12], m[13]) + g(3, 4, 9, 14, m[14], m[15]) + previous := m + for i, index := range blakePermutation { + m[i] = previous[index] + } + } + for i := 0; i < 8; i++ { + v[i] = u.Xor(v[i], v[i+8]) + v[i+8] = u.Xor(v[i+8], out.cv[i]) + } + return v +} + +func (out blakeOutput) chaining(u *uints.BinaryField[uints.U32]) [8]uints.U32 { + v := blakeCompress(u, out, out.counter, out.flags) + return [8]uints.U32(v[:8]) +} + +func blakeChunk(u *uints.BinaryField[uints.U32], input []uints.U8, counter uint64) blakeOutput { + var cv [8]uints.U32 + for i := range cv { + cv[i] = uints.NewU32(blakeIV[i]) + } + blocks := max(1, (len(input)+63)/64) + var out blakeOutput + for i := 0; i < blocks; i++ { + start, end := i*64, min((i+1)*64, len(input)) + var padded [64]uints.U8 + for j := range padded { + padded[j] = uints.NewU8(0) + } + copy(padded[:], input[start:end]) + out = blakeOutput{cv: cv, counter: counter, length: uint32(end - start)} + for j := range out.block { + out.block[j] = u.PackLSB(padded[4*j : 4*j+4]...) + } + if i == 0 { + out.flags |= 1 + } + if i == blocks-1 { + out.flags |= 2 + } else { + cv = out.chaining(u) + } + } + return out +} + +func blakeParent(left, right [8]uints.U32) blakeOutput { + out := blakeOutput{length: 64, flags: 4} + for i := range out.cv { + out.cv[i] = uints.NewU32(blakeIV[i]) + } + copy(out.block[:8], left[:]) + copy(out.block[8:], right[:]) + return out +} + +// blakeXOF implements unkeyed BLAKE3 for a circuit-fixed input and output length. +func blakeXOF(u *uints.BinaryField[uints.U32], input []uints.U8, length int) []uints.U8 { + chunks := max(1, (len(input)+1023)/1024) + stack := make([][8]uints.U32, 0) + var out blakeOutput + for i := 0; i < chunks; i++ { + out = blakeChunk(u, input[i*1024:min((i+1)*1024, len(input))], uint64(i)) + if i == chunks-1 { + break + } + cv := out.chaining(u) + for count := i + 1; count&1 == 0; count >>= 1 { + cv = blakeParent(stack[len(stack)-1], cv).chaining(u) + stack = stack[:len(stack)-1] + } + stack = append(stack, cv) + } + for i := len(stack) - 1; i >= 0; i-- { + out = blakeParent(stack[i], out.chaining(u)) + } + result := make([]uints.U8, 0, length+63) + for counter := uint64(0); len(result) < length; counter++ { + block := blakeCompress(u, out, counter, out.flags|8) + for _, word := range block { + result = append(result, word[:]...) + } + } + return result[:length] +} + +type transcript struct { + api frontend.API + u *uints.BinaryField[uints.U32] + state []uints.U8 + buffer []uints.U8 +} + +func newTranscript(api frontend.API, u *uints.BinaryField[uints.U32], seed []byte) *transcript { + return &transcript{api: api, u: u, state: uints.NewU8Array(make([]byte, 32)), buffer: uints.NewU8Array(seed)} +} + +func (t *transcript) observe(v []uints.U8) { t.buffer = append(t.buffer, v...) } + +func (t *transcript) sample() frontend.Variable { + input := append(append([]uints.U8(nil), t.state...), t.buffer...) + out := blakeXOF(t.u, input, 96) + t.state, t.buffer = out[64:], nil + var result frontend.Variable = 0 + for i := 63; i >= 0; i-- { + result = t.api.Add(t.api.Mul(result, 256), t.u.Value(out[i])) + } + return result +} diff --git a/tools/bls-plonk-wrapper/blake3_test.go b/tools/bls-plonk-wrapper/blake3_test.go new file mode 100644 index 000000000..e7d1cdbb4 --- /dev/null +++ b/tools/bls-plonk-wrapper/blake3_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "io" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" + "github.com/consensys/gnark/std/math/uints" + "github.com/consensys/gnark/test" + "github.com/zeebo/blake3" +) + +type blakeCircuit struct { + Input []uints.U8 + Output [96]uints.U8 +} + +func (c *blakeCircuit) Define(api frontend.API) error { + u, err := uints.NewBinaryField[uints.U32](api) + if err != nil { + return err + } + result := blakeXOF(u, c.Input, len(c.Output)) + for i := range result { + u.Bytes.AssertIsEqual(result[i], c.Output[i]) + } + return nil +} + +func TestBlake3Boundaries(t *testing.T) { + for _, n := range []int{0, 1, 63, 64, 65, 1023, 1024, 1025, 2048, 3073} { + input := make([]byte, n) + for i := range input { + input[i] = byte(i % 251) + } + h := blake3.New() + h.Write(input) + var expected [96]byte + if _, err := io.ReadFull(h.Digest(), expected[:]); err != nil { + t.Fatal(err) + } + circuit := &blakeCircuit{Input: make([]uints.U8, n)} + assignment := &blakeCircuit{Input: uints.NewU8Array(input)} + copy(assignment.Output[:], uints.NewU8Array(expected[:])) + if err := test.IsSolved(circuit, assignment, ecc.BLS12_381.ScalarField()); err != nil { + t.Fatalf("length %d: %v", n, err) + } + assignment.Output[95] = uints.NewU8(expected[95] ^ 1) + if err := test.IsSolved(circuit, assignment, ecc.BLS12_381.ScalarField()); err == nil { + t.Fatalf("length %d accepted changed XOF state", n) + } + } +} + +func TestBlake3CompiledConstraints(t *testing.T) { + input := []byte("ix BLAKE3 wrapper") + h := blake3.New() + h.Write(input) + var expected [96]byte + io.ReadFull(h.Digest(), expected[:]) + circuit := &blakeCircuit{Input: make([]uints.U8, len(input))} + assignment := &blakeCircuit{Input: uints.NewU8Array(input)} + copy(assignment.Output[:], uints.NewU8Array(expected[:])) + ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, circuit) + if err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + if err != nil { + t.Fatal(err) + } + if err := ccs.IsSolved(w); err != nil { + t.Fatal(err) + } + t.Logf("one-block BLAKE3 XOF: %d constraints", ccs.GetNbConstraints()) + assignment.Input[0] = uints.NewU8(input[0] ^ 1) + w, err = frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + if err != nil { + t.Fatal(err) + } + if err := ccs.IsSolved(w); err == nil { + t.Fatal("compiled circuit accepted changed input") + } +} diff --git a/tools/bls-plonk-wrapper/circuit.go b/tools/bls-plonk-wrapper/circuit.go new file mode 100644 index 000000000..1be9471aa --- /dev/null +++ b/tools/bls-plonk-wrapper/circuit.go @@ -0,0 +1,381 @@ +package main + +import ( + "encoding/binary" + "fmt" + "math/big" + + bls "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark/frontend" + sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" + "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" + "github.com/consensys/gnark/std/math/bits" + "github.com/consensys/gnark/std/math/cmp" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" +) + +type wrapperCircuit struct { + Claim [2]frontend.Variable `gnark:",public"` + Points []sw.G1Affine + Deferred [2]sw.G1Affine + Values []frontend.Variable + Profile *profile `gnark:"-"` +} + +func scalarBytes(api frontend.API, u *uints.BinaryField[uints.U32], v frontend.Variable) []uints.U8 { + b := bits.ToBinary(api, v, bits.WithNbDigits(256)) + out := make([]uints.U8, 32) + for i := range out { + out[i] = u.Bytes.FromBits(b[8*i : 8*i+8]...) + } + return out +} + +func pointBytes(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[sw.BaseField], point *sw.G1Affine) []uints.U8 { + f.AssertIsInRange(&point.X) + f.AssertIsInRange(&point.Y) + x := f.ToBitsCanonical(&point.X) + y := f.ToBitsCanonical(&point.Y) + half := new(big.Int).Rsh(new(big.Int).Set(fp.Modulus()), 1) + bound := make([]frontend.Variable, len(y)) + for i := range bound { + bound[i] = half.Bit(i) + } + sign := cmp.IsLessBinary(api, bound, y) + infinity := api.And(f.IsZero(&point.X), f.IsZero(&point.Y)) + for len(x) < 384 { + x = append(x, 0) + } + out := make([]uints.U8, 48) + for i := range out { + out[47-i] = u.Bytes.FromBits(x[8*i : 8*i+8]...) + } + out[0] = u.ByteValueOf(api.Add(u.Value(out[0]), 128, api.Mul(64, infinity), api.Mul(32, sign))) + return out +} + +func constantU64(value uint64) []uints.U8 { + var bytes [8]byte + binary.LittleEndian.PutUint64(bytes[:], value) + return uints.NewU8Array(bytes[:]) +} + +func littleEndian(api frontend.API, u *uints.BinaryField[uints.U32], bytes []uints.U8) frontend.Variable { + var value frontend.Variable = 0 + for i := len(bytes) - 1; i >= 0; i-- { + value = api.Add(api.Mul(value, 256), u.Value(bytes[i])) + } + return value +} + +func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints.U32], encoded [][]uints.U8, publics []frontend.Variable) []frontend.Variable { + p := c.Profile + seed, _ := unhex(p.Seed, -1) + t := newTranscript(api, u, seed) + field := func(v frontend.Variable) { t.observe(scalarBytes(api, u, v)) } + commitment := func(round int) { + t.observe(constantU64(2)) + for i := 0; i < 2; i++ { + m := p.Matrices[2*round+i] + t.observe(constantU64(uint64(m.Width))) + for j := 0; j < m.Width; j++ { + t.observe(encoded[m.PointOffset+j]) + } + } + } + field(2) + for _, circuit := range p.Circuits { + for _, value := range circuit.Shape { + field(value) + } + } + field(1) + field(1) + commitment(3) + commitment(0) + for _, circuit := range p.Circuits { + field(circuit.LogDegree) + } + field(23) + for i, value := range publics { + field(4) + field(94) + field(1) + field(i) + field(value) + } + ch := make([]frontend.Variable, 6) + ch[0] = t.sample() + field(ch[0]) + ch[1] = t.sample() + field(ch[1]) + commitment(1) + field(c.Values[0]) + field(0) + ch[2] = t.sample() + commitment(2) + ch[3] = t.sample() + for round := 0; round < 4; round++ { + commitment(round) + for _, circuit := range p.Circuits { + t.observe(constantU64(uint64(circuit.LogDegree))) + } + } + for _, value := range c.Values[1:] { + field(value) + } + ch[4] = t.sample() + for _, point := range encoded[p.WitnessOffset:p.PointCount] { + t.observe(point) + } + ch[5] = t.sample() + return ch +} + +func (p *scalarProgram) constrain(api frontend.API, challenges, values, publics []frontend.Variable) ([]frontend.Variable, []frontend.Variable) { + nodes := make([]frontend.Variable, len(p.Nodes)) + inputs := map[string][]frontend.Variable{"challenge": challenges, "value": values, "public": publics} + for i, n := range p.Nodes { + switch n.Op { + case "constant": + nodes[i] = n.Value + case "input": + nodes[i] = inputs[n.Kind][n.Index] + case "add": + nodes[i] = api.Add(nodes[n.A], nodes[n.B]) + case "sub": + nodes[i] = api.Sub(nodes[n.A], nodes[n.B]) + case "mul": + nodes[i] = api.Mul(nodes[n.A], nodes[n.B]) + case "inverse": + nodes[i] = api.Inverse(nodes[n.A]) + default: + panic("unvalidated scalar program") + } + } + for _, id := range p.Assertions { + api.AssertIsEqual(nodes[id], 0) + } + outputs := func(ids []int) []frontend.Variable { + out := make([]frontend.Variable, len(ids)) + for i, id := range ids { + out[i] = nodes[id] + } + return out + } + return outputs(p.Coefficients), outputs(p.Witnesses) +} + +func (c *wrapperCircuit) Define(api frontend.API) error { + return c.constrain(api, 6) +} + +func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { + p := c.Profile + if p == nil { + return fmt.Errorf("missing trusted profile") + } + if err := p.validate(); err != nil { + return err + } + u, err := uints.NewBinaryField[uints.U32](api) + if err != nil { + return err + } + f, err := emulated.NewField[sw.BaseField](api) + if err != nil { + return err + } + s, err := emulated.NewField[sw.ScalarField](api) + if err != nil { + return err + } + curve, err := sw_emulated.New[sw.BaseField, sw.ScalarField](api, sw_emulated.GetBLS12381Params()) + if err != nil { + return err + } + pairing, err := sw.NewPairing(api) + if err != nil { + return err + } + encoded := make([][]uints.U8, p.PointCount+len(p.Fixed)) + points := make([]*sw.G1Affine, 0, p.PointCount+len(p.Fixed)+1) + for i := range c.Points { + pairing.AssertIsOnG1(&c.Points[i]) + encoded[i] = pointBytes(api, u, f, &c.Points[i]) + points = append(points, &c.Points[i]) + } + for i, fixed := range p.Fixed { + native, _ := fixed.native() + point := sw.NewG1Affine(native) + points = append(points, &point) + bytes := native.Bytes() + encoded[p.PointCount+i] = uints.NewU8Array(bytes[:]) + } + g, _ := p.Generator.native() + generator := sw.NewG1Affine(g) + points = append(points, &generator) + publics := make([]frontend.Variable, 23) + publics[0] = 0 + for i, word := range p.Prefix { + publics[1+i] = word + } + for half := 0; half < 2; half++ { + b := bits.ToBinary(api, c.Claim[half], bits.WithNbDigits(128)) + for word := 0; word < 4; word++ { + publics[11+4*half+word] = bits.FromBinary(api, b[32*word:32*word+32]) + } + } + for i := range c.Deferred { + pairing.AssertIsOnG1(&c.Deferred[i]) + bytes := pointBytes(api, u, f, &c.Deferred[i]) + publics[19+2*i] = littleEndian(api, u, bytes[:24]) + publics[20+2*i] = littleEndian(api, u, bytes[24:]) + } + if phase == 1 { + return nil + } + challenges := c.challenges(api, u, encoded, publics) + if phase == 2 { + return nil + } + coefficients, witnessCoefficients := p.Program.constrain(api, challenges, c.Values, publics) + if phase == 3 { + return nil + } + leftPoints, leftCoefficients := foldFixedBases(api, p, points, coefficients) + left, err := completeMSM(api, curve, s, leftPoints, leftCoefficients) + if err != nil { + return err + } + right, err := completeMSM(api, curve, s, points[p.WitnessOffset:p.PointCount], witnessCoefficients) + if err != nil { + return err + } + if phase == 4 { + return nil + } + outer, _ := g2Keys(p.OuterKeys) + h, tauH := sw.NewG2AffineFixed(outer[0]), sw.NewG2AffineFixed(outer[1]) + if err := pairing.PairingCheck([]*sw.G1Affine{left, curve.Neg(right)}, []*sw.G2Affine{&h, &tauH}); err != nil { + return err + } + if phase == 5 { + return nil + } + inner, _ := g2Keys(p.DeferredKeys) + ih, itauH := sw.NewG2AffineFixed(inner[0]), sw.NewG2AffineFixed(inner[1]) + return pairing.PairingCheck([]*sw.G1Affine{&c.Deferred[0], &c.Deferred[1]}, []*sw.G2Affine{&ih, &itauH}) +} + +// Fixed-point relations are checked at circuit construction. Variable inputs +// retain their curve and subgroup checks even when a scalar is constant zero. +func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars []frontend.Variable) ([]*sw.G1Affine, []frontend.Variable) { + weights := append([]frontend.Variable(nil), scalars...) + indices := make([]int, p.PointCount) + for i := range indices { + indices[i] = i + } + generator, _ := p.Generator.native() + generatorIndex := len(points) - 1 + type base struct { + point bls.G1Affine + index int + } + kept := []base{{generator, generatorIndex}} + for i, fixed := range p.Fixed { + point, _ := fixed.native() + index := p.PointCount + i + if point.IsInfinity() { + continue + } + matched := false + for _, previous := range kept { + var negative bls.G1Affine + negative.Neg(&previous.point) + if point.Equal(&previous.point) || point.Equal(&negative) { + weight := weights[index] + if point.Equal(&negative) { + weight = api.Neg(weight) + } + weights[previous.index] = api.Add(weights[previous.index], weight) + matched = true + break + } + for _, shift := range []int64{-2, -1, 1, 2} { + var offset, candidate bls.G1Affine + magnitude := shift + if magnitude < 0 { + magnitude = -magnitude + } + offset.ScalarMultiplication(&generator, big.NewInt(magnitude)) + if shift < 0 { + offset.Neg(&offset) + } + candidate.Add(&previous.point, &offset) + if !point.Equal(&candidate) { + continue + } + weights[previous.index] = api.Add(weights[previous.index], weights[index]) + weights[generatorIndex] = api.Add(weights[generatorIndex], api.Mul(shift, weights[index])) + matched = true + break + } + if matched { + break + } + } + if !matched { + kept = append(kept, base{point, index}) + indices = append(indices, index) + } + } + indices = append(indices, generatorIndex) + selected := make([]*sw.G1Affine, len(indices)) + coefficients := make([]frontend.Variable, len(indices)) + for i, index := range indices { + selected[i], coefficients[i] = points[index], weights[index] + } + return selected, coefficients +} + +func completeMSM(api frontend.API, curve *sw_emulated.Curve[sw.BaseField, sw.ScalarField], field *emulated.Field[sw.ScalarField], points []*sw.G1Affine, scalars []frontend.Variable) (*sw.G1Affine, error) { + if len(points) != len(scalars) { + return nil, fmt.Errorf("MSM point and scalar counts differ") + } + var direct *sw.G1Affine + var bases []*sw.G1Affine + var coefficients []*sw.Scalar + minusOne := new(big.Int).Sub(fr.Modulus(), big.NewInt(1)) + for i, point := range points { + if constant, ok := api.Compiler().ConstantValue(scalars[i]); ok { + if constant.Sign() == 0 { + continue + } + if constant.IsInt64() && constant.Int64() == 1 || constant.Cmp(minusOne) == 0 { + if constant.Cmp(minusOne) == 0 { + point = curve.Neg(point) + } + if direct == nil { + direct = point + } else { + direct = curve.AddUnified(direct, point) + } + continue + } + } + bases = append(bases, point) + coefficients = append(coefficients, field.FromBits(bits.ToBinary(api, scalars[i])...)) + } + result, err := curve.MultiScalarMul(bases, coefficients) + if err != nil { + return nil, err + } + if direct != nil { + result = curve.AddUnified(direct, result) + } + return result, nil +} diff --git a/tools/bls-plonk-wrapper/circuit_test.go b/tools/bls-plonk-wrapper/circuit_test.go new file mode 100644 index 000000000..94269717c --- /dev/null +++ b/tools/bls-plonk-wrapper/circuit_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "math/big" + "path/filepath" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + bls "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" + "github.com/consensys/gnark/frontend" + sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" + "github.com/consensys/gnark/test" +) + +type encodingCircuit struct { + Point sw.G1Affine + Encoded [48]uints.U8 +} + +func (c *encodingCircuit) Define(api frontend.API) error { + u, err := uints.NewBinaryField[uints.U32](api) + if err != nil { + return err + } + f, err := emulated.NewField[sw.BaseField](api) + if err != nil { + return err + } + pairing, err := sw.NewPairing(api) + if err != nil { + return err + } + pairing.AssertIsOnG1(&c.Point) + for i, b := range pointBytes(api, u, f, &c.Point) { + u.Bytes.AssertIsEqual(b, c.Encoded[i]) + } + return nil +} + +func TestIdentityAndCanonicalPointEncoding(t *testing.T) { + _, _, g, _ := bls.Generators() + var negative bls.G1Affine + negative.Neg(&g) + for _, point := range []bls.G1Affine{{}, g, negative} { + assignment := &encodingCircuit{Point: sw.NewG1Affine(point)} + bytes := point.Bytes() + copy(assignment.Encoded[:], uints.NewU8Array(bytes[:])) + if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BLS12_381.ScalarField()); err != nil { + t.Fatal(err) + } + assignment.Encoded[0] = uints.NewU8(bytes[0] ^ 0x20) + if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BLS12_381.ScalarField()); err == nil { + t.Fatal("accepted wrong sign bit") + } + } + var torsion bls.G1Affine + torsion.Y.SetUint64(2) + assignment := &encodingCircuit{Point: sw.NewG1Affine(torsion)} + bytes := torsion.Bytes() + copy(assignment.Encoded[:], uints.NewU8Array(bytes[:])) + if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BLS12_381.ScalarField()); err == nil { + t.Fatal("accepted wrong-subgroup point") + } + identity := new(bls.G1Affine).Bytes() + alias := &encodingCircuit{Point: sw.NewG1Affine(bls.G1Affine{})} + copy(alias.Encoded[:], uints.NewU8Array(identity[:])) + alias.Point.X.Limbs = make([]frontend.Variable, 6) + modulus := fp.Modulus() + for i := range alias.Point.X.Limbs { + alias.Point.X.Limbs[i] = new(big.Int).And(new(big.Int).Rsh(new(big.Int).Set(modulus), uint(64*i)), new(big.Int).SetUint64(^uint64(0))) + } + if err := test.IsSolved(&encodingCircuit{}, alias, ecc.BLS12_381.ScalarField()); err == nil { + t.Fatal("accepted noncanonical identity coordinates") + } +} + +func TestFullVerifierWitness(t *testing.T) { + p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"fixture", "fixture-other-statement", "fixture-bad-pairing"} { + assignment, err := witnessAssignment(p, filepath.Join("testdata", "outer", name+".json")) + if err != nil { + t.Fatal(err) + } + err = test.IsSolved(newCircuit(p), assignment, ecc.BLS12_381.ScalarField()) + if name == "fixture-bad-pairing" { + if err == nil { + t.Fatal("accepted valid outer proof with invalid deferred pairing") + } + } else if err != nil { + t.Fatalf("%s: %v", name, err) + } + } + assignment, err := witnessAssignment(p, filepath.Join("testdata", "outer", "fixture.json")) + if err != nil { + t.Fatal(err) + } + assignment.Claim[0] = 123 + if err := test.IsSolved(newCircuit(p), assignment, ecc.BLS12_381.ScalarField()); err == nil { + t.Fatal("accepted a changed claim") + } +} diff --git a/tools/bls-plonk-wrapper/go.mod b/tools/bls-plonk-wrapper/go.mod new file mode 100644 index 000000000..d9381645d --- /dev/null +++ b/tools/bls-plonk-wrapper/go.mod @@ -0,0 +1,27 @@ +module github.com/argumentcomputer/ix/tools/bls-plonk-wrapper + +go 1.26.8 + +require ( + github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107 + github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b + github.com/zeebo/blake3 v0.2.4 +) + +require ( + github.com/bits-and-blooms/bitset v1.25.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect + github.com/klauspost/cpuid/v2 v2.0.12 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ronanh/intcomp v1.1.1 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect +) diff --git a/tools/bls-plonk-wrapper/go.sum b/tools/bls-plonk-wrapper/go.sum new file mode 100644 index 000000000..32dcd1fc5 --- /dev/null +++ b/tools/bls-plonk-wrapper/go.sum @@ -0,0 +1,46 @@ +github.com/bits-and-blooms/bitset v1.25.0 h1:0Ro0qF4abCkM6SqWPVj29sFhAbMPAZpaDD7xhJ10beM= +github.com/bits-and-blooms/bitset v1.25.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107 h1:3j1bREIJfIVutzqH6FuEpLlY40+m7VAFua9CZBxdpzc= +github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107/go.mod h1:Y4orPf3hT0CjoUOyjo22jJN+0TD0gD1tS4BFWL1mkVw= +github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b h1:IDT/Waq2euwKsSOVZGufUN1tjYTN94Jf7FdSqt0XrH0= +github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b/go.mod h1:HL8XflRG4cvau1PEG3CWBTD2kkzUzdlqZF6BBgy57F8= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ronanh/intcomp v1.1.1 h1:+1bGV/wEBiHI0FvzS7RHgzqOpfbBJzLIxkqMJ9e6yxY= +github.com/ronanh/intcomp v1.1.1/go.mod h1:7FOLy3P3Zj3er/kVrU/pl+Ql7JFZj7bwliMGketo0IU= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/tools/bls-plonk-wrapper/main.go b/tools/bls-plonk-wrapper/main.go new file mode 100644 index 000000000..3c9322fea --- /dev/null +++ b/tools/bls-plonk-wrapper/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "crypto/rand" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/constraint" + "github.com/consensys/gnark/constraint/solver" + "github.com/consensys/gnark/frontend" + fcs "github.com/consensys/gnark/frontend/cs" + "github.com/consensys/gnark/frontend/cs/scs" + "github.com/consensys/gnark/logger" +) + +func compileProfile(path, dir string) error { + p, err := loadProfile(path) + if err != nil { + return err + } + profileHash, err := fileHash(path) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil { + m, err := loadManifest(dir) + if err != nil { + return err + } + if m.ProfileSHA256 != profileHash.SHA256 { + return fmt.Errorf("artifact directory belongs to a different profile") + } + if err := m.checkFile(dir, "circuit.bin"); err != nil { + return err + } + fmt.Printf("reused_circuit=true constraints=%d domain=%d commitments=%d\n", m.Constraints, m.Domain, m.Commitments) + return nil + } else if !os.IsNotExist(err) { + return err + } + start := time.Now() + ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, newCircuit(p)) + if err != nil { + return err + } + seconds := time.Since(start).Seconds() + identity, err := os.Executable() + if err != nil { + return err + } + executable, err := fileHash(identity) + if err != nil { + return err + } + m := &manifest{ + Format: "ix-bls-plonk-artifacts-v1", ExecutableSHA256: executable.SHA256, + ProfileSHA256: profileHash.SHA256, Constraints: ccs.GetNbConstraints(), + PublicInputs: ccs.GetNbPublicVariables(), + Commitments: len(ccs.GetCommitments().CommitmentIndexes()), + Domain: ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())), + Files: make(map[string]artifact), Seconds: map[string]float64{"compile": seconds}, + } + if m.PublicInputs != 2 { + return fmt.Errorf("expected two public digest limbs") + } + if err := atomicFile(filepath.Join(dir, "profile.json"), func(w io.Writer) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }); err != nil { + return err + } + m.Files["profile.json"] = profileHash + if err := saveBinary(dir, "circuit.bin", ccs, m); err != nil { + return err + } + if err := writeJSON(filepath.Join(dir, "manifest.json"), m); err != nil { + return err + } + fmt.Printf("constraints=%d domain=%d public=%d commitments=%d compile_seconds=%.3f\n", m.Constraints, m.Domain, m.PublicInputs, m.Commitments, seconds) + return nil +} + +func loadCircuit(dir string) (*profile, constraint.ConstraintSystem, *manifest, error) { + m, err := loadManifest(dir) + if err != nil { + return nil, nil, nil, err + } + if err := m.checkFile(dir, "circuit.bin"); err != nil { + return nil, nil, nil, err + } + p, err := loadProfile(filepath.Join(dir, "profile.json")) + if err != nil { + return nil, nil, nil, err + } + ccs := plonk.NewCS(ecc.BLS12_381) + if err := readBinary(filepath.Join(dir, "circuit.bin"), ccs); err != nil { + return nil, nil, nil, err + } + if ccs.GetNbPublicVariables() != 2 || ccs.GetNbConstraints() != m.Constraints || ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+2)) != m.Domain { + return nil, nil, nil, fmt.Errorf("constraint metadata differs") + } + return p, ccs, m, nil +} + +// diagnosticCommitment is only for checking assignments without a proving key. +// PLONK proving supplies its own KZG-bound commitment challenge. +func diagnosticCommitment(modulus *big.Int, _ []*big.Int, outputs []*big.Int) error { + if len(outputs) != 1 { + return fmt.Errorf("unexpected diagnostic commitment outputs") + } + for { + value, err := rand.Int(rand.Reader, modulus) + if err != nil { + return err + } + if value.Sign() != 0 { + outputs[0].Set(value) + return nil + } + } +} + +func checkWitness(dir, path string) error { + p, ccs, _, err := loadCircuit(dir) + if err != nil { + return err + } + assignment, err := witnessAssignment(p, path) + if err != nil { + return err + } + w, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + if err != nil { + return err + } + start := time.Now() + option := solver.OverrideHint(solver.GetHintID(fcs.Bsb22CommitmentComputePlaceholder), diagnosticCommitment) + if err := ccs.IsSolved(w, option); err != nil { + return err + } + fmt.Printf("diagnostic_witness_check=passed proof_created=false witness_seconds=%.3f\n", time.Since(start).Seconds()) + return nil +} + +func run(args []string) error { + if len(args) == 3 && args[0] == "compile" { + return compileProfile(args[1], args[2]) + } + if len(args) == 3 && args[0] == "check-witness" { + return checkWitness(args[1], args[2]) + } + if len(args) == 4 && args[0] == "prepare" { + return prepare(args[1], args[2], args[3]) + } + if len(args) == 4 && args[0] == "prove" { + return prove(args[1], args[2], args[3]) + } + if len(args) == 5 && args[0] == "verify" { + return verify(args[1], args[2], args[3], args[4]) + } + if len(args) == 6 && args[0] == "verify" { + return verify(args[1], args[2], args[3], args[4], args[5]) + } + return fmt.Errorf("usage: ix-bls-plonk-wrapper compile | check-witness | prepare | prove | verify ") +} + +func main() { + logger.SetOutput(os.Stderr) + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/tools/bls-plonk-wrapper/parameters.go b/tools/bls-plonk-wrapper/parameters.go new file mode 100644 index 000000000..a11b4f457 --- /dev/null +++ b/tools/bls-plonk-wrapper/parameters.go @@ -0,0 +1,113 @@ +package main + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + + bls "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" +) + +type lagrangeManifest struct { + Format, SetupID, MonomialBLAKE3, ExecutableSHA256 string + Domain uint64 + Points artifact +} + +func readAffinePoints(r io.Reader, count uint64) ([]bls.G1Affine, error) { + points := make([]bls.G1Affine, count) + for i := range points { + var raw [96]byte + if _, err := io.ReadFull(r, raw[:]); err != nil { + return nil, err + } + if err := points[i].X.SetBytesCanonical(raw[:48]); err != nil { + return nil, err + } + if err := points[i].Y.SetBytesCanonical(raw[48:]); err != nil { + return nil, err + } + } + return points, nil +} + +func writeAffinePoints(w io.Writer, points []bls.G1Affine) error { + b := bufio.NewWriterSize(w, 1<<20) + for _, point := range points { + x, y := point.X.Bytes(), point.Y.Bytes() + if _, err := b.Write(x[:]); err != nil { + return err + } + if _, err := b.Write(y[:]); err != nil { + return err + } + } + return b.Flush() +} + +func lagrangeParameters(root string, srs *kzg.SRS, domain uint64, setupID, receipt, executable string) (*kzg.SRS, bool, error) { + if domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || uint64(len(srs.Pk.G1)) < domain+3 { + return nil, false, fmt.Errorf("invalid Lagrange domain or monomial prefix") + } + want := lagrangeManifest{Format: "ix-plonk-lagrange-v1", SetupID: setupID, MonomialBLAKE3: receipt, ExecutableSHA256: executable, Domain: domain} + identity, err := json.Marshal(want) + if err != nil { + return nil, false, err + } + digest := sha256.Sum256(identity) + dir := filepath.Join(root, strconv.FormatUint(domain, 10), hex.EncodeToString(digest[:])) + metadata := filepath.Join(dir, "manifest.json") + pointsPath := filepath.Join(dir, "points.bin") + if _, err := os.Stat(metadata); err == nil { + var stored lagrangeManifest + if err := decodeJSON(metadata, &stored); err != nil { + return nil, false, err + } + pointsInfo := stored.Points + stored.Points = artifact{} + if stored != want || pointsInfo.Bytes != int64(96*domain) { + return nil, false, fmt.Errorf("Lagrange cache identity differs") + } + got, err := fileHash(pointsPath) + if err != nil { + return nil, false, err + } + if got != pointsInfo { + return nil, false, fmt.Errorf("Lagrange cache checksum differs") + } + f, err := os.Open(pointsPath) + if err != nil { + return nil, false, err + } + defer f.Close() + points, err := readAffinePoints(bufio.NewReaderSize(f, 1<<20), domain) + if err != nil { + return nil, false, err + } + return &kzg.SRS{Pk: kzg.ProvingKey{G1: points}, Vk: srs.Vk}, true, nil + } else if !os.IsNotExist(err) { + return nil, false, err + } + points, err := kzg.ToLagrangeG1(srs.Pk.G1[:domain]) + if err != nil { + return nil, false, err + } + if err := atomicFile(pointsPath, func(w io.Writer) error { return writeAffinePoints(w, points) }); err != nil { + return nil, false, err + } + want.Points, err = fileHash(pointsPath) + if err != nil { + return nil, false, err + } + if err := writeJSON(metadata, want); err != nil { + return nil, false, err + } + return &kzg.SRS{Pk: kzg.ProvingKey{G1: points}, Vk: srs.Vk}, false, nil +} diff --git a/tools/bls-plonk-wrapper/parameters_test.go b/tools/bls-plonk-wrapper/parameters_test.go new file mode 100644 index 000000000..a4c342d25 --- /dev/null +++ b/tools/bls-plonk-wrapper/parameters_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr/fft" + "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" +) + +func TestLagrangeCacheAndBasisConversion(t *testing.T) { + p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + if err != nil { + t.Fatal(err) + } + var receipt struct { + Format, Blake3, SetupID, FilecoinManifest string + PublicMaxDegree, Powers uint64 + } + if err := decodeJSON(filepath.Join("testdata", "srs.json"), &receipt); err != nil { + t.Fatal(err) + } + srs, err := readSRS(filepath.Join("testdata", "srs.bin"), receipt.Blake3, 128, p) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + executable := strings.Repeat("a", 64) + first, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) + if err != nil || reused { + t.Fatalf("initial derivation: reused=%t error=%v", reused, err) + } + second, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) + if err != nil || !reused { + t.Fatalf("cached derivation: reused=%t error=%v", reused, err) + } + for i := range first.Pk.G1 { + if !first.Pk.G1[i].Equal(&second.Pk.G1[i]) { + t.Fatal("cache changed Lagrange points") + } + } + coefficients := make([]fr.Element, 8) + for i := 0; i < 3; i++ { + coefficients[i].SetUint64(uint64(2*i + 1)) + } + evaluations := append([]fr.Element(nil), coefficients...) + fft.NewDomain(8).FFT(evaluations, fft.DIF) + fft.BitReverse(evaluations) + a, err := kzg.Commit(coefficients, srs.Pk) + if err != nil { + t.Fatal(err) + } + b, err := kzg.Commit(evaluations, second.Pk) + if err != nil { + t.Fatal(err) + } + if !a.Equal(&b) { + t.Fatal("monomial and Lagrange commitments differ") + } + files, err := filepath.Glob(filepath.Join(root, "8", "*", "points.bin")) + if err != nil || len(files) != 1 { + t.Fatalf("cache files: %v %v", files, err) + } + f, err := os.OpenFile(files[0], os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + var changed [1]byte + if _, err := f.ReadAt(changed[:], 30); err != nil { + t.Fatal(err) + } + changed[0] ^= 1 + if _, err := f.WriteAt(changed[:], 30); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if _, _, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable); err == nil { + t.Fatal("accepted corrupted Lagrange cache") + } + if _, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, strings.Repeat("b", 64)); err != nil || reused { + t.Fatalf("new backend reused old cache: %t %v", reused, err) + } +} diff --git a/tools/bls-plonk-wrapper/profile.go b/tools/bls-plonk-wrapper/profile.go new file mode 100644 index 000000000..165e3622f --- /dev/null +++ b/tools/bls-plonk-wrapper/profile.go @@ -0,0 +1,270 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "strings" + + bls "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark/frontend" + sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" +) + +type pointJSON struct{ X, Y string } +type matrix struct{ Width, Rows, PointOffset, ValueOffset int } +type circuitShape struct { + LogDegree int + Shape [7]uint64 +} +type instruction struct { + Op, Value, Kind string + Index, A, B int +} +type scalarProgram struct { + Nodes []instruction + Assertions, Coefficients, Witnesses []int +} +type profile struct { + Format, ProfileID, SetupID, Seed string + PublicMaxDegree uint64 + Prefix []uint64 + Circuits []circuitShape + Matrices []matrix + PointCount, FieldCount, WitnessOffset, OpeningCount int + Fixed []pointJSON + Generator pointJSON + OuterKeys, DeferredKeys []string + Program scalarProgram +} +type witnessJSON struct { + ClaimDigest string + Points, Deferred []pointJSON + Values []string +} + +func decodeJSON(path string, out any) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + d := json.NewDecoder(io.LimitReader(f, 16<<20)) + d.DisallowUnknownFields() + if err := d.Decode(out); err != nil { + return err + } + if err := d.Decode(new(any)); err != io.EOF { + return fmt.Errorf("trailing JSON data") + } + return nil +} + +func unhex(s string, length int) ([]byte, error) { + b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + if err != nil || (length >= 0 && len(b) != length) { + return nil, fmt.Errorf("invalid hexadecimal encoding (expected %d bytes)", length) + } + return b, nil +} + +func canonical(s string, modulus *big.Int) (*big.Int, error) { + v, ok := new(big.Int).SetString(s, 0) + if !ok || v.Sign() < 0 || v.Cmp(modulus) >= 0 { + return nil, fmt.Errorf("noncanonical field element") + } + return v, nil +} + +func (p pointJSON) native() (bls.G1Affine, error) { + var result bls.G1Affine + x, err := canonical(p.X, fp.Modulus()) + if err != nil { + return result, err + } + y, err := canonical(p.Y, fp.Modulus()) + if err != nil { + return result, err + } + result.X.SetBigInt(x) + result.Y.SetBigInt(y) + if !result.IsOnCurve() || !result.IsInSubGroup() { + return result, fmt.Errorf("invalid G1 point") + } + return result, nil +} + +func g2Keys(encoded []string) ([2]bls.G2Affine, error) { + var result [2]bls.G2Affine + if len(encoded) != 2 { + return result, fmt.Errorf("expected two G2 anchors") + } + for i, s := range encoded { + b, err := unhex(s, 96) + if err != nil { + return result, err + } + if _, err := result[i].SetBytes(b); err != nil { + return result, err + } + if result[i].IsInfinity() { + return result, fmt.Errorf("identity G2 anchor") + } + canonical := result[i].Bytes() + if !bytes.Equal(canonical[:], b) { + return result, fmt.Errorf("noncanonical G2 anchor") + } + } + return result, nil +} + +func loadProfile(path string) (*profile, error) { + var p profile + if err := decodeJSON(path, &p); err != nil { + return nil, err + } + if err := p.validate(); err != nil { + return nil, err + } + return &p, nil +} + +func (p *profile) validate() error { + if p.Format != "ix-bls-plonk-profile-v1" || len(p.Prefix) != 10 || len(p.Circuits) != 2 || len(p.Matrices) != 8 || p.PointCount < 1 || p.PointCount > 256 || p.FieldCount < 1 || p.FieldCount > 4096 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 3 || p.WitnessOffset+p.OpeningCount != p.PointCount { + return fmt.Errorf("invalid wrapper profile dimensions") + } + if _, err := unhex(p.ProfileID, 32); err != nil { + return err + } + if _, err := unhex(p.SetupID, 32); err != nil { + return err + } + if _, err := unhex(p.Seed, -1); err != nil { + return err + } + for _, c := range p.Circuits { + if c.LogDegree < 1 || c.LogDegree > 28 || c.Shape[2] != 1< 2 || m.ValueOffset < 1 || m.ValueOffset > p.FieldCount || m.Width > (p.FieldCount-m.ValueOffset)/m.Rows || m.PointOffset < 0 || m.PointOffset > p.PointCount+len(p.Fixed) || m.Width > p.PointCount+len(p.Fixed)-m.PointOffset { + return fmt.Errorf("invalid matrix %d", i) + } + } + for _, f := range append(append([]pointJSON(nil), p.Fixed...), p.Generator) { + if _, err := f.native(); err != nil { + return err + } + } + g, _ := p.Generator.native() + if g.IsInfinity() { + return fmt.Errorf("identity G1 anchor") + } + outer, err := g2Keys(p.OuterKeys) + if err != nil { + return err + } + inner, err := g2Keys(p.DeferredKeys) + if err != nil { + return err + } + if !outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1]) { + return fmt.Errorf("Filecoin pairing anchors differ") + } + if len(p.Program.Coefficients) != p.PointCount+len(p.Fixed)+1 || len(p.Program.Witnesses) != p.OpeningCount || len(p.Program.Assertions) != 2 { + return fmt.Errorf("invalid scalar program outputs") + } + for i, n := range p.Program.Nodes { + valid := func(id int) bool { return id >= 0 && id < i } + switch n.Op { + case "constant": + if _, err := canonical(n.Value, fr.Modulus()); err != nil { + return err + } + case "input": + bound := map[string]int{"challenge": 6, "value": p.FieldCount, "public": 23}[n.Kind] + if n.Index < 0 || n.Index >= bound { + return fmt.Errorf("invalid scalar input") + } + case "add", "sub", "mul": + if !valid(n.A) || !valid(n.B) { + return fmt.Errorf("invalid scalar reference") + } + case "inverse": + if !valid(n.A) { + return fmt.Errorf("invalid inverse reference") + } + default: + return fmt.Errorf("unknown scalar instruction %q", n.Op) + } + } + for _, ids := range [][]int{p.Program.Assertions, p.Program.Coefficients, p.Program.Witnesses} { + for _, id := range ids { + if id < 0 || id >= len(p.Program.Nodes) { + return fmt.Errorf("invalid scalar output") + } + } + } + return nil +} + +func newCircuit(p *profile) *wrapperCircuit { + return &wrapperCircuit{Profile: p, Points: make([]sw.G1Affine, p.PointCount), Values: make([]frontend.Variable, p.FieldCount)} +} + +func witnessAssignment(p *profile, path string) (*wrapperCircuit, error) { + var w witnessJSON + if err := decodeJSON(path, &w); err != nil { + return nil, err + } + if len(w.Points) != p.PointCount || len(w.Deferred) != 2 || len(w.Values) != p.FieldCount { + return nil, fmt.Errorf("witness dimensions differ from profile") + } + result := newCircuit(p) + digest, err := unhex(w.ClaimDigest, 32) + if err != nil { + return nil, err + } + result.Claim = digestLimbs(digest) + for i, point := range w.Points { + v, err := point.native() + if err != nil { + return nil, err + } + result.Points[i] = sw.NewG1Affine(v) + } + for i, point := range w.Deferred { + v, err := point.native() + if err != nil { + return nil, err + } + result.Deferred[i] = sw.NewG1Affine(v) + } + for i, v := range w.Values { + scalar, err := canonical(v, fr.Modulus()) + if err != nil { + return nil, err + } + result.Values[i] = scalar + } + return result, nil +} + +func digestLimbs(digest []byte) [2]frontend.Variable { + var result [2]frontend.Variable + for i := range result { + var half [16]byte + for j := range half { + half[15-j] = digest[16*i+j] + } + result[i] = new(big.Int).SetBytes(half[:]) + } + return result +} diff --git a/tools/bls-plonk-wrapper/sizing_test.go b/tools/bls-plonk-wrapper/sizing_test.go new file mode 100644 index 000000000..047aa849a --- /dev/null +++ b/tools/bls-plonk-wrapper/sizing_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +type componentCircuit struct { + Inner wrapperCircuit + Phase int `gnark:"-"` +} + +func (c *componentCircuit) Define(api frontend.API) error { return c.Inner.constrain(api, c.Phase) } + +func TestComponentSizing(t *testing.T) { + if os.Getenv("IX_PLONK_SIZE") != "1" { + t.Skip("set IX_PLONK_SIZE=1 for full component compilation") + } + p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + if err != nil { + t.Fatal(err) + } + type measurement struct { + Phase string + Constraints, Commitments int + Domain uint64 + Seconds float64 + } + var measurements []measurement + for phase, name := range []string{"point validation and public binding", "transcript", "scalar relation", "opening MSMs", "outer pairing", "deferred pairing"} { + runtime.GC() + start := time.Now() + ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, + &componentCircuit{Inner: *newCircuit(p), Phase: phase + 1}, frontend.IgnoreUnconstrainedInputs()) + if err != nil { + t.Fatal(err) + } + row := measurement{name, ccs.GetNbConstraints(), len(ccs.GetCommitments().CommitmentIndexes()), ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + 2)), time.Since(start).Seconds()} + measurements = append(measurements, row) + t.Logf("through %s: constraints=%d N=%d commitments=%d seconds=%.2f", row.Phase, row.Constraints, row.Domain, row.Commitments, row.Seconds) + } + if path := os.Getenv("IX_PLONK_SIZE_REPORT"); path != "" { + data, err := json.MarshalIndent(measurements, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } +} diff --git a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json new file mode 100644 index 000000000..35edb4eaa --- /dev/null +++ b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json @@ -0,0 +1,21 @@ +{ + "challenges": [ + "0x31913d370d61ac6d88ae01d97a96131b747484b1fd32388d885a43cb58a2cf3b", + "0x23c20c79e623feed525eac4849b9389e5f95e8ba1abcaec6e13a0b890416543e", + "0x09a363993a3bb6f0656c7744b2e9176b0c7828548a47bb1044fc1cf9135c84b3", + "0x450fff7a6dadb61b94e98091ad38fb2b4b6cf7f4a557709a136e5b542364a36a", + "0x3febfac0e5d86cf2a609cd0a9481e2431387daf511c24d5fd9bf239e9f990518", + "0x60686bd8591bd918c0b0242c7ccf2e2e3b846017a72968d03e8d475adb4f5e16" + ], + "compositions": [ + "0x084dcc720dbc90ae6e64559a7eb310509cbf69aad2749dce120a396671ab6e6d", + "0x051d960e1836ea26f783f640223a3aaa9b9a35635cbb43a43a7911ae3469418b" + ], + "outerL": "0x000000000000000000000000000000000dc247410774f8792f52266dbe30b3d482d3b7b672dc985af306964493299433eac15cf108f21a00f9228ce490f192560000000000000000000000000000000002e753e033ff8c32ac3f1de1a5d7349029878a221bb577ede30acf28f8a787cc313bab83d5586719c4fe7ba25a00e98e", + "outerR": "0x0000000000000000000000000000000019ba2c8e06a137b1a1bce6e197508bc7374836efdcaf77f984a3b75347544c8b81cc73057b4f5eec0515d2454e8cafd800000000000000000000000000000000095ac1da17393b74beefa32643c0399604e6a001d44ab14e609ca01dd5a66c608afc24f13913f6cc53b701767e6b2e5b", + "publicAccumulator": "0x046a10b9480962b677090634e7e256f61dcae10b2c4f0988bb1f1ac3c8936647", + "quotients": [ + "0x0eac21a8d75b378484e5e1f64699532187387756794963c90aad23ebb0201155", + "0x3a99ea48de8ef1189fc74fbcc4eadf3f781b83e74954003222cbd3798fa9b7f3" + ] +} \ No newline at end of file diff --git a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json new file mode 100644 index 000000000..49b014f71 --- /dev/null +++ b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json @@ -0,0 +1,20 @@ +[ + 0, + 17, + 1002, + 1003, + 1004, + 1005, + 1006, + 1007, + 1008, + 1009, + 1010, + 1011, + 1012, + 1013, + 1014, + 1015, + 1016, + 1017 +] \ No newline at end of file diff --git a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing.bin b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing.bin new file mode 100644 index 0000000000000000000000000000000000000000..32ee9aeb38390db10f5b8d14e5becaeaee54f377 GIT binary patch literal 2181 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 literal 0 HcmV?d00001 diff --git a/tools/bls-plonk-wrapper/testdata/srs.bin b/tools/bls-plonk-wrapper/testdata/srs.bin new file mode 100644 index 0000000000000000000000000000000000000000..1646a4d8d9285b37a10b56a279342cbbd72e47e5 GIT binary patch literal 12856 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 Wa?F|WF`9dF%#~aD3(^7f1YNcskd9{n literal 0 HcmV?d00001 diff --git a/tools/bls-plonk-wrapper/testdata/srs.json b/tools/bls-plonk-wrapper/testdata/srs.json new file mode 100644 index 000000000..39c742487 --- /dev/null +++ b/tools/bls-plonk-wrapper/testdata/srs.json @@ -0,0 +1,8 @@ +{ + "blake3": "187d35e08e47a1a33117a26a1bde25b0b9dfc2c0651c617d5226ec5096acc6fd", + "filecoinManifest": "185c556e5f5f0c67ddc0c8ac210c8b11d1a48574defbc44299dc712747a0507d", + "format": "ix-plonk-srs-export-v1", + "powers": 131, + "publicMaxDegree": 268435454, + "setupId": "48b74609da5ec9a9d59efb1d4c9d91d81815a304a8baab8ba35af4fe0145239f" +} \ No newline at end of file From 3364f9af5be132bdf012834258dd8176f3a4104d Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:51:51 +0000 Subject: [PATCH 05/31] feat(compression): BN254 curve, Ignition setup and CUDA PLONK Add an optional BN254 KZG pipeline beside the BLS12-381 default, selected by the `bn254` or `bls12-381` feature (IX_KZG_CURVE in Lake). BN254 keeps its own proof formats, setup identities, caches and verifier profiles, with native recursive field, curve, transcript, MSM and deferred-pairing constraints ported to it. The KZG backend, its SRS validation, compact constant columns and CUDA kernels are generic over the curve. Expose saved-root and saved-FRI pipeline commands with explicit development setup, claim binding and pinned-profile verification, plus a first-stage sizing preflight. Add authenticated Aztec Ignition import and admission for BN254, and curve-selectable terminal PLONK export, verification-key catalogs and CLI packaging. Split the profile relation into `wrap/outer/relation`, shared by the PLONK exporter. Classic GLV fits the real Init wrapper into a 2^24 domain with separate pairing checks. Add an optional native CUDA PLONK backend with pinned gnark/sppark patches, MSM/FFT dispatch, fused quotient computation and bounded Pippenger bucket accumulation for structured witnesses. contracts/plonk carries SP1's v6.1.0 BN254 PlonkVerifier with its circuit-key constants replaced and the IxPlonkVerifier claim adapter. Recorded measurements: real Init BN254 recursion has 61,151,005 rows padded to 2^26, against 118,389,942 rows at 2^27 for BLS12-381; three matched Init PLONK rounds took a 53.895 s median on CPU and 11.352 s with CUDA; the BN254 proof verifies on local Anvil through the claim adapter at 320,103 total transaction gas. docs/proof-benchmark-comparison.md compares warm Init proofs across the pipelines. --- .github/workflows/ci.yml | 33 + Cargo.lock | 12 + Ix/Cli/Compression.lean | 4 +- Tests/CompressionCli.lean | 3 + contracts/plonk/README.md | 85 +- contracts/plonk/bn254/.gitignore | 1 + contracts/plonk/bn254/bench.py | 281 + contracts/plonk/bn254/provenance.json | 125 + contracts/plonk/bn254/src/PlonkVerifier.sol | 1341 +++++ contracts/plonk/bn254/testdata/init.json | 15 + .../plonk/bn254/upstream/ISP1Verifier.sol | 24 + .../bn254/upstream/v6.1.0/PlonkVerifier.sol | 1341 +++++ .../upstream/v6.1.0/SP1VerifierPlonk.sol | 86 + crates/ffi/Cargo.toml | 7 +- crates/proof-compression/Cargo.toml | 15 +- crates/proof-compression/README.md | 825 ++- crates/proof-compression/build.rs | 21 +- crates/proof-compression/cuda/kzg.cu | 55 +- .../cuda/kzg_conformance.cuh | 58 + crates/proof-compression/cuda/kzg_curve.cuh | 55 + crates/proof-compression/cuda/kzg_lookup.cuh | 4 + crates/proof-compression/cuda/kzg_passes.cuh | 7 + .../proof-compression/cuda/kzg_quotient.cuh | 15 +- .../proof-compression/cuda/kzg_transfer.cuh | 8 + .../cuda/plonk/blst-source-manifest.json | 155 + crates/proof-compression/cuda/plonk/build.py | 241 + .../proof-compression/cuda/plonk/exports.map | 4 + .../cuda/plonk/ix_plonk_bn254.h | 146 + .../patches/sppark-split-buckets-v1.json | 15 + .../patches/sppark-split-buckets-v1.patch | 360 ++ crates/proof-compression/cuda/plonk/plonk.cu | 828 +++ .../proof-compression/cuda/plonk/runtime.cpp | 2 + .../cuda/plonk/sppark-source-manifest.json | 103 + .../cuda/plonk/sppark_namespace.h | 23 + .../examples/kzg_backend_parity.rs | 266 + .../proof-compression/examples/kzg_dev_srs.rs | 40 + .../examples/kzg_ignition_import.rs | 188 + .../examples/kzg_stage_resources.rs | 133 + .../examples/recursive_size.rs | 174 + .../src/bin/ix_compression_worker.rs | 5 +- crates/proof-compression/src/foreign.rs | 4 +- .../proof-compression/src/kzg/coefficients.rs | 2 +- crates/proof-compression/src/kzg/compact.rs | 29 +- crates/proof-compression/src/kzg/config.rs | 24 +- crates/proof-compression/src/kzg/cuda.rs | 91 +- crates/proof-compression/src/kzg/cuda/abi.rs | 91 + .../proof-compression/src/kzg/cuda/lookup.rs | 26 +- .../proof-compression/src/kzg/cuda/passes.rs | 11 + .../src/kzg/cuda/quotient.rs | 34 +- .../src/kzg/cuda/srs_cache.rs | 21 +- .../src/kzg/cuda/srs_cache/tests.rs | 120 + .../proof-compression/src/kzg/cuda/tests.rs | 186 +- .../src/kzg/cuda/tests/constraints.rs | 237 + .../src/kzg/cuda/tests/plonk_scale.rs | 585 ++ crates/proof-compression/src/kzg/curve.rs | 145 + crates/proof-compression/src/kzg/domain.rs | 41 +- crates/proof-compression/src/kzg/encoding.rs | 7 +- crates/proof-compression/src/kzg/fft_batch.rs | 2 +- crates/proof-compression/src/kzg/field.rs | 59 +- crates/proof-compression/src/kzg/mod.rs | 3 +- crates/proof-compression/src/kzg/pcs.rs | 137 +- crates/proof-compression/src/kzg/srs.rs | 66 +- .../proof-compression/src/kzg/srs/ignition.rs | 260 + .../src/kzg/srs/ignition/admission.rs | 607 ++ .../src/kzg/srs/ignition/cache.rs | 421 ++ .../src/kzg/srs/ignition/points.rs | 106 + .../src/kzg/srs/ignition/source.rs | 461 ++ .../src/kzg/srs/ignition/tests.rs | 608 ++ crates/proof-compression/src/kzg/srs_cache.rs | 29 +- .../proof-compression/src/kzg/transcript.rs | 47 +- .../src/kzg/verifier_params.rs | 63 +- crates/proof-compression/src/lib.rs | 6 +- .../proof-compression/src/pipeline/driver.rs | 245 + .../proof-compression/src/pipeline/first.rs | 76 +- .../src/pipeline/fixed_cache.rs | 3 + .../src/pipeline/init_fri.rs | 4 +- crates/proof-compression/src/pipeline/mod.rs | 277 +- .../proof-compression/src/pipeline/plonk.rs | 261 +- crates/proof-compression/src/pipeline/root.rs | 17 +- .../proof-compression/src/pipeline/setup.rs | 366 +- .../src/pipeline/setup/ignition_tests.rs | 292 + .../src/pipeline/stages/codec.rs | 42 +- .../src/pipeline/stages/mod.rs | 7 +- .../src/pipeline/stages/setup.rs | 4 + .../src/pipeline/stages/tests.rs | 7 + .../src/pipeline/stages/verification.rs | 165 +- .../src/pipeline/stages/wrap.rs | 33 +- .../src/pipeline/verifier.rs | 170 +- .../src/pipeline/wrap/mod.rs | 2 - .../src/pipeline/wrap/outer.rs | 423 +- .../{relation/plonk.rs => plonk_export.rs} | 56 +- .../{relation/plonk => plonk_export}/ir.rs | 0 .../src/pipeline/wrap/outer/relation.rs | 92 +- .../pipeline/wrap/outer/relation/codegen.rs | 33 +- .../src/pipeline/wrap/saved.rs | 1 + crates/proof-compression/src/recursive/mod.rs | 1 + .../src/recursive/native_curve.rs | 128 +- .../src/recursive/native_field.rs | 130 +- .../src/recursive/native_field/advice.rs | 20 +- .../recursive/native_field/advice/tests.rs | 53 +- .../src/recursive/native_msm.rs | 93 +- .../src/recursive/native_transcript.rs | 145 +- .../src/recursive/native_verifier.rs | 56 +- .../src/recursive/native_verifier/bindings.rs | 15 +- .../native_verifier/parallel_prefix_tests.rs | 18 +- .../src/recursive/native_verifier/plan.rs | 29 +- .../recursive/native_verifier/reuse_tests.rs | 113 +- .../src/recursive/native_verifier/shape.rs | 2 +- .../src/recursive/pairing.rs | 118 + crates/proof-compression/tests/bn254_kzg.rs | 223 + .../tests/kzg_transcripts.rs | 1 + .../proof-compression/tests/worker_tracing.rs | 58 + docs/proof-benchmark-comparison.md | 105 + flake.nix | 73 +- lakefile.lean | 127 +- tests/check_compression_build.py | 110 + tests/compression_build_config.lean | 17 + tests/kzg_backend_parity.py | 171 + tests/run_gpu_stage.py | 143 + tools/bls-plonk-wrapper/README.md | 219 +- tools/bls-plonk-wrapper/artifacts.go | 2 +- tools/bls-plonk-wrapper/backend.go | 75 +- tools/bls-plonk-wrapper/backend_bn254_test.go | 154 + tools/bls-plonk-wrapper/backend_cpu.go | 28 + tools/bls-plonk-wrapper/backend_cuda_bn254.go | 96 + .../backend_cuda_bn254_test.go | 99 + .../bls-plonk-wrapper/backend_no_cuda_test.go | 15 + .../backend_selection_test.go | 27 + tools/bls-plonk-wrapper/backend_test.go | 2 + tools/bls-plonk-wrapper/blake3_test.go | 11 +- tools/bls-plonk-wrapper/build_cuda.py | 222 + tools/bls-plonk-wrapper/circuit.go | 102 +- tools/bls-plonk-wrapper/circuit_bn254_test.go | 173 + tools/bls-plonk-wrapper/circuit_test.go | 2 + .../bls-plonk-wrapper/cuda_init_bn254_test.go | 334 ++ .../cuda_msm_benchmark_bn254_test.go | 176 + .../cuda_msm_replay_bn254_test.go | 149 + .../cuda_profile_bn254_test.go | 436 ++ tools/bls-plonk-wrapper/curve_bls.go | 98 + tools/bls-plonk-wrapper/curve_bn254.go | 116 + tools/bls-plonk-wrapper/internal/cuda/abi.go | 243 + .../internal/cuda/accelerator.go | 454 ++ .../internal/cuda/accelerator_test.go | 546 ++ .../internal/cuda/masking_test.go | 83 + .../internal/cuda/split_boundaries_test.go | 195 + .../internal/cuda/structured_test.go | 90 + .../bls-plonk-wrapper/internal/cuda/trace.go | 64 + .../lagrange_import_bn254.go | 237 + .../lagrange_import_bn254_test.go | 138 + tools/bls-plonk-wrapper/main.go | 13 +- .../msm_capture_bn254_test.go | 373 ++ tools/bls-plonk-wrapper/optimization_test.go | 81 + tools/bls-plonk-wrapper/pairing_batch.go | 83 + .../pairing_batch_bn254_test.go | 267 + tools/bls-plonk-wrapper/parameters.go | 66 +- .../parameters_bn254_test.go | 90 + tools/bls-plonk-wrapper/parameters_test.go | 2 + .../patches/gnark-cuda-generator-v1.patch | 801 +++ .../patches/gnark-cuda-v1.json | 26 + .../patches/gnark-cuda-v1.patch | 1261 ++++ tools/bls-plonk-wrapper/profile.go | 43 +- tools/bls-plonk-wrapper/sizing_test.go | 31 +- .../testdata/bn254/profile.json | 5048 +++++++++++++++++ .../testdata/bn254/provenance.json | 17 + .../bls-plonk-wrapper/testdata/bn254/srs.bin | Bin 0 -> 8728 bytes .../bls-plonk-wrapper/testdata/bn254/srs.json | 8 + .../testdata/bn254/witness-diagnostic.json | 38 + .../testdata/bn254/witness.json | 114 + .../bls-plonk-wrapper/verifier_bn254_test.go | 80 + 169 files changed, 28266 insertions(+), 920 deletions(-) create mode 100644 contracts/plonk/bn254/.gitignore create mode 100644 contracts/plonk/bn254/bench.py create mode 100644 contracts/plonk/bn254/provenance.json create mode 100644 contracts/plonk/bn254/src/PlonkVerifier.sol create mode 100644 contracts/plonk/bn254/testdata/init.json create mode 100644 contracts/plonk/bn254/upstream/ISP1Verifier.sol create mode 100644 contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol create mode 100644 contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol create mode 100644 crates/proof-compression/cuda/kzg_conformance.cuh create mode 100644 crates/proof-compression/cuda/kzg_curve.cuh create mode 100644 crates/proof-compression/cuda/plonk/blst-source-manifest.json create mode 100644 crates/proof-compression/cuda/plonk/build.py create mode 100644 crates/proof-compression/cuda/plonk/exports.map create mode 100644 crates/proof-compression/cuda/plonk/ix_plonk_bn254.h create mode 100644 crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json create mode 100644 crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch create mode 100644 crates/proof-compression/cuda/plonk/plonk.cu create mode 100644 crates/proof-compression/cuda/plonk/runtime.cpp create mode 100644 crates/proof-compression/cuda/plonk/sppark-source-manifest.json create mode 100644 crates/proof-compression/cuda/plonk/sppark_namespace.h create mode 100644 crates/proof-compression/examples/kzg_backend_parity.rs create mode 100644 crates/proof-compression/examples/kzg_dev_srs.rs create mode 100644 crates/proof-compression/examples/kzg_ignition_import.rs create mode 100644 crates/proof-compression/examples/kzg_stage_resources.rs create mode 100644 crates/proof-compression/examples/recursive_size.rs create mode 100644 crates/proof-compression/src/kzg/cuda/abi.rs create mode 100644 crates/proof-compression/src/kzg/cuda/srs_cache/tests.rs create mode 100644 crates/proof-compression/src/kzg/cuda/tests/constraints.rs create mode 100644 crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs create mode 100644 crates/proof-compression/src/kzg/curve.rs create mode 100644 crates/proof-compression/src/kzg/srs/ignition.rs create mode 100644 crates/proof-compression/src/kzg/srs/ignition/admission.rs create mode 100644 crates/proof-compression/src/kzg/srs/ignition/cache.rs create mode 100644 crates/proof-compression/src/kzg/srs/ignition/points.rs create mode 100644 crates/proof-compression/src/kzg/srs/ignition/source.rs create mode 100644 crates/proof-compression/src/kzg/srs/ignition/tests.rs create mode 100644 crates/proof-compression/src/pipeline/driver.rs create mode 100644 crates/proof-compression/src/pipeline/setup/ignition_tests.rs rename crates/proof-compression/src/pipeline/wrap/outer/{relation/plonk.rs => plonk_export.rs} (73%) rename crates/proof-compression/src/pipeline/wrap/outer/{relation/plonk => plonk_export}/ir.rs (100%) create mode 100644 crates/proof-compression/src/recursive/pairing.rs create mode 100644 crates/proof-compression/tests/bn254_kzg.rs create mode 100644 docs/proof-benchmark-comparison.md create mode 100644 tests/check_compression_build.py create mode 100644 tests/compression_build_config.lean create mode 100644 tests/kzg_backend_parity.py create mode 100644 tests/run_gpu_stage.py create mode 100644 tools/bls-plonk-wrapper/backend_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/backend_cpu.go create mode 100644 tools/bls-plonk-wrapper/backend_cuda_bn254.go create mode 100644 tools/bls-plonk-wrapper/backend_cuda_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/backend_no_cuda_test.go create mode 100644 tools/bls-plonk-wrapper/backend_selection_test.go create mode 100644 tools/bls-plonk-wrapper/build_cuda.py create mode 100644 tools/bls-plonk-wrapper/circuit_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/cuda_init_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/cuda_profile_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/curve_bls.go create mode 100644 tools/bls-plonk-wrapper/curve_bn254.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/abi.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/accelerator.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/masking_test.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/structured_test.go create mode 100644 tools/bls-plonk-wrapper/internal/cuda/trace.go create mode 100644 tools/bls-plonk-wrapper/lagrange_import_bn254.go create mode 100644 tools/bls-plonk-wrapper/lagrange_import_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/msm_capture_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/optimization_test.go create mode 100644 tools/bls-plonk-wrapper/pairing_batch.go create mode 100644 tools/bls-plonk-wrapper/pairing_batch_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/parameters_bn254_test.go create mode 100644 tools/bls-plonk-wrapper/patches/gnark-cuda-generator-v1.patch create mode 100644 tools/bls-plonk-wrapper/patches/gnark-cuda-v1.json create mode 100644 tools/bls-plonk-wrapper/patches/gnark-cuda-v1.patch create mode 100644 tools/bls-plonk-wrapper/testdata/bn254/profile.json create mode 100644 tools/bls-plonk-wrapper/testdata/bn254/provenance.json create mode 100644 tools/bls-plonk-wrapper/testdata/bn254/srs.bin create mode 100644 tools/bls-plonk-wrapper/testdata/bn254/srs.json create mode 100644 tools/bls-plonk-wrapper/testdata/bn254/witness-diagnostic.json create mode 100644 tools/bls-plonk-wrapper/testdata/bn254/witness.json create mode 100644 tools/bls-plonk-wrapper/verifier_bn254_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc3db20cb..911e5f297 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,39 @@ jobs: run: cargo check --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile - name: Tests run: cargo nextest run --release --profile ci --workspace ${{ github.event_name == 'push' && '--no-run' || '' }} + - name: Check BN254 CPU KZG + run: cargo clippy --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel,plonk --lib --tests --example recursive_size --example kzg_ignition_import -- -D warnings + - name: Test BN254 CPU KZG + run: | + cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ + --test bn254_kzg --test kzg_transcripts --test kzg_sharding --test kzg_streaming \ + ${{ github.event_name == 'push' && '--no-run' || '' }} + cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ + --lib kzg:: ${{ github.event_name == 'push' && '--no-run' || '' }} -- \ + --skip development_powers_match_naive_at_chunk_boundaries \ + --skip disk_coefficients_match_resident_and_detect_changes \ + --skip chunked_commitment_matches_native_msm_with_tail \ + --skip field_stream_matches_arkworks_across_blocks + - name: Test BN254 recursive verifier + run: | + for recursive_filter in native_field:: native_curve:: native_transcript:: native_msm:: pairing:: \ + native_verifier::parallel_prefix_tests:: \ + native_verifier::reuse_tests::recursive_witness_rejects_tampering_without_native_preverification \ + native_verifier::reuse_tests::recursive_profile_rejects_identity_anchors_and_oversized_layout_caps; do + cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ + --lib "recursive::$recursive_filter" ${{ github.event_name == 'push' && '--no-run' || '' }} + done + - name: Test BN254 worker and portable profiles + run: | + cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ + --test worker_tracing ${{ github.event_name == 'push' && '--no-run' || '' }} + for pipeline_filter in driver:: tests:: setup:: stages::codec:: stages::verification:: stages::wrap:: \ + verifier::bn254_tests:: wrap::outer::tests::fused_profiles_prove_and_portable_verifiers_round_trip; do + cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ + --lib "pipeline::$pipeline_filter" ${{ github.event_name == 'push' && '--no-run' || '' }} + done + - name: Test BN254 PLONK catalog + run: "cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel,plonk --lib pipeline::plonk:: ${{ github.event_name == 'push' && '--no-run' || '' }}" - name: Get Rust version if: github.event_name != 'push' run: | diff --git a/Cargo.lock b/Cargo.lock index 24538d778..42b3c7242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,17 @@ dependencies = [ "ark-std", ] +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + [[package]] name = "ark-ec" version = "0.5.0" @@ -2086,6 +2097,7 @@ version = "0.1.0" dependencies = [ "aiur", "ark-bls12-381", + "ark-bn254", "ark-ec", "ark-ff", "ark-poly", diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 557f1ee15..38c6d20ea 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -10,7 +10,7 @@ namespace Ix.Cli.Compression opaque checkClaim (claim statement : @& ByteArray) : IO Unit /-- Select development parameters when `devSrs` is set; otherwise require -the configured Filecoin cache. -/ +authenticated production parameters for the compiled curve. -/ @[extern "rs_compression_setup"] opaque describeSetup (devSrs : Bool) : IO String @@ -34,7 +34,7 @@ def cacheDir : IO System.FilePath := do -- Reserve the family prefix, including unknown versions, so they fail closed. def isCompressed (proof : ByteArray) : Bool := - proof.extract 0 5 == "IXCMP".toUTF8 + proof.extract 0 5 == "IXCMP".toUTF8 || proof.extract 0 4 == "IXBN".toUTF8 def readProof (addr : Address) : IO Ixon.Proof := do let bytes ← StoreIO.toIO (Store.read addr) diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index 497387c8c..9a0971555 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -26,6 +26,9 @@ private def rejects (home : System.FilePath) (args : Array String) (message : St throw <| IO.userError s!"expected rejection ({message}): {args}\n{out.stdout}\n{out.stderr}" public def run : IO Unit := do + for tag in ["IXCMP", "IXBN"] do + unless Compression.isCompressed tag.toUTF8 do + throw <| IO.userError s!"unrecognized compressed proof family: {tag}" let home ← IO.FS.createTempDir try let help ← invoke home #["compress", "--help"] diff --git a/contracts/plonk/README.md b/contracts/plonk/README.md index e3dbd2e9e..1c6b6f9cc 100644 --- a/contracts/plonk/README.md +++ b/contracts/plonk/README.md @@ -2,8 +2,8 @@ `IxPlonkVerifier` binds a caller-supplied expected claim digest to two little-endian 128-bit public inputs and calls a fixed gnark verifier address. -The gnark key fixes the complete BLS12-381 wrapper circuit, including both -inner pairing obligations. The contract requires EIP-2537 support. +The gnark key fixes the complete wrapper circuit, including both inner pairing +obligations. BLS12-381 uses EIP-2537; BN254 uses Ethereum's BN254 precompiles. Use `PlonkVerifier.sol` and `proof.solidity.bin` emitted by the helper for the reviewed wrapper key. Deploy the generated verifier and pass its address to @@ -28,3 +28,84 @@ Prague bytecode and Osaka execution rules. Keep deployment, external-call gas and total transaction gas separate in benchmark results. The target for a full wrapper transaction is at most 500k gas and at least 25% below the validated optimized direct verifier. + +## Actual BN254 Init proof on Anvil + +The actual `2^24` BN254 Init proof passes the adapted SP1 v6.1.0 PLONK verifier +and the ix claim-digest adapter on local Anvil. This uses the 14,689,440-constraint +classic-GLV circuit with both inner pairing checks, not the tiny backend fixture. + +The untouched SP1 verifier rejects the proof. Its fixed circuit key uses five +public inputs and domain `2^25`; ix uses two inputs and domain `2^24`. The test +also supplies five inputs to the stock low-level verifier and correctly framed +SP1 selector/exit-code/vk-root/nonce bytes to its high-level facade. Those paths +reject cryptographically; the facade returns `InvalidProof()`. + +Only **23 `VK_*` constants** differ between the adapted contract and SP1's +low-level contract: public-input count, domain/inverse/root, circuit commitments +and the commitment-API index. The verification algorithm, SRS anchors, proof +layout and transcript logic are unchanged. With the pragma normalized, the +adapted source equals the verifier emitted by our pinned gnark helper byte for +byte. The existing ix adapter supplies the two little-endian 128-bit limbs of +the independently expected claim digest, instead of SP1's five-field public +statement mapping. The proof itself is unchanged. + +Source: [SP1 v6.1.0 verifier](https://github.com/succinctlabs/sp1-contracts/blob/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/v6.1.0/PlonkVerifier.sol). +The original sources, licenses and hashes are retained under `bn254/upstream`; +`bn254/provenance.json` records every changed constant and the authenticated ix +key/proof identities. `bn254/src/PlonkVerifier.sol` is specific to this +benchmark circuit, not an interchangeable SP1 verification key. + +### Receipt measurements + +Measured on 2026-10-17 with Foundry/Anvil 1.8.5, solc 0.8.37, via-IR, optimizer +200 runs, Prague bytecode and Osaka execution rules. Deployment-size limits +were enabled. These are mined transactions on a fresh local chain: + +| Path | Execution gas | Intrinsic/calldata gas | Total receipt gas | ABI calldata | +| --- | ---: | ---: | ---: | ---: | +| Adapted SP1 `Verify(bytes,uint256[])` | 279,915 | 36,088 | **316,003** | 1,060 bytes | +| ix `verify(bytes,bytes32)` adapter | 284,435 | 35,668 | **320,103** | 964 bytes | + +The Solidity proof is **864 bytes**. The native 584-byte encoding and 624-byte +ix envelope are storage/interchange formats and are rejected by this Solidity +interface. Both successful calls return `true`; transaction traces confirm the +return value as well as the receipt status. The totals exceed the EIP-7623 +calldata floor, so the execution/intrinsic split above applies. + +The adapted verifier has **7,531 bytes** of runtime code and **7,557 bytes** of +initcode; deployment used **1,681,283 gas**. The adapter has **913 runtime bytes** +and **1,159 initcode bytes including its constructor argument**; deployment used +**255,356 gas**. All fit ordinary EIP-170/EIP-3860 limits. These deployment costs +are excluded from verification gas. + +The valid trace calls SHA-256 nine times, MODEXP six times, BN254 scalar +multiplication 21 times, BN254 addition 21 times and the two-pair BN254 pairing +precompile once. Their combined precompile gas is 257,366. Both inner pairing +obligations are already constrained by the PLONK circuit; no extra application +pairing calls are required. + +All **47 acceptance/rejection checks passed**: stock-key incompatibility, +positive raw/adapter verification, both digest halves, swapped/missing/extra or +noncanonical public inputs, empty/truncated/trailing proofs, wrong proof formats, +mutations of all 27 proof words, and noncanonical point/scalar encodings. Every +call uses the same 2M gas bound, including the positive controls. The test uses +only an isolated local Anvil chain and shuts down its own node after completion. + +### Reproduce + +Use the hash-pinned Foundry and Solidity binaries documented in +[`../kzg/README.md`](../kzg/README.md#run-local-checks). No prover, proving key, +SRS download or new proof generation is needed: + +```sh +python3 contracts/plonk/bn254/bench.py \ + --tools target/toolchains/solidity \ + --out target/plonk-evm/replay +``` + +The output directory must be new. Compilation input/output, deployment and +verification receipts, call traces, negative-call results and the Anvil log are +saved there. The completed run is `target/plonk-evm/run-final/report.json`. +This validates the exported circuit key and fixture; production applications +still select their intended key and obtain the expected digest independently. diff --git a/contracts/plonk/bn254/.gitignore b/contracts/plonk/bn254/.gitignore new file mode 100644 index 000000000..c18dd8d83 --- /dev/null +++ b/contracts/plonk/bn254/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/contracts/plonk/bn254/bench.py b/contracts/plonk/bn254/bench.py new file mode 100644 index 000000000..d0c0ee5b7 --- /dev/null +++ b/contracts/plonk/bn254/bench.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Verify the pinned Init proof with stock and adapted SP1 contracts on local Anvil.""" + +import argparse +import hashlib +import json +import re +import socket +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +R = 21888242871839275222246405745257275088548364400416034343698204186575808495617 +P = 21888242871839275222246405745257275088696311157297823662689037894645226208583 +CALL_GAS = 2_000_000 + + +def read(path): + return json.loads(path.read_text()) + + +def write(path, value): + path.write_text(json.dumps(value, indent=2) + '\n') + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def word(value): + return value.to_bytes(32, 'big') + + +def blob(value): + return word(len(value)) + value + bytes((-len(value)) % 32) + + +class RpcError(Exception): + def __init__(self, error): + super().__init__(json.dumps(error)) + self.error = error + + +class Rpc: + def __init__(self, url): + self.url = url + self.sequence = 0 + + def __call__(self, method, params=None): + self.sequence += 1 + body = json.dumps({'jsonrpc': '2.0', 'id': self.sequence, + 'method': method, 'params': params or []}).encode() + request = urllib.request.Request(self.url, body, {'Content-Type': 'application/json'}) + with urllib.request.urlopen(request, timeout=30) as response: + result = json.load(response) + if 'error' in result: + raise RpcError(result['error']) + return result['result'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--tools', type=Path, required=True, help='directory containing solc, anvil and cast') + parser.add_argument('--out', type=Path, required=True, help='new directory for compiler output and receipts') + args = parser.parse_args() + args.tools = args.tools.resolve() + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=False) + versions = {name: subprocess.check_output([str(args.tools / name), '--version'], text=True).strip() + for name in ['solc', 'anvil', 'cast']} + assert '0.8.37+' in versions['solc'] and '1.8.5' in versions['anvil'], versions + fixture_path = ROOT / 'testdata/init.json' + fixture, provenance = read(fixture_path), read(ROOT / 'provenance.json') + assert sha(fixture_path.read_bytes()) == provenance['adaptation']['fixture_sha256'] + proof = bytes.fromhex(fixture['proof'][2:]) + digest = bytes.fromhex(fixture['claimDigest'][2:]) + claim = bytes.fromhex(fixture['claim'][2:]) + assert len(proof) == 864 and len(digest) == 32 + assert sha(proof) == provenance['adaptation']['proof_solidity_sha256'] + assert fixture['verifyingKeySHA256'] == provenance['adaptation']['verifying_key_sha256'] + for name, record in provenance['files'].items(): + directory = 'upstream' if name == 'ISP1Verifier.sol' else 'upstream/v6.1.0' + assert sha((ROOT / directory / name).read_bytes()) == record['sha256'], name + source = (ROOT / 'src/PlonkVerifier.sol').read_text() + assert sha(source.encode()) == provenance['adaptation']['adapted_source_sha256'] + stock_source = (ROOT / 'upstream/v6.1.0/PlonkVerifier.sol').read_text() + substitutions = provenance['adaptation']['changed_constants'] + pattern = r'(uint256 private constant )(VK_\w+)( = )([^;]+)(;)' + adapted = re.sub(pattern, lambda m: m[1] + m[2] + m[3] + substitutions.get(m[2], {'ix': m[4]})['ix'] + m[5], stock_source) + assert adapted == source, 'adaptation changed more than the pinned key constants' + assert sha(source.replace('pragma solidity ^0.8.20;', 'pragma solidity ^0.8.0;').encode()) == provenance['adaptation']['generated_gnark_source_sha256'] + sources = {str(p.relative_to(ROOT)): {'content': p.read_text()} + for p in [ROOT / 'src/PlonkVerifier.sol', ROOT / 'upstream/ISP1Verifier.sol', + ROOT / 'upstream/v6.1.0/PlonkVerifier.sol', ROOT / 'upstream/v6.1.0/SP1VerifierPlonk.sol']} + sources['IxPlonkVerifier.sol'] = {'content': (ROOT.parent / 'src/IxPlonkVerifier.sol').read_text()} + compiler = {'language': 'Solidity', 'sources': sources, 'settings': { + 'optimizer': {'enabled': True, 'runs': 200}, 'viaIR': True, 'evmVersion': 'prague', + 'outputSelection': {'*': {'*': ['abi', 'evm.bytecode.object', 'evm.deployedBytecode.object']}}}} + write(out / 'compiler-input.json', compiler) + print('Compiling stock SP1, adapted SP1 and ix adapter contracts.', flush=True) + compiled = subprocess.run([str(args.tools / 'solc'), '--standard-json'], + input=json.dumps(compiler), capture_output=True, text=True, check=True) + (out / 'compiler-output.json').write_text(compiled.stdout) + output = json.loads(compiled.stdout) + errors = [e for e in output.get('errors', []) if e['severity'] == 'error'] + assert not errors, errors + + def selector(signature): + return bytes.fromhex(subprocess.check_output([str(args.tools / 'cast'), 'sig', signature], text=True).strip()[2:]) + + verify_selector = selector('Verify(bytes,uint256[])') + adapter_selector = selector('verify(bytes,bytes32)') + facade_selector = selector('verifyProof(bytes32,bytes,bytes)') + inputs = [int.from_bytes(digest[:16], 'little'), int.from_bytes(digest[16:], 'little')] + + def raw_call(payload=proof, public=inputs): + encoded = blob(payload) + return verify_selector + word(64) + word(64 + len(encoded)) + encoded + word(len(public)) + b''.join(word(x) for x in public) + + def adapter_call(payload=proof, expected=digest): + return adapter_selector + word(64) + expected + blob(payload) + + report = {'format': 'ix-bn254-plonk-anvil-v1', 'toolchain': versions, + 'compiler_settings': compiler['settings'], 'execution_hardfork': 'osaka', + 'fixture_sha256': sha(fixture_path.read_bytes()), 'verifying_key_sha256': fixture['verifyingKeySHA256'], + 'claim_digest': fixture['claimDigest'], 'sp1_commit': provenance['commit'], + 'changed_vk_constants': list(substitutions), 'verifier_algorithm_unchanged': True, + 'proof_solidity_bytes': len(proof), 'native_proof_bytes': len(bytes.fromhex(fixture['nativeProof'][2:])), + 'envelope_bytes': len(bytes.fromhex(fixture['envelope'][2:])), + 'deployments': {}, 'checks': {}, 'transactions': {}, 'call_gas_limit': CALL_GAS} + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + port = listener.getsockname()[1] + rpc = Rpc(f'http://127.0.0.1:{port}') + anvil_log = (out / 'anvil.log').open('w') + process = subprocess.Popen([str(args.tools / 'anvil'), '--host', '127.0.0.1', '--port', str(port), + '--hardfork', 'osaka', '--chain-id', '31337', '--gas-limit', '30000000', + '--steps-tracing'], stdout=anvil_log, stderr=subprocess.STDOUT) + try: + for _ in range(100): + if process.poll() is not None: + raise RuntimeError('Anvil exited before becoming ready') + try: + if rpc('eth_chainId') == hex(31337): + break + except (OSError, urllib.error.URLError): + time.sleep(0.1) + else: + raise RuntimeError('Anvil did not become ready') + report['client_version'] = rpc('web3_clientVersion') + account = rpc('eth_accounts')[0] + + def transaction(name, data, address=None, gas=CALL_GAS): + tx = {'from': account, 'data': '0x' + data.hex(), 'gas': hex(gas)} + if address is not None: + tx['to'] = address + txid = rpc('eth_sendTransaction', [tx]) + for _ in range(100): + receipt = rpc('eth_getTransactionReceipt', [txid]) + if receipt is not None: + break + time.sleep(0.05) + else: + raise RuntimeError('missing transaction receipt') + assert int(receipt['status'], 16) == 1, (name, receipt) + write(out / f'{name}-receipt.json', receipt) + result = {'transaction_hash': txid, 'gas_used': int(receipt['gasUsed'], 16), 'calldata_bytes': len(data)} + if address is not None: + zeros = data.count(0) + intrinsic = 21000 + 4 * zeros + 16 * (len(data) - zeros) + floor = 21000 + 10 * (zeros + 4 * (len(data) - zeros)) + assert result['gas_used'] > floor + result.update(intrinsic_calldata_gas=intrinsic, eip7623_floor_gas=floor, + execution_gas=result['gas_used'] - intrinsic) + trace = rpc('debug_traceTransaction', [txid, {'tracer': 'callTracer'}]) + assert trace.get('output') == '0x' + word(1).hex(), (name, trace) + write(out / f'{name}-trace.json', trace) + precompiles = [] + def collect(call): + address_value = int(call.get('to', '0x0'), 16) + if 1 <= address_value <= 17: + precompiles.append({'address': address_value, 'gas_used': int(call['gasUsed'], 16), + 'input_bytes': len(bytes.fromhex(call.get('input', '0x')[2:]))}) + for child in call.get('calls', []): + collect(child) + collect(trace) + result['precompile_calls'] = precompiles + report['transactions'][name] = result + return receipt, result + + def deploy(name, file, contract, arguments=b''): + artifact = output['contracts'][file][contract]['evm'] + init = bytes.fromhex(artifact['bytecode']['object']) + arguments + runtime = bytes.fromhex(artifact['deployedBytecode']['object']) + assert len(init) <= 49152 and len(runtime) <= 24576, (name, len(init), len(runtime)) + receipt, result = transaction('deploy-' + name, init, gas=10_000_000) + address = receipt['contractAddress'] + deployed = bytes.fromhex(rpc('eth_getCode', [address, 'latest'])[2:]) + assert len(deployed) == len(runtime) + result.update(address=address, initcode_bytes=len(init), runtime_bytes=len(deployed), runtime_sha256=sha(deployed)) + report['deployments'][name] = result + return address + + stock = deploy('stock-sp1', 'upstream/v6.1.0/PlonkVerifier.sol', 'PlonkVerifier') + facade = deploy('stock-sp1-facade', 'upstream/v6.1.0/SP1VerifierPlonk.sol', 'SP1Verifier') + adapted_address = deploy('adapted-sp1', 'src/PlonkVerifier.sol', 'PlonkVerifier') + adapter = deploy('ix-adapter', 'IxPlonkVerifier.sol', 'IxPlonkVerifier', word(int(adapted_address, 16))) + + def evaluate(name, address, data, expected, returns_bool=True): + try: + result = rpc('eth_call', [{'to': address, 'from': account, 'data': '0x' + data.hex(), 'gas': hex(CALL_GAS)}, 'latest']) + if returns_bool: + assert result in ['0x' + word(0).hex(), '0x' + word(1).hex()], (name, result) + accepted = result == '0x' + word(1).hex() + else: + assert result == '0x', (name, result) + accepted = True + record = {'accepted': accepted, 'result': result} + except RpcError as error: + message = json.dumps(error.error).lower() + assert 'revert' in message, (name, error.error) + accepted = False + record = {'accepted': False, 'reverted': True, 'error': error.error} + assert accepted == expected, (name, record) + report['checks'][name] = record + + evaluate('stock-rejects-two-ix-inputs', stock, raw_call(), False) + evaluate('stock-rejects-proof-with-five-inputs', stock, raw_call(public=inputs + [0, 0, 0]), False) + verifier_hash = bytes.fromhex(rpc('eth_call', [{'to': facade, 'data': '0x' + selector('VERIFIER_HASH()').hex()}, 'latest'])[2:]) + vk_root = bytes.fromhex(rpc('eth_call', [{'to': facade, 'data': '0x' + selector('VK_ROOT()').hex()}, 'latest'])[2:]) + framed = verifier_hash[:4] + word(0) + vk_root + word(0) + proof + public_values = blob(claim) + framed_call = facade_selector + word(1) + word(96) + word(96 + len(public_values)) + public_values + blob(framed) + evaluate('stock-facade-rejects-correctly-framed-ix-proof', facade, framed_call, False, returns_bool=False) + assert report['checks']['stock-facade-rejects-correctly-framed-ix-proof']['error']['data'] == '0x' + selector('InvalidProof()').hex() + evaluate('adapted-sp1-accepts-init', adapted_address, raw_call(), True) + evaluate('ix-adapter-accepts-init', adapter, adapter_call(), True) + transaction('adapted-sp1-verify', raw_call(), adapted_address) + transaction('ix-adapter-verify', adapter_call(), adapter) + print('Actual Init proof accepted by adapted SP1 verifier and ix adapter.', flush=True) + + for index in [0, 15, 16, 31]: + wrong = bytearray(digest) + wrong[index] ^= 1 + evaluate(f'changed-claim-byte-{index}', adapter, adapter_call(expected=bytes(wrong)), False) + for name, public in [('swapped-inputs', inputs[::-1]), ('missing-input', inputs[:1]), + ('extra-input', inputs + [0]), ('noncanonical-input', [R, inputs[1]])]: + evaluate(name, adapted_address, raw_call(public=public), False) + for name, payload in [('empty-proof', b''), ('truncated-proof', proof[:-1]), ('trailing-proof', proof + b'\x00'), + ('native-format', bytes.fromhex(fixture['nativeProof'][2:])), + ('ix-envelope-format', bytes.fromhex(fixture['envelope'][2:]))]: + evaluate(name, adapter, adapter_call(payload=payload), False) + for index in range(len(proof) // 32): + wrong = bytearray(proof) + wrong[32 * index + 31] ^= 1 + evaluate(f'changed-proof-word-{index}', adapter, adapter_call(payload=bytes(wrong)), False) + for name, offset, modulus in [('noncanonical-point', 0, P), ('noncanonical-scalar', 0x180, R)]: + wrong = bytearray(proof) + wrong[offset:offset+32] = word(modulus) + evaluate(name, adapter, adapter_call(payload=bytes(wrong)), False) + report['all_checks_passed'] = True + report['acceptance_scope'] = 'Actual Init proof, independently fixed expected claim digest and pinned circuit verification key; isolated local Anvil only.' + write(out / 'report.json', report) + print(json.dumps({'transactions': {k: {field: value for field, value in v.items() if field != 'precompile_calls'} for k, v in report['transactions'].items()}, + 'deployments': report['deployments'], 'checks_passed': len(report['checks'])}, indent=2), flush=True) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + assert process.poll() is not None + anvil_log.close() + + +if __name__ == '__main__': + main() diff --git a/contracts/plonk/bn254/provenance.json b/contracts/plonk/bn254/provenance.json new file mode 100644 index 000000000..b3314008a --- /dev/null +++ b/contracts/plonk/bn254/provenance.json @@ -0,0 +1,125 @@ +{ + "repository": "https://github.com/succinctlabs/sp1-contracts", + "default_branch": "main", + "commit": "d3629729c3216eb51bd4859d027a8eb729399fa4", + "files": { + "PlonkVerifier.sol": { + "url": "https://raw.githubusercontent.com/succinctlabs/sp1-contracts/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/v6.1.0/PlonkVerifier.sol", + "sha256": "0263d7fd2b47d41798ac6093188cc39a7cf1c9bfbd4cde6294f3999c41bbd6f9" + }, + "SP1VerifierPlonk.sol": { + "url": "https://raw.githubusercontent.com/succinctlabs/sp1-contracts/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/v6.1.0/SP1VerifierPlonk.sol", + "sha256": "23484564ed34dd6b083f591d52c311a5567951f32b2322e7036e804cc782dd04" + }, + "ISP1Verifier.sol": { + "url": "https://raw.githubusercontent.com/succinctlabs/sp1-contracts/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/ISP1Verifier.sol", + "sha256": "9e918032a5aa799c1319b14b013154d6a40ca6e5f2267c9f540560abd7fd7689" + } + }, + "adaptation": { + "changed_constants": { + "VK_NB_PUBLIC_INPUTS": { + "sp1": "5", + "ix": "2" + }, + "VK_DOMAIN_SIZE": { + "sp1": "33554432", + "ix": "16777216" + }, + "VK_INV_DOMAIN_SIZE": { + "sp1": "21888242219518804655518433051623070663413851959604507555939307129453691614729", + "ix": "21888241567198334088790460357988866238279339518792980768180410072331574733841" + }, + "VK_OMEGA": { + "sp1": "19200870435978225707111062059747084165650991997241425080699860725083300967194", + "ix": "5709868443893258075976348696661355716898495876243883251619397131511003808859" + }, + "VK_QL_COM_X": { + "sp1": "9496857109770518741292064488682135861248962674494967861604446614769298140801", + "ix": "5309045740510031945991932551018364072006311610820055731927692072309170001839" + }, + "VK_QL_COM_Y": { + "sp1": "13306697158707148893187289359474517489309092662132847111865615083807847780686", + "ix": "15178165910664754266902111074643904424063340409370666177336989271314478243182" + }, + "VK_QR_COM_X": { + "sp1": "15168106396031721196861946917417531048941395918951726120971774887904231224033", + "ix": "4333128755228398984050452785484293868085136011195057857131447951644517246358" + }, + "VK_QR_COM_Y": { + "sp1": "17067422783799132011484353829344785446718705273302151466551760455276544179973", + "ix": "451943038604306803478917644270289509587617153611512448210003017134715864094" + }, + "VK_QM_COM_X": { + "sp1": "2062503189072358517228813627364112801206468481541880929627061212524046867500", + "ix": "13654492804778691259164665408002942238210394757487282492782630714578010550105" + }, + "VK_QM_COM_Y": { + "sp1": "16383264006058748048241814683452691377147195760917894158624764699848260181996", + "ix": "4170689851748144627682006991866933098380421530894955798140164640479630857422" + }, + "VK_QO_COM_X": { + "sp1": "11276993554321744761007348475396375004546042576992247443165667159541648019615", + "ix": "2970551836734429030743170272625326962021756571268061179291098770692736928725" + }, + "VK_QO_COM_Y": { + "sp1": "1336009123797329867458955655460754684518485281155722686318249389277951918969", + "ix": "14253370184471031778663230296712970012512019999228359328162740821710798273297" + }, + "VK_QK_COM_X": { + "sp1": "3509076513963323471275614870100244642947645425705684850560588726111445186717", + "ix": "21087794933144987813778627740507349690142120785431442678765829888195147140486" + }, + "VK_QK_COM_Y": { + "sp1": "15082251669986564403690950092410766520944967428195940447691297074393449013243", + "ix": "18664134873073965629410443508646780836583853861541542336381733986376705446547" + }, + "VK_S1_COM_X": { + "sp1": "20702610951114261233096944021168607816381642857429919420395478944329365232425", + "ix": "3160083449751345232768572700367928453171558982969385372902272237051111945683" + }, + "VK_S1_COM_Y": { + "sp1": "6287924284852772991087729385363871923831267251255140888671281832443192104403", + "ix": "3173893476425843604675313553504367870971306498389983578189512126041225303974" + }, + "VK_S2_COM_X": { + "sp1": "645470009383750670501092774958765334436477314405650592677994973080307449345", + "ix": "12972998826842100335667323871952459614736937618220985729849619557275074897278" + }, + "VK_S2_COM_Y": { + "sp1": "4256397297118091181646823265799511434474377494065149067487129126410465669924", + "ix": "15584446085400045812352698340044777040401645716047298734682398815882560235895" + }, + "VK_S3_COM_X": { + "sp1": "7492538199234778964992190797161279888333633666731339557560747528992222778140", + "ix": "11850267738493914463571045673465636487389201930732259686382873306579288151723" + }, + "VK_S3_COM_Y": { + "sp1": "19883641962643068517995924785061749749272042967551599977561118882192215161378", + "ix": "12366451455213994943132905838640349406977921177620674929449191152457830777150" + }, + "VK_QCP_0_X": { + "sp1": "21276573233863489069809736193483015861331560639958294945226697354328765942402", + "ix": "8403212342070192176300521556178609155662477728821447177638870487361910854373" + }, + "VK_QCP_0_Y": { + "sp1": "21667749868347388386642256911557299806363086510301513644953688310359382808300", + "ix": "3273252539024198469180603526521578769816238206567705742708524768856957917807" + }, + "VK_INDEX_COMMIT_API_0": { + "sp1": "25304569", + "ix": "5919900" + } + }, + "algorithm_unchanged": true, + "generated_gnark_source_sha256": "6f3b0d598d3b08a925575b1494c58a4267852bb9fbd29cb1e9179005f45edfd4", + "adapted_source_sha256": "feb3dc9770417b30ac16e99ffe592f4b94126108fae29263c2d36d102720d65a", + "verifying_key_sha256": "4f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff368", + "proof_solidity_sha256": "9eb6e46e1d89e72b6983d1c5b2c36e68c5085b4d42e3cfa47e81b4e3a8690100", + "native_proof_sha256": "06084a415648171e93bc638e49c111f75e67ef24c24186543d91e9398f42b438", + "claim_blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", + "fixture_sha256": "b195b17be039346e2b47bdbde57c380a32e58193c2e6bfc498b406e2d68c65ed", + "gnark": "262cf453c107d147a228b6b7418c5cf791c3b79b", + "benchmark_report": "target/bn254-plonk-2p24/report.json" + } +} diff --git a/contracts/plonk/bn254/src/PlonkVerifier.sol b/contracts/plonk/bn254/src/PlonkVerifier.sol new file mode 100644 index 000000000..e9e029b1e --- /dev/null +++ b/contracts/plonk/bn254/src/PlonkVerifier.sol @@ -0,0 +1,1341 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 Consensys Software Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by gnark DO NOT EDIT + +pragma solidity ^0.8.20; + +contract PlonkVerifier { + + uint256 private constant R_MOD = 21888242871839275222246405745257275088548364400416034343698204186575808495617; + uint256 private constant R_MOD_MINUS_ONE = 21888242871839275222246405745257275088548364400416034343698204186575808495616; + uint256 private constant P_MOD = 21888242871839275222246405745257275088696311157297823662689037894645226208583; + + uint256 private constant G2_SRS_0_X_0 = 11559732032986387107991004021392285783925812861821192530917403151452391805634; + uint256 private constant G2_SRS_0_X_1 = 10857046999023057135944570762232829481370756359578518086990519993285655852781; + uint256 private constant G2_SRS_0_Y_0 = 4082367875863433681332203403145435568316851327593401208105741076214120093531; + uint256 private constant G2_SRS_0_Y_1 = 8495653923123431417604973247489272438418190587263600148770280649306958101930; + + uint256 private constant G2_SRS_1_X_0 = 15805639136721018565402881920352193254830339253282065586954346329754995870280; + uint256 private constant G2_SRS_1_X_1 = 19089565590083334368588890253123139704298730990782503769911324779715431555531; + uint256 private constant G2_SRS_1_Y_0 = 9779648407879205346559610309258181044130619080926897934572699915909528404984; + uint256 private constant G2_SRS_1_Y_1 = 6779728121489434657638426458390319301070371227460768374343986326751507916979; + + uint256 private constant G1_SRS_X = 14312776538779914388377568895031746459131577658076416373430523308756343304251; + uint256 private constant G1_SRS_Y = 11763105256161367503191792604679297387056316997144156930871823008787082098465; + + // ----------------------- vk --------------------- + uint256 private constant VK_NB_PUBLIC_INPUTS = 2; + uint256 private constant VK_DOMAIN_SIZE = 16777216; + uint256 private constant VK_INV_DOMAIN_SIZE = 21888241567198334088790460357988866238279339518792980768180410072331574733841; + uint256 private constant VK_OMEGA = 5709868443893258075976348696661355716898495876243883251619397131511003808859; + uint256 private constant VK_QL_COM_X = 5309045740510031945991932551018364072006311610820055731927692072309170001839; + uint256 private constant VK_QL_COM_Y = 15178165910664754266902111074643904424063340409370666177336989271314478243182; + uint256 private constant VK_QR_COM_X = 4333128755228398984050452785484293868085136011195057857131447951644517246358; + uint256 private constant VK_QR_COM_Y = 451943038604306803478917644270289509587617153611512448210003017134715864094; + uint256 private constant VK_QM_COM_X = 13654492804778691259164665408002942238210394757487282492782630714578010550105; + uint256 private constant VK_QM_COM_Y = 4170689851748144627682006991866933098380421530894955798140164640479630857422; + uint256 private constant VK_QO_COM_X = 2970551836734429030743170272625326962021756571268061179291098770692736928725; + uint256 private constant VK_QO_COM_Y = 14253370184471031778663230296712970012512019999228359328162740821710798273297; + uint256 private constant VK_QK_COM_X = 21087794933144987813778627740507349690142120785431442678765829888195147140486; + uint256 private constant VK_QK_COM_Y = 18664134873073965629410443508646780836583853861541542336381733986376705446547; + + uint256 private constant VK_S1_COM_X = 3160083449751345232768572700367928453171558982969385372902272237051111945683; + uint256 private constant VK_S1_COM_Y = 3173893476425843604675313553504367870971306498389983578189512126041225303974; + + uint256 private constant VK_S2_COM_X = 12972998826842100335667323871952459614736937618220985729849619557275074897278; + uint256 private constant VK_S2_COM_Y = 15584446085400045812352698340044777040401645716047298734682398815882560235895; + + uint256 private constant VK_S3_COM_X = 11850267738493914463571045673465636487389201930732259686382873306579288151723; + uint256 private constant VK_S3_COM_Y = 12366451455213994943132905838640349406977921177620674929449191152457830777150; + + uint256 private constant VK_COSET_SHIFT = 5; + + + uint256 private constant VK_QCP_0_X = 8403212342070192176300521556178609155662477728821447177638870487361910854373; + uint256 private constant VK_QCP_0_Y = 3273252539024198469180603526521578769816238206567705742708524768856957917807; + + + uint256 private constant VK_INDEX_COMMIT_API_0 = 5919900; + uint256 private constant VK_NB_CUSTOM_GATES = 1; + + // ------------------------------------------------ + + // size of the proof without call custom gate + uint256 private constant FIXED_PROOF_SIZE = 0x300; + + // offset proof + + uint256 private constant PROOF_L_COM_X = 0x0; + uint256 private constant PROOF_L_COM_Y = 0x20; + uint256 private constant PROOF_R_COM_X = 0x40; + uint256 private constant PROOF_R_COM_Y = 0x60; + uint256 private constant PROOF_O_COM_X = 0x80; + uint256 private constant PROOF_O_COM_Y = 0xa0; + + // h = h_0 + x^{n+2}h_1 + x^{2(n+2)}h_2 + uint256 private constant PROOF_H_0_COM_X = 0xc0; + uint256 private constant PROOF_H_0_COM_Y = 0xe0; + uint256 private constant PROOF_H_1_COM_X = 0x100; + uint256 private constant PROOF_H_1_COM_Y = 0x120; + uint256 private constant PROOF_H_2_COM_X = 0x140; + uint256 private constant PROOF_H_2_COM_Y = 0x160; + + // "evaluations of wire polynomials at zeta + uint256 private constant PROOF_L_AT_ZETA = 0x180; + uint256 private constant PROOF_R_AT_ZETA = 0x1a0; + uint256 private constant PROOF_O_AT_ZETA = 0x1c0; + + // S1(zeta),S2(zeta) + uint256 private constant PROOF_S1_AT_ZETA = 0x1e0; // Sσ1(zeta) + uint256 private constant PROOF_S2_AT_ZETA = 0x200; // Sσ2(zeta) + + // [Z] + uint256 private constant PROOF_GRAND_PRODUCT_COMMITMENT_X = 0x220; + uint256 private constant PROOF_GRAND_PRODUCT_COMMITMENT_Y = 0x240; + + uint256 private constant PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA = 0x260; // z(w*zeta) + + // Folded proof for the opening of linearised poly, l, r, o, s_1, s_2, qcp + uint256 private constant PROOF_BATCH_OPENING_AT_ZETA_X = 0x280; + uint256 private constant PROOF_BATCH_OPENING_AT_ZETA_Y = 0x2a0; + + uint256 private constant PROOF_OPENING_AT_ZETA_OMEGA_X = 0x2c0; + uint256 private constant PROOF_OPENING_AT_ZETA_OMEGA_Y = 0x2e0; + + uint256 private constant PROOF_OPENING_QCP_AT_ZETA = 0x300; + uint256 private constant PROOF_BSB_COMMITMENTS = 0x320; + + // -------- offset state + + // challenges to check the claimed quotient + + uint256 private constant STATE_ALPHA = 0x0; + uint256 private constant STATE_BETA = 0x20; + uint256 private constant STATE_GAMMA = 0x40; + uint256 private constant STATE_ZETA = 0x60; + uint256 private constant STATE_ALPHA_SQUARE_LAGRANGE_0 = 0x80; + uint256 private constant STATE_FOLDED_H_X = 0xa0; + uint256 private constant STATE_FOLDED_H_Y = 0xc0; + uint256 private constant STATE_LINEARISED_POLYNOMIAL_X = 0xe0; + uint256 private constant STATE_LINEARISED_POLYNOMIAL_Y = 0x100; + uint256 private constant STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA = 0x120; + uint256 private constant STATE_FOLDED_CLAIMED_VALUES = 0x140; // Folded proof for the opening of H, linearised poly, l, r, o, s_1, s_2, qcp + uint256 private constant STATE_FOLDED_DIGESTS_X = 0x160; // linearised poly, l, r, o, s_1, s_2, qcp + uint256 private constant STATE_FOLDED_DIGESTS_Y = 0x180; + uint256 private constant STATE_PI = 0x1a0; + uint256 private constant STATE_ZETA_POWER_N_MINUS_ONE = 0x1c0; + uint256 private constant STATE_GAMMA_KZG = 0x1e0; + uint256 private constant STATE_SUCCESS = 0x200; + uint256 private constant STATE_CHECK_VAR = 0x220; // /!\ this slot is used for debugging only + uint256 private constant STATE_LAST_MEM = 0x240; + + // -------- utils (for Fiat Shamir) + uint256 private constant FS_ALPHA = 0x616C706861; // "alpha" + uint256 private constant FS_BETA = 0x62657461; // "beta" + uint256 private constant FS_GAMMA = 0x67616d6d61; // "gamma" + uint256 private constant FS_ZETA = 0x7a657461; // "zeta" + uint256 private constant FS_GAMMA_KZG = 0x67616d6d61; // "gamma" + + // -------- errors + uint256 private constant ERROR_STRING_ID = 0x08c379a000000000000000000000000000000000000000000000000000000000; // selector for function Error(string) + + + // -------- utils (for hash_fr) + uint256 private constant HASH_FR_BB = 340282366920938463463374607431768211456; // 2**128 + uint256 private constant HASH_FR_ZERO_UINT256 = 0; + uint8 private constant HASH_FR_LEN_IN_BYTES = 48; + uint8 private constant HASH_FR_SIZE_DOMAIN = 11; + uint8 private constant HASH_FR_ONE = 1; + uint8 private constant HASH_FR_TWO = 2; + + + // -------- precompiles + uint8 private constant SHA2 = 0x2; + uint8 private constant MOD_EXP = 0x5; + uint8 private constant EC_ADD = 0x6; + uint8 private constant EC_MUL = 0x7; + uint8 private constant EC_PAIR = 0x8; + + /// Verify a Plonk proof. + /// Reverts if the proof or the public inputs are malformed. + /// @param proof serialised plonk proof (using gnark's MarshalSolidity) + /// @param public_inputs (must be reduced) + /// @return success true if the proof passes false otherwise + function Verify(bytes calldata proof, uint256[] calldata public_inputs) + public view returns(bool success) { + + assembly { + + let mem := mload(0x40) + let freeMem := add(mem, STATE_LAST_MEM) + + // sanity checks + check_number_of_public_inputs(public_inputs.length) + check_inputs_size(public_inputs.length, public_inputs.offset) + check_proof_size(proof.length) + check_proof_openings_size(proof.offset) + + // compute the challenges + let prev_challenge_non_reduced + prev_challenge_non_reduced := derive_gamma(proof.offset, public_inputs.length, public_inputs.offset) + prev_challenge_non_reduced := derive_beta(prev_challenge_non_reduced) + prev_challenge_non_reduced := derive_alpha(proof.offset, prev_challenge_non_reduced) + derive_zeta(proof.offset, prev_challenge_non_reduced) + + // evaluation of Z=Xⁿ-1 at ζ, we save this value + let zeta := mload(add(mem, STATE_ZETA)) + let zeta_power_n_minus_one := addmod(pow(zeta, VK_DOMAIN_SIZE, freeMem), sub(R_MOD, 1), R_MOD) + mstore(add(mem, STATE_ZETA_POWER_N_MINUS_ONE), zeta_power_n_minus_one) + + // public inputs contribution + let l_pi := sum_pi_wo_api_commit(public_inputs.offset, public_inputs.length, freeMem) + let l_pi_commit := sum_pi_commit(proof.offset, public_inputs.length, freeMem) + l_pi := addmod(l_pi_commit, l_pi, R_MOD) + mstore(add(mem, STATE_PI), l_pi) + + compute_alpha_square_lagrange_0() + compute_opening_linearised_polynomial(proof.offset) + fold_h(proof.offset) + compute_commitment_linearised_polynomial(proof.offset) + compute_gamma_kzg(proof.offset) + fold_state(proof.offset) + batch_verify_multi_points(proof.offset) + + success := mload(add(mem, STATE_SUCCESS)) + + // Beginning errors ------------------------------------------------- + + function error_nb_public_inputs() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x1d) + mstore(add(ptError, 0x44), "wrong number of public inputs") + revert(ptError, 0x64) + } + + /// Called when an exponentiation mod r fails + function error_mod_exp() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0xc) + mstore(add(ptError, 0x44), "error mod exp") + revert(ptError, 0x64) + } + + /// Called when an operation on Bn254 fails + /// @dev for instance when calling EcMul on a point not on Bn254. + function error_ec_op() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x12) + mstore(add(ptError, 0x44), "error ec operation") + revert(ptError, 0x64) + } + + /// Called when one of the public inputs is not reduced. + function error_inputs_size() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x18) + mstore(add(ptError, 0x44), "inputs are bigger than r") + revert(ptError, 0x64) + } + + /// Called when the size proof is not as expected + /// @dev to avoid overflow attack for instance + function error_proof_size() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x10) + mstore(add(ptError, 0x44), "wrong proof size") + revert(ptError, 0x64) + } + + /// Called when one the openings is bigger than r + /// The openings are the claimed evalutions of a polynomial + /// in a Kzg proof. + function error_proof_openings_size() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x16) + mstore(add(ptError, 0x44), "openings bigger than r") + revert(ptError, 0x64) + } + + function error_pairing() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0xd) + mstore(add(ptError, 0x44), "error pairing") + revert(ptError, 0x64) + } + + function error_verify() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0xc) + mstore(add(ptError, 0x44), "error verify") + revert(ptError, 0x64) + } + + function error_random_generation() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x14) + mstore(add(ptError, 0x44), "error random gen kzg") + revert(ptError, 0x64) + } + // end errors ------------------------------------------------- + + // Beginning checks ------------------------------------------------- + + /// @param s actual number of public inputs + function check_number_of_public_inputs(s) { + if iszero(eq(s, VK_NB_PUBLIC_INPUTS)) { + error_nb_public_inputs() + } + } + + /// Checks that the public inputs are < R_MOD. + /// @param s number of public inputs + /// @param p pointer to the public inputs array + function check_inputs_size(s, p) { + for {let i} lt(i, s) {i:=add(i,1)} + { + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_inputs_size() + } + p := add(p, 0x20) + } + } + + /// Checks if the proof is of the correct size + /// @param actual_proof_size size of the proof (not the expected size) + function check_proof_size(actual_proof_size) { + let expected_proof_size := add(FIXED_PROOF_SIZE, mul(VK_NB_CUSTOM_GATES,0x60)) + if iszero(eq(actual_proof_size, expected_proof_size)) { + error_proof_size() + } + } + + /// Checks if the multiple openings of the polynomials are < R_MOD. + /// @param aproof pointer to the beginning of the proof + /// @dev the 'a' prepending proof is to have a local name + function check_proof_openings_size(aproof) { + + // PROOF_L_AT_ZETA + let p := add(aproof, PROOF_L_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_R_AT_ZETA + p := add(aproof, PROOF_R_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_O_AT_ZETA + p := add(aproof, PROOF_O_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_S1_AT_ZETA + p := add(aproof, PROOF_S1_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_S2_AT_ZETA + p := add(aproof, PROOF_S2_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA + p := add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_OPENING_QCP_AT_ZETA + + p := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + for {let i:=0} lt(i, VK_NB_CUSTOM_GATES) {i:=add(i,1)} + { + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + p := add(p, 0x20) + } + + } + // end checks ------------------------------------------------- + + // Beginning challenges ------------------------------------------------- + + /// Derive gamma as Sha256() + /// @param aproof pointer to the proof + /// @param nb_pi number of public inputs + /// @param pi pointer to the array of public inputs + /// @return the challenge gamma, not reduced + /// @notice The transcript is the concatenation (in this order) of: + /// * the word "gamma" in ascii, equal to [0x67,0x61,0x6d, 0x6d, 0x61] and encoded as a uint256. + /// * the commitments to the permutation polynomials S1, S2, S3, where we concatenate the coordinates of those points + /// * the commitments of Ql, Qr, Qm, Qo, Qk + /// * the public inputs + /// * the commitments of the wires related to the custom gates (commitments_wires_commit_api) + /// * commitments to L, R, O (proof__com_) + /// The data described above is written starting at mPtr. "gamma" lies on 5 bytes, + /// and is encoded as a uint256 number n. In basis b = 256, the number looks like this + /// [0 0 0 .. 0x67 0x61 0x6d, 0x6d, 0x61]. The first non zero entry is at position 27=0x1b + /// Gamma reduced (the actual challenge) is stored at add(state, state_gamma) + function derive_gamma(aproof, nb_pi, pi)->gamma_not_reduced { + + let state := mload(0x40) + let mPtr := add(state, STATE_LAST_MEM) + + mstore(mPtr, FS_GAMMA) // "gamma" + + + mstore(add(mPtr, 0x20), VK_S1_COM_X) + mstore(add(mPtr, 0x40), VK_S1_COM_Y) + mstore(add(mPtr, 0x60), VK_S2_COM_X) + mstore(add(mPtr, 0x80), VK_S2_COM_Y) + mstore(add(mPtr, 0xa0), VK_S3_COM_X) + mstore(add(mPtr, 0xc0), VK_S3_COM_Y) + mstore(add(mPtr, 0xe0), VK_QL_COM_X) + mstore(add(mPtr, 0x100), VK_QL_COM_Y) + mstore(add(mPtr, 0x120), VK_QR_COM_X) + mstore(add(mPtr, 0x140), VK_QR_COM_Y) + mstore(add(mPtr, 0x160), VK_QM_COM_X) + mstore(add(mPtr, 0x180), VK_QM_COM_Y) + mstore(add(mPtr, 0x1a0), VK_QO_COM_X) + mstore(add(mPtr, 0x1c0), VK_QO_COM_Y) + mstore(add(mPtr, 0x1e0), VK_QK_COM_X) + mstore(add(mPtr, 0x200), VK_QK_COM_Y) + + mstore(add(mPtr, 0x220), VK_QCP_0_X) + mstore(add(mPtr, 0x240), VK_QCP_0_Y) + + // public inputs + let _mPtr := add(mPtr, 0x260) + let size_pi_in_bytes := mul(nb_pi, 0x20) + calldatacopy(_mPtr, pi, size_pi_in_bytes) + _mPtr := add(_mPtr, size_pi_in_bytes) + + // commitments to l, r, o + let size_commitments_lro_in_bytes := 0xc0 + calldatacopy(_mPtr, aproof, size_commitments_lro_in_bytes) + _mPtr := add(_mPtr, size_commitments_lro_in_bytes) + + // total size is : + // sizegamma(=0x5) + 11*64(=0x2c0) + // + nb_public_inputs*0x20 + // + nb_custom gates*0x40 + let size := add(0x2c5, size_pi_in_bytes) + + size := add(size, mul(VK_NB_CUSTOM_GATES, 0x40)) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1b), size, mPtr, 0x20) //0x1b -> 000.."gamma" + if iszero(l_success) { + error_verify() + } + gamma_not_reduced := mload(mPtr) + mstore(add(state, STATE_GAMMA), mod(gamma_not_reduced, R_MOD)) + } + + /// derive beta as Sha256 + /// @param gamma_not_reduced the previous challenge (gamma) not reduced + /// @return beta_not_reduced the next challenge, beta, not reduced + /// @notice the transcript consists of the previous challenge only. + /// The reduced version of beta is stored at add(state, state_beta) + function derive_beta(gamma_not_reduced)->beta_not_reduced{ + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + // beta + mstore(mPtr, FS_BETA) // "beta" + mstore(add(mPtr, 0x20), gamma_not_reduced) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1c), 0x24, mPtr, 0x20) //0x1b -> 000.."gamma" + if iszero(l_success) { + error_verify() + } + beta_not_reduced := mload(mPtr) + mstore(add(state, STATE_BETA), mod(beta_not_reduced, R_MOD)) + } + + /// derive alpha as sha256 + /// @param aproof pointer to the proof object + /// @param beta_not_reduced the previous challenge (beta) not reduced + /// @return alpha_not_reduced the next challenge, alpha, not reduced + /// @notice the transcript consists of the previous challenge (beta) + /// not reduced, the commitments to the wires associated to the QCP_i, + /// and the commitment to the grand product polynomial + function derive_alpha(aproof, beta_not_reduced)->alpha_not_reduced { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + let full_size := 0x65 // size("alpha") + 0x20 (previous challenge) + + // alpha + mstore(mPtr, FS_ALPHA) // "alpha" + let _mPtr := add(mPtr, 0x20) + mstore(_mPtr, beta_not_reduced) + _mPtr := add(_mPtr, 0x20) + + // Bsb22Commitments + let proof_bsb_commitments := add(aproof, PROOF_BSB_COMMITMENTS) + let size_bsb_commitments := mul(0x40, VK_NB_CUSTOM_GATES) + calldatacopy(_mPtr, proof_bsb_commitments, size_bsb_commitments) + _mPtr := add(_mPtr, size_bsb_commitments) + full_size := add(full_size, size_bsb_commitments) + + // [Z], the commitment to the grand product polynomial + calldatacopy(_mPtr, add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X), 0x40) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1b), full_size, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + + alpha_not_reduced := mload(mPtr) + mstore(add(state, STATE_ALPHA), mod(alpha_not_reduced, R_MOD)) + } + + /// derive zeta as sha256 + /// @param aproof pointer to the proof object + /// @param alpha_not_reduced the previous challenge (alpha) not reduced + /// The transcript consists of the previous challenge and the commitment to + /// the quotient polynomial h. + function derive_zeta(aproof, alpha_not_reduced) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + // zeta + mstore(mPtr, FS_ZETA) // "zeta" + mstore(add(mPtr, 0x20), alpha_not_reduced) + calldatacopy(add(mPtr, 0x40), add(aproof, PROOF_H_0_COM_X), 0xc0) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1c), 0xe4, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + let zeta_not_reduced := mload(mPtr) + mstore(add(state, STATE_ZETA), mod(zeta_not_reduced, R_MOD)) + } + // END challenges ------------------------------------------------- + + // BEGINNING compute_pi ------------------------------------------------- + + /// sum_pi_wo_api_commit computes the public inputs contributions, + /// except for the public inputs coming from the custom gate + /// @param ins pointer to the public inputs + /// @param n number of public inputs + /// @param mPtr free memory + /// @return pi_wo_commit public inputs contribution (except the public inputs coming from the custom gate) + function sum_pi_wo_api_commit(ins, n, mPtr)->pi_wo_commit { + + let state := mload(0x40) + let z := mload(add(state, STATE_ZETA)) + let zpnmo := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) + + let li := mPtr + batch_compute_lagranges_at_z(z, zpnmo, n, li) + + let tmp := 0 + for {let i:=0} lt(i,n) {i:=add(i,1)} + { + tmp := mulmod(mload(li), calldataload(ins), R_MOD) + pi_wo_commit := addmod(pi_wo_commit, tmp, R_MOD) + li := add(li, 0x20) + ins := add(ins, 0x20) + } + + } + + /// batch_compute_lagranges_at_z computes [L_0(z), .., L_{n-1}(z)] + /// @param z point at which the Lagranges are evaluated + /// @param zpnmo ζⁿ-1 + /// @param n_pub number of public inputs (number of Lagranges to compute) + /// @param mPtr pointer to which the results are stored + function batch_compute_lagranges_at_z(z, zpnmo, n_pub, mPtr) { + + let zn := mulmod(zpnmo, VK_INV_DOMAIN_SIZE, R_MOD) // 1/n * (ζⁿ - 1) + + let _w := 1 + let _mPtr := mPtr + for {let i:=0} lt(i,n_pub) {i:=add(i,1)} + { + mstore(_mPtr, addmod(z,sub(R_MOD, _w), R_MOD)) + _w := mulmod(_w, VK_OMEGA, R_MOD) + _mPtr := add(_mPtr, 0x20) + } + batch_invert(mPtr, n_pub, _mPtr) + _mPtr := mPtr + _w := 1 + for {let i:=0} lt(i,n_pub) {i:=add(i,1)} + { + mstore(_mPtr, mulmod(mulmod(mload(_mPtr), zn , R_MOD), _w, R_MOD)) + _mPtr := add(_mPtr, 0x20) + _w := mulmod(_w, VK_OMEGA, R_MOD) + } + } + + /// @notice Montgomery trick for batch inversion mod R_MOD + /// @param ins pointer to the data to batch invert + /// @param number of elements to batch invert + /// @param mPtr free memory + function batch_invert(ins, nb_ins, mPtr) { + mstore(mPtr, 1) + let offset := 0 + for {let i:=0} lt(i, nb_ins) {i:=add(i,1)} + { + let prev := mload(add(mPtr, offset)) + let cur := mload(add(ins, offset)) + cur := mulmod(prev, cur, R_MOD) + offset := add(offset, 0x20) + mstore(add(mPtr, offset), cur) + } + ins := add(ins, sub(offset, 0x20)) + mPtr := add(mPtr, offset) + let inv := pow(mload(mPtr), sub(R_MOD,2), add(mPtr, 0x20)) + for {let i:=0} lt(i, nb_ins) {i:=add(i,1)} + { + mPtr := sub(mPtr, 0x20) + let tmp := mload(ins) + let cur := mulmod(inv, mload(mPtr), R_MOD) + mstore(ins, cur) + inv := mulmod(inv, tmp, R_MOD) + ins := sub(ins, 0x20) + } + } + + + /// Public inputs (the ones coming from the custom gate) contribution + /// @param aproof pointer to the proof + /// @param nb_public_inputs number of public inputs + /// @param mPtr pointer to free memory + /// @return pi_commit custom gate public inputs contribution + function sum_pi_commit(aproof, nb_public_inputs, mPtr)->pi_commit { + + let state := mload(0x40) + let z := mload(add(state, STATE_ZETA)) + let zpnmo := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) + + let p := add(aproof, PROOF_BSB_COMMITMENTS) + + let h_fr, ith_lagrange + + + h_fr := hash_fr(calldataload(p), calldataload(add(p, 0x20)), mPtr) + ith_lagrange := compute_ith_lagrange_at_z(z, zpnmo, add(nb_public_inputs, VK_INDEX_COMMIT_API_0), mPtr) + pi_commit := addmod(pi_commit, mulmod(h_fr, ith_lagrange, R_MOD), R_MOD) + + + + } + + /// Computes L_i(zeta) = ωⁱ/n * (ζⁿ-1)/(ζ-ωⁱ) where: + /// @param z zeta + /// @param zpmno ζⁿ-1 + /// @param i i-th lagrange + /// @param mPtr free memory + /// @return res = ωⁱ/n * (ζⁿ-1)/(ζ-ωⁱ) + function compute_ith_lagrange_at_z(z, zpnmo, i, mPtr)->res { + + let w := pow(VK_OMEGA, i, mPtr) // w**i + i := addmod(z, sub(R_MOD, w), R_MOD) // z-w**i + w := mulmod(w, VK_INV_DOMAIN_SIZE, R_MOD) // w**i/n + i := pow(i, sub(R_MOD,2), mPtr) // (z-w**i)**-1 + w := mulmod(w, i, R_MOD) // w**i/n*(z-w)**-1 + res := mulmod(w, zpnmo, R_MOD) + + } + + /// @dev https://tools.ietf.org/html/draft-irtf-cfrg-hash-to-curve-06#section-5.2 + /// @param x x coordinate of a point on Bn254(𝔽_p) + /// @param y y coordinate of a point on Bn254(𝔽_p) + /// @param mPtr free memory + /// @return res an element mod R_MOD + function hash_fr(x, y, mPtr)->res { + + // [0x00, .. , 0x00 || x, y, || 0, 48, 0, dst, HASH_FR_SIZE_DOMAIN] + // <- 64 bytes -> <-64b -> <- 1 bytes each -> + + // [0x00, .., 0x00] 64 bytes of zero + mstore(mPtr, HASH_FR_ZERO_UINT256) + mstore(add(mPtr, 0x20), HASH_FR_ZERO_UINT256) + + // msg = x || y , both on 32 bytes + mstore(add(mPtr, 0x40), x) + mstore(add(mPtr, 0x60), y) + + // 0 || 48 || 0 all on 1 byte + mstore8(add(mPtr, 0x80), 0) + mstore8(add(mPtr, 0x81), HASH_FR_LEN_IN_BYTES) + mstore8(add(mPtr, 0x82), 0) + + // "BSB22-Plonk" = [42, 53, 42, 32, 32, 2d, 50, 6c, 6f, 6e, 6b,] + mstore8(add(mPtr, 0x83), 0x42) + mstore8(add(mPtr, 0x84), 0x53) + mstore8(add(mPtr, 0x85), 0x42) + mstore8(add(mPtr, 0x86), 0x32) + mstore8(add(mPtr, 0x87), 0x32) + mstore8(add(mPtr, 0x88), 0x2d) + mstore8(add(mPtr, 0x89), 0x50) + mstore8(add(mPtr, 0x8a), 0x6c) + mstore8(add(mPtr, 0x8b), 0x6f) + mstore8(add(mPtr, 0x8c), 0x6e) + mstore8(add(mPtr, 0x8d), 0x6b) + + // size domain + mstore8(add(mPtr, 0x8e), HASH_FR_SIZE_DOMAIN) + + let l_success := staticcall(gas(), SHA2, mPtr, 0x8f, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + + let b0 := mload(mPtr) + + // [b0 || one || dst || HASH_FR_SIZE_DOMAIN] + // <-64bytes -> <- 1 byte each -> + mstore8(add(mPtr, 0x20), HASH_FR_ONE) // 1 + + mstore8(add(mPtr, 0x21), 0x42) // dst + mstore8(add(mPtr, 0x22), 0x53) + mstore8(add(mPtr, 0x23), 0x42) + mstore8(add(mPtr, 0x24), 0x32) + mstore8(add(mPtr, 0x25), 0x32) + mstore8(add(mPtr, 0x26), 0x2d) + mstore8(add(mPtr, 0x27), 0x50) + mstore8(add(mPtr, 0x28), 0x6c) + mstore8(add(mPtr, 0x29), 0x6f) + mstore8(add(mPtr, 0x2a), 0x6e) + mstore8(add(mPtr, 0x2b), 0x6b) + + mstore8(add(mPtr, 0x2c), HASH_FR_SIZE_DOMAIN) // size domain + l_success := staticcall(gas(), SHA2, mPtr, 0x2d, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + + // b1 is located at mPtr. We store b2 at add(mPtr, 0x20) + + // [b0^b1 || two || dst || HASH_FR_SIZE_DOMAIN] + // <-64bytes -> <- 1 byte each -> + mstore(add(mPtr, 0x20), xor(mload(mPtr), b0)) + mstore8(add(mPtr, 0x40), HASH_FR_TWO) + + mstore8(add(mPtr, 0x41), 0x42) // dst + mstore8(add(mPtr, 0x42), 0x53) + mstore8(add(mPtr, 0x43), 0x42) + mstore8(add(mPtr, 0x44), 0x32) + mstore8(add(mPtr, 0x45), 0x32) + mstore8(add(mPtr, 0x46), 0x2d) + mstore8(add(mPtr, 0x47), 0x50) + mstore8(add(mPtr, 0x48), 0x6c) + mstore8(add(mPtr, 0x49), 0x6f) + mstore8(add(mPtr, 0x4a), 0x6e) + mstore8(add(mPtr, 0x4b), 0x6b) + + mstore8(add(mPtr, 0x4c), HASH_FR_SIZE_DOMAIN) // size domain + + let offset := add(mPtr, 0x20) + l_success := staticcall(gas(), SHA2, offset, 0x2d, offset, 0x20) + if iszero(l_success) { + error_verify() + } + + // at this point we have mPtr = [ b1 || b2] where b1 is on 32byes and b2 in 16bytes. + // we interpret it as a big integer mod r in big endian (similar to regular decimal notation) + // the result is then 2**(8*16)*mPtr[:32] + mPtr[32:48] + res := mulmod(mload(mPtr), HASH_FR_BB, R_MOD) // <- res = 2**128 * mPtr[:32] + let b1 := shr(128, mload(add(mPtr, 0x20))) // b1 <- [0, 0, .., 0 || b2[:16] ] + res := addmod(res, b1, R_MOD) + + } + + // END compute_pi ------------------------------------------------- + + /// @notice compute α² * 1/n * (ζ{n}-1)/(ζ - 1) where + /// * α = challenge derived in derive_gamma_beta_alpha_zeta + /// * n = vk_domain_size + /// * ω = vk_omega (generator of the multiplicative cyclic group of order n in (ℤ/rℤ)*) + /// * ζ = zeta (challenge derived with Fiat Shamir) + function compute_alpha_square_lagrange_0() { + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + let res := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) + let den := addmod(mload(add(state, STATE_ZETA)), sub(R_MOD, 1), R_MOD) + den := pow(den, sub(R_MOD, 2), mPtr) + den := mulmod(den, VK_INV_DOMAIN_SIZE, R_MOD) + res := mulmod(den, res, R_MOD) + + let l_alpha := mload(add(state, STATE_ALPHA)) + res := mulmod(res, l_alpha, R_MOD) + res := mulmod(res, l_alpha, R_MOD) + mstore(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0), res) + } + + /// @notice follows alg. p.13 of https://eprint.iacr.org/2019/953.pdf + /// with t₁ = t₂ = 1, and the proofs are ([digest] + [quotient] +purported evaluation): + /// * [state_folded_state_digests], [proof_batch_opening_at_zeta_x], state_folded_evals + /// * [proof_grand_product_commitment], [proof_opening_at_zeta_omega_x], [proof_grand_product_at_zeta_omega] + /// @param aproof pointer to the proof + function batch_verify_multi_points(aproof) { + let state := mload(0x40) + let mPtr := add(state, STATE_LAST_MEM) + + // derive a random number. As there is no random generator, we + // do an FS like challenge derivation, depending on both digests and + // ζ to ensure that the prover cannot control the random number. + // Note: adding the other point ζω is not needed, as ω is known beforehand. + mstore(mPtr, mload(add(state, STATE_FOLDED_DIGESTS_X))) + mstore(add(mPtr, 0x20), mload(add(state, STATE_FOLDED_DIGESTS_Y))) + mstore(add(mPtr, 0x40), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X))) + mstore(add(mPtr, 0x60), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_Y))) + mstore(add(mPtr, 0x80), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X))) + mstore(add(mPtr, 0xa0), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_Y))) + mstore(add(mPtr, 0xc0), calldataload(add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X))) + mstore(add(mPtr, 0xe0), calldataload(add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_Y))) + mstore(add(mPtr, 0x100), mload(add(state, STATE_ZETA))) + mstore(add(mPtr, 0x120), mload(add(state, STATE_GAMMA_KZG))) + let random := staticcall(gas(), SHA2, mPtr, 0x140, mPtr, 0x20) + if iszero(random){ + error_random_generation() + } + random := mod(mload(mPtr), R_MOD) // use the same variable as we are one variable away from getting stack-too-deep error... + + let folded_quotients := mPtr + mPtr := add(folded_quotients, 0x40) + mstore(folded_quotients, calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X))) + mstore(add(folded_quotients, 0x20), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_Y))) + point_acc_mul_calldata(folded_quotients, add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X), random, mPtr) + + let folded_digests := add(state, STATE_FOLDED_DIGESTS_X) + point_acc_mul_calldata(folded_digests, add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X), random, mPtr) + + let folded_evals := add(state, STATE_FOLDED_CLAIMED_VALUES) + fr_acc_mul_calldata(folded_evals, add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA), random) + + let folded_evals_commit := mPtr + mPtr := add(folded_evals_commit, 0x40) + mstore(folded_evals_commit, G1_SRS_X) + mstore(add(folded_evals_commit, 0x20), G1_SRS_Y) + mstore(add(folded_evals_commit, 0x40), mload(folded_evals)) + let check_staticcall := staticcall(gas(), 7, folded_evals_commit, 0x60, folded_evals_commit, 0x40) + if iszero(check_staticcall) { + error_verify() + } + + let folded_evals_commit_y := add(folded_evals_commit, 0x20) + mstore(folded_evals_commit_y, sub(P_MOD, mload(folded_evals_commit_y))) + point_add(folded_digests, folded_digests, folded_evals_commit, mPtr) + + let folded_points_quotients := mPtr + mPtr := add(mPtr, 0x40) + point_mul_calldata( + folded_points_quotients, + add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X), + mload(add(state, STATE_ZETA)), + mPtr + ) + let zeta_omega := mulmod(mload(add(state, STATE_ZETA)), VK_OMEGA, R_MOD) + random := mulmod(random, zeta_omega, R_MOD) + point_acc_mul_calldata(folded_points_quotients, add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X), random, mPtr) + + point_add(folded_digests, folded_digests, folded_points_quotients, mPtr) + + let folded_quotients_y := add(folded_quotients, 0x20) + mstore(folded_quotients_y, sub(P_MOD, mload(folded_quotients_y))) + + mstore(mPtr, mload(folded_digests)) + + mstore(add(mPtr, 0x20), mload(add(folded_digests, 0x20))) + mstore(add(mPtr, 0x40), G2_SRS_0_X_0) // the 4 lines are the canonical G2 point on BN254 + mstore(add(mPtr, 0x60), G2_SRS_0_X_1) + mstore(add(mPtr, 0x80), G2_SRS_0_Y_0) + mstore(add(mPtr, 0xa0), G2_SRS_0_Y_1) + mstore(add(mPtr, 0xc0), mload(folded_quotients)) + mstore(add(mPtr, 0xe0), mload(add(folded_quotients, 0x20))) + mstore(add(mPtr, 0x100), G2_SRS_1_X_0) + mstore(add(mPtr, 0x120), G2_SRS_1_X_1) + mstore(add(mPtr, 0x140), G2_SRS_1_Y_0) + mstore(add(mPtr, 0x160), G2_SRS_1_Y_1) + check_pairing_kzg(mPtr) + } + + /// @notice check_pairing_kzg checks the result of the final pairing product of the batched + /// kzg verification. The purpose of this function is to avoid exhausting the stack + /// in the function batch_verify_multi_points. + /// @param mPtr pointer storing the tuple of pairs + function check_pairing_kzg(mPtr) { + let state := mload(0x40) + + let l_success := staticcall(gas(), 8, mPtr, 0x180, 0x00, 0x20) + if iszero(l_success) { + error_pairing() + } + let res_pairing := mload(0x00) + mstore(add(state, STATE_SUCCESS), res_pairing) + } + + /// @notice Fold the opening proofs at ζ: + /// * at state+state_folded_digest we store: [Linearised_polynomial]+γ[L] + γ²[R] + γ³[O] + γ⁴[S₁] +γ⁵[S₂] + ∑ᵢγ⁵⁺ⁱ[Pi_{i}] + /// * at state+state_folded_claimed_values we store: Linearised_polynomial(ζ)+γL(ζ) + γ²R(ζ)+ γ³O(ζ) + γ⁴S₁(ζ) +γ⁵S₂(ζ) + ∑ᵢγ⁵⁺ⁱPi_{i}(ζ) + /// @param aproof pointer to the proof + /// acc_gamma stores the γⁱ + function fold_state(aproof) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + let mPtr20 := add(mPtr, 0x20) + let mPtr40 := add(mPtr, 0x40) + + let l_gamma_kzg := mload(add(state, STATE_GAMMA_KZG)) + let acc_gamma := l_gamma_kzg + let state_folded_digests := add(state, STATE_FOLDED_DIGESTS_X) + + mstore(state_folded_digests, mload(add(state, STATE_LINEARISED_POLYNOMIAL_X))) + mstore(add(state, STATE_FOLDED_DIGESTS_Y), mload(add(state, STATE_LINEARISED_POLYNOMIAL_Y))) + mstore(add(state, STATE_FOLDED_CLAIMED_VALUES), mload(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA))) + + point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_L_COM_X), acc_gamma, mPtr) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_L_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_R_COM_X), acc_gamma, mPtr) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_R_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_O_COM_X), acc_gamma, mPtr) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_O_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + mstore(mPtr, VK_S1_COM_X) + mstore(mPtr20, VK_S1_COM_Y) + point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_S1_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + mstore(mPtr, VK_S2_COM_X) + mstore(mPtr20, VK_S2_COM_Y) + point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_S2_AT_ZETA), acc_gamma) + let poqaz := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + mstore(mPtr, VK_QCP_0_X) + mstore(mPtr20, VK_QCP_0_Y) + point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), poqaz, acc_gamma) + poqaz := add(poqaz, 0x20) + + } + + /// @notice generate the challenge (using Fiat Shamir) to fold the opening proofs + /// at ζ. + /// The process for deriving γ is the same as in derive_gamma but this time the inputs are + /// in this order (the [] means it's a commitment): + /// * ζ + /// * [Linearised polynomial] + /// * [L], [R], [O] + /// * [S₁] [S₂] + /// * [Pi_{i}] (wires associated to custom gates) + /// Then there are the purported evaluations of the previous committed polynomials: + /// * Linearised_polynomial(ζ) + /// * L(ζ), R(ζ), O(ζ), S₁(ζ), S₂(ζ) + /// * Pi_{i}(ζ) + /// * Z(ζω) + /// @param aproof pointer to the proof + function compute_gamma_kzg(aproof) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + mstore(mPtr, FS_GAMMA_KZG) // "gamma" + mstore(add(mPtr, 0x20), mload(add(state, STATE_ZETA))) + mstore(add(mPtr,0x40), mload(add(state, STATE_LINEARISED_POLYNOMIAL_X))) + mstore(add(mPtr,0x60), mload(add(state, STATE_LINEARISED_POLYNOMIAL_Y))) + calldatacopy(add(mPtr, 0x80), add(aproof, PROOF_L_COM_X), 0xc0) + mstore(add(mPtr,0x140), VK_S1_COM_X) + mstore(add(mPtr,0x160), VK_S1_COM_Y) + mstore(add(mPtr,0x180), VK_S2_COM_X) + mstore(add(mPtr,0x1a0), VK_S2_COM_Y) + + let offset := 0x1c0 + + mstore(add(mPtr,offset), VK_QCP_0_X) + mstore(add(mPtr,add(offset, 0x20)), VK_QCP_0_Y) + offset := add(offset, 0x40) + mstore(add(mPtr, offset), mload(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA))) + mstore(add(mPtr, add(offset, 0x20)), calldataload(add(aproof, PROOF_L_AT_ZETA))) + mstore(add(mPtr, add(offset, 0x40)), calldataload(add(aproof, PROOF_R_AT_ZETA))) + mstore(add(mPtr, add(offset, 0x60)), calldataload(add(aproof, PROOF_O_AT_ZETA))) + mstore(add(mPtr, add(offset, 0x80)), calldataload(add(aproof, PROOF_S1_AT_ZETA))) + mstore(add(mPtr, add(offset, 0xa0)), calldataload(add(aproof, PROOF_S2_AT_ZETA))) + + let _mPtr := add(mPtr, add(offset, 0xc0)) + + + let _poqaz := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + calldatacopy(_mPtr, _poqaz, mul(VK_NB_CUSTOM_GATES, 0x20)) + _mPtr := add(_mPtr, mul(VK_NB_CUSTOM_GATES, 0x20)) + + + mstore(_mPtr, calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA))) + + let start_input := 0x1b // 00.."gamma" + let size_input := add(0x14, mul(VK_NB_CUSTOM_GATES,3)) // number of 32bytes elmts = 0x14 (zeta+3*6 for the digests+openings) + 3*VK_NB_CUSTOM_GATES (for the commitments of the selectors) + 1 (opening of Z at ζω) + size_input := add(0x5, mul(size_input, 0x20)) // size in bytes: 15*32 bytes + 5 bytes for gamma + let check_staticcall := staticcall(gas(), SHA2, add(mPtr,start_input), size_input, add(state, STATE_GAMMA_KZG), 0x20) + if iszero(check_staticcall) { + error_verify() + } + mstore(add(state, STATE_GAMMA_KZG), mod(mload(add(state, STATE_GAMMA_KZG)), R_MOD)) + } + + function compute_commitment_linearised_polynomial_ec(aproof, s1, s2) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + mstore(mPtr, VK_QL_COM_X) + mstore(add(mPtr, 0x20), VK_QL_COM_Y) + point_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(add(aproof, PROOF_L_AT_ZETA)), + add(mPtr, 0x40) + ) + + mstore(mPtr, VK_QR_COM_X) + mstore(add(mPtr, 0x20), VK_QR_COM_Y) + point_acc_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(add(aproof, PROOF_R_AT_ZETA)), + add(mPtr, 0x40) + ) + + let rl := mulmod(calldataload(add(aproof, PROOF_L_AT_ZETA)), calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + mstore(mPtr, VK_QM_COM_X) + mstore(add(mPtr, 0x20), VK_QM_COM_Y) + point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, rl, add(mPtr, 0x40)) + + mstore(mPtr, VK_QO_COM_X) + mstore(add(mPtr, 0x20), VK_QO_COM_Y) + point_acc_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(add(aproof, PROOF_O_AT_ZETA)), + add(mPtr, 0x40) + ) + + mstore(mPtr, VK_QK_COM_X) + mstore(add(mPtr, 0x20), VK_QK_COM_Y) + point_add( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + add(mPtr, 0x40) + ) + + + let qcp_opening_at_zeta := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + let bsb_commitments := add(aproof, PROOF_BSB_COMMITMENTS) + for { + let i := 0 + } lt(i, VK_NB_CUSTOM_GATES) { + i := add(i, 1) + } { + mstore(mPtr, calldataload(bsb_commitments)) + mstore(add(mPtr, 0x20), calldataload(add(bsb_commitments, 0x20))) + point_acc_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(qcp_opening_at_zeta), + add(mPtr, 0x40) + ) + qcp_opening_at_zeta := add(qcp_opening_at_zeta, 0x20) + bsb_commitments := add(bsb_commitments, 0x40) + } + + + mstore(mPtr, VK_S3_COM_X) + mstore(add(mPtr, 0x20), VK_S3_COM_Y) + point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, s1, add(mPtr, 0x40)) + + mstore(mPtr, calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X))) + mstore(add(mPtr, 0x20), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_Y))) + point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, s2, add(mPtr, 0x40)) + + point_add( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + add(state, STATE_LINEARISED_POLYNOMIAL_X), + add(state, STATE_FOLDED_H_X), + mPtr) + } + + /// @notice Compute the commitment to the linearized polynomial equal to + /// L(ζ)[Qₗ]+r(ζ)[Qᵣ]+R(ζ)L(ζ)[Qₘ]+O(ζ)[Qₒ]+[Qₖ]+Σᵢqc'ᵢ(ζ)[BsbCommitmentᵢ] + + /// α*( Z(μζ)(L(ζ)+β*S₁(ζ)+γ)*(R(ζ)+β*S₂(ζ)+γ)[S₃]-[Z](L(ζ)+β*id_{1}(ζ)+γ)*(R(ζ)+β*id_{2}(ζ)+γ)*(O(ζ)+β*id_{3}(ζ)+γ) ) + + /// α²*L₁(ζ)[Z] - Z_{H}(ζ)*(([H₀] + ζᵐ⁺²*[H₁] + ζ²⁽ᵐ⁺²⁾*[H₂]) + /// where + /// * id_1 = id, id_2 = vk_coset_shift*id, id_3 = vk_coset_shift^{2}*id + /// * the [] means that it's a commitment (i.e. a point on Bn254(F_p)) + /// * Z_{H}(ζ) = ζ^n-1 + /// @param aproof pointer to the proof + function compute_commitment_linearised_polynomial(aproof) { + let state := mload(0x40) + let l_beta := mload(add(state, STATE_BETA)) + let l_gamma := mload(add(state, STATE_GAMMA)) + let l_zeta := mload(add(state, STATE_ZETA)) + let l_alpha := mload(add(state, STATE_ALPHA)) + + let u := mulmod(calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA)), l_beta, R_MOD) + let v := mulmod(l_beta, calldataload(add(aproof, PROOF_S1_AT_ZETA)), R_MOD) + v := addmod(v, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) + v := addmod(v, l_gamma, R_MOD) + + let w := mulmod(l_beta, calldataload(add(aproof, PROOF_S2_AT_ZETA)), R_MOD) + w := addmod(w, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + w := addmod(w, l_gamma, R_MOD) + + let s1 := mulmod(u, v, R_MOD) + s1 := mulmod(s1, w, R_MOD) + s1 := mulmod(s1, l_alpha, R_MOD) + + let coset_square := mulmod(VK_COSET_SHIFT, VK_COSET_SHIFT, R_MOD) + let betazeta := mulmod(l_beta, l_zeta, R_MOD) + u := addmod(betazeta, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) + u := addmod(u, l_gamma, R_MOD) + + v := mulmod(betazeta, VK_COSET_SHIFT, R_MOD) + v := addmod(v, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + v := addmod(v, l_gamma, R_MOD) + + w := mulmod(betazeta, coset_square, R_MOD) + w := addmod(w, calldataload(add(aproof, PROOF_O_AT_ZETA)), R_MOD) + w := addmod(w, l_gamma, R_MOD) + + let s2 := mulmod(u, v, R_MOD) + s2 := mulmod(s2, w, R_MOD) + s2 := sub(R_MOD, s2) + s2 := mulmod(s2, l_alpha, R_MOD) + s2 := addmod(s2, mload(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0)), R_MOD) + + // at this stage: + // * s₁ = α*Z(μζ)(l(ζ)+β*s₁(ζ)+γ)*(r(ζ)+β*s₂(ζ)+γ)*β + // * s₂ = -α*(l(ζ)+β*ζ+γ)*(r(ζ)+β*u*ζ+γ)*(o(ζ)+β*u²*ζ+γ) + α²*L₁(ζ) + + compute_commitment_linearised_polynomial_ec(aproof, s1, s2) + } + + /// @notice compute -z_h(ζ)*([H₁] + ζⁿ⁺²[H₂] + ζ²⁽ⁿ⁺²⁾[H₃]) and store the result at + /// state + state_folded_h + /// @param aproof pointer to the proof + function fold_h(aproof) { + let state := mload(0x40) + let n_plus_two := add(VK_DOMAIN_SIZE, 2) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + let zeta_power_n_plus_two := pow(mload(add(state, STATE_ZETA)), n_plus_two, mPtr) + point_mul_calldata(add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_2_COM_X), zeta_power_n_plus_two, mPtr) + point_add_calldata(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_1_COM_X), mPtr) + point_mul(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), zeta_power_n_plus_two, mPtr) + point_add_calldata(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_0_COM_X), mPtr) + point_mul(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)), mPtr) + let folded_h_y := mload(add(state, STATE_FOLDED_H_Y)) + folded_h_y := sub(P_MOD, folded_h_y) + mstore(add(state, STATE_FOLDED_H_Y), folded_h_y) + } + + /// @notice check that the opening of the linearised polynomial at zeta is equal to + /// - [ PI(ζ) - α²*L₁(ζ) + α(l(ζ)+β*s1(ζ)+γ)(r(ζ)+β*s2(ζ)+γ)(o(ζ)+γ)*z(ωζ) ] + /// @param aproof pointer to the proof + function compute_opening_linearised_polynomial(aproof) { + + let state := mload(0x40) + + // (l(ζ)+β*s1(ζ)+γ) + let s1 + s1 := mulmod(calldataload(add(aproof, PROOF_S1_AT_ZETA)), mload(add(state, STATE_BETA)), R_MOD) + s1 := addmod(s1, mload(add(state, STATE_GAMMA)), R_MOD) + s1 := addmod(s1, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) + + // (r(ζ)+β*s2(ζ)+γ) + let s2 + s2 := mulmod(calldataload(add(aproof, PROOF_S2_AT_ZETA)), mload(add(state, STATE_BETA)), R_MOD) + s2 := addmod(s2, mload(add(state, STATE_GAMMA)), R_MOD) + s2 := addmod(s2, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + + // (o(ζ)+γ) + let o + o := addmod(calldataload(add(aproof, PROOF_O_AT_ZETA)), mload(add(state, STATE_GAMMA)), R_MOD) + + // α*Z(μζ)*(l(ζ)+β*s1(ζ)+γ)*(r(ζ)+β*s2(ζ)+γ)*(o(ζ)+γ) + s1 := mulmod(s1, s2, R_MOD) + s1 := mulmod(s1, o, R_MOD) + s1 := mulmod(s1, mload(add(state, STATE_ALPHA)), R_MOD) + s1 := mulmod(s1, calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA)), R_MOD) + + // PI(ζ) - α²*L₁(ζ) + α(l(ζ)+β*s1(ζ)+γ)(r(ζ)+β*s2(ζ)+γ)(o(ζ)+γ)*z(ωζ) + s1 := addmod(s1, mload(add(state, STATE_PI)), R_MOD) + s2 := mload(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0)) + s2 := sub(R_MOD, s2) + s1 := addmod(s1, s2, R_MOD) + s1 := sub(R_MOD, s1) + + mstore(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA), s1) + } + + // BEGINNING utils math functions ------------------------------------------------- + + /// @param dst pointer storing the result + /// @param p pointer to the first point + /// @param q pointer to the second point + /// @param mPtr pointer to free memory + function point_add(dst, p, q, mPtr) { + mstore(mPtr, mload(p)) + mstore(add(mPtr, 0x20), mload(add(p, 0x20))) + mstore(add(mPtr, 0x40), mload(q)) + mstore(add(mPtr, 0x60), mload(add(q, 0x20))) + let l_success := staticcall(gas(),EC_ADD,mPtr,0x80,dst,0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @param dst pointer storing the result + /// @param p pointer to the first point (calldata) + /// @param q pointer to the second point (calladata) + /// @param mPtr pointer to free memory + function point_add_calldata(dst, p, q, mPtr) { + mstore(mPtr, mload(p)) + mstore(add(mPtr, 0x20), mload(add(p, 0x20))) + mstore(add(mPtr, 0x40), calldataload(q)) + mstore(add(mPtr, 0x60), calldataload(add(q, 0x20))) + let l_success := staticcall(gas(), EC_ADD, mPtr, 0x80, dst, 0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @parma dst pointer storing the result + /// @param src pointer to a point on Bn254(𝔽_p) + /// @param s scalar + /// @param mPtr free memory + function point_mul(dst,src,s, mPtr) { + mstore(mPtr,mload(src)) + mstore(add(mPtr,0x20),mload(add(src,0x20))) + mstore(add(mPtr,0x40),s) + let l_success := staticcall(gas(),EC_MUL,mPtr,0x60,dst,0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @parma dst pointer storing the result + /// @param src pointer to a point on Bn254(𝔽_p) on calldata + /// @param s scalar + /// @param mPtr free memory + function point_mul_calldata(dst, src, s, mPtr) { + mstore(mPtr, calldataload(src)) + mstore(add(mPtr, 0x20), calldataload(add(src, 0x20))) + mstore(add(mPtr, 0x40), s) + let l_success := staticcall(gas(), EC_MUL, mPtr, 0x60, dst, 0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @notice dst <- dst + [s]src (Elliptic curve) + /// @param dst pointer accumulator point storing the result + /// @param src pointer to the point to multiply and add + /// @param s scalar + /// @param mPtr free memory + function point_acc_mul(dst,src,s, mPtr) { + mstore(mPtr,mload(src)) + mstore(add(mPtr,0x20),mload(add(src,0x20))) + mstore(add(mPtr,0x40),s) + let l_success := staticcall(gas(),7,mPtr,0x60,mPtr,0x40) + mstore(add(mPtr,0x40),mload(dst)) + mstore(add(mPtr,0x60),mload(add(dst,0x20))) + l_success := and(l_success, staticcall(gas(),EC_ADD,mPtr,0x80,dst, 0x40)) + if iszero(l_success) { + error_ec_op() + } + } + + /// @notice dst <- dst + [s]src (Elliptic curve) + /// @param dst pointer accumulator point storing the result + /// @param src pointer to the point to multiply and add (on calldata) + /// @param s scalar + /// @mPtr free memory + function point_acc_mul_calldata(dst, src, s, mPtr) { + mstore(mPtr, calldataload(src)) + mstore(add(mPtr, 0x20), calldataload(add(src, 0x20))) + mstore(add(mPtr, 0x40), s) + let l_success := staticcall(gas(), 7, mPtr, 0x60, mPtr, 0x40) + mstore(add(mPtr, 0x40), mload(dst)) + mstore(add(mPtr, 0x60), mload(add(dst, 0x20))) + l_success := and(l_success, staticcall(gas(), EC_ADD, mPtr, 0x80, dst, 0x40)) + if iszero(l_success) { + error_ec_op() + } + } + + /// @notice dst <- dst + src*s (Fr) dst,src are addresses, s is a value + /// @param dst pointer storing the result + /// @param src pointer to the scalar to multiply and add (on calldata) + /// @param s scalar + function fr_acc_mul_calldata(dst, src, s) { + let tmp := mulmod(calldataload(src), s, R_MOD) + mstore(dst, addmod(mload(dst), tmp, R_MOD)) + } + + /// @param x element to exponentiate + /// @param e exponent + /// @param mPtr free memory + /// @return res x ** e mod r + function pow(x, e, mPtr)->res { + mstore(mPtr, 0x20) + mstore(add(mPtr, 0x20), 0x20) + mstore(add(mPtr, 0x40), 0x20) + mstore(add(mPtr, 0x60), x) + mstore(add(mPtr, 0x80), e) + mstore(add(mPtr, 0xa0), R_MOD) + let check_staticcall := staticcall(gas(),MOD_EXP,mPtr,0xc0,mPtr,0x20) + if eq(check_staticcall, 0) { + error_mod_exp() + } + res := mload(mPtr) + } + } + } +} diff --git a/contracts/plonk/bn254/testdata/init.json b/contracts/plonk/bn254/testdata/init.json new file mode 100644 index 000000000..361f58e68 --- /dev/null +++ b/contracts/plonk/bn254/testdata/init.json @@ -0,0 +1,15 @@ +{ + "format": "ix-bn254-plonk-solidity-init-v1", + "scope": "Actual Init recursive KZG proof wrapped with classic GLV and two separate pairing checks", + "proof": "0x10dd966bbaebed3b2898d68e08d39d32d11d22856e2cebfbef62d50440d6750503d361f6fcc09ff2f2adced99fb3ef92dedc4097db58729dea58b4d2ab583ba8004839298956fc8e9cd685113211353ec4f73dd0064e8a214b21b66d78a64217073a1d2cfa6b7f228d0acec710a744461b3bedce2dc39b2afbf451dc6002e2f5073a6df5b606a53a87c37970f43b33a9d3ca052f9adad2a43a1bf25ab4f981492ec0a499b16e23b81822fc87cba0f5c7832273e1ab7b50f86d1bf619b4c945ef1c13c4e90d7701f38e596a8d54a4405add02fc2c307ff25a8114714f84b64c91245dd79f72ea95f236dbd2b6a35cbf6e6cb4e5a5a96d9924295754535605b1eb1b31b55edeeec3677908e3a3ed3085aee8d1d35961c2724b07a1eb24b6bd49aa1e36a96b730b86d4c7537aa66786a922927d1dea993eaba35bd1d5fb4f8609ac1b518a87a55609410f22bb24fc59f9f513bd08ce49da5904fa48bef99bf24e091b405adf29d7885abbab2e2b44b14ed5ecd111fc39888026b143408eb8ced6312c74fc410f7eef302152066dc66c128f90462c54009d177372614b44389011a203bd5d6e165893dcc7c45b0569450b7bcf9ce6716d6959e07d193c716240b27229ac1e0fce3d91f4b8a2506906e3eca6203e433a5a928e2bcfad43fee2f2922a01eebf2b1701db7f9548975d42f447d0b59d257ddcb69eaa99f4f8c0571945062c7ff40d5ee2833ea81f834e26b7a48f7d399af35a961c018b236ad234dc71b626255dd9299a0a4c2d1e148e014fb144eca6cd43b8acec93caf2ada143c5bdcc04407221f13938f04c5f7d9e3958340da51c3a7f5d60b79d20c662dbf961a8f52d5c6770f25be2fddde439dc43fd071afc2f3fca65e551dc7438ae7c6d1c3061155a3c899ad5ef97b8e5547ae5f33dd85323fc973a22278e058e2cb1a9ab9f832a625c37ff03784549d827bdf9febbdf8fada2a7f028774118afe8b71c87949513407c2ecf7d3d2e1c9376704ceb69206c2ec1d11882e7da031ba1dd2a93ac3e2d388c7412d4efcd2d7988ef3d1447aa4cd2fcd93e511e5a372ff60802378c35210e4715adbebcb506c45ac95cd577118b4180a3a087f95626a2329fc51fadb32f574c2f40fbc2d655ad35f4529380e69a541695c712e317ae2da2a4a0892ad51bb21e30aeba60746ebf5fedbf1cc5d61a733629a4a7c63bc62334947a371b32", + "nativeProof": "0x90dd966bbaebed3b2898d68e08d39d32d11d22856e2cebfbef62d50440d67505804839298956fc8e9cd685113211353ec4f73dd0064e8a214b21b66d78a64217c73a6df5b606a53a87c37970f43b33a9d3ca052f9adad2a43a1bf25ab4f98149a6255dd9299a0a4c2d1e148e014fb144eca6cd43b8acec93caf2ada143c5bdccdc13c4e90d7701f38e596a8d54a4405add02fc2c307ff25a8114714f84b64c91db31b55edeeec3677908e3a3ed3085aee8d1d35961c2724b07a1eb24b6bd49aadb518a87a55609410f22bb24fc59f9f513bd08ce49da5904fa48bef99bf24e09d55a3c899ad5ef97b8e5547ae5f33dd85323fc973a22278e058e2cb1a9ab9f83000000071422800092d527f07d96b568456c47aa53094847bd7faebcf68bec915473b2df2c74fc410f7eef302152066dc66c128f90462c54009d177372614b44389011a203bd5d6e165893dcc7c45b0569450b7bcf9ce6716d6959e07d193c716240b27229ac1e0fce3d91f4b8a2506906e3eca6203e433a5a928e2bcfad43fee2f2922a01eebf2b1701db7f9548975d42f447d0b59d257ddcb69eaa99f4f8c0571945062c7ff40d5ee2833ea81f834e26b7a48f7d399af35a961c018b236ad234dc71b6210e4715adbebcb506c45ac95cd577118b4180a3a087f95626a2329fc51fadb3d3407c2ecf7d3d2e1c9376704ceb69206c2ec1d11882e7da031ba1dd2a93ac3e2d5c6770f25be2fddde439dc43fd071afc2f3fca65e551dc7438ae7c6d1c306100000001ef574c2f40fbc2d655ad35f4529380e69a541695c712e317ae2da2a4a0892ad5", + "envelope": "0x4958424e000000024f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff36890dd966bbaebed3b2898d68e08d39d32d11d22856e2cebfbef62d50440d67505804839298956fc8e9cd685113211353ec4f73dd0064e8a214b21b66d78a64217c73a6df5b606a53a87c37970f43b33a9d3ca052f9adad2a43a1bf25ab4f98149a6255dd9299a0a4c2d1e148e014fb144eca6cd43b8acec93caf2ada143c5bdccdc13c4e90d7701f38e596a8d54a4405add02fc2c307ff25a8114714f84b64c91db31b55edeeec3677908e3a3ed3085aee8d1d35961c2724b07a1eb24b6bd49aadb518a87a55609410f22bb24fc59f9f513bd08ce49da5904fa48bef99bf24e09d55a3c899ad5ef97b8e5547ae5f33dd85323fc973a22278e058e2cb1a9ab9f83000000071422800092d527f07d96b568456c47aa53094847bd7faebcf68bec915473b2df2c74fc410f7eef302152066dc66c128f90462c54009d177372614b44389011a203bd5d6e165893dcc7c45b0569450b7bcf9ce6716d6959e07d193c716240b27229ac1e0fce3d91f4b8a2506906e3eca6203e433a5a928e2bcfad43fee2f2922a01eebf2b1701db7f9548975d42f447d0b59d257ddcb69eaa99f4f8c0571945062c7ff40d5ee2833ea81f834e26b7a48f7d399af35a961c018b236ad234dc71b6210e4715adbebcb506c45ac95cd577118b4180a3a087f95626a2329fc51fadb3d3407c2ecf7d3d2e1c9376704ceb69206c2ec1d11882e7da031ba1dd2a93ac3e2d5c6770f25be2fddde439dc43fd071afc2f3fca65e551dc7438ae7c6d1c306100000001ef574c2f40fbc2d655ad35f4529380e69a541695c712e317ae2da2a4a0892ad5", + "claim": "0xe50401f8912c57f41e1b38ebd63e53cf05b0775cff02b42185084a014c07b18d0e2b0c00", + "claimDigest": "0xc019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", + "verifyingKeySHA256": "4f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff368", + "domain": 16777216, + "constraints": 14689440, + "publicInputs": 2, + "commitments": 1, + "innerProfileBLAKE3": "a4cf1b3b2489af6c939847d5060452bccb1f87ed8cff0fb90658e477549eafcd" +} diff --git a/contracts/plonk/bn254/upstream/ISP1Verifier.sol b/contracts/plonk/bn254/upstream/ISP1Verifier.sol new file mode 100644 index 000000000..21eb13d62 --- /dev/null +++ b/contracts/plonk/bn254/upstream/ISP1Verifier.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title SP1 Verifier Interface +/// @author Succinct Labs +/// @notice This contract is the interface for the SP1 Verifier. +interface ISP1Verifier { + /// @notice Verifies a proof with given public values and vkey. + /// @dev It is expected that the first 4 bytes of proofBytes must match the first 4 bytes of + /// target verifier's VERIFIER_HASH. + /// @param programVKey The verification key for the RISC-V program. + /// @param publicValues The public values encoded as bytes. + /// @param proofBytes The proof of the program execution the SP1 zkVM encoded as bytes. + function verifyProof( + bytes32 programVKey, + bytes calldata publicValues, + bytes calldata proofBytes + ) external view; +} + +interface ISP1VerifierWithHash is ISP1Verifier { + /// @notice Returns the hash of the verifier. + function VERIFIER_HASH() external pure returns (bytes32); +} diff --git a/contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol b/contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol new file mode 100644 index 000000000..9ecc18126 --- /dev/null +++ b/contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol @@ -0,0 +1,1341 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 Consensys Software Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by gnark DO NOT EDIT + +pragma solidity ^0.8.20; + +contract PlonkVerifier { + + uint256 private constant R_MOD = 21888242871839275222246405745257275088548364400416034343698204186575808495617; + uint256 private constant R_MOD_MINUS_ONE = 21888242871839275222246405745257275088548364400416034343698204186575808495616; + uint256 private constant P_MOD = 21888242871839275222246405745257275088696311157297823662689037894645226208583; + + uint256 private constant G2_SRS_0_X_0 = 11559732032986387107991004021392285783925812861821192530917403151452391805634; + uint256 private constant G2_SRS_0_X_1 = 10857046999023057135944570762232829481370756359578518086990519993285655852781; + uint256 private constant G2_SRS_0_Y_0 = 4082367875863433681332203403145435568316851327593401208105741076214120093531; + uint256 private constant G2_SRS_0_Y_1 = 8495653923123431417604973247489272438418190587263600148770280649306958101930; + + uint256 private constant G2_SRS_1_X_0 = 15805639136721018565402881920352193254830339253282065586954346329754995870280; + uint256 private constant G2_SRS_1_X_1 = 19089565590083334368588890253123139704298730990782503769911324779715431555531; + uint256 private constant G2_SRS_1_Y_0 = 9779648407879205346559610309258181044130619080926897934572699915909528404984; + uint256 private constant G2_SRS_1_Y_1 = 6779728121489434657638426458390319301070371227460768374343986326751507916979; + + uint256 private constant G1_SRS_X = 14312776538779914388377568895031746459131577658076416373430523308756343304251; + uint256 private constant G1_SRS_Y = 11763105256161367503191792604679297387056316997144156930871823008787082098465; + + // ----------------------- vk --------------------- + uint256 private constant VK_NB_PUBLIC_INPUTS = 5; + uint256 private constant VK_DOMAIN_SIZE = 33554432; + uint256 private constant VK_INV_DOMAIN_SIZE = 21888242219518804655518433051623070663413851959604507555939307129453691614729; + uint256 private constant VK_OMEGA = 19200870435978225707111062059747084165650991997241425080699860725083300967194; + uint256 private constant VK_QL_COM_X = 9496857109770518741292064488682135861248962674494967861604446614769298140801; + uint256 private constant VK_QL_COM_Y = 13306697158707148893187289359474517489309092662132847111865615083807847780686; + uint256 private constant VK_QR_COM_X = 15168106396031721196861946917417531048941395918951726120971774887904231224033; + uint256 private constant VK_QR_COM_Y = 17067422783799132011484353829344785446718705273302151466551760455276544179973; + uint256 private constant VK_QM_COM_X = 2062503189072358517228813627364112801206468481541880929627061212524046867500; + uint256 private constant VK_QM_COM_Y = 16383264006058748048241814683452691377147195760917894158624764699848260181996; + uint256 private constant VK_QO_COM_X = 11276993554321744761007348475396375004546042576992247443165667159541648019615; + uint256 private constant VK_QO_COM_Y = 1336009123797329867458955655460754684518485281155722686318249389277951918969; + uint256 private constant VK_QK_COM_X = 3509076513963323471275614870100244642947645425705684850560588726111445186717; + uint256 private constant VK_QK_COM_Y = 15082251669986564403690950092410766520944967428195940447691297074393449013243; + + uint256 private constant VK_S1_COM_X = 20702610951114261233096944021168607816381642857429919420395478944329365232425; + uint256 private constant VK_S1_COM_Y = 6287924284852772991087729385363871923831267251255140888671281832443192104403; + + uint256 private constant VK_S2_COM_X = 645470009383750670501092774958765334436477314405650592677994973080307449345; + uint256 private constant VK_S2_COM_Y = 4256397297118091181646823265799511434474377494065149067487129126410465669924; + + uint256 private constant VK_S3_COM_X = 7492538199234778964992190797161279888333633666731339557560747528992222778140; + uint256 private constant VK_S3_COM_Y = 19883641962643068517995924785061749749272042967551599977561118882192215161378; + + uint256 private constant VK_COSET_SHIFT = 5; + + + uint256 private constant VK_QCP_0_X = 21276573233863489069809736193483015861331560639958294945226697354328765942402; + uint256 private constant VK_QCP_0_Y = 21667749868347388386642256911557299806363086510301513644953688310359382808300; + + + uint256 private constant VK_INDEX_COMMIT_API_0 = 25304569; + uint256 private constant VK_NB_CUSTOM_GATES = 1; + + // ------------------------------------------------ + + // size of the proof without call custom gate + uint256 private constant FIXED_PROOF_SIZE = 0x300; + + // offset proof + + uint256 private constant PROOF_L_COM_X = 0x0; + uint256 private constant PROOF_L_COM_Y = 0x20; + uint256 private constant PROOF_R_COM_X = 0x40; + uint256 private constant PROOF_R_COM_Y = 0x60; + uint256 private constant PROOF_O_COM_X = 0x80; + uint256 private constant PROOF_O_COM_Y = 0xa0; + + // h = h_0 + x^{n+2}h_1 + x^{2(n+2)}h_2 + uint256 private constant PROOF_H_0_COM_X = 0xc0; + uint256 private constant PROOF_H_0_COM_Y = 0xe0; + uint256 private constant PROOF_H_1_COM_X = 0x100; + uint256 private constant PROOF_H_1_COM_Y = 0x120; + uint256 private constant PROOF_H_2_COM_X = 0x140; + uint256 private constant PROOF_H_2_COM_Y = 0x160; + + // "evaluations of wire polynomials at zeta + uint256 private constant PROOF_L_AT_ZETA = 0x180; + uint256 private constant PROOF_R_AT_ZETA = 0x1a0; + uint256 private constant PROOF_O_AT_ZETA = 0x1c0; + + // S1(zeta),S2(zeta) + uint256 private constant PROOF_S1_AT_ZETA = 0x1e0; // Sσ1(zeta) + uint256 private constant PROOF_S2_AT_ZETA = 0x200; // Sσ2(zeta) + + // [Z] + uint256 private constant PROOF_GRAND_PRODUCT_COMMITMENT_X = 0x220; + uint256 private constant PROOF_GRAND_PRODUCT_COMMITMENT_Y = 0x240; + + uint256 private constant PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA = 0x260; // z(w*zeta) + + // Folded proof for the opening of linearised poly, l, r, o, s_1, s_2, qcp + uint256 private constant PROOF_BATCH_OPENING_AT_ZETA_X = 0x280; + uint256 private constant PROOF_BATCH_OPENING_AT_ZETA_Y = 0x2a0; + + uint256 private constant PROOF_OPENING_AT_ZETA_OMEGA_X = 0x2c0; + uint256 private constant PROOF_OPENING_AT_ZETA_OMEGA_Y = 0x2e0; + + uint256 private constant PROOF_OPENING_QCP_AT_ZETA = 0x300; + uint256 private constant PROOF_BSB_COMMITMENTS = 0x320; + + // -------- offset state + + // challenges to check the claimed quotient + + uint256 private constant STATE_ALPHA = 0x0; + uint256 private constant STATE_BETA = 0x20; + uint256 private constant STATE_GAMMA = 0x40; + uint256 private constant STATE_ZETA = 0x60; + uint256 private constant STATE_ALPHA_SQUARE_LAGRANGE_0 = 0x80; + uint256 private constant STATE_FOLDED_H_X = 0xa0; + uint256 private constant STATE_FOLDED_H_Y = 0xc0; + uint256 private constant STATE_LINEARISED_POLYNOMIAL_X = 0xe0; + uint256 private constant STATE_LINEARISED_POLYNOMIAL_Y = 0x100; + uint256 private constant STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA = 0x120; + uint256 private constant STATE_FOLDED_CLAIMED_VALUES = 0x140; // Folded proof for the opening of H, linearised poly, l, r, o, s_1, s_2, qcp + uint256 private constant STATE_FOLDED_DIGESTS_X = 0x160; // linearised poly, l, r, o, s_1, s_2, qcp + uint256 private constant STATE_FOLDED_DIGESTS_Y = 0x180; + uint256 private constant STATE_PI = 0x1a0; + uint256 private constant STATE_ZETA_POWER_N_MINUS_ONE = 0x1c0; + uint256 private constant STATE_GAMMA_KZG = 0x1e0; + uint256 private constant STATE_SUCCESS = 0x200; + uint256 private constant STATE_CHECK_VAR = 0x220; // /!\ this slot is used for debugging only + uint256 private constant STATE_LAST_MEM = 0x240; + + // -------- utils (for Fiat Shamir) + uint256 private constant FS_ALPHA = 0x616C706861; // "alpha" + uint256 private constant FS_BETA = 0x62657461; // "beta" + uint256 private constant FS_GAMMA = 0x67616d6d61; // "gamma" + uint256 private constant FS_ZETA = 0x7a657461; // "zeta" + uint256 private constant FS_GAMMA_KZG = 0x67616d6d61; // "gamma" + + // -------- errors + uint256 private constant ERROR_STRING_ID = 0x08c379a000000000000000000000000000000000000000000000000000000000; // selector for function Error(string) + + + // -------- utils (for hash_fr) + uint256 private constant HASH_FR_BB = 340282366920938463463374607431768211456; // 2**128 + uint256 private constant HASH_FR_ZERO_UINT256 = 0; + uint8 private constant HASH_FR_LEN_IN_BYTES = 48; + uint8 private constant HASH_FR_SIZE_DOMAIN = 11; + uint8 private constant HASH_FR_ONE = 1; + uint8 private constant HASH_FR_TWO = 2; + + + // -------- precompiles + uint8 private constant SHA2 = 0x2; + uint8 private constant MOD_EXP = 0x5; + uint8 private constant EC_ADD = 0x6; + uint8 private constant EC_MUL = 0x7; + uint8 private constant EC_PAIR = 0x8; + + /// Verify a Plonk proof. + /// Reverts if the proof or the public inputs are malformed. + /// @param proof serialised plonk proof (using gnark's MarshalSolidity) + /// @param public_inputs (must be reduced) + /// @return success true if the proof passes false otherwise + function Verify(bytes calldata proof, uint256[] calldata public_inputs) + public view returns(bool success) { + + assembly { + + let mem := mload(0x40) + let freeMem := add(mem, STATE_LAST_MEM) + + // sanity checks + check_number_of_public_inputs(public_inputs.length) + check_inputs_size(public_inputs.length, public_inputs.offset) + check_proof_size(proof.length) + check_proof_openings_size(proof.offset) + + // compute the challenges + let prev_challenge_non_reduced + prev_challenge_non_reduced := derive_gamma(proof.offset, public_inputs.length, public_inputs.offset) + prev_challenge_non_reduced := derive_beta(prev_challenge_non_reduced) + prev_challenge_non_reduced := derive_alpha(proof.offset, prev_challenge_non_reduced) + derive_zeta(proof.offset, prev_challenge_non_reduced) + + // evaluation of Z=Xⁿ-1 at ζ, we save this value + let zeta := mload(add(mem, STATE_ZETA)) + let zeta_power_n_minus_one := addmod(pow(zeta, VK_DOMAIN_SIZE, freeMem), sub(R_MOD, 1), R_MOD) + mstore(add(mem, STATE_ZETA_POWER_N_MINUS_ONE), zeta_power_n_minus_one) + + // public inputs contribution + let l_pi := sum_pi_wo_api_commit(public_inputs.offset, public_inputs.length, freeMem) + let l_pi_commit := sum_pi_commit(proof.offset, public_inputs.length, freeMem) + l_pi := addmod(l_pi_commit, l_pi, R_MOD) + mstore(add(mem, STATE_PI), l_pi) + + compute_alpha_square_lagrange_0() + compute_opening_linearised_polynomial(proof.offset) + fold_h(proof.offset) + compute_commitment_linearised_polynomial(proof.offset) + compute_gamma_kzg(proof.offset) + fold_state(proof.offset) + batch_verify_multi_points(proof.offset) + + success := mload(add(mem, STATE_SUCCESS)) + + // Beginning errors ------------------------------------------------- + + function error_nb_public_inputs() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x1d) + mstore(add(ptError, 0x44), "wrong number of public inputs") + revert(ptError, 0x64) + } + + /// Called when an exponentiation mod r fails + function error_mod_exp() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0xc) + mstore(add(ptError, 0x44), "error mod exp") + revert(ptError, 0x64) + } + + /// Called when an operation on Bn254 fails + /// @dev for instance when calling EcMul on a point not on Bn254. + function error_ec_op() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x12) + mstore(add(ptError, 0x44), "error ec operation") + revert(ptError, 0x64) + } + + /// Called when one of the public inputs is not reduced. + function error_inputs_size() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x18) + mstore(add(ptError, 0x44), "inputs are bigger than r") + revert(ptError, 0x64) + } + + /// Called when the size proof is not as expected + /// @dev to avoid overflow attack for instance + function error_proof_size() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x10) + mstore(add(ptError, 0x44), "wrong proof size") + revert(ptError, 0x64) + } + + /// Called when one the openings is bigger than r + /// The openings are the claimed evalutions of a polynomial + /// in a Kzg proof. + function error_proof_openings_size() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x16) + mstore(add(ptError, 0x44), "openings bigger than r") + revert(ptError, 0x64) + } + + function error_pairing() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0xd) + mstore(add(ptError, 0x44), "error pairing") + revert(ptError, 0x64) + } + + function error_verify() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0xc) + mstore(add(ptError, 0x44), "error verify") + revert(ptError, 0x64) + } + + function error_random_generation() { + let ptError := mload(0x40) + mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) + mstore(add(ptError, 0x4), 0x20) + mstore(add(ptError, 0x24), 0x14) + mstore(add(ptError, 0x44), "error random gen kzg") + revert(ptError, 0x64) + } + // end errors ------------------------------------------------- + + // Beginning checks ------------------------------------------------- + + /// @param s actual number of public inputs + function check_number_of_public_inputs(s) { + if iszero(eq(s, VK_NB_PUBLIC_INPUTS)) { + error_nb_public_inputs() + } + } + + /// Checks that the public inputs are < R_MOD. + /// @param s number of public inputs + /// @param p pointer to the public inputs array + function check_inputs_size(s, p) { + for {let i} lt(i, s) {i:=add(i,1)} + { + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_inputs_size() + } + p := add(p, 0x20) + } + } + + /// Checks if the proof is of the correct size + /// @param actual_proof_size size of the proof (not the expected size) + function check_proof_size(actual_proof_size) { + let expected_proof_size := add(FIXED_PROOF_SIZE, mul(VK_NB_CUSTOM_GATES,0x60)) + if iszero(eq(actual_proof_size, expected_proof_size)) { + error_proof_size() + } + } + + /// Checks if the multiple openings of the polynomials are < R_MOD. + /// @param aproof pointer to the beginning of the proof + /// @dev the 'a' prepending proof is to have a local name + function check_proof_openings_size(aproof) { + + // PROOF_L_AT_ZETA + let p := add(aproof, PROOF_L_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_R_AT_ZETA + p := add(aproof, PROOF_R_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_O_AT_ZETA + p := add(aproof, PROOF_O_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_S1_AT_ZETA + p := add(aproof, PROOF_S1_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_S2_AT_ZETA + p := add(aproof, PROOF_S2_AT_ZETA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA + p := add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA) + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + + // PROOF_OPENING_QCP_AT_ZETA + + p := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + for {let i:=0} lt(i, VK_NB_CUSTOM_GATES) {i:=add(i,1)} + { + if gt(calldataload(p), R_MOD_MINUS_ONE) { + error_proof_openings_size() + } + p := add(p, 0x20) + } + + } + // end checks ------------------------------------------------- + + // Beginning challenges ------------------------------------------------- + + /// Derive gamma as Sha256() + /// @param aproof pointer to the proof + /// @param nb_pi number of public inputs + /// @param pi pointer to the array of public inputs + /// @return the challenge gamma, not reduced + /// @notice The transcript is the concatenation (in this order) of: + /// * the word "gamma" in ascii, equal to [0x67,0x61,0x6d, 0x6d, 0x61] and encoded as a uint256. + /// * the commitments to the permutation polynomials S1, S2, S3, where we concatenate the coordinates of those points + /// * the commitments of Ql, Qr, Qm, Qo, Qk + /// * the public inputs + /// * the commitments of the wires related to the custom gates (commitments_wires_commit_api) + /// * commitments to L, R, O (proof__com_) + /// The data described above is written starting at mPtr. "gamma" lies on 5 bytes, + /// and is encoded as a uint256 number n. In basis b = 256, the number looks like this + /// [0 0 0 .. 0x67 0x61 0x6d, 0x6d, 0x61]. The first non zero entry is at position 27=0x1b + /// Gamma reduced (the actual challenge) is stored at add(state, state_gamma) + function derive_gamma(aproof, nb_pi, pi)->gamma_not_reduced { + + let state := mload(0x40) + let mPtr := add(state, STATE_LAST_MEM) + + mstore(mPtr, FS_GAMMA) // "gamma" + + + mstore(add(mPtr, 0x20), VK_S1_COM_X) + mstore(add(mPtr, 0x40), VK_S1_COM_Y) + mstore(add(mPtr, 0x60), VK_S2_COM_X) + mstore(add(mPtr, 0x80), VK_S2_COM_Y) + mstore(add(mPtr, 0xa0), VK_S3_COM_X) + mstore(add(mPtr, 0xc0), VK_S3_COM_Y) + mstore(add(mPtr, 0xe0), VK_QL_COM_X) + mstore(add(mPtr, 0x100), VK_QL_COM_Y) + mstore(add(mPtr, 0x120), VK_QR_COM_X) + mstore(add(mPtr, 0x140), VK_QR_COM_Y) + mstore(add(mPtr, 0x160), VK_QM_COM_X) + mstore(add(mPtr, 0x180), VK_QM_COM_Y) + mstore(add(mPtr, 0x1a0), VK_QO_COM_X) + mstore(add(mPtr, 0x1c0), VK_QO_COM_Y) + mstore(add(mPtr, 0x1e0), VK_QK_COM_X) + mstore(add(mPtr, 0x200), VK_QK_COM_Y) + + mstore(add(mPtr, 0x220), VK_QCP_0_X) + mstore(add(mPtr, 0x240), VK_QCP_0_Y) + + // public inputs + let _mPtr := add(mPtr, 0x260) + let size_pi_in_bytes := mul(nb_pi, 0x20) + calldatacopy(_mPtr, pi, size_pi_in_bytes) + _mPtr := add(_mPtr, size_pi_in_bytes) + + // commitments to l, r, o + let size_commitments_lro_in_bytes := 0xc0 + calldatacopy(_mPtr, aproof, size_commitments_lro_in_bytes) + _mPtr := add(_mPtr, size_commitments_lro_in_bytes) + + // total size is : + // sizegamma(=0x5) + 11*64(=0x2c0) + // + nb_public_inputs*0x20 + // + nb_custom gates*0x40 + let size := add(0x2c5, size_pi_in_bytes) + + size := add(size, mul(VK_NB_CUSTOM_GATES, 0x40)) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1b), size, mPtr, 0x20) //0x1b -> 000.."gamma" + if iszero(l_success) { + error_verify() + } + gamma_not_reduced := mload(mPtr) + mstore(add(state, STATE_GAMMA), mod(gamma_not_reduced, R_MOD)) + } + + /// derive beta as Sha256 + /// @param gamma_not_reduced the previous challenge (gamma) not reduced + /// @return beta_not_reduced the next challenge, beta, not reduced + /// @notice the transcript consists of the previous challenge only. + /// The reduced version of beta is stored at add(state, state_beta) + function derive_beta(gamma_not_reduced)->beta_not_reduced{ + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + // beta + mstore(mPtr, FS_BETA) // "beta" + mstore(add(mPtr, 0x20), gamma_not_reduced) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1c), 0x24, mPtr, 0x20) //0x1b -> 000.."gamma" + if iszero(l_success) { + error_verify() + } + beta_not_reduced := mload(mPtr) + mstore(add(state, STATE_BETA), mod(beta_not_reduced, R_MOD)) + } + + /// derive alpha as sha256 + /// @param aproof pointer to the proof object + /// @param beta_not_reduced the previous challenge (beta) not reduced + /// @return alpha_not_reduced the next challenge, alpha, not reduced + /// @notice the transcript consists of the previous challenge (beta) + /// not reduced, the commitments to the wires associated to the QCP_i, + /// and the commitment to the grand product polynomial + function derive_alpha(aproof, beta_not_reduced)->alpha_not_reduced { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + let full_size := 0x65 // size("alpha") + 0x20 (previous challenge) + + // alpha + mstore(mPtr, FS_ALPHA) // "alpha" + let _mPtr := add(mPtr, 0x20) + mstore(_mPtr, beta_not_reduced) + _mPtr := add(_mPtr, 0x20) + + // Bsb22Commitments + let proof_bsb_commitments := add(aproof, PROOF_BSB_COMMITMENTS) + let size_bsb_commitments := mul(0x40, VK_NB_CUSTOM_GATES) + calldatacopy(_mPtr, proof_bsb_commitments, size_bsb_commitments) + _mPtr := add(_mPtr, size_bsb_commitments) + full_size := add(full_size, size_bsb_commitments) + + // [Z], the commitment to the grand product polynomial + calldatacopy(_mPtr, add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X), 0x40) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1b), full_size, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + + alpha_not_reduced := mload(mPtr) + mstore(add(state, STATE_ALPHA), mod(alpha_not_reduced, R_MOD)) + } + + /// derive zeta as sha256 + /// @param aproof pointer to the proof object + /// @param alpha_not_reduced the previous challenge (alpha) not reduced + /// The transcript consists of the previous challenge and the commitment to + /// the quotient polynomial h. + function derive_zeta(aproof, alpha_not_reduced) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + // zeta + mstore(mPtr, FS_ZETA) // "zeta" + mstore(add(mPtr, 0x20), alpha_not_reduced) + calldatacopy(add(mPtr, 0x40), add(aproof, PROOF_H_0_COM_X), 0xc0) + let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1c), 0xe4, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + let zeta_not_reduced := mload(mPtr) + mstore(add(state, STATE_ZETA), mod(zeta_not_reduced, R_MOD)) + } + // END challenges ------------------------------------------------- + + // BEGINNING compute_pi ------------------------------------------------- + + /// sum_pi_wo_api_commit computes the public inputs contributions, + /// except for the public inputs coming from the custom gate + /// @param ins pointer to the public inputs + /// @param n number of public inputs + /// @param mPtr free memory + /// @return pi_wo_commit public inputs contribution (except the public inputs coming from the custom gate) + function sum_pi_wo_api_commit(ins, n, mPtr)->pi_wo_commit { + + let state := mload(0x40) + let z := mload(add(state, STATE_ZETA)) + let zpnmo := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) + + let li := mPtr + batch_compute_lagranges_at_z(z, zpnmo, n, li) + + let tmp := 0 + for {let i:=0} lt(i,n) {i:=add(i,1)} + { + tmp := mulmod(mload(li), calldataload(ins), R_MOD) + pi_wo_commit := addmod(pi_wo_commit, tmp, R_MOD) + li := add(li, 0x20) + ins := add(ins, 0x20) + } + + } + + /// batch_compute_lagranges_at_z computes [L_0(z), .., L_{n-1}(z)] + /// @param z point at which the Lagranges are evaluated + /// @param zpnmo ζⁿ-1 + /// @param n_pub number of public inputs (number of Lagranges to compute) + /// @param mPtr pointer to which the results are stored + function batch_compute_lagranges_at_z(z, zpnmo, n_pub, mPtr) { + + let zn := mulmod(zpnmo, VK_INV_DOMAIN_SIZE, R_MOD) // 1/n * (ζⁿ - 1) + + let _w := 1 + let _mPtr := mPtr + for {let i:=0} lt(i,n_pub) {i:=add(i,1)} + { + mstore(_mPtr, addmod(z,sub(R_MOD, _w), R_MOD)) + _w := mulmod(_w, VK_OMEGA, R_MOD) + _mPtr := add(_mPtr, 0x20) + } + batch_invert(mPtr, n_pub, _mPtr) + _mPtr := mPtr + _w := 1 + for {let i:=0} lt(i,n_pub) {i:=add(i,1)} + { + mstore(_mPtr, mulmod(mulmod(mload(_mPtr), zn , R_MOD), _w, R_MOD)) + _mPtr := add(_mPtr, 0x20) + _w := mulmod(_w, VK_OMEGA, R_MOD) + } + } + + /// @notice Montgomery trick for batch inversion mod R_MOD + /// @param ins pointer to the data to batch invert + /// @param number of elements to batch invert + /// @param mPtr free memory + function batch_invert(ins, nb_ins, mPtr) { + mstore(mPtr, 1) + let offset := 0 + for {let i:=0} lt(i, nb_ins) {i:=add(i,1)} + { + let prev := mload(add(mPtr, offset)) + let cur := mload(add(ins, offset)) + cur := mulmod(prev, cur, R_MOD) + offset := add(offset, 0x20) + mstore(add(mPtr, offset), cur) + } + ins := add(ins, sub(offset, 0x20)) + mPtr := add(mPtr, offset) + let inv := pow(mload(mPtr), sub(R_MOD,2), add(mPtr, 0x20)) + for {let i:=0} lt(i, nb_ins) {i:=add(i,1)} + { + mPtr := sub(mPtr, 0x20) + let tmp := mload(ins) + let cur := mulmod(inv, mload(mPtr), R_MOD) + mstore(ins, cur) + inv := mulmod(inv, tmp, R_MOD) + ins := sub(ins, 0x20) + } + } + + + /// Public inputs (the ones coming from the custom gate) contribution + /// @param aproof pointer to the proof + /// @param nb_public_inputs number of public inputs + /// @param mPtr pointer to free memory + /// @return pi_commit custom gate public inputs contribution + function sum_pi_commit(aproof, nb_public_inputs, mPtr)->pi_commit { + + let state := mload(0x40) + let z := mload(add(state, STATE_ZETA)) + let zpnmo := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) + + let p := add(aproof, PROOF_BSB_COMMITMENTS) + + let h_fr, ith_lagrange + + + h_fr := hash_fr(calldataload(p), calldataload(add(p, 0x20)), mPtr) + ith_lagrange := compute_ith_lagrange_at_z(z, zpnmo, add(nb_public_inputs, VK_INDEX_COMMIT_API_0), mPtr) + pi_commit := addmod(pi_commit, mulmod(h_fr, ith_lagrange, R_MOD), R_MOD) + + + + } + + /// Computes L_i(zeta) = ωⁱ/n * (ζⁿ-1)/(ζ-ωⁱ) where: + /// @param z zeta + /// @param zpmno ζⁿ-1 + /// @param i i-th lagrange + /// @param mPtr free memory + /// @return res = ωⁱ/n * (ζⁿ-1)/(ζ-ωⁱ) + function compute_ith_lagrange_at_z(z, zpnmo, i, mPtr)->res { + + let w := pow(VK_OMEGA, i, mPtr) // w**i + i := addmod(z, sub(R_MOD, w), R_MOD) // z-w**i + w := mulmod(w, VK_INV_DOMAIN_SIZE, R_MOD) // w**i/n + i := pow(i, sub(R_MOD,2), mPtr) // (z-w**i)**-1 + w := mulmod(w, i, R_MOD) // w**i/n*(z-w)**-1 + res := mulmod(w, zpnmo, R_MOD) + + } + + /// @dev https://tools.ietf.org/html/draft-irtf-cfrg-hash-to-curve-06#section-5.2 + /// @param x x coordinate of a point on Bn254(𝔽_p) + /// @param y y coordinate of a point on Bn254(𝔽_p) + /// @param mPtr free memory + /// @return res an element mod R_MOD + function hash_fr(x, y, mPtr)->res { + + // [0x00, .. , 0x00 || x, y, || 0, 48, 0, dst, HASH_FR_SIZE_DOMAIN] + // <- 64 bytes -> <-64b -> <- 1 bytes each -> + + // [0x00, .., 0x00] 64 bytes of zero + mstore(mPtr, HASH_FR_ZERO_UINT256) + mstore(add(mPtr, 0x20), HASH_FR_ZERO_UINT256) + + // msg = x || y , both on 32 bytes + mstore(add(mPtr, 0x40), x) + mstore(add(mPtr, 0x60), y) + + // 0 || 48 || 0 all on 1 byte + mstore8(add(mPtr, 0x80), 0) + mstore8(add(mPtr, 0x81), HASH_FR_LEN_IN_BYTES) + mstore8(add(mPtr, 0x82), 0) + + // "BSB22-Plonk" = [42, 53, 42, 32, 32, 2d, 50, 6c, 6f, 6e, 6b,] + mstore8(add(mPtr, 0x83), 0x42) + mstore8(add(mPtr, 0x84), 0x53) + mstore8(add(mPtr, 0x85), 0x42) + mstore8(add(mPtr, 0x86), 0x32) + mstore8(add(mPtr, 0x87), 0x32) + mstore8(add(mPtr, 0x88), 0x2d) + mstore8(add(mPtr, 0x89), 0x50) + mstore8(add(mPtr, 0x8a), 0x6c) + mstore8(add(mPtr, 0x8b), 0x6f) + mstore8(add(mPtr, 0x8c), 0x6e) + mstore8(add(mPtr, 0x8d), 0x6b) + + // size domain + mstore8(add(mPtr, 0x8e), HASH_FR_SIZE_DOMAIN) + + let l_success := staticcall(gas(), SHA2, mPtr, 0x8f, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + + let b0 := mload(mPtr) + + // [b0 || one || dst || HASH_FR_SIZE_DOMAIN] + // <-64bytes -> <- 1 byte each -> + mstore8(add(mPtr, 0x20), HASH_FR_ONE) // 1 + + mstore8(add(mPtr, 0x21), 0x42) // dst + mstore8(add(mPtr, 0x22), 0x53) + mstore8(add(mPtr, 0x23), 0x42) + mstore8(add(mPtr, 0x24), 0x32) + mstore8(add(mPtr, 0x25), 0x32) + mstore8(add(mPtr, 0x26), 0x2d) + mstore8(add(mPtr, 0x27), 0x50) + mstore8(add(mPtr, 0x28), 0x6c) + mstore8(add(mPtr, 0x29), 0x6f) + mstore8(add(mPtr, 0x2a), 0x6e) + mstore8(add(mPtr, 0x2b), 0x6b) + + mstore8(add(mPtr, 0x2c), HASH_FR_SIZE_DOMAIN) // size domain + l_success := staticcall(gas(), SHA2, mPtr, 0x2d, mPtr, 0x20) + if iszero(l_success) { + error_verify() + } + + // b1 is located at mPtr. We store b2 at add(mPtr, 0x20) + + // [b0^b1 || two || dst || HASH_FR_SIZE_DOMAIN] + // <-64bytes -> <- 1 byte each -> + mstore(add(mPtr, 0x20), xor(mload(mPtr), b0)) + mstore8(add(mPtr, 0x40), HASH_FR_TWO) + + mstore8(add(mPtr, 0x41), 0x42) // dst + mstore8(add(mPtr, 0x42), 0x53) + mstore8(add(mPtr, 0x43), 0x42) + mstore8(add(mPtr, 0x44), 0x32) + mstore8(add(mPtr, 0x45), 0x32) + mstore8(add(mPtr, 0x46), 0x2d) + mstore8(add(mPtr, 0x47), 0x50) + mstore8(add(mPtr, 0x48), 0x6c) + mstore8(add(mPtr, 0x49), 0x6f) + mstore8(add(mPtr, 0x4a), 0x6e) + mstore8(add(mPtr, 0x4b), 0x6b) + + mstore8(add(mPtr, 0x4c), HASH_FR_SIZE_DOMAIN) // size domain + + let offset := add(mPtr, 0x20) + l_success := staticcall(gas(), SHA2, offset, 0x2d, offset, 0x20) + if iszero(l_success) { + error_verify() + } + + // at this point we have mPtr = [ b1 || b2] where b1 is on 32byes and b2 in 16bytes. + // we interpret it as a big integer mod r in big endian (similar to regular decimal notation) + // the result is then 2**(8*16)*mPtr[:32] + mPtr[32:48] + res := mulmod(mload(mPtr), HASH_FR_BB, R_MOD) // <- res = 2**128 * mPtr[:32] + let b1 := shr(128, mload(add(mPtr, 0x20))) // b1 <- [0, 0, .., 0 || b2[:16] ] + res := addmod(res, b1, R_MOD) + + } + + // END compute_pi ------------------------------------------------- + + /// @notice compute α² * 1/n * (ζ{n}-1)/(ζ - 1) where + /// * α = challenge derived in derive_gamma_beta_alpha_zeta + /// * n = vk_domain_size + /// * ω = vk_omega (generator of the multiplicative cyclic group of order n in (ℤ/rℤ)*) + /// * ζ = zeta (challenge derived with Fiat Shamir) + function compute_alpha_square_lagrange_0() { + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + let res := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) + let den := addmod(mload(add(state, STATE_ZETA)), sub(R_MOD, 1), R_MOD) + den := pow(den, sub(R_MOD, 2), mPtr) + den := mulmod(den, VK_INV_DOMAIN_SIZE, R_MOD) + res := mulmod(den, res, R_MOD) + + let l_alpha := mload(add(state, STATE_ALPHA)) + res := mulmod(res, l_alpha, R_MOD) + res := mulmod(res, l_alpha, R_MOD) + mstore(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0), res) + } + + /// @notice follows alg. p.13 of https://eprint.iacr.org/2019/953.pdf + /// with t₁ = t₂ = 1, and the proofs are ([digest] + [quotient] +purported evaluation): + /// * [state_folded_state_digests], [proof_batch_opening_at_zeta_x], state_folded_evals + /// * [proof_grand_product_commitment], [proof_opening_at_zeta_omega_x], [proof_grand_product_at_zeta_omega] + /// @param aproof pointer to the proof + function batch_verify_multi_points(aproof) { + let state := mload(0x40) + let mPtr := add(state, STATE_LAST_MEM) + + // derive a random number. As there is no random generator, we + // do an FS like challenge derivation, depending on both digests and + // ζ to ensure that the prover cannot control the random number. + // Note: adding the other point ζω is not needed, as ω is known beforehand. + mstore(mPtr, mload(add(state, STATE_FOLDED_DIGESTS_X))) + mstore(add(mPtr, 0x20), mload(add(state, STATE_FOLDED_DIGESTS_Y))) + mstore(add(mPtr, 0x40), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X))) + mstore(add(mPtr, 0x60), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_Y))) + mstore(add(mPtr, 0x80), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X))) + mstore(add(mPtr, 0xa0), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_Y))) + mstore(add(mPtr, 0xc0), calldataload(add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X))) + mstore(add(mPtr, 0xe0), calldataload(add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_Y))) + mstore(add(mPtr, 0x100), mload(add(state, STATE_ZETA))) + mstore(add(mPtr, 0x120), mload(add(state, STATE_GAMMA_KZG))) + let random := staticcall(gas(), SHA2, mPtr, 0x140, mPtr, 0x20) + if iszero(random){ + error_random_generation() + } + random := mod(mload(mPtr), R_MOD) // use the same variable as we are one variable away from getting stack-too-deep error... + + let folded_quotients := mPtr + mPtr := add(folded_quotients, 0x40) + mstore(folded_quotients, calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X))) + mstore(add(folded_quotients, 0x20), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_Y))) + point_acc_mul_calldata(folded_quotients, add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X), random, mPtr) + + let folded_digests := add(state, STATE_FOLDED_DIGESTS_X) + point_acc_mul_calldata(folded_digests, add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X), random, mPtr) + + let folded_evals := add(state, STATE_FOLDED_CLAIMED_VALUES) + fr_acc_mul_calldata(folded_evals, add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA), random) + + let folded_evals_commit := mPtr + mPtr := add(folded_evals_commit, 0x40) + mstore(folded_evals_commit, G1_SRS_X) + mstore(add(folded_evals_commit, 0x20), G1_SRS_Y) + mstore(add(folded_evals_commit, 0x40), mload(folded_evals)) + let check_staticcall := staticcall(gas(), 7, folded_evals_commit, 0x60, folded_evals_commit, 0x40) + if iszero(check_staticcall) { + error_verify() + } + + let folded_evals_commit_y := add(folded_evals_commit, 0x20) + mstore(folded_evals_commit_y, sub(P_MOD, mload(folded_evals_commit_y))) + point_add(folded_digests, folded_digests, folded_evals_commit, mPtr) + + let folded_points_quotients := mPtr + mPtr := add(mPtr, 0x40) + point_mul_calldata( + folded_points_quotients, + add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X), + mload(add(state, STATE_ZETA)), + mPtr + ) + let zeta_omega := mulmod(mload(add(state, STATE_ZETA)), VK_OMEGA, R_MOD) + random := mulmod(random, zeta_omega, R_MOD) + point_acc_mul_calldata(folded_points_quotients, add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X), random, mPtr) + + point_add(folded_digests, folded_digests, folded_points_quotients, mPtr) + + let folded_quotients_y := add(folded_quotients, 0x20) + mstore(folded_quotients_y, sub(P_MOD, mload(folded_quotients_y))) + + mstore(mPtr, mload(folded_digests)) + + mstore(add(mPtr, 0x20), mload(add(folded_digests, 0x20))) + mstore(add(mPtr, 0x40), G2_SRS_0_X_0) // the 4 lines are the canonical G2 point on BN254 + mstore(add(mPtr, 0x60), G2_SRS_0_X_1) + mstore(add(mPtr, 0x80), G2_SRS_0_Y_0) + mstore(add(mPtr, 0xa0), G2_SRS_0_Y_1) + mstore(add(mPtr, 0xc0), mload(folded_quotients)) + mstore(add(mPtr, 0xe0), mload(add(folded_quotients, 0x20))) + mstore(add(mPtr, 0x100), G2_SRS_1_X_0) + mstore(add(mPtr, 0x120), G2_SRS_1_X_1) + mstore(add(mPtr, 0x140), G2_SRS_1_Y_0) + mstore(add(mPtr, 0x160), G2_SRS_1_Y_1) + check_pairing_kzg(mPtr) + } + + /// @notice check_pairing_kzg checks the result of the final pairing product of the batched + /// kzg verification. The purpose of this function is to avoid exhausting the stack + /// in the function batch_verify_multi_points. + /// @param mPtr pointer storing the tuple of pairs + function check_pairing_kzg(mPtr) { + let state := mload(0x40) + + let l_success := staticcall(gas(), 8, mPtr, 0x180, 0x00, 0x20) + if iszero(l_success) { + error_pairing() + } + let res_pairing := mload(0x00) + mstore(add(state, STATE_SUCCESS), res_pairing) + } + + /// @notice Fold the opening proofs at ζ: + /// * at state+state_folded_digest we store: [Linearised_polynomial]+γ[L] + γ²[R] + γ³[O] + γ⁴[S₁] +γ⁵[S₂] + ∑ᵢγ⁵⁺ⁱ[Pi_{i}] + /// * at state+state_folded_claimed_values we store: Linearised_polynomial(ζ)+γL(ζ) + γ²R(ζ)+ γ³O(ζ) + γ⁴S₁(ζ) +γ⁵S₂(ζ) + ∑ᵢγ⁵⁺ⁱPi_{i}(ζ) + /// @param aproof pointer to the proof + /// acc_gamma stores the γⁱ + function fold_state(aproof) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + let mPtr20 := add(mPtr, 0x20) + let mPtr40 := add(mPtr, 0x40) + + let l_gamma_kzg := mload(add(state, STATE_GAMMA_KZG)) + let acc_gamma := l_gamma_kzg + let state_folded_digests := add(state, STATE_FOLDED_DIGESTS_X) + + mstore(state_folded_digests, mload(add(state, STATE_LINEARISED_POLYNOMIAL_X))) + mstore(add(state, STATE_FOLDED_DIGESTS_Y), mload(add(state, STATE_LINEARISED_POLYNOMIAL_Y))) + mstore(add(state, STATE_FOLDED_CLAIMED_VALUES), mload(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA))) + + point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_L_COM_X), acc_gamma, mPtr) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_L_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_R_COM_X), acc_gamma, mPtr) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_R_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_O_COM_X), acc_gamma, mPtr) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_O_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + mstore(mPtr, VK_S1_COM_X) + mstore(mPtr20, VK_S1_COM_Y) + point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_S1_AT_ZETA), acc_gamma) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + mstore(mPtr, VK_S2_COM_X) + mstore(mPtr20, VK_S2_COM_Y) + point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_S2_AT_ZETA), acc_gamma) + let poqaz := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + + acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) + mstore(mPtr, VK_QCP_0_X) + mstore(mPtr20, VK_QCP_0_Y) + point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) + fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), poqaz, acc_gamma) + poqaz := add(poqaz, 0x20) + + } + + /// @notice generate the challenge (using Fiat Shamir) to fold the opening proofs + /// at ζ. + /// The process for deriving γ is the same as in derive_gamma but this time the inputs are + /// in this order (the [] means it's a commitment): + /// * ζ + /// * [Linearised polynomial] + /// * [L], [R], [O] + /// * [S₁] [S₂] + /// * [Pi_{i}] (wires associated to custom gates) + /// Then there are the purported evaluations of the previous committed polynomials: + /// * Linearised_polynomial(ζ) + /// * L(ζ), R(ζ), O(ζ), S₁(ζ), S₂(ζ) + /// * Pi_{i}(ζ) + /// * Z(ζω) + /// @param aproof pointer to the proof + function compute_gamma_kzg(aproof) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + mstore(mPtr, FS_GAMMA_KZG) // "gamma" + mstore(add(mPtr, 0x20), mload(add(state, STATE_ZETA))) + mstore(add(mPtr,0x40), mload(add(state, STATE_LINEARISED_POLYNOMIAL_X))) + mstore(add(mPtr,0x60), mload(add(state, STATE_LINEARISED_POLYNOMIAL_Y))) + calldatacopy(add(mPtr, 0x80), add(aproof, PROOF_L_COM_X), 0xc0) + mstore(add(mPtr,0x140), VK_S1_COM_X) + mstore(add(mPtr,0x160), VK_S1_COM_Y) + mstore(add(mPtr,0x180), VK_S2_COM_X) + mstore(add(mPtr,0x1a0), VK_S2_COM_Y) + + let offset := 0x1c0 + + mstore(add(mPtr,offset), VK_QCP_0_X) + mstore(add(mPtr,add(offset, 0x20)), VK_QCP_0_Y) + offset := add(offset, 0x40) + mstore(add(mPtr, offset), mload(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA))) + mstore(add(mPtr, add(offset, 0x20)), calldataload(add(aproof, PROOF_L_AT_ZETA))) + mstore(add(mPtr, add(offset, 0x40)), calldataload(add(aproof, PROOF_R_AT_ZETA))) + mstore(add(mPtr, add(offset, 0x60)), calldataload(add(aproof, PROOF_O_AT_ZETA))) + mstore(add(mPtr, add(offset, 0x80)), calldataload(add(aproof, PROOF_S1_AT_ZETA))) + mstore(add(mPtr, add(offset, 0xa0)), calldataload(add(aproof, PROOF_S2_AT_ZETA))) + + let _mPtr := add(mPtr, add(offset, 0xc0)) + + + let _poqaz := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + calldatacopy(_mPtr, _poqaz, mul(VK_NB_CUSTOM_GATES, 0x20)) + _mPtr := add(_mPtr, mul(VK_NB_CUSTOM_GATES, 0x20)) + + + mstore(_mPtr, calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA))) + + let start_input := 0x1b // 00.."gamma" + let size_input := add(0x14, mul(VK_NB_CUSTOM_GATES,3)) // number of 32bytes elmts = 0x14 (zeta+3*6 for the digests+openings) + 3*VK_NB_CUSTOM_GATES (for the commitments of the selectors) + 1 (opening of Z at ζω) + size_input := add(0x5, mul(size_input, 0x20)) // size in bytes: 15*32 bytes + 5 bytes for gamma + let check_staticcall := staticcall(gas(), SHA2, add(mPtr,start_input), size_input, add(state, STATE_GAMMA_KZG), 0x20) + if iszero(check_staticcall) { + error_verify() + } + mstore(add(state, STATE_GAMMA_KZG), mod(mload(add(state, STATE_GAMMA_KZG)), R_MOD)) + } + + function compute_commitment_linearised_polynomial_ec(aproof, s1, s2) { + + let state := mload(0x40) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + + mstore(mPtr, VK_QL_COM_X) + mstore(add(mPtr, 0x20), VK_QL_COM_Y) + point_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(add(aproof, PROOF_L_AT_ZETA)), + add(mPtr, 0x40) + ) + + mstore(mPtr, VK_QR_COM_X) + mstore(add(mPtr, 0x20), VK_QR_COM_Y) + point_acc_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(add(aproof, PROOF_R_AT_ZETA)), + add(mPtr, 0x40) + ) + + let rl := mulmod(calldataload(add(aproof, PROOF_L_AT_ZETA)), calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + mstore(mPtr, VK_QM_COM_X) + mstore(add(mPtr, 0x20), VK_QM_COM_Y) + point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, rl, add(mPtr, 0x40)) + + mstore(mPtr, VK_QO_COM_X) + mstore(add(mPtr, 0x20), VK_QO_COM_Y) + point_acc_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(add(aproof, PROOF_O_AT_ZETA)), + add(mPtr, 0x40) + ) + + mstore(mPtr, VK_QK_COM_X) + mstore(add(mPtr, 0x20), VK_QK_COM_Y) + point_add( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + add(mPtr, 0x40) + ) + + + let qcp_opening_at_zeta := add(aproof, PROOF_OPENING_QCP_AT_ZETA) + let bsb_commitments := add(aproof, PROOF_BSB_COMMITMENTS) + for { + let i := 0 + } lt(i, VK_NB_CUSTOM_GATES) { + i := add(i, 1) + } { + mstore(mPtr, calldataload(bsb_commitments)) + mstore(add(mPtr, 0x20), calldataload(add(bsb_commitments, 0x20))) + point_acc_mul( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + mPtr, + calldataload(qcp_opening_at_zeta), + add(mPtr, 0x40) + ) + qcp_opening_at_zeta := add(qcp_opening_at_zeta, 0x20) + bsb_commitments := add(bsb_commitments, 0x40) + } + + + mstore(mPtr, VK_S3_COM_X) + mstore(add(mPtr, 0x20), VK_S3_COM_Y) + point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, s1, add(mPtr, 0x40)) + + mstore(mPtr, calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X))) + mstore(add(mPtr, 0x20), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_Y))) + point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, s2, add(mPtr, 0x40)) + + point_add( + add(state, STATE_LINEARISED_POLYNOMIAL_X), + add(state, STATE_LINEARISED_POLYNOMIAL_X), + add(state, STATE_FOLDED_H_X), + mPtr) + } + + /// @notice Compute the commitment to the linearized polynomial equal to + /// L(ζ)[Qₗ]+r(ζ)[Qᵣ]+R(ζ)L(ζ)[Qₘ]+O(ζ)[Qₒ]+[Qₖ]+Σᵢqc'ᵢ(ζ)[BsbCommitmentᵢ] + + /// α*( Z(μζ)(L(ζ)+β*S₁(ζ)+γ)*(R(ζ)+β*S₂(ζ)+γ)[S₃]-[Z](L(ζ)+β*id_{1}(ζ)+γ)*(R(ζ)+β*id_{2}(ζ)+γ)*(O(ζ)+β*id_{3}(ζ)+γ) ) + + /// α²*L₁(ζ)[Z] - Z_{H}(ζ)*(([H₀] + ζᵐ⁺²*[H₁] + ζ²⁽ᵐ⁺²⁾*[H₂]) + /// where + /// * id_1 = id, id_2 = vk_coset_shift*id, id_3 = vk_coset_shift^{2}*id + /// * the [] means that it's a commitment (i.e. a point on Bn254(F_p)) + /// * Z_{H}(ζ) = ζ^n-1 + /// @param aproof pointer to the proof + function compute_commitment_linearised_polynomial(aproof) { + let state := mload(0x40) + let l_beta := mload(add(state, STATE_BETA)) + let l_gamma := mload(add(state, STATE_GAMMA)) + let l_zeta := mload(add(state, STATE_ZETA)) + let l_alpha := mload(add(state, STATE_ALPHA)) + + let u := mulmod(calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA)), l_beta, R_MOD) + let v := mulmod(l_beta, calldataload(add(aproof, PROOF_S1_AT_ZETA)), R_MOD) + v := addmod(v, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) + v := addmod(v, l_gamma, R_MOD) + + let w := mulmod(l_beta, calldataload(add(aproof, PROOF_S2_AT_ZETA)), R_MOD) + w := addmod(w, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + w := addmod(w, l_gamma, R_MOD) + + let s1 := mulmod(u, v, R_MOD) + s1 := mulmod(s1, w, R_MOD) + s1 := mulmod(s1, l_alpha, R_MOD) + + let coset_square := mulmod(VK_COSET_SHIFT, VK_COSET_SHIFT, R_MOD) + let betazeta := mulmod(l_beta, l_zeta, R_MOD) + u := addmod(betazeta, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) + u := addmod(u, l_gamma, R_MOD) + + v := mulmod(betazeta, VK_COSET_SHIFT, R_MOD) + v := addmod(v, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + v := addmod(v, l_gamma, R_MOD) + + w := mulmod(betazeta, coset_square, R_MOD) + w := addmod(w, calldataload(add(aproof, PROOF_O_AT_ZETA)), R_MOD) + w := addmod(w, l_gamma, R_MOD) + + let s2 := mulmod(u, v, R_MOD) + s2 := mulmod(s2, w, R_MOD) + s2 := sub(R_MOD, s2) + s2 := mulmod(s2, l_alpha, R_MOD) + s2 := addmod(s2, mload(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0)), R_MOD) + + // at this stage: + // * s₁ = α*Z(μζ)(l(ζ)+β*s₁(ζ)+γ)*(r(ζ)+β*s₂(ζ)+γ)*β + // * s₂ = -α*(l(ζ)+β*ζ+γ)*(r(ζ)+β*u*ζ+γ)*(o(ζ)+β*u²*ζ+γ) + α²*L₁(ζ) + + compute_commitment_linearised_polynomial_ec(aproof, s1, s2) + } + + /// @notice compute -z_h(ζ)*([H₁] + ζⁿ⁺²[H₂] + ζ²⁽ⁿ⁺²⁾[H₃]) and store the result at + /// state + state_folded_h + /// @param aproof pointer to the proof + function fold_h(aproof) { + let state := mload(0x40) + let n_plus_two := add(VK_DOMAIN_SIZE, 2) + let mPtr := add(mload(0x40), STATE_LAST_MEM) + let zeta_power_n_plus_two := pow(mload(add(state, STATE_ZETA)), n_plus_two, mPtr) + point_mul_calldata(add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_2_COM_X), zeta_power_n_plus_two, mPtr) + point_add_calldata(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_1_COM_X), mPtr) + point_mul(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), zeta_power_n_plus_two, mPtr) + point_add_calldata(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_0_COM_X), mPtr) + point_mul(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)), mPtr) + let folded_h_y := mload(add(state, STATE_FOLDED_H_Y)) + folded_h_y := sub(P_MOD, folded_h_y) + mstore(add(state, STATE_FOLDED_H_Y), folded_h_y) + } + + /// @notice check that the opening of the linearised polynomial at zeta is equal to + /// - [ PI(ζ) - α²*L₁(ζ) + α(l(ζ)+β*s1(ζ)+γ)(r(ζ)+β*s2(ζ)+γ)(o(ζ)+γ)*z(ωζ) ] + /// @param aproof pointer to the proof + function compute_opening_linearised_polynomial(aproof) { + + let state := mload(0x40) + + // (l(ζ)+β*s1(ζ)+γ) + let s1 + s1 := mulmod(calldataload(add(aproof, PROOF_S1_AT_ZETA)), mload(add(state, STATE_BETA)), R_MOD) + s1 := addmod(s1, mload(add(state, STATE_GAMMA)), R_MOD) + s1 := addmod(s1, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) + + // (r(ζ)+β*s2(ζ)+γ) + let s2 + s2 := mulmod(calldataload(add(aproof, PROOF_S2_AT_ZETA)), mload(add(state, STATE_BETA)), R_MOD) + s2 := addmod(s2, mload(add(state, STATE_GAMMA)), R_MOD) + s2 := addmod(s2, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) + + // (o(ζ)+γ) + let o + o := addmod(calldataload(add(aproof, PROOF_O_AT_ZETA)), mload(add(state, STATE_GAMMA)), R_MOD) + + // α*Z(μζ)*(l(ζ)+β*s1(ζ)+γ)*(r(ζ)+β*s2(ζ)+γ)*(o(ζ)+γ) + s1 := mulmod(s1, s2, R_MOD) + s1 := mulmod(s1, o, R_MOD) + s1 := mulmod(s1, mload(add(state, STATE_ALPHA)), R_MOD) + s1 := mulmod(s1, calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA)), R_MOD) + + // PI(ζ) - α²*L₁(ζ) + α(l(ζ)+β*s1(ζ)+γ)(r(ζ)+β*s2(ζ)+γ)(o(ζ)+γ)*z(ωζ) + s1 := addmod(s1, mload(add(state, STATE_PI)), R_MOD) + s2 := mload(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0)) + s2 := sub(R_MOD, s2) + s1 := addmod(s1, s2, R_MOD) + s1 := sub(R_MOD, s1) + + mstore(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA), s1) + } + + // BEGINNING utils math functions ------------------------------------------------- + + /// @param dst pointer storing the result + /// @param p pointer to the first point + /// @param q pointer to the second point + /// @param mPtr pointer to free memory + function point_add(dst, p, q, mPtr) { + mstore(mPtr, mload(p)) + mstore(add(mPtr, 0x20), mload(add(p, 0x20))) + mstore(add(mPtr, 0x40), mload(q)) + mstore(add(mPtr, 0x60), mload(add(q, 0x20))) + let l_success := staticcall(gas(),EC_ADD,mPtr,0x80,dst,0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @param dst pointer storing the result + /// @param p pointer to the first point (calldata) + /// @param q pointer to the second point (calladata) + /// @param mPtr pointer to free memory + function point_add_calldata(dst, p, q, mPtr) { + mstore(mPtr, mload(p)) + mstore(add(mPtr, 0x20), mload(add(p, 0x20))) + mstore(add(mPtr, 0x40), calldataload(q)) + mstore(add(mPtr, 0x60), calldataload(add(q, 0x20))) + let l_success := staticcall(gas(), EC_ADD, mPtr, 0x80, dst, 0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @parma dst pointer storing the result + /// @param src pointer to a point on Bn254(𝔽_p) + /// @param s scalar + /// @param mPtr free memory + function point_mul(dst,src,s, mPtr) { + mstore(mPtr,mload(src)) + mstore(add(mPtr,0x20),mload(add(src,0x20))) + mstore(add(mPtr,0x40),s) + let l_success := staticcall(gas(),EC_MUL,mPtr,0x60,dst,0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @parma dst pointer storing the result + /// @param src pointer to a point on Bn254(𝔽_p) on calldata + /// @param s scalar + /// @param mPtr free memory + function point_mul_calldata(dst, src, s, mPtr) { + mstore(mPtr, calldataload(src)) + mstore(add(mPtr, 0x20), calldataload(add(src, 0x20))) + mstore(add(mPtr, 0x40), s) + let l_success := staticcall(gas(), EC_MUL, mPtr, 0x60, dst, 0x40) + if iszero(l_success) { + error_ec_op() + } + } + + /// @notice dst <- dst + [s]src (Elliptic curve) + /// @param dst pointer accumulator point storing the result + /// @param src pointer to the point to multiply and add + /// @param s scalar + /// @param mPtr free memory + function point_acc_mul(dst,src,s, mPtr) { + mstore(mPtr,mload(src)) + mstore(add(mPtr,0x20),mload(add(src,0x20))) + mstore(add(mPtr,0x40),s) + let l_success := staticcall(gas(),7,mPtr,0x60,mPtr,0x40) + mstore(add(mPtr,0x40),mload(dst)) + mstore(add(mPtr,0x60),mload(add(dst,0x20))) + l_success := and(l_success, staticcall(gas(),EC_ADD,mPtr,0x80,dst, 0x40)) + if iszero(l_success) { + error_ec_op() + } + } + + /// @notice dst <- dst + [s]src (Elliptic curve) + /// @param dst pointer accumulator point storing the result + /// @param src pointer to the point to multiply and add (on calldata) + /// @param s scalar + /// @mPtr free memory + function point_acc_mul_calldata(dst, src, s, mPtr) { + mstore(mPtr, calldataload(src)) + mstore(add(mPtr, 0x20), calldataload(add(src, 0x20))) + mstore(add(mPtr, 0x40), s) + let l_success := staticcall(gas(), 7, mPtr, 0x60, mPtr, 0x40) + mstore(add(mPtr, 0x40), mload(dst)) + mstore(add(mPtr, 0x60), mload(add(dst, 0x20))) + l_success := and(l_success, staticcall(gas(), EC_ADD, mPtr, 0x80, dst, 0x40)) + if iszero(l_success) { + error_ec_op() + } + } + + /// @notice dst <- dst + src*s (Fr) dst,src are addresses, s is a value + /// @param dst pointer storing the result + /// @param src pointer to the scalar to multiply and add (on calldata) + /// @param s scalar + function fr_acc_mul_calldata(dst, src, s) { + let tmp := mulmod(calldataload(src), s, R_MOD) + mstore(dst, addmod(mload(dst), tmp, R_MOD)) + } + + /// @param x element to exponentiate + /// @param e exponent + /// @param mPtr free memory + /// @return res x ** e mod r + function pow(x, e, mPtr)->res { + mstore(mPtr, 0x20) + mstore(add(mPtr, 0x20), 0x20) + mstore(add(mPtr, 0x40), 0x20) + mstore(add(mPtr, 0x60), x) + mstore(add(mPtr, 0x80), e) + mstore(add(mPtr, 0xa0), R_MOD) + let check_staticcall := staticcall(gas(),MOD_EXP,mPtr,0xc0,mPtr,0x20) + if eq(check_staticcall, 0) { + error_mod_exp() + } + res := mload(mPtr) + } + } + } +} diff --git a/contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol b/contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol new file mode 100644 index 000000000..2bd03a30c --- /dev/null +++ b/contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ISP1Verifier, ISP1VerifierWithHash} from "../ISP1Verifier.sol"; +import {PlonkVerifier} from "./PlonkVerifier.sol"; + +/// @title SP1 Verifier +/// @author Succinct Labs +/// @notice This contracts implements a solidity verifier for SP1. +contract SP1Verifier is PlonkVerifier, ISP1VerifierWithHash { + /// @notice Thrown when the verifier selector from this proof does not match the one in this + /// verifier. This indicates that this proof was sent to the wrong verifier. + /// @param received The verifier selector from the first 4 bytes of the proof. + /// @param expected The verifier selector from the first 4 bytes of the VERIFIER_HASH(). + error WrongVerifierSelector(bytes4 received, bytes4 expected); + + /// @notice Thrown when the exit code is invalid. + error InvalidExitCode(); + + /// @notice Thrown when the proof is invalid. + error InvalidProof(); + + /// @notice Thrown when the vkRoot is invalid. + error InvalidVkRoot(); + + /// @notice The version of the circuit. + function VERSION() external pure returns (string memory) { + return "v6.1.0"; + } + + /// @inheritdoc ISP1VerifierWithHash + function VERIFIER_HASH() public pure returns (bytes32) { + return 0x5a093a2fcb46394f5cadfe55c44d4d572fad9cec7aeb38026b0278322ef07fac; + } + + /// @notice The recursion vk root. + function VK_ROOT() public pure returns (bytes32) { + return 0x002f850ee998974d6cc00e50cd0814b098c05bfade466d28573240d057f25352; + } + + /// @notice Hashes the public values to a field elements inside Bn254. + /// @param publicValues The public values. + function hashPublicValues(bytes calldata publicValues) public pure returns (bytes32) { + return sha256(publicValues) & bytes32(uint256((1 << 253) - 1)); + } + + /// @notice Verifies a proof with given public values and vkey. + /// @param programVKey The verification key for the RISC-V program. + /// @param publicValues The public values encoded as bytes. + /// @param proofBytes The proof of the program execution the SP1 zkVM encoded as bytes. + function verifyProof( + bytes32 programVKey, + bytes calldata publicValues, + bytes calldata proofBytes + ) external view { + bytes4 receivedSelector = bytes4(proofBytes[:4]); + bytes4 expectedSelector = bytes4(VERIFIER_HASH()); + if (receivedSelector != expectedSelector) { + revert WrongVerifierSelector(receivedSelector, expectedSelector); + } + + uint256 expectedVkRoot = uint256(VK_ROOT()); + + bytes32 publicValuesDigest = hashPublicValues(publicValues); + uint256 exitCode = uint256(bytes32(proofBytes[4:36])); + uint256 vkRoot = uint256(bytes32(proofBytes[36:68])); + uint256 nonce = uint256(bytes32(proofBytes[68:100])); + + uint256[] memory inputs = new uint256[](5); + inputs[0] = uint256(programVKey); + inputs[1] = uint256(publicValuesDigest); + inputs[2] = exitCode; + inputs[3] = vkRoot; + inputs[4] = nonce; + if (exitCode != 0) { + revert InvalidExitCode(); + } + if (vkRoot != expectedVkRoot) { + revert InvalidVkRoot(); + } + bool success = this.Verify(proofBytes[100:], inputs); + if (!success) { + revert InvalidProof(); + } + } +} diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index c3f881ea3..6a44c38be 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -21,7 +21,7 @@ ix-common = { workspace = true } ix-compile = { workspace = true } ixon = { workspace = true } ix-kernel = { workspace = true } -ix-proof-compression = { path = "../proof-compression", default-features = false, features = ["plonk"] } +ix-proof-compression = { path = "../proof-compression", default-features = false } lean-ffi = { workspace = true } memmap2 = { workspace = true } multi-stark = { workspace = true } @@ -47,7 +47,10 @@ bincode = { version = "2.0.1", optional = true } serde = { version = "1.0.219", features = ["derive"], optional = true } [features] -default = [] +default = ["bls12-381", "plonk"] +bls12-381 = ["ix-proof-compression/bls12-381"] +bn254 = ["ix-proof-compression/bn254"] +plonk = ["ix-proof-compression/plonk"] parallel = ["aiur/parallel", "ix-proof-compression/parallel"] # The KZG prover's CUDA code is compiled into the ix-compression-worker # executable, not into the library the Lean executable links. diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index bfba3af03..28b0a435c 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -13,6 +13,7 @@ path = "src/bin/ix_compression_worker.rs" aiur = { workspace = true } multi-stark = { workspace = true } ark-bls12-381 = { version = "0.5", features = ["curve"] } +ark-bn254 = { version = "0.5", features = ["curve"], optional = true } ark-ec = "0.5" ark-ff = { version = "0.5", features = ["asm"] } ark-poly = "0.5" @@ -50,9 +51,21 @@ rev = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8" optional = true features = ["cuda"] +[[example]] +name = "kzg_filecoin_import" +required-features = ["bls12-381"] + +[[example]] +name = "kzg_ignition_import" +required-features = ["bn254"] + [features] +bls12-381 = [] +# BN254 accepts admitted Ignition or development setup; CUDA support is experimental. +# Production parameters and terminal proof backends require separate profiles. +bn254 = ["dep:ark-bn254"] plonk = [] -default = ["parallel", "plonk"] +default = ["parallel", "bls12-381", "plonk"] # GPU proving: multi-stark's Goldilocks backend for FRI compression and the # KZG kernels here. Needs the CUDA toolkit at build time and a visible GPU # at runtime; CPU-only builds neither compile sppark nor link CUDA. diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 5ca0531de..9f9822b45 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -1,5 +1,515 @@ # Proof compression +## Experimental BN254 backend + +The `bn254` feature selects BN254 for the KZG library, including scalar +arithmetic, FFTs, commitments, batched openings, BLAKE3 transcripts, compact +proof encoding, recursive verifier gadgets and the standalone compression +worker. Use it independently of the BLS12-381 terminal-backend features: + +```sh +cargo test -p ix-proof-compression --no-default-features \ + --features bn254,parallel --test bn254_kzg +``` + +BN254 supports an explicit known-trapdoor development SRS and caller-admitted +Ignition parameters. The importer, admission and pipeline passed crate tests, +real transcript authentication/import, and cold/warm Init GPU proofs with +independent CPU verification. No production ceremony pin or BN254 verifying +key is bundled yet. Filecoin environment variables cannot select a BN254 +setup. Admission of parameters and authorization of a verifying key are +separate trust decisions. +The `ix` library and its worker select the same curve through Lake's +`IX_KZG_CURVE=bls12-381|bn254` setting; BLS12-381 remains the default. +The Solidity exporter and gnark wrapper remain BLS12-381. Combining `bn254` +with the default features, `bls12-381`, `solidity` or `plonk` is unsupported +and rejected. The `bn254,cuda` integration passes CUDA component checks and +ordinary KZG proof-byte parity on a small fixture. Saved Init development +GPU proofs match CPU output; Ignition GPU proofs pass independent CPU +verification and produce identical bytes with cold/warm fixed preprocessing. + +BN254's scalar field permits radix-2 domains through `2^28`; the trace size +multiplied by the quotient factor must fit that limit. Compressed G1/G2 points +occupy 32/64 bytes in arkworks' canonical encoding. Proofs, checkpoints, +portable verifier parameters, transcript seeds and development setup/cache +identities distinguish the curve. Existing BLS12-381 formats retain their +identities. + +Curve selection is per build. Separate target directories, such as +`--target-dir target/bls12-381` and `--target-dir target/bn254`, keep both +executables available for comparison. The shared `kzg_transcripts`, +`kzg_sharding` and `kzg_streaming` test targets run on either curve; enable +`parallel` for both. These are correctness checks, not performance benchmarks. + +The recursive source port uses four 80-bit base-field limbs for BN254, a +257-bit biased quotient and the existing bounded carry construction. G1 +validation uses BN254's cofactor-one property after enforcing the curve +equation. Its canonical 32-byte point encoding is exposed as two 128-bit +little-endian public limbs per deferred pairing operand; BLS retains two +192-bit limbs per point. The native assignment path checks that these public +values match the constrained points and verifies the deferred pairing. + +The CPU baseline at `52c61479` records focused debug validation with Rust +1.98.1 passing 132 BN254 tests and 114 BLS regression tests using locked, +offline dependencies. Coverage includes KZG +proofs, recursive field/curve/transcript/MSM constraints, malformed witnesses, +deferred pairings, verifying key authentication, worker selection and existing BLS +catalog fixtures. BN254 also passed verifier reuse, FRI-to-KZG stage checks, +saved Init FRI input verification and the small root-stage integration tests. +Clippy with `-D warnings` passed for both configurations, including the sizing +example. These baseline checks excluded ignored proving/benchmark tests and +larger KZG component cases; they were not the complete repository suite. + +Init's complete BN254 development pipeline passed, including independent +envelope verification and rejection of substituted claims, statements, +verifying key digests and missing development opt-in. Mathlib passed native FRI, +sizing and source/translated witness checks; no complete mathlib KZG proof +was produced. The complete saved Init CUDA pipeline now passes with both +development and admitted Ignition parameters. Terminal BN254 PLONK is +available with admitted Ignition parameters: CPU and CUDA Init proofs pass +independent native verification and the adapted Solidity verifier. + +### Real-input measurements + +The saved mathlib aggregate FRI proof was verified and compressed through one +additional Goldilocks FRI layer using the existing CUDA worker. The run took +60.0 seconds end to end, including 50.2 seconds for outer proving and +verification, with 186.3 GiB peak host RSS. The raw input proof was 5,554,329 +bytes and the compressed FRI proof was 1,441,095 bytes. These measurements +used a 48-logical-CPU Xeon Platinum 8559C host with two RTX PRO 6000 Blackwell +Server GPUs; they measure FRI, not BN254 KZG. + +For the same saved Init FRI proof and first-stage log-height 24, the BN254 +frontend and padded main traces match the historical BLS12-381 run exactly: + +| First KZG metric | BN254 and BLS12-381 | +| --- | ---: | +| Gates | 113,913,480 | +| Lookups | 31,689,479 | +| Witness values | 132,837,040 | +| Partitions | 19 | +| Padded main-trace cells | 1,071,842,048 | +| Main scalar storage only | 31.943 GiB | + +The mathlib frontend has eight more gates and four more values than Init, +with the same lookup count. Main-trace storage excludes the frontend, fixed, +lookup and quotient columns, and scratch space. The historical BLS recursive +verifier and measured BN254 verifier compare as follows: + +| Recursive verifier metric | BLS12-381 reference | BN254 Init | +| --- | ---: | ---: | +| Gates | 85,902,077 | 44,895,563 | +| Lookups | 32,487,842 | 16,255,419 | +| Frontend rows | 118,389,942 | 61,151,005 | +| Witness values | 115,263,448 | 59,590,881 | +| Padded computation height | `2^27` | `2^26` | +| Opening MSM terms | 583 and 9 | 583 and 9 | + +BN254 has about 48% fewer recursive frontend rows and halves the padded +computation trace. Its full recursive witness and external pairing checks +passed against the real Init first KZG proof. That first proof is 32,869 bytes, +or 33,045 bytes including its packet header and statement. The final recursive +proof is 1,669 bytes, its packet is 1,909 bytes and the complete envelope is +1,917 bytes. Independent verification against the original Init claim and +root statement passed without a proving SRS or cache. Negative checks rejected +the mathlib claim, its root statement, both together, an incorrect profile +pin and omitted development opt-in. The profile pin came from the locally +generated run; it is not an independently authorized production profile. + +At the recursive frontend boundaries, point validation accounts for 36.47 +million BLS rows versus 0.41 million BN254 rows; BN254's cofactor-one G1 +removes the BLS subgroup-check machinery. The two opening MSMs add 62.80 +million versus 45.45 million rows. Transcript processing adds 19.12 million +versus 15.28 million rows, with shorter point encodings on BN254. These +increments include newly exposed public values and are not operation counts +or gnark PLONK constraint counts. + +The complete Init CPU run took 3,193.0 seconds (53.2 minutes) and peaked at +96.0 GiB host RSS. Fixed preprocessing was cold and both development SRS +prefixes were generated during the run. Stage spans from TeXRay are rounded: + +| Init CPU phase | First KZG | Recursive KZG | +| --- | ---: | ---: | +| Preparation and main commitments | 16.17 min | 6.44 min | +| Lookup construction | 7.04 min | 2.35 min | +| Quotient construction | 11.02 min | 4.11 min | +| Openings | 1.98 min | 1.27 min | +| Complete stage | 38.34 min | 14.87 min | + +Complete stages also include input handling, circuit/witness construction, +parameter preparation, verification and output. Nested spans and prefetched +work overlap; do not sum arbitrary child spans. Native final packet +verification took 3.96 ms inside the prover; the separate verification process +took 15.5 ms including startup and file loading. Main trace shapes and phase +logs do not provide complete FFT-call, field-operation or prover-MSM totals. + +These are structural comparisons. Historical BLS timings use CUDA, Filecoin +parameters and an older build with a Keccak final transcript; current BN254 +runs use CPU, development parameters and BLAKE3. A curve-speed comparison +requires matching those conditions. + +### Ignition parameter import + +The BN254-only `kzg_ignition_import` example provides local transcript +inspection, import and authenticated prefix loading. The real `2^26` import +and authenticated cache loads passed on 2026-10-16. To build the tool: + +```sh +cargo build --release --locked -p ix-proof-compression \ + --no-default-features --features bn254,parallel --example kzg_ignition_import +IGNITION_IMPORTER="$PWD/target/release/examples/kzg_ignition_import" +"$IGNITION_IMPORTER" info +"$IGNITION_IMPORTER" inspect \ + /path/to/ignition-downloads /path/to/source-manifest.candidate.json +``` + +`inspect` reads the complete local contributions 196 and 197, in the +`NNN_0xaddress/transcript00.dat` through `transcript19.dat` directories. It +checks framing and embedded checksums and produces an **untrusted candidate** +inventory with complete-file BLAKE2b-512 hashes. This importer-specific source +manifest is different from Aztec's downloaded `manifest.json`. Inspection +does not authenticate participants or replay the ceremony. + +Independently authenticate the selected ceremony bytes before pinning the +exact source-manifest JSON's BLAKE3 digest in trusted configuration. Then: + +```sh +"$IGNITION_IMPORTER" import \ + /path/to/ignition-downloads /path/to/source-manifest.json \ + "$IGNITION_SOURCE_MANIFEST_BLAKE3" /path/to/new-ignition-prefix.bin 26 +"$IGNITION_IMPORTER" verify \ + /path/to/new-ignition-prefix.bin "$IGNITION_CACHE_MANIFEST_BLAKE3" 26 +``` + +Import reports `cache_manifest_blake3`; retain it separately from the cache +for subsequent loads. It authenticates all source files, including unused +suffixes, by hash. It checks canonical coordinates and curve/subgroup +membership for retained G1 points and G2 anchors, the shifted first power, +every retained adjacency and the selected predecessor link. The link check +is not full participant-signature or ceremony verification. Prefixes must be +powers of two from 2 through `2^26`. +The default inspection inventory includes both contributions; library +callers can supply a separately pinned manifest without predecessor evidence. + +The importer preserves the sequence beginning at `tau * G1`; it never +inserts the canonical generator. A complete 100,800,000-entry source declares +public polynomial degree 100,799,999 even when fewer points are retained. +Import memory is bounded by chunks, and cache publication is atomic and +refuses existing destinations. Normalized G1 payloads occupy 64 bytes per +point (about 4 GiB for `2^26`), plus metadata. Cache loads authenticate whole +overlapping chunks; a two-point verifier load reads just the authenticated +manifest and anchors. + +Library entry points are `kzg::srs::ignition::{inspect_ignition_source, +read_source_manifest, import_ignition_transcripts, load_ignition_cache}`. +`load_ignition_cache_with_identity` also returns the source receipt metadata. +No ceremony pin is built in. The import follows SP1's PLONK setup path, +Aztec Ignition's `MAIN IGNITION` transcripts through participants 196 and +197; this selects a candidate, not an authorized production setup. + +The validated source snapshot contains all forty transcript files from +participants 196 and 197, matching the SP1-selected participant snapshot. +All forty per-file signatures verified using EIP-191 over the SHA256 of the +complete transcript. Both participants' first G1 and contribution G2 points +also matched the historical records in +[Aztec's verification repository at `c333ec47`](https://github.com/AztecProtocol/ignition-verification/tree/c333ec4775045139f86732abfbbd65728404ab7f). +The source inventory's complete-file BLAKE2b-512 hashes were then matched +against that signature evidence before import. This authenticates the files +to the documented participant keys; it does not replay the entire ceremony +or establish erasure of their secret randomness. The separate `sealed` +snapshot has different anchors and is not part of this import. + +The prepared cache, all forty source transcripts, admission and authentication +evidence are stored in the same S3 bucket as the BLS parameters: +`s3://argument-kzg-params-063002298335-us-east-1-an/bn254/ignition/main-197/`. +All 116 objects (17,197,946,185 bytes) passed size and SHA-256 verification +after upload, including multipart checksum verification. The local validation +copy is at `/opt/dlami/nvme/ix-bn254/parameters/ignition/`: + +- `source-authentication.json` binds `source-manifest.json` to the signature + evidence and its pinned upstream revision. +- `authentication/verified-signatures.json` records every digest, signature + and recovered address; `authentication/verify_signatures.py` reproduces + those checks against the downloaded transcripts. +- `import-receipt.json` and `admission-receipt.json` record the + independent source, cache and admission pins and the complete setup identity. + +Proving needs the prepared cache and admission; the source transcripts are +needed only to reproduce the import. Restore them onto fast local storage: + +```sh +IGNITION_DATA=/path/to/local-nvme/parameters/ignition +IGNITION_S3=s3://argument-kzg-params-063002298335-us-east-1-an/bn254/ignition/main-197 +mkdir -p "$IGNITION_DATA" +export IX_KZG_IGNITION_CACHE="$IGNITION_DATA/ix-prefix-26.bin" +export IX_KZG_IGNITION_ADMISSION="$IGNITION_DATA/admission.json" +aws s3 cp "$IGNITION_S3/ix-prefix-26.bin" "$IX_KZG_IGNITION_CACHE" --region us-east-1 +aws s3 cp "$IGNITION_S3/admission.json" "$IX_KZG_IGNITION_ADMISSION" --region us-east-1 +export IX_KZG_IGNITION_CACHE_BLAKE3=6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b +export IX_KZG_IGNITION_ADMISSION_BLAKE3=4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46 +``` + +The host-specific `setup.env` and `setup.json` were excluded from S3; the +commands above configure replacement local paths with the verified pins. + +| Validated operation | Time | Peak process RSS | +| --- | ---: | ---: | +| Import `2^26` powers, checking contributions 196 and 197 | 211.88 s | 0.134 GiB | +| Authenticate and load the complete `2^26` cache | 5.54 s | 4.532 GiB | +| Load two verifier anchors from the authenticated cache | 4.31 ms | — | + +Times are the tool's operation timings; resource logs are under +`/opt/dlami/nvme/ix-bn254/logs/ignition-{import,verify}-26/`. CPU crate tests +passed: 249 for BN254 and 238 for BLS12-381, with five ignored on each. +The CUDA module suites also passed all thirty checks on each curve. +The complete cold and warm Init runs under Ignition also passed; see +[the real-input validation](#real-init-with-ignition). + +### Admitted Ignition pipeline + +An `IgnitionAdmission` carries the exact source-manifest JSON, its pin, +source-derived setup identity and degree, and four verifier anchors. Its +independent BLAKE3 pin authorizes those parameters. It contains no proving +cache path or cache-prefix receipt, so verification can use it without a +proving cache and different authenticated prefixes can share an admission. +The source-derived identity prevents relabeling an existing development +setup as Ignition. Source provenance still depends on the caller's trust in +the descriptor; creating this file does not independently authenticate a +ceremony or authorize a verifying key. + +Produce the descriptor from already trusted source and cache pins: + +```sh +"$IGNITION_IMPORTER" admission \ + /path/to/ignition-prefix.bin "$IGNITION_CACHE_MANIFEST_BLAKE3" \ + /path/to/source-manifest.json "$IGNITION_SOURCE_MANIFEST_BLAKE3" \ + /path/to/new-admission.json +``` + +Retain the reported `admission_blake3` independently. To select these +parameters for proving, configure all four settings: + +```sh +export IX_KZG_IGNITION_CACHE=/path/to/ignition-prefix.bin +export IX_KZG_IGNITION_CACHE_BLAKE3="$IGNITION_CACHE_MANIFEST_BLAKE3" +export IX_KZG_IGNITION_ADMISSION=/path/to/new-admission.json +export IX_KZG_IGNITION_ADMISSION_BLAKE3="$IGNITION_ADMISSION_BLAKE3" + +"$IX_COMPRESSION_WORKER" pipeline run-fri \ + /path/to/fri /path/to/claim.bin /path/to/root/root-claims.bin \ + /path/to/new-ignition-run 24 +``` + +On the validation host, the retained configuration supplies all four settings: + +```sh +source /opt/dlami/nvme/ix-bn254/parameters/ignition/setup.env +``` + +It selects `ix-prefix-26.bin` and `admission.json`, using the pins recorded in +the receipts above. It does not select or authorize a verifying key. + +Partial configuration, incorrect pins, changed anchors/source receipts and +insufficient prefixes fail closed. `--dev-srs` explicitly selects development +parameters instead. The worker checks Ignition capability before starting +stages and binds both KZG stages to the same admitted setup; the recursive +verifying key must also retain its admitted deferred pairing keys. + +For local verification, supply the independently trusted verifying key digest +as well as the original claim, expected root statement and admitted setup: + +```sh +"$IX_COMPRESSION_WORKER" pipeline verify-ignition \ + /path/to/run/proof.envelope.bin /path/to/claim.bin \ + /path/to/root/root-claims.bin /path/to/run/final-kzg/verifier.bin \ + "$BN254_PROFILE_DIGEST" /path/to/new-admission.json \ + "$IGNITION_ADMISSION_BLAKE3" +``` + +This command reads no proving cache or setup environment and rejects +`--dev-srs`. The library equivalent is `verify_with_ignition_admission`, +with an `IgnitionAdmission` authenticated using `read` or `from_bytes`. +Ordinary application verification continues to use its release-bundled +verifying key catalog. A future BN254 production entry must bundle both +the verifying key digest and the admitted setup pin; generating either +artifact does not add that entry. The BN254 catalog remains empty and admits +no development entries. Existing BLS interfaces/catalogs are unchanged. + +### Real Init with Ignition + +The saved Init FRI proof completed both KZG stages using admitted Ignition +parameters, first with cold fixed preprocessing and then with it reused. +The ceremony import was already prepared and is excluded from these timings. +Independent CPU verification passed for both runs after removing +proving-cache and setup environment variables. Seven negative checks per run +rejected a corrupt proof, wrong claim, wrong expected statement, wrong +verifying key digest, wrong admission pin, corrupt admission and a development +verifying key. Each verification report includes a CPU capability check as +its ninth check. + +| Measurement | Cold fixed preprocessing | Warm fixed preprocessing | +| --- | ---: | ---: | +| First KZG wall time | 210.89 s | 135.94 s | +| Recursive KZG wall time | 72.69 s | 44.56 s | +| Complete pipeline wall time | 284.30 s | 181.13 s | +| First KZG peak process RSS | 175.283 GiB | 175.287 GiB | +| Recursive KZG peak process RSS | 81.155 GiB | 63.161 GiB | +| First KZG sampled peak GPU memory | 28,954 MiB | 28,952 MiB | +| Recursive KZG sampled peak GPU memory | 60,696 MiB | 58,648 MiB | + +Both stages use the same admitted setup identity and full public degree +100,799,999. The first stage retains nineteen circuits, 113,913,480 gates and +31,689,479 lookups. The recursive frontend has 61,151,377 rows, 372 more than +the development setup, and still pads to `2^26`. These are single runs with +uncontrolled OS page cache. Different binaries and setup parameters prevent +attributing differences from the development benchmark to the ceremony. + +The envelope is **1,917 bytes** and the portable verifying key is **1,542 +bytes**. Both are byte-identical between the cold and warm runs, as are the +compact proofs from both KZG stages. The verifying key uses the same +structure as BLS12-381, implemented by `VerifierProfile`, with BN254 version 7, +circuit metadata and both opening equations. Its locally pinned BLAKE3 +digest is +`a4cf1b3b2489af6c939847d5060452bccb1f87ed8cff0fb90658e477549eafcd`. +This key has not been added to the verifying key catalog. + +Artifacts are under `/opt/dlami/nvme/ix-bn254/ignition-validation/`: +`experiment.json` records both runs and byte comparisons; `cold/run/` and +`warm/run/` contain their envelopes and verifying keys. Each run's +`verification/verification-report.json` records all independent checks and +`stages/` contains resource logs. The admitted parameter settings +remain in `/opt/dlami/nvme/ix-bn254/parameters/ignition/setup.env`. + +### Saved Init pipeline + +The worker has two entry points for a complete KZG run. `pipeline run` reads +`root-vk.bin`, `root-proof.bin` and `root-claims.bin`, performs FRI compression, +then runs first and recursive KZG stages. `pipeline run-fri` starts from an +existing `outer-vk.bin`, `outer-proof.bin` and `outer-claims.bin`; it verifies +that saved FRI proof and compares all eighteen statement words with the +separately supplied root claims before starting the KZG workers. + +Build and execute these commands only when machine time is available. Real +inputs require tens of millions of rows and substantial RAM; use the +measurements above when choosing a host and place outputs and caches on fast +local storage. + +```sh +cargo build --release --locked -p ix-proof-compression --bin ix-compression-worker \ + --no-default-features --features bn254,parallel --target-dir target/bn254 +export IX_COMPRESSION_WORKER="$PWD/target/bn254/release/ix-compression-worker" +"$IX_COMPRESSION_WORKER" fri capabilities + +"$IX_COMPRESSION_WORKER" pipeline run \ + /path/to/root /path/to/claim.bin /path/to/bn254-run 24 --dev-srs +``` + +Alternatively, reuse an already saved FRI proof in a separate empty output +directory: + +```sh +"$IX_COMPRESSION_WORKER" pipeline run-fri \ + /path/to/fri /path/to/claim.bin /path/to/root/root-claims.bin \ + /path/to/bn254-from-fri 24 --dev-srs +``` + +To verify the saved FRI input and count the translated frontend before +proving, use: + +```sh +"$IX_COMPRESSION_WORKER" kzg size \ + /path/to/fri /path/to/kzg-size.json --dev-srs +``` + +This retains the source Goldilocks circuit and translation wire map, without +allocating the translated circuit, proving traces or SRS. Its exact gate, +lookup and value counts do not estimate total prover memory or padded trace +sizes. The saved Init and mathlib inputs each used about 8.13 GiB peak RSS +and 20 seconds wall time for this preflight on the measurement host. + +`claim.bin` contains the canonical serialized claim whose digest appears in +the root statement. The top-level driver admits first-stage log-heights 24 +and 25, matching the saved recursive layout policy. Workers run in separate +processes and are checked for the selected curve before proving. Each run +writes `proof.envelope.bin`, `final-kzg/verifier.bin`, +`expected-root-claims.bin` and `pipeline-report.json`; failed stages retain +their checkpoints. Shared cache environment variables override the default +per-run `cache/` directory. + +BN254 has no bundled verifying key catalog entry. Its packet and verifying +key versions are separate from BLS, and generating a key does not authorize +it. For local verification, set `BN254_PROFILE_DIGEST` to the independently +reviewed BLAKE3 digest of `final-kzg/verifier.bin`, then supply that pin, +the original claim and independently expected root claims: + +```sh +"$IX_COMPRESSION_WORKER" pipeline verify \ + /path/to/bn254-run/proof.envelope.bin /path/to/claim.bin \ + /path/to/root/root-claims.bin /path/to/bn254-run/final-kzg/verifier.bin \ + "$BN254_PROFILE_DIGEST" --dev-srs +``` + +Verification needs no proving SRS or cache. It checks the verifying key +digest, development opt-in, claim digest, all expected statement words, outer +KZG proof and deferred pairing. The generated report records the verifying +key digest for inspection; the verifier never obtains its trusted pin from +the proof. +The library equivalents are `compress`, `compress_fri` and +`verify_with_profile`. `ix verify` uses the catalog for its compiled curve; +BN254 has no authorized entries yet, so use the worker command above with an +explicit verifying key for development verification. + +### Validation and sizing entry points + +Focused entry points include the field primitives and complete verifier +witness, with additional curve, transcript, MSM and public-pairing modules: + +```sh +cargo test -p ix-proof-compression --no-default-features --features bn254,parallel \ + --lib recursive::native_field:: +cargo test -p ix-proof-compression --no-default-features --features bn254,parallel \ + --lib recursive::native_verifier::reuse_tests::recursive_witness_rejects_tampering_without_native_preverification -- --exact +``` + +The sizing example uses the same four-row inner circuit on both curves. It +proves that small development fixture, then counts the recursive frontend +without retaining the full recursive IR, generating its witness or producing +an outer proof: + +```sh +cargo run -p ix-proof-compression --no-default-features --features parallel \ + --example recursive_size > target/recursive-bls12-381.json +cargo run -p ix-proof-compression --no-default-features --features bn254,parallel \ + --example recursive_size > target/recursive-bn254.json +``` + +The reports contain cumulative and incremental gates, lookups and values at +the curve-input, transcript, scalar-check and opening-MSM boundaries. Shared +tables and scalar preparation contribute to these phases; these frontend +counts are not gnark PLONK constraint counts. The real Init comparison above +uses saved pipeline artifacts; this smaller example provides another +controlled input for comparing the two curves. + +CPU/GPU parity and real Init pipeline measurements are recorded below. The +BN254 gnark wrapper now proves and verifies the actual Ignition Init packet, +including both pairing obligations and the two public claim-digest limbs. +The classic-GLV helper with separate pairing checks uses 14,689,442 rows and +domain 2^24. Core proving took 53.79 seconds, the fresh prover process took +73.70 seconds, and peak RSS was 25.19 GiB. The native proof is 584 bytes, or +624 bytes with its ix envelope. The matching basis and keys are now cached; +their 5m 58s preparation cost is excluded from proof timing. Independent native +and catalog verification passed. + +The same proof passes local Anvil verification through a constants-only SP1 +verifier adaptation and ix's claim adapter at 320,103 total transaction gas. +See the [Solidity result](../../contracts/plonk/README.md#actual-bn254-init-proof-on-anvil). +Remaining work includes complete CLI packaging. +Review the authenticated BN254 +ceremony provisioning, verifying key/catalog authorization and regenerated +fixtures before enabling production releases. + +## BLS12-381 compression pipeline + Compress an aggregate `CheckEnv` root proof through FRI → KZG → recursive KZG. The statement the compressed proof exposes is the root's public claim row, derived from the aggregation backend as `ix verify --aggregate` derives @@ -53,7 +563,7 @@ checks the cache against its built-in receipt and the pinned tau anchors. A `--dev-srs` selects a **known-trapdoor development SRS**, including when a Filecoin cache is configured. Its proofs are **not production-secure**. -Verification requires the corresponding bundled development profile and +Verification requires the corresponding bundled development verifying key and explicit `--dev-srs` opt-in; proof, claim and pairing checks still apply. ```sh @@ -70,55 +580,59 @@ Fixed preprocessing and development SRS files are cached across runs under (default: 24) and `--root-vk PATH` for a trusted root key instead of the current aggregate key. Full proving needs hundreds of GiB of RAM. -Verification uses the profiles compiled into the application from `profiles/`. -`ix verify` selects a profile by the packet's ID and derives the expected -statement from that profile's pinned aggregation-program prefix and the hash -of the caller's serialized claim. It checks the compact proof and both -pairing obligations without a verifier file, registry, proving SRS or GPU. - -External verifier imports are not supported, and local files under -`~/.ix/verifiers` have no effect. Unknown profile IDs are rejected; adding an -accepted profile requires an application update. The bundled profiles are: - -| Profile | Setup | First-stage log-height | +Verification uses the verifying keys compiled into the application from +`profiles/`. `ix verify` selects a verifying key by the packet's ID and derives +the expected statement from that key's pinned aggregation-program prefix and +the hash of the caller's serialized claim. It checks the compact proof and +both pairing obligations without an external key file, registry, proving SRS +or GPU. + +Ordinary `ix verify` does not import external verifying keys, and local files +under `~/.ix/verifiers` have no effect. Unknown verifying key IDs are rejected; +adding an accepted key requires an application update. The standalone +worker's `pipeline verify` command instead requires an explicit verifying key +digest and expected statement, as described above. The bundled BLS verifying +keys are: + +| Verifying key | Setup | First-stage log-height | | --- | --- | --- | | `aggregate-filecoin-v5.bin` | Filecoin | 24 | | `aggregate-development-v5.bin` | Development, requires `--dev-srs` | 24 | The adjacent JSON files record the setup, root key and artifact identities. -The verifier catalog pins each profile and its aggregation-program prefix. -The public claim is supplied by the caller rather than copied from the packet, so a bundled profile verifies -any aggregate root proved by the same recursive circuit. `ix compress` derives -the statement from the aggregation backend instead and fails if a bundled -profile pins a different program than the build it runs in, so a stale -bundle is caught where the backend is available. - -Compression does not require a profile to be bundled before proving. It +The verifying key catalog pins each key and its aggregation-program prefix. +The public claim is supplied by the caller rather than copied from the +packet, so a bundled key verifies any aggregate root proved by the same +recursive circuit. `ix compress` derives the statement from the aggregation +backend instead and fails if a bundled key pins a different program than +the build it runs in, so a stale bundle is caught where the backend is available. + +Compression does not require a verifying key to be bundled before proving. It constructs its circuit, verifies its output and writes `final-kzg/verifier.bin` as an output of the run. That file can be reviewed and bundled in a later application release; generating it does not register it for verification. -The packet carries the 18-word public claim, a profile ID, two compressed G1 +The packet carries the 18-word public claim, a verifying key ID, two compressed G1 opening-pairing operands and the compact `KQP4` KZG proof. Generic Serde serialization of complete KZG proofs is unsupported; callers use -`FixedProofCodec` with the expected profile. Internal commitment metadata +`FixedProofCodec` with the expected proof shape. Internal commitment metadata retains its canonical encoding and checks nested counts before allocating. Every setup declares its complete public polynomial degree. Loading a shorter proving prefix or only verifier anchors preserves that degree and the setup identity. The Filecoin identity derives from the ceremony digest and its `2^28 - 2` public degree, so every prefix cache of it binds the same -checkpoints and verifier profiles. Development identities derive from the +checkpoints and verifying keys. Development identities derive from the seed and declared degree; the trapdoor remains known. ## Final proof transcript -New recursive KZG proofs use Keccak-256 in portable verifier profile v5. +Portable BLS verifying key version 5 uses Keccak-256 for recursive KZG proofs. The FRI proof, first KZG proof and the recursive circuit's verification of that first proof retain BLAKE3. The compact proof layout and SRS are unchanged. -The profile version selects the transcript without trying alternatives; -application verification also requires that exact profile to be bundled. -Setup bindings include the transcript choice. Metadata checksums and profile +The verifying key version selects the transcript without trying alternatives; +application verification also requires that exact key to be bundled. +Setup bindings include the transcript choice. Metadata checksums and key identities remain BLAKE3. The Keccak transcript starts with a zero 32-byte state. For counters 0, 1 and @@ -131,12 +645,161 @@ Observations keep the existing canonical field/point and vector encodings. The parameter seed has its own Keccak protocol domain and binds the same public setup anchors and degree limits as the BLAKE3 configuration. -This profile makes a custom Ethereum verifier's transcript use native Keccak; -it does not change the proof into a standard PLONK format or implement a -Solidity verifier. +This transcript lets a custom Ethereum verifier use native Keccak; +it does not change the proof into a standard PLONK format. ## GPU proving +### Curve selection and validation status + +Lake uses `IX_KZG_CURVE=bls12-381|bn254` for both `ix` and the single +`ix-compression-worker` target. `IX_CUDA=1` enables the selected KZG CUDA +backend and Goldilocks CUDA. BN254 builds its matching PLONK helper and selects +explicitly admitted Ignition parameters or `--dev-srs`. Curve features +participate in the Cargo arguments and +Lake artifact traces, so switching the curve rebuilds both the CLI library +and worker. Keep complete builds in separate worktrees for side-by-side +comparisons; both curves use the usual executable names and runtime layout. + +The source changes select sppark's BN254 field instantiation, use four-limb +MSM outputs and 72-byte affine uploads, derive SRS-cache allocation estimates +from the selected point types, and limit BN254 FFT domains to `2^28`. +Rust and C++ layout assertions cover the bridge. A host-only check compares +Fq/Fr moduli, Montgomery one and R-squared before CUDA device discovery. + +The lookup, quotient and constraint-pass descriptors have paired Rust/C++ +layout assertions. Dense and pass execution retain their existing supported +trace ranges. `IX_KZG_CUDA_PASSES=force` requires the pass path for an eligible +kernel invocation and fails if it cannot run; it cannot silently use the +dense path. Unsupported lookup/quotient shapes and storage report CPU +construction in the logs. MSM scratch keeps the conservative 384-byte +per-point allowance on both curves, with separate bucket headroom and native +cache estimates; it does not scale every buffer by the point-size reduction. +Pinned transfer rings remain bounded at 256 MiB per device when both lanes +are used. FFT batches publish resident handles only after every job succeeds; +native destruction checks device ownership, and transfer rings clean up in +their owning context. + +Small or disk-backed FFT, MSM and opening work reports CPU dispatch at debug +level; use `RUST_LOG=ix_proof_compression=debug` when collecting parity logs. + +On October 16, 2026, the BN254 CUDA library test target compiled with Rust +1.98.1, CUDA 13.3.73 and NVIDIA driver 595.91.07 for SM 120. The focused +`kzg::cuda::tests::cuda_base_and_scalar_arithmetic_match_arkworks` test passed +on GPU 0, an RTX PRO 6000 Blackwell Server Edition. It checks six device +operations on 32 inputs in each of Fq and Fr against Arkworks, including zero, +modulus-boundary and high-limb values. The host field-representation guard +also passed during device initialization. Compilation took 1m 02s and the +test runner took 0.34s; these are test timings, not proving benchmarks. + +The command used the working-tree CUDA port atop CPU baseline `52c61479`: + +```sh +CARGO_HOME="$PWD/target/cargo-home" CUDA_HOME=/usr/local/cuda-13.3 \ +NVCC=/usr/local/cuda-13.3/bin/nvcc IX_KZG_CUDA_ARCHS=120 \ +MULTI_STARK_CUDA_ARCHS=120 CUDA_VISIBLE_DEVICES=0 \ +cargo test --locked -p ix-proof-compression --no-default-features \ + --features bn254,parallel,cuda --lib \ + kzg::cuda::tests::cuda_base_and_scalar_arithmetic_match_arkworks \ + -- --exact --ignored --nocapture +``` + +Follow-up validation on the same source completed every CUDA-module test on +both curves, using normal build and test parallelism: + +| Configuration | Host/ABI checks | GPU checks | Failures | +| --- | ---: | ---: | ---: | +| `bn254,parallel,cuda` | 12 | 18 | 0 | +| `bls12-381,parallel,cuda` | 12 | 18 | 0 | + +The 60 checks cover native field parameters and their rejection cases, every +supported NTT root and inverse-size factor, oversized-domain rejection, +point transport, subgroup/coset FFTs, zero-padding and batched order. MSM and +polynomial cases cover exceptional points, chunk tails, structured-scalar +normalization, resident inputs, evaluation and division. Cached MSM checks +cover offsets, owner isolation, eviction/re-upload and rejected requests. +Dense and forced-pass lookup/quotient output matches CPU calculations on +1,024-row host and 65,536-row resident fixtures, including next-row access, +constant columns, zero denominators and quotient cosets. + +The single-device concurrency checks also passed, including the `2^20`-point +MSM case. Each curve's two-device admission check passed separately with +`CUDA_VISIBLE_DEVICES=0,1`; other GPU checks used device 0. This validates +resident-buffer selection, not multi-GPU proving performance. The BLS CUDA +test target compiled in 42.90s. Test timings are not curve-speed benchmarks. +Local per-run commands, logs and source hashes are recorded in +`target/gpu-parity/cuda-component-parity.json`. + +The [proof fixture](examples/kzg_backend_parity.rs) also passed exact byte +comparison between separate CPU and CUDA binaries on both curves. It proves +two ordinary KZG circuits with 4,096 and 8,192 rows, five grouped lookups, +next-row openings and a quotient-factor cap of four, using an 8,192-power +development SRS. The CPU, dense GPU, forced-pass GPU and cached-SRS GPU modes +produce identical proof and verifier bytes within each curve: + +| Curve | Compact proof bytes | Modes with identical bytes | +| --- | ---: | --- | +| BN254 | 1,861 | CPU, dense GPU, forced passes, cached SRS | +| BLS12-381 | 2,197 | CPU, dense GPU, forced passes, cached SRS | + +GPU dispatch is checked in the logs. CPU binaries independently verify every +mode with the CPU-generated, digest-pinned verifier; CUDA binaries also +verify the CPU proofs with no GPU visible. Changed claims, evaluations, +verifier pins and proofs from the other curve are rejected. These checks +exclude FRI compression, recursive KZG proving and production setup trust. +Peak process RSS across the fixture runs was under 480 MiB; peak VRAM was +not recorded. This is not a production resource or performance measurement. +The [runner](../../tests/kzg_backend_parity.py) retains separate binaries, +native libraries, proofs, verifier pins, command logs and resource records. +The local result is `target/gpu-parity/proof-matrix-v2/report.json`, with +strict rejection checks in `strict-negative-report.json` alongside it. + +The complete Lake configuration typechecked with Lean 4.34.1. +[`tests/check_compression_build.py`](../../tests/check_compression_build.py) +passed seven argument-selection cases covering both curves, CPU/CUDA, +trace-codegen implying CUDA, the default, and the nonparallel configuration; +an invalid curve is rejected. These evaluate build arguments without +starting a project build or constraining the parallelism of proving runs. +Cargo feature-tree inspection also confirms the matching curve selection in +`ix-ffi`. The local configuration report is +`target/gpu-parity/cli-config/report.json`. Narrow Clippy checks with +`-D warnings` passed for the parity example under BN254/CUDA and BLS/CPU. + +The full saved Init first-KZG and recursive-KZG stages also pass on both +curves. BN254 produces exactly the CPU run's proof bytes at both boundaries, +and its final envelope passes the original CPU worker's verifier. The +[matched Init measurement](#matched-init-gpu-comparison) records timings, +memory, setup and cache conditions. GPU FRI compression, full-size BLS +CPU/GPU byte comparison, injected asynchronous failure recovery, complete +CLI linking and larger inputs still require validation. + +After authorizing machine time, reproduce the small proof comparison with +the CUDA toolkit and locked Cargo dependencies available: + +```sh +python3 tests/kzg_backend_parity.py --out target/kzg-proof-parity +python3 tests/check_compression_build.py \ + --toolchain /path/to/lean-4.34.1-linux --out target/kzg-build-config +``` + +After machine time is authorized, the BN254 build selection is: + +```sh +IX_KZG_CURVE=bn254 IX_CUDA=1 MULTI_STARK_CUDA_ARCHS=120 lake build ix +``` + +Omit `IX_CUDA=1` for a CPU build. Direct Cargo builds select +`--no-default-features --features bn254,parallel,plonk` for both `ix-ffi` and +`ix-proof-compression`; add `cuda` to each for the GPU configuration. +KZG kernels remain in the worker, while `ix-ffi/cuda` enables Aiur CUDA. +Default Cargo and Lake builds retain BLS12-381 and its terminal backends. +Terminal BN254 PLONK requires authenticated Ignition parameters and an +independently authorized verifier catalog. See the +[helper documentation](../../tools/bls-plonk-wrapper/README.md) for parameter +export, validated SP1 Lagrange import and the selected helper executable. + +### Backend configuration + The `cuda` feature compiles the KZG kernels with nvcc and runs interpolation, commitments, coset evaluation, lookup and quotient construction and opening divisions on NVIDIA GPUs. It needs the CUDA @@ -287,7 +950,8 @@ ix-compression-worker wrap stage-and-prove first-kzg/ final-kzg/ ``` The KZG stages generate traces directly into commitment and proving. -Every KZG stage requires `--dev-srs` when no Filecoin cache is configured. +Every KZG stage requires an admitted production setup or explicit `--dev-srs`. +BLS uses Filecoin configuration; BN254 uses the four Ignition settings above. `ix compress` sets the fixed-preprocessing and development SRS cache directories under `~/.ix/compression/cache` unless the environment already @@ -360,6 +1024,105 @@ between the pair invalidates preprocessing reuse. For full compression from an aggregate root, time `ix compress` with a fresh `--out` directory and the same explicit cache placement instead. +### Matched Init GPU comparison + +On October 16, 2026, both optimized CUDA workers completed the same saved +Init compressed FRI input, using the working tree above CPU baseline +`52c61479`. Both used BLAKE3 transcripts and known-trapdoor development +parameters, one RTX PRO 6000 Blackwell Server Edition, all 48 logical CPUs +of the Xeon Platinum 8559C host, 499 GiB RAM, and local NVMe. Toolchain: +Rust 1.98.1, CUDA 13.3.73, driver 595.91.07, SM 120. + +These are **cold fixed-preprocessing** measurements with **prepared SRS +caches** and fresh worker processes. Each curve started with an empty fixed +cache. OS page caches were uncontrolled. Whole-process wall times include +SRS loading, frontend construction, witness generation, fixed preprocessing, +proving, verification, output and cache publication. Builds, SRS generation, +FRI compression and gaps between worker invocations are excluded. There is +one cold measurement per curve. A subsequent warm BN254 rerun is recorded +below; BLS warm timing has not been measured for these builds. + +| Stage | BN254 | BLS12-381 | BLS time / BN254 time | +| --- | ---: | ---: | ---: | +| First KZG | 206.66 s | 225.18 s | 1.09× | +| Recursive KZG | 69.34 s | 153.01 s | 2.21× | +| **Total** | **276.00 s** | **378.19 s** | **1.37×** | + +BN254 used 27.0% less total wall time in this run. Its recursive computation +trace has `2^26` rows, versus BLS's `2^27`; this measures the complete +curve-specific pipelines, not an isolated field or MSM speedup. Both used +`IX_KZG_CUDA_DEVICES=0`, `CUDA_VISIBLE_DEVICES=0`, +`IX_KZG_CUDA_RESIDENT_GIB=8`, `IX_KZG_MSM_CHUNK_POINTS=16777216`, +`IX_KZG_CUDA_PASSES=1`, `IX_KZG_CUDA_SRS_CACHE=0`, and `IX_TEXRAY=1`. +Neither run capped CPU thread counts. First-stage constraints ran densely; +both recursive lookups used GPU passes. The BN254 recursive quotient fit +densely, while BLS used passes. + +| Curve / stage | Peak process RSS | Sampled peak GPU memory | +| --- | ---: | ---: | +| BN254 first | 175.28 GiB | 27.73 GiB | +| BN254 recursive | 81.16 GiB | 58.73 GiB | +| BLS first | 175.80 GiB | 27.73 GiB | +| BLS recursive | 164.52 GiB | 64.70 GiB | + +GPU memory was sampled every two seconds and includes allocator +reservations. Both stages verified natively, including the deferred +pairings. BN254's compact proofs, packets and profile match the completed +CPU run byte for byte; its 1,917-byte envelope also verifies with that CPU +worker and its prior profile pin. The BLS envelope is 2,189 bytes and its +profile matches the existing bundled development-v6 profile. Both portable +envelopes verify with no GPU visible, and wrong profile pins and missing +development opt-in are rejected. + +The local record is `target/gpu-parity/init-comparison.json`. Frozen workers, +native libraries, proofs, fixed caches, command logs and resource samples +are under `/opt/dlami/nvme/ix-gpu-compare-52c61479/`; `experiment.json` records +input and source hashes, and `report.json` contains the comparison. +The [stage supervisor](../../tests/run_gpu_stage.py) measures whole-process +wall time and memory. [SRS preparation](examples/kzg_dev_srs.rs) can be run +before timing, and the [metadata inspector](examples/kzg_stage_resources.rs) +estimates column/pass memory without allocating proving matrices. + +BN254's fixed checkpoints total 114.79 GiB (84.77 GiB first stage and +30.02 GiB recursive). Generating and writing them took 122.39 seconds in +the fixed-preparation spans. These figures favor local generation and reuse +on this hardware; downloading that volume is not necessarily cheaper. +The current cache is trusted local data keyed by executable and profile. +Distributing checkpoints would additionally require authenticated manifests +and an explicit compatibility contract. Ceremony SRS distribution is a +separate concern. + +### Warm BN254 Init rerun + +A second BN254 run reused the exact frozen worker, native library, input, +GPU settings, SRS cache and fixed-preprocessing entries from the matched +comparison. Both stages used fresh processes and output directories, and +both logged a fixed-cache hit. OS page caches remained uncontrolled. + +| Stage | Cold | Warm | Saved | +| --- | ---: | ---: | ---: | +| First KZG | 206.66 s | 153.16 s | 53.49 s | +| Recursive KZG | 69.34 s | 48.48 s | 20.86 s | +| **Total** | **276.00 s** | **201.64 s** | **74.36 s** | + +Warm proving used 26.9% less total wall time (1.37× speedup). Simply +subtracting the cold fixed-preparation spans predicted 153.61 seconds, +48.03 seconds below the measured warm total. Cache validation and restore +took 10.60 seconds for the first stage and 5.32 seconds for recursion; +loading the saved coefficients during preparation adds further work. + +The compact proofs, packets, profiles and final 1,917-byte envelope match +the cold run exactly. The original CPU worker independently verifies the +warm envelope with the original claim, expected root claims and profile +pin, with no GPU visible. Peak process RSS was 175.28 GiB for the first +stage and 63.17 GiB for recursion. + +The local report is `target/gpu-parity/init-bn254-warm.json`; the NVMe copy +is `/opt/dlami/nvme/ix-gpu-compare-52c61479/bn254/warm-report.json`. Proofs +are under that curve's `warm/` directory, with stage logs and resource +samples in `logs/warm-first-kzg/` and `logs/warm-recursive-kzg/`. These +measurements use the same saved-FRI-to-KZG scope as the cold comparison. + ### Recorded Init KZG run One cold/warm pair used a saved compressed Init FRI proof, Filecoin parameters, diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs index b652c913a..00b141010 100644 --- a/crates/proof-compression/build.rs +++ b/crates/proof-compression/build.rs @@ -1,7 +1,7 @@ //! KZG CUDA kernels for the compression crate. //! //! Without the `cuda` feature this script does nothing, so CPU builds need -//! neither nvcc nor the CUDA headers and libraries. With it, the BLS12-381 +//! neither nvcc nor the CUDA headers and libraries. With it, the selected //! sppark instantiation and the KZG kernels are compiled into their own //! archive, isolated from multi-stark's Goldilocks instantiation by the //! symbol renames in `cuda/kzg_sppark.cuh`. @@ -12,6 +12,8 @@ use std::process::Command; const SOURCES: &[&str] = &[ "cuda/kzg.cu", + "cuda/kzg_curve.cuh", + "cuda/kzg_conformance.cuh", "cuda/kzg_quotient.cuh", "cuda/kzg_srs.cuh", "cuda/kzg_lookup.cuh", @@ -23,6 +25,11 @@ const SOURCES: &[&str] = &[ ]; fn main() { + assert!( + env::var_os("CARGO_FEATURE_BN254").is_none() + || env::var_os("CARGO_FEATURE_BLS12_381").is_none(), + "BN254 KZG requires --no-default-features --features bn254; BLS12-381 and direct Solidity features cannot be combined with it" + ); println!("cargo:rerun-if-changed=build.rs"); for source in SOURCES { println!("cargo:rerun-if-changed={source}"); @@ -86,6 +93,12 @@ fn build_kzg_cuda() { println!("cargo:rerun-if-changed={}", root.join(directory).display()); } let mut objects = Vec::new(); + let (curve_flag, domain_flag) = + if env::var_os("CARGO_FEATURE_BN254").is_some() { + ("-DFEATURE_BN254", "-DMAX_LG_DOMAIN_SIZE=28") + } else { + ("-DFEATURE_BLS12_381", "-DMAX_LG_DOMAIN_SIZE=32") + }; for (source, name) in [("cuda/kzg.cu", "kzg.o"), ("cuda/kzg_runtime.cpp", "kzg_runtime.o")] { @@ -93,9 +106,9 @@ fn build_kzg_cuda() { let mut command = cuda_compile_command(&nvcc, &architectures); command.args([ "-c", - "-DFEATURE_BLS12_381", + curve_flag, "-D__BLST_PORTABLE__", - "-DMAX_LG_DOMAIN_SIZE=32", + domain_flag, "-include", "cuda/kzg_sppark.cuh", ]); @@ -152,7 +165,7 @@ fn cuda_compile_command( } fn link_cuda_runtime(nvcc: &std::ffi::OsStr) { - // The standalone worker links the BLS12-381 backend with its host runtime. + // The standalone worker links the KZG backend with its host runtime. println!("cargo:rustc-link-lib=static=cudart_static"); for lib in ["dl", "rt", "pthread", "stdc++"] { println!("cargo:rustc-link-lib=dylib={lib}"); diff --git a/crates/proof-compression/cuda/kzg.cu b/crates/proof-compression/cuda/kzg.cu index cae6b33ed..3d7aef83b 100644 --- a/crates/proof-compression/cuda/kzg.cu +++ b/crates/proof-compression/cuda/kzg.cu @@ -1,4 +1,4 @@ -#include +#include "kzg_curve.cuh" #include #include #include @@ -24,16 +24,19 @@ using bucket_t = xyzz_t; using affine_t = bucket_t::affine_t; using msm_impl = msm_t; -static_assert(sizeof(fr_t) == 32); -static_assert(sizeof(affine_t) == 96); -static_assert(sizeof(point_t) == 144); +static_assert(sizeof(affine_t) == 2 * sizeof(fp_t)); +static_assert(sizeof(point_t) == 3 * sizeof(fp_t)); struct ArkAffine { - uint64_t coordinates[12]; + uint64_t x[KZG_FQ_LIMBS]; + uint64_t y[KZG_FQ_LIMBS]; uint8_t infinity; }; -static_assert(sizeof(ArkAffine) == 104); -static_assert(offsetof(ArkAffine, infinity) == 96); +static_assert(alignof(ArkAffine) == alignof(uint64_t)); +static_assert(sizeof(ArkAffine) == 2 * sizeof(fp_t) + sizeof(uint64_t)); +static_assert(offsetof(ArkAffine, x) == 0); +static_assert(offsetof(ArkAffine, y) == sizeof(fp_t)); +static_assert(offsetof(ArkAffine, infinity) == 2 * sizeof(fp_t)); __global__ void unpack_affine(affine_t* output, const ArkAffine* input, size_t count) { for (size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x; @@ -42,7 +45,7 @@ __global__ void unpack_affine(affine_t* output, const ArkAffine* input, size_t c if (input[i].infinity) memset(&point, 0, sizeof(point)); else - memcpy(&point, input[i].coordinates, sizeof(point)); + memcpy(&point, &input[i], sizeof(point)); output[i] = point; } } @@ -110,11 +113,12 @@ extern "C" int ix_kzg_memory(int device, size_t* free, size_t* total) { namespace { struct MsmContext { + int device; size_t count; dev_ptr_t points; msm_impl msm; MsmContext(const gpu_t& gpu, size_t count) - : count(count), points(count, gpu), + : device(gpu.cid()), count(count), points(count, gpu), msm(nullptr, count, sizeof(affine_t), gpu.id()) {} }; @@ -173,7 +177,7 @@ extern "C" int ix_kzg_msm_invoke(int device, void* context, point_t* out, return cudaErrorInvalidValue; const auto& gpu = device_gpu(device); auto& msm = *static_cast(context); - if (count > msm.count) return cudaErrorInvalidValue; + if (msm.device != device || count > msm.count) return cudaErrorInvalidValue; dev_ptr_t data(count, gpu); TransferStats stats; auto& transfer = transfer_lane(gpu); @@ -203,8 +207,10 @@ extern "C" int ix_kzg_msm_invoke(int device, void* context, point_t* out, extern "C" int ix_kzg_msm_destroy(int device, void* context) { try { + auto* msm = static_cast(context); + if (!msm || msm->device != device) return cudaErrorInvalidValue; const auto& gpu = device_gpu(device); - delete static_cast(context); + delete msm; gpu.sync(); return 0; } catch (const cuda_error& e) { return failure(e.code(), e.what()); } @@ -265,6 +271,11 @@ struct FftJob { size_t input_count; ResidentPolynomial** resident_output; }; +static_assert(sizeof(FftJob) == 40 && alignof(FftJob) == 8); +static_assert(offsetof(FftJob, resident_input) == 8); +static_assert(offsetof(FftJob, output) == 16); +static_assert(offsetof(FftJob, input_count) == 24); +static_assert(offsetof(FftJob, resident_output) == 32); void transform(const gpu_t& gpu, stream_t& stream, fr_t* data, uint32_t lg, bool inverse, const fr_t& shift) { @@ -309,8 +320,11 @@ extern "C" int ix_kzg_polynomial_upload(int device, const fr_t* input, extern "C" int ix_kzg_polynomial_destroy(int device, void* pointer) { try { + auto* polynomial = static_cast(pointer); + if (!polynomial || polynomial->device != device) return cudaErrorInvalidValue; const auto& gpu = device_gpu(device); - delete static_cast(pointer); + gpu.sync(); + delete polynomial; gpu.sync(); return 0; } catch (const cuda_error& e) { return failure(e.code(), e.what()); } @@ -327,7 +341,7 @@ extern "C" int ix_kzg_msm_invoke_resident(int device, void* context, const auto& gpu = device_gpu(device); const auto& coefficients = *static_cast(polynomial); auto& msm = *static_cast(context); - if (count > msm.count || coefficients.device != device || + if (msm.device != device || count > msm.count || coefficients.device != device || offset > coefficients.count || count > coefficients.count - offset) return cudaErrorInvalidValue; dev_ptr_t normalized(normalization ? count : 0, gpu); @@ -361,7 +375,7 @@ extern "C" int ix_kzg_fft_batch(int device, const FftJob* jobs, const fr_t* shift, size_t slots) { ProfileRange range(inverse ? "kzg/ifft-batch" : "kzg/fft-batch"); try { - if (!jobs || !shift || lg > 31 || slots < 1 || slots > 2) + if (!jobs || !shift || lg > KZG_MAX_FFT_LOG || slots < 1 || slots > 2) return cudaErrorInvalidValue; const size_t count = size_t(1) << lg; for (size_t i = 0; i < job_count; ++i) { @@ -373,6 +387,9 @@ extern "C" int ix_kzg_fft_batch(int device, const FftJob* jobs, return cudaErrorInvalidValue; } const auto& gpu = device_gpu(device); + std::vector> residents(job_count); + for (size_t i = 0; i < job_count; ++i) + if (jobs[i].resident_output) *jobs[i].resident_output = nullptr; struct Slot { std::unique_ptr buffer; const FftJob* job = nullptr; @@ -391,7 +408,7 @@ extern "C" int ix_kzg_fft_batch(int device, const FftJob* jobs, slot.kernel.collect(slot.stats); slot.stats.report(device, inverse ? "ifft" : "fft", count); if (slot.job->resident_output) - *slot.job->resident_output = slot.buffer.release(); + residents[slot.job - jobs] = std::move(slot.buffer); slot.job = nullptr; }; for (size_t i = 0; i < job_count; ++i) { @@ -424,6 +441,10 @@ extern "C" int ix_kzg_fft_batch(int device, const FftJob* jobs, } // Async frees precede the next memory admission on this device. gpu.sync(); + // Publish only after the entire batch succeeds. A later job's failure + // must also reclaim earlier resident outputs before returning to Rust. + for (size_t i = 0; i < job_count; ++i) + if (jobs[i].resident_output) *jobs[i].resident_output = residents[i].release(); return 0; } catch (const cuda_error& e) { return failure(e.code(), e.what()); } catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } @@ -438,7 +459,7 @@ extern "C" int ix_kzg_fft_from(int device, const fr_t* input, extern "C" int ix_kzg_fft(int device, fr_t* values, uint32_t lg, bool inverse, const fr_t* shift) { - if (lg > 31) return cudaErrorInvalidValue; + if (lg > KZG_MAX_FFT_LOG) return cudaErrorInvalidValue; return ix_kzg_fft_from(device, values, size_t(1) << lg, values, lg, inverse, shift); } @@ -565,3 +586,5 @@ extern "C" int ix_kzg_polynomial_sample(int device, const void* pointer, } catch (const cuda_error& e) { return failure(e.code(), e.what()); } catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } } + +#include "kzg_conformance.cuh" diff --git a/crates/proof-compression/cuda/kzg_conformance.cuh b/crates/proof-compression/cuda/kzg_conformance.cuh new file mode 100644 index 000000000..b5849e767 --- /dev/null +++ b/crates/proof-compression/cuda/kzg_conformance.cuh @@ -0,0 +1,58 @@ +#pragma once + +#ifndef __CUDA_ARCH__ +extern "C" int ix_kzg_ntt_parameters(uint32_t lg, fr_t* output) { + if (!output || lg > KZG_MAX_FFT_LOG) return cudaErrorInvalidValue; + output[0] = forward_roots_of_unity[lg]; + output[1] = inverse_roots_of_unity[lg]; + output[2] = domain_size_inverse[lg]; + return 0; +} +#endif + +namespace { + +template __global__ void field_conformance(const F* input, F* output) { + const size_t i = threadIdx.x; + const F a = input[i], b = input[(i + 1) % 32]; + const F one = F::one(); + const F zero = one - one; + // Reciprocal cooperates with the adjacent lane; every lane participates, + // including zero inputs, whose output follows the inverse(0) = 0 convention. + const F safe = F::csel(one, a, a.is_zero()); + const F inverse = safe.reciprocal(); + output[6 * i] = one; + output[6 * i + 1] = a + b; + output[6 * i + 2] = a - b; + output[6 * i + 3] = a * b; + output[6 * i + 4] = -a; + output[6 * i + 5] = F::csel(zero, inverse, a.is_zero()); +} + +} // namespace + +// Fixed-size conformance vectors exercise both device fields independently +// of the MSM and FFT algorithms that consume their Montgomery representation. +extern "C" int ix_kzg_field_conformance(int device, const fp_t* fq, const fr_t* fr, + fp_t* fq_output, fr_t* fr_output) { + try { + if (!fq || !fr || !fq_output || !fr_output) return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + { + dev_ptr_t fq_input(32, gpu), fq_result(32 * 6, gpu); + dev_ptr_t fr_input(32, gpu), fr_result(32 * 6, gpu); + gpu.HtoD(fq_input.data(), fq, 32); + gpu.HtoD(fr_input.data(), fr, 32); + field_conformance<<<1, 32, 0, gpu>>>(fq_input.data(), fq_result.data()); + CUDA_OK(cudaGetLastError()); + field_conformance<<<1, 32, 0, gpu>>>(fr_input.data(), fr_result.data()); + CUDA_OK(cudaGetLastError()); + gpu.DtoH(fq_output, fq_result.data(), 32 * 6); + gpu.DtoH(fr_output, fr_result.data(), 32 * 6); + gpu.sync(); + } + gpu.sync(); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} diff --git a/crates/proof-compression/cuda/kzg_curve.cuh b/crates/proof-compression/cuda/kzg_curve.cuh new file mode 100644 index 000000000..98957888b --- /dev/null +++ b/crates/proof-compression/cuda/kzg_curve.cuh @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +#if defined(FEATURE_BN254) && !defined(FEATURE_BLS12_381) +#include +constexpr size_t KZG_FQ_LIMBS = 4; +static_assert(MAX_LG_DOMAIN_SIZE == 28); +#elif defined(FEATURE_BLS12_381) && !defined(FEATURE_BN254) +#include +constexpr size_t KZG_FQ_LIMBS = 6; +static_assert(MAX_LG_DOMAIN_SIZE == 32); +#else +#error "Select exactly one KZG curve" +#endif + +// The NTT domain stride is 32 bits even when the field has a 2^32 root. +constexpr uint32_t KZG_MAX_FFT_LOG = MAX_LG_DOMAIN_SIZE < 31 ? MAX_LG_DOMAIN_SIZE : 31; + +static_assert(sizeof(fr_t) == 4 * sizeof(uint64_t)); +static_assert(sizeof(fp_t) == KZG_FQ_LIMBS * sizeof(uint64_t)); + +#ifndef __CUDA_ARCH__ +static_assert(alignof(fr_t) == alignof(uint64_t)); +static_assert(alignof(fp_t) == alignof(uint64_t)); + +// Raw Montgomery storage crosses the FFI without conversion. Check the +// modulus, representation of one and R^2 before accepting host limb buffers. +extern "C" int ix_kzg_check_field_abi(const uint64_t* fq, size_t fq_bytes, + const uint64_t* fr, size_t fr_bytes) { + if (!fq || !fr || fq_bytes != 3 * sizeof(fp_t) || fr_bytes != 3 * sizeof(fr_t)) + return 1; +#if defined(FEATURE_BN254) + const auto& fq_modulus = alt_bn128::ALT_BN128_P; + const auto& fq_r2 = alt_bn128::ALT_BN128_RR; + const auto& fr_modulus = alt_bn128::ALT_BN128_r; + const auto& fr_r2 = alt_bn128::ALT_BN128_rRR; +#else + const auto& fq_modulus = bls12_381::BLS12_381_P; + const auto& fq_r2 = bls12_381::BLS12_381_RR; + const auto& fr_modulus = bls12_381::BLS12_381_r; + const auto& fr_r2 = bls12_381::BLS12_381_rRR; +#endif + const fp_t fq_one = fp_t::one(); + const fr_t fr_one = fr_t::one(); + return std::memcmp(fq, fq_modulus, sizeof(fp_t)) != 0 + || std::memcmp(fq + KZG_FQ_LIMBS, &fq_one, sizeof(fp_t)) != 0 + || std::memcmp(fq + 2 * KZG_FQ_LIMBS, fq_r2, sizeof(fp_t)) != 0 + || std::memcmp(fr, fr_modulus, sizeof(fr_t)) != 0 + || std::memcmp(fr + 4, &fr_one, sizeof(fr_t)) != 0 + || std::memcmp(fr + 8, fr_r2, sizeof(fr_t)) != 0; +} +#endif diff --git a/crates/proof-compression/cuda/kzg_lookup.cuh b/crates/proof-compression/cuda/kzg_lookup.cuh index c069bda0e..df41fcec0 100644 --- a/crates/proof-compression/cuda/kzg_lookup.cuh +++ b/crates/proof-compression/cuda/kzg_lookup.cuh @@ -10,6 +10,10 @@ struct alignas(32) LookupParameters { uint32_t trace_log, slots, group_size, blocks; }; static_assert(sizeof(LookupParameters) == 96); +static_assert(alignof(LookupParameters) == 32); +static_assert(offsetof(LookupParameters, gamma) == 32); +static_assert(offsetof(LookupParameters, trace_log) == 64); +static_assert(offsetof(LookupParameters, blocks) == 76); __device__ __forceinline__ void lookup_rational_row( fr_t* numerators, fr_t* denominators, fr_t* values, const fr_t* evaluations, diff --git a/crates/proof-compression/cuda/kzg_passes.cuh b/crates/proof-compression/cuda/kzg_passes.cuh index 7fe739e3b..44fe9c010 100644 --- a/crates/proof-compression/cuda/kzg_passes.cuh +++ b/crates/proof-compression/cuda/kzg_passes.cuh @@ -16,6 +16,13 @@ struct alignas(32) ConstraintPass { size_t arg_count, slots, group; }; static_assert(sizeof(ConstraintPass) == 160); +static_assert(alignof(ConstraintPass) == 32); +static_assert(offsetof(ConstraintPass, columns) == 32); +static_assert(offsetof(ConstraintPass, nodes) == 56); +static_assert(offsetof(ConstraintPass, roots) == 72); +static_assert(offsetof(ConstraintPass, lookups) == 88); +static_assert(offsetof(ConstraintPass, args) == 104); +static_assert(offsetof(ConstraintPass, slots) == 120); static_assert(offsetof(ConstraintPass, group) == 128); struct PassShape { diff --git a/crates/proof-compression/cuda/kzg_quotient.cuh b/crates/proof-compression/cuda/kzg_quotient.cuh index 24d5bc983..41507e5e4 100644 --- a/crates/proof-compression/cuda/kzg_quotient.cuh +++ b/crates/proof-compression/cuda/kzg_quotient.cuh @@ -26,10 +26,22 @@ struct alignas(32) QuotientParameters { }; static_assert(sizeof(QuotientInstruction) == 64); +static_assert(alignof(QuotientInstruction) == 32); +static_assert(offsetof(QuotientInstruction, a) == 32); +static_assert(offsetof(QuotientInstruction, out) == 44); static_assert(sizeof(QuotientLookup) == 12); +static_assert(alignof(QuotientLookup) == 4); +static_assert(offsetof(QuotientLookup, arg_count) == 8); static_assert(sizeof(QuotientColumn) == 96); +static_assert(alignof(QuotientColumn) == 32); +static_assert(offsetof(QuotientColumn, resident) == 8); +static_assert(offsetof(QuotientColumn, count) == 16); static_assert(offsetof(QuotientColumn, constant) == 32); +static_assert(offsetof(QuotientColumn, slot) == 64); static_assert(sizeof(QuotientParameters) == 192); +static_assert(alignof(QuotientParameters) == 32); +static_assert(offsetof(QuotientParameters, trace_log) == 160); +static_assert(offsetof(QuotientParameters, blocks) == 180); __global__ void quotient_ones(fr_t* values, size_t count) { for (size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x; @@ -168,7 +180,8 @@ extern "C" int ix_kzg_quotient( if (!parameters || !columns || !publics || !outputs || !coset_shifts || !inverse_vanishing) return cudaErrorInvalidValue; const auto p = *parameters; - if (p.trace_log < 10 || p.trace_log > p.quotient_log || p.quotient_log > MAX_LG_DOMAIN_SIZE + if (p.trace_log < 10 || p.trace_log > p.quotient_log || p.quotient_log > 28 + || p.quotient_log > KZG_MAX_FFT_LOG || !p.slots || !p.blocks || p.blocks > 256 || !p.group_size || p.group_size > 8 || p.stage2_start >= column_count || public_count < 4) return cudaErrorInvalidValue; diff --git a/crates/proof-compression/cuda/kzg_transfer.cuh b/crates/proof-compression/cuda/kzg_transfer.cuh index 180ceef9f..a33e09655 100644 --- a/crates/proof-compression/cuda/kzg_transfer.cuh +++ b/crates/proof-compression/cuda/kzg_transfer.cuh @@ -68,6 +68,7 @@ class TransferRing { static constexpr size_t CHUNK = size_t(16) << 20; static constexpr size_t RING = 4; unsigned char* memory_ = nullptr; + int device_ = -1; std::array ready_{}; std::array start_{}; std::array pending_{}; @@ -109,6 +110,7 @@ class TransferRing { public: TransferRing() { try { + CUDA_OK(cudaGetDevice(&device_)); CUDA_OK(cudaMallocHost(reinterpret_cast(&memory_), RING * CHUNK)); for (size_t i = 0; i < RING; ++i) { CUDA_OK(cudaEventCreateWithFlags(&ready_[i], transfer_profiling() @@ -121,6 +123,11 @@ class TransferRing { ~TransferRing() { release(); } void release() { + // Events and pinned allocations belong to the creating context; + // process teardown may run with a different device selected. + int saved = -1; + (void)cudaGetDevice(&saved); + if (device_ >= 0) (void)cudaSetDevice(device_); // An exception may leave a DMA using the pinned memory in flight. for (size_t i = 0; i < RING; ++i) { if (ready_[i]) { @@ -130,6 +137,7 @@ class TransferRing { if (start_[i]) cudaEventDestroy(start_[i]); } if (memory_) cudaFreeHost(memory_); + if (saved >= 0) (void)cudaSetDevice(saved); } void upload(const gpu_t& gpu, cudaStream_t stream, void* dst, diff --git a/crates/proof-compression/cuda/plonk/blst-source-manifest.json b/crates/proof-compression/cuda/plonk/blst-source-manifest.json new file mode 100644 index 000000000..6d95201e7 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/blst-source-manifest.json @@ -0,0 +1,155 @@ +{ + "format": "ix-native-source-files/v1", + "crate": "blst", + "version": "0.3.16", + "crate_sha256": "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45", + "subdirectory": "blst", + "files_sha256": { + "bindings/blst.h": "a11731b7e3fdb84e7026186f12aa5ce93ff8ee9a0ecc7fc8f1b077b23794e988", + "bindings/blst.hpp": "73cf17dbd235ad1350513595a2e5471d0d36700cfeb680c5851180b67632da4e", + "bindings/blst_aux.h": "af001093f836f0ad258b567444e1b066cd73392d2e1e982b6de5a9659c01be37", + "build/assembly.S": "ef84dc6ef8670a1deb3f4a2a16c529b1091dd416b6a7463c9a9e8eb1c7ff6552", + "build/bindings_trim.pl": "c1ac6b4e9a834635e72a7446d25d485f8e168a0f02a2e14599e009c59ef16ef8", + "build/cheri/add_mod_256-armv8.S": "1f6b007b132720b16ba0ed979a74571c190e86c6f623cd835ead686d56f2f32e", + "build/cheri/add_mod_384-armv8.S": "f3acf39a6542115bef7518e17ad88f287e2d5c6f67a5c5773f54e9db36a54daf", + "build/cheri/ct_inverse_mod_256-armv8.S": "d7aab022ab128395870bd814c983995e9c2693257e2b5d418c42c16bbdc55196", + "build/cheri/ct_inverse_mod_384-armv8.S": "c4b8894889eabd9b03862f7e9264255542d99efff8c6700895509a9d61e9da59", + "build/cheri/ct_is_square_mod_384-armv8.S": "a209f9134f97bcd6dfb4204c740a290d3c97da9fa5c3b014e5968a91e57b0889", + "build/cheri/div3w-armv8.S": "8e5afe91747840e275f21dc1264d5873d36436170090c37afe8cb4934a49a716", + "build/cheri/mul_mont_256-armv8.S": "b4f1ab598044a178ad8a506736d65ca8ad638b9e7619df9f61d3e4ef3e78d5f2", + "build/cheri/mul_mont_384-armv8.S": "4f68450938099b0f0321acf706f0a2900442b390378215201a74151fc8a1586b", + "build/cheri/sha256-armv8.S": "ad609e2a7aed35693978426f568971e051b1f42a7da5e1c1d27cda5492a7bd25", + "build/coff/add_mod_256-armv8.S": "c52b33148c13ba3ad3a449fa7b24fc298b95ba7ce80094fd256114f1f42e9cf5", + "build/coff/add_mod_256-x86_64.s": "93653067788cb7d7c6b3696093922e35b5948436d0f405fa6bc6aaae9c26edad", + "build/coff/add_mod_384-armv8.S": "f375c083e2ebad74bec200d0464cb7a34870574fcf0d555ac8d162a1f4016c90", + "build/coff/add_mod_384-x86_64.s": "10da3a249e3590598676fe5eb9486a200995e2063284c122c03a4ddd5440db60", + "build/coff/add_mod_384x384-x86_64.s": "89e3674ba43cacbcd8c9bc7a077fd46ec686f2a930d7ce3418616fa641b53a44", + "build/coff/ct_inverse_mod_256-armv8.S": "cef66ed2c0b701511f8858722e7daacd510160d290c1bd6e4b406d8aa8d5e839", + "build/coff/ct_inverse_mod_256-x86_64.s": "d2cbcd63a463ce013beda3d5c525681712ae1618e5358d5900edd55ce6a45e0d", + "build/coff/ct_inverse_mod_384-armv8.S": "b8a13fa05dcb87a33b9a35b9c06a6039f1c03a5bedb5f30b169c6a661e5b4022", + "build/coff/ct_is_square_mod_384-armv8.S": "afc9ebba52ab4d7a6afc2a319a0e9721cc062de7657ce69c8071c35b071cef02", + "build/coff/ct_is_square_mod_384-x86_64.s": "274c98f95345a3ab266f3ff1d5e151e68fea6db25085f6417df2ea015f5ae368", + "build/coff/ctq_inverse_mod_384-x86_64.s": "609ebf85650a56c5f6152e882f90f86899cd7da9e2da7c0aa98730fb43a43986", + "build/coff/ctx_inverse_mod_384-x86_64.s": "e507d463189793d54f18800af6d24c03a0ffc0b7b28cb27b45c31d8fe05b715c", + "build/coff/div3w-armv8.S": "414447f6816afc1aba5ad99440a0f62398113a50847657f45c87284f00ad0c73", + "build/coff/div3w-x86_64.s": "767b8cde73f10b08d3bc0f19faf2e9de9675c773ff26a65e5938667e110def50", + "build/coff/mul_mont_256-armv8.S": "8bf3dc0fe7470d1b32e92bbaeb325d182f3a8b0613f8044e4046c04d405b572b", + "build/coff/mul_mont_384-armv8.S": "14672f4797d4b18ad3acf9d76c20da20808f1b4644773a21317f4a845c2a6c82", + "build/coff/mulq_mont_256-x86_64.s": "6fc9bb464399c2f6f57402a6735dfa02c63cddc111fd3e56044a86cee2a32928", + "build/coff/mulq_mont_384-x86_64.s": "6cc1563abf4bb321303ee3d341c7eba397fd66f3cafd43f81b52d34bdb4dd7d6", + "build/coff/mulx_mont_256-x86_64.s": "6dd019649ba4cd79e9c5ac311a744f1909b47e9178492e19d108d98eb8b95c77", + "build/coff/mulx_mont_384-x86_64.s": "e35847d26a0bc5a457f39242fb64cbdc5b10ecd88df776a76a30f0346c02967d", + "build/coff/sha256-armv8.S": "852ea0bb9d7e3f3752e95b37f7e510b295f02ee475b9214cb417fa5db7851b28", + "build/coff/sha256-portable-x86_64.s": "14efff9c1a246fef6298f9d8b8e0da811f4d361f50d3a82c3cce1fb10a46c73a", + "build/coff/sha256-x86_64.s": "87faafcc56e690a042f231827217d919e0441e477c4d6a26285744a429e808b3", + "build/elf/add_mod_256-armv8.S": "41db62ec43d0be76e095d17c89a8dff1c48e1c5bac817162a2b1b84645832bd9", + "build/elf/add_mod_256-x86_64.s": "d1b05c6be93e7fb1e64f0bbaf74018e97787bdba516ce38c928470e601961294", + "build/elf/add_mod_384-armv8.S": "c5a9c56ce29e8dd29d7fdbdeb268953102e9752f453c65d2bec23e07d9f5141b", + "build/elf/add_mod_384-x86_64.s": "baafa2aac9182d6082587a4ce6097d8ac201d9011d7550b7d01d83a51572410a", + "build/elf/add_mod_384x384-x86_64.s": "0ef77bd5b30ededecd865f0c2e74e8d2c97efe014fafdcb5cbd947d8e45ed02b", + "build/elf/ct_inverse_mod_256-armv8.S": "7ca734a4ec85ba4d8fb8d6cafcc335d214ebacfc96706c341aca0f550a6b206c", + "build/elf/ct_inverse_mod_256-x86_64.s": "64c37af3585d2331800c49cd52a27b55f25f3e5326e49051b2328b588246a080", + "build/elf/ct_inverse_mod_384-armv8.S": "04572708033c59bb86a4750df360eb32836f1d1064510baa7991b8e54eb94011", + "build/elf/ct_is_square_mod_384-armv8.S": "0a4962c305dbd8d34786def65575b31e38a763c764fd106f544f46629cb4494c", + "build/elf/ct_is_square_mod_384-x86_64.s": "bdbca779b340ade2cbaa2fcd898f14cd7317844255682576ffeee38ab74fee21", + "build/elf/ctq_inverse_mod_384-x86_64.s": "f816bef4ad4d437fe7a1dc14faa1717549d75df4061f8bb8c5796d0692f769e7", + "build/elf/ctx_inverse_mod_384-x86_64.s": "869469fafff1d07e6b5ead94b48401cb1f5c06a70577e59b036de980894cf654", + "build/elf/div3w-armv8.S": "55910486920d76efdd4e0cd5113d2104e38e203152b456d7274f43a110fd719e", + "build/elf/div3w-x86_64.s": "548d258efa0c46f4c5bf8020dbea448e63f15265e840a4399bc2380591470b9f", + "build/elf/mul_mont_256-armv8.S": "99df0e7bc55b07d2555831a58da775217711823713ef0074e9746c8cb0f2707b", + "build/elf/mul_mont_384-armv8.S": "97835b52fd2e219b8123d877bb86c8ac1c4bb024fac9642e8216c3878413eecf", + "build/elf/mulq_mont_256-x86_64.s": "93562f722b32885b0810b6ea15d0a6086f81433561f22447865fcd4505ec4a28", + "build/elf/mulq_mont_384-x86_64.s": "171292798aea6392ac8858a3354955a264d58ad3f95cd187e3600e944c8e6a8c", + "build/elf/mulx_mont_256-x86_64.s": "e9e3483ec6174e25fee49d27023a66e3a71a3073a3043eb5195c5b1a10662eb8", + "build/elf/mulx_mont_384-x86_64.s": "30d7c0bdd842abdd15f316e107bb5e514a7da19667208afe78dbe30e95686fe4", + "build/elf/sha256-armv8.S": "6dced131033c5a451d209a31a00f27ccd6e63b042a1b7aa7379c7fb9e85882d9", + "build/elf/sha256-portable-x86_64.s": "25b7d54b248a1ccb910d4abb8a45ea82d7bdc78e48162b80e492dd5f2100a03c", + "build/elf/sha256-x86_64.s": "0216306f90681189d9ecdcc126cca8da58497cd89f9a91b221317101755d1579", + "build/mach-o/add_mod_256-armv8.S": "803057a71553a4de247cee2e5fc3093c10a5586830a418c31c897f94569bbdea", + "build/mach-o/add_mod_256-x86_64.s": "ffc21a55fb23e2fe5e5acf9adcea836a0ddc83d6e58fc9ae748630660f9fa7a0", + "build/mach-o/add_mod_384-armv8.S": "18994e7cb09de079394891e8d5268ebe11d4a31b7e8e254a64ec647583d5f075", + "build/mach-o/add_mod_384-x86_64.s": "3a8fd579a55839f90b2ef044c33e5d0fed3bd17ded97b7c4e99f21d3a2838a0d", + "build/mach-o/add_mod_384x384-x86_64.s": "98dd0bbb0609a3a63156441c83f584de6067c8582bb4a34df4af3a2617e6af12", + "build/mach-o/ct_inverse_mod_256-armv8.S": "eece0ecacadd6fff42b6291515873af6dcdec5b513849f7ca6d95319c4b7f7b9", + "build/mach-o/ct_inverse_mod_256-x86_64.s": "b61ed05a1303e24bdb53ac39feb6b18d97aceef1b20f3a21b9781aced1fe19f4", + "build/mach-o/ct_inverse_mod_384-armv8.S": "c41ac8b59bdbf6263650118132fd306e42daf8310cbdf3f83379ef07b1601a12", + "build/mach-o/ct_is_square_mod_384-armv8.S": "0da87342965a9ea7eec68c0631d7462583553526181227e60a1355ea23f986b4", + "build/mach-o/ct_is_square_mod_384-x86_64.s": "6145d1fde1f407a3779b3b8017588fbaa74688f6745e577bffda2abe13ca1b9b", + "build/mach-o/ctq_inverse_mod_384-x86_64.s": "3de1a27f3b09dfc5cf847c77b50e8bdad36dba7f9d1736a60169d4b205162e8f", + "build/mach-o/ctx_inverse_mod_384-x86_64.s": "72af33caa9d379e171a7e243da2a8a3a362d5ff74fede1930ba5eeb17ffb7608", + "build/mach-o/div3w-armv8.S": "054bae3ea9662ba9e0b677d7db8f14d79a59a3ec791f84179f633dc5bdd72895", + "build/mach-o/div3w-x86_64.s": "143c4b2fc4279e8e45fcfff512769f25f22d635585584a0a44bddd8b1c589359", + "build/mach-o/mul_mont_256-armv8.S": "768b46ac0b0566888ee8a0933c32158fe5f9d2ea0f4439e8d250ec5c549e6097", + "build/mach-o/mul_mont_384-armv8.S": "857453526bff660e724afc4176163ef643728e83f47dd7b9bf386fa3eb29e36c", + "build/mach-o/mulq_mont_256-x86_64.s": "97bf607ea2db61fb10b733535751ed99b7682c117528fca70be5ce9276c67e79", + "build/mach-o/mulq_mont_384-x86_64.s": "c383e18bfbb6e3a22b2d4cac84f669c75b54ced23ba83a95a2c122995fe31147", + "build/mach-o/mulx_mont_256-x86_64.s": "945cc854e8c5d791add9b30f996f1243453514506039efcc3cf6b0b88e7a8439", + "build/mach-o/mulx_mont_384-x86_64.s": "34735e178f2a32495f8b60471a8f2f418a97ac83a95dfb2ace27224ff8e47752", + "build/mach-o/sha256-armv8.S": "2f11ce94c76870ea995f65b0cc360eab4a6d64f226c8ecf55886976d67219203", + "build/mach-o/sha256-portable-x86_64.s": "3063e17bd8c61f1cdeefe65b9b3f1073daafeb4d39a6d75222b4fe26c6145185", + "build/mach-o/sha256-x86_64.s": "bbd6d9feeeaa323d9d58b92c9b57cded74bd4aa617e0681a87481913e969aa3d", + "build/refresh.sh": "aa2f1332ae7644cf63fd48a2393c47c07ae22b017216b06936f0ea9ddc9ef743", + "build/srcroot.go": "2d386c6ab317195559e3fe9d70ff69f650c031cc953c933efa9cf2671990cec7", + "build/win64/add_mod_256-armv8.asm": "854ee96f3e47525a47068b673e3a66282a29647d99b4da64d490b4cd9def3c36", + "build/win64/add_mod_256-x86_64.asm": "e4172e082e9c5b168c30a838cd6750a8c440107f2ec0d353ca07607d12fc1968", + "build/win64/add_mod_384-armv8.asm": "0c523b10612a6dbe2126055bc4da5dc36cd6f413faf0299bf01564c3d6759cfc", + "build/win64/add_mod_384-x86_64.asm": "e6d51dc8154c3b00e24c1e04ee0bb2ce6d901f834bdfe30483f2b1751b659b51", + "build/win64/add_mod_384x384-x86_64.asm": "8b31dae5190ec90215248098bc27565bee28f3a554a869964c646a15b087d738", + "build/win64/blst.def": "3de9a596559f12fcc2dc575b1fa27c4b0ef41d9b316f375f8aea7cfb608cc58a", + "build/win64/ct_inverse_mod_256-armv8.asm": "eaf01bed8fcae2176e02cc8b00d25659a936c4f87e097abcaedb807707573bd7", + "build/win64/ct_inverse_mod_256-x86_64.asm": "562cff2c8c33b7a9ae1f39cb8130a6226d9d809801eb834d13ef95992b7bc1a7", + "build/win64/ct_inverse_mod_384-armv8.asm": "53577ca20e4796ffb2f55fcea4fdbc068e09409b49295993661d438e05ac535b", + "build/win64/ct_is_square_mod_384-armv8.asm": "24d73fefd592beeab3b4c1ed04fe0b9aa33eb04650b904f9e05ea796b4481c40", + "build/win64/ct_is_square_mod_384-x86_64.asm": "cfc800dcd3b6e10f95a5653492970c94f0460d7036c84b7f082ddff35188b4b1", + "build/win64/ctq_inverse_mod_384-x86_64.asm": "15dbcf3777de9a369603235d54125ba5f756c49c8ae691a4115b79f0e5c8f2ca", + "build/win64/ctx_inverse_mod_384-x86_64.asm": "a09f32fc5f19756910f0b948e6414116e05a1d94af1b97dc16c391c83aae5b7c", + "build/win64/div3w-armv8.asm": "7f841a88f067862c46b65bcbd2b7c92a32f91fe4f5b4b88c0df031aa14d5ee48", + "build/win64/div3w-x86_64.asm": "40d94d29c8ea45e3b8e19cef05e926d3a32b93688d981cdddd80900732b510c9", + "build/win64/dll.c": "91ee8d567ffb48a427b2a9eb155cdcdce1e5343d75509f400ef564c9dbd08594", + "build/win64/mul_mont_256-armv8.asm": "f4b4d494a43442a4f387a33a72c045e844fb46397128b61d9e7898e7dfd543e4", + "build/win64/mul_mont_384-armv8.asm": "166d60fc018fabbc4050d8918b0041364792adbacd79c0141cd5fe92fe642632", + "build/win64/mulq_mont_256-x86_64.asm": "602cd01285064116fca28aca99d5117db4bb3b5e81b2fb3f491679c4d2442199", + "build/win64/mulq_mont_384-x86_64.asm": "d32d5b5ba99caaf05725f5dcf9f4a5c5f35778d32799341388a3046878059773", + "build/win64/mulx_mont_256-x86_64.asm": "b36a919d12bbe7de9597f3b2038c6fe1103ad96aeb26940e86255321189b8384", + "build/win64/mulx_mont_384-x86_64.asm": "99959b4710f3e4d12189fb898ecd22d724fc7171591789371219954bfbfe3631", + "build/win64/sha256-armv8.asm": "0c8a70abf0a776a491a1ef0a39da8a521d833ba759b93006cc50ad8badb475e0", + "build/win64/sha256-x86_64.asm": "f47476003c328216a0c447eea4ce1a4c7313614cb50cfb27532f8c1881fc7a79", + "src/aggregate.c": "371b64e0cef2b52abe59291ea98600a7e6d8904c42f84266a1d7761277f06f9b", + "src/blst_t.hpp": "a156bc1374347f14f7a69e337369291ac021fa3a03c596b61c131649562e5c76", + "src/bulk_addition.c": "cc808b1fc07eea97c300fd0486b6e01fc063f462c2994f16495c20a25496b621", + "src/bytes.h": "ab19306003f4033ed522238580836bc662334df2a9ed56e2f8bfa822e79e1ae9", + "src/client_min_pk.c": "9084cdc870a3ef8fc90e92bedafda01600f43aef1fb9d8494804ed23bf4fe0dc", + "src/client_min_sig.c": "d4d9dd37b3f500a7dd65d0796f25ca541cdb6a5765b293b1d1e4aef6027f04c0", + "src/consts.c": "b5de026855b1f370a26c471abcc4f5a0debb2d195d7661844be6d87159cc0283", + "src/consts.h": "e983c6c80045adc46379ff1fd088dbf85e20c4946eb98191835eed91f7598727", + "src/cpuid.c": "60f18f97c54eaef84f47e94478e184ae6f9df23b0908f00de1f98527232d4a42", + "src/e1.c": "4939fe48680b3f3aad94501aacbfd081907a1561cb66d39559885f9d0025770e", + "src/e2.c": "ad3bd7a4d559fffffe1f4aa6377bd6a1653d0f672d111566be377ec4e3875cc5", + "src/ec_mult.h": "fd95cf849912d7801fed38902b84abd5b4aa79f7d888eb91ffad48d7695e1443", + "src/ec_ops.h": "b4ace5a1efe5354fc622a11b05eb65f4cc81aa91f6a0baa1912caa34600d4fd0", + "src/errors.h": "bc3f2136c74680046cf09a790b1eb34df02b8894cb3739626b55428f3631ba1c", + "src/exp.c": "fe1332c1480c9b95a3b46ca15f55f4086f53c365139186bd20b527b8abee854d", + "src/exports.c": "a4ed36689c06458f04388f08fb67a3aed2df09640c560cdd342a89aa2be28412", + "src/fields.h": "2151a9b8bf00d5deb6444bbbfc836f9e6bab24a1e147131a028d155dbfa5a809", + "src/fp12_tower.c": "09e63dac5fe51c62006141e48e525c2a50fa6459173be8296d1ba76cb2fd5809", + "src/hash_to_field.c": "6bfe3fa4d38a2253257c7d32ffae840047b4c6386060c9fe195496efa02b6545", + "src/keygen.c": "1f8f4f6a30bfd48607ca87696913d3cdd94d18ef420e28ffdf34b9a2b45ab268", + "src/map_to_g1.c": "7a43f77bc4f64d215d2fa38f5465ae3c04aa81d7a98754a62f6e68a28ee26684", + "src/map_to_g2.c": "57b4f880ba2c6811aea25eb1d56d30cd191c20a1e60036d51989071728c85f5d", + "src/multi_scalar.c": "26c2288aa5b24243102c5838241fb8dce9bef26b7a1ea144218422ec4ef3b2bd", + "src/no_asm.h": "a230ef097ce1b9826c3bd5b000aeb594899296329eac9635e75e9316035c3048", + "src/pairing.c": "c20f30198d8c5f723b571296a4d16d1834a507e991c95b984e9a057b6c9b006b", + "src/pentaroot-addchain.h": "5b060295eed3af6c5bfd3ca1ae7d45f01fe2598b6e9f4e33033ff9a7b545097c", + "src/pentaroot.c": "9872013bf00a3440223c819f3abfefbf89f6325751a2337f78a813f615e7dea8", + "src/point.h": "09207151264a44d0b232c5f54c56dd20b84037b9d692e952045561c49979963f", + "src/rb_tree.c": "443bd6482638664e2d336d5d96469356df6292475433f69ffe8c83d952f2c41e", + "src/recip-addchain.h": "31e3949b6cd78b8eb7590cfae7b4aedb25a70281eb2e1a0c72a83933c2cc5ff4", + "src/recip.c": "115cdb1b02f26740aec680f963422e4aa56790e8d74370e936dc346c69db203e", + "src/server.c": "fb3c3a6a4db81d90113e7ef79d7dca4de33a5b5fd0baa0d56ef987947f93f9a8", + "src/sha256.h": "ffccc1387c9cb16be34785a8b87bc37ab6e3f3f4d432d73682d573b5507df90c", + "src/sqrt-addchain.h": "2ee9dff636a330b9203c75e606d7e7eb251737d690540247e6ec2e6348f2ecad", + "src/sqrt.c": "3ee19bfc3ebdf68abd91375e6969300ccc26aec95767b8958c2fb281c12a8abb", + "src/vect.c": "b8f189510e4795aa5a2d827ca8347a672cc36b7a9d5ab36457a3c3823916090a", + "src/vect.h": "55e35561f0a6159463eb8a7ced80ad769a31f08666a4868c5bc8f808f0be3124" + } +} diff --git a/crates/proof-compression/cuda/plonk/build.py b/crates/proof-compression/cuda/plonk/build.py new file mode 100644 index 000000000..81fd18583 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/build.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Build the standalone BN254 PLONK CUDA library from explicit source roots.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +SPPARK_REVISION = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8" +BLST_CRATE_SHA256 = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" + + +def digest(path): + result = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + result.update(block) + return result.hexdigest() + + +def _validate_tree(root, manifest_path, expected_override=None): + receipt = json.loads(manifest_path.read_text()) + if receipt.get("format") != "ix-native-source-files/v1": + raise ValueError(f"unsupported source manifest: {manifest_path}") + expected = receipt["files_sha256"] if expected_override is None else expected_override + directories = {"."} + for relative in expected: + path = Path(relative) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"invalid source manifest path: {relative}") + directories.update(str(parent) for parent in path.parents) + actual = set() + for current, children, files in os.walk(root, followlinks=False): + current = Path(current) + for child in list(children): + path = current / child + relative = path.relative_to(root).as_posix() + if relative == ".git": + children.remove(child) + continue + if path.is_symlink() or relative not in directories: + raise ValueError(f"unexpected source directory: {path}") + for child in files: + path = current / child + relative = path.relative_to(root).as_posix() + if relative in (".git", ".cargo-ok"): + continue + if path.is_symlink() or not path.is_file() or relative not in expected: + raise ValueError(f"unexpected source file: {path}") + actual.add(relative) + if actual != set(expected): + raise ValueError(f"missing source files in {root}: {sorted(set(expected) - actual)}") + for relative, expected_hash in expected.items(): + if digest(root / relative) != expected_hash: + raise ValueError(f"source digest differs: {root / relative}") + return { + "root": str(root), "manifest": str(manifest_path), + "manifest_sha256": digest(manifest_path), + "files_sha256": expected, + }, receipt + + +def validate_sources(sppark, blst): + """Authenticate complete dependency trees for builds and native-library reuse.""" + source = Path(__file__).resolve().parent + sppark = Path(sppark).resolve(strict=True) + blst = Path(blst).resolve(strict=True) + sppark_result, sppark_receipt = _validate_tree(sppark, source / "sppark-source-manifest.json") + if sppark_receipt.get("revision") != SPPARK_REVISION: + raise ValueError("sppark source receipt revision differs") + blst_result, blst_receipt = _validate_tree(blst, source / "blst-source-manifest.json") + if (blst_receipt.get("crate") != "blst" or blst_receipt.get("version") != "0.3.16" + or blst_receipt.get("crate_sha256") != BLST_CRATE_SHA256 + or blst_receipt.get("subdirectory") != "blst"): + raise ValueError("blst source receipt identity differs") + sppark_result["revision"] = SPPARK_REVISION + blst_result.update(crate="blst", version="0.3.16", crate_sha256=BLST_CRATE_SHA256) + return {"sppark": sppark_result, "blst": blst_result} + + +def split_patch(): + source = Path(__file__).resolve().parent + patch_path = source / "patches/sppark-split-buckets-v1.patch" + receipt_path = source / "patches/sppark-split-buckets-v1.json" + patch = patch_path.read_bytes() + receipt = json.loads(receipt_path.read_text()) + if (receipt.get("format") != "ix-sppark-split-buckets/v1" + or receipt.get("revision") != SPPARK_REVISION + or receipt.get("patch_sha256") != hashlib.sha256(patch).hexdigest()): + raise ValueError("sppark split patch identity differs") + manifest_path = source / "sppark-source-manifest.json" + expected = dict(json.loads(manifest_path.read_text())["files_sha256"]) + blocks = patch.decode().split("diff --git ") + if blocks[0].strip(): + raise ValueError("sppark patch must use git diff headers") + seen = set() + for block in blocks[1:]: + lines = block.splitlines() + before, relative = lines[0].split(" b/", 1) + if (before != "a/" + relative or relative in seen + or relative not in receipt["files"] or Path(relative).is_absolute() + or ".." in Path(relative).parts): + raise ValueError("unexpected sppark patch path") + hashes = receipt["files"][relative] + if expected.get(relative) != hashes["before"]: + raise ValueError("sppark patch input differs from pinned source") + old_header = "--- /dev/null" if hashes["before"] is None else "--- a/" + relative + if [line for line in lines if line.startswith("--- ")] != [old_header] or [ + line for line in lines if line.startswith("+++ ")] != ["+++ b/" + relative]: + raise ValueError("sppark patch file headers differ") + expected[relative] = hashes["after"] + seen.add(relative) + if seen != set(receipt["files"]): + raise ValueError("sppark patch is incomplete") + return patch, receipt_path, expected + + +def validate_patched_sppark(root): + source = Path(__file__).resolve().parent + patch, receipt_path, expected = split_patch() + result, _ = _validate_tree(Path(root).resolve(strict=True), + source / "sppark-source-manifest.json", expected) + result.update(patch_sha256=hashlib.sha256(patch).hexdigest(), + patch_receipt_sha256=digest(receipt_path)) + return result + + +def prepare_sppark(sppark, output): + source = Path(__file__).resolve().parent + patch, receipt_path, expected = split_patch() + identity = hashlib.sha256(patch + receipt_path.read_bytes() + + (source / "sppark-source-manifest.json").read_bytes()).hexdigest() + destination = output / ("sppark-" + identity[:20]) + if not destination.exists(): + temporary = Path(tempfile.mkdtemp(prefix=".sppark-", dir=output)) + try: + pristine = json.loads((source / "sppark-source-manifest.json").read_text())["files_sha256"] + for relative in pristine: + path = temporary / relative + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(sppark / relative, path) + subprocess.run(["patch", "--batch", "--fuzz=0", "-p1"], + cwd=temporary, input=patch, check=True) + validate_patched_sppark(temporary) + temporary.rename(destination) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + return destination, validate_patched_sppark(destination) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sppark", required=True, type=Path) + parser.add_argument("--blst", required=True, type=Path, + help="blst source root containing src/server.c and build/assembly.S") + parser.add_argument("--out", required=True, type=Path) + args = parser.parse_args() + source = Path(__file__).resolve().parent + sppark = args.sppark.resolve(strict=True) + blst = args.blst.resolve(strict=True) + output = args.out.resolve() + output.mkdir(parents=True, exist_ok=True) + try: + dependencies = validate_sources(sppark, blst) + patched_sppark, patched_receipt = prepare_sppark(sppark, output) + except ValueError as error: + parser.error(str(error)) + cuda = Path(os.environ.get("CUDA_HOME", os.environ.get("CUDA_PATH", "/usr/local/cuda"))) + nvcc = os.environ.get("NVCC", str(cuda / "bin/nvcc")) + cc = os.environ.get("CC", "cc") + architectures = os.environ.get("IX_PLONK_CUDA_ARCHS", "120").replace(",", " ").split() + if not architectures or any(not item.isdigit() for item in architectures): + parser.error("IX_PLONK_CUDA_ARCHS must contain numeric CUDA architectures") + architecture_header = output / "architectures.h" + architecture_header.write_text( + "#pragma once\n#define IX_PLONK_CUDA_ARCHITECTURES {" + ",".join(architectures) + "}\n" + ) + include = [f"-I{patched_sppark}", f"-I{patched_sppark / 'util'}", f"-I{blst / 'src'}", f"-I{source}"] + common = [nvcc, "--std=c++17", "--cudart=static", "--default-stream=per-thread", + "-O3", "-lineinfo", "--compiler-options=-fPIC,-fvisibility=hidden", + "-DFEATURE_BN254", "-DMAX_LG_DOMAIN_SIZE=28", "-D__BLST_PORTABLE__", + "-include", str(source / "sppark_namespace.h"), + "-include", str(architecture_header), *include] + for architecture in architectures: + common.append(f"-gencode=arch=compute_{architecture},code=sm_{architecture}") + commands = [] + + def run(command): + commands.append(command) + print(json.dumps({"command": command}), flush=True) + subprocess.run(command, check=True) + + native_sources = [source / "plonk.cu", source / "runtime.cpp"] + objects = [] + for path in native_sources: + obj = output / (path.stem + ".o") + run([*common, "-c", str(path), "-o", str(obj)]) + objects.append(obj) + for path, name in [(blst / "src/server.c", "blst-server.o"), + (blst / "build/assembly.S", "blst-assembly.o")]: + obj = output / name + run([cc, "-O3", "-fPIC", "-fvisibility=hidden", "-D__BLST_PORTABLE__", + f"-I{blst / 'src'}", "-c", str(path), "-o", str(obj)]) + objects.append(obj) + library = output / "libix_plonk_bn254_cuda.so" + run([nvcc, "--shared", "--cudart=static", *map(str, objects), + "-Xlinker", f"--version-script={source / 'exports.map'}", + "-Xlinker", "-z", "-Xlinker", "defs", + "-Xlinker", "-soname", "-Xlinker", library.name, + "-o", str(library), "-ldl", "-lrt", "-lpthread"]) + shutil.copyfile(source / "ix_plonk_bn254.h", output / "ix_plonk_bn254.h") + files = [*native_sources, source / "ix_plonk_bn254.h", source / "sppark_namespace.h", + source / "exports.map", source / "build.py", source / "sppark-source-manifest.json", + source / "blst-source-manifest.json", source.parent / "kzg_runtime.cpp", + source.parent / "kzg_transfer.cuh", blst / "src/server.c", blst / "build/assembly.S", + source / "patches/sppark-split-buckets-v1.patch", source / "patches/sppark-split-buckets-v1.json"] + files.extend(patched_sppark / name for name in patched_receipt["files_sha256"]) + metadata = { + "format": "ix-plonk-bn254-cuda-build/v1", "sppark_revision": SPPARK_REVISION, + "architectures": architectures, "commands": commands, + "nvcc": subprocess.check_output([nvcc, "--version"], text=True), + "dependencies": dependencies, + "patched_sppark": patched_receipt, + "inputs_sha256": {str(path): digest(path) for path in files}, + "library_sha256": digest(library), + } + if validate_sources(sppark, blst) != dependencies: + raise ValueError("native dependency source identity changed during compilation") + if validate_patched_sppark(patched_sppark) != patched_receipt: + raise ValueError("patched sppark source changed during compilation") + (output / "build-manifest.json").write_text(json.dumps(metadata, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/crates/proof-compression/cuda/plonk/exports.map b/crates/proof-compression/cuda/plonk/exports.map new file mode 100644 index 000000000..8347759ad --- /dev/null +++ b/crates/proof-compression/cuda/plonk/exports.map @@ -0,0 +1,4 @@ +IX_PLONK_BN254_1 { + global: ix_plonk_bn254_*; + local: *; +}; diff --git a/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h b/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h new file mode 100644 index 000000000..82c500866 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h @@ -0,0 +1,146 @@ +#ifndef IX_PLONK_BN254_H +#define IX_PLONK_BN254_H + +#include + +#if defined(__GNUC__) +#pragma GCC visibility push(default) +#endif +#ifdef __cplusplus +extern "C" { +#endif + +#define IX_PLONK_BN254_ABI_VERSION 7u +#define IX_PLONK_BN254_MAX_MSM_POINTS (UINT64_C(1) << 24) + +typedef struct { uint64_t limbs[4]; } ix_plonk_bn254_fr; +typedef struct { uint64_t limbs[4]; } ix_plonk_bn254_fq; +typedef struct { ix_plonk_bn254_fq x, y; } ix_plonk_bn254_affine; +/* Jacobian affine coordinates are (X/Z^2,Y/Z^3); Z=0 is infinity. */ +typedef struct { ix_plonk_bn254_fq x, y, z; } ix_plonk_bn254_jacobian; +typedef struct ix_plonk_bn254_context ix_plonk_bn254_context; +typedef uint64_t ix_plonk_bn254_basis; +typedef uint64_t ix_plonk_bn254_quotient; + +enum ix_plonk_bn254_status { + IX_PLONK_BN254_OK = 0, + IX_PLONK_BN254_INVALID = 1, + IX_PLONK_BN254_UNSUPPORTED = 2, + IX_PLONK_BN254_MEMORY = 3, + IX_PLONK_BN254_RUNTIME = 4, + IX_PLONK_BN254_CANCELLED = 5, + IX_PLONK_BN254_POISONED = 6 +}; + +typedef struct { + int32_t code; + char message[256]; +} ix_plonk_bn254_error; + +typedef struct { + uint32_t abi_version; + uint32_t struct_bytes; + int32_t device; + uint32_t msm_strategy; + uint64_t budget_bytes; + uint64_t driver_headroom_bytes; +} ix_plonk_bn254_config; + +enum ix_plonk_bn254_msm_strategy { + IX_PLONK_BN254_MSM_SPLIT = 0, + IX_PLONK_BN254_MSM_MASK = 1 +}; + +typedef struct { + uint64_t n, m; + uint32_t commitments; + uint32_t reserved; + ix_plonk_bn254_fr omega, omega_big, coset; + ix_plonk_bn254_fr alpha, beta, gamma; + ix_plonk_bn254_fr bl[2], br[2], bo[2], bz[3]; +} ix_plonk_bn254_quotient_config; + +typedef struct { + uint64_t driver_free_bytes, driver_total_bytes; + uint64_t pool_used_bytes, pool_used_high_bytes; + uint64_t pool_reserved_bytes, pool_reserved_high_bytes; + uint64_t accounted_bytes, accounted_high_bytes, budget_bytes; + uint64_t msm_calls, fft_calls, numerator_calls, quotient_calls; + uint64_t upload_bytes, download_bytes; + /* Successful logical transfers; each can contain multiple DMA chunks. */ + uint64_t upload_calls, download_calls; + /* Inclusive boundaries: queued predecessor GPU work can delay transfers. */ + double upload_wall_ms, download_wall_ms; + /* Existing ring events measure DMA only when transfer_timing_enabled=1. */ + double upload_dma_ms, download_dma_ms; + uint64_t transfer_timing_enabled; + /* Host staging copies are included in the transfer wall times. */ + double transfer_host_copy_ms; + uint64_t masked_msm_calls, mask_sum_calls, mask_cache_hits; + uint64_t split_msm_calls, split_unit_points; + uint64_t split_max_bucket[32], split_chunk_count[32]; + uint64_t split_nonzero_digits[32], split_zero_digits[32]; + double split_classify_ms, split_chunks_ms, split_reduce_ms; +} ix_plonk_bn254_stats; + +/* All field records contain reduced little-endian Montgomery limbs. */ +int ix_plonk_bn254_check_field_abi( + const uint64_t fq[3][4], const uint64_t fr[3][4], + ix_plonk_bn254_error *error); +int ix_plonk_bn254_ntt_parameters(uint32_t log_size, + ix_plonk_bn254_fr *root, ix_plonk_bn254_fr *inverse_root, + ix_plonk_bn254_fr *inverse_size, ix_plonk_bn254_error *error); +int ix_plonk_bn254_context_create(const ix_plonk_bn254_config *config, + ix_plonk_bn254_context **out, ix_plonk_bn254_error *error); +int ix_plonk_bn254_context_destroy(ix_plonk_bn254_context *context, + ix_plonk_bn254_error *error); +int ix_plonk_bn254_context_cancel(ix_plonk_bn254_context *context, + ix_plonk_bn254_error *error); +int ix_plonk_bn254_get_stats(ix_plonk_bn254_context *context, + ix_plonk_bn254_stats *out, ix_plonk_bn254_error *error); + +int ix_plonk_bn254_basis_upload(ix_plonk_bn254_context *context, + const ix_plonk_bn254_affine *points, uint64_t count, + ix_plonk_bn254_basis *out, ix_plonk_bn254_error *error); +int ix_plonk_bn254_basis_destroy(ix_plonk_bn254_context *context, + ix_plonk_bn254_basis basis, ix_plonk_bn254_error *error); +/* Both strategies preserve the original scalar vector and resulting MSM. */ +int ix_plonk_bn254_msm(ix_plonk_bn254_context *context, + ix_plonk_bn254_basis basis, uint64_t point_offset, + const ix_plonk_bn254_fr *scalars, uint64_t count, + ix_plonk_bn254_jacobian *out, ix_plonk_bn254_error *error); + +/* Regular or bit-reversed input, always regular output. coset is the FORWARD + * coset multiplier in both directions; inverse transforms undo its powers. */ +int ix_plonk_bn254_fft(ix_plonk_bn254_context *context, + ix_plonk_bn254_fr *values, uint64_t count, uint32_t inverse, + uint32_t input_bit_reversed, const ix_plonk_bn254_fr *coset, + ix_plonk_bn254_error *error); + +/* Slots: L,R,O,Z,Qk_complete,Ql,Qr,Qm,Qo,S1,S2,S3,Qcp0,Pi0,... . + * put accepts Lagrange/regular input, replaces the host slice with canonical/ + * regular coefficients synchronously, and retains a private device copy. + * Sessions support n>=2 and m/n=4 or8, with m<=2^26 and <=16 commitments. */ +int ix_plonk_bn254_quotient_begin(ix_plonk_bn254_context *context, + const ix_plonk_bn254_quotient_config *config, + ix_plonk_bn254_quotient *out, ix_plonk_bn254_error *error); +int ix_plonk_bn254_quotient_put(ix_plonk_bn254_context *context, + ix_plonk_bn254_quotient quotient, uint32_t slot, + ix_plonk_bn254_fr *values, uint64_t count, + ix_plonk_bn254_error *error); +/* fused=0 yields numerator LagrangeCoset/BitReverse; fused=1 yields canonical/ + * regular H, including division by ZH and the inverse coset transform. */ +int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context *context, + ix_plonk_bn254_quotient quotient, uint32_t fused, + ix_plonk_bn254_fr *output, uint64_t count, + ix_plonk_bn254_error *error); +int ix_plonk_bn254_quotient_destroy(ix_plonk_bn254_context *context, + ix_plonk_bn254_quotient quotient, ix_plonk_bn254_error *error); + +#ifdef __cplusplus +} +#endif +#if defined(__GNUC__) +#pragma GCC visibility pop +#endif +#endif diff --git a/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json b/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json new file mode 100644 index 000000000..6d2a0fb75 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json @@ -0,0 +1,15 @@ +{ + "format": "ix-sppark-split-buckets/v1", + "revision": "114feb69ebfe029e2caa5e6c2d372c695e5d4da8", + "patch_sha256": "faec2b98ec2dcd74474c9d67eb38a10ed04a748565287a19385f804ceda2d514", + "files": { + "msm/pippenger.cuh": { + "before": "413013c734be4aabb6a5905a890e43c5f87a4e5fb3c75494a580ded8c52d5634", + "after": "20af74f72fa786447c42f36adad75c8487d1b389eea4824ccea340ec0359a11a" + }, + "msm/split_buckets.cuh": { + "before": null, + "after": "3b46697880c3e21750afe0c95e717e0edb254ab13495520cda2adee872e3b129" + } + } +} diff --git a/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch b/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch new file mode 100644 index 000000000..5573dc7c3 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch @@ -0,0 +1,360 @@ +diff --git a/msm/pippenger.cuh b/msm/pippenger.cuh +--- a/msm/pippenger.cuh ++++ b/msm/pippenger.cuh +@@ -157,7 +157,8 @@ + __launch_bounds__(ACCUMULATE_NTHREADS) __global__ + void accumulate(bucket_h buckets_[], uint32_t nwins, uint32_t wbits, + /*const*/ affine_h points_[], const vec2d_t digits, +- const vec2d_t histogram, uint32_t sid = 0) ++ const vec2d_t histogram, uint32_t sid = 0, ++ uint32_t max_bucket_length = 0xffffffffu) + { + vec2d_t buckets{buckets_, 1U<<--wbits}; + const affine_h* points = points_; +@@ -199,6 +200,7 @@ + idx = h[-1]; + + if ((len -= idx) && !(x == 0 && y == 0)) { ++ len = min(len, max_bucket_length); + const uint32_t* digs_ptr = &digits[y][idx]; + uint32_t digit = *digs_ptr++; + +@@ -311,7 +313,7 @@ + /*const*/ affine_t::mem_t points_[], + const vec2d_t digits, + const vec2d_t histogram, +- uint32_t sid); ++ uint32_t sid, uint32_t max_bucket_length); + template __global__ + void batch_addition(bucket_t::mem_t buckets[], + const affine_t::mem_t points[], size_t npoints, +@@ -329,6 +331,7 @@ + #include + #include + #include ++#include "split_buckets.cuh" + + template d_hist; ++ bool split_buckets_ = false; ++ msm_split_statistics split_statistics_{}; + + template using vec_t = slice_t; + +@@ -356,6 +361,11 @@ + { int ret=0; while (n>>=1) ret++; return ret; } + + public: ++ void set_split_buckets(bool enabled) { split_buckets_ = enabled; } ++ const msm_split_statistics& split_statistics() const { return split_statistics_; } ++ static size_t split_scratch_bytes(size_t count, uint32_t window, uint32_t windows) { ++ return msm_split_geometry(count, window, windows, sizeof(bucket_h)).bytes; ++ } + msm_t(const affine_t points[], size_t np, + size_t ffi_affine_sz = sizeof(affine_t), int device_id = -1) + : gpu(select_gpu(device_id)), d_points(nullptr), d_scalars(nullptr) +@@ -460,6 +470,7 @@ + size_t ffi_affine_sz = sizeof(affine_t)) + { + assert(this->npoints == 0 || npoints <= this->npoints); ++ split_statistics_ = msm_split_statistics{}; + + uint32_t lg_npoints = lg2(npoints + npoints/2); + size_t batch = 1 << (std::max(lg_npoints, wbits) - wbits); +@@ -489,6 +500,17 @@ + size_t digits_sz = nwins * stride * sizeof(uint32_t); + + dev_ptr_t d_temp{temp_sz + digits_sz + d_point_sz, gpu[2]}; ++ const msm_split_geometry geometry(split_buckets_ ? npoints : 1, wbits, nwins, sizeof(bucket_h)); ++ dev_ptr_t split_storage{split_buckets_ ? geometry.bytes : 0, gpu}; ++ msm_unwind_completion completion(gpu.cid()); ++ if (split_buckets_) gpu.sync(); ++ auto* split_header = split_buckets_ ? reinterpret_cast(&split_storage[0]) : nullptr; ++ auto* split_ranges = split_buckets_ ? reinterpret_cast(&split_storage[geometry.ranges_offset]) : nullptr; ++ auto* split_tasks = split_buckets_ ? reinterpret_cast(&split_storage[geometry.tasks_offset]) : nullptr; ++ auto* split_heavy = split_buckets_ ? reinterpret_cast(&split_storage[geometry.heavy_offset]) : nullptr; ++ auto* split_partials = split_buckets_ ? reinterpret_cast(&split_storage[geometry.partials_offset]) : nullptr; ++ msm_split_timer classify_timer(split_buckets_), chunks_timer(split_buckets_), reduce_timer(split_buckets_); ++ msm_split_header split_sample{}; + + vec2d_t d_temps{&d_temp[0], stride}; + vec2d_t d_digits{&d_temp[temp_sz], stride}; +@@ -515,6 +537,16 @@ + for (uint32_t i = 0; i < batch; i++) { + gpu[i&1].wait(ev); + auto bucket_timer = SPPARK_MSM_PROFILE_START(gpu[i&1]); ++ if (split_buckets_) { ++ classify_timer.start(gpu[i&1]); ++ gpu[i&1].bzero(split_header, 1); ++ msm_split_classify<<>>( ++ d_hist, nwins, wbits, static_cast(num), geometry.capacity, ++ split_header, split_ranges, split_tasks, split_heavy); ++ CUDA_OK(cudaGetLastError()); ++ classify_timer.stop(gpu[i&1]); ++ chunks_timer.start(gpu[i&1]); ++ } + + batch_addition<<>>( +@@ -525,8 +557,24 @@ + + gpu[i&1].launch_coop(accumulate, + {gpu.sm_count(), 0}, +- d_buckets, nwins, wbits, &d_points[d_off], d_digits, d_hist, i&1 ++ d_buckets, nwins, wbits, &d_points[d_off], d_digits, d_hist, i&1, ++ split_buckets_ ? MSM_SPLIT_CHUNK : 0xffffffffu + ); ++ if (split_buckets_) { ++ msm_split_accumulate<<>>( ++ split_partials, &d_points[d_off], d_digits, wbits, split_tasks, ++ split_header, geometry.capacity); ++ CUDA_OK(cudaGetLastError()); ++ chunks_timer.stop(gpu[i&1]); ++ reduce_timer.start(gpu[i&1]); ++ msm_split_reduce<<>>( ++ d_buckets, split_partials, split_ranges, split_heavy, split_header, geometry.capacity); ++ CUDA_OK(cudaGetLastError()); ++ reduce_timer.stop(gpu[i&1]); ++ } ++ // The next digit pass may overwrite its histogram only after ++ // every split chunk has consumed the current sorted indices. + gpu[i&1].record(ev); + + integrate<< ++#include ++ ++constexpr uint32_t MSM_SPLIT_CHUNK = 256; ++constexpr uint32_t MSM_SPLIT_REDUCE_THREADS = 256; ++ ++struct msm_split_statistics { ++ uint64_t max_bucket[32]{}, chunk_count[32]{}; ++ uint64_t nonzero_digits[32]{}, zero_digits[32]{}; ++ uint64_t unit_points = 0; ++ double classify_ms = 0, chunks_ms = 0, reduce_ms = 0; ++}; ++ ++struct msm_split_header { ++ uint32_t tasks, heavy, overflow, reserved; ++ uint32_t max_bucket[32], chunk_count[32]; ++ uint32_t nonzero_digits[32], zero_digits[32]; ++ uint32_t unit_points; ++}; ++ ++struct msm_split_geometry { ++ size_t ranges_offset, tasks_offset, heavy_offset, partials_offset, bytes; ++ uint32_t capacity; ++ ++ msm_split_geometry(size_t count, uint32_t window, uint32_t windows, ++ size_t bucket_bytes) { ++ if (!count || count > (size_t(1) << 24) || window < 10 || window > 18 || !windows || windows > 32) ++ CUDA_OK(cudaErrorInvalidValue); ++ uint32_t bits = 0; ++ for (size_t n = count + count / 2; n >>= 1;) ++bits; ++ size_t batch = std::max(size_t(1), (size_t(1) << (std::max(bits, window) - window)) >> 6); ++ size_t stride = ((count + batch - 1) / batch + 31) & ~size_t(31); ++ // The rounded-window boundary can halve the invocation's batch count. ++ // Even then count <= 2^24 bounds each reduction lane to 256 partials. ++ if ((stride + MSM_SPLIT_CHUNK - 1) / MSM_SPLIT_CHUNK ++ > size_t(MSM_SPLIT_REDUCE_THREADS) * MSM_SPLIT_CHUNK) ++ CUDA_OK(cudaErrorInvalidValue); ++ size_t tasks = windows * ((stride + MSM_SPLIT_CHUNK - 1) / MSM_SPLIT_CHUNK); ++ if (tasks > std::numeric_limits::max()) CUDA_OK(cudaErrorInvalidValue); ++ capacity = static_cast(tasks); ++ ranges_offset = (sizeof(msm_split_header) + 31) & ~size_t(31); ++ tasks_offset = ranges_offset + size_t(windows) * (size_t(1) << (window-1)) * sizeof(uint2); ++ heavy_offset = tasks_offset + tasks * sizeof(uint4); ++ partials_offset = (heavy_offset + tasks * sizeof(uint32_t) + 31) & ~size_t(31); ++ if (tasks > (std::numeric_limits::max() - partials_offset) / bucket_bytes) ++ CUDA_OK(cudaErrorInvalidValue); ++ bytes = partials_offset + tasks * bucket_bytes; ++ } ++}; ++ ++// Cross-stream consumers must finish before asynchronous scratch frees unwind. ++class msm_unwind_completion { ++ int device_, exceptions_; ++ public: ++ explicit msm_unwind_completion(int device) noexcept ++ : device_(device), exceptions_(std::uncaught_exceptions()) {} ++ msm_unwind_completion(const msm_unwind_completion&) = delete; ++ msm_unwind_completion& operator=(const msm_unwind_completion&) = delete; ++ ~msm_unwind_completion() noexcept { ++ if (std::uncaught_exceptions() > exceptions_) { ++ (void)cudaSetDevice(device_); ++ (void)cudaDeviceSynchronize(); ++ } ++ } ++}; ++ ++class msm_split_timer { ++ cudaEvent_t begin_ = nullptr, end_ = nullptr; ++ public: ++ explicit msm_split_timer(bool enabled) { ++ if (!enabled) return; ++ CUDA_OK(cudaEventCreate(&begin_)); ++ try { CUDA_OK(cudaEventCreate(&end_)); } ++ catch (...) { cudaEventDestroy(begin_); throw; } ++ } ++ ~msm_split_timer() { ++ if (begin_) (void)cudaEventDestroy(begin_); ++ if (end_) (void)cudaEventDestroy(end_); ++ } ++ void start(cudaStream_t stream) { if (begin_) CUDA_OK(cudaEventRecord(begin_, stream)); } ++ void stop(cudaStream_t stream) { if (end_) CUDA_OK(cudaEventRecord(end_, stream)); } ++ double elapsed() { ++ if (!end_) return 0; ++ float ms = 0; ++ CUDA_OK(cudaEventElapsedTime(&ms, begin_, end_)); ++ return ms; ++ } ++}; ++ ++__device__ inline uint32_t msm_split_reserve(uint32_t* next, uint32_t count, ++ uint32_t capacity, uint32_t* overflow) { ++ uint32_t current = atomicAdd(next, 0); ++ for (;;) { ++ if (current > capacity || count > capacity - current) { ++ atomicExch(overflow, 1); return 0xffffffffu; ++ } ++ uint32_t observed = atomicCAS(next, current, current + count); ++ if (observed == current) return current; ++ current = observed; ++ } ++} ++ ++__global__ void msm_split_classify(const vec2d_t histogram, ++ uint32_t windows, uint32_t window, uint32_t num, uint32_t capacity, ++ msm_split_header* header, uint2* ranges, uint4* tasks, uint32_t* heavy) { ++ const uint32_t row = 1u << (window-1); ++ for (uint32_t id = blockIdx.x*blockDim.x + threadIdx.x; ++ id < windows*row; id += gridDim.x*blockDim.x) { ++ uint32_t w = id / row, bucket = id % row; ++ uint32_t end = histogram[w][bucket]; ++ uint32_t start = bucket ? histogram[w][bucket-1] : 0; ++ ranges[id] = uint2{0, 0}; ++ if (end < start || end > num) { atomicExch(&header->overflow, 1); continue; } ++ uint32_t length = end - start; ++ if (bucket == 0) { ++ uint32_t nonzero = histogram[w][row-1]; ++ if (nonzero > num) { atomicExch(&header->overflow, 1); continue; } ++ header->nonzero_digits[w] = nonzero; ++ header->zero_digits[w] = num - nonzero; ++ } ++ // The lowest-window unit bucket is handled by batch_addition. ++ if (id == 0) { header->unit_points = length; continue; } ++ atomicMax(&header->max_bucket[w], length); ++ if (length <= MSM_SPLIT_CHUNK) continue; ++ uint32_t extra = (length - 1) / MSM_SPLIT_CHUNK; ++ uint32_t base = msm_split_reserve(&header->tasks, extra, capacity, &header->overflow); ++ if (base == 0xffffffffu) continue; ++ uint32_t heavy_id = msm_split_reserve(&header->heavy, 1, capacity, &header->overflow); ++ if (heavy_id == 0xffffffffu) continue; ++ ranges[id] = uint2{base, extra}; ++ heavy[heavy_id] = id; ++ atomicAdd(&header->chunk_count[w], extra); ++ for (uint32_t part = 0; part < extra; ++part) { ++ uint32_t first = start + (part + 1) * MSM_SPLIT_CHUNK; ++ tasks[base + part] = uint4{id, first, min(end-first, MSM_SPLIT_CHUNK), 0}; ++ } ++ } ++} ++ ++template ++__launch_bounds__(128) __global__ ++void msm_split_accumulate(bucket_h* partials, const affine_h* points, ++ const vec2d_t digits, uint32_t window, const uint4* tasks, ++ const msm_split_header* header, uint32_t capacity) { ++ static_assert(bucket_t::degree == 1, "split buckets require a base-field G1"); ++ if (header->overflow) return; ++ uint32_t count = min(header->tasks, capacity); ++ for (uint32_t id = blockIdx.x*blockDim.x + threadIdx.x; ++ id < count; id += gridDim.x*blockDim.x) { ++ uint4 task = tasks[id]; ++ uint32_t w = task.x >> (window-1); ++ uint32_t digit = digits[w][task.y]; ++ typename bucket_t::affine_t point = points[digit & 0x7fffffff]; ++ bucket_t sum = point; ++ sum.cneg(digit >> 31); ++ for (uint32_t i = 1; i < task.z; ++i) { ++ digit = digits[w][task.y+i]; ++ point = points[digit & 0x7fffffff]; ++ sum.add(point, digit >> 31); ++ } ++ partials[id] = sum; ++ } ++} ++ ++template ++__launch_bounds__(MSM_SPLIT_REDUCE_THREADS) __global__ ++void msm_split_reduce(bucket_h* buckets, const bucket_h* partials, ++ const uint2* ranges, const uint32_t* heavy, const msm_split_header* header, ++ uint32_t capacity) { ++ static_assert(alignof(bucket_h) <= 32, "split shared-memory alignment"); ++ extern __shared__ __align__(32) unsigned char scratch_bytes[]; ++ auto* scratch = reinterpret_cast(scratch_bytes); ++ if (header->overflow) return; ++ uint32_t count = min(header->heavy, capacity); ++ for (uint32_t id = blockIdx.x; id < count; id += gridDim.x) { ++ uint32_t bucket = heavy[id]; ++ uint2 range = ranges[bucket]; ++ bucket_t sum; ++ memset(&sum, 0, sizeof(sum)); ++ if (threadIdx.x == 0) sum = buckets[bucket]; ++ for (uint32_t i = threadIdx.x; i < range.y; i += MSM_SPLIT_REDUCE_THREADS) { ++ bucket_t part = partials[range.x+i]; ++ sum.add(part); ++ } ++ scratch[threadIdx.x] = sum; ++ __syncthreads(); ++ for (uint32_t offset = MSM_SPLIT_REDUCE_THREADS/2; offset; offset >>= 1) { ++ if (threadIdx.x < offset) { ++ bucket_t other = scratch[threadIdx.x+offset]; ++ sum.add(other); ++ scratch[threadIdx.x] = sum; ++ } ++ __syncthreads(); ++ } ++ if (threadIdx.x == 0) buckets[bucket] = sum; ++ __syncthreads(); ++ } ++} ++#endif diff --git a/crates/proof-compression/cuda/plonk/plonk.cu b/crates/proof-compression/cuda/plonk/plonk.cu new file mode 100644 index 000000000..71c7721d7 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/plonk.cu @@ -0,0 +1,828 @@ +#include "ix_plonk_bn254.h" +#include +// Complete host Jacobian addition depends on BLST's alias-safe vec_select. +#include +#include +#include +#include +#define SPPARK_DONT_INSTANTIATE_TEMPLATES +#define TAKE_RESPONSIBILITY_FOR_ERROR_MESSAGE +#include +#include "../kzg_transfer.cuh" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using point_t = jacobian_t; +using bucket_t = xyzz_t; +using affine_t = bucket_t::affine_t; +using msm_impl = msm_t; + +static_assert(sizeof(fr_t) == 32 && sizeof(fp_t) == 32); +static_assert(sizeof(affine_t) == 64 && sizeof(point_t) == 96); +static_assert(sizeof(ix_plonk_bn254_fr) == sizeof(fr_t)); +static_assert(sizeof(ix_plonk_bn254_affine) == sizeof(affine_t)); +static_assert(sizeof(ix_plonk_bn254_jacobian) == sizeof(point_t)); +static_assert(sizeof(ix_plonk_bn254_config) == 32); +static_assert(MAX_LG_DOMAIN_SIZE == 28); + +namespace { +constexpr uint64_t NTT_BYTES = 2 * (512 * 32 + 512 * 512 * 32 + 2 * 2 * 16384 * 32); +constexpr uint64_t MAX_M = UINT64_C(1) << 26; +constexpr uint32_t MAX_COMMITMENTS = 16; +constexpr uint32_t MAX_SLOTS = 12 + 2 * MAX_COMMITMENTS; +constexpr uint64_t MASK_THRESHOLD = 4096; +constexpr uint64_t MASK_SEED = UINT64_C(0x243f6a8885a308d3); +constexpr size_t MAX_MASK_SUMS = 256; + +__host__ __device__ uint64_t mix_counter(uint64_t value) { + value = (value ^ (value >> 30)) * UINT64_C(0xbf58476d1ce4e5b9); + value = (value ^ (value >> 27)) * UINT64_C(0x94d049bb133111eb); + return value ^ (value >> 31); +} + +struct Failure : std::runtime_error { + int code; + Failure(int code, const char* text) : std::runtime_error(text), code(code) {} +}; + +void require(bool condition, const char* message) { + if (!condition) throw Failure(IX_PLONK_BN254_INVALID, message); +} + +uint64_t multiply(uint64_t a, uint64_t b) { + if (b && a > std::numeric_limits::max() / b) + throw Failure(IX_PLONK_BN254_INVALID, "allocation size overflow"); + return a * b; +} + +bool power_of_two(uint64_t n) { return n && !(n & (n - 1)); } +uint32_t log_size(uint64_t n) { + require(power_of_two(n) && n <= MAX_M, "unsupported polynomial length"); + uint32_t log = 0; + while (n >>= 1) ++log; + return log; +} + +fr_t field(const ix_plonk_bn254_fr& value) { + constexpr uint64_t modulus[4] = { + UINT64_C(0x43e1f593f0000001), UINT64_C(0x2833e84879b97091), + UINT64_C(0xb85045b68181585d), UINT64_C(0x30644e72e131a029), + }; + bool less = false; + for (int i = 3; i >= 0; --i) { + if (value.limbs[i] != modulus[i]) { + less = value.limbs[i] < modulus[i]; + break; + } + } + require(less, "noncanonical Montgomery field parameter"); + fr_t result; + std::memcpy(&result, &value, sizeof(result)); + return result; +} + +void copy_field(ix_plonk_bn254_fr* out, const fr_t& value) { + std::memcpy(out, &value, sizeof(value)); +} + +struct MsmPlan { + uint32_t window; + uint64_t persistent, temporary; +}; + +MsmPlan msm_plan(const gpu_t& gpu, uint64_t count, bool split) { + require(count && count <= IX_PLONK_BN254_MAX_MSM_POINTS, "unsupported MSM size"); + const uint64_t original_count = count; + count = (count + 31) & ~UINT64_C(31); + uint32_t bits = 0; + for (uint64_t n = count + count / 2; n >>= 1;) ++bits; + uint32_t window = count <= 192 ? 10 : std::max(10u, std::min(bits - 8, 18u)); + uint64_t windows = (fr_t::bit_length() - 1) / window + 1; + uint64_t row = UINT64_C(1) << (window - 1); + uint64_t persistent = (windows * row + gpu.sm_count() * BATCH_ADD_BLOCK_SIZE / WARP_SZ) + * sizeof(bucket_t::mem_t) + windows * row * sizeof(uint32_t); + uint32_t invocation_bits = 0; + for (uint64_t n = original_count + original_count / 2; n >>= 1;) ++invocation_bits; + uint64_t batch = std::max(UINT64_C(1), (UINT64_C(1) << (std::max(invocation_bits, window) - window)) >> 6); + uint64_t stride = ((original_count + batch - 1) / batch + 31) & ~UINT64_C(31); + uint64_t temporary = stride * (2 * sizeof(uint2) + windows * sizeof(uint32_t)) + + count * sizeof(fr_t); + if (split) temporary += msm_impl::split_scratch_bytes(original_count, window, static_cast(windows)); + return {window, persistent, temporary}; +} + +class Buffer { + void* data_ = nullptr; + const gpu_t* gpu_ = nullptr; + public: + Buffer() = default; + Buffer(const gpu_t& gpu, uint64_t bytes) : gpu_(&gpu) { + if (bytes) data_ = gpu.Dmalloc(bytes); + } + Buffer(const Buffer&) = delete; + Buffer& operator=(const Buffer&) = delete; + ~Buffer() { + if (data_) { + gpu_->select(); + (void)cudaFreeAsync(data_, *gpu_); + (void)cudaStreamSynchronize(*gpu_); + } + } + template T* as() const { return static_cast(data_); } +}; + +struct Basis; +struct Quotient; +struct Workspace; +std::mutex contexts_mutex; +std::unordered_set owned_devices; +} + +struct ix_plonk_bn254_context { + const gpu_t& gpu; + uint64_t budget, headroom, accounted = 0, high = 0, next = 1; + uint32_t msm_strategy; + std::mutex mutex; + std::atomic cancelled{false}; + std::atomic poisoned{false}; + std::unique_ptr transfer; + std::unordered_map> bases; + std::unordered_map> quotients; + std::unique_ptr workspace; + ix_plonk_bn254_stats stats{}; + + ix_plonk_bn254_context(const gpu_t& gpu, uint64_t budget, uint64_t headroom, uint32_t strategy) + : gpu(gpu), budget(budget), headroom(headroom), msm_strategy(strategy) {} + ~ix_plonk_bn254_context(); + + void ready() { + gpu.select(); + if (poisoned) throw Failure(IX_PLONK_BN254_POISONED, "CUDA context is poisoned"); + if (cancelled.load()) throw Failure(IX_PLONK_BN254_CANCELLED, "CUDA proof cancelled"); + } + + void reserve(uint64_t bytes) { + ready(); + if (bytes > budget - std::min(accounted, budget)) + throw Failure(IX_PLONK_BN254_MEMORY, "CUDA allocation budget exceeded"); + size_t free = 0, total = 0; + CUDA_OK(cudaMemGetInfo(&free, &total)); + cudaMemPool_t pool; + CUDA_OK(cudaDeviceGetMemPool(&pool, gpu.cid())); + uint64_t reserved = 0, used = 0; + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrReservedMemCurrent, &reserved)); + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrUsedMemCurrent, &used)); + uint64_t available = std::min(total, free + (reserved > used ? reserved - used : 0)); + if (headroom > available || bytes > available - headroom) + throw Failure(IX_PLONK_BN254_MEMORY, "insufficient CUDA memory with required headroom"); + accounted += bytes; + high = std::max(high, accounted); + } + + void upload(void* destination, const void* source, uint64_t bytes) { + ready(); + const auto started = std::chrono::steady_clock::now(); + TransferStats sample; + transfer->upload.upload(gpu, gpu, destination, source, bytes, sample); + gpu.sync(); + transfer->upload.finish_upload(sample); + stats.upload_wall_ms += std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + stats.transfer_host_copy_ms += sample.host_copy_ms; + stats.upload_dma_ms += sample.upload_ms; + stats.upload_bytes += bytes; + ++stats.upload_calls; + } + + void download(void* destination, const void* source, uint64_t bytes) { + ready(); + const auto started = std::chrono::steady_clock::now(); + TransferStats sample; + transfer->download.download(gpu, gpu, destination, source, bytes, sample); + gpu.sync(); + stats.download_wall_ms += std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + stats.transfer_host_copy_ms += sample.host_copy_ms; + stats.download_dma_ms += sample.download_ms; + stats.download_bytes += bytes; + ++stats.download_calls; + } + + uint64_t handle() { + if (next == std::numeric_limits::max()) + throw Failure(IX_PLONK_BN254_INVALID, "CUDA handle generation exhausted"); + return next++; + } +}; + +namespace { +struct Reservation { + ix_plonk_bn254_context& context; + uint64_t bytes; + Reservation(ix_plonk_bn254_context& context, uint64_t bytes) : context(context), bytes(bytes) { + context.reserve(bytes); + } + ~Reservation() { context.accounted -= bytes; } +}; + +struct Basis { + Reservation reservation; + Buffer points; + uint64_t count; + uint64_t mask_seed = 0; + struct Range { + uint64_t offset, count; + bool operator==(const Range& other) const { + return offset == other.offset && count == other.count; + } + }; + struct RangeHash { + size_t operator()(const Range& range) const { + return static_cast(mix_counter(range.offset) ^ mix_counter(range.count)); + } + }; + std::unordered_map mask_sums; + Basis(ix_plonk_bn254_context& context, uint64_t count) + : reservation(context, multiply(count, sizeof(affine_t))), + points(context.gpu, reservation.bytes), count(count) {} +}; + +struct Workspace { + Reservation reservation; + uint32_t window; + msm_impl* msm; + Workspace(ix_plonk_bn254_context& context, uint64_t count, const MsmPlan& plan) + : reservation(context, plan.persistent), window(plan.window), + msm(new msm_impl(nullptr, count, sizeof(affine_t), context.gpu.id())) { + msm->set_split_buckets(context.msm_strategy == IX_PLONK_BN254_MSM_SPLIT); + } + ~Workspace() { + // A sticky device fault makes sppark's throwing destructor unsafe. + // The poisoned worker must exit so CUDA can reclaim that context. + if (cudaDeviceSynchronize() == cudaSuccess) delete msm; + } +}; + +struct alignas(32) NumeratorParameters { + fr_t omega, coset, identity_coset, alpha, beta, gamma, vanishing, vanishing_inverse; + fr_t bl[2], br[2], bo[2], bz[3]; + const fr_t* columns[MAX_SLOTS]; + const fr_t* boundary; + fr_t* output; + uint64_t n; + uint32_t commitments, ratio, coset_index, log_m, fused; +}; + +struct Quotient { + Reservation reservation; + Buffer canonical, evaluations, boundary, output; + ix_plonk_bn254_quotient_config config; + std::vector present; + bool finished = false; + + Quotient(ix_plonk_bn254_context& context, const ix_plonk_bn254_quotient_config& config) + : reservation(context, multiply(2 * (12 + 2 * uint64_t(config.commitments)) + 1, multiply(config.n, 32)) + multiply(config.m, 32)), + canonical(context.gpu, multiply(12 + 2 * uint64_t(config.commitments), multiply(config.n, 32))), + evaluations(context.gpu, multiply(12 + 2 * uint64_t(config.commitments), multiply(config.n, 32))), + boundary(context.gpu, multiply(config.n, 32)), + output(context.gpu, multiply(config.m, 32)), config(config), + present(12 + 2 * config.commitments, false) {} +}; + +__global__ void copy_scaled(fr_t* output, const fr_t* input, uint64_t count, + fr_t shift, uint32_t reverse_log, fr_t constant, bool fill) { + uint64_t i = uint64_t(blockIdx.x) * blockDim.x + threadIdx.x; + const uint64_t stride = uint64_t(gridDim.x) * blockDim.x; + fr_t step = shift ^ static_cast(stride); + fr_t power = shift ^ static_cast(i); + for (; i < count; i += stride) { + fr_t scale = reverse_log ? shift ^ (__brev(static_cast(i)) >> (32 - reverse_log)) : power; + output[i] = (fill ? constant : input[i]) * scale; + power *= step; + } +} + +__device__ fr_t mask_scalar(uint64_t seed, uint64_t index) { + constexpr uint64_t step = UINT64_C(0x9e3779b97f4a7c15); + constexpr uint64_t modulus[4] = { + UINT64_C(0x43e1f593f0000001), UINT64_C(0x2833e84879b97091), + UINT64_C(0xb85045b68181585d), UINT64_C(0x30644e72e131a029), + }; + uint64_t words[4]; + for (unsigned i = 0; i < 4; ++i) + words[i] = mix_counter(seed + (4 * index + i + 1) * step); + // Reduced raw Montgomery residues have the same full-width distribution + // as field elements. These masks balance digits; they are not randomness + // for the proof protocol and need no cryptographic pseudorandomness. + for (;;) { + bool greater_or_equal = true; + for (int i = 3; i >= 0; --i) { + if (words[i] != modulus[i]) { + greater_or_equal = words[i] > modulus[i]; + break; + } + } + if (!greater_or_equal) break; + uint64_t borrow = 0; + for (unsigned i = 0; i < 4; ++i) { + uint64_t difference = words[i] - modulus[i]; + uint64_t next_borrow = (words[i] < modulus[i]) | (difference < borrow); + words[i] = difference - borrow; + borrow = next_borrow; + } + } + fr_t result; + memcpy(&result, words, sizeof(result)); + return result; +} + +__global__ void mask_scalars(fr_t* values, uint64_t count, uint64_t seed, bool add) { + for (uint64_t i = uint64_t(blockIdx.x) * blockDim.x + threadIdx.x; + i < count; i += uint64_t(gridDim.x) * blockDim.x) { + fr_t mask = mask_scalar(seed, i); + values[i] = add ? values[i] + mask : mask; + } +} + +void scale(ix_plonk_bn254_context& context, fr_t* out, const fr_t* in, + uint64_t n, const fr_t& shift, uint32_t reverse_log = 0, + bool fill = false, const fr_t& constant = fr_t::one()) { + copy_scaled<<>>( + out, in, n, shift, reverse_log, constant, fill); + CUDA_OK(cudaGetLastError()); +} + +void transform(ix_plonk_bn254_context& context, fr_t* data, uint64_t count, + bool inverse, bool bit_reversed, const fr_t& coset) { + context.ready(); + uint32_t log = log_size(count); + require(!coset.is_zero(), "zero FFT coset"); + if (count == 1) { ++context.stats.fft_calls; return; } + if (!inverse && !coset.is_one()) + scale(context, data, data, count, coset, bit_reversed ? log : 0); + NTT::Base_dev_ptr(context.gpu, data, log, + bit_reversed ? NTT::InputOutputOrder::RN : NTT::InputOutputOrder::NN, + inverse ? NTT::Direction::inverse : NTT::Direction::forward, + NTT::Type::standard); + if (inverse && !coset.is_one()) + scale(context, data, data, count, coset.reciprocal()); + CUDA_OK(cudaGetLastError()); + ++context.stats.fft_calls; +} + +__device__ fr_t linear_blinder(const fr_t* coefficients, const fr_t& x) { + return coefficients[0] + coefficients[1] * x; +} + +__device__ fr_t quadratic_blinder(const fr_t* coefficients, const fr_t& x) { + return coefficients[0] + (coefficients[1] + coefficients[2] * x) * x; +} + +__global__ void numerator(NumeratorParameters p) { + uint64_t j = uint64_t(blockIdx.x) * blockDim.x + threadIdx.x; + const uint64_t stride = uint64_t(gridDim.x) * blockDim.x; + fr_t x = p.coset * (p.omega ^ static_cast(j)); + const fr_t step = p.omega ^ static_cast(stride); + for (; j < p.n; j += stride) { + const uint64_t next = (j + 1) & (p.n - 1); + fr_t l = p.columns[0][j] + p.vanishing * linear_blinder(p.bl, x); + fr_t r = p.columns[1][j] + p.vanishing * linear_blinder(p.br, x); + fr_t o = p.columns[2][j] + p.vanishing * linear_blinder(p.bo, x); + fr_t z = p.columns[3][j] + p.vanishing * quadratic_blinder(p.bz, x); + fr_t zs = p.columns[3][next] + p.vanishing * quadratic_blinder(p.bz, p.omega * x); + fr_t gate = p.columns[5][j] * l + p.columns[6][j] * r + + p.columns[7][j] * l * r + p.columns[8][j] * o + p.columns[4][j]; + for (uint32_t i = 0; i < p.commitments; ++i) + gate += p.columns[12 + 2*i][j] * p.columns[13 + 2*i][j]; + fr_t left = zs * (l + p.beta * p.columns[9][j] + p.gamma) + * (r + p.beta * p.columns[10][j] + p.gamma) + * (o + p.beta * p.columns[11][j] + p.gamma); + // The permutation's three identity cosets use the domain's fixed + // multiplicative generator, not the current quotient coset shift. + fr_t id = p.beta * x; + const fr_t u = p.identity_coset; + fr_t right = z * (l + id + p.gamma) * (r + u * id + p.gamma) + * (o + u * u * id + p.gamma); + fr_t boundary = (z - fr_t::one()) * p.boundary[j]; + fr_t value = gate + p.alpha * (left - right + p.alpha * boundary); + if (p.fused) value *= p.vanishing_inverse; + uint32_t destination = __brev(static_cast(p.ratio*j + p.coset_index)) >> (32-p.log_m); + p.output[destination] = value; + x *= step; + } +} + +int error_result(ix_plonk_bn254_error* out, int code, const char* message) noexcept { + if (out) { + out->code = code; + std::snprintf(out->message, sizeof(out->message), "%s", message ? message : "CUDA failure"); + } + return code; +} + +template +int guarded(ix_plonk_bn254_context* context, ix_plonk_bn254_error* error, + Function&& function, bool cleanup = false) noexcept { + if (error) { error->code = 0; error->message[0] = 0; } + try { + require(context != nullptr, "null CUDA context"); + std::lock_guard lock(context->mutex); + context->gpu.select(); + if (!cleanup) context->ready(); + function(); + context->gpu.sync(); + if (!cleanup) context->ready(); + return 0; + } catch (const Failure& failure) { + if (context && failure.code == IX_PLONK_BN254_RUNTIME) context->poisoned = true; + if (context) (void)cudaDeviceSynchronize(); + return error_result(error, failure.code, failure.what()); + } catch (const cuda_error& failure) { + if (context) { context->poisoned = true; (void)cudaDeviceSynchronize(); } + return error_result(error, IX_PLONK_BN254_RUNTIME, failure.what()); + } catch (const std::exception& failure) { + if (context) { context->poisoned = true; (void)cudaDeviceSynchronize(); } + return error_result(error, IX_PLONK_BN254_RUNTIME, failure.what()); + } catch (...) { + if (context) { context->poisoned = true; (void)cudaDeviceSynchronize(); } + return error_result(error, IX_PLONK_BN254_RUNTIME, "unknown CUDA adapter exception"); + } +} + +Quotient& session(ix_plonk_bn254_context& context, uint64_t id) { + auto found = context.quotients.find(id); + require(found != context.quotients.end(), "unknown quotient handle"); + return *found->second; +} +} + +ix_plonk_bn254_context::~ix_plonk_bn254_context() { + gpu.select(); + (void)cudaDeviceSynchronize(); + quotients.clear(); + bases.clear(); + workspace.reset(); + transfer.reset(); + (void)cudaDeviceSynchronize(); + std::lock_guard lock(contexts_mutex); + owned_devices.erase(gpu.cid()); +} + +#ifndef __CUDA_ARCH__ +extern "C" int ix_plonk_bn254_check_field_abi(const uint64_t fq[3][4], + const uint64_t fr[3][4], ix_plonk_bn254_error* error) { + if (!fq || !fr) return error_result(error, IX_PLONK_BN254_INVALID, "null field ABI parameters"); + const fp_t fq_one = fp_t::one(); + const fr_t fr_one = fr_t::one(); + if (std::memcmp(fq[0], alt_bn128::ALT_BN128_P, 32) || std::memcmp(fq[1], &fq_one, 32) + || std::memcmp(fq[2], alt_bn128::ALT_BN128_RR, 32) + || std::memcmp(fr[0], alt_bn128::ALT_BN128_r, 32) || std::memcmp(fr[1], &fr_one, 32) + || std::memcmp(fr[2], alt_bn128::ALT_BN128_rRR, 32)) + return error_result(error, IX_PLONK_BN254_INVALID, "BN254 Montgomery ABI mismatch"); + return error_result(error, 0, ""); +} +#endif + +extern "C" int ix_plonk_bn254_ntt_parameters(uint32_t log, + ix_plonk_bn254_fr* root, ix_plonk_bn254_fr* inverse, + ix_plonk_bn254_fr* size_inverse, ix_plonk_bn254_error* error) { + if (log > 26 || !root || !inverse || !size_inverse) + return error_result(error, IX_PLONK_BN254_INVALID, "unsupported NTT parameter request"); + copy_field(root, forward_roots_of_unity[log]); + copy_field(inverse, inverse_roots_of_unity[log]); + copy_field(size_inverse, domain_size_inverse[log]); + return error_result(error, 0, ""); +} + +extern "C" int ix_plonk_bn254_context_create(const ix_plonk_bn254_config* config, + ix_plonk_bn254_context** out, ix_plonk_bn254_error* error) { + if (out) *out = nullptr; + bool claimed = false; + int ordinal = -1; + try { + require(config && out, "missing CUDA context configuration"); + require(config->abi_version == IX_PLONK_BN254_ABI_VERSION + && config->struct_bytes == sizeof(*config) + && config->msm_strategy <= IX_PLONK_BN254_MSM_MASK, + "CUDA context ABI mismatch"); + require(config->budget_bytes > NTT_BYTES, "CUDA budget is too small"); + const gpu_t* gpu = nullptr; + for (const auto* candidate : all_gpus()) + if (candidate->cid() == config->device) gpu = candidate; + if (!gpu) throw Failure(IX_PLONK_BN254_UNSUPPORTED, "no compatible CUDA device"); + ordinal = gpu->cid(); + { + std::lock_guard lock(contexts_mutex); + require(owned_devices.insert(ordinal).second, "CUDA device already has an adapter context"); + claimed = true; + } + gpu->select(); + auto context = std::make_unique(*gpu, config->budget_bytes, config->driver_headroom_bytes, config->msm_strategy); + claimed = false; + context->reserve(NTT_BYTES); + (void)NTTParameters::all(); + context->transfer = std::make_unique(); + gpu->sync(); + *out = context.release(); + return error_result(error, 0, ""); + } catch (const Failure& failure) { + if (claimed) { std::lock_guard lock(contexts_mutex); owned_devices.erase(ordinal); } + return error_result(error, failure.code, failure.what()); + } catch (const std::exception& failure) { + if (claimed) { std::lock_guard lock(contexts_mutex); owned_devices.erase(ordinal); } + return error_result(error, IX_PLONK_BN254_RUNTIME, failure.what()); + } catch (...) { + if (claimed) { std::lock_guard lock(contexts_mutex); owned_devices.erase(ordinal); } + return error_result(error, IX_PLONK_BN254_RUNTIME, "CUDA context initialization failed"); + } +} + +extern "C" int ix_plonk_bn254_context_destroy(ix_plonk_bn254_context* context, + ix_plonk_bn254_error* error) { + if (!context) return error_result(error, 0, ""); + int result = guarded(context, error, [&] { + context->cancelled.store(true); + context->quotients.clear(); context->bases.clear(); context->workspace.reset(); + }, true); + delete context; + return result; +} + +extern "C" int ix_plonk_bn254_context_cancel(ix_plonk_bn254_context* context, + ix_plonk_bn254_error* error) { + if (!context) return error_result(error, IX_PLONK_BN254_INVALID, "null CUDA context"); + context->cancelled.store(true); + return error_result(error, 0, ""); +} + +extern "C" int ix_plonk_bn254_get_stats(ix_plonk_bn254_context* context, + ix_plonk_bn254_stats* out, ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + require(out, "null CUDA statistics output"); + *out = context->stats; + out->transfer_timing_enabled = transfer_profiling() ? 1 : 0; + size_t free, total; + CUDA_OK(cudaMemGetInfo(&free, &total)); + out->driver_free_bytes = free; out->driver_total_bytes = total; + cudaMemPool_t pool; + CUDA_OK(cudaDeviceGetMemPool(&pool, context->gpu.cid())); + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrUsedMemCurrent, &out->pool_used_bytes)); + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrUsedMemHigh, &out->pool_used_high_bytes)); + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrReservedMemCurrent, &out->pool_reserved_bytes)); + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrReservedMemHigh, &out->pool_reserved_high_bytes)); + out->accounted_bytes = context->accounted; out->accounted_high_bytes = context->high; + out->budget_bytes = context->budget; + }, true); +} + +extern "C" int ix_plonk_bn254_basis_upload(ix_plonk_bn254_context* context, + const ix_plonk_bn254_affine* points, uint64_t count, + ix_plonk_bn254_basis* out, ix_plonk_bn254_error* error) { + if (out) *out = 0; + return guarded(context, error, [&] { + require(points && out && count && count <= IX_PLONK_BN254_MAX_MSM_POINTS, "invalid CUDA basis upload"); + auto basis = std::make_unique(*context, count); + context->upload(basis->points.as(), points, count * sizeof(affine_t)); + uint64_t id = context->handle(); + basis->mask_seed = mix_counter(MASK_SEED ^ id); + context->bases.emplace(id, std::move(basis)); *out = id; + }); +} + +extern "C" int ix_plonk_bn254_basis_destroy(ix_plonk_bn254_context* context, + ix_plonk_bn254_basis basis, ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + require(context->bases.erase(basis) == 1, "unknown CUDA basis handle"); + }, true); +} + +namespace { +point_t invoke_msm(ix_plonk_bn254_context& context, affine_t* points, + fr_t* scalars, uint64_t count) { + dev_ptr_t point_view(points, count); + dev_ptr_t scalar_view(scalars, count); + point_t result; + RustError status = context.workspace->msm->invoke(result, point_view, count, scalar_view, true); + if (status.code) { + std::string message = status.message ? status.message : "sppark MSM failed"; + std::free(status.message); + throw Failure(IX_PLONK_BN254_RUNTIME, message.c_str()); + } + std::free(status.message); + context.gpu.sync(); + context.ready(); + if (context.msm_strategy == IX_PLONK_BN254_MSM_SPLIT) { + const auto& statistics = context.workspace->msm->split_statistics(); + ++context.stats.split_msm_calls; + context.stats.split_unit_points += statistics.unit_points; + for (unsigned window = 0; window < 32; ++window) { + context.stats.split_max_bucket[window] = std::max(context.stats.split_max_bucket[window], statistics.max_bucket[window]); + context.stats.split_chunk_count[window] += statistics.chunk_count[window]; + context.stats.split_nonzero_digits[window] += statistics.nonzero_digits[window]; + context.stats.split_zero_digits[window] += statistics.zero_digits[window]; + } + context.stats.split_classify_ms += statistics.classify_ms; + context.stats.split_chunks_ms += statistics.chunks_ms; + context.stats.split_reduce_ms += statistics.reduce_ms; + } + if (result.is_inf()) { + fp_t zero; zero.zero(); + result = point_t(fp_t::one(), fp_t::one(), zero); + } + return result; +} + +void run_msm(ix_plonk_bn254_context* context, + ix_plonk_bn254_basis basis, uint64_t offset, + const ix_plonk_bn254_fr* scalars, uint64_t count, + ix_plonk_bn254_jacobian* out) { + require(scalars && out && count, "missing MSM operands"); + auto found = context->bases.find(basis); + require(found != context->bases.end(), "unknown CUDA basis handle"); + require(offset <= found->second->count && count <= found->second->count - offset, + "CUDA MSM range exceeds basis"); + auto plan = msm_plan(context->gpu, count, context->msm_strategy == IX_PLONK_BN254_MSM_SPLIT); + if (context->workspace && context->workspace->window != plan.window) context->workspace.reset(); + if (!context->workspace) context->workspace = std::make_unique(*context, count, plan); + Reservation temporary(*context, plan.temporary); + Buffer values(context->gpu, multiply((count + 31) & ~UINT64_C(31), 32)); + Basis& owner = *found->second; + affine_t* points = owner.points.as() + offset; + const bool masked = context->msm_strategy == IX_PLONK_BN254_MSM_MASK && count >= MASK_THRESHOLD; + const Basis::Range range{offset, count}; + const uint64_t mask_seed = mix_counter(owner.mask_seed ^ mix_counter(offset) + ^ mix_counter(count + UINT64_C(0x13198a2e03707344))); + point_t mask_sum; + if (masked) { + auto cached = owner.mask_sums.find(range); + if (cached != owner.mask_sums.end()) { + mask_sum = cached->second; + ++context->stats.mask_cache_hits; + } else { + mask_scalars<<gpu.sm_count() * 4, 256, 0, context->gpu>>>( + values.as(), count, mask_seed, false); + CUDA_OK(cudaGetLastError()); + context->gpu.sync(); + mask_sum = invoke_msm(*context, points, values.as(), count); + if (owner.mask_sums.size() >= MAX_MASK_SUMS) owner.mask_sums.clear(); + owner.mask_sums.emplace(range, mask_sum); + ++context->stats.mask_sum_calls; + } + } + context->upload(values.as(), scalars, count * 32); + if (masked) { + mask_scalars<<gpu.sm_count() * 4, 256, 0, context->gpu>>>( + values.as(), count, mask_seed, true); + CUDA_OK(cudaGetLastError()); + context->gpu.sync(); + } + point_t result = invoke_msm(*context, points, values.as(), count); + if (masked) { + mask_sum.cneg(true); + result.dadd(mask_sum); + ++context->stats.masked_msm_calls; + } + if (result.is_inf()) { + std::memset(out, 0, sizeof(*out)); + const fp_t one = fp_t::one(); + std::memcpy(&out->x, &one, sizeof(one)); + std::memcpy(&out->y, &one, sizeof(one)); + } else { + std::memcpy(out, &result, sizeof(result)); + } + ++context->stats.msm_calls; +} +} + +extern "C" int ix_plonk_bn254_msm(ix_plonk_bn254_context* context, + ix_plonk_bn254_basis basis, uint64_t offset, + const ix_plonk_bn254_fr* scalars, uint64_t count, + ix_plonk_bn254_jacobian* out, ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + run_msm(context, basis, offset, scalars, count, out); + }); +} + +extern "C" int ix_plonk_bn254_fft(ix_plonk_bn254_context* context, + ix_plonk_bn254_fr* values, uint64_t count, uint32_t inverse, + uint32_t reversed, const ix_plonk_bn254_fr* coset, + ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + require(values && coset && inverse <= 1 && reversed <= 1, "invalid FFT arguments"); + (void)log_size(count); + fr_t shift = field(*coset); + require(!shift.is_zero(), "zero FFT coset"); + Reservation reservation(*context, multiply(count, 32)); + Buffer buffer(context->gpu, reservation.bytes); + context->upload(buffer.as(), values, reservation.bytes); + transform(*context, buffer.as(), count, inverse, reversed, shift); + context->download(values, buffer.as(), reservation.bytes); + }); +} + +extern "C" int ix_plonk_bn254_quotient_begin(ix_plonk_bn254_context* context, + const ix_plonk_bn254_quotient_config* config, + ix_plonk_bn254_quotient* out, ix_plonk_bn254_error* error) { + if (out) *out = 0; + return guarded(context, error, [&] { + require(config && out && !config->reserved, "invalid quotient configuration"); + uint32_t lg_n = log_size(config->n), lg_m = log_size(config->m); + require(config->n >= 2 && config->m >= config->n && + (config->m / config->n == 4 || config->m / config->n == 8) + && config->commitments <= MAX_COMMITMENTS, "unsupported quotient dimensions"); + require((field(config->omega) - forward_roots_of_unity[lg_n]).is_zero() + && (field(config->omega_big) - forward_roots_of_unity[lg_m]).is_zero(), "quotient domain root mismatch"); + require((field(config->coset) - group_gen).is_zero(), "unsupported quotient coset multiplier"); + (void)field(config->alpha); (void)field(config->beta); (void)field(config->gamma); + for (const auto& x : config->bl) (void)field(x); + for (const auto& x : config->br) (void)field(x); + for (const auto& x : config->bo) (void)field(x); + for (const auto& x : config->bz) (void)field(x); + require(context->quotients.empty(), "one quotient session is allowed per context"); + auto quotient = std::make_unique(*context, *config); + uint64_t id = context->handle(); context->quotients.emplace(id, std::move(quotient)); *out = id; + }); +} + +extern "C" int ix_plonk_bn254_quotient_put(ix_plonk_bn254_context* context, + ix_plonk_bn254_quotient id, uint32_t slot, ix_plonk_bn254_fr* values, + uint64_t count, ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + auto& q = session(*context, id); + require(values && !q.finished && slot < q.present.size() && !q.present[slot] + && count == q.config.n, "invalid quotient polynomial slot"); + fr_t* destination = q.canonical.as() + uint64_t(slot) * count; + context->upload(destination, values, count * 32); + transform(*context, destination, count, true, false, fr_t::one()); + context->download(values, destination, count * 32); + q.present[slot] = true; + }); +} + +extern "C" int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context* context, + ix_plonk_bn254_quotient id, uint32_t fused, ix_plonk_bn254_fr* output, + uint64_t count, ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + auto& q = session(*context, id); + require(output && !q.finished && fused <= 1 && count == q.config.m + && std::all_of(q.present.begin(), q.present.end(), [](bool v) { return v; }), + "incomplete quotient session"); + NumeratorParameters p{}; + p.omega = field(q.config.omega); + p.identity_coset = field(q.config.coset); + p.alpha = field(q.config.alpha); p.beta = field(q.config.beta); p.gamma = field(q.config.gamma); + for (unsigned i = 0; i < 2; ++i) { + p.bl[i] = field(q.config.bl[i]); p.br[i] = field(q.config.br[i]); p.bo[i] = field(q.config.bo[i]); + } + for (unsigned i = 0; i < 3; ++i) p.bz[i] = field(q.config.bz[i]); + p.n = q.config.n; p.commitments = q.config.commitments; + p.ratio = static_cast(q.config.m / q.config.n); + p.log_m = log_size(q.config.m); p.fused = fused; + p.output = q.output.as(); p.boundary = q.boundary.as(); + for (uint32_t i = 0; i < q.present.size(); ++i) + p.columns[i] = q.evaluations.as() + uint64_t(i) * p.n; + fr_t coset = field(q.config.coset); + const fr_t omega_big = field(q.config.omega_big); + for (uint32_t i = 0; i < p.ratio; ++i) { + context->ready(); p.coset_index = i; p.coset = coset; + p.vanishing = (coset ^ static_cast(p.n)) - fr_t::one(); + require(!p.vanishing.is_zero(), "quotient coset intersects the base domain"); + p.vanishing_inverse = p.vanishing.reciprocal(); + for (uint32_t slot = 0; slot < q.present.size(); ++slot) { + context->ready(); + fr_t* destination = q.evaluations.as() + uint64_t(slot) * p.n; + scale(*context, destination, q.canonical.as() + uint64_t(slot) * p.n, p.n, coset); + transform(*context, destination, p.n, false, false, fr_t::one()); + } + scale(*context, q.boundary.as(), nullptr, p.n, coset, 0, true, + domain_size_inverse[log_size(p.n)]); + transform(*context, q.boundary.as(), p.n, false, false, fr_t::one()); + numerator<<gpu.sm_count() * 4, 256, 0, context->gpu>>>(p); + CUDA_OK(cudaGetLastError()); + coset *= omega_big; + } + if (fused) { + transform(*context, q.output.as(), q.config.m, true, true, field(q.config.coset)); + ++context->stats.quotient_calls; + } else { ++context->stats.numerator_calls; } + context->download(output, q.output.as(), q.config.m * 32); + q.finished = true; + }); +} + +extern "C" int ix_plonk_bn254_quotient_destroy(ix_plonk_bn254_context* context, + ix_plonk_bn254_quotient id, ix_plonk_bn254_error* error) { + return guarded(context, error, [&] { + require(context->quotients.erase(id) == 1, "unknown quotient handle"); + }, true); +} diff --git a/crates/proof-compression/cuda/plonk/runtime.cpp b/crates/proof-compression/cuda/plonk/runtime.cpp new file mode 100644 index 000000000..d9c281a3c --- /dev/null +++ b/crates/proof-compression/cuda/plonk/runtime.cpp @@ -0,0 +1,2 @@ +#define IX_KZG_CUDA_ARCHITECTURES IX_PLONK_CUDA_ARCHITECTURES +#include "../kzg_runtime.cpp" diff --git a/crates/proof-compression/cuda/plonk/sppark-source-manifest.json b/crates/proof-compression/cuda/plonk/sppark-source-manifest.json new file mode 100644 index 000000000..5487ece58 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/sppark-source-manifest.json @@ -0,0 +1,103 @@ +{ + "format": "ix-native-source-files/v1", + "revision": "114feb69ebfe029e2caa5e6c2d372c695e5d4da8", + "files_sha256": { + ".github/workflows/ci.yml": "958b3723643b22b2e933a3aed90c8c1a41dc7a26927243b1742294d02def936a", + ".gitignore": "049fd316be29cbdbabba6f0e89864522b5fac1e735ec799087643a00702c1517", + "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", + "README.md": "e5e121c13e2f7928e8c477a235552c34ebafec375f944d13255b90e60ebff9a8", + "ec/affine_t.hpp": "94dff251d793c604a78a69dd858dc24b93b940803b31c9af169843dcad283d54", + "ec/jacobian_t.hpp": "92440bd6133aaec292e2a6c32daece1d25ac40e48de5e7c15dba7b46a007214a", + "ec/xyzz_t.hpp": "bc81198ba5600f6b8a392dd56ca4a6a82dbdf8c91289701eebfc6123dc6a5960", + "ff/alt_bn128-fp2.hpp": "370be2e47a337564c3fdcb6c74fb3d6dce0de6ecbcf2516c14664292684eb4cf", + "ff/alt_bn128.hpp": "4afca223a1fc4513c2b04dcb92d96fc0e739e2d777401ffbf17a09f2d813c63a", + "ff/baby_bear.hpp": "e6dc01638d24e98dbb410158975e78a9572b41347ab612f7a759ac2276cde1fd", + "ff/batch_inversion.hpp": "f7231fea765aad21a0adfaea22212ba177b5c237da273eefc775a74e98465bca", + "ff/bls12-377-fp2.hpp": "80bc99bbf50f03b391be78a8cb6949abb64dbfa07aec07e6b98fa79a495eec12", + "ff/bls12-377.hpp": "bdfe7f8a3783a67ef3d85d4b5558e2225f7b6cdb715eb412cf0501e7d976342f", + "ff/bls12-381-fp2.hpp": "bb37be5b4bedc043bed28d8663e18592f086d62944aa65e587a86409ee310ac2", + "ff/bls12-381.hpp": "4bd85884e46a9e3361cdb8afc1a34f9deb3da873edd14432b55fbe2a1d6c27ea", + "ff/gl64_t.cuh": "b9d8b1bc45c92007ee1f646e20c1328c0a03f7012eaf155d47601188d298f9d8", + "ff/gl64_t.hip": "0e7075344a0a4d15b856977775e794d559b328d8476c2f878f132a338d2a47f8", + "ff/goldilocks.hpp": "1c2bdd5149caca4f54f41a1b196a806737a7382dae54c4d7bc3c883ddcf02c16", + "ff/mersenne31.hpp": "0fc6b3803f67296ab935bac9c32cea3cfa8f183795fb28705def6ff487e1fdc5", + "ff/mont32_t.cuh": "5acd2a306044d008a159ecf5e0efa0c6fa5f75e932d1ad7cfadc05fcb8dadd03", + "ff/mont32_t.hip": "57f31f1ebd2525510c9b2b87fa1db980a75f0333583d1ff2be4e202f35f206ff", + "ff/mont_t.cuh": "f6f89b574507a6b07dec5e5f62d4d4d705a4a3b062187d7cda9d003809b96473", + "ff/mont_t.hip": "7ee6be71e2ebcb2c597f7c512f83e804622169567fd261c1f4b05251cc099b47", + "ff/pasta.hpp": "a2ee84fff357f62ba8028ccd6c60518fdb85f4885a94b9e03774ec581ecd3a6e", + "ff/pow.hpp": "487b8c43b7c64956f5ba5a45413c218c5a1848c2b56141f643b124bddb49611f", + "ff/shfl.cuh": "16e14553266d85a2b166fa565a2298d270bcadc9627310e1efe47823fc187157", + "go.mod": "cbd0a0e0cd8ba259631f0d9b940727d78ffcdd2fe70e00a355323c2f10f66521", + "go.sum": "6836d46d5b21f0de959d406ff3e89729faba54b6447416e51589e79544085842", + "go/README.md": "045d438434306bb2f57623209b6e1b2c31a045c194b7f39192db9b008bc56498", + "go/cgo_sppark.h": "b157bef8c7b30b85e623812a9a064d4741a3ebc4331195e7b238d7878d159028", + "go/sppark.go": "b2a06fa2ea4d01bd54c163ee3c53d079bba7fc6101862070a93bd386124ced91", + "msm/batch_addition.cuh": "73bac7598a34812c194e3db65bdb976e5d7236d9d6f17093275fc6743f3b2d0a", + "msm/pippenger.cuh": "413013c734be4aabb6a5905a890e43c5f87a4e5fb3c75494a580ded8c52d5634", + "msm/pippenger.hpp": "b4763b9afa0080dab4dd8ef9cac811c9ae48c890309d94fe60ffebba3152d9e1", + "msm/sort.cuh": "75bcd3c4eea0ab19f8ada599b33a906668ed791be327764148059dcbf597ea0d", + "ntt/kernels.cu": "9450f0197a9bd236c3164b606bf5057fd561aa4214ce050db4fbaba3327f1b83", + "ntt/kernels/ct_mixed_radix_narrow.cu": "b26c51c19dbc430a191f531d2b18b4c8534dd7de3bcd8955fa7ec157d6c7ba74", + "ntt/kernels/ct_mixed_radix_wide.cu": "a05717c8404148c125f205b03b9c13c5714c63dfeb9ce1c4304cc6d7867cb5df", + "ntt/kernels/gs_mixed_radix_narrow.cu": "35e1c0fb61383ffa08636845eefae12d7f8427f95cb2a1f2bd8093a6478b57b5", + "ntt/kernels/gs_mixed_radix_wide.cu": "bb49649a792216aa65d36391439ab3bedcf81345c0b2a4664aa5b8257ff27991", + "ntt/ntt.cuh": "39e840d8d2b89eaaf09df14816b7c117a0e7b7a2a175a5bd213b878cbb589fde", + "ntt/parameters.cuh": "07db8a4915359ddba20376f13dba466458131ff2aa6d6e0b95edb9c583446d67", + "ntt/parameters/.gitattributes": "454ab81e2b8651223acfdd141bbbde0fe85c3d5fa134a5a7a1c537c43033f370", + "ntt/parameters/alt_bn128.h": "e9366761b4f585e175507d49b44b36f415f0acd4aef5900377c4529415e8133e", + "ntt/parameters/baby_bear.h": "8a956fba97f9876a05cf604cb65b8c80f9162c9a57e84c699e365e4b7fb0923d", + "ntt/parameters/bls12_377.h": "4cfad166a70580372f30b62d025fced5e1a150734846f092878d0245dcef2cae", + "ntt/parameters/bls12_381.h": "42cacbb7999ccd82cdbfba0c41fd0dc0f90641db4eea47208d5f6f63d843f2c5", + "ntt/parameters/goldilocks.h": "b8bc924f19c66be6b82e0dd35cafc9284a4db25483468aff80ea2d1e26d6e5b4", + "ntt/parameters/pallas.h": "407d061cb5133e963ccc267116a79d4706e934d9fbcb41f000496b62d7710319", + "ntt/parameters/vesta.h": "41eec6b0a2e2cfd7035f63c17e4f0a3c77ae81bf0f3968131e7c0dece6117b4d", + "poc/go/cgo_sppark.h": "05f51438f4f976729814d3fef3ca442562806ed9588dfecc09fc8b92e3b7cd00", + "poc/go/go.mod": "dc2ebdf1ee4ee7fc063f1396377bbf36e1152e3129eefccd5254e0980636dcaa", + "poc/go/go.sum": "6836d46d5b21f0de959d406ff3e89729faba54b6447416e51589e79544085842", + "poc/go/poc.cu": "b10bb95f377fcbefe3b14d1180d8b8e5012c735fe5813e90bdfec2c08e63cf7e", + "poc/go/poc.go": "432e2c7e161349e3a8b3117ffe8942cf439cfeeadc1a6c58338d727c21c87e2c", + "poc/go/poc_test.go": "87491eb03348f6e48d4eb95f150a6dd15ae69bf8bc63a740a4a15b876f7dfbf2", + "poc/msm-cuda/.gitignore": "c1e953ee360e77de57f7b02f1b7880bd6a3dc22d1a69e953c2ac2c52cc52d247", + "poc/msm-cuda/Cargo.toml": "ee4928ef151eea8876978a1668ab955ce86317d94bf308048eddd73cf9543f9e", + "poc/msm-cuda/benches/msm.rs": "c5ef1df3307dd582c07bd3742d660e23bfb1b24acac3b18dc208b9ab2fd05d90", + "poc/msm-cuda/build.rs": "ea1ea7a84c9ebb248979270a9ffacfffad4ec7e1d35761fb195b8acf6dc84177", + "poc/msm-cuda/cuda/pippenger.cu": "0a0248556ff58061ec880e275ee151c6cb06f5d8ac188def8981465507ec9bcb", + "poc/msm-cuda/cuda/pippenger_inf.cu": "1f098eb7dd7e8ca09cbb0c9c88ccc2535cc87d6f6e94bc5fd0d324a7041f3a73", + "poc/msm-cuda/rustfmt.toml": "ad860a6eb3241ca19b7d0fe82a055b42998f5513883fddd889266f2bd2117dff", + "poc/msm-cuda/src/lib.c": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "poc/msm-cuda/src/lib.rs": "db829a2158542259de90086ebf5d09c42dfc3b2f2352ddcfdb66e194e0966b49", + "poc/msm-cuda/src/util.rs": "5c1c294f16950403d6758d26f5d87ea34440a207106254907d4eded459ede67e", + "poc/msm-cuda/tests/msm.rs": "25afcaca569213df0bd01c868f067c4ee2d68c946d5e90ec5bb81939a875de4a", + "poc/ntt-cuda/Cargo.toml": "343047a5df9c46f405d150755ac8d2b3909b91cd40b748e9caa282b4b213e949", + "poc/ntt-cuda/build.rs": "ae7ff9f35c30f70f95f5f7f312a3348f9a06daae0027a91c0a24846944cd6ec2", + "poc/ntt-cuda/cuda/ntt_api.cu": "2e3da799311e94607958b810c08739660bb43ca8fe935137cdc2c609a57324c5", + "poc/ntt-cuda/go.mod": "dc2ebdf1ee4ee7fc063f1396377bbf36e1152e3129eefccd5254e0980636dcaa", + "poc/ntt-cuda/go.sum": "6836d46d5b21f0de959d406ff3e89729faba54b6447416e51589e79544085842", + "poc/ntt-cuda/go/cgo_sppark.h": "05f51438f4f976729814d3fef3ca442562806ed9588dfecc09fc8b92e3b7cd00", + "poc/ntt-cuda/go/goldilocks.go": "76eeaadf3e375eb4b531772b16219400117391c0db6ca7e79452b97452d602eb", + "poc/ntt-cuda/go/goldilocks_test.go": "04fcc405e9128c24a53e0b875defd5e38a3766ea7d5839b802708d0175910054", + "poc/ntt-cuda/src/lib.rs": "e08266af058bb265a9b8dd4a1d4cfecd8ead6934bb64f1fd45ab7017afd10594", + "poc/ntt-cuda/tests/ntt.rs": "be2a48e16b1483e7778f8cd9f9bb74b69021635ab769d425b33e30dced456f7a", + "polynomial/div_by_x_minus_z.cuh": "5d167c13dc1efec1c557d2f221347735a508de0deaf79712adbcd89e54b2eaad", + "polynomial/evaluate.cuh": "23e9d073b49ef4f2d32ea6b3e74afbae916752cf1dfb876ff172999449744fc0", + "polynomial/prefix_op.cuh": "8475309a9a16e25f39d4e5989aed894cbb8679961417b9c773a00cce1f3e1cd3", + "rust/Cargo.toml": "8ed2ee1fef3fec740cbd0a13ed05bf9e9447f71938be020386587feacd6440db", + "rust/build.rs": "46fde4e59966227b57a266b582238bf6ece073d156e4dda052e17d6607527d44", + "rust/publish.sh": "bd2942dde82b3295c93f0e478e558dc929247764a4d4be9e053b526368263587", + "rust/src/build.rs": "496017dc50d77ffe5213908a969e990783b460af3b17e2f9bcdfc1010633fb16", + "rust/src/cuda_available.cpp": "4dea40a382f19a4d633dab8e22fda1a421d89028acb014c6d4a1bdeb4b1a6930", + "rust/src/lib.rs": "e5f656dce699ad7f6da2994a3bc15f7c872722676a2bad853c6c0af2077c3d78", + "util/all_gpus.cpp": "8dd47c76cab79d9a9b1b9855f75b5ee7b2058fb1f1e6c22121fb5763d2d34ff7", + "util/cuda2hip.hpp": "63fe68f78dc6d9257c56806a6490007cd34ca9573bd8793d5945283048f87d72", + "util/cuda_available.cu": "6c98730a7eb89e9fed52c8bf645b4779dc62316f0076ed79b821ced0618d205c", + "util/cuda_timer_t.cuh": "cde2feba8421c718892fa1c48bcab2066b1a8fc2bfdaf6e5e63a34b1bbbea628", + "util/exception.cuh": "eb7c6d2c30ec9fcab3b6551fa1b93a4e7b569dbfb225edc9c4a6564a3f41fa7f", + "util/exception.hpp": "3282cdbceac712613bd016a8df52fdc0071349ebdb97b840e84da2ebfdf1733b", + "util/gpu_t.cuh": "01ae74f52cc3754641b3eed40841771d2cc3efc688840ca7990c553e06251571", + "util/rusterror.h": "5b15b12c081f617cdb22dd059852a4b961fcc552315608b2bbd6ace1e4d7b0fa", + "util/slice_t.hpp": "18fd191d1c282dfda18bf03245a3352d4267d9a2a621c19f7602130f283662e9", + "util/thread_pool_t.hpp": "e37d39e7a7c4e58d8cd856bd082af70d8b3793618a9b8d89a824321dd58fbb43", + "util/vec2d_t.hpp": "d24662ca34ec438e854a9d0cc0b2e591feaf2c58ea17babb36fe6d1239ce5f41" + } +} diff --git a/crates/proof-compression/cuda/plonk/sppark_namespace.h b/crates/proof-compression/cuda/plonk/sppark_namespace.h new file mode 100644 index 000000000..de9dbe983 --- /dev/null +++ b/crates/proof-compression/cuda/plonk/sppark_namespace.h @@ -0,0 +1,23 @@ +#pragma once + +// Keep the standalone BN254 registry and NTT singletons separate from other +// sppark instantiations that may be linked into the same process. +#define gpu_t ix_plonk_native_gpu_t +#define stream_t ix_plonk_native_stream_t +#define event_t ix_plonk_native_event_t +#define gpu_ptr_t ix_plonk_native_gpu_ptr_t +#define dev_ptr_t ix_plonk_native_dev_ptr_t +#define select_gpu ix_plonk_native_select_gpu +#define gpu_props ix_plonk_native_gpu_props +#define all_gpus ix_plonk_native_all_gpus +#define ngpus ix_plonk_native_ngpus +#define cuda_available ix_plonk_native_cuda_available +#define drop_gpu_ptr_t ix_plonk_native_drop_gpu_ptr_t +#define clone_gpu_ptr_t ix_plonk_native_clone_gpu_ptr_t +#define drop_error_message ix_plonk_native_drop_error_message +#define sppark_error ix_plonk_native_sppark_error +#define cuda_error ix_plonk_native_cuda_error +#define NTT IxPlonkNativeNTT +#define NTTParameters IxPlonkNativeNTTParameters +#define CT_launcher IxPlonkNativeCTLauncher +#define GS_launcher IxPlonkNativeGSLauncher diff --git a/crates/proof-compression/examples/kzg_backend_parity.rs b/crates/proof-compression/examples/kzg_backend_parity.rs new file mode 100644 index 000000000..6f834fbd0 --- /dev/null +++ b/crates/proof-compression/examples/kzg_backend_parity.rs @@ -0,0 +1,266 @@ +//! Deterministic development proofs for comparing independently built backends. + +use ix_proof_compression::kzg::{ + KzgCommitment, KzgConfig, Scalar, Srs, VerifierParams, + compact::FixedProofCodec, curve, +}; +use multi_stark::{ + config::ProofConfig, + expr::Expr, + lookup::Lookup, + system::{Circuit, CircuitInputs, System, SystemWitness}, + traits::{Algebra, Field}, +}; +use p3_matrix::dense::RowMajorMatrix; +use serde::{Deserialize, Serialize}; +use std::{fs, path::Path, sync::Arc, time::Instant}; + +type Result = std::result::Result>; +const HEIGHTS: [usize; 2] = [4096, 8192]; +const QUOTIENT: usize = 4; +const SEED: &[u8] = b"ix-kzg-backend-parity/development/v1"; + +#[derive(Serialize, Deserialize)] +struct Verifier { + curve: String, + parameters: Vec, + circuits: Vec>, + fixed: Option, + indices: Vec>, +} + +fn claims() -> [[Scalar; 3]; 2] { + HEIGHTS.map(|height| { + let x = Scalar::from_usize(height - 1); + [Scalar::from_usize(height), x, x * x] + }) +} + +fn circuit(height: usize) -> CircuitInputs { + let x = Expr::main(0); + let square = Expr::main(1); + let transition_message = + vec![x.clone(), Expr::main_next(0), Expr::preprocessed_next(0)]; + let local_message = vec![Expr::preprocessed(0), square.clone()]; + // Lookup multiplicities need Boolean trace values; the transition selector + // is not normalized to one away from the last row. + let active = Expr::constant(Scalar::ONE) - Expr::preprocessed(1); + CircuitInputs { + main_width: 2, + preprocessed: Some(RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + [Scalar::from_usize(row), Scalar::from_bool(row + 1 == height)] + }) + .collect(), + 2, + )), + constraints: vec![ + x.clone() - Expr::preprocessed(0), + square.clone() - x.clone() * x.clone(), + x.clone() + * x.clone() + * x.clone() + * (square.clone() - x.clone() * x.clone()), + Expr::IsTransition + * (Expr::main_next(0) - x.clone() - Expr::constant(Scalar::ONE)), + ], + lookups: vec![ + Lookup::pull( + Expr::preprocessed(1), + vec![Expr::constant(Scalar::from_usize(height)), x, square], + ), + Lookup::push(Expr::constant(Scalar::ONE), transition_message.clone()), + Lookup::pull(Expr::constant(Scalar::ONE), transition_message), + Lookup::push(active.clone(), local_message.clone()), + Lookup::pull(active, local_message), + ], + lookup_group_size: 2, + ..Default::default() + } +} + +fn configure(mode: &str) -> Result<()> { + #[cfg(feature = "cuda")] + { + use ix_proof_compression::kzg::cuda::{self, PassMode, Settings}; + let (passes, srs_cache) = match mode { + "dense" => (PassMode::Off, false), + "passes" => (PassMode::Force, false), + "cached" => (PassMode::Off, true), + _ => { + return Err("CUDA builds require dense, passes or cached mode".into()); + }, + }; + if !cuda::configure(Settings { + devices: Some(vec![0]), + msm_chunk_points: 2048, + passes, + srs_cache, + ..Settings::default() + }) { + return Err("CUDA settings already installed".into()); + } + } + #[cfg(not(feature = "cuda"))] + if mode != "cpu" { + return Err("CPU builds require cpu mode".into()); + } + Ok(()) +} + +fn prove(out: &Path, mode: &str) -> Result<()> { + configure(mode)?; + if out.exists() && fs::read_dir(out)?.next().is_some() { + return Err("output directory must be empty".into()); + } + let started = Instant::now(); + let srs = Arc::new(Srs::unsafe_dev_setup(HEIGHTS[1], SEED)); + let config = KzgConfig::new(srs, QUOTIENT) + .with_streaming_lookups() + .with_streaming_quotient(); + let (mut system, key) = System::new(config, HEIGHTS.map(circuit)); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|c| c.as_slice()).collect(); + let traces: Vec<_> = HEIGHTS + .into_iter() + .map(|height| { + RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + let x = Scalar::from_usize(row); + [x, x * x] + }) + .collect(), + 2, + ) + }) + .collect(); + let input = bincode::serde::encode_to_vec( + (&claims, &traces), + bincode::config::standard(), + )?; + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("new proof: {e:?}"))?; + let parameters = system.config.pcs().verifier_params().to_bytes()?; + system.config = KzgConfig::verifier_only( + VerifierParams::from_bytes(¶meters)?, + QUOTIENT, + ); + let codec = FixedProofCodec::new(&system, &proof.log_degrees)?; + let bytes = codec.encode(&proof)?; + system + .verify_multiple_claims(&refs, &codec.decode(&bytes)?) + .map_err(|e| format!("compact proof: {e:?}"))?; + for circuit in &mut system.circuits { + circuit.preprocessed = None; + } + let verifier = bincode::serde::encode_to_vec( + Verifier { + curve: curve::NAME.to_owned(), + parameters, + circuits: system.circuits, + fixed: system.preprocessed_commit, + indices: system.preprocessed_indices, + }, + bincode::config::standard(), + )?; + let report = serde_json::json!({ + "format": "ix-kzg-backend-parity/v1", + "curve": curve::NAME, + "mode": mode, + "known_trapdoor": true, + "cuda": cfg!(feature = "cuda"), + "trace_heights": HEIGHTS, + "quotient_factor_cap": QUOTIENT, + "input_blake3": blake3::hash(&input).to_hex().as_str(), + "proof_blake3": blake3::hash(&bytes).to_hex().as_str(), + "verifier_blake3": blake3::hash(&verifier).to_hex().as_str(), + "proof_bytes": bytes.len(), + "opening_points": proof.opening_proof.0.len(), + "native_verification_passed": true, + "elapsed_seconds": started.elapsed().as_secs_f64(), + "scope": "Two ordinary KZG circuits; excludes FRI compression and recursive KZG proving.", + }); + fs::create_dir_all(out)?; + fs::write(out.join("proof.bin"), &bytes)?; + fs::write(out.join("verifier.bin"), &verifier)?; + fs::write(out.join("report.json"), serde_json::to_vec_pretty(&report)?)?; + println!("{}", serde_json::to_string(&report)?); + Ok(()) +} + +fn verify(proof: &Path, verifier: &Path, trusted: &str) -> Result<()> { + let verifier = fs::read(verifier)?; + if blake3::hash(&verifier).to_hex().as_str() != trusted { + return Err("verifier differs from the independently supplied pin".into()); + } + let (key, used): (Verifier, usize) = bincode::serde::decode_from_slice( + &verifier, + bincode::config::standard().with_limit::<16777216>(), + )?; + if used != verifier.len() || key.curve != curve::NAME { + return Err("invalid verifier curve or encoding".into()); + } + let system = System { + config: KzgConfig::verifier_only( + VerifierParams::from_bytes(&key.parameters)?, + QUOTIENT, + ), + circuits: key.circuits, + preprocessed_commit: key.fixed, + preprocessed_indices: key.indices, + }; + let logs = HEIGHTS.map(|h| u8::try_from(h.ilog2()).expect("fixture height")); + let codec = FixedProofCodec::new(&system, &logs)?; + let bytes = fs::read(proof)?; + let proof = codec.decode(&bytes)?; + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|c| c.as_slice()).collect(); + system + .verify_multiple_claims(&refs, &proof) + .map_err(|e| format!("proof: {e:?}"))?; + let mut wrong = claims; + wrong[0][2] += Scalar::ONE; + let wrong: Vec<_> = wrong.iter().map(|c| c.as_slice()).collect(); + if system.verify_multiple_claims(&wrong, &proof).is_ok() { + return Err("changed claim accepted".into()); + } + let mut changed = proof.clone(); + changed.stage_1_opened_values[0][0][0] += Scalar::ONE; + if system.verify_multiple_claims(&refs, &changed).is_ok() { + return Err("changed evaluation accepted".into()); + } + println!( + "{}", + serde_json::json!({ + "curve": curve::NAME, "verified": true, "proof_blake3": blake3::hash(&bytes).to_hex().as_str(), + "changed_claim_rejected": true, "changed_evaluation_rejected": true, "proving_srs_loaded": false, + }) + ); + Ok(()) +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_ansi(false) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else( + |_| tracing_subscriber::EnvFilter::new("ix_proof_compression=debug"), + ), + ) + .init(); + let args: Vec<_> = std::env::args().skip(1).collect(); + match args.iter().map(String::as_str).collect::>().as_slice() { + ["prove", out, mode] => prove(Path::new(out), mode), + ["verify", proof, verifier, trusted] => verify(Path::new(proof), Path::new(verifier), trusted), + _ => Err("usage: kzg_backend_parity prove | verify ".into()), + } +} diff --git a/crates/proof-compression/examples/kzg_dev_srs.rs b/crates/proof-compression/examples/kzg_dev_srs.rs new file mode 100644 index 000000000..0c80663ff --- /dev/null +++ b/crates/proof-compression/examples/kzg_dev_srs.rs @@ -0,0 +1,40 @@ +//! Prepare deterministic development parameters separately from proof timings. + +use ix_proof_compression::kzg::{Scalar, Srs, curve}; +use multi_stark::traits::TwoAdicField; +use std::{path::Path, time::Instant}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let [directory, log, seed] = args.as_slice() else { + return Err( + "usage: kzg_dev_srs ".into(), + ); + }; + let log: usize = log.parse()?; + if !(1..=Scalar::TWO_ADICITY).contains(&log) { + return Err("unsupported SRS size".into()); + } + let count = + 1usize.checked_shl(u32::try_from(log)?).ok_or("SRS size overflow")?; + let started = Instant::now(); + let srs = Srs::unsafe_dev_setup_prefix_in_cache_directory( + count, + count - 1, + seed.as_bytes(), + Path::new(directory), + )?; + println!( + "{}", + serde_json::json!({ + "curve": curve::NAME, + "known_trapdoor": true, + "powers": count, + "seed": seed, + "public_max_degree": srs.public_setup.max_degree, + "public_setup_id": blake3::Hash::from_bytes(srs.public_setup.id).to_hex().as_str(), + "elapsed_seconds": started.elapsed().as_secs_f64(), + }) + ); + Ok(()) +} diff --git a/crates/proof-compression/examples/kzg_ignition_import.rs b/crates/proof-compression/examples/kzg_ignition_import.rs new file mode 100644 index 000000000..101373567 --- /dev/null +++ b/crates/proof-compression/examples/kzg_ignition_import.rs @@ -0,0 +1,188 @@ +//! Inspect, import and authenticate a local Ignition SRS prefix. + +use ix_proof_compression::kzg::srs::ignition::{ + FINAL_ADDRESS, FINAL_POSITION, IGNITION_MANIFEST_URL, IgnitionAdmission, + MAX_G1_POINTS, MAX_PREFIX_LEN, PREDECESSOR_ADDRESS, PREDECESSOR_POSITION, + SHIFTED_TAU_BASIS, import_ignition_transcripts, inspect_ignition_source, + load_ignition_cache, +}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::Path, + time::Instant, +}; + +type Result = std::result::Result>; + +const USAGE: &str = "usage: kzg_ignition_import info\n\ + kzg_ignition_import inspect \n\ + kzg_ignition_import import \n\ + kzg_ignition_import admission \n\ + kzg_ignition_import verify "; + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn digest(text: &str) -> Result<[u8; 32]> { + if text.len() != 64 || !text.is_ascii() { + return Err( + "manifest digest must contain 64 hexadecimal characters".into(), + ); + } + let mut digest = [0; 32]; + for (i, byte) in digest.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; + } + Ok(digest) +} + +fn prefix(text: &str) -> Result { + let log: u32 = text.parse()?; + if !(1..=MAX_PREFIX_LEN.ilog2()).contains(&log) { + return Err("prefix log must be in 1..=26".into()); + } + Ok(1usize << log) +} + +fn print(report: &serde_json::Value) -> Result<()> { + println!("{}", serde_json::to_string_pretty(report)?); + Ok(()) +} + +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let start = Instant::now(); + match args.iter().map(String::as_str).collect::>().as_slice() { + ["info"] => print(&serde_json::json!({ + "curve": "bn254", + "ceremony_manifest_url": IGNITION_MANIFEST_URL, + "basis": SHIFTED_TAU_BASIS, + "available_g1_entries": MAX_G1_POINTS, + "public_max_degree": MAX_G1_POINTS - 1, + "max_prefix_len": MAX_PREFIX_LEN, + "contribution": {"position": FINAL_POSITION, "address": FINAL_ADDRESS}, + "predecessor": {"position": PREDECESSOR_POSITION, "address": PREDECESSOR_ADDRESS}, + "requires_independently_trusted_source_manifest": true, + })), + ["inspect", source, destination] => { + let destination = Path::new(destination); + if destination.try_exists()? { + return Err("candidate manifest destination already exists".into()); + } + eprintln!( + "Hashing complete local contributions 196 and 197; output is an untrusted candidate, not ceremony authentication." + ); + let manifest = inspect_ignition_source( + Path::new(source), + FINAL_POSITION, + FINAL_ADDRESS, + Some((PREDECESSOR_POSITION, PREDECESSOR_ADDRESS)), + )?; + let mut bytes = serde_json::to_vec_pretty(&manifest)?; + bytes.push(b'\n'); + if let Some(parent) = + destination.parent().filter(|p| !p.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let mut file = + OpenOptions::new().write(true).create_new(true).open(destination)?; + file.write_all(&bytes)?; + file.sync_all()?; + print(&serde_json::json!({ + "candidate_manifest": destination, + "candidate_manifest_blake3": blake3::hash(&bytes).to_hex().as_str(), + "source_provenance_authenticated": false, + "scope": "Observed file hashes and internal framing/checksums only. Independently authenticate the ceremony source before trusting this manifest digest.", + "elapsed_seconds": start.elapsed().as_secs_f64(), + })) + }, + ["import", source, manifest, trusted, destination, log] => { + let prefix_len = prefix(log)?; + let trusted = digest(trusted)?; + eprintln!( + "Authenticating complete source files and validating {prefix_len} retained powers per selected contribution; the destination must be new." + ); + let receipt = import_ignition_transcripts( + Path::new(source), + Path::new(manifest), + trusted, + Path::new(destination), + prefix_len, + )?; + print(&serde_json::json!({ + "cache": destination, + "prefix_len": receipt.prefix_len, + "cache_manifest_blake3": hex(&receipt.digest), + "source_manifest_blake3": hex(&receipt.source_manifest_digest), + "setup_id": hex(&receipt.public_setup.id), + "public_max_degree": receipt.public_setup.max_degree, + "basis": SHIFTED_TAU_BASIS, + "scope": "Source bytes authenticated against the caller's pinned manifest and retained powers validated; no participant-signature or full ceremony replay.", + "elapsed_seconds": start.elapsed().as_secs_f64(), + }))?; + eprintln!( + "Retain cache_manifest_blake3 in trusted configuration separately from the cache." + ); + Ok(()) + }, + ["verify", cache, trusted, log] => { + let srs = + load_ignition_cache(Path::new(cache), digest(trusted)?, prefix(log)?)?; + print(&serde_json::json!({ + "cache_authenticated": true, + "loaded_prefix_len": srs.max_len(), + "public_max_degree": srs.public_setup.max_degree, + "setup_id": hex(&srs.public_setup.id), + "basis": SHIFTED_TAU_BASIS, + "elapsed_seconds": start.elapsed().as_secs_f64(), + })) + }, + [ + "admission", + cache, + trusted_cache, + source, + trusted_source, + destination, + ] => { + let destination = Path::new(destination); + if destination.try_exists()? { + return Err("admission destination already exists".into()); + } + let admission = IgnitionAdmission::from_cache( + Path::new(cache), + digest(trusted_cache)?, + Path::new(source), + digest(trusted_source)?, + )?; + let bytes = admission.to_bytes()?; + if let Some(parent) = + destination.parent().filter(|p| !p.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let mut file = + OpenOptions::new().write(true).create_new(true).open(destination)?; + file.write_all(&bytes)?; + file.sync_all()?; + print(&serde_json::json!({ + "admission": destination, + "admission_blake3": hex(blake3::hash(&bytes).as_bytes()), + "source_manifest_blake3": hex(&admission.source_manifest_digest()), + "setup_id": hex(&admission.public_setup().id), + "public_max_degree": admission.public_setup().max_degree, + "basis": SHIFTED_TAU_BASIS, + "scope": "Portable authorization from the caller's trusted source and cache pins. The admission pin must be configured separately; no participant-signature or full ceremony replay.", + "elapsed_seconds": start.elapsed().as_secs_f64(), + }))?; + eprintln!( + "Retain admission_blake3 in trusted verifier configuration separately from the admission and proof." + ); + Ok(()) + }, + _ => Err(USAGE.into()), + } +} diff --git a/crates/proof-compression/examples/kzg_stage_resources.rs b/crates/proof-compression/examples/kzg_stage_resources.rs new file mode 100644 index 000000000..40d5e10d2 --- /dev/null +++ b/crates/proof-compression/examples/kzg_stage_resources.rs @@ -0,0 +1,133 @@ +//! Inspect saved stage metadata without loading coefficient matrices or an SRS. + +use ix_proof_compression::kzg::{KzgCommitment, Scalar, curve}; +use multi_stark::{ + expr::Source, + graph::{ConstraintGraph, Node, NodeId}, + system::Circuit, +}; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::{collections::BTreeSet, fs, path::Path}; + +type Result = std::result::Result>; + +fn decode(path: &Path) -> Result { + let bytes = fs::read(path)?; + let (value, used) = bincode::serde::decode_from_slice( + &bytes, + bincode::config::standard().with_limit::<16777216>(), + )?; + if used != bytes.len() { + return Err("trailing metadata bytes".into()); + } + Ok(value) +} + +fn columns( + graph: &ConstraintGraph, + roots: impl IntoIterator, +) -> BTreeSet<(u8, usize)> { + let mut result = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<_> = roots.into_iter().collect(); + while let Some(id) = pending.pop() { + if !visited.insert(id.index()) { + continue; + } + match graph.nodes[id.index()] { + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { + pending.extend([a, b]) + }, + Node::Neg(a) => pending.push(a), + Node::Var(column) => { + let source = match column.source { + Source::Preprocessed => 0, + Source::Main => 1, + Source::Stage2 => 2, + }; + result.insert((source, column.index as usize)); + }, + _ => {}, + } + } + result +} + +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let [stage] = args.as_slice() else { + return Err("usage: kzg_stage_resources ".into()); + }; + let stage = Path::new(stage); + let (widths, heights, _claims): (Vec, Vec, Vec>) = + decode(&stage.join("manifest.bin"))?; + let mut circuits = Vec::new(); + let mut scalar_bytes = 0usize; + for (i, (&width, &height)) in widths.iter().zip(&heights).enumerate() { + let (circuit, _): (Circuit, KzgCommitment) = + decode(&stage.join(format!("kzg/setup-{i}.bin")))?; + if width != circuit.main_width || height != circuit.preprocessed_height { + return Err("manifest/setup mismatch".into()); + } + let group_size = circuit.lookup_group_size.max(1); + let groups = circuit.stage_2_width; + let ratio = circuit.quotient_degree(); + let graph = &circuit.graph; + let mut lookup_columns = 0; + let mut quotient_columns = graph + .zeros + .iter() + .map(|&root| columns(graph, [root]).len()) + .max() + .unwrap_or(0); + for (group, lookups) in graph.lookups.chunks(group_size).enumerate() { + let mut used = columns( + graph, + lookups.iter().flat_map(|lookup| { + std::iter::once(lookup.multiplicity) + .chain(lookup.args.iter().copied()) + }), + ); + lookup_columns = lookup_columns.max(used.len()); + used.insert((2, group)); + used.insert((2, if group + 1 == groups { 0 } else { group + 1 })); + quotient_columns = quotient_columns.max(used.len()); + } + let all = circuit.preprocessed_width + circuit.main_width + groups; + let bytes_per_column = height * size_of::(); + scalar_bytes += bytes_per_column * (all + ratio); + // Treat every source column as varying and every graph node as a live + // scratch slot. Runtime constant folding and liveness can reduce this bound. + let overhead = (1usize << 30) + + 256 * 128 * graph.nodes.len().max(1) * size_of::() + + (graph.nodes.len() + + graph.lookups.len() + + all + + circuit.num_publics + + 32) + * 1024; + circuits.push(json!({ + "index": i, "rows": height, "main_columns": width, + "fixed_columns": circuit.preprocessed_width, "lookup_columns": groups, + "quotient_factor": ratio, "graph_nodes": graph.nodes.len(), + "lookup_count": graph.lookups.len(), "lookup_group_size": group_size, + "max_lookup_pass_columns": lookup_columns, "max_quotient_pass_columns": quotient_columns, + "dense_lookup_bytes_upper": bytes_per_column * (all - groups + 4 * groups) + overhead, + "pass_lookup_bytes_upper": bytes_per_column * (lookup_columns + groups + 3) + overhead, + "dense_quotient_bytes_upper": bytes_per_column * (all + ratio + 1) + overhead, + "pass_quotient_bytes_upper": bytes_per_column * (quotient_columns + ratio + 1) + overhead, + })); + } + let max_height = heights.iter().copied().max().ok_or("empty stage")?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "curve": curve::NAME, "stage": stage, "circuits": circuits, + "scalar_column_bytes": scalar_bytes, + "srs_host_bytes": max_height * size_of::(), + "scope": "Conservative column and GPU-pass accounting from metadata. Excludes frontend/witness memory, host transient copies, CUDA retained coefficients, SRS caches and MSM workspace. No proving arrays, SRS or CUDA context are allocated.", + }))? + ); + Ok(()) +} diff --git a/crates/proof-compression/examples/recursive_size.rs b/crates/proof-compression/examples/recursive_size.rs new file mode 100644 index 000000000..b7b139a67 --- /dev/null +++ b/crates/proof-compression/examples/recursive_size.rs @@ -0,0 +1,174 @@ +//! Count the recursive frontend for a four-row development KZG proof. + +use ix_proof_compression::{ + kzg::{KzgConfig, Scalar, Srs, compact::FixedProofCodec}, + recursive::native_verifier::{self, Profile}, +}; +use multi_stark::{ + config::ProofConfig, + expr::Expr, + lookup::Lookup, + plonkish::CircuitStats, + system::{CircuitInputs, System, SystemWitness}, + traits::{Algebra, Field}, +}; +use p3_matrix::dense::RowMajorMatrix; +use serde_json::{Value, json}; +use std::{io::Write, sync::Arc}; + +fn counters(stats: CircuitStats) -> [(&'static str, usize); 10] { + [ + ("hash_calls", stats.hash_calls), + ("hash_compressions", stats.hash_compressions), + ("values", stats.values), + ("inputs", stats.inputs), + ("gates", stats.gates), + ("lookups", stats.lookups), + ("tables", stats.tables), + ("publics", stats.publics), + ("hint_calls", stats.hint_calls), + ("hint_outputs", stats.hint_outputs), + ] +} + +fn stats_json(stats: CircuitStats, previous: CircuitStats) -> Value { + Value::Object( + counters(stats) + .into_iter() + .zip(counters(previous)) + .map(|((name, value), (_, before))| { + ( + name.to_owned(), + json!(value.checked_sub(before).expect("cumulative frontend count")), + ) + }) + .collect(), + ) +} + +fn opening_values(matrices: &[Vec>]) -> usize { + matrices.iter().flatten().map(Vec::len).sum() +} + +fn main() -> Result<(), Box> { + let srs = + Arc::new(Srs::unsafe_dev_setup(4, b"recursive-size-development-only")); + let trace = RowMajorMatrix::new( + (2..=5u64) + .flat_map(|x| [Scalar::from_u64(x), Scalar::from_u64(x * x)]) + .collect(), + 2, + ); + let last_row = Expr::preprocessed(0); + let x = Expr::main(0); + let square = Expr::main(1); + let one = Expr::constant(Scalar::ONE); + let (system, key) = System::new( + KzgConfig::new(srs.clone(), 2), + [CircuitInputs { + main_width: 2, + preprocessed: Some(RowMajorMatrix::new_col(vec![ + Scalar::ZERO, + Scalar::ZERO, + Scalar::ZERO, + Scalar::ONE, + ])), + constraints: vec![ + square.clone() - x.clone() * x.clone(), + (one.clone() - last_row.clone()) + * (Expr::main_next(0) - x.clone() - one), + ], + lookups: vec![Lookup::pull( + last_row, + vec![Expr::constant(Scalar::from_u8(93)), x, square], + )], + ..Default::default() + }], + ); + let claims = + vec![vec![Scalar::from_u8(93), Scalar::from_u8(5), Scalar::from_u8(25)]]; + let claim_refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); + let proof = system.prove_multiple_claims( + &key, + &claim_refs, + SystemWitness::from_stage_1(vec![trace], &system), + ); + system + .verify_multiple_claims(&claim_refs, &proof) + .map_err(|error| format!("inner KZG verification failed: {error:?}"))?; + let fixed = + system.preprocessed_commit.as_ref().ok_or("missing fixed commitment")?; + let profile = Profile { + circuits: &system.circuits, + fixed, + transcript_seed: system.config.transcript_seed(), + transcript_kind: system.config.transcript_kind(), + generator: srs.g1[0], + g2: srs.g2, + tau_g2: srs.tau_g2, + max_log_degree: system.config.max_log_degree(), + }; + let counts = + native_verifier::count(&profile, &proof, &claims, &[(0, 1), (0, 2)])?; + let compact = + FixedProofCodec::new(&system, &proof.log_degrees)?.encode(&proof)?; + let mut previous = CircuitStats::default(); + let phases: Vec<_> = counts + .phases + .iter() + .map(|phase| { + let result = json!({ + "name": phase.name, + "incremental": stats_json(phase.stats, previous), + "cumulative": stats_json(phase.stats, CircuitStats::default()), + }); + previous = phase.stats; + result + }) + .collect(); + let tables: Vec<_> = counts + .table_dimensions + .iter() + .map(|&(rows, width)| json!({ "rows": rows, "width": width })) + .collect(); + let report = json!({ + "curve": if cfg!(feature = "bn254") { "bn254" } else { "bls12-381" }, + "scope": "Four-row development fixture; frontend gates and lookups, not gnark PLONK constraints or production performance.", + "recursive_proof_generated": false, + "frontend": { + "total": stats_json(counts.stats, CircuitStats::default()), + "phases": phases, + "tables": tables, + "msm_terms": counts.msm_terms, + }, + "inner": { + "rows": 4, + "main_columns": 2, + "fixed_columns": 1, + "claims": [[93, 5, 25]], + "public_locations": [[0, 1], [0, 2]], + "max_quotient_degree": 2, + "development_srs_powers": srs.g1.len(), + "log_degrees": proof.log_degrees, + "commitments": { + "fixed": fixed.0.iter().map(Vec::len).sum::(), + "main": proof.commitments.stage_1_trace.0.iter().map(Vec::len).sum::(), + "stage2": proof.commitments.stage_2_trace.0.iter().map(Vec::len).sum::(), + "quotient": proof.commitments.quotient_chunks.0.iter().map(Vec::len).sum::(), + }, + "opening_values": { + "fixed": opening_values(proof.preprocessed_opened_values.as_deref().unwrap_or_default()), + "main": opening_values(&proof.stage_1_opened_values), + "stage2": opening_values(&proof.stage_2_opened_values), + "quotient": opening_values(&proof.quotient_opened_values), + }, + "opening_proof_points": proof.opening_proof.0.len(), + "compact_bytes": compact.len(), + "native_verification_passed": true, + }, + }); + let mut output = std::io::stdout().lock(); + serde_json::to_writer_pretty(&mut output, &report)?; + writeln!(output)?; + Ok(()) +} diff --git a/crates/proof-compression/src/bin/ix_compression_worker.rs b/crates/proof-compression/src/bin/ix_compression_worker.rs index 1dfa2eae5..a90495b06 100644 --- a/crates/proof-compression/src/bin/ix_compression_worker.rs +++ b/crates/proof-compression/src/bin/ix_compression_worker.rs @@ -9,7 +9,9 @@ use tracing_subscriber::{ fn main() -> Result<(), Box> { let args: Vec = std::env::args().skip(1).collect(); let Some((stage, rest)) = args.split_first() else { - return Err("usage: ix-compression-worker ".into()); + return Err( + "usage: ix-compression-worker ".into(), + ); }; let texray = std::env::var("IX_TEXRAY").as_deref() == Ok("1"); let timing = texray.then(|| { @@ -41,6 +43,7 @@ fn main() -> Result<(), Box> { "fri" => tracing::info_span!("compression/fri"), "kzg" => tracing::info_span!("compression/kzg"), "wrap" => tracing::info_span!("compression/wrap"), + "pipeline" => tracing::info_span!("compression/pipeline"), #[cfg(feature = "plonk")] "plonk" => tracing::info_span!("compression/plonk"), _ => return Err("unknown compression stage".into()), diff --git a/crates/proof-compression/src/foreign.rs b/crates/proof-compression/src/foreign.rs index eba28cdb4..ccde5bb63 100644 --- a/crates/proof-compression/src/foreign.rs +++ b/crates/proof-compression/src/foreign.rs @@ -1,4 +1,4 @@ -//! Goldilocks constraints over the BLS12-381 scalar field. +//! Goldilocks constraints over the selected KZG curve's scalar field. //! Each modular relation has a bounded quotient; small integer relations lift directly. use num_bigint::{BigInt, BigUint, Sign}; @@ -92,7 +92,7 @@ fn tune_kzg_input( karatsuba: false, }) .expect("valid lowering"); - let commitment_bytes = 48; + let commitment_bytes = crate::kzg::curve::G1_COMPRESSED_BYTES; let best = (1..=MAX_LOOKUP_GROUP) .filter_map(|group| { let degree = graph diff --git a/crates/proof-compression/src/kzg/coefficients.rs b/crates/proof-compression/src/kzg/coefficients.rs index 88ede3474..2b2a4becc 100644 --- a/crates/proof-compression/src/kzg/coefficients.rs +++ b/crates/proof-compression/src/kzg/coefficients.rs @@ -7,7 +7,7 @@ use std::{ sync::{Arc, Mutex}, }; -use ark_bls12_381::Fr; +use super::curve::Fr; use ark_ff::Zero; use ark_serialize::{CanonicalDeserialize, SerializationError}; use p3_maybe_rayon::prelude::*; diff --git a/crates/proof-compression/src/kzg/compact.rs b/crates/proof-compression/src/kzg/compact.rs index 60b250e1c..bae56f071 100644 --- a/crates/proof-compression/src/kzg/compact.rs +++ b/crates/proof-compression/src/kzg/compact.rs @@ -4,7 +4,7 @@ #[cfg(test)] use crate::foreign::KzgCircuitExt; -use ark_bls12_381::G1Affine; +use super::curve::{Fr, G1_COMPRESSED_BYTES, G1Affine, read_g1_compressed}; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use super::KzgCommitment; @@ -17,6 +17,11 @@ use multi_stark::prover::Proof; use multi_stark::system::System; use multi_stark::traits::Algebra; +#[cfg(not(feature = "bn254"))] +const MAGIC: &[u8; 4] = b"KQP4"; +#[cfg(feature = "bn254")] +const MAGIC: &[u8; 4] = b"KBN1"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CompactProofError; @@ -122,8 +127,8 @@ impl FixedProofCodec { .as_ref() .map(|_| prep), }; - let body_bytes = - points(&template).count() * 48 + fields(&template).count() * 32; + let body_bytes = points(&template).count() * G1_COMPRESSED_BYTES + + fields(&template).count() * 32; Ok(Self { template, max_openings: heights.len() + 1, body_bytes }) } @@ -140,7 +145,7 @@ impl FixedProofCodec { { return Err(CompactProofError); } - let mut bytes = b"KQP4".to_vec(); + let mut bytes = MAGIC.to_vec(); bytes.push( u8::try_from(proof.opening_proof.0.len()) .map_err(|_error| CompactProofError)?, @@ -163,13 +168,13 @@ impl FixedProofCodec { &self, bytes: &[u8], ) -> Result, CompactProofError> { - if bytes.len() < 5 || &bytes[..4] != b"KQP4" { + if bytes.len() < 5 || &bytes[..4] != MAGIC { return Err(CompactProofError); } let openings = usize::from(bytes[4]); if openings == 0 || openings > self.max_openings - || bytes.len() != 5 + self.body_bytes + 48 * openings + || bytes.len() != 5 + self.body_bytes + G1_COMPRESSED_BYTES * openings { return Err(CompactProofError); } @@ -182,13 +187,13 @@ impl FixedProofCodec { &mut proof.commitments.quotient_chunks, ] { for point in commitment.0.iter_mut().flatten() { - *point = G1Affine::deserialize_compressed(&mut input) - .map_err(|_error| CompactProofError)?; + *point = + read_g1_compressed(&mut input).map_err(|_error| CompactProofError)?; } } for point in &mut proof.opening_proof.0 { - *point = G1Affine::deserialize_compressed(&mut input) - .map_err(|_error| CompactProofError)?; + *point = + read_g1_compressed(&mut input).map_err(|_error| CompactProofError)?; } let accumulator_count = proof.intermediate_accumulators.len() - 1; for value in @@ -205,7 +210,7 @@ impl FixedProofCodec { .flatten(), ) { - value.0 = ark_bls12_381::Fr::deserialize_compressed(&mut input) + value.0 = Fr::deserialize_compressed(&mut input) .map_err(|_error| CompactProofError)?; } if !input.is_empty() { @@ -363,7 +368,7 @@ mod tests { bad.push(0); assert!(codec.decode(&bad).is_err()); bad = bytes.clone(); - bad[5..53].fill(0xff); + bad[5..5 + G1_COMPRESSED_BYTES].fill(0xff); assert!(codec.decode(&bad).is_err()); bad = bytes.clone(); let n = bad.len(); diff --git a/crates/proof-compression/src/kzg/config.rs b/crates/proof-compression/src/kzg/config.rs index 516e89e44..927b77888 100644 --- a/crates/proof-compression/src/kzg/config.rs +++ b/crates/proof-compression/src/kzg/config.rs @@ -1,4 +1,4 @@ -//! [`ProofConfig`] instantiation for the KZG backend: BLS12-381 +//! [`ProofConfig`] instantiation for the KZG backend: the selected curve's //! scalar field (its own challenge field, `D = 1`), a selected transcript, //! monomial KZG commitments. @@ -100,10 +100,16 @@ impl KzgConfig { max_quotient_degree: usize, kind: TranscriptKind, ) -> Vec { + #[cfg(not(feature = "bn254"))] let domain: &[u8] = match kind { TranscriptKind::Blake3 => b"multi-stark/kzg/v4", TranscriptKind::Keccak256 => b"multi-stark/kzg/keccak256/v1", }; + #[cfg(feature = "bn254")] + let domain: &[u8] = match kind { + TranscriptKind::Blake3 => b"multi-stark/kzg/bn254/blake3/v1", + TranscriptKind::Keccak256 => b"multi-stark/kzg/bn254/keccak256/v1", + }; let mut seed = domain.to_vec(); for parameter in [p3_util::log2_strict_usize(params.max_trace_len()), max_quotient_degree] @@ -326,10 +332,16 @@ impl ProofConfig for KzgConfig { ) }) .collect(); - // Partitions the devices did not admit use the host path one at a - // time, keeping one trace-sized working set. + // Shapes and storage unsupported by the kernels use one host working + // set at a time. Device admission failures are errors. for (i, (input, part)) in inputs.iter().zip(&mut parts).enumerate() { if part.is_none() { + tracing::info!( + curve = super::curve::NAME, + circuit = i, + reason = "shape or storage unsupported by CUDA lookup kernels", + "KZG lookup using CPU construction" + ); *part = Some(self.host_lookup_part(i, input, beta, gamma)); } } @@ -378,6 +390,12 @@ impl ProofConfig for KzgConfig { .collect(); for (i, (input, part)) in inputs.iter().zip(&mut parts).enumerate() { if part.is_none() { + tracing::info!( + curve = super::curve::NAME, + circuit = i, + reason = "shape or storage unsupported by CUDA quotient kernels", + "KZG quotient using CPU construction" + ); *part = Some(self.host_quotient_part(i, input, alpha)); } } diff --git a/crates/proof-compression/src/kzg/cuda.rs b/crates/proof-compression/src/kzg/cuda.rs index 51f6c937e..bc3cbd240 100644 --- a/crates/proof-compression/src/kzg/cuda.rs +++ b/crates/proof-compression/src/kzg/cuda.rs @@ -1,4 +1,4 @@ -//! Bounded, synchronous BLS12-381 operations on the visible sppark devices. +//! Bounded, synchronous KZG operations on the visible sppark devices. //! //! With this feature compiled, every proving operation above the size //! thresholds in the prover runs on the devices; only tiny shapes and @@ -8,7 +8,8 @@ //! a device lease; leases are per thread and refuse reentrant acquisition so //! Rayon work stealing cannot deadlock a worker on its own device. -use ark_bls12_381::{Fq, Fr, G1Affine, G1Projective}; +use super::curve::{Fq, Fr, G1Affine, G1Projective}; +use abi::{DEVICE_AFFINE_BYTES, FQ_LIMBS, MAX_FFT_LOG, ProjectiveLimbs}; use ark_ff::{AdditiveGroup, BigInt, Field, Zero}; use blst as _; use rayon::prelude::*; @@ -17,6 +18,7 @@ use std::collections::HashMap; use std::ffi::c_void; use std::sync::{Arc, Condvar, Mutex, OnceLock}; +mod abi; pub(super) mod lookup; mod passes; pub(super) mod quotient; @@ -24,6 +26,11 @@ pub(super) mod srs_cache; #[cfg(test)] mod tests; +// Conservative bound for point storage, upload staging, scalars, signed +// digits and sorting scratch on either curve. Buckets use separate headroom; +// smaller Fq coordinates do not shrink scalar and sorting allocations. +const MSM_BYTES_PER_POINT: usize = 384; + /// When the constraint-pass layout replaces dense device admission. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PassMode { @@ -122,7 +129,7 @@ unsafe extern "C" { fn ix_kzg_msm_invoke( device: i32, context: *mut c_void, - out: *mut [[u64; 6]; 3], + out: *mut ProjectiveLimbs, scalars: *const [u64; 4], count: usize, normalization: *const [u64; 4], @@ -130,7 +137,7 @@ unsafe extern "C" { fn ix_kzg_msm_invoke_resident( device: i32, context: *mut c_void, - out: *mut [[u64; 6]; 3], + out: *mut ProjectiveLimbs, polynomial: *const c_void, offset: usize, count: usize, @@ -203,22 +210,6 @@ impl Drop for ProfileRange { } } -// The FFI names explicit limb arrays. These checks reject an arkworks layout -// change before its field storage can be borrowed without a packing copy. -const _: () = { - assert!(size_of::() == size_of::<[u64; 4]>()); - assert!(align_of::() == align_of::<[u64; 4]>()); - assert!(std::mem::offset_of!(Fr, 0) == 0); - assert!(std::mem::offset_of!(BigInt<4>, 0) == 0); - assert!(size_of::() == 104); - assert!(std::mem::offset_of!(G1Affine, x) == 0); - assert!(std::mem::offset_of!(G1Affine, y) == 48); - assert!(std::mem::offset_of!(G1Affine, infinity) == 96); - assert!(size_of::() == size_of::<[u64; 6]>()); - assert!(std::mem::offset_of!(Fq, 0) == 0); - assert!(std::mem::offset_of!(BigInt<6>, 0) == 0); -}; - fn check(code: i32, operation: &str) { assert_eq!(code, 0, "KZG CUDA {operation} failed (CUDA status {code})"); } @@ -250,6 +241,7 @@ impl Devices { fn get() -> &'static Self { DEVICES.get_or_init(|| { + abi::validate(); let settings = settings(); let mut count = 0; // The FFI writes one integer and initializes sppark's device registry. @@ -290,7 +282,7 @@ impl Devices { .min(free.saturating_sub(total / 4)) }) .collect(); - tracing::info!(?ids, chunk_points, ?resident_limits, "KZG sppark CUDA backend"); + tracing::info!(curve = super::curve::NAME, ?ids, chunk_points, ?resident_limits, "KZG sppark CUDA backend"); Devices { busy: Mutex::new(vec![false; ids.len()]), resident_bytes: Mutex::new(vec![0; ids.len()]), @@ -475,7 +467,8 @@ pub(super) fn fft_columns( inverse: bool, shift: Fr, ) -> Vec> { - assert!(lg <= 31 && !shift.is_zero()); + assert!(lg <= MAX_FFT_LOG, "unsupported KZG CUDA FFT domain"); + assert!(!shift.is_zero()); let count = 1usize << lg; assert!(inputs.iter().all(|(input, _)| input.len() <= count)); if inputs.is_empty() { @@ -570,7 +563,8 @@ pub(super) fn interpolate_columns( lg: usize, shift: Fr, ) -> (Vec>, Vec>>) { - assert!(lg <= 31 && !shift.is_zero()); + assert!(lg <= MAX_FFT_LOG, "unsupported KZG CUDA FFT domain"); + assert!(!shift.is_zero()); let count = 1usize << lg; let devices = Devices::get(); let mut groups: Vec> = @@ -637,15 +631,7 @@ pub(super) fn interpolate_columns( slots, ) }; - // A later job can fail after an earlier retained output completed. - // Reclaim those handles while the lease still excludes native users. - if code != 0 { - for handle in handles.into_iter().filter(|p| !p.is_null()) { - unsafe { ix_kzg_polynomial_destroy(device.id(), handle) }; - } - check(code, "interpolation queue"); - unreachable!(); - } + check(code, "interpolation queue"); tracing::debug!( device = device.id(), columns = group.len(), @@ -725,10 +711,12 @@ pub(super) fn msm_columns_resident( .map(|(index, group)| { let device = devices.acquire_at(index); device.prepare_budget( - devices.chunk_points.min(points.len()) * 384 + (1 << 30), + devices.chunk_points.min(points.len()) * MSM_BYTES_PER_POINT + + (1 << 30), ); - let chunk = - devices.chunk_points.min(device.budget().saturating_sub(1 << 30) / 384); + let chunk = devices + .chunk_points + .min(device.budget().saturating_sub(1 << 30) / MSM_BYTES_PER_POINT); assert!(chunk > 0, "insufficient free VRAM for KZG MSM"); let mut sums = vec![G1Projective::zero(); group.len()]; for (part, points) in points.chunks(chunk).enumerate() { @@ -738,7 +726,7 @@ pub(super) fn msm_columns_resident( let (coefficients, resident) = columns[column]; let input = &coefficients[start..start + points.len()]; let normalization = msm_normalization(input); - let mut out = [[0; 6]; 3]; + let mut out = [[0; FQ_LIMBS]; 3]; // Resident coefficients are read-only. Normalization uses a // bounded temporary on the same device as this SRS chunk. check( @@ -777,7 +765,7 @@ pub(super) fn msm_columns_resident( sums } -fn projective(limbs: [[u64; 6]; 3]) -> G1Projective { +fn projective(limbs: ProjectiveLimbs) -> G1Projective { G1Projective::new_unchecked( Fq::new_unchecked(BigInt(limbs[0])), Fq::new_unchecked(BigInt(limbs[1])), @@ -864,10 +852,11 @@ fn msm_columns_chunked( .enumerate() .map(|(chunk_index, points)| { let device = devices.acquire(); - device.prepare_budget(points.len() * 384 + (1 << 30)); + device.prepare_budget(points.len() * MSM_BYTES_PER_POINT + (1 << 30)); // The largest window uses < 1 GiB of buckets. Resident affine points, // scalars, signed digits and sorting scratch fit in 384 B/point. - let admitted = device.budget().saturating_sub(1 << 30) / 384; + let admitted = + device.budget().saturating_sub(1 << 30) / MSM_BYTES_PER_POINT; assert!(admitted > 0, "insufficient free VRAM for KZG MSM"); let mut sums = vec![G1Projective::zero(); columns.len()]; for (part, points) in points.chunks(admitted).enumerate() { @@ -877,7 +866,7 @@ fn msm_columns_chunked( for (sum, column) in sums.iter_mut().zip(columns) { let input = &column[start..start + count]; let normalization = msm_normalization(input); - let mut out = [[0; 6]; 3]; + let mut out = [[0; FQ_LIMBS]; 3]; // The resident point chunk is immutable across all column calls. check( unsafe { @@ -916,8 +905,11 @@ fn msm_columns_chunked( #[cfg(test)] pub(super) fn fft(values: &mut [Fr], inverse: bool, shift: Fr) { - // sppark's 32-bit domain stride cannot represent 2^32. - assert!(values.len().is_power_of_two() && values.len().ilog2() <= 31); + assert!(values.len().is_power_of_two()); + assert!( + values.len().ilog2() as usize <= MAX_FFT_LOG, + "unsupported KZG CUDA FFT domain" + ); assert!(!shift.is_zero()); if values.len() == 1 { return; @@ -959,7 +951,8 @@ pub(super) fn fft(values: &mut [Fr], inverse: bool, shift: Fr) { #[cfg(test)] pub(super) fn fft_from(coefficients: &[Fr], lg: usize, shift: Fr) -> Vec { - assert!(lg <= 31 && !shift.is_zero()); + assert!(lg <= MAX_FFT_LOG, "unsupported KZG CUDA FFT domain"); + assert!(!shift.is_zero()); let count = 1usize << lg; assert!(coefficients.len() <= count); let device = Devices::get().acquire(); @@ -1113,11 +1106,9 @@ fn divide_and_msm_chunked( let devices = Devices::get(); let device = devices.acquire(); device.prepare_budget( - coefficients - .len() - .checked_mul(32)? - .checked_add(1 << 30)? - .checked_add(chunk_limit.min(points.len()).checked_mul(384)?)?, + coefficients.len().checked_mul(32)?.checked_add(1 << 30)?.checked_add( + chunk_limit.min(points.len()).checked_mul(MSM_BYTES_PER_POINT)?, + )?, ); let chunk = opening_chunk(coefficients.len(), chunk_limit, device.budget())?; let mut context = std::ptr::null_mut(); @@ -1143,7 +1134,7 @@ fn opening_chunk( budget: usize, ) -> Option { let fixed = count.checked_mul(32)?.checked_add(1 << 30)?; - let chunk = chunk_limit.min(budget.checked_sub(fixed)? / 384); + let chunk = chunk_limit.min(budget.checked_sub(fixed)? / MSM_BYTES_PER_POINT); (chunk > 0).then_some(chunk) } @@ -1175,7 +1166,7 @@ fn commit_leased_quotient( None }; let resident_points = upload_msm_points(device, points); - let mut out = [[0; 6]; 3]; + let mut out = [[0; FQ_LIMBS]; 3]; check( unsafe { ix_kzg_msm_invoke_resident( diff --git a/crates/proof-compression/src/kzg/cuda/abi.rs b/crates/proof-compression/src/kzg/cuda/abi.rs new file mode 100644 index 000000000..c432e6bb4 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/abi.rs @@ -0,0 +1,91 @@ +//! Zero-copy field and point layouts shared with the native KZG backend. + +use super::{BigInt, FftJob, Fq, Fr, G1Affine}; +use ark_ff::{FftField, Field, PrimeField}; + +#[cfg(feature = "bn254")] +pub(super) const FQ_LIMBS: usize = 4; +#[cfg(not(feature = "bn254"))] +pub(super) const FQ_LIMBS: usize = 6; + +pub(super) type FqLimbs = [u64; FQ_LIMBS]; +pub(super) type ProjectiveLimbs = [FqLimbs; 3]; +pub(super) const DEVICE_AFFINE_BYTES: usize = 2 * size_of::(); + +// Sppark's 32-bit domain stride cannot represent 2^32. +pub(super) const MAX_FFT_LOG: usize = + if Fr::TWO_ADICITY < 31 { Fr::TWO_ADICITY as usize } else { 31 }; + +const _: () = { + assert!(cfg!(target_endian = "little")); + assert!(size_of::() == size_of::<[u64; 4]>()); + assert!(align_of::() == align_of::<[u64; 4]>()); + assert!(std::mem::offset_of!(Fr, 0) == 0); + assert!(std::mem::offset_of!(BigInt<4>, 0) == 0); + assert!(size_of::() == size_of::()); + assert!(align_of::() == align_of::()); + assert!(std::mem::offset_of!(Fq, 0) == 0); + assert!(std::mem::offset_of!(BigInt, 0) == 0); + assert!(size_of::() == DEVICE_AFFINE_BYTES + size_of::()); + assert!(align_of::() == align_of::()); + assert!(std::mem::offset_of!(G1Affine, x) == 0); + assert!(std::mem::offset_of!(G1Affine, y) == size_of::()); + assert!(std::mem::offset_of!(G1Affine, infinity) == DEVICE_AFFINE_BYTES); + assert!(size_of::() == 40 && align_of::() == 8); + assert!(std::mem::offset_of!(FftJob, resident_input) == 8); + assert!(std::mem::offset_of!(FftJob, output) == 16); + assert!(std::mem::offset_of!(FftJob, input_count) == 24); + assert!(std::mem::offset_of!(FftJob, resident_output) == 32); +}; + +unsafe extern "C" { + fn ix_kzg_check_field_abi( + fq: *const [FqLimbs; 3], + fq_bytes: usize, + fr: *const [[u64; 4]; 3], + fr_bytes: usize, + ) -> i32; +} + +fn matches_native(fq: &[FqLimbs; 3], fr: &[[u64; 4]; 3]) -> bool { + // The native check reads only these fixed-size arrays and does not use CUDA. + unsafe { + ix_kzg_check_field_abi(fq, size_of_val(fq), fr, size_of_val(fr)) == 0 + } +} + +pub(super) fn validate() { + assert!( + matches_native( + &[Fq::MODULUS.0, Fq::ONE.0.0, Fq::R2.0], + &[Fr::MODULUS.0, Fr::ONE.0.0, Fr::R2.0], + ), + "KZG native and Rust field representations differ" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_montgomery_parameters_match_arkworks() { + validate(); + let fq = [Fq::MODULUS.0, Fq::ONE.0.0, Fq::R2.0]; + let fr = [Fr::MODULUS.0, Fr::ONE.0.0, Fr::R2.0]; + for parameter in 0..3 { + let mut wrong_fq = fq; + wrong_fq[parameter].reverse(); + assert!(!matches_native(&wrong_fq, &fr)); + let mut wrong_fr = fr; + wrong_fr[parameter].reverse(); + assert!(!matches_native(&fq, &wrong_fr)); + } + assert_ne!( + unsafe { + ix_kzg_check_field_abi(&fq, size_of_val(&fq) - 8, &fr, size_of_val(&fr)) + }, + 0 + ); + } +} diff --git a/crates/proof-compression/src/kzg/cuda/lookup.rs b/crates/proof-compression/src/kzg/cuda/lookup.rs index 5e2cd80a8..d23957374 100644 --- a/crates/proof-compression/src/kzg/cuda/lookup.rs +++ b/crates/proof-compression/src/kzg/cuda/lookup.rs @@ -17,6 +17,13 @@ pub(super) struct Parameters { pub(super) blocks: u32, } +const _: () = { + assert!(size_of::() == 96 && align_of::() == 32); + assert!(std::mem::offset_of!(Parameters, gamma) == 32); + assert!(std::mem::offset_of!(Parameters, trace_log) == 64); + assert!(std::mem::offset_of!(Parameters, blocks) == 76); +}; + unsafe extern "C" { fn ix_kzg_lookup( device: i32, @@ -66,6 +73,15 @@ pub(in crate::kzg) fn coefficients( input: &LookupCommitInput<'_, KzgConfig>, beta: Scalar, gamma: Scalar, +) -> Option<(Vec>, Scalar)> { + coefficients_with_mode(input, beta, gamma, settings().passes) +} + +pub(super) fn coefficients_with_mode( + input: &LookupCommitInput<'_, KzgConfig>, + beta: Scalar, + gamma: Scalar, + mode: PassMode, ) -> Option<(Vec>, Scalar)> { let main = quotient::matrix(input.stage_1)?; let fixed = match input.preprocessed { @@ -113,15 +129,15 @@ pub(in crate::kzg) fn coefficients( }; let run_passes = || passes::lookup_coefficients(input, ¶meters, &all_columns, widths); - if settings().passes == PassMode::Force - && let Some(output) = run_passes() - { - return Some(output); + if mode == PassMode::Force { + return Some(run_passes().expect( + "forced KZG lookup passes are unsupported or no selected device admits them", + )); } let mut columns = quotient::column_descriptors(&all_columns); let device = quotient::acquire_columns(&mut columns, &all_columns, bytes); if device.is_none() - && settings().passes == PassMode::Fallback + && mode == PassMode::Fallback && let Some(output) = run_passes() { return Some(output); diff --git a/crates/proof-compression/src/kzg/cuda/passes.rs b/crates/proof-compression/src/kzg/cuda/passes.rs index 51e77b27e..5748258c0 100644 --- a/crates/proof-compression/src/kzg/cuda/passes.rs +++ b/crates/proof-compression/src/kzg/cuda/passes.rs @@ -27,6 +27,17 @@ struct NativePass { group: usize, } +const _: () = { + assert!(size_of::() == 160 && align_of::() == 32); + assert!(std::mem::offset_of!(NativePass, columns) == 32); + assert!(std::mem::offset_of!(NativePass, nodes) == 56); + assert!(std::mem::offset_of!(NativePass, roots) == 72); + assert!(std::mem::offset_of!(NativePass, lookups) == 88); + assert!(std::mem::offset_of!(NativePass, args) == 104); + assert!(std::mem::offset_of!(NativePass, slots) == 120); + assert!(std::mem::offset_of!(NativePass, group) == 128); +}; + pub(super) struct Pass { program: Program, pub(super) columns: Vec, diff --git a/crates/proof-compression/src/kzg/cuda/quotient.rs b/crates/proof-compression/src/kzg/cuda/quotient.rs index dcf909457..369cd03bf 100644 --- a/crates/proof-compression/src/kzg/cuda/quotient.rs +++ b/crates/proof-compression/src/kzg/cuda/quotient.rs @@ -55,6 +55,22 @@ pub(super) struct Parameters { pub(super) blocks: u32, } +const _: () = { + assert!(size_of::() == 64 && align_of::() == 32); + assert!(std::mem::offset_of!(Instruction, a) == 32); + assert!(std::mem::offset_of!(Instruction, out) == 44); + assert!(size_of::() == 12 && align_of::() == 4); + assert!(std::mem::offset_of!(Lookup, arg_count) == 8); + assert!(size_of::() == 96 && align_of::() == 32); + assert!(std::mem::offset_of!(Column, resident) == 8); + assert!(std::mem::offset_of!(Column, count) == 16); + assert!(std::mem::offset_of!(Column, constant) == 32); + assert!(std::mem::offset_of!(Column, slot) == 64); + assert!(size_of::() == 192 && align_of::() == 32); + assert!(std::mem::offset_of!(Parameters, trace_log) == 160); + assert!(std::mem::offset_of!(Parameters, blocks) == 180); +}; + unsafe extern "C" { fn ix_kzg_quotient( device: i32, @@ -351,6 +367,14 @@ pub(super) fn views<'a>( pub(in crate::kzg) fn coefficients( input: &QuotientCommitInput<'_, KzgConfig>, alpha: Scalar, +) -> Option>> { + coefficients_with_mode(input, alpha, settings().passes) +} + +pub(super) fn coefficients_with_mode( + input: &QuotientCommitInput<'_, KzgConfig>, + alpha: Scalar, + mode: PassMode, ) -> Option>> { let trace = input.trace_domain; let quotient = input.quotient_domain; @@ -448,15 +472,15 @@ pub(in crate::kzg) fn coefficients( &inverse_vanishing, ) }; - if settings().passes == PassMode::Force - && let Some(output) = run_passes() - { - return Some(output); + if mode == PassMode::Force { + return Some(run_passes().expect( + "forced KZG quotient passes are unsupported or no selected device admits them", + )); } let mut columns = column_descriptors(&all_columns); let device = acquire_columns(&mut columns, &all_columns, bytes); if device.is_none() - && settings().passes == PassMode::Fallback + && mode == PassMode::Fallback && let Some(output) = run_passes() { return Some(output); diff --git a/crates/proof-compression/src/kzg/cuda/srs_cache.rs b/crates/proof-compression/src/kzg/cuda/srs_cache.rs index 139203651..389671de5 100644 --- a/crates/proof-compression/src/kzg/cuda/srs_cache.rs +++ b/crates/proof-compression/src/kzg/cuda/srs_cache.rs @@ -12,6 +12,9 @@ const HEADROOM: usize = 1 << 30; const MAX_CHUNK: usize = 1 << 24; const UPLOAD_POINTS: usize = 1 << 18; +#[cfg(test)] +mod tests; + pub(in crate::kzg) struct Owner { id: u64, srs: Arc, @@ -59,7 +62,7 @@ unsafe extern "C" { workspace: *mut c_void, points: *const c_void, point_offset: usize, - output: *mut [[u64; 6]; 3], + output: *mut ProjectiveLimbs, host: *const [u64; 4], polynomial: *const c_void, scalar_offset: usize, @@ -221,8 +224,10 @@ fn prepare( }, "MSM memory plan", ); - let point_bytes = count.div_ceil(32).checked_mul(32 * 96).unwrap(); - let staging_bytes = count.min(UPLOAD_POINTS).div_ceil(32) * 32 * 104; + let point_bytes = + count.div_ceil(32).checked_mul(32 * DEVICE_AFFINE_BYTES).unwrap(); + let staging_bytes = + count.min(UPLOAD_POINTS).div_ceil(32) * 32 * size_of::(); let mut cache = caches()[device.index].lock().unwrap(); cache.purge(device); if cache @@ -356,7 +361,7 @@ fn invoke( (std::ptr::null(), pointer as *const c_void, offset, count) }, }; - let mut output = [[0; 6]; 3]; + let mut output = [[0; FQ_LIMBS]; 3]; check( unsafe { ix_kzg_srs_msm( @@ -423,8 +428,10 @@ pub(in crate::kzg) fn msm_columns( .into_par_iter() .map(|(index, offset, length, group)| { let device = devices.acquire_at(index); - let chunk = limit - .min(devices.potential_budget(index).saturating_sub(HEADROOM) / 384); + let chunk = limit.min( + devices.potential_budget(index).saturating_sub(HEADROOM) + / MSM_BYTES_PER_POINT, + ); assert!(chunk > 0, "insufficient free VRAM for KZG MSM"); let mut sums = vec![G1Projective::zero(); group.len()]; for part in (offset..offset + length).step_by(chunk) { @@ -474,7 +481,7 @@ pub(in crate::kzg) fn single_device_opening( .len() .checked_mul(32)? .checked_add(HEADROOM)? - .checked_add(chunk.checked_mul(384)?)?; + .checked_add(chunk.checked_mul(MSM_BYTES_PER_POINT)?)?; if !device.prepare_budget(required) { return None; } diff --git a/crates/proof-compression/src/kzg/cuda/srs_cache/tests.rs b/crates/proof-compression/src/kzg/cuda/srs_cache/tests.rs new file mode 100644 index 000000000..84ff7a281 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/srs_cache/tests.rs @@ -0,0 +1,120 @@ +use super::*; +use ark_ec::VariableBaseMSM; + +fn reference(owner: &Owner, start: usize, values: &[Fr]) -> G1Projective { + G1Projective::msm(&owner.srs.g1[start..start + values.len()], values).unwrap() +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cached_msm_preserves_offsets_owners_and_resident_scalars() { + let count = 4099; + let mut bases = Srs::unsafe_dev_setup(8192, b"cuda-cache-a"); + // MSM accepts arbitrary bases, including infinity and repeated points. + bases.g1[0] = G1Affine::identity(); + bases.g1[1] = -bases.g1[2]; + bases.g1[3] = bases.g1[2]; + let first = Owner::new(Arc::new(bases)); + let second = + Owner::new(Arc::new(Srs::unsafe_dev_setup(8192, b"cuda-cache-b"))); + let values: Vec<_> = (0..count).map(|i| Fr::from(i as u64)).collect(); + let scale = Fr::from(19); + let inverse = scale.inverse().unwrap(); + let structured: Vec<_> = + (0..count).map(|i| if i % 7 == 0 { Fr::ZERO } else { inverse }).collect(); + let device = Devices::get().acquire_at(0); + let handles = prepare(&device, &first, 0, count); + for column in [&values, &structured, &vec![Fr::ZERO; count]] { + assert_eq!( + invoke( + &device, + &handles, + Scalars::Host(column), + msm_normalization(column) + ), + reference(&first, 0, column) + ); + } + let hits = caches()[device.index].lock().unwrap().hits; + let subset = prepare(&device, &first, 7, 33); + assert_eq!(subset.point_offset, 7); + assert_eq!(caches()[device.index].lock().unwrap().hits, hits + 1); + assert_eq!( + invoke(&device, &subset, Scalars::Host(&values[11..44]), None), + reference(&first, 7, &values[11..44]) + ); + + let mut pointer = std::ptr::null_mut(); + check( + unsafe { + ix_kzg_polynomial_upload( + device.id(), + values.as_ptr().cast(), + count, + &mut pointer, + ) + }, + "cache test scalar upload", + ); + let polynomial = LeasedPolynomial { device: &device, context: pointer }; + assert_eq!(unsafe { ix_kzg_polynomial_destroy(-1, pointer) }, 1); + let resident = + Scalars::Resident { pointer: pointer as usize, offset: 11, count: 33 }; + assert_eq!( + invoke(&device, &subset, resident, None), + reference(&first, 7, &values[11..44]) + ); + assert_eq!( + invoke(&device, &subset, resident, Some((scale, inverse))), + reference(&first, 7, &values[11..44]) + ); + let mut output = [[0; FQ_LIMBS]; 3]; + let code = unsafe { + ix_kzg_srs_msm( + device.id(), + subset.workspace as *mut c_void, + subset.points as *const c_void, + count + 1, + &mut output, + values.as_ptr().cast(), + std::ptr::null(), + 0, + 33, + std::ptr::null(), + ) + }; + assert_eq!(code, 1); + assert_eq!( + invoke(&device, &subset, resident, None), + reference(&first, 7, &values[11..44]) + ); + drop(polynomial); + + let other = prepare(&device, &second, 7, 33); + assert_eq!(other.point_offset, 0); + assert_eq!( + invoke(&device, &other, Scalars::Host(&values[11..44]), None), + reference(&second, 7, &values[11..44]) + ); + let uploads = { + let mut cache = caches()[device.index].lock().unwrap(); + let index = + cache.entries.iter().position(|entry| entry.id == first.id).unwrap(); + cache.evict(&device, index); + cache.uploads + }; + let reloaded = prepare(&device, &first, 7, 33); + assert_eq!(caches()[device.index].lock().unwrap().uploads, uploads + 1); + assert_eq!( + invoke(&device, &reloaded, Scalars::Host(&values[11..44]), None), + reference(&first, 7, &values[11..44]) + ); + let second_id = second.id; + drop(second); + let mut cache = caches()[device.index].lock().unwrap(); + cache.purge(&device); + assert!(cache.entries.iter().all(|entry| entry.id != second_id)); + drop(first); + cache.purge(&device); + cache.drop_workspace(&device); +} diff --git a/crates/proof-compression/src/kzg/cuda/tests.rs b/crates/proof-compression/src/kzg/cuda/tests.rs index 66b33f29c..91e8e52ea 100644 --- a/crates/proof-compression/src/kzg/cuda/tests.rs +++ b/crates/proof-compression/src/kzg/cuda/tests.rs @@ -3,6 +3,107 @@ use ark_ec::{AffineRepr, CurveGroup, VariableBaseMSM}; use ark_ff::PrimeField; use ark_poly::{EvaluationDomain, Radix2EvaluationDomain}; +mod constraints; +#[cfg(feature = "bn254")] +mod plonk_scale; + +unsafe extern "C" { + fn ix_kzg_ntt_parameters(lg: u32, output: *mut [[u64; 4]; 3]) -> i32; + fn ix_kzg_field_conformance( + device: i32, + fq: *const [abi::FqLimbs; 32], + fr: *const [[u64; 4]; 32], + fq_output: *mut [abi::FqLimbs; 192], + fr_output: *mut [[u64; 4]; 192], + ) -> i32; +} + +#[test] +fn native_ntt_parameters_match_scalar_roots_without_large_allocations() { + use crate::kzg::Scalar; + use multi_stark::traits::TwoAdicField; + for lg in 0..=MAX_FFT_LOG { + let mut output = [[0; 4]; 3]; + let status = unsafe { ix_kzg_ntt_parameters(lg as u32, &mut output) }; + assert_eq!(status, 0); + let [root, inverse, scale] = + output.map(|limbs| Fr::new_unchecked(BigInt(limbs))); + assert_eq!(root, Scalar::two_adic_generator(lg).0); + assert_eq!(inverse, root.inverse().unwrap()); + assert_eq!(scale, Fr::from(1u64 << lg).inverse().unwrap()); + } + let mut output = [[0; 4]; 3]; + assert_eq!( + unsafe { ix_kzg_ntt_parameters(MAX_FFT_LOG as u32 + 1, &mut output) }, + 1 + ); +} + +fn field_inputs() -> [F; 32] { + let mut values = std::array::from_fn(|i| { + let mut bytes = [0; 64]; + blake3::Hasher::new() + .update(&(i as u64).to_le_bytes()) + .finalize_xof() + .fill(&mut bytes); + F::from_le_bytes_mod_order(&bytes) + }); + values[..5].copy_from_slice(&[ + F::ZERO, + F::ONE, + -F::ONE, + F::from(2u8), + -F::from(2u8), + ]); + for (value, bit) in values[5..9].iter_mut().zip([63, 64, 127, 192]) { + *value = F::from(2u8).pow([bit]); + } + values +} + +fn field_reference(values: [F; 32]) -> Vec { + values + .iter() + .enumerate() + .flat_map(|(i, &a)| { + let b = values[(i + 1) % values.len()]; + [F::ONE, a + b, a - b, a * b, -a, a.inverse().unwrap_or(F::ZERO)] + }) + .collect() +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_base_and_scalar_arithmetic_match_arkworks() { + let fq = field_inputs::(); + let fr = field_inputs::(); + let fq_input = fq.map(|v| v.0.0); + let fr_input = fr.map(|v| v.0.0); + let mut fq_output = [[0; FQ_LIMBS]; 192]; + let mut fr_output = [[0; 4]; 192]; + let device = Devices::get().acquire(); + check( + unsafe { + ix_kzg_field_conformance( + device.id(), + &fq_input, + &fr_input, + &mut fq_output, + &mut fr_output, + ) + }, + "field conformance", + ); + assert_eq!( + fq_output.map(|v| Fq::new_unchecked(BigInt(v))).as_slice(), + field_reference(fq) + ); + assert_eq!( + fr_output.map(|v| Fr::new_unchecked(BigInt(v))).as_slice(), + field_reference(fr) + ); +} + fn scalars(n: usize) -> Vec { (0..n) .map(|i| { @@ -16,6 +117,54 @@ fn scalars(n: usize) -> Vec { .collect() } +#[test] +fn oversized_fft_domains_fail_before_allocation_or_device_access() { + let unsupported = MAX_FFT_LOG + 1; + assert!( + std::panic::catch_unwind(|| fft_columns(&[], unsupported, false, Fr::ONE)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + interpolate_columns(vec![], unsupported, Fr::ONE) + }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| fft_from(&[], unsupported, Fr::ONE)).is_err() + ); + let shift = Fr::ONE.0.0; + let mut value = Fr::ZERO.0.0; + let original = value; + const INVALID_VALUE: i32 = 1; + // Invalid domains must fail before the invalid ordinal is resolved. + for lg in [unsupported as u32, u32::MAX] { + let code = unsafe { ix_kzg_fft(-1, &mut value, lg, false, &shift) }; + assert_eq!(code, INVALID_VALUE); + let job = FftJob { + input: &value, + resident_input: std::ptr::null(), + output: &mut value, + input_count: 1, + resident_output: std::ptr::null_mut(), + }; + let code = unsafe { ix_kzg_fft_batch(-1, &job, 1, lg, false, &shift, 1) }; + assert_eq!(code, INVALID_VALUE); + assert_eq!(value, original); + } +} + +#[test] +fn projective_limb_transport_preserves_coordinates_and_infinity() { + let generator = G1Affine::generator(); + for point in [generator, -generator, G1Affine::identity()] { + let native = point.into_group(); + let decoded = projective([native.x.0.0, native.y.0.0, native.z.0.0]); + assert_eq!(decoded.into_affine(), point); + } + assert!(projective([[0; FQ_LIMBS]; 3]).is_zero()); +} + #[test] fn settings_install_once() { // The first installation wins; later ones report that they were ignored. @@ -153,6 +302,32 @@ fn normalization_samples_structured_columns_only() { // The tests below need a visible CUDA GPU. +#[test] +#[ignore = "requires a CUDA GPU"] +fn msm_handle_rejects_destruction_on_another_device() { + let points = + [G1Affine::generator(), -G1Affine::generator(), G1Affine::identity()]; + let values = [Fr::ONE, Fr::from(2), Fr::from(3)]; + let device = Devices::get().acquire(); + let context = upload_msm_points(&device, &points); + assert_eq!(unsafe { ix_kzg_msm_destroy(-1, context.context) }, 1); + let mut output = [[0; FQ_LIMBS]; 3]; + check( + unsafe { + ix_kzg_msm_invoke( + device.id(), + context.context, + &mut output, + values.as_ptr().cast(), + values.len(), + std::ptr::null(), + ) + }, + "MSM after rejected destruction", + ); + assert_eq!(projective(output), G1Projective::msm(&points, &values).unwrap()); +} + #[test] #[ignore = "requires a CUDA GPU"] fn cuda_msm_matches_arkworks_with_chunks_and_exceptional_points() { @@ -235,17 +410,6 @@ fn cuda_msm_normalizes_structured_columns() { } } -#[test] -#[ignore = "requires a CUDA GPU"] -fn cuda_fft_rejects_overflowing_domain_stride() { - let mut value = Fr::ONE.0.0; - let original = value; - // The invalid size must be rejected before any transfer or allocation. - let status = unsafe { ix_kzg_fft(0, &mut value, 32, false, &original) }; - assert_ne!(status, 0); - assert_eq!(value, original); -} - #[test] #[ignore = "requires a CUDA GPU"] fn cuda_fft_matches_arkworks_on_subgroups_and_arbitrary_cosets() { diff --git a/crates/proof-compression/src/kzg/cuda/tests/constraints.rs b/crates/proof-compression/src/kzg/cuda/tests/constraints.rs new file mode 100644 index 000000000..fcf2f8faa --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/tests/constraints.rs @@ -0,0 +1,237 @@ +use super::*; +use crate::kzg::{KzgConfig, Scalar, domain::Radix2Coset, pcs::KzgProverData}; +use multi_stark::{ + config::{LookupCommitInput, QuotientCommitInput}, + expr::Expr, + lookup::{Lookup, LookupValues}, + system::{ + Circuit, CircuitInputs, compute_lookup_values_range, extension_params, + }, + traits::{Algebra, EvaluationDomain as _}, +}; +use p3_matrix::dense::RowMajorMatrix; + +fn evaluations( + columns: &[Vec], + domain: Radix2EvaluationDomain, +) -> RowMajorMatrix { + let columns: Vec<_> = columns.iter().map(|c| domain.fft(c)).collect(); + RowMajorMatrix::new( + (0..domain.size()) + .flat_map(|row| columns.iter().map(move |c| Scalar(c[row]))) + .collect(), + columns.len(), + ) +} + +fn coefficients( + matrix: &RowMajorMatrix, + domain: Radix2EvaluationDomain, +) -> Vec> { + (0..matrix.width) + .map(|column| { + let values: Vec<_> = matrix + .values + .iter() + .skip(column) + .step_by(matrix.width) + .map(|v| v.0) + .collect(); + domain.ifft(&values) + }) + .collect() +} + +fn fixture(fixed: RowMajorMatrix) -> Circuit { + let x = Expr::main(0); + let message = vec![x.clone(), Expr::main_next(0), Expr::preprocessed_next(1)]; + let spec = CircuitInputs { + main_width: 2, + preprocessed: Some(fixed.clone()), + constraints: vec![ + x.clone() * Expr::main_next(0) - Expr::preprocessed(0), + Expr::preprocessed_next(1) - Expr::main(1), + Expr::IsFirstRow * x.clone() + Expr::IsLastRow * Expr::main_next(0), + Expr::IsTransition * (x - Expr::preprocessed(1)), + ], + lookups: vec![ + Lookup::push( + Expr::constant(Scalar::ONE), + vec![Expr::constant(Scalar::ZERO)], + ), + Lookup::push(Expr::IsFirstRow + Expr::IsLastRow, message.clone()), + Lookup::pull(Expr::IsTransition, message.clone()), + Lookup::push(Expr::main(1), message.clone()), + Lookup::pull(Expr::preprocessed(0), message), + ], + lookup_group_size: 2, + ..Default::default() + }; + let graph = spec.compile_graph(&extension_params::()).unwrap(); + let groups = multi_stark::lookup::stage2_width(spec.lookups.len(), 2, 1); + let constraint_count = graph.zeros.len() + groups; + let max_constraint_degree = graph + .max_constraint_degree + .max(multi_stark::lookup::logup_max_degree(&graph, 2)) + as usize; + Circuit { + graph, + main_width: 2, + preprocessed_height: fixed.values.len() / fixed.width, + preprocessed_width: fixed.width, + preprocessed: Some(fixed), + num_lookups: spec.lookups.len(), + stage_2_width: groups, + num_publics: 4, + lookup_group_size: 2, + constraint_count, + max_constraint_degree, + } +} + +fn quotient_reference( + input: &QuotientCommitInput<'_, KzgConfig>, + fixed: &[Vec], + main: &[Vec], + stage2: &[Vec], + alpha: Scalar, +) -> Vec> { + let rows = input.trace_domain.size(); + let ratio = input.quotient_domain.size() / rows; + let mut values = vec![Scalar::ZERO; rows * ratio]; + let passes = crate::kzg::quotient_plan::plan( + &input.circuit.graph, + input.circuit.lookup_group_size, + ); + let mut shift = input.quotient_domain.shift; + for coset in 0..ratio { + let domain = Radix2Coset { log_size: input.trace_domain.log_size, shift }; + let ark_domain = Radix2EvaluationDomain::::new(rows) + .unwrap() + .get_coset(shift.0) + .unwrap(); + let main = evaluations(main, ark_domain); + let stage2 = evaluations(stage2, ark_domain); + for pass in &passes { + let selected: Vec<_> = + pass.fixed.iter().map(|&i| fixed[i].clone()).collect(); + let fixed = + (!selected.is_empty()).then(|| evaluations(&selected, ark_domain)); + crate::kzg::quotient::evaluate_coset( + input, + pass, + domain, + fixed.as_ref(), + &main, + &stage2, + alpha, + coset, + ratio, + &mut values, + ); + } + shift *= input.quotient_domain.generator(); + } + let domain = Radix2EvaluationDomain::::new(rows * ratio) + .unwrap() + .get_coset(input.quotient_domain.shift.0) + .unwrap(); + let values: Vec<_> = values.into_iter().map(|v| v.0).collect(); + domain.ifft(&values).chunks(rows).map(<[Fr]>::to_vec).collect() +} + +fn compare_paths(log_size: usize) { + let rows = 1 << log_size; + let domain = Radix2Coset { log_size, shift: Scalar::ONE }; + let ark_domain = Radix2EvaluationDomain::::new(rows).unwrap(); + let fixed = vec![vec![Fr::from(3), Fr::ONE], scalars(rows)]; + let main = vec![scalars(rows), vec![Fr::from(7)]]; + let fixed_data = KzgProverData::coefficient_fixture(domain, fixed.clone()); + let main_data = KzgProverData::coefficient_fixture(domain, main.clone()); + if log_size >= 16 { + assert!( + fixed_data.matrices[0].resident_columns()[1].is_some(), + "fixture must retain fixed GPU input" + ); + assert!( + main_data.matrices[0].resident_columns()[0].is_some(), + "fixture must retain main GPU input" + ); + } + let fixed_values = evaluations(&fixed, ark_domain); + let main_values = evaluations(&main, ark_domain); + let circuit = fixture(fixed_values.clone()); + for beta in [Scalar::ZERO, Scalar(Fr::from(17))] { + let gamma = Scalar(Fr::from(23)); + let lookup_values = compute_lookup_values_range( + &circuit.graph, + &main_values, + Some(&fixed_values), + 0..rows, + ); + let (traces, totals) = LookupValues::stage_2_traces( + &[lookup_values.clone()], + &[circuit.lookup_group_size], + beta, + &gamma, + Scalar::ZERO, + ); + let expected = coefficients(&traces[0], ark_domain); + let input = LookupCommitInput { + circuit: &circuit, + lookup_values: &lookup_values, + preprocessed: Some((&fixed_data, 0)), + stage_1: (&main_data, 0), + }; + for mode in [PassMode::Off, PassMode::Force] { + let actual = lookup::coefficients_with_mode(&input, beta, gamma, mode) + .expect("fixture must execute CUDA lookup construction"); + assert_eq!( + actual, + (expected.clone(), totals[0]), + "lookup mode={mode:?}, beta={beta:?}" + ); + } + let stage2 = KzgProverData::coefficient_fixture(domain, expected.clone()); + if log_size >= 16 { + assert!( + stage2.matrices[0].resident_columns().iter().any(Option::is_some), + "fixture must retain lookup GPU input" + ); + } + let quotient_input = QuotientCommitInput { + circuit: &circuit, + lookup_publics: vec![beta, gamma, Scalar::ZERO, totals[0]], + trace_domain: domain, + quotient_domain: Radix2Coset { + log_size: log_size + 2, + shift: Scalar(Fr::from(7)), + }, + preprocessed: Some((&fixed_data, 0)), + stage_1: (&main_data, 0), + stage_2: (&stage2, 0), + constraint_count: circuit.constraint_count, + }; + let alpha = Scalar(Fr::from(29)); + let reference = + quotient_reference("ient_input, &fixed, &main, &expected, alpha); + for mode in [PassMode::Off, PassMode::Force] { + let actual = + quotient::coefficients_with_mode("ient_input, alpha, mode) + .expect("fixture must execute CUDA quotient construction"); + assert_eq!(actual, reference, "quotient mode={mode:?}, beta={beta:?}"); + } + } +} + +#[test] +#[ignore = "requires a CUDA GPU; 1024-row host inputs"] +fn dense_and_pass_constraints_match_cpu_with_zero_denominators() { + compare_paths(10); +} + +#[test] +#[ignore = "requires a CUDA GPU; 65536-row resident inputs"] +fn resident_dense_and_pass_constraints_match_cpu() { + compare_paths(16); +} diff --git a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs new file mode 100644 index 000000000..ba22c5864 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs @@ -0,0 +1,585 @@ +//! Opt-in dense primitive measurements for the 2^24 PLONK domain. + +use super::*; +use crate::kzg::srs::ignition::load_ignition_cache_with_identity; +use ark_serialize::CanonicalSerialize; +use serde_json::{Value, json}; +use std::{ + fs::{File, OpenOptions}, + io::{Seek, Write}, + path::PathBuf, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +const N: usize = 1 << 24; +const CACHE: &str = + "/opt/dlami/nvme/ix-bn254/parameters/ignition/ix-prefix-26.bin"; +const CACHE_PIN: &str = + "6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b"; +const SEED: &[u8] = b"ix/bn254/plonk-scale/dense-primitives/v1"; + +unsafe extern "C" { + fn ix_kzg_srs_plan( + device: i32, + count: usize, + window: *mut u32, + persistent: *mut usize, + temporary: *mut usize, + ) -> i32; + fn ix_kzg_srs_upload( + device: i32, + input: *const c_void, + count: usize, + output: *mut *mut c_void, + ) -> i32; + fn ix_kzg_srs_destroy(device: i32, points: *mut c_void) -> i32; + fn ix_kzg_srs_workspace_create( + device: i32, + count: usize, + output: *mut *mut c_void, + ) -> i32; + fn ix_kzg_srs_workspace_destroy(device: i32, workspace: *mut c_void) -> i32; + fn ix_kzg_srs_msm( + device: i32, + workspace: *mut c_void, + points: *const c_void, + point_offset: usize, + output: *mut ProjectiveLimbs, + host: *const [u64; 4], + polynomial: *const c_void, + scalar_offset: usize, + count: usize, + normalization: *const [u64; 4], + ) -> i32; +} + +struct Report { + file: File, + value: Value, +} + +impl Report { + fn new(operation: &str, repeats: usize, ordinal: i32) -> Self { + let path = PathBuf::from( + std::env::var_os("IX_PLONK_PRIMITIVE_REPORT") + .expect("set IX_PLONK_PRIMITIVE_REPORT to a new report path"), + ); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .expect("create a new primitive benchmark report"); + let mut report = Self { + file, + value: json!({ + "format": "ix-bn254-plonk-scale-primitives/v1", + "operation": operation, + "completed": false, + "all_comparisons_passed": false, + "started_unix_seconds": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), + "device_ordinal": ordinal, + "cuda_visible_devices": std::env::var("CUDA_VISIBLE_DEVICES").ok(), + "compiled_cuda_architectures": env!("IX_KZG_CUDA_COMPILED_ARCHS"), + "rayon_threads": rayon::current_num_threads(), + "warm_repeats": repeats, + "seed": String::from_utf8_lossy(SEED), + "field_vector_hash_encoding": "BLAKE3 of little-endian Montgomery Fr limbs; native ABI checked", + "scope": "Existing ix BN254 CUDA primitives, not a gnark CUDA prover. Native calls include host transfers. Only the first case is process-cold; OS page-cache state is uncontrolled.", + "warm_definition": "Repeated identical operation within one case; uncached MSM still uploads its bases on every call", + "records": [], + }), + }; + report.save(); + report + } + + fn save(&mut self) { + self.file.set_len(0).unwrap(); + self.file.rewind().unwrap(); + serde_json::to_writer_pretty(&mut self.file, &self.value).unwrap(); + self.file.write_all(b"\n").unwrap(); + self.file.flush().unwrap(); + } + + fn record(&mut self, record: Value) { + eprintln!("plonk-scale {}", serde_json::to_string(&record).unwrap()); + self.value["records"].as_array_mut().unwrap().push(record); + self.save(); + } +} + +fn timed(operation: impl FnOnce() -> T) -> (T, f64) { + let start = Instant::now(); + let result = operation(); + (result, start.elapsed().as_secs_f64()) +} + +fn dense(count: usize) -> Vec { + (0..count) + .into_par_iter() + .map(|index| { + let mut wide = [0; 64]; + blake3::Hasher::new() + .update(SEED) + .update(&(index as u64).to_le_bytes()) + .finalize_xof() + .fill(&mut wide); + let value = Fr::from_le_bytes_mod_order(&wide); + if value.is_zero() { Fr::ONE } else { value } + }) + .collect() +} + +fn field_hash(values: &[Fr]) -> String { + // The ABI asserts a padding-free four-limb Fr representation. + let bytes = unsafe { + std::slice::from_raw_parts( + values.as_ptr().cast::(), + size_of_val(values), + ) + }; + let mut hash = blake3::Hasher::new(); + hash.update_rayon(bytes); + hash.finalize().to_hex().to_string() +} + +fn point_hex(point: G1Projective) -> String { + let mut bytes = Vec::new(); + point.into_affine().serialize_compressed(&mut bytes).unwrap(); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn memory(device: i32) -> Value { + unsafe extern "C" { + fn cudaMemGetInfo(free: *mut usize, total: *mut usize) -> i32; + fn cudaDeviceGetDefaultMemPool(pool: *mut *mut c_void, device: i32) -> i32; + fn cudaDeviceGetMemPool(pool: *mut *mut c_void, device: i32) -> i32; + fn cudaMemPoolGetAttribute( + pool: *mut c_void, + attribute: i32, + value: *mut c_void, + ) -> i32; + } + + let (mut free, mut total) = (0, 0); + check( + unsafe { ix_kzg_memory(device, &mut free, &mut total) }, + "benchmark memory query", + ); + // ix_kzg_memory selects this device before querying its allocation pool. + let (mut driver_free, mut driver_total) = (0, 0); + check( + unsafe { cudaMemGetInfo(&mut driver_free, &mut driver_total) }, + "benchmark driver memory query", + ); + let mut default_pool = std::ptr::null_mut(); + check( + unsafe { cudaDeviceGetDefaultMemPool(&mut default_pool, device) }, + "benchmark default allocation pool", + ); + let mut active_pool = std::ptr::null_mut(); + check( + unsafe { cudaDeviceGetMemPool(&mut active_pool, device) }, + "benchmark active allocation pool", + ); + let statistics = |pool| { + // cudaMemPoolAttr 5..=8 each returns a uint64_t byte count. + let mut values = [0u64; 4]; + for (value, attribute) in values.iter_mut().zip(5..=8) { + check( + unsafe { + cudaMemPoolGetAttribute( + pool, + attribute, + std::ptr::from_mut(value).cast(), + ) + }, + "benchmark allocation pool statistics", + ); + } + json!({ + "reserved_current_bytes": values[0], + "reserved_high_bytes": values[1], + "used_current_bytes": values[2], + "used_high_bytes": values[3], + }) + }; + json!({ + "total_bytes": total, + "available_including_reusable_pool_bytes": free, + "driver_total_bytes": driver_total, + "driver_free_bytes": driver_free, + "driver_used_bytes": driver_total.saturating_sub(driver_free), + "default_pool": statistics(default_pool), + "active_pool_is_default": active_pool == default_pool, + "different_active_pool": if active_pool == default_pool { + None + } else { + Some(statistics(active_pool)) + }, + "pool_high_water_scope": "Cumulative since pool creation or last external reset; this benchmark does not reset it", + "pool_peak_is_total_vram_peak": false, + "pool_peak_exclusions": "Driver/context/module overhead and allocations outside this pool; driver_used_bytes is only an instantaneous snapshot", + }) +} + +struct CachedMsm<'a, 'b> { + device: &'a Device<'b>, + points: *mut c_void, + workspace: *mut c_void, +} + +impl<'a, 'b> CachedMsm<'a, 'b> { + fn new(device: &'a Device<'b>, points: &[G1Affine]) -> (Self, Value) { + let mut handle = Self { + device, + points: std::ptr::null_mut(), + workspace: std::ptr::null_mut(), + }; + let (mut window, mut persistent, mut temporary) = (0, 0, 0); + check( + unsafe { + ix_kzg_srs_plan( + device.id(), + points.len(), + &mut window, + &mut persistent, + &mut temporary, + ) + }, + "benchmark cached MSM plan", + ); + let (_, workspace_seconds) = timed(|| { + check( + unsafe { + ix_kzg_srs_workspace_create( + device.id(), + points.len(), + &mut handle.workspace, + ) + }, + "benchmark cached MSM workspace", + ) + }); + let (_, upload_seconds) = timed(|| { + check( + unsafe { + ix_kzg_srs_upload( + device.id(), + points.as_ptr().cast(), + points.len(), + &mut handle.points, + ) + }, + "benchmark cached MSM points", + ) + }); + ( + handle, + json!({ + "native_count": points.len(), "window": window, + "planned_persistent_workspace_bytes": persistent, + "planned_temporary_workspace_bytes": temporary, + "device_point_bytes": points.len() * 64, + "workspace_seconds": workspace_seconds, "point_upload_seconds": upload_seconds, + "initialization_seconds": workspace_seconds + upload_seconds, + }), + ) + } + + fn invoke(&self, scalars: &[Fr]) -> G1Projective { + let mut output = [[0; FQ_LIMBS]; 3]; + check( + unsafe { + ix_kzg_srs_msm( + self.device.id(), + self.workspace, + self.points, + 0, + &mut output, + scalars.as_ptr().cast(), + std::ptr::null(), + 0, + scalars.len(), + std::ptr::null(), + ) + }, + "benchmark cached MSM", + ); + projective(output) + } + + fn close(mut self) { + let points = std::mem::replace(&mut self.points, std::ptr::null_mut()); + check( + unsafe { ix_kzg_srs_destroy(self.device.id(), points) }, + "benchmark cached points destruction", + ); + let workspace = + std::mem::replace(&mut self.workspace, std::ptr::null_mut()); + check( + unsafe { ix_kzg_srs_workspace_destroy(self.device.id(), workspace) }, + "benchmark cached workspace destruction", + ); + } +} + +impl Drop for CachedMsm<'_, '_> { + fn drop(&mut self) { + if !self.points.is_null() { + unsafe { ix_kzg_srs_destroy(self.device.id(), self.points) }; + } + if !self.workspace.is_null() { + unsafe { ix_kzg_srs_workspace_destroy(self.device.id(), self.workspace) }; + } + } +} + +fn uncached_msm( + device: &Device<'_>, + points: &[G1Affine], + scalars: &[Fr], +) -> (G1Projective, Value) { + let (resident, upload_seconds) = timed(|| upload_msm_points(device, points)); + let mut output = [[0; FQ_LIMBS]; 3]; + let (_, invoke_seconds) = timed(|| { + check( + unsafe { + ix_kzg_msm_invoke( + device.id(), + resident.context, + &mut output, + scalars.as_ptr().cast(), + scalars.len(), + std::ptr::null(), + ) + }, + "benchmark uncached MSM", + ) + }); + let (_, destroy_seconds) = timed(|| { + let status = unsafe { ix_kzg_msm_destroy(device.id(), resident.context) }; + std::mem::forget(resident); + check(status, "benchmark uncached MSM destruction"); + }); + ( + projective(output), + json!({ + "point_upload_and_workspace_seconds": upload_seconds, + "invoke_seconds": invoke_seconds, "destroy_seconds": destroy_seconds, + }), + ) +} + +fn msm_benchmark(report: &mut Report, cached: bool, repeats: usize) { + let pin: [u8; 32] = std::array::from_fn(|i| { + u8::from_str_radix(&CACHE_PIN[2 * i..2 * i + 2], 16).unwrap() + }); + // The power-of-two loader must read 2N to authenticate the N+3 range. + let ((srs, identity), load_seconds) = timed(|| { + load_ignition_cache_with_identity(std::path::Path::new(CACHE), pin, 2 * N) + .unwrap() + }); + report.record(json!({ + "phase": "authenticated_srs_load", "seconds": load_seconds, + "cache": CACHE, "cache_manifest_blake3": CACHE_PIN, + "loaded_prefix_points": srs.g1.len(), "largest_used_point_end": N+10, + "public_max_degree": srs.public_setup.max_degree, + "setup_id": blake3::Hash::from(srs.public_setup.id).to_hex().to_string(), + "source_manifest_blake3": blake3::Hash::from(identity.source_manifest_digest).to_hex().to_string(), + })); + let (values, generate_seconds) = timed(|| dense(N + 3)); + let (input_hash, hash_seconds) = timed(|| field_hash(&values)); + report.record(json!({ + "phase": "dense_scalar_generation", "elements": values.len(), + "seconds": generate_seconds, "hash_seconds": hash_seconds, + "input_montgomery_blake3": input_hash, "zero_scalars": 0, + })); + for (offset, count) in [(0, N), (7, N + 3)] { + let points = &srs.g1[offset..offset + count]; + let scalars = &values[..count]; + eprintln!("plonk-scale phase=cpu_msm offset={offset} count={count}"); + let (expected, cpu_seconds) = + timed(|| G1Projective::msm(points, scalars).unwrap()); + assert!( + !expected.is_zero(), + "dense reference unexpectedly equals infinity" + ); + report.record(json!({ + "phase": "cpu_msm_reference", "offset": offset, "count": count, + "seconds": cpu_seconds, "compressed_result": point_hex(expected), + })); + let device = Devices::get().acquire_at(0); + assert!( + device.prepare_budget(N * MSM_BYTES_PER_POINT + (1 << 30)), + "full N-point MSM cannot be admitted" + ); + let (resident, initialization) = if cached { + let (resident, initialization) = CachedMsm::new(&device, &points[..N]); + (Some(resident), initialization) + } else { + (None, json!({"initialization_seconds": 0.0})) + }; + report.record(json!({ + "phase": "msm_initialization", "offset": offset, "count": count, + "cached": cached, "details": initialization, + "memory": memory(device.id()), + })); + for trial in 0..=repeats { + let (point, gpu_seconds, details) = if let Some(resident) = &resident { + let (point, seconds) = timed(|| resident.invoke(&scalars[..N])); + (point, seconds, json!({})) + } else { + let ((point, details), seconds) = + timed(|| uncached_msm(&device, &points[..N], &scalars[..N])); + (point, seconds, details) + }; + let (actual, tail_seconds) = timed(|| { + if count == N { + point + } else { + point + G1Projective::msm(&points[N..], &scalars[N..]).unwrap() + } + }); + let matches = actual == expected; + report.record(json!({ + "phase": "gpu_msm", "trial": trial, + "warm": trial != 0, "cached": cached, "offset": offset, + "process_first_msm": offset == 0 && trial == 0, + "basis_first_use": trial == 0, + "resident_basis_reused": cached && trial != 0, + "host_point_end": offset+count, "count": count, + "native_gpu_count": N, "native_gpu_calls": 1, "native_point_offset": 0, + "cpu_tail_points": count-N, + "gpu_call_seconds": gpu_seconds, "cpu_tail_and_add_seconds": tail_seconds, + "operation_seconds": gpu_seconds+tail_seconds, + "first_use_with_initialization_seconds": if trial == 0 { + Some(gpu_seconds+tail_seconds+initialization["initialization_seconds"].as_f64().unwrap()) + } else { None }, + "compressed_result": point_hex(actual), "matches_full_cpu_reference": matches, + "native_details": details, "memory": memory(device.id()), + })); + assert!(matches, "large MSM differs from full CPU reference"); + } + if let Some(resident) = resident { + let (_, seconds) = timed(|| resident.close()); + report.record(json!({"phase": "cached_msm_cleanup", "seconds": seconds})); + } + } +} + +fn fft_benchmark(report: &mut Report, log_size: usize, repeats: usize) { + let inverse = match std::env::var("IX_PLONK_PRIMITIVE_DIRECTION") + .as_deref() + .unwrap_or("forward") + { + "forward" => false, + "inverse" => true, + _ => panic!("IX_PLONK_PRIMITIVE_DIRECTION must be forward or inverse"), + }; + let shift: u64 = std::env::var("IX_PLONK_PRIMITIVE_SHIFT") + .unwrap_or_else(|_| "5".into()) + .parse() + .unwrap(); + assert!(shift != 0, "FFT coset shift must be nonzero"); + let shift = Fr::from(shift); + let count = 1usize << log_size; + let (input, generate_seconds) = timed(|| dense(count)); + let (input_hash, input_hash_seconds) = timed(|| field_hash(&input)); + report.record(json!({ + "phase": "dense_fft_input", "log_size": log_size, "elements": count, + "bytes_per_vector": count*32, "inverse": inverse, + "shift": shift.into_bigint().to_string(), "generation_seconds": generate_seconds, + "hash_seconds": input_hash_seconds, "input_montgomery_blake3": input_hash, + })); + let (domain, domain_seconds) = timed(|| { + Radix2EvaluationDomain::::new(count).unwrap().get_coset(shift).unwrap() + }); + let (mut expected, cpu_copy_seconds) = timed(|| input.clone()); + eprintln!("plonk-scale phase=cpu_fft log_size={log_size} inverse={inverse}"); + let (_, cpu_seconds) = timed(|| { + if inverse { + domain.ifft_in_place(&mut expected) + } else { + domain.fft_in_place(&mut expected) + } + }); + let (expected_hash, hash_seconds) = timed(|| field_hash(&expected)); + report.record(json!({ + "phase": "cpu_fft_reference", "domain_seconds": domain_seconds, + "input_copy_seconds": cpu_copy_seconds, "seconds": cpu_seconds, + "hash_seconds": hash_seconds, "output_montgomery_blake3": expected_hash, + })); + for trial in 0..=repeats { + let (mut actual, copy_seconds) = timed(|| input.clone()); + let (_, gpu_seconds) = timed(|| fft(&mut actual, inverse, shift)); + let (mismatch, compare_seconds) = timed(|| { + actual + .par_iter() + .zip(&expected) + .enumerate() + .find_any(|(_, (a, b))| a != b) + .map(|(index, _)| index) + }); + let (actual_hash, hash_seconds) = timed(|| field_hash(&actual)); + report.record(json!({ + "phase": "gpu_fft", "trial": trial, "warm": trial != 0, + "native_gpu_count": count, "native_gpu_log_size": log_size, + "native_gpu_calls": 1, "inverse": inverse, + "input_copy_seconds": copy_seconds, "gpu_call_seconds": gpu_seconds, + "compare_seconds": compare_seconds, "hash_seconds": hash_seconds, + "output_montgomery_blake3": actual_hash, "mismatch_index": mismatch, + "matches_full_cpu_reference": mismatch.is_none(), + "memory": memory(Devices::get().ids[0]), + })); + assert!(mismatch.is_none(), "large FFT differs from full CPU reference"); + } +} + +#[test] +#[ignore = "full-scale CUDA benchmark; set IX_PLONK_PRIMITIVE_OP and IX_PLONK_PRIMITIVE_REPORT and run this test alone"] +fn bn254_plonk_scale_primitives() { + assert!(cfg!(feature = "parallel"), "build with parallel CPU references"); + let operation = std::env::var("IX_PLONK_PRIMITIVE_OP") + .expect("explicit primitive operation required"); + assert!(matches!( + operation.as_str(), + "msm-cached" | "msm-uncached" | "fft-n" | "fft-m" + )); + let repeats: usize = std::env::var("IX_PLONK_PRIMITIVE_REPEATS") + .unwrap_or_else(|_| "3".into()) + .parse() + .unwrap(); + assert!((1..=10).contains(&repeats)); + let device: i32 = std::env::var("IX_PLONK_PRIMITIVE_DEVICE") + .unwrap_or_else(|_| "0".into()) + .parse() + .unwrap(); + assert!( + configure(Settings { + devices: Some(vec![device]), + msm_chunk_points: N, + srs_cache: true, + ..Settings::default() + }), + "run the benchmark alone in a fresh process" + ); + let mut report = Report::new(&operation, repeats, device); + let start = Instant::now(); + let (_, initialization_seconds) = timed(Devices::get); + report.record(json!({ + "phase": "device_initialization", "seconds": initialization_seconds, + "memory": memory(device), + })); + match operation.as_str() { + "msm-cached" => msm_benchmark(&mut report, true, repeats), + "msm-uncached" => msm_benchmark(&mut report, false, repeats), + "fft-n" => fft_benchmark(&mut report, 24, repeats), + "fft-m" => fft_benchmark(&mut report, 26, repeats), + _ => unreachable!(), + } + report.value["total_seconds"] = json!(start.elapsed().as_secs_f64()); + report.value["completed"] = json!(true); + report.value["all_comparisons_passed"] = json!(true); + report.save(); +} diff --git a/crates/proof-compression/src/kzg/curve.rs b/crates/proof-compression/src/kzg/curve.rs new file mode 100644 index 000000000..ddac3eafe --- /dev/null +++ b/crates/proof-compression/src/kzg/curve.rs @@ -0,0 +1,145 @@ +//! Curve types and canonical encoding widths for the selected KZG backend. +//! +//! BN254 is selected with `--no-default-features --features bn254`. +//! The default backend is BLS12-381. Encodings are arkworks canonical +//! encodings; these widths do not describe in-memory or EVM representations. + +use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, Read, SerializationError, +}; + +#[cfg(not(feature = "bn254"))] +pub use ark_bls12_381::{ + Bls12_381 as PairingCurve, Fq, Fr, G1Affine, G1Projective, G2Affine, + G2Projective, +}; +#[cfg(feature = "bn254")] +pub use ark_bn254::{ + Bn254 as PairingCurve, Fq, Fr, G1Affine, G1Projective, G2Affine, G2Projective, +}; + +#[cfg(not(feature = "bn254"))] +pub const NAME: &str = "bls12-381"; +#[cfg(feature = "bn254")] +pub const NAME: &str = "bn254"; + +#[cfg(not(feature = "bn254"))] +pub const G1_COMPRESSED_BYTES: usize = 48; +#[cfg(feature = "bn254")] +pub const G1_COMPRESSED_BYTES: usize = 32; + +pub const G1_UNCOMPRESSED_BYTES: usize = G1_COMPRESSED_BYTES * 2; +pub const G2_COMPRESSED_BYTES: usize = G1_COMPRESSED_BYTES * 2; +pub const G2_UNCOMPRESSED_BYTES: usize = G2_COMPRESSED_BYTES * 2; + +/// Decode one validated point with a unique compressed representation. +/// Arkworks' generic short-Weierstrass decoder ignores the x-coordinate +/// when the infinity flag is set, so validation alone permits aliases. +pub(crate) fn read_g1_compressed( + input: &mut &[u8], +) -> Result { + let mut bytes = [0; G1_COMPRESSED_BYTES]; + input.read_exact(&mut bytes)?; + let point = G1Affine::deserialize_compressed(bytes.as_slice())?; + let mut canonical = [0; G1_COMPRESSED_BYTES]; + point.serialize_compressed(canonical.as_mut_slice())?; + if bytes != canonical { + return Err(SerializationError::InvalidData); + } + Ok(point) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ec::AffineRepr; + + #[test] + fn canonical_point_widths_match_arkworks() { + for point in [G1Affine::identity(), G1Affine::generator()] { + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), G1_COMPRESSED_BYTES); + assert_eq!(G1Affine::deserialize_compressed(&*bytes).unwrap(), point); + bytes.clear(); + point.serialize_uncompressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), G1_UNCOMPRESSED_BYTES); + assert_eq!(G1Affine::deserialize_uncompressed(&*bytes).unwrap(), point); + } + for point in [G2Affine::identity(), G2Affine::generator()] { + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), G2_COMPRESSED_BYTES); + assert_eq!(G2Affine::deserialize_compressed(&*bytes).unwrap(), point); + bytes.clear(); + point.serialize_uncompressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), G2_UNCOMPRESSED_BYTES); + assert_eq!(G2Affine::deserialize_uncompressed(&*bytes).unwrap(), point); + } + } + + #[test] + fn canonical_point_reader_preserves_sign_and_consumes_one_point() { + for point in + [G1Affine::identity(), G1Affine::generator(), -G1Affine::generator()] + { + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + bytes.extend([0x12, 0x34]); + let mut input = bytes.as_slice(); + assert_eq!(read_g1_compressed(&mut input).unwrap(), point); + assert_eq!(input, &[0x12, 0x34]); + } + } + + #[test] + fn canonical_point_reader_rejects_invalid_and_truncated_encodings() { + let mut bytes = Vec::new(); + G1Affine::generator().serialize_compressed(&mut bytes).unwrap(); + assert!( + read_g1_compressed(&mut &bytes[..G1_COMPRESSED_BYTES - 1]).is_err() + ); + assert!( + read_g1_compressed(&mut [0xff; G1_COMPRESSED_BYTES].as_slice()).is_err() + ); + } + + #[cfg(feature = "bn254")] + #[test] + fn bn254_compressed_point_vectors() { + let mut positive = [0; 32]; + positive[0] = 1; + let mut negative = positive; + negative[31] = 0x80; + let mut infinity = [0; 32]; + infinity[31] = 0x40; + for (point, expected) in [ + (G1Affine::generator(), positive), + (-G1Affine::generator(), negative), + (G1Affine::identity(), infinity), + ] { + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.as_slice(), expected.as_slice()); + assert_eq!(read_g1_compressed(&mut expected.as_slice()).unwrap(), point); + } + } + + #[cfg(feature = "bn254")] + #[test] + fn bn254_point_reader_rejects_infinity_alias_and_noncanonical_x() { + use ark_ff::{BigInteger, PrimeField}; + + let mut alias = [0; 32]; + alias[0] = 1; + alias[31] = 0x40; + assert_eq!( + G1Affine::deserialize_compressed(alias.as_slice()).unwrap(), + G1Affine::identity() + ); + assert!(read_g1_compressed(&mut alias.as_slice()).is_err()); + assert!( + read_g1_compressed(&mut Fq::MODULUS.to_bytes_le().as_slice()).is_err() + ); + } +} diff --git a/crates/proof-compression/src/kzg/domain.rs b/crates/proof-compression/src/kzg/domain.rs index 746a2305c..91503203e 100644 --- a/crates/proof-compression/src/kzg/domain.rs +++ b/crates/proof-compression/src/kzg/domain.rs @@ -18,12 +18,11 @@ use multi_stark::traits::LagrangeSelectors; use multi_stark::traits::TwoAdicField; use multi_stark::traits::batch_inverse; -use super::field::Scalar; +use super::{curve::Fr, field::Scalar}; const ONE: Scalar = >::ONE; -/// A coset `shift · H`, `|H| = 2^log_size`, over the BLS12-381 scalar -/// field. +/// A coset `shift · H`, `|H| = 2^log_size`, over the selected scalar field. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Radix2Coset { pub log_size: usize, @@ -54,6 +53,10 @@ impl EvaluationDomain for Radix2Coset { #[inline] fn size(&self) -> usize { + assert!( + self.log_size <= Scalar::TWO_ADICITY, + "domain exceeds scalar two-adicity" + ); 1 << self.log_size } @@ -68,13 +71,17 @@ impl EvaluationDomain for Radix2Coset { } fn create_disjoint_domain(&self, min_size: usize) -> Self { + let log_size = p3_util::log2_ceil_usize(min_size); + assert!( + log_size <= Scalar::TWO_ADICITY, + "domain exceeds scalar two-adicity" + ); // Multiplying the shift by the field's multiplicative generator // leaves the subgroup (and every subgroup coset reachable by // repeated application), exactly as in p3. Self { - log_size: p3_util::log2_ceil_usize(min_size), - shift: self.shift - * Scalar(::GENERATOR), + log_size, + shift: self.shift * Scalar(::GENERATOR), } } @@ -176,4 +183,26 @@ mod tests { assert_eq!(domain.next_point(points[i]), points[i + 1]); } } + + #[test] + fn largest_domain_has_supported_order() { + let trace = Radix2Coset { log_size: Scalar::TWO_ADICITY, shift: ONE }; + assert_eq!(trace.size(), 1usize << Scalar::TWO_ADICITY); + assert_eq!(trace.generator().exp_power_of_2(Scalar::TWO_ADICITY), ONE); + let coset = trace.create_disjoint_domain(trace.size()); + assert_ne!(coset.shift.exp_power_of_2(Scalar::TWO_ADICITY), ONE); + } + + #[test] + #[should_panic(expected = "domain exceeds scalar two-adicity")] + fn disjoint_domain_rejects_oversized_quotient() { + let trace = Radix2Coset { log_size: Scalar::TWO_ADICITY, shift: ONE }; + trace.create_disjoint_domain(trace.size() + 1); + } + + #[test] + #[should_panic(expected = "domain exceeds scalar two-adicity")] + fn rejects_oversized_domain() { + Radix2Coset { log_size: Scalar::TWO_ADICITY + 1, shift: ONE }.size(); + } } diff --git a/crates/proof-compression/src/kzg/encoding.rs b/crates/proof-compression/src/kzg/encoding.rs index 75884eeb0..eecffc89c 100644 --- a/crates/proof-compression/src/kzg/encoding.rs +++ b/crates/proof-compression/src/kzg/encoding.rs @@ -2,7 +2,7 @@ use std::io::{self, Read, Write}; -use ark_bls12_381::Fr; +use super::curve::Fr; use ark_ff::{BigInt, PrimeField}; use p3_maybe_rayon::prelude::*; @@ -100,7 +100,7 @@ pub fn read_scalars( #[cfg(test)] mod tests { use super::*; - use ark_ff::Field; + use ark_ff::{BigInteger, Field}; use ark_serialize::CanonicalSerialize; #[test] @@ -121,6 +121,9 @@ mod tests { #[test] fn scalar_stream_rejects_invalid_and_truncated_fields() { + assert!( + read_scalars(&mut Fr::MODULUS.to_bytes_le().as_slice(), 1).is_err() + ); assert!(read_scalars(&mut [255; 32].as_slice(), 1).is_err()); assert!(read_scalars(&mut [0; 31].as_slice(), 1).is_err()); assert!(read_scalars(&mut [].as_slice(), 0).unwrap().is_empty()); diff --git a/crates/proof-compression/src/kzg/fft_batch.rs b/crates/proof-compression/src/kzg/fft_batch.rs index aae143fe9..9ecd47cc3 100644 --- a/crates/proof-compression/src/kzg/fft_batch.rs +++ b/crates/proof-compression/src/kzg/fft_batch.rs @@ -1,5 +1,5 @@ use super::coefficients::Column; -use ark_bls12_381::Fr; +use super::curve::Fr; use ark_ff::{AdditiveGroup, Zero}; use ark_poly::{EvaluationDomain, Radix2EvaluationDomain}; use p3_maybe_rayon::prelude::*; diff --git a/crates/proof-compression/src/kzg/field.rs b/crates/proof-compression/src/kzg/field.rs index bd414eb08..8465b07b7 100644 --- a/crates/proof-compression/src/kzg/field.rs +++ b/crates/proof-compression/src/kzg/field.rs @@ -1,19 +1,19 @@ -//! Crate field traits for the BLS12-381 scalar field. +//! Crate field traits for the selected KZG curve's scalar field. //! -//! [`Scalar`] is a `repr(transparent)` newtype over `ark_bls12_381::Fr`. +//! [`Scalar`] is a `repr(transparent)` newtype over [`Fr`]. //! A newtype (unlike the p3 fields, which implement the crate traits //! directly) because the traits require serde and arkworks types only //! speak `CanonicalSerialize`; the wrapper carries a canonical 32-byte //! little-endian serde encoding and every crate trait. //! //! The field is its own challenge field: `ExtensionOf` with -//! `D = 1` (|Fr| ~ 2^255 dwarfs every Schwartz-Zippel term), and its own +//! `D = 1`, and its own //! packing with `WIDTH = 1` (the sweep runs scalar; MSM dominates the //! prover here, not constraint evaluation). use core::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; -use ark_bls12_381::Fr; +use super::curve::Fr; use ark_ff::{AdditiveGroup, FftField, Field as ArkField}; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use p3_matrix::Matrix; @@ -26,7 +26,7 @@ use multi_stark::traits::Packed; use multi_stark::traits::PackedExtension; use multi_stark::traits::TwoAdicField; -/// The BLS12-381 scalar field as a crate [`Field`]. +/// The selected KZG curve's scalar field as a crate [`Field`]. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] #[repr(transparent)] pub struct Scalar(pub Fr); @@ -186,12 +186,11 @@ impl Field for Scalar { } impl TwoAdicField for Scalar { - // Fr has a 2^32 root of unity; pinned by a test below. - const TWO_ADICITY: usize = 32; + const TWO_ADICITY: usize = ::TWO_ADICITY as usize; #[inline] fn two_adic_generator(bits: usize) -> Self { - assert!(bits <= Self::TWO_ADICITY); + assert!(bits <= Self::TWO_ADICITY, "domain exceeds scalar two-adicity"); Self(Fr::get_root_of_unity(1u64 << bits).expect("two-adic subgroup exists")) } } @@ -267,6 +266,7 @@ impl multi_stark::traits::PrimeField for Scalar { #[cfg(test)] mod tests { use super::*; + use ark_ff::{BigInteger, PrimeField}; #[test] fn two_adicity_matches_ark() { @@ -280,17 +280,46 @@ mod tests { assert_eq!(g * g, Scalar::two_adic_generator(bits - 1)); } assert_eq!(Scalar::two_adic_generator(0), >::ONE); + assert_eq!( + Scalar::two_adic_generator(Scalar::TWO_ADICITY) + .exp_power_of_2(Scalar::TWO_ADICITY - 1), + Scalar::NEG_ONE + ); + #[cfg(feature = "bn254")] + assert_eq!(Scalar::TWO_ADICITY, 28); + #[cfg(not(feature = "bn254"))] + assert_eq!(Scalar::TWO_ADICITY, 32); + } + + #[test] + #[should_panic(expected = "domain exceeds scalar two-adicity")] + fn rejects_oversized_generator() { + Scalar::two_adic_generator(Scalar::TWO_ADICITY + 1); } #[test] fn serde_round_trip() { - let x = Scalar::from_u64(0xdead_beef_cafe_f00d); - let bytes = - bincode::serde::encode_to_vec(x, bincode::config::standard()).unwrap(); - let (y, _): (Scalar, usize) = - bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) - .unwrap(); - assert_eq!(x, y); + for x in + [Scalar::ZERO, Scalar::from_u64(0xdead_beef_cafe_f00d), Scalar::NEG_ONE] + { + let bytes = + bincode::serde::encode_to_vec(x, bincode::config::standard()).unwrap(); + assert_eq!(bytes, x.0.into_bigint().to_bytes_le()); + let (y, consumed): (Scalar, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) + .unwrap(); + assert_eq!(consumed, 32); + assert_eq!(x, y); + } + } + + #[test] + fn serde_rejects_noncanonical_scalars() { + for bytes in [Fr::MODULUS.to_bytes_le(), vec![255; 32], vec![0; 31]] { + let decoded: Result<(Scalar, usize), _> = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()); + assert!(decoded.is_err()); + } } #[test] diff --git a/crates/proof-compression/src/kzg/mod.rs b/crates/proof-compression/src/kzg/mod.rs index 7440a5110..97e6da4c8 100644 --- a/crates/proof-compression/src/kzg/mod.rs +++ b/crates/proof-compression/src/kzg/mod.rs @@ -1,4 +1,4 @@ -//! BLS12-381 KZG backend with BLAKE3 and Keccak-256 transcripts. +//! KZG backend with BLAKE3 and Keccak-256 transcripts. //! Commitments contain one G1 point per column; openings are batched by point. mod buffer; @@ -7,6 +7,7 @@ pub mod compact; pub mod config; #[cfg(feature = "cuda")] pub mod cuda; +pub mod curve; pub mod domain; pub mod encoding; mod fft_batch; diff --git a/crates/proof-compression/src/kzg/pcs.rs b/crates/proof-compression/src/kzg/pcs.rs index 03766b321..c867c565a 100644 --- a/crates/proof-compression/src/kzg/pcs.rs +++ b/crates/proof-compression/src/kzg/pcs.rs @@ -1,4 +1,4 @@ -//! Monomial-basis KZG over BLS12-381 implementing [`Pcs`]. +//! Monomial-basis KZG implementing [`Pcs`]. //! //! Each column has one G1 commitment under the declared full public degree. //! Domain sizes describe trace geometry; the public setup supplies the global @@ -22,7 +22,10 @@ use std::sync::Arc; -use ark_bls12_381::{Bls12_381, Fr, G1Affine, G1Projective}; +use super::curve::{ + Fr, G1_COMPRESSED_BYTES, G1Affine, G1Projective, PairingCurve, + read_g1_compressed, +}; use ark_ec::{CurveGroup, VariableBaseMSM, pairing::Pairing}; use ark_ff::{AdditiveGroup, Field as ArkField, Zero}; use ark_poly::{ @@ -39,6 +42,7 @@ use multi_stark::traits::OpenedValues; use multi_stark::traits::OpeningRounds; use multi_stark::traits::Pcs; use multi_stark::traits::Transcript; +use multi_stark::traits::TwoAdicField; use multi_stark::traits::VerifyRounds; use super::coefficients::Column; @@ -49,6 +53,11 @@ use super::field::Scalar; use super::srs::Srs; use super::transcript::KzgTranscript; +#[cfg(not(feature = "bn254"))] +const CHECKPOINT_MAGIC: &[u8; 8] = b"IXKZGCP4"; +#[cfg(feature = "bn254")] +const CHECKPOINT_MAGIC: &[u8; 8] = b"IXBNCP01"; + /// Per-matrix column commitments. #[derive(Clone, Debug, PartialEq, Eq)] pub struct KzgCommitment(pub Vec>); @@ -96,10 +105,10 @@ fn decode_commitment( let rows = count(&mut input, 8)?; let mut matrices = Vec::with_capacity(rows); for _ in 0..rows { - let width = count(&mut input, 48)?; + let width = count(&mut input, G1_COMPRESSED_BYTES)?; let mut points = Vec::with_capacity(width); for _ in 0..width { - points.push(G1Affine::deserialize_compressed(&mut input)?); + points.push(read_g1_compressed(&mut input)?); } matrices.push(points); } @@ -246,7 +255,7 @@ impl KzgProverData { &self, mut out: impl std::io::Write, ) -> Result<(), ark_serialize::SerializationError> { - out.write_all(b"IXKZGCP4")?; + out.write_all(CHECKPOINT_MAGIC)?; self.commitment.0.serialize_compressed(&mut out)?; (self.matrices.len() as u64).serialize_compressed(&mut out)?; for matrix in &self.matrices { @@ -276,7 +285,7 @@ impl KzgProverData { ) -> Result { let mut magic = [0; 8]; input.read_exact(&mut magic)?; - if &magic != b"IXKZGCP4" { + if &magic != CHECKPOINT_MAGIC { return Err(ark_serialize::SerializationError::InvalidData); } let commitment = KzgCommitment(Vec::deserialize_compressed(&mut input)?); @@ -289,7 +298,7 @@ impl KzgProverData { for i in 0..count { let log_size = usize::try_from(u64::deserialize_compressed(&mut input)?) .map_err(|_error| ark_serialize::SerializationError::InvalidData)?; - if log_size > 32 { + if log_size > Scalar::TWO_ADICITY { return Err(ark_serialize::SerializationError::InvalidData); } let shift = Scalar(Fr::deserialize_compressed(&mut input)?); @@ -338,7 +347,7 @@ impl KzgProverData { let mut reader = BufReader::with_capacity(1 << 20, &mut *guard); let mut magic = [0; 8]; reader.read_exact(&mut magic)?; - if &magic != b"IXKZGCP4" { + if &magic != CHECKPOINT_MAGIC { return Err(SerializationError::InvalidData); } let commitment = KzgCommitment(Vec::deserialize_compressed(&mut reader)?); @@ -350,7 +359,7 @@ impl KzgProverData { let mut matrices = Vec::with_capacity(count); for i in 0..count { let log = u64::deserialize_compressed(&mut reader)?; - if log > 32 { + if log > Scalar::TWO_ADICITY as u64 { return Err(SerializationError::InvalidData); } let log_size = usize::try_from(log) @@ -460,6 +469,13 @@ impl KzgPcs { 1 << 30, ); } + #[cfg(feature = "cuda")] + tracing::debug!( + rows = domain.size(), + columns = selected.len(), + reason = "small domain or disk-backed columns", + "KZG FFT using CPU evaluation" + ); let ark = Self::ark_domain(domain); let width = selected.len(); let height = domain.size(); @@ -574,6 +590,7 @@ impl KzgPcs { ) -> Self { let verifier = super::VerifierParams::with_max_trace_len(&srs, max_trace_len); + Self::validate_quotient_domain(&verifier, max_quotient_degree); Self { #[cfg(feature = "cuda")] srs_owner: Some(cuda::srs_cache::Owner::new(Arc::clone(&srs))), @@ -587,6 +604,7 @@ impl KzgPcs { verifier: super::VerifierParams, max_quotient_degree: usize, ) -> Self { + Self::validate_quotient_domain(&verifier, max_quotient_degree); Self { srs: None, #[cfg(feature = "cuda")] @@ -596,6 +614,18 @@ impl KzgPcs { } } + fn validate_quotient_domain( + verifier: &super::VerifierParams, + max_quotient_degree: usize, + ) { + assert!(max_quotient_degree.is_power_of_two()); + assert!( + verifier.max_trace_len().ilog2() + max_quotient_degree.ilog2() + <= ::TWO_ADICITY, + "trace and quotient domains exceed scalar two-adicity" + ); + } + fn prover_srs(&self) -> &Srs { self.srs.as_deref().expect("proving requires the full SRS") } @@ -737,6 +767,11 @@ impl KzgPcs { "proving requires the complete coefficient prefix" ); if coeffs.iter().skip(1).all(Zero::is_zero) { + #[cfg(feature = "cuda")] + tracing::debug!( + reason = "constant polynomial", + "KZG commitment using CPU arithmetic" + ); return self.prover_srs().g1[0] * coeffs.first().copied().unwrap_or(Fr::ZERO); } @@ -751,6 +786,12 @@ impl KzgPcs { } return cuda::msm(&self.prover_srs().g1[..coeffs.len()], coeffs); } + #[cfg(feature = "cuda")] + tracing::debug!( + points = coeffs.len(), + reason = "small MSM", + "KZG commitment using CPU MSM" + ); // Bound digit/bucket scratch and expose point-range parallelism in // addition to the MSM implementation's small number of scalar windows. const CHUNK: usize = 1 << 18; @@ -821,6 +862,12 @@ impl KzgPcs { let _ = resident.set(all_residents); columns } else { + tracing::debug!( + rows = domain.size(), + columns = inputs.len(), + reason = "small domain", + "KZG interpolation using CPU FFT" + ); Self::interpolate_on_host(domain, inputs) }; #[cfg(not(feature = "cuda"))] @@ -1010,6 +1057,13 @@ impl Pcs for KzgPcs { } continue; } + #[cfg(feature = "cuda")] + tracing::debug!( + column = column_index, + elements = column.len(), + reason = "small or disk-backed polynomial", + "KZG opening using CPU evaluation" + ); // Decode a disk column once for all requested points. column .visit(|start, chunk| { @@ -1224,7 +1278,7 @@ impl Pcs for KzgPcs { rhs += witness * r_power; r_power *= r; } - let check = Bls12_381::multi_pairing( + let check = PairingCurve::multi_pairing( [lhs.into_affine(), (-rhs).into_affine()], [self.verifier.g2, self.verifier.tau_g2], ); @@ -1283,6 +1337,12 @@ fn divide_by_linear(mut p: Vec, z: Fr) -> Vec { if p.len() >= 1 << 16 { return cuda::divide(&p, z); } + #[cfg(feature = "cuda")] + tracing::debug!( + elements = p.len(), + reason = "small polynomial", + "KZG opening using CPU division" + ); let n = p.len() - 1; if n <= POLYNOMIAL_BLOCK { let mut carry = Fr::ZERO; @@ -1326,6 +1386,63 @@ fn divide_by_linear(mut p: Vec, z: Fr) -> Vec { mod tests { use super::*; + #[test] + fn trace_and_quotient_domains_fit_the_scalar_field() { + let max_domain = 1usize << Scalar::TWO_ADICITY; + let anchors = + Srs::unsafe_dev_setup_prefix(2, max_domain - 1, b"domain-cap"); + let params = super::super::VerifierParams::with_max_trace_len( + &anchors, + max_domain / 2, + ); + KzgPcs::verifier_only(params.clone(), 2); + for degree in [0, 3, 4] { + assert!( + std::panic::catch_unwind(|| { + KzgPcs::verifier_only(params.clone(), degree) + }) + .is_err() + ); + } + let params = + super::super::VerifierParams::with_max_trace_len(&anchors, max_domain); + KzgPcs::verifier_only(params.clone(), 1); + assert!( + std::panic::catch_unwind(|| { KzgPcs::verifier_only(params.clone(), 2) }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + KzgPcs::with_max_trace_len(Arc::new(anchors), max_domain, 2) + }) + .is_err() + ); + } + + #[test] + fn checkpoints_reject_other_curves_and_oversized_domains() { + let pcs = + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(4, b"checkpoint-curve")), 2); + let domain = pcs.natural_domain_for_degree(4); + let (commitment, data) = + pcs.commit(vec![(domain, RowMajorMatrix::new_col(vec![Scalar::ONE; 4]))]); + let mut bytes = Vec::new(); + data.write_checkpoint(&mut bytes).unwrap(); + let decoded = KzgProverData::read_checkpoint(bytes.as_slice()).unwrap(); + assert_eq!(decoded.commitment, commitment); + + let mut wrong_curve = bytes.clone(); + let other_magic = + if cfg!(feature = "bn254") { b"IXKZGCP4" } else { b"IXBNCP01" }; + wrong_curve[..8].copy_from_slice(other_magic); + assert!(KzgProverData::read_checkpoint(wrong_curve.as_slice()).is_err()); + + let log_offset = 8 + commitment.0.compressed_size() + 8; + bytes[log_offset..log_offset + 8] + .copy_from_slice(&(Scalar::TWO_ADICITY as u64 + 1).to_le_bytes()); + assert!(KzgProverData::read_checkpoint(bytes.as_slice()).is_err()); + } + #[test] fn commitment_metadata_rejects_impossible_counts() { assert!(decode_commitment(&[255; 8]).is_err()); diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs index fef34990c..242cc4b78 100644 --- a/crates/proof-compression/src/kzg/srs.rs +++ b/crates/proof-compression/src/kzg/srs.rs @@ -4,8 +4,8 @@ //! The declared public degree is independent of the locally loaded prefix. //! [`Srs::unsafe_dev_setup`] reveals its trapdoor and is only for tests. -use ark_bls12_381::{ - Bls12_381, Fr, G1Affine, G1Projective, G2Affine, G2Projective, +use super::curve::{ + Fr, G1Affine, G1Projective, G2Affine, G2Projective, PairingCurve, }; use ark_ec::{ AffineRepr, CurveGroup, PrimeGroup, VariableBaseMSM, pairing::Pairing, @@ -15,7 +15,25 @@ use ark_serialize::CanonicalSerialize; use p3_maybe_rayon::prelude::*; use std::io::{self, Write}; +#[cfg(not(feature = "bn254"))] pub mod filecoin; +#[cfg(feature = "bn254")] +pub mod ignition; + +#[cfg(not(feature = "bn254"))] +const PUBLIC_SETUP_DOMAIN: &[u8] = + b"multi-stark/kzg/known-trapdoor-public-degree/v1"; +#[cfg(feature = "bn254")] +const PUBLIC_SETUP_DOMAIN: &[u8] = + b"multi-stark/kzg/bn254/known-trapdoor-public-degree/v1"; +#[cfg(not(feature = "bn254"))] +const DEV_TAU_DOMAIN: &[u8] = b"multi-stark/kzg/dev-srs"; +#[cfg(feature = "bn254")] +const DEV_TAU_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/dev-srs"; +#[cfg(not(feature = "bn254"))] +const VALIDATION_DOMAIN: &[u8] = b"multi-stark/kzg/srs-validate/v4"; +#[cfg(feature = "bn254")] +const VALIDATION_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/srs-validate/v4"; /// Identity and maximum polynomial degree of the complete public parameter set. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -28,7 +46,7 @@ impl PublicSetup { pub fn known_trapdoor(max_degree: usize, seed: &[u8]) -> Self { assert!(max_degree >= 1 && max_degree.checked_add(1).is_some()); let mut hash = blake3::Hasher::new(); - hash.update(b"multi-stark/kzg/known-trapdoor-public-degree/v1"); + hash.update(PUBLIC_SETUP_DOMAIN); hash.update(&(max_degree as u64).to_le_bytes()); hash.update(blake3::hash(seed).as_bytes()); Self { max_degree, id: *hash.finalize().as_bytes() } @@ -39,7 +57,7 @@ impl PublicSetup { pub(super) fn dev_tau(seed: &[u8]) -> Fr { let mut wide = [0u8; 64]; blake3::Hasher::new() - .update(b"multi-stark/kzg/dev-srs") + .update(DEV_TAU_DOMAIN) .update(seed) .finalize_xof() .fill(&mut wide); @@ -120,7 +138,7 @@ impl Srs { } // Preserve the validation challenge's exact encoding without retaining a - // second, serialized copy of all powers (12 GiB for a 2^28 SRS). + // second, serialized copy of all powers (12 GiB for a 2^28 BLS12-381 SRS). struct HashWriter(blake3::Hasher); impl Write for HashWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { @@ -132,7 +150,7 @@ impl Srs { } } let mut writer = HashWriter(blake3::Hasher::new()); - writer.0.update(b"multi-stark/kzg/srs-validate/v4"); + writer.0.update(VALIDATION_DOMAIN); writer.0.update(&self.public_setup.id); writer.0.update(&(self.public_setup.max_degree as u64).to_le_bytes()); self.g1.serialize_compressed(&mut writer).expect("infallible hash writer"); @@ -185,7 +203,7 @@ impl Srs { } } // e(high, H) = e(low, τH) ⇔ e(high, H)·e(−low, τH) = 1. - let check = Bls12_381::multi_pairing( + let check = PairingCurve::multi_pairing( [high.into_affine(), (-low).into_affine()], [self.g2, self.tau_g2], ); @@ -252,13 +270,45 @@ impl Srs { mod tests { use super::*; + #[test] + fn development_identity_and_anchors_are_curve_specific() { + let seed = b"curve-specific-setup"; + let srs = Srs::unsafe_dev_setup(4, seed); + let mut legacy_id = blake3::Hasher::new(); + legacy_id.update(b"multi-stark/kzg/known-trapdoor-public-degree/v1"); + legacy_id.update(&3u64.to_le_bytes()); + legacy_id.update(blake3::hash(seed).as_bytes()); + assert_eq!( + srs.public_setup.id == *legacy_id.finalize().as_bytes(), + !cfg!(feature = "bn254") + ); + + let mut wide = [0; 64]; + blake3::Hasher::new() + .update(b"multi-stark/kzg/dev-srs") + .update(seed) + .finalize_xof() + .fill(&mut wide); + let legacy_tau = Fr::from_le_bytes_mod_order(&wide); + assert_eq!(dev_tau(seed) == legacy_tau, !cfg!(feature = "bn254")); + assert_eq!( + srs.g1[1] == (G1Projective::generator() * legacy_tau).into_affine(), + !cfg!(feature = "bn254") + ); + assert_eq!( + srs.tau_g2 == (G2Projective::generator() * legacy_tau).into_affine(), + !cfg!(feature = "bn254") + ); + srs.validate().unwrap(); + } + #[test] fn development_powers_match_naive_at_chunk_boundaries() { let seed = b"chunk-powers"; let srs = Srs::unsafe_dev_setup(1 << 16, seed); let mut wide = [0u8; 64]; blake3::Hasher::new() - .update(b"multi-stark/kzg/dev-srs") + .update(DEV_TAU_DOMAIN) .update(seed) .finalize_xof() .fill(&mut wide); diff --git a/crates/proof-compression/src/kzg/srs/ignition.rs b/crates/proof-compression/src/kzg/srs/ignition.rs new file mode 100644 index 000000000..aba507e71 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/ignition.rs @@ -0,0 +1,260 @@ +//! Import Ignition transcripts using an independently trusted source manifest. +//! +//! A transcript's embedded checksum establishes integrity, not ceremony +//! provenance. The caller pins a manifest of complete source-file digests; +//! inspecting local downloads only produces a candidate for that trust decision. +//! Import checks all pinned file bytes and the retained powers, then publishes +//! a prefix cache atomically. Cache loads require the import receipt separately. +//! Ignition's shifted basis starts at `tau * G1`, with public degree `M - 1` +//! for `M` published entries, independent of the retained prefix. + +mod admission; +mod cache; +mod points; +mod source; + +pub use admission::IgnitionAdmission; +#[cfg(test)] +pub(crate) use admission::{test_admission, test_admission_cache}; +pub use cache::{ + IgnitionCacheIdentity, load_ignition_cache, load_ignition_cache_with_identity, +}; +pub use source::{ + Contribution, SHIFTED_TAU_BASIS, SOURCE_FORMAT, SourceManifest, Transcript, + inspect_ignition_source, read_source_manifest, +}; + +use super::super::curve::{Fr, G1Affine, G1Projective, G2Affine, PairingCurve}; +use ark_ec::{AffineRepr, CurveGroup, VariableBaseMSM, pairing::Pairing}; +use ark_ff::{Field, PrimeField, Zero}; +use std::{io, path::Path}; + +pub const MAX_G1_POINTS: usize = 100_800_000; +pub const MAX_PREFIX_LEN: usize = 1 << 26; +pub const IGNITION_MANIFEST_URL: &str = + "https://aztec-ignition.s3.amazonaws.com/MAIN%20IGNITION/manifest.json"; +pub const FINAL_POSITION: u32 = 197; +pub const FINAL_ADDRESS: &str = "0xa57ec000b7fb9ea9b0119efdee0b8f15f895c2f3"; +pub const PREDECESSOR_POSITION: u32 = 196; +pub const PREDECESSOR_ADDRESS: &str = + "0x9c3f372a2aacc0d7272f56a4664311b7647b0031"; + +#[derive(Clone, Copy, Debug)] +struct Anchors { + g1: [G1Affine; 2], + tau_g2: G2Affine, + contribution_g2: G2Affine, +} + +fn invalid( + message: impl Into>, +) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn validate_prefix(prefix_len: usize, total: usize) -> io::Result<()> { + if prefix_len < 2 + || !prefix_len.is_power_of_two() + || prefix_len > MAX_PREFIX_LEN + || prefix_len > total + { + return Err(invalid("invalid Ignition prefix length")); + } + Ok(()) +} + +fn validate_anchors(anchors: &Anchors) -> io::Result<()> { + if anchors.g1.iter().any(|point| { + point.is_zero() + || !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + }) || [anchors.tau_g2, anchors.contribution_g2].iter().any(|point| { + point.is_zero() + || !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + }) { + return Err(invalid("invalid Ignition anchor")); + } + if !PairingCurve::multi_pairing( + [anchors.g1[0], -G1Affine::generator()], + [G2Affine::generator(), anchors.tau_g2], + ) + .is_zero() + { + return Err(invalid( + "Ignition first power does not match its shifted basis", + )); + } + if !PairingCurve::multi_pairing( + [anchors.g1[1], -anchors.g1[0]], + [G2Affine::generator(), anchors.tau_g2], + ) + .is_zero() + { + return Err(invalid("Ignition anchors are not successive powers")); + } + Ok(()) +} + +/// Authenticate complete local source files and create a reusable prefix cache. +/// +/// `trusted_source_manifest` is the BLAKE3 of independently trusted manifest +/// bytes, not a digest read from the same untrusted download. All selected +/// transcript files are hashed, including bytes beyond the retained prefix. +/// An optional predecessor is checked against its own pinned files and powers, +/// followed by a contribution-link pairing. This is not a replay of the full +/// ceremony or verification of participant signatures. Memory is bounded by +/// chunks; the destination must not exist. +pub fn import_ignition_transcripts( + source_root: &Path, + source_manifest: &Path, + trusted_source_manifest: [u8; 32], + cache: &Path, + prefix_len: usize, +) -> io::Result { + let manifest = + read_source_manifest(source_manifest, trusted_source_manifest)?; + validate_prefix(prefix_len, manifest.num_g1_points as usize)?; + let mut output = cache::CacheWriter::create( + cache, + prefix_len, + manifest.public_setup()?, + trusted_source_manifest, + )?; + let previous = if let Some(previous) = &manifest.predecessor { + let mut progression = ProgressionCheck::new( + trusted_source_manifest, + prefix_len, + previous.position, + ); + let anchors = source::stream_contribution( + source_root, + &manifest, + previous, + prefix_len, + |points| progression.absorb(points), + )?; + validate_anchors(&anchors)?; + progression.verify(&anchors)?; + Some(anchors) + } else { + None + }; + let mut progression = ProgressionCheck::new( + trusted_source_manifest, + prefix_len, + manifest.contribution.position, + ); + let anchors = source::stream_contribution( + source_root, + &manifest, + &manifest.contribution, + prefix_len, + |points| { + progression.absorb(points)?; + output.push(points) + }, + )?; + validate_anchors(&anchors)?; + progression.verify(&anchors)?; + if let Some(previous) = previous + && !PairingCurve::multi_pairing( + [anchors.g1[0], -previous.g1[0]], + [G2Affine::generator(), anchors.contribution_g2], + ) + .is_zero() + { + return Err(invalid( + "Ignition contribution does not follow its predecessor", + )); + } + output.finish(&anchors) +} + +struct ProgressionCheck { + r: Fr, + next_power: Fr, + previous: Option, + low: G1Projective, + high: G1Projective, + expected: usize, + seen: usize, +} + +impl ProgressionCheck { + fn new(source_manifest: [u8; 32], prefix_len: usize, position: u32) -> Self { + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/bn254/ignition-prefix-progression/v1"); + hash.update(&source_manifest); + hash.update(&(prefix_len as u64).to_le_bytes()); + hash.update(&position.to_le_bytes()); + let mut reader = hash.finalize_xof(); + let r = loop { + let mut wide = [0; 64]; + reader.fill(&mut wide); + let candidate = Fr::from_le_bytes_mod_order(&wide); + if !candidate.is_zero() { + break candidate; + } + }; + Self { + r, + next_power: Fr::ONE, + previous: None, + low: G1Projective::zero(), + high: G1Projective::zero(), + expected: prefix_len, + seen: 0, + } + } + + fn absorb(&mut self, points: &[G1Affine]) -> io::Result<()> { + if points.is_empty() { + return Ok(()); + } + self.seen = self + .seen + .checked_add(points.len()) + .filter(|seen| *seen <= self.expected) + .ok_or_else(|| invalid("too many Ignition prefix points"))?; + if let Some(previous) = self.previous { + // Adjacency across transcript and chunk boundaries is part of the relation. + self.low += previous * self.next_power; + self.high += points[0] * self.next_power; + self.next_power *= self.r; + } + if points.len() > 1 { + let scalars: Vec<_> = (1..points.len()) + .map(|_| { + let scalar = self.next_power; + self.next_power *= self.r; + scalar + }) + .collect(); + self.low += G1Projective::msm(&points[..points.len() - 1], &scalars) + .expect("equal lengths"); + self.high += + G1Projective::msm(&points[1..], &scalars).expect("equal lengths"); + } + self.previous = points.last().copied(); + Ok(()) + } + + fn verify(self, anchors: &Anchors) -> io::Result<()> { + if self.seen != self.expected { + return Err(invalid("incomplete Ignition prefix")); + } + if !PairingCurve::multi_pairing( + [self.high.into_affine(), (-self.low).into_affine()], + [G2Affine::generator(), anchors.tau_g2], + ) + .is_zero() + { + return Err(invalid("Ignition prefix is not one tau progression")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/proof-compression/src/kzg/srs/ignition/admission.rs b/crates/proof-compression/src/kzg/srs/ignition/admission.rs new file mode 100644 index 000000000..2f632ba79 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/ignition/admission.rs @@ -0,0 +1,607 @@ +//! Portable, explicitly pinned authorization for one Ignition setup. + +use std::{ + fs::{self, File}, + io::{self, Read}, + path::Path, +}; + +use serde::{Deserialize, Serialize}; + +use super::{ + IgnitionCacheIdentity, SHIFTED_TAU_BASIS, cache, invalid, + load_ignition_cache_with_identity, + points::{decode_g1, decode_g2, encode_g1, encode_g2}, + source::{parse_source_manifest, read_source_manifest_bytes}, +}; +use crate::kzg::{ + PublicSetup, Srs, VerifierParams, + curve::{self, G1Affine, G2Affine}, +}; + +const FORMAT: &str = "ix-bn254-ignition-admission-v1"; +const MAX_ADMISSION_BYTES: u64 = 4 << 20; + +/// The pin is an explicit trust decision over setup identity and anchors. +/// Its embedded source manifest binds the identity to a specific ceremony +/// descriptor; neither the admission nor its parser authenticates participants. +#[derive(Clone, Debug)] +pub struct IgnitionAdmission { + source_manifest_json: String, + source_manifest_digest: [u8; 32], + public_setup: PublicSetup, + g1: [G1Affine; 2], + g2: G2Affine, + tau_g2: G2Affine, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdmissionEncoding { + format: String, + curve: String, + basis: String, + source_manifest_json: String, + source_manifest_blake3: String, + setup_id: String, + public_max_degree: u64, + g1: [String; 2], + g2: String, + tau_g2: String, +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn unhex(text: &str) -> io::Result<[u8; N]> { + if text.len() != 2 * N || !text.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(invalid("invalid Ignition admission hexadecimal field")); + } + let mut bytes = [0; N]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * index..2 * index + 2], 16) + .map_err(invalid)?; + } + Ok(bytes) +} + +impl IgnitionAdmission { + /// Authorize anchors from an authenticated import against an independently + /// pinned source descriptor. Only the cache's small manifest is loaded. + pub fn from_cache( + cache: &Path, + trusted_cache: [u8; 32], + source_manifest: &Path, + trusted_source: [u8; 32], + ) -> io::Result { + let (source_bytes, manifest) = + read_source_manifest_bytes(source_manifest, trusted_source)?; + let (srs, identity) = + load_ignition_cache_with_identity(cache, trusted_cache, 2)?; + if identity.source_manifest_digest != trusted_source + || identity.public_setup != manifest.public_setup()? + { + return Err(invalid( + "Ignition admission cache does not match its pinned source manifest", + )); + } + let admission = Self { + source_manifest_json: String::from_utf8(source_bytes).map_err(invalid)?, + source_manifest_digest: trusted_source, + public_setup: srs.public_setup, + g1: [srs.g1[0], srs.g1[1]], + g2: srs.g2, + tau_g2: srs.tau_g2, + }; + admission.check_cache(&srs, &identity)?; + Ok(admission) + } + + /// Canonical JSON for a new admission pin. The original source JSON bytes + /// remain unchanged inside its string field. + pub fn to_bytes(&self) -> io::Result> { + let encoding = AdmissionEncoding { + format: FORMAT.to_owned(), + curve: curve::NAME.to_owned(), + basis: SHIFTED_TAU_BASIS.to_owned(), + source_manifest_json: self.source_manifest_json.clone(), + source_manifest_blake3: hex(&self.source_manifest_digest), + setup_id: hex(&self.public_setup.id), + public_max_degree: self.public_setup.max_degree as u64, + g1: self.g1.map(|point| hex(&encode_g1(&point))), + g2: hex(&encode_g2(&self.g2)), + tau_g2: hex(&encode_g2(&self.tau_g2)), + }; + let mut bytes = serde_json::to_vec_pretty(&encoding).map_err(invalid)?; + bytes.push(b'\n'); + Ok(bytes) + } + + /// Authenticate the exact admission bytes before parsing. The trusted pin + /// must be configured separately from the proof, profile and admission file. + pub fn from_bytes( + bytes: &[u8], + trusted_admission: [u8; 32], + ) -> io::Result { + if bytes.len() as u64 > MAX_ADMISSION_BYTES { + return Err(invalid("Ignition admission exceeds 4 MiB")); + } + if blake3::hash(bytes).as_bytes() != &trusted_admission { + return Err(invalid("Ignition admission digest mismatch")); + } + let encoding: AdmissionEncoding = + serde_json::from_slice(bytes).map_err(invalid)?; + if encoding.format != FORMAT + || encoding.curve != curve::NAME + || encoding.basis != SHIFTED_TAU_BASIS + { + return Err(invalid( + "Ignition admission format, curve or basis mismatch", + )); + } + let source_manifest_digest = unhex(&encoding.source_manifest_blake3)?; + let manifest = parse_source_manifest( + encoding.source_manifest_json.as_bytes(), + source_manifest_digest, + )?; + let public_setup = PublicSetup { + max_degree: usize::try_from(encoding.public_max_degree) + .map_err(invalid)?, + id: unhex(&encoding.setup_id)?, + }; + if public_setup != manifest.public_setup()? { + return Err(invalid( + "Ignition admission setup identity or degree disagrees with its source manifest", + )); + } + let g1 = [ + decode_g1(&unhex::<64>(&encoding.g1[0])?)?, + decode_g1(&unhex::<64>(&encoding.g1[1])?)?, + ]; + let g2 = decode_g2(&unhex::<128>(&encoding.g2)?)?; + let tau_g2 = decode_g2(&unhex::<128>(&encoding.tau_g2)?)?; + cache::validate_anchors(&g1, g2, tau_g2)?; + Ok(Self { + source_manifest_json: encoding.source_manifest_json, + source_manifest_digest, + public_setup, + g1, + g2, + tau_g2, + }) + } + + pub fn read(path: &Path, pin: [u8; 32]) -> io::Result { + if !fs::metadata(path)?.is_file() { + return Err(invalid("Ignition admission must be a regular file")); + } + let file = File::open(path)?; + if !file.metadata()?.is_file() { + return Err(invalid("Ignition admission must be a regular file")); + } + let mut bytes = Vec::new(); + file.take(MAX_ADMISSION_BYTES + 1).read_to_end(&mut bytes)?; + Self::from_bytes(&bytes, pin) + } + + pub fn public_setup(&self) -> PublicSetup { + self.public_setup + } + + pub fn source_manifest_digest(&self) -> [u8; 32] { + self.source_manifest_digest + } + + pub fn anchors(&self) -> Srs { + Srs { + g1: self.g1.to_vec(), + g2: self.g2, + tau_g2: self.tau_g2, + public_setup: self.public_setup, + } + } + + pub fn pairing_keys(&self) -> [G2Affine; 2] { + [self.g2, self.tau_g2] + } + + pub fn check_verifier(&self, params: &VerifierParams) -> io::Result<()> { + if params.public_setup != self.public_setup + || params.g1 != self.g1 + || params.g2 != self.g2 + || params.tau_g2 != self.tau_g2 + { + return Err(invalid( + "verifier parameters do not match Ignition admission", + )); + } + super::validate_prefix( + params.max_trace_len(), + self.public_setup.max_degree + 1, + ) + } + + /// Compare an authenticated cache load with the admitted source and anchors. + /// This does not replace loading the cache against its separately trusted pin. + pub fn check_cache( + &self, + srs: &Srs, + identity: &IgnitionCacheIdentity, + ) -> io::Result<()> { + if srs.public_setup != self.public_setup + || identity.public_setup != self.public_setup + || identity.source_manifest_digest != self.source_manifest_digest + || srs.g1.len() < 2 + || srs.g1[..2] != self.g1 + || srs.g2 != self.g2 + || srs.tau_g2 != self.tau_g2 + || srs.max_len() > identity.prefix_len + { + return Err(invalid("cache does not match Ignition admission")); + } + super::validate_prefix( + identity.prefix_len, + self.public_setup.max_degree + 1, + )?; + super::validate_prefix(srs.max_len(), self.public_setup.max_degree + 1) + } +} + +#[cfg(test)] +pub(crate) fn test_admission( + tau: u64, + count: usize, + marker: u8, +) -> (IgnitionAdmission, Srs) { + use super::{ + Contribution, FINAL_ADDRESS, SOURCE_FORMAT, SourceManifest, Transcript, + }; + use crate::kzg::curve::{Fr, G1Projective}; + use ark_ec::{AffineRepr, CurveGroup, PrimeGroup}; + use ark_ff::{Field, PrimeField}; + + assert!(tau != 0 && count >= 2 && count.is_power_of_two()); + let tau = Fr::from(tau); + let g1: Vec<_> = (1..=count) + .map(|exponent| { + (G1Projective::generator() * tau.pow([exponent as u64])).into_affine() + }) + .collect(); + let tau_g2 = (G2Affine::generator() * tau).into_affine(); + let total = u32::try_from(count).unwrap(); + let mut transcript: Vec<_> = [0, 1, total, 1, total, 2, 0] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + for point in &g1 { + for coordinate in [point.x, point.y] { + transcript.extend( + coordinate.into_bigint().0.into_iter().flat_map(u64::to_be_bytes), + ); + } + } + for point in [tau_g2; 2] { + for coordinate in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { + transcript.extend( + coordinate.into_bigint().0.into_iter().flat_map(u64::to_be_bytes), + ); + } + } + transcript.extend_from_slice(blake2b_simd::blake2b(&transcript).as_bytes()); + let manifest = SourceManifest { + format: SOURCE_FORMAT.to_owned(), + ceremony: format!("synthetic ignition {marker}"), + basis: SHIFTED_TAU_BASIS.to_owned(), + num_g1_points: total, + points_per_transcript: total, + contribution: Contribution { + position: 197, + address: FINAL_ADDRESS.to_owned(), + transcripts: vec![Transcript { + index: 0, + blake2b512: blake2b_simd::blake2b(&transcript).to_hex().to_string(), + }], + }, + predecessor: None, + }; + let source_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let srs = Srs { + g1, + g2: G2Affine::generator(), + tau_g2, + public_setup: manifest.public_setup().unwrap(), + }; + let admission = IgnitionAdmission { + source_manifest_json: String::from_utf8(source_bytes.clone()).unwrap(), + source_manifest_digest: *blake3::hash(&source_bytes).as_bytes(), + public_setup: srs.public_setup, + g1: [srs.g1[0], srs.g1[1]], + g2: srs.g2, + tau_g2: srs.tau_g2, + }; + let bytes = admission.to_bytes().unwrap(); + ( + IgnitionAdmission::from_bytes(&bytes, *blake3::hash(&bytes).as_bytes()) + .unwrap(), + srs, + ) +} + +#[cfg(test)] +pub(crate) fn test_admission_cache( + path: &Path, + admission: &IgnitionAdmission, + srs: &Srs, +) -> IgnitionCacheIdentity { + srs.validate().unwrap(); + admission.check_verifier(&VerifierParams::from_srs(srs)).unwrap(); + let mut output = cache::CacheWriter::create( + path, + srs.max_len(), + admission.public_setup, + admission.source_manifest_digest, + ) + .unwrap(); + output.push(&srs.g1).unwrap(); + output + .finish(&super::Anchors { + g1: admission.g1, + tau_g2: admission.tau_g2, + contribution_g2: admission.tau_g2, + }) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ec::AffineRepr; + use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct Directory(PathBuf); + + impl Directory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "ix-ignition-admission-{}-{}", + std::process::id(), + NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn repin(value: &serde_json::Value) -> io::Result { + let bytes = serde_json::to_vec(value).unwrap(); + IgnitionAdmission::from_bytes(&bytes, *blake3::hash(&bytes).as_bytes()) + } + + #[test] + fn admission_roundtrip_authorizes_verifier_without_cache() { + let (admission, srs) = test_admission(7, 16, 1); + let bytes = admission.to_bytes().unwrap(); + let decoded = + IgnitionAdmission::from_bytes(&bytes, *blake3::hash(&bytes).as_bytes()) + .unwrap(); + assert_eq!(decoded.to_bytes().unwrap(), bytes); + assert_eq!(decoded.source_manifest_json, admission.source_manifest_json); + assert_eq!(decoded.anchors().g1, srs.g1[..2]); + assert_eq!(decoded.pairing_keys(), [srs.g2, srs.tau_g2]); + let params = VerifierParams::with_max_trace_len(&decoded.anchors(), 16); + let portable = + VerifierParams::from_bytes(¶ms.to_bytes().unwrap()).unwrap(); + decoded.check_verifier(&portable).unwrap(); + } + + #[test] + fn cache_admission_preserves_source_bytes_and_ignores_cached_prefix_size() { + let directory = Directory::new(); + let (admission, srs) = test_admission(7, 16, 1); + let source = directory.0.join("source.json"); + fs::write(&source, &admission.source_manifest_json).unwrap(); + for prefix_len in [4, 16] { + let cache = directory.0.join(format!("cache-{prefix_len}.bin")); + let prefix = Srs { + g1: srs.g1[..prefix_len].to_vec(), + g2: srs.g2, + tau_g2: srs.tau_g2, + public_setup: srs.public_setup, + }; + let receipt = test_admission_cache(&cache, &admission, &prefix); + let (anchors, identity) = + load_ignition_cache_with_identity(&cache, receipt.digest, 2).unwrap(); + assert_eq!(identity, receipt); + assert_eq!(identity.prefix_len, prefix_len); + admission.check_cache(&anchors, &identity).unwrap(); + let derived = IgnitionAdmission::from_cache( + &cache, + receipt.digest, + &source, + admission.source_manifest_digest, + ) + .unwrap(); + assert_eq!(derived.to_bytes().unwrap(), admission.to_bytes().unwrap()); + assert!( + IgnitionAdmission::from_cache( + &cache, + [0; 32], + &source, + admission.source_manifest_digest, + ) + .is_err() + ); + fs::remove_file(cache).unwrap(); + } + fs::remove_file(source).unwrap(); + let path = directory.0.join("admission.json"); + let bytes = admission.to_bytes().unwrap(); + fs::write(&path, &bytes).unwrap(); + let loaded = + IgnitionAdmission::read(&path, *blake3::hash(&bytes).as_bytes()).unwrap(); + loaded.check_verifier(&VerifierParams::from_srs(&srs)).unwrap(); + } + + #[test] + fn cache_admission_rejects_relabelled_identity_and_different_source_pin() { + let directory = Directory::new(); + let (admission, srs) = test_admission(7, 16, 1); + let source = directory.0.join("source.json"); + fs::write(&source, &admission.source_manifest_json).unwrap(); + let cache = directory.0.join("cache.bin"); + let dev_setup = PublicSetup::known_trapdoor(15, b"admission-relabel"); + let mut output = cache::CacheWriter::create( + &cache, + 16, + dev_setup, + admission.source_manifest_digest, + ) + .unwrap(); + output.push(&srs.g1).unwrap(); + let receipt = output + .finish(&super::super::Anchors { + g1: admission.g1, + tau_g2: admission.tau_g2, + contribution_g2: admission.tau_g2, + }) + .unwrap(); + assert!( + IgnitionAdmission::from_cache( + &cache, + receipt.digest, + &source, + admission.source_manifest_digest, + ) + .is_err() + ); + + let real_cache = directory.0.join("valid-cache.bin"); + let receipt = test_admission_cache(&real_cache, &admission, &srs); + let mut changed_source = admission.source_manifest_json.clone(); + changed_source.push(' '); + fs::write(&source, &changed_source).unwrap(); + assert!( + IgnitionAdmission::from_cache( + &real_cache, + receipt.digest, + &source, + *blake3::hash(changed_source.as_bytes()).as_bytes(), + ) + .is_err() + ); + } + + #[test] + fn admission_requires_exact_external_pin_and_bounded_schema() { + let (admission, _) = test_admission(7, 16, 1); + let bytes = admission.to_bytes().unwrap(); + assert!(IgnitionAdmission::from_bytes(&bytes, [0; 32]).is_err()); + let mut changed = bytes.clone(); + changed.push(b' '); + assert!( + IgnitionAdmission::from_bytes(&changed, *blake3::hash(&bytes).as_bytes()) + .is_err() + ); + let oversized = + vec![b' '; usize::try_from(MAX_ADMISSION_BYTES).unwrap() + 1]; + assert!( + IgnitionAdmission::from_bytes( + &oversized, + *blake3::hash(&oversized).as_bytes() + ) + .is_err() + ); + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + value["extra"] = true.into(); + assert!(repin(&value).is_err()); + } + + #[test] + fn admission_rejects_inconsistent_source_identity_and_anchors() { + let (admission, _) = test_admission(7, 16, 1); + let original: serde_json::Value = + serde_json::from_slice(&admission.to_bytes().unwrap()).unwrap(); + let zero_digest = "00".repeat(32); + for (field, replacement) in [ + ("curve", "bls12-381"), + ("basis", "unshifted"), + ("source_manifest_blake3", zero_digest.as_str()), + ("setup_id", zero_digest.as_str()), + ] { + let mut value = original.clone(); + value[field] = replacement.into(); + assert!(repin(&value).is_err()); + } + let mut wrong_degree = original.clone(); + wrong_degree["public_max_degree"] = 31.into(); + assert!(repin(&wrong_degree).is_err()); + let mut wrong_g1 = original.clone(); + wrong_g1["g1"][0] = hex(&encode_g1(&G1Affine::generator())).into(); + assert!(repin(&wrong_g1).is_err()); + let mut noncanonical = original.clone(); + noncanonical["g2"] = "ff".repeat(128).into(); + assert!(repin(&noncanonical).is_err()); + let mut wrong_source = original; + let (other, _) = test_admission(7, 16, 2); + wrong_source["source_manifest_json"] = other.source_manifest_json.into(); + wrong_source["source_manifest_blake3"] = + hex(&other.source_manifest_digest).into(); + assert!(repin(&wrong_source).is_err()); + } + + #[test] + fn admission_rejects_known_development_identity_and_relabelled_verifier() { + let (admission, _) = test_admission(7, 16, 1); + let mut development = Srs::unsafe_dev_setup(16, b"ignition-admission-dev"); + let mut value: serde_json::Value = + serde_json::from_slice(&admission.to_bytes().unwrap()).unwrap(); + value["setup_id"] = hex(&development.public_setup.id).into(); + assert!(repin(&value).is_err()); + assert!( + admission + .check_verifier(&VerifierParams::from_srs(&development)) + .is_err() + ); + development.public_setup = admission.public_setup(); + assert!( + admission + .check_verifier(&VerifierParams::from_srs(&development)) + .is_err() + ); + } + + #[test] + fn admission_checks_cache_source_receipt_and_all_anchors() { + let (admission, mut srs) = test_admission(7, 16, 1); + let identity = IgnitionCacheIdentity { + prefix_len: 16, + digest: [9; 32], + public_setup: admission.public_setup(), + source_manifest_digest: admission.source_manifest_digest(), + }; + admission.check_cache(&srs, &identity).unwrap(); + let mut bad = identity; + bad.source_manifest_digest[0] ^= 1; + assert!(admission.check_cache(&srs, &bad).is_err()); + let mut bad = identity; + bad.public_setup.max_degree += 1; + assert!(admission.check_cache(&srs, &bad).is_err()); + let mut bad = identity; + bad.prefix_len = 8; + assert!(admission.check_cache(&srs, &bad).is_err()); + srs.g1[0] = -srs.g1[0]; + assert!(admission.check_cache(&srs, &identity).is_err()); + } +} diff --git a/crates/proof-compression/src/kzg/srs/ignition/cache.rs b/crates/proof-compression/src/kzg/srs/ignition/cache.rs new file mode 100644 index 000000000..85ab54709 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/ignition/cache.rs @@ -0,0 +1,421 @@ +//! Normalized Ignition prefixes authenticated by an externally pinned receipt. + +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use ark_bn254::{Bn254, G1Affine, G2Affine}; +use ark_ec::{AffineRepr, pairing::Pairing}; +use ark_ff::Zero; +use p3_maybe_rayon::prelude::*; + +use super::super::{PublicSetup, Srs}; +use super::{ + Anchors, MAX_G1_POINTS, MAX_PREFIX_LEN, + points::{decode_g1, decode_g2, encode_g1, encode_g2}, +}; + +const MAGIC: &[u8] = b"multi-stark/bn254/ignition-srs/v1\0"; +const G1_BYTES: usize = 64; +const G2_BYTES: usize = 128; +const BLOCK_POINTS: usize = 1 << 16; +const HEADER_BYTES: usize = + MAGIC.len() + 64 + 3 * 8 + 2 * G1_BYTES + 2 * G2_BYTES; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +/// Pin `digest` through trusted configuration after a successful import. +/// It authenticates the source identity, complete public degree, shifted +/// generators, retained prefix length and every retained G1 chunk digest. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IgnitionCacheIdentity { + pub prefix_len: usize, + pub digest: [u8; 32], + pub public_setup: PublicSetup, + pub source_manifest_digest: [u8; 32], +} + +fn invalid( + message: impl Into>, +) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn validate_prefix(prefix_len: usize) -> io::Result<()> { + if prefix_len < 2 + || !prefix_len.is_power_of_two() + || prefix_len > MAX_PREFIX_LEN + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Ignition prefix must be a power of two in 2..={MAX_PREFIX_LEN}"), + )); + } + Ok(()) +} + +fn validate_degree( + public_setup: PublicSetup, + prefix_len: usize, +) -> io::Result<()> { + if public_setup.max_degree >= MAX_G1_POINTS + || public_setup.max_degree < prefix_len - 1 + { + return Err(invalid("Ignition public degree does not cover the prefix")); + } + Ok(()) +} + +pub(super) fn validate_anchors( + g1: &[G1Affine; 2], + g2: G2Affine, + tau_g2: G2Affine, +) -> io::Result<()> { + if g1.iter().any(|point| point.is_zero() || !point.is_on_curve()) + || [g2, tau_g2].iter().any(|point| { + point.is_zero() + || !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + }) + || g2 != G2Affine::generator() + { + return Err(invalid("invalid Ignition cache anchors")); + } + if !Bn254::multi_pairing([g1[0], -G1Affine::generator()], [g2, tau_g2]) + .is_zero() + || !Bn254::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]).is_zero() + { + return Err(invalid("Ignition cache anchors are not a shifted tau basis")); + } + Ok(()) +} + +fn encode_header( + prefix_len: usize, + public_setup: PublicSetup, + source_manifest_digest: [u8; 32], + anchors: &Anchors, +) -> Vec { + let mut header = Vec::with_capacity(HEADER_BYTES); + header.extend_from_slice(MAGIC); + header.extend_from_slice(&public_setup.id); + header.extend_from_slice(&source_manifest_digest); + header.extend_from_slice(&(public_setup.max_degree as u64).to_le_bytes()); + header.extend_from_slice(&(prefix_len as u64).to_le_bytes()); + header.extend_from_slice(&(BLOCK_POINTS as u64).to_le_bytes()); + for point in &anchors.g1 { + header.extend_from_slice(&encode_g1(point)); + } + header.extend_from_slice(&encode_g2(&G2Affine::generator())); + header.extend_from_slice(&encode_g2(&anchors.tau_g2)); + header +} + +fn block_digest(index: usize, bytes: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"multi-stark/kzg/bn254/ignition-cache-block/v1"); + hasher.update(&(index as u64).to_le_bytes()); + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + *hasher.finalize().as_bytes() +} + +fn manifest_digest(header: &[u8], index: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"multi-stark/kzg/bn254/ignition-cache-manifest/v1"); + hasher.update(header); + hasher.update(index); + *hasher.finalize().as_bytes() +} + +struct Temporary(PathBuf); + +impl Temporary { + fn create(destination: &Path) -> io::Result<(Self, File)> { + if destination.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "Ignition cache already exists", + )); + } + loop { + let path = destination.with_extension(format!( + "{}.{}.partial", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => return Ok((Self(path), file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, + Err(error) => return Err(error), + } + } + } + + fn publish(&self, destination: &Path) -> io::Result<()> { + // Hard-link publication is atomic and never replaces an existing cache. + fs::hard_link(&self.0, destination) + } +} + +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +pub(super) struct CacheWriter { + output: File, + temporary: Temporary, + destination: PathBuf, + prefix_len: usize, + public_setup: PublicSetup, + source_manifest_digest: [u8; 32], + points_written: usize, + first_g1: [Option; 2], + pending: Vec, + index: Vec, + failed: bool, +} + +impl CacheWriter { + pub(super) fn create( + cache: &Path, + prefix_len: usize, + public_setup: PublicSetup, + source_manifest_digest: [u8; 32], + ) -> io::Result { + validate_prefix(prefix_len)?; + validate_degree(public_setup, prefix_len)?; + let (temporary, mut output) = Temporary::create(cache)?; + output.write_all(&[0; HEADER_BYTES])?; + Ok(Self { + output, + temporary, + destination: cache.to_path_buf(), + prefix_len, + public_setup, + source_manifest_digest, + points_written: 0, + first_g1: [None; 2], + pending: Vec::with_capacity(prefix_len.min(BLOCK_POINTS) * G1_BYTES), + index: Vec::with_capacity(prefix_len.div_ceil(BLOCK_POINTS) * 32), + failed: false, + }) + } + + pub(super) fn push(&mut self, points: &[G1Affine]) -> io::Result<()> { + if self.failed { + return Err(invalid("Ignition cache writer has already failed")); + } + let result = self.push_inner(points); + if result.is_err() { + self.failed = true; + } + result + } + + fn push_inner(&mut self, points: &[G1Affine]) -> io::Result<()> { + if points.len() > self.prefix_len - self.points_written { + return Err(invalid("too many points for the Ignition cache prefix")); + } + for point in points { + if self.points_written < 2 { + self.first_g1[self.points_written] = Some(*point); + } + self.pending.extend_from_slice(&encode_g1(point)); + self.points_written += 1; + if self.pending.len() == BLOCK_POINTS * G1_BYTES { + self.flush_block()?; + } + } + Ok(()) + } + + fn flush_block(&mut self) -> io::Result<()> { + if !self.pending.is_empty() { + self.output.write_all(&self.pending)?; + self + .index + .extend_from_slice(&block_digest(self.index.len() / 32, &self.pending)); + self.pending.clear(); + } + Ok(()) + } + + /// Call only after source authentication and complete prefix progression + /// checks. Until this point the temporary file has no valid cache header. + pub(super) fn finish( + mut self, + anchors: &Anchors, + ) -> io::Result { + if self.failed || self.points_written != self.prefix_len { + return Err(invalid("incomplete Ignition cache prefix")); + } + if self.first_g1 != anchors.g1.map(Some) { + return Err(invalid("Ignition cache prefix disagrees with its anchors")); + } + validate_anchors(&anchors.g1, G2Affine::generator(), anchors.tau_g2)?; + self.flush_block()?; + let header = encode_header( + self.prefix_len, + self.public_setup, + self.source_manifest_digest, + anchors, + ); + let digest = manifest_digest(&header, &self.index); + self.output.write_all(&self.index)?; + self.output.write_all(&digest)?; + self.output.seek(SeekFrom::Start(0))?; + self.output.write_all(&header)?; + self.output.sync_all()?; + drop(self.output); + self.temporary.publish(&self.destination)?; + Ok(IgnitionCacheIdentity { + prefix_len: self.prefix_len, + digest, + public_setup: self.public_setup, + source_manifest_digest: self.source_manifest_digest, + }) + } +} + +/// Authenticate the cache with a receipt supplied by trusted configuration. +/// A two-point load reads only the manifest and validates its anchors. Larger +/// loads authenticate complete overlapping chunks before decoding requested +/// points; unrequested chunks are not read. Prefix loads preserve the complete +/// ceremony's public degree and setup identity. +pub fn load_ignition_cache( + cache: &Path, + expected_manifest_digest: [u8; 32], + prefix_len: usize, +) -> io::Result { + load_ignition_cache_with_identity(cache, expected_manifest_digest, prefix_len) + .map(|(srs, _)| srs) +} + +/// Load a prefix and the authenticated identity of its complete cache. +/// The identity's prefix length describes the stored cache, which may be +/// larger than the requested prefix. +pub fn load_ignition_cache_with_identity( + cache: &Path, + expected_manifest_digest: [u8; 32], + prefix_len: usize, +) -> io::Result<(Srs, IgnitionCacheIdentity)> { + validate_prefix(prefix_len)?; + if !fs::metadata(cache)?.is_file() { + return Err(invalid("Ignition cache must be a regular file")); + } + let mut file = File::open(cache)?; + if !file.metadata()?.is_file() { + return Err(invalid("Ignition cache must be a regular file")); + } + let mut header = [0; HEADER_BYTES]; + file.read_exact(&mut header)?; + if !header.starts_with(MAGIC) { + return Err(invalid("Ignition cache format or curve mismatch")); + } + let numbers = MAGIC.len() + 64; + let number = |offset: usize| { + u64::from_le_bytes( + header[numbers + offset..numbers + offset + 8] + .try_into() + .expect("fixed header field"), + ) + }; + let stored_len = usize::try_from(number(8)).map_err(invalid)?; + validate_prefix(stored_len).map_err(invalid)?; + let public_setup = PublicSetup { + max_degree: usize::try_from(number(0)).map_err(invalid)?, + id: header[MAGIC.len()..MAGIC.len() + 32] + .try_into() + .expect("fixed header field"), + }; + validate_degree(public_setup, stored_len)?; + if number(16) != BLOCK_POINTS as u64 { + return Err(invalid("Ignition cache block size mismatch")); + } + if prefix_len > stored_len { + return Err(invalid("Ignition cache prefix is smaller than requested")); + } + let index_bytes = stored_len + .div_ceil(BLOCK_POINTS) + .checked_mul(32) + .ok_or_else(|| invalid("Ignition cache index length overflow"))?; + let index_offset = u64::try_from(stored_len) + .map_err(invalid)? + .checked_mul(G1_BYTES as u64) + .and_then(|bytes| bytes.checked_add(HEADER_BYTES as u64)) + .ok_or_else(|| invalid("Ignition cache data length overflow"))?; + let expected_bytes = index_offset + .checked_add(u64::try_from(index_bytes).map_err(invalid)?) + .and_then(|bytes| bytes.checked_add(32)) + .ok_or_else(|| invalid("Ignition cache total length overflow"))?; + if file.metadata()?.len() != expected_bytes { + return Err(invalid("Ignition cache has the wrong length")); + } + file.seek(SeekFrom::Start(index_offset))?; + let mut index = vec![0; index_bytes]; + file.read_exact(&mut index)?; + let mut declared_digest = [0; 32]; + file.read_exact(&mut declared_digest)?; + if declared_digest != expected_manifest_digest + || manifest_digest(&header, &index) != expected_manifest_digest + { + return Err(invalid( + "Ignition cache does not match the pinned manifest digest", + )); + } + let g1_offset = numbers + 24; + let g2_offset = g1_offset + 2 * G1_BYTES; + let g1_anchors = [ + decode_g1(&header[g1_offset..g1_offset + G1_BYTES])?, + decode_g1(&header[g1_offset + G1_BYTES..g2_offset])?, + ]; + let g2 = decode_g2(&header[g2_offset..g2_offset + G2_BYTES])?; + let tau_g2 = decode_g2(&header[g2_offset + G2_BYTES..])?; + validate_anchors(&g1_anchors, g2, tau_g2)?; + let mut g1 = Vec::new(); + g1.try_reserve_exact(prefix_len).map_err(io::Error::other)?; + if prefix_len == 2 { + g1.extend_from_slice(&g1_anchors); + } else { + file.seek(SeekFrom::Start(HEADER_BYTES as u64))?; + let mut buffer = vec![0; stored_len.min(BLOCK_POINTS) * G1_BYTES]; + for block_index in 0..prefix_len.div_ceil(BLOCK_POINTS) { + let start = block_index * BLOCK_POINTS; + let count = (stored_len - start).min(BLOCK_POINTS); + let bytes = &mut buffer[..count * G1_BYTES]; + file.read_exact(bytes)?; + if block_digest(block_index, bytes) + != index[block_index * 32..(block_index + 1) * 32] + { + return Err(invalid(format!( + "Ignition cache chunk {block_index} digest mismatch" + ))); + } + let needed = (prefix_len - start).min(count); + let points = bytes[..needed * G1_BYTES] + .par_chunks_exact(G1_BYTES) + .map(decode_g1) + .collect::>>()?; + g1.extend(points); + } + if g1[..2] != g1_anchors { + return Err(invalid( + "Ignition cache G1 anchors disagree with its prefix", + )); + } + } + let identity = IgnitionCacheIdentity { + prefix_len: stored_len, + digest: expected_manifest_digest, + public_setup, + source_manifest_digest: header[MAGIC.len() + 32..MAGIC.len() + 64] + .try_into() + .expect("fixed header field"), + }; + Ok((Srs { g1, g2, tau_g2, public_setup }, identity)) +} diff --git a/crates/proof-compression/src/kzg/srs/ignition/points.rs b/crates/proof-compression/src/kzg/srs/ignition/points.rs new file mode 100644 index 000000000..3dd920e6d --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/ignition/points.rs @@ -0,0 +1,106 @@ +use std::io; + +use ark_bn254::Fq2; +use ark_ec::AffineRepr; +use ark_ff::{BigInt, PrimeField}; + +use crate::kzg::curve::{Fq, G1Affine, G2Affine}; + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn decode_field(bytes: &[u8], source_encoding: bool) -> io::Result { + if bytes.len() != 32 { + return Err(invalid("invalid BN254 coordinate length")); + } + let mut limbs = [0; 4]; + for (limb, bytes) in limbs.iter_mut().zip(bytes.as_chunks::<8>().0) { + let word = *bytes; + *limb = if source_encoding { + u64::from_be_bytes(word) + } else { + u64::from_le_bytes(word) + }; + } + Fq::from_bigint(BigInt(limbs)) + .ok_or_else(|| invalid("noncanonical BN254 coordinate")) +} + +fn read_g1(bytes: &[u8], source_encoding: bool) -> io::Result { + if bytes.len() != 64 { + return Err(invalid("invalid BN254 G1 length")); + } + let point = G1Affine::new_unchecked( + decode_field(&bytes[..32], source_encoding)?, + decode_field(&bytes[32..], source_encoding)?, + ); + // BN254 G1 has cofactor one, so curve membership implies subgroup membership. + if point.is_zero() || !point.is_on_curve() { + return Err(invalid("invalid or identity BN254 G1 point")); + } + Ok(point) +} + +fn read_g2(bytes: &[u8], source_encoding: bool) -> io::Result { + if bytes.len() != 128 { + return Err(invalid("invalid BN254 G2 length")); + } + let point = G2Affine::new_unchecked( + Fq2::new( + decode_field(&bytes[..32], source_encoding)?, + decode_field(&bytes[32..64], source_encoding)?, + ), + Fq2::new( + decode_field(&bytes[64..96], source_encoding)?, + decode_field(&bytes[96..], source_encoding)?, + ), + ); + if point.is_zero() + || !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err(invalid("invalid or identity BN254 G2 point")); + } + Ok(point) +} + +pub(super) fn decode_source_g1(bytes: &[u8]) -> io::Result { + read_g1(bytes, true) +} + +pub(super) fn decode_source_g2(bytes: &[u8]) -> io::Result { + read_g2(bytes, true) +} + +fn encode_field(value: &Fq, bytes: &mut [u8]) { + for (limb, bytes) in + value.into_bigint().0.iter().zip(bytes.as_chunks_mut::<8>().0) + { + bytes.copy_from_slice(&limb.to_le_bytes()); + } +} + +pub(super) fn encode_g1(point: &G1Affine) -> [u8; 64] { + let mut bytes = [0; 64]; + encode_field(&point.x, &mut bytes[..32]); + encode_field(&point.y, &mut bytes[32..]); + bytes +} + +pub(super) fn decode_g1(bytes: &[u8]) -> io::Result { + read_g1(bytes, false) +} + +pub(super) fn encode_g2(point: &G2Affine) -> [u8; 128] { + let mut bytes = [0; 128]; + encode_field(&point.x.c0, &mut bytes[..32]); + encode_field(&point.x.c1, &mut bytes[32..64]); + encode_field(&point.y.c0, &mut bytes[64..96]); + encode_field(&point.y.c1, &mut bytes[96..]); + bytes +} + +pub(super) fn decode_g2(bytes: &[u8]) -> io::Result { + read_g2(bytes, false) +} diff --git a/crates/proof-compression/src/kzg/srs/ignition/source.rs b/crates/proof-compression/src/kzg/srs/ignition/source.rs new file mode 100644 index 000000000..abfe62394 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/ignition/source.rs @@ -0,0 +1,461 @@ +use std::{ + fs::{self, File}, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +use p3_maybe_rayon::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::{ + Anchors, + points::{decode_source_g1, decode_source_g2}, +}; +use crate::kzg::{ + curve::{self, G1Affine, G2Affine}, + srs::PublicSetup, +}; + +pub const SOURCE_FORMAT: &str = "ix-bn254-ignition-source-v1"; +pub const SHIFTED_TAU_BASIS: &str = "shifted-tau"; +const MAX_MANIFEST_BYTES: u64 = 1 << 20; +const MAX_G1_POINTS: u32 = 100_800_000; +const MAX_TRANSCRIPTS: u32 = 32; +const BLOCK_POINTS: usize = 1 << 16; + +/// A separately authenticated description of the complete ceremony snapshot. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceManifest { + pub format: String, + pub ceremony: String, + pub basis: String, + pub num_g1_points: u32, + pub points_per_transcript: u32, + pub contribution: Contribution, + pub predecessor: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Contribution { + pub position: u32, + pub address: String, + pub transcripts: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transcript { + pub index: u32, + /// BLAKE2b-512 of the complete file, including its embedded checksum. + pub blake2b512: String, +} + +fn invalid( + message: impl Into>, +) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn open_regular_file(path: &Path) -> io::Result { + if !fs::metadata(path)?.is_file() { + return Err(invalid("Ignition input must be a regular file")); + } + let file = File::open(path)?; + if !file.metadata()?.is_file() { + return Err(invalid("Ignition input must be a regular file")); + } + Ok(file) +} + +fn validate_participant(position: u32, address: &str) -> io::Result<()> { + if position > 9999 + || address.len() != 42 + || !address.starts_with("0x") + || !address.as_bytes()[2..] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(invalid("invalid Ignition contribution position or address")); + } + Ok(()) +} + +fn decode_digest(text: &str) -> io::Result<[u8; 64]> { + if text.len() != 128 || !text.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(invalid("invalid Ignition BLAKE2b-512 digest")); + } + let mut bytes = [0; 64]; + for (byte, hex) in bytes.iter_mut().zip(text.as_bytes().as_chunks::<2>().0) { + let digit = |value: u8| { + if value.is_ascii_digit() { + value - b'0' + } else { + value.to_ascii_lowercase() - b'a' + 10 + } + }; + *byte = digit(hex[0]) * 16 + digit(hex[1]); + } + Ok(bytes) +} + +fn encode_digest(bytes: &[u8; 64]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut text = String::with_capacity(128); + for byte in bytes { + text.push(char::from(HEX[usize::from(byte >> 4)])); + text.push(char::from(HEX[usize::from(byte & 15)])); + } + text +} + +impl SourceManifest { + pub fn validate(&self) -> io::Result<()> { + if self.format != SOURCE_FORMAT + || self.basis != SHIFTED_TAU_BASIS + || self.ceremony.trim().is_empty() + || self.ceremony.len() > 256 + || self.ceremony.chars().any(char::is_control) + || !(2..=MAX_G1_POINTS).contains(&self.num_g1_points) + || self.points_per_transcript == 0 + || self.points_per_transcript > self.num_g1_points + { + return Err(invalid("invalid Ignition source manifest metadata")); + } + let count = self.num_g1_points.div_ceil(self.points_per_transcript); + if count > MAX_TRANSCRIPTS { + return Err(invalid("too many Ignition transcripts")); + } + for contribution in + std::iter::once(&self.contribution).chain(self.predecessor.iter()) + { + validate_participant(contribution.position, &contribution.address)?; + if contribution.transcripts.len() != count as usize { + return Err(invalid("wrong number of Ignition transcript digests")); + } + for (expected, transcript) in (0..count).zip(&contribution.transcripts) { + if transcript.index != expected { + return Err(invalid( + "Ignition transcript indices are not contiguous", + )); + } + decode_digest(&transcript.blake2b512)?; + } + } + if self + .predecessor + .as_ref() + .is_some_and(|previous| previous.position >= self.contribution.position) + { + return Err(invalid("Ignition predecessor must precede contribution")); + } + Ok(()) + } + + /// The shifted basis starts at `τG`, so N points allow degree N−1. + /// Optional predecessor evidence does not change the selected setup's identity. + pub fn public_setup(&self) -> io::Result { + self.validate()?; + let mut hash = blake3::Hasher::new(); + hash.update(b"multi-stark/kzg/bn254/ignition-public-setup/v1"); + for text in [ + curve::NAME, + &self.format, + &self.basis, + &self.ceremony, + &self.contribution.address, + ] { + hash.update(&(text.len() as u64).to_le_bytes()); + hash.update(text.as_bytes()); + } + hash.update(&self.num_g1_points.to_le_bytes()); + hash.update(&self.points_per_transcript.to_le_bytes()); + hash.update(&self.contribution.position.to_le_bytes()); + for transcript in &self.contribution.transcripts { + hash.update(&transcript.index.to_le_bytes()); + hash.update(&decode_digest(&transcript.blake2b512)?); + } + Ok(PublicSetup { + max_degree: self.num_g1_points as usize - 1, + id: *hash.finalize().as_bytes(), + }) + } +} + +/// The expected digest must come from trusted configuration, independently of +/// the source directory and its manifest. It covers the exact JSON bytes. +pub fn read_source_manifest( + path: &Path, + expected: [u8; 32], +) -> io::Result { + read_source_manifest_bytes(path, expected).map(|(_, manifest)| manifest) +} + +pub(super) fn read_source_manifest_bytes( + path: &Path, + expected: [u8; 32], +) -> io::Result<(Vec, SourceManifest)> { + let mut bytes = Vec::new(); + open_regular_file(path)? + .take(MAX_MANIFEST_BYTES + 1) + .read_to_end(&mut bytes)?; + let manifest = parse_source_manifest(&bytes, expected)?; + Ok((bytes, manifest)) +} + +pub(super) fn parse_source_manifest( + bytes: &[u8], + expected: [u8; 32], +) -> io::Result { + if bytes.len() as u64 > MAX_MANIFEST_BYTES { + return Err(invalid("Ignition source manifest exceeds 1 MiB")); + } + if blake3::hash(bytes).as_bytes() != &expected { + return Err(invalid("Ignition source manifest digest mismatch")); + } + let manifest: SourceManifest = + serde_json::from_slice(bytes).map_err(invalid)?; + manifest.validate()?; + Ok(manifest) +} + +fn transcript_path( + root: &Path, + contribution: &Contribution, + index: u32, +) -> PathBuf { + root + .join(format!("{:03}_{}", contribution.position, contribution.address)) + .join(format!("transcript{index:02}.dat")) +} + +fn parse_header(bytes: &[u8; 28]) -> [u32; 7] { + let mut header = [0; 7]; + for (word, bytes) in header.iter_mut().zip(bytes.as_chunks::<4>().0) { + *word = u32::from_be_bytes(*bytes); + } + header +} + +fn expected_header(manifest: &SourceManifest, index: u32) -> [u32; 7] { + let start = index * manifest.points_per_transcript; + [ + index, + manifest.num_g1_points.div_ceil(manifest.points_per_transcript), + manifest.num_g1_points, + 1, + manifest.points_per_transcript.min(manifest.num_g1_points - start), + if index == 0 { 2 } else { 0 }, + start, + ] +} + +struct FileResult { + digest: [u8; 64], + first_g1: Vec, + g2: Option<[G2Affine; 2]>, +} + +fn stream_file( + root: &Path, + manifest: &SourceManifest, + contribution: &Contribution, + index: u32, + prefix_len: usize, + visit: &mut impl FnMut(&[G1Affine]) -> io::Result<()>, +) -> io::Result { + let path = transcript_path(root, contribution, index); + let mut file = open_regular_file(&path)?; + let mut bytes = [0; 28]; + file.read_exact(&mut bytes)?; + let header = parse_header(&bytes); + if header != expected_header(manifest, index) { + return Err(invalid(format!( + "Ignition header mismatch in {}", + path.display() + ))); + } + let expected_bytes = + 28 + u64::from(header[4]) * 64 + u64::from(header[5]) * 128 + 64; + if file.metadata()?.len() != expected_bytes { + return Err(invalid(format!( + "Ignition file length mismatch in {}", + path.display() + ))); + } + let mut checksum = blake2b_simd::Params::new().hash_length(64).to_state(); + checksum.update(&bytes); + let mut first_g1 = Vec::with_capacity(2); + let mut buffer = vec![0; BLOCK_POINTS * 64]; + let mut consumed = 0; + let count = header[4] as usize; + let retained = prefix_len.saturating_sub(header[6] as usize).min(count); + while consumed < count { + let chunk_len = (count - consumed).min(BLOCK_POINTS); + let chunk = &mut buffer[..chunk_len * 64]; + file.read_exact(chunk)?; + checksum.update(chunk); + let retained_chunk = retained.saturating_sub(consumed).min(chunk_len); + if retained_chunk != 0 { + let points: Vec<_> = chunk[..retained_chunk * 64] + .par_chunks_exact(64) + .map(decode_source_g1) + .collect::>()?; + first_g1.extend(points.iter().take(2 - first_g1.len()).copied()); + visit(&points)?; + } + consumed += chunk_len; + } + let g2 = if index == 0 { + let mut bytes = [0; 256]; + file.read_exact(&mut bytes)?; + checksum.update(&bytes); + Some([decode_source_g2(&bytes[..128])?, decode_source_g2(&bytes[128..])?]) + } else { + None + }; + let mut stored_checksum = [0; 64]; + file.read_exact(&mut stored_checksum)?; + let mut trailing = [0; 1]; + if file.read(&mut trailing)? != 0 { + return Err(invalid("trailing bytes in Ignition transcript")); + } + if checksum.finalize().as_bytes() != stored_checksum { + return Err(invalid(format!( + "Ignition internal checksum mismatch in {}", + path.display() + ))); + } + checksum.update(&stored_checksum); + let digest = checksum.finalize().as_bytes().try_into().expect("BLAKE2b-512"); + Ok(FileResult { digest, first_g1, g2 }) +} + +/// Read and authenticate complete files, decoding only the retained prefix. +/// The visitor must keep its output private until this function succeeds. +pub(super) fn stream_contribution( + root: &Path, + manifest: &SourceManifest, + contribution: &Contribution, + prefix_len: usize, + mut visit: impl FnMut(&[G1Affine]) -> io::Result<()>, +) -> io::Result { + manifest.validate()?; + if contribution != &manifest.contribution + && manifest.predecessor.as_ref() != Some(contribution) + { + return Err(invalid( + "Ignition contribution is absent from source manifest", + )); + } + if prefix_len < 2 || prefix_len > manifest.num_g1_points as usize { + return Err(invalid("Ignition retained prefix is outside source bounds")); + } + let mut first_g1 = Vec::with_capacity(2); + let mut anchors_g2 = None; + for transcript in &contribution.transcripts { + let result = stream_file( + root, + manifest, + contribution, + transcript.index, + prefix_len, + &mut visit, + )?; + if result.digest != decode_digest(&transcript.blake2b512)? { + return Err(invalid(format!( + "Ignition source digest mismatch at transcript {}", + transcript.index, + ))); + } + first_g1.extend(result.first_g1.into_iter().take(2 - first_g1.len())); + if result.g2.is_some() { + anchors_g2 = result.g2; + } + } + let [tau_g2, contribution_g2] = + anchors_g2.ok_or_else(|| invalid("missing Ignition G2 anchors"))?; + Ok(Anchors { + g1: first_g1 + .try_into() + .map_err(|_points| invalid("missing Ignition G1 anchors"))?, + tau_g2, + contribution_g2, + }) +} + +/// Produce an untrusted inventory, not evidence of ceremony provenance. +/// Checksums can detect damage but an attacker can replace both data and hashes. +pub fn inspect_ignition_source( + root: &Path, + position: u32, + address: &str, + predecessor: Option<(u32, &str)>, +) -> io::Result { + validate_participant(position, address)?; + if let Some((position, address)) = predecessor { + validate_participant(position, address)?; + } + let contribution = Contribution { + position, + address: address.to_owned(), + transcripts: Vec::new(), + }; + let mut bytes = [0; 28]; + open_regular_file(&transcript_path(root, &contribution, 0))? + .read_exact(&mut bytes)?; + let header = parse_header(&bytes); + let mut manifest = SourceManifest { + format: SOURCE_FORMAT.to_owned(), + ceremony: "MAIN IGNITION".to_owned(), + basis: SHIFTED_TAU_BASIS.to_owned(), + num_g1_points: header[2], + points_per_transcript: header[4], + contribution, + predecessor: predecessor.map(|(position, address)| Contribution { + position, + address: address.to_owned(), + transcripts: Vec::new(), + }), + }; + if header[1] == 0 || header[1] > MAX_TRANSCRIPTS { + return Err(invalid("invalid Ignition transcript count")); + } + let placeholders: Vec<_> = (0..header[1]) + .map(|index| Transcript { index, blake2b512: "0".repeat(128) }) + .collect(); + manifest.contribution.transcripts = placeholders.clone(); + if let Some(previous) = &mut manifest.predecessor { + previous.transcripts = placeholders; + } + manifest.validate()?; + let inspect = |contribution: &Contribution| -> io::Result> { + contribution + .transcripts + .iter() + .map(|transcript| { + let result = stream_file( + root, + &manifest, + contribution, + transcript.index, + 0, + &mut |_| Ok(()), + )?; + Ok(Transcript { + index: transcript.index, + blake2b512: encode_digest(&result.digest), + }) + }) + .collect() + }; + let current = inspect(&manifest.contribution)?; + let previous = manifest.predecessor.as_ref().map(inspect).transpose()?; + manifest.contribution.transcripts = current; + if let Some(previous) = previous { + manifest.predecessor.as_mut().expect("requested predecessor").transcripts = + previous; + } + Ok(manifest) +} diff --git a/crates/proof-compression/src/kzg/srs/ignition/tests.rs b/crates/proof-compression/src/kzg/srs/ignition/tests.rs new file mode 100644 index 000000000..845f2076a --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/ignition/tests.rs @@ -0,0 +1,608 @@ +use super::*; +use ark_bn254::{Fq, Fq2, G2Projective}; +use ark_ec::PrimeGroup; +use std::{ + fs, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use crate::kzg::{KzgPcs, KzgTranscript, Scalar, VerifierParams}; +use multi_stark::traits::{EvaluationDomain, Pcs}; +use p3_matrix::dense::RowMajorMatrix; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); +const TOTAL_POINTS: u32 = 12; +const POINTS_PER_TRANSCRIPT: u32 = 5; + +fn source_field(value: Fq) -> Vec { + value.into_bigint().0.into_iter().flat_map(u64::to_be_bytes).collect() +} + +fn source_g1(point: G1Affine) -> Vec { + [source_field(point.x), source_field(point.y)].concat() +} + +fn source_g2(point: G2Affine) -> Vec { + [point.x.c0, point.x.c1, point.y.c0, point.y.c1] + .into_iter() + .flat_map(source_field) + .collect() +} + +fn digest_text(bytes: &[u8]) -> String { + blake2b_simd::blake2b(bytes).to_hex().to_string() +} + +fn transcript_path( + root: &Path, + contribution: &Contribution, + index: u32, +) -> PathBuf { + root + .join(format!("{:03}_{}", contribution.position, contribution.address)) + .join(format!("transcript{index:02}.dat")) +} + +fn shifted_points(tau: Fr, count: usize) -> Vec { + (1..=count) + .map(|exponent| { + (G1Projective::generator() * tau.pow([exponent as u64])).into_affine() + }) + .collect() +} + +fn write_contribution( + root: &Path, + position: u32, + address: &str, + tau: Fr, + update: Fr, +) -> Contribution { + let mut contribution = Contribution { + position, + address: address.to_owned(), + transcripts: Vec::new(), + }; + let points = shifted_points(tau, TOTAL_POINTS as usize); + let count = TOTAL_POINTS.div_ceil(POINTS_PER_TRANSCRIPT); + for index in 0..count { + let start = index * POINTS_PER_TRANSCRIPT; + let length = POINTS_PER_TRANSCRIPT.min(TOTAL_POINTS - start); + let mut bytes: Vec = [ + index, + count, + TOTAL_POINTS, + 1, + length, + if index == 0 { 2 } else { 0 }, + start, + ] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + for point in &points[start as usize..(start + length) as usize] { + bytes.extend(source_g1(*point)); + } + if index == 0 { + for scalar in [tau, update] { + bytes.extend(source_g2( + (G2Projective::generator() * scalar).into_affine(), + )); + } + } + bytes.extend_from_slice(blake2b_simd::blake2b(&bytes).as_bytes()); + let path = transcript_path(root, &contribution, index); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, &bytes).unwrap(); + contribution + .transcripts + .push(Transcript { index, blake2b512: digest_text(&bytes) }); + } + contribution +} + +struct Fixture { + directory: PathBuf, + root: PathBuf, + manifest_path: PathBuf, + cache: PathBuf, + manifest: SourceManifest, + pin: [u8; 32], + points: Vec, +} + +impl Fixture { + fn new(with_predecessor: bool) -> Self { + let directory = std::env::temp_dir().join(format!( + "ix-ignition-import-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&directory).unwrap(); + let root = directory.join("source"); + let previous_tau = Fr::from(7u64); + let update = Fr::from(11u64); + let tau = previous_tau * update; + let contribution = + write_contribution(&root, FINAL_POSITION, FINAL_ADDRESS, tau, update); + let predecessor = with_predecessor.then(|| { + write_contribution( + &root, + PREDECESSOR_POSITION, + PREDECESSOR_ADDRESS, + previous_tau, + previous_tau, + ) + }); + let manifest = SourceManifest { + format: SOURCE_FORMAT.to_owned(), + ceremony: "MAIN IGNITION".to_owned(), + basis: SHIFTED_TAU_BASIS.to_owned(), + num_g1_points: TOTAL_POINTS, + points_per_transcript: POINTS_PER_TRANSCRIPT, + contribution, + predecessor, + }; + let mut fixture = Self { + manifest_path: directory.join("source.json"), + cache: directory.join("cache.bin"), + directory, + root, + manifest, + pin: [0; 32], + points: shifted_points(tau, TOTAL_POINTS as usize), + }; + fixture.save_manifest(); + fixture + } + + fn save_manifest(&mut self) { + let bytes = serde_json::to_vec_pretty(&self.manifest).unwrap(); + self.pin = *blake3::hash(&bytes).as_bytes(); + fs::write(&self.manifest_path, bytes).unwrap(); + } + + fn mutate_source( + &mut self, + predecessor: bool, + index: u32, + repair_checksum: bool, + mutate: impl FnOnce(&mut Vec), + ) { + let contribution = if predecessor { + self.manifest.predecessor.as_mut().unwrap() + } else { + &mut self.manifest.contribution + }; + let path = transcript_path(&self.root, contribution, index); + let mut bytes = fs::read(&path).unwrap(); + mutate(&mut bytes); + if repair_checksum { + let end = bytes.len() - 64; + let checksum = blake2b_simd::blake2b(&bytes[..end]); + bytes[end..].copy_from_slice(checksum.as_bytes()); + } + fs::write(path, &bytes).unwrap(); + contribution.transcripts[index as usize].blake2b512 = digest_text(&bytes); + self.save_manifest(); + } + + fn import(&self, prefix_len: usize) -> io::Result { + import_ignition_transcripts( + &self.root, + &self.manifest_path, + self.pin, + &self.cache, + prefix_len, + ) + } + + fn assert_rejected(&self, prefix_len: usize, reason: &str) { + let error = self.import(prefix_len).unwrap_err(); + assert!(error.to_string().contains(reason), "unexpected error: {error}"); + assert!(!self.cache.exists()); + assert!(fs::read_dir(&self.directory).unwrap().all(|entry| { + !entry.unwrap().file_name().to_string_lossy().ends_with(".partial") + })); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} + +#[test] +fn authenticated_shifted_prefixes_preserve_public_identity_and_degree() { + let fixture = Fixture::new(true); + let receipt = fixture.import(8).unwrap(); + assert_eq!(receipt.source_manifest_digest, fixture.pin); + assert_eq!(receipt.public_setup, fixture.manifest.public_setup().unwrap()); + assert_eq!(receipt.public_setup.max_degree, TOTAL_POINTS as usize - 1); + for prefix_len in [2, 4, 8] { + let srs = + load_ignition_cache(&fixture.cache, receipt.digest, prefix_len).unwrap(); + assert_eq!(srs.g1, fixture.points[..prefix_len]); + assert_ne!(srs.g1[0], G1Affine::generator()); + assert_eq!(srs.g2, G2Affine::generator()); + assert_eq!(srs.public_setup, receipt.public_setup); + srs.validate().unwrap(); + } + let smaller_cache = fixture.directory.join("smaller.bin"); + let smaller = import_ignition_transcripts( + &fixture.root, + &fixture.manifest_path, + fixture.pin, + &smaller_cache, + 4, + ) + .unwrap(); + assert_eq!(smaller.public_setup, receipt.public_setup); + assert_ne!(smaller.digest, receipt.digest); +} + +#[test] +fn shifted_import_supports_native_kzg_openings_with_anchor_only_verifier() { + let fixture = Fixture::new(false); + let receipt = fixture.import(8).unwrap(); + let srs = load_ignition_cache(&fixture.cache, receipt.digest, 8).unwrap(); + let anchors = load_ignition_cache(&fixture.cache, receipt.digest, 2).unwrap(); + let verifier = + KzgPcs::verifier_only(VerifierParams::with_max_trace_len(&anchors, 8), 2); + let pcs = KzgPcs::new(Arc::new(srs), 2); + let domain = pcs.natural_domain_for_degree(8); + let matrix = RowMajorMatrix::new( + (0..domain.size()) + .flat_map(|i| { + [Scalar(Fr::from(7u64)), Scalar(Fr::from((i * i + 3) as u64))] + }) + .collect(), + 2, + ); + let (commitment, data) = pcs.commit(vec![(domain, matrix)]); + let point = Scalar(Fr::from(19u64)); + let (values, proof) = + pcs.open(vec![(&data, vec![vec![point]])], &mut KzgTranscript::default()); + let mut rounds = + vec![(commitment, vec![(domain, vec![(point, values[0][0][0].clone())])])]; + verifier + .verify(rounds.clone(), &proof, &mut KzgTranscript::default()) + .unwrap(); + rounds[0].1[0].1[0].1[0] += Scalar(Fr::ONE); + assert!( + verifier.verify(rounds, &proof, &mut KzgTranscript::default()).is_err() + ); +} + +#[test] +fn inventory_hashes_complete_files_and_does_not_change_setup_identity() { + let fixture = Fixture::new(true); + let inventory = inspect_ignition_source( + &fixture.root, + FINAL_POSITION, + FINAL_ADDRESS, + Some((PREDECESSOR_POSITION, PREDECESSOR_ADDRESS)), + ) + .unwrap(); + assert_eq!(inventory, fixture.manifest); + let mut without_previous = inventory.clone(); + without_previous.predecessor = None; + assert_eq!( + inventory.public_setup().unwrap(), + without_previous.public_setup().unwrap() + ); + let first = + fs::read(transcript_path(&fixture.root, &inventory.contribution, 0)) + .unwrap(); + assert_ne!( + inventory.contribution.transcripts[0].blake2b512, + digest_text(&first[..first.len() - 64]), + ); +} + +#[test] +fn source_manifest_requires_an_external_exact_byte_pin() { + let fixture = Fixture::new(false); + assert!(read_source_manifest(&fixture.manifest_path, [0; 32]).is_err()); + let mut bytes = fs::read(&fixture.manifest_path).unwrap(); + bytes.push(b'\n'); + fs::write(&fixture.manifest_path, bytes).unwrap(); + fixture.assert_rejected(8, "manifest digest mismatch"); +} + +#[test] +fn source_manifest_rejects_oversize_and_unknown_fields() { + let fixture = Fixture::new(false); + let oversized = vec![b' '; (1 << 20) + 1]; + fs::write(&fixture.manifest_path, &oversized).unwrap(); + assert!( + read_source_manifest( + &fixture.manifest_path, + *blake3::hash(&oversized).as_bytes() + ) + .is_err() + ); + let mut value = serde_json::to_value(&fixture.manifest).unwrap(); + value["unrecognized"] = serde_json::Value::Bool(true); + let bytes = serde_json::to_vec(&value).unwrap(); + fs::write(&fixture.manifest_path, &bytes).unwrap(); + assert!( + read_source_manifest( + &fixture.manifest_path, + *blake3::hash(&bytes).as_bytes() + ) + .is_err() + ); +} + +#[test] +fn descriptor_rejects_bad_geometry_paths_and_transcript_order() { + let fixture = Fixture::new(true); + let original = fixture.manifest.clone(); + let mutations: [fn(&mut SourceManifest); 8] = [ + |m| m.basis = "unshifted".to_owned(), + |m| m.num_g1_points = u32::MAX, + |m| m.points_per_transcript = 0, + |m| m.contribution.address = "../outside".to_owned(), + |m| m.contribution.position = u32::MAX, + |m| m.contribution.transcripts.swap(0, 1), + |m| m.contribution.transcripts[0].blake2b512 = "ff".to_owned(), + |m| m.predecessor.as_mut().unwrap().position = m.contribution.position, + ]; + for mutate in mutations { + let mut manifest = original.clone(); + mutate(&mut manifest); + assert!(manifest.validate().is_err()); + } +} + +#[test] +fn wrong_source_pin_in_unretained_transcript_never_publishes_cache() { + let mut fixture = Fixture::new(false); + fixture.manifest.contribution.transcripts[2].blake2b512 = "00".repeat(64); + fixture.save_manifest(); + fixture.assert_rejected(8, "source digest mismatch"); +} + +#[test] +fn embedded_checksum_is_checked_even_with_a_matching_external_file_pin() { + let mut fixture = Fixture::new(false); + fixture + .mutate_source(false, 2, false, |bytes| *bytes.last_mut().unwrap() ^= 1); + fixture.assert_rejected(8, "internal checksum mismatch"); +} + +#[test] +fn malformed_header_and_file_lengths_never_publish_cache() { + let mut fixture = Fixture::new(false); + fixture.mutate_source(false, 1, true, |bytes| bytes[27] ^= 1); + fixture.assert_rejected(8, "header mismatch"); + for extra_byte in [false, true] { + let mut fixture = Fixture::new(false); + fixture.mutate_source(false, 2, false, |bytes| { + if extra_byte { + bytes.push(0); + } else { + bytes.pop(); + } + }); + fixture.assert_rejected(8, "file length mismatch"); + } +} + +#[test] +fn wrong_successive_power_with_repaired_hashes_is_rejected() { + let mut fixture = Fixture::new(false); + let wrong = source_g1(fixture.points[6]); + fixture.mutate_source(false, 1, true, |bytes| { + bytes[28..92].copy_from_slice(&wrong) + }); + fixture.assert_rejected(8, "not one tau progression"); +} + +#[test] +fn rescaled_valid_progression_is_not_the_ignition_shifted_basis() { + let mut fixture = Fixture::new(false); + let rescaled: Vec<_> = fixture + .points + .iter() + .map(|p| (*p * Fr::from(13u64)).into_affine()) + .collect(); + for index in 0..TOTAL_POINTS.div_ceil(POINTS_PER_TRANSCRIPT) { + fixture.mutate_source(false, index, true, |bytes| { + let start = (index * POINTS_PER_TRANSCRIPT) as usize; + let count = + (TOTAL_POINTS as usize - start).min(POINTS_PER_TRANSCRIPT as usize); + for (i, point) in rescaled[start..start + count].iter().enumerate() { + bytes[28 + i * 64..28 + (i + 1) * 64] + .copy_from_slice(&source_g1(*point)); + } + }); + } + fixture.assert_rejected(8, "does not match its shifted basis"); +} + +#[test] +fn incorrect_contribution_link_is_rejected() { + let mut fixture = Fixture::new(true); + fixture.mutate_source(false, 0, true, |bytes| { + let start = 28 + POINTS_PER_TRANSCRIPT as usize * 64 + 128; + let wrong = + source_g2((G2Projective::generator() * Fr::from(13u64)).into_affine()); + bytes[start..start + 128].copy_from_slice(&wrong); + }); + fixture.assert_rejected(8, "does not follow its predecessor"); +} + +#[test] +fn predecessor_prefix_and_unused_files_are_authenticated() { + let mut fixture = Fixture::new(true); + let wrong = source_g1(G1Affine::generator()); + fixture.mutate_source(true, 1, true, |bytes| { + bytes[28..92].copy_from_slice(&wrong) + }); + fixture.assert_rejected(8, "not one tau progression"); + let mut fixture = Fixture::new(true); + fixture.manifest.predecessor.as_mut().unwrap().transcripts[2].blake2b512 = + "00".repeat(64); + fixture.save_manifest(); + fixture.assert_rejected(8, "source digest mismatch"); +} + +#[test] +fn invalid_prefixes_are_rejected_before_publication() { + let fixture = Fixture::new(false); + for prefix_len in [0, 1, 3, 16, usize::MAX] { + fixture.assert_rejected(prefix_len, "prefix length"); + } +} + +#[test] +fn destination_is_never_overwritten() { + let fixture = Fixture::new(false); + fs::write(&fixture.cache, b"existing cache").unwrap(); + assert_eq!( + fixture.import(8).unwrap_err().kind(), + io::ErrorKind::AlreadyExists + ); + assert_eq!(fs::read(&fixture.cache).unwrap(), b"existing cache"); +} + +#[test] +fn cache_pin_header_and_requested_data_are_authenticated() { + let fixture = Fixture::new(false); + let receipt = fixture.import(8).unwrap(); + assert!(load_ignition_cache(&fixture.cache, [0; 32], 8).is_err()); + assert!(load_ignition_cache(&fixture.cache, receipt.digest, 16).is_err()); + let original = fs::read(&fixture.cache).unwrap(); + let payload_offset = original.len() - 8 * 64 - 32 - 32; + let mut corrupt = original.clone(); + corrupt[payload_offset + 6 * 64] ^= 1; + fs::write(&fixture.cache, &corrupt).unwrap(); + assert!(load_ignition_cache(&fixture.cache, receipt.digest, 8).is_err()); + assert!(load_ignition_cache(&fixture.cache, receipt.digest, 4).is_err()); + let anchors = load_ignition_cache(&fixture.cache, receipt.digest, 2).unwrap(); + assert_eq!(anchors.g1, fixture.points[..2]); + for offset in [0, payload_offset - 1, original.len() - 33, original.len() - 1] + { + let mut corrupt = original.clone(); + corrupt[offset] ^= 1; + fs::write(&fixture.cache, &corrupt).unwrap(); + assert!(load_ignition_cache(&fixture.cache, receipt.digest, 2).is_err()); + } + fs::write(&fixture.cache, &original[..original.len() - 1]).unwrap(); + assert!(load_ignition_cache(&fixture.cache, receipt.digest, 2).is_err()); +} + +#[test] +fn cache_prefix_loads_skip_unrequested_chunks() { + let fixture = Fixture::new(false); + let block_points = 1 << 16; + let prefix_len = 2 * block_points; + let setup = + crate::kzg::PublicSetup { max_degree: prefix_len - 1, id: [29; 32] }; + let anchors = Anchors { + g1: [G1Affine::generator(); 2], + tau_g2: G2Affine::generator(), + contribution_g2: G2Affine::generator(), + }; + let mut writer = + cache::CacheWriter::create(&fixture.cache, prefix_len, setup, [31; 32]) + .unwrap(); + let block = vec![G1Affine::generator(); block_points]; + writer.push(&block).unwrap(); + writer.push(&block).unwrap(); + let receipt = writer.finish(&anchors).unwrap(); + let mut bytes = fs::read(&fixture.cache).unwrap(); + let payload_offset = bytes.len() - prefix_len * 64 - 2 * 32 - 32; + bytes[payload_offset + block_points * 64] ^= 1; + fs::write(&fixture.cache, bytes).unwrap(); + let loaded = load_ignition_cache(&fixture.cache, receipt.digest, 4).unwrap(); + assert_eq!(loaded.g1, vec![G1Affine::generator(); 4]); + assert_eq!(loaded.public_setup, setup); + assert!( + load_ignition_cache(&fixture.cache, receipt.digest, prefix_len).is_err() + ); +} + +#[test] +fn coordinate_decoders_match_canonical_generators_and_reject_aliases() { + let g1 = G1Affine::generator(); + let g2 = G2Affine::generator(); + let mut source_generator = [0; 64]; + source_generator[7] = 1; + source_generator[39] = 2; + assert_eq!(points::decode_source_g1(&source_generator).unwrap(), g1); + let mut cache_generator = [0; 64]; + cache_generator[0] = 1; + cache_generator[32] = 2; + assert_eq!(points::encode_g1(&g1), cache_generator); + assert_eq!(points::decode_source_g1(&source_g1(g1)).unwrap(), g1); + assert_eq!(points::decode_source_g2(&source_g2(g2)).unwrap(), g2); + assert_eq!(points::decode_g1(&points::encode_g1(&g1)).unwrap(), g1); + assert_eq!(points::decode_g2(&points::encode_g2(&g2)).unwrap(), g2); + let mut noncanonical = source_g1(g1); + let modulus: Vec<_> = + Fq::MODULUS.0.into_iter().flat_map(u64::to_be_bytes).collect(); + noncanonical[..32].copy_from_slice(&modulus); + assert!(points::decode_source_g1(&noncanonical).is_err()); + assert!(points::decode_source_g1(&[0; 64]).is_err()); + assert!(points::decode_source_g2(&[0; 128]).is_err()); + assert!(points::decode_source_g1(&[0; 63]).is_err()); + assert!(points::decode_source_g2(&[0; 129]).is_err()); + let mut swapped = source_g2(g2); + for coordinate in swapped.as_chunks_mut::<64>().0 { + coordinate.rotate_left(32); + } + assert!(points::decode_source_g2(&swapped).is_err()); +} + +#[test] +fn g2_decoder_rejects_on_curve_points_outside_the_prime_order_subgroup() { + let point = (0..100u64).find_map(|value| { + let x = Fq2::new(Fq::from(value), Fq::ONE); + let rhs = x.square() * x + ::COEFF_B; + let point = G2Affine::new_unchecked(x, rhs.sqrt()?); + (!point.is_in_correct_subgroup_assuming_on_curve()).then_some(point) + }).expect("small deterministic G2 point outside the subgroup"); + assert!(point.is_on_curve()); + assert!(points::decode_source_g2(&source_g2(point)).is_err()); +} + +#[test] +fn progression_checks_include_chunk_boundaries() { + let tau = Fr::from(7u64); + let points = shifted_points(tau, 8); + let anchors = Anchors { + g1: [points[0], points[1]], + tau_g2: (G2Projective::generator() * tau).into_affine(), + contribution_g2: G2Affine::generator(), + }; + for chunk_size in [1, 2, 3, 8] { + let mut check = ProgressionCheck::new([17; 32], points.len(), 197); + check.absorb(&[]).unwrap(); + for chunk in points.chunks(chunk_size) { + check.absorb(chunk).unwrap(); + } + check.verify(&anchors).unwrap(); + } + let mut corrupt = points; + for point in &mut corrupt[4..] { + *point = (*point * Fr::from(13u64)).into_affine(); + } + let mut check = ProgressionCheck::new([17; 32], corrupt.len(), 197); + for chunk in corrupt.chunks(4) { + check.absorb(chunk).unwrap(); + } + assert!(check.verify(&anchors).is_err()); + let mut check = ProgressionCheck::new([17; 32], 8, 197); + check.absorb(&corrupt[..2]).unwrap(); + assert!(check.verify(&anchors).is_err()); + let mut check = ProgressionCheck::new([17; 32], 2, 197); + assert!(check.absorb(&corrupt).is_err()); +} diff --git a/crates/proof-compression/src/kzg/srs_cache.rs b/crates/proof-compression/src/kzg/srs_cache.rs index 7bb7629ec..51fa07b15 100644 --- a/crates/proof-compression/src/kzg/srs_cache.rs +++ b/crates/proof-compression/src/kzg/srs_cache.rs @@ -3,9 +3,12 @@ //! uncompressed points behind a BLAKE3 checksum; loading checks canonical //! coordinates, curve membership and the seed-dependent anchors, not the //! full subgroup and progression validation of an import. +use super::curve::{ + G1_UNCOMPRESSED_BYTES as G1_BYTES, G1Affine, G1Projective, + G2_UNCOMPRESSED_BYTES as G2_BYTES, G2Affine, G2Projective, +}; use super::srs::dev_tau; use super::{PublicSetup, Srs}; -use ark_bls12_381::{G1Affine, G1Projective, G2Affine, G2Projective}; use ark_ec::{AffineRepr, CurveGroup, PrimeGroup}; use ark_ff::Field; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; @@ -18,9 +21,10 @@ use std::{ time::Instant, }; +#[cfg(not(feature = "bn254"))] const MAGIC: &[u8] = b"ix/bls12-381/dev-srs/v1\0"; -const G1_BYTES: usize = 96; -const G2_BYTES: usize = 192; +#[cfg(feature = "bn254")] +const MAGIC: &[u8] = b"ix/bn254/dev-srs/v1\0"; const BLOCK_POINTS: usize = 1 << 18; static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); @@ -255,6 +259,19 @@ mod directory_tests { } } + #[test] + fn cache_identity_is_curve_specific() { + let seed = b"curve-specific-cache"; + let mut legacy_header = b"ix/bls12-381/dev-srs/v1\0".to_vec(); + legacy_header.extend_from_slice(&16u64.to_le_bytes()); + legacy_header.extend_from_slice(blake3::hash(seed).as_bytes()); + assert_eq!(header(16, seed) == legacy_header, !cfg!(feature = "bn254")); + assert_eq!( + blake3::hash(&header(16, seed)) == blake3::hash(&legacy_header), + !cfg!(feature = "bn254") + ); + } + #[test] fn cache_round_trip_and_key_separation() { let directory = TestDirectory::new(); @@ -267,6 +284,12 @@ mod directory_tests { let fresh = cached_in_directory(size, 63, seed, &directory.0).unwrap(); let cached = cached_in_directory(size, 63, seed, &directory.0).unwrap(); let expected = Srs::unsafe_dev_setup_prefix(size, 63, seed); + assert_eq!(expected.g1[0].uncompressed_size(), G1_BYTES); + assert_eq!(expected.g2.uncompressed_size(), G2_BYTES); + assert_eq!( + fs::metadata(directory.path(size, seed)).unwrap().len(), + (header(size, seed).len() + size * G1_BYTES + 2 * G2_BYTES + 32) as u64 + ); for actual in [fresh, cached] { assert_eq!(actual.g1, expected.g1); assert_eq!(actual.g2, expected.g2); diff --git a/crates/proof-compression/src/kzg/transcript.rs b/crates/proof-compression/src/kzg/transcript.rs index c97350d4f..183fed576 100644 --- a/crates/proof-compression/src/kzg/transcript.rs +++ b/crates/proof-compression/src/kzg/transcript.rs @@ -1,11 +1,11 @@ -//! Fiat-Shamir transcripts over the BLS12-381 scalar field. +//! Fiat-Shamir transcripts over the selected KZG curve's scalar field. //! //! BLAKE3 samples hash the chain state and buffered canonical observations, //! reduce 64 XOF bytes little-endian into the scalar field, take 32 more //! bytes as the next state, and clear the buffer. Keccak-256 uses separate //! digest domains for its wide challenge and next state. -use ark_bls12_381::Fr; +use super::curve::Fr; use ark_ff::PrimeField; use ark_serialize::CanonicalSerialize; use tiny_keccak::{Hasher, Keccak}; @@ -169,7 +169,10 @@ impl Transcript for Blake3Transcript { } } +#[cfg(not(feature = "bn254"))] const KECCAK256_DOMAIN: &[u8] = b"multi-stark/kzg/keccak256/v1"; +#[cfg(feature = "bn254")] +const KECCAK256_DOMAIN: &[u8] = b"ix/kzg/bn254/keccak256/v1"; fn keccak256(parts: &[&[u8]]) -> [u8; 32] { let mut hasher = Keccak::v256(); @@ -262,10 +265,11 @@ impl Transcript for Keccak256Transcript { #[cfg(test)] mod tests { use super::*; - use ark_bls12_381::G1Affine; + use crate::kzg::curve::G1Affine; use ark_ec::AffineRepr; use ark_ff::BigInteger; use multi_stark::traits::Field; + use num_bigint::BigUint; #[test] fn deterministic_and_sensitive() { @@ -288,6 +292,42 @@ mod tests { assert_ne!(t.sample_challenge(), t.sample_challenge()); } + #[test] + fn blake3_challenges_match_integer_reduction() { + let mut t = Blake3Transcript::new(); + t.observe_bytes(b"kzg-blake3-vector-v1"); + t.observe_field(Scalar::from_u64(7)); + t.observe_bytes(&(0u8..160).collect::>()); + + let mut observed = b"kzg-blake3-vector-v1".to_vec(); + observed.extend(7u64.to_le_bytes()); + observed.extend([0; 24]); + observed.extend(0u8..160); + let modulus = BigUint::from_bytes_le(&Fr::MODULUS.to_bytes_le()); + let mut state = [0u8; 32]; + for round in 0..3 { + if round == 2 { + t.observe_bytes(b"after-squeeze"); + t.observe_field(Scalar::NEG_ONE); + observed.extend(b"after-squeeze"); + let mut field_bytes = (&modulus - 1u8).to_bytes_le(); + field_bytes.resize(32, 0); + observed.extend(field_bytes); + } + let input: Vec<_> = + state.iter().copied().chain(observed.drain(..)).collect(); + let mut output = [0; 96]; + blake3::Hasher::new().update(&input).finalize_xof().fill(&mut output); + let reduced = BigUint::from_bytes_le(&output[..64]) % &modulus; + let mut expected = reduced.to_bytes_le(); + expected.resize(32, 0); + state.copy_from_slice(&output[64..]); + assert_eq!(t.sample_challenge().0.into_bigint().to_bytes_le(), expected); + assert_eq!(t.state, state); + assert!(t.buffer.is_empty()); + } + } + /// Slice observation must equal element-wise observation (trait /// contract). #[test] @@ -318,6 +358,7 @@ mod tests { } #[test] + #[cfg(not(feature = "bn254"))] fn keccak_transcript_matches_independent_vectors() { // PyCryptodome 3.23.0 Crypto.Hash.keccak, digest_bits=256, independently // generates each digest from the documented concatenation. Challenges diff --git a/crates/proof-compression/src/kzg/verifier_params.rs b/crates/proof-compression/src/kzg/verifier_params.rs index cae1725fe..0b54e86ff 100644 --- a/crates/proof-compression/src/kzg/verifier_params.rs +++ b/crates/proof-compression/src/kzg/verifier_params.rs @@ -1,11 +1,17 @@ //! Portable KZG anchors and the complete public degree allowance. -use super::{PublicSetup, Srs}; -use ark_bls12_381::{G1Affine, G2Affine}; +use super::curve::{G1Affine, G2Affine, PairingCurve}; +use super::{PublicSetup, Scalar, Srs}; use ark_ec::{AffineRepr, pairing::Pairing}; use ark_ff::Zero; use ark_serialize::{ CanonicalDeserialize, CanonicalSerialize, SerializationError, }; +use multi_stark::traits::TwoAdicField; + +#[cfg(not(feature = "bn254"))] +const VERSION: u64 = 4; +#[cfg(feature = "bn254")] +const VERSION: u64 = 0x424e0001; #[derive(Clone)] pub struct VerifierParams { @@ -19,7 +25,7 @@ pub struct VerifierParams { fn valid_cap(cap: usize, setup: PublicSetup) -> bool { cap >= 2 && cap.is_power_of_two() - && cap.ilog2() <= 32 + && cap.ilog2() as usize <= Scalar::TWO_ADICITY && setup.max_degree >= cap - 1 && setup.max_degree.checked_add(1).is_some() } @@ -55,7 +61,7 @@ impl VerifierParams { pub fn to_bytes(&self) -> Result, SerializationError> { let mut out = vec![]; - 4u64.serialize_compressed(&mut out)?; + VERSION.serialize_compressed(&mut out)?; (self.max_trace_len as u64).serialize_compressed(&mut out)?; (self.public_setup.max_degree as u64).serialize_compressed(&mut out)?; self.public_setup.id.serialize_compressed(&mut out)?; @@ -69,7 +75,7 @@ impl VerifierParams { /// Validated point decoding and shape checks; provenance is authenticated separately. pub fn from_bytes(mut input: &[u8]) -> Result { - if u64::deserialize_compressed(&mut input)? != 4 { + if u64::deserialize_compressed(&mut input)? != VERSION { return Err(SerializationError::InvalidData); } let max_trace_len = @@ -94,8 +100,7 @@ impl VerifierParams { || g1.iter().any(AffineRepr::is_zero) || g2.is_zero() || tau_g2.is_zero() - || !ark_bls12_381::Bls12_381::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]) - .is_zero() + || !PairingCurve::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]).is_zero() { return Err(SerializationError::InvalidData); } @@ -107,6 +112,50 @@ impl VerifierParams { mod tests { use super::*; + #[test] + fn trace_cap_respects_scalar_two_adicity() { + #[cfg(feature = "bn254")] + assert_eq!(Scalar::TWO_ADICITY, 28); + #[cfg(not(feature = "bn254"))] + assert_eq!(Scalar::TWO_ADICITY, 32); + let Some(max_cap) = + 1usize.checked_shl(u32::try_from(Scalar::TWO_ADICITY).unwrap()) + else { + return; + }; + let anchors = + Srs::unsafe_dev_setup_prefix(2, 2 * max_cap - 1, b"trace-cap"); + let params = VerifierParams::with_max_trace_len(&anchors, max_cap); + assert_eq!( + VerifierParams::from_bytes(¶ms.to_bytes().unwrap()) + .unwrap() + .max_trace_len(), + max_cap + ); + let mut invalid = params; + invalid.max_trace_len = 2 * max_cap; + assert!(!valid_cap(invalid.max_trace_len, invalid.public_setup)); + assert!(VerifierParams::from_bytes(&invalid.to_bytes().unwrap()).is_err()); + } + + #[test] + fn parameter_encoding_identifies_curve() { + use super::super::curve::{G1_COMPRESSED_BYTES, G2_COMPRESSED_BYTES}; + + let params = + VerifierParams::from_srs(&Srs::unsafe_dev_setup(2, b"curve-version")); + let mut encoded = params.to_bytes().unwrap(); + assert_eq!( + encoded.len(), + 3 * 8 + 32 + 2 * G1_COMPRESSED_BYTES + 2 * G2_COMPRESSED_BYTES + ); + assert_eq!(&encoded[..8], &VERSION.to_le_bytes()); + let other_curve_version: u64 = + if cfg!(feature = "bn254") { 4 } else { 0x424e0001 }; + encoded[..8].copy_from_slice(&other_curve_version.to_le_bytes()); + assert!(VerifierParams::from_bytes(&encoded).is_err()); + } + #[test] fn public_identity_and_degree_are_independent_of_loaded_prefix() { let prover = Srs::unsafe_dev_setup(64, b"verifier-params"); diff --git a/crates/proof-compression/src/lib.rs b/crates/proof-compression/src/lib.rs index 80b19414c..ac0e8e283 100644 --- a/crates/proof-compression/src/lib.rs +++ b/crates/proof-compression/src/lib.rs @@ -1,5 +1,9 @@ //! FRI and KZG proof compression for Ix. -extern crate self as ix_proof_compression; +#[cfg(all(feature = "bn254", feature = "bls12-381"))] +compile_error!( + "BN254 KZG requires --no-default-features --features bn254; the BLS12-381 feature cannot be combined with it" +); + mod circuit_metadata; pub mod foreign; pub mod fri; diff --git a/crates/proof-compression/src/pipeline/driver.rs b/crates/proof-compression/src/pipeline/driver.rs new file mode 100644 index 000000000..0cd95322b --- /dev/null +++ b/crates/proof-compression/src/pipeline/driver.rs @@ -0,0 +1,245 @@ +//! Saved-proof compression and verification with an explicitly pinned profile. + +use super::{ + Result, + setup::{SetupClass, SetupConfig}, + storage, +}; +use crate::kzg::curve; +use std::{fs, path::Path}; + +const USAGE: &str = "usage: ix-compression-worker pipeline run [24|25] [--dev-srs]\n ix-compression-worker pipeline run-fri [24|25] [--dev-srs]\n ix-compression-worker pipeline verify [--dev-srs]\n ix-compression-worker pipeline verify-ignition "; + +fn arguments(args: &[String]) -> Result<(Vec<&str>, bool)> { + let mut positional = Vec::new(); + let mut development = false; + for arg in args { + if arg == "--dev-srs" { + if development { + return Err("duplicate --dev-srs".into()); + } + development = true; + } else if arg.starts_with("--") { + return Err(format!("unknown pipeline option: {arg}").into()); + } else { + positional.push(arg.as_str()); + } + } + Ok((positional, development)) +} + +fn height(args: &[&str]) -> Result { + let height = + args.first().map(|value| value.parse()).transpose()?.unwrap_or(24); + if args.len() > 1 || !matches!(height, 24 | 25) { + return Err("pipeline log-height must be 24 or 25".into()); + } + Ok(height) +} + +fn digest(text: &str) -> Result<[u8; 32]> { + if text.len() != 64 || !text.is_ascii() { + return Err( + "trusted profile BLAKE3 must contain exactly 64 hexadecimal characters" + .into(), + ); + } + let mut digest = [0; 32]; + for (i, byte) in digest.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; + } + Ok(digest) +} + +fn publish( + out: &Path, + envelope: &[u8], + claim: &[u8], + expected: &[u8], + source: &str, + setup: &SetupConfig, +) -> Result<()> { + let profile_path = out.join("final-kzg/verifier.bin"); + let profile = fs::read(&profile_path)?; + let profile_digest = *blake3::hash(&profile).as_bytes(); + #[cfg(feature = "bn254")] + let verified_setup = if let Some(admission) = setup.ignition_admission() { + super::verify_with_ignition_admission( + envelope, + claim, + expected, + &profile, + &profile_digest, + admission, + )? + } else { + super::verify_with_profile( + envelope, + claim, + expected, + &profile, + &profile_digest, + setup.class() == SetupClass::Development, + )? + }; + #[cfg(not(feature = "bn254"))] + let verified_setup = super::verify_with_profile( + envelope, + claim, + expected, + &profile, + &profile_digest, + setup.class() == SetupClass::Development, + )?; + if verified_setup != setup.class() { + return Err("completed proof differs from the selected setup class".into()); + } + storage::write_atomic(&out.join("proof.envelope.bin"), envelope)?; + storage::write_atomic(&out.join("expected-root-claims.bin"), expected)?; + let report = serde_json::json!({ + "format": "ix-saved-compression-run/v1", + "curve": curve::NAME, + "input_stage": source, + "setup": verified_setup.label(), + "known_trapdoor": verified_setup == SetupClass::Development, + "cuda": cfg!(feature = "cuda"), + "parallel": cfg!(feature = "parallel"), + "native_verification_passed": true, + "proof_bytes": envelope.len(), + "proof_blake3": blake3::hash(envelope).to_hex().as_str(), + "profile": "final-kzg/verifier.bin", + "profile_blake3": blake3::Hash::from_bytes(profile_digest).to_hex().as_str(), + "expected_root_claims": "expected-root-claims.bin", + }); + storage::write_atomic( + &out.join("pipeline-report.json"), + &serde_json::to_vec_pretty(&report)?, + )?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +pub(super) fn run(args: &[String]) -> Result<()> { + let (positional, development) = arguments(args)?; + let Some((command, args)) = positional.split_first() else { + return Err(USAGE.into()); + }; + match (*command, args) { + ("run", [root, claim, out, log @ ..]) if log.len() <= 1 => { + let log_height = height(log)?; + let setup = SetupConfig::from_env(development)?; + let root = Path::new(root); + let out = Path::new(out); + let claim = fs::read(claim)?; + let expected = fs::read(root.join("root-claims.bin"))?; + let envelope = super::compress( + &fs::read(root.join("root-vk.bin"))?, + &fs::read(root.join("root-proof.bin"))?, + &claim, + &expected, + out, + &out.join("cache"), + development, + log_height, + false, + )?; + publish(out, &envelope, &claim, &expected, "root", &setup) + }, + ("run-fri", [fri, claim, expected, out, log @ ..]) if log.len() <= 1 => { + let log_height = height(log)?; + let setup = SetupConfig::from_env(development)?; + let out = Path::new(out); + let claim = fs::read(claim)?; + let expected = fs::read(expected)?; + let envelope = super::compress_fri( + Path::new(fri), + &claim, + &expected, + out, + &out.join("cache"), + development, + log_height, + )?; + publish(out, &envelope, &claim, &expected, "fri", &setup) + }, + #[cfg(feature = "bn254")] + ( + "verify-ignition", + [proof, claim, expected, profile, trusted, admission, admitted], + ) => { + if development { + return Err("verify-ignition does not accept --dev-srs".into()); + } + let trusted = digest(trusted)?; + let admitted = digest(admitted)?; + let admission = crate::kzg::srs::ignition::IgnitionAdmission::read( + Path::new(admission), + admitted, + )?; + let class = super::verify_with_ignition_admission( + &fs::read(proof)?, + &fs::read(claim)?, + &fs::read(expected)?, + &fs::read(profile)?, + &trusted, + &admission, + )?; + println!( + "{}", + serde_json::json!({ + "curve": curve::NAME, + "verified": true, + "setup": class.label(), + "profile_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), + "admission_blake3": blake3::Hash::from_bytes(admitted).to_hex().as_str(), + }) + ); + Ok(()) + }, + ("verify", [proof, claim, expected, profile, trusted]) => { + let trusted = digest(trusted)?; + let class = super::verify_with_profile( + &fs::read(proof)?, + &fs::read(claim)?, + &fs::read(expected)?, + &fs::read(profile)?, + &trusted, + development, + )?; + println!( + "{}", + serde_json::json!({ + "curve": curve::NAME, + "verified": true, + "setup": class.label(), + "profile_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), + }) + ); + Ok(()) + }, + _ => Err(USAGE.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn options_and_profile_digest_are_explicit() { + let args = ["run", "root", "claim", "out", "--dev-srs"].map(str::to_owned); + let (positional, development) = arguments(&args).unwrap(); + assert_eq!(positional, ["run", "root", "claim", "out"]); + assert!(development); + assert!(arguments(&["--dev-srs".into(), "--dev-srs".into()]).is_err()); + assert!(arguments(&["--unknown".into()]).is_err()); + assert_eq!(height(&[]).unwrap(), 24); + assert_eq!(height(&["25"]).unwrap(), 25); + assert!(height(&["28"]).is_err()); + assert!(height(&["24", "25"]).is_err()); + assert_eq!(digest(&"a5".repeat(32)).unwrap(), [0xa5; 32]); + for text in ["ab".repeat(31), "gg".repeat(32), "é".repeat(32)] { + assert!(digest(&text).is_err()); + } + } +} diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index b0ad37c59..270f5e4ab 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -6,13 +6,13 @@ //! proof and the metadata the recursive stage verifies it with, plus the //! fixed-preprocessing checkpoints that populate the cross-run disk cache. use crate::fri::{VerifierLimits, VerifierPlan}; -#[cfg(test)] use crate::kzg::Scalar; use multi_stark::config::ProofConfig; #[cfg(test)] use multi_stark::plonkish::{Assignment, MultiStarkCircuit}; #[cfg(test)] use multi_stark::traits::Field; +use multi_stark::traits::TwoAdicField; use std::{fs, path::Path, time::Instant}; use super::stages::construct::{self, FriConstruction, Lowered, max_height}; @@ -32,8 +32,15 @@ pub(super) use super::stages::statement::first_claims as expected_claims; fn log_height_arg(arg: Option<&String>) -> storage::Result { let log_height = arg.map(|s| s.parse::()).transpose()?.unwrap_or(24); - if !(24..=28).contains(&log_height) { - return Err("log-height must be 24..28".into()); + let maximum = 28.min(Scalar::TWO_ADICITY - 1); + if !(24..=maximum).contains(&log_height) { + return Err( + format!( + "log-height must be 24..{maximum} for {} with quotient factor 2", + crate::kzg::curve::NAME + ) + .into(), + ); } Ok(log_height) } @@ -42,12 +49,13 @@ pub(super) fn run( args: &[String], config: &super::WorkerConfig, ) -> storage::Result<()> { - const USAGE: &str = "usage: ix-compression-worker kzg run [log-height: 24..28]"; + const USAGE: &str = "usage: ix-compression-worker kzg run [log-height] [--dev-srs] | kzg size [--dev-srs]"; let Some(command) = args.first() else { return Err(USAGE.into()); }; let rest = &args[1..]; match command.as_str() { + "size" if rest.len() == 2 => size(Path::new(&rest[0]), Path::new(&rest[1])), "run" if (2..=3).contains(&rest.len()) => { let log_height = log_height_arg(rest.get(2))?; stage_and_prove( @@ -61,6 +69,51 @@ pub(super) fn run( } } +fn size(fri: &Path, report: &Path) -> storage::Result<()> { + let start = Instant::now(); + let fixture = init_fri::load(fri)?; + let plan = VerifierPlan::validate( + &fixture.key, + fixture.profile, + VerifierLimits::default(), + )?; + let plan_id = plan.identity(&fixture.schema)?; + let source = FriConstruction::build(&plan, fixture.schema)?.circuit; + let translated = crate::foreign::GoldilocksCircuit::estimate(&source); + let counts = |stats: multi_stark::plonkish::CircuitStats| { + serde_json::json!({ + "values": stats.values, + "inputs": stats.inputs, + "gates": stats.gates, + "lookups": stats.lookups, + "tables": stats.tables, + "publics": stats.publics, + "hint_calls": stats.hint_calls, + "hint_outputs": stats.hint_outputs, + "hash_calls": stats.hash_calls, + "hash_compressions": stats.hash_compressions, + }) + }; + let result = serde_json::json!({ + "format": "ix-first-kzg-sizing/v1", + "curve": crate::kzg::curve::NAME, + "native_input_verified": true, + "plan_id": blake3::Hash::from_bytes(plan_id).to_hex().as_str(), + "source_frontend": counts(source.stats()), + "scalar_frontend": counts(translated), + "scalar_bytes": size_of::(), + "g1_affine_bytes": size_of::(), + "max_trace_log_with_quotient2": Scalar::TWO_ADICITY - 1, + "elapsed_seconds": start.elapsed().as_secs_f64(), + "srs_loaded": false, + "proof_generated": false, + "scope": "Exact translated frontend counts; retains the source Goldilocks IR and translation wire map, not the translated IR or proving traces. Padded layouts, custom trace widths and total prover memory require separate accounting.", + }); + storage::write_atomic(report, &serde_json::to_vec_pretty(&result)?)?; + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(()) +} + fn stage_and_prove( fri: &Path, out: &Path, @@ -132,7 +185,7 @@ fn prove( return Err("unexpected trace layout".into()); } let height = *manifest.heights.iter().max().ok_or("empty manifest")?; - if height > 1 << 28 + if height > 1 << 28.min(Scalar::TWO_ADICITY - 1) || manifest.heights.iter().any(|h| *h < 2 || !h.is_power_of_two()) { return Err("unsupported trace height".into()); @@ -180,7 +233,10 @@ fn prove( // The label is part of every published profile id and predates the // Filecoin setup; the setup class is bound through the transcript seed. let mut profile = blake3::Hasher::new(); + #[cfg(not(feature = "bn254"))] profile.update(b"init-fri-kzg-ordinary-v1/development-srs/quotient2"); + #[cfg(feature = "bn254")] + profile.update(b"init-fri-kzg-bn254/v1/development-srs/quotient2"); profile.update(system.config.transcript_seed()); profile.update(&plan_id); profile.update(&fs::read(dir.join("manifest.bin"))?); @@ -199,6 +255,7 @@ fn prove( let development = setup.class() == SetupClass::Development; let public_setup = system.config.pcs().public_setup(); let evidence = serde_json::json!({ + "curve": crate::kzg::curve::NAME, "proof_bytes": bytes.len(), "packet_bytes": packet.len(), "trace_heights": manifest.heights, @@ -231,6 +288,15 @@ mod tests { use multi_stark::plonkish::gadgets::blake3; use multi_stark::plonkish::{CircuitBuilder, Value}; + #[test] + fn first_stage_height_leaves_room_for_quotients() { + let maximum = 28.min(Scalar::TWO_ADICITY - 1); + assert_eq!(log_height_arg(None).unwrap(), 24); + assert_eq!(log_height_arg(Some(&maximum.to_string())).unwrap(), maximum); + assert!(log_height_arg(Some(&(maximum + 1).to_string())).is_err()); + assert!(log_height_arg(Some(&"23".to_owned())).is_err()); + } + const OPTIONS: super::super::WorkerConfig = super::super::WorkerConfig { prefetch_bytes: 1 << 20, setup: super::super::setup::SetupConfig::Development { diff --git a/crates/proof-compression/src/pipeline/fixed_cache.rs b/crates/proof-compression/src/pipeline/fixed_cache.rs index 2b9eb2cd0..ba95a992f 100644 --- a/crates/proof-compression/src/pipeline/fixed_cache.rs +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -59,7 +59,10 @@ impl FixedCache { /// dependency versions, SRS recipe, and all circuit implementation choices. pub(crate) fn from_profile(root: &Path, profile: &[u8]) -> Result { let mut hasher = blake3::Hasher::new(); + #[cfg(not(feature = "bn254"))] hasher.update(b"ix/kzg-fixed-cache/v5"); + #[cfg(feature = "bn254")] + hasher.update(b"ix/kzg-fixed-cache/bn254/v1"); hasher.update(&executable_digest()?); hasher.update(profile); let identity = *hasher.finalize().as_bytes(); diff --git a/crates/proof-compression/src/pipeline/init_fri.rs b/crates/proof-compression/src/pipeline/init_fri.rs index c7d6fc5a1..6b31c9dcc 100644 --- a/crates/proof-compression/src/pipeline/init_fri.rs +++ b/crates/proof-compression/src/pipeline/init_fri.rs @@ -1,6 +1,6 @@ //! The saved intermediate FRI proof and the statement it exposes. use super::stages::statement::decode_claims; -use ix_proof_compression::fri::*; +use crate::fri::*; use multi_stark::prover::Proof; use multi_stark::types::GoldilocksBlake3Config; use multi_stark::types::Val; @@ -45,7 +45,7 @@ pub(crate) fn load(dir: &Path) -> Result> { .system() .verify_multiple_claims(&refs, &proof) .map_err(|e| format!("saved recursive proof: {e:?}"))?; - println!("Saved {}-byte recursive FRI proof verified", bytes.len()); + eprintln!("Saved {}-byte recursive FRI proof verified", bytes.len()); let profile = super::stages::construct::fri_profile(&key, &claims)?; if proof.active != profile.active || proof.log_degrees != profile.log_degrees { diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index bb2d1c428..4c1325c31 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -6,6 +6,7 @@ pub use stages::{ prepare_root, setup, }; +mod driver; mod first; mod fixed_cache; #[cfg(test)] @@ -27,14 +28,18 @@ use std::{ process::{Command, Stdio}, }; +#[cfg(not(feature = "bn254"))] pub const PROOF_TAG: &[u8; 8] = b"IXCMP\0\0\x04"; +#[cfg(feature = "bn254")] +pub const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x01"; pub type Result = std::result::Result>; pub fn command(stage: &str, args: &[String]) -> Result<()> { - if stage != "plonk" { + if !matches!(stage, "plonk" | "pipeline") { configure_backend(stage)?; } match stage { + "pipeline" => driver::run(args), #[cfg(feature = "plonk")] "plonk" => plonk::run(args), "fri" => root::run(args), @@ -202,6 +207,42 @@ fn worker_executable() -> Result { ) } +fn check_worker_capabilities(bytes: &[u8]) -> Result<()> { + let capabilities: serde_json::Value = serde_json::from_slice(bytes)?; + if capabilities["format"] != "ix-compression-capabilities/v1" + || capabilities["binary"] != WORKER_EXECUTABLE + || capabilities["curve"] != crate::kzg::curve::NAME + { + return Err(format!("compression worker must report curve {}; select a matching IX_COMPRESSION_WORKER", crate::kzg::curve::NAME).into()); + } + Ok(()) +} + +fn check_worker_setup_capabilities(bytes: &[u8], ignition: bool) -> Result<()> { + check_worker_capabilities(bytes)?; + let capabilities: serde_json::Value = serde_json::from_slice(bytes)?; + if ignition && capabilities["ignition_setup"] != true { + return Err("compression worker does not support admitted Ignition parameters; select a matching IX_COMPRESSION_WORKER".into()); + } + Ok(()) +} + +fn check_worker_curve(ignition: bool) -> Result<()> { + let output = Command::new(worker_executable()?) + .args(["fri", "capabilities"]) + .output()?; + if !output.status.success() { + return Err( + format!( + "compression worker capability check failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into(), + ); + } + check_worker_setup_capabilities(&output.stdout, ignition) +} + fn child(stage: &str, args: &[&str], caches: &Caches) -> Result<()> { let status = Command::new(worker_executable()?) .arg(stage) @@ -254,6 +295,56 @@ pub fn verify( verifier::verify(packet, claim, allow_development) } +/// Verify with a caller-authorized profile digest and independently expected +/// root statement. No proving parameters or bundled catalog entry are needed. +pub fn verify_with_profile( + proof: &[u8], + claim: &[u8], + expected_claims: &[u8], + profile: &[u8], + trusted_profile_digest: &[u8; 32], + allow_development: bool, +) -> Result { + let words = check_claim(claim, expected_claims)?; + let packet = proof + .strip_prefix(PROOF_TAG) + .ok_or("unsupported compressed proof curve or version")?; + verifier::verify_with_profile( + packet, + claim, + &words, + profile, + trusted_profile_digest, + allow_development, + ) +} + +/// Verify with an independently authorized profile and Ignition admission. +/// Both outer and deferred pairing keys must match the admitted parameters. +/// No proving cache or setup environment variables are read. +#[cfg(feature = "bn254")] +pub fn verify_with_ignition_admission( + proof: &[u8], + claim: &[u8], + expected_claims: &[u8], + profile: &[u8], + trusted_profile_digest: &[u8; 32], + admission: &crate::kzg::srs::ignition::IgnitionAdmission, +) -> Result { + let words = check_claim(claim, expected_claims)?; + let packet = proof + .strip_prefix(PROOF_TAG) + .ok_or("unsupported compressed proof curve or version")?; + verifier::verify_with_ignition_admission( + packet, + claim, + &words, + profile, + trusted_profile_digest, + admission, + ) +} + /// Cross-run caches the stages share: fixed preprocessing keyed by the /// executable and profile, and development SRS files. Variables already set /// in the environment take precedence over the directory given to `compress`. @@ -274,32 +365,23 @@ impl Caches { } } -/// Run the three stages into an empty `out` directory, each as a child -/// process so every heavy phase releases its memory and device state before -/// the next starts. Only each stage's proof and verifying metadata are -/// written; a rerun starts over, reusing the caches under `cache_root`. -pub fn compress( - vk: &[u8], - proof: &[u8], - claim: &[u8], - statement: &[u8], - out: &Path, - cache_root: &Path, +fn validate_compression_options( development: bool, log_height: u32, terminal_plonk: bool, -) -> Result> { - let words = check_claim(claim, statement)?; +) -> Result { if terminal_plonk { - if development { - return Err( - "terminal PLONK requires Filecoin parameters; omit --dev-srs".into(), - ); - } - #[cfg(feature = "plonk")] - plonk::helper_executable()?; #[cfg(not(feature = "plonk"))] return Err("this build does not include terminal PLONK support".into()); + #[cfg(feature = "plonk")] + { + if development { + return Err( + "terminal PLONK requires admitted ceremony parameters; omit --dev-srs".into(), + ); + } + plonk::helper_executable()?; + } } if !matches!(log_height, 24 | 25) { return Err( @@ -309,19 +391,52 @@ pub fn compress( } // Child stages re-read the same environment; the policy is enforced once // here so an unconfigured run fails before any checkpoint is written. - setup::SetupConfig::from_env(development)?; + let setup = setup::SetupConfig::from_env(development)?; if std::env::var_os("IX_ROOT_GENERIC_HASHES").is_some() { return Err( "unset IX_ROOT_GENERIC_HASHES for the supported compression profile" .into(), ); } + #[cfg(feature = "bn254")] + let ignition = setup.ignition_admission().is_some(); + #[cfg(not(feature = "bn254"))] + let ignition = false; + check_worker_curve(ignition)?; + Ok(setup) +} + +fn prepare_output(out: &Path) -> Result<()> { fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { return Err( "output directory must be empty; use a new --out directory".into(), ); } + Ok(()) +} + +fn path_string(path: &Path) -> Result { + Ok(path.to_str().ok_or("non-UTF8 artifact path")?.to_owned()) +} + +/// Run FRI, first KZG and recursive KZG in separate worker processes. +/// The output directory must be empty; interrupted stages retain checkpoints. +pub fn compress( + vk: &[u8], + proof: &[u8], + claim: &[u8], + statement: &[u8], + out: &Path, + cache_root: &Path, + development: bool, + log_height: u32, + terminal_plonk: bool, +) -> Result> { + let words = check_claim(claim, statement)?; + let setup = + validate_compression_options(development, log_height, terminal_plonk)?; + prepare_output(out)?; let caches = Caches::under(cache_root); let root = out.join("root"); fs::create_dir_all(&root)?; @@ -329,13 +444,56 @@ pub fn compress( fs::write(root.join("root-proof.bin"), proof)?; fs::write(root.join("root-claims.bin"), statement::to_claims_bytes(&words))?; let fri = out.join("fri"); + child( + "fri", + &[&path_string(&root)?, &path_string(&fri)?, "--prove-outer"], + &caches, + )?; + finish_kzg(&fri, &words, out, cache_root, &setup, log_height, terminal_plonk) +} + +/// Compress an already saved FRI stage while binding its statement to a +/// caller-supplied claim and expected root claims. The FRI stage is verified +/// before starting the KZG workers. +pub fn compress_fri( + fri: &Path, + claim: &[u8], + expected_claims: &[u8], + out: &Path, + cache_root: &Path, + development: bool, + log_height: u32, +) -> Result> { + let words = check_claim(claim, expected_claims)?; + let setup = validate_compression_options(development, log_height, false)?; + prepare_output(out)?; + { + let fixture = init_fri::load(fri)?; + let actual = + statement::from_scalars(&stages::scalar_statement(&fixture.public))?; + if actual != words { + return Err("saved FRI statement differs from the independently expected root statement".into()); + } + } + finish_kzg(fri, &words, out, cache_root, &setup, log_height, false) +} + +fn finish_kzg( + fri: &Path, + words: &statement::Statement, + out: &Path, + cache_root: &Path, + setup: &setup::SetupConfig, + log_height: u32, + terminal_plonk: bool, +) -> Result> { + let development = setup.class() == setup::SetupClass::Development; + let caches = Caches::under(cache_root); let first = out.join("first-kzg"); let final_dir = out.join("final-kzg"); - let path = |p: &Path| -> Result { - Ok(p.to_str().ok_or("non-UTF8 output path")?.to_owned()) - }; - let (root_s, fri_s, first_s, final_s) = - (path(&root)?, path(&fri)?, path(&first)?, path(&final_dir)?); + let path = path_string; + let (fri_s, first_s, final_s) = + (path(fri)?, path(&first)?, path(&final_dir)?); // Each KZG worker selects its setup from the forwarded development flag // and cache configuration. let kzg = |stage, args: &[&str]| { @@ -345,14 +503,24 @@ pub fn compress( } child(stage, &args, &caches) }; - child("fri", &[&root_s, &fri_s, "--prove-outer"], &caches)?; let height = log_height.to_string(); kzg("kzg", &["run", &fri_s, &first_s, &height])?; kzg("wrap", &["stage-and-prove", &first_s, &final_s])?; let mut result = PROOF_TAG.to_vec(); result.extend(fs::read(final_dir.join("kzg/packet.bin"))?); let packet = &result[PROOF_TAG.len()..]; - wrap::verify(&final_dir, packet, Some(&words))?; + let profile = wrap::VerifierProfile::from_bytes(&fs::read( + final_dir.join("verifier.bin"), + )?)?; + #[cfg(feature = "bn254")] + if let Some(admission) = setup.ignition_admission() { + profile.check_ignition_admission(admission)?; + } else { + profile.check_setup_class(setup.class())?; + } + #[cfg(not(feature = "bn254"))] + profile.check_setup_class(setup.class())?; + profile.verify(packet, words)?; // A bundled profile pins the aggregation program its proofs attest; the // backend this run proved under must be that program, or the bundle is // stale and verification elsewhere would accept the wrong statement. @@ -395,6 +563,57 @@ pub fn compress( #[cfg(test)] mod tests { use super::*; + + #[test] + fn worker_capabilities_reject_other_curves_and_missing_identity() { + let capabilities = |curve: &str| { + serde_json::to_vec(&serde_json::json!({ + "format": "ix-compression-capabilities/v1", + "binary": WORKER_EXECUTABLE, + "curve": curve, + })) + .unwrap() + }; + check_worker_capabilities(&capabilities(crate::kzg::curve::NAME)).unwrap(); + let other = if cfg!(feature = "bn254") { "bls12-381" } else { "bn254" }; + assert!(check_worker_capabilities(&capabilities(other)).is_err()); + assert!(check_worker_capabilities(b"{}").is_err()); + assert!(check_worker_capabilities(b"not-json").is_err()); + } + + #[test] + fn ignition_runs_require_explicit_worker_support() { + let mut capabilities = serde_json::json!({ + "format": "ix-compression-capabilities/v1", + "binary": WORKER_EXECUTABLE, + "curve": crate::kzg::curve::NAME, + }); + let bytes = serde_json::to_vec(&capabilities).unwrap(); + check_worker_setup_capabilities(&bytes, false).unwrap(); + assert!(check_worker_setup_capabilities(&bytes, true).is_err()); + capabilities["ignition_setup"] = true.into(); + check_worker_setup_capabilities( + &serde_json::to_vec(&capabilities).unwrap(), + true, + ) + .unwrap(); + } + + #[test] + fn explicit_profile_verification_rejects_other_curve_envelopes() { + let expected = + statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); + let other = if cfg!(feature = "bn254") { + b"IXCMP\0\0\x04" + } else { + b"IXBN\0\0\0\x01" + }; + let error = + verify_with_profile(other, b"claim", &expected, b"", &[0; 32], true) + .unwrap_err(); + assert!(error.to_string().contains("curve or version")); + } + #[test] fn unsupported_claim_is_rejected_before_io() { assert!( diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 3ea80c4f3..323285839 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -5,6 +5,7 @@ use super::{ statement, storage, wrap, }; use ark_ff::{BigInteger, PrimeField}; +#[cfg(not(feature = "bn254"))] use ark_serialize::CanonicalSerialize; use sha2::{Digest, Sha256}; use std::{ @@ -14,8 +15,22 @@ use std::{ process::{Command, Stdio}, }; +#[cfg(not(feature = "bn254"))] pub(super) const PROOF_TAG: &[u8; 8] = b"IXCMP\0\0\x05"; -const HELPER: &str = "ix-bls-plonk-wrapper"; +#[cfg(feature = "bn254")] +pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; +const HELPER: &str = if cfg!(feature = "bn254") { + "ix-bn254-plonk-wrapper" +} else { + "ix-bls-plonk-wrapper" +}; +const CATALOG_FORMAT: &str = if cfg!(feature = "bn254") { + "ix-bn254-plonk-verifiers-v1" +} else { + "ix-bls-plonk-verifiers-v1" +}; +const SETUP_NAME: &str = + if cfg!(feature = "bn254") { "ignition" } else { "filecoin" }; const CATALOG_VAR: &str = "IX_PLONK_VERIFIER_CATALOG"; const MAX_PROOF_BYTES: usize = 1 << 20; @@ -36,6 +51,10 @@ struct TrustedKey { circuit_sha256: String, helper_sha256: String, setup: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + admission: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + admission_blake3: Option, } struct Scratch(PathBuf); @@ -124,18 +143,75 @@ fn checked(command: &mut Command) -> Result<()> { Ok(()) } -fn filecoin_anchor_encoding() -> Result { - let anchors = crate::kzg::srs::filecoin::filecoin_anchors(); +fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { + match config { + #[cfg(not(feature = "bn254"))] + SetupConfig::Filecoin { manifest, .. } => Ok(*manifest), + #[cfg(feature = "bn254")] + SetupConfig::Ignition { manifest, .. } => Ok(*manifest), + _ => Err("PLONK requires authenticated ceremony parameters".into()), + } +} + +fn anchors(config: &SetupConfig) -> Result { + setup_manifest(config)?; + #[cfg(not(feature = "bn254"))] + return Ok(crate::kzg::srs::filecoin::filecoin_anchors()); + #[cfg(feature = "bn254")] + Ok(config.ignition_admission().ok_or("missing Ignition admission")?.anchors()) +} + +fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { let mut bytes = Vec::new(); - anchors.g1[0].serialize_compressed(&mut bytes)?; - anchors.g2.serialize_compressed(&mut bytes)?; - anchors.tau_g2.serialize_compressed(&mut bytes)?; - Ok(hex(&bytes)) + #[cfg(not(feature = "bn254"))] + point.serialize_compressed(&mut bytes).expect("Vec serialization"); + #[cfg(feature = "bn254")] + for value in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { + bytes.extend(value.into_bigint().to_bytes_be()); + } + bytes +} + +fn anchor_encoding(srs: &crate::kzg::Srs) -> String { + let mut bytes = Vec::new(); + #[cfg(not(feature = "bn254"))] + srs.g1[0].serialize_compressed(&mut bytes).expect("Vec serialization"); + #[cfg(feature = "bn254")] + for value in [srs.g1[0].x, srs.g1[0].y] { + bytes.extend(value.into_bigint().to_bytes_be()); + } + bytes.extend(encode_g2(srs.g2)); + bytes.extend(encode_g2(srs.tau_g2)); + hex(&bytes) +} + +#[cfg_attr(not(feature = "bn254"), allow(clippy::unnecessary_wraps))] +fn key_anchors(key: &TrustedKey) -> Result { + #[cfg(not(feature = "bn254"))] + { + let _ = key; + Ok(crate::kzg::srs::filecoin::filecoin_anchors()) + } + #[cfg(feature = "bn254")] + { + Ok( + crate::kzg::srs::ignition::IgnitionAdmission::read( + key.admission.as_ref().ok_or("missing trusted admission path")?, + digest( + key + .admission_blake3 + .as_ref() + .ok_or("missing trusted admission pin")?, + )?, + )? + .anchors(), + ) + } } fn select_key(path: &Path, id: &[u8; 32]) -> Result { let catalog: TrustedCatalog = load_json(path)?; - if catalog.format != "ix-bls-plonk-verifiers-v1" { + if catalog.format != CATALOG_FORMAT { return Err("unsupported PLONK verifier catalog".into()); } let mut selected = None; @@ -148,8 +224,17 @@ fn select_key(path: &Path, id: &[u8; 32]) -> Result { if !seen.insert(key_id) { return Err("duplicate PLONK verifier key in catalog".into()); } - if key.setup != "filecoin" { - return Err("PLONK catalog requires Filecoin keys".into()); + if key.setup != SETUP_NAME { + return Err("PLONK catalog setup class differs".into()); + } + #[cfg(feature = "bn254")] + { + digest( + key.admission_blake3.as_ref().ok_or("missing trusted admission pin")?, + )?; + if key.admission.is_none() { + return Err("missing trusted admission path".into()); + } } let words = statement::with_claim(&key.statement_prefix, b""); statement::from_claims_bytes(&statement::to_claims_bytes(&words))?; @@ -163,6 +248,12 @@ fn select_key(path: &Path, id: &[u8; 32]) -> Result { if key.path.is_relative() { key.path = path.parent().unwrap_or_else(|| Path::new(".")).join(&key.path); } + if let Some(admission) = &mut key.admission + && admission.is_relative() + { + *admission = + path.parent().unwrap_or_else(|| Path::new(".")).join(&*admission); + } Ok(key) } @@ -192,18 +283,29 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { .arg(hex(id)) .arg(&proof) .arg(blake3::hash(claim).to_hex().as_str()) - .arg(filecoin_anchor_encoding()?), + .arg(anchor_encoding(&key_anchors(&key)?)), )?; - Ok(SetupClass::Filecoin) + #[cfg(not(feature = "bn254"))] + return Ok(SetupClass::Filecoin); + #[cfg(feature = "bn254")] + Ok(SetupClass::Ignition) } pub(crate) fn run(args: &[String]) -> Result<()> { match args.iter().map(String::as_str).collect::>().as_slice() { + ["verify", proof, claim] => { + let bytes = fs::read(proof)?; + let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported PLONK proof curve or version")?; + let setup = verify(packet, &fs::read(claim)?)?; + println!("{}", serde_json::json!({"verified": true, "curve": crate::kzg::curve::NAME, "setup": setup.label()})); + Ok(()) + }, ["export", verifier, packet, digest, statement, out] => { let expected = serde_json::from_slice(&fs::read(statement)?)?; wrap::plonk_export(Path::new(verifier), Path::new(packet), digest, &expected, Path::new(out)) }, ["srs", domain, out] => export_srs(domain.parse()?, Path::new(out)), + #[cfg(not(feature = "bn254"))] ["profile-blake3", source, trusted, out] => { let source = fs::read(source)?; if *blake3::hash(&source).as_bytes() != digest(trusted)? { return Err("trusted source profile digest differs".into()); } @@ -216,7 +318,7 @@ pub(crate) fn run(args: &[String]) -> Result<()> { let expected = load_json(Path::new(expected))?; wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(cache), Path::new(out)) }, - _ => Err("usage: ix-compression-worker plonk export | srs | wrap ".into()), + _ => Err("usage: ix-compression-worker plonk export | srs | wrap | verify ".into()), } } @@ -235,9 +337,7 @@ fn export_parameters( let count = domain.checked_add(3).ok_or("PLONK SRS length overflow")?; let prefix = count.checked_next_power_of_two().ok_or("PLONK SRS length overflow")?; - let SetupConfig::Filecoin { manifest, .. } = config else { - return Err("PLONK requires authenticated Filecoin parameters".into()); - }; + let manifest = setup_manifest(config)?; let source = config.for_stage(b"ix/bls-plonk/srs/v1", prefix)?; let srs = source.srs(prefix, false)?; let temporary = storage::temporary_path(path); @@ -245,13 +345,17 @@ fn export_parameters( fs::create_dir_all(parent)?; } let mut out = std::io::BufWriter::new(fs::File::create(&temporary)?); - out.write_all(b"IXPSRS01")?; + out.write_all(if cfg!(feature = "bn254") { + b"IXBPSR01" + } else { + b"IXPSRS01" + })?; out.write_all(&srs.public_setup.id)?; out.write_all(&(srs.public_setup.max_degree as u64).to_le_bytes())?; - out.write_all(manifest)?; + out.write_all(&manifest)?; out.write_all(&(count as u64).to_le_bytes())?; - srs.g2.serialize_compressed(&mut out)?; - srs.tau_g2.serialize_compressed(&mut out)?; + out.write_all(&encode_g2(srs.g2))?; + out.write_all(&encode_g2(srs.tau_g2))?; for point in &srs.g1[..count] { out.write_all(&point.x.into_bigint().to_bytes_be())?; out.write_all(&point.y.into_bigint().to_bytes_be())?; @@ -262,10 +366,10 @@ fn export_parameters( let mut hash = blake3::Hasher::new(); hash.update_mmap_rayon(path)?; let receipt = serde_json::json!({ - "format": "ix-plonk-srs-export-v1", "blake3": hash.finalize().to_hex().as_str(), + "format": if cfg!(feature = "bn254") { "ix-bn254-plonk-srs-export-v1" } else { "ix-plonk-srs-export-v1" }, "blake3": hash.finalize().to_hex().as_str(), "setupId": blake3::Hash::from_bytes(srs.public_setup.id).to_hex().as_str(), "publicMaxDegree": srs.public_setup.max_degree, "powers": count, - "filecoinManifest": blake3::Hash::from_bytes(*manifest).to_hex().as_str(), + (if cfg!(feature = "bn254") { "ignitionManifest" } else { "filecoinManifest" }): blake3::Hash::from_bytes(manifest).to_hex().as_str(), }); storage::write_atomic( &path.with_extension("json"), @@ -282,7 +386,8 @@ struct ParameterReceipt { setup_id: String, public_max_degree: usize, powers: usize, - filecoin_manifest: String, + #[serde(rename = "filecoinManifest", alias = "ignitionManifest")] + setup_manifest: String, } fn cached_parameters( @@ -290,14 +395,12 @@ fn cached_parameters( root: &Path, config: &SetupConfig, ) -> Result<(PathBuf, String)> { - let SetupConfig::Filecoin { manifest, .. } = config else { - return Err("PLONK requires Filecoin parameters".into()); - }; - let anchors = crate::kzg::srs::filecoin::filecoin_anchors(); + let manifest = setup_manifest(config)?; + let anchors = anchors(config)?; let mut identity = blake3::Hasher::new(); identity.update(b"ix/plonk-parameter-export/v1"); identity.update(&anchors.public_setup.id); - identity.update(manifest); + identity.update(&manifest); identity.update(&u64::try_from(domain)?.to_le_bytes()); let path = root.join(identity.finalize().to_hex().as_str()).join("srs.bin"); let receipt_path = path.with_extension("json"); @@ -305,9 +408,14 @@ fn cached_parameters( export_parameters(domain, &path, config)?; } let receipt: ParameterReceipt = load_json(&receipt_path)?; - if receipt.format != "ix-plonk-srs-export-v1" + if receipt.format + != if cfg!(feature = "bn254") { + "ix-bn254-plonk-srs-export-v1" + } else { + "ix-plonk-srs-export-v1" + } || digest(&receipt.setup_id)? != anchors.public_setup.id - || digest(&receipt.filecoin_manifest)? != *manifest + || digest(&receipt.setup_manifest)? != manifest || receipt.public_max_degree != anchors.public_setup.max_degree || receipt.powers != domain.checked_add(3).ok_or("PLONK SRS length overflow")? @@ -353,9 +461,7 @@ fn wrap_packet( statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; let expected_digest = claim_digest(expected)?; let setup = SetupConfig::from_env(false)?; - let SetupConfig::Filecoin { manifest, .. } = &setup else { - return Err("PLONK requires Filecoin parameters".into()); - }; + let manifest = setup_manifest(&setup)?; let helper = helper_executable()?; fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { @@ -370,7 +476,7 @@ fn wrap_packet( identity.update(&fixed_cache::executable_digest()?); identity.update(&sha256(&helper)?); identity.update(&sha256(&profile_path)?); - identity.update(manifest); + identity.update(&manifest); let cache = cache_root.join("circuits").join(identity.finalize().to_hex().as_str()); checked(Command::new(&helper).arg("compile").arg(&profile_path).arg(&cache))?; @@ -420,7 +526,7 @@ fn wrap_packet( .arg(key_hash) .arg(out.join("proof.bin")) .arg(hex(&expected_digest)) - .arg(filecoin_anchor_encoding()?), + .arg(anchor_encoding(&anchors(&setup)?)), )?; let proof = fs::read(out.join("proof.bin"))?; if proof.is_empty() || proof.len() > MAX_PROOF_BYTES { @@ -435,8 +541,15 @@ fn wrap_packet( &out.join("PlonkVerifier.sol"), &fs::read(cache.join("PlonkVerifier.sol"))?, )?; + #[cfg(feature = "bn254")] + let admission_bytes = setup + .ignition_admission() + .ok_or("missing Ignition admission")? + .to_bytes()?; + #[cfg(feature = "bn254")] + storage::write_atomic(&out.join("admission.json"), &admission_bytes)?; let candidate = TrustedCatalog { - format: "ix-bls-plonk-verifiers-v1".into(), + format: CATALOG_FORMAT.into(), keys: vec![TrustedKey { sha256: key_hash.into(), path: "verifying-key.bin".into(), @@ -448,7 +561,17 @@ fn wrap_packet( )? .into(), helper_sha256: metadata_string(&metadata, "ExecutableSHA256")?.into(), - setup: "filecoin".into(), + setup: SETUP_NAME.into(), + #[cfg(feature = "bn254")] + admission: Some("admission.json".into()), + #[cfg(feature = "bn254")] + admission_blake3: Some( + blake3::hash(&admission_bytes).to_hex().to_string(), + ), + #[cfg(not(feature = "bn254"))] + admission: None, + #[cfg(not(feature = "bn254"))] + admission_blake3: None, }], }; storage::write_atomic( @@ -458,7 +581,7 @@ fn wrap_packet( Ok(()) } -#[cfg(test)] +#[cfg(all(test, not(feature = "bn254")))] mod tests { use super::*; @@ -471,6 +594,8 @@ mod tests { circuit_sha256: hex(&[3; 32]), helper_sha256: hex(&[4; 32]), setup: "filecoin".into(), + admission: None, + admission_blake3: None, } } @@ -478,10 +603,8 @@ mod tests { fn catalog_requires_an_independently_authorized_key() -> Result<()> { let scratch = Scratch::new()?; let path = scratch.0.join("catalog.json"); - let mut catalog = TrustedCatalog { - format: "ix-bls-plonk-verifiers-v1".into(), - keys: vec![key()], - }; + let mut catalog = + TrustedCatalog { format: CATALOG_FORMAT.into(), keys: vec![key()] }; let write = |catalog: &TrustedCatalog| { storage::write_atomic(&path, &serde_json::to_vec(catalog).unwrap()) }; @@ -530,3 +653,57 @@ mod tests { Ok(()) } } + +#[cfg(all(test, feature = "bn254"))] +mod bn254_tests { + use super::*; + + #[test] + fn catalog_requires_pinned_ignition_anchors_without_a_proving_cache() + -> Result<()> { + let scratch = Scratch::new()?; + let (admission, _) = crate::kzg::srs::ignition::test_admission(7, 8, 41); + let bytes = admission.to_bytes()?; + let pin = blake3::hash(&bytes).to_hex().to_string(); + storage::write_atomic(&scratch.0.join("admission.json"), &bytes)?; + let mut catalog = TrustedCatalog { + format: CATALOG_FORMAT.into(), + keys: vec![TrustedKey { + sha256: hex(&[1; 32]), + path: "key.bin".into(), + inner_profile_id: hex(&[2; 32]), + statement_prefix: [0; 10], + circuit_sha256: hex(&[3; 32]), + helper_sha256: hex(&[4; 32]), + setup: "ignition".into(), + admission: Some("admission.json".into()), + admission_blake3: Some(pin), + }], + }; + let path = scratch.0.join("catalog.json"); + let write = |catalog: &TrustedCatalog| { + storage::write_atomic(&path, &serde_json::to_vec(catalog).unwrap()) + }; + write(&catalog)?; + let key = select_key(&path, &[1; 32])?; + assert_eq!( + anchor_encoding(&key_anchors(&key)?), + anchor_encoding(&admission.anchors()) + ); + assert!(select_key(&path, &[9; 32]).is_err()); + catalog.keys[0].admission_blake3 = Some(hex(&[0; 32])); + write(&catalog)?; + assert!(key_anchors(&select_key(&path, &[1; 32])?).is_err()); + catalog.keys[0].admission = None; + write(&catalog)?; + assert!(select_key(&path, &[1; 32]).is_err()); + catalog.keys[0].setup = "filecoin".into(); + write(&catalog)?; + assert!(select_key(&path, &[1; 32]).is_err()); + catalog.format = "ix-bls-plonk-verifiers-v1".into(); + write(&catalog)?; + assert!(select_key(&path, &[1; 32]).is_err()); + assert_eq!(PROOF_TAG, b"IXBN\0\0\0\x02"); + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index 685c096ad..f0104167e 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -14,12 +14,17 @@ use std::{fs, path::PathBuf, time::Instant}; /// Compiled worker features for deployment checks. fn print_capabilities() { println!( - concat!( - "{{\"format\":\"ix-compression-capabilities/v1\",\"binary\":\"ix-compression-worker\",", - "\"cuda\":{},\"parallel\":{}}}" - ), - cfg!(feature = "cuda"), - cfg!(feature = "parallel"), + "{}", + serde_json::json!({ + "format": "ix-compression-capabilities/v1", + "binary": "ix-compression-worker", + "curve": crate::kzg::curve::NAME, + "cuda": cfg!(feature = "cuda"), + "parallel": cfg!(feature = "parallel"), + "development_setup_only": false, + "ignition_setup": cfg!(feature = "bn254"), + "terminal_plonk": cfg!(feature = "plonk"), + }) ); } diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 126020e07..2abcbed12 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -4,16 +4,24 @@ //! Filecoin policy loads an authenticated prefix of the phase-one powers of //! tau from the cache named by `IX_KZG_FILECOIN_CACHE`, pinned by the manifest //! receipt in `IX_KZG_FILECOIN_MANIFEST` or, when that is unset, the built-in -//! receipt of the complete `2^27` cache. The development policy derives a +//! receipt of the complete `2^27` cache. Filecoin is available only with +//! BLS12-381. BN254's Ignition policy requires independently pinned admission +//! metadata and an authenticated prefix cache. The development policy derives a //! known trapdoor from the stage seed, with parameters from a per-stage cache //! file or from the shared directory named by `IX_KZG_SRS_CACHE`. use super::storage; -use crate::kzg::{ - KzgConfig, PublicSetup, Srs, TranscriptKind, VerifierParams, - srs::filecoin::{ - FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3, filecoin_anchors, load_filecoin_cache, - }, +#[cfg(not(feature = "bn254"))] +use crate::kzg::srs::filecoin::{ + FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3, filecoin_anchors, load_filecoin_cache, +}; +#[cfg(feature = "bn254")] +use crate::kzg::srs::ignition::{ + IgnitionAdmission, load_ignition_cache_with_identity, }; +use crate::kzg::{KzgConfig, PublicSetup, Srs, TranscriptKind, VerifierParams}; +use multi_stark::traits::TwoAdicField; +#[cfg(feature = "bn254")] +use std::ffi::{OsStr, OsString}; use std::{ fs, path::{Path, PathBuf}, @@ -22,15 +30,38 @@ use std::{ pub(crate) const FIRST_SEED: &[u8] = b"init-fri-kzg-ordinary-v1"; pub(crate) const BINDING_FILE: &str = "kzg-setup-id.bin"; +#[cfg(not(feature = "bn254"))] pub(crate) const FILECOIN_CACHE_VAR: &str = "IX_KZG_FILECOIN_CACHE"; +#[cfg(not(feature = "bn254"))] pub(crate) const FILECOIN_MANIFEST_VAR: &str = "IX_KZG_FILECOIN_MANIFEST"; +#[cfg(feature = "bn254")] +pub(crate) const IGNITION_CACHE_VAR: &str = "IX_KZG_IGNITION_CACHE"; +#[cfg(feature = "bn254")] +pub(crate) const IGNITION_CACHE_MANIFEST_VAR: &str = + "IX_KZG_IGNITION_CACHE_BLAKE3"; +#[cfg(feature = "bn254")] +pub(crate) const IGNITION_ADMISSION_VAR: &str = "IX_KZG_IGNITION_ADMISSION"; +#[cfg(feature = "bn254")] +pub(crate) const IGNITION_ADMISSION_DIGEST_VAR: &str = + "IX_KZG_IGNITION_ADMISSION_BLAKE3"; +#[cfg(feature = "bn254")] +const IGNITION_ENV_VARS: [&str; 4] = [ + IGNITION_CACHE_VAR, + IGNITION_CACHE_MANIFEST_VAR, + IGNITION_ADMISSION_VAR, + IGNITION_ADMISSION_DIGEST_VAR, +]; /// The trust class of verifier parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SetupClass { /// Filecoin's phase-one powers of tau. The trapdoor is unknown unless - /// every ceremony participant colluded. + /// every ceremony participant colluded. No BN254 parameters have this class. Filecoin, + /// Ignition parameters whose identity, degree and anchors match an + /// independently pinned admission of the ceremony source. + #[cfg(feature = "bn254")] + Ignition, /// A trapdoor derived from a seed; anyone can forge proofs. Development, } @@ -39,6 +70,7 @@ impl SetupClass { /// Filecoin only when the identity, public degree and all four anchor /// points equal the pinned ceremony values; an identity alone is just /// bytes any profile could carry. + #[cfg(not(feature = "bn254"))] pub fn of(params: &VerifierParams) -> Self { let pinned = filecoin_anchors(); if params.public_setup == pinned.public_setup @@ -52,9 +84,27 @@ impl SetupClass { } } + /// Production classification requires independently admitted Ignition + /// metadata; verifier parameters alone do not establish provenance. + #[cfg(feature = "bn254")] + pub fn of(_params: &VerifierParams) -> Self { + Self::Development + } + + #[cfg(feature = "bn254")] + pub fn of_with_ignition( + params: &VerifierParams, + admission: &IgnitionAdmission, + ) -> storage::Result { + admission.check_verifier(params)?; + Ok(Self::Ignition) + } + pub fn label(self) -> &'static str { match self { Self::Filecoin => "filecoin", + #[cfg(feature = "bn254")] + Self::Ignition => "ignition", Self::Development => "development", } } @@ -62,6 +112,8 @@ impl SetupClass { pub fn description(self) -> &'static str { match self { Self::Filecoin => "the Filecoin phase-one powers of tau", + #[cfg(feature = "bn254")] + Self::Ignition => "the admitted Aztec Ignition BN254 powers of tau", Self::Development => { "a known-trapdoor development SRS (not production-secure)" }, @@ -70,57 +122,133 @@ impl SetupClass { } fn development_refused() -> storage::Result { - Err( - format!( - "no Filecoin SRS cache is configured; set {FILECOIN_CACHE_VAR} to an \ - authenticated prefix cache, or pass --dev-srs to use the \ - known-trapdoor development setup (not production-secure)" - ) - .into(), - ) + #[cfg(feature = "bn254")] + let message = format!( + "no admitted BN254 Ignition setup is configured; set \ + {IGNITION_CACHE_VAR}, {IGNITION_CACHE_MANIFEST_VAR}, \ + {IGNITION_ADMISSION_VAR} and {IGNITION_ADMISSION_DIGEST_VAR}, or pass \ + --dev-srs to use the known-trapdoor development setup \ + (not production-secure)" + ); + #[cfg(not(feature = "bn254"))] + let message = format!( + "no Filecoin SRS cache is configured; set {FILECOIN_CACHE_VAR} to an \ + authenticated prefix cache, or pass --dev-srs to use the \ + known-trapdoor development setup (not production-secure)" + ); + Err(message.into()) } /// Setup policy captured for one worker invocation. #[derive(Clone)] pub(crate) enum SetupConfig { - Filecoin { cache: PathBuf, manifest: [u8; 32] }, - Development { cache_directory: Option }, + #[cfg(not(feature = "bn254"))] + Filecoin { + cache: PathBuf, + manifest: [u8; 32], + }, + #[cfg(feature = "bn254")] + Ignition { + cache: PathBuf, + manifest: [u8; 32], + admission: Arc, + }, + Development { + cache_directory: Option, + }, } impl SetupConfig { pub(crate) fn from_env(development: bool) -> storage::Result { + #[cfg(feature = "bn254")] + { + Self::from_ignition_env(development, |name| std::env::var_os(name)) + } + #[cfg(not(feature = "bn254"))] + { + if development { + return Ok(Self::Development { + cache_directory: std::env::var_os("IX_KZG_SRS_CACHE") + .map(PathBuf::from), + }); + } + if let Some(cache) = std::env::var_os(FILECOIN_CACHE_VAR) { + let cache = PathBuf::from(cache); + if !cache.is_file() { + return Err( + format!( + "{FILECOIN_CACHE_VAR} does not name a file: {}", + cache.display() + ) + .into(), + ); + } + let manifest = match std::env::var(FILECOIN_MANIFEST_VAR) { + Ok(text) => parse_digest(text.trim())?, + Err(std::env::VarError::NotPresent) => { + FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3 + }, + Err(error) => return Err(error.into()), + }; + return Ok(Self::Filecoin { cache, manifest }); + } + development_refused() + } + } + + #[cfg(feature = "bn254")] + fn from_ignition_env( + development: bool, + mut lookup: impl FnMut(&str) -> Option, + ) -> storage::Result { if development { return Ok(Self::Development { - cache_directory: std::env::var_os("IX_KZG_SRS_CACHE") - .map(PathBuf::from), + cache_directory: lookup("IX_KZG_SRS_CACHE").map(PathBuf::from), }); } - if let Some(cache) = std::env::var_os(FILECOIN_CACHE_VAR) { - let cache = PathBuf::from(cache); - if !cache.is_file() { - return Err( - format!( - "{FILECOIN_CACHE_VAR} does not name a file: {}", - cache.display() - ) - .into(), - ); - } - let manifest = match std::env::var(FILECOIN_MANIFEST_VAR) { - Ok(text) => parse_digest(text.trim())?, - Err(std::env::VarError::NotPresent) => { - FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3 - }, - Err(error) => return Err(error.into()), - }; - return Ok(Self::Filecoin { cache, manifest }); + let values = IGNITION_ENV_VARS.map(&mut lookup); + if values.iter().all(Option::is_none) { + return development_refused(); + } + let [cache, manifest, admission, admission_digest] = values; + let required = |name, value: Option| { + value.ok_or_else(|| { + format!("incomplete BN254 Ignition configuration: missing {name}") + }) + }; + let cache = PathBuf::from(required(IGNITION_CACHE_VAR, cache)?); + let manifest = parse_ignition_digest( + IGNITION_CACHE_MANIFEST_VAR, + &required(IGNITION_CACHE_MANIFEST_VAR, manifest)?, + )?; + let admission_path = + PathBuf::from(required(IGNITION_ADMISSION_VAR, admission)?); + let admission_digest = parse_ignition_digest( + IGNITION_ADMISSION_DIGEST_VAR, + &required(IGNITION_ADMISSION_DIGEST_VAR, admission_digest)?, + )?; + let admission = + Arc::new(IgnitionAdmission::read(&admission_path, admission_digest)?); + let (anchors, identity) = + load_ignition_cache_with_identity(&cache, manifest, 2)?; + admission.check_cache(&anchors, &identity)?; + Ok(Self::Ignition { cache, manifest, admission }) + } + + #[cfg(feature = "bn254")] + pub(crate) fn ignition_admission(&self) -> Option<&IgnitionAdmission> { + match self { + Self::Ignition { admission, .. } => Some(admission), + Self::Development { .. } => None, } - development_refused() } pub(crate) fn class(&self) -> SetupClass { match self { + #[cfg(not(feature = "bn254"))] Self::Filecoin { .. } => SetupClass::Filecoin, + #[cfg(feature = "bn254")] + Self::Ignition { .. } => SetupClass::Ignition, Self::Development { .. } => SetupClass::Development, } } @@ -130,13 +258,24 @@ impl SetupConfig { seed: &[u8], height: usize, ) -> storage::Result { - if height < 2 || !height.is_power_of_two() { + if height < 2 + || !height.is_power_of_two() + || height.ilog2() as usize > crate::kzg::Scalar::TWO_ADICITY + { return Err("invalid trace height".into()); } match self { + #[cfg(not(feature = "bn254"))] Self::Filecoin { cache, manifest } => { Ok(SetupSource::filecoin(cache.clone(), *manifest)) }, + #[cfg(feature = "bn254")] + Self::Ignition { cache, manifest, admission } => { + let source = + SetupSource::ignition(cache.clone(), *manifest, admission.clone()); + source.check_height(height)?; + Ok(source) + }, Self::Development { cache_directory } => { let mut source = SetupSource::development(seed, height - 1)?; if let Some(directory) = cache_directory { @@ -150,8 +289,21 @@ impl SetupConfig { #[derive(Clone)] enum Policy { - Development { seed: Vec, cache_directory: Option }, - Filecoin { cache: PathBuf, manifest: [u8; 32] }, + Development { + seed: Vec, + cache_directory: Option, + }, + #[cfg(not(feature = "bn254"))] + Filecoin { + cache: PathBuf, + manifest: [u8; 32], + }, + #[cfg(feature = "bn254")] + Ignition { + cache: PathBuf, + manifest: [u8; 32], + admission: Arc, + }, } #[derive(Clone)] @@ -161,6 +313,7 @@ pub(crate) struct SetupSource { transcript_kind: TranscriptKind, } +#[cfg(not(feature = "bn254"))] fn parse_digest(text: &str) -> storage::Result<[u8; 32]> { if text.len() != 64 || !text.is_ascii() { return Err( @@ -175,6 +328,29 @@ fn parse_digest(text: &str) -> storage::Result<[u8; 32]> { Ok(digest) } +#[cfg(feature = "bn254")] +fn parse_ignition_digest( + variable: &str, + value: &OsStr, +) -> storage::Result<[u8; 32]> { + let text = value + .to_str() + .ok_or_else(|| format!("{variable} must contain an ASCII BLAKE3 digest"))? + .trim(); + if text.len() != 64 || !text.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err( + format!("{variable} must be a 64-character BLAKE3 digest").into(), + ); + } + let mut digest = [0; 32]; + for (byte, pair) in digest.iter_mut().zip(text.as_bytes().as_chunks::<2>().0) + { + let pair = std::str::from_utf8(pair).expect("validated ASCII hex"); + *byte = u8::from_str_radix(pair, 16)?; + } + Ok(digest) +} + impl SetupSource { pub(crate) fn development( seed: &[u8], @@ -195,6 +371,7 @@ impl SetupSource { /// Filecoin parameters: pinned anchors for verification, and proving /// prefixes from an authenticated cache pinned by its import receipt. + #[cfg(not(feature = "bn254"))] pub(crate) fn filecoin(cache: PathBuf, manifest: [u8; 32]) -> Self { Self { policy: Policy::Filecoin { cache, manifest }, @@ -203,6 +380,29 @@ impl SetupSource { } } + /// Verification uses admitted anchors directly. Proving additionally + /// authenticates the requested cache prefix and matches its provenance. + #[cfg(feature = "bn254")] + pub(crate) fn ignition( + cache: PathBuf, + manifest: [u8; 32], + admission: Arc, + ) -> Self { + Self { + public_setup: admission.public_setup(), + policy: Policy::Ignition { cache, manifest, admission }, + transcript_kind: TranscriptKind::Blake3, + } + } + + #[cfg(feature = "bn254")] + pub(crate) fn ignition_admission(&self) -> Option<&IgnitionAdmission> { + match &self.policy { + Policy::Ignition { admission, .. } => Some(admission), + Policy::Development { .. } => None, + } + } + /// Bind checkpoint identity to the selected Fiat-Shamir protocol. pub(crate) fn with_transcript(mut self, kind: TranscriptKind) -> Self { self.transcript_kind = kind; @@ -212,8 +412,14 @@ impl SetupSource { /// Serve development parameters from a shared directory of cached /// files, keyed by prefix length and seed, instead of regenerating them. pub(crate) fn with_cache_directory(mut self, directory: PathBuf) -> Self { - if let Policy::Development { cache_directory, .. } = &mut self.policy { - *cache_directory = Some(directory); + match &mut self.policy { + Policy::Development { cache_directory, .. } => { + *cache_directory = Some(directory); + }, + #[cfg(not(feature = "bn254"))] + Policy::Filecoin { .. } => {}, + #[cfg(feature = "bn254")] + Policy::Ignition { .. } => {}, } self } @@ -221,7 +427,10 @@ impl SetupSource { pub(crate) fn class(&self) -> SetupClass { match self.policy { Policy::Development { .. } => SetupClass::Development, + #[cfg(not(feature = "bn254"))] Policy::Filecoin { .. } => SetupClass::Filecoin, + #[cfg(feature = "bn254")] + Policy::Ignition { .. } => SetupClass::Ignition, } } @@ -230,6 +439,10 @@ impl SetupSource { SetupClass::Filecoin => { "Filecoin phase-one powers of tau (challenge_19), authenticated against the published BLAKE2b-512 digest and loaded from a receipt-pinned prefix cache. Public ceremony parameters; the trapdoor is unknown unless every participant colluded.\n" }, + #[cfg(feature = "bn254")] + SetupClass::Ignition => { + "Aztec Ignition BN254 powers of tau, with setup identity, full public degree and shifted-basis anchors authorized by an independently pinned admission. Proving prefixes are authenticated against a separate cache receipt. Ceremony provenance depends on the admission's trusted source pins.\n" + }, SetupClass::Development => { "Known-trapdoor development public-degree KZG. Not ceremony parameters. For development and testing only.\n" }, @@ -239,7 +452,7 @@ impl SetupSource { fn check_height(&self, height: usize) -> storage::Result<()> { if height < 2 || !height.is_power_of_two() - || height.ilog2() > 32 + || height.ilog2() as usize > crate::kzg::Scalar::TWO_ADICITY || height - 1 > self.public_setup.max_degree { return Err("trace height exceeds public setup allowance".into()); @@ -256,10 +469,23 @@ impl SetupSource { self.check_height(height)?; let length = if verify_only { 2 } else { height }; match &self.policy { + #[cfg(not(feature = "bn254"))] Policy::Filecoin { .. } if verify_only => Ok(filecoin_anchors()), + #[cfg(not(feature = "bn254"))] Policy::Filecoin { cache, manifest } => { Ok(load_filecoin_cache(cache, *manifest, length)?) }, + #[cfg(feature = "bn254")] + Policy::Ignition { admission, .. } if verify_only => { + Ok(admission.anchors()) + }, + #[cfg(feature = "bn254")] + Policy::Ignition { cache, manifest, admission } => { + let (srs, identity) = + load_ignition_cache_with_identity(cache, *manifest, length)?; + admission.check_cache(&srs, &identity)?; + Ok(srs) + }, Policy::Development { seed, cache_directory } => { if !verify_only && let Some(directory) = cache_directory { return Ok(Srs::unsafe_dev_setup_prefix_in_cache_directory( @@ -291,9 +517,15 @@ impl SetupSource { quotient: usize, verify_only: bool, ) -> storage::Result { + self.check_height(height)?; if !quotient.is_power_of_two() { return Err("invalid quotient budget".into()); } + if height.ilog2() as usize + quotient.ilog2() as usize + > crate::kzg::Scalar::TWO_ADICITY + { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } Ok( (if verify_only { KzgConfig::verifier_only(self.verifier_params(height)?, quotient) @@ -353,6 +585,7 @@ impl SetupSource { #[cfg(test)] mod tests { use super::*; + #[cfg(not(feature = "bn254"))] use crate::kzg::srs::filecoin::{ FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, }; @@ -425,6 +658,7 @@ mod tests { Ok(()) } + #[cfg(not(feature = "bn254"))] #[test] fn setup_class_requires_the_pinned_anchors_and_identity() { let pinned = filecoin_anchors(); @@ -488,6 +722,7 @@ mod tests { config.for_stage(FIRST_SEED, 64)?.identity(64, 2)?, SetupSource::development(FIRST_SEED, 63)?.identity(64, 2)? ); + #[cfg(not(feature = "bn254"))] if std::env::var_os(FILECOIN_CACHE_VAR).is_none() { assert!( SetupConfig::from_env(false) @@ -500,6 +735,7 @@ mod tests { Ok(()) } + #[cfg(not(feature = "bn254"))] #[test] fn checked_in_filecoin_cache_proves_and_binds_under_its_identity() -> storage::Result<()> { @@ -533,4 +769,44 @@ mod tests { ); Ok(()) } + + #[cfg(feature = "bn254")] + #[test] + fn bn254_requires_an_admission_or_explicit_development_setup() + -> storage::Result<()> { + if IGNITION_ENV_VARS.iter().all(|name| std::env::var_os(name).is_none()) { + let error = SetupConfig::from_env(false).err().unwrap().to_string(); + assert!(error.contains("BN254")); + assert!(error.contains("--dev-srs")); + } + let source = SetupConfig::from_env(true)?.for_stage(FIRST_SEED, 8)?; + assert_eq!(source.class(), SetupClass::Development); + assert_eq!( + SetupClass::of(&source.verifier_params(8)?), + SetupClass::Development + ); + Ok(()) + } + + #[test] + fn setup_rejects_unsupported_trace_and_quotient_domains() + -> storage::Result<()> { + let max_height = 1usize << crate::kzg::Scalar::TWO_ADICITY; + let source = SetupSource::development(FIRST_SEED, max_height * 2 - 1)?; + assert!(source.config(max_height / 2, 2, true).is_ok()); + assert!(source.config(max_height, 1, true).is_ok()); + assert!(source.config(max_height, 2, true).is_err()); + assert!(source.verifier_params(max_height * 2).is_err()); + assert!(source.config(8, 0, true).is_err()); + assert!(source.config(8, 3, true).is_err()); + assert!( + SetupConfig::from_env(true)? + .for_stage(FIRST_SEED, max_height * 2) + .is_err() + ); + Ok(()) + } } + +#[cfg(all(test, feature = "bn254"))] +mod ignition_tests; diff --git a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs new file mode 100644 index 000000000..70135a65d --- /dev/null +++ b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs @@ -0,0 +1,292 @@ +use super::*; +use crate::kzg::srs::ignition::{ + IgnitionCacheIdentity, test_admission, test_admission_cache, +}; +use std::{ + collections::BTreeMap, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + +struct Fixture { + directory: PathBuf, + cache: PathBuf, + admission_path: PathBuf, + admission: Arc, + admission_digest: [u8; 32], + receipt: IgnitionCacheIdentity, + srs: Srs, +} + +impl Fixture { + fn new(tau: u64, marker: u8) -> Self { + let directory = std::env::temp_dir().join(format!( + "ix-ignition-setup-{}-{}", + std::process::id(), + NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&directory).unwrap(); + let (admission, srs) = test_admission(tau, 16, marker); + let cache = directory.join("prefix.bin"); + let receipt = test_admission_cache(&cache, &admission, &srs); + let admission_path = directory.join("admission.json"); + let bytes = admission.to_bytes().unwrap(); + fs::write(&admission_path, &bytes).unwrap(); + let admission_digest = *blake3::hash(&bytes).as_bytes(); + Self { + directory, + cache, + admission_path, + admission: Arc::new(admission), + admission_digest, + receipt, + srs, + } + } + + fn configuration(&self) -> SetupConfig { + SetupConfig::Ignition { + cache: self.cache.clone(), + manifest: self.receipt.digest, + admission: self.admission.clone(), + } + } + + fn environment(&self) -> BTreeMap<&'static str, OsString> { + BTreeMap::from([ + (IGNITION_CACHE_VAR, self.cache.as_os_str().to_owned()), + (IGNITION_CACHE_MANIFEST_VAR, digest_text(self.receipt.digest).into()), + (IGNITION_ADMISSION_VAR, self.admission_path.as_os_str().to_owned()), + ( + IGNITION_ADMISSION_DIGEST_VAR, + digest_text(self.admission_digest).into(), + ), + ]) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} + +fn digest_text(digest: [u8; 32]) -> String { + blake3::Hash::from(digest).to_hex().to_string() +} + +fn parse_environment( + values: &BTreeMap<&'static str, OsString>, +) -> storage::Result { + SetupConfig::from_ignition_env(false, |name| values.get(name).cloned()) +} + +#[test] +fn ignition_class_requires_admitted_identity_degree_and_every_anchor() { + let (admission, srs) = test_admission(7, 16, 1); + let params = VerifierParams::from_srs(&srs); + assert_eq!(SetupClass::of(¶ms), SetupClass::Development); + assert_eq!( + SetupClass::of_with_ignition(¶ms, &admission).unwrap(), + SetupClass::Ignition, + ); + let mut changed = Vec::new(); + let mut params_changed = params.clone(); + params_changed.public_setup.id[0] ^= 1; + changed.push(params_changed); + let mut params_changed = params.clone(); + params_changed.public_setup.max_degree += 1; + changed.push(params_changed); + for i in 0..2 { + let mut params_changed = params.clone(); + params_changed.g1[i] = -params_changed.g1[i]; + changed.push(params_changed); + } + let mut params_changed = params.clone(); + params_changed.g2 = -params_changed.g2; + changed.push(params_changed); + let mut params_changed = params.clone(); + params_changed.tau_g2 = -params_changed.tau_g2; + changed.push(params_changed); + for params in changed { + assert!(SetupClass::of_with_ignition(¶ms, &admission).is_err()); + assert_eq!(SetupClass::of(¶ms), SetupClass::Development); + } + let development = Srs::unsafe_dev_setup(16, b"admission-substitution"); + assert!( + SetupClass::of_with_ignition( + &VerifierParams::from_srs(&development), + &admission, + ) + .is_err() + ); +} + +#[test] +fn ignition_prefixes_preserve_identity_and_verification_needs_no_cache() +-> storage::Result<()> { + let fixture = Fixture::new(7, 2); + let config = fixture.configuration(); + assert_eq!(config.class(), SetupClass::Ignition); + assert_eq!( + config.ignition_admission().unwrap().public_setup(), + fixture.srs.public_setup, + ); + let source = config.for_stage(FIRST_SEED, 8)?; + assert_eq!(source.class(), SetupClass::Ignition); + assert_eq!( + source.ignition_admission().unwrap().public_setup(), + fixture.srs.public_setup, + ); + let prefix = source.srs(8, false)?; + assert_eq!(prefix.g1, fixture.srs.g1[..8]); + assert_eq!(prefix.public_setup, fixture.srs.public_setup); + assert_eq!(prefix.public_setup.max_degree, 15); + assert_eq!( + source.config(8, 2, false)?.transcript_seed(), + source.config(8, 2, true)?.transcript_seed(), + ); + assert!(config.for_stage(FIRST_SEED, 32).is_err()); + assert!(source.srs(32, true).is_err()); + assert!(source.srs(3, true).is_err()); + fs::remove_file(&fixture.cache)?; + let verifier = source.srs(16, true)?; + assert_eq!(verifier.g1, fixture.srs.g1[..2]); + assert_eq!(verifier.public_setup, fixture.srs.public_setup); + assert!(source.srs(16, false).is_err()); + Ok(()) +} + +#[test] +fn ignition_stage_seeds_and_cache_locations_do_not_change_public_identity() +-> storage::Result<()> { + let fixture = Fixture::new(7, 3); + let config = fixture.configuration(); + let first = config.for_stage(FIRST_SEED, 8)?; + let recursive = config.for_stage(b"recursive-stage", 8)?; + assert_eq!(first.identity(8, 2)?, recursive.identity(8, 2)?); + let missing = SetupSource::ignition( + fixture.directory.join("missing"), + [0; 32], + fixture.admission.clone(), + ) + .with_cache_directory(fixture.directory.join("development-cache")); + assert_eq!(first.identity(8, 2)?, missing.identity(8, 2)?); + assert!(missing.srs(8, false).is_err()); + assert!(!fixture.directory.join("development-cache").exists()); + assert_ne!(first.identity(8, 2)?, first.identity(4, 2)?); + assert_ne!(first.identity(8, 2)?, first.identity(8, 4)?); + assert_ne!( + first.identity(8, 2)?, + first.clone().with_transcript(TranscriptKind::Keccak256).identity(8, 2)?, + ); + let (other_admission, _) = test_admission(7, 16, 4); + let other = SetupSource::ignition( + fixture.cache.clone(), + fixture.receipt.digest, + Arc::new(other_admission), + ); + assert_ne!(first.identity(8, 2)?, other.identity(8, 2)?); + assert!(other.srs(8, false).is_err()); + Ok(()) +} + +#[test] +fn ignition_cache_receipt_must_match_admitted_provenance() { + let fixture = Fixture::new(7, 5); + fixture.admission.check_cache(&fixture.srs, &fixture.receipt).unwrap(); + let mut changed = fixture.receipt; + changed.source_manifest_digest[0] ^= 1; + assert!(fixture.admission.check_cache(&fixture.srs, &changed).is_err()); + let mut changed = fixture.receipt; + changed.public_setup.id[0] ^= 1; + assert!(fixture.admission.check_cache(&fixture.srs, &changed).is_err()); + let mut changed = fixture.receipt; + changed.public_setup.max_degree += 1; + assert!(fixture.admission.check_cache(&fixture.srs, &changed).is_err()); + let other = Fixture::new(11, 6); + let wrong_cache = SetupSource::ignition( + other.cache.clone(), + other.receipt.digest, + fixture.admission.clone(), + ); + assert!(wrong_cache.srs(8, false).is_err()); +} + +#[test] +fn ignition_environment_requires_every_value_and_authenticates_before_stages() +-> storage::Result<()> { + let fixture = Fixture::new(7, 7); + let environment = fixture.environment(); + let config = parse_environment(&environment)?; + assert_eq!(config.class(), SetupClass::Ignition); + for mask in 0usize..15 { + let partial: BTreeMap<_, _> = IGNITION_ENV_VARS + .iter() + .enumerate() + .filter(|(i, _)| mask & (1 << *i) != 0) + .map(|(_, name)| (*name, environment[*name].clone())) + .collect(); + assert!(parse_environment(&partial).is_err()); + } + for variable in [IGNITION_CACHE_MANIFEST_VAR, IGNITION_ADMISSION_DIGEST_VAR] { + for bad in ["".to_owned(), "g".repeat(64), "0".repeat(63)] { + let mut changed = environment.clone(); + changed.insert(variable, bad.into()); + assert!(parse_environment(&changed).is_err()); + } + let mut changed = environment.clone(); + changed.insert(variable, digest_text([0; 32]).into()); + assert!(parse_environment(&changed).is_err()); + } + let other = Fixture::new(11, 8); + let mut changed = environment.clone(); + changed.insert( + IGNITION_ADMISSION_VAR, + other.admission_path.as_os_str().to_owned(), + ); + changed.insert( + IGNITION_ADMISSION_DIGEST_VAR, + digest_text(other.admission_digest).into(), + ); + assert!(parse_environment(&changed).is_err()); + fs::remove_file(&fixture.cache)?; + assert!(parse_environment(&environment).is_err()); + assert!(config.for_stage(FIRST_SEED, 8)?.srs(8, true).is_ok()); + assert!(config.for_stage(FIRST_SEED, 8)?.srs(8, false).is_err()); + Ok(()) +} + +#[test] +fn explicit_development_ignores_production_configuration() -> storage::Result<()> +{ + let config = SetupConfig::from_ignition_env(true, |name| { + assert_eq!(name, "IX_KZG_SRS_CACHE"); + Some("development-cache".into()) + })?; + assert_eq!(config.class(), SetupClass::Development); + assert!(config.ignition_admission().is_none()); + assert!(config.for_stage(FIRST_SEED, 8)?.ignition_admission().is_none()); + assert_eq!( + config.for_stage(FIRST_SEED, 8)?.identity(8, 2)?, + SetupSource::development(FIRST_SEED, 7)?.identity(8, 2)?, + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn ignition_digest_parser_rejects_non_unicode_values() { + use std::os::unix::ffi::OsStringExt; + let value = OsString::from_vec(vec![0xff; 64]); + assert!(parse_ignition_digest(IGNITION_CACHE_MANIFEST_VAR, &value).is_err()); + assert_eq!( + parse_ignition_digest( + IGNITION_CACHE_MANIFEST_VAR, + OsStr::new(&"ab".repeat(32)) + ) + .unwrap(), + [0xab; 32], + ); +} diff --git a/crates/proof-compression/src/pipeline/stages/codec.rs b/crates/proof-compression/src/pipeline/stages/codec.rs index 242c787c6..497717b65 100644 --- a/crates/proof-compression/src/pipeline/stages/codec.rs +++ b/crates/proof-compression/src/pipeline/stages/codec.rs @@ -6,9 +6,15 @@ use crate::kzg::{ KzgCommitment, KzgConfig, Scalar, TranscriptKind, VerifierParams, }; use multi_stark::system::{Circuit, System}; +use multi_stark::traits::TwoAdicField; use multi_stark::types::Val; use serde::{Serialize, de::DeserializeOwned}; +#[cfg(not(feature = "bn254"))] +const KZG_VERIFIER_MAGIC: &[u8; 8] = b"IXKZGV02"; +#[cfg(feature = "bn254")] +const KZG_VERIFIER_MAGIC: &[u8; 8] = b"IXBNKV02"; + pub(super) fn encode(tag: &[u8; 8], value: impl Serialize) -> Result> { let mut bytes = tag.to_vec(); bytes @@ -54,7 +60,7 @@ impl KzgVerifier { /// the public statement schema. Proving powers and matrices are excluded. pub fn to_bytes(&self) -> Result> { encode( - b"IXKZGV02", + KZG_VERIFIER_MAGIC, ( self.params.to_bytes()?, match self.system.config.transcript_kind() { @@ -76,14 +82,17 @@ impl KzgVerifier { Vec>, Option, Statement, - ) = if bytes.starts_with(b"IXKZGV01") { + ) = if !cfg!(feature = "bn254") && bytes.starts_with(b"IXKZGV01") { let (params, circuits, preprocessing, statement) = decode(b"IXKZGV01", bytes)?; (params, 0, circuits, preprocessing, statement) } else { - decode(b"IXKZGV02", bytes)? + decode(KZG_VERIFIER_MAGIC, bytes)? }; let params = VerifierParams::from_bytes(¶ms)?; + if params.max_trace_len().ilog2() as usize + 1 > Scalar::TWO_ADICITY { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } let transcript = match transcript { 0 => TranscriptKind::Blake3, 1 => TranscriptKind::Keccak256, @@ -101,3 +110,30 @@ impl KzgVerifier { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::Srs; + + #[test] + fn verifier_decoder_rejects_trace_cap_without_quotient_headroom() -> Result<()> + { + let cap = 1usize << Scalar::TWO_ADICITY; + let anchors = Srs::unsafe_dev_setup_prefix(2, cap - 1, b"domain-cap"); + let params = VerifierParams::with_max_trace_len(&anchors, cap); + let encoded = encode( + KZG_VERIFIER_MAGIC, + ( + params.to_bytes()?, + 0u8, + Vec::>::new(), + Option::::None, + Statement::recursive(0), + ), + )?; + let error = KzgVerifier::from_bytes(&encoded).err().unwrap(); + assert!(error.to_string().contains("quotient domain")); + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/stages/mod.rs b/crates/proof-compression/src/pipeline/stages/mod.rs index 9ff5648d8..be8db6955 100644 --- a/crates/proof-compression/src/pipeline/stages/mod.rs +++ b/crates/proof-compression/src/pipeline/stages/mod.rs @@ -129,7 +129,7 @@ impl Prepared { } } -/// Prepare a FRI verifier over the BLS12-381 scalar field. The statement and +/// Prepare a FRI verifier over the selected KZG scalar field. The statement and /// all activation anchors come from the trusted previous stage's verifier. pub fn prepare_kzg( verifier: &FriVerifier, @@ -260,6 +260,11 @@ impl Prepared { "partition height must be a power of two within the SRS range".into(), ); } + if max_height.ilog2() as usize + 1 + > ::TWO_ADICITY + { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } let compiled = construct::lower_first(self.circuit, max_height, max_height)?; let height = construct::max_height(&compiled)?; diff --git a/crates/proof-compression/src/pipeline/stages/setup.rs b/crates/proof-compression/src/pipeline/stages/setup.rs index f003af445..20bab4aa4 100644 --- a/crates/proof-compression/src/pipeline/stages/setup.rs +++ b/crates/proof-compression/src/pipeline/stages/setup.rs @@ -1,6 +1,7 @@ //! Deterministic verifier construction. No proof or witness is an input. use super::*; use aiur::vk_codec::AiurVerifyingKey; +use multi_stark::traits::TwoAdicField; /// Derive a complete fixed compression verifier from locally trusted inputs. /// This performs circuit construction and preprocessing commitments, but no @@ -64,6 +65,9 @@ pub(super) fn kzg( "partition height must be a power of two within the SRS range".into(), ); } + if height.ilog2() as usize + 1 > Scalar::TWO_ADICITY { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } let public_count = circuit.public_values().len(); let compiled = construct::lower_first(circuit, height, height)?; let height = construct::max_height(&compiled)?; diff --git a/crates/proof-compression/src/pipeline/stages/tests.rs b/crates/proof-compression/src/pipeline/stages/tests.rs index 28e78331f..678f63e2b 100644 --- a/crates/proof-compression/src/pipeline/stages/tests.rs +++ b/crates/proof-compression/src/pipeline/stages/tests.rs @@ -153,6 +153,13 @@ fn kzg_stages_derive_claims_for_different_layouts_and_parameters() -> Result<()> let key_bytes = verifier.to_bytes()?; let verifier = KzgVerifier::from_bytes(&key_bytes)?; assert_eq!(verifier.to_bytes()?, key_bytes); + let mut other_curve = key_bytes.clone(); + other_curve[..8].copy_from_slice(if cfg!(feature = "bn254") { + b"IXKZGV02" + } else { + b"IXBNKV02" + }); + assert!(KzgVerifier::from_bytes(&other_curve).is_err()); assert!( KzgVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]).is_err() ); diff --git a/crates/proof-compression/src/pipeline/stages/verification.rs b/crates/proof-compression/src/pipeline/stages/verification.rs index c4b1fa54c..b93dff520 100644 --- a/crates/proof-compression/src/pipeline/stages/verification.rs +++ b/crates/proof-compression/src/pipeline/stages/verification.rs @@ -3,13 +3,26 @@ use super::{ KzgVerifier, Result, scalar_statement, statement::Statement, wrap::RecursiveProof, }; -use crate::kzg::{KzgConfig, Scalar}; -use ark_bls12_381::{Bls12_381, G1Affine, G2Affine}; +use crate::kzg::{ + KzgConfig, Scalar, + curve::{ + G1_COMPRESSED_BYTES, G1Affine, G2_COMPRESSED_BYTES, G2Affine, PairingCurve, + read_g1_compressed, + }, +}; +use crate::recursive::pairing::{self, PUBLIC_VALUES}; use ark_ec::{AffineRepr, pairing::Pairing}; use ark_ff::Zero; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use multi_stark::{system::System, types::Val}; +#[cfg(not(feature = "bn254"))] +const RECURSIVE_VERIFIER_MAGIC: &[u8; 8] = b"IXRECV02"; +#[cfg(feature = "bn254")] +const RECURSIVE_VERIFIER_MAGIC: &[u8; 8] = b"IXBNRV02"; + +pub(in crate::pipeline) const PAIRING_BYTES: usize = 2 * G1_COMPRESSED_BYTES; + pub(in crate::pipeline) struct Pairings { pub(in crate::pipeline) keys: [G2Affine; 2], } @@ -23,7 +36,7 @@ impl Pairings { } pub(in crate::pipeline) fn verify(&self, values: &[Scalar]) -> Result<()> { - self.verify_bytes(&self.encode(values)?) + self.verify_points(pairing::decode(values)?) } } @@ -42,7 +55,7 @@ impl RecursiveVerifier { key.serialize_compressed(&mut keys)?; } super::codec::encode( - b"IXRECV02", + RECURSIVE_VERIFIER_MAGIC, (self.verifier.to_bytes()?, self.claim_len, keys), ) } @@ -50,19 +63,28 @@ impl RecursiveVerifier { /// Decode an independently trusted verifier with a 64 MiB decoding budget. pub fn from_bytes(bytes: &[u8]) -> Result { let (verifier, claim_len, keys): (Vec, usize, Vec) = - super::codec::decode(b"IXRECV02", bytes)?; + super::codec::decode(RECURSIVE_VERIFIER_MAGIC, bytes)?; let verifier = KzgVerifier::from_bytes(&verifier)?; - if keys.len() != 2 * 96 - || claim_len.checked_add(4) != Some(verifier.statement.locations.len()) + if keys.len() != 2 * G2_COMPRESSED_BYTES + || claim_len.checked_add(PUBLIC_VALUES) + != Some(verifier.statement.locations.len()) { return Err("invalid recursive public statement dimensions".into()); } - let keys = keys - .as_chunks::<96>() + let encoded_keys = keys; + let keys = encoded_keys + .as_chunks::() .0 .iter() .map(|bytes| G2Affine::deserialize_compressed(bytes.as_slice())) .collect::, _>>()?; + let mut canonical_keys = Vec::with_capacity(encoded_keys.len()); + for key in &keys { + key.serialize_compressed(&mut canonical_keys)?; + } + if canonical_keys != encoded_keys { + return Err("noncanonical recursive pairing keys".into()); + } Self::from_parts(verifier.system, verifier.statement, &keys, claim_len) } @@ -105,7 +127,7 @@ impl RecursiveVerifier { || !p.is_on_curve() || !p.is_in_correct_subgroup_assuming_on_curve() }) || statement.locations.len() - != claim_len.checked_add(4).ok_or("public count overflow")? + != claim_len.checked_add(PUBLIC_VALUES).ok_or("public count overflow")? { return Err("invalid recursive verifier parameters".into()); } @@ -137,57 +159,35 @@ impl RecursiveVerifier { impl Pairings { pub(in crate::pipeline) fn verify_bytes(&self, bytes: &[u8]) -> Result<()> { - if bytes.len() != 96 { - return Err("pairing point count".into()); - } - let points = bytes - .as_chunks::<48>() - .0 - .iter() - .map(|bytes| G1Affine::deserialize_compressed(bytes.as_slice())) - .collect::, _>>()?; - if !Bls12_381::multi_pairing(points, self.keys).is_zero() { + self.verify_points(pairing_points(bytes)?) + } + + fn verify_points(&self, points: [G1Affine; 2]) -> Result<()> { + if !PairingCurve::multi_pairing(points, self.keys).is_zero() { return Err("external opening pairing equation failed".into()); } Ok(()) } } +fn pairing_points(mut bytes: &[u8]) -> Result<[G1Affine; 2]> { + if bytes.len() != PAIRING_BYTES { + return Err("pairing point count".into()); + } + Ok([read_g1_compressed(&mut bytes)?, read_g1_compressed(&mut bytes)?]) +} + +#[cfg(any(test, feature = "plonk"))] pub(in crate::pipeline) fn unpack_pairings( bytes: &[u8], ) -> Result> { - if bytes.len() != 96 { - return Err("pairing point count".into()); - } - Ok( - bytes - .as_chunks::<24>() - .0 - .iter() - .map(|chunk| { - let mut limbs = [0; 4]; - for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) { - *limb = u64::from_le_bytes(*bytes); - } - Scalar::from_limbs_le(limbs) - }) - .collect(), - ) + Ok(pairing::encode(pairing_points(bytes)?).to_vec()) } pub(in crate::pipeline) fn pack_pairings(values: &[Scalar]) -> Result> { - if values.len() != 4 { - return Err("incorrect pairing operand count".into()); - } - let mut bytes = Vec::with_capacity(values.len() * 24); - for value in values { - let limbs = value.canonical_limbs_le(); - if limbs[3] != 0 { - return Err("noncanonical packed pairing operand".into()); - } - for limb in &limbs[..3] { - bytes.extend(limb.to_le_bytes()); - } + let mut bytes = Vec::with_capacity(PAIRING_BYTES); + for point in pairing::decode(values)? { + point.serialize_compressed(&mut bytes)?; } Ok(bytes) } @@ -200,18 +200,77 @@ pub(in crate::pipeline) fn verify_recursive_bytes( bytes: &[u8], expected: &[Scalar], ) -> Result<()> { - if bytes.len() < 96 { + if bytes.len() < PAIRING_BYTES { return Err("truncated pairing operands".into()); } + let points = pairing_points(&bytes[..PAIRING_BYTES])?; let mut public = expected.to_vec(); - public.extend(unpack_pairings(&bytes[..96])?); + public.extend(pairing::encode(points)); let claims = statement.claims(&public)?; - let proof = codec.decode(&bytes[96..])?; + let proof = codec.decode(&bytes[PAIRING_BYTES..])?; system .verify_multiple_claims( &claims.iter().map(Vec::as_slice).collect::>(), &proof, ) .map_err(|e| format!("outer KZG proof: {e:?}"))?; - pairings.verify_bytes(&bytes[..96]) + pairings.verify_points(points) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::curve::Fr; + use crate::recursive::pairing::POINT_LIMB_BYTES; + use ark_ec::CurveGroup; + use multi_stark::traits::Algebra; + + #[test] + fn pairing_transport_preserves_points_and_rejects_noncanonical_values() + -> Result<()> { + let generator = G1Affine::generator(); + let multiple = (generator * Fr::from(7u8)).into_affine(); + for points in [ + [generator, multiple], + [-generator, -multiple], + [G1Affine::identity(), generator], + [G1Affine::identity(); 2], + ] { + let values = pairing::encode(points); + let encoded = pack_pairings(&values)?; + assert_eq!(encoded.len(), PAIRING_BYTES); + assert_eq!(pairing_points(&encoded)?, points); + assert_eq!(unpack_pairings(&encoded)?, values); + assert!(unpack_pairings(&encoded[..encoded.len() - 1]).is_err()); + let mut extra = encoded.clone(); + extra.push(0); + assert!(unpack_pairings(&extra).is_err()); + let mut invalid = encoded; + invalid[..G1_COMPRESSED_BYTES].fill(0xff); + assert!(unpack_pairings(&invalid).is_err()); + for i in 0..PUBLIC_VALUES { + let mut high_limb = [0; 4]; + high_limb[POINT_LIMB_BYTES / 8] = 1; + let mut aliased = values; + aliased[i] += Scalar::from_limbs_le(high_limb); + assert!(pack_pairings(&aliased).is_err()); + } + assert!(pack_pairings(&values[..PUBLIC_VALUES - 1]).is_err()); + assert!(pack_pairings(&[Scalar::ZERO; PUBLIC_VALUES + 1]).is_err()); + } + Ok(()) + } + + #[cfg(feature = "bn254")] + #[test] + fn pairing_transport_rejects_infinity_coordinate_aliases() -> Result<()> { + let values = pairing::encode([G1Affine::identity(); 2]); + let mut encoded = pack_pairings(&values)?; + encoded[0] = 1; + assert!(unpack_pairings(&encoded).is_err()); + let mut aliased = values; + aliased[0] = Scalar::ONE; + assert!(pack_pairings(&aliased).is_err()); + Ok(()) + } } diff --git a/crates/proof-compression/src/pipeline/stages/wrap.rs b/crates/proof-compression/src/pipeline/stages/wrap.rs index 5cc6205de..f393b93a1 100644 --- a/crates/proof-compression/src/pipeline/stages/wrap.rs +++ b/crates/proof-compression/src/pipeline/stages/wrap.rs @@ -1,7 +1,7 @@ use super::verification::{Pairings, RecursiveVerifier}; use super::{KzgVerifier, Prepared, Result, scalar_statement}; use crate::kzg::{KzgConfig, Scalar, Srs}; -use crate::recursive::native_verifier; +use crate::recursive::{native_verifier, pairing::PUBLIC_VALUES}; use multi_stark::prover::Proof; use multi_stark::traits::Algebra; use multi_stark::types::Val; @@ -120,7 +120,8 @@ pub(super) fn setup_recursive( .with_transcript(crate::kzg::TranscriptKind::Blake3) .with_streaming_lookups() .with_streaming_quotient(); - let outer = super::setup::kzg_lowered(compiled, config, claim_len + 4)?; + let outer = + super::setup::kzg_lowered(compiled, config, claim_len + PUBLIC_VALUES)?; Ok(RecursiveVerifier { verifier: outer, pairings: Pairings { @@ -136,8 +137,9 @@ pub(super) fn setup_recursive( #[cfg(test)] mod tests { use super::*; + use crate::kzg::curve::{Fr, G1_COMPRESSED_BYTES}; + use crate::recursive::pairing; use ark_ec::CurveGroup; - use ark_serialize::CanonicalSerialize; use multi_stark::plonkish::CircuitBuilder; use multi_stark::traits::Algebra; use multi_stark::traits::Field; @@ -155,21 +157,9 @@ mod tests { let mut points = [inner.g1[1], -inner.g1[0]]; let keys = [inner.g2, inner.tau_g2]; if invalid_pairing { - points[0] = (points[0] * ark_bls12_381::Fr::from(2u8)).into_affine(); - } - let mut packed = vec![]; - for point in points { - let mut bytes = vec![]; - point.serialize_compressed(&mut bytes)?; - for chunk in bytes.as_chunks::<24>().0 { - let mut limbs = [0; 4]; - for (limb, bytes) in limbs.iter_mut().zip(chunk.as_chunks::<8>().0) - { - *limb = u64::from_le_bytes(*bytes); - } - packed.push(Scalar::from_limbs_le(limbs)); - } + points[0] = (points[0] * Fr::from(2u8)).into_affine(); } + let packed = pairing::encode(points); let expected: Vec<_> = (0..claim_len).map(|i| Val::from_usize(7 + i)).collect(); let mut public = scalar_statement(&expected); @@ -205,6 +195,13 @@ mod tests { let mut old_key = key_bytes.clone(); old_key[..8].copy_from_slice(b"IXRECV01"); assert!(RecursiveVerifier::from_bytes(&old_key).is_err()); + let mut other_curve = key_bytes.clone(); + other_curve[..8].copy_from_slice(if cfg!(feature = "bn254") { + b"IXRECV02" + } else { + b"IXBNRV02" + }); + assert!(RecursiveVerifier::from_bytes(&other_curve).is_err()); assert!( RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) .is_err() @@ -223,7 +220,7 @@ mod tests { extra.push(0); assert!(verifier.verify_bytes(&extra, &expected).is_err()); let mut malformed = bytes.clone(); - malformed[..48].fill(0xff); + malformed[..G1_COMPRESSED_BYTES].fill(0xff); assert!(verifier.verify_bytes(&malformed, &expected).is_err()); let mut wrong = expected.clone(); wrong[0] += Val::ONE; diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs index d802c4b7d..e64d4b69a 100644 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -1,6 +1,6 @@ -//! Verifier profiles authenticated by the application release. A packet -//! names its profile; the profile pins the aggregation program its proofs -//! attest, so the statement for a claim follows without the proving backend. +//! Verifier profiles authenticated by a release bundle or an independent +//! digest. Bundles pin an aggregation program; local profiles require its +//! independently supplied statement as well as the certificate claim. use super::{ Result, setup::SetupClass, @@ -16,15 +16,35 @@ struct BundledVerifier { /// this profile compresses. statement_prefix: [u64; 10], setup: SetupClass, + #[cfg(feature = "bn254")] + ignition_admission: Option, bytes: &'static [u8], decoded: OnceLock>, } +#[cfg(feature = "bn254")] +struct BundledIgnitionAdmission { + bytes: &'static [u8], + digest: [u8; 32], +} + +#[cfg(feature = "bn254")] +impl BundledIgnitionAdmission { + fn decode(&self) -> Result { + Ok(crate::kzg::srs::ignition::IgnitionAdmission::from_bytes( + self.bytes, + self.digest, + )?) + } +} + +#[cfg(not(feature = "bn254"))] const AGGREGATE_PREFIX: [u64; 10] = [ 0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, 1176003176, 683533119, 3085659920, ]; +#[cfg(not(feature = "bn254"))] static VERIFIERS: [BundledVerifier; 4] = [ BundledVerifier { id: "4224c47197ed387320dfb9b8bbf2369fa326f72c54e1cbc65907f5e6e8541ca0", @@ -60,19 +80,47 @@ static VERIFIERS: [BundledVerifier; 4] = [ }, ]; +#[cfg(feature = "bn254")] +static VERIFIERS: [BundledVerifier; 0] = []; + impl BundledVerifier { + #[cfg(feature = "bn254")] + fn check_release_class(&self) -> Result<()> { + if self.setup != SetupClass::Ignition { + return Err( + "BN254 release verifier catalog admits only Ignition setups".into(), + ); + } + Ok(()) + } + fn profile(&self) -> Result<&PreparedVerifierProfile> { self .decoded .get_or_init(|| { + #[cfg(feature = "bn254")] + self.check_release_class().map_err(|error| error.to_string())?; if blake3::hash(self.bytes).to_hex().as_str() != self.digest { return Err("bundled compression verifier digest differs".into()); } let profile = VerifierProfile::from_bytes(self.bytes) .map_err(|error| error.to_string())?; + #[cfg(not(feature = "bn254"))] profile .check_setup_class(self.setup) .map_err(|error| error.to_string())?; + #[cfg(feature = "bn254")] + { + let admission = self + .ignition_admission + .as_ref() + .ok_or("bundled Ignition verifier has no trusted admission")? + .decode() + .map_err(|error| error.to_string())?; + profile + .check_ignition_admission(&admission) + .map_err(|error| error.to_string())?; + } if blake3::Hash::from_bytes(profile.id()).to_hex().as_str() != self.id { return Err("bundled compression verifier metadata differs".into()); } @@ -103,6 +151,8 @@ pub(super) fn verify( let verifier = bundled(&id).ok_or( "compressed proof uses a verifier profile not bundled with this application", )?; + #[cfg(feature = "bn254")] + verifier.check_release_class()?; if verifier.setup == SetupClass::Development && !allow_development { return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); } @@ -111,7 +161,55 @@ pub(super) fn verify( Ok(verifier.setup) } -#[cfg(test)] +/// Verify using an independently authenticated profile and expected statement. +pub(super) fn verify_with_profile( + packet: &[u8], + claim: &[u8], + expected: &statement::Statement, + profile: &[u8], + trusted_digest: &[u8; 32], + allow_development: bool, +) -> Result { + if blake3::hash(profile).as_bytes() != trusted_digest { + return Err( + "local verifier profile digest differs from the trusted digest".into(), + ); + } + statement::bind_claim(claim, expected)?; + let profile = VerifierProfile::from_bytes(profile)?; + let setup = SetupClass::of(&profile.verifier_params()?); + profile.check_setup_class(setup)?; + if setup == SetupClass::Development && !allow_development { + return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); + } + profile.verify(packet, expected)?; + Ok(setup) +} + +/// Verify with an independently authenticated profile and ceremony admission. +/// The admission contains the verifier anchors; no proving cache is required. +#[cfg(feature = "bn254")] +pub(super) fn verify_with_ignition_admission( + packet: &[u8], + claim: &[u8], + expected: &statement::Statement, + profile: &[u8], + trusted_digest: &[u8; 32], + admission: &crate::kzg::srs::ignition::IgnitionAdmission, +) -> Result { + if blake3::hash(profile).as_bytes() != trusted_digest { + return Err( + "local verifier profile digest differs from the trusted digest".into(), + ); + } + statement::bind_claim(claim, expected)?; + let profile = VerifierProfile::from_bytes(profile)?; + profile.check_ignition_admission(admission)?; + profile.verify(packet, expected)?; + Ok(SetupClass::Ignition) +} + +#[cfg(all(test, not(feature = "bn254")))] mod tests { use super::*; use crate::pipeline::{self, PROOF_TAG}; @@ -280,3 +378,67 @@ mod tests { } } } + +#[cfg(all(test, feature = "bn254"))] +mod bn254_tests { + use super::*; + + #[test] + fn bn254_release_catalog_rejects_non_ignition_classes() { + for setup in [SetupClass::Development, SetupClass::Filecoin] { + let verifier = BundledVerifier { + id: "", + digest: "", + statement_prefix: [0; 10], + setup, + ignition_admission: None, + bytes: &[], + decoded: OnceLock::new(), + }; + assert!(verifier.check_release_class().is_err()); + assert!( + verifier + .profile() + .err() + .unwrap() + .to_string() + .contains("admits only Ignition") + ); + } + } + + #[test] + fn bundled_ignition_admission_requires_its_release_pin() { + let (admission, _) = crate::kzg::srs::ignition::test_admission(7, 16, 43); + let bytes: &'static [u8] = + Box::leak(admission.to_bytes().unwrap().into_boxed_slice()); + let digest = *blake3::hash(bytes).as_bytes(); + let bundled = BundledIgnitionAdmission { bytes, digest }; + assert_eq!( + bundled.decode().unwrap().public_setup(), + admission.public_setup() + ); + let mut wrong_digest = digest; + wrong_digest[0] ^= 1; + assert!( + BundledIgnitionAdmission { bytes, digest: wrong_digest } + .decode() + .is_err() + ); + } + + #[test] + fn bn254_requires_an_explicit_trusted_profile() { + assert!(bundled(&[0; 32]).is_none()); + let bls_packet = include_bytes!( + "../../tests/fixtures/aggregate-kzg/development-v5-packet.bin" + ); + assert!(verify(bls_packet, b"claim", true).is_err()); + assert!( + VerifierProfile::from_bytes(include_bytes!( + "../../profiles/aggregate-development-v6.bin" + )) + .is_err() + ); + } +} diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index 1c8cbaf8d..ea60b617d 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -57,6 +57,4 @@ fn spawn_memory_monitor() { }); } -pub(super) use outer::verify_saved as verify; - pub(super) use outer::{PreparedVerifierProfile, VerifierProfile}; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 787b1103b..9e776a767 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -1,9 +1,11 @@ //! Recursive KZG wrapper: staging and proving in one process, and the //! portable verifier its packets are checked with. #[cfg(feature = "plonk")] +mod plonk_export; +#[cfg(feature = "plonk")] mod relation; #[cfg(feature = "plonk")] -pub(in crate::pipeline) use relation::plonk::export as plonk_export; +pub(in crate::pipeline) use plonk_export::export as plonk_export; use super::super::stages::{ construct::{self, Lowered}, @@ -21,18 +23,28 @@ use super::{ }; use crate::{ kzg::{ - KzgCommitment, KzgConfig, Scalar, TranscriptKind, compact::FixedProofCodec, + KzgCommitment, KzgConfig, Scalar, TranscriptKind, + compact::FixedProofCodec, + curve::{G1_COMPRESSED_BYTES, G2_COMPRESSED_BYTES, G2Affine}, }, - recursive::native_verifier::Built, + recursive::{native_verifier::Built, pairing::PUBLIC_VALUES}, }; use multi_stark::{ plonkish::{Assignment, MultiStarkCircuit}, - traits::Field, + traits::{Field, TwoAdicField}, }; use std::{fs, path::Path}; +#[cfg(not(feature = "bn254"))] pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-v1"; +#[cfg(feature = "bn254")] +pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-bn254-v1"; +#[cfg(not(feature = "bn254"))] const OUTER_PROFILE_VERSION: u32 = 6; +#[cfg(feature = "bn254")] +const OUTER_PROFILE_VERSION: u32 = 7; +const PAIRING_BYTES: usize = 2 * G1_COMPRESSED_BYTES; +const RECURSIVE_PUBLIC_VALUES: usize = statement::WORDS + PUBLIC_VALUES; /// Largest admitted recursive computation trace. The development public /// degree of a staged profile covers exactly its own height. pub(super) const MAX_TRACE_LEN: usize = construct::RECURSIVE_HEIGHT; @@ -69,7 +81,7 @@ impl Frontend { pub(crate) fn new( circuit: multi_stark::plonkish::Circuit, - keys: &[ark_bls12_381::G2Affine], + keys: &[G2Affine], profile: &[u8], max_height: usize, ) -> storage::Result { @@ -97,7 +109,7 @@ impl Frontend { statement: &Statement, config: &super::super::WorkerConfig, ) -> storage::Result<()> { - if assignment.public_values().len() != 18 + 2 * 2 + if assignment.public_values().len() != RECURSIVE_PUBLIC_VALUES || assignment.public_values()[..18] .iter() .zip(statement) @@ -113,6 +125,14 @@ impl Frontend { .setup .for_stage(OUTER_SEED, height)? .with_transcript(TranscriptKind::Blake3); + #[cfg(feature = "bn254")] + if let Some(admission) = setup.ignition_admission() + && decode_pairing_keys(&self.pairings)? != admission.pairing_keys() + { + return Err( + "recursive inner pairing anchors differ from Ignition admission".into(), + ); + } let setup_id = setup.identity(height, 2)?; fs::create_dir_all(dir)?; setup.bind_stage(dir, height, 2)?; @@ -139,40 +159,39 @@ impl Frontend { } } -fn pairing_keys(dir: &Path) -> storage::Result> { +fn pairing_keys(dir: &Path) -> storage::Result> { let profile: Pairings = storage::load(&dir.join("pairings.bin"))?; decode_pairing_keys(&profile) } -fn decode_pairing_keys( - profile: &Pairings, -) -> storage::Result> { +fn decode_pairing_keys(profile: &Pairings) -> storage::Result> { use ark_ec::AffineRepr; - use ark_serialize::CanonicalDeserialize; - if profile.keys.len() != 8 + 2 * 96 + use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; + if profile.keys.len() != 8 + 2 * G2_COMPRESSED_BYTES || profile.keys.get(..8) != Some(&2u64.to_le_bytes()) { return Err("invalid pairing profile".into()); } let mut input = profile.keys.as_slice(); - let keys = - Vec::::deserialize_compressed(&mut input)?; + let keys = Vec::::deserialize_compressed(&mut input)?; if !input.is_empty() || keys.len() != 2 || keys.iter().any(AffineRepr::is_zero) { return Err("invalid pairing profile".into()); } + let mut canonical = Vec::new(); + keys.serialize_compressed(&mut canonical)?; + if canonical != profile.keys { + return Err("noncanonical pairing profile".into()); + } Ok(keys) } fn pairing_bytes(publics: &[Scalar]) -> storage::Result> { verification::pack_pairings(publics) } -fn check_pairings( - bytes: &[u8], - keys: &[ark_bls12_381::G2Affine], -) -> storage::Result<()> { +fn check_pairings(bytes: &[u8], keys: &[G2Affine]) -> storage::Result<()> { verification::Pairings { keys: keys.try_into().map_err(|_keys| "pairing point count")?, } @@ -184,7 +203,7 @@ fn packet_body<'a>( expected: &Statement, ) -> storage::Result<&'a [u8]> { let offset = 32 + statement::WORDS * 8; - if packet.len() < offset + 96 || packet[..32] != *profile { + if packet.len() < offset + PAIRING_BYTES || packet[..32] != *profile { return Err("packet profile mismatch".into()); } for (bytes, &word) in @@ -210,16 +229,16 @@ fn packet_claims( let body = packet_body(packet, profile, expected)?; let mut public: Vec<_> = expected.iter().copied().map(Scalar::from_u64).collect(); - public.extend(verification::unpack_pairings(&body[..96])?); + public.extend(verification::unpack_pairings(&body[..PAIRING_BYTES])?); let claims = StageStatement::recursive(public.len()).claims(&public)?; - Ok((claims, 32 + statement::WORDS * 8 + 96)) + Ok((claims, 32 + statement::WORDS * 8 + PAIRING_BYTES)) } fn verify_packet( system: &multi_stark::system::System, codec: &FixedProofCodec, packet: &[u8], profile: &[u8; 32], - keys: &[ark_bls12_381::G2Affine], + keys: &[G2Affine], expected: &Statement, ) -> storage::Result<()> { let body = packet_body(packet, profile, expected)?; @@ -229,7 +248,7 @@ fn verify_packet( verification::verify_recursive_bytes( system, codec, - &StageStatement::recursive(22), + &StageStatement::recursive(RECURSIVE_PUBLIC_VALUES), &pairings, body, &expected.iter().copied().map(Scalar::from_u64).collect::>(), @@ -240,11 +259,10 @@ fn staged_publics( claims: &[Vec], pairing_count: usize, ) -> storage::Result> { - let count = pairing_count - .checked_mul(2) - .and_then(|n| n.checked_add(18)) - .ok_or("public count overflow")?; - StageStatement::recursive(count).public_values(claims) + if pairing_count != 2 { + return Err("pairing point count".into()); + } + StageStatement::recursive(RECURSIVE_PUBLIC_VALUES).public_values(claims) } /// Commit the fixed and witness traces from memory, prove, verify the @@ -265,10 +283,12 @@ fn prove( || manifest.widths.len() != 2 || manifest.widths[0] != 3 || manifest.widths[1] != 1 - || manifest - .heights - .iter() - .any(|h| *h < 2 || !h.is_power_of_two() || *h > 1 << 28) + || manifest.heights.iter().any(|h| { + *h < 2 + || !h.is_power_of_two() + || *h > 1 << 28 + || h.ilog2() as usize >= Scalar::TWO_ADICITY + }) || manifest.heights[1] > manifest.heights[0] { return Err("expected one computation trace and one merged table".into()); @@ -348,7 +368,7 @@ fn prove( "proof_bytes": bytes.len(), "packet_bytes": packet.len(), "claim_bytes": 144, - "pairing_bytes": keys.len() * 48, + "pairing_bytes": PAIRING_BYTES, "profile_bytes": 32, "profile_version": OUTER_PROFILE_VERSION, "transcript": "blake3", @@ -376,7 +396,7 @@ fn prove( Ok(()) } -/// Recursive verifier metadata. Application verification accepts only bundled profiles. +/// Recursive verifier metadata authenticated by a bundle or an independent digest. #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct VerifierProfile { version: u32, @@ -401,6 +421,7 @@ fn decode(bytes: &[u8]) -> storage::Result { } impl VerifierProfile { + #[cfg(all(not(feature = "bn254"), any(test, feature = "plonk")))] pub(crate) fn reencode_blake3( bytes: &[u8], ) -> PipelineResult<(Vec, [u8; 32])> { @@ -422,9 +443,11 @@ impl VerifierProfile { pub(crate) fn id(&self) -> [u8; 32] { let mut hash = blake3::Hasher::new(); - hash.update( - format!("init-kzg-recursive-profile/v{}", self.version).as_bytes(), - ); + #[cfg(not(feature = "bn254"))] + let namespace = "init-kzg-recursive-profile"; + #[cfg(feature = "bn254")] + let namespace = "init-kzg-recursive-bn254-profile"; + hash.update(format!("{namespace}/v{}", self.version).as_bytes()); hash.update(OUTER_SEED); hash.update(&self.parameters); hash.update(&(self.heights[0] as u64).to_le_bytes()); @@ -435,7 +458,7 @@ impl VerifierProfile { *hash.finalize().as_bytes() } - fn keys(&self) -> storage::Result> { + fn keys(&self) -> storage::Result> { decode_pairing_keys(&decode(&self.pairings)?) } @@ -452,6 +475,12 @@ impl VerifierProfile { &self, expected: SetupClass, ) -> PipelineResult<()> { + #[cfg(feature = "bn254")] + if expected == SetupClass::Ignition { + return Err( + "Ignition verification requires an explicit trusted admission".into(), + ); + } let parameters = self.verifier_params()?; if SetupClass::of(¶meters) != expected { return Err("bundled verifier setup class differs".into()); @@ -466,10 +495,29 @@ impl VerifierProfile { Ok(()) } + /// Admit both opening equations against independently pinned ceremony data. + #[cfg(feature = "bn254")] + pub(crate) fn check_ignition_admission( + &self, + admission: &crate::kzg::srs::ignition::IgnitionAdmission, + ) -> PipelineResult<()> { + SetupClass::of_with_ignition(&self.verifier_params()?, admission)?; + if self.keys()? != admission.pairing_keys() { + return Err( + "Ignition verifier has different inner pairing anchors".into(), + ); + } + Ok(()) + } + fn system(&self) -> storage::Result> { use crate::kzg::VerifierParams; use multi_stark::system::{Circuit, System}; - if !matches!(self.version, 5 | OUTER_PROFILE_VERSION) { + #[cfg(not(feature = "bn254"))] + let supported = matches!(self.version, 5 | OUTER_PROFILE_VERSION); + #[cfg(feature = "bn254")] + let supported = self.version == OUTER_PROFILE_VERSION; + if !supported { return Err("unsupported recursive verifier profile".into()); } if self @@ -484,6 +532,11 @@ impl VerifierProfile { if params.max_trace_len() != self.heights[0] { return Err("verifier SRS range mismatch".into()); } + if params.max_trace_len().ilog2() as usize >= Scalar::TWO_ADICITY { + return Err( + "trace and quotient domains exceed scalar two-adicity".into(), + ); + } let mut circuits = vec![]; let mut fixed = KzgCommitment(vec![]); for (i, bytes) in self.setup.iter().enumerate() { @@ -516,7 +569,7 @@ impl VerifierProfile { id: self.id(), verifier: super::super::stages::RecursiveVerifier::from_parts( self.system()?, - StageStatement::recursive(22), + StageStatement::recursive(RECURSIVE_PUBLIC_VALUES), &self.keys()?, 18, )?, @@ -554,6 +607,7 @@ impl PreparedVerifierProfile { /// Verify a packet against the stage's portable verifier. Without an /// independently derived statement, the one the stage's own claim rows /// carry is used, so the check covers the proof but not the claim binding. +#[cfg(test)] pub(crate) fn verify_saved( dir: &Path, packet: &[u8], @@ -587,6 +641,7 @@ mod tests { }; use std::sync::Arc; + #[cfg(not(feature = "bn254"))] #[test] fn bundled_and_public_recursive_verifiers_share_transport_validation() -> storage::Result<()> { @@ -624,6 +679,7 @@ mod tests { Ok(()) } + #[cfg(not(feature = "bn254"))] #[test] fn filecoin_bundle_requires_filecoin_inner_pairing_anchors() { use ark_serialize::CanonicalSerialize; @@ -646,6 +702,221 @@ mod tests { assert!(profile.check_setup_class(SetupClass::Filecoin).is_err()); } + #[cfg(feature = "bn254")] + fn ignition_profile_fixture() -> storage::Result<( + crate::kzg::srs::ignition::IgnitionAdmission, + VerifierProfile, + Vec, + )> { + use crate::kzg::{VerifierParams, srs::ignition::test_admission}; + let (admission, srs) = test_admission(7, 256, 41); + let claims = init_packet_claims(&srs); + let publics = staged_publics(&claims, 2)?; + let mut builder = CircuitBuilder::new(); + let table = + builder.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let one = builder.constant(Scalar::ONE); + builder.lookup(table, &[one]); + for claim in &claims[1..] { + let value = builder.constant(claim[3]); + builder.expose_public(value); + } + let compiled = builder + .finish() + .lower_to_multi_stark_sharded(Scalar::from_u8(94), 128)? + .merge_table_traces(128)?; + let assignment = compiled.witness().generate()?; + let height = compiled.main_height(); + let parameters = VerifierParams::with_max_trace_len(&srs, height); + let config = KzgConfig::with_max_trace_len(Arc::new(srs), height, 2) + .with_transcript(TranscriptKind::Blake3) + .with_streaming_lookups() + .with_streaming_quotient(); + let proved = Lowered { compiled, assignment } + .commit(config, None, 0)? + .prove(&claims)?; + assert_eq!(proved.system.circuits.len(), 2); + let fixed = proved.system.preprocessed_commit.as_ref().unwrap(); + let encode_setup = |index: usize| -> storage::Result> { + Ok(bincode::serde::encode_to_vec( + ( + &proved.system.circuits[index], + KzgCommitment(vec![fixed.0[index].clone()]), + ), + bincode::config::standard(), + )?) + }; + let mut keys = Vec::new(); + admission.pairing_keys().to_vec().serialize_compressed(&mut keys)?; + let profile = VerifierProfile { + version: OUTER_PROFILE_VERSION, + heights: [ + proved.system.circuits[0].preprocessed_height, + proved.system.circuits[1].preprocessed_height, + ], + setup: [encode_setup(0)?, encode_setup(1)?], + pairings: bincode::serde::encode_to_vec( + Pairings { keys }, + bincode::config::standard(), + )?, + parameters: parameters.to_bytes()?, + }; + let mut packet = profile.id().to_vec(); + for word in init_claim::INIT_PUBLIC_WORDS { + packet.extend(word.to_le_bytes()); + } + packet.extend(pairing_bytes(&publics[18..])?); + packet.extend(&proved.bytes); + Ok((admission, profile, packet)) + } + + #[cfg(feature = "bn254")] + #[test] + fn ignition_staging_rejects_other_inner_keys_before_creating_files() + -> storage::Result<()> { + use crate::{ + kzg::srs::ignition::test_admission, + pipeline::{WorkerConfig, setup::SetupConfig}, + }; + let (admission, _) = test_admission(7, 256, 44); + let inner = Srs::unsafe_dev_setup(2, b"ignition-stage-development-inner"); + let claims = init_packet_claims(&inner); + let mut builder = CircuitBuilder::new(); + let table = + builder.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let one = builder.constant(Scalar::ONE); + builder.lookup(table, &[one]); + for claim in &claims[1..] { + let value = builder.constant(claim[3]); + builder.expose_public(value); + } + let circuit = builder.finish(); + let assignment = circuit.witness().generate()?; + let frontend = Frontend::new( + circuit, + &[inner.g2, inner.tau_g2], + b"ignition-stage-admission", + 128, + )?; + let root = std::env::temp_dir() + .join(format!("ignition-stage-admission-{}", std::process::id(),)); + assert!(!root.exists()); + let config = WorkerConfig { + setup: SetupConfig::Ignition { + cache: root.join("absent-cache.bin"), + manifest: [0; 32], + admission: Arc::new(admission), + }, + fixed_cache_root: None, + prefetch_bytes: 0, + }; + let error = frontend + .stage(assignment, &root, &init_claim::INIT_PUBLIC_WORDS, &config) + .unwrap_err(); + assert!(error.to_string().contains("inner pairing anchors differ")); + assert!(!root.exists()); + Ok(()) + } + + #[cfg(feature = "bn254")] + #[test] + fn ignition_admission_authenticates_both_openings_without_a_cache() + -> storage::Result<()> { + use super::super::super::verifier::verify_with_ignition_admission; + use crate::kzg::{ + VerifierParams, + srs::ignition::{IgnitionAdmission, test_admission}, + }; + let (admission, profile, packet) = ignition_profile_fixture()?; + let claim = include_bytes!( + "../../../tests/fixtures/aggregate-kzg/filecoin-claim.bin" + ); + let expected = init_claim::INIT_PUBLIC_WORDS; + let portable = + bincode::serde::encode_to_vec(&profile, bincode::config::standard())?; + let trusted = *blake3::hash(&portable).as_bytes(); + let verify = |packet: &[u8], + claim: &[u8], + expected: &_, + trusted: &_, + admission: &IgnitionAdmission| { + verify_with_ignition_admission( + packet, claim, expected, &portable, trusted, admission, + ) + }; + assert_eq!( + verify(&packet, claim, &expected, &trusted, &admission)?, + SetupClass::Ignition + ); + assert!(profile.check_setup_class(SetupClass::Ignition).is_err()); + assert_eq!( + SetupClass::of(&profile.verifier_params()?), + SetupClass::Development + ); + assert!(verify(&packet, claim, &expected, &[0; 32], &admission).is_err()); + assert!( + verify(&packet, b"wrong", &expected, &trusted, &admission).is_err() + ); + let mut wrong_statement = expected; + wrong_statement[0] ^= 1; + assert!( + verify(&packet, claim, &wrong_statement, &trusted, &admission).is_err() + ); + let mut bad_packet = packet.clone(); + *bad_packet.last_mut().unwrap() ^= 1; + assert!( + verify(&bad_packet, claim, &expected, &trusted, &admission).is_err() + ); + for (tau, count, marker) in [(11, 256, 41), (7, 512, 41), (7, 256, 42)] { + let (other, _) = test_admission(tau, count, marker); + assert!(verify(&packet, claim, &expected, &trusted, &other).is_err()); + } + for change in [0, 1, 2] { + let mut changed: VerifierProfile = decode(&portable)?; + let mut parameters = changed.verifier_params()?; + match change { + 0 => parameters.public_setup.id[0] ^= 1, + 1 => parameters.public_setup.max_degree += 1, + 2 => { + let (_, other) = test_admission(11, 256, 41); + parameters = VerifierParams::with_max_trace_len( + &other, + parameters.max_trace_len(), + ); + }, + _ => unreachable!(), + } + changed.parameters = parameters.to_bytes()?; + assert!(changed.check_ignition_admission(&admission).is_err()); + } + let mut mixed: VerifierProfile = decode(&portable)?; + let development = Srs::unsafe_dev_setup(2, b"ignition-mixed-inner"); + let mut keys = Vec::new(); + vec![development.g2, development.tau_g2].serialize_compressed(&mut keys)?; + mixed.pairings = bincode::serde::encode_to_vec( + Pairings { keys }, + bincode::config::standard(), + )?; + assert!( + mixed + .check_ignition_admission(&admission) + .unwrap_err() + .to_string() + .contains("inner pairing anchors") + ); + let mut development_profile: VerifierProfile = decode(&portable)?; + let development = Srs::unsafe_dev_setup_prefix( + 2, + 255, + b"ignition-existing-development-profile", + ); + development_profile.parameters = + VerifierParams::with_max_trace_len(&development, profile.heights[0]) + .to_bytes()?; + assert!(development_profile.check_ignition_admission(&admission).is_err()); + Ok(()) + } + #[test] fn packet_requires_outer_proof_claim_and_external_pairings() { let inner = Srs::unsafe_dev_setup(8, b"packet-inner"); @@ -657,7 +928,7 @@ mod tests { packet.extend(v.to_le_bytes()); } let lhs = if wrong { - (inner.g1[1] * ark_bls12_381::Fr::from(2u8)).into_affine() + (inner.g1[1] * crate::kzg::curve::Fr::from(2u8)).into_affine() } else { inner.g1[1] }; @@ -789,7 +1060,7 @@ mod tests { cache_root: &Path, compiled: &MultiStarkCircuit, assignment: &Assignment, - keys: &[ark_bls12_381::G2Affine], + keys: &[G2Affine], ) -> storage::Result { fs::create_dir_all(dir)?; let height = compiled.main_height(); @@ -813,8 +1084,10 @@ mod tests { #[test] fn fused_profiles_prove_and_portable_verifiers_round_trip() -> storage::Result<()> { - let dir = std::env::temp_dir() - .join(format!("outer-staged-{}-v6", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "outer-staged-{}-v{OUTER_PROFILE_VERSION}", + std::process::id() + )); fs::create_dir(&dir)?; let inner = Srs::unsafe_dev_setup(8, b"staged-inner"); let keys = vec![inner.g2, inner.tau_g2]; @@ -880,6 +1153,36 @@ mod tests { assert!(staged_publics(&bad, keys.len()).is_err()); assert!(staged_publics(&claims[..claims.len() - 1], keys.len()).is_err()); let portable = fs::read(first.join("verifier.bin"))?; + let claim = include_bytes!( + "../../../tests/fixtures/aggregate-kzg/filecoin-claim.bin" + ); + let trusted = *blake3::hash(&portable).as_bytes(); + let verify_local = |claim: &[u8], expected, trusted: &[u8; 32], allow| { + super::super::super::verifier::verify_with_profile( + &packet, claim, expected, &portable, trusted, allow, + ) + }; + assert_eq!( + verify_local(claim, &init_claim::INIT_PUBLIC_WORDS, &trusted, true)?, + SetupClass::Development + ); + assert!( + verify_local(claim, &init_claim::INIT_PUBLIC_WORDS, &trusted, false) + .is_err() + ); + let mut wrong_digest = trusted; + wrong_digest[0] ^= 1; + assert!( + verify_local(claim, &init_claim::INIT_PUBLIC_WORDS, &wrong_digest, true) + .is_err() + ); + assert!( + verify_local(b"wrong", &init_claim::INIT_PUBLIC_WORDS, &trusted, true) + .is_err() + ); + let mut wrong_program = init_claim::INIT_PUBLIC_WORDS; + wrong_program[0] ^= 1; + assert!(verify_local(claim, &wrong_program, &trusted, true).is_err()); let profile = VerifierProfile::from_bytes(&portable)?; assert_eq!(profile.version, OUTER_PROFILE_VERSION); assert_eq!( @@ -887,7 +1190,11 @@ mod tests { TranscriptKind::Blake3 ); - for version in [0, 4, 7] { + #[cfg(not(feature = "bn254"))] + let unsupported_versions = [0, 4, 7]; + #[cfg(feature = "bn254")] + let unsupported_versions = [0, 5, 6]; + for version in unsupported_versions { let mut unsupported: VerifierProfile = decode(&portable)?; unsupported.version = version; let unsupported = bincode::serde::encode_to_vec( @@ -903,6 +1210,32 @@ mod tests { let mut wrong_range: VerifierProfile = decode(&portable)?; wrong_range.heights[0] *= 2; assert!(wrong_range.system().is_err()); + #[cfg(feature = "bn254")] + { + let max_domain = 1usize << Scalar::TWO_ADICITY; + let anchors = + Srs::unsafe_dev_setup_prefix(2, max_domain - 1, b"outer-domain-cap"); + let params = + crate::kzg::VerifierParams::with_max_trace_len(&anchors, max_domain); + let mut oversized: VerifierProfile = decode(&portable)?; + oversized.heights = [max_domain; 2]; + oversized.parameters = params.to_bytes()?; + for setup in &mut oversized.setup { + let (mut circuit, fixed): ( + multi_stark::system::Circuit, + KzgCommitment, + ) = decode(setup)?; + circuit.preprocessed_height = max_domain; + *setup = bincode::serde::encode_to_vec( + (circuit, fixed), + bincode::config::standard(), + )?; + } + let bytes = + bincode::serde::encode_to_vec(oversized, bincode::config::standard())?; + let error = VerifierProfile::from_bytes(&bytes).err().unwrap(); + assert!(error.to_string().contains("quotient domains exceed")); + } Ok(()) } diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs similarity index 73% rename from crates/proof-compression/src/pipeline/wrap/outer/relation/plonk.rs rename to crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index f5a33244f..a545a5a2c 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -1,5 +1,20 @@ -//! Canonical verifier programs and witnesses for the terminal BLS PLONK circuit. +//! Canonical verifier programs and witnesses for terminal PLONK circuits. +use super::relation::{codegen, *}; use super::*; +use crate::kzg::curve::G1Affine; +use ark_ec::AffineRepr; +#[cfg(feature = "bn254")] +use ark_ff::{BigInteger, PrimeField}; +use ark_serialize::CanonicalDeserialize; +#[cfg(not(feature = "bn254"))] +use ark_serialize::CanonicalSerialize; +use multi_stark::{ + config::ProofConfig, + system::System, + traits::{Algebra, Pcs}, +}; +use serde_json::{Value as Json, json}; +type Result = std::result::Result>; mod ir; fn point(point: G1Affine) -> Json { @@ -12,7 +27,12 @@ fn point(point: G1Affine) -> Json { fn key(point: &G2Affine) -> String { let mut bytes = vec![]; + #[cfg(not(feature = "bn254"))] point.serialize_compressed(&mut bytes).unwrap(); + #[cfg(feature = "bn254")] + for value in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { + bytes.extend(value.into_bigint().to_bytes_be()); + } hex_json(&bytes) } @@ -28,7 +48,16 @@ pub(in crate::pipeline) fn export( return Err("trusted verifier digest differs".into()); } let profile = VerifierProfile::from_bytes(&bytes)?; + #[cfg(not(feature = "bn254"))] profile.check_setup_class(SetupClass::Filecoin)?; + #[cfg(feature = "bn254")] + { + let setup = super::super::super::setup::SetupConfig::from_env(false)?; + let admission = setup + .ignition_admission() + .ok_or("PLONK export requires admitted Ignition parameters")?; + profile.check_ignition_admission(admission)?; + } write_export( &profile, &fs::read(packet)?, @@ -51,7 +80,6 @@ pub(super) fn write_export( if system.config.transcript_kind() != TranscriptKind::Blake3 { return Err("PLONK wrapping requires a BLAKE3 final KZG profile".into()); } - profile.check_setup_class(SetupClass::Filecoin)?; let (claims, offset) = packet_claims(packet, &profile.id(), 2, expected)?; let logs = profile .heights @@ -61,6 +89,23 @@ pub(super) fn write_export( let proof = FixedProofCodec::new(&system, &logs)?.decode(&packet[offset..])?; let diagnostic = diagnostics(&system, &proof, &claims)?; + #[cfg(feature = "bn254")] + let diagnostic = { + let mut value = diagnostic; + let encodings = proof_points(&proof) + .into_iter() + .map(|point| { + let mut bytes = Vec::new(); + ark_serialize::CanonicalSerialize::serialize_compressed( + &point, &mut bytes, + ) + .expect("Vec serialization"); + hex_json(&bytes) + }) + .collect::>(); + value["pointEncodings"] = json!(encodings); + value + }; let deferred_bytes = &packet[32 + 18 * 8..offset]; if check_pairings(deferred_bytes, &profile.keys()?).is_ok() != valid_pairing { return Err("unexpected deferred pairing result".into()); @@ -78,6 +123,9 @@ pub(super) fn write_export( .copied() .map(point) .collect(); + if fixed.len() != layout.fixed_count { + return Err("fixed commitment count differs from layout".into()); + } let circuits: Vec<_> = system.circuits.iter().enumerate().map(|(i, c)| { json!({ "logDegree": logs[i], @@ -96,7 +144,7 @@ pub(super) fn write_export( }) .collect(); let exported = json!({ - "format": "ix-bls-plonk-profile-v1", + "format": if cfg!(feature = "bn254") { "ix-bn254-plonk-profile-v1" } else { "ix-bls-plonk-profile-v1" }, "profileId": hex_json(&profile.id()), "setupId": hex_json(¶ms.public_setup.id), "publicMaxDegree": params.public_setup.max_degree, @@ -127,7 +175,7 @@ pub(super) fn write_export( return Err("exported scalar count differs".into()); } let deferred = deferred_bytes - .as_chunks::<48>() + .as_chunks::<{ G1_COMPRESSED_BYTES }>() .0 .iter() .map(|bytes| G1Affine::deserialize_compressed(bytes.as_slice()).map(point)) diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/plonk/ir.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs similarity index 100% rename from crates/proof-compression/src/pipeline/wrap/outer/relation/plonk/ir.rs rename to crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs index f8cd4558d..cbe498c9f 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs @@ -1,12 +1,8 @@ -//! Verifier relations of authenticated recursive profiles: opening layouts, -//! validated constraint graphs and native differential vectors for terminal -//! proof exporters. - +//! Shared verifier layouts, graph validation and native differential vectors. use super::*; -use ark_bls12_381::{Fq, G1Affine, G1Projective, G2Affine}; +use crate::kzg::curve::{Fq, G1Affine, G1Projective}; use ark_ec::{AffineRepr, CurveGroup}; use ark_ff::{BigInteger, PrimeField, Zero}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use multi_stark::{ config::ProofConfig, eval::VarValues, @@ -18,13 +14,9 @@ use multi_stark::{ }; use serde_json::{Value as Json, json}; use std::fmt::Write as _; - -mod codegen; -pub(in crate::pipeline) mod plonk; - type Result = std::result::Result>; - -fn hex(bytes: &[u8]) -> String { +pub(super) mod codegen; +pub(super) fn hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for byte in bytes { write!(&mut out, "{byte:02x}").unwrap(); @@ -32,26 +24,32 @@ fn hex(bytes: &[u8]) -> String { out } -fn hex_json(bytes: &[u8]) -> String { +pub(super) fn hex_json(bytes: &[u8]) -> String { format!("0x{}", hex(bytes)) } -fn scalar(value: Scalar) -> String { +pub(super) fn scalar(value: Scalar) -> String { hex_json(&value.0.into_bigint().to_bytes_be()) } -fn fq(value: Fq) -> Vec { +pub(super) fn fq(value: Fq) -> Vec { value.into_bigint().to_bytes_be() } -fn g1(point: G1Affine) -> Vec { +pub(super) fn g1(point: G1Affine) -> Vec { if point.is_zero() { - return vec![0; 128]; + return vec![0; if cfg!(feature = "bn254") { 64 } else { 128 }]; } - [vec![0; 16], fq(point.x), vec![0; 16], fq(point.y)].concat() + [ + vec![0; if cfg!(feature = "bn254") { 0 } else { 16 }], + fq(point.x), + vec![0; if cfg!(feature = "bn254") { 0 } else { 16 }], + fq(point.y), + ] + .concat() } -fn proof_points(proof: &Proof) -> Vec { +pub(super) fn proof_points(proof: &Proof) -> Vec { [ &proof.commitments.stage_1_trace, &proof.commitments.stage_2_trace, @@ -64,23 +62,26 @@ fn proof_points(proof: &Proof) -> Vec { } #[derive(Clone, Copy)] -struct GraphMatrix { - index: usize, - width: usize, - rows: usize, +pub(super) struct GraphMatrix { + pub(super) index: usize, + pub(super) width: usize, + pub(super) rows: usize, } /// Opening windows indexed by the graph's three variable sources. #[derive(Clone, Copy)] -struct GraphInputs { - main: GraphMatrix, - preprocessed: GraphMatrix, - stage2: GraphMatrix, - public_count: usize, +pub(super) struct GraphInputs { + pub(super) main: GraphMatrix, + pub(super) preprocessed: GraphMatrix, + pub(super) stage2: GraphMatrix, + pub(super) public_count: usize, } impl GraphInputs { - fn for_circuit(system: &System, index: usize) -> Result { + pub(super) fn for_circuit( + system: &System, + index: usize, + ) -> Result { if index >= 2 { return Err("Solidity graph requires one of two circuits".into()); } @@ -102,7 +103,7 @@ impl GraphInputs { }) } - fn matrix(self, source: Source) -> GraphMatrix { + pub(super) fn matrix(self, source: Source) -> GraphMatrix { match source { Source::Main => self.main, Source::Preprocessed => self.preprocessed, @@ -113,7 +114,7 @@ impl GraphInputs { /// Validated arithmetic with variable references resolved into opening windows. #[derive(Clone, Copy)] -enum GraphInstruction { +pub(super) enum GraphInstruction { Constant(Scalar), Opening { matrix: usize, offset: usize }, Public(usize), @@ -126,12 +127,12 @@ enum GraphInstruction { Neg(usize), } -struct GraphProgram { - instructions: Vec, +pub(super) struct GraphProgram { + pub(super) instructions: Vec, } impl GraphProgram { - fn compile( + pub(super) fn compile( graph: &ConstraintGraph, inputs: GraphInputs, ) -> Result { @@ -143,8 +144,9 @@ impl GraphProgram { return Err("invalid Solidity graph opening window".into()); } } - let mut program = - Self { instructions: Vec::with_capacity(graph.nodes.len()) }; + let mut program = Self { + instructions: Vec::with_capacity(graph.nodes.len()), + }; for (index, node) in graph.nodes.iter().enumerate() { let child = |id: multi_stark::graph::NodeId| -> Result { let child = id.index(); @@ -175,9 +177,15 @@ impl GraphProgram { } GraphInstruction::Public(index) }, - Node::IsFirstRow => GraphInstruction::First, - Node::IsLastRow => GraphInstruction::Last, - Node::IsTransition => GraphInstruction::Transition, + Node::IsFirstRow => { + GraphInstruction::First + }, + Node::IsLastRow => { + GraphInstruction::Last + }, + Node::IsTransition => { + GraphInstruction::Transition + }, Node::Add(a, b) => GraphInstruction::Add(child(a)?, child(b)?), Node::Sub(a, b) => GraphInstruction::Sub(child(a)?, child(b)?), Node::Mul(a, b) => GraphInstruction::Mul(child(a)?, child(b)?), @@ -197,7 +205,8 @@ impl GraphProgram { Ok(program) } - fn evaluate + Copy + From>( + #[cfg(feature = "plonk")] + pub(super) fn evaluate + Copy + From>( &self, opening: impl Fn(usize, usize) -> A, publics: &[A], @@ -222,6 +231,7 @@ impl GraphProgram { } values } + } struct Batch { @@ -230,7 +240,7 @@ struct Batch { values: Vec, } -fn diagnostics( +pub(super) fn diagnostics( system: &System, proof: &Proof, claims: &[Vec], diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs index bad78f938..63a1a7f73 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs @@ -19,23 +19,23 @@ struct CircuitShape { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct MatrixLayout { - pub(super) width: usize, - pub(super) rows: usize, - pub(super) point_offset: usize, - pub(super) value_offset: usize, - pub(super) groups: [usize; 2], +pub(in crate::pipeline::wrap::outer) struct MatrixLayout { + pub(in crate::pipeline::wrap::outer) width: usize, + pub(in crate::pipeline::wrap::outer) rows: usize, + pub(in crate::pipeline::wrap::outer) point_offset: usize, + pub(in crate::pipeline::wrap::outer) value_offset: usize, + pub(in crate::pipeline::wrap::outer) groups: [usize; 2], } -pub(super) struct OpeningLayout { - pub(super) matrices: Vec, - pub(super) factors: Vec, - pub(super) commitment_count: usize, - pub(super) point_count: usize, - pub(super) field_count: usize, - pub(super) fixed_count: usize, - pub(super) generator_index: usize, - pub(super) coefficient_count: usize, +pub(in crate::pipeline::wrap::outer) struct OpeningLayout { + pub(in crate::pipeline::wrap::outer) matrices: Vec, + pub(in crate::pipeline::wrap::outer) factors: Vec, + pub(in crate::pipeline::wrap::outer) commitment_count: usize, + pub(in crate::pipeline::wrap::outer) point_count: usize, + pub(in crate::pipeline::wrap::outer) field_count: usize, + pub(in crate::pipeline::wrap::outer) fixed_count: usize, + pub(in crate::pipeline::wrap::outer) generator_index: usize, + pub(in crate::pipeline::wrap::outer) coefficient_count: usize, } fn add(a: usize, b: usize) -> Result { @@ -70,7 +70,7 @@ fn quotient_width(max_degree: usize) -> Result { } impl OpeningLayout { - pub(super) fn new( + pub(in crate::pipeline::wrap::outer) fn new( profile: &VerifierProfile, system: &System, ) -> Result { @@ -184,6 +184,7 @@ impl OpeningLayout { coefficient_count, }) } + } #[cfg(test)] diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index ce99c22e3..d51280781 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -202,6 +202,7 @@ pub(crate) fn check( let witness_seconds = witness_start.elapsed().as_secs_f64(); let stats = built.circuit.stats(); let result = serde_json::json!({ + "curve": crate::kzg::curve::NAME, "gates": stats.gates, "lookups": stats.lookups, "values": stats.values, diff --git a/crates/proof-compression/src/recursive/mod.rs b/crates/proof-compression/src/recursive/mod.rs index 0f8be92f9..37c415914 100644 --- a/crates/proof-compression/src/recursive/mod.rs +++ b/crates/proof-compression/src/recursive/mod.rs @@ -4,3 +4,4 @@ pub mod native_field; pub mod native_msm; pub mod native_transcript; pub mod native_verifier; +pub mod pairing; diff --git a/crates/proof-compression/src/recursive/native_curve.rs b/crates/proof-compression/src/recursive/native_curve.rs index e0e9322bd..af505d47d 100644 --- a/crates/proof-compression/src/recursive/native_curve.rs +++ b/crates/proof-compression/src/recursive/native_curve.rs @@ -1,7 +1,9 @@ //! Affine arithmetic with constrained denominators and a complete final addition. -use crate::kzg::Scalar; -use crate::recursive::native_field::{Builder, FqGadget, FqVar, fq_value}; -use ark_bls12_381::{Fq, G1Affine}; +use crate::kzg::{ + Scalar, + curve::{Fq, G1Affine}, +}; +use crate::recursive::native_field::{Builder, FqGadget, FqVar, L, fq_value}; use ark_ec::{AdditiveGroup, AffineRepr}; use ark_ff::Field; use multi_stark::{ @@ -33,7 +35,8 @@ impl Affine { } pub fn on_curve(self, b: &mut Builder, f: &FqGadget) { let xx = f.mul(b, self.x, self.x); - f.relation(b, &[(1, xx, self.x), (-1, self.y, self.y)], &[], 4); + let coefficient = if cfg!(feature = "bn254") { 3 } else { 4 }; + f.relation(b, &[(1, xx, self.x), (-1, self.y, self.y)], &[], coefficient); } pub fn double(self, b: &mut Builder, f: &FqGadget) -> Self { let slope = f.hint_pure(b, "doubling slope", &[self.x, self.y], |v| { @@ -141,6 +144,7 @@ impl Affine { f.equal(b, self.x, rhs.x); f.equal(b, self.y, rhs.y); } + #[cfg(not(feature = "bn254"))] pub fn subgroup(self, b: &mut Builder, f: &FqGadget) { // Same endomorphism criterion as ark-bls12-381's native checker. let xp = self.constant_mul(b, f, 0xd201000000010000); @@ -156,16 +160,21 @@ impl Affine { f.relation(b, &[(1, beta, self.x)], &[(-1, x2p.x)], 0); f.relation(b, &[], &[(1, self.y), (1, x2p.y)], 0); } + + /// BN254 G1 has cofactor one, so `on_curve` already proves membership. + #[cfg(feature = "bn254")] + pub fn subgroup(self, _b: &mut Builder, _f: &FqGadget) {} } fn equal_flag(b: &mut Builder, f: &FqGadget, lhs: FqVar, rhs: FqVar) -> Bool { let deps: Vec<_> = lhs.0.into_iter().chain(rhs.0).collect(); let equal = b.hint("Fq equality", &deps, |v| { - Ok(Scalar::from_bool(fq_value(&v[..5]) == fq_value(&v[5..]))) + Ok(Scalar::from_bool(fq_value(&v[..L]) == fq_value(&v[L..]))) }); let flag = b.assert_bool(equal); let zero = b.constant(Scalar::ZERO); - let flag_fq = FqVar([equal, zero, zero, zero, zero]); + let flag_fq = + FqVar(std::array::from_fn(|i| if i == 0 { equal } else { zero })); let inverse = f.hint_pure(b, "Fq equality inverse", &[lhs, rhs], |v| { Ok((v[0] - v[1]).inverse().unwrap_or(Fq::from(0u8))) }); @@ -223,6 +232,7 @@ impl PointInput { #[cfg(test)] fn check_operation(operation: &str) -> Result<(), Box> { + use crate::kzg::curve::Fr; use ark_ec::CurveGroup; let mut b = Builder::new(); let f = FqGadget::new(&mut b); @@ -233,25 +243,15 @@ fn check_operation(operation: &str) -> Result<(), Box> { "native-subgroup" => {}, "native-double" => { let out = a.double(&mut b, &f); - let expected = Affine::constant( - &mut b, - &f, - (g * ark_bls12_381::Fr::from(2u8)).into_affine(), - ); + let expected = + Affine::constant(&mut b, &f, (g * Fr::from(2u8)).into_affine()); out.equal(&mut b, &f, expected); }, "native-add" => { - let rhs = Affine::constant( - &mut b, - &f, - (g * ark_bls12_381::Fr::from(7u8)).into_affine(), - ); + let rhs = Affine::constant(&mut b, &f, (g * Fr::from(7u8)).into_affine()); let out = a.add(&mut b, &f, rhs); - let expected = Affine::constant( - &mut b, - &f, - (g * ark_bls12_381::Fr::from(8u8)).into_affine(), - ); + let expected = + Affine::constant(&mut b, &f, (g * Fr::from(8u8)).into_affine()); out.equal(&mut b, &f, expected); }, _ => return Err("unknown native operation".into()), @@ -268,9 +268,10 @@ fn check_operation(operation: &str) -> Result<(), Box> { #[cfg(test)] mod tests { use super::*; + use crate::kzg::curve::Fr; use ark_ec::CurveGroup; #[test] - fn subgroup_infinity_and_reject_wrong_subgroup() { + fn subgroup_infinity_and_reject_invalid_points() { let mut b = Builder::new(); let f = FqGadget::new(&mut b); let p = PointInput::new(&mut b, &f, "p"); @@ -283,31 +284,78 @@ mod tests { w.generate() }; for n in [0u64, 1, 2, 123456789, u64::MAX] { - run((G1Affine::generator() * ark_bls12_381::Fr::from(n)).into_affine()) - .unwrap(); + let point = (G1Affine::generator() * Fr::from(n)).into_affine(); + run(point).unwrap(); + run(-point).unwrap(); } - let torsion = G1Affine::new_unchecked(Fq::from(0u8), Fq::from(2u8)); - assert!(torsion.is_on_curve()); - assert!(!torsion.is_in_correct_subgroup_assuming_on_curve()); - assert!(run(torsion).is_err()); - let mut checked = 0; - for x in 1..100u64 { - if let Some(point) = - G1Affine::get_point_from_x_unchecked(Fq::from(x), false) - { - assert!(!point.is_in_correct_subgroup_assuming_on_curve()); - assert!(run(point).is_err()); - checked += 1; - if checked == 8 { - break; + #[cfg(not(feature = "bn254"))] + { + let torsion = G1Affine::new_unchecked(Fq::from(0u8), Fq::from(2u8)); + assert!(torsion.is_on_curve()); + assert!(!torsion.is_in_correct_subgroup_assuming_on_curve()); + assert!(run(torsion).is_err()); + let mut checked = 0; + for x in 1..100u64 { + if let Some(point) = + G1Affine::get_point_from_x_unchecked(Fq::from(x), false) + { + assert!(!point.is_in_correct_subgroup_assuming_on_curve()); + assert!(run(point).is_err()); + checked += 1; + if checked == 8 { + break; + } } } + assert_eq!(checked, 8); } - assert_eq!(checked, 8); assert!( run(G1Affine::new_unchecked(Fq::from(7u8), Fq::from(9u8))).is_err() ); } + + #[test] + fn rejects_coordinate_aliases_and_invalid_infinity() { + use crate::recursive::native_field::{W, modulus, scalar}; + use ark_ff::{BigInteger, PrimeField}; + use num_bigint::BigUint; + + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let p = PointInput::new(&mut b, &f, "p"); + let c = b.finish(); + let generator = G1Affine::generator(); + let radix_mask = (BigUint::from(1u8) << W) - 1u8; + for coordinate in [p.point.x, p.point.y] { + let value = if coordinate.0[0] == p.point.x.0[0] { + generator.x + } else { + generator.y + }; + let alias = + modulus() + BigUint::from_bytes_le(&value.into_bigint().to_bytes_le()); + let mut w = c.witness(); + for (handle, mut value) in p.inputs(generator) { + if let Some(i) = coordinate.0.iter().position(|&v| v == handle) { + value = scalar(&((&alias >> (W * i)) & &radix_mask)); + } + w.set(handle, value).unwrap(); + } + assert!(w.generate().is_err()); + } + for (point, flag) in [ + (generator, Scalar::ONE), + (generator, Scalar::TWO), + (G1Affine::identity(), Scalar::ZERO), + ] { + let mut w = c.witness(); + for (handle, value) in p.inputs(point) { + w.set(handle, if handle == p.infinity.value() { flag } else { value }) + .unwrap(); + } + assert!(w.generate().is_err()); + } + } #[test] fn affine_operations() { for operation in ["native-add", "native-double"] { @@ -327,7 +375,7 @@ mod tests { let result = result.canonical(&mut b, &f); let c = b.finish(); let p = G1Affine::generator(); - for q in [p, -p, (p * ark_bls12_381::Fr::from(7u8)).into_affine()] { + for q in [p, -p, (p * Fr::from(7u8)).into_affine()] { let mut w = c.witness(); for (v, n) in left.inputs(p).into_iter().chain(right.inputs(q)) { w.set(v, n).unwrap(); diff --git a/crates/proof-compression/src/recursive/native_field.rs b/crates/proof-compression/src/recursive/native_field.rs index 27f6d18fa..e6e4ca979 100644 --- a/crates/proof-compression/src/recursive/native_field.rs +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -1,6 +1,5 @@ //! Bounded Fq arithmetic in a native Fr Plonkish circuit. -use crate::kzg::Scalar; -use ark_bls12_381::Fq; +use crate::kzg::{Scalar, curve::Fq}; use ark_ff::{BigInteger, PrimeField}; use multi_stark::{ plonkish::{CircuitBuilder, Table, Value}, @@ -10,9 +9,17 @@ use num_bigint::{BigInt, BigUint, Sign}; use std::collections::BTreeMap; pub type Builder = CircuitBuilder; -const W: usize = 80; -const L: usize = 5; +pub(super) const W: usize = 80; +const FIELD_BITS: usize = Fq::MODULUS_BIT_SIZE as usize; +pub(super) const L: usize = FIELD_BITS.div_ceil(W); +const TOP_LIMB_BITS: usize = FIELD_BITS - W * (L - 1); +const QUOTIENT_BITS: usize = FIELD_BITS + 3; +const QUOTIENT_BIAS_BITS: usize = QUOTIENT_BITS - 1; +const QUOTIENT_TOP_BITS: usize = QUOTIENT_BITS - W * (L - 1); const CARRY_BITS: usize = 89; +const CARRY_BIAS_BITS: usize = CARRY_BITS - 1; +const RELATION_ADVICE_LEN: usize = 2 * L - 1; +const _: () = assert!(TOP_LIMB_BITS <= W && QUOTIENT_TOP_BITS <= W); mod advice; @@ -152,17 +159,19 @@ impl FqGadget { } pub fn bound(&self, b: &mut Builder, v: FqVar) { for (i, &limb) in v.0.iter().enumerate() { - self.range(b, limb, if i == L - 1 { 61 } else { W }); + self.range(b, limb, if i == L - 1 { TOP_LIMB_BITS } else { W }); } } pub fn canonical(&self, b: &mut Builder, v: FqVar) { let p = digits(&(modulus() - 1u8)); let p_hint = p.clone(); - let advice = - b.hint_many_pure::<10>("Fq canonical subtraction", &v.0, move |values| { + let advice = b.hint_many_pure::<{ 2 * L }>( + "Fq canonical subtraction", + &v.0, + move |values| { let mut borrow = BigInt::from(0u8); let radix = BigInt::from(1u8) << W; - let mut out = [Scalar::ZERO; 10]; + let mut out = [Scalar::ZERO; 2 * L]; for i in 0..L { let mut d = BigInt::from(p_hint[i].clone()) - BigInt::from(integer(values[i])) @@ -175,7 +184,8 @@ impl FqGadget { out[L + i] = signed(&borrow); } Ok(out) - }); + }, + ); let radix = scalar(&(BigUint::from(1u8) << W)); let mut borrow = b.constant(Scalar::ZERO); for i in 0..L { @@ -196,10 +206,14 @@ impl FqGadget { b.assert_zero(borrow); } /// Sum of bounded quadratic and linear terms is zero modulo Fq. - /// Each side has at most three unit-weight products. Limbs are <2^80, - /// field representatives <2^381, quotient+2^383 <2^384. Four signed - /// carries are biased by 2^88 and range-checked to 89 bits. Pairing two - /// radix-2^80 coefficients keeps every integer equality below 2^251 < Fr. + /// Each sign has total product and linear weight at most three. For + /// n-bit representatives, |quotient| < 2^(n+2) for either supported Fq; + /// biasing by 2^(n+2) therefore requires n+3 bits. Carries are biased by + /// 2^88 and range-checked to 89 bits. With B=2^80, each polynomial + /// coefficient has magnitude below A=4*L*B^2+3*B+4. Pairing coefficients + /// bounds an integer equality by (B+1)*A+(B^2+1)*2^88 < Fr, so a zero + /// field equation is also an integer equality. The integer-bound test + /// checks these inequalities against the selected curve's exact moduli. pub fn relation( &self, b: &mut Builder, @@ -236,21 +250,24 @@ impl FqGadget { let p = &advice::constants().modulus_digits; #[cfg(test)] let reference_advice = self.reference_advice; - let advice = - b.hint_many_pure::<9>("Fq quotient and carries", &deps, move |values| { + let advice = b.hint_many_pure::( + "Fq quotient and carries", + &deps, + move |values| { #[cfg(test)] if reference_advice { return advice::relation_reference(values, &pc, &lc, constant, p); } advice::relation(values, &pc, &lc, constant) - }); + }, + ); for (i, &v) in advice[..L].iter().enumerate() { - self.range(b, v, if i == L - 1 { 64 } else { W }); + self.range(b, v, if i == L - 1 { QUOTIENT_TOP_BITS } else { W }); } for &v in &advice[L..] { self.range(b, v, CARRY_BITS); } - let mut terms: Vec> = vec![vec![]; 10]; + let mut terms: Vec> = vec![vec![]; 2 * L]; let mut cached = BTreeMap::new(); for &(c, a, d) in products { for i in 0..L { @@ -269,8 +286,8 @@ impl FqGadget { terms.push((signed(&BigInt::from(c)), v)); } } - let bias = digits(&(BigUint::from(1u8) << 383)); - let mut constants = vec![BigInt::from(0u8); 10]; + let bias = digits(&(BigUint::from(1u8) << QUOTIENT_BIAS_BITS)); + let mut constants = vec![BigInt::from(0u8); 2 * L]; constants[0] += constant; for i in 0..L { for j in 0..L { @@ -280,8 +297,8 @@ impl FqGadget { } let radix = scalar(&(BigUint::from(1u8) << W)); let block = radix * radix; - let carry_bias = scalar(&(BigUint::from(1u8) << 88)); - for j in 0..5 { + let carry_bias = scalar(&(BigUint::from(1u8) << CARRY_BIAS_BITS)); + for j in 0..L { let mut equation = terms[2 * j].clone(); equation.extend(terms[2 * j + 1].iter().map(|&(c, v)| (c * radix, v))); let mut constant = @@ -290,7 +307,7 @@ impl FqGadget { equation.push((Scalar::ONE, advice[L + j - 1])); constant -= carry_bias; } - if j < 4 { + if j < L - 1 { equation.push((-block, advice[L + j])); constant += block * carry_bias; } @@ -379,7 +396,9 @@ mod tests { let f = FqGadget::new(&mut b); let x = f.input(&mut b, "x"); let c = b.finish(); - for n in [modulus(), modulus() + 1u8, (BigUint::from(1u8) << 381) - 1u8] { + for n in + [modulus(), modulus() + 1u8, (BigUint::from(1u8) << FIELD_BITS) - 1u8] + { let mut w = c.witness(); for (v, n) in x.0.into_iter().zip(digits(&n)) { w.set(v, scalar(&n)).unwrap(); @@ -392,7 +411,10 @@ mod tests { w.set( v, if i == j { - scalar(&(BigUint::from(1u8) << if i == 4 { 61 } else { 80 })) + scalar( + &(BigUint::from(1u8) + << if i == L - 1 { TOP_LIMB_BITS } else { W }), + ) } else { Scalar::ZERO }, @@ -421,18 +443,60 @@ mod tests { check_mutations(&c, &w.generate().unwrap()); } + #[test] + fn maximal_representatives_support_both_quotient_signs() { + let max = (BigUint::from(1u8) << FIELD_BITS) - 1u8; + let total = 3u8 * &max * &max + 3u8 * &max + 4u8; + let residue = + Fq::from_le_bytes_mod_order(&(&total % modulus()).to_bytes_le()); + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let x = FqVar(std::array::from_fn(|i| b.input(format!("x{i}")))); + f.bound(&mut b, x); + let residue = f.constant(&mut b, residue); + f.relation(&mut b, &[(3, x, x)], &[(3, x), (-1, residue)], 4); + f.relation(&mut b, &[(-3, x, x)], &[(-3, x), (1, residue)], -4); + let c = b.finish(); + let mut w = c.witness(); + for (v, n) in x.0.into_iter().zip(digits(&max)) { + w.set(v, scalar(&n)).unwrap(); + } + check_mutations(&c, &w.generate().unwrap()); + } + + #[test] + fn range_checks_reject_boundary_overflow_and_negative_integers() { + for bits in [TOP_LIMB_BITS, QUOTIENT_TOP_BITS, W, CARRY_BITS] { + let mut b = Builder::new(); + let f = FqGadget::new(&mut b); + let x = b.input("x"); + f.range(&mut b, x, bits); + let c = b.finish(); + let limit = BigUint::from(1u8) << bits; + for (value, accepted) in [ + (scalar(&(&limit - 1u8)), true), + (scalar(&limit), false), + (Scalar::NEG_ONE, false), + ] { + let mut w = c.witness(); + w.set(x, value).unwrap(); + assert_eq!(w.generate().is_ok(), accepted, "range width {bits}"); + } + } + } + #[test] fn integer_bounds_do_not_wrap_fr() { - use ark_bls12_381::Fr; + use crate::kzg::curve::Fr; let one = BigUint::from(1u8); - let radix = &one << 80; - let max = (&one << 381) - 1u8; + let radix = &one << W; + let max = (&one << FIELD_BITS) - 1u8; let max_expression = 3u8 * &max * &max + 3u8 * &max + 4u8; - assert!(max_expression / modulus() < (&one << 383)); - // Up to 15 products on either side of a coefficient, plus five - // quotient*modulus terms, three linear limbs, and a constant. - let coefficient_bound = 20u8 * &radix * &radix + 3u8 * &radix + 4u8; - let carry_bound = &one << 88; + assert!(max_expression / modulus() < (&one << QUOTIENT_BIAS_BITS)); + // Each coefficient has at most 3*L product terms of either sign, + // L modulus times unbiased-quotient terms, and three linear terms. + let coefficient_bound = (4 * L) * &radix * &radix + 3u8 * &radix + 4u8; + let carry_bound = &one << CARRY_BIAS_BITS; let equation_bound: BigUint = (&radix + 1u8) * &coefficient_bound + (&radix * &radix + 1u8) * &carry_bound; let fr = BigUint::from_bytes_le(&Fr::MODULUS.to_bytes_le()); diff --git a/crates/proof-compression/src/recursive/native_field/advice.rs b/crates/proof-compression/src/recursive/native_field/advice.rs index 4fe58aeb8..3ad0c474a 100644 --- a/crates/proof-compression/src/recursive/native_field/advice.rs +++ b/crates/proof-compression/src/recursive/native_field/advice.rs @@ -1,7 +1,9 @@ use std::sync::OnceLock; use super::{ - BigInt, BigUint, CARRY_BITS, L, Scalar, W, digits, integer, modulus, scalar, + BigInt, BigUint, CARRY_BIAS_BITS, CARRY_BITS, L, QUOTIENT_BIAS_BITS, + QUOTIENT_BITS, RELATION_ADVICE_LEN, Scalar, W, digits, integer, modulus, + scalar, }; use multi_stark::traits::{Algebra, Field}; @@ -19,14 +21,14 @@ pub(super) fn constants() -> &'static Constants { CONSTANTS.get_or_init(|| { let prime = modulus(); let modulus_digits = digits(&prime); - let quotient_bias = BigUint::from(1u8) << 383; + let quotient_bias = BigUint::from(1u8) << QUOTIENT_BIAS_BITS; Constants { modulus_signed_digits: modulus_digits.clone().map(BigInt::from), modulus_digits, prime: BigInt::from(prime), bias_digits: digits("ient_bias).map(BigInt::from), quotient_bias: BigInt::from(quotient_bias), - carry_bias: BigInt::from(1u8) << 88usize, + carry_bias: BigInt::from(1u8) << CARRY_BIAS_BITS, } }) } @@ -43,11 +45,11 @@ pub(super) fn relation( products: &[i8], linear: &[i8], constant: i8, -) -> Result<[Scalar; 9], String> { +) -> Result<[Scalar; RELATION_ADVICE_LEN], String> { let constants = constants(); let values: Vec<_> = values.iter().map(|&v| BigInt::from(integer(v))).collect(); - let mut terms: [BigInt; 10] = std::array::from_fn(|_| BigInt::from(0u8)); + let mut terms: [BigInt; 2 * L] = std::array::from_fn(|_| BigInt::from(0u8)); let mut at = 0; for &coefficient in products { let scaled: [BigInt; L] = @@ -74,7 +76,7 @@ pub(super) fn relation( } let quotient = quotient + &constants.quotient_bias; let quotient = quotient.to_biguint().ok_or("negative biased quotient")?; - if quotient.bits() > 384 { + if quotient.bits() > QUOTIENT_BITS as u64 { return Err("Fq quotient overflow".into()); } let quotient = digits("ient); @@ -86,15 +88,15 @@ pub(super) fn relation( terms[i + j] -= &constants.modulus_signed_digits[i] * &unbiased[j]; } } - let mut out = [Scalar::ZERO; 9]; + let mut out = [Scalar::ZERO; RELATION_ADVICE_LEN]; for i in 0..L { out[i] = scalar("ient[i]); } let mut carry = BigInt::from(0u8); - for j in 0..5 { + for j in 0..L { carry += &terms[2 * j] + (&terms[2 * j + 1] << W); divide_carry(&mut carry)?; - if j < 4 { + if j < L - 1 { let biased = &carry + &constants.carry_bias; let value = biased.to_biguint().ok_or("negative biased carry")?; if value.bits() > CARRY_BITS as u64 { diff --git a/crates/proof-compression/src/recursive/native_field/advice/tests.rs b/crates/proof-compression/src/recursive/native_field/advice/tests.rs index 18d3ea6c7..5ae8a7e84 100644 --- a/crates/proof-compression/src/recursive/native_field/advice/tests.rs +++ b/crates/proof-compression/src/recursive/native_field/advice/tests.rs @@ -1,6 +1,8 @@ use super::*; -use crate::recursive::native_field::{Builder, FqGadget}; -use ark_bls12_381::Fq; +use crate::{ + kzg::curve::Fq, + recursive::native_field::{Builder, FqGadget}, +}; use ark_ff::PrimeField; fn integer_reference(value: Scalar) -> BigUint { @@ -26,8 +28,8 @@ pub(in crate::recursive::native_field) fn relation_reference( lc: &[i8], constant: i8, p_hint: &[BigUint; L], -) -> Result<[Scalar; 9], String> { - let mut t = vec![BigInt::from(0u8); 10]; +) -> Result<[Scalar; RELATION_ADVICE_LEN], String> { + let mut t = vec![BigInt::from(0u8); 2 * L]; let mut at = 0; for &c in pc { for i in 0..L { @@ -51,34 +53,34 @@ pub(in crate::recursive::native_field) fn relation_reference( if &total % &prime != BigInt::from(0u8) { return Err("nonzero Fq relation".into()); } - let q: BigInt = total / prime + (BigInt::from(1u8) << 383usize); + let q: BigInt = total / prime + (BigInt::from(1u8) << QUOTIENT_BIAS_BITS); let q = q.to_biguint().ok_or("negative biased quotient")?; - if q.bits() > 384 { + if q.bits() > QUOTIENT_BITS as u64 { return Err("Fq quotient overflow".into()); } let q = digits(&q); - let bias = digits(&(BigUint::from(1u8) << 383)); + let bias = digits(&(BigUint::from(1u8) << QUOTIENT_BIAS_BITS)); for i in 0..L { for j in 0..L { t[i + j] -= BigInt::from(p_hint[i].clone()) * (BigInt::from(q[j].clone()) - BigInt::from(bias[j].clone())); } } - let mut out = [Scalar::ZERO; 9]; + let mut out = [Scalar::ZERO; RELATION_ADVICE_LEN]; for i in 0..L { out[i] = scalar(&q[i]); } let radix = BigInt::from(1u8) << W; let block_radix = &radix * &radix; let mut carry = BigInt::from(0u8); - for j in 0..5 { + for j in 0..L { carry += &t[2 * j] + &radix * &t[2 * j + 1]; if &carry % &block_radix != BigInt::from(0u8) { return Err("nonintegral Fq carry".into()); } carry /= &block_radix; - if j < 4 { - let biased: BigInt = &carry + (BigInt::from(1u8) << 88usize); + if j < L - 1 { + let biased: BigInt = &carry + (BigInt::from(1u8) << CARRY_BIAS_BITS); let v = biased.to_biguint().ok_or("negative biased carry")?; if v.bits() > CARRY_BITS as u64 { return Err("Fq carry overflow".into()); @@ -214,6 +216,35 @@ fn zero_and_quotient_limit_errors_match_reference() { } } +#[test] +fn maximal_representatives_have_exact_signed_integer_quotients() { + let field_bits = Fq::MODULUS_BIT_SIZE as usize; + let max = (BigUint::from(1u8) << field_bits) - 1u8; + let total = 3u8 * &max * &max + 3u8 * &max + 4u8; + let residue = &total % modulus(); + let quotient = &total / modulus(); + let mut values = Vec::new(); + for n in [&max, &max, &max, &residue] { + values.extend(digits(n).map(|limb| scalar(&limb))); + } + for sign in [-1i8, 1] { + let advice = + relation(&values, &[3 * sign], &[3 * sign, -sign], 4 * sign).unwrap(); + let encoded_quotient = advice[..L] + .iter() + .rev() + .fold(BigUint::from(0u8), |acc, &limb| (acc << W) + integer(limb)); + let expected = (BigInt::from(1u8) << QUOTIENT_BIAS_BITS) + + sign * BigInt::from(quotient.clone()); + assert_eq!(BigInt::from(encoded_quotient), expected); + let top = integer(advice[L - 1]); + assert!(top.bits() <= (QUOTIENT_BITS - W * (L - 1)) as u64); + for &carry in &advice[L..] { + assert!(integer(carry).bits() <= CARRY_BITS as u64); + } + } +} + #[test] fn quotient_carries_and_rejections_match_reference() { let modulus_digits = constants().modulus_digits.clone(); diff --git a/crates/proof-compression/src/recursive/native_msm.rs b/crates/proof-compression/src/recursive/native_msm.rs index f376f66b2..034762003 100644 --- a/crates/proof-compression/src/recursive/native_msm.rs +++ b/crates/proof-compression/src/recursive/native_msm.rs @@ -1,10 +1,12 @@ -use crate::kzg::Scalar; +use crate::kzg::{ + Scalar, + curve::{Fr, G1Affine, G1Projective}, +}; use crate::recursive::{ native_curve::{Affine, PointInput}, - native_field::{Builder, FqGadget, FqVar, fq_value}, + native_field::{Builder, FqGadget, FqVar, L, fq_value}, native_transcript::scalar_bytes, }; -use ark_bls12_381::{Fr, G1Affine, G1Projective}; use ark_ec::{AdditiveGroup, AffineRepr, CurveGroup}; use ark_ff::{BigInteger, Field, PrimeField, Zero}; use multi_stark::{ @@ -197,38 +199,43 @@ fn witness_offset( .chain([point.infinity.value(), scalar]) }) .collect(); - let limbs = b.hint_many::<10>("nonexceptional MSM offset", &deps, move |v| { - let mut points = Vec::with_capacity(v.len() / 12); - let mut scalars = Vec::with_capacity(v.len() / 12); - for values in v.as_chunks::<12>().0 { - let infinity = values[10] == Scalar::ONE; - if values[10] != Scalar::ZERO && !infinity { - return Err("invalid point infinity flag".into()); - } - let point = if infinity { - G1Affine::generator() - } else { - G1Affine::new_unchecked( - fq_value(&values[..5]), - fq_value(&values[5..10]), - ) - }; - if !point.is_on_curve() - || !point.is_in_correct_subgroup_assuming_on_curve() - { - return Err("invalid MSM point".into()); + let limbs = + b.hint_many::<{ 2 * L }>("nonexceptional MSM offset", &deps, move |v| { + let mut points = Vec::with_capacity(v.len() / (2 * L + 2)); + let mut scalars = Vec::with_capacity(v.len() / (2 * L + 2)); + for values in v.as_chunks::<{ 2 * L + 2 }>().0 { + let infinity = values[2 * L] == Scalar::ONE; + if values[2 * L] != Scalar::ZERO && !infinity { + return Err("invalid point infinity flag".into()); + } + let point = if infinity { + G1Affine::generator() + } else { + G1Affine::new_unchecked( + fq_value(&values[..L]), + fq_value(&values[L..2 * L]), + ) + }; + if !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err("invalid MSM point".into()); + } + points.push(point); + scalars.push(if infinity { + Fr::from(0u8) + } else { + values[2 * L + 1].0 + }); } - points.push(point); - scalars.push(if infinity { Fr::from(0u8) } else { values[11].0 }); - } - let (offset, _) = choose_offset(&points, &scalars, first)?; - let x = FqGadget::witness_values(offset.x); - let y = FqGadget::witness_values(offset.y); - Ok(std::array::from_fn(|i| if i < 5 { x[i] } else { y[i - 5] })) - }); + let (offset, _) = choose_offset(&points, &scalars, first)?; + let x = FqGadget::witness_values(offset.x); + let y = FqGadget::witness_values(offset.y); + Ok(std::array::from_fn(|i| if i < L { x[i] } else { y[i - L] })) + }); let h = Affine { - x: FqVar(limbs[..5].try_into().unwrap()), - y: FqVar(limbs[5..].try_into().unwrap()), + x: FqVar(limbs[..L].try_into().unwrap()), + y: FqVar(limbs[L..].try_into().unwrap()), }; for coordinate in [h.x, h.y] { f.bound(b, coordinate); @@ -348,7 +355,7 @@ mod tests { let encoded = crate::recursive::native_transcript::point_bytes(&mut b, &bytes, result); let c = b.finish(); - for (ns, ss) in [ + let mut cases: Vec<_> = [ ([2, 3, 7], [11, 0, 17]), ([0, 9, 11], [17, 91, 0]), ([2, 3, 7], [0, 0, 0]), @@ -357,12 +364,24 @@ mod tests { ([2, -2, 7], [1, 1, 0]), ([2, 2, -2], [1, 1, 2]), ([-2, 2, -7], [-1, 2, 3]), - ] { + ] + .into_iter() + .map(|(ns, ss)| (ns.map(fr), ss.map(fr))) + .collect(); + cases.push(( + [fr(2), fr(3), fr(7)], + [ + Fr::from(2u8).pow([u64::from(Fr::MODULUS_BIT_SIZE - 1)]), + -Fr::ONE, + Fr::ONE, + ], + )); + for (ns, ss) in cases { let mut w = c.witness(); let mut expected = G1Projective::default(); for i in 0..3 { - let p = (G1Affine::generator() * fr(ns[i])).into_affine(); - let s = fr(ss[i]); + let p = (G1Affine::generator() * ns[i]).into_affine(); + let s = ss[i]; expected += p * s; for (v, n) in points[i].inputs(p) { w.set(v, n).unwrap(); diff --git a/crates/proof-compression/src/recursive/native_transcript.rs b/crates/proof-compression/src/recursive/native_transcript.rs index 774b2496c..00876168d 100644 --- a/crates/proof-compression/src/recursive/native_transcript.rs +++ b/crates/proof-compression/src/recursive/native_transcript.rs @@ -1,9 +1,11 @@ -use crate::kzg::Scalar; +use crate::kzg::{ + Scalar, + curve::{Fr, G1_COMPRESSED_BYTES}, +}; use crate::recursive::{ native_curve::PointInput, - native_field::{Builder, FqVar, integer, modulus}, + native_field::{Builder, FqVar, W, integer, modulus}, }; -use ark_bls12_381::Fr; use ark_ff::{BigInteger, PrimeField}; use multi_stark::{ plonkish::{ @@ -70,10 +72,20 @@ pub fn scalar_bytes( .collect(); Ok(std::array::from_fn(|i| Scalar::from_u8(data[i]))) }); - let result = raw.map(|v| bytes.constrain_byte(b, v)); + constrain_scalar_bytes(b, bytes, v, &raw) +} + +fn constrain_scalar_bytes( + b: &mut Builder, + bytes: &ByteGadgets, + v: Value, + raw: &[Value; 32], +) -> [ByteValue; 32] { + let result = std::array::from_fn(|i| bytes.constrain_byte(b, raw[i])); let mut coeff = Scalar::ONE; let terms: Vec<_> = raw - .into_iter() + .iter() + .copied() .map(|v| { let term = (coeff, v); coeff *= Scalar::from_u16(256); @@ -94,12 +106,12 @@ pub fn fq_bytes( b: &mut Builder, bytes: &ByteGadgets, v: FqVar, -) -> [ByteValue; 48] { +) -> [ByteValue; G1_COMPRESSED_BYTES] { let mut result = Vec::new(); for (i, &limb) in v.0.iter().enumerate() { - let raw = b.hint_many_pure::<10>("Fq limb bytes", &[limb], |v| { + let raw = b.hint_many_pure::<{ W / 8 }>("Fq limb bytes", &[limb], |v| { let mut data = integer(v[0]).to_bytes_le(); - data.resize(10, 0); + data.resize(W / 8, 0); Ok(std::array::from_fn(|i| Scalar::from_u8(data[i]))) }); let values = raw.map(|v| bytes.constrain_byte(b, v)); @@ -114,13 +126,11 @@ pub fn fq_bytes( .collect(); let packed = b.linear_combination(&terms, Scalar::ZERO); b.assert_equal(packed, limb); - if i == 4 { - b.assert_zero(raw[8]); - b.assert_zero(raw[9]); - result.extend_from_slice(&values[..8]); - } else { - result.extend(values); + let used = (G1_COMPRESSED_BYTES - i * (W / 8)).min(W / 8); + for &unused in &raw[used..] { + b.assert_zero(unused); } + result.extend_from_slice(&values[..used]); } result.try_into().unwrap() } @@ -130,22 +140,34 @@ pub fn point_bytes( b: &mut Builder, bytes: &ByteGadgets, p: PointInput, -) -> [ByteValue; 48] { +) -> [ByteValue; G1_COMPRESSED_BYTES] { let x = fq_bytes(b, bytes, p.point.x); let y = fq_bytes(b, bytes, p.point.y); let mut half = ((modulus() - 1u8) / 2u8).to_bytes_le(); - half.resize(48, 0); + half.resize(G1_COMPRESSED_BYTES, 0); let sign = greater_than(b, bytes, &y, &half); + #[cfg(not(feature = "bn254"))] + let (sign_mask, compressed_mask) = (32, 128); + #[cfg(feature = "bn254")] + let (sign_mask, compressed_mask) = (128, 0); let flagged = b.linear_combination( &[ - (Scalar::ONE, x[47].value()), + (Scalar::ONE, x[G1_COMPRESSED_BYTES - 1].value()), (Scalar::from_u8(64), p.infinity.value()), - (Scalar::from_u8(32), sign.value()), + (Scalar::from_u8(sign_mask), sign.value()), ], - Scalar::from_u8(128), + Scalar::from_u8(compressed_mask), ); - let first = bytes.constrain_byte(b, flagged); - std::array::from_fn(|i| if i == 0 { first } else { x[47 - i] }) + let flagged = bytes.constrain_byte(b, flagged); + #[cfg(not(feature = "bn254"))] + let result = std::array::from_fn(|i| { + if i == 0 { flagged } else { x[G1_COMPRESSED_BYTES - 1 - i] } + }); + #[cfg(feature = "bn254")] + let result = std::array::from_fn(|i| { + if i == G1_COMPRESSED_BYTES - 1 { flagged } else { x[i] } + }); + result } pub struct Transcript { @@ -196,8 +218,8 @@ impl Transcript { mod tests { use super::*; use crate::kzg::Blake3Transcript; + use crate::kzg::curve::G1Affine; use crate::recursive::{native_curve::PointInput, native_field::FqGadget}; - use ark_bls12_381::G1Affine; use ark_ec::{AffineRepr, CurveGroup}; use ark_serialize::CanonicalSerialize; use multi_stark::traits::Transcript as _; @@ -215,9 +237,19 @@ mod tests { let first = t.sample(&mut b, &bytes); let second = t.sample(&mut b, &bytes); let c = b.finish(); - for n in [0u64, 1, 2, 17] { - let pval = (G1Affine::generator() * Fr::from(n)).into_affine(); - let scalar = Scalar(-Fr::from(n + 1)); + let g = G1Affine::generator(); + for (n, pval) in [ + G1Affine::identity(), + g, + -g, + (g * Fr::from(2u8)).into_affine(), + (g * Fr::from(17u8)).into_affine(), + (g * Fr::from(u64::MAX)).into_affine(), + ] + .into_iter() + .enumerate() + { + let scalar = Scalar(-Fr::from(n as u64 + 1)); let mut w = c.witness(); for (v, n) in p.inputs(pval) { w.set(v, n).unwrap(); @@ -226,6 +258,7 @@ mod tests { let a = w.generate().unwrap(); let mut expected = vec![]; pval.serialize_compressed(&mut expected).unwrap(); + assert_eq!(expected.len(), encoded.len()); for (v, &n) in encoded.iter().zip(&expected) { assert_eq!(a.value(v.value()).unwrap(), Scalar::from_u8(n)); } @@ -237,4 +270,66 @@ mod tests { assert_eq!(a.value(second).unwrap(), t.sample_challenge()); } } + + #[test] + fn scalar_encoding_rejects_modulus_aliases() { + let mut b = Builder::new(); + let bytes = ByteGadgets::new(&mut b); + let v = b.input("scalar"); + let encoded = scalar_bytes(&mut b, &bytes, v); + let supplied = std::array::from_fn(|i| b.input(format!("byte{i}"))); + constrain_scalar_bytes(&mut b, &bytes, v, &supplied); + let c = b.finish(); + let modulus = BigUint::from_bytes_le(&Fr::MODULUS.to_bytes_le()); + for integer in [ + BigUint::from(0u8), + BigUint::from(1u8), + BigUint::from(1u8) << (Fr::MODULUS_BIT_SIZE - 1), + &modulus - 1u8, + modulus.clone(), + &modulus + 1u8, + (BigUint::from(1u8) << 256) - 1u8, + ] { + let mut data = integer.to_bytes_le(); + data.resize(32, 0); + let mut w = c.witness(); + w.set(v, Scalar(Fr::from_le_bytes_mod_order(&data))).unwrap(); + for (handle, &byte) in supplied.iter().zip(&data) { + w.set(*handle, Scalar::from_u8(byte)).unwrap(); + } + if integer < modulus { + let a = w.generate().unwrap(); + for (handle, &byte) in encoded.iter().zip(&data) { + assert_eq!(a.value(handle.value()).unwrap(), Scalar::from_u8(byte)); + } + } else { + assert!(w.generate().is_err()); + } + } + } + + #[test] + fn coordinate_bytes_reject_truncation() { + use crate::recursive::native_field::{L, scalar}; + + let mut b = Builder::new(); + let bytes = ByteGadgets::new(&mut b); + let coordinate = + FqVar(std::array::from_fn(|i| b.input(format!("limb{i}")))); + fq_bytes(&mut b, &bytes, coordinate); + let c = b.finish(); + for changed in 0..L { + let mut w = c.witness(); + let bits = (G1_COMPRESSED_BYTES * 8 - W * changed).min(W); + for (i, handle) in coordinate.0.into_iter().enumerate() { + let value = if i == changed { + scalar(&(BigUint::from(1u8) << bits)) + } else { + Scalar::ZERO + }; + w.set(handle, value).unwrap(); + } + assert!(w.generate().is_err()); + } + } } diff --git a/crates/proof-compression/src/recursive/native_verifier.rs b/crates/proof-compression/src/recursive/native_verifier.rs index 6b539959b..fa1de4e90 100644 --- a/crates/proof-compression/src/recursive/native_verifier.rs +++ b/crates/proof-compression/src/recursive/native_verifier.rs @@ -1,14 +1,17 @@ //! Fixed-profile ordinary KZG verification with publicly bound pairing inputs. use crate::{ - kzg::{KzgCommitment, KzgConfig, Scalar}, + kzg::{ + KzgCommitment, KzgConfig, Scalar, + curve::{Fq, G1_COMPRESSED_BYTES, G1Affine, G2Affine, PairingCurve}, + }, recursive::{ native_curve::{Affine, PointInput}, native_field::{Builder, FqGadget}, native_msm::{self, Base}, native_transcript::{self, Transcript}, + pairing, }, }; -use ark_bls12_381::{Bls12_381, G1Affine, G2Affine}; #[cfg(test)] use ark_ec::CurveGroup; use ark_ec::pairing::Pairing; @@ -73,6 +76,14 @@ pub struct Counts { pub stats: CircuitStats, pub table_dimensions: Vec<(usize, usize)>, pub msm_terms: Vec, + pub phases: Vec, +} + +/// Cumulative frontend counts at a verifier construction boundary. +#[derive(Clone, Copy, Debug)] +pub struct PhaseCounts { + pub name: &'static str, + pub stats: CircuitStats, } struct Construction { @@ -80,6 +91,7 @@ struct Construction { pairing_outputs: Vec, pairing_keys: Vec, terms: Vec>, + phases: Vec, } impl Construction { @@ -106,6 +118,7 @@ impl Construction { stats: self.context.b.stats(), table_dimensions: self.context.b.table_dimensions().collect(), msm_terms: self.terms.iter().map(Vec::len).collect(), + phases: self.phases.clone(), } } } @@ -118,7 +131,7 @@ struct Context { bases: Vec, points: Vec, point_ends: Vec, - encodings: Vec<[ByteValue; 48]>, + encodings: Vec<[ByteValue; G1_COMPRESSED_BYTES]>, scalar_encodings: BTreeMap, } impl Context { @@ -157,7 +170,7 @@ impl Context { x: self.f.constant(&mut self.b, value.x), y: self.f.constant( &mut self.b, - if value.infinity { ark_bls12_381::Fq::from(0u8) } else { value.y }, + if value.infinity { Fq::from(0u8) } else { value.y }, ), }; let flag = self.b.constant(Scalar::from_bool(value.infinity)); @@ -283,7 +296,8 @@ fn combine(b: &mut Builder, terms: Vec<(usize, Value)>) -> Vec<(usize, Value)> { unique.into_iter().collect() } -#[cfg(test)] +/// Compile the fixed verifier relation and bind one proof's witness inputs. +/// Call [`Built::assign`] to check its constraints and deferred pairing. pub fn build( system: &System, srs: &crate::kzg::Srs, @@ -351,6 +365,8 @@ fn construct( let prefix_blocks = c.point_ends.len(); let prefix_values = c.b.stats().values; let parallel_prefix = c.seal_point_prefix(); + let mut phases = + vec![PhaseCounts { name: "curve_inputs", stats: c.b.stats() }]; eprintln!("Curve inputs complete: {:?}", c.b.stats()); eprintln!( "Point witness prefix: admitted={parallel_prefix}, blocks={prefix_blocks}, values={prefix_values}" @@ -495,6 +511,7 @@ fn construct( t.observe(&c.encodings[id]); } let r = c.sample(&mut t); + phases.push(PhaseCounts { name: "transcript", stats: c.b.stats() }); eprintln!("Transcript complete: {:?}", c.b.stats()); let mut lhs = vec![]; let mut rhs = vec![]; @@ -636,6 +653,7 @@ fn construct( terms.push(combine(&mut c.b, rhs)); keys.push(profile.tau_g2); let mut outputs = vec![]; + phases.push(PhaseCounts { name: "scalar_checks", stats: c.b.stats() }); for (i, terms) in terms.iter().enumerate() { eprintln!("MSM {i}/{}: {} bases", keys.len(), terms.len()); let point = native_msm::msm( @@ -648,7 +666,7 @@ fn construct( ); let point = point.canonical(&mut c.b, &c.f); let encoded = native_transcript::point_bytes(&mut c.b, &c.bytes, point); - for chunk in encoded.as_chunks::<24>().0 { + for chunk in encoded.as_chunks::<{ pairing::POINT_LIMB_BYTES }>().0 { let mut coefficient = Scalar::ONE; let terms: Vec<_> = chunk .iter() @@ -662,6 +680,10 @@ fn construct( c.b.expose_public(packed); } outputs.push(point); + phases.push(PhaseCounts { + name: ["opening_msm_0", "opening_msm_1"][i], + stats: c.b.stats(), + }); eprintln!("MSM {i} complete: {:?}", c.b.stats()); } Ok(Construction { @@ -669,6 +691,7 @@ fn construct( pairing_outputs: outputs, pairing_keys: keys, terms, + phases, }) } @@ -767,15 +790,9 @@ mod tests { .all(|v| assignment.value(v).unwrap() == Scalar::ZERO) ); } - assert_eq!( - assignment.public_values(), - [ - Scalar::from_u8(0xc0), - Scalar::ZERO, - Scalar::from_u8(0xc0), - Scalar::ZERO - ] - ); + let expected = pairing::encode([G1Affine::identity(); 2]); + assert_eq!(assignment.public_values(), expected); + assert_eq!(pairing::decode(&expected).unwrap(), [G1Affine::identity(); 2]); } fn wrapper_fixture(public_degree: bool) { @@ -837,6 +854,12 @@ mod tests { encoded.len() ); assert_eq!(counted.stats, built.circuit.stats()); + assert_eq!(counted.phases.last().unwrap().stats, counted.stats); + assert!(counted.phases.windows(2).all(|pair| { + pair[0].stats.gates <= pair[1].stats.gates + && pair[0].stats.lookups <= pair[1].stats.lookups + && pair[0].stats.values <= pair[1].stats.values + })); assert_eq!( counted.table_dimensions, built @@ -852,7 +875,8 @@ mod tests { ); built.check().unwrap(); let key = built.pairing_keys[1]; - built.pairing_keys[1] = (key * ark_bls12_381::Fr::from(2u8)).into_affine(); + built.pairing_keys[1] = + (key * crate::kzg::curve::Fr::from(2u8)).into_affine(); assert!(built.assign().is_err()); built.pairing_keys[1] = key; let public = built.circuit.public_values()[0]; diff --git a/crates/proof-compression/src/recursive/native_verifier/bindings.rs b/crates/proof-compression/src/recursive/native_verifier/bindings.rs index e48124a0e..2cd37be79 100644 --- a/crates/proof-compression/src/recursive/native_verifier/bindings.rs +++ b/crates/proof-compression/src/recursive/native_verifier/bindings.rs @@ -180,9 +180,20 @@ pub(super) fn check_pairings( pairing_keys: &[G2Affine], ) -> Result<(), Box> { let outputs = pairing_points(assignment, pairing_outputs)?; + let public_values = assignment.public_values(); + let public_start = public_values + .len() + .checked_sub(pairing::PUBLIC_VALUES) + .ok_or("missing public pairing operands")?; + let public_outputs = pairing::decode(&public_values[public_start..])?; + if outputs != public_outputs { + return Err( + "public pairing operands differ from the constrained points".into(), + ); + } if outputs.len() != 2 || pairing_keys.len() != 2 - || !Bls12_381::multi_pairing(outputs, pairing_keys.iter().copied()) + || !PairingCurve::multi_pairing(outputs, pairing_keys.iter().copied()) .is_zero() { return Err("external opening pairing check failed".into()); @@ -203,7 +214,7 @@ pub(super) fn check_msm_outputs( let expected = terms .iter() .map(|&(id, scalar)| points[id] * assignment.value(scalar).unwrap().0) - .sum::() + .sum::() .into_affine(); if *point != expected { return Err("MSM differs from native computation".into()); diff --git a/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs index 9b67df083..5fbf15875 100644 --- a/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs +++ b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs @@ -1,5 +1,6 @@ use super::*; -use ark_bls12_381::Fr; +use crate::kzg::curve::Fr; +use crate::recursive::native_field::L; use ark_ec::AffineRepr; use ark_ff::PrimeField; use multi_stark::plonkish::{Assignment, WitnessError}; @@ -186,7 +187,7 @@ fn point_prefix_matches_serial_inputs_values_and_encodings() { assert!( parallel.circuit.num_values() > *parallel.prefix_ends.last().unwrap() ); - assert_eq!(parallel.circuit.stats().inputs, 11 * points.len()); + assert_eq!(parallel.circuit.stats().inputs, (2 * L + 1) * points.len()); } #[test] @@ -194,14 +195,15 @@ fn point_prefix_preserves_earliest_point_error() { let points = points(4); let serial = PointFixture::new(&points, false); let parallel = PointFixture::new(&points, true); + let stride = 2 * L + 1; for (missing, corrupt) in [ (Some(0), None), - (Some(21), None), - (Some(43), None), - (None, Some(11)), - (Some(0), Some(11)), - (Some(33), Some(11)), - (Some(33), Some(22)), + (Some(2 * stride - 1), None), + (Some(4 * stride - 1), None), + (None, Some(stride)), + (Some(0), Some(stride)), + (Some(3 * stride), Some(stride)), + (Some(3 * stride), Some(2 * stride)), ] { let expected = serial.generate_changed(missing, corrupt).err().unwrap(); let actual = parallel.generate_changed(missing, corrupt).err().unwrap(); diff --git a/crates/proof-compression/src/recursive/native_verifier/plan.rs b/crates/proof-compression/src/recursive/native_verifier/plan.rs index 250bd2af8..1bdf4faea 100644 --- a/crates/proof-compression/src/recursive/native_verifier/plan.rs +++ b/crates/proof-compression/src/recursive/native_verifier/plan.rs @@ -1,7 +1,11 @@ use super::*; +use ark_ec::AffineRepr; use shape::Shape; +#[cfg(not(feature = "bn254"))] const FRONTEND_VERSION: &[u8] = b"init-kzg-recursive/frontend/v1"; +#[cfg(feature = "bn254")] +const FRONTEND_VERSION: &[u8] = b"init-kzg-recursive/bn254/frontend/v1"; #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub(crate) enum ClaimSlot { @@ -103,17 +107,22 @@ impl LayoutPolicy { pub(super) fn legacy() -> Self { Self { namespace: Scalar::from_u8(94), - max_computation_height: 1 << 29, - max_table_height: 1 << 29, + max_computation_height: 1 << 29.min(Scalar::TWO_ADICITY - 1), + max_table_height: 1 << 29.min(Scalar::TWO_ADICITY - 1), } } fn validate(self) -> Result<(), String> { - if [self.max_computation_height, self.max_table_height] - .iter() - .any(|&height| height < 2 || !height.is_power_of_two()) - { - return Err("layout caps must be nontrivial powers of two".into()); + if [self.max_computation_height, self.max_table_height].iter().any( + |&height| { + height < 2 + || !height.is_power_of_two() + || height.ilog2() as usize > Scalar::TWO_ADICITY + }, + ) { + return Err( + "layout caps must be powers of two within scalar two-adicity".into(), + ); } Ok(()) } @@ -188,6 +197,12 @@ impl<'a> Plan<'a> { } layout.validate()?; let shape = Shape::new(profile, logs)?; + if profile.generator.is_zero() + || profile.g2.is_zero() + || profile.tau_g2.is_zero() + { + return Err("recursive verifier anchors must be nonidentity".into()); + } for point in profile .fixed .0 diff --git a/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs index 498e7c785..c0046aadf 100644 --- a/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs +++ b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::kzg::Srs; -use ark_bls12_381::Fq; +use ark_ec::AffineRepr; use multi_stark::{ expr::Expr, lookup::Lookup, @@ -198,6 +198,115 @@ fn recursive_frontend_reuses_two_statements_and_returns_first() { ); } +#[test] +fn recursive_witness_rejects_tampering_without_native_preverification() { + let fixture = Fixture::new(); + let (proof, claims) = fixture.proof(17); + let plan = + Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); + let Compiled { circuit, bindings } = plan.compile().unwrap(); + let counts = plan.count().unwrap(); + assert_eq!(counts.stats, circuit.stats()); + assert_eq!(counts.phases.last().unwrap().stats, counts.stats); + let valid = bindings + .check(plan.identity(), &proof, &claims, circuit.witness()) + .unwrap(); + assert_eq!(valid.public_values()[0], claims[0][3]); + let pairings = pairing::decode(&valid.public_values()[1..]).unwrap(); + assert!(!pairings[1].is_zero()); + let mut wrong_keys = bindings.pairing_keys.clone(); + wrong_keys[1] = + (wrong_keys[1] * crate::kzg::curve::Fr::from(2u8)).into_affine(); + assert!( + bindings::check_pairings(&valid, &bindings.pairing_outputs, &wrong_keys) + .is_err() + ); + + for mutation in ["claim", "evaluation", "commitment", "opening"] { + let mut bad = proof.clone(); + let mut bad_claims = claims.clone(); + match mutation { + "claim" => bad_claims[0][3] += Scalar::ONE, + "evaluation" => bad.stage_1_opened_values[0][0][0] += Scalar::ONE, + "commitment" => { + let point = &mut bad.commitments.stage_1_trace.0[0][0]; + *point = (*point + G1Affine::generator()).into_affine(); + }, + "opening" => { + let point = &mut bad.opening_proof.0[0]; + *point = (*point + G1Affine::generator()).into_affine(); + }, + _ => unreachable!(), + } + assert!( + bindings + .check(plan.identity(), &bad, &bad_claims, circuit.witness()) + .is_err(), + "accepted {mutation}" + ); + } + + let input_point = bindings + .points + .iter() + .find(|point| !matches!(point.source, PointSource::Fixed(_))) + .unwrap() + .point; + for (changed, replacement) in [ + (input_point.infinity.value(), Scalar::TWO), + (input_point.point.x.0[0], Scalar::NEG_ONE), + ] { + let inputs = + bindings.request_inputs(plan.identity(), &proof, &claims).unwrap(); + let mut witness = circuit.witness(); + for (wire, value) in inputs.inputs { + witness + .set(wire, if wire == changed { replacement } else { value }) + .unwrap(); + } + assert!(witness.generate().is_err()); + } + + for &changed in &circuit.public_values()[1..] { + assert!(circuit.gates().iter().any(|gate| { + let [a, b, c] = gate.wires.map(|wire| { + valid.value(wire).unwrap() + + if wire == changed { Scalar::ONE } else { Scalar::ZERO } + }); + let [qm, qa, qb, qc, k] = gate.coefficients; + qm * a * b + qa * a + qb * b + qc * c + k != Scalar::ZERO + })); + } +} + +#[test] +fn recursive_profile_rejects_identity_anchors_and_oversized_layout_caps() { + let fixture = Fixture::new(); + let (_, claims) = fixture.proof(17); + let plan = + Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); + for anchor in 0..3 { + let mut profile = plan.profile; + match anchor { + 0 => profile.generator = G1Affine::identity(), + 1 => profile.g2 = G2Affine::identity(), + _ => profile.tau_g2 = G2Affine::identity(), + } + assert!( + Plan::from_profile(&profile, &[2], schema(&claims), layout()).is_err() + ); + } + let oversized = 1usize << (Scalar::TWO_ADICITY + 1); + for bad_layout in [ + LayoutPolicy { max_computation_height: oversized, ..layout() }, + LayoutPolicy { max_table_height: oversized, ..layout() }, + ] { + assert!( + Plan::new(&fixture.system, &[2], schema(&claims), bad_layout).is_err() + ); + } +} + #[test] fn recursive_schema_rejects_malformed_and_constant_claims() { let claims = vec![vec![Scalar::from_u8(9), Scalar::from_u8(17)]]; @@ -350,7 +459,7 @@ fn recursive_profile_identity_binds_metadata_and_schema() { } let point = fixture.system.preprocessed_commit.as_ref().unwrap().0[0][0]; fixture.system.preprocessed_commit.as_mut().unwrap().0[0][0] = - (point * ark_bls12_381::Fr::from(2u8)).into_affine(); + (point * crate::kzg::curve::Fr::from(2u8)).into_affine(); assert_ne!(baseline, id(&fixture.system, schema(&claims), layout())); fixture.system.preprocessed_commit.as_mut().unwrap().0[0][0] = point; fixture.system.circuits[0].graph.nodes.push(Node::Const(Scalar::from_u8(43))); diff --git a/crates/proof-compression/src/recursive/native_verifier/shape.rs b/crates/proof-compression/src/recursive/native_verifier/shape.rs index 1d6911946..64195d884 100644 --- a/crates/proof-compression/src/recursive/native_verifier/shape.rs +++ b/crates/proof-compression/src/recursive/native_verifier/shape.rs @@ -193,7 +193,7 @@ impl Shape { } } points - .checked_mul(48) + .checked_mul(G1_COMPRESSED_BYTES) .and_then(|bytes| { fields.checked_mul(32).and_then(|fields| bytes.checked_add(fields)) }) diff --git a/crates/proof-compression/src/recursive/pairing.rs b/crates/proof-compression/src/recursive/pairing.rs new file mode 100644 index 000000000..f22d6c065 --- /dev/null +++ b/crates/proof-compression/src/recursive/pairing.rs @@ -0,0 +1,118 @@ +//! Canonical public encoding of the two deferred KZG pairing operands. +//! +//! Each compressed point occupies two little-endian scalar limbs: 24 bytes +//! per limb for BLS12-381 and 16 for BN254. Both fit injectively in Fr. + +use crate::kzg::{ + Scalar, + curve::{Fr, G1_COMPRESSED_BYTES, G1Affine, read_g1_compressed}, +}; +use ark_ff::PrimeField; +use ark_serialize::{CanonicalSerialize, SerializationError}; + +pub const POINT_LIMB_BYTES: usize = G1_COMPRESSED_BYTES / 2; +pub const PUBLIC_VALUES: usize = 4; +const _: () = assert!( + POINT_LIMB_BYTES.is_multiple_of(8) + && G1_COMPRESSED_BYTES == 2 * POINT_LIMB_BYTES + && POINT_LIMB_BYTES * 8 < Fr::MODULUS_BIT_SIZE as usize +); + +pub fn encode(points: [G1Affine; 2]) -> [Scalar; PUBLIC_VALUES] { + let mut bytes = [0; 2 * G1_COMPRESSED_BYTES]; + let mut output = bytes.as_mut_slice(); + for point in points { + point.serialize_compressed(&mut output).expect("fixed point width"); + } + std::array::from_fn(|i| { + Scalar(Fr::from_le_bytes_mod_order( + &bytes[i * POINT_LIMB_BYTES..(i + 1) * POINT_LIMB_BYTES], + )) + }) +} + +pub fn decode(values: &[Scalar]) -> Result<[G1Affine; 2], SerializationError> { + if values.len() != PUBLIC_VALUES { + return Err(SerializationError::InvalidData); + } + let mut bytes = [0; 2 * G1_COMPRESSED_BYTES]; + for (value, chunk) in + values.iter().zip(bytes.as_chunks_mut::().0) + { + let limbs = value.canonical_limbs_le(); + let scalar_bytes: [u8; 32] = + std::array::from_fn(|i| limbs[i / 8].to_le_bytes()[i % 8]); + if scalar_bytes[POINT_LIMB_BYTES..].iter().any(|&byte| byte != 0) { + return Err(SerializationError::InvalidData); + } + chunk.copy_from_slice(&scalar_bytes[..POINT_LIMB_BYTES]); + } + let mut input = bytes.as_slice(); + Ok([read_g1_compressed(&mut input)?, read_g1_compressed(&mut input)?]) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ec::{AffineRepr, CurveGroup}; + use multi_stark::traits::Algebra; + + #[test] + fn public_pairing_encoding_preserves_both_points_and_flags() { + let generator = G1Affine::generator(); + let multiple = (generator * Fr::from(7u8)).into_affine(); + for points in [ + [generator, multiple], + [-generator, -multiple], + [G1Affine::identity(), generator], + [G1Affine::identity(); 2], + ] { + assert_eq!(decode(&encode(points)).unwrap(), points); + } + let positive = encode([generator; 2]); + let negative = encode([-generator; 2]); + let identity = encode([G1Affine::identity(); 2]); + assert_ne!(positive, negative); + assert_ne!(positive, identity); + #[cfg(feature = "bn254")] + { + assert_eq!(positive[0], Scalar::ONE); + assert_eq!(positive[1], Scalar::ZERO); + assert_eq!(identity[0], Scalar::ZERO); + assert_eq!(identity[1], Scalar::from_limbs_le([0, 1 << 62, 0, 0])); + assert_eq!(negative[1], Scalar::from_limbs_le([0, 1 << 63, 0, 0])); + } + #[cfg(not(feature = "bn254"))] + assert_eq!( + identity, + [ + Scalar(Fr::from(0xc0u8)), + Scalar::ZERO, + Scalar(Fr::from(0xc0u8)), + Scalar::ZERO + ] + ); + } + + #[test] + fn public_pairing_encoding_rejects_wrong_lengths_and_limb_aliases() { + let values = encode([G1Affine::generator(); 2]); + assert!(decode(&values[..3]).is_err()); + assert!(decode(&[Scalar::ZERO; 5]).is_err()); + let mut overflow = [0; 4]; + overflow[POINT_LIMB_BYTES / 8] = 1; + for i in 0..4 { + let mut bad = values; + bad[i] += Scalar::from_limbs_le(overflow); + assert!(decode(&bad).is_err()); + } + } + + #[cfg(feature = "bn254")] + #[test] + fn bn254_public_pairing_encoding_rejects_noncanonical_infinity() { + let mut values = encode([G1Affine::identity(); 2]); + values[0] = Scalar::ONE; + assert!(decode(&values).is_err()); + } +} diff --git a/crates/proof-compression/tests/bn254_kzg.rs b/crates/proof-compression/tests/bn254_kzg.rs new file mode 100644 index 000000000..1178cd1b0 --- /dev/null +++ b/crates/proof-compression/tests/bn254_kzg.rs @@ -0,0 +1,223 @@ +#![cfg(feature = "bn254")] + +use std::sync::Arc; + +use ark_bn254::Fr; +use ark_ec::{AffineRepr, CurveGroup}; +use ark_ff::{BigInteger, PrimeField}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use ix_proof_compression::kzg::compact::FixedProofCodec; +use ix_proof_compression::kzg::curve::{G1Affine, G2Affine}; +use ix_proof_compression::kzg::encoding::{read_scalars, write_scalars}; +use ix_proof_compression::kzg::{ + KzgConfig, Scalar, Srs, TranscriptKind, VerifierParams, +}; +use multi_stark::config::ProofConfig; +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::system::{CircuitInputs, System, SystemWitness}; +use multi_stark::traits::{Algebra, Field, TwoAdicField}; +use p3_matrix::dense::RowMajorMatrix; + +const HEIGHTS: [usize; 2] = [4, 8]; +const QUOTIENT_DEGREE: usize = 4; +const SEED: &[u8] = b"bn254-kzg-integration"; + +fn claims() -> [[Scalar; 3]; 2] { + HEIGHTS.map(|height| { + let last = Scalar::from_usize(height - 1); + [Scalar::from_usize(height), last, last * last] + }) +} + +#[test] +fn blake3_proof_roundtrips_and_rejects_tampering_without_proving_powers() { + let srs = Srs::unsafe_dev_setup(8, SEED); + srs.validate().unwrap(); + let config = KzgConfig::new(Arc::new(srs), QUOTIENT_DEGREE) + .with_transcript(TranscriptKind::Blake3); + let (mut system, key) = System::new( + config, + HEIGHTS.map(|height| { + let x = Expr::main(0); + let square = Expr::main(1); + CircuitInputs { + main_width: 2, + preprocessed: Some(RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + [Scalar::from_usize(row), Scalar::from_bool(row + 1 == height)] + }) + .collect(), + 2, + )), + constraints: vec![ + x.clone() - Expr::preprocessed(0), + square.clone() - x.clone() * x.clone(), + Expr::IsTransition + * (Expr::main_next(0) - x.clone() - Expr::constant(Scalar::ONE)), + ], + lookups: vec![Lookup::pull( + Expr::preprocessed(1), + vec![Expr::constant(Scalar::from_usize(height)), x, square], + )], + ..Default::default() + } + }), + ); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let traces = HEIGHTS + .map(|height| { + RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + let x = Scalar::from_usize(row); + [x, x * x] + }) + .collect(), + 2, + ) + }) + .to_vec(); + let proof = system.prove_multiple_claims( + &key, + &refs, + SystemWitness::from_stage_1(traces, &system), + ); + system.verify_multiple_claims(&refs, &proof).unwrap(); + assert_eq!(proof.log_degrees, [2, 3]); + assert_eq!(proof.opening_proof.0.len(), 3); + + let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); + let encoded = codec.encode(&proof).unwrap(); + assert_eq!(&encoded[..4], b"KBN1"); + let mut wrong_curve = encoded.clone(); + wrong_curve[..4].copy_from_slice(b"KQP4"); + assert!(codec.decode(&wrong_curve).is_err()); + let decoded = codec.decode(&encoded).unwrap(); + assert_eq!(codec.encode(&decoded).unwrap(), encoded); + + let anchors = Srs::unsafe_dev_setup_prefix(2, 7, SEED); + let params = VerifierParams::with_max_trace_len(&anchors, 8); + let parameters = params.to_bytes().unwrap(); + assert_eq!( + parameters, + system.config.pcs().verifier_params().to_bytes().unwrap() + ); + let params = VerifierParams::from_bytes(¶meters).unwrap(); + assert_eq!(params.public_setup(), anchors.public_setup); + assert_eq!(params.max_trace_len(), 8); + let verifier = KzgConfig::verifier_only(params, QUOTIENT_DEGREE) + .with_transcript(TranscriptKind::Blake3); + assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); + system.config = verifier.clone(); + drop(key); + system.verify_multiple_claims(&refs, &proof).unwrap(); + system.verify_multiple_claims(&refs, &decoded).unwrap(); + + let mut changed_claims = claims; + changed_claims[0][2] += Scalar::ONE; + let changed_refs: Vec<_> = + changed_claims.iter().map(|claim| claim.as_slice()).collect(); + assert!(system.verify_multiple_claims(&changed_refs, &decoded).is_err()); + + for attack in ["evaluation", "commitment", "opening", "accumulator"] { + let mut bad = proof.clone(); + match attack { + "evaluation" => bad.stage_1_opened_values[0][0][0] += Scalar::ONE, + "commitment" => { + let point = &mut bad.commitments.stage_1_trace.0[0][0]; + *point = (*point + G1Affine::generator()).into_affine(); + }, + "opening" => { + let point = &mut bad.opening_proof.0[0]; + *point = (*point + G1Affine::generator()).into_affine(); + }, + "accumulator" => bad.intermediate_accumulators[0] += Scalar::ONE, + _ => unreachable!(), + } + let bad = codec.decode(&codec.encode(&bad).unwrap()).unwrap(); + assert!( + system.verify_multiple_claims(&refs, &bad).is_err(), + "accepted changed {attack}" + ); + } + + for end in [0, 4, 5, encoded.len() - 1] { + assert!(codec.decode(&encoded[..end]).is_err()); + } + let mut bad = encoded.clone(); + bad.push(0); + assert!(codec.decode(&bad).is_err()); + let mut bad = encoded.clone(); + bad[5..37].fill(0xff); + assert!(codec.decode(&bad).is_err()); + let mut with_identity = proof.clone(); + with_identity.commitments.stage_1_trace.0[0][0] = G1Affine::identity(); + let mut aliased_identity = codec.encode(&with_identity).unwrap(); + codec.decode(&aliased_identity).unwrap(); + aliased_identity[5] = 1; + assert!(codec.decode(&aliased_identity).is_err()); + let mut bad = encoded; + let scalar_start = bad.len() - 32; + bad[scalar_start..].copy_from_slice(&Fr::MODULUS.to_bytes_le()); + assert!(codec.decode(&bad).is_err()); + + system.config = verifier.with_transcript(TranscriptKind::Keccak256); + assert!(system.verify_multiple_claims(&refs, &proof).is_err()); +} + +#[test] +fn bn254_scalars_and_points_use_canonical_encodings() { + let _: Fr = Scalar::ONE.0; + assert_eq!(Scalar::TWO_ADICITY, 28); + let values = [Scalar::ZERO, Scalar::ONE, Scalar::NEG_ONE]; + let mut encoded = Vec::new(); + write_scalars(&mut encoded, &values).unwrap(); + assert_eq!(encoded.len(), 3 * 32); + assert_eq!(read_scalars(&mut encoded.as_slice(), 3).unwrap(), values); + let mut native = Vec::new(); + for value in values { + value.0.serialize_compressed(&mut native).unwrap(); + } + assert_eq!(encoded, native); + let modulus = Fr::MODULUS.to_bytes_le(); + assert!(read_scalars(&mut modulus.as_slice(), 1).is_err()); + assert!(read_scalars(&mut &encoded[..31], 1).is_err()); + + for point in [G1Affine::generator(), G1Affine::identity()] { + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), 32); + assert_eq!( + G1Affine::deserialize_compressed(bytes.as_slice()).unwrap(), + point + ); + } + let point = G2Affine::generator(); + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), 64); + assert_eq!( + G2Affine::deserialize_compressed(bytes.as_slice()).unwrap(), + point + ); + assert!(G1Affine::deserialize_compressed([0xff; 32].as_slice()).is_err()); +} + +#[test] +fn portable_parameters_reject_inconsistent_anchors_and_extra_bytes() { + let mut srs = Srs::unsafe_dev_setup(8, SEED); + let parameters = VerifierParams::from_srs(&srs).to_bytes().unwrap(); + let mut trailing = parameters.clone(); + trailing.push(0); + assert!(VerifierParams::from_bytes(&trailing).is_err()); + assert!( + VerifierParams::from_bytes(¶meters[..parameters.len() - 1]).is_err() + ); + + srs.g1[1] = (srs.g1[1] + G1Affine::generator()).into_affine(); + let inconsistent = VerifierParams::from_srs(&srs).to_bytes().unwrap(); + assert!(VerifierParams::from_bytes(&inconsistent).is_err()); +} diff --git a/crates/proof-compression/tests/kzg_transcripts.rs b/crates/proof-compression/tests/kzg_transcripts.rs index 1c3cb6287..6d94749e8 100644 --- a/crates/proof-compression/tests/kzg_transcripts.rs +++ b/crates/proof-compression/tests/kzg_transcripts.rs @@ -181,6 +181,7 @@ fn default_transcript_preserves_explicit_blake3_proof_bytes() { } #[test] +#[cfg(not(feature = "bn254"))] fn filecoin_keccak_proof_verifies_with_portable_authenticated_parameters() { use ix_proof_compression::kzg::VerifierParams; use ix_proof_compression::kzg::srs::filecoin::{ diff --git a/crates/proof-compression/tests/worker_tracing.rs b/crates/proof-compression/tests/worker_tracing.rs index 431b47eb2..6a326c3b8 100644 --- a/crates/proof-compression/tests/worker_tracing.rs +++ b/crates/proof-compression/tests/worker_tracing.rs @@ -6,6 +6,10 @@ fn worker(texray: bool) -> Command { .env("IX_TEXRAY", if texray { "1" } else { "0" }) .env_remove("IX_KZG_FILECOIN_CACHE") .env_remove("IX_KZG_FILECOIN_MANIFEST") + .env_remove("IX_KZG_IGNITION_CACHE") + .env_remove("IX_KZG_IGNITION_CACHE_BLAKE3") + .env_remove("IX_KZG_IGNITION_ADMISSION") + .env_remove("IX_KZG_IGNITION_ADMISSION_BLAKE3") .env_remove("IX_KZG_PREFETCH_GIB"); command } @@ -22,6 +26,9 @@ fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { let capabilities: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(capabilities["binary"], "ix-compression-worker"); + assert_eq!(capabilities["curve"], ix_proof_compression::kzg::curve::NAME); + assert_eq!(capabilities["development_setup_only"], false); + assert_eq!(capabilities["ignition_setup"], cfg!(feature = "bn254")); assert_eq!( String::from_utf8_lossy(&output.stderr) .contains("[texray] compression/fri:"), @@ -53,3 +60,54 @@ fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { } fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn worker_pipeline_requires_an_explicit_profile_digest_before_io() { + let output = worker(false) + .args([ + "pipeline", + "verify", + "missing-proof", + "missing-claim", + "missing-claims", + "missing-profile", + "not-a-digest", + "--dev-srs", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("64 hexadecimal characters") + ); +} + +#[cfg(feature = "bn254")] +#[test] +fn ignition_verification_requires_both_pins_and_refuses_development_mode() { + let pin = "00".repeat(32); + let args = [ + "pipeline", + "verify-ignition", + "missing-proof", + "missing-claim", + "missing-claims", + "missing-profile", + pin.as_str(), + "missing-admission", + ]; + let output = worker(false).args(args).arg("not-a-digest").output().unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("64 hexadecimal characters") + ); + let output = + worker(false).args(args).arg(&pin).arg("--dev-srs").output().unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("does not accept --dev-srs") + ); +} diff --git a/docs/proof-benchmark-comparison.md b/docs/proof-benchmark-comparison.md new file mode 100644 index 000000000..89183112e --- /dev/null +++ b/docs/proof-benchmark-comparison.md @@ -0,0 +1,105 @@ +# Init proof benchmark comparison + +Measured on October 16–17, 2026, using 48 logical CPUs of an Intel Xeon +Platinum 8559C, 499 GiB of host RAM, local NVMe storage, and one NVIDIA RTX +PRO 6000 Blackwell Server GPU for GPU runs. + +Proving timings assume reusable preprocessing and proving keys are already +available. One-time key preparation is shown separately. Full prover processes +still load and authenticate those artifacts; core proving starts after the +circuit and keys are loaded. Times are rounded to the nearest second and +written as minutes and seconds. + +Proof size uses the largest available proof encoding: the ix envelope for +KZG and the Solidity payload for PLONK. Transaction calldata includes +additional arguments and is accounted for in the gas measurements. + +The GPU KZG pipeline starts with the saved Init FRI proof and produces the +final recursive KZG proof. It uses authenticated Filecoin parameters for +BLS12-381 and Ignition parameters for BN254. Initial aggregation and FRI +compression are outside these timings. + +| GPU KZG measurement | BLS12-381 | BN254 | +| ----------------------- | --------------: | --------------: | +| First KZG stage | 2m27s | 2m16s | +| Recursive KZG stage | 1m32s | 0m45s | +| **Total proving time** | **3m59s** | **3m0s** | +| Peak host RAM | 175.79 GiB | 175.29 GiB | +| Sampled peak GPU memory | 64.70 GiB | 57.27 GiB | +| **Proof size** | **2,189 bytes** | **1,917 bytes** | + +Total proving time is the sum of the two worker processes. BN254's complete +driver, including orchestration overhead, took 3m1s. Both stages reuse fixed +preprocessing and start fresh worker processes; OS page-cache state is +uncontrolled. These are one run per curve; both proofs passed independent +CPU verification. BN254 took approximately **25% less proving time**. + +The PLONK wrapper runs after KZG compression. Its timings are additional to +the KZG pipeline above. + +| PLONK measurement | BLS12-381 CPU | BN254 CPU | BN254 GPU | +| ------------------------ | --------------: | ------------: | ------------: | +| Constraints | 21,184,618 | 14,689,440 | 14,689,440 | +| Domain | 2²⁵ | 2²⁴ | 2²⁴ | +| One-time key preparation | 20m17s | 5m58s | 5m57s | +| **Core proving time** | **2m7s** | **0m54s** | **0m11s** | +| **Full prover process** | **4m54s** | **1m14s** | **0m32s** | +| Peak host RAM | 53.63 GiB | 25.19 GiB | 20.64 GiB | +| Sampled peak GPU memory | — | — | 19.26 GiB | +| **Proof size** | **1,184 bytes** | **864 bytes** | **864 bytes** | + +The matched BN254 core timings are medians of three runs with the same loaded +circuit, keys and witness. GPU acceleration gives a **4.75× speedup** over +CPU. BLS core proving and each complete process timing come from +one run. Full process time includes startup, artifact authentication/loading, +witness assembly, proving, verification and output. + +Key preparation covers SRS import, Lagrange conversion and generation of the +circuit's proving and verifying keys. Parameter downloads and circuit +compilation are excluded. This work is reusable across proofs and is not +included in either proving-time row. CPU and GPU use the same Lagrange basis +and keys. The current cache binds artifacts to a helper executable, so the +GPU helper required a separate preparation run despite that equivalence. + +**TODO:** Allow validated Lagrange-basis reuse across compatible CPU/GPU +helper builds without requiring the same executable hash. Preserve SRS and +domain binding, content hashes, format compatibility and basis validation; +keep circuit and proving-key compatibility checks separate. + +GPU process time and host RAM come from a separate production CLI run, using +the same circuit and byte-identical proving and verifying keys as the CPU +baseline. Its proof passed independent CPU verification, including rejection +of an altered claim and proof. Key preparation is excluded. GPU device +memory uses the earlier repeated benchmark; polling in the standalone run +missed the short peak. Its native allocation pool recorded an 18.59 GiB +reservation peak, excluding driver overhead. Device samples may miss peaks. + +Verification costs use **1.1718 gwei**, the arithmetic mean of Etherscan's +daily averages for **September 15–October 14, 2026**, the latest 30 days +available through [YCharts](https://ycharts.com/indicators/ethereum_average_gas_price) +when this comparison was prepared. The USD conversion uses +**$2,439.96 per ETH**, the [October 16 price](https://ycharts.com/indicators/ethereum_price). +That is approximately **$2.859 per million gas**. + +| Proof | Verification transaction gas | Estimated cost | +| ---------------------------------- | ---------------------------: | -------------: | +| BLS12-381 KZG, Keccak v5 reference | **825,229** | **$2.36** | +| BLS12-381 CPU PLONK | **404,637** | **$1.16** | +| BN254 CPU PLONK | **320,103** | **$0.92** | +| BN254 GPU PLONK | **320,091** | **$0.92** | + +These are complete verification transactions, including calldata and the +PLONK claim-digest adapter, but excluding contract deployment. Measurements +use local Anvil with Osaka execution rules and solc 0.8.37, optimizer 200, +via-IR and Prague bytecode. The 12-gas difference between BN254 CPU and GPU +proofs comes from calldata bytes; the verifier execution costs are identical. + +The KZG row verifies the aggregate BLS12-381 Keccak v5 proof. The PLONK rows +verify wrappers around the current BLAKE3 KZG proofs; the KZG reference uses +a different transcript. + +The dollar estimate is `transaction gas × average gwei × 10⁻⁹ × ETH/USD`. +It uses the historical gas average rather than a live gas quote. + +Implementation context is in the +[KZG compression README](../crates/proof-compression/README.md). diff --git a/flake.nix b/flake.nix index 0ba7c0a4a..5ece5f2d0 100644 --- a/flake.nix +++ b/flake.nix @@ -110,22 +110,82 @@ file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" || file.name == "go.mod" || file.name == "go.sum" ) ./tools/bls-plonk-wrapper; - plonkHelper = (cudaPkgs.buildGoModule.override { go = plonkGo; }) { - pname = "ix-bls-plonk-wrapper"; + plonkCudaSources = pkgs.lib.fileset.fileFilter ( + file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" + || file.hasExt "py" || file.hasExt "patch" + || file.name == "go.mod" || file.name == "go.sum" + ) ./tools/bls-plonk-wrapper; + plonkSppark = pkgs.fetchFromGitHub { + owner = "argumentcomputer"; + repo = "sppark"; + rev = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8"; + hash = "sha256-/KXq6RuAUpRT0xJsXCSZsT1iONhbeHnXWcmTJL4ltQs="; + }; + plonkBlst = pkgs.fetchzip { + url = "https://static.crates.io/crates/blst/blst-0.3.16.crate"; + extension = "tar.gz"; + hash = "sha256-OHEKeKwl7RhPOiqILiRTZACmRLlmIaRuCE2qwKTfiIc="; + }; + plonkCudaNative = pkgs.stdenv.mkDerivation { + pname = "ix-plonk-bn254-cuda"; + version = "0.1.0"; + src = pkgs.lib.fileset.toSource { + root = ./crates/proof-compression/cuda; + fileset = pkgs.lib.fileset.fileFilter ( + file: builtins.any file.hasExt [ "cu" "cuh" "cpp" "h" "hpp" "py" "map" "json" "patch" ] + ) ./crates/proof-compression/cuda; + }; + nativeBuildInputs = [ pkgs.python3 pkgs.patch cudaToolkit ]; + CUDA_HOME = "${cudaToolkit}"; + NVCC = "${cudaToolkit}/bin/nvcc"; + IX_PLONK_CUDA_ARCHS = "120"; + dontConfigure = true; + buildPhase = '' + runHook preBuild + python3 plonk/build.py --sppark ${plonkSppark} --blst ${plonkBlst}/blst --out native + runHook postBuild + ''; + installPhase = '' + mkdir -p "$out/lib" "$out/include" "$out/share/ix-plonk-cuda" + cp native/libix_plonk_bn254_cuda.so "$out/lib/" + cp plonk/ix_plonk_bn254.h "$out/include/" + cp native/*.json "$out/share/ix-plonk-cuda/" + ''; + meta.platforms = [ "x86_64-linux" ]; + }; + mkPlonkHelper = { bn254, cuda ? false }: (cudaPkgs.buildGoModule.override { go = plonkGo; }) { + pname = (if bn254 then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper") + + pkgs.lib.optionalString cuda "-cuda"; + tags = pkgs.lib.optional bn254 "bn254" ++ pkgs.lib.optional cuda "cuda"; version = "0.1.0"; src = pkgs.lib.fileset.toSource { root = ./tools/bls-plonk-wrapper; - fileset = plonkSources; + fileset = if cuda then plonkCudaSources else plonkSources; }; vendorHash = "sha256-bmRyQn7iMLGRATQStghs1KmlXwln70Sxzu5oSWR5pRU="; subPackages = [ "." ]; - env.CGO_ENABLED = "0"; + env.CGO_ENABLED = if cuda then "1" else "0"; env.GOTOOLCHAIN = "local"; + env.CGO_CFLAGS = pkgs.lib.optionalString cuda "-I${plonkCudaNative}/include"; + env.CGO_LDFLAGS = pkgs.lib.optionalString cuda "-L${plonkCudaNative}/lib -Wl,-rpath,${plonkCudaNative}/lib -lix_plonk_bn254_cuda"; + nativeBuildInputs = pkgs.lib.optionals cuda [ pkgs.python3 pkgs.patch ]; + buildInputs = pkgs.lib.optional cuda plonkCudaNative; + postConfigure = pkgs.lib.optionalString cuda '' + chmod -R u+w vendor/github.com/consensys/gnark + python3 build_cuda.py --patch-vendor vendor/github.com/consensys/gnark + ''; postInstall = '' - mv "$out/bin/bls-plonk-wrapper" "$out/bin/ix-bls-plonk-wrapper" + mv "$out/bin/bls-plonk-wrapper" "$out/bin/${if bn254 then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper"}" + '' + pkgs.lib.optionalString cuda '' + mkdir -p "$out/lib" + ln -s ${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so "$out/lib/" ''; }; + plonkHelper = mkPlonkHelper { bn254 = false; }; + bn254PlonkHelper = mkPlonkHelper { bn254 = true; }; + bn254PlonkCudaHelper = mkPlonkHelper { bn254 = true; cuda = true; }; + # Rust package craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; # The Cargo files the workspace builds from. @@ -390,6 +450,8 @@ default = ixLib; ix = ixCLI; plonk-wrapper = plonkHelper; + plonk-wrapper-bn254 = bn254PlonkHelper; + plonk-wrapper-bn254-cuda = bn254PlonkCudaHelper; # `checks` are built by `nix flake check`; exposing this derivation # as a package keeps the ignored suite available on demand without # adding it to the default check set. @@ -408,6 +470,7 @@ checks = { plonk-wrapper = plonkHelper; + plonk-wrapper-bn254 = bn254PlonkHelper; # Lint the host workspace; warnings are errors. clippy = craneLib.cargoClippy ( craneArgs diff --git a/lakefile.lean b/lakefile.lean index e60afe849..642197c1d 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -51,14 +51,21 @@ The archives are built when native targets need them. Keeping Rust linkage on -/ section FFI +/-- Curve selection shared by the CLI library and compression worker. -/ +def compressionFeatures : IO (Array String) := do + match (← IO.getEnv "IX_KZG_CURVE").getD "bls12-381" with + | "bls12-381" => return #["bls12-381", "plonk"] + | "bn254" => return #["bn254", "plonk"] + | other => throw <| IO.userError s!"IX_KZG_CURVE must be bls12-381 or bn254, got {other}" + /-- Rust feature selection shared by compilation and native runtime linkage. -/ -def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : BaseIO (Array String) := do +def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : IO (Array String) := do -- IX_NO_PAR=1 disables parallel; IX_CUDA=1/true/yes enables CUDA; -- IX_CUDA_TRACE_CODEGEN=1 enables generated trace kernels and implies CUDA. let ixNoPar ← IO.getEnv "IX_NO_PAR" let ixCuda ← IO.getEnv "IX_CUDA" let ixTraceCodegen ← IO.getEnv "IX_CUDA_TRACE_CODEGEN" - let mut features : Array String := #[] + let mut features ← compressionFeatures if ixNoPar != some "1" then features := features.push "parallel" if ixCuda == some "1" || ixCuda == some "true" || ixCuda == some "yes" then features := features.push "cuda" @@ -73,7 +80,7 @@ Cargo output is visible with `lake -v build`. -/ def cargoArgs (testFfi : Bool := false) (net : Bool := false) : IO (Array String) := do let features ← cargoFeatures testFfi net IO.println s!"Ix Rust features: {if features.isEmpty then "none" else ",".intercalate features.toList}" - let buildArgs := #["build", "--release", "-p", "ix-ffi"] + let buildArgs := #["build", "--release", "--no-default-features", "-p", "ix-ffi"] if features.isEmpty then return buildArgs else return buildArgs ++ #["--features", ",".intercalate features.toList] @@ -98,6 +105,15 @@ def usesCuda (features : Array String) : Bool := features.any fun feature => (feature.splitOn ",").any fun name => name == "cuda" || name == "cuda-trace-codegen" +/-- Worker features follow the CLI's curve while omitting CLI-only features. -/ +def compressionWorkerArgs (features : Array String) : Array String := Id.run do + let mut selected := features.filter fun name => + #["bls12-381", "bn254", "plonk", "parallel"].contains name + if usesCuda features then selected := selected.push "cuda" + return #["build", "--release", "-p", "ix-proof-compression", + "--bin", "ix-compression-worker", "--no-default-features", + "--features", ",".intercalate selected.toList] + def cudaLibraryName := "libmulti_stark_cuda.so" /-- Cargo reports native build outputs even when the build is already fresh. -/ @@ -199,45 +215,100 @@ target ix_cuda_runtime pkg : FilePath := do /-- The compression worker and Lean executables use the same native library. -/ target ix_compression_worker pkg : FilePath := do let features ← cargoFeatures - let mut args := #["build", "--release", "-p", "ix-proof-compression", - "--bin", "ix-compression-worker"] - unless features.contains "parallel" do - args := args.push "--no-default-features" - args := args ++ #["--features", "plonk"] + let args := compressionWorkerArgs features let mut before := none if usesCuda features then - args := args ++ #["--features", "cuda"] before := some (← ix_rs.fetch) buildCargoArtifact pkg args "worker" (pkg.dir / "target" / "release" / "ix-compression-worker") (pkg.buildDir / "bin" / "ix-compression-worker") before /-- The terminal prover is a native Go executable with a pinned module graph. -/ -target ix_plonk_wrapper pkg : FilePath := do - let sources ← inputDir (pkg.dir / "tools/bls-plonk-wrapper") true fun path => +def compressionHelperName (features : Array String) : String := + if features.contains "bn254" then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper" + +def compressionHelperArgs (features : Array String) (output : FilePath) : Array String := + #["build", "-buildvcs=false", "-trimpath", "-mod=readonly"] ++ + (if features.contains "bn254" then #["-tags", "bn254"] else #[]) ++ + #["-o", output.toString, "."] + +/-- PLONK arithmetic selection is independent of the inner KZG CUDA backend. -/ +def compressionHelperCuda (features : Array String) : IO Bool := do + let value := (← IO.getEnv "IX_PLONK_CUDA").getD "0" + let enabled ← match value with + | "1" | "true" | "yes" => pure true + | "0" | "false" | "no" | "" => pure false + | _ => throw <| IO.userError "IX_PLONK_CUDA must be 0/1, false/true or no/yes" + if enabled && !features.contains "bn254" then + throw <| IO.userError "IX_PLONK_CUDA requires IX_KZG_CURVE=bn254" + return enabled + +def plonkCudaLibraryName := "libix_plonk_bn254_cuda.so" + +target ix_plonk_wrapper pkg : Array FilePath := do + let features ← compressionFeatures + let cuda ← compressionHelperCuda features + unless features.contains "plonk" do + return pure #[] + let mut sources ← inputDir (pkg.dir / "tools/bls-plonk-wrapper") true fun path => path.extension == some "go" || path.extension == some "json" || + path.extension == some "py" || path.extension == some "patch" || + path.extension == some "h" || path.extension == some "bin" || path.fileName == "go.mod" || path.fileName == "go.sum" - let output := pkg.buildDir / "bin" / "ix-bls-plonk-wrapper" + if cuda then + let native ← inputDir (pkg.dir / "crates/proof-compression/cuda") true fun path => + #["cu", "cuh", "cpp", "h", "hpp", "py", "map", "json", "patch"].contains (path.extension.getD "") + sources := sources.zipWith (fun paths _ => paths) native + let helperName := compressionHelperName features + let output := pkg.buildDir / "bin" / helperName + let library := pkg.buildDir / "lib" / plonkCudaLibraryName + let outputs := if cuda then #[output, library] else #[output] let prebuilt ← IO.getEnv "IX_PLONK_PREBUILT" + if let some path := prebuilt then + let executable ← inputBinFile (FilePath.mk path) + sources := sources.zipWith (fun paths _ => paths) executable + if cuda then + let native ← inputBinFile ((FilePath.mk path).parent.get!.parent.get! / "lib" / plonkCudaLibraryName) + sources := sources.zipWith (fun paths _ => paths) native + let environment ← #["IX_PLONK_CUDA_ARCHS", "IX_PLONK_SPPARK_SOURCE", "IX_PLONK_BLST_SOURCE", + "NVCC", "CUDA_HOME", "NVCC_CCBIN", "CC", "CXX"].mapM fun name => + return (name, ← IO.getEnv name) sources.mapM fun _ => do let compiler ← match prebuilt with | some path => pure path | none => (·.stdout) <$> captureProc' { cmd := "go", args := #["version"] } - addPureTrace (prebuilt, compiler) s!"PLONK helper compiler: {compiler}" - buildUnlessUpToDate #[output] (← getTrace) (output.addExtension "trace") do + addPureTrace (prebuilt, compiler, features, cuda, environment) s!"PLONK helper compiler: {compiler}" + buildUnlessUpToDate outputs (← getTrace) (output.addExtension "trace") do match prebuilt with - | some path => copyNativeArtifact (FilePath.mk path) output "plonk" + | some path => + copyNativeArtifact (FilePath.mk path) output "plonk" + if cuda then + let source := (FilePath.mk path).parent.get!.parent.get! / "lib" / plonkCudaLibraryName + copyNativeArtifact source library "plonk-cuda" | none => IO.FS.createDirAll output.parent.get! - let temporary := ((← IO.FS.realPath output.parent.get!) / "ix-bls-plonk-wrapper").addExtension "tmp" - proc { - cmd := "go" - args := #["build", "-buildvcs=false", "-trimpath", "-mod=readonly", "-o", temporary.toString, "."] - cwd := pkg.dir / "tools/bls-plonk-wrapper" - } + let temporary := ((← IO.FS.realPath output.parent.get!) / helperName).addExtension "tmp" + if cuda then + let some sppark ← IO.getEnv "IX_PLONK_SPPARK_SOURCE" + | error "IX_PLONK_SPPARK_SOURCE must name the pinned sppark source tree" + let some blst ← IO.getEnv "IX_PLONK_BLST_SOURCE" + | error "IX_PLONK_BLST_SOURCE must name the pinned blst source tree" + proc { + cmd := "python3" + args := #["tools/bls-plonk-wrapper/build_cuda.py", "--out", temporary.toString, + "--build-dir", (pkg.buildDir / "plonk-cuda").toString, + "--sppark", sppark, "--blst", blst] + cwd := pkg.dir + } + else + proc { + cmd := "go" + args := compressionHelperArgs features temporary + cwd := pkg.dir / "tools/bls-plonk-wrapper" + } IO.FS.rename temporary output - setTrace (← computeTrace output) - return output + setTrace (← computeTrace outputs) + return outputs /-- Cargo feature variants wait for the default archive before building. -/ target ix_rs_test pkg : FilePath := do @@ -430,7 +501,10 @@ script install := do let fileRight := { user := fullAccess, group := fullAccess, other := noWriteAccess } FS.createDirAll binDir -- Compression resolves its worker and terminal prover beside the CLI. - for name in ["ix", "ix-compression-worker", "ix-bls-plonk-wrapper"] do + let mut binaries := #["ix", "ix-compression-worker"] + if (← compressionFeatures).contains "plonk" then + binaries := binaries.push (compressionHelperName (← compressionFeatures)) + for name in binaries do let destination := binDir / name FS.writeBinFile destination (← FS.readBinFile $ ".lake" / "build" / "bin" / name) setAccessRights destination fileRight @@ -439,6 +513,11 @@ script install := do let libraryDir := binDir.parent.getD "." / "lib" FS.createDirAll libraryDir FS.writeBinFile (libraryDir / cudaLibraryName) (← FS.readBinFile cudaLibrary) + if ← compressionHelperCuda (← compressionFeatures) then + let libraryDir := binDir.parent.getD "." / "lib" + FS.createDirAll libraryDir + let source : FilePath := ".lake" / "build" / "lib" / plonkCudaLibraryName + FS.writeBinFile (libraryDir / plonkCudaLibraryName) (← FS.readBinFile source) return 0 script "get-exe-targets" := do diff --git a/tests/check_compression_build.py b/tests/check_compression_build.py new file mode 100644 index 000000000..6568556e3 --- /dev/null +++ b/tests/check_compression_build.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Check the actual Lake-generated CLI and worker arguments without building ix.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + + +def features(arguments): + index = arguments.index('--features') + return set(arguments[index + 1].split(',')) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--toolchain', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + toolchain = args.toolchain.resolve() + lean = toolchain / 'bin' / 'lean' + plugin = toolchain / 'lib' / 'lean' / 'libLake_shared.so' + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + prefix = [str(lean), f'--load-dynlib={plugin}'] + version = subprocess.check_output([str(lean), '--version'], text=True).strip() + expected_version = Path('lean-toolchain').read_text().strip().split(':')[-1].removeprefix('v') + if f'version {expected_version},' not in version: + raise RuntimeError(f'wrong toolchain: {version}') + result = subprocess.run(prefix + ['-o', str(out / 'lakefile.olean'), 'lakefile.lean'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + (out / 'typecheck.log').write_text(result.stdout) + if result.returncode: + raise RuntimeError(f'Lake configuration typecheck failed: {out / "typecheck.log"}') + cases = [('default', {}, 'bls12-381', False, True)] + for curve in ['bn254', 'bls12-381']: + for cuda in [False, True]: + cases.append((f'{curve}-{"cuda" if cuda else "cpu"}', + {'IX_KZG_CURVE': curve, 'IX_CUDA': str(int(cuda))}, curve, cuda, True)) + cases += [ + ('trace-codegen', {'IX_KZG_CURVE': 'bn254', 'IX_CUDA_TRACE_CODEGEN': '1'}, 'bn254', True, True), + ('no-parallel', {'IX_KZG_CURVE': 'bn254', 'IX_NO_PAR': '1'}, 'bn254', False, False), + ('plonk-cuda-only', {'IX_KZG_CURVE': 'bn254', 'IX_PLONK_CUDA': '1'}, 'bn254', False, True), + ('both-cuda', {'IX_KZG_CURVE': 'bn254', 'IX_PLONK_CUDA': '1', 'IX_CUDA': '1'}, 'bn254', True, True), + ] + report = {'lean': version, 'typecheck_passed': True, 'cases': []} + for name, selection, curve, cuda, parallel in cases: + environment = dict(os.environ, LEAN_PATH=str(out)) + for variable in ['IX_KZG_CURVE', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR', 'IX_PLONK_CUDA']: + environment.pop(variable, None) + environment.update(selection) + result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], + env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + (out / f'{name}.log').write_text(result.stdout) + if result.returncode: + raise RuntimeError(f'configuration evaluation failed: {name}') + actual = json.loads(result.stdout.splitlines()[-1]) + required = {curve, 'plonk'} | ({'solidity'} if curve == 'bls12-381' else set()) + required |= {'parallel'} if parallel else set() + worker = required | ({'cuda'} if cuda else set()) + ffi = required | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) + for target, expected in [ + ('worker', worker), ('ffi', ffi), + ('net', ffi | ({'net'} if sys.platform != 'darwin' else set())), + ('test', ffi | {'test-ffi'}), + ]: + arguments = actual[target] + package = 'ix-proof-compression' if target == 'worker' else 'ix-ffi' + if arguments[arguments.index('-p') + 1] != package: + raise RuntimeError(f'{name}: wrong package for {target}') + if '--no-default-features' not in arguments or features(arguments) != expected: + raise RuntimeError(f'{name}: unexpected {target} features: {arguments}') + if actual['worker'][actual['worker'].index('--bin') + 1] != 'ix-compression-worker': + raise RuntimeError(f'{name}: wrong worker executable') + if actual['terminal_plonk'] is not True: + raise RuntimeError(f'{name}: wrong terminal backend selection') + if actual['plonk_cuda'] != (selection.get('IX_PLONK_CUDA') == '1'): + raise RuntimeError(f'{name}: PLONK CUDA selection depends on inner CUDA selection') + helper = 'ix-bn254-plonk-wrapper' if curve == 'bn254' else 'ix-bls-plonk-wrapper' + if actual['plonk_helper'] != helper or ('-tags' in actual['plonk_build']) != (curve == 'bn254'): + raise RuntimeError(f'{name}: wrong PLONK helper or Go build tags') + report['cases'].append({'name': name, 'environment': selection, 'arguments': actual, 'passed': True}) + environment.update(IX_KZG_CURVE='invalid') + result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], + env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + (out / 'invalid.log').write_text(result.stdout) + if result.returncode != 1 or 'IX_KZG_CURVE must be bls12-381 or bn254' not in result.stdout: + raise RuntimeError('invalid curve was not rejected as expected') + report['invalid_curve_rejected'] = True + environment.update(IX_KZG_CURVE='bls12-381', IX_PLONK_CUDA='1') + result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], + env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + (out / 'invalid-plonk-cuda.log').write_text(result.stdout) + if result.returncode != 1 or 'IX_PLONK_CUDA requires IX_KZG_CURVE=bn254' not in result.stdout: + raise RuntimeError('BLS CUDA PLONK configuration was not rejected') + report['unsupported_plonk_cuda_rejected'] = True + report['source_sha256'] = {p: hashlib.sha256(Path(p).read_bytes()).hexdigest() for p in [ + 'lakefile.lean', 'tests/compression_build_config.lean', 'tests/check_compression_build.py', + 'crates/ffi/Cargo.toml', 'crates/proof-compression/Cargo.toml', 'lean-toolchain', + ]} + (out / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'report': str(out / 'report.json'), 'typecheck': 'passed', + 'configurations_passed': len(cases), 'invalid_curve_rejected': True})) + + +if __name__ == '__main__': + main() diff --git a/tests/compression_build_config.lean b/tests/compression_build_config.lean new file mode 100644 index 000000000..e30068373 --- /dev/null +++ b/tests/compression_build_config.lean @@ -0,0 +1,17 @@ +import lakefile + +def main : IO Unit := do + let features ← cargoFeatures + let ffi ← cargoArgs + let net ← cargoArgs (net := true) + let test ← cargoArgs (testFfi := true) + IO.println <| (Lean.Json.mkObj [ + ("ffi", Lean.toJson ffi), + ("net", Lean.toJson net), + ("test", Lean.toJson test), + ("worker", Lean.toJson (compressionWorkerArgs features)), + ("terminal_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), + ("plonk_helper", Lean.toJson (compressionHelperName features)), + ("plonk_cuda", Lean.toJson (← compressionHelperCuda features)), + ("plonk_build", Lean.toJson (compressionHelperArgs features "helper-output")) + ]).compress diff --git a/tests/kzg_backend_parity.py b/tests/kzg_backend_parity.py new file mode 100644 index 000000000..96b6fa225 --- /dev/null +++ b/tests/kzg_backend_parity.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Compare CPU and CUDA development proofs using independently built binaries.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import time + + +def sha256(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def run(command, log, environment, *, success=True, expected_error=None): + print(json.dumps({'command': command, 'log': str(log)}), flush=True) + started = time.monotonic() + timing = log.with_suffix('.time') + measured = ['/usr/bin/time', '-v', '-o', str(timing), '--', *command] + with log.open('w') as output: + result = subprocess.run( + measured if Path('/usr/bin/time').is_file() else command, + env=environment, stdout=output, stderr=subprocess.STDOUT, + ) + if result.returncode != (0 if success else 1): + raise RuntimeError(f'unexpected exit {result.returncode}: {log}') + if expected_error is not None and expected_error not in log.read_text(): + raise RuntimeError(f'missing expected rejection {expected_error!r}: {log}') + return { + 'command': command, 'log': str(log), 'exit_code': result.returncode, + 'elapsed_seconds': time.monotonic() - started, + 'timing': str(timing) if timing.exists() else None, + } + + +def build(curve, cuda, directory, environment): + mode = 'cuda' if cuda else 'cpu' + features = f'{curve},parallel' + (',cuda' if cuda else '') + command = [ + 'cargo', 'build', '--offline', '--locked', '-p', 'ix-proof-compression', + '--no-default-features', '--features', features, + '--example', 'kzg_backend_parity', '--message-format=json-render-diagnostics', + ] + log = directory / f'build-{mode}.jsonl' + record = run(command, log, environment) + executable = None + native = None + for line in log.read_text().splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get('reason') == 'compiler-artifact' and event.get('target', {}).get('name') == 'kzg_backend_parity': + executable = event.get('executable') + if 'dylib=multi_stark_cuda' in event.get('linked_libs', []): + native = Path(event['out_dir']) / 'libmulti_stark_cuda.so' + if not executable or (cuda and native is None): + raise RuntimeError(f'Cargo did not report the requested artifacts: {log}') + # Cargo reuses the example's output name across feature selections. + # Snapshot each binary and its shared library before building another one. + binary = directory / 'bin' / mode + binary.parent.mkdir(exist_ok=True) + shutil.copy2(executable, binary) + if cuda: + library = directory / 'lib' / native.name + library.parent.mkdir(exist_ok=True) + shutil.copy2(native, library) + record['native_sha256'] = sha256(library) + record.update({'features': features, 'binary': str(binary), 'binary_sha256': sha256(binary)}) + return binary, record + + +def check_dispatch(log, mode): + text = log.read_text() + if mode == 'passes': + required = ['KZG lookup constructed in GPU passes', 'KZG quotient evaluated in GPU passes'] + else: + required = ['KZG lookup constructed on device', 'KZG quotient evaluated on device'] + required += ['KZG interpolation queue'] + if mode == 'cached': + required += ['KZG SRS cache access'] + missing = [entry for entry in required if entry not in text] + if missing: + raise RuntimeError(f'missing GPU dispatch evidence {missing}: {log}') + return required + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--curve', choices=['bn254', 'bls12-381', 'both'], default='both') + args = parser.parse_args() + root = args.out.resolve() + if root.exists() and any(root.iterdir()): + parser.error('output directory must be empty') + root.mkdir(parents=True, exist_ok=True) + environment = dict(os.environ, RUST_LOG='ix_proof_compression=debug') + curves = ['bn254', 'bls12-381'] if args.curve == 'both' else [args.curve] + report = { + 'baseline_revision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'scope': 'Two ordinary KZG circuits at 4096/8192 rows with development parameters; no FRI or recursive KZG proving.', + 'curves': {}, + } + sources = subprocess.check_output([ + 'git', 'ls-files', '--cached', '--others', '--exclude-standard', '--', + 'crates/proof-compression', 'crates/aiur', 'Cargo.toml', 'Cargo.lock', + '.cargo/config.toml', 'rust-toolchain.toml', 'tests/kzg_backend_parity.py', + ], text=True).splitlines() + report['source_sha256'] = {p: sha256(Path(p)) for p in sorted(set(sources)) + if Path(p).suffix in {'.rs', '.cu', '.cuh', '.cpp', '.h', '.hpp', '.toml', '.lock', '.py'}} + for curve in curves: + directory = root / curve + directory.mkdir() + cpu, cpu_build = build(curve, False, directory, environment) + cpu_env = dict(environment, CUDA_VISIBLE_DEVICES='') + cpu_run = run([str(cpu), 'prove', str(directory / 'cpu'), 'cpu'], directory / 'prove-cpu.log', cpu_env) + reference = json.loads((directory / 'cpu' / 'report.json').read_text()) + gpu, gpu_build = build(curve, True, directory, environment) + gpu_env = dict(environment, CUDA_VISIBLE_DEVICES='0') + gpu_env['LD_LIBRARY_PATH'] = str(directory / 'lib') + ( + ':' + environment['LD_LIBRARY_PATH'] if environment.get('LD_LIBRARY_PATH') else '') + runs = {'cpu': cpu_run} + for mode in ['dense', 'passes', 'cached']: + log = directory / f'prove-{mode}.log' + runs[mode] = run([str(gpu), 'prove', str(directory / mode), mode], log, gpu_env) + runs[mode]['dispatch'] = check_dispatch(log, mode) + candidate = json.loads((directory / mode / 'report.json').read_text()) + for key in ['curve', 'input_blake3', 'proof_blake3', 'verifier_blake3']: + if candidate[key] != reference[key]: + raise RuntimeError(f'{curve} {mode}: {key} differs from CPU') + for artifact in ['proof.bin', 'verifier.bin']: + if (directory / mode / artifact).read_bytes() != (directory / 'cpu' / artifact).read_bytes(): + raise RuntimeError(f'{curve} {mode}: {artifact} differs from CPU') + verifier = directory / 'cpu' / 'verifier.bin' + pin = reference['verifier_blake3'] + verified = [] + for mode in runs: + verified.append(run([str(cpu), 'verify', str(directory / mode / 'proof.bin'), str(verifier), pin], + directory / f'verify-{mode}-on-cpu.log', cpu_env)) + verified.append(run([str(gpu), 'verify', str(directory / 'cpu' / 'proof.bin'), str(verifier), pin], + directory / 'verify-cpu-on-cuda-binary.log', dict(gpu_env, CUDA_VISIBLE_DEVICES=''))) + rejected = run([str(cpu), 'verify', str(directory / 'cpu' / 'proof.bin'), str(verifier), '0' * 64], + directory / 'reject-verifier-pin.log', cpu_env, success=False, + expected_error='verifier differs from the independently supplied pin') + report['curves'][curve] = { + 'builds': [cpu_build, gpu_build], 'proving_runs': runs, 'verification_runs': verified, + 'wrong_pin_rejection': rejected, 'proof_bytes': reference['proof_bytes'], + 'proof_blake3': reference['proof_blake3'], 'verifier_blake3': pin, + 'input_blake3': reference['input_blake3'], 'exact_byte_parity': True, + } + (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + if len(curves) == 2: + report['cross_curve_rejections'] = [] + for curve, other in [('bn254', 'bls12-381'), ('bls12-381', 'bn254')]: + directory = root / curve + record = run([ + str(directory / 'bin' / 'cpu'), 'verify', str(root / other / 'cpu' / 'proof.bin'), + str(directory / 'cpu' / 'verifier.bin'), report['curves'][curve]['verifier_blake3'], + ], directory / 'reject-other-curve.log', dict(environment, CUDA_VISIBLE_DEVICES=''), + success=False, expected_error='CompactProofError') + report['cross_curve_rejections'].append(record) + (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'report': str(root / 'report.json'), 'curves': curves, 'exact_byte_parity': True}), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/run_gpu_stage.py b/tests/run_gpu_stage.py new file mode 100644 index 000000000..3eb208a20 --- /dev/null +++ b/tests/run_gpu_stage.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Measure one GPU proof stage and stop its process group on memory exhaustion.""" + +import argparse +import datetime +import json +import os +from pathlib import Path +import signal +import subprocess +import time + + +def available_kib(): + return next(int(line.split()[1]) for line in Path('/proc/meminfo').read_text().splitlines() + if line.startswith('MemAvailable:')) + + +def tree_rss(pid, seen=None): + seen = set() if seen is None else seen + if pid in seen: + return 0 + seen.add(pid) + try: + status = Path(f'/proc/{pid}/status').read_text() + own = next((int(line.split()[1]) for line in status.splitlines() if line.startswith('VmRSS:')), 0) + children = Path(f'/proc/{pid}/task/{pid}/children').read_text().split() + return own + sum(tree_rss(int(child), seen) for child in children) + except (OSError, ProcessLookupError): + return 0 + + +def gpu_memory(): + result = subprocess.run(['nvidia-smi', '--query-gpu=index,memory.used,utilization.gpu', + '--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=5) + if result.returncode: + raise RuntimeError(result.stderr.strip()) + return {row[0].strip(): {'used_mib': int(row[1]), 'utilization_percent': int(row[2])} + for line in result.stdout.splitlines() if (row := line.split(','))} + + +def stop_group(process): + for sig in [signal.SIGTERM, signal.SIGKILL]: + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + break + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + continue + process.wait(timeout=10) + for _ in range(20): + try: + os.killpg(process.pid, 0) + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + time.sleep(0.5) + raise RuntimeError(f'process group {process.pid} still exists after termination') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', type=Path) + parser.add_argument('command', nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ['--'] else args.command + if not command: + parser.error('missing command') + args.directory.mkdir(parents=True, exist_ok=False) + (args.directory / 'supervisor.pid').write_text(str(os.getpid()) + '\n') + if available_kib() < 32 * 1024 * 1024: + raise RuntimeError('less than 32 GiB host memory available before launch') + environment_keys = [ + 'IX_COMPRESSION_WORKER', 'IX_KZG_FIXED_CACHE', 'IX_KZG_SRS_CACHE', 'IX_KZG_PREFETCH_GIB', + 'IX_TEXRAY', 'IX_KZG_CUDA_DEVICES', 'IX_KZG_CUDA_RESIDENT_GIB', 'IX_KZG_CUDA_SRS_CACHE', + 'IX_KZG_MSM_CHUNK_POINTS', + 'IX_KZG_CUDA_PASSES', 'IX_KZG_CUDA_PROFILE', 'CUDA_VISIBLE_DEVICES', 'LD_LIBRARY_PATH', + 'RAYON_NUM_THREADS', 'OMP_NUM_THREADS', 'CARGO_BUILD_JOBS', 'RUST_LOG', + ] + record = { + 'command': command, 'started_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'environment': {k: os.environ[k] for k in environment_keys if k in os.environ}, + 'cpu_count': os.cpu_count(), 'initial_gpu_memory': gpu_memory(), + 'memory_stop_available_gib': 16, 'telemetry_interval_seconds': 2, + } + (args.directory / 'command.json').write_text(json.dumps(record, indent=2) + '\n') + interrupted = [] + signal.signal(signal.SIGINT, lambda sig, frame: interrupted.append(sig)) + signal.signal(signal.SIGTERM, lambda sig, frame: interrupted.append(sig)) + peak = 0 + minimum = available_kib() + gpu_peaks = {} + reason = None + started = time.monotonic() + next_report = started + time_path = args.directory / 'time.txt' + with (args.directory / 'worker.log').open('w') as log, (args.directory / 'samples.jsonl').open('w') as samples: + process = subprocess.Popen(['/usr/bin/time', '-v', '-o', str(time_path), '--', *command], + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + (args.directory / 'child.pid').write_text(str(process.pid) + '\n') + try: + while process.poll() is None: + available = available_kib() + minimum = min(minimum, available) + rss = tree_rss(process.pid) + peak = max(peak, rss) + gpus = gpu_memory() + for index, gpu in gpus.items(): + gpu_peaks[index] = max(gpu_peaks.get(index, 0), gpu['used_mib']) + sample = {'elapsed_seconds': time.monotonic() - started, + 'tree_rss_gib': rss / 2**20, 'available_gib': available / 2**20, 'gpus': gpus} + samples.write(json.dumps(sample) + '\n') + samples.flush() + if time.monotonic() >= next_report: + print(json.dumps({'stage': str(args.directory), **sample}), flush=True) + next_report = time.monotonic() + 30 + if interrupted or available < 16 * 1024 * 1024: + reason = 'interrupted' if interrupted else 'host memory pressure' + stop_group(process) + break + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + except BaseException: + stop_group(process) + raise + code = process.wait() + record.update({'exit_status': code, 'wall_seconds': time.monotonic() - started, + 'sampled_tree_peak_rss_gib': peak / 2**20, 'minimum_host_available_gib': minimum / 2**20, + 'sampled_device_peak_used_mib': gpu_peaks, 'stop_reason': reason}) + for line in time_path.read_text().splitlines(): + if 'Maximum resident set size (kbytes):' in line: + record['peak_rss_gib'] = int(line.rsplit(':', 1)[1]) / 2**20 + (args.directory / 'result.json').write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps(record), flush=True) + return 0 if code == 0 and reason is None else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/bls-plonk-wrapper/README.md b/tools/bls-plonk-wrapper/README.md index 301af3139..a105eb18a 100644 --- a/tools/bls-plonk-wrapper/README.md +++ b/tools/bls-plonk-wrapper/README.md @@ -1,14 +1,15 @@ -# BLS12-381 PLONK wrapper +# BLS12-381 and BN254 PLONK wrappers -This CPU helper proves verification of an ix BLAKE3 KZG packet, including its +This helper proves verification of an ix BLAKE3 KZG packet, including its outer opening equation and its deferred pairing equation. Its only public inputs are the expected claim digest, packed as two little-endian 128-bit -limbs. The trusted circuit fixes the inner profile and aggregation-program +limbs. The trusted circuit fixes the inner verifying key and aggregation-program prefix. -The implementation is experimental. The checked-in outer packets are small -conformance circuits, not aggregate Lean proofs. Production verification keys -need review of the complete circuit and its measurements before authorization. +The implementation is experimental. The BLS conformance packets exercise small +circuits; the BN254 fixture comes from an authenticated Ignition Init proof. +Production verification keys need review of the complete circuit and its +measurements before authorization. ## Build and use through ix @@ -18,6 +19,12 @@ all three. `nix build .#ix` packages them, and `nix build .#plonk-wrapper` build and tests only this helper. Runtime proving and verification do not need Go or Cargo installed. +`IX_KZG_CURVE=bn254 lake build ix` selects BN254 for the CLI, compression +worker and `ix-bn254-plonk-wrapper` helper. Set the same selection for +`lake run install`. Both helpers share this module; the `bn254` Go build tag +selects BN254, and the default build selects BLS12-381. The standalone Nix +helper package is `.#plonk-wrapper-bn254`; the default `.#ix` remains BLS. + ```sh export IX_KZG_FILECOIN_CACHE=/path/to/filecoin-prefix-2p27.bin ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output @@ -34,6 +41,101 @@ generated verifier, public key and a candidate verifier catalog. `ix compress` preserves the claim and stores a new proof address. Override the helper location with `IX_PLONK_HELPER` if it is not beside the CLI/worker. +## Optional BN254 CUDA backend + +CPU is the default. The experimental CUDA helper supports BN254 domains through +`2^24`, including the current Init circuit, with GPU MSMs, FFTs and a fused +PLONK quotient. Gnark retains witness solving, randomness, transcript challenges, +opening construction and verification. Proof encoding and the verifying key +are independent of the selected arithmetic backend. + +The source build uses explicit copies of sppark revision +`114feb69ebfe029e2caa5e6c2d372c695e5d4da8` and blst 0.3.16. The blst source root +must contain `src/server.c` and `build/assembly.S`. No Rust build archive is +required. Provide these source roots and a CUDA toolkit supporting the chosen +GPU architecture: + +```sh +export IX_PLONK_SPPARK_SOURCE=/path/to/pinned-sppark +export IX_PLONK_BLST_SOURCE=/path/to/blst-0.3.16/blst +export CUDA_HOME=/usr/local/cuda +export NVCC="$CUDA_HOME/bin/nvcc" +export IX_PLONK_CUDA_ARCHS=120 +IX_KZG_CURVE=bn254 IX_PLONK_CUDA=1 lake build ix_plonk_wrapper +``` + +The standalone equivalent is: + +```sh +python3 tools/bls-plonk-wrapper/build_cuda.py \ + --out .lake/build/bin/ix-bn254-plonk-wrapper \ + --build-dir .lake/build/plonk-cuda \ + --sppark "$IX_PLONK_SPPARK_SOURCE" --blst "$IX_PLONK_BLST_SOURCE" +``` + +The build checks source receipts, applies the tracked gnark and sppark patches +to private build copies, and records executable/library hashes. It leaves +`go.mod` and the dependency caches unchanged. The native library is installed +in `../lib` relative +to the helper. Keep both artifacts together when installing; `lake run install` +does this when `IX_PLONK_CUDA=1`. The optional Nix package is +`.#plonk-wrapper-bn254-cuda`, targeting SM120. CPU packages remain independent of +the CUDA toolkit. + +Select the runtime backend separately from building it: + +```sh +CUDA_VISIBLE_DEVICES=0 IX_PLONK_BACKEND=cuda \ + .lake/build/bin/ix-bn254-plonk-wrapper prove artifacts input/witness.json proof-output +``` + +`IX_PLONK_CUDA_DEVICE` selects the visible ordinal (default 0), and +`IX_PLONK_CUDA_MEMORY_GIB` sets the allocation budget (default 64 GiB), with +8 GiB additional device headroom. Use one visible physical GPU per helper. +`IX_PLONK_CUDA` does not imply the inner prover's `IX_CUDA`, or vice versa. +Requesting CUDA from a CPU-only helper fails explicitly. CUDA errors abort that +proof; they do not silently switch its arithmetic to CPU. + +The helper emits callback times, dispatch counts, transferred bytes, allocation +accounting and CUDA pool high-water marks. Callback times include transfers; +quotient timing includes its internal transforms and host restoration. Pool +high-water marks exclude allocations outside the pool. Complete-process timing +also includes artifact authentication/loading and backend setup. +Queue wait and nested native timings overlap callback timings; they are not +independent stages to add to the total. +Set `IX_PLONK_CUDA_TRACE=1` for operation start/end diagnostics without scalar +values. Transfer counters cover explicit adapter transfers; internal sppark +result transfers may add traffic. + +ABI7 reports per-callback transfer byte/call deltas and separates inclusive +transfer wall time, host staging and DMA event time. Wall time can include +previously queued kernels; host staging and DMA overlap. DMA timings are valid +only when `TransferTimingEnabled` is true. The shared transfer implementation +currently enables those events with `IX_KZG_CUDA_PROFILE=1`. + +The opt-in full Init test supports `IX_PLONK_CUDA_INIT_MODES`, +`IX_PLONK_CUDA_INIT_REPEATS`, `IX_PLONK_CUDA_INIT_STAGE_PROFILE=1` and +`IX_PLONK_CUDA_INIT_CPU_PROFILE=1`, in addition to its required artifact/claim +pins. CPU sampling covers only the first selected proof; stage observation +covers every selected proof. Reports separate backend setup and profiler +startup/shutdown from core proving and record overlapping stage intervals. + +`IX_PLONK_CUDA_MSM_STRATEGY=split` is the default: large Pippenger buckets are +split into bounded tasks while retaining the zero and ±1 fast paths. The +explicit `mask` comparison strategy computes `MSM(s+r)-MSM(r)` and caches its +correction points by registered range. Neither path applies whole-scalar +affine normalization. Both preserve the original commitment exactly. + +Artifact manifests still bind their producer executable. Compile and prepare +artifacts with the frozen helper used for proving; CPU/CUDA mode changes within +that helper do not require different keys. A changed helper does not authorize +editing an old manifest. The full Init test harness accepts an independently +pinned existing manifest without changing production loading rules. + +The persistent worker protocol, immutable selector caching across proofs, +shared admission with KZG workers, and cancellation/restart policy are subsequent +service work. + ## Authorize verification keys independently Set `IX_PLONK_VERIFIER_CATALOG` to an independently trusted JSON catalog before @@ -51,6 +153,13 @@ Verification checks the selected key's digest and Filecoin G1/G2 anchors and derives the expected public digest from the independently supplied ix claim. It requires no proving SRS or GPU. +For BN254, the catalog format is `ix-bn254-plonk-verifiers-v1` and each key +has `setup: "ignition"`, an `admission` path and its independently trusted +`admission_blake3`. Verification authenticates that admission and checks the +key's Ignition anchors without loading the proving powers. Relative key and +admission paths resolve beside the catalog. The BN254 envelope is separately +tagged and cannot be accepted as a BLS proof. + ## Standalone operations `ix-compression-worker plonk export` authenticates a portable BLAKE3 profile, @@ -93,6 +202,48 @@ by setup, domain, receipt and helper identity; `IX_PLONK_LAGRANGE_CACHE` can select the shared directory. A changed executable needs a new artifact directory. Corrupt artifacts fail closed. +## BN254 Ignition parameters and Lagrange import + +BN254 uses the same commands with `ix-bn254-plonk-wrapper`. Export requires +`IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, +`IX_KZG_IGNITION_ADMISSION` and `IX_KZG_IGNITION_ADMISSION_BLAKE3`, as described +in the [compression setup documentation](../../crates/proof-compression/README.md). +The provisioned `2^26` monomial cache supports PLONK domain `2^25`, which +requires `2^25+3` powers. The normal helper's classic-GLV Init wrapper has +14,689,440 constraints plus two public rows, fitting `2^24` with 2,087,774 +rows to spare. The completed full proof benchmark below used the earlier +17,996,081-constraint configuration and domain `2^25`. + +SP1 v6.1.0 publishes a legacy gnark `plonk_pk.bin` containing a `2^25` +Lagrange basis for the same Ignition anchors. It is an untrusted source of +candidate points. After compilation and authenticated monomial export, import +it before `prepare`: + +```sh +ix-bn254-plonk-wrapper import-lagrange-sp1 \ + artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 /path/to/plonk_pk.bin +ix-bn254-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 +``` + +The importer bounds lengths, validates point encodings, compares the setup +anchors and domain, and checks the complete candidate basis against the +authenticated monomial powers with a transcript-bound random linear relation. +That relation requires a scalar inverse FFT and two MSMs. The cache is +published only after it passes, with the candidate-vector digest and validation +evidence in `validation.json`. The error bound for a fixed incorrect basis is +at most `(N-1)/Fr` in the random-oracle model. SP1 circuit keys are discarded; +`prepare` generates ix's own proving and verifying keys. A basis for `2^25` +cannot be truncated into a `2^24` basis. + +The import command requires an exact domain match. For the current `2^24` +circuit, `prepare` computes and caches a matching basis from the authenticated +monomial powers unless an independently validated matching cache is present. + +Ordinary BN254 tests run with `go test -tags bn254 ./...`. They cover the real +Init verifier witness and tampering, Arkworks-compatible transcript encodings, +exceptional MSM inputs, small PLONK proofs, parameter caches and rejection of +incorrect imported bases and malformed SP1 key containers. + ## Tests and sizing ```sh @@ -107,17 +258,29 @@ integrity, basis conversion and small PLONK proofs using real Filecoin powers. The small PLONK backend proof tests exercise `N=8`, separately from the full verifier circuit. -The measured full conformance verifier has 20,665,582 constraints, a `2^25` +The measured BLS full conformance verifier has 20,665,582 constraints, a `2^25` domain and one extra committed polynomial. Point validation and public binding account for about 3.21 million constraints, BLAKE3 adds 6.29 million, scalar identities add 441, opening MSMs add 7.77 million, and the pairing checks add about 1.70 million each. These are cumulative-compilation differences; shared range checks can affect component accounting. -Both pairing checks remain separate. Even eliminating the second check for -free would leave more than `2^24` constraints, so pairing batching cannot -reduce the current padded domain. The standard gnark MSM path is retained: -the tested alternative increased the full count to 25,429,821. +For that BLS fixture, even eliminating the second pairing check for free would +leave more than `2^24` constraints. BLS retains classic GLV; its tested +alternative increased the full count to 25,429,821. + +BN254 also selects classic GLV, reducing its actual Init wrapper to +14,689,442 total rows while retaining separate pairings. The randomized +batching measurement variant reaches 13,810,186 rows with classic GLV; batching +with BN254's previous multiplication path reaches 17,530,148 rows. Batching +is experimental and is not selected by the normal helper. Reproduce sizing +with `IX_PLONK_SIZE=1 go test -tags bn254 -run TestOptimizationSizing -v`; +`IX_PLONK_SIZE_PROFILE` selects a profile and `IX_PLONK_SIZE_REPORT` saves JSON. +`IX_PLONK_SIZE_VARIANT` can select `baseline`, `batched`, `classic-glv` or +`batched-classic-glv`. These are compilation results, separate from the +completed 2^25 proof benchmark. +`TestComponentSizing` accepts `baseline` or `classic-glv` for the component +breakdown; its default follows the normal helper's classic-GLV selection. The backend is native CPU gnark at `262cf453c107d147a228b6b7418c5cf791c3b79b`. Upstream experimental WebGPU/WASM PLONK acceleration exists, but this helper @@ -152,3 +315,37 @@ are archived in `s3://argument-kzg-params-063002298335-us-east-1-an/` Its `MANIFEST.json` has SHA-256 `6be357f686599d283acca68e6eb4a9aa9f29c31d0010e7c8190f5767a04daaee`; the archive's `verify_bundle.py --manifest-sha256` authenticates a restored copy. + +The earlier 2^25 configuration of the actual Ignition/BLAKE3 Init packet +proved and verified with BN254: +**104.33 seconds** core proving, **143.59 seconds** for the fresh prover process, +**47.18 GiB** peak RSS and **1.20 ms** in-memory native verification. Seven fresh +verifier processes had medians of **1.92 ms** for decode/public-witness/verify +and **21.70 ms** including startup and key loading. The native proof is +**584 bytes**, its ix envelope **624 bytes**, and the Solidity encoding +**864 bytes**. Prepared keys were excluded from these proof timings. + +Full validation and cache publication of SP1's `2^25` Lagrange basis took +**27.70 seconds**, followed by **63.55 seconds** for parameter loading and ix +circuit-key preparation. This avoids computing the group FFT locally; the +setup-time difference from the BLS run is not a curve-speed comparison. + +The current **2^24 classic-GLV configuration**, retaining separate pairing +checks, proves the same Init packet in **53.79 seconds** for core proving and +**73.70 seconds** for the complete fresh process. Peak prover RSS is +**25.19 GiB**. Native in-memory verification took **1.49 ms**; seven fresh +verifier processes had medians of **1.81 ms** for decode/public-witness/verify +and **22.52 ms** including startup and key loading. Proof sizes remain +**584 bytes native**, **624 bytes enveloped** and **864 bytes for Solidity**. + +Local 2^24 parameter/key preparation took **5m 58s**, including **5m 25s** for +Lagrange conversion/cache publication. These artifacts are now cached; all +preparation is excluded from proof timings. The proof and independent native +and catalog verification passed, including negative-input checks. + +The same BN254 proof passes Solidity verification on local Anvil using SP1's +v6.1.0 verifier with only its 23 circuit-key constants changed. Total receipt +gas is **316,003** directly or **320,103** through ix's expected-claim adapter, +including transaction intrinsic/calldata gas. All 47 compatibility and +negative-input checks passed. See the +[Anvil reproduction and gas breakdown](../../contracts/plonk/README.md#actual-bn254-init-proof-on-anvil). diff --git a/tools/bls-plonk-wrapper/artifacts.go b/tools/bls-plonk-wrapper/artifacts.go index 0897b5651..d4ec41c82 100644 --- a/tools/bls-plonk-wrapper/artifacts.go +++ b/tools/bls-plonk-wrapper/artifacts.go @@ -102,7 +102,7 @@ func loadManifest(dir string) (*manifest, error) { if err := decodeJSON(filepath.Join(dir, "manifest.json"), &m); err != nil { return nil, err } - if m.Format != "ix-bls-plonk-artifacts-v1" || m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil || m.Seconds == nil { + if m.Format != artifactFormat || m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil || m.Seconds == nil { return nil, fmt.Errorf("invalid wrapper artifact manifest") } if m.KeysReady { diff --git a/tools/bls-plonk-wrapper/backend.go b/tools/bls-plonk-wrapper/backend.go index b7ae60063..bd4ef8bb0 100644 --- a/tools/bls-plonk-wrapper/backend.go +++ b/tools/bls-plonk-wrapper/backend.go @@ -13,12 +13,8 @@ import ( "strings" "time" - "github.com/consensys/gnark-crypto/ecc" - bls "github.com/consensys/gnark-crypto/ecc/bls12-381" - "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/plonk" - plonkbls "github.com/consensys/gnark/backend/plonk/bls12-381" "github.com/consensys/gnark/backend/solidity" "github.com/consensys/gnark/frontend" "github.com/zeebo/blake3" @@ -26,7 +22,7 @@ import ( // readSRS consumes ix's authenticated export. The expected digest comes from // the Rust parameter loader, independently of this file's contents. -func readSRS(path, expectedDigest string, domain uint64, p *profile) (*kzg.SRS, error) { +func readSRS(path, expectedDigest string, domain uint64, p *profile) (*nativeSRS, error) { expected, err := unhex(expectedDigest, 32) if err != nil { return nil, err @@ -56,26 +52,26 @@ func readSRS(path, expectedDigest string, domain uint64, p *profile) (*kzg.SRS, return nil, err } count := binary.LittleEndian.Uint64(header[80:]) - if string(header[:8]) != "IXPSRS01" || !bytes.Equal(header[8:40], setup) || binary.LittleEndian.Uint64(header[40:48]) != p.PublicMaxDegree || count != domain+3 || domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || count-1 > p.PublicMaxDegree { + if string(header[:8]) != srsMagic || !bytes.Equal(header[8:40], setup) || binary.LittleEndian.Uint64(header[40:48]) != p.PublicMaxDegree || count != domain+3 || domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || count-1 > p.PublicMaxDegree { return nil, fmt.Errorf("SRS identity, degree or required power count differs") } stat, err := f.Stat() if err != nil { return nil, err } - if stat.Size() != int64(280+96*count) { + if stat.Size() != int64(88+2*g2ExportBytes+2*baseBytes*count) { return nil, fmt.Errorf("invalid SRS export length") } - srs := &kzg.SRS{} + srs := &nativeSRS{} for i := range srs.Vk.G2 { - var point [96]byte + var point [g2ExportBytes]byte if _, err := io.ReadFull(r, point[:]); err != nil { return nil, err } - if _, err := srs.Vk.G2[i].SetBytes(point[:]); err != nil { + if srs.Vk.G2[i], err = decodeG2(point[:]); err != nil { return nil, err } - srs.Vk.Lines[i] = bls.PrecomputeLines(srs.Vk.G2[i]) + srs.Vk.Lines[i] = precomputeLines(srs.Vk.G2[i]) } anchors, err := g2Keys(p.OuterKeys) if err != nil { @@ -159,7 +155,7 @@ func prepare(dir, path, digest string) error { return err } m.Seconds["index"] = time.Since(start).Seconds() - m.Commitments = len(vk.(*plonkbls.VerifyingKey).Qcp) + m.Commitments = len(vk.(*nativeVerifyingKey).Qcp) debug.FreeOSMemory() fmt.Fprintln(os.Stderr, "stage=write_keys") if err := saveBinary(dir, "proving-key.bin", pk, m); err != nil { @@ -184,6 +180,9 @@ func prepare(dir, path, digest string) error { } func prove(dir, path, out string) error { + if err := validateProvingBackend(); err != nil { + return err + } p, ccs, m, err := loadCircuit(dir) if err != nil { return err @@ -191,11 +190,11 @@ func prove(dir, path, out string) error { if err := m.checkKeys(dir); err != nil { return err } - pk := plonk.NewProvingKey(ecc.BLS12_381) + pk := plonk.NewProvingKey(curveID) if err := readBinary(filepath.Join(dir, "proving-key.bin"), pk); err != nil { return err } - vk := plonk.NewVerifyingKey(ecc.BLS12_381) + vk := plonk.NewVerifyingKey(curveID) if err := readBinary(filepath.Join(dir, "verifying-key.bin"), vk); err != nil { return err } @@ -203,16 +202,27 @@ func prove(dir, path, out string) error { if err != nil { return err } - w, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + return err + } + setupStarted := time.Now() + cleanup, err := configureProvingBackend(pk) if err != nil { return err } + defer func() { _ = cleanup() }() + backendSetupSeconds := time.Since(setupStarted).Seconds() start := time.Now() proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + seconds := time.Since(start).Seconds() + cleanupErr := cleanup() if err != nil { return err } - seconds := time.Since(start).Seconds() + if cleanupErr != nil { + return cleanupErr + } public, err := w.Public() if err != nil { return err @@ -225,11 +235,15 @@ func prove(dir, path, out string) error { if err := atomicFile(filepath.Join(out, "proof.bin"), func(w io.Writer) error { _, err := proof.WriteTo(w); return err }); err != nil { return err } - solidityBytes := proof.(*plonkbls.Proof).MarshalSolidity() + solidityBytes := proof.(*nativeProof).MarshalSolidity() if err := atomicFile(filepath.Join(out, "proof.solidity.bin"), func(w io.Writer) error { _, err := w.Write(solidityBytes); return err }); err != nil { return err } - report := map[string]any{"proveSeconds": seconds, "verifySeconds": verifySeconds, "solidityBytes": len(solidityBytes), "verifyingKeySHA256": m.Files["verifying-key.bin"].SHA256} + selectedBackend := os.Getenv("IX_PLONK_BACKEND") + if selectedBackend == "" { + selectedBackend = "cpu" + } + report := map[string]any{"backend": selectedBackend, "backendSetupSeconds": backendSetupSeconds, "proveSeconds": seconds, "verifySeconds": verifySeconds, "solidityBytes": len(solidityBytes), "verifyingKeySHA256": m.Files["verifying-key.bin"].SHA256} if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { return err } @@ -237,28 +251,28 @@ func prove(dir, path, out string) error { return nil } -func decodeProof(data []byte, commitments int) (*plonkbls.Proof, error) { - if commitments < 0 || commitments > 1024 || len(data) != 664+80*commitments { +func decodeProof(data []byte, commitments int) (*nativeProof, error) { + if commitments < 0 || commitments > 1024 || len(data) != 9*pointBytesLen+232+(32+pointBytesLen)*commitments { return nil, fmt.Errorf("invalid PLONK proof length") } // Bound the two serialized vector lengths before gnark allocates them. - if binary.BigEndian.Uint32(data[384:388]) != uint32(6+commitments) || binary.BigEndian.Uint32(data[660+32*commitments:664+32*commitments]) != uint32(commitments) { + if binary.BigEndian.Uint32(data[8*pointBytesLen:8*pointBytesLen+4]) != uint32(6+commitments) || binary.BigEndian.Uint32(data[9*pointBytesLen+228+32*commitments:9*pointBytesLen+232+32*commitments]) != uint32(commitments) { return nil, fmt.Errorf("invalid PLONK proof vector length") } for i := 0; i < 8; i++ { - if data[48*i]&0x80 == 0 { + if !isCompressedPoint(data[pointBytesLen*i]) { return nil, fmt.Errorf("uncompressed proof point") } } - if data[580+32*commitments]&0x80 == 0 { + if !isCompressedPoint(data[8*pointBytesLen+196+32*commitments]) { return nil, fmt.Errorf("uncompressed shifted opening") } for i := 0; i < commitments; i++ { - if data[664+32*commitments+48*i]&0x80 == 0 { + if !isCompressedPoint(data[9*pointBytesLen+232+32*commitments+pointBytesLen*i]) { return nil, fmt.Errorf("uncompressed committed point") } } - proof := new(plonkbls.Proof) + proof := new(nativeProof) r := bytes.NewReader(data) if _, err := proof.ReadFrom(r); err != nil { return nil, err @@ -287,7 +301,7 @@ func verify(keyPath, trustedDigest, proofPath, expectedClaim string, expectedAnc if key.SHA256 != strings.TrimPrefix(trustedDigest, "0x") { return fmt.Errorf("trusted wrapper verification key digest differs") } - vk := new(plonkbls.VerifyingKey) + vk := new(nativeVerifyingKey) if err := readBinary(keyPath, vk); err != nil { return err } @@ -298,14 +312,11 @@ func verify(keyPath, trustedDigest, proofPath, expectedClaim string, expectedAnc return fmt.Errorf("expected one anchor encoding") } if len(expectedAnchors) == 1 { - expected, err := unhex(expectedAnchors[0], 240) + expected, err := unhex(expectedAnchors[0], anchorBytesLen) if err != nil { return err } - g := vk.Kzg.G1.Bytes() - h := vk.Kzg.G2[0].Bytes() - tauH := vk.Kzg.G2[1].Bytes() - actual := append(append(append([]byte(nil), g[:]...), h[:]...), tauH[:]...) + actual := encodeAnchors(vk) if !bytes.Equal(actual, expected) { return fmt.Errorf("wrapper verification key setup anchors differ") } @@ -328,7 +339,7 @@ func verify(keyPath, trustedDigest, proofPath, expectedClaim string, expectedAnc if err != nil { return err } - w, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, ecc.BLS12_381.ScalarField(), frontend.PublicOnly()) + w, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) if err != nil { return err } diff --git a/tools/bls-plonk-wrapper/backend_bn254_test.go b/tools/bls-plonk-wrapper/backend_bn254_test.go new file mode 100644 index 000000000..434ec07b4 --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_bn254_test.go @@ -0,0 +1,154 @@ +//go:build bn254 + +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + plonkbls "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +type backendCircuit struct { + Claim [2]frontend.Variable `gnark:",public"` + X frontend.Variable + Commit bool `gnark:"-"` +} + +func (c *backendCircuit) Define(api frontend.API) error { + api.AssertIsEqual(api.Mul(c.X, c.X), c.Claim[0]) + api.AssertIsEqual(c.Claim[1], 1) + if c.Commit { + challenge, err := api.(frontend.Committer).Commit(c.X) + if err != nil { + return err + } + api.AssertIsDifferent(challenge, 0) + } + return nil +} + +func TestIgnitionBackendAndProofCodec(t *testing.T) { + p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + if err != nil { + t.Fatal(err) + } + var receipt struct { + Format, Blake3, SetupID, IgnitionManifest string + PublicMaxDegree, Powers uint64 + } + if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { + t.Fatal(err) + } + srsPath := filepath.Join("testdata", "bn254", "srs.bin") + srs, err := readSRS(srsPath, receipt.Blake3, 128, p) + if err != nil { + t.Fatal(err) + } + if _, err := readSRS(srsPath, receipt.Blake3, 64, p); err == nil { + t.Fatal("accepted wrong power count") + } + if _, err := readSRS(srsPath, string(bytes.Repeat([]byte{'0'}, 64)), 128, p); err == nil { + t.Fatal("accepted wrong export receipt") + } + for _, commit := range []bool{false, true} { + ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: commit}) + if err != nil { + t.Fatal(err) + } + n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + 2)) + if n > 128 { + t.Fatalf("tiny circuit exceeded fixture SRS: %d", n) + } + lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) + if err != nil { + t.Fatal(err) + } + pk, vk, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) + if err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7}, ecc.BN254.ScalarField()) + if err != nil { + t.Fatal(err) + } + proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + if err != nil { + t.Fatal(err) + } + public, _ := w.Public() + if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + proof.WriteTo(&encoded) + m := len(vk.(*plonkbls.VerifyingKey).Qcp) + decoded, err := decodeProof(encoded.Bytes(), m) + if err != nil { + t.Fatal(err) + } + if err := plonk.Verify(decoded, vk, public); err != nil { + t.Fatal(err) + } + for _, offset := range []int{0, 256, 516 + 32*m} { + bad := append([]byte(nil), encoded.Bytes()...) + bad[offset] = 0xff + if _, err := decodeProof(bad, m); err == nil { + t.Fatalf("accepted malformed proof at %d", offset) + } + } + if _, err := decodeProof(append(encoded.Bytes(), 0), m); err == nil { + t.Fatal("accepted trailing proof bytes") + } + if commit { + dir := os.Getenv("IX_PLONK_TEST_OUTPUT") + if dir == "" { + dir = t.TempDir() + } + keyPath := filepath.Join(dir, "verifying-key.bin") + if err := atomicFile(keyPath, func(w io.Writer) error { _, err := vk.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + keyHash, _ := fileHash(keyPath) + proofPath := filepath.Join(dir, "proof.bin") + if err := atomicFile(proofPath, func(w io.Writer) error { _, err := w.Write(encoded.Bytes()); return err }); err != nil { + t.Fatal(err) + } + var digest [32]byte + digest[0], digest[16] = 49, 1 + if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err != nil { + t.Fatal(err) + } + digest[31] = 1 + if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err == nil { + t.Fatal("accepted changed public digest") + } + digest[31] = 0 + if err := atomicFile(filepath.Join(dir, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }); err != nil { + t.Fatal(err) + } + payload := proof.(*plonkbls.Proof).MarshalSolidity() + if len(payload) != 768+96*m { + t.Fatal("unexpected Solidity proof length") + } + vector := map[string]any{"scope": "tiny-backend-test-not-an-ix-wrapper", "proof": "0x" + hex.EncodeToString(payload), "digest": "0x" + hex.EncodeToString(digest[:]), "commitments": m, "domain": n} + data, _ := json.MarshalIndent(vector, "", " ") + if err := atomicFile(filepath.Join(dir, "proof.json"), func(w io.Writer) error { _, err := w.Write(data); return err }); err != nil { + t.Fatal(err) + } + } + t.Logf("Ignition PLONK backend: N=%d commitments=%d native_bytes=%d solidity_bytes=%d", n, m, encoded.Len(), len(proof.(*plonkbls.Proof).MarshalSolidity())) + } +} diff --git a/tools/bls-plonk-wrapper/backend_cpu.go b/tools/bls-plonk-wrapper/backend_cpu.go new file mode 100644 index 000000000..870d3dfed --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_cpu.go @@ -0,0 +1,28 @@ +//go:build !bn254 || !cuda + +package main + +import ( + "fmt" + "os" + + "github.com/consensys/gnark/backend/plonk" +) + +func validateProvingBackend() error { + switch os.Getenv("IX_PLONK_BACKEND") { + case "", "cpu": + return nil + case "cuda": + return fmt.Errorf("CUDA PLONK requires a helper built with bn254,cuda and cgo") + default: + return fmt.Errorf("IX_PLONK_BACKEND must be cpu or cuda") + } +} + +func configureProvingBackend(_ plonk.ProvingKey) (func() error, error) { + if err := validateProvingBackend(); err != nil { + return nil, err + } + return func() error { return nil }, nil +} diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254.go b/tools/bls-plonk-wrapper/backend_cuda_bn254.go new file mode 100644 index 000000000..915ae2421 --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_cuda_bn254.go @@ -0,0 +1,96 @@ +//go:build bn254 && cuda + +package main + +import ( + "encoding/json" + "fmt" + "math" + "os" + "strconv" + "sync" + + "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/consensys/gnark/backend/plonk" + plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" +) + +func provingCUDAConfig() (cuda.Config, error) { + config := cuda.Config{MemoryBytes: 64 << 30, HeadroomBytes: 8 << 30, Features: cuda.AllFeatures} + switch os.Getenv("IX_PLONK_CUDA_MSM_STRATEGY") { + case "", "split": + config.MSMStrategy = cuda.MSMSplit + case "mask": + config.MSMStrategy = cuda.MSMMask + default: + return config, fmt.Errorf("IX_PLONK_CUDA_MSM_STRATEGY must be split or mask") + } + if value := os.Getenv("IX_PLONK_CUDA_DEVICE"); value != "" { + device, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return config, fmt.Errorf("invalid IX_PLONK_CUDA_DEVICE: %w", err) + } + config.Device = int(device) + } + if value := os.Getenv("IX_PLONK_CUDA_MEMORY_GIB"); value != "" { + gib, err := strconv.ParseUint(value, 10, 64) + if err != nil || gib == 0 || gib > math.MaxUint64>>30 { + return config, fmt.Errorf("IX_PLONK_CUDA_MEMORY_GIB must be a positive bounded integer") + } + config.MemoryBytes = gib << 30 + } + return config, nil +} + +func validateProvingBackend() error { + switch os.Getenv("IX_PLONK_BACKEND") { + case "", "cpu": + return nil + case "cuda": + _, err := provingCUDAConfig() + return err + default: + return fmt.Errorf("IX_PLONK_BACKEND must be cpu or cuda") + } +} + +func configureProvingBackend(pk plonk.ProvingKey) (func() error, error) { + if err := validateProvingBackend(); err != nil { + return nil, err + } + if os.Getenv("IX_PLONK_BACKEND") != "cuda" { + return func() error { return nil }, nil + } + config, err := provingCUDAConfig() + if err != nil { + return nil, err + } + nativePK, ok := pk.(*plonkbn254.ProvingKey) + if !ok { + return nil, fmt.Errorf("CUDA PLONK requires a BN254 proving key") + } + accelerator, err := cuda.Attach(nativePK, config) + if err != nil { + return nil, err + } + var once sync.Once + var closeErr error + return func() error { + once.Do(func() { + closeErr = accelerator.Close() + stats, statsErr := accelerator.Stats() + if statsErr == nil { + data, err := json.Marshal(struct { + Kind string `json:"kind"` + Device int `json:"device"` + Strategy string `json:"msm_strategy"` + Stats cuda.Stats `json:"stats"` + }{"plonk_cuda_metrics", config.Device, config.MSMStrategy.String(), stats}) + if err == nil { + fmt.Fprintln(os.Stderr, string(data)) + } + } + }) + return closeErr + }, nil +} diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go b/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go new file mode 100644 index 000000000..a716f4970 --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go @@ -0,0 +1,99 @@ +//go:build bn254 && cuda + +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + native "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +func TestCUDAProvingBackendFactory(t *testing.T) { + if os.Getenv("IX_PLONK_CUDA_TEST") != "1" { + t.Skip("set IX_PLONK_CUDA_TEST=1 for the native CUDA backend factory proof test") + } + t.Setenv("IX_PLONK_BACKEND", "cuda") + t.Setenv("IX_PLONK_CUDA_MEMORY_GIB", "8") + p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + if err != nil { + t.Fatal(err) + } + var receipt struct { + Format, Blake3, SetupID, IgnitionManifest string + PublicMaxDegree, Powers uint64 + } + if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { + t.Fatal(err) + } + srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128, p) + if err != nil { + t.Fatal(err) + } + ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: true}) + if err != nil { + t.Fatal(err) + } + n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())) + lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) + if err != nil { + t.Fatal(err) + } + pk, vk, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) + if err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7}, ecc.BN254.ScalarField()) + if err != nil { + t.Fatal(err) + } + public, err := frontend.NewWitness(&publicClaim{Claim: [2]frontend.Variable{49, 1}}, ecc.BN254.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } + cleanup, err := configureProvingBackend(pk) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := cleanup(); err != nil { + t.Error(err) + } + }) + nativePK := pk.(*native.ProvingKey) + accelerator, ok := nativePK.Accelerator().(*cuda.Accelerator) + if !ok { + t.Fatal("CUDA factory did not attach its accelerator") + } + proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + if err != nil { + t.Fatal(err) + } + if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + stats, err := accelerator.Stats() + if err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 || stats.QuotientCalls != 1 || stats.NumeratorCalls != 0 { + t.Fatalf("factory proof did not execute fused CUDA operations: %+v", stats) + } + for range 2 { + if err := cleanup(); err != nil { + t.Fatal(err) + } + if nativePK.Accelerator() != nil { + t.Fatal("CUDA factory cleanup left an attached accelerator") + } + } +} diff --git a/tools/bls-plonk-wrapper/backend_no_cuda_test.go b/tools/bls-plonk-wrapper/backend_no_cuda_test.go new file mode 100644 index 000000000..da805a536 --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_no_cuda_test.go @@ -0,0 +1,15 @@ +//go:build !bn254 || !cuda + +package main + +import "testing" + +func TestCUDARequestRequiresBuiltBackend(t *testing.T) { + t.Setenv("IX_PLONK_BACKEND", "cuda") + if err := validateProvingBackend(); err == nil { + t.Fatal("accepted unavailable CUDA backend") + } + if _, err := configureProvingBackend(nil); err == nil { + t.Fatal("configured unavailable CUDA backend") + } +} diff --git a/tools/bls-plonk-wrapper/backend_selection_test.go b/tools/bls-plonk-wrapper/backend_selection_test.go new file mode 100644 index 000000000..91d975c94 --- /dev/null +++ b/tools/bls-plonk-wrapper/backend_selection_test.go @@ -0,0 +1,27 @@ +package main + +import "testing" + +func TestCPUBackendSelectionNeedsNoGPU(t *testing.T) { + t.Setenv("IX_PLONK_BACKEND", "cpu") + t.Setenv("IX_PLONK_CUDA_DEVICE", "invalid") + t.Setenv("IX_PLONK_CUDA_MEMORY_GIB", "invalid") + t.Setenv("IX_PLONK_CUDA_MSM_STRATEGY", "invalid") + if err := validateProvingBackend(); err != nil { + t.Fatal(err) + } + close, err := configureProvingBackend(nil) + if err != nil { + t.Fatal(err) + } + if err := close(); err != nil { + t.Fatal(err) + } + if err := close(); err != nil { + t.Fatal(err) + } + t.Setenv("IX_PLONK_BACKEND", "unknown") + if err := validateProvingBackend(); err == nil { + t.Fatal("accepted unknown backend") + } +} diff --git a/tools/bls-plonk-wrapper/backend_test.go b/tools/bls-plonk-wrapper/backend_test.go index 0e4194a3b..5e141a821 100644 --- a/tools/bls-plonk-wrapper/backend_test.go +++ b/tools/bls-plonk-wrapper/backend_test.go @@ -1,3 +1,5 @@ +//go:build !bn254 + package main import ( diff --git a/tools/bls-plonk-wrapper/blake3_test.go b/tools/bls-plonk-wrapper/blake3_test.go index e7d1cdbb4..078bb5076 100644 --- a/tools/bls-plonk-wrapper/blake3_test.go +++ b/tools/bls-plonk-wrapper/blake3_test.go @@ -4,7 +4,6 @@ import ( "io" "testing" - "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark/frontend" "github.com/consensys/gnark/frontend/cs/scs" "github.com/consensys/gnark/std/math/uints" @@ -44,11 +43,11 @@ func TestBlake3Boundaries(t *testing.T) { circuit := &blakeCircuit{Input: make([]uints.U8, n)} assignment := &blakeCircuit{Input: uints.NewU8Array(input)} copy(assignment.Output[:], uints.NewU8Array(expected[:])) - if err := test.IsSolved(circuit, assignment, ecc.BLS12_381.ScalarField()); err != nil { + if err := test.IsSolved(circuit, assignment, curveID.ScalarField()); err != nil { t.Fatalf("length %d: %v", n, err) } assignment.Output[95] = uints.NewU8(expected[95] ^ 1) - if err := test.IsSolved(circuit, assignment, ecc.BLS12_381.ScalarField()); err == nil { + if err := test.IsSolved(circuit, assignment, curveID.ScalarField()); err == nil { t.Fatalf("length %d accepted changed XOF state", n) } } @@ -63,11 +62,11 @@ func TestBlake3CompiledConstraints(t *testing.T) { circuit := &blakeCircuit{Input: make([]uints.U8, len(input))} assignment := &blakeCircuit{Input: uints.NewU8Array(input)} copy(assignment.Output[:], uints.NewU8Array(expected[:])) - ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, circuit) + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, circuit) if err != nil { t.Fatal(err) } - w, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) if err != nil { t.Fatal(err) } @@ -76,7 +75,7 @@ func TestBlake3CompiledConstraints(t *testing.T) { } t.Logf("one-block BLAKE3 XOF: %d constraints", ccs.GetNbConstraints()) assignment.Input[0] = uints.NewU8(input[0] ^ 1) - w, err = frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + w, err = frontend.NewWitness(assignment, curveID.ScalarField()) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/build_cuda.py b/tools/bls-plonk-wrapper/build_cuda.py new file mode 100644 index 000000000..54c4a52bf --- /dev/null +++ b/tools/bls-plonk-wrapper/build_cuda.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Build the optional BN254 CUDA helper against authenticated local sources.""" + +import argparse +import fcntl +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tempfile + + +GNARK_VERSION = "v0.16.5-0.20261015185324-262cf453c107" +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def run(args, **kwargs): + return subprocess.run([str(x) for x in args], check=True, **kwargs) + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def patch_bytes(): + return b"".join((HERE / "patches" / name).read_bytes() for name in ( + "gnark-cuda-v1.patch", "gnark-cuda-generator-v1.patch")) + + +def tree_hashes(root): + result = {} + for directory, directories, files in os.walk(root): + for name in directories + files: + if (Path(directory) / name).is_symlink(): + raise ValueError("symlink in gnark module tree") + for name in files: + path = Path(directory) / name + result[str(path.relative_to(root))] = digest(path) + return result + + +def apply_patch(source, vendored=False): + receipt = json.loads((HERE / "patches/gnark-cuda-v1.json").read_text()) + if receipt["version"] != GNARK_VERSION: + raise ValueError("CUDA patch targets a different gnark version") + selected = {} + for relative, hashes in receipt["files"].items(): + path = source / relative + if Path(relative).is_absolute() or ".." in Path(relative).parts: + raise ValueError("invalid path in gnark patch receipt") + if vendored and not path.exists() and not relative.startswith("backend/plonk/bn254/"): + continue + before = hashes["before"] + if (before is None and path.exists()) or (before is not None and (not path.is_file() or digest(path) != before)): + raise ValueError(f"gnark source differs from patch receipt: {relative}") + selected[relative] = hashes + text = patch_bytes().decode() + blocks = text.split("diff --git ") + if blocks[0].strip(): + raise ValueError("gnark patch must use git diff headers") + filtered = [] + for block in blocks[1:]: + line = block.splitlines()[0] + relative = line.split(" b/", 1)[1] + if relative in selected: + filtered.append("diff --git " + block) + if not filtered: + raise ValueError("no authenticated gnark patch files selected") + run(["patch", "--batch", "--fuzz=0", "-p1"], cwd=source, + input="".join(filtered), text=True) + for relative, hashes in selected.items(): + if digest(source / relative) != hashes["after"]: + raise ValueError(f"patched gnark digest differs: {relative}") + + +def patched_module(go, build, environment): + info = json.loads(subprocess.check_output( + [go, "list", "-m", "-json", "github.com/consensys/gnark"], + cwd=HERE, env=environment, text=True)) + if info.get("Version") != GNARK_VERSION or info.get("Replace"): + raise ValueError("CUDA build requires the pinned, unreplaced gnark module") + patch_hash = hashlib.sha256( + patch_bytes() + + (HERE / "patches/gnark-cuda-v1.json").read_bytes()).hexdigest() + expected = tree_hashes(Path(info["Dir"])) + receipt = json.loads((HERE / "patches/gnark-cuda-v1.json").read_text()) + for relative, hashes in receipt["files"].items(): + if expected.get(relative) != hashes["before"]: + raise ValueError(f"gnark module differs from the pinned patch input: {relative}") + expected[relative] = hashes["after"] + destination = build / ("gnark-" + patch_hash[:20]) + if not destination.exists(): + temporary = Path(tempfile.mkdtemp(prefix=".gnark-", dir=build)) + try: + shutil.copytree(info["Dir"], temporary, dirs_exist_ok=True) + for directory, _, files in os.walk(temporary): + Path(directory).chmod(Path(directory).stat().st_mode | stat.S_IWUSR) + for name in files: + path = Path(directory) / name + path.chmod(path.stat().st_mode | stat.S_IWUSR) + apply_patch(temporary) + temporary.rename(destination) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + else: + if tree_hashes(destination) != expected: + raise ValueError("private gnark build copy differs from the complete pinned source tree") + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path) + parser.add_argument("--build-dir", type=Path) + parser.add_argument("--sppark", type=Path) + parser.add_argument("--blst", type=Path) + parser.add_argument("--go", default=os.environ.get("GO", "go")) + parser.add_argument("--test-binary", action="store_true") + parser.add_argument("--package", choices=[".", "./internal/cuda"], default=".") + parser.add_argument("--reuse-native", action="store_true") + parser.add_argument("--patch-vendor", type=Path) + args = parser.parse_args() + if args.patch_vendor: + apply_patch(args.patch_vendor.resolve(), vendored=True) + return + if not all([args.out, args.build_dir, args.sppark, args.blst]): + parser.error("--out, --build-dir, --sppark and --blst are required") + output = args.out.resolve() + build = args.build_dir.resolve() + build.mkdir(parents=True, exist_ok=True) + output.parent.mkdir(parents=True, exist_ok=True) + with (build / ".build.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + build_helper(args, output, build) + + +def build_helper(args, output, build): + environment = dict(os.environ, GOTOOLCHAIN="local", CGO_ENABLED="1") + source = patched_module(args.go, build, environment) + native = build / "native" + if args.reuse_native: + specification = importlib.util.spec_from_file_location("ix_plonk_native_build", + ROOT / "crates/proof-compression/cuda/plonk/build.py") + native_builder = importlib.util.module_from_spec(specification) + previous_bytecode_setting = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + specification.loader.exec_module(native_builder) + finally: + sys.dont_write_bytecode = previous_bytecode_setting + native_builder.validate_sources(args.sppark.resolve(), args.blst.resolve()) + receipt = json.loads((native / "build-manifest.json").read_text()) + if "patched_sppark" not in receipt: + raise ValueError("native library needs rebuilding with the pinned sppark patch") + patched_receipt = native_builder.validate_patched_sppark(Path(receipt["patched_sppark"]["root"])) + if patched_receipt != receipt["patched_sppark"]: + raise ValueError("private sppark source identity differs from native build receipt") + architectures = environment.get("IX_PLONK_CUDA_ARCHS", "120").replace(",", " ").split() + if architectures != receipt["architectures"]: + raise ValueError("native library architecture selection changed") + if digest(native / "libix_plonk_bn254_cuda.so") != receipt["library_sha256"]: + raise ValueError("native library differs from its build receipt") + for path, expected in receipt["inputs_sha256"].items(): + if digest(Path(path)) != expected: + raise ValueError(f"native build input changed: {path}") + else: + run(["python3", ROOT / "crates/proof-compression/cuda/plonk/build.py", + "--sppark", args.sppark.resolve(), "--blst", args.blst.resolve(), + "--out", native], env=environment) + modfile = build / "cuda.mod" + modfile.write_text((HERE / "go.mod").read_text() + + "\nreplace github.com/consensys/gnark => " + + json.dumps(str(source)) + "\n") + shutil.copyfile(HERE / "go.sum", modfile.with_suffix(".sum")) + header = ROOT / "crates/proof-compression/cuda/plonk" + native_identity = hashlib.sha256((digest(native / "libix_plonk_bn254_cuda.so") + + digest(header / "ix_plonk_bn254.h")).encode()).hexdigest() + # cgo does not track headers outside its package as Go cache inputs. + environment["CGO_CFLAGS"] = (environment.get("CGO_CFLAGS", "") + + f" -I{header} -DIX_PLONK_NATIVE_BUILD_{native_identity[:20]}=1").strip() + library_dir = output.parent.parent / "lib" + library_dir.mkdir(parents=True, exist_ok=True) + library = library_dir / "libix_plonk_bn254_cuda.so" + with tempfile.NamedTemporaryFile(prefix=".plonk-cuda-", dir=library_dir, delete=False) as stream: + temporary_library = Path(stream.name) + try: + shutil.copy2(native / library.name, temporary_library) + temporary_library.replace(library) + finally: + temporary_library.unlink(missing_ok=True) + environment["CGO_LDFLAGS"] = (environment.get("CGO_LDFLAGS", "") + + f" -L{library_dir} -Wl,-rpath,$ORIGIN/../lib -lix_plonk_bn254_cuda").strip() + command = [args.go, "test", "-c"] if args.test_binary else [args.go, "build"] + temporary_output = output.with_suffix(output.suffix + ".tmp") + command += ["-buildvcs=false", "-trimpath", "-mod=readonly", + "-modfile=" + str(modfile), "-tags", "bn254,cuda", + "-o", str(temporary_output), args.package] + run(command, cwd=HERE, env=environment) + temporary_output.replace(output) + record = { + "format": "ix-bn254-plonk-cuda-build-v1", "gnark_version": GNARK_VERSION, + "gnark_patch_sha256": hashlib.sha256(patch_bytes()).hexdigest(), + "go_version": subprocess.check_output([args.go, "version"], text=True).strip(), + "binary": str(output), "binary_sha256": digest(output), + "library": str(library), "library_sha256": digest(library), + "native_build_identity": native_identity, + "native_receipts": [str(p) for p in native.glob("*.json")], + "go_command": command, "test_binary": args.test_binary, + } + output.with_suffix(output.suffix + ".build.json").write_text(json.dumps(record, indent=2) + "\n") + print(json.dumps(record)) + + +if __name__ == "__main__": + main() diff --git a/tools/bls-plonk-wrapper/circuit.go b/tools/bls-plonk-wrapper/circuit.go index 1be9471aa..9bb3fc2e5 100644 --- a/tools/bls-plonk-wrapper/circuit.go +++ b/tools/bls-plonk-wrapper/circuit.go @@ -5,22 +5,17 @@ import ( "fmt" "math/big" - bls "github.com/consensys/gnark-crypto/ecc/bls12-381" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" "github.com/consensys/gnark/frontend" - sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" "github.com/consensys/gnark/std/math/bits" - "github.com/consensys/gnark/std/math/cmp" "github.com/consensys/gnark/std/math/emulated" "github.com/consensys/gnark/std/math/uints" ) type wrapperCircuit struct { Claim [2]frontend.Variable `gnark:",public"` - Points []sw.G1Affine - Deferred [2]sw.G1Affine + Points []circuitG1 + Deferred [2]circuitG1 Values []frontend.Variable Profile *profile `gnark:"-"` } @@ -34,29 +29,6 @@ func scalarBytes(api frontend.API, u *uints.BinaryField[uints.U32], v frontend.V return out } -func pointBytes(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[sw.BaseField], point *sw.G1Affine) []uints.U8 { - f.AssertIsInRange(&point.X) - f.AssertIsInRange(&point.Y) - x := f.ToBitsCanonical(&point.X) - y := f.ToBitsCanonical(&point.Y) - half := new(big.Int).Rsh(new(big.Int).Set(fp.Modulus()), 1) - bound := make([]frontend.Variable, len(y)) - for i := range bound { - bound[i] = half.Bit(i) - } - sign := cmp.IsLessBinary(api, bound, y) - infinity := api.And(f.IsZero(&point.X), f.IsZero(&point.Y)) - for len(x) < 384 { - x = append(x, 0) - } - out := make([]uints.U8, 48) - for i := range out { - out[47-i] = u.Bytes.FromBits(x[8*i : 8*i+8]...) - } - out[0] = u.ByteValueOf(api.Add(u.Value(out[0]), 128, api.Mul(64, infinity), api.Mul(32, sign))) - return out -} - func constantU64(value uint64) []uints.U8 { var bytes [8]byte binary.LittleEndian.PutUint64(bytes[:], value) @@ -71,7 +43,7 @@ func littleEndian(api frontend.API, u *uints.BinaryField[uints.U32], bytes []uin return value } -func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints.U32], encoded [][]uints.U8, publics []frontend.Variable) []frontend.Variable { +func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints.U32], encoded [][]uints.U8, publics []frontend.Variable) ([]frontend.Variable, []uints.U8) { p := c.Profile seed, _ := unhex(p.Seed, -1) t := newTranscript(api, u, seed) @@ -132,7 +104,7 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints t.observe(point) } ch[5] = t.sample() - return ch + return ch, t.state } func (p *scalarProgram) constrain(api frontend.API, challenges, values, publics []frontend.Variable) ([]frontend.Variable, []frontend.Variable) { @@ -174,6 +146,15 @@ func (c *wrapperCircuit) Define(api frontend.API) error { } func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { + return c.constrainWithOptions(api, phase, circuitOptions{classicGLV: true}) +} + +type circuitOptions struct { + batchPairings bool + classicGLV bool +} + +func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, options circuitOptions) error { p := c.Profile if p == nil { return fmt.Errorf("missing trusted profile") @@ -185,24 +166,28 @@ func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { if err != nil { return err } - f, err := emulated.NewField[sw.BaseField](api) + f, err := emulated.NewField[baseField](api) if err != nil { return err } - s, err := emulated.NewField[sw.ScalarField](api) + s, err := emulated.NewField[scalarField](api) if err != nil { return err } - curve, err := sw_emulated.New[sw.BaseField, sw.ScalarField](api, sw_emulated.GetBLS12381Params()) + parameters := curveParameters() + if options.classicGLV { + parameters.PreferClassicGLV = true + } + curve, err := sw_emulated.New[baseField, scalarField](api, parameters) if err != nil { return err } - pairing, err := sw.NewPairing(api) + pairing, err := newPairing(api) if err != nil { return err } encoded := make([][]uints.U8, p.PointCount+len(p.Fixed)) - points := make([]*sw.G1Affine, 0, p.PointCount+len(p.Fixed)+1) + points := make([]*circuitG1, 0, p.PointCount+len(p.Fixed)+1) for i := range c.Points { pairing.AssertIsOnG1(&c.Points[i]) encoded[i] = pointBytes(api, u, f, &c.Points[i]) @@ -210,13 +195,13 @@ func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { } for i, fixed := range p.Fixed { native, _ := fixed.native() - point := sw.NewG1Affine(native) + point := newCircuitG1(native) points = append(points, &point) - bytes := native.Bytes() + bytes := encodePoint(native) encoded[p.PointCount+i] = uints.NewU8Array(bytes[:]) } g, _ := p.Generator.native() - generator := sw.NewG1Affine(g) + generator := newCircuitG1(g) points = append(points, &generator) publics := make([]frontend.Variable, 23) publics[0] = 0 @@ -232,13 +217,13 @@ func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { for i := range c.Deferred { pairing.AssertIsOnG1(&c.Deferred[i]) bytes := pointBytes(api, u, f, &c.Deferred[i]) - publics[19+2*i] = littleEndian(api, u, bytes[:24]) - publics[20+2*i] = littleEndian(api, u, bytes[24:]) + publics[19+2*i] = littleEndian(api, u, bytes[:pointLimbBytes]) + publics[20+2*i] = littleEndian(api, u, bytes[pointLimbBytes:]) } if phase == 1 { return nil } - challenges := c.challenges(api, u, encoded, publics) + challenges, packetDigest := c.challenges(api, u, encoded, publics) if phase == 2 { return nil } @@ -258,22 +243,25 @@ func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { if phase == 4 { return nil } + if options.batchPairings { + return c.batchedPairing(api, u, f, s, curve, packetDigest, left, right) + } outer, _ := g2Keys(p.OuterKeys) - h, tauH := sw.NewG2AffineFixed(outer[0]), sw.NewG2AffineFixed(outer[1]) - if err := pairing.PairingCheck([]*sw.G1Affine{left, curve.Neg(right)}, []*sw.G2Affine{&h, &tauH}); err != nil { + h, tauH := newCircuitG2Fixed(outer[0]), newCircuitG2Fixed(outer[1]) + if err := pairing.PairingCheck([]*circuitG1{left, curve.Neg(right)}, []*circuitG2{&h, &tauH}); err != nil { return err } if phase == 5 { return nil } inner, _ := g2Keys(p.DeferredKeys) - ih, itauH := sw.NewG2AffineFixed(inner[0]), sw.NewG2AffineFixed(inner[1]) - return pairing.PairingCheck([]*sw.G1Affine{&c.Deferred[0], &c.Deferred[1]}, []*sw.G2Affine{&ih, &itauH}) + ih, itauH := newCircuitG2Fixed(inner[0]), newCircuitG2Fixed(inner[1]) + return pairing.PairingCheck([]*circuitG1{&c.Deferred[0], &c.Deferred[1]}, []*circuitG2{&ih, &itauH}) } // Fixed-point relations are checked at circuit construction. Variable inputs // retain their curve and subgroup checks even when a scalar is constant zero. -func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars []frontend.Variable) ([]*sw.G1Affine, []frontend.Variable) { +func foldFixedBases(api frontend.API, p *profile, points []*circuitG1, scalars []frontend.Variable) ([]*circuitG1, []frontend.Variable) { weights := append([]frontend.Variable(nil), scalars...) indices := make([]int, p.PointCount) for i := range indices { @@ -282,7 +270,7 @@ func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars generator, _ := p.Generator.native() generatorIndex := len(points) - 1 type base struct { - point bls.G1Affine + point nativeG1 index int } kept := []base{{generator, generatorIndex}} @@ -294,7 +282,7 @@ func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars } matched := false for _, previous := range kept { - var negative bls.G1Affine + var negative nativeG1 negative.Neg(&previous.point) if point.Equal(&previous.point) || point.Equal(&negative) { weight := weights[index] @@ -306,7 +294,7 @@ func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars break } for _, shift := range []int64{-2, -1, 1, 2} { - var offset, candidate bls.G1Affine + var offset, candidate nativeG1 magnitude := shift if magnitude < 0 { magnitude = -magnitude @@ -334,7 +322,7 @@ func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars } } indices = append(indices, generatorIndex) - selected := make([]*sw.G1Affine, len(indices)) + selected := make([]*circuitG1, len(indices)) coefficients := make([]frontend.Variable, len(indices)) for i, index := range indices { selected[i], coefficients[i] = points[index], weights[index] @@ -342,14 +330,14 @@ func foldFixedBases(api frontend.API, p *profile, points []*sw.G1Affine, scalars return selected, coefficients } -func completeMSM(api frontend.API, curve *sw_emulated.Curve[sw.BaseField, sw.ScalarField], field *emulated.Field[sw.ScalarField], points []*sw.G1Affine, scalars []frontend.Variable) (*sw.G1Affine, error) { +func completeMSM(api frontend.API, curve *sw_emulated.Curve[baseField, scalarField], field *emulated.Field[scalarField], points []*circuitG1, scalars []frontend.Variable) (*circuitG1, error) { if len(points) != len(scalars) { return nil, fmt.Errorf("MSM point and scalar counts differ") } - var direct *sw.G1Affine - var bases []*sw.G1Affine - var coefficients []*sw.Scalar - minusOne := new(big.Int).Sub(fr.Modulus(), big.NewInt(1)) + var direct *circuitG1 + var bases []*circuitG1 + var coefficients []*circuitScalar + minusOne := new(big.Int).Sub(scalarModulus(), big.NewInt(1)) for i, point := range points { if constant, ok := api.Compiler().ConstantValue(scalars[i]); ok { if constant.Sign() == 0 { diff --git a/tools/bls-plonk-wrapper/circuit_bn254_test.go b/tools/bls-plonk-wrapper/circuit_bn254_test.go new file mode 100644 index 000000000..32b6dded3 --- /dev/null +++ b/tools/bls-plonk-wrapper/circuit_bn254_test.go @@ -0,0 +1,173 @@ +//go:build bn254 + +package main + +import ( + "math/big" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + bls "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fp" + "github.com/consensys/gnark/frontend" + sw "github.com/consensys/gnark/std/algebra/emulated/sw_bn254" + "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" + "github.com/consensys/gnark/test" +) + +type encodingCircuit struct { + Point sw.G1Affine + Encoded [32]uints.U8 +} + +func (c *encodingCircuit) Define(api frontend.API) error { + u, err := uints.NewBinaryField[uints.U32](api) + if err != nil { + return err + } + f, err := emulated.NewField[sw.BaseField](api) + if err != nil { + return err + } + pairing, err := sw.NewPairing(api) + if err != nil { + return err + } + pairing.AssertIsOnG1(&c.Point) + for i, b := range pointBytes(api, u, f, &c.Point) { + u.Bytes.AssertIsEqual(b, c.Encoded[i]) + } + return nil +} + +func TestIdentityAndCanonicalPointEncoding(t *testing.T) { + _, _, g, _ := bls.Generators() + var negative bls.G1Affine + negative.Neg(&g) + for _, point := range []bls.G1Affine{{}, g, negative} { + assignment := &encodingCircuit{Point: sw.NewG1Affine(point)} + bytes := encodePoint(point) + copy(assignment.Encoded[:], uints.NewU8Array(bytes[:])) + if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BN254.ScalarField()); err != nil { + t.Fatal(err) + } + assignment.Encoded[31] = uints.NewU8(bytes[31] ^ 0x80) + if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BN254.ScalarField()); err == nil { + t.Fatal("accepted wrong sign bit") + } + } + identity := encodePoint(bls.G1Affine{}) + alias := &encodingCircuit{Point: sw.NewG1Affine(bls.G1Affine{})} + copy(alias.Encoded[:], uints.NewU8Array(identity[:])) + alias.Point.X.Limbs = make([]frontend.Variable, 4) + modulus := fp.Modulus() + for i := range alias.Point.X.Limbs { + alias.Point.X.Limbs[i] = new(big.Int).And(new(big.Int).Rsh(new(big.Int).Set(modulus), uint(64*i)), new(big.Int).SetUint64(^uint64(0))) + } + if err := test.IsSolved(&encodingCircuit{}, alias, ecc.BN254.ScalarField()); err == nil { + t.Fatal("accepted noncanonical identity coordinates") + } +} + +func TestBN254CanonicalVectors(t *testing.T) { + _, _, g, _ := bls.Generators() + var negative bls.G1Affine + negative.Neg(&g) + cases := []struct { + p bls.G1Affine + first, last byte + }{{g, 1, 0}, {negative, 1, 0x80}, {bls.G1Affine{}, 0, 0x40}} + for _, c := range cases { + b := encodePoint(c.p) + for i, v := range b { + want := byte(0) + if i == 0 { + want = c.first + } + if i == 31 { + want = c.last + } + if v != want { + t.Fatalf("byte %d: %x != %x", i, v, want) + } + } + } +} + +type completeMSMCircuit struct { + Points [2]circuitG1 + Scalars [2]frontend.Variable + Expected circuitG1 + ClassicGLV bool `gnark:"-"` +} + +func (c *completeMSMCircuit) Define(api frontend.API) error { + field, err := emulated.NewField[scalarField](api) + if err != nil { + return err + } + parameters := curveParameters() + parameters.PreferClassicGLV = c.ClassicGLV + curve, err := sw_emulated.New[baseField, scalarField](api, parameters) + if err != nil { + return err + } + actual, err := completeMSM(api, curve, field, []*circuitG1{&c.Points[0], &c.Points[1]}, c.Scalars[:]) + if err != nil { + return err + } + curve.AssertIsEqual(actual, &c.Expected) + return nil +} + +func TestBN254CompleteMSMExceptionalCases(t *testing.T) { + _, _, g, _ := bls.Generators() + var neg, twice bls.G1Affine + neg.Neg(&g) + twice.Add(&g, &g) + for _, c := range []struct { + points [2]bls.G1Affine + scalars [2]frontend.Variable + expected bls.G1Affine + }{ + {[2]bls.G1Affine{g, g}, [2]frontend.Variable{0, 0}, bls.G1Affine{}}, + {[2]bls.G1Affine{g, neg}, [2]frontend.Variable{1, 1}, bls.G1Affine{}}, + {[2]bls.G1Affine{{}, g}, [2]frontend.Variable{7, 0}, bls.G1Affine{}}, + {[2]bls.G1Affine{g, g}, [2]frontend.Variable{1, 1}, twice}, + } { + w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(c.points[0]), newCircuitG1(c.points[1])}, Scalars: c.scalars, Expected: newCircuitG1(c.expected)} + if err := test.IsSolved(&completeMSMCircuit{}, w, curveID.ScalarField()); err != nil { + t.Fatal(err) + } + } +} + +func TestBN254ClassicGLVMatchesNativeMSM(t *testing.T) { + _, _, g, _ := bls.Generators() + var neg, twice, other bls.G1Affine + neg.Neg(&g) + twice.Add(&g, &g) + other.ScalarMultiplication(&g, big.NewInt(1234567)) + minusOne := new(big.Int).Sub(scalarModulus(), big.NewInt(1)) + scalars := []*big.Int{new(big.Int), big.NewInt(1), big.NewInt(2), minusOne, new(big.Int).Rsh(new(big.Int).Set(scalarModulus()), 1), new(big.Int).Lsh(big.NewInt(1), 200)} + pairs := [][2]bls.G1Affine{{g, g}, {g, neg}, {g, twice}, {other, g}, {{}, g}, {{}, {}}} + for i, pair := range pairs { + for j, s1 := range scalars { + s2 := scalars[(j+i)%len(scalars)] + var a, b, want bls.G1Affine + a.ScalarMultiplication(&pair[0], s1) + b.ScalarMultiplication(&pair[1], s2) + want.Add(&a, &b) + w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(pair[0]), newCircuitG1(pair[1])}, Scalars: [2]frontend.Variable{s1, s2}, Expected: newCircuitG1(want), ClassicGLV: true} + if err := test.IsSolved(&completeMSMCircuit{ClassicGLV: true}, w, curveID.ScalarField()); err != nil { + t.Fatalf("pair %d scalar %d: %v", i, j, err) + } + } + } + w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(g), newCircuitG1(neg)}, Scalars: [2]frontend.Variable{1, 1}, Expected: newCircuitG1(g), ClassicGLV: true} + if err := test.IsSolved(&completeMSMCircuit{ClassicGLV: true}, w, curveID.ScalarField()); err == nil { + t.Fatal("accepted incorrect MSM result") + } +} diff --git a/tools/bls-plonk-wrapper/circuit_test.go b/tools/bls-plonk-wrapper/circuit_test.go index 94269717c..25f1d9127 100644 --- a/tools/bls-plonk-wrapper/circuit_test.go +++ b/tools/bls-plonk-wrapper/circuit_test.go @@ -1,3 +1,5 @@ +//go:build !bn254 + package main import ( diff --git a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go b/tools/bls-plonk-wrapper/cuda_init_bn254_test.go new file mode 100644 index 000000000..7737ce8bf --- /dev/null +++ b/tools/bls-plonk-wrapper/cuda_init_bn254_test.go @@ -0,0 +1,334 @@ +//go:build bn254 && cuda + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + native "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" +) + +func TestBN254CUDAInit(t *testing.T) { + if os.Getenv("IX_PLONK_CUDA_INIT") != "1" { + t.Skip("set IX_PLONK_CUDA_INIT=1 with explicit artifact and claim pins for full Init proofs") + } + repeats := 1 + if value := os.Getenv("IX_PLONK_CUDA_INIT_REPEATS"); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 || parsed > 10 { + t.Fatal("IX_PLONK_CUDA_INIT_REPEATS must be an integer in 1..10") + } + repeats = parsed + } + modes := []string{"cpu", "cuda-unfused", "cuda-fused"} + if value := os.Getenv("IX_PLONK_CUDA_INIT_MODES"); value != "" { + selected := make(map[string]bool) + for _, mode := range strings.Split(value, ",") { + mode = strings.TrimSpace(mode) + if (mode != "cpu" && mode != "cuda-unfused" && mode != "cuda-fused") || selected[mode] { + t.Fatal("IX_PLONK_CUDA_INIT_MODES must be a unique comma-separated subset of cpu,cuda-unfused,cuda-fused") + } + selected[mode] = true + } + filtered := make([]string, 0, len(selected)) + for _, mode := range modes { + if selected[mode] { + filtered = append(filtered, mode) + } + } + modes = filtered + } + flag := func(name string) bool { + switch os.Getenv(name) { + case "", "0": + return false + case "1": + return true + default: + t.Fatalf("%s must be 0 or 1", name) + return false + } + } + cpuProfile := flag("IX_PLONK_CUDA_INIT_CPU_PROFILE") + stageProfile := flag("IX_PLONK_CUDA_INIT_STAGE_PROFILE") + required := func(name string) string { + value := os.Getenv("IX_PLONK_CUDA_INIT_" + name) + if value == "" { + t.Fatalf("IX_PLONK_CUDA_INIT_%s must be explicit", name) + } + return value + } + dir, witnessPath, output := required("ARTIFACTS"), required("WITNESS"), required("OUTPUT") + manifestPin, err := cpuProfileDigest(required("MANIFEST_SHA256")) + if err != nil { + t.Fatal(err) + } + claim, err := cpuProfileDigest(required("EXPECTED_CLAIM")) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(output, 0700); err != nil { + t.Fatal(err) + } + started := time.Now() + m, err := cpuProfileManifest(dir, manifestPin) + if err != nil { + t.Fatal(err) + } + ccs, pk, vk := plonk.NewCS(curveID), plonk.NewProvingKey(curveID), plonk.NewVerifyingKey(curveID) + if err := cpuProfileReadBinary(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { + t.Fatal(err) + } + if err := cpuProfileReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { + t.Fatal(err) + } + if err := cpuProfileReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { + t.Fatal(err) + } + p, err := loadProfile(filepath.Join(dir, "profile.json")) + if err != nil { + t.Fatal(err) + } + if err := m.checkFile(dir, "profile.json"); err != nil { + t.Fatal(err) + } + assignment, err := witnessAssignment(p, witnessPath) + if err != nil { + t.Fatal(err) + } + witness, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + t.Fatal(err) + } + digest, _ := hex.DecodeString(claim) + public, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } + actualPublic, err := witness.Public() + if err != nil { + t.Fatal(err) + } + actualBytes, err := actualPublic.MarshalBinary() + if err != nil { + t.Fatal(err) + } + expectedBytes, err := public.MarshalBinary() + if err != nil || !bytes.Equal(actualBytes, expectedBytes) { + t.Fatal("witness differs from independently expected claim") + } + nativePK := pk.(*native.ProvingKey) + var embedded bytes.Buffer + if _, err := nativePK.Vk.WriteTo(&embedded); err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(embedded.Bytes())) != m.Files["verifying-key.bin"].SHA256 { + t.Fatal("proving key embeds another verifying key") + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + identity, err := fileHash(executable) + if err != nil { + t.Fatal(err) + } + report := map[string]any{ + "format": "ix-bn254-plonk-cuda-init-v1", "complete": false, + "scope": "Externally pinned existing Init artifacts; same loaded circuit, keys and witness for CPU and CUDA, fresh gnark blinding. No compilation or key preparation.", + "manifestSHA256": manifestPin, "manifest": m, "expectedClaim": claim, + "testBinary": identity, "artifactAndWitnessSeconds": time.Since(started).Seconds(), + "repeats": repeats, "modes": modes, "executionOrder": "iteration-major: " + strings.Join(modes, ", "), + "numCPU": runtime.NumCPU(), "gomaxprocs": runtime.GOMAXPROCS(0), + "freshCUDAContextPerProof": true, "forcedGC": false, + "cpuProfile": cpuProfile, "stageProfile": stageProfile, + "cpuProfileScope": "Only the first selected run: CPU samples immediately around plonk.Prove, excluding artifact/witness loading, backend setup, serialization and verification. Profiler startup/shutdown are timed separately; subsequent runs are unprofiled.", + "stageProfileScope": "Optional gnark spans from one monotonic Prove origin. Task, compute and wait intervals may nest or overlap and must not be summed as independent wall-clock stages.", + } + if stageProfile { + t.Cleanup(func() { nativePK.SetStageObserverV1(nil) }) + } + var runs []map[string]any + for iteration := 1; iteration <= repeats; iteration++ { + for _, mode := range modes { + stem := mode + if iteration > 1 { + stem = fmt.Sprintf("%s-%02d", mode, iteration) + } + t.Logf("stage=%s iteration=%d repeats=%d", mode, iteration, repeats) + setupStarted := time.Now() + var accelerator *cuda.Accelerator + var cudaConfig *cuda.Config + if mode != "cpu" { + config, err := provingCUDAConfig() + if err != nil { + t.Fatal(err) + } + if mode == "cuda-unfused" { + config.Features &^= native.AcceleratorFeatureQuotientV1 + } + cudaConfig = &config + accelerator, err = cuda.Attach(nativePK, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = accelerator.Close() }) + } + setupSeconds := time.Since(setupStarted).Seconds() + var setupStats cuda.Stats + if accelerator != nil { + setupStats, err = accelerator.Stats() + if err != nil { + t.Fatal(err) + } + } + var stageMu sync.Mutex + var stageEvents []native.StageEventV1 + if stageProfile { + nativePK.SetStageObserverV1(func(event native.StageEventV1) { + stageMu.Lock() + stageEvents = append(stageEvents, event) + stageMu.Unlock() + }) + } + profileThisRun := cpuProfile && len(runs) == 0 + profilePath := "" + var profileFile *os.File + var profileStartSeconds, profileStopSeconds float64 + if profileThisRun { + profileStarted := time.Now() + profilePath = filepath.Join(output, stem+".cpu.pprof") + profileFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if err := pprof.StartCPUProfile(profileFile); err != nil { + profileFile.Close() + t.Fatal(err) + } + profileStartSeconds = time.Since(profileStarted).Seconds() + } + proveStarted := time.Now() + var proof plonk.Proof + var proveSeconds float64 + var profileErr error + func() { + if profileFile != nil { + defer func() { + profileStopped := time.Now() + pprof.StopCPUProfile() + profileErr = profileFile.Close() + profileStopSeconds = time.Since(profileStopped).Seconds() + }() + } + proof, err = plonk.Prove(ccs, pk, witness, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + proveSeconds = time.Since(proveStarted).Seconds() + }() + var spans []map[string]any + if stageProfile { + nativePK.SetStageObserverV1(nil) + stageMu.Lock() + for _, event := range stageEvents { + spans = append(spans, map[string]any{"stage": event.Stage, "phase": event.Phase, "startSeconds": event.Start.Seconds(), "endSeconds": event.End.Seconds(), "durationSeconds": (event.End - event.Start).Seconds(), "failed": event.Failed}) + } + stageMu.Unlock() + } + if err != nil { + t.Fatal(err) + } + if profileErr != nil { + t.Fatal(profileErr) + } + if stageProfile && len(spans) == 0 { + t.Fatal("enabled stage observer produced no spans") + } + var stats cuda.Stats + if accelerator != nil { + stats, err = accelerator.Stats() + if err != nil { + t.Fatal(err) + } + if err := accelerator.Close(); err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 || stats.FFTCalls == 0 { + t.Fatal("CUDA arithmetic was not dispatched") + } + if mode == "cuda-fused" && (stats.QuotientCalls != 1 || stats.NumeratorCalls != 0) { + t.Fatal("fused quotient was not dispatched exactly once") + } + if mode == "cuda-unfused" && (stats.QuotientCalls != 0 || stats.NumeratorCalls != 1) { + t.Fatal("unfused numerator was not dispatched exactly once") + } + } + var encoded bytes.Buffer + if _, err := proof.WriteTo(&encoded); err != nil { + t.Fatal(err) + } + decoded, err := decodeProof(encoded.Bytes(), m.Commitments) + if err != nil { + t.Fatal(err) + } + verifyStarted := time.Now() + if err := plonk.Verify(decoded, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + verifySeconds := time.Since(verifyStarted).Seconds() + proofPath := filepath.Join(output, stem+".proof.bin") + if err := os.WriteFile(proofPath, encoded.Bytes(), 0600); err != nil { + t.Fatal(err) + } + solidityPath := filepath.Join(output, stem+".solidity.bin") + if err := os.WriteFile(solidityPath, decoded.MarshalSolidity(), 0600); err != nil { + t.Fatal(err) + } + run := map[string]any{"mode": mode, "iteration": iteration, "cudaConfig": cudaConfig, "startedUTC": setupStarted.UTC().Format(time.RFC3339Nano), + "proveStartedUTC": proveStarted.UTC().Format(time.RFC3339Nano), "finishedUTC": time.Now().UTC().Format(time.RFC3339Nano), + "backendSetupSeconds": setupSeconds, "proveSeconds": proveSeconds, + "verifySeconds": verifySeconds, "proofPath": proofPath, "solidityPath": solidityPath, "proofBytes": encoded.Len(), + "proofSHA256": fmt.Sprintf("%x", sha256.Sum256(encoded.Bytes())), "cudaStats": stats, + "cudaSetupStats": setupStats} + run["cpuProfiled"], run["cpuProfilePath"] = profileThisRun, profilePath + run["profileStartSeconds"], run["profileStopSeconds"] = profileStartSeconds, profileStopSeconds + run["stageProfiled"], run["stageSpans"] = stageProfile, spans + if profileThisRun { + profileIdentity, err := fileHash(profilePath) + if err != nil { + t.Fatal(err) + } + run["cpuProfileArtifact"] = profileIdentity + report["profiledRun"] = map[string]any{"mode": mode, "iteration": iteration, "path": profilePath, "artifact": profileIdentity} + } + if cudaConfig != nil { + run["msmStrategy"] = cudaConfig.MSMStrategy.String() + } + runs = append(runs, run) + report["runs"] = runs + if err := writeJSON(filepath.Join(output, "report.json"), report); err != nil { + t.Fatal(err) + } + t.Logf("stage=%s iteration=%d prove_seconds=%.6f setup_seconds=%.6f verify_seconds=%.6f stats=%+v", mode, iteration, proveSeconds, setupSeconds, verifySeconds, stats) + } + } + report["complete"], report["wallSeconds"] = true, time.Since(started).Seconds() + if err := writeJSON(filepath.Join(output, "report.json"), report); err != nil { + t.Fatal(err) + } +} diff --git a/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go b/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go new file mode 100644 index 000000000..5ada43682 --- /dev/null +++ b/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go @@ -0,0 +1,176 @@ +//go:build bn254 && cuda + +package main + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + native "github.com/consensys/gnark/backend/plonk/bn254" +) + +func TestCUDAStructuredMSMBenchmark(t *testing.T) { + if os.Getenv("IX_PLONK_CUDA_MSM_BENCH") != "1" { + t.Skip("set IX_PLONK_CUDA_MSM_BENCH=1 and explicit artifact pins for 2^20 structured MSM comparisons") + } + if os.Getenv("GOMAXPROCS") != "" { + t.Fatal("unset GOMAXPROCS to keep default CPU parallelism") + } + required := func(name string) string { + value := os.Getenv("IX_PLONK_CUDA_MSM_BENCH_" + name) + if value == "" { + t.Fatalf("IX_PLONK_CUDA_MSM_BENCH_%s must be explicit", name) + } + return value + } + dir, out := required("ARTIFACTS"), required("OUTPUT") + pin, err := cpuProfileDigest(required("MANIFEST_SHA256")) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(out, 0700); err != nil { + t.Fatal(err) + } + started := time.Now() + m, err := cpuProfileManifest(dir, pin) + if err != nil { + t.Fatal(err) + } + validationSeconds := time.Since(started).Seconds() + started = time.Now() + pk := new(native.ProvingKey) + if err := cpuProfileReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { + t.Fatal(err) + } + loadSeconds := time.Since(started).Seconds() + config, err := provingCUDAConfig() + if err != nil { + t.Fatal(err) + } + config.Features = native.AcceleratorFeatureMSMV1 + started = time.Now() + a, err := cuda.Attach(pk, config) + if err != nil { + t.Fatal(err) + } + setupSeconds := time.Since(started).Seconds() + defer func() { + if err := a.Close(); err != nil { + t.Error(err) + } + }() + const count = 1 << 20 + const offset = 2 + points := pk.KzgLagrange.G1[offset : offset+count] + report := map[string]any{"format": "ix-bn254-cuda-structured-msm-v1", "complete": false, "scope": "Synthetic structured scalars and authenticated production Lagrange bases; exact CPU/GPU MSM comparison, not a PLONK proof or full Init benchmark.", "manifestSHA256": pin, "provingKeySHA256": m.Files["proving-key.bin"].SHA256, "count": count, "offset": offset, "strategy": config.MSMStrategy.String(), "validationSeconds": validationSeconds, "keyLoadSeconds": loadSeconds, "gpuSetupSeconds": setupSeconds} + var rows []map[string]any + defer func() { + report["rows"] = rows + if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { + t.Error(err) + } + }() + var c, d, minusC fr.Element + if _, err := c.SetString("837628908879347212334541733913009433"); err != nil { + t.Fatal(err) + } + d.SetUint64(7919) + minusC.Neg(&c) + one := fr.One() + for _, kind := range []string{"shifted-binary", "signed-zero", "three-hot", "distinct-shifted", "narrow-shifted", "narrow-small"} { + started = time.Now() + values := make([]fr.Element, count) + for i := range values { + switch kind { + case "shifted-binary": + values[i] = minusC + if i%4 == 0 { + values[i].Add(&values[i], &one) + } + case "signed-zero": + switch i % 3 { + case 0: + values[i] = c + case 1: + values[i] = minusC + } + case "three-hot": + values[i] = c + switch i % 3 { + case 0: + values[i].Sub(&values[i], &d) + case 1: + values[i].Add(&values[i], &d) + } + case "distinct-shifted": + values[i].SetUint64(uint64(i)).Add(&values[i], &c) + case "narrow-shifted": + values[i].SetUint64(uint64(i%65536)).Add(&values[i], &c) + case "narrow-small": + values[i].SetUint64(uint64(i % 65536)) + } + if i%17 == 0 { + var seed [8]byte + binary.LittleEndian.PutUint64(seed[:], uint64(i)) + digest := sha256.Sum256(seed[:]) + values[i].SetBytes(digest[:]) + } + } + generationSeconds := time.Since(started).Seconds() + original := append([]fr.Element(nil), values...) + started = time.Now() + var want curve.G1Affine + if _, err := want.MultiExp(points, values, ecc.MultiExpConfig{}); err != nil { + t.Fatal(err) + } + cpuSeconds := time.Since(started).Seconds() + before, err := a.Stats() + if err != nil { + t.Fatal(err) + } + var gpuSeconds []float64 + for iteration := range 2 { + started = time.Now() + got, err := a.MultiExp(points, values) + elapsed := time.Since(started).Seconds() + if err != nil || !got.Equal(&want) { + t.Fatalf("%s iteration %d differs from CPU: %v", kind, iteration, err) + } + if !slices.Equal(original, values) { + t.Fatal("structured MSM changed caller scalars") + } + gpuSeconds = append(gpuSeconds, elapsed) + t.Logf("kind=%s count=%d iteration=%d cpu_seconds=%.6f gpu_seconds=%.6f", kind, count, iteration, cpuSeconds, elapsed) + } + after, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if config.MSMStrategy == cuda.MSMMask { + if after.MaskedMSMCalls-before.MaskedMSMCalls != 2 || after.MaskCacheHits-before.MaskCacheHits < 1 { + t.Fatal("mask comparison did not exercise mask cache") + } + } else if after.MaskedMSMCalls != before.MaskedMSMCalls { + t.Fatal("split strategy applied masking") + } + rows = append(rows, map[string]any{"kind": kind, "generationSeconds": generationSeconds, "cpuSeconds": cpuSeconds, "gpuSeconds": gpuSeconds, "before": before, "after": after, "resultSHA256": fmt.Sprintf("%x", sha256.Sum256(want.Marshal())), "matchesCPU": true, "hostScalarsUnchanged": true}) + report["rows"] = rows + if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { + t.Fatal(err) + } + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + report["complete"] = true +} diff --git a/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go b/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go new file mode 100644 index 000000000..4c5ed3231 --- /dev/null +++ b/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go @@ -0,0 +1,149 @@ +//go:build bn254 && cuda + +package main + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + native "github.com/consensys/gnark/backend/plonk/bn254" +) + +func TestReplayActualInitMSMs(t *testing.T) { + if os.Getenv("IX_PLONK_CUDA_MSM_REPLAY") != "1" { + t.Skip("set IX_PLONK_CUDA_MSM_REPLAY=1 with explicit artifact and capture pins") + } + if os.Getenv("GOMAXPROCS") != "" { + t.Fatal("unset GOMAXPROCS for default CPU parallelism") + } + if err := checkRawMontgomeryABI(); err != nil { + t.Fatal(err) + } + const prefix = "IX_PLONK_CUDA_MSM_REPLAY_" + dir, out, source := msmTestRequired(t, prefix, "ARTIFACTS"), msmTestRequired(t, prefix, "OUTPUT"), msmTestRequired(t, prefix, "CAPTURES") + pin, err := cpuProfileDigest(msmTestRequired(t, prefix, "MANIFEST_SHA256")) + if err != nil { + t.Fatal(err) + } + capturePin, err := cpuProfileDigest(msmTestRequired(t, prefix, "CAPTURE_SHA256")) + if err != nil { + t.Fatal(err) + } + claim, err := cpuProfileDigest(msmTestRequired(t, prefix, "EXPECTED_CLAIM")) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(out, 0700); err != nil { + t.Fatal(err) + } + capture, err := readCaptureManifest(filepath.Join(source, "capture.json"), capturePin) + if err != nil { + t.Fatal(err) + } + m, pk, validationSeconds, loadSeconds := authenticatedMSMKey(t, dir, pin) + if err := validateCapture(capture, m); err != nil { + t.Fatal(err) + } + if capture.ArtifactManifestSHA256 != pin || capture.ExpectedClaim != claim { + t.Fatal("capture artifact/claim binding differs") + } + config, err := provingCUDAConfig() + if err != nil { + t.Fatal(err) + } + config.Features = native.AcceleratorFeatureMSMV1 + replayBinary, err := executableHash() + if err != nil { + t.Fatal(err) + } + report := map[string]any{"format": "ix-bn254-actual-init-msm-replay-v1", "complete": false, "scope": "Authenticated captured Init MSMs, compared with captured CPU results; one fresh GPU context per vector, two invocations per context; no PLONK proof produced.", "captureSHA256": capturePin, "artifactManifestSHA256": pin, "expectedClaim": claim, "strategy": config.MSMStrategy.String(), "validationSeconds": validationSeconds, "keyLoadSeconds": loadSeconds} + report["testBinarySHA256"], report["goVersion"] = replayBinary, runtime.Version() + report["numCPU"], report["gomaxprocs"] = runtime.NumCPU(), runtime.GOMAXPROCS(0) + var rows []map[string]any + defer func() { + report["rows"] = rows + if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { + t.Error(err) + } + }() + for _, row := range capture.Records { + started := time.Now() + path := filepath.Join(source, row.ScalarsFile) + identity, err := fileHash(path) + if err != nil || identity != row.Scalars { + t.Fatalf("scalar capture %d failed authentication", row.Index) + } + values := make(rawScalarVector, int(row.Count)) + if err := cpuProfileReadBinary(path, row.Scalars, values); err != nil { + t.Fatal(err) + } + decodeSeconds := time.Since(started).Seconds() + encoded, err := unhex(row.CPUResult, 32) + if err != nil { + t.Fatal(err) + } + var expected curve.G1Affine + n, err := expected.SetBytes(encoded) + if err != nil || n != len(encoded) || !expected.IsOnCurve() || !expected.IsInSubGroup() { + t.Fatal("invalid captured CPU result") + } + started = time.Now() + a, err := cuda.Attach(pk, config) + if err != nil { + t.Fatal(err) + } + setupSeconds := time.Since(started).Seconds() + var operations []map[string]any + func() { + defer func() { + if err := a.Close(); err != nil { + t.Error(err) + } + }() + for iteration := range 2 { + before, err := a.Stats() + if err != nil { + t.Fatal(err) + } + started = time.Now() + got, err := a.MultiExp(pk.KzgLagrange.G1[row.Offset:row.Offset+row.Count], values) + elapsed := time.Since(started).Seconds() + if err != nil || !got.Equal(&expected) { + t.Fatalf("capture %d iteration %d differs from CPU: %v", row.Index, iteration, err) + } + after, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if config.MSMStrategy == cuda.MSMSplit { + if after.MaskedMSMCalls != 0 || after.SplitMSMCalls-before.SplitMSMCalls != 1 { + t.Fatal("default replay did not execute the split MSM path") + } + } else if after.MaskedMSMCalls-before.MaskedMSMCalls != 1 { + t.Fatal("mask replay did not execute masked MSM") + } + operations = append(operations, map[string]any{"iteration": iteration, "seconds": elapsed, "before": before, "after": after, "matchesCPU": true}) + t.Logf("capture=%d caller=%s count=%d strategy=%s iteration=%d seconds=%.6f matches_cpu=true", row.Index, row.Caller, row.Count, config.MSMStrategy.String(), iteration, elapsed) + } + }() + if t.Failed() { + return + } + if fmt.Sprintf("%x", sha256.Sum256(rawMontgomery(values))) != row.Scalars.SHA256 { + t.Fatal("replay changed caller scalar bytes") + } + rows = append(rows, map[string]any{"capture": row.Index, "caller": row.Caller, "basis": row.Basis, "offset": row.Offset, "count": row.Count, "scalarSHA256": row.Scalars.SHA256, "cpuResultSHA256": row.CPUResultSHA256, "captureCPUSeconds": row.CPUSeconds, "scalarLoadSeconds": decodeSeconds, "gpuSetupSeconds": setupSeconds, "operations": operations}) + report["rows"] = rows + if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { + t.Fatal(err) + } + } + report["complete"] = true +} diff --git a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go b/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go new file mode 100644 index 000000000..4e3e65b6f --- /dev/null +++ b/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go @@ -0,0 +1,436 @@ +//go:build bn254 + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "runtime/pprof" + "strconv" + "testing" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/backend/witness" + "github.com/consensys/gnark/constraint" + "github.com/consensys/gnark/frontend" +) + +const cpuProfileEnvPrefix = "IX_PLONK_CPU_PROFILE_" + +type cpuProfileMemory struct { + HeapAlloc, HeapSys, TotalAlloc, Sys uint64 + NumGC uint32 +} + +func cpuProfileMemorySnapshot() cpuProfileMemory { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return cpuProfileMemory{m.HeapAlloc, m.HeapSys, m.TotalAlloc, m.Sys, m.NumGC} +} + +type cpuProfileRun struct { + Kind string + Index int + CPUProfiled bool + StartedUTC string + ProveSeconds, SerializeSeconds, VerifySeconds float64 + ProfileStartSeconds, ProfileStopSeconds, WallSeconds float64 + Proof, SolidityProof artifact + ProofPath, SolidityProofPath string + Before, After cpuProfileMemory +} + +type cpuProfileReport struct { + Format, Scope, Stage string + StartedUTC string + Complete bool + Artifacts, ManifestSHA256, WitnessPath string + ExpectedClaim string + SourceManifest *manifest + Witness, TestBinary, CPUProfile artifact + TestBinaryPath, CPUProfilePath, CPUProfileScope string + GoVersion string + NumCPU, GOMAXPROCS, WarmupRuns, MeasuredRuns int + BuildInfo *debug.BuildInfo + Environment map[string]string + ValidationSeconds, WitnessSetupSeconds, WallSeconds float64 + LoadSeconds map[string]float64 + Runs []cpuProfileRun +} + +func cpuProfileRequired(t *testing.T, name string) string { + t.Helper() + value := os.Getenv(cpuProfileEnvPrefix + name) + if value == "" { + t.Fatalf("%s%s must be explicitly configured", cpuProfileEnvPrefix, name) + } + return value +} + +func cpuProfileDigest(text string) (string, error) { + value, err := unhex(text, sha256.Size) + if err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +// Test executables have their own identity. This benchmark entry point trusts +// an externally pinned manifest; production loading retains its executable pin. +func cpuProfileManifest(dir, expectedSHA256 string) (*manifest, error) { + f, err := os.Open(filepath.Join(dir, "manifest.json")) + if err != nil { + return nil, err + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + return nil, err + } + if !stat.Mode().IsRegular() || stat.Size() > 1<<20 { + return nil, fmt.Errorf("profiling manifest must be a regular file of at most 1 MiB") + } + data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) + if err != nil { + return nil, err + } + if len(data) > 1<<20 || fmt.Sprintf("%x", sha256.Sum256(data)) != expectedSHA256 { + return nil, fmt.Errorf("profiling manifest differs from the explicitly trusted SHA-256") + } + var m manifest + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&m); err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("trailing profiling manifest data") + } + if m.Format != artifactFormat || !m.KeysReady || m.Domain != 1<<24 || m.Constraints != 14689440 || m.PublicInputs != 2 || m.Commitments != 1 || len(m.Files) != 5 { + return nil, fmt.Errorf("profiling requires the prepared classic-GLV 2^24 BN254 wrapper") + } + for _, digest := range []string{m.ExecutableSHA256, m.ProfileSHA256, m.SRSBLAKE3} { + if _, err := cpuProfileDigest(digest); err != nil { + return nil, fmt.Errorf("invalid profiling manifest identity: %w", err) + } + } + for _, name := range []string{"circuit.bin", "proving-key.bin", "verifying-key.bin", "profile.json", "PlonkVerifier.sol"} { + want, ok := m.Files[name] + if !ok || want.Bytes <= 0 { + return nil, fmt.Errorf("missing profiling artifact %s", name) + } + if _, err := cpuProfileDigest(want.SHA256); err != nil { + return nil, fmt.Errorf("invalid profiling artifact digest for %s", name) + } + stat, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + return nil, err + } + if !stat.Mode().IsRegular() || stat.Size() != want.Bytes { + return nil, fmt.Errorf("profiling artifact %s has an invalid type or size", name) + } + if err := m.checkFile(dir, name); err != nil { + return nil, err + } + } + if m.Files["profile.json"].SHA256 != m.ProfileSHA256 { + return nil, fmt.Errorf("profile identity differs from its artifact digest") + } + return &m, nil +} + +// Hash the bytes actually decoded as well as the prior authentication pass. +func cpuProfileReadBinary(path string, want artifact, value io.ReaderFrom) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + n, err := value.ReadFrom(io.TeeReader(io.LimitReader(f, want.Bytes+1), h)) + if err != nil { + return err + } + if n != want.Bytes || hex.EncodeToString(h.Sum(nil)) != want.SHA256 { + return fmt.Errorf("decoded artifact %s differs from its pinned bytes", path) + } + var extra [1]byte + if n, err := f.Read(extra[:]); n != 0 || err != io.EOF { + return fmt.Errorf("trailing artifact data in %s", path) + } + return nil +} + +func cpuProfileProve(ccs constraint.ConstraintSystem, pk plonk.ProvingKey, w witness.Witness, profilePath string, index int) (proof plonk.Proof, seconds, startSeconds, stopSeconds float64, err error) { + var profileFile *os.File + if profilePath != "" { + started := time.Now() + profileFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return + } + if err = pprof.StartCPUProfile(profileFile); err != nil { + profileFile.Close() + return + } + startSeconds = time.Since(started).Seconds() + defer func() { + started := time.Now() + pprof.StopCPUProfile() + if closeErr := profileFile.Close(); err == nil { + err = closeErr + } + stopSeconds = time.Since(started).Seconds() + }() + } + started := time.Now() + pprof.Do(context.Background(), pprof.Labels("workload", "bn254-plonk-2p24", "run", strconv.Itoa(index)), func(context.Context) { + proof, err = plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + }) + seconds = time.Since(started).Seconds() + return +} + +func TestBN254CPUProfile(t *testing.T) { + if os.Getenv("IX_PLONK_CPU_PROFILE") != "1" { + t.Skip("set IX_PLONK_CPU_PROFILE=1 and explicit artifact/claim pins for full 2^24 proving") + } + if os.Getenv("GOMAXPROCS") != "" { + t.Fatal("unset GOMAXPROCS to retain the default CPU parallelism") + } + dir := cpuProfileRequired(t, "ARTIFACTS") + expectedManifest, err := cpuProfileDigest(cpuProfileRequired(t, "MANIFEST_SHA256")) + if err != nil { + t.Fatal(err) + } + expectedClaim, err := cpuProfileDigest(cpuProfileRequired(t, "EXPECTED_CLAIM")) + if err != nil { + t.Fatal(err) + } + witnessPath := cpuProfileRequired(t, "WITNESS") + out := cpuProfileRequired(t, "OUTPUT") + measured := 3 + if value := os.Getenv(cpuProfileEnvPrefix + "RUNS"); value != "" { + measured, err = strconv.Atoi(value) + if err != nil || (measured != 2 && measured != 3) { + t.Fatal("IX_PLONK_CPU_PROFILE_RUNS must be 2 or 3") + } + } + if err := os.MkdirAll(filepath.Dir(out), 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(out, 0700); err != nil { + t.Fatalf("profiling output directory must be new: %v", err) + } + started := time.Now() + report := cpuProfileReport{ + Format: "ix-bn254-plonk-cpu-profile-v1", + Scope: "Authenticated existing 2^24 artifacts; one unmeasured warmup, followed by measured proofs in one process. CS/PK/VK/witness reused; fresh gnark randomness each proof; default CPU parallelism and natural GC. No accelerator, circuit compilation, SRS conversion or key setup.", + Stage: "artifact-validation", StartedUTC: started.UTC().Format(time.RFC3339Nano), + Artifacts: dir, ManifestSHA256: expectedManifest, WitnessPath: witnessPath, + ExpectedClaim: expectedClaim, GoVersion: runtime.Version(), + NumCPU: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), + WarmupRuns: 1, MeasuredRuns: measured, + CPUProfilePath: filepath.Join(out, "cpu.pprof"), + CPUProfileScope: "Measured run 1 only: StartCPUProfile immediately before plonk.Prove, StopCPUProfile immediately after it. Artifact/witness preparation, verification, proof serialization, warmup and other measured runs are excluded. ProveSeconds excludes profiler startup/shutdown.", + LoadSeconds: make(map[string]float64), Environment: make(map[string]string), + } + report.BuildInfo, _ = debug.ReadBuildInfo() + for _, name := range []string{"GOMAXPROCS", "GOGC", "GOMEMLIMIT", "GODEBUG", "CUDA_VISIBLE_DEVICES"} { + if value, present := os.LookupEnv(name); present { + report.Environment[name] = value + } + } + defer func() { + report.WallSeconds = time.Since(started).Seconds() + report.Complete = report.Stage == "complete" && !t.Failed() + if err := writeJSON(filepath.Join(out, "report.json"), &report); err != nil { + t.Error(err) + } + }() + report.TestBinaryPath, err = os.Executable() + if err != nil { + t.Fatal(err) + } + report.TestBinary, err = fileHash(report.TestBinaryPath) + if err != nil { + t.Fatal(err) + } + validationStarted := time.Now() + m, err := cpuProfileManifest(dir, expectedManifest) + if err != nil { + t.Fatal(err) + } + report.SourceManifest = m + report.ValidationSeconds = time.Since(validationStarted).Seconds() + report.Stage = "artifact-load" + ccs := plonk.NewCS(curveID) + pk := plonk.NewProvingKey(curveID) + vk := plonk.NewVerifyingKey(curveID) + for _, entry := range []struct { + name string + value io.ReaderFrom + }{{"circuit.bin", ccs}, {"proving-key.bin", pk}, {"verifying-key.bin", vk}} { + loadStarted := time.Now() + if err := cpuProfileReadBinary(filepath.Join(dir, entry.name), m.Files[entry.name], entry.value); err != nil { + t.Fatal(err) + } + report.LoadSeconds[entry.name] = time.Since(loadStarted).Seconds() + } + loadStarted := time.Now() + p, err := loadProfile(filepath.Join(dir, "profile.json")) + if err != nil { + t.Fatal(err) + } + if err := m.checkFile(dir, "profile.json"); err != nil { + t.Fatal(err) + } + report.LoadSeconds["profile.json"] = time.Since(loadStarted).Seconds() + if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != m.PublicInputs || ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+ccs.GetNbPublicVariables())) != m.Domain || len(ccs.GetCommitments().CommitmentIndexes()) != m.Commitments { + t.Fatal("loaded circuit metadata differs from the authenticated manifest") + } + nativePK := pk.(*plonkbn254.ProvingKey) + nativeVK := vk.(*plonkbn254.VerifyingKey) + if nativePK.Accelerator() != nil || nativePK.Vk == nil || uint64(len(nativePK.Kzg.G1)) != m.Domain+3 || uint64(len(nativePK.KzgLagrange.G1)) != m.Domain || nativeVK.Size != m.Domain || nativeVK.NbPublicVariables != 2 || len(nativeVK.Qcp) != m.Commitments { + t.Fatal("loaded key shape differs or an accelerator is attached") + } + pkVerifierHash := sha256.New() + pkVerifierBytes, err := nativePK.Vk.WriteTo(pkVerifierHash) + if err != nil { + t.Fatal(err) + } + if pkVerifierBytes != m.Files["verifying-key.bin"].Bytes || hex.EncodeToString(pkVerifierHash.Sum(nil)) != m.Files["verifying-key.bin"].SHA256 { + t.Fatal("proving key embeds a different verifying key") + } + g1, err := p.Generator.native() + if err != nil { + t.Fatal(err) + } + g2, err := g2Keys(p.OuterKeys) + if err != nil { + t.Fatal(err) + } + if !nativeVK.Kzg.G1.Equal(&g1) || !nativeVK.Kzg.G2[0].Equal(&g2[0]) || !nativeVK.Kzg.G2[1].Equal(&g2[1]) { + t.Fatal("verifying key anchors differ from the authenticated profile") + } + report.Stage = "witness-setup" + witnessStarted := time.Now() + stat, err := os.Stat(witnessPath) + if err != nil || !stat.Mode().IsRegular() || stat.Size() > 16<<20 { + t.Fatal("profiling witness must be a regular file of at most 16 MiB") + } + report.Witness, err = fileHash(witnessPath) + if err != nil { + t.Fatal(err) + } + assignment, err := witnessAssignment(p, witnessPath) + if err != nil { + t.Fatal(err) + } + if after, err := fileHash(witnessPath); err != nil || after != report.Witness { + t.Fatal("profiling witness changed during loading") + } + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + t.Fatal(err) + } + digest, _ := hex.DecodeString(expectedClaim) + expectedPublic, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } + actualPublic, err := w.Public() + if err != nil { + t.Fatal(err) + } + actualBytes, err := actualPublic.MarshalBinary() + if err != nil { + t.Fatal(err) + } + expectedBytes, err := expectedPublic.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(actualBytes, expectedBytes) { + t.Fatal("witness claim differs from the independently expected claim") + } + report.WitnessSetupSeconds = time.Since(witnessStarted).Seconds() + seenProofs := make(map[string]bool) + for index := 0; index <= measured; index++ { + kind := "measured" + if index == 0 { + kind = "warmup" + } + report.Stage = fmt.Sprintf("%s-%d", kind, index) + t.Logf("stage=%s retained_cs_pk_vk=true accelerator=none gomaxprocs=%d", report.Stage, runtime.GOMAXPROCS(0)) + runStarted := time.Now() + row := cpuProfileRun{Kind: kind, Index: index, StartedUTC: runStarted.UTC().Format(time.RFC3339Nano), CPUProfiled: index == 1, Before: cpuProfileMemorySnapshot()} + profilePath := "" + if row.CPUProfiled { + profilePath = report.CPUProfilePath + } + proof, proveSeconds, profileStart, profileStop, err := cpuProfileProve(ccs, pk, w, profilePath, index) + if err != nil { + t.Fatal(err) + } + row.ProveSeconds, row.ProfileStartSeconds, row.ProfileStopSeconds = proveSeconds, profileStart, profileStop + serializeStarted := time.Now() + var encoded bytes.Buffer + if _, err := proof.WriteTo(&encoded); err != nil { + t.Fatal(err) + } + decoded, err := decodeProof(encoded.Bytes(), m.Commitments) + if err != nil { + t.Fatal(err) + } + solidityBytes := decoded.MarshalSolidity() + row.SerializeSeconds = time.Since(serializeStarted).Seconds() + verifyStarted := time.Now() + if err := plonk.Verify(decoded, vk, expectedPublic, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + row.VerifySeconds = time.Since(verifyStarted).Seconds() + row.Proof = artifact{SHA256: fmt.Sprintf("%x", sha256.Sum256(encoded.Bytes())), Bytes: int64(encoded.Len())} + row.SolidityProof = artifact{SHA256: fmt.Sprintf("%x", sha256.Sum256(solidityBytes)), Bytes: int64(len(solidityBytes))} + if seenProofs[row.Proof.SHA256] { + t.Fatal("repeated proof bytes despite fresh gnark blinding") + } + seenProofs[row.Proof.SHA256] = true + row.ProofPath = filepath.Join(out, fmt.Sprintf("%s-%d.proof.bin", kind, index)) + row.SolidityProofPath = filepath.Join(out, fmt.Sprintf("%s-%d.proof.solidity.bin", kind, index)) + if err := os.WriteFile(row.ProofPath, encoded.Bytes(), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(row.SolidityProofPath, solidityBytes, 0600); err != nil { + t.Fatal(err) + } + row.After = cpuProfileMemorySnapshot() + row.WallSeconds = time.Since(runStarted).Seconds() + report.Runs = append(report.Runs, row) + if row.CPUProfiled { + report.CPUProfile, err = fileHash(report.CPUProfilePath) + if err != nil { + t.Fatal(err) + } + } + if err := writeJSON(filepath.Join(out, "report.json"), &report); err != nil { + t.Fatal(err) + } + t.Logf("stage=%s prove_seconds=%.6f verify_seconds=%.6f cpu_profiled=%t proof_sha256=%s", report.Stage, row.ProveSeconds, row.VerifySeconds, row.CPUProfiled, row.Proof.SHA256) + } + report.Stage = "complete" +} diff --git a/tools/bls-plonk-wrapper/curve_bls.go b/tools/bls-plonk-wrapper/curve_bls.go new file mode 100644 index 000000000..e653e6aca --- /dev/null +++ b/tools/bls-plonk-wrapper/curve_bls.go @@ -0,0 +1,98 @@ +//go:build !bn254 + +package main + +import ( + "bytes" + "fmt" + "github.com/consensys/gnark-crypto/ecc" + native "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" + backend "github.com/consensys/gnark/backend/plonk/bls12-381" + "github.com/consensys/gnark/frontend" + sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" + "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" + "github.com/consensys/gnark/std/math/cmp" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" + "math/big" +) + +type nativeG1 = native.G1Affine +type nativeG2 = native.G2Affine +type nativeSRS = kzg.SRS +type nativeKzgProvingKey = kzg.ProvingKey +type nativeVerifyingKey = backend.VerifyingKey +type nativeProof = backend.Proof +type circuitG1 = sw.G1Affine +type circuitG2 = sw.G2Affine +type circuitScalar = sw.Scalar +type baseField = sw.BaseField +type scalarField = sw.ScalarField + +var baseModulus = fp.Modulus +var scalarModulus = fr.Modulus +var precomputeLines = native.PrecomputeLines +var toLagrange = kzg.ToLagrangeG1 +var newCircuitG1 = sw.NewG1Affine +var newCircuitG2Fixed = sw.NewG2AffineFixed +var newPairing = sw.NewPairing + +const curveID = ecc.BLS12_381 +const baseBytes = 48 +const pointBytesLen = 48 +const pointLimbBytes = 24 +const g2ExportBytes = 96 +const anchorBytesLen = 240 +const srsMagic = "IXPSRS01" +const profileFormat = "ix-bls-plonk-profile-v1" +const artifactFormat = "ix-bls-plonk-artifacts-v1" +const lagrangeFormat = "ix-plonk-lagrange-v1" + +var curveParameters = sw_emulated.GetBLS12381Params + +func isCompressedPoint(b byte) bool { return b&0x80 != 0 } +func encodePoint(p nativeG1) []byte { b := p.Bytes(); return b[:] } +func decodeG2(b []byte) (nativeG2, error) { + var p nativeG2 + if _, err := p.SetBytes(b); err != nil { + return p, err + } + canonical := p.Bytes() + if p.IsInfinity() || !bytes.Equal(canonical[:], b) { + return p, fmt.Errorf("invalid G2 anchor") + } + return p, nil +} +func encodeAnchors(vk *nativeVerifyingKey) []byte { + g, h, t := vk.Kzg.G1.Bytes(), vk.Kzg.G2[0].Bytes(), vk.Kzg.G2[1].Bytes() + return append(append(append([]byte{}, g[:]...), h[:]...), t[:]...) +} +func pointBytes(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], point *circuitG1) []uints.U8 { + f.AssertIsInRange(&point.X) + f.AssertIsInRange(&point.Y) + x := f.ToBitsCanonical(&point.X) + y := f.ToBitsCanonical(&point.Y) + half := new(big.Int).Rsh(new(big.Int).Set(baseModulus()), 1) + bound := make([]frontend.Variable, len(y)) + for i := range bound { + bound[i] = half.Bit(i) + } + sign := cmp.IsLessBinary(api, bound, y) + infinity := api.And(f.IsZero(&point.X), f.IsZero(&point.Y)) + for len(x) < 384 { + x = append(x, 0) + } + out := make([]uints.U8, 48) + for i := range out { + out[47-i] = u.Bytes.FromBits(x[8*i : 8*i+8]...) + } + out[0] = u.ByteValueOf(api.Add(u.Value(out[0]), 128, api.Mul(64, infinity), api.Mul(32, sign))) + return out +} + +func importLagrangeSP1(_, _, _, _ string) error { + return fmt.Errorf("SP1 Lagrange import requires the BN254 build") +} diff --git a/tools/bls-plonk-wrapper/curve_bn254.go b/tools/bls-plonk-wrapper/curve_bn254.go new file mode 100644 index 000000000..7554391bc --- /dev/null +++ b/tools/bls-plonk-wrapper/curve_bn254.go @@ -0,0 +1,116 @@ +//go:build bn254 + +package main + +import ( + "fmt" + "github.com/consensys/gnark-crypto/ecc" + native "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fp" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + backend "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/frontend" + sw "github.com/consensys/gnark/std/algebra/emulated/sw_bn254" + "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" + "github.com/consensys/gnark/std/math/cmp" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" + "math/big" +) + +type nativeG1 = native.G1Affine +type nativeG2 = native.G2Affine +type nativeSRS = kzg.SRS +type nativeKzgProvingKey = kzg.ProvingKey +type nativeVerifyingKey = backend.VerifyingKey +type nativeProof = backend.Proof +type circuitG1 = sw.G1Affine +type circuitG2 = sw.G2Affine +type circuitScalar = sw.Scalar +type baseField = sw.BaseField +type scalarField = sw.ScalarField + +var baseModulus = fp.Modulus +var scalarModulus = fr.Modulus +var precomputeLines = native.PrecomputeLines +var toLagrange = kzg.ToLagrangeG1 +var newCircuitG1 = sw.NewG1Affine +var newCircuitG2Fixed = sw.NewG2AffineFixed +var newPairing = sw.NewPairing + +const curveID = ecc.BN254 +const baseBytes = 32 +const pointBytesLen = 32 +const pointLimbBytes = 16 +const g2ExportBytes = 128 +const anchorBytesLen = 320 +const srsMagic = "IXBPSR01" +const profileFormat = "ix-bn254-plonk-profile-v1" +const artifactFormat = "ix-bn254-plonk-artifacts-v1" +const lagrangeFormat = "ix-bn254-plonk-lagrange-v1" + +var curveParameters = sw_emulated.GetBN254Params + +func isCompressedPoint(b byte) bool { return b&0xc0 != 0 } +func encodePoint(p nativeG1) []byte { + out := make([]byte, 32) + if p.IsInfinity() { + out[31] = 0x40 + return out + } + x := p.X.Bytes() + for i := range out { + out[i] = x[31-i] + } + var y big.Int + p.Y.BigInt(&y) + if y.Cmp(new(big.Int).Rsh(new(big.Int).Set(fp.Modulus()), 1)) > 0 { + out[31] |= 0x80 + } + return out +} +func decodeG2(b []byte) (nativeG2, error) { + var p nativeG2 + if len(b) != 128 { + return p, fmt.Errorf("invalid G2 length") + } + for i, v := range []*fp.Element{&p.X.A0, &p.X.A1, &p.Y.A0, &p.Y.A1} { + if err := v.SetBytesCanonical(b[i*32 : (i+1)*32]); err != nil { + return p, err + } + } + if p.IsInfinity() || !p.IsOnCurve() || !p.IsInSubGroup() { + return p, fmt.Errorf("invalid G2 anchor") + } + return p, nil +} +func encodeAnchors(vk *nativeVerifyingKey) []byte { + out := make([]byte, 0, 320) + for _, v := range []*fp.Element{&vk.Kzg.G1.X, &vk.Kzg.G1.Y, &vk.Kzg.G2[0].X.A0, &vk.Kzg.G2[0].X.A1, &vk.Kzg.G2[0].Y.A0, &vk.Kzg.G2[0].Y.A1, &vk.Kzg.G2[1].X.A0, &vk.Kzg.G2[1].X.A1, &vk.Kzg.G2[1].Y.A0, &vk.Kzg.G2[1].Y.A1} { + b := v.Bytes() + out = append(out, b[:]...) + } + return out +} +func pointBytes(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], p *circuitG1) []uints.U8 { + f.AssertIsInRange(&p.X) + f.AssertIsInRange(&p.Y) + x, y := f.ToBitsCanonical(&p.X), f.ToBitsCanonical(&p.Y) + half := new(big.Int).Rsh(new(big.Int).Set(fp.Modulus()), 1) + bound := make([]frontend.Variable, len(y)) + for i := range bound { + bound[i] = half.Bit(i) + } + sign := cmp.IsLessBinary(api, bound, y) + infinity := api.And(f.IsZero(&p.X), f.IsZero(&p.Y)) + for len(x) < 256 { + x = append(x, 0) + } + out := make([]uints.U8, 32) + for i := range out { + out[i] = u.Bytes.FromBits(x[8*i : 8*i+8]...) + } + out[31] = u.ByteValueOf(api.Add(u.Value(out[31]), api.Mul(64, infinity), api.Mul(128, sign))) + return out +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/abi.go b/tools/bls-plonk-wrapper/internal/cuda/abi.go new file mode 100644 index 000000000..baf5ee43e --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/abi.go @@ -0,0 +1,243 @@ +//go:build bn254 && cuda + +package cuda + +/* +#cgo LDFLAGS: -lix_plonk_bn254_cuda -lstdc++ +#include +*/ +import "C" + +import ( + "fmt" + "math/big" + "runtime" + "strings" + "unsafe" + + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fp" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" +) + +type nativeContext struct{ pointer *C.ix_plonk_bn254_context } +type basisHandle uint64 +type quotientHandle uint64 + +type nativeParameters struct{ root, inverseRoot, inverseSize fr.Element } + +type quotientConfig struct { + n, m uint64 + commitments int + omega, omegaBig, coset, alpha, beta, gamma fr.Element + bl, br, bo [2]fr.Element + bz [3]fr.Element +} + +func nativeError(operation string, status C.int, detail *C.ix_plonk_bn254_error) error { + if status == 0 { + return nil + } + message := C.GoBytes(unsafe.Pointer(&detail.message[0]), C.int(len(detail.message))) + return fmt.Errorf("CUDA PLONK %s: status %d: %s", operation, int(status), strings.TrimRight(string(message), "\x00")) +} + +func fieldRecord(modulus *big.Int, one [4]uint64) [3][4]uint64 { + var result [3][4]uint64 + result[1] = one + r2 := new(big.Int).Lsh(big.NewInt(1), 512) + r2.Mod(r2, modulus) + for index, value := range map[int]*big.Int{0: modulus, 2: r2} { + var bytes [32]byte + value.FillBytes(bytes[:]) + for limb := range 4 { + for octet := range 8 { + result[index][limb] |= uint64(bytes[31-8*limb-octet]) << (8 * octet) + } + } + } + return result +} + +func checkFieldABI() error { + var affine curve.G1Affine + var jacobian curve.G1Jac + var word uint64 = 1 + if *(*byte)(unsafe.Pointer(&word)) != 1 || unsafe.Sizeof(fr.Element{}) != 32 || unsafe.Sizeof(fp.Element{}) != 32 || unsafe.Alignof(fr.Element{}) != 8 || unsafe.Alignof(fp.Element{}) != 8 || unsafe.Sizeof(affine) != 64 || unsafe.Offsetof(affine.Y) != 32 || unsafe.Sizeof(jacobian) != 96 || unsafe.Offsetof(jacobian.Y) != 32 || unsafe.Offsetof(jacobian.Z) != 64 { + return fmt.Errorf("CUDA PLONK requires the canonical little-endian Montgomery limb ABI") + } + fqRecord := fieldRecord(fp.Modulus(), [4]uint64(fp.One())) + frRecord := fieldRecord(fr.Modulus(), [4]uint64(fr.One())) + var detail C.ix_plonk_bn254_error + return nativeError("field ABI", C.ix_plonk_bn254_check_field_abi( + (*[4]C.uint64_t)(unsafe.Pointer(&fqRecord[0][0])), + (*[4]C.uint64_t)(unsafe.Pointer(&frRecord[0][0])), &detail), &detail) +} + +func getNativeParameters(logSize int) (nativeParameters, error) { + var result nativeParameters + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_ntt_parameters(C.uint32_t(logSize), + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&result.root)), + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&result.inverseRoot)), + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&result.inverseSize)), &detail) + return result, nativeError("NTT parameters", status, &detail) +} + +func createNativeContext(config Config) (*nativeContext, error) { + c := C.ix_plonk_bn254_config{ + abi_version: C.IX_PLONK_BN254_ABI_VERSION, + struct_bytes: C.uint32_t(C.sizeof_ix_plonk_bn254_config), + device: C.int32_t(config.Device), + msm_strategy: C.uint32_t(config.MSMStrategy), + budget_bytes: C.uint64_t(config.MemoryBytes), + driver_headroom_bytes: C.uint64_t(config.HeadroomBytes), + } + var pointer *C.ix_plonk_bn254_context + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_context_create(&c, &pointer, &detail) + if err := nativeError("context creation", status, &detail); err != nil { + return nil, err + } + if pointer == nil { + return nil, fmt.Errorf("CUDA PLONK returned an empty context") + } + return &nativeContext{pointer: pointer}, nil +} + +func (c *nativeContext) close() error { + var detail C.ix_plonk_bn254_error + return nativeError("context destruction", C.ix_plonk_bn254_context_destroy(c.pointer, &detail), &detail) +} + +func (c *nativeContext) uploadBasis(points []curve.G1Affine) (basisHandle, error) { + var handle C.ix_plonk_bn254_basis + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_basis_upload(c.pointer, + (*C.ix_plonk_bn254_affine)(unsafe.Pointer(&points[0])), C.uint64_t(len(points)), &handle, &detail) + runtime.KeepAlive(points) + return basisHandle(handle), nativeError("basis upload", status, &detail) +} + +func checkedAffine(jacobian *curve.G1Jac) (curve.G1Affine, error) { + if jacobian.Z.IsZero() { + return curve.G1Affine{}, nil + } + if !jacobian.IsOnCurve() { + return curve.G1Affine{}, fmt.Errorf("CUDA PLONK MSM returned an off-curve point") + } + var result curve.G1Affine + result.FromJacobian(jacobian) + return result, nil +} + +func (c *nativeContext) msm(handle basisHandle, offset int, scalars []fr.Element) (curve.G1Affine, error) { + var jacobian curve.G1Jac + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_msm(c.pointer, C.ix_plonk_bn254_basis(handle), C.uint64_t(offset), + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&scalars[0])), C.uint64_t(len(scalars)), + (*C.ix_plonk_bn254_jacobian)(unsafe.Pointer(&jacobian)), &detail) + runtime.KeepAlive(scalars) + if err := nativeError("MSM", status, &detail); err != nil { + return curve.G1Affine{}, err + } + return checkedAffine(&jacobian) +} + +func (c *nativeContext) fft(values []fr.Element, inverse, bitReversed bool, coset fr.Element) error { + var inverseFlag, reversedFlag C.uint32_t + if inverse { + inverseFlag = 1 + } + if bitReversed { + reversedFlag = 1 + } + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_fft(c.pointer, (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&values[0])), + C.uint64_t(len(values)), inverseFlag, reversedFlag, + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&coset)), &detail) + runtime.KeepAlive(values) + return nativeError("FFT", status, &detail) +} + +func toNativeFr(value fr.Element) C.ix_plonk_bn254_fr { + var result C.ix_plonk_bn254_fr + for i := range 4 { + result.limbs[i] = C.uint64_t(value[i]) + } + return result +} + +func (c *nativeContext) beginQuotient(config quotientConfig) (quotientHandle, error) { + native := C.ix_plonk_bn254_quotient_config{ + n: C.uint64_t(config.n), m: C.uint64_t(config.m), commitments: C.uint32_t(config.commitments), + omega: toNativeFr(config.omega), omega_big: toNativeFr(config.omegaBig), coset: toNativeFr(config.coset), + alpha: toNativeFr(config.alpha), beta: toNativeFr(config.beta), gamma: toNativeFr(config.gamma), + } + for i := range 2 { + native.bl[i] = toNativeFr(config.bl[i]) + native.br[i] = toNativeFr(config.br[i]) + native.bo[i] = toNativeFr(config.bo[i]) + } + for i := range 3 { + native.bz[i] = toNativeFr(config.bz[i]) + } + var handle C.ix_plonk_bn254_quotient + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_quotient_begin(c.pointer, &native, &handle, &detail) + return quotientHandle(handle), nativeError("quotient preparation", status, &detail) +} + +func (c *nativeContext) putQuotient(handle quotientHandle, slot int, values []fr.Element) error { + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_quotient_put(c.pointer, C.ix_plonk_bn254_quotient(handle), C.uint32_t(slot), + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&values[0])), C.uint64_t(len(values)), &detail) + runtime.KeepAlive(values) + return nativeError("quotient polynomial upload", status, &detail) +} + +func (c *nativeContext) finishQuotient(handle quotientHandle, fused bool, values []fr.Element) error { + var flag C.uint32_t + if fused { + flag = 1 + } + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_quotient_finish(c.pointer, C.ix_plonk_bn254_quotient(handle), flag, + (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&values[0])), C.uint64_t(len(values)), &detail) + runtime.KeepAlive(values) + return nativeError("quotient computation", status, &detail) +} + +func (c *nativeContext) destroyQuotient(handle quotientHandle) error { + var detail C.ix_plonk_bn254_error + return nativeError("quotient destruction", C.ix_plonk_bn254_quotient_destroy(c.pointer, C.ix_plonk_bn254_quotient(handle), &detail), &detail) +} + +func (c *nativeContext) stats() (Stats, error) { + var native C.ix_plonk_bn254_stats + var detail C.ix_plonk_bn254_error + status := C.ix_plonk_bn254_get_stats(c.pointer, &native, &detail) + stats := Stats{ + DriverFreeBytes: uint64(native.driver_free_bytes), DriverTotalBytes: uint64(native.driver_total_bytes), + PoolUsedBytes: uint64(native.pool_used_bytes), PoolUsedHighBytes: uint64(native.pool_used_high_bytes), + PoolReservedBytes: uint64(native.pool_reserved_bytes), PoolReservedHighBytes: uint64(native.pool_reserved_high_bytes), + AccountedBytes: uint64(native.accounted_bytes), AccountedHighBytes: uint64(native.accounted_high_bytes), BudgetBytes: uint64(native.budget_bytes), + MSMCalls: uint64(native.msm_calls), FFTCalls: uint64(native.fft_calls), NumeratorCalls: uint64(native.numerator_calls), QuotientCalls: uint64(native.quotient_calls), + UploadBytes: uint64(native.upload_bytes), DownloadBytes: uint64(native.download_bytes), + UploadCalls: uint64(native.upload_calls), DownloadCalls: uint64(native.download_calls), + UploadWallMS: float64(native.upload_wall_ms), DownloadWallMS: float64(native.download_wall_ms), + TransferHostCopyMS: float64(native.transfer_host_copy_ms), + UploadDMAMS: float64(native.upload_dma_ms), DownloadDMAMS: float64(native.download_dma_ms), + TransferTimingEnabled: native.transfer_timing_enabled != 0, + MaskedMSMCalls: uint64(native.masked_msm_calls), MaskSumCalls: uint64(native.mask_sum_calls), MaskCacheHits: uint64(native.mask_cache_hits), + SplitMSMCalls: uint64(native.split_msm_calls), SplitUnitPoints: uint64(native.split_unit_points), + SplitClassifyMS: float64(native.split_classify_ms), SplitChunksMS: float64(native.split_chunks_ms), SplitReduceMS: float64(native.split_reduce_ms), + } + for i := range 32 { + stats.SplitMaxBucket[i] = uint64(native.split_max_bucket[i]) + stats.SplitChunkCount[i] = uint64(native.split_chunk_count[i]) + stats.SplitNonzeroDigits[i] = uint64(native.split_nonzero_digits[i]) + stats.SplitZeroDigits[i] = uint64(native.split_zero_digits[i]) + } + return stats, nativeError("statistics", status, &detail) +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/accelerator.go b/tools/bls-plonk-wrapper/internal/cuda/accelerator.go new file mode 100644 index 000000000..656c857cc --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/accelerator.go @@ -0,0 +1,454 @@ +//go:build bn254 && cuda + +package cuda + +import ( + "errors" + "fmt" + "math" + "math/big" + "math/bits" + "os" + "runtime" + "sync" + "time" + "unsafe" + + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/iop" + native "github.com/consensys/gnark/backend/plonk/bn254" +) + +const AllFeatures = native.AcceleratorFeatureMSMV1 | native.AcceleratorFeatureToCanonicalV1 | native.AcceleratorFeatureNumeratorV1 | native.AcceleratorFeatureQuotientV1 + +type MSMStrategy uint32 + +const ( + MSMSplit MSMStrategy = iota + MSMMask +) + +func (strategy MSMStrategy) String() string { + if strategy == MSMMask { + return "mask" + } + return "split" +} + +type Config struct { + MSMStrategy MSMStrategy + Device int + MemoryBytes, HeadroomBytes uint64 + Features native.AcceleratorFeatureFlagsV1 +} + +type Stats struct { + DriverFreeBytes, DriverTotalBytes uint64 + PoolUsedBytes, PoolUsedHighBytes, PoolReservedBytes, PoolReservedHighBytes uint64 + AccountedBytes, AccountedHighBytes, BudgetBytes uint64 + MSMCalls, FFTCalls, NumeratorCalls, QuotientCalls uint64 + UploadBytes, DownloadBytes uint64 + UploadCalls, DownloadCalls uint64 + // Wall times include waiting for queued predecessor GPU work. + UploadWallMS, DownloadWallMS, TransferHostCopyMS float64 + UploadDMAMS, DownloadDMAMS float64 + TransferTimingEnabled bool + SmallCPUCalls, CPUTailPoints uint64 + MSMCallbackSeconds, CanonicalCallbackSeconds float64 + NumeratorCallbackSeconds, QuotientCallbackSeconds float64 + QueueWaitSeconds float64 + MaskedMSMCalls, MaskSumCalls, MaskCacheHits uint64 + SplitMSMCalls, SplitUnitPoints uint64 + SplitMaxBucket, SplitChunkCount, SplitNonzeroDigits, SplitZeroDigits [32]uint64 + SplitClassifyMS, SplitChunksMS, SplitReduceMS float64 +} + +type registeredBasis struct { + name string + points []curve.G1Affine + start, end uintptr + cached int + handle basisHandle +} + +// Accelerator serializes mutable native workspaces. The proving key keeps both +// registered host arrays alive until every borrowed native call has returned. +type Accelerator struct { + mu sync.Mutex + pk *native.ProvingKey + context *nativeContext + bases [2]registeredBasis + features native.AcceleratorFeatureFlagsV1 + domain uint64 + parameters [27]nativeParameters + closed bool + closeErr error + finalStats Stats + smallCPUCalls, cpuTailPoints uint64 + msmSeconds, canonicalSeconds, numeratorSeconds, quotientSeconds float64 + queueWaitSeconds float64 + trace bool + strategy MSMStrategy +} + +var _ native.Accelerator = (*Accelerator)(nil) +var _ native.AcceleratorFeaturesV1 = (*Accelerator)(nil) +var _ native.QuotientAcceleratorV1 = (*Accelerator)(nil) + +func Attach(pk *native.ProvingKey, config Config) (*Accelerator, error) { + if pk == nil || pk.Vk == nil || pk.Accelerator() != nil { + return nil, fmt.Errorf("CUDA PLONK needs an unattached proving key") + } + n := pk.Vk.Size + if n < 2 || n > 1<<24 || n&(n-1) != 0 || uint64(len(pk.Kzg.G1)) != n+3 || uint64(len(pk.KzgLagrange.G1)) != n { + return nil, fmt.Errorf("CUDA PLONK requires N<=2^24 and exact N+3/N proving bases") + } + if config.Device < 0 || config.Device > math.MaxInt32 || config.Features & ^AllFeatures != 0 || config.MSMStrategy > MSMMask { + return nil, fmt.Errorf("invalid CUDA PLONK device or features") + } + if config.MemoryBytes == 0 { + config.MemoryBytes = 64 << 30 + } + if config.HeadroomBytes == 0 { + config.HeadroomBytes = 8 << 30 + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := checkFieldABI(); err != nil { + return nil, err + } + a := &Accelerator{pk: pk, features: config.Features, domain: n, trace: os.Getenv("IX_PLONK_CUDA_TRACE") == "1", strategy: config.MSMStrategy} + for logSize := range a.parameters { + parameters, err := getNativeParameters(logSize) + if err != nil { + return nil, err + } + root, err := fft.Generator(uint64(1) << logSize) + if err != nil { + return nil, err + } + var inverse, size fr.Element + inverse.Inverse(&root) + size.SetUint64(uint64(1) << logSize).Inverse(&size) + if parameters.root != root || parameters.inverseRoot != inverse || parameters.inverseSize != size { + return nil, fmt.Errorf("CUDA PLONK NTT parameters differ at log size %d", logSize) + } + a.parameters[logSize] = parameters + } + context, err := createNativeContext(config) + if err != nil { + return nil, err + } + a.context = context + for i, points := range [][]curve.G1Affine{pk.Kzg.G1, pk.KzgLagrange.G1} { + handle, err := context.uploadBasis(points[:int(n)]) + if err != nil { + return nil, errors.Join(err, context.close()) + } + start := uintptr(unsafe.Pointer(&points[0])) + end := start + uintptr(len(points))*unsafe.Sizeof(points[0]) + if end < start { + return nil, errors.Join(fmt.Errorf("CUDA PLONK basis address overflow"), context.close()) + } + a.bases[i] = registeredBasis{name: []string{"monomial", "lagrange"}[i], points: points, start: start, end: end, cached: int(n), handle: handle} + } + pk.SetAccelerator(a) + return a, nil +} + +func (a *Accelerator) FeaturesV1() native.AcceleratorFeatureFlagsV1 { return a.features } + +func (a *Accelerator) withContext(fn func(*nativeContext) error) error { + queued := time.Now() + a.mu.Lock() + defer a.mu.Unlock() + if a.closed { + return fmt.Errorf("CUDA PLONK accelerator is closed") + } + a.queueWaitSeconds += time.Since(queued).Seconds() + runtime.LockOSThread() + defer runtime.UnlockOSThread() + err := fn(a.context) + runtime.KeepAlive(a.pk) + return err +} + +func (a *Accelerator) hostStats(stats Stats) Stats { + stats.SmallCPUCalls, stats.CPUTailPoints = a.smallCPUCalls, a.cpuTailPoints + stats.MSMCallbackSeconds, stats.CanonicalCallbackSeconds = a.msmSeconds, a.canonicalSeconds + stats.NumeratorCallbackSeconds, stats.QuotientCallbackSeconds = a.numeratorSeconds, a.quotientSeconds + stats.QueueWaitSeconds = a.queueWaitSeconds + return stats +} + +func (a *Accelerator) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed { + return a.closeErr + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + stats, statsErr := a.context.stats() + a.finalStats = a.hostStats(stats) + a.closeErr = errors.Join(statsErr, a.context.close()) + a.closed = true + if a.pk.Accelerator() == native.Accelerator(a) { + a.pk.SetAccelerator(nil) + } + a.bases = [2]registeredBasis{} + a.context, a.pk = nil, nil + return a.closeErr +} + +func (a *Accelerator) Stats() (Stats, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed { + return a.finalStats, a.closeErr + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + stats, err := a.context.stats() + return a.hostStats(stats), err +} + +func (a *Accelerator) resolve(points []curve.G1Affine) (*registeredBasis, int, error) { + start := uintptr(unsafe.Pointer(&points[0])) + width := unsafe.Sizeof(points[0]) + end := start + uintptr(len(points))*width + if end < start { + return nil, 0, fmt.Errorf("CUDA PLONK MSM address overflow") + } + for i := range a.bases { + basis := &a.bases[i] + if start >= basis.start && end <= basis.end && (start-basis.start)%width == 0 { + return basis, int((start - basis.start) / width), nil + } + } + return nil, 0, fmt.Errorf("CUDA PLONK MSM uses an unregistered basis") +} + +func (a *Accelerator) MultiExp(points []curve.G1Affine, scalars []fr.Element) (result curve.G1Affine, err error) { + if len(points) != len(scalars) { + return result, fmt.Errorf("CUDA PLONK MSM length mismatch") + } + err = a.withContext(func(context *nativeContext) (err error) { + transfers := a.startTransferTrace(context) + started := time.Now() + fields := map[string]any{"count": len(points), "strategy": a.strategy.String()} + defer func() { + elapsed := time.Since(started).Seconds() + a.msmSeconds += elapsed + fields["elapsed_seconds"] = elapsed + fields["failed"] = err != nil + transfers.finish(context, fields) + a.traceEvent("end", "msm", fields) + }() + if len(points) == 0 { + a.traceEvent("start", "msm", fields) + a.smallCPUCalls++ + return nil + } + basis, offset, err := a.resolve(points) + if err != nil { + return err + } + fields["basis"], fields["offset"] = basis.name, offset + if len(points) <= 3 { + fields["cpu_small"] = true + a.traceEvent("start", "msm", fields) + a.smallCPUCalls++ + _, err := result.MultiExp(points, scalars, ecc.MultiExpConfig{}) + return err + } + cached := min(len(points), max(0, basis.cached-offset)) + if len(points)-cached > 3 { + return fmt.Errorf("CUDA PLONK MSM exceeds its cached range and tail") + } + if cached > 0 { + a.traceEvent("start", "msm", fields) + result, err = context.msm(basis.handle, offset, scalars[:cached]) + if err != nil { + return err + } + } + if cached < len(points) { + var tail curve.G1Affine + if _, err := tail.MultiExp(points[cached:], scalars[cached:], ecc.MultiExpConfig{}); err != nil { + return err + } + result.Add(&result, &tail) + a.cpuTailPoints += uint64(len(points) - cached) + } + return nil + }) + runtime.KeepAlive(points) + runtime.KeepAlive(scalars) + return +} + +func (a *Accelerator) checkDomain(domain *fft.Domain) error { + if domain == nil || domain.Cardinality < 2 || domain.Cardinality > 1<<26 || domain.Cardinality&(domain.Cardinality-1) != 0 { + return fmt.Errorf("unsupported CUDA PLONK domain") + } + parameters := a.parameters[bits.TrailingZeros64(domain.Cardinality)] + if domain.Generator != parameters.root || domain.GeneratorInv != parameters.inverseRoot || domain.CardinalityInv != parameters.inverseSize { + return fmt.Errorf("CUDA PLONK domain parameters differ") + } + return nil +} + +func (a *Accelerator) ToCanonical(domain *fft.Domain, p *iop.Polynomial) error { + if err := a.checkDomain(domain); err != nil { + return err + } + if p == nil || uint64(len(p.Coefficients())) != domain.Cardinality || (p.Basis != iop.Lagrange && p.Basis != iop.LagrangeCoset) || (p.Layout != iop.Regular && p.Layout != iop.BitReverse) { + return fmt.Errorf("unsupported CUDA PLONK canonical conversion input") + } + coset := fr.One() + if p.Basis == iop.LagrangeCoset { + coset = domain.FrMultiplicativeGen + } + if coset.IsZero() { + return fmt.Errorf("zero CUDA PLONK coset") + } + return a.withContext(func(context *nativeContext) (err error) { + transfers := a.startTransferTrace(context) + started := time.Now() + fields := map[string]any{"count": len(p.Coefficients())} + a.traceEvent("start", "canonical", fields) + defer func() { + elapsed := time.Since(started).Seconds() + a.canonicalSeconds += elapsed + fields["elapsed_seconds"] = elapsed + fields["failed"] = err != nil + transfers.finish(context, fields) + a.traceEvent("end", "canonical", fields) + }() + if err := context.fft(p.Coefficients(), true, p.Layout == iop.BitReverse, coset); err != nil { + return err + } + p.Basis, p.Layout = iop.Canonical, iop.Regular + return nil + }) +} + +func numeratorPolynomials(in *native.NumeratorInput) []*iop.Polynomial { + result := []*iop.Polynomial{in.L, in.R, in.O, in.Z, in.Qk, in.Ql, in.Qr, in.Qm, in.Qo, in.S1, in.S2, in.S3} + for i := range in.Qcp { + result = append(result, in.Qcp[i], in.Pi[i]) + } + return result +} + +func (a *Accelerator) numeratorConfig(in *native.NumeratorInput) (quotientConfig, error) { + var config quotientConfig + if in == nil { + return config, fmt.Errorf("missing CUDA PLONK numerator input") + } + if err := a.checkDomain(in.Domain0); err != nil { + return config, err + } + if err := a.checkDomain(in.Domain1); err != nil { + return config, err + } + n, m := in.Domain0.Cardinality, in.Domain1.Cardinality + if n != a.domain || n > 1<<24 || m%n != 0 || (m/n != 4 && m/n != 8) || len(in.Qcp) != len(in.Pi) || len(in.Qcp) > 16 { + return config, fmt.Errorf("unsupported CUDA PLONK quotient shape") + } + var root fr.Element + root.Exp(in.Domain1.Generator, new(big.Int).SetUint64(m/n)) + if root != in.Domain0.Generator || in.Domain0.FrMultiplicativeGen != in.Domain1.FrMultiplicativeGen || in.Domain1.FrMultiplicativeGen.IsZero() { + return config, fmt.Errorf("CUDA PLONK quotient roots or cosets differ") + } + config = quotientConfig{n: n, m: m, commitments: len(in.Qcp), omega: in.Domain0.Generator, omegaBig: in.Domain1.Generator, coset: in.Domain1.FrMultiplicativeGen, alpha: in.Alpha, beta: in.Beta, gamma: in.Gamma} + var ranges [][2]uintptr + for _, polynomial := range numeratorPolynomials(in) { + if polynomial == nil || uint64(len(polynomial.Coefficients())) != n || polynomial.Basis != iop.Lagrange || polynomial.Layout != iop.Regular { + return config, fmt.Errorf("CUDA PLONK numerator requires Lagrange/regular inputs of size N") + } + start := uintptr(unsafe.Pointer(&polynomial.Coefficients()[0])) + end := start + uintptr(n)*unsafe.Sizeof(fr.Element{}) + if end < start { + return config, fmt.Errorf("CUDA PLONK polynomial address overflow") + } + for _, prior := range ranges { + if start < prior[1] && end > prior[0] { + return config, fmt.Errorf("aliased CUDA PLONK numerator inputs") + } + } + ranges = append(ranges, [2]uintptr{start, end}) + } + for _, blind := range []struct { + p *iop.Polynomial + out []fr.Element + }{{in.Bl, config.bl[:]}, {in.Br, config.br[:]}, {in.Bo, config.bo[:]}, {in.Bz, config.bz[:]}} { + if blind.p == nil || len(blind.p.Coefficients()) < 1 || len(blind.p.Coefficients()) > len(blind.out) || blind.p.Basis != iop.Canonical || blind.p.Layout != iop.Regular { + return config, fmt.Errorf("invalid CUDA PLONK blinding polynomial") + } + copy(blind.out, blind.p.Coefficients()) + } + return config, nil +} + +func (a *Accelerator) compute(in *native.NumeratorInput, fused bool) (*iop.Polynomial, error) { + config, err := a.numeratorConfig(in) + if err != nil { + return nil, err + } + var output []fr.Element + err = a.withContext(func(context *nativeContext) (err error) { + transfers := a.startTransferTrace(context) + started := time.Now() + operation := "numerator" + if fused { + operation = "quotient" + } + fields := map[string]any{"n": config.n, "m": config.m, "commitments": config.commitments} + a.traceEvent("start", operation, fields) + defer func() { + elapsed := time.Since(started).Seconds() + fields["elapsed_seconds"] = elapsed + fields["failed"] = err != nil + if fused { + a.quotientSeconds += elapsed + } else { + a.numeratorSeconds += elapsed + } + transfers.finish(context, fields) + a.traceEvent("end", operation, fields) + }() + handle, err := context.beginQuotient(config) + if err != nil { + return err + } + defer func() { err = errors.Join(err, context.destroyQuotient(handle)) }() + for index, polynomial := range numeratorPolynomials(in) { + if err := context.putQuotient(handle, index, polynomial.Coefficients()); err != nil { + return err + } + polynomial.Basis, polynomial.Layout = iop.Canonical, iop.Regular + } + output = make([]fr.Element, int(config.m)) + return context.finishQuotient(handle, fused, output) + }) + if err != nil { + return nil, err + } + form := iop.Form{Basis: iop.LagrangeCoset, Layout: iop.BitReverse} + if fused { + form = iop.Form{Basis: iop.Canonical, Layout: iop.Regular} + } + return iop.NewPolynomial(&output, form), nil +} + +func (a *Accelerator) ComputeNumerator(in *native.NumeratorInput) (*iop.Polynomial, error) { + return a.compute(in, false) +} +func (a *Accelerator) ComputeQuotientV1(in *native.NumeratorInput) (*iop.Polynomial, error) { + return a.compute(in, true) +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go b/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go new file mode 100644 index 000000000..0c704634e --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go @@ -0,0 +1,546 @@ +//go:build bn254 && cuda + +package cuda + +import ( + "fmt" + "math/big" + "os" + "slices" + "strconv" + "sync" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/iop" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + native "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +func gpuConfig(t *testing.T, features native.AcceleratorFeatureFlagsV1) Config { + t.Helper() + if os.Getenv("IX_PLONK_CUDA_TEST") != "1" { + t.Skip("set IX_PLONK_CUDA_TEST=1 for bounded native CUDA tests") + } + config := Config{MemoryBytes: 8 << 30, HeadroomBytes: 8 << 30, Features: features} + if value := os.Getenv("IX_PLONK_CUDA_DEVICE"); value != "" { + device, err := strconv.Atoi(value) + if err != nil { + t.Fatal(err) + } + config.Device = device + } + return config +} + +func shiftedSRS(count int) *kzg.SRS { + _, _, g1, g2 := curve.Generators() + var tau, power fr.Element + tau.SetUint64(7) + power = tau + srs := &kzg.SRS{Pk: kzg.ProvingKey{G1: make([]curve.G1Affine, count)}} + var scalar big.Int + for i := range srs.Pk.G1 { + power.BigInt(&scalar) + srs.Pk.G1[i].ScalarMultiplication(&g1, &scalar) + power.Mul(&power, &tau) + } + srs.Vk.G1 = srs.Pk.G1[0] + srs.Vk.G2[0] = g2 + srs.Vk.G2[1].ScalarMultiplication(&g2, big.NewInt(7)) + for i := range srs.Vk.G2 { + srs.Vk.Lines[i] = curve.PrecomputeLines(srs.Vk.G2[i]) + } + return srs +} + +func kernelKey(t *testing.T, n int) *native.ProvingKey { + t.Helper() + srs := shiftedSRS(n + 3) + lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) + if err != nil { + t.Fatal(err) + } + return &native.ProvingKey{Kzg: srs.Pk, KzgLagrange: kzg.ProvingKey{G1: lagrange}, Vk: &native.VerifyingKey{Size: uint64(n), Kzg: srs.Vk}} +} + +func attachForTest(t *testing.T, pk *native.ProvingKey, features native.AcceleratorFeatureFlagsV1) *Accelerator { + t.Helper() + accelerator, err := Attach(pk, gpuConfig(t, features)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := accelerator.Close(); err != nil { + t.Error(err) + } + }) + return accelerator +} + +func TestCUDAMSMRangesTailsConcurrencyAndLifecycle(t *testing.T) { + gpuConfig(t, AllFeatures) + pk := kernelKey(t, 64) + a := attachForTest(t, pk, AllFeatures) + for _, basis := range [][]curve.G1Affine{pk.Kzg.G1, pk.KzgLagrange.G1} { + for _, span := range [][2]int{{0, len(basis)}, {2, 17}, {len(basis) - 4, 4}, {len(basis) - 3, 3}, {0, 0}} { + points := basis[span[0] : span[0]+span[1]] + scalars := make([]fr.Element, len(points)) + for i := range scalars { + scalars[i].SetUint64(uint64(11*i + 1)) + } + var want curve.G1Affine + if _, err := want.MultiExp(points, scalars, ecc.MultiExpConfig{}); err != nil { + t.Fatal(err) + } + got, err := a.MultiExp(points, scalars) + if err != nil || !got.Equal(&want) { + t.Fatalf("MSM span %v differs: %v", span, err) + } + } + } + zeros := make([]fr.Element, 64) + result, err := a.MultiExp(pk.Kzg.G1[:64], zeros) + if err != nil || !result.IsInfinity() { + t.Fatalf("zero MSM: %v", err) + } + zeros[0].SetUint64(7) + zeros[1].SetInt64(-1) + result, err = a.MultiExp(pk.Kzg.G1[:64], zeros) + if err != nil || !result.IsInfinity() { + t.Fatalf("cancelled MSM: %v", err) + } + if _, err := a.MultiExp(append([]curve.G1Affine(nil), pk.Kzg.G1[:4]...), zeros[:4]); err == nil { + t.Fatal("accepted copied unregistered bases") + } + if _, err := a.MultiExp(pk.Kzg.G1[:4], zeros[:3]); err == nil { + t.Fatal("accepted mismatched MSM lengths") + } + var wait sync.WaitGroup + errors := make(chan error, 4) + for i := range 4 { + wait.Add(1) + go func(offset int) { + defer wait.Done() + points := pk.Kzg.G1[offset : offset+32] + scalars := make([]fr.Element, 32) + for j := range scalars { + scalars[j].SetUint64(uint64(j + offset + 2)) + } + var want curve.G1Affine + if _, err := want.MultiExp(points, scalars, ecc.MultiExpConfig{}); err != nil { + errors <- err + return + } + got, err := a.MultiExp(points, scalars) + if err != nil { + errors <- err + } else if !got.Equal(&want) { + errors <- fmt.Errorf("concurrent MSM mismatch") + } + }(i) + } + wait.Wait() + close(errors) + for err := range errors { + t.Error(err) + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 || stats.CPUTailPoints == 0 || stats.SmallCPUCalls == 0 { + t.Fatalf("missing dispatch evidence: %+v", stats) + } + if stats.MSMCallbackSeconds <= 0 || stats.QueueWaitSeconds < 0 { + t.Fatalf("invalid callback timing: %+v", stats) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + finalStats, err := a.Stats() + if err != nil || finalStats.MSMCallbackSeconds != stats.MSMCallbackSeconds || finalStats.QueueWaitSeconds != stats.QueueWaitSeconds { + t.Fatalf("callback timings changed at close: %+v, %v", finalStats, err) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + if pk.Accelerator() != nil { + t.Fatal("closed accelerator is still attached") + } + if _, err := a.MultiExp(pk.Kzg.G1[:4], zeros[:4]); err == nil { + t.Fatal("accepted MSM after close") + } +} + +func TestCUDAFFTCanonicalFormsAndRoots(t *testing.T) { + gpuConfig(t, AllFeatures) + a := attachForTest(t, kernelKey(t, 64), AllFeatures) + for _, n := range []int{2, 8, 64, 512, 4096} { + domain := fft.NewDomain(uint64(n)) + coefficients := make([]fr.Element, n) + for i := range coefficients { + coefficients[i].SetUint64(uint64(i*i + 3*i + 9)) + } + for _, coset := range []bool{false, true} { + for _, reversed := range []bool{false, true} { + values := append([]fr.Element(nil), coefficients...) + p := iop.NewPolynomial(&values, iop.Form{Basis: iop.Canonical, Layout: iop.Regular}) + if coset { + p.ToLagrangeCoset(domain).ToRegular() + } else { + p.ToLagrange(domain).ToRegular() + } + if reversed { + fft.BitReverse(p.Coefficients()) + p.Layout = iop.BitReverse + } + want := p.Clone().ToCanonical(domain).ToRegular() + if err := a.ToCanonical(domain, p); err != nil { + t.Fatal(err) + } + if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), want.Coefficients()) || !slices.Equal(p.Coefficients(), coefficients) { + t.Fatalf("N=%d coset=%t reversed=%t inverse differs", n, coset, reversed) + } + } + got := append([]fr.Element(nil), coefficients...) + shift := fr.One() + wantValues := append([]fr.Element(nil), coefficients...) + want := iop.NewPolynomial(&wantValues, iop.Form{Basis: iop.Canonical, Layout: iop.Regular}) + if coset { + shift = domain.FrMultiplicativeGen + want.ToLagrangeCoset(domain).ToRegular() + } else { + want.ToLagrange(domain).ToRegular() + } + if err := a.withContext(func(context *nativeContext) error { return context.fft(got, false, false, shift) }); err != nil { + t.Fatal(err) + } + if !slices.Equal(got, want.Coefficients()) { + t.Fatalf("N=%d coset=%t forward differs", n, coset) + } + } + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.FFTCalls == 0 { + t.Fatal("FFT tests dispatched no CUDA FFTs") + } +} + +func numeratorFixture(n, rho, commitments int) (*native.NumeratorInput, [][]fr.Element) { + domain := fft.NewDomain(uint64(n)) + in := &native.NumeratorInput{Domain0: domain, Domain1: fft.NewDomain(uint64(n * rho))} + in.Alpha.SetUint64(19) + in.Beta.SetUint64(23) + in.Gamma.SetUint64(29) + var canonical [][]fr.Element + polynomials := make([]*iop.Polynomial, 12+2*commitments) + for slot := range polynomials { + coefficients := make([]fr.Element, n) + for i := range coefficients { + coefficients[i].SetUint64(uint64(3 + slot*17 + i*i + slot*i)) + } + canonical = append(canonical, append([]fr.Element(nil), coefficients...)) + polynomials[slot] = iop.NewPolynomial(&coefficients, iop.Form{Basis: iop.Canonical, Layout: iop.Regular}).ToLagrange(domain).ToRegular() + } + in.L, in.R, in.O, in.Z, in.Qk = polynomials[0], polynomials[1], polynomials[2], polynomials[3], polynomials[4] + in.Ql, in.Qr, in.Qm, in.Qo = polynomials[5], polynomials[6], polynomials[7], polynomials[8] + in.S1, in.S2, in.S3 = polynomials[9], polynomials[10], polynomials[11] + for i := range commitments { + in.Qcp = append(in.Qcp, polynomials[12+2*i]) + in.Pi = append(in.Pi, polynomials[13+2*i]) + } + blinder := func(values ...uint64) *iop.Polynomial { + coefficients := make([]fr.Element, len(values)) + for i := range coefficients { + coefficients[i].SetUint64(values[i]) + } + return iop.NewPolynomial(&coefficients, iop.Form{Basis: iop.Canonical, Layout: iop.Regular}) + } + in.Bl, in.Br, in.Bo, in.Bz = blinder(31, 37), blinder(41, 43), blinder(47, 53), blinder(59, 61, 67) + return in, canonical +} + +func evaluate(coefficients []fr.Element, x fr.Element) fr.Element { + var value fr.Element + for i := len(coefficients) - 1; i >= 0; i-- { + value.Mul(&value, &x).Add(&value, &coefficients[i]) + } + return value +} + +func numeratorOracle(in *native.NumeratorInput, canonical [][]fr.Element) []fr.Element { + result := make([]fr.Element, int(in.Domain1.Cardinality)) + x := in.Domain1.FrMultiplicativeGen + one := fr.One() + for row := range result { + values := make([]fr.Element, len(canonical)) + for i := range canonical { + values[i] = evaluate(canonical[i], x) + } + var zh, shiftedX, shiftedZ fr.Element + zh.Exp(x, new(big.Int).SetUint64(in.Domain0.Cardinality)).Sub(&zh, &one) + shiftedX.Mul(&x, &in.Domain0.Generator) + shiftedZ = evaluate(canonical[3], shiftedX) + blinders := []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} + for i, p := range blinders { + b := evaluate(p.Coefficients(), x) + b.Mul(&b, &zh) + values[i].Add(&values[i], &b) + } + b := evaluate(in.Bz.Coefficients(), shiftedX) + b.Mul(&b, &zh) + shiftedZ.Add(&shiftedZ, &b) + var gate, tmp fr.Element + gate.Mul(&values[5], &values[0]) + tmp.Mul(&values[6], &values[1]) + gate.Add(&gate, &tmp) + tmp.Mul(&values[7], &values[0]).Mul(&tmp, &values[1]) + gate.Add(&gate, &tmp) + tmp.Mul(&values[8], &values[2]) + gate.Add(&gate, &tmp).Add(&gate, &values[4]) + for i := range in.Pi { + tmp.Mul(&values[12+2*i], &values[13+2*i]) + gate.Add(&gate, &tmp) + } + left, right := shiftedZ, values[3] + identity := x + for i := range 3 { + tmp.Mul(&in.Beta, &values[9+i]).Add(&tmp, &values[i]).Add(&tmp, &in.Gamma) + left.Mul(&left, &tmp) + tmp.Mul(&in.Beta, &identity).Add(&tmp, &values[i]).Add(&tmp, &in.Gamma) + right.Mul(&right, &tmp) + identity.Mul(&identity, &in.Domain1.FrMultiplicativeGen) + } + var ordering, boundary, denominator fr.Element + ordering.Sub(&left, &right) + denominator.Sub(&x, &one) + boundary.Div(&zh, &denominator).Mul(&boundary, &in.Domain0.CardinalityInv) + tmp.Sub(&values[3], &one) + boundary.Mul(&boundary, &tmp) + boundary.Mul(&boundary, &in.Alpha).Add(&boundary, &ordering).Mul(&boundary, &in.Alpha).Add(&boundary, &gate) + result[row] = boundary + x.Mul(&x, &in.Domain1.Generator) + } + return result +} + +func TestCUDANumeratorAndFusedQuotientAgainstDirectOracle(t *testing.T) { + gpuConfig(t, AllFeatures) + for _, shape := range [][3]int{{2, 8, 0}, {4, 8, 1}, {8, 4, 0}, {16, 4, 1}, {32, 4, 2}} { + n, rho, commitments := shape[0], shape[1], shape[2] + t.Run(fmt.Sprint(shape), func(t *testing.T) { + a := attachForTest(t, kernelKey(t, n), AllFeatures) + for _, fused := range []bool{false, true} { + in, canonical := numeratorFixture(n, rho, commitments) + blinds := [][]fr.Element{append([]fr.Element(nil), in.Bl.Coefficients()...), append([]fr.Element(nil), in.Br.Coefficients()...), append([]fr.Element(nil), in.Bo.Coefficients()...), append([]fr.Element(nil), in.Bz.Coefficients()...)} + want := numeratorOracle(in, canonical) + var got *iop.Polynomial + var err error + if fused { + x := in.Domain1.FrMultiplicativeGen + one := fr.One() + for i := range want { + var zh fr.Element + zh.Exp(x, new(big.Int).SetUint64(uint64(n))).Sub(&zh, &one) + want[i].Div(&want[i], &zh) + x.Mul(&x, &in.Domain1.Generator) + } + p := iop.NewPolynomial(&want, iop.Form{Basis: iop.LagrangeCoset, Layout: iop.Regular}).ToCanonical(in.Domain1).ToRegular() + want = p.Coefficients() + got, err = a.ComputeQuotientV1(in) + } else { + fft.BitReverse(want) + got, err = a.ComputeNumerator(in) + } + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got.Coefficients(), want) { + t.Fatalf("fused=%t numerator/quotient differs", fused) + } + form := iop.Form{Basis: iop.LagrangeCoset, Layout: iop.BitReverse} + if fused { + form = iop.Form{Basis: iop.Canonical, Layout: iop.Regular} + } + if got.Form != form { + t.Fatalf("false result form: %+v", got.Form) + } + for i, p := range numeratorPolynomials(in) { + if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), canonical[i]) { + t.Fatalf("input %d not restored", i) + } + } + for i, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { + if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), blinds[i]) { + t.Fatalf("blinding polynomial %d changed", i) + } + } + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.NumeratorCalls != 1 || stats.QuotientCalls != 1 { + t.Fatalf("wrong quotient dispatch: %+v", stats) + } + }) + } +} + +func TestCUDARejectsMalformedInputBeforeMutation(t *testing.T) { + config := gpuConfig(t, AllFeatures) + pk := kernelKey(t, 16) + a := attachForTest(t, pk, AllFeatures) + before, err := a.Stats() + if err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*native.NumeratorInput){ + func(in *native.NumeratorInput) { in.Pi = nil }, + func(in *native.NumeratorInput) { in.L.Layout = iop.BitReverse }, + func(in *native.NumeratorInput) { in.R = in.L }, + func(in *native.NumeratorInput) { in.Domain1.Generator.SetOne() }, + func(in *native.NumeratorInput) { in.Domain1.FrMultiplicativeGen.SetZero() }, + func(in *native.NumeratorInput) { in.Bl.Basis = iop.Lagrange }, + } { + in, _ := numeratorFixture(16, 4, 1) + domainCopy := *in.Domain1 + in.Domain1 = &domainCopy + mutate(in) + original := append([]fr.Element(nil), in.L.Coefficients()...) + if _, err := a.ComputeQuotientV1(in); err == nil { + t.Fatal("accepted malformed numerator input") + } + if !slices.Equal(original, in.L.Coefficients()) { + t.Fatal("rejected input was mutated") + } + } + after, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if after.MSMCalls != before.MSMCalls || after.FFTCalls != before.FFTCalls || after.NumeratorCalls != before.NumeratorCalls || after.QuotientCalls != before.QuotientCalls { + t.Fatal("malformed input reached a native computation") + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + config.MemoryBytes = 1 + if unexpected, err := Attach(pk, config); err == nil { + unexpected.Close() + t.Fatal("accepted insufficient device budget") + } +} + +type proofCircuit struct { + Claim [2]frontend.Variable `gnark:",public"` + X, Y frontend.Variable + Commitments int `gnark:"-"` +} + +func (c *proofCircuit) Define(api frontend.API) error { + api.AssertIsEqual(api.Mul(c.X, c.X), c.Claim[0]) + api.AssertIsEqual(api.Mul(c.Y, c.Y), c.Claim[1]) + for i := range c.Commitments { + value := api.Add(c.X, api.Mul(c.Y, i+1)) + challenge, err := api.(frontend.Committer).Commit(value) + if err != nil { + return err + } + api.AssertIsDifferent(challenge, 0) + } + return nil +} + +func TestCUDAShiftedPLONKProofsVerifyOnCPU(t *testing.T) { + gpuConfig(t, AllFeatures) + for commitments := range 3 { + ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &proofCircuit{Commitments: commitments}) + if err != nil { + t.Fatal(err) + } + if len(ccs.GetCommitments().CommitmentIndexes()) != commitments { + t.Fatalf("fixture compiled %d commitments instead of %d", len(ccs.GetCommitments().CommitmentIndexes()), commitments) + } + n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())) + srs := shiftedSRS(int(n) + 3) + lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:int(n)]) + if err != nil { + t.Fatal(err) + } + pk, vk, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) + if err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(&proofCircuit{Claim: [2]frontend.Variable{49, 121}, X: 7, Y: 11, Commitments: commitments}, ecc.BN254.ScalarField()) + if err != nil { + t.Fatal(err) + } + public, err := frontend.NewWitness(&proofCircuit{Claim: [2]frontend.Variable{49, 121}}, ecc.BN254.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } + wrong, err := frontend.NewWitness(&proofCircuit{Claim: [2]frontend.Variable{50, 121}}, ecc.BN254.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } + for _, zk := range []bool{false, true} { + opts := []backend.ProverOption{solidity.WithProverTargetSolidityVerifier(backend.PLONK)} + if zk { + opts = append(opts, backend.WithStatisticalZeroKnowledge()) + } + cpu, err := plonk.Prove(ccs, pk, w, opts...) + if err != nil { + t.Fatal(err) + } + if err := plonk.Verify(cpu, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + for _, features := range []native.AcceleratorFeatureFlagsV1{native.AcceleratorFeatureMSMV1, AllFeatures &^ native.AcceleratorFeatureQuotientV1, AllFeatures} { + t.Run(fmt.Sprintf("commitments%d-zk%t-features%d", commitments, zk, features), func(t *testing.T) { + a := attachForTest(t, pk.(*native.ProvingKey), features) + proof, err := plonk.Prove(ccs, pk, w, opts...) + if err != nil { + t.Fatal(err) + } + if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + if err := plonk.Verify(proof, vk, wrong, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err == nil { + t.Fatal("accepted wrong expected public inputs") + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 { + t.Fatal("proof dispatched no CUDA MSMs") + } + if features&native.AcceleratorFeatureQuotientV1 != 0 { + if stats.QuotientCalls != 1 || stats.NumeratorCalls != 0 { + t.Fatalf("fused proof dispatched incorrectly: %+v", stats) + } + } else if features&native.AcceleratorFeatureNumeratorV1 != 0 { + if stats.NumeratorCalls != 1 || stats.QuotientCalls != 0 { + t.Fatalf("numerator proof dispatched incorrectly: %+v", stats) + } + } else if stats.NumeratorCalls != 0 || stats.QuotientCalls != 0 || stats.FFTCalls != 0 { + t.Fatalf("MSM-only proof dispatched extra operations: %+v", stats) + } + }) + } + } + } +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/masking_test.go b/tools/bls-plonk-wrapper/internal/cuda/masking_test.go new file mode 100644 index 000000000..340d8afe5 --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/masking_test.go @@ -0,0 +1,83 @@ +//go:build bn254 && cuda + +package cuda + +import ( + "crypto/sha256" + "encoding/binary" + "slices" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" +) + +func TestCUDAMaskedMSMTransparencyAndRangeCache(t *testing.T) { + config := gpuConfig(t, AllFeatures) + config.MSMStrategy = MSMMask + pk := kernelKey(t, 8192) + a, err := Attach(pk, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := a.Close(); err != nil { + t.Error(err) + } + }) + ranges := [][]curve.G1Affine{pk.Kzg.G1[:4096], pk.Kzg.G1[1:4097], pk.Kzg.G1[:4097]} + kinds := []string{"zero", "cancellation", "distinct-shifted", "narrow-small", "shifted-binary"} + var shift fr.Element + if _, err := shift.SetString("837628908879347212334541733913009433"); err != nil { + t.Fatal(err) + } + for _, points := range ranges { + for _, kind := range kinds { + values := make([]fr.Element, len(points)) + switch kind { + case "cancellation": + values[0].SetUint64(7) + values[1].SetInt64(-1) + case "shifted-binary": + values = structuredScalars(len(points), kind) + case "distinct-shifted", "narrow-small": + for i := range values { + if kind == "distinct-shifted" { + values[i].SetUint64(uint64(i)).Add(&values[i], &shift) + } else { + values[i].SetUint64(uint64(i % 256)) + } + if i%17 == 0 { + var seed [8]byte + binary.LittleEndian.PutUint64(seed[:], uint64(i)) + digest := sha256.Sum256(seed[:]) + values[i].SetBytes(digest[:]) + } + } + } + original := append([]fr.Element(nil), values...) + var want curve.G1Affine + if _, err := want.MultiExp(points, values, ecc.MultiExpConfig{}); err != nil { + t.Fatal(err) + } + for range 2 { + got, err := a.MultiExp(points, values) + if err != nil || !got.Equal(&want) { + t.Fatalf("masked %s MSM differs: %v", kind, err) + } + if !slices.Equal(values, original) { + t.Fatal("masking changed caller scalars") + } + } + } + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + calls := uint64(len(ranges) * len(kinds) * 2) + if stats.MaskedMSMCalls != calls || stats.MaskSumCalls != uint64(len(ranges)) || stats.MaskCacheHits != calls-uint64(len(ranges)) { + t.Fatalf("mask/correction range cache was not exercised: %+v", stats) + } +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go b/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go new file mode 100644 index 000000000..e1a030599 --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go @@ -0,0 +1,195 @@ +//go:build bn254 && cuda + +package cuda + +import ( + "fmt" + "math/big" + "slices" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + native "github.com/consensys/gnark/backend/plonk/bn254" +) + +func splitBoundaryKey(n int) *native.ProvingKey { + _, _, generator, _ := curve.Generators() + var negativeGenerator curve.G1Affine + negativeGenerator.Neg(&generator) + pattern := make([]curve.G1Affine, 263) + for i := range pattern { + if i%17 == 0 { + continue + } + scalar := big.NewInt(int64((i+1)*(i+1)*(i+1) + 11)) + pattern[i].ScalarMultiplication(&generator, scalar) + if i%7 == 0 { + pattern[i].Neg(&pattern[i]) + } + } + pk := &native.ProvingKey{ + Kzg: kzg.ProvingKey{G1: make([]curve.G1Affine, n+3)}, + KzgLagrange: kzg.ProvingKey{G1: make([]curve.G1Affine, n)}, + Vk: &native.VerifyingKey{Size: uint64(n)}, + } + for i := range pk.Kzg.G1 { + pk.Kzg.G1[i] = pattern[i%len(pattern)] + } + for i := range pk.KzgLagrange.G1 { + pk.KzgLagrange.G1[i] = pattern[(17*i+7)%len(pattern)] + } + // Equal, inverse, and infinity partials exercise every projective addition + // branch. These are kernel test bases, not a polynomial commitment setup. + for i := range 256 { + pk.Kzg.G1[i] = generator + pk.Kzg.G1[i+256] = negativeGenerator + pk.Kzg.G1[i+512] = curve.G1Affine{} + } + return pk +} + +func checkSplitMSM(t *testing.T, accelerator *Accelerator, points []curve.G1Affine, scalars []fr.Element) { + t.Helper() + original := slices.Clone(scalars) + var want curve.G1Affine + if _, err := want.MultiExp(points, scalars, ecc.MultiExpConfig{}); err != nil { + t.Fatal(err) + } + got, err := accelerator.MultiExp(points, scalars) + if err != nil { + t.Fatal(err) + } + if !got.Equal(&want) { + t.Fatal("split-bucket MSM differs from the complete CPU MSM") + } + if !slices.Equal(scalars, original) { + t.Fatal("split-bucket MSM changed caller scalars") + } +} + +func TestCUDASplitBucketBoundariesAndSignedDigits(t *testing.T) { + config := gpuConfig(t, AllFeatures) + if config.MSMStrategy != MSMSplit { + t.Fatal("default CUDA MSM policy is not split-bucket accumulation") + } + const domain = 1 << 17 + pk := splitBoundaryKey(domain) + a, err := Attach(pk, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := a.Close(); err != nil { + t.Error(err) + } + }) + var high fr.Element + high.SetBigInt(new(big.Int).Lsh(big.NewInt(1), 252)) + for _, count := range []int{255, 256, 257, 511, 512, 513, 65535, 65536, 65537} { + for _, kind := range []string{"positive", "negative", "top-window"} { + t.Run(fmt.Sprintf("count_%d/%s", count, kind), func(t *testing.T) { + values := make([]fr.Element, count) + for i := range values { + switch kind { + case "positive": + values[i].SetUint64(2) + case "negative": + values[i].SetInt64(-2) + case "top-window": + values[i] = high + } + } + checkSplitMSM(t, a, pk.Kzg.G1[:count], values) + }) + } + } + for _, kind := range []string{"zero", "one", "negative-one", "signed-small", "powers-of-two", "half-modulus", "distinct-concentrated"} { + t.Run(kind, func(t *testing.T) { + const count = 4099 + values := make([]fr.Element, count) + half := new(big.Int).Rsh(fr.Modulus(), 1) + for i := range values { + switch kind { + case "one": + values[i].SetOne() + case "negative-one": + values[i].SetInt64(-1) + case "signed-small": + values[i].SetInt64(int64(i%31 - 15)) + case "powers-of-two": + values[i].SetBigInt(new(big.Int).Lsh(big.NewInt(1), uint(i%253))) + if i%2 != 0 { + values[i].Neg(&values[i]) + } + case "half-modulus": + value := new(big.Int).Add(half, big.NewInt(int64(i%7-3))) + values[i].SetBigInt(value) + case "distinct-concentrated": + values[i].SetUint64((uint64(i) << 32) | 2) + if i%2 != 0 { + values[i].Neg(&values[i]) + } + } + } + before, err := a.Stats() + if err != nil { + t.Fatal(err) + } + checkSplitMSM(t, a, pk.KzgLagrange.G1[3:3+count], values) + if kind == "zero" || kind == "one" || kind == "negative-one" { + after, err := a.Stats() + if err != nil { + t.Fatal(err) + } + var expectedUnits uint64 + if kind != "zero" { + expectedUnits = count + } + if after.SplitChunkCount != before.SplitChunkCount || after.SplitUnitPoints-before.SplitUnitPoints != expectedUnits { + t.Fatalf("zero/±1 input did not retain its sparse path: before=%+v after=%+v", before, after) + } + } + }) + } + t.Run("multiple-batches-and-offset", func(t *testing.T) { + points := pk.Kzg.G1[3:domain] + values := make([]fr.Element, len(points)) + for i := range values { + values[i].SetUint64((uint64(i) << 32) | 2) + if i%5 == 0 { + values[i].Neg(&values[i]) + } + } + checkSplitMSM(t, a, points, values) + }) + t.Run("infinity-points", func(t *testing.T) { + values := make([]fr.Element, 256) + for i := range values { + values[i].SetInt64(int64(i + 2)) + } + checkSplitMSM(t, a, pk.Kzg.G1[512:768], values) + }) + t.Run("unchanged-monomial-tail", func(t *testing.T) { + values := make([]fr.Element, domain+3) + for i := range values { + values[i].SetInt64(int64(i%9 - 4)) + } + checkSplitMSM(t, a, pk.Kzg.G1, values) + }) + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 || stats.CPUTailPoints != 3 { + t.Fatalf("missing split MSM or tail dispatch evidence: %+v", stats) + } + if stats.SplitMSMCalls != stats.MSMCalls || slices.Max(stats.SplitMaxBucket[:]) < 65537 || slices.Max(stats.SplitChunkCount[:]) == 0 { + t.Fatalf("concentrated inputs did not exercise split-bucket accumulation: %+v", stats) + } + if stats.MaskedMSMCalls != 0 || stats.MaskSumCalls != 0 || stats.MaskCacheHits != 0 { + t.Fatalf("default split policy performed scalar masking: %+v", stats) + } +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/structured_test.go b/tools/bls-plonk-wrapper/internal/cuda/structured_test.go new file mode 100644 index 000000000..7ddbf9537 --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/structured_test.go @@ -0,0 +1,90 @@ +//go:build bn254 && cuda + +package cuda + +import ( + "crypto/sha256" + "encoding/binary" + "slices" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" +) + +func structuredScalars(count int, kind string) []fr.Element { + values := make([]fr.Element, count) + var c, d, minusC fr.Element + c.SetString("837628908879347212334541733913009433") + d.SetUint64(7919) + minusC.Neg(&c) + one := fr.One() + for i := range values { + switch kind { + case "shifted-binary": + values[i] = minusC + if i%4 == 0 { + values[i].Add(&values[i], &one) + } + case "signed-zero": + switch i % 3 { + case 0: + values[i] = c + case 1: + values[i] = minusC + } + case "three-hot": + values[i] = c + switch i % 3 { + case 0: + values[i].Sub(&values[i], &d) + case 1: + values[i].Add(&values[i], &d) + } + } + if i%17 == 0 { + var input [8]byte + binary.LittleEndian.PutUint64(input[:], uint64(i)) + digest := sha256.Sum256(input[:]) + values[i].SetBytes(digest[:]) + } + } + return values +} + +func TestCUDAStructuredMSMMatchesRegisteredArrays(t *testing.T) { + gpuConfig(t, AllFeatures) + pk := kernelKey(t, 128) + // The generic MSM contract applies to the registered arrays independently of + // whether their labels satisfy a proving-key basis relation. + pk.KzgLagrange.G1[0].Neg(&pk.KzgLagrange.G1[0]) + a := attachForTest(t, pk, AllFeatures) + ranges := [][]curve.G1Affine{pk.KzgLagrange.G1, pk.Kzg.G1, pk.KzgLagrange.G1[:64], pk.KzgLagrange.G1[1:65], pk.KzgLagrange.G1[:96]} + for _, points := range ranges { + for _, kind := range []string{"shifted-binary", "signed-zero", "three-hot"} { + values := structuredScalars(len(points), kind) + original := append([]fr.Element(nil), values...) + var want curve.G1Affine + if _, err := want.MultiExp(points, values, ecc.MultiExpConfig{}); err != nil { + t.Fatal(err) + } + for range 2 { + got, err := a.MultiExp(points, values) + if err != nil || !got.Equal(&want) { + t.Fatalf("%s MSM differs: %v", kind, err) + } + if !slices.Equal(values, original) { + t.Fatal("MSM changed caller scalars") + } + } + } + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 || stats.SplitMSMCalls != stats.MSMCalls || stats.MaskedMSMCalls != 0 { + t.Fatalf("split MSM used an unexpected scalar transformation: %+v", stats) + } +} diff --git a/tools/bls-plonk-wrapper/internal/cuda/trace.go b/tools/bls-plonk-wrapper/internal/cuda/trace.go new file mode 100644 index 000000000..d45f39cf0 --- /dev/null +++ b/tools/bls-plonk-wrapper/internal/cuda/trace.go @@ -0,0 +1,64 @@ +//go:build bn254 && cuda + +package cuda + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +func (a *Accelerator) traceEvent(event, operation string, fields map[string]any) { + if !a.trace { + return + } + fields["kind"], fields["event"], fields["operation"] = "plonk_cuda_callback", event, operation + fields["time"] = time.Now().UTC().Format(time.RFC3339Nano) + if encoded, err := json.Marshal(fields); err == nil { + fmt.Fprintln(os.Stderr, string(encoded)) + } +} + +// Callback snapshots run while the accelerator lock and OS-thread lease are held. +type transferTrace struct { + before Stats + err error + enabled bool +} + +func (a *Accelerator) startTransferTrace(context *nativeContext) transferTrace { + if !a.trace { + return transferTrace{} + } + before, err := context.stats() + return transferTrace{before: before, err: err, enabled: true} +} + +func (trace transferTrace) finish(context *nativeContext, fields map[string]any) { + if !trace.enabled { + return + } + if trace.err != nil { + fields["transfer_stats_error"] = trace.err.Error() + return + } + after, err := context.stats() + if err != nil { + fields["transfer_stats_error"] = err.Error() + return + } + fields["native_transfers"] = map[string]any{ + "dma_timing_enabled": after.TransferTimingEnabled, + "wall_includes_predecessor_work": true, + "upload_dma_ms": after.UploadDMAMS - trace.before.UploadDMAMS, + "download_dma_ms": after.DownloadDMAMS - trace.before.DownloadDMAMS, + "upload_bytes": after.UploadBytes - trace.before.UploadBytes, + "download_bytes": after.DownloadBytes - trace.before.DownloadBytes, + "upload_calls": after.UploadCalls - trace.before.UploadCalls, + "download_calls": after.DownloadCalls - trace.before.DownloadCalls, + "upload_wall_ms": after.UploadWallMS - trace.before.UploadWallMS, + "download_wall_ms": after.DownloadWallMS - trace.before.DownloadWallMS, + "host_copy_ms_nested": after.TransferHostCopyMS - trace.before.TransferHostCopyMS, + } +} diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254.go b/tools/bls-plonk-wrapper/lagrange_import_bn254.go new file mode 100644 index 000000000..ab552e2e6 --- /dev/null +++ b/tools/bls-plonk-wrapper/lagrange_import_bn254.go @@ -0,0 +1,237 @@ +//go:build bn254 + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "time" + + bn "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + "github.com/zeebo/blake3" +) + +// readSP1Lagrange accepts the legacy gnark BN254 key layout used by SP1 +// v6.1.0. Circuit-specific key data is discarded. Vector lengths are bounded +// before invoking gnark's decoder, and only compressed G1 arrays are accepted. +func readSP1Lagrange(path string, domain uint64, srs *nativeSRS) ([]nativeG1, [32]byte, error) { + var digest [32]byte + f, err := os.Open(path) + if err != nil { + return nil, digest, err + } + defer f.Close() + if stat, err := f.Stat(); err != nil || !stat.Mode().IsRegular() { + return nil, digest, fmt.Errorf("candidate must be a regular file") + } + if domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 { + return nil, digest, fmt.Errorf("invalid candidate domain") + } + header := make([]byte, 112+8*32+4) + if _, err := io.ReadFull(f, header); err != nil { + return nil, digest, err + } + if binary.BigEndian.Uint64(header[:8]) != domain || binary.BigEndian.Uint64(header[72:80]) > 64 { + return nil, digest, fmt.Errorf("SP1 key domain or public count differs") + } + commitments := binary.BigEndian.Uint32(header[len(header)-4:]) + if commitments > 64 { + return nil, digest, fmt.Errorf("oversized SP1 commitment count") + } + for i := 0; i < 8; i++ { + if !isCompressedPoint(header[112+32*i]) { + return nil, digest, fmt.Errorf("uncompressed SP1 verifier point") + } + } + // The fixed-G2 line table contains 2*2*66 lines with two Fp2 coefficients. + tail := make([]byte, int(commitments)*32+32+2*64+2*2*66*2*2*32+4) + if _, err := io.ReadFull(f, tail); err != nil { + return nil, digest, err + } + for i := uint32(0); i <= commitments; i++ { + if !isCompressedPoint(tail[32*i]) { + return nil, digest, fmt.Errorf("uncompressed SP1 commitment or generator") + } + } + for i := 0; i < 2; i++ { + if !isCompressedPoint(tail[int(commitments)*32+32+64*i]) { + return nil, digest, fmt.Errorf("uncompressed SP1 G2 anchor") + } + } + indexes := binary.BigEndian.Uint32(tail[len(tail)-4:]) + if indexes != commitments { + return nil, digest, fmt.Errorf("SP1 commitment index count differs") + } + header = append(header, tail...) + last := make([]byte, int(indexes)*8) + if _, err := io.ReadFull(f, last); err != nil { + return nil, digest, err + } + header = append(header, last...) + vk := new(nativeVerifyingKey) + r := bytes.NewReader(header) + if _, err := vk.ReadFrom(r); err != nil { + return nil, digest, err + } + if r.Len() != 0 || !bytes.Equal(encodeAnchors(vk), encodeAnchors(&nativeVerifyingKey{Kzg: srs.Vk})) { + return nil, digest, fmt.Errorf("SP1 setup anchors differ from admitted SRS") + } + d := fft.NewDomain(domain, fft.WithoutPrecompute()) + if !vk.Generator.Equal(&d.Generator) || !vk.SizeInv.Equal(&d.CardinalityInv) { + return nil, digest, fmt.Errorf("SP1 evaluation domain differs") + } + var count [4]byte + if _, err := io.ReadFull(f, count[:]); err != nil { + return nil, digest, err + } + if uint64(binary.BigEndian.Uint32(count[:])) != domain+3 { + return nil, digest, fmt.Errorf("SP1 monomial length differs") + } + offset, err := f.Seek(int64(32*(domain+3)), io.SeekCurrent) + if err != nil { + return nil, digest, err + } + stat, err := f.Stat() + if err != nil { + return nil, digest, err + } + if stat.Size() != offset+4+int64(32*domain) { + return nil, digest, fmt.Errorf("SP1 key length differs") + } + encoded := make([]byte, 4+32*domain) + if _, err := io.ReadFull(f, encoded); err != nil { + return nil, digest, err + } + if uint64(binary.BigEndian.Uint32(encoded[:4])) != domain { + return nil, digest, fmt.Errorf("SP1 Lagrange count differs") + } + for i := uint64(0); i < domain; i++ { + if !isCompressedPoint(encoded[4+32*i]) { + return nil, digest, fmt.Errorf("uncompressed SP1 Lagrange point") + } + } + digest = sha256.Sum256(encoded) + var points []nativeG1 + if err := bn.NewDecoder(bytes.NewReader(encoded)).Decode(&points); err != nil { + return nil, digest, err + } + for i := range points { + canonical := points[i].Bytes() + if !bytes.Equal(canonical[:], encoded[4+32*i:4+32*(i+1)]) { + return nil, digest, fmt.Errorf("noncanonical Lagrange point") + } + } + return points, digest, nil +} + +// A nonzero difference between two N-point prime-order bases is a group +// polynomial of degree below N. A challenge sampled after binding every +// candidate point detects it except with probability at most (N-1)/Fr. +// Interpolating the challenge powers uses scalar FFTs, avoiding a group FFT. +func validateLagrange(srs *nativeSRS, points []nativeG1, setup, receipt string, digest [32]byte) (string, error) { + n := len(points) + if n < 2 || n&(n-1) != 0 || len(srs.Pk.G1) < n { + return "", fmt.Errorf("invalid Lagrange validation dimensions") + } + h := blake3.New() + h.Write([]byte("ix/bn254/plonk/lagrange-validation/v1")) + for _, text := range []string{setup, receipt} { + value, err := unhex(text, 32) + if err != nil { + return "", err + } + h.Write(value) + } + var length [8]byte + binary.LittleEndian.PutUint64(length[:], uint64(n)) + h.Write(length[:]) + h.Write(digest[:]) + var wide [64]byte + if _, err := io.ReadFull(h.Digest(), wide[:]); err != nil { + return "", err + } + var challenge, order fr.Element + challenge.SetBytes(wide[:]) + order.Exp(challenge, new(big.Int).SetUint64(uint64(n))) + if challenge.IsZero() || order.IsOne() { + return "", fmt.Errorf("degenerate validation challenge") + } + powers := make([]fr.Element, n) + powers[0].SetOne() + for i := 1; i < n; i++ { + powers[i].Mul(&powers[i-1], &challenge) + } + coefficients := append([]fr.Element(nil), powers...) + domain := fft.NewDomain(uint64(n)) + domain.FFTInverse(coefficients, fft.DIF) + fft.BitReverse(coefficients) + expected, err := kzg.Commit(coefficients, srs.Pk) + if err != nil { + return "", err + } + actual, err := kzg.Commit(powers, nativeKzgProvingKey{G1: points}) + if err != nil { + return "", err + } + if !actual.Equal(&expected) { + return "", fmt.Errorf("candidate Lagrange basis does not match authenticated monomial SRS") + } + value := challenge.Bytes() + return hex.EncodeToString(value[:]), nil +} + +func importLagrangeSP1(artifacts, monomial, receipt, candidate string) error { + started := time.Now() + m, err := loadManifest(artifacts) + if err != nil { + return err + } + p, err := loadProfile(filepath.Join(artifacts, "profile.json")) + if err != nil { + return err + } + srs, err := readSRS(monomial, receipt, m.Domain, p) + if err != nil { + return err + } + points, digest, err := readSP1Lagrange(candidate, m.Domain, srs) + if err != nil { + return err + } + challenge, err := validateLagrange(srs, points, p.SetupID, receipt, digest) + if err != nil { + return err + } + root := os.Getenv("IX_PLONK_LAGRANGE_CACHE") + if root == "" { + root = filepath.Join(filepath.Dir(artifacts), "lagrange") + } + want, dir, err := lagrangeLocation(root, m.Domain, p.SetupID, receipt, m.ExecutableSHA256) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(dir, "manifest.json")); !os.IsNotExist(err) { + return fmt.Errorf("Lagrange cache already exists or cannot be inspected") + } + validation := map[string]any{"format": "ix-bn254-lagrange-validation-v1", "domain": m.Domain, + "setupID": p.SetupID, "monomialBLAKE3": receipt, "candidateVectorSHA256": hex.EncodeToString(digest[:]), + "challenge": challenge, "basisRelationVerified": true, "elapsedSeconds": time.Since(started).Seconds()} + if err := writeJSON(filepath.Join(dir, "validation.json"), validation); err != nil { + return err + } + if err := publishLagrange(dir, want, points); err != nil { + return err + } + fmt.Printf("validated_lagrange_import=true domain=%d elapsed_seconds=%.3f\n", m.Domain, time.Since(started).Seconds()) + return nil +} diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go b/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go new file mode 100644 index 000000000..a5c08373c --- /dev/null +++ b/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go @@ -0,0 +1,138 @@ +//go:build bn254 + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "os" + "path/filepath" + "testing" + + bn "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +func importedFixture(t *testing.T) (*profile, *nativeSRS, string) { + t.Helper() + p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + if err != nil { + t.Fatal(err) + } + var receipt struct { + Format, Blake3, SetupID, IgnitionManifest string + PublicMaxDegree, Powers uint64 + } + if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { + t.Fatal(err) + } + srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128, p) + if err != nil { + t.Fatal(err) + } + return p, srs, receipt.Blake3 +} + +func candidateDigest(points []nativeG1) [32]byte { + var out bytes.Buffer + if err := bn.NewEncoder(&out).Encode(points); err != nil { + panic(err) + } + return sha256.Sum256(out.Bytes()) +} + +func TestImportedLagrangeBasisRejectsAlteredAndWrongDomainPoints(t *testing.T) { + p, srs, receipt := importedFixture(t) + points, err := toLagrange(srs.Pk.G1[:8]) + if err != nil { + t.Fatal(err) + } + if _, err := validateLagrange(srs, points, p.SetupID, receipt, candidateDigest(points)); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"changed", "permuted", "truncated"} { + bad := append([]nativeG1(nil), points...) + switch kind { + case "changed": + bad[0].Add(&bad[0], &srs.Pk.G1[0]) + case "permuted": + bad[0], bad[1] = bad[1], bad[0] + case "truncated": + bad = bad[:4] + } + if _, err := validateLagrange(srs, bad, p.SetupID, receipt, candidateDigest(bad)); err == nil { + t.Fatalf("accepted %s basis", kind) + } + } +} + +func TestLegacySP1ReaderBoundsAndAnchors(t *testing.T) { + _, srs, _ := importedFixture(t) + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: true}) + if err != nil { + t.Fatal(err) + } + points, err := toLagrange(srs.Pk.G1[:8]) + if err != nil { + t.Fatal(err) + } + pk, _, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: points}, Vk: srs.Vk}) + if err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + if _, err := pk.WriteTo(&encoded); err != nil { + t.Fatal(err) + } + legacy := append([]byte(nil), encoded.Bytes()[16:]...) + if binary.BigEndian.Uint64(legacy[:8]) != 8 { + t.Fatal("unexpected key version framing") + } + path := filepath.Join(t.TempDir(), "key.bin") + if err := os.WriteFile(path, legacy, 0600); err != nil { + t.Fatal(err) + } + got, digest, err := readSP1Lagrange(path, 8, srs) + if err != nil { + t.Fatal(err) + } + if digest != candidateDigest(points) { + t.Fatal("candidate digest differs") + } + for i := range points { + if !got[i].Equal(&points[i]) { + t.Fatal("extraction changed points") + } + } + for _, kind := range []string{"count", "domain", "truncated", "trailing"} { + bad := append([]byte(nil), legacy...) + switch kind { + case "count": + binary.BigEndian.PutUint32(bad[112+8*32:], ^uint32(0)) + case "domain": + binary.BigEndian.PutUint64(bad[:8], 16) + case "truncated": + bad = bad[:len(bad)-1] + case "trailing": + bad = append(bad, 0) + } + if err := os.WriteFile(path, bad, 0600); err != nil { + t.Fatal(err) + } + if _, _, err := readSP1Lagrange(path, 8, srs); err == nil { + t.Fatalf("accepted %s key", kind) + } + } + if err := os.WriteFile(path, legacy, 0600); err != nil { + t.Fatal(err) + } + other := *srs + other.Vk.G1 = srs.Pk.G1[1] + if _, _, err := readSP1Lagrange(path, 8, &other); err == nil { + t.Fatal("accepted different setup anchor") + } +} diff --git a/tools/bls-plonk-wrapper/main.go b/tools/bls-plonk-wrapper/main.go index 3c9322fea..c9cbda700 100644 --- a/tools/bls-plonk-wrapper/main.go +++ b/tools/bls-plonk-wrapper/main.go @@ -45,7 +45,7 @@ func compileProfile(path, dir string) error { return err } start := time.Now() - ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, newCircuit(p)) + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, newCircuit(p)) if err != nil { return err } @@ -59,7 +59,7 @@ func compileProfile(path, dir string) error { return err } m := &manifest{ - Format: "ix-bls-plonk-artifacts-v1", ExecutableSHA256: executable.SHA256, + Format: artifactFormat, ExecutableSHA256: executable.SHA256, ProfileSHA256: profileHash.SHA256, Constraints: ccs.GetNbConstraints(), PublicInputs: ccs.GetNbPublicVariables(), Commitments: len(ccs.GetCommitments().CommitmentIndexes()), @@ -103,7 +103,7 @@ func loadCircuit(dir string) (*profile, constraint.ConstraintSystem, *manifest, if err != nil { return nil, nil, nil, err } - ccs := plonk.NewCS(ecc.BLS12_381) + ccs := plonk.NewCS(curveID) if err := readBinary(filepath.Join(dir, "circuit.bin"), ccs); err != nil { return nil, nil, nil, err } @@ -140,7 +140,7 @@ func checkWitness(dir, path string) error { if err != nil { return err } - w, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) if err != nil { return err } @@ -154,6 +154,9 @@ func checkWitness(dir, path string) error { } func run(args []string) error { + if len(args) == 5 && args[0] == "import-lagrange-sp1" { + return importLagrangeSP1(args[1], args[2], args[3], args[4]) + } if len(args) == 3 && args[0] == "compile" { return compileProfile(args[1], args[2]) } @@ -172,7 +175,7 @@ func run(args []string) error { if len(args) == 6 && args[0] == "verify" { return verify(args[1], args[2], args[3], args[4], args[5]) } - return fmt.Errorf("usage: ix-bls-plonk-wrapper compile | check-witness | prepare | prove | verify ") + return fmt.Errorf("usage: PLONK helper compile | check-witness | import-lagrange-sp1 | prepare | prove | verify ") } func main() { diff --git a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go b/tools/bls-plonk-wrapper/msm_capture_bn254_test.go new file mode 100644 index 000000000..295fe1ddc --- /dev/null +++ b/tools/bls-plonk-wrapper/msm_capture_bn254_test.go @@ -0,0 +1,373 @@ +//go:build bn254 + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + "unsafe" + + "github.com/consensys/gnark-crypto/ecc" + curve "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/iop" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + native "github.com/consensys/gnark/backend/plonk/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" +) + +const msmCaptureFormat = "ix-bn254-init-msm-capture-v1" +const msmScalarFormat = "bn254-fr-u64le-montgomery-v1" + +var errMSMCaptureComplete = errors.New("required Init MSM captures are complete") + +type msmCaptureRecord struct { + Index int + Caller, Basis, ScalarsFile string + Offset, Count uint64 + Scalars artifact + CPUResult, CPUResultSHA256 string + CPUSeconds, WriteSeconds float64 +} + +type msmCaptureManifest struct { + Format, ScalarFormat, Scope string + Complete, StoppedBeforeQuotient bool + ArtifactManifestSHA256, ProvingKeySHA256, VerifyingKeySHA256, ProfileSHA256 string + ExpectedClaim, TestBinarySHA256 string + Domain uint64 + Constraints, PublicInputs, NumCPU, GOMAXPROCS int + Witness artifact + ValidationSeconds, LoadSeconds, WitnessSeconds, CaptureSeconds float64 + Records []msmCaptureRecord +} + +func msmTestRequired(t *testing.T, prefix, name string) string { + t.Helper() + value := os.Getenv(prefix + name) + if value == "" { + t.Fatalf("%s%s must be explicit", prefix, name) + } + return value +} + +func rawMontgomery(values []fr.Element) []byte { + if len(values) == 0 { + return nil + } + return unsafe.Slice((*byte)(unsafe.Pointer(&values[0])), len(values)*32) +} + +func checkRawMontgomeryABI() error { + var one uint64 = 1 + if unsafe.Sizeof(fr.Element{}) != 32 || *(*byte)(unsafe.Pointer(&one)) != 1 { + return fmt.Errorf("MSM capture requires the 32-byte little-endian field ABI") + } + return nil +} + +func authenticatedMSMKey(t *testing.T, dir, pin string) (*manifest, *native.ProvingKey, float64, float64) { + t.Helper() + started := time.Now() + m, err := cpuProfileManifest(dir, pin) + if err != nil { + t.Fatal(err) + } + validation := time.Since(started).Seconds() + started = time.Now() + pk := new(native.ProvingKey) + if err := cpuProfileReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { + t.Fatal(err) + } + if pk.Vk == nil || pk.Vk.Size != m.Domain || uint64(len(pk.Kzg.G1)) != m.Domain+3 || uint64(len(pk.KzgLagrange.G1)) != m.Domain { + t.Fatal("authenticated proving-key dimensions differ") + } + h := sha256.New() + n, err := pk.Vk.WriteTo(h) + if err != nil { + t.Fatal(err) + } + if n != m.Files["verifying-key.bin"].Bytes || hex.EncodeToString(h.Sum(nil)) != m.Files["verifying-key.bin"].SHA256 { + t.Fatal("proving key embeds a different verifying key") + } + return m, pk, validation, time.Since(started).Seconds() +} + +type recordingCPUAccelerator struct { + mu sync.Mutex + pk *native.ProvingKey + out string + records []msmCaptureRecord +} + +func (*recordingCPUAccelerator) ToCanonical(*fft.Domain, *iop.Polynomial) error { + return fmt.Errorf("unexpected FFT during MSM-only capture") +} +func (*recordingCPUAccelerator) ComputeNumerator(*native.NumeratorInput) (*iop.Polynomial, error) { + return nil, fmt.Errorf("unexpected numerator during MSM-only capture") +} + +func captureBasisRange(full, points []curve.G1Affine) (uint64, bool) { + if len(full) == 0 || len(points) == 0 { + return 0, false + } + width := unsafe.Sizeof(curve.G1Affine{}) + start := uintptr(unsafe.Pointer(&full[0])) + end := start + uintptr(len(full))*width + pointStart := uintptr(unsafe.Pointer(&points[0])) + pointEnd := pointStart + uintptr(len(points))*width + if end < start || pointEnd < pointStart || pointStart < start || pointEnd > end || (pointStart-start)%width != 0 { + return 0, false + } + return uint64((pointStart - start) / width), true +} + +func captureCaller() string { + var pcs [20]uintptr + n := runtime.Callers(2, pcs[:]) + frames := runtime.CallersFrames(pcs[:n]) + for { + frame, more := frames.Next() + if strings.Contains(frame.Function, "commitToLRO.func") || strings.HasSuffix(frame.Function, ".bsb22Hint") { + return frame.Function + } + if !more { + return "unknown" + } + } +} + +func (a *recordingCPUAccelerator) MultiExp(points []curve.G1Affine, scalars []fr.Element) (curve.G1Affine, error) { + a.mu.Lock() + defer a.mu.Unlock() + if len(a.records) >= 4 { + return curve.G1Affine{}, errMSMCaptureComplete + } + if len(points) != len(scalars) { + return curve.G1Affine{}, fmt.Errorf("capture MSM length mismatch") + } + offset, ok := captureBasisRange(a.pk.KzgLagrange.G1, points) + if !ok { + return curve.G1Affine{}, fmt.Errorf("capture expected the initial Lagrange MSMs") + } + row := msmCaptureRecord{Index: len(a.records), Caller: captureCaller(), Basis: "lagrange", Offset: offset, Count: uint64(len(scalars))} + row.ScalarsFile = fmt.Sprintf("msm-%02d.scalars.bin", row.Index) + started := time.Now() + var result curve.G1Affine + if _, err := result.MultiExp(points, scalars, ecc.MultiExpConfig{}); err != nil { + return result, err + } + row.CPUSeconds = time.Since(started).Seconds() + started = time.Now() + f, err := os.OpenFile(filepath.Join(a.out, row.ScalarsFile), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return result, err + } + h := sha256.New() + n, writeErr := io.Copy(io.MultiWriter(f, h), bytes.NewReader(rawMontgomery(scalars))) + if writeErr == nil { + writeErr = f.Sync() + } + writeErr = errors.Join(writeErr, f.Close()) + if writeErr != nil { + return result, writeErr + } + if n != int64(row.Count*32) { + return result, fmt.Errorf("incomplete scalar capture") + } + row.Scalars = artifact{SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: n} + encoded := result.Bytes() + row.CPUResult = hex.EncodeToString(encoded[:]) + row.CPUResultSHA256 = fmt.Sprintf("%x", sha256.Sum256(encoded[:])) + row.WriteSeconds = time.Since(started).Seconds() + a.records = append(a.records, row) + runtime.KeepAlive(points) + runtime.KeepAlive(scalars) + if len(a.records) == 4 { + return result, errMSMCaptureComplete + } + return result, nil +} + +func validateCapture(m *msmCaptureManifest, artifacts *manifest) error { + if m.Format != msmCaptureFormat || m.ScalarFormat != msmScalarFormat || !m.Complete || !m.StoppedBeforeQuotient || len(m.Records) != 4 || m.Domain != artifacts.Domain || m.Constraints != artifacts.Constraints || m.PublicInputs != 2 || m.ProvingKeySHA256 != artifacts.Files["proving-key.bin"].SHA256 || m.VerifyingKeySHA256 != artifacts.Files["verifying-key.bin"].SHA256 || m.ProfileSHA256 != artifacts.ProfileSHA256 { + return fmt.Errorf("capture identity or shape differs") + } + shapes := make(map[[2]uint64]int) + for i, row := range m.Records { + if row.Index != i || row.Basis != "lagrange" || row.ScalarsFile != fmt.Sprintf("msm-%02d.scalars.bin", i) || row.Count == 0 || row.Offset > m.Domain || row.Count > m.Domain-row.Offset || row.Scalars.Bytes != int64(row.Count*32) { + return fmt.Errorf("invalid capture range or filename") + } + if _, err := cpuProfileDigest(row.Scalars.SHA256); err != nil { + return err + } + encoded, err := unhex(row.CPUResult, 32) + if err != nil { + return err + } + if fmt.Sprintf("%x", sha256.Sum256(encoded)) != row.CPUResultSHA256 { + return fmt.Errorf("captured CPU result digest differs") + } + shapes[[2]uint64{row.Offset, row.Count}]++ + } + rows := uint64(m.Constraints + m.PublicInputs) + if len(shapes) != 3 || shapes[[2]uint64{0, m.Domain}] != 1 || shapes[[2]uint64{0, rows}] != 1 || shapes[[2]uint64{2, uint64(m.Constraints)}] != 2 { + return fmt.Errorf("capture does not contain BSB22 and all three LRO MSMs") + } + return nil +} + +func TestCaptureActualInitMSMs(t *testing.T) { + if os.Getenv("IX_PLONK_CUDA_MSM_CAPTURE") != "1" { + t.Skip("set IX_PLONK_CUDA_MSM_CAPTURE=1 with explicit artifact and claim pins") + } + if os.Getenv("GOMAXPROCS") != "" { + t.Fatal("unset GOMAXPROCS for default CPU parallelism") + } + if err := checkRawMontgomeryABI(); err != nil { + t.Fatal(err) + } + const prefix = "IX_PLONK_CUDA_MSM_CAPTURE_" + dir, out, witnessPath := msmTestRequired(t, prefix, "ARTIFACTS"), msmTestRequired(t, prefix, "OUTPUT"), msmTestRequired(t, prefix, "WITNESS") + pin, err := cpuProfileDigest(msmTestRequired(t, prefix, "MANIFEST_SHA256")) + if err != nil { + t.Fatal(err) + } + claim, err := cpuProfileDigest(msmTestRequired(t, prefix, "EXPECTED_CLAIM")) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(out, 0700); err != nil { + t.Fatal(err) + } + m, pk, validationSeconds, keySeconds := authenticatedMSMKey(t, dir, pin) + started := time.Now() + ccs := plonk.NewCS(curveID) + if err := cpuProfileReadBinary(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { + t.Fatal(err) + } + vk := new(native.VerifyingKey) + if err := cpuProfileReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { + t.Fatal(err) + } + if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != 2 || len(ccs.GetCommitments().CommitmentIndexes()) != 1 { + t.Fatal("capture circuit metadata differs") + } + p, err := loadProfile(filepath.Join(dir, "profile.json")) + if err != nil { + t.Fatal(err) + } + if err := m.checkFile(dir, "profile.json"); err != nil { + t.Fatal(err) + } + loadSeconds := keySeconds + time.Since(started).Seconds() + started = time.Now() + witnessIdentity, err := fileHash(witnessPath) + if err != nil { + t.Fatal(err) + } + assignment, err := witnessAssignment(p, witnessPath) + if err != nil { + t.Fatal(err) + } + after, err := fileHash(witnessPath) + if err != nil || after != witnessIdentity { + t.Fatal("witness changed during loading") + } + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + t.Fatal(err) + } + digest, _ := hex.DecodeString(claim) + expected, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } + actual, err := w.Public() + if err != nil { + t.Fatal(err) + } + wantBytes, err := expected.MarshalBinary() + if err != nil { + t.Fatal(err) + } + actualBytes, err := actual.MarshalBinary() + if err != nil || !bytes.Equal(actualBytes, wantBytes) { + t.Fatal("capture witness differs from independently expected claim") + } + identity, err := executableHash() + if err != nil { + t.Fatal(err) + } + report := msmCaptureManifest{Format: msmCaptureFormat, ScalarFormat: msmScalarFormat, Scope: "Actual pinned Init witness solved on CPU; first four Lagrange MSMs captured before any quotient. CPU callbacks are serialized at default CPU parallelism. Raw local scalar captures are test artifacts, not proofs.", ArtifactManifestSHA256: pin, ProvingKeySHA256: m.Files["proving-key.bin"].SHA256, VerifyingKeySHA256: m.Files["verifying-key.bin"].SHA256, ProfileSHA256: m.ProfileSHA256, ExpectedClaim: claim, TestBinarySHA256: identity, Domain: m.Domain, Constraints: m.Constraints, PublicInputs: 2, Witness: witnessIdentity, NumCPU: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ValidationSeconds: validationSeconds, LoadSeconds: loadSeconds, WitnessSeconds: time.Since(started).Seconds()} + capture := &recordingCPUAccelerator{pk: pk, out: out} + pk.SetAccelerator(capture) + defer pk.SetAccelerator(nil) + started = time.Now() + _, err = plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + report.CaptureSeconds = time.Since(started).Seconds() + report.Records = capture.records + if !errors.Is(err, errMSMCaptureComplete) || len(report.Records) != 4 { + t.Fatalf("capture did not reach the expected stop; completed MSM count=%d", len(report.Records)) + } + report.Complete, report.StoppedBeforeQuotient = true, true + if err := validateCapture(&report, m); err != nil { + t.Fatal(err) + } + path := filepath.Join(out, "capture.json") + if err := writeJSON(path, &report); err != nil { + t.Fatal(err) + } + receipt, err := fileHash(path) + if err != nil { + t.Fatal(err) + } + t.Logf("capture_manifest_sha256=%s msm_count=%d stopped_before_quotient=true", receipt.SHA256, len(report.Records)) +} + +type rawScalarVector []fr.Element + +func (values rawScalarVector) ReadFrom(reader io.Reader) (int64, error) { + n, err := io.ReadFull(reader, rawMontgomery(values)) + return int64(n), err +} + +func readCaptureManifest(path, pin string) (*msmCaptureManifest, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) + if err != nil { + return nil, err + } + if len(data) > 1<<20 || fmt.Sprintf("%x", sha256.Sum256(data)) != pin { + return nil, fmt.Errorf("capture manifest differs from trusted SHA-256") + } + var m msmCaptureManifest + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&m); err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("trailing capture manifest data") + } + return &m, nil +} diff --git a/tools/bls-plonk-wrapper/optimization_test.go b/tools/bls-plonk-wrapper/optimization_test.go new file mode 100644 index 000000000..4a94c9d80 --- /dev/null +++ b/tools/bls-plonk-wrapper/optimization_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +type optimizedCircuit struct { + Inner wrapperCircuit + Options circuitOptions `gnark:"-"` +} + +func (c *optimizedCircuit) Define(api frontend.API) error { + return c.Inner.constrainWithOptions(api, 6, c.Options) +} + +func TestOptimizationSizing(t *testing.T) { + if os.Getenv("IX_PLONK_SIZE") != "1" { + t.Skip("set IX_PLONK_SIZE=1 for full variant compilation") + } + path := os.Getenv("IX_PLONK_SIZE_PROFILE") + if path == "" { + fixture := "outer" + if curveID == ecc.BN254 { + fixture = "bn254" + } + path = filepath.Join("testdata", fixture, "profile.json") + } + p, err := loadProfile(path) + if err != nil { + t.Fatal(err) + } + type measurement struct { + Variant string + Constraints, Rows, GapTo24, Commitments int + Domain uint64 + Seconds float64 + } + var measurements []measurement + for _, variant := range []struct { + name string + options circuitOptions + }{ + {"baseline", circuitOptions{}}, + {"batched", circuitOptions{batchPairings: true}}, + {"classic-glv", circuitOptions{classicGLV: true}}, + {"batched-classic-glv", circuitOptions{batchPairings: true, classicGLV: true}}, + } { + if selectVariant := os.Getenv("IX_PLONK_SIZE_VARIANT"); selectVariant != "" && selectVariant != variant.name { + continue + } + runtime.GC() + start := time.Now() + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, + &optimizedCircuit{Inner: *newCircuit(p), Options: variant.options}) + if err != nil { + t.Fatal(err) + } + rows := ccs.GetNbConstraints() + ccs.GetNbPublicVariables() + row := measurement{variant.name, ccs.GetNbConstraints(), rows, rows - (1 << 24), len(ccs.GetCommitments().CommitmentIndexes()), ecc.NextPowerOfTwo(uint64(rows)), time.Since(start).Seconds()} + measurements = append(measurements, row) + t.Logf("%s: constraints=%d rows=%d gap_to_2p24=%d domain=%d seconds=%.2f", row.Variant, row.Constraints, row.Rows, row.GapTo24, row.Domain, row.Seconds) + if path := os.Getenv("IX_PLONK_SIZE_REPORT"); path != "" { + encoded, err := json.MarshalIndent(measurements, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/tools/bls-plonk-wrapper/pairing_batch.go b/tools/bls-plonk-wrapper/pairing_batch.go new file mode 100644 index 000000000..509cd3933 --- /dev/null +++ b/tools/bls-plonk-wrapper/pairing_batch.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" + "github.com/consensys/gnark/std/math/bits" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" + "github.com/zeebo/blake3" +) + +func pairingBatchContext(p *profile) ([]byte, error) { + encoded, err := json.Marshal(p) + if err != nil { + return nil, err + } + digest := blake3.Sum256(encoded) + return append([]byte("ix/plonk/pairing-batch/"+curveID.String()+"/v1\x00"), digest[:]...), nil +} + +// The final inner transcript state commits to all packet scalars and points, +// including the deferred public operands and the final opening witnesses. +// The fresh challenge also binds the computed MSM outputs after they are fixed. +func pairingBatchChallenge(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], context []byte, claim [2]frontend.Variable, packetDigest []uints.U8, operands [4]*circuitG1) frontend.Variable { + if len(packetDigest) != 32 { + panic("invalid packet transcript digest length") + } + input := uints.NewU8Array(context) + for _, limb := range claim { + b := bits.ToBinary(api, limb, bits.WithNbDigits(128)) + for j := 0; j < 16; j++ { + input = append(input, u.Bytes.FromBits(b[8*j:8*j+8]...)) + } + } + input = append(input, packetDigest...) + for _, point := range operands { + input = append(input, pointBytes(api, u, f, point)...) + } + out := blakeXOF(u, input, 64) + return pairingBatchScalar(api, u, out) +} + +func pairingBatchScalar(api frontend.API, u *uints.BinaryField[uints.U32], wide []uints.U8) frontend.Variable { + if len(wide) != 64 { + panic("batching challenge requires 512 bits") + } + rho := littleEndian(api, u, wide) + api.AssertIsDifferent(rho, 0) + return rho +} + +func (c *wrapperCircuit) batchedPairing(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], s *emulated.Field[scalarField], curve *sw_emulated.Curve[baseField, scalarField], packetDigest []uints.U8, left, right *circuitG1) error { + outer, err := g2Keys(c.Profile.OuterKeys) + if err != nil { + return err + } + inner, err := g2Keys(c.Profile.DeferredKeys) + if err != nil { + return err + } + if !outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1]) { + return fmt.Errorf("pairing batching requires identical ordered G2 anchors") + } + context, err := pairingBatchContext(c.Profile) + if err != nil { + return err + } + leftCanonical := circuitG1{X: *f.ReduceStrict(&left.X), Y: *f.ReduceStrict(&left.Y)} + rightCanonical := circuitG1{X: *f.ReduceStrict(&right.X), Y: *f.ReduceStrict(&right.Y)} + rho := pairingBatchChallenge(api, u, f, context, c.Claim, packetDigest, [4]*circuitG1{&leftCanonical, &rightCanonical, &c.Deferred[0], &c.Deferred[1]}) + scalar := s.FromBits(bits.ToBinary(api, rho)...) + a := curve.AddUnified(left, curve.ScalarMul(&c.Deferred[0], scalar)) + b := curve.AddUnified(curve.Neg(right), curve.ScalarMul(&c.Deferred[1], scalar)) + pairing, err := newPairing(api) + if err != nil { + return err + } + h, tauH := newCircuitG2Fixed(outer[0]), newCircuitG2Fixed(outer[1]) + return pairing.PairingCheck([]*circuitG1{a, b}, []*circuitG2{&h, &tauH}) +} diff --git a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go b/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go new file mode 100644 index 000000000..63921f2b5 --- /dev/null +++ b/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go @@ -0,0 +1,267 @@ +//go:build bn254 + +package main + +import ( + "bytes" + "math/big" + "path/filepath" + "testing" + + bn "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" + "github.com/consensys/gnark/std/math/emulated" + "github.com/consensys/gnark/std/math/uints" + "github.com/consensys/gnark/test" + "github.com/zeebo/blake3" +) + +type pairingBatchCircuit struct { + Operands [4]circuitG1 + Claim [2]frontend.Variable + PacketDigest [32]uints.U8 + Profile *profile `gnark:"-"` + ExpectedRho frontend.Variable + ChallengeOnly bool `gnark:"-"` +} + +func (c *pairingBatchCircuit) Define(api frontend.API) error { + u, err := uints.NewBinaryField[uints.U32](api) + if err != nil { + return err + } + f, err := emulated.NewField[baseField](api) + if err != nil { + return err + } + s, err := emulated.NewField[scalarField](api) + if err != nil { + return err + } + curve, err := sw_emulated.New[baseField, scalarField](api, curveParameters()) + if err != nil { + return err + } + pairing, err := newPairing(api) + if err != nil { + return err + } + for i := range c.Operands { + pairing.AssertIsOnG1(&c.Operands[i]) + } + if c.ChallengeOnly { + context, err := pairingBatchContext(c.Profile) + if err != nil { + return err + } + rho := pairingBatchChallenge(api, u, f, context, c.Claim, c.PacketDigest[:], [4]*circuitG1{&c.Operands[0], &c.Operands[1], &c.Operands[2], &c.Operands[3]}) + api.AssertIsEqual(rho, c.ExpectedRho) + return nil + } + w := &wrapperCircuit{Profile: c.Profile, Claim: c.Claim, Deferred: [2]circuitG1{c.Operands[2], c.Operands[3]}} + return w.batchedPairing(api, u, f, s, curve, c.PacketDigest[:], &c.Operands[0], &c.Operands[1]) +} + +func nativePairingBatchChallenge(p *profile, claim [2]uint64, packet [32]byte, points [4]nativeG1) *big.Int { + input, err := pairingBatchContext(p) + if err != nil { + panic(err) + } + for _, limb := range claim { + for i := 0; i < 16; i++ { + var b byte + if i < 8 { + b = byte(limb >> uint(8*i)) + } + input = append(input, b) + } + } + input = append(input, packet[:]...) + for _, point := range points { + input = append(input, encodePoint(point)...) + } + h := blake3.New() + h.Write(input) + wide := make([]byte, 64) + h.Digest().Read(wide) + for i, j := 0, len(wide)-1; i < j; i, j = i+1, j-1 { + wide[i], wide[j] = wide[j], wide[i] + } + return new(big.Int).Mod(new(big.Int).SetBytes(wide), scalarModulus()) +} + +func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { + p, srs, _ := importedFixture(t) + C, T := srs.Pk.G1[0], srs.Pk.G1[1] + _, _, g, _ := bn.Generators() + var negC nativeG1 + negC.Neg(&C) + valid := [4]nativeG1{T, C, T, negC} + claim := [2]uint64{123, 456} + packet := [32]byte{9, 8, 7} + assignment := func(points [4]nativeG1, profile *profile) *pairingBatchCircuit { + w := &pairingBatchCircuit{Profile: profile, Claim: [2]frontend.Variable{claim[0], claim[1]}, ExpectedRho: nativePairingBatchChallenge(profile, claim, packet, points)} + copy(w.PacketDigest[:], uints.NewU8Array(packet[:])) + for i := range points { + w.Operands[i] = newCircuitG1(points[i]) + } + return w + } + check := func(name string, points [4]nativeG1, ok bool) { + t.Helper() + err := test.IsSolved(&pairingBatchCircuit{Profile: p}, assignment(points, p), curveID.ScalarField()) + if (err == nil) != ok { + t.Fatalf("%s: expected accepted=%t: %v", name, ok, err) + } + } + check("both-valid", valid, true) + check("all-infinity", [4]nativeG1{}, true) + check("one-identity-equation", [4]nativeG1{T, C, {}, {}}, true) + bad := valid + bad[0].Add(&bad[0], &g) + check("invalid-outer", bad, false) + bad = valid + bad[2].Add(&bad[2], &g) + check("invalid-deferred", bad, false) + bad = [4]nativeG1{} + bad[0] = g + check("invalid-outer-identity-deferred", bad, false) + bad = [4]nativeG1{} + bad[2] = g + check("valid-outer-invalid-deferred", bad, false) + var weighted nativeG1 + weighted.ScalarMultiplication(&g, big.NewInt(7)) + cancel := [4]nativeG1{T, C, g, g} + cancel[0].Sub(&T, &weighted) + cancel[1].Add(&C, &weighted) + if nativePairingBatchChallenge(p, claim, packet, cancel).Cmp(big.NewInt(7)) == 0 { + t.Fatal("cancellation fixture has accidental challenge collision") + } + anchors, _ := g2Keys(p.OuterKeys) + var a, b, neg nativeG1 + neg.Neg(&cancel[1]) + for _, equation := range [][2]nativeG1{{cancel[0], neg}, {cancel[2], cancel[3]}} { + if ok, err := bn.PairingCheck(equation[:], anchors[:]); err != nil || ok { + t.Fatal("cancellation fixture must have two invalid equations", err) + } + } + a.Add(&cancel[0], &weighted) + neg.Neg(&cancel[1]) + b.Add(&neg, &weighted) + if ok, err := bn.PairingCheck([]nativeG1{a, b}, anchors[:]); err != nil || !ok { + t.Fatal("predetermined-coefficient cancellation fixture is invalid", err) + } + check("predetermined-coefficient-cancellation", cancel, false) + for _, points := range [][4]nativeG1{valid, cancel, {}} { + w := assignment(points, p) + if err := test.IsSolved(&pairingBatchCircuit{Profile: p, ChallengeOnly: true}, w, curveID.ScalarField()); err != nil { + t.Fatal(err) + } + } + for _, kind := range []string{"left", "right", "deferred-a", "deferred-b", "claim-low", "claim-high", "packet", "context"} { + w := assignment(valid, p) + profileCopy := *p + switch kind { + case "left", "right", "deferred-a", "deferred-b": + i := map[string]int{"left": 0, "right": 1, "deferred-a": 2, "deferred-b": 3}[kind] + point := valid[i] + point.Add(&point, &g) + w.Operands[i] = newCircuitG1(point) + case "claim-low": + w.Claim[0] = claim[0] + 1 + case "claim-high": + w.Claim[1] = claim[1] + 1 + case "packet": + w.PacketDigest[0] = uints.NewU8(packet[0] ^ 1) + case "context": + profileCopy.Seed = "0x01" + } + if err := test.IsSolved(&pairingBatchCircuit{Profile: &profileCopy, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { + t.Fatalf("unbound %s", kind) + } + } + w := assignment(valid, p) + w.ExpectedRho = 0 + if err := test.IsSolved(&pairingBatchCircuit{Profile: p, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { + t.Fatal("accepted zero challenge") + } + other := *p + other.DeferredKeys = []string{p.DeferredKeys[1], p.DeferredKeys[0]} + if err := test.IsSolved(&pairingBatchCircuit{Profile: &other}, assignment(valid, &other), curveID.ScalarField()); err == nil { + t.Fatal("accepted mismatched anchors") + } + context, _ := pairingBatchContext(p) + other = *p + other.SetupID = "0x" + string(bytes.Repeat([]byte{'0'}, 64)) + changed, _ := pairingBatchContext(&other) + if bytes.Equal(context, changed) { + t.Fatal("setup identity absent from context") + } +} + +func TestBN254OptimizedFullVerifier(t *testing.T) { + p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + if err != nil { + t.Fatal(err) + } + for _, options := range []circuitOptions{{batchPairings: true}, {classicGLV: true}, {batchPairings: true, classicGLV: true}} { + c := &optimizedCircuit{Inner: *newCircuit(p), Options: options} + for _, kind := range []string{"valid", "claim", "evaluation", "commitment", "deferred"} { + w, err := witnessAssignment(p, filepath.Join("testdata", "bn254", "witness.json")) + if err != nil { + t.Fatal(err) + } + switch kind { + case "claim": + w.Claim[0] = new(big.Int).Add(w.Claim[0].(*big.Int), big.NewInt(1)) + case "evaluation": + w.Values[1] = new(big.Int).Add(w.Values[1].(*big.Int), big.NewInt(1)) + case "commitment": + _, _, g, _ := bn.Generators() + w.Points[0] = newCircuitG1(g) + case "deferred": + _, _, g, _ := bn.Generators() + w.Deferred[0] = newCircuitG1(g) + } + err = test.IsSolved(c, &optimizedCircuit{Inner: *w, Options: options}, curveID.ScalarField()) + if (err == nil) != (kind == "valid") { + t.Fatalf("options=%+v mutation=%s: %v", options, kind, err) + } + } + } +} + +type pairingBatchReductionCircuit struct { + Wide [64]uints.U8 + Expected frontend.Variable +} + +func (c *pairingBatchReductionCircuit) Define(api frontend.API) error { + u, err := uints.NewBinaryField[uints.U32](api) + if err != nil { + return err + } + wide := make([]uints.U8, len(c.Wide)) + for i, b := range c.Wide { + wide[i] = u.ByteValueOf(u.Value(b)) + } + api.AssertIsEqual(pairingBatchScalar(api, u, wide), c.Expected) + return nil +} + +func TestPairingBatchReductionRejectsZero(t *testing.T) { + for _, value := range []*big.Int{new(big.Int), new(big.Int).Set(scalarModulus()), big.NewInt(1), new(big.Int).Add(scalarModulus(), big.NewInt(9)), new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 512), big.NewInt(1))} { + wide := value.FillBytes(make([]byte, 64)) + for i, j := 0, len(wide)-1; i < j; i, j = i+1, j-1 { + wide[i], wide[j] = wide[j], wide[i] + } + expected := new(big.Int).Mod(value, scalarModulus()) + w := &pairingBatchReductionCircuit{Expected: expected} + copy(w.Wide[:], uints.NewU8Array(wide)) + err := test.IsSolved(&pairingBatchReductionCircuit{}, w, curveID.ScalarField()) + if (err == nil) != (expected.Sign() != 0) { + t.Fatalf("challenge reduction %s: %v", value, err) + } + } +} diff --git a/tools/bls-plonk-wrapper/parameters.go b/tools/bls-plonk-wrapper/parameters.go index a11b4f457..17ab81401 100644 --- a/tools/bls-plonk-wrapper/parameters.go +++ b/tools/bls-plonk-wrapper/parameters.go @@ -10,9 +10,6 @@ import ( "os" "path/filepath" "strconv" - - bls "github.com/consensys/gnark-crypto/ecc/bls12-381" - "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" ) type lagrangeManifest struct { @@ -21,24 +18,24 @@ type lagrangeManifest struct { Points artifact } -func readAffinePoints(r io.Reader, count uint64) ([]bls.G1Affine, error) { - points := make([]bls.G1Affine, count) +func readAffinePoints(r io.Reader, count uint64) ([]nativeG1, error) { + points := make([]nativeG1, count) for i := range points { - var raw [96]byte + var raw [2 * baseBytes]byte if _, err := io.ReadFull(r, raw[:]); err != nil { return nil, err } - if err := points[i].X.SetBytesCanonical(raw[:48]); err != nil { + if err := points[i].X.SetBytesCanonical(raw[:baseBytes]); err != nil { return nil, err } - if err := points[i].Y.SetBytesCanonical(raw[48:]); err != nil { + if err := points[i].Y.SetBytesCanonical(raw[baseBytes:]); err != nil { return nil, err } } return points, nil } -func writeAffinePoints(w io.Writer, points []bls.G1Affine) error { +func writeAffinePoints(w io.Writer, points []nativeG1) error { b := bufio.NewWriterSize(w, 1<<20) for _, point := range points { x, y := point.X.Bytes(), point.Y.Bytes() @@ -52,17 +49,41 @@ func writeAffinePoints(w io.Writer, points []bls.G1Affine) error { return b.Flush() } -func lagrangeParameters(root string, srs *kzg.SRS, domain uint64, setupID, receipt, executable string) (*kzg.SRS, bool, error) { +func lagrangeLocation(root string, domain uint64, setupID, receipt, executable string) (lagrangeManifest, string, error) { + want := lagrangeManifest{Format: lagrangeFormat, SetupID: setupID, MonomialBLAKE3: receipt, ExecutableSHA256: executable, Domain: domain} + identity, err := json.Marshal(want) + if err != nil { + return want, "", err + } + digest := sha256.Sum256(identity) + dir := filepath.Join(root, strconv.FormatUint(domain, 10), hex.EncodeToString(digest[:])) + return want, dir, nil +} + +func publishLagrange(dir string, want lagrangeManifest, points []nativeG1) error { + pointsPath, metadata := filepath.Join(dir, "points.bin"), filepath.Join(dir, "manifest.json") + var err error + if err := atomicFile(pointsPath, func(w io.Writer) error { return writeAffinePoints(w, points) }); err != nil { + return err + } + want.Points, err = fileHash(pointsPath) + if err != nil { + return err + } + if err := writeJSON(metadata, want); err != nil { + return err + } + return nil +} + +func lagrangeParameters(root string, srs *nativeSRS, domain uint64, setupID, receipt, executable string) (*nativeSRS, bool, error) { if domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || uint64(len(srs.Pk.G1)) < domain+3 { return nil, false, fmt.Errorf("invalid Lagrange domain or monomial prefix") } - want := lagrangeManifest{Format: "ix-plonk-lagrange-v1", SetupID: setupID, MonomialBLAKE3: receipt, ExecutableSHA256: executable, Domain: domain} - identity, err := json.Marshal(want) + want, dir, err := lagrangeLocation(root, domain, setupID, receipt, executable) if err != nil { return nil, false, err } - digest := sha256.Sum256(identity) - dir := filepath.Join(root, strconv.FormatUint(domain, 10), hex.EncodeToString(digest[:])) metadata := filepath.Join(dir, "manifest.json") pointsPath := filepath.Join(dir, "points.bin") if _, err := os.Stat(metadata); err == nil { @@ -72,7 +93,7 @@ func lagrangeParameters(root string, srs *kzg.SRS, domain uint64, setupID, recei } pointsInfo := stored.Points stored.Points = artifact{} - if stored != want || pointsInfo.Bytes != int64(96*domain) { + if stored != want || pointsInfo.Bytes != int64(2*baseBytes*domain) { return nil, false, fmt.Errorf("Lagrange cache identity differs") } got, err := fileHash(pointsPath) @@ -91,23 +112,16 @@ func lagrangeParameters(root string, srs *kzg.SRS, domain uint64, setupID, recei if err != nil { return nil, false, err } - return &kzg.SRS{Pk: kzg.ProvingKey{G1: points}, Vk: srs.Vk}, true, nil + return &nativeSRS{Pk: nativeKzgProvingKey{G1: points}, Vk: srs.Vk}, true, nil } else if !os.IsNotExist(err) { return nil, false, err } - points, err := kzg.ToLagrangeG1(srs.Pk.G1[:domain]) - if err != nil { - return nil, false, err - } - if err := atomicFile(pointsPath, func(w io.Writer) error { return writeAffinePoints(w, points) }); err != nil { - return nil, false, err - } - want.Points, err = fileHash(pointsPath) + points, err := toLagrange(srs.Pk.G1[:domain]) if err != nil { return nil, false, err } - if err := writeJSON(metadata, want); err != nil { + if err := publishLagrange(dir, want, points); err != nil { return nil, false, err } - return &kzg.SRS{Pk: kzg.ProvingKey{G1: points}, Vk: srs.Vk}, false, nil + return &nativeSRS{Pk: nativeKzgProvingKey{G1: points}, Vk: srs.Vk}, false, nil } diff --git a/tools/bls-plonk-wrapper/parameters_bn254_test.go b/tools/bls-plonk-wrapper/parameters_bn254_test.go new file mode 100644 index 000000000..e807a5ea3 --- /dev/null +++ b/tools/bls-plonk-wrapper/parameters_bn254_test.go @@ -0,0 +1,90 @@ +//go:build bn254 + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" +) + +func TestLagrangeCacheAndBasisConversion(t *testing.T) { + p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + if err != nil { + t.Fatal(err) + } + var receipt struct { + Format, Blake3, SetupID, IgnitionManifest string + PublicMaxDegree, Powers uint64 + } + if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { + t.Fatal(err) + } + srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128, p) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + executable := strings.Repeat("a", 64) + first, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) + if err != nil || reused { + t.Fatalf("initial derivation: reused=%t error=%v", reused, err) + } + second, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) + if err != nil || !reused { + t.Fatalf("cached derivation: reused=%t error=%v", reused, err) + } + for i := range first.Pk.G1 { + if !first.Pk.G1[i].Equal(&second.Pk.G1[i]) { + t.Fatal("cache changed Lagrange points") + } + } + coefficients := make([]fr.Element, 8) + for i := 0; i < 3; i++ { + coefficients[i].SetUint64(uint64(2*i + 1)) + } + evaluations := append([]fr.Element(nil), coefficients...) + fft.NewDomain(8).FFT(evaluations, fft.DIF) + fft.BitReverse(evaluations) + a, err := kzg.Commit(coefficients, srs.Pk) + if err != nil { + t.Fatal(err) + } + b, err := kzg.Commit(evaluations, second.Pk) + if err != nil { + t.Fatal(err) + } + if !a.Equal(&b) { + t.Fatal("monomial and Lagrange commitments differ") + } + files, err := filepath.Glob(filepath.Join(root, "8", "*", "points.bin")) + if err != nil || len(files) != 1 { + t.Fatalf("cache files: %v %v", files, err) + } + f, err := os.OpenFile(files[0], os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + var changed [1]byte + if _, err := f.ReadAt(changed[:], 30); err != nil { + t.Fatal(err) + } + changed[0] ^= 1 + if _, err := f.WriteAt(changed[:], 30); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if _, _, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable); err == nil { + t.Fatal("accepted corrupted Lagrange cache") + } + if _, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, strings.Repeat("b", 64)); err != nil || reused { + t.Fatalf("new backend reused old cache: %t %v", reused, err) + } +} diff --git a/tools/bls-plonk-wrapper/parameters_test.go b/tools/bls-plonk-wrapper/parameters_test.go index a4c342d25..3fad12509 100644 --- a/tools/bls-plonk-wrapper/parameters_test.go +++ b/tools/bls-plonk-wrapper/parameters_test.go @@ -1,3 +1,5 @@ +//go:build !bn254 + package main import ( diff --git a/tools/bls-plonk-wrapper/patches/gnark-cuda-generator-v1.patch b/tools/bls-plonk-wrapper/patches/gnark-cuda-generator-v1.patch new file mode 100644 index 000000000..a8d12ef37 --- /dev/null +++ b/tools/bls-plonk-wrapper/patches/gnark-cuda-generator-v1.patch @@ -0,0 +1,801 @@ +diff --git a/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl b/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl +--- a/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl ++++ b/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl +@@ -111,6 +111,93 @@ + ComputeNumerator(in *NumeratorInput) (*iop.Polynomial, error) + } + ++// AcceleratorFeatureFlagsV1 selects operations before a proof mutates its polynomials. ++type AcceleratorFeatureFlagsV1 uint64 ++ ++const ( ++ AcceleratorFeatureMSMV1 AcceleratorFeatureFlagsV1 = 1 << iota ++ AcceleratorFeatureToCanonicalV1 ++ AcceleratorFeatureNumeratorV1 ++ AcceleratorFeatureQuotientV1 ++) ++ ++const legacyAcceleratorFeaturesV1 = AcceleratorFeatureMSMV1 | AcceleratorFeatureToCanonicalV1 | AcceleratorFeatureNumeratorV1 ++const allAcceleratorFeaturesV1 = legacyAcceleratorFeaturesV1 | AcceleratorFeatureQuotientV1 ++ ++// AcceleratorFeaturesV1 optionally selects accelerated operations. FeaturesV1 ++// is called exactly once per Prove; its result is frozen for that proof. ++// Implementations must make this method safe for concurrent proofs. ++type AcceleratorFeaturesV1 interface { ++ FeaturesV1() AcceleratorFeatureFlagsV1 ++} ++ ++// QuotientAcceleratorV1 computes H after division by X^N-1 and the inverse FFT. ++// Its result has Domain1.Cardinality coefficients in Canonical/Regular form. ++// Every nonblinding input must be restored to Canonical/Regular on Domain0; ++// blinding polynomials must remain unchanged. The corresponding feature bit ++// must be set explicitly; implementing this interface alone does not enable it. ++type QuotientAcceleratorV1 interface { ++ ComputeQuotientV1(*NumeratorInput) (*iop.Polynomial, error) ++} ++ ++type acceleratorState struct { ++ implementation Accelerator ++ features AcceleratorFeatureFlagsV1 ++ quotient QuotientAcceleratorV1 ++} ++ ++// StageEventV1 is a monotonic wall-clock interval relative to proof initialization. ++// Task spans include dependency waits and nested compute spans. Compute spans may ++// include backend queueing; overlapping intervals must not be summed. ++type StageEventV1 struct { ++ Stage, Phase string ++ Start, End time.Duration ++ Failed bool ++} ++ ++type stageObserverStateV1 struct { callback func(StageEventV1) } ++ ++// SetStageObserverV1 installs a runtime-only diagnostic callback. Prove freezes ++// the callback at entry. It may be called concurrently and must not panic or ++// block. Passing nil disables observation; key serialization is unchanged. ++func (pk *ProvingKey) SetStageObserverV1(observer func(StageEventV1)) { ++ if observer == nil { pk.stageObserver.Store(nil); return } ++ pk.stageObserver.Store(&stageObserverStateV1{callback: observer}) ++} ++ ++// proofKey snapshots runtime dispatch while retaining the immutable key arrays. ++// Concurrent SetAccelerator calls cannot change an already-started proof. ++func (pk *ProvingKey) proofKey() (*ProvingKey, error) { ++ view := &ProvingKey{Kzg: pk.Kzg, KzgLagrange: pk.KzgLagrange, Vk: pk.Vk} ++ view.stageObserver.Store(pk.stageObserver.Load()) ++ configured := pk.accelerator.Load() ++ if configured == nil { ++ return view, nil ++ } ++ features := legacyAcceleratorFeaturesV1 ++ if selectable, ok := configured.implementation.(AcceleratorFeaturesV1); ok { ++ features = selectable.FeaturesV1() ++ } ++ if features & ^allAcceleratorFeaturesV1 != 0 { ++ return nil, fmt.Errorf("unsupported accelerator feature bits: %#x", features & ^allAcceleratorFeaturesV1) ++ } ++ state := &acceleratorState{implementation: configured.implementation, features: features} ++ if features&AcceleratorFeatureQuotientV1 != 0 { ++ var ok bool ++ state.quotient, ok = configured.implementation.(QuotientAcceleratorV1) ++ if !ok { ++ return nil, errors.New("quotient feature requires QuotientAcceleratorV1") ++ } ++ } ++ view.accelerator.Store(state) ++ return view, nil ++} ++ ++func (pk *ProvingKey) accelerated(feature AcceleratorFeatureFlagsV1) bool { ++ state := pk.accelerator.Load() ++ return state != nil && state.features&feature != 0 ++} ++ + // NumeratorInput is the input of Accelerator.ComputeNumerator. All polynomials + // have Domain0.Cardinality coefficients except the blinding polynomials, which + // are small-degree canonical polynomials. +@@ -133,39 +220,47 @@ + // SetAccelerator attaches an Accelerator to the proving key. Passing nil restores + // the CPU implementation. + func (pk *ProvingKey) SetAccelerator(a Accelerator) { +- pk.accelerator = a ++ if a == nil { ++ pk.accelerator.Store(nil) ++ return ++ } ++ pk.accelerator.Store(&acceleratorState{implementation: a, features: legacyAcceleratorFeaturesV1}) + } + + // Accelerator returns the accelerator attached to the proving key, or nil. + func (pk *ProvingKey) Accelerator() Accelerator { +- return pk.accelerator ++ state := pk.accelerator.Load() ++ if state == nil { ++ return nil ++ } ++ return state.implementation + } + + // commit computes the KZG commitment of p with key. nbTasks is a parallelism + // hint for the CPU implementation. + func (pk *ProvingKey) commit(key kzg.ProvingKey, p []fr.Element, nbTasks ...int) (kzg.Digest, error) { +- if pk.accelerator == nil { ++ if !pk.accelerated(AcceleratorFeatureMSMV1) { + return kzg.Commit(p, key, nbTasks...) + } + if len(p) > len(key.G1) { + return kzg.Digest{}, kzg.ErrInvalidPolynomialSize + } +- return pk.accelerator.MultiExp(key.G1[:len(p)], p) ++ return pk.accelerator.Load().implementation.MultiExp(key.G1[:len(p)], p) + } + + func (pk *ProvingKey) multiExp(res *curve.G1Affine, bases []curve.G1Affine, scalars []fr.Element) error { +- if pk.accelerator == nil { ++ if !pk.accelerated(AcceleratorFeatureMSMV1) { + _, err := res.MultiExp(bases, scalars, ecc.MultiExpConfig{}) + return err + } + var err error +- *res, err = pk.accelerator.MultiExp(bases, scalars) ++ *res, err = pk.accelerator.Load().implementation.MultiExp(bases, scalars) + return err + } + + // committer returns the kzg.Committer used for KZG openings with key. + func (pk *ProvingKey) committer(key kzg.ProvingKey) kzg.Committer { +- if pk.accelerator == nil { ++ if !pk.accelerated(AcceleratorFeatureMSMV1) { + return key + } + return acceleratedCommitter{pk: pk, key: key} +@@ -180,7 +275,12 @@ + return c.pk.commit(c.key, p) + } + +-func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (*Proof, error) { ++func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (_ *Proof, err error) { ++ ++ pk, err = pk.proofKey() ++ if err != nil { ++ return nil, fmt.Errorf("accelerator configuration: %w", err) ++ } + + log := logger.Logger().With(). + Str("curve", spr.CurveID().String()). +@@ -197,37 +297,48 @@ + + // init instance + g, ctx := errgroup.WithContext(context.Background()) ++ observer := pk.stageObserver.Load() ++ if observer != nil { ++ defer func() { ++ observer.callback(StageEventV1{Stage: "prove", Phase: "task", End: time.Since(start), Failed: err != nil}) ++ }() ++ } + instance, err := newInstance(ctx, spr, pk, fullWitness, &opt) ++ if observer != nil { ++ observer.callback(StageEventV1{Stage: "initialization", Phase: "compute", End: time.Since(start), Failed: err != nil}) ++ } + if err != nil { + return nil, fmt.Errorf("new instance: %w", err) + } + ++ instance.stageObserver, instance.stageOrigin = observer, start ++ + // solve constraints +- g.Go(instance.solveConstraints) ++ g.Go(instance.observeTaskV1("solver-lro", instance.solveConstraints)) + + // complete qk +- g.Go(instance.completeQk) ++ g.Go(instance.observeTaskV1("qk", instance.completeQk)) + + // init blinding polynomials +- g.Go(instance.initBlindingPolynomials) ++ g.Go(instance.observeTaskV1("blinding", instance.initBlindingPolynomials)) + + // derive gamma, beta (copy constraint) +- g.Go(instance.deriveGammaAndBeta) ++ g.Go(instance.observeTaskV1("gamma-beta", instance.deriveGammaAndBeta)) + + // compute accumulating ratio for the copy constraint +- g.Go(instance.buildRatioCopyConstraint) ++ g.Go(instance.observeTaskV1("z-permutation", instance.buildRatioCopyConstraint)) + + // compute h +- g.Go(instance.computeQuotient) ++ g.Go(instance.observeTaskV1("quotient-h", instance.computeQuotient)) + + // open Z (blinded) at ωζ (proof.ZShiftedOpening) +- g.Go(instance.openZ) ++ g.Go(instance.observeTaskV1("z-opening", instance.openZ)) + + // linearized polynomial +- g.Go(instance.computeLinearizedPolynomial) ++ g.Go(instance.observeTaskV1("evaluations-linearization", instance.computeLinearizedPolynomial)) + + // Batch opening +- g.Go(instance.batchOpening) ++ g.Go(instance.observeTaskV1("batch-opening", instance.batchOpening)) + + if err := g.Wait(); err != nil { + return nil, err +@@ -240,6 +351,8 @@ + // represents a Prover instance + type instance struct { + ctx context.Context ++ stageObserver *stageObserverStateV1 ++ stageOrigin time.Time + + pk *ProvingKey + proof *Proof +@@ -285,6 +398,37 @@ + domain0, domain1 *fft.Domain + + trace *Trace ++} ++ ++func (s *instance) beginStageV1() time.Time { ++ if s.stageObserver == nil { return time.Time{} } ++ return time.Now() ++} ++ ++func (s *instance) endStageV1(stage, phase string, start time.Time, failed bool) { ++ if s.stageObserver == nil { return } ++ s.stageObserver.callback(StageEventV1{Stage: stage, Phase: phase, ++ Start: start.Sub(s.stageOrigin), End: time.Since(s.stageOrigin), Failed: failed}) ++} ++ ++func (s *instance) observeTaskV1(stage string, fn func() error) func() error { ++ if s.stageObserver == nil { return fn } ++ return func() (err error) { ++ start := s.beginStageV1() ++ defer func() { s.endStageV1(stage, "task", start, err != nil) }() ++ return fn() ++ } ++} ++ ++func (s *instance) waitStageV1(stage string, ready <-chan struct{}) (err error) { ++ start := s.beginStageV1() ++ defer func() { s.endStageV1(stage, "wait", start, err != nil) }() ++ select { ++ case <-s.ctx.Done(): ++ return errContextDone ++ case <-ready: ++ return nil ++ } + } + + func newInstance(ctx context.Context, spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts *backend.ProverConfig) (*instance, error) { +@@ -402,10 +546,13 @@ + // solveConstraints computes the evaluation of the polynomials L, R, O + // and sets x[id_L], x[id_R], x[id_O] in Lagrange form + func (s *instance) solveConstraints() error { ++ solverStart := s.beginStageV1() + _solution, err := s.spr.Solve(s.fullWitness, s.opt.SolverOpts...) ++ s.endStageV1("solver", "compute", solverStart, err != nil) + if err != nil { + return err + } ++ polynomialsStart := s.beginStageV1() + solution := _solution.(*cs.SparseR1CSSolution) + evaluationLDomainSmall := []fr.Element(solution.L) + evaluationRDomainSmall := []fr.Element(solution.R) +@@ -424,6 +571,7 @@ + s.x[id_O] = iop.NewPolynomial(&evaluationODomainSmall, iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}) + + wg.Wait() ++ s.endStageV1("witness-polynomials", "compute", polynomialsStart, false) + + // commit to l, r, o and add blinding factors + if err := s.commitToLRO(); err != nil { +@@ -445,10 +593,8 @@ + copy(qkCoeffs, wWitness[:len(s.spr.Public)]) + + // wait for solver to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLRO: ++ if err := s.waitStageV1("qk/lro", s.chLRO); err != nil { ++ return err + } + + for i := range s.commitmentInfo { +@@ -486,14 +632,14 @@ + // The (P[i]-s0) terms are zero in the padding region, so the MSM only needs + // the non-padding entries. For a 2.2M-constraint circuit on a 4M domain, + // this nearly halves each MSM. +-func (s *instance) commitToLRO() error { ++func (s *instance) commitToLRO() (err error) { + // wait for blinding polynomials to be initialized or context to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chbp: +- } +- ++ if err := s.waitStageV1("lro/blinding", s.chbp); err != nil { ++ return err ++ } ++ ++ lroStart := s.beginStageV1() ++ defer func() { s.endStageV1("lro", "compute", lroStart, err != nil) }() + n := int(s.domain0.Cardinality) + nbPublic := len(s.spr.Public) + offset := nbPublic + s.spr.GetNbConstraints() +@@ -515,6 +661,8 @@ + + // L: subtract s0, MSM on [0:offset], add correction + blinding, restore + g.Go(func() (err error) { ++ commitStart := s.beginStageV1() ++ defer func() { s.endStageV1("l-commitment", "compute", commitStart, err != nil) }() + coeffs := s.x[id_L].Coefficients() + for i := 0; i < offset; i++ { + coeffs[i].Sub(&coeffs[i], &s0) +@@ -534,6 +682,8 @@ + + // R: subtract s0, MSM on [nbPublic:offset], add correction + blinding, restore + g.Go(func() (err error) { ++ commitStart := s.beginStageV1() ++ defer func() { s.endStageV1("r-commitment", "compute", commitStart, err != nil) }() + coeffs := s.x[id_R].Coefficients() + for i := nbPublic; i < offset; i++ { + coeffs[i].Sub(&coeffs[i], &s0) +@@ -553,6 +703,8 @@ + + // O: same as R + g.Go(func() (err error) { ++ commitStart := s.beginStageV1() ++ defer func() { s.endStageV1("o-commitment", "compute", commitStart, err != nil) }() + coeffs := s.x[id_O].Coefficients() + for i := nbPublic; i < offset; i++ { + coeffs[i].Sub(&coeffs[i], &s0) +@@ -585,10 +737,8 @@ + } + + // wait for LRO to be committed +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLRO: ++ if err := s.waitStageV1("gamma-beta/lro", s.chLRO); err != nil { ++ return err + } + + gamma, err := deriveRandomness(s.fs, "gamma", &s.proof.LRO[0], &s.proof.LRO[1], &s.proof.LRO[2]) +@@ -641,6 +791,7 @@ + + // computeQuotient computes H + func (s *instance) computeQuotient() (err error) { ++ prepareStart := s.beginStageV1() + s.x[id_Ql] = s.trace.Ql + s.x[id_Qr] = s.trace.Qr + s.x[id_Qm] = s.trace.Qm +@@ -657,11 +808,11 @@ + lone := make([]fr.Element, n) + lone[0].SetOne() + ++ s.endStageV1("quotient-preparation", "compute", prepareStart, false) ++ + // wait for solver to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLRO: ++ if err := s.waitStageV1("quotient/lro", s.chLRO); err != nil { ++ return err + } + + for i := 0; i < len(s.commitmentInfo); i++ { +@@ -669,10 +820,8 @@ + } + + // wait for Z to be committed or context done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chZ: ++ if err := s.waitStageV1("quotient/z", s.chZ); err != nil { ++ return err + } + + // derive alpha +@@ -686,24 +835,31 @@ + + s.x[id_ZS] = s.x[id_Z].ShallowClone().Shift(1) + +- var numerator *iop.Polynomial +- if s.pk.accelerator == nil { +- numerator, err = s.computeNumerator() ++ if s.pk.accelerated(AcceleratorFeatureQuotientV1) { ++ s.h, err = s.computeQuotientAccelerated() + } else { +- numerator, err = s.computeNumeratorAccelerated() ++ var numerator *iop.Polynomial ++ if s.pk.accelerated(AcceleratorFeatureNumeratorV1) { ++ numerator, err = s.computeNumeratorAccelerated() ++ } else { ++ numerator, err = s.computeNumerator() ++ } ++ if err == nil { ++ divideStart := s.beginStageV1() ++ s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) ++ s.endStageV1("quotient-division", "compute", divideStart, err != nil) ++ } + } + if err != nil { + return err + } + +- s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) +- if err != nil { +- return err +- } +- + // commit to h +- if err := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk); err != nil { +- return err ++ commitStart := s.beginStageV1() ++ commitErr := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk) ++ s.endStageV1("h-commitments", "compute", commitStart, commitErr != nil) ++ if commitErr != nil { ++ return commitErr + } + + if err := s.deriveZeta(); err != nil { +@@ -711,10 +867,8 @@ + } + + // wait for clean up tasks to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chRestoreLRO: ++ if err := s.waitStageV1("quotient/restore", s.chRestoreLRO); err != nil { ++ return err + } + + close(s.chH) +@@ -724,14 +878,13 @@ + + func (s *instance) buildRatioCopyConstraint() (err error) { + // wait for gamma and beta to be derived (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chGammaBeta: ++ if err := s.waitStageV1("z-permutation/gamma-beta", s.chGammaBeta); err != nil { ++ return err + } + + // TODO @gbotrel having iop.BuildRatioCopyConstraint return something + // with capacity = len() + 4 would avoid extra alloc / copy during openZ ++ permutationStart := s.beginStageV1() + s.x[id_Z], err = iop.BuildRatioCopyConstraint( + []*iop.Polynomial{ + s.x[id_L], +@@ -744,12 +897,15 @@ + iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}, + s.domain0, + ) ++ s.endStageV1("permutation", "compute", permutationStart, err != nil) + if err != nil { + return err + } + + // commit to the blinded version of z ++ commitStart := s.beginStageV1() + s.proof.Z, err = s.commitToPolyAndBlinding(s.x[id_Z], s.bp[id_Bz]) ++ s.endStageV1("z-commitment", "compute", commitStart, err != nil) + + close(s.chZ) + +@@ -759,11 +915,11 @@ + // open Z (blinded) at ωζ + func (s *instance) openZ() (err error) { + // wait for H to be committed and zeta to be derived (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chH: +- } ++ if err := s.waitStageV1("z-opening/h", s.chH); err != nil { ++ return err ++ } ++ openingStart := s.beginStageV1() ++ defer func() { s.endStageV1("z-opening", "compute", openingStart, err != nil) }() + var zetaShifted fr.Element + zetaShifted.Mul(&s.zeta, &s.pk.Vk.Generator) + s.blindedZ = getBlindedCoefficients(s.x[id_Z], s.bp[id_Bz]) +@@ -816,10 +972,8 @@ + func (s *instance) computeLinearizedPolynomial() error { + + // wait for H to be committed and zeta to be derived (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chH: ++ if err := s.waitStageV1("linearization/h", s.chH); err != nil { ++ return err + } + + qcpzeta := make([]fr.Element, len(s.commitmentInfo)) +@@ -829,35 +983,44 @@ + + for i := 0; i < len(s.commitmentInfo); i++ { + go func(i int) { ++ evaluationStart := s.beginStageV1() + qcpzeta[i] = s.trace.Qcp[i].Evaluate(s.zeta) ++ s.endStageV1("evaluation-qcp", "compute", evaluationStart, false) + wg.Done() + }(i) + } + + go func() { ++ evaluationStart := s.beginStageV1() + blzeta = evaluateBlinded(s.x[id_L], s.bp[id_Bl], s.zeta) ++ s.endStageV1("evaluation-l", "compute", evaluationStart, false) + wg.Done() + }() + + go func() { ++ evaluationStart := s.beginStageV1() + brzeta = evaluateBlinded(s.x[id_R], s.bp[id_Br], s.zeta) ++ s.endStageV1("evaluation-r", "compute", evaluationStart, false) + wg.Done() + }() + + go func() { ++ evaluationStart := s.beginStageV1() + bozeta = evaluateBlinded(s.x[id_O], s.bp[id_Bo], s.zeta) ++ s.endStageV1("evaluation-o", "compute", evaluationStart, false) + wg.Done() + }() + + // wait for Z to be opened at zeta (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chZOpening: ++ if err := s.waitStageV1("linearization/z-opening", s.chZOpening); err != nil { ++ return err + } + bzuzeta := s.proof.ZShiftedOpening.ClaimedValue + ++ evaluationsWait := s.beginStageV1() + wg.Wait() ++ s.endStageV1("linearization/evaluations", "wait", evaluationsWait, false) ++ linearizationStart := s.beginStageV1() + + s.linearizedPolynomial = s.innerComputeLinearizedPoly( + blzeta, +@@ -874,8 +1037,11 @@ + s.pk, + ) + ++ s.endStageV1("linearization", "compute", linearizationStart, false) ++ commitStart := s.beginStageV1() + var err error + s.linearizedPolynomialDigest, err = s.pk.commit(s.pk.Kzg, s.linearizedPolynomial, runtime.NumCPU()*2) ++ s.endStageV1("linearization-commitment", "compute", commitStart, err != nil) + if err != nil { + return err + } +@@ -883,15 +1049,15 @@ + return nil + } + +-func (s *instance) batchOpening() error { ++func (s *instance) batchOpening() (err error) { + + // wait for linearizedPolynomial to be computed (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLinearizedPolynomial: +- } +- ++ if err := s.waitStageV1("batch-opening/linearization", s.chLinearizedPolynomial); err != nil { ++ return err ++ } ++ ++ openingStart := s.beginStageV1() ++ defer func() { s.endStageV1("batch-opening", "compute", openingStart, err != nil) }() + polysQcp := coefficients(s.trace.Qcp) + polysToOpen := make([][]fr.Element, 6+len(polysQcp)) + copy(polysToOpen[6:], polysQcp) +@@ -913,7 +1079,6 @@ + digestsToOpen[4] = s.pk.Vk.S[0] + digestsToOpen[5] = s.pk.Vk.S[1] + +- var err error + s.proof.BatchedProof, err = kzg.BatchOpenSinglePointWithCommitter( + polysToOpen, + digestsToOpen, +@@ -930,13 +1095,21 @@ + // proving key's Accelerator. Like computeNumerator, it leaves the polynomials + // in x in canonical regular form; unlike it, the restoration is synchronous. + func (s *instance) computeNumeratorAccelerated() (*iop.Polynomial, error) { ++ return s.computeAcceleratedQuotient(false) ++} ++ ++func (s *instance) computeQuotientAccelerated() (*iop.Polynomial, error) { ++ return s.computeAcceleratedQuotient(true) ++} ++ ++func (s *instance) computeAcceleratedQuotient(fused bool) (_ *iop.Polynomial, err error) { + // wait for chQk to be closed (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return nil, errContextDone +- case <-s.chQk: +- } +- ++ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { ++ return nil, err ++ } ++ ++ computeStart := s.beginStageV1() ++ defer func() { s.endStageV1("quotient", "compute", computeStart, err != nil) }() + in := &NumeratorInput{ + Domain0: s.domain0, + Domain1: s.domain1, +@@ -967,8 +1140,18 @@ + in.Pi[i] = s.x[id_Qci+2*i+1] + } + +- res, err := s.pk.accelerator.ComputeNumerator(in) ++ blindings := snapshotAcceleratorBlindings(in) ++ state := s.pk.accelerator.Load() ++ var res *iop.Polynomial ++ if fused { ++ res, err = state.quotient.ComputeQuotientV1(in) ++ } else { ++ res, err = state.implementation.ComputeNumerator(in) ++ } + if err != nil { ++ return nil, err ++ } ++ if err := validateAcceleratorResult(in, res, fused, blindings); err != nil { + return nil, err + } + +@@ -979,9 +1162,63 @@ + return res, nil + } + ++type acceleratorBlindingSnapshot struct { ++ polynomial *iop.Polynomial ++ basis iop.Basis ++ layout iop.Layout ++ size int ++ coefficients []fr.Element ++} ++ ++func snapshotAcceleratorBlindings(in *NumeratorInput) []acceleratorBlindingSnapshot { ++ result := make([]acceleratorBlindingSnapshot, 0, 4) ++ for _, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { ++ result = append(result, acceleratorBlindingSnapshot{ ++ polynomial: p, basis: p.Basis, layout: p.Layout, size: p.Size(), ++ coefficients: append([]fr.Element(nil), p.Coefficients()...), ++ }) ++ } ++ return result ++} ++ ++func validateAcceleratorResult(in *NumeratorInput, result *iop.Polynomial, fused bool, blindings []acceleratorBlindingSnapshot) error { ++ basis, layout := iop.LagrangeCoset, iop.BitReverse ++ if fused { ++ basis, layout = iop.Canonical, iop.Regular ++ } ++ if result == nil || result.Basis != basis || result.Layout != layout || ++ uint64(result.Size()) != in.Domain1.Cardinality || ++ uint64(len(result.Coefficients())) != in.Domain1.Cardinality { ++ return errors.New("accelerator returned an invalid quotient-domain polynomial") ++ } ++ inputs := []*iop.Polynomial{in.L, in.R, in.O, in.Z, in.Qk, in.Ql, in.Qr, in.Qm, in.Qo, in.S1, in.S2, in.S3} ++ inputs = append(inputs, in.Qcp...) ++ inputs = append(inputs, in.Pi...) ++ for _, p := range inputs { ++ if p == nil || p.Basis != iop.Canonical || p.Layout != iop.Regular || ++ uint64(p.Size()) != in.Domain0.Cardinality || ++ uint64(len(p.Coefficients())) != in.Domain0.Cardinality { ++ return errors.New("accelerator did not restore canonical input polynomials") ++ } ++ } ++ for _, saved := range blindings { ++ p := saved.polynomial ++ if p.Basis != saved.basis || p.Layout != saved.layout || p.Size() != saved.size || ++ len(p.Coefficients()) != len(saved.coefficients) { ++ return errors.New("accelerator changed a blinding polynomial") ++ } ++ for i, coefficient := range saved.coefficients { ++ if coefficient != p.Coefficients()[i] { ++ return errors.New("accelerator changed a blinding polynomial") ++ } ++ } ++ } ++ return nil ++} ++ + // evaluate the full set of constraints, all polynomials in x are back in + // canonical regular form at the end +-func (s *instance) computeNumerator() (*iop.Polynomial, error) { ++func (s *instance) computeNumerator() (_ *iop.Polynomial, err error) { + // init vectors that are used multiple times throughout the computation + n := s.domain0.Cardinality + twiddles0 := make([]fr.Element, n) +@@ -1001,12 +1238,12 @@ + } + + // wait for chQk to be closed (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return nil, errContextDone +- case <-s.chQk: +- } +- ++ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { ++ return nil, err ++ } ++ ++ computeStart := s.beginStageV1() ++ defer func() { s.endStageV1("quotient-numerator", "compute", computeStart, err != nil) }() + nbBsbGates := len(s.proof.Bsb22Commitments) + + gateConstraint := func(u ...fr.Element) fr.Element { +@@ -1231,6 +1468,7 @@ + + // scale everything back + go func() { ++ restoreStart := s.beginStageV1() + s.x[id_ZS] = nil + s.x[id_Qk] = nil + +@@ -1253,6 +1491,7 @@ + scalePowers(q, cs) + } + ++ s.endStageV1("polynomial-restoration", "compute", restoreStart, false) + close(s.chRestoreLRO) + }() + +@@ -1459,9 +1698,12 @@ + } + }) + +- if pk.accelerator != nil { +- if err := pk.accelerator.ToCanonical(bigDomain, a); err != nil { ++ if pk.accelerated(AcceleratorFeatureToCanonicalV1) { ++ if err := pk.accelerator.Load().implementation.ToCanonical(bigDomain, a); err != nil { + return nil, err ++ } ++ if a.Basis != iop.Canonical || a.Layout != iop.Regular || uint64(len(a.Coefficients())) != bigDomain.Cardinality { ++ return nil, errors.New("accelerator canonical conversion returned an invalid polynomial") + } + return a, nil + } +diff --git a/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl b/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl +--- a/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl ++++ b/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl +@@ -4,6 +4,7 @@ + {{- template "import_fft" . }} + {{- template "import_backend_cs" . }} + "fmt" ++ "sync/atomic" + "github.com/consensys/gnark-crypto/ecc/{{toLower .Curve}}/fr/iop" + "github.com/consensys/gnark/backend/plonk/internal" + "github.com/consensys/gnark/constraint" +@@ -86,7 +87,8 @@ + // accelerator, when set, offloads the multi-scalar multiplications, the + // quotient numerator and the large inverse FFT of Prove (see Accelerator). + // Runtime-only, not serialized. +- accelerator Accelerator ++ accelerator atomic.Pointer[acceleratorState] ++ stageObserver atomic.Pointer[stageObserverStateV1] + } + + func Setup(spr *cs.SparseR1CS, srs, srsLagrange kzg.SRS) (*ProvingKey, *VerifyingKey, error) { diff --git a/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.json b/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.json new file mode 100644 index 000000000..30dbae555 --- /dev/null +++ b/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.json @@ -0,0 +1,26 @@ +{ + "version": "v0.16.5-0.20261015185324-262cf453c107", + "commit": "262cf453c107d147a228b6b7418c5cf791c3b79b", + "files": { + "backend/plonk/bn254/prove.go": { + "before": "d83326a2676352d1d00efa3260128fd094d7250400a91f4d765a2a64a2e5ac57", + "after": "36add5ba1f06a4d23552f80045912d161bbc89378a9797439f8c3099eb3545cd" + }, + "backend/plonk/bn254/setup.go": { + "before": "c4585b77aaabd6b14cdb163d100b39c6e4d390cc1b6a6bbcb79d08dab3cfb343", + "after": "51ba9060e878f28488fda677163f225f5dda68c122a3e6df1aec499ae08e1a44" + }, + "backend/plonk/bn254/accelerator_v1_test.go": { + "before": null, + "after": "1b4b120fa5e32f7e65c2bbee10ed9ea70ebdc89ed3cd0ef84f80268c240d5ab6" + }, + "internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl": { + "before": "48c5fc2246266e30aa4be92d4827b987e57b5c0207269684ca0ea22374027b06", + "after": "b92c0d0f86aac9fb6fc261d3e83581f4539a93014928a2c1aaa474635342b5cc" + }, + "internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl": { + "before": "de5e7b25954070babc2b968159cd15c0cc22cef1b6904ae3a11f8391ba48d991", + "after": "fdca533c84ab307c95a3556f65f4254207ad62b23a1bfdb92993f658d73ae3d5" + } + } +} diff --git a/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.patch b/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.patch new file mode 100644 index 000000000..a72384f64 --- /dev/null +++ b/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.patch @@ -0,0 +1,1261 @@ +diff --git a/backend/plonk/bn254/prove.go b/backend/plonk/bn254/prove.go +--- a/backend/plonk/bn254/prove.go ++++ b/backend/plonk/bn254/prove.go +@@ -123,6 +123,96 @@ + ComputeNumerator(in *NumeratorInput) (*iop.Polynomial, error) + } + ++// AcceleratorFeatureFlagsV1 selects operations before a proof mutates its polynomials. ++type AcceleratorFeatureFlagsV1 uint64 ++ ++const ( ++ AcceleratorFeatureMSMV1 AcceleratorFeatureFlagsV1 = 1 << iota ++ AcceleratorFeatureToCanonicalV1 ++ AcceleratorFeatureNumeratorV1 ++ AcceleratorFeatureQuotientV1 ++) ++ ++const legacyAcceleratorFeaturesV1 = AcceleratorFeatureMSMV1 | AcceleratorFeatureToCanonicalV1 | AcceleratorFeatureNumeratorV1 ++const allAcceleratorFeaturesV1 = legacyAcceleratorFeaturesV1 | AcceleratorFeatureQuotientV1 ++ ++// AcceleratorFeaturesV1 optionally selects accelerated operations. FeaturesV1 ++// is called exactly once per Prove; its result is frozen for that proof. ++// Implementations must make this method safe for concurrent proofs. ++type AcceleratorFeaturesV1 interface { ++ FeaturesV1() AcceleratorFeatureFlagsV1 ++} ++ ++// QuotientAcceleratorV1 computes H after division by X^N-1 and the inverse FFT. ++// Its result has Domain1.Cardinality coefficients in Canonical/Regular form. ++// Every nonblinding input must be restored to Canonical/Regular on Domain0; ++// blinding polynomials must remain unchanged. The corresponding feature bit ++// must be set explicitly; implementing this interface alone does not enable it. ++type QuotientAcceleratorV1 interface { ++ ComputeQuotientV1(*NumeratorInput) (*iop.Polynomial, error) ++} ++ ++type acceleratorState struct { ++ implementation Accelerator ++ features AcceleratorFeatureFlagsV1 ++ quotient QuotientAcceleratorV1 ++} ++ ++// StageEventV1 is a monotonic wall-clock interval relative to proof initialization. ++// Task spans include dependency waits and nested compute spans. Compute spans may ++// include backend queueing; overlapping intervals must not be summed. ++type StageEventV1 struct { ++ Stage, Phase string ++ Start, End time.Duration ++ Failed bool ++} ++ ++type stageObserverStateV1 struct{ callback func(StageEventV1) } ++ ++// SetStageObserverV1 installs a runtime-only diagnostic callback. Prove freezes ++// the callback at entry. It may be called concurrently and must not panic or ++// block. Passing nil disables observation; key serialization is unchanged. ++func (pk *ProvingKey) SetStageObserverV1(observer func(StageEventV1)) { ++ if observer == nil { ++ pk.stageObserver.Store(nil) ++ return ++ } ++ pk.stageObserver.Store(&stageObserverStateV1{callback: observer}) ++} ++ ++// proofKey snapshots runtime dispatch while retaining the immutable key arrays. ++// Concurrent SetAccelerator calls cannot change an already-started proof. ++func (pk *ProvingKey) proofKey() (*ProvingKey, error) { ++ view := &ProvingKey{Kzg: pk.Kzg, KzgLagrange: pk.KzgLagrange, Vk: pk.Vk} ++ view.stageObserver.Store(pk.stageObserver.Load()) ++ configured := pk.accelerator.Load() ++ if configured == nil { ++ return view, nil ++ } ++ features := legacyAcceleratorFeaturesV1 ++ if selectable, ok := configured.implementation.(AcceleratorFeaturesV1); ok { ++ features = selectable.FeaturesV1() ++ } ++ if features & ^allAcceleratorFeaturesV1 != 0 { ++ return nil, fmt.Errorf("unsupported accelerator feature bits: %#x", features & ^allAcceleratorFeaturesV1) ++ } ++ state := &acceleratorState{implementation: configured.implementation, features: features} ++ if features&AcceleratorFeatureQuotientV1 != 0 { ++ var ok bool ++ state.quotient, ok = configured.implementation.(QuotientAcceleratorV1) ++ if !ok { ++ return nil, errors.New("quotient feature requires QuotientAcceleratorV1") ++ } ++ } ++ view.accelerator.Store(state) ++ return view, nil ++} ++ ++func (pk *ProvingKey) accelerated(feature AcceleratorFeatureFlagsV1) bool { ++ state := pk.accelerator.Load() ++ return state != nil && state.features&feature != 0 ++} ++ + // NumeratorInput is the input of Accelerator.ComputeNumerator. All polynomials + // have Domain0.Cardinality coefficients except the blinding polynomials, which + // are small-degree canonical polynomials. +@@ -145,39 +235,47 @@ + // SetAccelerator attaches an Accelerator to the proving key. Passing nil restores + // the CPU implementation. + func (pk *ProvingKey) SetAccelerator(a Accelerator) { +- pk.accelerator = a ++ if a == nil { ++ pk.accelerator.Store(nil) ++ return ++ } ++ pk.accelerator.Store(&acceleratorState{implementation: a, features: legacyAcceleratorFeaturesV1}) + } + + // Accelerator returns the accelerator attached to the proving key, or nil. + func (pk *ProvingKey) Accelerator() Accelerator { +- return pk.accelerator ++ state := pk.accelerator.Load() ++ if state == nil { ++ return nil ++ } ++ return state.implementation + } + + // commit computes the KZG commitment of p with key. nbTasks is a parallelism + // hint for the CPU implementation. + func (pk *ProvingKey) commit(key kzg.ProvingKey, p []fr.Element, nbTasks ...int) (kzg.Digest, error) { +- if pk.accelerator == nil { ++ if !pk.accelerated(AcceleratorFeatureMSMV1) { + return kzg.Commit(p, key, nbTasks...) + } + if len(p) > len(key.G1) { + return kzg.Digest{}, kzg.ErrInvalidPolynomialSize + } +- return pk.accelerator.MultiExp(key.G1[:len(p)], p) ++ return pk.accelerator.Load().implementation.MultiExp(key.G1[:len(p)], p) + } + + func (pk *ProvingKey) multiExp(res *curve.G1Affine, bases []curve.G1Affine, scalars []fr.Element) error { +- if pk.accelerator == nil { ++ if !pk.accelerated(AcceleratorFeatureMSMV1) { + _, err := res.MultiExp(bases, scalars, ecc.MultiExpConfig{}) + return err + } + var err error +- *res, err = pk.accelerator.MultiExp(bases, scalars) ++ *res, err = pk.accelerator.Load().implementation.MultiExp(bases, scalars) + return err + } + + // committer returns the kzg.Committer used for KZG openings with key. + func (pk *ProvingKey) committer(key kzg.ProvingKey) kzg.Committer { +- if pk.accelerator == nil { ++ if !pk.accelerated(AcceleratorFeatureMSMV1) { + return key + } + return acceleratedCommitter{pk: pk, key: key} +@@ -192,7 +290,12 @@ + return c.pk.commit(c.key, p) + } + +-func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (*Proof, error) { ++func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (_ *Proof, err error) { ++ ++ pk, err = pk.proofKey() ++ if err != nil { ++ return nil, fmt.Errorf("accelerator configuration: %w", err) ++ } + + log := logger.Logger().With(). + Str("curve", spr.CurveID().String()). +@@ -209,37 +312,48 @@ + + // init instance + g, ctx := errgroup.WithContext(context.Background()) ++ observer := pk.stageObserver.Load() ++ if observer != nil { ++ defer func() { ++ observer.callback(StageEventV1{Stage: "prove", Phase: "task", End: time.Since(start), Failed: err != nil}) ++ }() ++ } + instance, err := newInstance(ctx, spr, pk, fullWitness, &opt) ++ if observer != nil { ++ observer.callback(StageEventV1{Stage: "initialization", Phase: "compute", End: time.Since(start), Failed: err != nil}) ++ } + if err != nil { + return nil, fmt.Errorf("new instance: %w", err) + } + ++ instance.stageObserver, instance.stageOrigin = observer, start ++ + // solve constraints +- g.Go(instance.solveConstraints) ++ g.Go(instance.observeTaskV1("solver-lro", instance.solveConstraints)) + + // complete qk +- g.Go(instance.completeQk) ++ g.Go(instance.observeTaskV1("qk", instance.completeQk)) + + // init blinding polynomials +- g.Go(instance.initBlindingPolynomials) ++ g.Go(instance.observeTaskV1("blinding", instance.initBlindingPolynomials)) + + // derive gamma, beta (copy constraint) +- g.Go(instance.deriveGammaAndBeta) ++ g.Go(instance.observeTaskV1("gamma-beta", instance.deriveGammaAndBeta)) + + // compute accumulating ratio for the copy constraint +- g.Go(instance.buildRatioCopyConstraint) ++ g.Go(instance.observeTaskV1("z-permutation", instance.buildRatioCopyConstraint)) + + // compute h +- g.Go(instance.computeQuotient) ++ g.Go(instance.observeTaskV1("quotient-h", instance.computeQuotient)) + + // open Z (blinded) at ωζ (proof.ZShiftedOpening) +- g.Go(instance.openZ) ++ g.Go(instance.observeTaskV1("z-opening", instance.openZ)) + + // linearized polynomial +- g.Go(instance.computeLinearizedPolynomial) ++ g.Go(instance.observeTaskV1("evaluations-linearization", instance.computeLinearizedPolynomial)) + + // Batch opening +- g.Go(instance.batchOpening) ++ g.Go(instance.observeTaskV1("batch-opening", instance.batchOpening)) + + if err := g.Wait(); err != nil { + return nil, err +@@ -251,7 +365,9 @@ + + // represents a Prover instance + type instance struct { +- ctx context.Context ++ ctx context.Context ++ stageObserver *stageObserverStateV1 ++ stageOrigin time.Time + + pk *ProvingKey + proof *Proof +@@ -297,6 +413,43 @@ + domain0, domain1 *fft.Domain + + trace *Trace ++} ++ ++func (s *instance) beginStageV1() time.Time { ++ if s.stageObserver == nil { ++ return time.Time{} ++ } ++ return time.Now() ++} ++ ++func (s *instance) endStageV1(stage, phase string, start time.Time, failed bool) { ++ if s.stageObserver == nil { ++ return ++ } ++ s.stageObserver.callback(StageEventV1{Stage: stage, Phase: phase, ++ Start: start.Sub(s.stageOrigin), End: time.Since(s.stageOrigin), Failed: failed}) ++} ++ ++func (s *instance) observeTaskV1(stage string, fn func() error) func() error { ++ if s.stageObserver == nil { ++ return fn ++ } ++ return func() (err error) { ++ start := s.beginStageV1() ++ defer func() { s.endStageV1(stage, "task", start, err != nil) }() ++ return fn() ++ } ++} ++ ++func (s *instance) waitStageV1(stage string, ready <-chan struct{}) (err error) { ++ start := s.beginStageV1() ++ defer func() { s.endStageV1(stage, "wait", start, err != nil) }() ++ select { ++ case <-s.ctx.Done(): ++ return errContextDone ++ case <-ready: ++ return nil ++ } + } + + func newInstance(ctx context.Context, spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts *backend.ProverConfig) (*instance, error) { +@@ -414,10 +567,13 @@ + // solveConstraints computes the evaluation of the polynomials L, R, O + // and sets x[id_L], x[id_R], x[id_O] in Lagrange form + func (s *instance) solveConstraints() error { ++ solverStart := s.beginStageV1() + _solution, err := s.spr.Solve(s.fullWitness, s.opt.SolverOpts...) ++ s.endStageV1("solver", "compute", solverStart, err != nil) + if err != nil { + return err + } ++ polynomialsStart := s.beginStageV1() + solution := _solution.(*cs.SparseR1CSSolution) + evaluationLDomainSmall := []fr.Element(solution.L) + evaluationRDomainSmall := []fr.Element(solution.R) +@@ -436,6 +592,7 @@ + s.x[id_O] = iop.NewPolynomial(&evaluationODomainSmall, iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}) + + wg.Wait() ++ s.endStageV1("witness-polynomials", "compute", polynomialsStart, false) + + // commit to l, r, o and add blinding factors + if err := s.commitToLRO(); err != nil { +@@ -457,10 +614,8 @@ + copy(qkCoeffs, wWitness[:len(s.spr.Public)]) + + // wait for solver to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLRO: ++ if err := s.waitStageV1("qk/lro", s.chLRO); err != nil { ++ return err + } + + for i := range s.commitmentInfo { +@@ -498,14 +653,14 @@ + // The (P[i]-s0) terms are zero in the padding region, so the MSM only needs + // the non-padding entries. For a 2.2M-constraint circuit on a 4M domain, + // this nearly halves each MSM. +-func (s *instance) commitToLRO() error { ++func (s *instance) commitToLRO() (err error) { + // wait for blinding polynomials to be initialized or context to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chbp: +- } +- ++ if err := s.waitStageV1("lro/blinding", s.chbp); err != nil { ++ return err ++ } ++ ++ lroStart := s.beginStageV1() ++ defer func() { s.endStageV1("lro", "compute", lroStart, err != nil) }() + n := int(s.domain0.Cardinality) + nbPublic := len(s.spr.Public) + offset := nbPublic + s.spr.GetNbConstraints() +@@ -527,6 +682,8 @@ + + // L: subtract s0, MSM on [0:offset], add correction + blinding, restore + g.Go(func() (err error) { ++ commitStart := s.beginStageV1() ++ defer func() { s.endStageV1("l-commitment", "compute", commitStart, err != nil) }() + coeffs := s.x[id_L].Coefficients() + for i := 0; i < offset; i++ { + coeffs[i].Sub(&coeffs[i], &s0) +@@ -546,6 +703,8 @@ + + // R: subtract s0, MSM on [nbPublic:offset], add correction + blinding, restore + g.Go(func() (err error) { ++ commitStart := s.beginStageV1() ++ defer func() { s.endStageV1("r-commitment", "compute", commitStart, err != nil) }() + coeffs := s.x[id_R].Coefficients() + for i := nbPublic; i < offset; i++ { + coeffs[i].Sub(&coeffs[i], &s0) +@@ -565,6 +724,8 @@ + + // O: same as R + g.Go(func() (err error) { ++ commitStart := s.beginStageV1() ++ defer func() { s.endStageV1("o-commitment", "compute", commitStart, err != nil) }() + coeffs := s.x[id_O].Coefficients() + for i := nbPublic; i < offset; i++ { + coeffs[i].Sub(&coeffs[i], &s0) +@@ -597,10 +758,8 @@ + } + + // wait for LRO to be committed +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLRO: ++ if err := s.waitStageV1("gamma-beta/lro", s.chLRO); err != nil { ++ return err + } + + gamma, err := deriveRandomness(s.fs, "gamma", &s.proof.LRO[0], &s.proof.LRO[1], &s.proof.LRO[2]) +@@ -653,6 +812,7 @@ + + // computeQuotient computes H + func (s *instance) computeQuotient() (err error) { ++ prepareStart := s.beginStageV1() + s.x[id_Ql] = s.trace.Ql + s.x[id_Qr] = s.trace.Qr + s.x[id_Qm] = s.trace.Qm +@@ -669,11 +829,11 @@ + lone := make([]fr.Element, n) + lone[0].SetOne() + ++ s.endStageV1("quotient-preparation", "compute", prepareStart, false) ++ + // wait for solver to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLRO: ++ if err := s.waitStageV1("quotient/lro", s.chLRO); err != nil { ++ return err + } + + for i := 0; i < len(s.commitmentInfo); i++ { +@@ -681,10 +841,8 @@ + } + + // wait for Z to be committed or context done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chZ: ++ if err := s.waitStageV1("quotient/z", s.chZ); err != nil { ++ return err + } + + // derive alpha +@@ -698,24 +856,31 @@ + + s.x[id_ZS] = s.x[id_Z].ShallowClone().Shift(1) + +- var numerator *iop.Polynomial +- if s.pk.accelerator == nil { +- numerator, err = s.computeNumerator() ++ if s.pk.accelerated(AcceleratorFeatureQuotientV1) { ++ s.h, err = s.computeQuotientAccelerated() + } else { +- numerator, err = s.computeNumeratorAccelerated() ++ var numerator *iop.Polynomial ++ if s.pk.accelerated(AcceleratorFeatureNumeratorV1) { ++ numerator, err = s.computeNumeratorAccelerated() ++ } else { ++ numerator, err = s.computeNumerator() ++ } ++ if err == nil { ++ divideStart := s.beginStageV1() ++ s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) ++ s.endStageV1("quotient-division", "compute", divideStart, err != nil) ++ } + } + if err != nil { + return err + } + +- s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) +- if err != nil { +- return err +- } +- + // commit to h +- if err := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk); err != nil { +- return err ++ commitStart := s.beginStageV1() ++ commitErr := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk) ++ s.endStageV1("h-commitments", "compute", commitStart, commitErr != nil) ++ if commitErr != nil { ++ return commitErr + } + + if err := s.deriveZeta(); err != nil { +@@ -723,10 +888,8 @@ + } + + // wait for clean up tasks to be done +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chRestoreLRO: ++ if err := s.waitStageV1("quotient/restore", s.chRestoreLRO); err != nil { ++ return err + } + + close(s.chH) +@@ -736,14 +899,13 @@ + + func (s *instance) buildRatioCopyConstraint() (err error) { + // wait for gamma and beta to be derived (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chGammaBeta: ++ if err := s.waitStageV1("z-permutation/gamma-beta", s.chGammaBeta); err != nil { ++ return err + } + + // TODO @gbotrel having iop.BuildRatioCopyConstraint return something + // with capacity = len() + 4 would avoid extra alloc / copy during openZ ++ permutationStart := s.beginStageV1() + s.x[id_Z], err = iop.BuildRatioCopyConstraint( + []*iop.Polynomial{ + s.x[id_L], +@@ -756,12 +918,15 @@ + iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}, + s.domain0, + ) ++ s.endStageV1("permutation", "compute", permutationStart, err != nil) + if err != nil { + return err + } + + // commit to the blinded version of z ++ commitStart := s.beginStageV1() + s.proof.Z, err = s.commitToPolyAndBlinding(s.x[id_Z], s.bp[id_Bz]) ++ s.endStageV1("z-commitment", "compute", commitStart, err != nil) + + close(s.chZ) + +@@ -771,11 +936,11 @@ + // open Z (blinded) at ωζ + func (s *instance) openZ() (err error) { + // wait for H to be committed and zeta to be derived (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chH: +- } ++ if err := s.waitStageV1("z-opening/h", s.chH); err != nil { ++ return err ++ } ++ openingStart := s.beginStageV1() ++ defer func() { s.endStageV1("z-opening", "compute", openingStart, err != nil) }() + var zetaShifted fr.Element + zetaShifted.Mul(&s.zeta, &s.pk.Vk.Generator) + s.blindedZ = getBlindedCoefficients(s.x[id_Z], s.bp[id_Bz]) +@@ -828,10 +993,8 @@ + func (s *instance) computeLinearizedPolynomial() error { + + // wait for H to be committed and zeta to be derived (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chH: ++ if err := s.waitStageV1("linearization/h", s.chH); err != nil { ++ return err + } + + qcpzeta := make([]fr.Element, len(s.commitmentInfo)) +@@ -841,35 +1004,44 @@ + + for i := 0; i < len(s.commitmentInfo); i++ { + go func(i int) { ++ evaluationStart := s.beginStageV1() + qcpzeta[i] = s.trace.Qcp[i].Evaluate(s.zeta) ++ s.endStageV1("evaluation-qcp", "compute", evaluationStart, false) + wg.Done() + }(i) + } + + go func() { ++ evaluationStart := s.beginStageV1() + blzeta = evaluateBlinded(s.x[id_L], s.bp[id_Bl], s.zeta) ++ s.endStageV1("evaluation-l", "compute", evaluationStart, false) + wg.Done() + }() + + go func() { ++ evaluationStart := s.beginStageV1() + brzeta = evaluateBlinded(s.x[id_R], s.bp[id_Br], s.zeta) ++ s.endStageV1("evaluation-r", "compute", evaluationStart, false) + wg.Done() + }() + + go func() { ++ evaluationStart := s.beginStageV1() + bozeta = evaluateBlinded(s.x[id_O], s.bp[id_Bo], s.zeta) ++ s.endStageV1("evaluation-o", "compute", evaluationStart, false) + wg.Done() + }() + + // wait for Z to be opened at zeta (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chZOpening: ++ if err := s.waitStageV1("linearization/z-opening", s.chZOpening); err != nil { ++ return err + } + bzuzeta := s.proof.ZShiftedOpening.ClaimedValue + ++ evaluationsWait := s.beginStageV1() + wg.Wait() ++ s.endStageV1("linearization/evaluations", "wait", evaluationsWait, false) ++ linearizationStart := s.beginStageV1() + + s.linearizedPolynomial = s.innerComputeLinearizedPoly( + blzeta, +@@ -886,8 +1058,11 @@ + s.pk, + ) + ++ s.endStageV1("linearization", "compute", linearizationStart, false) ++ commitStart := s.beginStageV1() + var err error + s.linearizedPolynomialDigest, err = s.pk.commit(s.pk.Kzg, s.linearizedPolynomial, runtime.NumCPU()*2) ++ s.endStageV1("linearization-commitment", "compute", commitStart, err != nil) + if err != nil { + return err + } +@@ -895,15 +1070,15 @@ + return nil + } + +-func (s *instance) batchOpening() error { ++func (s *instance) batchOpening() (err error) { + + // wait for linearizedPolynomial to be computed (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return errContextDone +- case <-s.chLinearizedPolynomial: +- } +- ++ if err := s.waitStageV1("batch-opening/linearization", s.chLinearizedPolynomial); err != nil { ++ return err ++ } ++ ++ openingStart := s.beginStageV1() ++ defer func() { s.endStageV1("batch-opening", "compute", openingStart, err != nil) }() + polysQcp := coefficients(s.trace.Qcp) + polysToOpen := make([][]fr.Element, 6+len(polysQcp)) + copy(polysToOpen[6:], polysQcp) +@@ -925,7 +1100,6 @@ + digestsToOpen[4] = s.pk.Vk.S[0] + digestsToOpen[5] = s.pk.Vk.S[1] + +- var err error + s.proof.BatchedProof, err = kzg.BatchOpenSinglePointWithCommitter( + polysToOpen, + digestsToOpen, +@@ -942,13 +1116,21 @@ + // proving key's Accelerator. Like computeNumerator, it leaves the polynomials + // in x in canonical regular form; unlike it, the restoration is synchronous. + func (s *instance) computeNumeratorAccelerated() (*iop.Polynomial, error) { ++ return s.computeAcceleratedQuotient(false) ++} ++ ++func (s *instance) computeQuotientAccelerated() (*iop.Polynomial, error) { ++ return s.computeAcceleratedQuotient(true) ++} ++ ++func (s *instance) computeAcceleratedQuotient(fused bool) (_ *iop.Polynomial, err error) { + // wait for chQk to be closed (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return nil, errContextDone +- case <-s.chQk: +- } +- ++ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { ++ return nil, err ++ } ++ ++ computeStart := s.beginStageV1() ++ defer func() { s.endStageV1("quotient", "compute", computeStart, err != nil) }() + in := &NumeratorInput{ + Domain0: s.domain0, + Domain1: s.domain1, +@@ -979,8 +1161,18 @@ + in.Pi[i] = s.x[id_Qci+2*i+1] + } + +- res, err := s.pk.accelerator.ComputeNumerator(in) ++ blindings := snapshotAcceleratorBlindings(in) ++ state := s.pk.accelerator.Load() ++ var res *iop.Polynomial ++ if fused { ++ res, err = state.quotient.ComputeQuotientV1(in) ++ } else { ++ res, err = state.implementation.ComputeNumerator(in) ++ } + if err != nil { ++ return nil, err ++ } ++ if err := validateAcceleratorResult(in, res, fused, blindings); err != nil { + return nil, err + } + +@@ -991,9 +1183,63 @@ + return res, nil + } + ++type acceleratorBlindingSnapshot struct { ++ polynomial *iop.Polynomial ++ basis iop.Basis ++ layout iop.Layout ++ size int ++ coefficients []fr.Element ++} ++ ++func snapshotAcceleratorBlindings(in *NumeratorInput) []acceleratorBlindingSnapshot { ++ result := make([]acceleratorBlindingSnapshot, 0, 4) ++ for _, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { ++ result = append(result, acceleratorBlindingSnapshot{ ++ polynomial: p, basis: p.Basis, layout: p.Layout, size: p.Size(), ++ coefficients: append([]fr.Element(nil), p.Coefficients()...), ++ }) ++ } ++ return result ++} ++ ++func validateAcceleratorResult(in *NumeratorInput, result *iop.Polynomial, fused bool, blindings []acceleratorBlindingSnapshot) error { ++ basis, layout := iop.LagrangeCoset, iop.BitReverse ++ if fused { ++ basis, layout = iop.Canonical, iop.Regular ++ } ++ if result == nil || result.Basis != basis || result.Layout != layout || ++ uint64(result.Size()) != in.Domain1.Cardinality || ++ uint64(len(result.Coefficients())) != in.Domain1.Cardinality { ++ return errors.New("accelerator returned an invalid quotient-domain polynomial") ++ } ++ inputs := []*iop.Polynomial{in.L, in.R, in.O, in.Z, in.Qk, in.Ql, in.Qr, in.Qm, in.Qo, in.S1, in.S2, in.S3} ++ inputs = append(inputs, in.Qcp...) ++ inputs = append(inputs, in.Pi...) ++ for _, p := range inputs { ++ if p == nil || p.Basis != iop.Canonical || p.Layout != iop.Regular || ++ uint64(p.Size()) != in.Domain0.Cardinality || ++ uint64(len(p.Coefficients())) != in.Domain0.Cardinality { ++ return errors.New("accelerator did not restore canonical input polynomials") ++ } ++ } ++ for _, saved := range blindings { ++ p := saved.polynomial ++ if p.Basis != saved.basis || p.Layout != saved.layout || p.Size() != saved.size || ++ len(p.Coefficients()) != len(saved.coefficients) { ++ return errors.New("accelerator changed a blinding polynomial") ++ } ++ for i, coefficient := range saved.coefficients { ++ if coefficient != p.Coefficients()[i] { ++ return errors.New("accelerator changed a blinding polynomial") ++ } ++ } ++ } ++ return nil ++} ++ + // evaluate the full set of constraints, all polynomials in x are back in + // canonical regular form at the end +-func (s *instance) computeNumerator() (*iop.Polynomial, error) { ++func (s *instance) computeNumerator() (_ *iop.Polynomial, err error) { + // init vectors that are used multiple times throughout the computation + n := s.domain0.Cardinality + twiddles0 := make([]fr.Element, n) +@@ -1013,12 +1259,12 @@ + } + + // wait for chQk to be closed (or ctx.Done()) +- select { +- case <-s.ctx.Done(): +- return nil, errContextDone +- case <-s.chQk: +- } +- ++ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { ++ return nil, err ++ } ++ ++ computeStart := s.beginStageV1() ++ defer func() { s.endStageV1("quotient-numerator", "compute", computeStart, err != nil) }() + nbBsbGates := len(s.proof.Bsb22Commitments) + + gateConstraint := func(u ...fr.Element) fr.Element { +@@ -1243,6 +1489,7 @@ + + // scale everything back + go func() { ++ restoreStart := s.beginStageV1() + s.x[id_ZS] = nil + s.x[id_Qk] = nil + +@@ -1265,6 +1512,7 @@ + scalePowers(q, cs) + } + ++ s.endStageV1("polynomial-restoration", "compute", restoreStart, false) + close(s.chRestoreLRO) + }() + +@@ -1471,9 +1719,12 @@ + } + }) + +- if pk.accelerator != nil { +- if err := pk.accelerator.ToCanonical(bigDomain, a); err != nil { ++ if pk.accelerated(AcceleratorFeatureToCanonicalV1) { ++ if err := pk.accelerator.Load().implementation.ToCanonical(bigDomain, a); err != nil { + return nil, err ++ } ++ if a.Basis != iop.Canonical || a.Layout != iop.Regular || uint64(len(a.Coefficients())) != bigDomain.Cardinality { ++ return nil, errors.New("accelerator canonical conversion returned an invalid polynomial") + } + return a, nil + } +diff --git a/backend/plonk/bn254/setup.go b/backend/plonk/bn254/setup.go +--- a/backend/plonk/bn254/setup.go ++++ b/backend/plonk/bn254/setup.go +@@ -7,6 +7,7 @@ + + import ( + "fmt" ++ "sync/atomic" + + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" +@@ -94,7 +95,8 @@ + // accelerator, when set, offloads the multi-scalar multiplications, the + // quotient numerator and the large inverse FFT of Prove (see Accelerator). + // Runtime-only, not serialized. +- accelerator Accelerator ++ accelerator atomic.Pointer[acceleratorState] ++ stageObserver atomic.Pointer[stageObserverStateV1] + } + + func Setup(spr *cs.SparseR1CS, srs, srsLagrange kzg.SRS) (*ProvingKey, *VerifyingKey, error) { +diff --git a/backend/plonk/bn254/accelerator_v1_test.go b/backend/plonk/bn254/accelerator_v1_test.go +new file mode 100644 +--- /dev/null ++++ b/backend/plonk/bn254/accelerator_v1_test.go +@@ -0,0 +1,444 @@ ++package plonk ++ ++import ( ++ "context" ++ "errors" ++ "math/big" ++ "strings" ++ "sync" ++ "sync/atomic" ++ "testing" ++ "time" ++ ++ "github.com/consensys/gnark-crypto/ecc" ++ curve "github.com/consensys/gnark-crypto/ecc/bn254" ++ "github.com/consensys/gnark-crypto/ecc/bn254/fr" ++ "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" ++ "github.com/consensys/gnark-crypto/ecc/bn254/fr/iop" ++ "github.com/consensys/gnark-crypto/ecc/bn254/kzg" ++ "github.com/consensys/gnark/backend" ++ cs "github.com/consensys/gnark/constraint/bn254" ++ "github.com/consensys/gnark/frontend" ++ "github.com/consensys/gnark/frontend/cs/scs" ++) ++ ++type testAcceleratorV1 struct { ++ msm, canonical, numerator atomic.Int32 ++ failure AcceleratorFeatureFlagsV1 ++} ++ ++var testAcceleratorFailureV1 = errors.New("accelerator sentinel failure") ++ ++func (a *testAcceleratorV1) MultiExp(bases []curve.G1Affine, scalars []fr.Element) (curve.G1Affine, error) { ++ a.msm.Add(1) ++ if a.failure == AcceleratorFeatureMSMV1 { ++ return curve.G1Affine{}, testAcceleratorFailureV1 ++ } ++ var result curve.G1Affine ++ _, err := result.MultiExp(bases, scalars, ecc.MultiExpConfig{}) ++ return result, err ++} ++ ++func (a *testAcceleratorV1) ToCanonical(domain *fft.Domain, p *iop.Polynomial) error { ++ a.canonical.Add(1) ++ if a.failure == AcceleratorFeatureToCanonicalV1 { ++ return testAcceleratorFailureV1 ++ } ++ p.ToCanonical(domain).ToRegular() ++ return nil ++} ++ ++func (a *testAcceleratorV1) ComputeNumerator(in *NumeratorInput) (*iop.Polynomial, error) { ++ a.numerator.Add(1) ++ if a.failure == AcceleratorFeatureNumeratorV1 { ++ return nil, testAcceleratorFailureV1 ++ } ++ return testNumeratorReferenceV1(in) ++} ++ ++type testSelectiveAcceleratorV1 struct { ++ *testAcceleratorV1 ++ flags atomic.Uint64 ++ featureCalls atomic.Int32 ++} ++ ++func (a *testSelectiveAcceleratorV1) FeaturesV1() AcceleratorFeatureFlagsV1 { ++ a.featureCalls.Add(1) ++ return AcceleratorFeatureFlagsV1(a.flags.Load()) ++} ++ ++type testFusedAcceleratorV1 struct { ++ *testSelectiveAcceleratorV1 ++ quotient atomic.Int32 ++ corrupt func(*NumeratorInput, *iop.Polynomial) ++} ++ ++func (a *testFusedAcceleratorV1) ComputeQuotientV1(in *NumeratorInput) (*iop.Polynomial, error) { ++ a.quotient.Add(1) ++ if a.failure == AcceleratorFeatureQuotientV1 { ++ return nil, testAcceleratorFailureV1 ++ } ++ numerator, err := testNumeratorReferenceV1(in) ++ if err != nil { ++ return nil, err ++ } ++ result, err := divideByZH(numerator, [2]*fft.Domain{in.Domain0, in.Domain1}, new(ProvingKey)) ++ if err == nil && a.corrupt != nil { ++ a.corrupt(in, result) ++ } ++ return result, err ++} ++ ++// The reference uses the unchanged CPU numerator and waits for its restoration ++// goroutine. Completed Qk is unused by CPU proving but is part of the adapter's ++// restoration contract, so it is restored explicitly as well. ++func testNumeratorReferenceV1(in *NumeratorInput) (*iop.Polynomial, error) { ++ s := &instance{ ++ ctx: context.Background(), domain0: in.Domain0, domain1: in.Domain1, ++ proof: &Proof{Bsb22Commitments: make([]curve.G1Affine, len(in.Qcp))}, ++ x: make([]*iop.Polynomial, id_Qci+2*len(in.Qcp)), ++ bp: []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz}, ++ alpha: in.Alpha, beta: in.Beta, gamma: in.Gamma, ++ chQk: make(chan struct{}), chRestoreLRO: make(chan struct{}), ++ } ++ copy(s.x, []*iop.Polynomial{ ++ in.L, in.R, in.O, in.Z, in.Z.ShallowClone().Shift(1), ++ in.Ql, in.Qr, in.Qm, in.Qo, in.Qk, in.S1, in.S2, in.S3, ++ }) ++ for i := range in.Qcp { ++ s.x[id_Qci+2*i], s.x[id_Qci+2*i+1] = in.Qcp[i], in.Pi[i] ++ } ++ close(s.chQk) ++ result, err := s.computeNumerator() ++ if err != nil { ++ return nil, err ++ } ++ <-s.chRestoreLRO ++ rho := in.Domain1.Cardinality / in.Domain0.Cardinality ++ var scale fr.Element ++ scale.Exp(in.Domain1.Generator, new(big.Int).SetUint64(rho-1)) ++ scale.Mul(&scale, &in.Domain1.FrMultiplicativeGen).Inverse(&scale) ++ in.Qk.ToCanonical(in.Domain0).ToRegular() ++ scalePowers(in.Qk, scale) ++ return result, nil ++} ++ ++type acceleratorCircuitV1 struct { ++ Public frontend.Variable `gnark:",public"` ++ Secret frontend.Variable ++ Commit bool `gnark:"-"` ++} ++ ++func (c *acceleratorCircuitV1) Define(api frontend.API) error { ++ x := c.Secret ++ for i := 0; i < 4; i++ { ++ x = api.Mul(x, c.Secret) ++ } ++ api.AssertIsEqual(x, c.Public) ++ if c.Commit { ++ challenge, err := api.(frontend.Committer).Commit(c.Secret) ++ if err != nil { ++ return err ++ } ++ api.AssertIsDifferent(challenge, 0) ++ } ++ return nil ++} ++ ++func TestAcceleratorV1CapabilitiesAreFrozen(t *testing.T) { ++ pk := new(ProvingKey) ++ legacy := new(testAcceleratorV1) ++ pk.SetAccelerator(legacy) ++ view, err := pk.proofKey() ++ if err != nil || view.accelerator.Load().features != legacyAcceleratorFeaturesV1 { ++ t.Fatalf("legacy accelerator: %v", err) ++ } ++ selective := &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)} ++ selective.flags.Store(uint64(AcceleratorFeatureMSMV1)) ++ pk.SetAccelerator(selective) ++ view, err = pk.proofKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ selective.flags.Store(uint64(AcceleratorFeatureNumeratorV1)) ++ pk.SetAccelerator(nil) ++ if !view.accelerated(AcceleratorFeatureMSMV1) || view.accelerated(AcceleratorFeatureNumeratorV1) || selective.featureCalls.Load() != 1 { ++ t.Fatal("feature selection changed after proof entry") ++ } ++ if pk.Accelerator() != nil || view.Accelerator() != selective { ++ t.Fatal("proof view did not retain its selected accelerator") ++ } ++ plain, err := pk.proofKey() ++ if err != nil || plain.Accelerator() != nil { ++ t.Fatalf("CPU view: %v", err) ++ } ++ pk.SetAccelerator(legacy) ++ if plain.Accelerator() != nil { ++ t.Fatal("CPU proof acquired a later accelerator") ++ } ++} ++ ++func TestAcceleratorV1RejectsConfigurationBeforeProofWork(t *testing.T) { ++ for _, flags := range []AcceleratorFeatureFlagsV1{1 << 63, AcceleratorFeatureQuotientV1} { ++ a := &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)} ++ a.flags.Store(uint64(flags)) ++ pk := new(ProvingKey) ++ pk.SetAccelerator(a) ++ if _, err := Prove(nil, pk, nil); err == nil { ++ t.Fatal("invalid capabilities were accepted") ++ } ++ if a.msm.Load()+a.canonical.Load()+a.numerator.Load() != 0 { ++ t.Fatal("an operation ran before capability validation") ++ } ++ } ++} ++ ++func TestAcceleratorV1ConcurrentSelection(t *testing.T) { ++ pk := new(ProvingKey) ++ a := new(testAcceleratorV1) ++ var wg sync.WaitGroup ++ for i := 0; i < 4; i++ { ++ wg.Add(1) ++ go func() { ++ defer wg.Done() ++ for j := 0; j < 64; j++ { ++ pk.SetAccelerator(a) ++ view, err := pk.proofKey() ++ if err != nil { ++ t.Error(err) ++ } ++ pk.SetAccelerator(nil) ++ if selected := view.Accelerator(); selected != nil && selected != a { ++ t.Error("unexpected accelerator snapshot") ++ } ++ } ++ }() ++ } ++ wg.Wait() ++} ++ ++func TestAcceleratorV1ProvingDispatch(t *testing.T) { ++ for _, commit := range []bool{false, true} { ++ ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &acceleratorCircuitV1{Commit: commit}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())) ++ srs, err := kzg.NewSRS(n+3, big.NewInt(7)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ pk, vk, err := Setup(ccs.(*cs.SparseR1CS), *srs, kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ w, err := frontend.NewWitness(&acceleratorCircuitV1{Public: 32, Secret: 2}, ecc.BN254.ScalarField()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ public, err := w.Public() ++ if err != nil { ++ t.Fatal(err) ++ } ++ for _, flags := range []AcceleratorFeatureFlagsV1{ ++ 0, AcceleratorFeatureMSMV1, AcceleratorFeatureToCanonicalV1, ++ AcceleratorFeatureNumeratorV1, legacyAcceleratorFeaturesV1, ++ AcceleratorFeatureQuotientV1, allAcceleratorFeaturesV1, ++ } { ++ a := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)}} ++ a.flags.Store(uint64(flags)) ++ pk.SetAccelerator(a) ++ proof, err := Prove(ccs.(*cs.SparseR1CS), pk, w) ++ if err != nil { ++ t.Fatalf("commit=%v flags=%x: %v", commit, flags, err) ++ } ++ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { ++ t.Fatalf("commit=%v flags=%x proof: %v", commit, flags, err) ++ } ++ fused := flags&AcceleratorFeatureQuotientV1 != 0 ++ if (a.msm.Load() > 0) != (flags&AcceleratorFeatureMSMV1 != 0) || ++ (a.canonical.Load() > 0) != (!fused && flags&AcceleratorFeatureToCanonicalV1 != 0) || ++ (a.numerator.Load() > 0) != (!fused && flags&AcceleratorFeatureNumeratorV1 != 0) || ++ (a.quotient.Load() > 0) != fused || a.featureCalls.Load() != 1 { ++ t.Fatalf("incorrect dispatch for flags=%x", flags) ++ } ++ } ++ legacy := new(testAcceleratorV1) ++ pk.SetAccelerator(legacy) ++ proof, err := Prove(ccs.(*cs.SparseR1CS), pk, w) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { ++ t.Fatal(err) ++ } ++ statistical := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)}} ++ statistical.flags.Store(uint64(AcceleratorFeatureMSMV1 | AcceleratorFeatureQuotientV1)) ++ pk.SetAccelerator(statistical) ++ proof, err = Prove(ccs.(*cs.SparseR1CS), pk, w, backend.WithStatisticalZeroKnowledge()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { ++ t.Fatal(err) ++ } ++ for _, failure := range []AcceleratorFeatureFlagsV1{ ++ AcceleratorFeatureMSMV1, AcceleratorFeatureToCanonicalV1, ++ AcceleratorFeatureNumeratorV1, AcceleratorFeatureQuotientV1, ++ } { ++ a := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: &testAcceleratorV1{failure: failure}}} ++ a.flags.Store(uint64(failure)) ++ pk.SetAccelerator(a) ++ if _, err := Prove(ccs.(*cs.SparseR1CS), pk, w); !errors.Is(err, testAcceleratorFailureV1) { ++ t.Fatalf("accelerator error fell back to CPU: flags=%x error=%v", failure, err) ++ } ++ } ++ for _, corrupt := range []func(*NumeratorInput, *iop.Polynomial){ ++ func(_ *NumeratorInput, result *iop.Polynomial) { result.Layout = iop.BitReverse }, ++ func(in *NumeratorInput, _ *iop.Polynomial) { in.L.Basis = iop.Lagrange }, ++ func(in *NumeratorInput, _ *iop.Polynomial) { ++ var one fr.Element ++ one.SetOne() ++ in.Bl.Coefficients()[0].Add(&in.Bl.Coefficients()[0], &one) ++ }, ++ } { ++ a := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)}, corrupt: corrupt} ++ a.flags.Store(uint64(AcceleratorFeatureQuotientV1)) ++ pk.SetAccelerator(a) ++ if _, err := Prove(ccs.(*cs.SparseR1CS), pk, w); err == nil || !strings.Contains(err.Error(), "accelerator") { ++ t.Fatalf("invalid fused output was accepted: %v", err) ++ } ++ } ++ } ++} ++ ++func TestStageObserverV1FreezeNilAndWaitFailure(t *testing.T) { ++ pk := new(ProvingKey) ++ plain, err := pk.proofKey() ++ if err != nil || plain.stageObserver.Load() != nil { ++ t.Fatalf("unexpected default observer: %v", err) ++ } ++ var first, second atomic.Int32 ++ pk.SetStageObserverV1(func(StageEventV1) { first.Add(1) }) ++ view, err := pk.proofKey() ++ if err != nil { ++ t.Fatal(err) ++ } ++ pk.SetStageObserverV1(func(StageEventV1) { second.Add(1) }) ++ view.stageObserver.Load().callback(StageEventV1{}) ++ if first.Load() != 1 || second.Load() != 0 || plain.stageObserver.Load() != nil { ++ t.Fatal("observer selection changed after proof entry") ++ } ++ pk.SetStageObserverV1(nil) ++ if pk.stageObserver.Load() != nil { ++ t.Fatal("nil observer did not disable diagnostics") ++ } ++ quiet := &instance{ctx: context.Background()} ++ if !quiet.beginStageV1().IsZero() { ++ t.Fatal("disabled observation acquired a clock timestamp") ++ } ++ ready := make(chan struct{}) ++ close(ready) ++ if err := quiet.waitStageV1("ready", ready); err != nil { ++ t.Fatal(err) ++ } ++ ctx, cancel := context.WithCancel(context.Background()) ++ cancel() ++ var failure StageEventV1 ++ failed := &instance{ctx: ctx, stageOrigin: time.Now(), ++ stageObserver: &stageObserverStateV1{callback: func(event StageEventV1) { failure = event }}} ++ if err := failed.waitStageV1("cancelled", make(chan struct{})); !errors.Is(err, errContextDone) { ++ t.Fatalf("cancelled dependency wait: %v", err) ++ } ++ if failure.Stage != "cancelled" || failure.Phase != "wait" || !failure.Failed || failure.Start < 0 || failure.End < failure.Start { ++ t.Fatalf("invalid failed wait event: %+v", failure) ++ } ++} ++ ++func TestStageObserverV1ProofIntervals(t *testing.T) { ++ ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &acceleratorCircuitV1{Commit: true}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())) ++ srs, err := kzg.NewSRS(n+3, big.NewInt(7)) ++ if err != nil { ++ t.Fatal(err) ++ } ++ lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) ++ if err != nil { ++ t.Fatal(err) ++ } ++ pk, vk, err := Setup(ccs.(*cs.SparseR1CS), *srs, kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) ++ if err != nil { ++ t.Fatal(err) ++ } ++ w, err := frontend.NewWitness(&acceleratorCircuitV1{Public: 32, Secret: 2}, ecc.BN254.ScalarField()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ public, err := w.Public() ++ if err != nil { ++ t.Fatal(err) ++ } ++ var lock sync.Mutex ++ var once sync.Once ++ var events []StageEventV1 ++ pk.SetStageObserverV1(func(event StageEventV1) { ++ once.Do(func() { pk.SetStageObserverV1(nil) }) ++ lock.Lock() ++ defer lock.Unlock() ++ events = append(events, event) ++ }) ++ started := time.Now() ++ proof, err := Prove(ccs.(*cs.SparseR1CS), pk, w) ++ elapsed := time.Since(started) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { ++ t.Fatal(err) ++ } ++ if len(events) == 0 || events[len(events)-1].Stage != "prove" { ++ t.Fatal("missing final proof interval") ++ } ++ phases := make(map[string]bool) ++ computed := make(map[string]StageEventV1) ++ for _, event := range events { ++ if event.Failed || event.Start < 0 || event.End < event.Start || event.End > elapsed { ++ t.Fatalf("invalid proof interval: %+v", event) ++ } ++ phases[event.Phase] = true ++ if event.Phase == "compute" { ++ computed[event.Stage] = event ++ } ++ } ++ for _, phase := range []string{"task", "compute", "wait"} { ++ if !phases[phase] { ++ t.Fatalf("missing %s intervals", phase) ++ } ++ } ++ for _, stage := range []string{"initialization", "solver", "lro", "permutation", "z-commitment", "quotient-numerator", "quotient-division", "h-commitments", "evaluation-l", "evaluation-r", "evaluation-o", "linearization", "linearization-commitment", "z-opening", "batch-opening"} { ++ if _, ok := computed[stage]; !ok { ++ t.Fatalf("missing %s compute interval", stage) ++ } ++ } ++ if computed["solver"].End > computed["lro"].Start || computed["h-commitments"].End > computed["z-opening"].Start { ++ t.Fatal("stage timing violates proof dependencies") ++ } ++ count := len(events) ++ proof, err = Prove(ccs.(*cs.SparseR1CS), pk, w) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { ++ t.Fatal(err) ++ } ++ if len(events) != count { ++ t.Fatal("later proof retained a disabled observer") ++ } ++} diff --git a/tools/bls-plonk-wrapper/profile.go b/tools/bls-plonk-wrapper/profile.go index 165e3622f..eb48213bf 100644 --- a/tools/bls-plonk-wrapper/profile.go +++ b/tools/bls-plonk-wrapper/profile.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "encoding/hex" "encoding/json" "fmt" @@ -10,11 +9,8 @@ import ( "os" "strings" - bls "github.com/consensys/gnark-crypto/ecc/bls12-381" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark/frontend" - sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" ) type pointJSON struct{ X, Y string } @@ -82,13 +78,13 @@ func canonical(s string, modulus *big.Int) (*big.Int, error) { return v, nil } -func (p pointJSON) native() (bls.G1Affine, error) { - var result bls.G1Affine - x, err := canonical(p.X, fp.Modulus()) +func (p pointJSON) native() (nativeG1, error) { + var result nativeG1 + x, err := canonical(p.X, baseModulus()) if err != nil { return result, err } - y, err := canonical(p.Y, fp.Modulus()) + y, err := canonical(p.Y, baseModulus()) if err != nil { return result, err } @@ -100,26 +96,19 @@ func (p pointJSON) native() (bls.G1Affine, error) { return result, nil } -func g2Keys(encoded []string) ([2]bls.G2Affine, error) { - var result [2]bls.G2Affine +func g2Keys(encoded []string) ([2]nativeG2, error) { + var result [2]nativeG2 if len(encoded) != 2 { return result, fmt.Errorf("expected two G2 anchors") } for i, s := range encoded { - b, err := unhex(s, 96) + b, err := unhex(s, g2ExportBytes) if err != nil { return result, err } - if _, err := result[i].SetBytes(b); err != nil { + if result[i], err = decodeG2(b); err != nil { return result, err } - if result[i].IsInfinity() { - return result, fmt.Errorf("identity G2 anchor") - } - canonical := result[i].Bytes() - if !bytes.Equal(canonical[:], b) { - return result, fmt.Errorf("noncanonical G2 anchor") - } } return result, nil } @@ -136,7 +125,7 @@ func loadProfile(path string) (*profile, error) { } func (p *profile) validate() error { - if p.Format != "ix-bls-plonk-profile-v1" || len(p.Prefix) != 10 || len(p.Circuits) != 2 || len(p.Matrices) != 8 || p.PointCount < 1 || p.PointCount > 256 || p.FieldCount < 1 || p.FieldCount > 4096 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 3 || p.WitnessOffset+p.OpeningCount != p.PointCount { + if p.Format != profileFormat || len(p.Prefix) != 10 || len(p.Circuits) != 2 || len(p.Matrices) != 8 || p.PointCount < 1 || p.PointCount > 256 || p.FieldCount < 1 || p.FieldCount > 4096 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 3 || p.WitnessOffset+p.OpeningCount != p.PointCount { return fmt.Errorf("invalid wrapper profile dimensions") } if _, err := unhex(p.ProfileID, 32); err != nil { @@ -175,7 +164,7 @@ func (p *profile) validate() error { if err != nil { return err } - if !outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1]) { + if curveID == ecc.BLS12_381 && (!outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1])) { return fmt.Errorf("Filecoin pairing anchors differ") } if len(p.Program.Coefficients) != p.PointCount+len(p.Fixed)+1 || len(p.Program.Witnesses) != p.OpeningCount || len(p.Program.Assertions) != 2 { @@ -185,7 +174,7 @@ func (p *profile) validate() error { valid := func(id int) bool { return id >= 0 && id < i } switch n.Op { case "constant": - if _, err := canonical(n.Value, fr.Modulus()); err != nil { + if _, err := canonical(n.Value, scalarModulus()); err != nil { return err } case "input": @@ -216,7 +205,7 @@ func (p *profile) validate() error { } func newCircuit(p *profile) *wrapperCircuit { - return &wrapperCircuit{Profile: p, Points: make([]sw.G1Affine, p.PointCount), Values: make([]frontend.Variable, p.FieldCount)} + return &wrapperCircuit{Profile: p, Points: make([]circuitG1, p.PointCount), Values: make([]frontend.Variable, p.FieldCount)} } func witnessAssignment(p *profile, path string) (*wrapperCircuit, error) { @@ -238,17 +227,17 @@ func witnessAssignment(p *profile, path string) (*wrapperCircuit, error) { if err != nil { return nil, err } - result.Points[i] = sw.NewG1Affine(v) + result.Points[i] = newCircuitG1(v) } for i, point := range w.Deferred { v, err := point.native() if err != nil { return nil, err } - result.Deferred[i] = sw.NewG1Affine(v) + result.Deferred[i] = newCircuitG1(v) } for i, v := range w.Values { - scalar, err := canonical(v, fr.Modulus()) + scalar, err := canonical(v, scalarModulus()) if err != nil { return nil, err } diff --git a/tools/bls-plonk-wrapper/sizing_test.go b/tools/bls-plonk-wrapper/sizing_test.go index 047aa849a..100ca04bc 100644 --- a/tools/bls-plonk-wrapper/sizing_test.go +++ b/tools/bls-plonk-wrapper/sizing_test.go @@ -14,20 +14,39 @@ import ( ) type componentCircuit struct { - Inner wrapperCircuit - Phase int `gnark:"-"` + Inner wrapperCircuit + Phase int `gnark:"-"` + Options circuitOptions `gnark:"-"` } -func (c *componentCircuit) Define(api frontend.API) error { return c.Inner.constrain(api, c.Phase) } +func (c *componentCircuit) Define(api frontend.API) error { + return c.Inner.constrainWithOptions(api, c.Phase, c.Options) +} func TestComponentSizing(t *testing.T) { if os.Getenv("IX_PLONK_SIZE") != "1" { t.Skip("set IX_PLONK_SIZE=1 for full component compilation") } - p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + path := os.Getenv("IX_PLONK_SIZE_PROFILE") + if path == "" { + fixture := "outer" + if curveID == ecc.BN254 { + fixture = "bn254" + } + path = filepath.Join("testdata", fixture, "profile.json") + } + p, err := loadProfile(path) if err != nil { t.Fatal(err) } + options := circuitOptions{classicGLV: true} + switch variant := os.Getenv("IX_PLONK_SIZE_VARIANT"); variant { + case "", "classic-glv": + case "baseline": + options.classicGLV = false + default: + t.Fatalf("component sizing requires baseline or classic-glv, got %q", variant) + } type measurement struct { Phase string Constraints, Commitments int @@ -38,8 +57,8 @@ func TestComponentSizing(t *testing.T) { for phase, name := range []string{"point validation and public binding", "transcript", "scalar relation", "opening MSMs", "outer pairing", "deferred pairing"} { runtime.GC() start := time.Now() - ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, - &componentCircuit{Inner: *newCircuit(p), Phase: phase + 1}, frontend.IgnoreUnconstrainedInputs()) + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, + &componentCircuit{Inner: *newCircuit(p), Phase: phase + 1, Options: options}, frontend.IgnoreUnconstrainedInputs()) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/testdata/bn254/profile.json b/tools/bls-plonk-wrapper/testdata/bn254/profile.json new file mode 100644 index 000000000..1a45b05c9 --- /dev/null +++ b/tools/bls-plonk-wrapper/testdata/bn254/profile.json @@ -0,0 +1,5048 @@ +{ + "circuits": [ + { + "logDegree": 26, + "shape": [ + 5, + 3, + 67108864, + 15, + 3, + 4, + 2 + ] + }, + { + "logDegree": 17, + "shape": [ + 1, + 2, + 131072, + 4, + 1, + 1, + 1 + ] + } + ], + "deferredKeys": [ + "0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c212c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b", + "0x2a344fad01c2ed0ed73142ae1752429eaea515c6f3f6b941103cc21c2308e1cb22f1acbb03c4508760c2430af35865e7cdf9f3eb1224504fdcc3708ddb954a480efd30ac7b6f8d0d3ccbc2207587c2acbad1532dc0293f0d034cf8258cd428b3159f15b842ba9c8449aa3268f981010d4c7142e5193473d80b464e964845c3f8" + ], + "fieldCount": 37, + "fixed": [ + { + "x": "0x19a4558a34fb549cf44b5e6d22c0128349736bc3c27c38c4f7eca2f1b434d88b", + "y": "0x2f4f4dec195c61a13beb5c36b85b7b7b4774eaa1f640617d100f3fa9ba7bdeb8" + }, + { + "x": "0x0847c517a1803449a62b38a726b4b950278a3e02445e3f5b86601378fb3a592c", + "y": "0x24b66857552e7827bafd8cc553376eb90e1fd83ee1ed8b5beca6a938e153404c" + }, + { + "x": "0x0f3790a2ad91c5ced6f5da8c895d99c683b0006aa62cf185037e5d2b8183f805", + "y": "0x2d6ff7f7d1f41529f88ec1a73a268fba95401e784e1074c4b68a65ede04d805f" + }, + { + "x": "0x107e5ab7a7e18d3ca1c7879cd68e04c53ca6404bf83f8547c6ab65af6387ad11", + "y": "0x0eb1ecf2c21c0a1ed198af2439c5d78c3c10f6cefe78145931e50ce5f2a68ed7" + }, + { + "x": "0x20c56e3a27ce6ee3c0e9893b8d64be70704a1e536c326a75397a41a1312f6c2e", + "y": "0x13e7c23ede8e55647b75123da58c5c2503aabd71e036eec1acf2574391a4e848" + }, + { + "x": "0x03c0f5a930c8265781669ce0329f08ace93be22e240df252a2698449ee3c2898", + "y": "0x2ef7bc6fb8140d48543358879c73c25fedbc1fff6818f37bce9e99c013a21406" + }, + { + "x": "0x0f0287f417a55c494e3941cda47550d2873798eeb9b07c49f0f0417545485a45", + "y": "0x26ca08f5cef6b9d13c32dbcddf68d7fe1d4a956c7fc5865dcc154c6d641bbca5" + }, + { + "x": "0x022bbc026b06534de185cd323fb83855d13afe601d794f35a33d6d6b0eb0b540", + "y": "0x1c6235baec3e2e59499da384fbbc6d4a45ee7222a2177697e2410dd97253c9df" + }, + { + "x": "0x0db324dcd3a947de2502bfb0fa1e160019d2cbf1634a7b10f97fb4ca3e365f7f", + "y": "0x2c64a4bb0d1ce221ea0f50a86219a2c8f9e250178ed3e8e2a0ded074f30280b2" + }, + { + "x": "0x111e9ccaca9f09341ae6651f8ed93cdf474ef8c876289661cf6c8397f34a6975", + "y": "0x1dd98a79eb0a5a4edaf4afd51cc048acf91c589ae9ed0bb8a71ffbd85b60dba2" + }, + { + "x": "0x301dedc62814418ab341743729002f5db0e49613ec141553ceede764bcdd646c", + "y": "0x155dc9b16c205c6ebd103261be600a2ad300d867aeeb60d806bcc8fb2c7483cb" + }, + { + "x": "0x297088c12ce30d9968e478801bee7538db5fba8d1940ae79f81d9c2677aaac96", + "y": "0x01170a108c367b21206e409e2f1743cafc4cf377964cbaa7ccf4428912b23e4d" + }, + { + "x": "0x0ede6b52527def2f069498548ed06a3b9eee46bb34918f749baf3d0fc6d61098", + "y": "0x1f6ef988dbe64030a73bb8a223599e3b556c0aae93a19876d2f7ec00fbc3f3b5" + }, + { + "x": "0x192bc8f6ac47ddd0ab619d0e63ccd0fe66c735286df40867d6b9574f93c63afd", + "y": "0x0b2bb80c14652514c25d73d3e06ff2514f54a0acb23d5a9ff01175a9a8cd3e4c" + }, + { + "x": "0x25fa43a898b7eebeb94ba7f89029036ca9d174ddd0c99d8064986cec4005aadc", + "y": "0x00b6ce6cb32876b309c29910c87ded62822ead0fd9d9ee0cbd46f7b1c8e26070" + }, + { + "x": "0x17453a51419471ad84e89f8f8c5c133ee8ecc03f1b64fd2a18d408c8f246733f", + "y": "0x248387e6b1783b38d30c56e51dd9f28e59457320d140eb44ab785b7503b7ac9d" + }, + { + "x": "0x06a1e5a97af94c809a1bcd7695c579d8817d9cc6f9957ca554b278caf2f805bb", + "y": "0x06ff713463dca29d82b4541eefa0db72ddcc4c16f3addbf7e88f20a7d2209f3b" + }, + { + "x": "0x131dc3b211ca86f115f50a42aff15c15ea695ec83521f9f175751d803254dbd3", + "y": "0x1b8e58c7eca2f15b079e6987646fd6d709bf8070e049be01eaa1802998d40705" + }, + { + "x": "0x2afba80562cad359c44656289e0d38817810c6733392ca1d68bc465e9da1fc41", + "y": "0x0f17b90800e4b2c4c3bfe6c2c596c0c896877d0f5f211850d0135874bfb08da8" + } + ], + "format": "ix-bn254-plonk-profile-v1", + "generator": { + "x": "0x1fa4be93b5e7f7e674d5059b63554fab99638b304ed8310e9fa44c281ac9b03b", + "y": "0x1a01ae7fac6228e39d3cb5a5e71fd31160f3241e79a5f48ffb3737e6c389b721" + }, + "matrices": [ + { + "pointOffset": 0, + "rows": 1, + "valueOffset": 1, + "width": 3 + }, + { + "pointOffset": 3, + "rows": 1, + "valueOffset": 4, + "width": 1 + }, + { + "pointOffset": 4, + "rows": 2, + "valueOffset": 5, + "width": 4 + }, + { + "pointOffset": 8, + "rows": 2, + "valueOffset": 13, + "width": 1 + }, + { + "pointOffset": 9, + "rows": 1, + "valueOffset": 15, + "width": 2 + }, + { + "pointOffset": 11, + "rows": 1, + "valueOffset": 17, + "width": 1 + }, + { + "pointOffset": 15, + "rows": 1, + "valueOffset": 18, + "width": 15 + }, + { + "pointOffset": 30, + "rows": 1, + "valueOffset": 33, + "width": 4 + } + ], + "openingCount": 3, + "outerKeys": [ + "0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c212c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b", + "0x2a344fad01c2ed0ed73142ae1752429eaea515c6f3f6b941103cc21c2308e1cb22f1acbb03c4508760c2430af35865e7cdf9f3eb1224504fdcc3708ddb954a480efd30ac7b6f8d0d3ccbc2207587c2acbad1532dc0293f0d034cf8258cd428b3159f15b842ba9c8449aa3268f981010d4c7142e5193473d80b464e964845c3f8" + ], + "pointCount": 15, + "prefix": [ + 0, + 293, + 3008464260, + 3936129970, + 1088962797, + 1141056675, + 2347219926, + 1176003176, + 683533119, + 3085659920 + ], + "profileId": "0x28fc7d40d30aa21f1db718b93a4f471241a02f6c6cfc58989abc5f92a9b8d258", + "program": { + "assertions": [ + 663, + 738 + ], + "coefficients": [ + 1017, + 748, + 755, + 762, + 970, + 978, + 983, + 988, + 1002, + 804, + 811, + 818, + 964, + 998, + 1015, + 825, + 832, + 839, + 846, + 853, + 860, + 867, + 874, + 881, + 888, + 895, + 902, + 909, + 916, + 923, + 930, + 937, + 944, + 951, + 1010 + ], + "nodes": [ + { + "index": 0, + "kind": "challenge", + "op": "input" + }, + { + "index": 1, + "kind": "challenge", + "op": "input" + }, + { + "index": 2, + "kind": "challenge", + "op": "input" + }, + { + "index": 3, + "kind": "challenge", + "op": "input" + }, + { + "index": 4, + "kind": "challenge", + "op": "input" + }, + { + "index": 5, + "kind": "challenge", + "op": "input" + }, + { + "index": 0, + "kind": "value", + "op": "input" + }, + { + "index": 1, + "kind": "value", + "op": "input" + }, + { + "index": 2, + "kind": "value", + "op": "input" + }, + { + "index": 3, + "kind": "value", + "op": "input" + }, + { + "index": 4, + "kind": "value", + "op": "input" + }, + { + "index": 5, + "kind": "value", + "op": "input" + }, + { + "index": 6, + "kind": "value", + "op": "input" + }, + { + "index": 7, + "kind": "value", + "op": "input" + }, + { + "index": 8, + "kind": "value", + "op": "input" + }, + { + "index": 9, + "kind": "value", + "op": "input" + }, + { + "index": 10, + "kind": "value", + "op": "input" + }, + { + "index": 11, + "kind": "value", + "op": "input" + }, + { + "index": 12, + "kind": "value", + "op": "input" + }, + { + "index": 13, + "kind": "value", + "op": "input" + }, + { + "index": 14, + "kind": "value", + "op": "input" + }, + { + "index": 15, + "kind": "value", + "op": "input" + }, + { + "index": 16, + "kind": "value", + "op": "input" + }, + { + "index": 17, + "kind": "value", + "op": "input" + }, + { + "index": 18, + "kind": "value", + "op": "input" + }, + { + "index": 19, + "kind": "value", + "op": "input" + }, + { + "index": 20, + "kind": "value", + "op": "input" + }, + { + "index": 21, + "kind": "value", + "op": "input" + }, + { + "index": 22, + "kind": "value", + "op": "input" + }, + { + "index": 23, + "kind": "value", + "op": "input" + }, + { + "index": 24, + "kind": "value", + "op": "input" + }, + { + "index": 25, + "kind": "value", + "op": "input" + }, + { + "index": 26, + "kind": "value", + "op": "input" + }, + { + "index": 27, + "kind": "value", + "op": "input" + }, + { + "index": 28, + "kind": "value", + "op": "input" + }, + { + "index": 29, + "kind": "value", + "op": "input" + }, + { + "index": 30, + "kind": "value", + "op": "input" + }, + { + "index": 31, + "kind": "value", + "op": "input" + }, + { + "index": 32, + "kind": "value", + "op": "input" + }, + { + "index": 33, + "kind": "value", + "op": "input" + }, + { + "index": 34, + "kind": "value", + "op": "input" + }, + { + "index": 35, + "kind": "value", + "op": "input" + }, + { + "index": 36, + "kind": "value", + "op": "input" + }, + { + "index": 0, + "kind": "public", + "op": "input" + }, + { + "index": 1, + "kind": "public", + "op": "input" + }, + { + "index": 2, + "kind": "public", + "op": "input" + }, + { + "index": 3, + "kind": "public", + "op": "input" + }, + { + "index": 4, + "kind": "public", + "op": "input" + }, + { + "index": 5, + "kind": "public", + "op": "input" + }, + { + "index": 6, + "kind": "public", + "op": "input" + }, + { + "index": 7, + "kind": "public", + "op": "input" + }, + { + "index": 8, + "kind": "public", + "op": "input" + }, + { + "index": 9, + "kind": "public", + "op": "input" + }, + { + "index": 10, + "kind": "public", + "op": "input" + }, + { + "index": 11, + "kind": "public", + "op": "input" + }, + { + "index": 12, + "kind": "public", + "op": "input" + }, + { + "index": 13, + "kind": "public", + "op": "input" + }, + { + "index": 14, + "kind": "public", + "op": "input" + }, + { + "index": 15, + "kind": "public", + "op": "input" + }, + { + "index": 16, + "kind": "public", + "op": "input" + }, + { + "index": 17, + "kind": "public", + "op": "input" + }, + { + "index": 18, + "kind": "public", + "op": "input" + }, + { + "index": 19, + "kind": "public", + "op": "input" + }, + { + "index": 20, + "kind": "public", + "op": "input" + }, + { + "index": 21, + "kind": "public", + "op": "input" + }, + { + "index": 22, + "kind": "public", + "op": "input" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 66, + "b": 1, + "op": "mul" + }, + { + "a": 67, + "b": 43, + "op": "add" + }, + { + "a": 68, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 69, + "b": 70, + "op": "add" + }, + { + "a": 71, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 72, + "b": 73, + "op": "add" + }, + { + "a": 74, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 75, + "b": 76, + "op": "add" + }, + { + "a": 0, + "b": 77, + "op": "add" + }, + { + "a": 78, + "op": "inverse" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 80, + "b": 79, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 82, + "b": 1, + "op": "mul" + }, + { + "a": 83, + "b": 44, + "op": "add" + }, + { + "a": 84, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 85, + "b": 86, + "op": "add" + }, + { + "a": 87, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 88, + "b": 89, + "op": "add" + }, + { + "a": 90, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 91, + "b": 92, + "op": "add" + }, + { + "a": 0, + "b": 93, + "op": "add" + }, + { + "a": 94, + "op": "inverse" + }, + { + "a": 81, + "b": 95, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 97, + "b": 1, + "op": "mul" + }, + { + "a": 98, + "b": 45, + "op": "add" + }, + { + "a": 99, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002" + }, + { + "a": 100, + "b": 101, + "op": "add" + }, + { + "a": 102, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 103, + "b": 104, + "op": "add" + }, + { + "a": 105, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 106, + "b": 107, + "op": "add" + }, + { + "a": 0, + "b": 108, + "op": "add" + }, + { + "a": 109, + "op": "inverse" + }, + { + "a": 96, + "b": 110, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 112, + "b": 1, + "op": "mul" + }, + { + "a": 113, + "b": 46, + "op": "add" + }, + { + "a": 114, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "a": 115, + "b": 116, + "op": "add" + }, + { + "a": 117, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 118, + "b": 119, + "op": "add" + }, + { + "a": 120, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 121, + "b": 122, + "op": "add" + }, + { + "a": 0, + "b": 123, + "op": "add" + }, + { + "a": 124, + "op": "inverse" + }, + { + "a": 111, + "b": 125, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 127, + "b": 1, + "op": "mul" + }, + { + "a": 128, + "b": 47, + "op": "add" + }, + { + "a": 129, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000004" + }, + { + "a": 130, + "b": 131, + "op": "add" + }, + { + "a": 132, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 133, + "b": 134, + "op": "add" + }, + { + "a": 135, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 136, + "b": 137, + "op": "add" + }, + { + "a": 0, + "b": 138, + "op": "add" + }, + { + "a": 139, + "op": "inverse" + }, + { + "a": 126, + "b": 140, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 142, + "b": 1, + "op": "mul" + }, + { + "a": 143, + "b": 48, + "op": "add" + }, + { + "a": 144, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "a": 145, + "b": 146, + "op": "add" + }, + { + "a": 147, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 148, + "b": 149, + "op": "add" + }, + { + "a": 150, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 151, + "b": 152, + "op": "add" + }, + { + "a": 0, + "b": 153, + "op": "add" + }, + { + "a": 154, + "op": "inverse" + }, + { + "a": 141, + "b": 155, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 157, + "b": 1, + "op": "mul" + }, + { + "a": 158, + "b": 49, + "op": "add" + }, + { + "a": 159, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000006" + }, + { + "a": 160, + "b": 161, + "op": "add" + }, + { + "a": 162, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 163, + "b": 164, + "op": "add" + }, + { + "a": 165, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 166, + "b": 167, + "op": "add" + }, + { + "a": 0, + "b": 168, + "op": "add" + }, + { + "a": 169, + "op": "inverse" + }, + { + "a": 156, + "b": 170, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 172, + "b": 1, + "op": "mul" + }, + { + "a": 173, + "b": 50, + "op": "add" + }, + { + "a": 174, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000007" + }, + { + "a": 175, + "b": 176, + "op": "add" + }, + { + "a": 177, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 178, + "b": 179, + "op": "add" + }, + { + "a": 180, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 181, + "b": 182, + "op": "add" + }, + { + "a": 0, + "b": 183, + "op": "add" + }, + { + "a": 184, + "op": "inverse" + }, + { + "a": 171, + "b": 185, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 187, + "b": 1, + "op": "mul" + }, + { + "a": 188, + "b": 51, + "op": "add" + }, + { + "a": 189, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000008" + }, + { + "a": 190, + "b": 191, + "op": "add" + }, + { + "a": 192, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 193, + "b": 194, + "op": "add" + }, + { + "a": 195, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 196, + "b": 197, + "op": "add" + }, + { + "a": 0, + "b": 198, + "op": "add" + }, + { + "a": 199, + "op": "inverse" + }, + { + "a": 186, + "b": 200, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 202, + "b": 1, + "op": "mul" + }, + { + "a": 203, + "b": 52, + "op": "add" + }, + { + "a": 204, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000009" + }, + { + "a": 205, + "b": 206, + "op": "add" + }, + { + "a": 207, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 208, + "b": 209, + "op": "add" + }, + { + "a": 210, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 211, + "b": 212, + "op": "add" + }, + { + "a": 0, + "b": 213, + "op": "add" + }, + { + "a": 214, + "op": "inverse" + }, + { + "a": 201, + "b": 215, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 217, + "b": 1, + "op": "mul" + }, + { + "a": 218, + "b": 53, + "op": "add" + }, + { + "a": 219, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000000a" + }, + { + "a": 220, + "b": 221, + "op": "add" + }, + { + "a": 222, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 223, + "b": 224, + "op": "add" + }, + { + "a": 225, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 226, + "b": 227, + "op": "add" + }, + { + "a": 0, + "b": 228, + "op": "add" + }, + { + "a": 229, + "op": "inverse" + }, + { + "a": 216, + "b": 230, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 232, + "b": 1, + "op": "mul" + }, + { + "a": 233, + "b": 54, + "op": "add" + }, + { + "a": 234, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000000b" + }, + { + "a": 235, + "b": 236, + "op": "add" + }, + { + "a": 237, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 238, + "b": 239, + "op": "add" + }, + { + "a": 240, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 241, + "b": 242, + "op": "add" + }, + { + "a": 0, + "b": 243, + "op": "add" + }, + { + "a": 244, + "op": "inverse" + }, + { + "a": 231, + "b": 245, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 247, + "b": 1, + "op": "mul" + }, + { + "a": 248, + "b": 55, + "op": "add" + }, + { + "a": 249, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000000c" + }, + { + "a": 250, + "b": 251, + "op": "add" + }, + { + "a": 252, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 253, + "b": 254, + "op": "add" + }, + { + "a": 255, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 256, + "b": 257, + "op": "add" + }, + { + "a": 0, + "b": 258, + "op": "add" + }, + { + "a": 259, + "op": "inverse" + }, + { + "a": 246, + "b": 260, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 262, + "b": 1, + "op": "mul" + }, + { + "a": 263, + "b": 56, + "op": "add" + }, + { + "a": 264, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000000d" + }, + { + "a": 265, + "b": 266, + "op": "add" + }, + { + "a": 267, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 268, + "b": 269, + "op": "add" + }, + { + "a": 270, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 271, + "b": 272, + "op": "add" + }, + { + "a": 0, + "b": 273, + "op": "add" + }, + { + "a": 274, + "op": "inverse" + }, + { + "a": 261, + "b": 275, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 277, + "b": 1, + "op": "mul" + }, + { + "a": 278, + "b": 57, + "op": "add" + }, + { + "a": 279, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000000e" + }, + { + "a": 280, + "b": 281, + "op": "add" + }, + { + "a": 282, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 283, + "b": 284, + "op": "add" + }, + { + "a": 285, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 286, + "b": 287, + "op": "add" + }, + { + "a": 0, + "b": 288, + "op": "add" + }, + { + "a": 289, + "op": "inverse" + }, + { + "a": 276, + "b": 290, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 292, + "b": 1, + "op": "mul" + }, + { + "a": 293, + "b": 58, + "op": "add" + }, + { + "a": 294, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000000f" + }, + { + "a": 295, + "b": 296, + "op": "add" + }, + { + "a": 297, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 298, + "b": 299, + "op": "add" + }, + { + "a": 300, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 301, + "b": 302, + "op": "add" + }, + { + "a": 0, + "b": 303, + "op": "add" + }, + { + "a": 304, + "op": "inverse" + }, + { + "a": 291, + "b": 305, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 307, + "b": 1, + "op": "mul" + }, + { + "a": 308, + "b": 59, + "op": "add" + }, + { + "a": 309, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000010" + }, + { + "a": 310, + "b": 311, + "op": "add" + }, + { + "a": 312, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 313, + "b": 314, + "op": "add" + }, + { + "a": 315, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 316, + "b": 317, + "op": "add" + }, + { + "a": 0, + "b": 318, + "op": "add" + }, + { + "a": 319, + "op": "inverse" + }, + { + "a": 306, + "b": 320, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 322, + "b": 1, + "op": "mul" + }, + { + "a": 323, + "b": 60, + "op": "add" + }, + { + "a": 324, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000011" + }, + { + "a": 325, + "b": 326, + "op": "add" + }, + { + "a": 327, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 328, + "b": 329, + "op": "add" + }, + { + "a": 330, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 331, + "b": 332, + "op": "add" + }, + { + "a": 0, + "b": 333, + "op": "add" + }, + { + "a": 334, + "op": "inverse" + }, + { + "a": 321, + "b": 335, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 337, + "b": 1, + "op": "mul" + }, + { + "a": 338, + "b": 61, + "op": "add" + }, + { + "a": 339, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000012" + }, + { + "a": 340, + "b": 341, + "op": "add" + }, + { + "a": 342, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 343, + "b": 344, + "op": "add" + }, + { + "a": 345, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 346, + "b": 347, + "op": "add" + }, + { + "a": 0, + "b": 348, + "op": "add" + }, + { + "a": 349, + "op": "inverse" + }, + { + "a": 336, + "b": 350, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 352, + "b": 1, + "op": "mul" + }, + { + "a": 353, + "b": 62, + "op": "add" + }, + { + "a": 354, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000013" + }, + { + "a": 355, + "b": 356, + "op": "add" + }, + { + "a": 357, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 358, + "b": 359, + "op": "add" + }, + { + "a": 360, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 361, + "b": 362, + "op": "add" + }, + { + "a": 0, + "b": 363, + "op": "add" + }, + { + "a": 364, + "op": "inverse" + }, + { + "a": 351, + "b": 365, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 367, + "b": 1, + "op": "mul" + }, + { + "a": 368, + "b": 63, + "op": "add" + }, + { + "a": 369, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000014" + }, + { + "a": 370, + "b": 371, + "op": "add" + }, + { + "a": 372, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 373, + "b": 374, + "op": "add" + }, + { + "a": 375, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 376, + "b": 377, + "op": "add" + }, + { + "a": 0, + "b": 378, + "op": "add" + }, + { + "a": 379, + "op": "inverse" + }, + { + "a": 366, + "b": 380, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 382, + "b": 1, + "op": "mul" + }, + { + "a": 383, + "b": 64, + "op": "add" + }, + { + "a": 384, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000015" + }, + { + "a": 385, + "b": 386, + "op": "add" + }, + { + "a": 387, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 388, + "b": 389, + "op": "add" + }, + { + "a": 390, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 391, + "b": 392, + "op": "add" + }, + { + "a": 0, + "b": 393, + "op": "add" + }, + { + "a": 394, + "op": "inverse" + }, + { + "a": 381, + "b": 395, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 397, + "b": 1, + "op": "mul" + }, + { + "a": 398, + "b": 65, + "op": "add" + }, + { + "a": 399, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000016" + }, + { + "a": 400, + "b": 401, + "op": "add" + }, + { + "a": 402, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 403, + "b": 404, + "op": "add" + }, + { + "a": 405, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 406, + "b": 407, + "op": "add" + }, + { + "a": 0, + "b": 408, + "op": "add" + }, + { + "a": 409, + "op": "inverse" + }, + { + "a": 396, + "b": 410, + "op": "add" + }, + { + "a": 3, + "op": "inverse" + }, + { + "a": 3, + "b": 3, + "op": "mul" + }, + { + "a": 413, + "b": 413, + "op": "mul" + }, + { + "a": 414, + "b": 414, + "op": "mul" + }, + { + "a": 415, + "b": 415, + "op": "mul" + }, + { + "a": 416, + "b": 416, + "op": "mul" + }, + { + "a": 417, + "b": 417, + "op": "mul" + }, + { + "a": 418, + "b": 418, + "op": "mul" + }, + { + "a": 419, + "b": 419, + "op": "mul" + }, + { + "a": 420, + "b": 420, + "op": "mul" + }, + { + "a": 421, + "b": 421, + "op": "mul" + }, + { + "a": 422, + "b": 422, + "op": "mul" + }, + { + "a": 423, + "b": 423, + "op": "mul" + }, + { + "a": 424, + "b": 424, + "op": "mul" + }, + { + "a": 425, + "b": 425, + "op": "mul" + }, + { + "a": 426, + "b": 426, + "op": "mul" + }, + { + "a": 427, + "b": 427, + "op": "mul" + }, + { + "a": 428, + "b": 428, + "op": "mul" + }, + { + "a": 429, + "b": 429, + "op": "mul" + }, + { + "a": 430, + "b": 430, + "op": "mul" + }, + { + "a": 431, + "b": 431, + "op": "mul" + }, + { + "a": 432, + "b": 432, + "op": "mul" + }, + { + "a": 433, + "b": 433, + "op": "mul" + }, + { + "a": 434, + "b": 434, + "op": "mul" + }, + { + "a": 435, + "b": 435, + "op": "mul" + }, + { + "a": 436, + "b": 436, + "op": "mul" + }, + { + "a": 437, + "b": 437, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 438, + "b": 439, + "op": "sub" + }, + { + "a": 440, + "op": "inverse" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 3, + "b": 442, + "op": "sub" + }, + { + "a": 443, + "op": "inverse" + }, + { + "a": 440, + "b": 444, + "op": "mul" + }, + { + "op": "constant", + "value": "0x1067569af1ff73b20113eff9b8d89d4a605b52b63d68f9ae1c79bd572f4e9212" + }, + { + "a": 3, + "b": 446, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 447, + "b": 448, + "op": "sub" + }, + { + "a": 449, + "op": "inverse" + }, + { + "a": 440, + "b": 450, + "op": "mul" + }, + { + "op": "constant", + "value": "0x1067569af1ff73b20113eff9b8d89d4a605b52b63d68f9ae1c79bd572f4e9212" + }, + { + "a": 451, + "b": 452, + "op": "mul" + }, + { + "op": "constant", + "value": "0x2dcd51d903ca2ef5531f42a56aeb019eac815161aa1b58e21d63cbae232e3983" + }, + { + "a": 3, + "b": 454, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 456, + "b": 35, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 458, + "b": 37, + "op": "sub" + }, + { + "a": 7, + "b": 24, + "op": "mul" + }, + { + "a": 8, + "b": 460, + "op": "mul" + }, + { + "a": 7, + "b": 25, + "op": "mul" + }, + { + "a": 461, + "b": 462, + "op": "add" + }, + { + "a": 8, + "b": 26, + "op": "mul" + }, + { + "a": 463, + "b": 464, + "op": "add" + }, + { + "a": 9, + "b": 27, + "op": "mul" + }, + { + "a": 465, + "b": 466, + "op": "add" + }, + { + "a": 467, + "b": 28, + "op": "add" + }, + { + "a": 6, + "b": 411, + "op": "sub" + }, + { + "op": "constant", + "value": "0x09e9da5bcf94e8473d485ad48f0be1755e41a505ab4ad3db540572a11c042295" + }, + { + "a": 469, + "b": 470, + "op": "mul" + }, + { + "a": 453, + "b": 471, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 473, + "b": 1, + "op": "mul" + }, + { + "a": 474, + "b": 7, + "op": "add" + }, + { + "a": 475, + "b": 1, + "op": "mul" + }, + { + "a": 476, + "b": 29, + "op": "add" + }, + { + "a": 477, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 478, + "b": 479, + "op": "add" + }, + { + "a": 480, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 481, + "b": 482, + "op": "add" + }, + { + "a": 483, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 485, + "b": 1, + "op": "mul" + }, + { + "a": 486, + "b": 7, + "op": "add" + }, + { + "a": 487, + "b": 1, + "op": "mul" + }, + { + "a": 488, + "b": 32, + "op": "add" + }, + { + "a": 489, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 490, + "b": 491, + "op": "add" + }, + { + "a": 492, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 493, + "b": 494, + "op": "add" + }, + { + "a": 495, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 497, + "b": 484, + "op": "mul" + }, + { + "a": 498, + "b": 496, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 496, + "b": 500, + "op": "mul" + }, + { + "a": 484, + "b": 501, + "op": "mul" + }, + { + "a": 12, + "b": 11, + "op": "sub" + }, + { + "a": 499, + "b": 503, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 505, + "b": 501, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 506, + "b": 507, + "op": "mul" + }, + { + "a": 504, + "b": 508, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 498, + "b": 510, + "op": "mul" + }, + { + "op": "constant", + "value": "0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000000" + }, + { + "a": 511, + "b": 512, + "op": "mul" + }, + { + "a": 509, + "b": 513, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 515, + "b": 1, + "op": "mul" + }, + { + "a": 516, + "b": 8, + "op": "add" + }, + { + "a": 517, + "b": 1, + "op": "mul" + }, + { + "a": 518, + "b": 30, + "op": "add" + }, + { + "a": 519, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 520, + "b": 521, + "op": "add" + }, + { + "a": 522, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 523, + "b": 524, + "op": "add" + }, + { + "a": 525, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 527, + "b": 1, + "op": "mul" + }, + { + "a": 528, + "b": 8, + "op": "add" + }, + { + "a": 529, + "b": 1, + "op": "mul" + }, + { + "a": 530, + "b": 33, + "op": "add" + }, + { + "a": 531, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 532, + "b": 533, + "op": "add" + }, + { + "a": 534, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 535, + "b": 536, + "op": "add" + }, + { + "a": 537, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 539, + "b": 526, + "op": "mul" + }, + { + "a": 540, + "b": 538, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 538, + "b": 542, + "op": "mul" + }, + { + "a": 526, + "b": 543, + "op": "mul" + }, + { + "a": 13, + "b": 12, + "op": "sub" + }, + { + "a": 541, + "b": 545, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 547, + "b": 543, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 548, + "b": 549, + "op": "mul" + }, + { + "a": 546, + "b": 550, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 540, + "b": 552, + "op": "mul" + }, + { + "op": "constant", + "value": "0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000000" + }, + { + "a": 553, + "b": 554, + "op": "mul" + }, + { + "a": 551, + "b": 555, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 557, + "b": 1, + "op": "mul" + }, + { + "a": 558, + "b": 9, + "op": "add" + }, + { + "a": 559, + "b": 1, + "op": "mul" + }, + { + "a": 560, + "b": 31, + "op": "add" + }, + { + "a": 561, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 562, + "b": 563, + "op": "add" + }, + { + "a": 564, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 565, + "b": 566, + "op": "add" + }, + { + "a": 567, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 569, + "b": 1, + "op": "mul" + }, + { + "a": 570, + "b": 9, + "op": "add" + }, + { + "a": 571, + "b": 1, + "op": "mul" + }, + { + "a": 572, + "b": 34, + "op": "add" + }, + { + "a": 573, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 574, + "b": 575, + "op": "add" + }, + { + "a": 576, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 577, + "b": 578, + "op": "add" + }, + { + "a": 579, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 581, + "b": 568, + "op": "mul" + }, + { + "a": 582, + "b": 580, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 580, + "b": 584, + "op": "mul" + }, + { + "a": 568, + "b": 585, + "op": "mul" + }, + { + "a": 14, + "b": 13, + "op": "sub" + }, + { + "a": 583, + "b": 587, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 589, + "b": 585, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 590, + "b": 591, + "op": "mul" + }, + { + "a": 588, + "b": 592, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 582, + "b": 594, + "op": "mul" + }, + { + "op": "constant", + "value": "0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000000" + }, + { + "a": 595, + "b": 596, + "op": "mul" + }, + { + "a": 593, + "b": 597, + "op": "sub" + }, + { + "a": 15, + "b": 472, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 600, + "b": 1, + "op": "mul" + }, + { + "a": 601, + "b": 7, + "op": "add" + }, + { + "a": 602, + "b": 1, + "op": "mul" + }, + { + "a": 603, + "b": 36, + "op": "add" + }, + { + "a": 604, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 605, + "b": 606, + "op": "add" + }, + { + "a": 607, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 608, + "b": 609, + "op": "add" + }, + { + "a": 610, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 612, + "b": 1, + "op": "mul" + }, + { + "a": 613, + "b": 9, + "op": "add" + }, + { + "a": 614, + "b": 1, + "op": "mul" + }, + { + "a": 615, + "b": 8, + "op": "add" + }, + { + "a": 616, + "b": 1, + "op": "mul" + }, + { + "a": 617, + "b": 7, + "op": "add" + }, + { + "a": 618, + "b": 1, + "op": "mul" + }, + { + "a": 619, + "b": 38, + "op": "add" + }, + { + "a": 620, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002" + }, + { + "a": 621, + "b": 622, + "op": "add" + }, + { + "a": 623, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 624, + "b": 625, + "op": "add" + }, + { + "a": 626, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 628, + "b": 611, + "op": "mul" + }, + { + "a": 629, + "b": 627, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 627, + "b": 631, + "op": "mul" + }, + { + "a": 611, + "b": 632, + "op": "mul" + }, + { + "a": 599, + "b": 14, + "op": "sub" + }, + { + "a": 630, + "b": 634, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 636, + "b": 632, + "op": "mul" + }, + { + "a": 637, + "b": 457, + "op": "mul" + }, + { + "a": 635, + "b": 638, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 629, + "b": 640, + "op": "mul" + }, + { + "a": 641, + "b": 459, + "op": "mul" + }, + { + "a": 639, + "b": 642, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 644, + "b": 2, + "op": "mul" + }, + { + "a": 645, + "b": 468, + "op": "add" + }, + { + "a": 646, + "b": 2, + "op": "mul" + }, + { + "a": 647, + "b": 514, + "op": "add" + }, + { + "a": 648, + "b": 2, + "op": "mul" + }, + { + "a": 649, + "b": 556, + "op": "add" + }, + { + "a": 650, + "b": 2, + "op": "mul" + }, + { + "a": 651, + "b": 598, + "op": "add" + }, + { + "a": 652, + "b": 2, + "op": "mul" + }, + { + "a": 653, + "b": 643, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 440, + "b": 655, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 657, + "b": 656, + "op": "mul" + }, + { + "a": 658, + "b": 22, + "op": "add" + }, + { + "a": 659, + "b": 656, + "op": "mul" + }, + { + "a": 660, + "b": 21, + "op": "add" + }, + { + "a": 440, + "b": 661, + "op": "mul" + }, + { + "a": 654, + "b": 662, + "op": "sub" + }, + { + "a": 3, + "b": 3, + "op": "mul" + }, + { + "a": 664, + "b": 664, + "op": "mul" + }, + { + "a": 665, + "b": 665, + "op": "mul" + }, + { + "a": 666, + "b": 666, + "op": "mul" + }, + { + "a": 667, + "b": 667, + "op": "mul" + }, + { + "a": 668, + "b": 668, + "op": "mul" + }, + { + "a": 669, + "b": 669, + "op": "mul" + }, + { + "a": 670, + "b": 670, + "op": "mul" + }, + { + "a": 671, + "b": 671, + "op": "mul" + }, + { + "a": 672, + "b": 672, + "op": "mul" + }, + { + "a": 673, + "b": 673, + "op": "mul" + }, + { + "a": 674, + "b": 674, + "op": "mul" + }, + { + "a": 675, + "b": 675, + "op": "mul" + }, + { + "a": 676, + "b": 676, + "op": "mul" + }, + { + "a": 677, + "b": 677, + "op": "mul" + }, + { + "a": 678, + "b": 678, + "op": "mul" + }, + { + "a": 679, + "b": 679, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 680, + "b": 681, + "op": "sub" + }, + { + "a": 682, + "op": "inverse" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 3, + "b": 684, + "op": "sub" + }, + { + "a": 685, + "op": "inverse" + }, + { + "a": 682, + "b": 686, + "op": "mul" + }, + { + "op": "constant", + "value": "0x1bf82deba7d74902c3708cc6e70e61f30512eca95655210e276e5858ce8f58e5" + }, + { + "a": 3, + "b": 688, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 689, + "b": 690, + "op": "sub" + }, + { + "a": 691, + "op": "inverse" + }, + { + "a": 682, + "b": 692, + "op": "mul" + }, + { + "op": "constant", + "value": "0x1bf82deba7d74902c3708cc6e70e61f30512eca95655210e276e5858ce8f58e5" + }, + { + "a": 693, + "b": 694, + "op": "mul" + }, + { + "op": "constant", + "value": "0x244cf010c43ca87237d8b00bf9dd50c4c01c7f086bd4e8c920e75251d96f0d22" + }, + { + "a": 3, + "b": 696, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 698, + "b": 6, + "op": "sub" + }, + { + "op": "constant", + "value": "0x16f476b8c8487b567e6df7add55e46c800c7575a3879a386ec76cc8010bdfcb8" + }, + { + "a": 699, + "b": 700, + "op": "mul" + }, + { + "a": 695, + "b": 701, + "op": "mul" + }, + { + "a": 20, + "b": 702, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 704, + "b": 1, + "op": "mul" + }, + { + "a": 705, + "b": 42, + "op": "add" + }, + { + "a": 706, + "b": 1, + "op": "mul" + }, + { + "a": 707, + "b": 41, + "op": "add" + }, + { + "a": 708, + "b": 1, + "op": "mul" + }, + { + "a": 709, + "b": 40, + "op": "add" + }, + { + "a": 710, + "b": 1, + "op": "mul" + }, + { + "a": 711, + "b": 39, + "op": "add" + }, + { + "a": 712, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002" + }, + { + "a": 713, + "b": 714, + "op": "add" + }, + { + "a": 715, + "b": 1, + "op": "mul" + }, + { + "op": "constant", + "value": "0x000000000000000000000000000000000000000000000000000000000000005e" + }, + { + "a": 716, + "b": 717, + "op": "add" + }, + { + "a": 718, + "b": 0, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 720, + "b": 719, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 719, + "b": 722, + "op": "mul" + }, + { + "a": 703, + "b": 19, + "op": "sub" + }, + { + "a": 721, + "b": 724, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 726, + "b": 10, + "op": "mul" + }, + { + "a": 725, + "b": 727, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 729, + "b": 2, + "op": "mul" + }, + { + "a": 730, + "b": 728, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 682, + "b": 732, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 734, + "b": 733, + "op": "mul" + }, + { + "a": 735, + "b": 23, + "op": "add" + }, + { + "a": 682, + "b": 736, + "op": "mul" + }, + { + "a": 731, + "b": 737, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 7, + "b": 739, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 741, + "b": 740, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 743, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 745, + "b": 744, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 747, + "b": 746, + "op": "add" + }, + { + "a": 8, + "b": 744, + "op": "mul" + }, + { + "a": 742, + "b": 749, + "op": "add" + }, + { + "a": 744, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 752, + "b": 751, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 754, + "b": 753, + "op": "add" + }, + { + "a": 9, + "b": 751, + "op": "mul" + }, + { + "a": 750, + "b": 756, + "op": "add" + }, + { + "a": 751, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 759, + "b": 758, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 761, + "b": 760, + "op": "add" + }, + { + "a": 10, + "b": 758, + "op": "mul" + }, + { + "a": 757, + "b": 763, + "op": "add" + }, + { + "a": 758, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 766, + "b": 765, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 768, + "b": 767, + "op": "add" + }, + { + "a": 11, + "b": 765, + "op": "mul" + }, + { + "a": 764, + "b": 770, + "op": "add" + }, + { + "a": 765, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 773, + "b": 772, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 775, + "b": 774, + "op": "add" + }, + { + "a": 12, + "b": 772, + "op": "mul" + }, + { + "a": 771, + "b": 777, + "op": "add" + }, + { + "a": 772, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 780, + "b": 779, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 782, + "b": 781, + "op": "add" + }, + { + "a": 13, + "b": 779, + "op": "mul" + }, + { + "a": 778, + "b": 784, + "op": "add" + }, + { + "a": 779, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 787, + "b": 786, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 789, + "b": 788, + "op": "add" + }, + { + "a": 14, + "b": 786, + "op": "mul" + }, + { + "a": 785, + "b": 791, + "op": "add" + }, + { + "a": 786, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 794, + "b": 793, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 796, + "b": 795, + "op": "add" + }, + { + "a": 19, + "b": 793, + "op": "mul" + }, + { + "a": 792, + "b": 798, + "op": "add" + }, + { + "a": 793, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 801, + "b": 800, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 803, + "b": 802, + "op": "add" + }, + { + "a": 21, + "b": 800, + "op": "mul" + }, + { + "a": 799, + "b": 805, + "op": "add" + }, + { + "a": 800, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 808, + "b": 807, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 810, + "b": 809, + "op": "add" + }, + { + "a": 22, + "b": 807, + "op": "mul" + }, + { + "a": 806, + "b": 812, + "op": "add" + }, + { + "a": 807, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 815, + "b": 814, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 817, + "b": 816, + "op": "add" + }, + { + "a": 23, + "b": 814, + "op": "mul" + }, + { + "a": 813, + "b": 819, + "op": "add" + }, + { + "a": 814, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 822, + "b": 821, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 824, + "b": 823, + "op": "add" + }, + { + "a": 24, + "b": 821, + "op": "mul" + }, + { + "a": 820, + "b": 826, + "op": "add" + }, + { + "a": 821, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 829, + "b": 828, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 831, + "b": 830, + "op": "add" + }, + { + "a": 25, + "b": 828, + "op": "mul" + }, + { + "a": 827, + "b": 833, + "op": "add" + }, + { + "a": 828, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 836, + "b": 835, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 838, + "b": 837, + "op": "add" + }, + { + "a": 26, + "b": 835, + "op": "mul" + }, + { + "a": 834, + "b": 840, + "op": "add" + }, + { + "a": 835, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 843, + "b": 842, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 845, + "b": 844, + "op": "add" + }, + { + "a": 27, + "b": 842, + "op": "mul" + }, + { + "a": 841, + "b": 847, + "op": "add" + }, + { + "a": 842, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 850, + "b": 849, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 852, + "b": 851, + "op": "add" + }, + { + "a": 28, + "b": 849, + "op": "mul" + }, + { + "a": 848, + "b": 854, + "op": "add" + }, + { + "a": 849, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 857, + "b": 856, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 859, + "b": 858, + "op": "add" + }, + { + "a": 29, + "b": 856, + "op": "mul" + }, + { + "a": 855, + "b": 861, + "op": "add" + }, + { + "a": 856, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 864, + "b": 863, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 866, + "b": 865, + "op": "add" + }, + { + "a": 30, + "b": 863, + "op": "mul" + }, + { + "a": 862, + "b": 868, + "op": "add" + }, + { + "a": 863, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 871, + "b": 870, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 873, + "b": 872, + "op": "add" + }, + { + "a": 31, + "b": 870, + "op": "mul" + }, + { + "a": 869, + "b": 875, + "op": "add" + }, + { + "a": 870, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 878, + "b": 877, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 880, + "b": 879, + "op": "add" + }, + { + "a": 32, + "b": 877, + "op": "mul" + }, + { + "a": 876, + "b": 882, + "op": "add" + }, + { + "a": 877, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 885, + "b": 884, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 887, + "b": 886, + "op": "add" + }, + { + "a": 33, + "b": 884, + "op": "mul" + }, + { + "a": 883, + "b": 889, + "op": "add" + }, + { + "a": 884, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 892, + "b": 891, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 894, + "b": 893, + "op": "add" + }, + { + "a": 34, + "b": 891, + "op": "mul" + }, + { + "a": 890, + "b": 896, + "op": "add" + }, + { + "a": 891, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 899, + "b": 898, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 901, + "b": 900, + "op": "add" + }, + { + "a": 35, + "b": 898, + "op": "mul" + }, + { + "a": 897, + "b": 903, + "op": "add" + }, + { + "a": 898, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 906, + "b": 905, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 908, + "b": 907, + "op": "add" + }, + { + "a": 36, + "b": 905, + "op": "mul" + }, + { + "a": 904, + "b": 910, + "op": "add" + }, + { + "a": 905, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 913, + "b": 912, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 915, + "b": 914, + "op": "add" + }, + { + "a": 37, + "b": 912, + "op": "mul" + }, + { + "a": 911, + "b": 917, + "op": "add" + }, + { + "a": 912, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 920, + "b": 919, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 922, + "b": 921, + "op": "add" + }, + { + "a": 38, + "b": 919, + "op": "mul" + }, + { + "a": 918, + "b": 924, + "op": "add" + }, + { + "a": 919, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 927, + "b": 926, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 929, + "b": 928, + "op": "add" + }, + { + "a": 39, + "b": 926, + "op": "mul" + }, + { + "a": 925, + "b": 931, + "op": "add" + }, + { + "a": 926, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 934, + "b": 933, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 936, + "b": 935, + "op": "add" + }, + { + "a": 40, + "b": 933, + "op": "mul" + }, + { + "a": 932, + "b": 938, + "op": "add" + }, + { + "a": 933, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 941, + "b": 940, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 943, + "b": 942, + "op": "add" + }, + { + "a": 41, + "b": 940, + "op": "mul" + }, + { + "a": 939, + "b": 945, + "op": "add" + }, + { + "a": 940, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 948, + "b": 947, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 950, + "b": 949, + "op": "add" + }, + { + "a": 42, + "b": 947, + "op": "mul" + }, + { + "a": 946, + "b": 952, + "op": "add" + }, + { + "a": 947, + "b": 4, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 955, + "b": 953, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 957, + "b": 956, + "op": "sub" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 959, + "b": 3, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 960, + "b": 961, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 963, + "b": 962, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 966, + "b": 5, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 967, + "b": 968, + "op": "mul" + }, + { + "a": 769, + "b": 969, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 15, + "b": 971, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 973, + "b": 972, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 975, + "b": 4, + "op": "mul" + }, + { + "a": 967, + "b": 976, + "op": "mul" + }, + { + "a": 776, + "b": 977, + "op": "add" + }, + { + "a": 16, + "b": 976, + "op": "mul" + }, + { + "a": 974, + "b": 979, + "op": "add" + }, + { + "a": 976, + "b": 4, + "op": "mul" + }, + { + "a": 967, + "b": 981, + "op": "mul" + }, + { + "a": 783, + "b": 982, + "op": "add" + }, + { + "a": 17, + "b": 981, + "op": "mul" + }, + { + "a": 980, + "b": 984, + "op": "add" + }, + { + "a": 981, + "b": 4, + "op": "mul" + }, + { + "a": 967, + "b": 986, + "op": "mul" + }, + { + "a": 790, + "b": 987, + "op": "add" + }, + { + "a": 18, + "b": 986, + "op": "mul" + }, + { + "a": 985, + "b": 989, + "op": "add" + }, + { + "a": 986, + "b": 4, + "op": "mul" + }, + { + "a": 967, + "b": 990, + "op": "mul" + }, + { + "a": 958, + "b": 992, + "op": "sub" + }, + { + "a": 967, + "b": 3, + "op": "mul" + }, + { + "op": "constant", + "value": "0x1067569af1ff73b20113eff9b8d89d4a605b52b63d68f9ae1c79bd572f4e9212" + }, + { + "a": 994, + "b": 995, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 997, + "b": 996, + "op": "add" + }, + { + "a": 967, + "b": 5, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 999, + "b": 1000, + "op": "mul" + }, + { + "a": 797, + "b": 1001, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 20, + "b": 1003, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 1005, + "b": 1004, + "op": "add" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "a": 1007, + "b": 4, + "op": "mul" + }, + { + "a": 999, + "b": 1006, + "op": "mul" + }, + { + "a": 993, + "b": 1009, + "op": "sub" + }, + { + "a": 999, + "b": 3, + "op": "mul" + }, + { + "op": "constant", + "value": "0x1bf82deba7d74902c3708cc6e70e61f30512eca95655210e276e5858ce8f58e5" + }, + { + "a": 1011, + "b": 1012, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "a": 1014, + "b": 1013, + "op": "add" + }, + { + "a": 999, + "b": 5, + "op": "mul" + }, + { + "op": "constant", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + ], + "witnesses": [ + 965, + 967, + 999 + ] + }, + "publicMaxDegree": 100799999, + "seed": "0x6d756c74692d737461726b2f6b7a672f626e3235342f626c616b65332f76311a000000000000000200000000000000ff1502060000000007f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b3bb0c91a284ca49f0e31d84e308b6399ab4f55639b05d574e6f7e7b593bea49fedf692d95cbdde46ddda5ef7d422436779445c5e66006a42761e1f12efde0018c212f3aeb785e49712e7a9353349aaf1255dfb31b7bf60723a480d9293938e19098493b799904a0b1919123a0c9434d2c133c78e655f1588a6a8c2faf1eb689fcbe108231cc23c1041b9f6f3c615a5ae9e425217ae4231d70eedc201ad4f342a484a95db8d70c3dc4f502412ebf3f9cde76558f30a43c2608750c403bbacf122", + "setupId": "0x07f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b", + "witnessOffset": 12 +} \ No newline at end of file diff --git a/tools/bls-plonk-wrapper/testdata/bn254/provenance.json b/tools/bls-plonk-wrapper/testdata/bn254/provenance.json new file mode 100644 index 000000000..901b13e4e --- /dev/null +++ b/tools/bls-plonk-wrapper/testdata/bn254/provenance.json @@ -0,0 +1,17 @@ +{ + "format": "ix-bn254-plonk-fixture-provenance-v1", + "source": "Actual Init recursive KZG proof with BLAKE3 and admitted Ignition parameters", + "innerVerifierBLAKE3": "a4cf1b3b2489af6c939847d5060452bccb1f87ed8cff0fb90658e477549eafcd", + "ignitionAdmissionBLAKE3": "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", + "originalClaimBLAKE3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", + "srsDomain": 128, + "srsPowers": 131, + "scope": "The witness covers the full Init verifier relation. The small SRS is only for backend and basis-conversion tests, not for proving the full wrapper.", + "files": { + "profile.json": "7f10e1e87329b57d148c915f38e28bc777988b7df8ead7e88fab5ad96b62a991", + "srs.bin": "5eb78499c386ce2dd170f773eaf6043741c83b9ddbb7c2e463f20532cbac6a29", + "srs.json": "2693bbb8f0c54af048915738afed28dda6ff6262550653404102e2391a947c78", + "witness-diagnostic.json": "83b65ceb2b2a20de824b0e4968ba6e85ec408e47eab49ba5be9d8f4de8733787", + "witness.json": "2034aff3138764b878280bb210dc15dadff693f8753f35a5be1fcc73d9ac06b3" + } +} diff --git a/tools/bls-plonk-wrapper/testdata/bn254/srs.bin b/tools/bls-plonk-wrapper/testdata/bn254/srs.bin new file mode 100644 index 0000000000000000000000000000000000000000..46485ed01f2f78061857303abd852e58314fa367 GIT binary patch literal 8728 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 Date: Tue, 27 Oct 2026 20:52:09 +0000 Subject: [PATCH 06/31] perf(proof-compression): hoist query-invariant alpha work out of the FRI opening reduction The in-circuit FRI verifier recomputed, for every query and every opened column, the alpha power and the product with the shared opening denominator: three extension multiplications per column, two of which depend only on the claims. Fold the per-height alpha ladder and the alpha-weighted out-of-domain sums once before the query loop, so each query pays one extension-by-base product per opened column and one denominator product per (matrix, point). This ports the "hoist the query-invariant alpha work out of open_inputs" step of Plonky3 PR #2015 (commit a724a1aec0acab47384b34efc2a4d163e0351251) from the native verifier to the circuit. The reduced value, the alpha index of every column, the transcript and the proof bytes are unchanged; the circuit changes, so first-stage profiles and fixed preprocessing regenerate. Sized on the checked-in Init FRI key, the translated BN254 frontend goes from 1,125,583 to 554,220 gates and 312,920 to 141,070 lookups per query, 113.9M to 57.2M gates at 100 queries, and the first stage lowers to five 2^24 computation partitions instead of nine. Measured on one RTX PRO 6000 with development parameters, the warm first KZG stage went from 135.2 s to 92.2 s and peak RSS from 175.3 GiB to 114.4 GiB; the recursive stage natively verified the resulting proof and was unchanged at 76 s. --- crates/proof-compression/src/fri/pcs.rs | 63 +++++++++++++++++++------ 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/crates/proof-compression/src/fri/pcs.rs b/crates/proof-compression/src/fri/pcs.rs index 7422d5e78..63108937a 100644 --- a/crates/proof-compression/src/fri/pcs.rs +++ b/crates/proof-compression/src/fri/pcs.rs @@ -424,6 +424,41 @@ pub(super) fn constrain_pcs( let zeta = algebra.challenges.zeta; let zero = Q::constant(b, [Val::ZERO; 2]); let one = Q::constant(b, [Val::ONE, Val::ZERO]); + // Alpha powers advance per height in (batch, matrix, point, column) + // order and do not depend on the query, nor do the alpha-weighted sums of + // the out-of-domain evaluations. Both are folded once here; each query + // then weights only its own opened row and pays one denominator product + // per (matrix, point) instead of one per value. This is the in-circuit + // form of the hoisted alpha ladder in Plonky3's native FRI verifier + // (PR #2015, commit a724a1aec0acab47384b34efc2a4d163e0351251); the + // reduced value and the alpha index of every column are unchanged. + let mut alpha_powers: BTreeMap> = BTreeMap::new(); + let mut cursors: BTreeMap = BTreeMap::new(); + let mut ood_terms: Vec>> = Vec::new(); + for batch in 0..4 { + let mut per_matrix = Vec::new(); + for matrix in 0..shape.widths[batch].len() { + let height = shape.heights[batch][matrix]; + let powers = alpha_powers.entry(height).or_insert_with(|| vec![one]); + let mut per_point = Vec::new(); + for row in opening_rows(algebra, shape, batch, matrix) { + let start = cursors.get(&height).copied().unwrap_or(0); + let mut weighted = zero; + for (j, &at_z) in row.iter().enumerate() { + while powers.len() <= start + j { + let next = powers[powers.len() - 1].mul(b, alpha); + powers.push(next); + } + let term = powers[start + j].mul(b, at_z); + weighted = weighted.add(b, term); + } + cursors.insert(height, start + row.len()); + per_point.push((start, weighted)); + } + per_matrix.push(per_point); + } + ood_terms.push(per_matrix); + } for (offset, query) in pcs.queries.iter().enumerate() { let query_index = pcs.query_start + offset; let bits = &queries[query_index]; @@ -468,26 +503,26 @@ pub(super) fn constrain_pcs( ); for (matrix, opened) in query.input_rows[batch].iter().enumerate() { let height = shape.heights[batch][matrix]; - let (alpha_power, reduced) = - reductions.entry(height).or_insert((one, zero)); - for (point, row) in - opening_rows(algebra, shape, batch, matrix).iter().enumerate() + let powers = &alpha_powers[&height]; + let reduced = reductions.entry(height).or_insert(zero); + for (point, &(start, weighted)) in + ood_terms[batch][matrix].iter().enumerate() { - for (&at_x, &at_z) in opened.iter().zip(*row) { - let at_x = Q::from_base(b, at_x); - let difference = at_z.sub(b, at_x); - let term = difference.mul(b, denominators[&height][point]); - let term = alpha_power.mul(b, term); - *reduced = reduced.add(b, term); - *alpha_power = alpha_power.mul(b, alpha); + let mut at_x = zero; + for (j, &x) in opened.iter().enumerate() { + let term = powers[start + j].mul_base(b, x); + at_x = at_x.add(b, term); } + let difference = weighted.sub(b, at_x); + let term = difference.mul(b, denominators[&height][point]); + *reduced = reduced.add(b, term); } } } - if let Some((_, constant)) = reductions.get(&system.config.log_blowup()) { + if let Some(constant) = reductions.get(&system.config.log_blowup()) { constant.assert_equal(b, zero); } - let mut folded = reductions[&shape.log_lde].1; + let mut folded = reductions[&shape.log_lde]; for (round, &beta) in betas.iter().enumerate() { let sibling = query.fri_siblings[round]; let lo = Q::select(b, bits[round], sibling, folded); @@ -513,7 +548,7 @@ pub(super) fn constrain_pcs( lo.sub(b, hi).mul_base(b, inv_x).scale(b, Val::ONE.halve()); let odd = difference.mul(b, beta); folded = sum.add(b, odd); - if let Some((_, reduced)) = reductions.get(&(shape.log_lde - round - 1)) { + if let Some(reduced) = reductions.get(&(shape.log_lde - round - 1)) { let beta_squared = beta.mul(b, beta); let injected = beta_squared.mul(b, *reduced); folded = folded.add(b, injected); From bd2be70ddbbee5f342910fd1838d8f4daac9656b Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:53:04 +0000 Subject: [PATCH 07/31] docs(compression): document stage-collapse sizing Record first-stage and recursive costs, direct-wrapper sizing, warm Ignition measurements, and gadget and lazy-nibble results in docs/compression-stage-collapse.md. Add the FRI sizing example and the FRI security-estimation script used for those figures. Require optimizations to preserve the current proven security bound. --- .../proof-compression/examples/fri_sizing.rs | 237 +++++++ docs/compression-stage-collapse.md | 615 ++++++++++++++++++ tests/fri_security_estimate.py | 192 ++++++ 3 files changed, 1044 insertions(+) create mode 100644 crates/proof-compression/examples/fri_sizing.rs create mode 100644 docs/compression-stage-collapse.md create mode 100644 tests/fri_security_estimate.py diff --git a/crates/proof-compression/examples/fri_sizing.rs b/crates/proof-compression/examples/fri_sizing.rs new file mode 100644 index 000000000..3c58a6387 --- /dev/null +++ b/crates/proof-compression/examples/fri_sizing.rs @@ -0,0 +1,237 @@ +//! Size the first KZG stage's FRI verifier for alternative query counts +//! without a proof: the verifier relation depends only on the key, the +//! profile and the FRI parameters, so a saved key can be re-parameterized +//! and counted directly. Arity and cap changes are not supported by the +//! in-circuit verifier and cannot be sized this way. +//! +//! Usage: fri_sizing [queries,...] [log-height] + +use ix_proof_compression::foreign::GoldilocksCircuit; +use ix_proof_compression::fri::{ + DEFAULT_FIELD_RETRIES, Envelope, ImplementationOptions, ProofProfile, + Statement, StatementSlot, VerifierKey, VerifierLimits, VerifierPlan, +}; +use multi_stark::config::StarkGenericConfig; +use multi_stark::system::{Circuit, System}; +use multi_stark::traits::Field; +use multi_stark::types::{ + CommitmentParameters, FriParameters, GoldilocksBlake3Config, Val, +}; +use p3_field::PrimeField64; +use std::{fs, path::Path, time::Instant}; + +type Result = std::result::Result>; + +fn decode_claims(bytes: &[u8]) -> Result>> { + let (words, remainder) = bytes.as_chunks::<8>(); + if !remainder.is_empty() { + return Err("unaligned claims".into()); + } + let mut words = words.iter().map(|w| u64::from_le_bytes(*w)); + let n = words.next().ok_or("missing claims count")?; + let mut claims = Vec::new(); + for _ in 0..n { + let len = words.next().ok_or("missing claim length")?; + let mut claim = Vec::new(); + for _ in 0..len { + let v = words.next().ok_or("missing claim value")?; + if v >= Val::ORDER_U64 { + return Err("noncanonical claim".into()); + } + claim.push(Val::from_u64(v)); + } + claims.push(claim); + } + Ok(claims) +} + +/// Shape overrides for cost attribution: `FRI_SIZING_MAX_LOG` caps every +/// trace height, `FRI_SIZING_DROP` lists circuit indices to omit, and +/// `FRI_SIZING_SET` assigns `index:log_height:main_width` per circuit. +fn with_queries(key: &VerifierKey, queries: usize) -> VerifierKey { + let system = key.system(); + let fri = system.config.fri_parameters(); + let max_log: Option = std::env::var("FRI_SIZING_MAX_LOG") + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v.parse().unwrap()); + let drop: Vec = std::env::var("FRI_SIZING_DROP") + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v.split(',').map(|i| i.parse().unwrap()).collect()) + .unwrap_or_default(); + let set: std::collections::HashMap = + std::env::var("FRI_SIZING_SET") + .ok() + .filter(|v| !v.is_empty()) + .map(|v| { + v.split(',') + .map(|item| { + let parts: Vec = + item.split(':').map(|x| x.parse().unwrap()).collect(); + (parts[0], (u32::try_from(parts[1]).unwrap(), parts[2])) + }) + .collect() + }) + .unwrap_or_default(); + let circuits: Vec<_> = system + .circuits + .iter() + .enumerate() + .filter(|(i, _)| !drop.contains(i)) + .map(|(i, c)| Circuit { + graph: c.graph.clone(), + main_width: set.get(&i).map_or(c.main_width, |&(_, w)| w), + preprocessed: None, + preprocessed_width: c.preprocessed_width, + preprocessed_height: set.get(&i).map_or_else( + || { + max_log.map_or(c.preprocessed_height, |m| { + c.preprocessed_height.min(1 << m) + }) + }, + |&(log, _)| 1 << log, + ), + num_lookups: c.num_lookups, + stage_2_width: c.stage_2_width, + num_publics: c.num_publics, + lookup_group_size: c.lookup_group_size, + constraint_count: c.constraint_count, + max_constraint_degree: c.max_constraint_degree, + }) + .collect(); + VerifierKey::new(System { + config: GoldilocksBlake3Config::new( + CommitmentParameters { + log_blowup: system.config.log_blowup(), + cap_height: system.config.cap_height(), + }, + FriParameters { num_queries: queries, ..fri }, + ), + preprocessed_indices: (0..circuits.len()).map(Some).collect(), + circuits, + preprocessed_commit: system.preprocessed_commit.clone(), + }) +} + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let dir = Path::new( + args + .get(1) + .ok_or("usage: fri_sizing [queries,...] [log-height]")?, + ); + let queries: Vec = args + .get(2) + .map(|s| s.split(',').map(str::parse).collect()) + .transpose()? + .unwrap_or_else(|| vec![1, 2, 40, 60, 80, 100]); + let log_height: u32 = + args.get(3).map(|s| s.parse()).transpose()?.unwrap_or(24); + let key = VerifierKey::from_bytes(&fs::read(dir.join("outer-vk.bin"))?)?; + let claims = decode_claims(&fs::read(dir.join("outer-claims.bin"))?)?; + // The saved statement exposes 18 public words at rows 1..=18, column 3; + // every other claim value is a circuit constant. + let schema = Statement { + claims: claims + .iter() + .enumerate() + .map(|(row, values)| { + values + .iter() + .enumerate() + .map(|(col, &v)| { + if (1..=18).contains(&row) && col == 3 { + StatementSlot::Public + } else { + StatementSlot::Constant(v) + } + }) + .collect() + }) + .collect(), + messages: vec![], + }; + let base = key.system().config.fri_parameters(); + eprintln!( + "key: {} circuits, log_blowup {}, queries {}, query_pow {}, arity {}", + key.system().circuits.len(), + key.system().config.log_blowup(), + base.num_queries, + base.query_proof_of_work_bits, + base.max_log_arity + ); + for &q in &queries { + let start = Instant::now(); + let key = with_queries(&key, q); + let profile = ProofProfile { + envelope: Envelope::Ordinary, + active: vec![true; key.system().circuits.len()], + log_degrees: key + .system() + .circuits + .iter() + .map(|c| u8::try_from(c.preprocessed_height.ilog2()).unwrap()) + .collect(), + claim_lengths: claims.iter().map(Vec::len).collect(), + message_lengths: vec![], + max_field_retries: DEFAULT_FIELD_RETRIES, + }; + let plan = + VerifierPlan::validate(&key, profile, VerifierLimits::default())?; + let resources = plan.resources(); + let (circuit, _inputs) = + plan.build(schema.clone(), ImplementationOptions::default())?; + let source = circuit.stats(); + let layout = circuit.multi_stark_layout_with_max_height(1 << log_height)?; + let scalar = GoldilocksCircuit::estimate(&circuit); + let scalar_rows = scalar.gates + scalar.lookups + scalar.publics + 1; + // `FRI_SIZING_LOWER` materializes the translated circuit and lays it + // out as the first stage does, which costs tens of GiB at full size. + let scalar_layout = if std::env::var_os("FRI_SIZING_LOWER").is_some() { + let translated = GoldilocksCircuit::new_preallocated(&circuit); + let layout = translated + .circuit + .multi_stark_layout_with_max_height(1 << log_height)?; + Some(serde_json::json!({ + "used_rows": layout.used_rows, + "main_heights": layout.main_heights, + "main_width": layout.main_width, + "preprocessed_width": layout.preprocessed_width, + "table_heights": layout.table_heights, + "custom_traces": layout.custom_traces, + "advice_cells": layout.advice_cells, + "preprocessed_cells": layout.preprocessed_cells, + "trace_field_bytes": layout.trace_field_bytes, + })) + } else { + None + }; + let report = serde_json::json!({ + "queries": q, + "proof_values": resources.proof_values, + "source": { + "gates": source.gates, "lookups": source.lookups, "values": source.values, + "hash_calls": source.hash_calls, "hash_compressions": source.hash_compressions, + "tables": source.tables, + }, + "source_layout": { + "used_rows": layout.used_rows, + "main_heights": layout.main_heights, + "main_width": layout.main_width, + "table_heights": layout.table_heights, + "custom_traces": layout.custom_traces, + }, + "scalar": { + "gates": scalar.gates, "lookups": scalar.lookups, "values": scalar.values, + "hash_calls": scalar.hash_calls, "hash_compressions": scalar.hash_compressions, + "generic_rows_without_hash_bridge": scalar_rows, + "generic_partitions_lower_bound": scalar_rows.div_ceil(1 << log_height), + }, + "scalar_layout": scalar_layout, + "elapsed_seconds": start.elapsed().as_secs_f64(), + }); + println!("{report}"); + } + Ok(()) +} diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md new file mode 100644 index 000000000..cb970fdb1 --- /dev/null +++ b/docs/compression-stage-collapse.md @@ -0,0 +1,615 @@ +# Collapsing the recursive KZG stage and the PLONK wrapper + +Review of the BN254 KZG → PLONK pipeline on `sb/bn254`, with sizing of the +FRI-retune alternative and a survey of upstream Plonky3 since the pinned +revision. Measurements are the branch's own benchmark records unless stated +otherwise (October 2026, one RTX PRO 6000 Blackwell, 48 logical CPUs). + +## Summary + +- **Security requirement.** Preserve the current proven security bound. + Performance improvements and stage removal must not lower it; preserving + only a conjectured bound is insufficient. +- **Not as one proof, on the available evidence.** Scaling the wrapper's + measured component costs to the first KZG proof gives 250M to 385M + constraints, and even its variable-base MSM terms alone exceed the 67M + rows that gnark PLONK on BN254 can reach (`4N` must fit the field's `2^28` + two-adicity). This is an extrapolation; the compile-and-size gate the + architecture doc asks for remains the way to close it. +- **Yes as one process, with a bounded win.** A persistent GPU worker holding + both stages is estimated to remove about 30 to 35 s of a 212 s pipeline; + the retained-worker path has not been measured. +- **The real collapse is upstream.** Shrinking the first KZG stage until its + proof can be wrapped directly removes the recursive stage. Sizing from the + checked-in Init key shows the first stage's cost is almost entirely per + query, and that 65% of it was an opening-reduction implementation that + rebuilt shared products per query. Restructuring that reduction (patch in + the working tree, verifier tests passing) halves the translated circuit: + at 100 queries the lowered layout drops from nine `2^24` computation + partitions to five, or three at `2^25`; at 40 queries it fits one `2^25` + trace. Measured on the GPU, the warm first stage goes from 135 s to 93 s + and from 175 GiB to 114 GiB under both development and Ignition + parameters; the warm recursive stage stays at about 41 s and verifies the + patched proof. The warm Ignition pipeline from saved FRI proof to + recursive packet is 134 s against the README's 181 s, and 121 s with the + lazy-nibble multi-stark patch measured below. The 40-query + configuration lowers the proven bound and is therefore excluded; its + sizing results are diagnostic only. Fewer rows do not by themselves make + the first proof wrappable: KZG commits per column, and the + four BLAKE3 custom traces leave a one-partition first proof with about 360 + commitments, estimated above the `2^26` wrapper ceiling. Merging those + traces into one brings the estimate to about 60M constraints, just under + the ceiling, so removing the recursive stage rests on exporting such a + packet and compiling the adapted wrapper; column packing in the first + stage's PCS is the fallback if that compile does not fit. + +## The pipeline as it stands + +Warm BN254 Init figures with admitted Ignition parameters. Each stage is a +separate process. + +| Stage | Prover | Output | Wall time | Share | +| --- | --- | --- | ---: | ---: | +| FRI compression | Goldilocks CUDA worker, root key's own parameters | 1.4 MB FRI proof | ~60 s | — | +| First KZG | ix Plonkish prover, BN254 CUDA, 19 partitions of `2^24` | 32,869 B proof, 583-term opening MSM | 136 s | 64% | +| Recursive KZG | Same prover, one `2^26` trace, 61M frontend rows | 1,917 B envelope, two deferred pairing points | 45 s | 21% | +| gnark PLONK | Go helper, CUDA MSM, FFT and fused quotient | 584 B proof, 320,091 gas on Anvil | 32 s process, 11.4 s core | 15% | + +Shares are of the 212 s from saved FRI proof to PLONK proof. Of the PLONK +stage's 32 s, about 20 s is decoding the 1 GB proving key in a fresh process. + +The recursive stage verifies the first proof inside a custom Plonkish circuit +with byte-table lookups, then exposes its two pairing operands as public +values. The PLONK wrapper verifies that small packet with gnark's emulated +BN254 arithmetic and performs both pairing checks in-circuit. + +## Why one gnark proof over the first KZG proof is out of reach + +The wrapper today verifies 15 variable points, 19 fixed commitments and 2 +deferred points. The branch's component sizing (baseline GLV variant, 17.99M +total) attributes the cost as follows; the production classic-GLV variant fits +`2^24` with 2.09M rows of headroom. + +| Wrapper component (recursive packet) | Constraints | +| --- | ---: | +| Point validation and public binding | 168,592 | +| BLAKE3 transcript replay, 1.9 KB packet | 4,914,403 | +| Opening MSMs, 35 terms | 9,735,484 | +| Outer pairing check | 1,588,862 | +| Deferred pairing check | 1,588,278 | +| Classic-GLV total, separate pairings | 14,689,440 | + +Verifying the first proof directly means 583 MSM terms and a 33 KB +transcript. Scaling only those two components: + +| Component of a direct relation | Estimate | +| --- | ---: | +| Opening MSMs, 592 terms at 0.28M to 0.5M each | 165M – 300M | +| BLAKE3 transcript, about 17× the bytes | ~85M | +| One pairing check instead of two | ~1.6M | +| **Estimated direct relation** | **250M – 385M** | +| gnark PLONK ceiling on BN254 (`4N ≤ 2^28`) | `N = 2^26` = 67M | +| Ignition G1 points available | 100.8M | + +The branch's PLONK adapter already rejects domains above `2^26` for this +reason. These are scaled component costs, not a compiled lower bound: the +wrapper folds equal, opposite and small-multiple fixed bases before its MSM +(`circuit.go`, `foldFixedBases`), handles constant scalars separately, and a +packet's byte length is not its transcript's compression count. The margin +is nevertheless large. The measured first proof has 326 variable and 247 +fixed commitments plus ten opening witnesses. Counting only the 336 +variable-base points at the current wrapper's blended 0.28M constraints per +term (a baseline-GLV component figure that already includes its cheap +fixed-base terms) gives about 94M constraints before any transcript or +pairing work, or about 77M after scaling by the production classic-GLV +ratio (14.69M / 18.00M), still over the 67M ceiling. The recursive circuit exists +because it is about six times denser per MSM term than gnark's emulated +arithmetic: 77k lookup-backed rows per term against roughly 500k gnark +constraints. The same arithmetic rules out the reverse direction, proving the +61M-row recursive relation with standard PLONK gates. The architecture doc's +"compile and size both relations" gate should stay; the figures above say +what result to expect from it. + +## What does make sense, ranked by payoff + +1. **Shrink the first KZG stage** (removes a stage). Start with the + opening-reduction change measured below, which halves the circuit with no + parameter or protocol change. The retune steps after it are + implementation work, not parameter selection: the + in-circuit verifier rejects folding arity above one and nonzero Merkle + caps (`fri/plan.rs`, `validate_parameters`; `fri/pcs.rs`, + `FixedPcsShape`), the first-stage and recursive workers enforce at least + eleven partitions (`pipeline/first.rs`, `MIN_PARTITIONS`; + `pipeline/wrap/saved.rs`), and the PLONK exporter and Go circuit hardcode + the two-circuit recursive packet with its deferred pairing operands + (`wrap/outer/relation/codegen.rs`, `tools/bls-plonk-wrapper/circuit.go`). + Every change re-derives the verifying keys and fixed preprocessing. + Every candidate must preserve the current proven security bound under + the same assumptions and accounting as the baseline. +2. **Fuse the recursive and PLONK stages into one warm worker** (estimated + 30 s; unmeasured). Both fit one GPU at the same time (59 GiB and 19 GiB + of VRAM). A persistent helper holding the proving key, compiled circuit + and CUDA context would take the PLONK stage from 32 s toward its 11.4 s + core time. On the KZG side, authenticating the `2^26` Ignition cache + (5.5 s, from the Ignition import record) and restoring fixed + preprocessing (5.3 s, from the warm development rerun) are per-process + costs that would disappear; the two figures come from different runs. + The recursive verifier already separates compilation from request + binding (`native_verifier/plan.rs`, `Plan::compile` and + `Bindings::request_inputs`); `saved.rs` calls `Plan::build`, which does + both per request, so the remaining work is retaining the compiled + circuit across requests in the worker. The wrapper plan already lists + the warm service as gate G6. +3. **Benchmark a faster proving-key load** (candidate, unmeasured). The + helper writes the 1 GB key with gnark's `WriteTo`, which compresses + points, and loads it with `ReadFrom` after verifying the file's SHA-256 + against the manifest. On BN254 G1 the subgroup check is only an on-curve + check, so the unchecked reader alone would save little; the cost to + measure is point decompression, one square root per point over about + `2^24` points. The candidate is raw serialization (`WriteRawTo`, about + twice the bytes) with the unchecked reader, keeping the hash check as the + integrity gate. How much of the 20 s this recovers is not established. +4. **Drop PLONK where latency matters more than gas** (trade-off, with + prerequisites). The recursive packet is 1,917 B and verifies natively in + 4 ms. The only like-for-like on-chain numbers are complete transactions: + 825,229 gas for the historical BLS Keccak v5 packet through the direct + verifier against 320,091 gas for BN254 PLONK (the record's 757,950 figure + is a conformance fixture's external-call cost without intrinsic or + calldata gas). At the comparison's $2.859 per million gas the difference + is about $1.44 per verification for 32 s of proving. A direct BN254 path + does not exist yet: the Solidity exporter is BLS-only and requires the + Keccak transcript, while BN254 packets use BLAKE3, so it would need a + Keccak transcript variant for the recursive stage plus a BN254 exporter. + +## FRI retune: what the sizing shows + +The first-stage circuit depends only on the saved FRI key and its parameters, +so it can be re-sized without a proof. The `fri_sizing` example under +`crates/proof-compression/examples` loads the checked-in Init key +(`tests/fixtures/init-fri`), overrides the query count and optionally the +trace heights (`FRI_SIZING_MAX_LOG`), the circuit set (`FRI_SIZING_DROP`) or +individual circuit shapes (`FRI_SIZING_SET=index:log_height:main_width`), +builds the verifier, and counts the translated BN254 frontend with the same +counting pass the worker's `kzg size` preflight uses. With +`FRI_SIZING_LOWER=1` it also materializes the translated circuit and runs +the partitioning step of the production lowering +(`multi_stark_layout_with_max_height` at the chosen height), which gives the +computation-partition count including hash-bridge rows plus the custom +trace and table heights before production's table merging; that costs 47 GiB +and 54 s at 100 queries. The final circuit inventory is the computation +partitions plus ten auxiliary circuits (four BLAKE3 add-rotate traces, five +smaller custom traces, one merged table), which the stage metadata of the +GPU runs below confirms: 9 + 10 = 19 and 5 + 10 = 15. + +With the committed reduction change (`ffd25b69`) the command below reports +57,233,358 gates at 100 queries. The unpatched baseline figures in this +section, 113,913,480 gates at 100 queries matching the README, come from the +parent commit `5761ef42`; check that out, or revert `ffd25b69`, to reproduce +them. + +```sh +cargo build --release --locked -p ix-proof-compression \ + --no-default-features --features bn254,parallel --example fri_sizing +FRI_SIZING_LOWER=1 target/release/examples/fri_sizing \ + crates/proof-compression/tests/fixtures/init-fri 40,100 24 +``` + +### Per-query cost and its attribution + +Each query of the current circuit costs 1,125,583 translated gates, 312,920 +lookups and 578 BLAKE3 compressions; the query-independent remainder is 1.35M +gates. Varying the key's shape splits the per-query cost: + +| Experiment on the key | Gates per query | Compressions | What it prices | +| --- | ---: | ---: | --- | +| Baseline, 19 circuits, largest LDE `2^26` | 1,125,583 | 578 | — | +| Heights capped at `2^20` | 1,035,911 | 450 | one hash level ≈ 700 gates, 150 lookups | +| Heights capped at `2^16` | 961,673 | 345 | same, 703 gates per level | +| Without the four wide BLAKE3 circuits | 778,415 | 541 | one opened value ≈ 920 gates, 290 lookups | +| Only the seven FRI computation traces | 630,153 | 490 | same, ≈ 890 gates per value | + +About 800 base-field values are opened per query across the 19 stage-3 +circuits (seven FRI computation partitions at `2^24` with 44 each, four +BLAKE3 custom traces at `2^23` with about 70 each, and eight small tables), +so the opening reduction was about 65% of the per-query cost and hashing +about 36%. + +### The opening reduction was an implementation cost + +The reduction loop in `fri/pcs.rs` spent three extension multiplications +per opened value per query: advancing the alpha power, weighting the +difference by it, and multiplying by the opening denominator `1/(ζ_p − x)`. +The alpha powers and the alpha-weighted sums of the out-of-domain +evaluations are query-independent; the denominator depends on the query +point but is shared by every column of one (height, opening point). The +change folds the two query-independent quantities once per height, so each +query pays one extension-by-base product per opened value and one +denominator product per (matrix, point). +This is not a protocol change: the reduced value, the alpha index of every +column, the transcript and the proof bytes are identical, and the circuit +still constrains every input. It is the in-circuit port of the same +restructuring Plonky3 applied to its native FRI verifier in PR #2015 +(commit `a724a1aec0acab47384b34efc2a4d163e0351251`, "hoist the +query-invariant alpha work out of open_inputs"), which is why its +description matches the figures here: one extension-by-base dot product +per opening point plus one multiply by `1/(z − x)`, instead of three +extension multiplies per column. The circuit changes, so the first-stage +verifying key regenerates. Measured with the same example: + +| Circuit | Gates per query | Lookups per query | Gates at 100 queries | Lookups at 100 queries | +| --- | ---: | ---: | ---: | ---: | +| Current | 1,125,583 | 312,920 | 113,913,480 | 31,689,479 | +| Shared alpha powers, factored denominators | 554,220 | 141,070 | 57,233,358 | 14,643,377 | + +The verifier integration tests (`plonkish_pcs`, `plonkish_plan`, +`plonkish_batch`, `plonkish_verifier_algebra`, `plonkish_verifier_security`, +`pipeline_root`, `root_profile`) pass against the patched circuit. After the +change the per-query cost is about 73% hashing, so folding arity and Merkle +caps become the next lever. + +### Measured on the GPU + +Both workers were built from the same tree with `bn254,parallel,cuda` for +SM 120, differing only in `fri/pcs.rs`, and run on the saved Init FRI fixture +with development parameters prepared beforehand (2^24 and 2^26, 46 s). Host: +32 logical CPUs, 249 GiB, one RTX PRO 6000 Blackwell (97 GB), local NVMe. +Settings follow the README's matched comparison: one device, 8 GiB resident, +2^24 MSM chunks, constraint passes on, SRS cache off, TeXRay on, default CPU +parallelism. Each stage ran under `tests/run_gpu_stage.py`; records are under +`/opt/dlami/nvme/ix-retune/runs/`. One run per cell, OS page cache +uncontrolled. For scale, the baseline cold first stage took 207 s on the +48-CPU reference host and 235 s here. + +| First KZG stage | Baseline | Patched reduction | Change | +| --- | ---: | ---: | ---: | +| Cold fixed cache, wall time | 235.4 s | 148.9 s | −37% | +| Warm fixed cache, wall time | 135.2 s | 92.2 s | −32% | +| Peak host RSS | 175.3 GiB | 114.4 GiB | −35% | +| Sampled peak VRAM | 28.4 GB | 28.9 GB | — | +| Circuits | 9 × 2^24 + 10 small = 19 | 5 × 2^24 + 10 small = 15 | | +| Compact proof | 32,869 B | 27,365 B | −17% | +| Opening MSM terms (variable + fixed) | 326 + 247 | 286 + 179 | −19% | + +Warm phase spans, baseline → patched: translate 36.2 → 23.2 s, prepare +28.6 → 20.0 s, prove 42.4 → 27.2 s (lookup construction 16.1 → 10.5 s, +quotient 14.3 → 8.8 s, openings 11.7 → 7.6 s), lowering 13.8 → 11.5 s. + +| Recursive KZG stage | Over baseline first proof | Over patched first proof | +| --- | ---: | ---: | +| Wall time, cold fixed cache | 77.4 s | 76.2 s | +| Peak host RSS | 81.2 GiB | 78.6 GiB | +| Frontend rows / opening MSM terms | 61,151,005 / 583 and 9 | 50,627,679 / 475 and 9 | +| Padded computation trace | 2^26 | 2^26 | +| Native verification of the first proof, deferred pairings | pass | pass | +| Final packet | 1,909 B | 1,909 B | + +The recursive stage natively verified the patched first proof and its +external pairings, which is the full-scale correctness check for the patched +circuit. Its own cost did not move because the frontend still pads to 2^26; +it would need to fall below 2^25 rows to halve. Those recursion figures are +cold-fixed-cache runs; the warm figure is below. + +Under admitted Ignition parameters, patched worker, warm fixed caches, same +settings, one run per stage (`/opt/dlami/nvme/ix-retune/runs/ignition-opt/`): + +| Stage, warm, Ignition | README reference host (48 CPUs) | Patched, 32-CPU host | Change | +| --- | ---: | ---: | ---: | +| First KZG | 135.9 s | 93.4 s | −31% | +| Recursive KZG | 44.6 s | 40.6 s | −9% | +| Total | 180.5 s | 134.0 s | −26% | + +Peak RSS 114.4 GiB and 60.6 GiB; sampled VRAM 28.9 GB and 58.1 GB. Both +proofs verified natively with the deferred pairings; the first proof is +27,365 B and the recursive packet 1,909 B. The recursion's cold pass on the +same host took 75.6 s, so its 44.6 → 40.6 s warm change is within the +noise of a single run, while the first stage's reduction is the circuit +change. + +Per circuit, the patched first proof's commitments are: five computation +partitions at 3 + 5 + 2 variable and 17 fixed each; four BLAKE3 add-rotate +traces at 32 to 40 main + 9 to 11 lookup + 2 quotient variable and 14 fixed +each; and six small traces. The four BLAKE3 traces alone hold 192 of the 286 +variable commitments. A one-partition first proof would therefore still carry +about 246 variable and 111 fixed commitments. + +### Exact lowered layouts + +Lowering the translated circuit at the first stage's height cap gives the +real partition layout. The computation partitions below are followed in each +case by the same ten auxiliary circuits: four BLAKE3 add-rotate traces +(widths 32 to 40, `2^21` to `2^22` rows), five smaller custom traces and one +merged table. + +| Circuit | Queries | Height cap | Computation partitions | Used rows | +| --- | ---: | ---: | --- | ---: | +| Current | 100 | `2^24` | 9 × `2^24` | 150,796,384 | +| Current | 80 | `2^24` | 7 × `2^24` + `2^22` | 120,993,524 | +| Current | 60 | `2^24` | 5 × `2^24` + `2^23` | 91,190,662 | +| Current | 40 | `2^24` | 4 × `2^24` | 61,387,802 | +| Optimized reduction | 100 | `2^24` | 5 × `2^24` | 77,070,160 | +| Optimized reduction | 100 | `2^25` | 2 × `2^25` + `2^24` | 77,070,160 | +| Optimized reduction | 40 | `2^24` | 2 × `2^24` | 32,254,358 | +| Optimized reduction | 40 | `2^25` | **1 × `2^25`** | 32,254,358 | +| Optimized, one `2^27` FRI trace and one `2^25` BLAKE3 trace in stage 3 | 100 | `2^25` | 2 × `2^25` + `2^19` | 67,620,479 | +| Optimized, same merged shape | 40 | `2^24` | 2 × `2^24` | 27,613,737 | + +The nine-partition baseline matches the real run's 19 circuits (nine +computation plus ten auxiliary). The merged stage-3 shape rows are hypothetical +keys (the stage-3 prover has not produced them) and are included to show that +merging buys less than the reduction change did. + +## Soundness accounting + +`tests/fri_security_estimate.py` decodes a saved `outer-vk.bin`, derives the +instance facts from its circuit shapes, and prints every error term per +regime. Its unique-decoding, conjectured, ALI and DEEP formulas follow +`p3-security` at the pinned Plonky3 `3152b14a`; the Johnson column is +printed twice, in that revision's BCHKS25 form and in the DKT26 line-count +form of Plonky3 `main` at `4615dcd`, and the table shows the DKT26 value with +the pinned value in the limiting-terms column where they differ. The lookup +term is multi-stark's documented `(W + 2)·N / |F|`. For the checked-in key: +largest trace `2^24`, degree-2 Goldilocks extension (128 bits), BLAKE3 at 128 +bits of collision resistance, 657 batched columns, 195 folded constraints, +maximum constraint degree 4, two opening points, `N = Σ height × lookups = +1,785,791,520`, `W = 8`. Hypothetical shapes recompute these: the single +`2^27` FRI trace raises `N` to 2,037,449,760 (lookup term 93.75) and drops +the batched columns to 441; adding the merged BLAKE3 trace gives `N = +1,970,340,896` (93.80) and 231 columns. + +```sh +K=crates/proof-compression/tests/fixtures/init-fri/outer-vk.bin +python3 tests/fri_security_estimate.py $K +python3 tests/fri_security_estimate.py $K --queries 40 +python3 tests/fri_security_estimate.py $K --drop 1,2,3,4,5,6 --set 0:27:3 --log-arity 3 +python3 tests/fri_security_estimate.py $K --drop 1,2,3,4,5,6,11,12,13 --set 0:27:3,10:25:40 --log-arity 3 +``` + +| Compression-stage shape | Proven, unique decoding | Proven, Johnson (DKT26; pinned BCHKS25) | Conjectured | Limiting terms | +| --- | ---: | ---: | ---: | --- | +| Current: `2^24`, blowup 4, 100 queries, 20 grinding bits, binary folding | 87.8 | 83.8 (81.2) | 93.9 | query / batching / lookup | +| `2^24`, folding arity 8 | 87.8 | 83.8 (81.2) | 93.9 | commit term falls 102.0 → 99.2 but does not bind | +| `2^24`, 40 queries | 47.1 | 59.9 (59.6) | 93.9 | query / query / lookup | +| One `2^27` FRI trace, arity 8, 100 queries | 87.8 | 81.4 (78.8) | 93.8 | query / batching / lookup; commit 96.2 | +| Same, 8 commit-grinding bits | 87.8 | 81.4 (78.8) | 93.8 | commit restored to 104.2 | +| One `2^27` FRI trace and one `2^25` BLAKE3 trace, arity 8, 100 queries | 87.8 | 82.3 (79.7) | 93.8 | query / batching / lookup | +| Same, 40 queries | 47.1 | 59.9 (59.5) | 93.8 | query / query / lookup | +| `2^24`, blowup 8, arity 8, 60 queries | 69.8 | 81.8 (78.7) | 93.9 | query / batching / lookup | + +Bits of security; each cell is the minimum over ALI, DEEP, lookup, batching +(proven regimes), FRI commit, FRI query and collision terms; the script +prints each term. Three corrections to the earlier draft of this document: +the conjectured figure is bounded by the lookup term near 93.9 bits, not by +the FRI commit term at 101.7; folding arity +is not free, it costs `log2(2^a − 1)` bits in the commit term (2.8 bits at +arity 8), which binds nowhere here but should be re-checked per shape; and +the taller `2^27` trace costs 3 bits in the commit term and 3 bits in the +Johnson batching term. Merkle caps cost nothing. + +**Required security floor:** the current proven security bound must not be +reduced further. Compare the baseline and each complete candidate shape +under the same proven assumptions and accounting, including all applicable +error terms. An unchanged conjectured bound does not satisfy this +requirement if the proven bound decreases. + +The 40-query configurations above are therefore excluded, despite their +unchanged conjectured bound. Their sizing results remain useful diagnostics, +not admissible parameter choices. Keep 100 queries for the current +configuration; any alternative query count, blowup, folding schedule or +trace layout must first demonstrate that it preserves the proven bound. +Increasing blowup at stage 3 also faces the 192 GiB LDE-array guard in +`pipeline/root.rs`. + +## Path to removing the recursive stage + +Measured layouts, in the order the changes should land: + +| Step | First stage at 100 queries | At 40 queries (sizing only) | Status | +| --- | --- | --- | --- | +| Today | 9 × `2^24` + 9 small circuits | 4 × `2^24` + 9 | measured | +| Opening reduction with shared alpha powers and factored denominators | 5 × `2^24`, or 2 × `2^25` + `2^24`, + 9 | 2 × `2^24`, or **1 × `2^25`**, + 9 | measured; patch uncommitted, tests pass, keys change | +| Plus folding arity 8 and Merkle cap 6 | ≈ 578 → 295 compressions per query, about −35% rows: projected 3 to 4 × `2^24` | projected 1 × `2^24` or `2^25` | blocked: in-circuit verifier asserts binary FRI and cap 0; multi-stark CUDA asserts the same; pinned p3-fri accepts a prover-chosen schedule (#2033) and has cap bugs (#2372, #2378) | +| Plus merged stage-3 shape (one `2^27` FRI trace, one BLAKE3 trace) | 2 × `2^25` + `2^19` measured on a hypothetical key, before arity | 2 × `2^24` | unmeasured stage-3 prover cost; smaller gain than the two rows above | + +To preserve the proven security bound, the current configuration keeps 100 +queries. The realistic landing point after the first two steps is about +three `2^25` computation partitions plus the ten auxiliary circuits, or two +if the merged shape pays off and preserves that bound. The measured +one-partition result at 40 queries is excluded from the implementation path +because it lowers the proven bound. + +Fewer rows do not by themselves make the first proof wrappable. KZG commits +to every column separately, and the first stage's columns are dominated by +the BLAKE3 custom traces (32 to 40 main columns each) rather than by the +computation partitions (3 main columns each). Counting from the measured +stage metadata, a one-partition first proof still carries about 246 variable +commitments and 111 fixed ones (the four BLAKE3 traces alone hold 192 +variable), against 15 and 19 in the recursive packet; its opened values and +transcript scale the same way. + +| Wrapped packet | MSM terms | MSM estimate, baseline GLV at 0.28M/term | Scaled to classic GLV (×0.82) | With transcript (today's 4.9M scaled by packet bytes) | +| --- | ---: | ---: | ---: | ---: | +| Recursive packet (measured wrapper) | 35 | 9.7M | — | 14.7M total, compiled | +| Patched five-partition first proof, four BLAKE3 traces (measured compile) | 475 | — | — | **130.85M, domain `2^27`** | +| One-partition first proof, four BLAKE3 traces | ≈ 360 | ≈ 101M | ≈ 82M | over the 67M ceiling | +| One-partition first proof, one merged BLAKE3 trace | ≈ 179 | ≈ 50M | ≈ 41M | ≈ 60M | + +The first of those rows is now a measurement. The PLONK exporter and the Go +circuit were generalized to any number of circuits, a claim schema and an +optional deferred pairing (`ix-compression-worker plonk export-first + [--dev-srs]`, then the +helper's `compile`). Exporting the patched 15-circuit first proof gives a +verifying key with 295 points, 560 scalars, 179 fixed commitments, 9 opening +groups and a 10,160-node scalar program; gnark compiles it to +**130,850,185 constraints, domain `2^27`**, in 121 s and 44 GiB. That is +twice the `2^26` ceiling, so wrapping the first proof as it stands is out. +The merged-trace row remains an estimate below the ceiling with no headroom +to speak of and none of the production constraints (CUDA path capped at +`2^24`, `2^25` CPU proving at 104 s) accounted for; sizing it needs a +first-stage key with that shape, which the stage-3 prover has not produced. + +If that compile fails to fit `2^25` or `2^26`, the remaining route is for the +first stage to commit to fewer polynomials, for example by interleaving the +columns of a trace into one taller polynomial (the "fflonk-style packing" +the architecture doc already lists as a later PCS change). Until one of +these lands, the recursive stage stays as the commitment-packing step and +the measured win is the first stage's own time, 135 s to 92 s warm on the +GPU, with the recursive stage unchanged. + +STIR does not help here. Its savings come from shrinking later rounds' query +counts, while the round-0 opening reduction and input Merkle paths that any +STIR or FRI verifier performs identically are most of this circuit. + +## Where the rows go: gadget-level attribution + +Two counting reports now live beside the gadgets and run with +`cargo test ... -- --ignored --nocapture`: `recursive::native_msm::cost_breakdown::report` +for the opening MSM and `fri::pcs::cost_breakdown::report` for the first-stage +verifier's per-query pieces. Rows are gates plus lookups in the builder's +counting mode. + +### Recursive opening MSM + +| Component | Rows | Notes | +| --- | ---: | --- | +| Fq multiplication | 164 | 108 gates, 56 lookups; a relation is about 130 rows of which 56 are 16-bit range lookups | +| Affine addition | 752 | one inverse hint plus four relations | +| Affine doubling | 536 | | +| 16-way point select, variable base | 360 | 15 selects of 8 limbs, 3 gates per limb | +| 16-way point select, fixed base | 232 | constant tables | +| Variable-base 16-entry table | 10,077 | 15 curve operations | +| Fixed-base 16-entry table | 637 | | +| Scalar to bits | 961 | per term | +| Per variable term, 64 windows | 82,445 | 64 × (360 + 752) + table + bits | +| Per fixed term, 64 windows | 64,781 | 64 × (232 + 752) + table + bits | +| Shared per MSM | 556,850 | 256 doublings and two 254-bit constant multiplications for the offset correction | + +For the real first proof (296 variable and 179 fixed terms) this reproduces +the 37.75M-row MSM phase within 3%. The affine addition is 58% of a variable +term and 74% of a fixed term; the window selects are 28% and 23%. This is why +GLV cannot help: with doublings already shared, splitting each scalar into two +halves leaves the number of additions unchanged. + +Widening the range-check limbs from 16 to 20 bits (`RANGE_BITS` in +`recursive/native_field.rs`, a `2^20`-row table instead of `2^16`) cuts an Fq +multiplication to 144 rows and an addition to 672, about 8% off every MSM +term. On the real first proof the recursive frontend goes from 50,628,004 to +47,662,557 rows with the witness and external pairings still passing; it +remains padded to `2^26`. The change is committed; the merged table trace +grows from `2^17` to `2^20` rows. + +### First-stage verifier, per query, Goldilocks side + +| Component | Rows | Compressions | +| --- | ---: | ---: | +| One Merkle level (order the pair, hash) | 736 | 1 | +| Same with an injected row hash | 864 | 2 | +| Hash 8 opened values | 608 | 1 | +| Hash 187 opened values | 11,348 | 25 | +| Encode one opened value to bytes | 60 | | +| Extension multiply | 10 | | +| FRI round 0 with a 25-level path | 21,284 | 26 | +| Sample 26 query bits | 192 | 1 | +| Sample an extension challenge | 4,084 | 4 | + +A Merkle level was 736 rows of which 608 gates were the 64 byte selects that +place the sibling and the current digest in hash order: 9.5 gates per byte, +because `ByteGadgets::select` selected the value and both nibbles. The +compact BLAKE3 lowering only consumes the value, so two thirds of that work +was dead. With about 455 levels per query these selects were roughly 300k of +the 349k Goldilocks gates per query, the single largest first-stage cost +after the opening reduction fix. + +### Lazy byte nibbles in multi-stark + +A candidate multi-stark change on top of the pinned revision makes +`ByteValue`'s nibble decomposition optional: `select` +produces a value-only byte, and the generic paths that read nibbles (XOR, +word packing, comparisons) materialize them on demand with the same +nibble-hint-and-lookup constraints they always had. Measured with the sizing +example against the patched dependency: + +| First stage, 100 queries | Before | Lazy nibbles | +| --- | ---: | ---: | +| Translated gates per query | 554,220 | 389,868 | +| Translated gates | 57,233,358 | 40,750,830 | +| Lookups | 14,643,377 | 14,646,161 | +| Used rows | 77,070,160 | 60,590,416 | +| Computation partitions at `2^24` | 5 | **4** | + +The patch is not applied in this repository; it needs an upstream +multi-stark change and a pin bump. A CUDA worker built against the patched +clone, with the 20-bit range limbs, ran the same warm Ignition sequence: + +| Stage, warm, Ignition | README reference | Reduction patch | Plus lazy nibbles | +| --- | ---: | ---: | ---: | +| First KZG | 135.9 s | 93.4 s | **80.6 s** | +| Recursive KZG | 44.6 s | 40.6 s | 40.3 s | +| Total | 180.5 s | 134.0 s | **120.9 s** | +| First-stage peak RSS | 175.3 GiB | 114.4 GiB | 99.3 GiB | +| First-stage circuits | 9 + 10 | 5 + 10 | 4 + 10 | +| First proof | 32,869 B | 27,365 B | 25,989 B | +| Recursive frontend rows | 61,151,005 | 50,628,004 | 45,179,819 | +| Recursive MSM terms | 583 and 9 | 475 and 9 | 448 and 9 | + +Both proofs verified natively with the deferred pairings under the admitted +setup; the recursive packet is unchanged at 1,669 B. Warm first-stage spans: +translate 16.6 s, lower 10.7 s, prepare 20.3 s, prove 24.1 s. The +recursion still pads to `2^26`, so its time did not move; its merged table +trace is now `2^21` rows because of the wider range table. All FRI, +first-stage and recursive gadget tests pass against the patched dependency. + + +## Upstream Plonky3 since the pin + +The pin is 31 August 2026; `main` is 18 October 2026, 362 commits later. +Relevant to this pipeline: + +- **Soundness fixes that matter for arity.** #2033 derives the FRI folding + schedule from configuration instead of trusting the prover; the pinned + verifier only checks that arities sum correctly, so two schedules can pass. + Harmless at binary folding, which is all ix uses, but mandatory before + enabling arity. #2372 and #2378 fix Merkle cap handling. +- **Security crate.** Legacy conjectured regime, the DKT26 Johnson bound, + grinding-difficulty checks against the model, and composition of + multi-STARK soundness with PCS budgets. A per-stage security report for the + ix pipeline could be built on it directly. +- **Breaking transcript changes.** A typed Fiat-Shamir layer now drives FRI, + STIR and WHIR, and the proof-of-work search squeezes the transcript first + (#2199, #2200). Upgrading the pin changes proof bytes, so the in-circuit + transcript and the recursive verifier would need re-deriving. This is the + main cost of moving the pin. +- **Prover and verifier performance.** Concurrent commit LDEs (#2397), + packed multi-round fold and cheaper opening quotient (#2204), closed-form + fold rows and the hoisted verifier alpha ladder (#2015; the latter is what + the first-stage circuit change above ports), + Goldilocks scalar and packed arithmetic (#2098, #2116), Merkle trees built + in subtree blocks (#2335). These speed the CPU paths; multi-stark's CUDA + backend has its own kernels. No GPU work landed upstream. +- **New protocols.** STIR is wired into the full pipeline with its own + schedule derivation, WHIR gained hiding and binary-domain variants, and a + multilinear SuperSpartan-style `p3-multi-stark` crate with buses, LogUp* + and binary-field backends has grown to 65k lines. These are + architecture-level alternatives, not drop-in improvements for the current + KZG path. + +## Review notes + +- The PLONK CUDA library is a second, independently built sppark + instantiation with renamed symbols so it can coexist with the KZG kernels. + The split-bucket MSM fix for structured witnesses lives only there. The KZG + prover's main-trace columns are also byte-structured, so its MSMs may show + the same bucket concentration. +- The driver's saved-FRI path never passes the terminal PLONK flag, so the + benchmarked PLONK runs were produced by invoking the helper by hand. The CLI + help text still describes the flag as BLS12-381 and Filecoin. +- The PLONK cache identity hashes both the Rust worker and the Go helper + executables, so any rebuild of either triggers the six-minute key + preparation. The benchmark doc already flags this. +- Recursive-stage phase timings for the GPU run are not recorded in the repo, + only the CPU run's. The split of the 45 s between witness generation, + commitment and quotient decides how much the fused worker is worth. + +Sources: `crates/proof-compression/README.md`, +`docs/proof-benchmark-comparison.md`, the pipeline, recursive verifier and +wrapper sources, multi-stark `416be66`, and Plonky3 `3152b14a` and `main` at +`4615dcd`. diff --git a/tests/fri_security_estimate.py b/tests/fri_security_estimate.py new file mode 100644 index 000000000..7fae6062c --- /dev/null +++ b/tests/fri_security_estimate.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Soundness estimate for a saved FRI compression key under candidate parameters. + +Decodes the circuit shapes from an `outer-vk.bin`, derives the instance facts +the bounds need (largest trace, batched columns, folded constraints, maximum +constraint degree, lookup rows and width) and prints every error term for the +unique-decoding, Johnson and conjectured regimes. Hypothetical shapes use the +same overrides as the `fri_sizing` example: `--drop` omits circuits and +`--set index:log_height:main_width` reassigns a circuit's height or width. + +Formulas: UDR, conjectured and DEEP/ALI terms follow p3-security at Plonky3 +3152b14a; the Johnson commit and batching terms are printed both in that +revision's BCHKS25 form and in the DKT26 line-count form of Plonky3 main at +4615dcd. Lookups use multi-stark's documented `(W + 2) N / |F|` bound. The +union bound is reported as the minimum term, as p3-security does. +""" +import argparse +import math +from pathlib import Path + +log2 = math.log2 +FIELD_BITS = 128 # degree-2 Goldilocks extension +COLLISION_BITS = 128 # BLAKE3 + + +def decode_key(path): + data = Path(path).read_bytes() + pos = 0 + + def varint(): + nonlocal pos + b = data[pos] + pos += 1 + if b < 251: + return b + width = {251: 2, 252: 4, 253: 8}[b] + v = int.from_bytes(data[pos:pos + width], 'little') + pos += width + return v + + version = varint() + assert version == 1, version + log_blowup, cap = varint(), varint() + fri = [varint() for _ in range(5)] + circuits = [] + for _ in range(varint()): + for _ in range(varint()): + v = varint() + if v == 0: + pos += 8 + elif v == 1: + varint(), varint(), varint() + elif v == 2: + varint() + elif v in (6, 7, 8): + varint(), varint() + elif v == 9: + varint() + for _ in range(varint()): + varint() + zeros = varint() + for _ in range(zeros): + varint() + widths = [] + for _ in range(varint()): + varint() + args = varint() + for _ in range(args): + varint() + widths.append(args) + varint() + varint() + meta = [varint() for _ in range(8)] + circuits.append(dict( + main=meta[0], pre=meta[1], height=meta[2], lookups=meta[3], stage2=meta[4], + group=meta[6], max_degree=meta[7], zeros=zeros, max_lookup_width=max(widths, default=0))) + return dict(log_blowup=log_blowup, cap=cap, num_queries=fri[2], commit_pow=fri[3], + query_pow=fri[4], max_log_arity=fri[1], circuits=circuits) + + +def apply_overrides(circuits, drop, sets): + out = [] + for i, c in enumerate(circuits): + if i in drop: + continue + c = dict(c) + if i in sets: + log_h, main = sets[i] + c['height'] = 1 << log_h + c['main'] = main + out.append(c) + return out + + +def instance(circuits, log_blowup): + quotient = lambda c: 1 << max(0, (max(c['max_degree'], 2) - 2).bit_length()) + return dict( + log_trace=max(c['height'] for c in circuits).bit_length() - 1, + columns=sum(c['main'] + c['pre'] + c['stage2'] + 2 * quotient(c) for c in circuits), + constraints=sum(c['zeros'] + 2 * max(1, -(-c['lookups'] // c['group'])) for c in circuits), + max_degree=max(c['max_degree'] for c in circuits), + lookup_rows=sum(c['height'] * c['lookups'] for c in circuits), + lookup_width=max(c['max_lookup_width'] for c in circuits), + combo=2, + ) + + +def terms(inst, log_blowup, queries, query_pow, log_arity, commit_pow): + lt, F = inst['log_trace'], FIELD_BITS + n, k, fold = 2.0 ** (lt + log_blowup), 2.0 ** lt, (1 << log_arity) - 1 + rho = 2.0 ** -log_blowup + ali = F - log2(inst['constraints']) + deep = F - log2(inst['max_degree'] * (k + inst['combo'] - 1) + (k - 1)) + lookup = F - log2((inst['lookup_width'] + 2) * inst['lookup_rows']) + nf = inst['columns'] + out = {} + alpha = (1 + (k + inst['combo']) / n) / 2 + out['unique decoding'] = dict( + ALI=ali, DEEP=deep, lookup=lookup, + batching=F - ((lt + log_blowup) + log2(nf - 1)), + commit=F - log2(fold * (n + 1)) + commit_pow, + query=query_pow - queries * log2(alpha), collision=COLLISION_BITS) + for label, line_count in (('Johnson, BCHKS25 (3152b14a)', None), ('Johnson, DKT26 (4615dcd)', True)): + best = None + for m in range(3, 300): + ms = m + 0.5 + if line_count: + lc = 3 + (lt + log_blowup) + 3 * log2(ms) - log2(3) - (log2(1 - 2.0 ** -lt) - log_blowup) + commit_linear = F - lc - log2(fold) + commit_pow + batching = F - (lc + log2(nf - 1)) + else: + pp = 1 - math.sqrt(rho) * (1 + 0.5 / m) + eps = ((2 * ms ** 5 + 3 * ms * pp * rho) * n / (3 * rho * math.sqrt(rho)) + ms / math.sqrt(rho)) * fold + commit_linear = F - log2(max(eps, 1.0)) + commit_pow + batching = F - ((lt + log_blowup) + log2(2 * ms ** 5 / 3) + 1.5 * log_blowup + log2(nf - 1)) + commit = min(commit_linear, + F - log_arity - log2(n + 1) - log2(2 * m + 1) + 0.5 * log2(rho) + commit_pow) + L = ms / math.sqrt(rho) + d = dict(ALI=F - log2(L) - log2(inst['constraints']), + DEEP=F - log2(L) - log2(inst['max_degree'] * (k + inst['combo'] - 1) + (k - 1)), + lookup=lookup, batching=batching, commit=commit, + query=query_pow - queries * log2((1 + 0.5 / m) * math.sqrt(rho)), + collision=COLLISION_BITS) + if best is None or min(d.values()) > min(best[1].values()): + best = (m, d) + out['%s, m=%d' % (label, best[0])] = best[1] + eta = ((math.log2(math.e) + log_blowup) * rho) / F + out['conjectured (2025/2010)'] = dict( + ALI=ali, DEEP=deep, lookup=lookup, + commit=F - log2(fold * (n + 1)) + commit_pow, + query=queries * (-log2(rho + eta)) + query_pow, collision=COLLISION_BITS) + return out + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('key', help='outer-vk.bin of a saved FRI compression stage') + parser.add_argument('--queries', type=int) + parser.add_argument('--query-pow', type=int) + parser.add_argument('--commit-pow', type=int) + parser.add_argument('--log-arity', type=int) + parser.add_argument('--log-blowup', type=int) + parser.add_argument('--drop', default='', help='comma-separated circuit indices to omit') + parser.add_argument('--set', default='', help='comma-separated index:log_height:main_width overrides') + args = parser.parse_args() + key = decode_key(args.key) + drop = {int(i) for i in args.drop.split(',') if i} + sets = {} + for item in args.set.split(','): + if item: + i, log_h, main = (int(x) for x in item.split(':')) + sets[i] = (log_h, main) + circuits = apply_overrides(key['circuits'], drop, sets) + log_blowup = args.log_blowup if args.log_blowup is not None else key['log_blowup'] + inst = instance(circuits, log_blowup) + queries = args.queries if args.queries is not None else key['num_queries'] + query_pow = args.query_pow if args.query_pow is not None else key['query_pow'] + commit_pow = args.commit_pow if args.commit_pow is not None else key['commit_pow'] + log_arity = args.log_arity if args.log_arity is not None else key['max_log_arity'] + print('key: %d circuits (%d after overrides), blowup 2^%d, %d queries, query pow %d, commit pow %d, arity 2^%d' + % (len(key['circuits']), len(circuits), log_blowup, queries, query_pow, commit_pow, log_arity)) + print('instance: trace 2^%d, %d batched columns, %d constraints, max degree %d, lookup rows %d, lookup width %d' + % (inst['log_trace'], inst['columns'], inst['constraints'], inst['max_degree'], + inst['lookup_rows'], inst['lookup_width'])) + for regime, d in terms(inst, log_blowup, queries, query_pow, log_arity, commit_pow).items(): + limiting = min(d, key=d.get) + print(' %-32s %6.2f bits (limited by %s) %s' % ( + regime, d[limiting], limiting, ' '.join('%s %.2f' % kv for kv in d.items()))) + + +if __name__ == '__main__': + main() From d100bd2c2f8d6f4195a97faae6b91fe780033d5b Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:54:06 +0000 Subject: [PATCH 08/31] refactor(compression): name the verifying key as SP1 does Rename the recursive verifying key throughout, following SP1's usage (`SP1VerifyingKey`, `vk`, `vk.bin`): the Rust type `VerifierProfile` becomes `VerifyingKey`, `profiles/` becomes `verifying-keys/`, stage outputs write `vk-id.bin` and `final-kzg/vk.bin`, the PLONK export is `vk.json` with format `ix-{bn254,bls}-plonk-vk-v1`, and identifiers, report keys, the worker's `plonk vk-blake3` subcommand, the Go helper (`verifyingKey`, `VK`, `InnerVKSHA256`) and the Lean, Lake, Nix, CI and Python references follow. The id derivation keeps its historical `init-kzg-recursive-profile` label so every published verifying key id is unchanged. Stage directories written before this change are not readable by the new artifact names. The FRI proof-shape `ProofProfile` and CPU-profiling names are unrelated and untouched. Generalize the PLONK exporter and the gnark circuit from the two-circuit recursive packet to any number of circuits: the opening layout, graph inputs and scalar program take the circuit count from the system, claim rows are exported as a schema of constants and public-scalar references, intermediate lookup accumulators are carried per circuit, and the deferred pairing is optional. `ix-compression-worker plonk export-first` exports a saved first KZG stage directly. Keys without the new fields keep the recursive defaults, so existing fixtures and keys are unaffected. Compiling the export of the 15-circuit Init first proof gives 130,850,185 constraints at domain 2^27. --- .github/workflows/ci.yml | 6 +- Ix/Cli/Compression.lean | 4 +- Tests/CompressionCli.lean | 10 +- crates/proof-compression/Cargo.toml | 2 +- crates/proof-compression/README.md | 62 ++--- .../proof-compression/examples/fri_sizing.rs | 7 +- .../examples/kzg_backend_parity.rs | 2 +- .../examples/recursive_size.rs | 7 +- crates/proof-compression/src/kzg/compact.rs | 6 +- .../src/kzg/srs/ignition/admission.rs | 2 +- .../proof-compression/src/pipeline/driver.rs | 51 ++-- .../proof-compression/src/pipeline/first.rs | 37 ++- .../src/pipeline/fixed_cache.rs | 6 +- crates/proof-compression/src/pipeline/mod.rs | 62 ++--- .../proof-compression/src/pipeline/plonk.rs | 43 +-- .../proof-compression/src/pipeline/setup.rs | 4 +- .../src/pipeline/stages/setup.rs | 5 +- .../src/pipeline/stages/verification.rs | 2 +- .../src/pipeline/verifier.rs | 103 ++++--- .../src/pipeline/wrap/mod.rs | 4 +- .../src/pipeline/wrap/outer.rs | 259 +++++++++--------- .../src/pipeline/wrap/outer/plonk_export.rs | 248 ++++++++++++++--- .../pipeline/wrap/outer/plonk_export/ir.rs | 45 ++- .../src/pipeline/wrap/outer/relation.rs | 16 +- .../pipeline/wrap/outer/relation/codegen.rs | 37 ++- .../src/pipeline/wrap/saved.rs | 31 ++- .../src/recursive/native_verifier.rs | 32 +-- .../src/recursive/native_verifier/bindings.rs | 6 +- .../src/recursive/native_verifier/plan.rs | 63 ++--- .../recursive/native_verifier/reuse_tests.rs | 27 +- .../src/recursive/native_verifier/shape.rs | 24 +- .../proof-compression/tests/worker_tracing.rs | 6 +- .../aggregate-development-v5.bin | Bin .../aggregate-development-v5.json | 4 +- .../aggregate-development-v6.bin | Bin .../aggregate-development-v6.json | 8 +- .../aggregate-filecoin-v5.bin | Bin .../aggregate-filecoin-v5.json | 4 +- .../aggregate-filecoin-v6.bin | Bin .../aggregate-filecoin-v6.json | 8 +- flake.nix | 2 +- lakefile.lean | 6 +- tests/kzg_backend_parity.py | 6 +- tools/bls-plonk-wrapper/README.md | 12 +- tools/bls-plonk-wrapper/artifacts.go | 8 +- tools/bls-plonk-wrapper/backend.go | 4 +- tools/bls-plonk-wrapper/backend_bn254_test.go | 2 +- .../backend_cuda_bn254_test.go | 2 +- tools/bls-plonk-wrapper/backend_test.go | 2 +- tools/bls-plonk-wrapper/circuit.go | 78 ++++-- tools/bls-plonk-wrapper/circuit_test.go | 2 +- .../bls-plonk-wrapper/cuda_init_bn254_test.go | 12 +- .../cuda_profile_bn254_test.go | 18 +- tools/bls-plonk-wrapper/curve_bls.go | 2 +- tools/bls-plonk-wrapper/curve_bn254.go | 2 +- .../lagrange_import_bn254.go | 2 +- .../lagrange_import_bn254_test.go | 4 +- tools/bls-plonk-wrapper/main.go | 20 +- .../msm_capture_bn254_test.go | 10 +- tools/bls-plonk-wrapper/optimization_test.go | 6 +- tools/bls-plonk-wrapper/pairing_batch.go | 8 +- .../pairing_batch_bn254_test.go | 28 +- .../parameters_bn254_test.go | 2 +- tools/bls-plonk-wrapper/parameters_test.go | 2 +- tools/bls-plonk-wrapper/profile.go | 160 +++++++++-- tools/bls-plonk-wrapper/sizing_test.go | 6 +- .../testdata/bn254/provenance.json | 2 +- .../testdata/bn254/{profile.json => vk.json} | 4 +- .../testdata/outer/{verifier.bin => vk.bin} | Bin .../testdata/outer/{profile.json => vk.json} | 4 +- .../bls-plonk-wrapper/verifier_bn254_test.go | 10 +- 71 files changed, 991 insertions(+), 678 deletions(-) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-development-v5.bin (100%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-development-v5.json (95%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-development-v6.bin (100%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-development-v6.json (61%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-filecoin-v5.bin (100%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-filecoin-v5.json (95%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-filecoin-v6.bin (100%) rename crates/proof-compression/{profiles => verifying-keys}/aggregate-filecoin-v6.json (62%) rename tools/bls-plonk-wrapper/testdata/bn254/{profile.json => vk.json} (99%) rename tools/bls-plonk-wrapper/testdata/outer/{verifier.bin => vk.bin} (100%) rename tools/bls-plonk-wrapper/testdata/outer/{profile.json => vk.json} (99%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 911e5f297..ebf96e7eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,16 +158,16 @@ jobs: for recursive_filter in native_field:: native_curve:: native_transcript:: native_msm:: pairing:: \ native_verifier::parallel_prefix_tests:: \ native_verifier::reuse_tests::recursive_witness_rejects_tampering_without_native_preverification \ - native_verifier::reuse_tests::recursive_profile_rejects_identity_anchors_and_oversized_layout_caps; do + native_verifier::reuse_tests::recursive_vk_rejects_identity_anchors_and_oversized_layout_caps; do cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ --lib "recursive::$recursive_filter" ${{ github.event_name == 'push' && '--no-run' || '' }} done - - name: Test BN254 worker and portable profiles + - name: Test BN254 worker and portable verifying keys run: | cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ --test worker_tracing ${{ github.event_name == 'push' && '--no-run' || '' }} for pipeline_filter in driver:: tests:: setup:: stages::codec:: stages::verification:: stages::wrap:: \ - verifier::bn254_tests:: wrap::outer::tests::fused_profiles_prove_and_portable_verifiers_round_trip; do + verifier::bn254_tests:: wrap::outer::tests::fused_vks_prove_and_portable_verifiers_round_trip; do cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ --lib "pipeline::$pipeline_filter" ${{ github.event_name == 'push' && '--no-run' || '' }} done diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 38c6d20ea..16a2cee68 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -20,7 +20,7 @@ preprocessing and development SRS caches under `cacheRoot`. -/ opaque compress (vk proof claim statement : @& ByteArray) (dir cacheRoot : @& String) (devSrs : Bool) (logHeight : UInt32) (terminalPlonk : Bool) : IO ByteArray -/-- Verify with the profile bundled under the packet's id, whose pinned +/-- Verify with the verifying key compiled in under the packet's id, whose pinned aggregation program fixes the statement for `claim`, and return the setup class: `"filecoin"` or `"development"`. Development proofs need `devSrs`. Needs neither the aggregation backend nor a GPU. -/ @@ -28,7 +28,7 @@ Needs neither the aggregation backend nor a GPU. -/ opaque verify (proof claim : @& ByteArray) (devSrs : Bool) : IO String /-- Caches shared by every compression run: fixed preprocessing keyed by the -executable and profile, and development SRS files. -/ +executable and verifying key, and development SRS files. -/ def cacheDir : IO System.FilePath := do return (← StoreIO.toIO Store.getHomeDir) / ".ix/compression/cache" diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index 9a0971555..c4f2f5846 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -67,7 +67,7 @@ public def run : IO Unit := do let wrapped : Ixon.Proof := { claim := expected, proof } let compressedAddr ← putProof home (Ixon.Proof.ser wrapped) let metadata ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile - "crates/proof-compression/profiles/aggregate-filecoin-v5.json")) + "crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json")) let expectedAddr ← IO.ofExcept (metadata.getObjValAs? String "compressed_proof_address") unless toString compressedAddr == expectedAddr do throw <| IO.userError "bundled proof address differs from its metadata" @@ -88,9 +88,9 @@ public def run : IO Unit := do -- A local registry cannot replace a verifier carried by the application. let alias := toString (Address.mk (packet.extract 0 32)) - let profiles := home / ".ix/verifiers/filecoin/profiles" - IO.FS.createDirAll profiles - IO.FS.writeBinFile (profiles / alias) "invalid registry entry".toUTF8 + let keys := home / ".ix/verifiers/filecoin/verifying-keys" + IO.FS.createDirAll keys + IO.FS.writeBinFile (keys / alias) "invalid registry entry".toUTF8 let independent ← invoke home args unless independent.exitCode == 0 do throw <| IO.userError s!"verification read an external registry: {independent.stderr}" @@ -111,7 +111,7 @@ public def run : IO Unit := do let devWrapped : Ixon.Proof := { claim := expected, proof := devProof } let devAddr ← putProof home (Ixon.Proof.ser devWrapped) let devMetadata ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile - "crates/proof-compression/profiles/aggregate-development-v5.json")) + "crates/proof-compression/verifying-keys/aggregate-development-v5.json")) let expectedDevAddr ← IO.ofExcept (devMetadata.getObjValAs? String "compressed_proof_address") unless toString devAddr == expectedDevAddr do throw <| IO.userError "bundled development proof address differs from its metadata" diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index 28b0a435c..11f42a2d0 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -62,7 +62,7 @@ required-features = ["bn254"] [features] bls12-381 = [] # BN254 accepts admitted Ignition or development setup; CUDA support is experimental. -# Production parameters and terminal proof backends require separate profiles. +# Production parameters and terminal proof backends require separate vks. bn254 = ["dep:ark-bn254"] plonk = [] default = ["parallel", "bls12-381", "plonk"] diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 9f9822b45..62d72f09d 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -112,9 +112,9 @@ or 33,045 bytes including its packet header and statement. The final recursive proof is 1,669 bytes, its packet is 1,909 bytes and the complete envelope is 1,917 bytes. Independent verification against the original Init claim and root statement passed without a proving SRS or cache. Negative checks rejected -the mathlib claim, its root statement, both together, an incorrect profile -pin and omitted development opt-in. The profile pin came from the locally -generated run; it is not an independently authorized production profile. +the mathlib claim, its root statement, both together, an incorrect verifying key +pin and omitted development opt-in. The verifying key pin came from the locally +generated run; it is not an independently authorized production verifying key. At the recursive frontend boundaries, point validation accounts for 36.47 million BLS rows versus 0.41 million BN254 rows; BN254's cofactor-one G1 @@ -321,8 +321,8 @@ as well as the original claim, expected root statement and admitted setup: ```sh "$IX_COMPRESSION_WORKER" pipeline verify-ignition \ /path/to/run/proof.envelope.bin /path/to/claim.bin \ - /path/to/root/root-claims.bin /path/to/run/final-kzg/verifier.bin \ - "$BN254_PROFILE_DIGEST" /path/to/new-admission.json \ + /path/to/root/root-claims.bin /path/to/run/final-kzg/vk.bin \ + "$BN254_VK_DIGEST" /path/to/new-admission.json \ "$IGNITION_ADMISSION_BLAKE3" ``` @@ -367,7 +367,7 @@ attributing differences from the development benchmark to the ceremony. The envelope is **1,917 bytes** and the portable verifying key is **1,542 bytes**. Both are byte-identical between the cold and warm runs, as are the compact proofs from both KZG stages. The verifying key uses the same -structure as BLS12-381, implemented by `VerifierProfile`, with BN254 version 7, +structure as BLS12-381, implemented by `VerifyingKey`, with BN254 version 7, circuit metadata and both opening equations. Its locally pinned BLAKE3 digest is `a4cf1b3b2489af6c939847d5060452bccb1f87ed8cff0fb90658e477549eafcd`. @@ -431,22 +431,22 @@ and 20 seconds wall time for this preflight on the measurement host. the root statement. The top-level driver admits first-stage log-heights 24 and 25, matching the saved recursive layout policy. Workers run in separate processes and are checked for the selected curve before proving. Each run -writes `proof.envelope.bin`, `final-kzg/verifier.bin`, +writes `proof.envelope.bin`, `final-kzg/vk.bin`, `expected-root-claims.bin` and `pipeline-report.json`; failed stages retain their checkpoints. Shared cache environment variables override the default per-run `cache/` directory. BN254 has no bundled verifying key catalog entry. Its packet and verifying key versions are separate from BLS, and generating a key does not authorize -it. For local verification, set `BN254_PROFILE_DIGEST` to the independently -reviewed BLAKE3 digest of `final-kzg/verifier.bin`, then supply that pin, +it. For local verification, set `BN254_VK_DIGEST` to the independently +reviewed BLAKE3 digest of `final-kzg/vk.bin`, then supply that pin, the original claim and independently expected root claims: ```sh "$IX_COMPRESSION_WORKER" pipeline verify \ /path/to/bn254-run/proof.envelope.bin /path/to/claim.bin \ - /path/to/root/root-claims.bin /path/to/bn254-run/final-kzg/verifier.bin \ - "$BN254_PROFILE_DIGEST" --dev-srs + /path/to/root/root-claims.bin /path/to/bn254-run/final-kzg/vk.bin \ + "$BN254_VK_DIGEST" --dev-srs ``` Verification needs no proving SRS or cache. It checks the verifying key @@ -455,7 +455,7 @@ KZG proof and deferred pairing. The generated report records the verifying key digest for inspection; the verifier never obtains its trusted pin from the proof. The library equivalents are `compress`, `compress_fri` and -`verify_with_profile`. `ix verify` uses the catalog for its compiled curve; +`verify_with_vk`. `ix verify` uses the catalog for its compiled curve; BN254 has no authorized entries yet, so use the worker command above with an explicit verifying key for development verification. @@ -581,7 +581,7 @@ Fixed preprocessing and development SRS files are cached across runs under current aggregate key. Full proving needs hundreds of GiB of RAM. Verification uses the verifying keys compiled into the application from -`profiles/`. `ix verify` selects a verifying key by the packet's ID and derives +`verifying-keys/`. `ix verify` selects a verifying key by the packet's ID and derives the expected statement from that key's pinned aggregation-program prefix and the hash of the caller's serialized claim. It checks the compact proof and both pairing obligations without an external key file, registry, proving SRS @@ -608,7 +608,7 @@ backend instead and fails if a bundled key pins a different program than the build it runs in, so a stale bundle is caught where the backend is available. Compression does not require a verifying key to be bundled before proving. It -constructs its circuit, verifies its output and writes `final-kzg/verifier.bin` +constructs its circuit, verifies its output and writes `final-kzg/vk.bin` as an output of the run. That file can be reviewed and bundled in a later application release; generating it does not register it for verification. @@ -913,14 +913,14 @@ payloads use 32 bytes per coefficient: fixed coefficient bytes = 32 × sum(preprocessed_width × trace_height) ``` -For the bundled v5 recursive profiles, the computation trace has 15 fixed +For the bundled v5 recursive verifying keys, the computation trace has 15 fixed columns at `2^27` rows and the table trace has four at `2^17` rows. Their combined coefficient payload is **60.015625 GiB**, excluding checkpoint metadata. Together with the Filecoin file, this already exceeds 72 GiB before first-stage preprocessing, intermediate artifacts, and filesystem headroom. -This is a calculation from those profiles, not a measured disk high-water mark +This is a calculation from those verifying keys, not a measured disk high-water mark for the current code. First-stage size depends on the layout; record the -actual checkpoint sizes when provisioning a new profile. +actual checkpoint sizes when provisioning a new verifying key. On one filesystem, cache entries and run checkpoints share storage through hard links. Deleting one directory does not free those bytes while another @@ -930,7 +930,7 @@ eviction of old runs or cache entries. Retire them explicitly once no worker uses them. Use `df -h` on the selected mount to check available space. Before discarding ephemeral storage, preserve the stored proof with its claim -and `final-kzg/verifier.bin` on durable storage. Keep intermediate proofs and +and `final-kzg/vk.bin` on durable storage. Keep intermediate proofs and caches when they are useful for later runs. The 72 GiB `challenge_19` source is needed only for parameter import; normal proving uses the prefix cache. @@ -1067,11 +1067,11 @@ densely, while BLS used passes. GPU memory was sampled every two seconds and includes allocator reservations. Both stages verified natively, including the deferred -pairings. BN254's compact proofs, packets and profile match the completed +pairings. BN254's compact proofs, packets and verifying key match the completed CPU run byte for byte; its 1,917-byte envelope also verifies with that CPU -worker and its prior profile pin. The BLS envelope is 2,189 bytes and its -profile matches the existing bundled development-v6 profile. Both portable -envelopes verify with no GPU visible, and wrong profile pins and missing +worker and its prior verifying key pin. The BLS envelope is 2,189 bytes and its +verifying key matches the existing bundled development-v6 verifying key. Both portable +envelopes verify with no GPU visible, and wrong verifying key pins and missing development opt-in are rejected. The local record is `target/gpu-parity/init-comparison.json`. Frozen workers, @@ -1087,7 +1087,7 @@ BN254's fixed checkpoints total 114.79 GiB (84.77 GiB first stage and 30.02 GiB recursive). Generating and writing them took 122.39 seconds in the fixed-preparation spans. These figures favor local generation and reuse on this hardware; downloading that volume is not necessarily cheaper. -The current cache is trusted local data keyed by executable and profile. +The current cache is trusted local data keyed by executable and verifying key. Distributing checkpoints would additionally require authenticated manifests and an explicit compatibility contract. Ceremony SRS distribution is a separate concern. @@ -1111,9 +1111,9 @@ subtracting the cold fixed-preparation spans predicted 153.61 seconds, took 10.60 seconds for the first stage and 5.32 seconds for recursion; loading the saved coefficients during preparation adds further work. -The compact proofs, packets, profiles and final 1,917-byte envelope match +The compact proofs, packets, verifying keys and final 1,917-byte envelope match the cold run exactly. The original CPU worker independently verifies the -warm envelope with the original claim, expected root claims and profile +warm envelope with the original claim, expected root claims and verifying key pin, with no GPU visible. Peak process RSS was 175.28 GiB for the first stage and 63.17 GiB for recursion. @@ -1131,7 +1131,7 @@ NVMe. Cold means empty fixed preprocessing; warm means reused preprocessing. Both used fresh worker processes and already-provisioned parameters. OS caches were uncontrolled. This historical result used the Init FRI fixture from `40c990ca`; the current fixture is regenerated by the current CLI. Use identical -input fixtures when comparing binaries or profiles. +input fixtures when comparing binaries or verifying keys. | Stage | Cold | Warm | | --- | ---: | ---: | @@ -1159,10 +1159,10 @@ KZG commitment consumes the lowered circuit and assignment before proving. The remaining phase owns committed columns and verifier metadata, allowing frontend memory to be released before lookup and quotient allocation. Bundled and library recursive verification share validation and pairing checks. -A bundled profile retains its prepared system, pairing keys and compact codec. +A compiled-in verifying key retains its prepared system, pairing keys and compact codec. The library's `IXKZGV02` key encoding binds the transcript explicitly. -The bundled v5 profile and packet encodings are unchanged. +The bundled v5 verifying key and packet encodings are unchanged. ## Code and tests @@ -1173,7 +1173,7 @@ proof witness. `Prepared::prove_fri`, `prepare_kzg`, `Prepared::prove_kzg` and `prepare_recursive` compose the stages with caller-selected bounds and SRS. These APIs return verifier-only metadata and keep the expected claim separate from the proof. They do not register a verifier in the application's bundled -catalog. The CLI uses the worker and trusted profiles described above. +catalog. The CLI uses the worker and trusted verifying keys described above. `fri` constrains FRI verification, `foreign` translates Goldilocks arithmetic into scalar-field constraints, `kzg` implements the commitment backend, and @@ -1189,7 +1189,7 @@ lake build ix compression-cli-tests The lakefile compiles Cargo's C units as C17 so they stay compatible with Lean's bundled libc on hosts whose C compiler defaults to C23, and tracks -that flag and the embedded profile bytes when deciding whether to rebuild +that flag and the embedded verifying key bytes when deciding whether to rebuild the Rust archive. Tests cover small proving, tampering, CLI validation and proof identity. @@ -1198,4 +1198,4 @@ The CLI test verifies both checked-in packets in a fresh home without a proving SRS or verifier import. Development proofs require `--dev-srs`; that flag also selects development parameters when a Filecoin cache is configured. Tests reject modified claims, proofs and pairing data even with development opt-in, and -confirm that a local verifier registry cannot override the application's profiles. +confirm that a local verifier registry cannot override the application's verifying keys. diff --git a/crates/proof-compression/examples/fri_sizing.rs b/crates/proof-compression/examples/fri_sizing.rs index 3c58a6387..20be2d098 100644 --- a/crates/proof-compression/examples/fri_sizing.rs +++ b/crates/proof-compression/examples/fri_sizing.rs @@ -1,6 +1,6 @@ //! Size the first KZG stage's FRI verifier for alternative query counts //! without a proof: the verifier relation depends only on the key, the -//! profile and the FRI parameters, so a saved key can be re-parameterized +//! verifying key and the FRI parameters, so a saved key can be re-parameterized //! and counted directly. Arity and cap changes are not supported by the //! in-circuit verifier and cannot be sized this way. //! @@ -164,7 +164,7 @@ fn main() -> Result<()> { for &q in &queries { let start = Instant::now(); let key = with_queries(&key, q); - let profile = ProofProfile { + let vk = ProofProfile { envelope: Envelope::Ordinary, active: vec![true; key.system().circuits.len()], log_degrees: key @@ -177,8 +177,7 @@ fn main() -> Result<()> { message_lengths: vec![], max_field_retries: DEFAULT_FIELD_RETRIES, }; - let plan = - VerifierPlan::validate(&key, profile, VerifierLimits::default())?; + let plan = VerifierPlan::validate(&key, vk, VerifierLimits::default())?; let resources = plan.resources(); let (circuit, _inputs) = plan.build(schema.clone(), ImplementationOptions::default())?; diff --git a/crates/proof-compression/examples/kzg_backend_parity.rs b/crates/proof-compression/examples/kzg_backend_parity.rs index 6f834fbd0..903bea908 100644 --- a/crates/proof-compression/examples/kzg_backend_parity.rs +++ b/crates/proof-compression/examples/kzg_backend_parity.rs @@ -190,7 +190,7 @@ fn prove(out: &Path, mode: &str) -> Result<()> { }); fs::create_dir_all(out)?; fs::write(out.join("proof.bin"), &bytes)?; - fs::write(out.join("verifier.bin"), &verifier)?; + fs::write(out.join("vk.bin"), &verifier)?; fs::write(out.join("report.json"), serde_json::to_vec_pretty(&report)?)?; println!("{}", serde_json::to_string(&report)?); Ok(()) diff --git a/crates/proof-compression/examples/recursive_size.rs b/crates/proof-compression/examples/recursive_size.rs index b7b139a67..78eff53dc 100644 --- a/crates/proof-compression/examples/recursive_size.rs +++ b/crates/proof-compression/examples/recursive_size.rs @@ -2,7 +2,7 @@ use ix_proof_compression::{ kzg::{KzgConfig, Scalar, Srs, compact::FixedProofCodec}, - recursive::native_verifier::{self, Profile}, + recursive::native_verifier::{self, InnerVerifyingKey}, }; use multi_stark::{ config::ProofConfig, @@ -98,7 +98,7 @@ fn main() -> Result<(), Box> { .map_err(|error| format!("inner KZG verification failed: {error:?}"))?; let fixed = system.preprocessed_commit.as_ref().ok_or("missing fixed commitment")?; - let profile = Profile { + let vk = InnerVerifyingKey { circuits: &system.circuits, fixed, transcript_seed: system.config.transcript_seed(), @@ -108,8 +108,7 @@ fn main() -> Result<(), Box> { tau_g2: srs.tau_g2, max_log_degree: system.config.max_log_degree(), }; - let counts = - native_verifier::count(&profile, &proof, &claims, &[(0, 1), (0, 2)])?; + let counts = native_verifier::count(&vk, &proof, &claims, &[(0, 1), (0, 2)])?; let compact = FixedProofCodec::new(&system, &proof.log_degrees)?.encode(&proof)?; let mut previous = CircuitStats::default(); diff --git a/crates/proof-compression/src/kzg/compact.rs b/crates/proof-compression/src/kzg/compact.rs index bae56f071..686c99c5a 100644 --- a/crates/proof-compression/src/kzg/compact.rs +++ b/crates/proof-compression/src/kzg/compact.rs @@ -1,4 +1,4 @@ -//! Fixed-profile proof transport. The verifier supplies the system and heights; +//! Fixed-verifying key proof transport. The verifier supplies the system and heights; //! this codec omits their redundant shape metadata, not cryptographic checks. #[cfg(test)] @@ -27,12 +27,12 @@ pub struct CompactProofError; impl std::fmt::Display for CompactProofError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("invalid compact KZG proof or profile") + f.write_str("invalid compact KZG proof or verifying key") } } impl std::error::Error for CompactProofError {} -/// Balanced ordinary proofs with all circuits active. Authenticate the system/profile separately and +/// Balanced ordinary proofs with all circuits active. Authenticate the system/verifying key separately and /// run normal proof verification after decoding; decoding is not verification. pub struct FixedProofCodec { template: Proof, diff --git a/crates/proof-compression/src/kzg/srs/ignition/admission.rs b/crates/proof-compression/src/kzg/srs/ignition/admission.rs index 2f632ba79..a3c2aa047 100644 --- a/crates/proof-compression/src/kzg/srs/ignition/admission.rs +++ b/crates/proof-compression/src/kzg/srs/ignition/admission.rs @@ -119,7 +119,7 @@ impl IgnitionAdmission { } /// Authenticate the exact admission bytes before parsing. The trusted pin - /// must be configured separately from the proof, profile and admission file. + /// must be configured separately from the proof, verifying key and admission file. pub fn from_bytes( bytes: &[u8], trusted_admission: [u8; 32], diff --git a/crates/proof-compression/src/pipeline/driver.rs b/crates/proof-compression/src/pipeline/driver.rs index 0cd95322b..b34136608 100644 --- a/crates/proof-compression/src/pipeline/driver.rs +++ b/crates/proof-compression/src/pipeline/driver.rs @@ -1,4 +1,4 @@ -//! Saved-proof compression and verification with an explicitly pinned profile. +//! Saved-proof compression and verification with an explicitly pinned verifying key. use super::{ Result, @@ -8,7 +8,7 @@ use super::{ use crate::kzg::curve; use std::{fs, path::Path}; -const USAGE: &str = "usage: ix-compression-worker pipeline run [24|25] [--dev-srs]\n ix-compression-worker pipeline run-fri [24|25] [--dev-srs]\n ix-compression-worker pipeline verify [--dev-srs]\n ix-compression-worker pipeline verify-ignition "; +const USAGE: &str = "usage: ix-compression-worker pipeline run [24|25] [--dev-srs]\n ix-compression-worker pipeline run-fri [24|25] [--dev-srs]\n ix-compression-worker pipeline verify [--dev-srs]\n ix-compression-worker pipeline verify-ignition "; fn arguments(args: &[String]) -> Result<(Vec<&str>, bool)> { let mut positional = Vec::new(); @@ -40,7 +40,7 @@ fn height(args: &[&str]) -> Result { fn digest(text: &str) -> Result<[u8; 32]> { if text.len() != 64 || !text.is_ascii() { return Err( - "trusted profile BLAKE3 must contain exactly 64 hexadecimal characters" + "trusted verifying key BLAKE3 must contain exactly 64 hexadecimal characters" .into(), ); } @@ -59,36 +59,31 @@ fn publish( source: &str, setup: &SetupConfig, ) -> Result<()> { - let profile_path = out.join("final-kzg/verifier.bin"); - let profile = fs::read(&profile_path)?; - let profile_digest = *blake3::hash(&profile).as_bytes(); + let vk_path = out.join("final-kzg/vk.bin"); + let vk = fs::read(&vk_path)?; + let vk_digest = *blake3::hash(&vk).as_bytes(); #[cfg(feature = "bn254")] let verified_setup = if let Some(admission) = setup.ignition_admission() { super::verify_with_ignition_admission( - envelope, - claim, - expected, - &profile, - &profile_digest, - admission, + envelope, claim, expected, &vk, &vk_digest, admission, )? } else { - super::verify_with_profile( + super::verify_with_vk( envelope, claim, expected, - &profile, - &profile_digest, + &vk, + &vk_digest, setup.class() == SetupClass::Development, )? }; #[cfg(not(feature = "bn254"))] - let verified_setup = super::verify_with_profile( + let verified_setup = super::verify_with_vk( envelope, claim, expected, - &profile, - &profile_digest, + &vk, + &vk_digest, setup.class() == SetupClass::Development, )?; if verified_setup != setup.class() { @@ -107,8 +102,8 @@ fn publish( "native_verification_passed": true, "proof_bytes": envelope.len(), "proof_blake3": blake3::hash(envelope).to_hex().as_str(), - "profile": "final-kzg/verifier.bin", - "profile_blake3": blake3::Hash::from_bytes(profile_digest).to_hex().as_str(), + "vk": "final-kzg/vk.bin", + "vk_blake3": blake3::Hash::from_bytes(vk_digest).to_hex().as_str(), "expected_root_claims": "expected-root-claims.bin", }); storage::write_atomic( @@ -165,7 +160,7 @@ pub(super) fn run(args: &[String]) -> Result<()> { #[cfg(feature = "bn254")] ( "verify-ignition", - [proof, claim, expected, profile, trusted, admission, admitted], + [proof, claim, expected, vk, trusted, admission, admitted], ) => { if development { return Err("verify-ignition does not accept --dev-srs".into()); @@ -180,7 +175,7 @@ pub(super) fn run(args: &[String]) -> Result<()> { &fs::read(proof)?, &fs::read(claim)?, &fs::read(expected)?, - &fs::read(profile)?, + &fs::read(vk)?, &trusted, &admission, )?; @@ -190,19 +185,19 @@ pub(super) fn run(args: &[String]) -> Result<()> { "curve": curve::NAME, "verified": true, "setup": class.label(), - "profile_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), + "vk_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), "admission_blake3": blake3::Hash::from_bytes(admitted).to_hex().as_str(), }) ); Ok(()) }, - ("verify", [proof, claim, expected, profile, trusted]) => { + ("verify", [proof, claim, expected, vk, trusted]) => { let trusted = digest(trusted)?; - let class = super::verify_with_profile( + let class = super::verify_with_vk( &fs::read(proof)?, &fs::read(claim)?, &fs::read(expected)?, - &fs::read(profile)?, + &fs::read(vk)?, &trusted, development, )?; @@ -212,7 +207,7 @@ pub(super) fn run(args: &[String]) -> Result<()> { "curve": curve::NAME, "verified": true, "setup": class.label(), - "profile_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), + "vk_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), }) ); Ok(()) @@ -226,7 +221,7 @@ mod tests { use super::*; #[test] - fn options_and_profile_digest_are_explicit() { + fn options_and_vk_digest_are_explicit() { let args = ["run", "root", "claim", "out", "--dev-srs"].map(str::to_owned); let (positional, development) = arguments(&args).unwrap(); assert_eq!(positional, ["run", "root", "claim", "out"]); diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index 270f5e4ab..7a62b6dab 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -25,7 +25,7 @@ use super::{ use super::{init_claim, setup::SetupSource}; use super::{init_fri, prover}; -/// Partitions of the supported Init profile: ten carry the public words and +/// Partitions of the supported Init layout: ten carry the public words and /// the rest are tables, which only exist above eleven partitions. pub(super) const MIN_PARTITIONS: usize = 11; pub(super) use super::stages::statement::first_claims as expected_claims; @@ -168,7 +168,7 @@ fn stage_and_prove( /// Commit the fixed and witness traces of every partition, prove them /// together, verify the proof and write it with its packet. Fixed -/// preprocessing comes from the cache when it holds this profile and is +/// preprocessing comes from the cache when it holds this verifying key and is /// published to it after the proof verifies. fn prove( dir: &Path, @@ -230,27 +230,27 @@ fn prove( .commit(config, Some(&out), prefetch_bytes)? .prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); - // The label is part of every published profile id and predates the + // The label is part of every published verifying key id and predates the // Filecoin setup; the setup class is bound through the transcript seed. - let mut profile = blake3::Hasher::new(); + let mut vk = blake3::Hasher::new(); #[cfg(not(feature = "bn254"))] - profile.update(b"init-fri-kzg-ordinary-v1/development-srs/quotient2"); + vk.update(b"init-fri-kzg-ordinary-v1/development-srs/quotient2"); #[cfg(feature = "bn254")] - profile.update(b"init-fri-kzg-bn254/v1/development-srs/quotient2"); - profile.update(system.config.transcript_seed()); - profile.update(&plan_id); - profile.update(&fs::read(dir.join("manifest.bin"))?); + vk.update(b"init-fri-kzg-bn254/v1/development-srs/quotient2"); + vk.update(system.config.transcript_seed()); + vk.update(&plan_id); + vk.update(&fs::read(dir.join("manifest.bin"))?); for i in 0..count { - profile.update(&fs::read(out.join(format!("setup-{i}.bin")))?); + vk.update(&fs::read(out.join(format!("setup-{i}.bin")))?); } - let profile = *profile.finalize().as_bytes(); - let mut packet = profile.to_vec(); + let vk = *vk.finalize().as_bytes(); + let mut packet = vk.to_vec(); for claim in &manifest.claims[1..19] { packet.extend_from_slice(&claim[3].canonical_limbs_le()[0].to_le_bytes()); } packet.extend_from_slice(&bytes); storage::write_atomic(&out.join("proof.compact.bin"), &bytes)?; - storage::write_atomic(&out.join("profile-id.bin"), &profile)?; + storage::write_atomic(&out.join("vk-id.bin"), &vk)?; storage::write_atomic(&out.join("packet.bin"), &packet)?; let development = setup.class() == SetupClass::Development; let public_setup = system.config.pcs().public_setup(); @@ -307,7 +307,7 @@ mod tests { fn artifacts(directory: &Path) -> storage::Result>> { Ok( - ["proof.compact.bin", "packet.bin", "profile-id.bin"] + ["proof.compact.bin", "packet.bin", "vk-id.bin"] .into_iter() .map(|name| fs::read(directory.join("kzg").join(name))) .collect::>()?, @@ -372,7 +372,7 @@ mod tests { SetupSource::development(FIRST_SEED, height - 1)? .bind_stage(dir, height, 2)?; fs::write(dir.join("plan-id.bin"), [0u8; 32])?; - let cache = FixedCache::from_profile(cache_root, b"fixture-v1")?; + let cache = FixedCache::from_identity(cache_root, b"fixture-v1")?; let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( compiled, @@ -439,11 +439,10 @@ mod tests { } prove(&warm, &cache, 9)?; assert_eq!(first, artifacts(&warm)?); - let different = - FixedCache::from_profile(&cache_root, b"different-profile")?; + let different = FixedCache::from_identity(&cache_root, b"different-vk")?; assert!(different.restore(&root.join("miss"))?.is_none()); - // A different witness changes the proof but not the profile. + // A different witness changes the proof but not the verifying key. let changed = root.join("changed"); let cache = stage(&changed, compiled, public, &cache_root)?; prove(&changed, &cache, 231)?; @@ -512,7 +511,7 @@ mod tests { let [entry] = entries.as_slice() else { panic!("expected one published entry, found {entries:?}"); }; - let cache = FixedCache::from_profile(&cache_root, b"fixture-v1")?; + let cache = FixedCache::from_identity(&cache_root, b"fixture-v1")?; let damaged = root.join("damaged"); for name in ["kzg/fixed-0.bin", "kzg/setup-0.bin"] { let published = entry.join(name); diff --git a/crates/proof-compression/src/pipeline/fixed_cache.rs b/crates/proof-compression/src/pipeline/fixed_cache.rs index ba95a992f..31ff5d963 100644 --- a/crates/proof-compression/src/pipeline/fixed_cache.rs +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -1,6 +1,6 @@ //! Trusted local preprocessing, independent of each proof's witness and //! openings. Entries are keyed by the executable digest and the stage -//! profile, so a rebuilt binary never reuses another build's preprocessing. +//! verifying key, so a rebuilt binary never reuses another build's preprocessing. //! The root is `IX_KZG_FIXED_CACHE`, or `/kzg/fixed-cache`. use super::storage::{self, Manifest, Result}; use serde::{Deserialize, Serialize}; @@ -57,14 +57,14 @@ fn file_digest(path: &Path) -> io::Result<[u8; 32]> { impl FixedCache { /// The executable digest conservatively binds the compiler, lowering, /// dependency versions, SRS recipe, and all circuit implementation choices. - pub(crate) fn from_profile(root: &Path, profile: &[u8]) -> Result { + pub(crate) fn from_identity(root: &Path, vk: &[u8]) -> Result { let mut hasher = blake3::Hasher::new(); #[cfg(not(feature = "bn254"))] hasher.update(b"ix/kzg-fixed-cache/v5"); #[cfg(feature = "bn254")] hasher.update(b"ix/kzg-fixed-cache/bn254/v1"); hasher.update(&executable_digest()?); - hasher.update(profile); + hasher.update(vk); let identity = *hasher.finalize().as_bytes(); Ok(Self::new(root, identity)) } diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 4c1325c31..e3ae4da5d 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -91,13 +91,13 @@ impl WorkerConfig { fn fixed_cache( &self, stage: &Path, - profile: &[u8], + vk: &[u8], ) -> Result { let root = self .fixed_cache_root .clone() .unwrap_or_else(|| stage.join("kzg/fixed-cache")); - fixed_cache::FixedCache::from_profile(&root, profile) + fixed_cache::FixedCache::from_identity(&root, vk) } #[cfg(test)] @@ -278,7 +278,7 @@ pub fn setup_description(development: bool) -> Result<&'static str> { Ok(setup::SetupConfig::from_env(development)?.class().description()) } -/// Verify using a bundled KZG profile or an independently configured PLONK +/// Verify using a bundled KZG verifying key or an independently configured PLONK /// verification key. Proving parameters are not required. pub fn verify( proof: &[u8], @@ -295,31 +295,31 @@ pub fn verify( verifier::verify(packet, claim, allow_development) } -/// Verify with a caller-authorized profile digest and independently expected +/// Verify with a caller-authorized verifying key digest and independently expected /// root statement. No proving parameters or bundled catalog entry are needed. -pub fn verify_with_profile( +pub fn verify_with_vk( proof: &[u8], claim: &[u8], expected_claims: &[u8], - profile: &[u8], - trusted_profile_digest: &[u8; 32], + vk: &[u8], + trusted_vk_digest: &[u8; 32], allow_development: bool, ) -> Result { let words = check_claim(claim, expected_claims)?; let packet = proof .strip_prefix(PROOF_TAG) .ok_or("unsupported compressed proof curve or version")?; - verifier::verify_with_profile( + verifier::verify_with_vk( packet, claim, &words, - profile, - trusted_profile_digest, + vk, + trusted_vk_digest, allow_development, ) } -/// Verify with an independently authorized profile and Ignition admission. +/// Verify with an independently authorized verifying key and Ignition admission. /// Both outer and deferred pairing keys must match the admitted parameters. /// No proving cache or setup environment variables are read. #[cfg(feature = "bn254")] @@ -327,8 +327,8 @@ pub fn verify_with_ignition_admission( proof: &[u8], claim: &[u8], expected_claims: &[u8], - profile: &[u8], - trusted_profile_digest: &[u8; 32], + vk: &[u8], + trusted_vk_digest: &[u8; 32], admission: &crate::kzg::srs::ignition::IgnitionAdmission, ) -> Result { let words = check_claim(claim, expected_claims)?; @@ -339,14 +339,14 @@ pub fn verify_with_ignition_admission( packet, claim, &words, - profile, - trusted_profile_digest, + vk, + trusted_vk_digest, admission, ) } /// Cross-run caches the stages share: fixed preprocessing keyed by the -/// executable and profile, and development SRS files. Variables already set +/// executable and verifying key, and development SRS files. Variables already set /// in the environment take precedence over the directory given to `compress`. struct Caches { fixed: PathBuf, @@ -385,7 +385,7 @@ fn validate_compression_options( } if !matches!(log_height, 24 | 25) { return Err( - "--log-height must be 24 or 25 for the supported recursive profiles" + "--log-height must be 24 or 25 for the supported recursive verifying keys" .into(), ); } @@ -394,7 +394,7 @@ fn validate_compression_options( let setup = setup::SetupConfig::from_env(development)?; if std::env::var_os("IX_ROOT_GENERIC_HASHES").is_some() { return Err( - "unset IX_ROOT_GENERIC_HASHES for the supported compression profile" + "unset IX_ROOT_GENERIC_HASHES for the supported compression verifying key" .into(), ); } @@ -509,26 +509,25 @@ fn finish_kzg( let mut result = PROOF_TAG.to_vec(); result.extend(fs::read(final_dir.join("kzg/packet.bin"))?); let packet = &result[PROOF_TAG.len()..]; - let profile = wrap::VerifierProfile::from_bytes(&fs::read( - final_dir.join("verifier.bin"), - )?)?; + let vk = + wrap::VerifyingKey::from_bytes(&fs::read(final_dir.join("vk.bin"))?)?; #[cfg(feature = "bn254")] if let Some(admission) = setup.ignition_admission() { - profile.check_ignition_admission(admission)?; + vk.check_ignition_admission(admission)?; } else { - profile.check_setup_class(setup.class())?; + vk.check_setup_class(setup.class())?; } #[cfg(not(feature = "bn254"))] - profile.check_setup_class(setup.class())?; - profile.verify(packet, words)?; - // A bundled profile pins the aggregation program its proofs attest; the + vk.check_setup_class(setup.class())?; + vk.verify(packet, words)?; + // A bundled verifying key pins the aggregation program its proofs attest; the // backend this run proved under must be that program, or the bundle is // stale and verification elsewhere would accept the wrong statement. if let Some(prefix) = verifier::bundled_prefix(packet[..32].try_into()?) && *prefix != words[..10] { return Err( - "the bundled verifier profile pins a different aggregation program \ + "the bundled verifier vk pins a different aggregation program \ than this build's backend; update the bundle" .into(), ); @@ -539,7 +538,7 @@ fn finish_kzg( let out_plonk = out.join("plonk"); let expected_path = final_dir.join("expected-statement.json"); storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; - let verifier = final_dir.join("verifier.bin"); + let verifier = final_dir.join("vk.bin"); let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); child( "plonk", @@ -600,7 +599,7 @@ mod tests { } #[test] - fn explicit_profile_verification_rejects_other_curve_envelopes() { + fn explicit_vk_verification_rejects_other_curve_envelopes() { let expected = statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); let other = if cfg!(feature = "bn254") { @@ -608,9 +607,8 @@ mod tests { } else { b"IXBN\0\0\0\x01" }; - let error = - verify_with_profile(other, b"claim", &expected, b"", &[0; 32], true) - .unwrap_err(); + let error = verify_with_vk(other, b"claim", &expected, b"", &[0; 32], true) + .unwrap_err(); assert!(error.to_string().contains("curve or version")); } diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 323285839..6f42f04ba 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -46,7 +46,7 @@ struct TrustedCatalog { struct TrustedKey { sha256: String, path: PathBuf, - inner_profile_id: String, + inner_vk_id: String, statement_prefix: [u64; 10], circuit_sha256: String, helper_sha256: String, @@ -218,7 +218,7 @@ fn select_key(path: &Path, id: &[u8; 32]) -> Result { let mut seen = std::collections::BTreeSet::new(); for key in catalog.keys { let key_id = digest(&key.sha256)?; - digest(&key.inner_profile_id)?; + digest(&key.inner_vk_id)?; digest(&key.circuit_sha256)?; digest(&key.helper_sha256)?; if !seen.insert(key_id) { @@ -292,7 +292,9 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { } pub(crate) fn run(args: &[String]) -> Result<()> { - match args.iter().map(String::as_str).collect::>().as_slice() { + let development = args.iter().any(|arg| arg == "--dev-srs"); + let args: Vec<_> = args.iter().filter(|arg| *arg != "--dev-srs").collect(); + match args.iter().map(|s| s.as_str()).collect::>().as_slice() { ["verify", proof, claim] => { let bytes = fs::read(proof)?; let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported PLONK proof curve or version")?; @@ -304,21 +306,25 @@ pub(crate) fn run(args: &[String]) -> Result<()> { let expected = serde_json::from_slice(&fs::read(statement)?)?; wrap::plonk_export(Path::new(verifier), Path::new(packet), digest, &expected, Path::new(out)) }, + ["export-first", stage, statement, out] => { + let expected = serde_json::from_slice(&fs::read(statement)?)?; + wrap::plonk_export_first(Path::new(stage), &expected, Path::new(out), development) + }, ["srs", domain, out] => export_srs(domain.parse()?, Path::new(out)), #[cfg(not(feature = "bn254"))] - ["profile-blake3", source, trusted, out] => { + ["vk-blake3", source, trusted, out] => { let source = fs::read(source)?; - if *blake3::hash(&source).as_bytes() != digest(trusted)? { return Err("trusted source profile digest differs".into()); } - let (bytes, id) = wrap::VerifierProfile::reencode_blake3(&source)?; + if *blake3::hash(&source).as_bytes() != digest(trusted)? { return Err("trusted source verifying key digest differs".into()); } + let (bytes, id) = wrap::VerifyingKey::reencode_blake3(&source)?; storage::write_atomic(Path::new(out), &bytes)?; - eprintln!("profile_id={} verifier_blake3={}", hex(&id), blake3::hash(&bytes).to_hex()); + eprintln!("vk_id={} verifier_blake3={}", hex(&id), blake3::hash(&bytes).to_hex()); Ok(()) }, ["wrap", verifier, packet, trusted, expected, cache, out] => { let expected = load_json(Path::new(expected))?; wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(cache), Path::new(out)) }, - _ => Err("usage: ix-compression-worker plonk export | srs | wrap | verify ".into()), + _ => Err("usage: ix-compression-worker plonk export | export-first [--dev-srs] | srs | wrap | verify ".into()), } } @@ -469,17 +475,17 @@ fn wrap_packet( } let input = out.join("input"); wrap::plonk_export(verifier, packet, trusted_digest, expected, &input)?; - let profile_path = input.join("profile.json"); - let exported: serde_json::Value = load_json(&profile_path)?; + let vk_path = input.join("vk.json"); + let exported: serde_json::Value = load_json(&vk_path)?; let mut identity = blake3::Hasher::new(); identity.update(b"ix/bls-plonk-cache/v1"); identity.update(&fixed_cache::executable_digest()?); identity.update(&sha256(&helper)?); - identity.update(&sha256(&profile_path)?); + identity.update(&sha256(&vk_path)?); identity.update(&manifest); let cache = cache_root.join("circuits").join(identity.finalize().to_hex().as_str()); - checked(Command::new(&helper).arg("compile").arg(&profile_path).arg(&cache))?; + checked(Command::new(&helper).arg("compile").arg(&vk_path).arg(&cache))?; let mut metadata: serde_json::Value = load_json(&cache.join("manifest.json"))?; let domain = usize::try_from( @@ -553,7 +559,7 @@ fn wrap_packet( keys: vec![TrustedKey { sha256: key_hash.into(), path: "verifying-key.bin".into(), - inner_profile_id: metadata_string(&exported, "profileId")?.into(), + inner_vk_id: metadata_string(&exported, "vkId")?.into(), statement_prefix: expected[..10].try_into()?, circuit_sha256: metadata_string( &metadata["Files"]["circuit.bin"], @@ -588,8 +594,8 @@ mod tests { fn key() -> TrustedKey { TrustedKey { sha256: hex(&[1; 32]), - path: "verifier.bin".into(), - inner_profile_id: hex(&[2; 32]), + path: "vk.bin".into(), + inner_vk_id: hex(&[2; 32]), statement_prefix: [0; 10], circuit_sha256: hex(&[3; 32]), helper_sha256: hex(&[4; 32]), @@ -609,10 +615,7 @@ mod tests { storage::write_atomic(&path, &serde_json::to_vec(catalog).unwrap()) }; write(&catalog)?; - assert_eq!( - select_key(&path, &[1; 32])?.path, - scratch.0.join("verifier.bin") - ); + assert_eq!(select_key(&path, &[1; 32])?.path, scratch.0.join("vk.bin")); assert!(select_key(&path, &[5; 32]).is_err()); catalog.keys.push(key()); write(&catalog)?; @@ -671,7 +674,7 @@ mod bn254_tests { keys: vec![TrustedKey { sha256: hex(&[1; 32]), path: "key.bin".into(), - inner_profile_id: hex(&[2; 32]), + inner_vk_id: hex(&[2; 32]), statement_prefix: [0; 10], circuit_sha256: hex(&[3; 32]), helper_sha256: hex(&[4; 32]), diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 2abcbed12..76dcd1686 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -69,7 +69,7 @@ pub enum SetupClass { impl SetupClass { /// Filecoin only when the identity, public degree and all four anchor /// points equal the pinned ceremony values; an identity alone is just - /// bytes any profile could carry. + /// bytes any verifying key could carry. #[cfg(not(feature = "bn254"))] pub fn of(params: &VerifierParams) -> Self { let pinned = filecoin_anchors(); @@ -540,7 +540,7 @@ impl SetupSource { ) } - /// The setup identity bound into checkpoints and fixed-cache profiles. + /// The setup identity bound into checkpoints and fixed-cache verifying keys. pub(crate) fn identity( &self, height: usize, diff --git a/crates/proof-compression/src/pipeline/stages/setup.rs b/crates/proof-compression/src/pipeline/stages/setup.rs index 20bab4aa4..ee529a790 100644 --- a/crates/proof-compression/src/pipeline/stages/setup.rs +++ b/crates/proof-compression/src/pipeline/stages/setup.rs @@ -46,10 +46,9 @@ pub(super) fn fri( } pub(super) fn foreign(verifier: &FriVerifier) -> Result> { - let profile = - construct::fri_profile(&verifier.key, &verifier.statement.template)?; + let vk = construct::fri_profile(&verifier.key, &verifier.statement.template)?; let plan = - fri::VerifierPlan::validate(&verifier.key, profile, Default::default())?; + fri::VerifierPlan::validate(&verifier.key, vk, Default::default())?; let built = construct::FriConstruction::build(&plan, verifier.statement.schema())?; Ok(construct::translate(&built.circuit).circuit) diff --git a/crates/proof-compression/src/pipeline/stages/verification.rs b/crates/proof-compression/src/pipeline/stages/verification.rs index b93dff520..ab1f74f00 100644 --- a/crates/proof-compression/src/pipeline/stages/verification.rs +++ b/crates/proof-compression/src/pipeline/stages/verification.rs @@ -1,4 +1,4 @@ -//! Prepared recursive verification shared by local and bundled profiles. +//! Prepared recursive verification shared by local and bundled verifying keys. use super::{ KzgVerifier, Result, scalar_statement, statement::Statement, wrap::RecursiveProof, diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs index e64d4b69a..a7b727ab9 100644 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -1,11 +1,11 @@ -//! Verifier profiles authenticated by a release bundle or an independent -//! digest. Bundles pin an aggregation program; local profiles require its +//! Verifier verifying keys authenticated by a release bundle or an independent +//! digest. Bundles pin an aggregation program; local verifying keys require its //! independently supplied statement as well as the certificate claim. use super::{ Result, setup::SetupClass, statement, - wrap::{PreparedVerifierProfile, VerifierProfile}, + wrap::{PreparedVerifyingKey, VerifyingKey}, }; use std::sync::OnceLock; @@ -13,13 +13,13 @@ struct BundledVerifier { id: &'static str, digest: &'static str, /// Backend and entrypoint words of the aggregation program whose roots - /// this profile compresses. + /// this verifying key compresses. statement_prefix: [u64; 10], setup: SetupClass, #[cfg(feature = "bn254")] ignition_admission: Option, bytes: &'static [u8], - decoded: OnceLock>, + decoded: OnceLock>, } #[cfg(feature = "bn254")] @@ -51,7 +51,7 @@ static VERIFIERS: [BundledVerifier; 4] = [ digest: "12ce28356f86ba34afb11b8e3b2c142f47ab9259e9c3f5751069bd1c41609084", statement_prefix: AGGREGATE_PREFIX, setup: SetupClass::Filecoin, - bytes: include_bytes!("../../profiles/aggregate-filecoin-v6.bin"), + bytes: include_bytes!("../../verifying-keys/aggregate-filecoin-v6.bin"), decoded: OnceLock::new(), }, BundledVerifier { @@ -59,7 +59,7 @@ static VERIFIERS: [BundledVerifier; 4] = [ digest: "116c668b17db0a1bd95ea8cce9d080af14b16031db128c47a3dcd46443147678", statement_prefix: AGGREGATE_PREFIX, setup: SetupClass::Development, - bytes: include_bytes!("../../profiles/aggregate-development-v6.bin"), + bytes: include_bytes!("../../verifying-keys/aggregate-development-v6.bin"), decoded: OnceLock::new(), }, BundledVerifier { @@ -67,7 +67,7 @@ static VERIFIERS: [BundledVerifier; 4] = [ digest: "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", statement_prefix: AGGREGATE_PREFIX, setup: SetupClass::Filecoin, - bytes: include_bytes!("../../profiles/aggregate-filecoin-v5.bin"), + bytes: include_bytes!("../../verifying-keys/aggregate-filecoin-v5.bin"), decoded: OnceLock::new(), }, BundledVerifier { @@ -75,7 +75,7 @@ static VERIFIERS: [BundledVerifier; 4] = [ digest: "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", statement_prefix: AGGREGATE_PREFIX, setup: SetupClass::Development, - bytes: include_bytes!("../../profiles/aggregate-development-v5.bin"), + bytes: include_bytes!("../../verifying-keys/aggregate-development-v5.bin"), decoded: OnceLock::new(), }, ]; @@ -94,7 +94,7 @@ impl BundledVerifier { Ok(()) } - fn profile(&self) -> Result<&PreparedVerifierProfile> { + fn vk(&self) -> Result<&PreparedVerifyingKey> { self .decoded .get_or_init(|| { @@ -103,12 +103,10 @@ impl BundledVerifier { if blake3::hash(self.bytes).to_hex().as_str() != self.digest { return Err("bundled compression verifier digest differs".into()); } - let profile = VerifierProfile::from_bytes(self.bytes) + let vk = VerifyingKey::from_bytes(self.bytes) .map_err(|error| error.to_string())?; #[cfg(not(feature = "bn254"))] - profile - .check_setup_class(self.setup) - .map_err(|error| error.to_string())?; + vk.check_setup_class(self.setup).map_err(|error| error.to_string())?; #[cfg(feature = "bn254")] { let admission = self @@ -117,14 +115,13 @@ impl BundledVerifier { .ok_or("bundled Ignition verifier has no trusted admission")? .decode() .map_err(|error| error.to_string())?; - profile - .check_ignition_admission(&admission) + vk.check_ignition_admission(&admission) .map_err(|error| error.to_string())?; } - if blake3::Hash::from_bytes(profile.id()).to_hex().as_str() != self.id { + if blake3::Hash::from_bytes(vk.id()).to_hex().as_str() != self.id { return Err("bundled compression verifier metadata differs".into()); } - profile.prepare().map_err(|error| error.to_string()) + vk.prepare().map_err(|error| error.to_string()) }) .as_ref() .map_err(|error| error.clone().into()) @@ -136,7 +133,7 @@ fn bundled(id: &[u8; 32]) -> Option<&'static BundledVerifier> { VERIFIERS.iter().find(|verifier| verifier.id == id.as_str()) } -/// The statement prefix a bundled profile pins, if `id` names one. +/// The statement prefix a bundled verifying key pins, if `id` names one. pub(super) fn bundled_prefix(id: &[u8; 32]) -> Option<&'static [u64; 10]> { bundled(id).map(|verifier| &verifier.statement_prefix) } @@ -149,7 +146,7 @@ pub(super) fn verify( let id: [u8; 32] = packet.get(..32).ok_or("truncated compressed proof")?.try_into()?; let verifier = bundled(&id).ok_or( - "compressed proof uses a verifier profile not bundled with this application", + "compressed proof uses a verifying key not bundled with this application", )?; #[cfg(feature = "bn254")] verifier.check_release_class()?; @@ -157,55 +154,55 @@ pub(super) fn verify( return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); } let statement = statement::with_claim(&verifier.statement_prefix, claim); - verifier.profile()?.verify(packet, &statement)?; + verifier.vk()?.verify(packet, &statement)?; Ok(verifier.setup) } -/// Verify using an independently authenticated profile and expected statement. -pub(super) fn verify_with_profile( +/// Verify using an independently authenticated verifying key and expected statement. +pub(super) fn verify_with_vk( packet: &[u8], claim: &[u8], expected: &statement::Statement, - profile: &[u8], + vk: &[u8], trusted_digest: &[u8; 32], allow_development: bool, ) -> Result { - if blake3::hash(profile).as_bytes() != trusted_digest { + if blake3::hash(vk).as_bytes() != trusted_digest { return Err( - "local verifier profile digest differs from the trusted digest".into(), + "local verifying key digest differs from the trusted digest".into(), ); } statement::bind_claim(claim, expected)?; - let profile = VerifierProfile::from_bytes(profile)?; - let setup = SetupClass::of(&profile.verifier_params()?); - profile.check_setup_class(setup)?; + let vk = VerifyingKey::from_bytes(vk)?; + let setup = SetupClass::of(&vk.verifier_params()?); + vk.check_setup_class(setup)?; if setup == SetupClass::Development && !allow_development { return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); } - profile.verify(packet, expected)?; + vk.verify(packet, expected)?; Ok(setup) } -/// Verify with an independently authenticated profile and ceremony admission. +/// Verify with an independently authenticated verifying key and ceremony admission. /// The admission contains the verifier anchors; no proving cache is required. #[cfg(feature = "bn254")] pub(super) fn verify_with_ignition_admission( packet: &[u8], claim: &[u8], expected: &statement::Statement, - profile: &[u8], + vk: &[u8], trusted_digest: &[u8; 32], admission: &crate::kzg::srs::ignition::IgnitionAdmission, ) -> Result { - if blake3::hash(profile).as_bytes() != trusted_digest { + if blake3::hash(vk).as_bytes() != trusted_digest { return Err( - "local verifier profile digest differs from the trusted digest".into(), + "local verifying key digest differs from the trusted digest".into(), ); } statement::bind_claim(claim, expected)?; - let profile = VerifierProfile::from_bytes(profile)?; - profile.check_ignition_admission(admission)?; - profile.verify(packet, expected)?; + let vk = VerifyingKey::from_bytes(vk)?; + vk.check_ignition_admission(admission)?; + vk.verify(packet, expected)?; Ok(SetupClass::Ignition) } @@ -226,12 +223,12 @@ mod tests { ( PACKET, SetupClass::Filecoin, - include_str!("../../profiles/aggregate-filecoin-v5.json"), + include_str!("../../verifying-keys/aggregate-filecoin-v5.json"), ), ( DEVELOPMENT_PACKET, SetupClass::Development, - include_str!("../../profiles/aggregate-development-v5.json"), + include_str!("../../verifying-keys/aggregate-development-v5.json"), ), ] } @@ -249,16 +246,16 @@ mod tests { } #[test] - fn blake3_profiles_derive_from_authenticated_circuit_metadata() { + fn blake3_vks_derive_from_authenticated_circuit_metadata() { for (source, target) in [(2, 0), (3, 1)] { let (bytes, id) = - VerifierProfile::reencode_blake3(VERIFIERS[source].bytes).unwrap(); + VerifyingKey::reencode_blake3(VERIFIERS[source].bytes).unwrap(); assert_eq!(bytes, VERIFIERS[target].bytes); assert_eq!( blake3::Hash::from_bytes(id).to_hex().as_str(), VERIFIERS[target].id ); - VERIFIERS[target].profile().unwrap(); + VERIFIERS[target].vk().unwrap(); } } @@ -274,7 +271,7 @@ mod tests { } #[test] - fn development_profile_requires_explicit_opt_in() { + fn development_vk_requires_explicit_opt_in() { assert!( verify(&envelope(DEVELOPMENT_PACKET), CLAIM, false) .unwrap_err() @@ -298,7 +295,7 @@ mod tests { assert_eq!(verifier.setup, setup); assert_eq!(metadata["setup"], setup.label()); assert_eq!(metadata["known_trapdoor"], setup == SetupClass::Development); - assert_eq!(metadata["profile_id"], verifier.id); + assert_eq!(metadata["vk_id"], verifier.id); assert_eq!(metadata["verifier_blake3"], verifier.digest); assert_eq!( metadata["packet_blake3"], @@ -328,17 +325,17 @@ mod tests { let id = packet[..32].try_into().unwrap(); let verifier = bundled(&id).unwrap(); let statement = statement::with_claim(&verifier.statement_prefix, CLAIM); - let profile = verifier.profile().unwrap(); - profile.verify(packet, &statement).unwrap(); + let vk = verifier.vk().unwrap(); + vk.verify(packet, &statement).unwrap(); for index in 0..statement.len() { let mut wrong = statement; wrong[index] ^= 1; - assert!(profile.verify(packet, &wrong).is_err()); + assert!(vk.verify(packet, &wrong).is_err()); } for index in [0, 32 + 18 * 8, packet.len() - 1] { let mut packet = packet.to_vec(); packet[index] ^= 1; - assert!(profile.verify(&packet, &statement).is_err()); + assert!(vk.verify(&packet, &statement).is_err()); } } } @@ -359,7 +356,7 @@ mod tests { } #[test] - fn changed_profiles_points_and_proof_lengths_are_rejected() { + fn changed_vks_points_and_proof_lengths_are_rejected() { for (packet, _, _) in fixtures() { for index in [0, 32 + 10 * 8, 32 + 18 * 8, packet.len() - 1] { let mut packet = packet.to_vec(); @@ -398,7 +395,7 @@ mod bn254_tests { assert!(verifier.check_release_class().is_err()); assert!( verifier - .profile() + .vk() .err() .unwrap() .to_string() @@ -428,15 +425,15 @@ mod bn254_tests { } #[test] - fn bn254_requires_an_explicit_trusted_profile() { + fn bn254_requires_an_explicit_trusted_vk() { assert!(bundled(&[0; 32]).is_none()); let bls_packet = include_bytes!( "../../tests/fixtures/aggregate-kzg/development-v5-packet.bin" ); assert!(verify(bls_packet, b"claim", true).is_err()); assert!( - VerifierProfile::from_bytes(include_bytes!( - "../../profiles/aggregate-development-v6.bin" + VerifyingKey::from_bytes(include_bytes!( + "../../verifying-keys/aggregate-development-v6.bin" )) .is_err() ); diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index ea60b617d..ba8e1d010 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -1,7 +1,7 @@ mod outer; mod saved; #[cfg(feature = "plonk")] -pub(super) use outer::plonk_export; +pub(super) use outer::{plonk_export, plonk_export_first}; /// Device bytes the recursive prover keeps resident per device unless /// `IX_KZG_CUDA_RESIDENT_GIB` overrides it. Its traces are the largest @@ -57,4 +57,4 @@ fn spawn_memory_monitor() { }); } -pub(super) use outer::{PreparedVerifierProfile, VerifierProfile}; +pub(super) use outer::{PreparedVerifyingKey, VerifyingKey}; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 9e776a767..44911eca2 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -5,7 +5,9 @@ mod plonk_export; #[cfg(feature = "plonk")] mod relation; #[cfg(feature = "plonk")] -pub(in crate::pipeline) use plonk_export::export as plonk_export; +pub(in crate::pipeline) use plonk_export::{ + export as plonk_export, export_first as plonk_export_first, +}; use super::super::stages::{ construct::{self, Lowered}, @@ -40,13 +42,13 @@ pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-v1"; #[cfg(feature = "bn254")] pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-bn254-v1"; #[cfg(not(feature = "bn254"))] -const OUTER_PROFILE_VERSION: u32 = 6; +const OUTER_VK_VERSION: u32 = 6; #[cfg(feature = "bn254")] -const OUTER_PROFILE_VERSION: u32 = 7; +const OUTER_VK_VERSION: u32 = 7; const PAIRING_BYTES: usize = 2 * G1_COMPRESSED_BYTES; const RECURSIVE_PUBLIC_VALUES: usize = statement::WORDS + PUBLIC_VALUES; /// Largest admitted recursive computation trace. The development public -/// degree of a staged profile covers exactly its own height. +/// degree of a staged verifying key covers exactly its own height. pub(super) const MAX_TRACE_LEN: usize = construct::RECURSIVE_HEIGHT; #[derive(serde::Serialize, serde::Deserialize)] @@ -58,50 +60,46 @@ pub(crate) fn stage( built: Built, assignment: Assignment, dir: &Path, - profile: &[u8], + vk: &[u8], statement: &Statement, config: &super::super::WorkerConfig, ) -> storage::Result<()> { - let frontend = Frontend::from_built(built, profile)?; + let frontend = Frontend::from_built(built, vk)?; frontend.stage(assignment, dir, statement, config) } /// A recursive circuit lowered into one computation trace and one merged -/// table, ready to stage any number of requests of its profile. +/// table, ready to stage any number of requests of its verifying key. pub(crate) struct Frontend { compiled: MultiStarkCircuit, pairings: Pairings, - profile: Vec, + vk: Vec, } impl Frontend { - fn from_built(built: Built, profile: &[u8]) -> storage::Result { - Self::new(built.circuit, &built.pairing_keys, profile, MAX_TRACE_LEN) + fn from_built(built: Built, vk: &[u8]) -> storage::Result { + Self::new(built.circuit, &built.pairing_keys, vk, MAX_TRACE_LEN) } pub(crate) fn new( circuit: multi_stark::plonkish::Circuit, keys: &[G2Affine], - profile: &[u8], + vk: &[u8], max_height: usize, ) -> storage::Result { use ark_serialize::CanonicalSerialize; if keys.len() != 2 { - return Err("invalid recursive pairing profile".into()); + return Err("invalid recursive pairing keys".into()); } let mut encoded = vec![]; keys.serialize_compressed(&mut encoded)?; let compiled = construct::lower_recursive(circuit, max_height)?; - Ok(Self { - compiled, - pairings: Pairings { keys: encoded }, - profile: profile.to_vec(), - }) + Ok(Self { compiled, pairings: Pairings { keys: encoded }, vk: vk.to_vec() }) } /// Stage and prove one request, keeping the witness traces in memory. /// Fixed preprocessing comes from the cache when it already holds this - /// profile and is published to it after the proof verifies. + /// verifying key and is published to it after the proof verifies. pub(crate) fn stage( self, assignment: Assignment, @@ -137,8 +135,8 @@ impl Frontend { fs::create_dir_all(dir)?; setup.bind_stage(dir, height, 2)?; storage::save(&dir.join("pairings.bin"), &self.pairings)?; - let cache_profile = [self.profile.as_slice(), &setup_id].concat(); - let cache = config.fixed_cache(dir, &cache_profile)?; + let cache_identity = [self.vk.as_slice(), &setup_id].concat(); + let cache = config.fixed_cache(dir, &cache_identity)?; let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( &self.compiled, @@ -160,30 +158,30 @@ impl Frontend { } fn pairing_keys(dir: &Path) -> storage::Result> { - let profile: Pairings = storage::load(&dir.join("pairings.bin"))?; - decode_pairing_keys(&profile) + let vk: Pairings = storage::load(&dir.join("pairings.bin"))?; + decode_pairing_keys(&vk) } -fn decode_pairing_keys(profile: &Pairings) -> storage::Result> { +fn decode_pairing_keys(vk: &Pairings) -> storage::Result> { use ark_ec::AffineRepr; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; - if profile.keys.len() != 8 + 2 * G2_COMPRESSED_BYTES - || profile.keys.get(..8) != Some(&2u64.to_le_bytes()) + if vk.keys.len() != 8 + 2 * G2_COMPRESSED_BYTES + || vk.keys.get(..8) != Some(&2u64.to_le_bytes()) { - return Err("invalid pairing profile".into()); + return Err("invalid pairing keys".into()); } - let mut input = profile.keys.as_slice(); + let mut input = vk.keys.as_slice(); let keys = Vec::::deserialize_compressed(&mut input)?; if !input.is_empty() || keys.len() != 2 || keys.iter().any(AffineRepr::is_zero) { - return Err("invalid pairing profile".into()); + return Err("invalid pairing keys".into()); } let mut canonical = Vec::new(); keys.serialize_compressed(&mut canonical)?; - if canonical != profile.keys { - return Err("noncanonical pairing profile".into()); + if canonical != vk.keys { + return Err("noncanonical pairing keys".into()); } Ok(keys) } @@ -199,12 +197,12 @@ fn check_pairings(bytes: &[u8], keys: &[G2Affine]) -> storage::Result<()> { } fn packet_body<'a>( packet: &'a [u8], - profile: &[u8; 32], + vk: &[u8; 32], expected: &Statement, ) -> storage::Result<&'a [u8]> { let offset = 32 + statement::WORDS * 8; - if packet.len() < offset + PAIRING_BYTES || packet[..32] != *profile { - return Err("packet profile mismatch".into()); + if packet.len() < offset + PAIRING_BYTES || packet[..32] != *vk { + return Err("packet verifying key mismatch".into()); } for (bytes, &word) in packet[32..offset].as_chunks::<8>().0.iter().zip(expected) @@ -219,14 +217,14 @@ fn packet_body<'a>( #[cfg(any(test, feature = "plonk"))] fn packet_claims( packet: &[u8], - profile: &[u8; 32], + vk: &[u8; 32], pairing_count: usize, expected: &Statement, ) -> storage::Result<(Vec>, usize)> { if pairing_count != 2 { return Err("pairing point count".into()); } - let body = packet_body(packet, profile, expected)?; + let body = packet_body(packet, vk, expected)?; let mut public: Vec<_> = expected.iter().copied().map(Scalar::from_u64).collect(); public.extend(verification::unpack_pairings(&body[..PAIRING_BYTES])?); @@ -237,11 +235,11 @@ fn verify_packet( system: &multi_stark::system::System, codec: &FixedProofCodec, packet: &[u8], - profile: &[u8; 32], + vk: &[u8; 32], keys: &[G2Affine], expected: &Statement, ) -> storage::Result<()> { - let body = packet_body(packet, profile, expected)?; + let body = packet_body(packet, vk, expected)?; let pairings = verification::Pairings { keys: keys.try_into().map_err(|_keys| "pairing point count")?, }; @@ -267,7 +265,7 @@ fn staged_publics( /// Commit the fixed and witness traces from memory, prove, verify the /// packet and write it with the portable verifier. Fixed columns are -/// checkpointed because every proof of the profile shares them through the +/// checkpointed because every proof of the verifying key shares them through the /// cache; witness columns never touch disk. fn prove( dir: &Path, @@ -329,8 +327,8 @@ fn prove( let prover::Proved { system, codec, bytes, .. } = lowered.commit(config, Some(&out), 0)?.prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); - let verifier = VerifierProfile { - version: OUTER_PROFILE_VERSION, + let verifier = VerifyingKey { + version: OUTER_VK_VERSION, heights: [manifest.heights[0], manifest.heights[1]], setup: [ fs::read(out.join("setup-0.bin"))?, @@ -339,8 +337,8 @@ fn prove( pairings: fs::read(dir.join("pairings.bin"))?, parameters: verifier_params.to_bytes()?, }; - let profile = verifier.id(); - let mut packet = profile.to_vec(); + let vk = verifier.id(); + let mut packet = vk.to_vec(); for word in statement { packet.extend(word.to_le_bytes()); } @@ -353,14 +351,14 @@ fn prove( } let verify_start = Instant::now(); tracing::info_span!("compression/verify_packet").in_scope(|| { - verify_packet(&system, &codec, &packet, &profile, &keys, &statement) + verify_packet(&system, &codec, &packet, &vk, &keys, &statement) })?; let verify_seconds = verify_start.elapsed().as_secs_f64(); let encoded = bincode::serde::encode_to_vec(&verifier, bincode::config::standard())?; storage::write_atomic(&out.join("proof.compact.bin"), &bytes)?; - storage::write_atomic(&dir.join("verifier.bin"), &encoded)?; - storage::write_atomic(&out.join("profile-id.bin"), &profile)?; + storage::write_atomic(&dir.join("vk.bin"), &encoded)?; + storage::write_atomic(&out.join("vk-id.bin"), &vk)?; storage::write_atomic(&out.join("packet.bin"), &packet)?; let public_setup = system.config.pcs().verifier_params().public_setup(); let development = setup.class() == SetupClass::Development; @@ -369,8 +367,8 @@ fn prove( "packet_bytes": packet.len(), "claim_bytes": 144, "pairing_bytes": PAIRING_BYTES, - "profile_bytes": 32, - "profile_version": OUTER_PROFILE_VERSION, + "vk_bytes": 32, + "vk_version": OUTER_VK_VERSION, "transcript": "blake3", "total_seconds": start.elapsed().as_secs_f64(), "verify_seconds": verify_seconds, @@ -383,7 +381,7 @@ fn prove( "public_setup_id": blake3::Hash::from_bytes(public_setup.id).to_hex().to_string(), "public_max_degree": public_setup.max_degree, "setup_id": blake3::Hash::from_bytes(setup_id).to_hex().to_string(), - "profile_id": blake3::Hash::from_bytes(profile).to_hex().to_string(), + "vk_id": blake3::Hash::from_bytes(vk).to_hex().to_string(), "trace_heights": manifest.heights, }); fs::write( @@ -398,7 +396,7 @@ fn prove( /// Recursive verifier metadata authenticated by a bundle or an independent digest. #[derive(serde::Serialize, serde::Deserialize)] -pub(crate) struct VerifierProfile { +pub(crate) struct VerifyingKey { version: u32, heights: [usize; 2], setup: [Vec; 2], @@ -408,41 +406,40 @@ pub(crate) struct VerifierProfile { fn decode(bytes: &[u8]) -> storage::Result { if bytes.len() > 1 << 20 { - return Err("verifier profile too large".into()); + return Err("verifying key too large".into()); } let (value, consumed) = bincode::serde::decode_from_slice( bytes, bincode::config::standard().with_limit::<{ 1 << 20 }>(), )?; if consumed != bytes.len() { - return Err("trailing verifier profile bytes".into()); + return Err("trailing verifying key bytes".into()); } Ok(value) } -impl VerifierProfile { +impl VerifyingKey { #[cfg(all(not(feature = "bn254"), any(test, feature = "plonk")))] pub(crate) fn reencode_blake3( bytes: &[u8], ) -> PipelineResult<(Vec, [u8; 32])> { - let mut profile = Self::from_bytes(bytes)?; - profile.version = OUTER_PROFILE_VERSION; - profile.prepare()?; - let id = profile.id(); - Ok(( - bincode::serde::encode_to_vec(profile, bincode::config::standard())?, - id, - )) + let mut vk = Self::from_bytes(bytes)?; + vk.version = OUTER_VK_VERSION; + vk.prepare()?; + let id = vk.id(); + Ok((bincode::serde::encode_to_vec(vk, bincode::config::standard())?, id)) } pub(crate) fn from_bytes(bytes: &[u8]) -> PipelineResult { - let profile: Self = decode(bytes)?; - profile.prepare()?; - Ok(profile) + let vk: Self = decode(bytes)?; + vk.prepare()?; + Ok(vk) } pub(crate) fn id(&self) -> [u8; 32] { let mut hash = blake3::Hasher::new(); + // The label predates the verifying-key naming and is part of every + // published id; changing it would re-key every bundled verifying key. #[cfg(not(feature = "bn254"))] let namespace = "init-kzg-recursive-profile"; #[cfg(feature = "bn254")] @@ -462,7 +459,7 @@ impl VerifierProfile { decode_pairing_keys(&decode(&self.pairings)?) } - /// The verifier parameters the profile carries. + /// The verifier parameters the verifying key carries. pub(crate) fn verifier_params( &self, ) -> storage::Result { @@ -514,11 +511,11 @@ impl VerifierProfile { use crate::kzg::VerifierParams; use multi_stark::system::{Circuit, System}; #[cfg(not(feature = "bn254"))] - let supported = matches!(self.version, 5 | OUTER_PROFILE_VERSION); + let supported = matches!(self.version, 5 | OUTER_VK_VERSION); #[cfg(feature = "bn254")] - let supported = self.version == OUTER_PROFILE_VERSION; + let supported = self.version == OUTER_VK_VERSION; if !supported { - return Err("unsupported recursive verifier profile".into()); + return Err("unsupported recursive verifying key".into()); } if self .heights @@ -526,7 +523,7 @@ impl VerifierProfile { .any(|h| *h < 2 || !h.is_power_of_two() || *h > 1 << 28) || self.heights[1] > self.heights[0] { - return Err("unsupported recursive verifier profile".into()); + return Err("unsupported recursive verifying key".into()); } let params = VerifierParams::from_bytes(&self.parameters)?; if params.max_trace_len() != self.heights[0] { @@ -564,8 +561,8 @@ impl VerifierProfile { }) } - pub(crate) fn prepare(&self) -> PipelineResult { - Ok(PreparedVerifierProfile { + pub(crate) fn prepare(&self) -> PipelineResult { + Ok(PreparedVerifyingKey { id: self.id(), verifier: super::super::stages::RecursiveVerifier::from_parts( self.system()?, @@ -585,12 +582,12 @@ impl VerifierProfile { } } -pub(crate) struct PreparedVerifierProfile { +pub(crate) struct PreparedVerifyingKey { id: [u8; 32], verifier: super::super::stages::RecursiveVerifier, } -impl PreparedVerifierProfile { +impl PreparedVerifyingKey { pub(crate) fn verify( &self, packet: &[u8], @@ -623,7 +620,7 @@ pub(crate) fn verify_saved( &staged }, }; - VerifierProfile::from_bytes(&fs::read(dir.join("verifier.bin"))?)? + VerifyingKey::from_bytes(&fs::read(dir.join("vk.bin"))?)? .verify(packet, expected) } @@ -645,8 +642,8 @@ mod tests { #[test] fn bundled_and_public_recursive_verifiers_share_transport_validation() -> storage::Result<()> { - let wire = VerifierProfile::from_bytes(include_bytes!( - "../../../profiles/aggregate-filecoin-v5.bin" + let wire = VerifyingKey::from_bytes(include_bytes!( + "../../../verifying-keys/aggregate-filecoin-v5.bin" ))?; let prepared = wire.prepare()?; let public = super::super::super::stages::RecursiveVerifier::from_bytes( @@ -683,29 +680,29 @@ mod tests { #[test] fn filecoin_bundle_requires_filecoin_inner_pairing_anchors() { use ark_serialize::CanonicalSerialize; - let mut profile = VerifierProfile::from_bytes(include_bytes!( - "../../../profiles/aggregate-filecoin-v5.bin" + let mut vk = VerifyingKey::from_bytes(include_bytes!( + "../../../verifying-keys/aggregate-filecoin-v5.bin" )) .unwrap(); - profile.check_setup_class(SetupClass::Filecoin).unwrap(); - assert!(profile.check_setup_class(SetupClass::Development).is_err()); + vk.check_setup_class(SetupClass::Filecoin).unwrap(); + assert!(vk.check_setup_class(SetupClass::Development).is_err()); let development = Srs::unsafe_dev_setup(2, b"mixed-pairing-setup"); let mut keys = Vec::new(); vec![development.g2, development.tau_g2] .serialize_compressed(&mut keys) .unwrap(); - profile.pairings = bincode::serde::encode_to_vec( + vk.pairings = bincode::serde::encode_to_vec( Pairings { keys }, bincode::config::standard(), ) .unwrap(); - assert!(profile.check_setup_class(SetupClass::Filecoin).is_err()); + assert!(vk.check_setup_class(SetupClass::Filecoin).is_err()); } #[cfg(feature = "bn254")] - fn ignition_profile_fixture() -> storage::Result<( + fn ignition_vk_fixture() -> storage::Result<( crate::kzg::srs::ignition::IgnitionAdmission, - VerifierProfile, + VerifyingKey, Vec, )> { use crate::kzg::{VerifierParams, srs::ignition::test_admission}; @@ -748,8 +745,8 @@ mod tests { }; let mut keys = Vec::new(); admission.pairing_keys().to_vec().serialize_compressed(&mut keys)?; - let profile = VerifierProfile { - version: OUTER_PROFILE_VERSION, + let vk = VerifyingKey { + version: OUTER_VK_VERSION, heights: [ proved.system.circuits[0].preprocessed_height, proved.system.circuits[1].preprocessed_height, @@ -761,13 +758,13 @@ mod tests { )?, parameters: parameters.to_bytes()?, }; - let mut packet = profile.id().to_vec(); + let mut packet = vk.id().to_vec(); for word in init_claim::INIT_PUBLIC_WORDS { packet.extend(word.to_le_bytes()); } packet.extend(pairing_bytes(&publics[18..])?); packet.extend(&proved.bytes); - Ok((admission, profile, packet)) + Ok((admission, vk, packet)) } #[cfg(feature = "bn254")] @@ -827,13 +824,13 @@ mod tests { VerifierParams, srs::ignition::{IgnitionAdmission, test_admission}, }; - let (admission, profile, packet) = ignition_profile_fixture()?; + let (admission, vk, packet) = ignition_vk_fixture()?; let claim = include_bytes!( "../../../tests/fixtures/aggregate-kzg/filecoin-claim.bin" ); let expected = init_claim::INIT_PUBLIC_WORDS; let portable = - bincode::serde::encode_to_vec(&profile, bincode::config::standard())?; + bincode::serde::encode_to_vec(&vk, bincode::config::standard())?; let trusted = *blake3::hash(&portable).as_bytes(); let verify = |packet: &[u8], claim: &[u8], @@ -848,11 +845,8 @@ mod tests { verify(&packet, claim, &expected, &trusted, &admission)?, SetupClass::Ignition ); - assert!(profile.check_setup_class(SetupClass::Ignition).is_err()); - assert_eq!( - SetupClass::of(&profile.verifier_params()?), - SetupClass::Development - ); + assert!(vk.check_setup_class(SetupClass::Ignition).is_err()); + assert_eq!(SetupClass::of(&vk.verifier_params()?), SetupClass::Development); assert!(verify(&packet, claim, &expected, &[0; 32], &admission).is_err()); assert!( verify(&packet, b"wrong", &expected, &trusted, &admission).is_err() @@ -872,7 +866,7 @@ mod tests { assert!(verify(&packet, claim, &expected, &trusted, &other).is_err()); } for change in [0, 1, 2] { - let mut changed: VerifierProfile = decode(&portable)?; + let mut changed: VerifyingKey = decode(&portable)?; let mut parameters = changed.verifier_params()?; match change { 0 => parameters.public_setup.id[0] ^= 1, @@ -889,7 +883,7 @@ mod tests { changed.parameters = parameters.to_bytes()?; assert!(changed.check_ignition_admission(&admission).is_err()); } - let mut mixed: VerifierProfile = decode(&portable)?; + let mut mixed: VerifyingKey = decode(&portable)?; let development = Srs::unsafe_dev_setup(2, b"ignition-mixed-inner"); let mut keys = Vec::new(); vec![development.g2, development.tau_g2].serialize_compressed(&mut keys)?; @@ -904,16 +898,13 @@ mod tests { .to_string() .contains("inner pairing anchors") ); - let mut development_profile: VerifierProfile = decode(&portable)?; - let development = Srs::unsafe_dev_setup_prefix( - 2, - 255, - b"ignition-existing-development-profile", - ); - development_profile.parameters = - VerifierParams::with_max_trace_len(&development, profile.heights[0]) + let mut development_vk: VerifyingKey = decode(&portable)?; + let development = + Srs::unsafe_dev_setup_prefix(2, 255, b"ignition-existing-development-vk"); + development_vk.parameters = + VerifierParams::with_max_trace_len(&development, vk.heights[0]) .to_bytes()?; - assert!(development_profile.check_ignition_admission(&admission).is_err()); + assert!(development_vk.check_ignition_admission(&admission).is_err()); Ok(()) } @@ -921,9 +912,9 @@ mod tests { fn packet_requires_outer_proof_claim_and_external_pairings() { let inner = Srs::unsafe_dev_setup(8, b"packet-inner"); let keys = vec![inner.g2, inner.tau_g2]; - let profile = [17u8; 32]; + let vk = [17u8; 32]; let packet_prefix = |wrong: bool| { - let mut packet = profile.to_vec(); + let mut packet = vk.to_vec(); for v in init_claim::INIT_PUBLIC_WORDS { packet.extend(v.to_le_bytes()); } @@ -938,7 +929,7 @@ mod tests { }; let (claims, _) = packet_claims( &packet_prefix(false), - &profile, + &vk, 2, &init_claim::INIT_PUBLIC_WORDS, ) @@ -966,8 +957,7 @@ mod tests { for wrong in [false, true] { let mut packet = packet_prefix(wrong); let (claims, _) = - packet_claims(&packet, &profile, 2, &init_claim::INIT_PUBLIC_WORDS) - .unwrap(); + packet_claims(&packet, &vk, 2, &init_claim::INIT_PUBLIC_WORDS).unwrap(); let mut w = lowered.witness(); for (&input, claim) in inputs.iter().zip(&claims[1..]) { w.set(input, claim[3]).unwrap(); @@ -984,7 +974,7 @@ mod tests { &system, &codec, &packet, - &profile, + &vk, &keys, &init_claim::INIT_PUBLIC_WORDS ) @@ -999,7 +989,7 @@ mod tests { &system, &codec, &changed, - &profile, + &vk, &keys, &init_claim::INIT_PUBLIC_WORDS ) @@ -1020,7 +1010,7 @@ mod tests { &system, &codec, &packet[..packet.len() - 1], - &profile, + &vk, &keys, &init_claim::INIT_PUBLIC_WORDS ) @@ -1032,7 +1022,7 @@ mod tests { &system, &codec, &packet, - &profile, + &vk, &keys, &init_claim::INIT_PUBLIC_WORDS ) @@ -1070,7 +1060,7 @@ mod tests { let mut encoded = vec![]; keys.serialize_compressed(&mut encoded)?; storage::save(&dir.join("pairings.bin"), &Pairings { keys: encoded })?; - let cache = fixed_cache::FixedCache::from_profile(cache_root, b"outer")?; + let cache = fixed_cache::FixedCache::from_identity(cache_root, b"outer")?; let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( compiled, @@ -1082,12 +1072,10 @@ mod tests { } #[test] - fn fused_profiles_prove_and_portable_verifiers_round_trip() - -> storage::Result<()> { - let dir = std::env::temp_dir().join(format!( - "outer-staged-{}-v{OUTER_PROFILE_VERSION}", - std::process::id() - )); + fn fused_vks_prove_and_portable_verifiers_round_trip() -> storage::Result<()> + { + let dir = std::env::temp_dir() + .join(format!("outer-staged-{}-v{OUTER_VK_VERSION}", std::process::id())); fs::create_dir(&dir)?; let inner = Srs::unsafe_dev_setup(8, b"staged-inner"); let keys = vec![inner.g2, inner.tau_g2]; @@ -1134,7 +1122,7 @@ mod tests { let mut extra = packet.clone(); extra.push(0); assert!(verify_saved(&first, &extra, None).is_err()); - // A second run of the profile restores the fixed preprocessing from + // A second run of the verifying key restores the fixed preprocessing from // the cache and produces the same packet. let second = dir.join("second"); let Lowered { compiled, assignment } = make()?; @@ -1152,13 +1140,13 @@ mod tests { bad[1].pop(); assert!(staged_publics(&bad, keys.len()).is_err()); assert!(staged_publics(&claims[..claims.len() - 1], keys.len()).is_err()); - let portable = fs::read(first.join("verifier.bin"))?; + let portable = fs::read(first.join("vk.bin"))?; let claim = include_bytes!( "../../../tests/fixtures/aggregate-kzg/filecoin-claim.bin" ); let trusted = *blake3::hash(&portable).as_bytes(); let verify_local = |claim: &[u8], expected, trusted: &[u8; 32], allow| { - super::super::super::verifier::verify_with_profile( + super::super::super::verifier::verify_with_vk( &packet, claim, expected, &portable, trusted, allow, ) }; @@ -1183,31 +1171,28 @@ mod tests { let mut wrong_program = init_claim::INIT_PUBLIC_WORDS; wrong_program[0] ^= 1; assert!(verify_local(claim, &wrong_program, &trusted, true).is_err()); - let profile = VerifierProfile::from_bytes(&portable)?; - assert_eq!(profile.version, OUTER_PROFILE_VERSION); - assert_eq!( - profile.system()?.config.transcript_kind(), - TranscriptKind::Blake3 - ); + let vk = VerifyingKey::from_bytes(&portable)?; + assert_eq!(vk.version, OUTER_VK_VERSION); + assert_eq!(vk.system()?.config.transcript_kind(), TranscriptKind::Blake3); #[cfg(not(feature = "bn254"))] let unsupported_versions = [0, 4, 7]; #[cfg(feature = "bn254")] let unsupported_versions = [0, 5, 6]; for version in unsupported_versions { - let mut unsupported: VerifierProfile = decode(&portable)?; + let mut unsupported: VerifyingKey = decode(&portable)?; unsupported.version = version; let unsupported = bincode::serde::encode_to_vec( &unsupported, bincode::config::standard(), )?; - assert!(VerifierProfile::from_bytes(&unsupported).is_err()); + assert!(VerifyingKey::from_bytes(&unsupported).is_err()); } // Verify using only the portable object after deleting the SRS and all checkpoints. fs::remove_dir_all(&dir)?; - profile.verify(&packet, &init_claim::INIT_PUBLIC_WORDS)?; - let mut wrong_range: VerifierProfile = decode(&portable)?; + vk.verify(&packet, &init_claim::INIT_PUBLIC_WORDS)?; + let mut wrong_range: VerifyingKey = decode(&portable)?; wrong_range.heights[0] *= 2; assert!(wrong_range.system().is_err()); #[cfg(feature = "bn254")] @@ -1217,7 +1202,7 @@ mod tests { Srs::unsafe_dev_setup_prefix(2, max_domain - 1, b"outer-domain-cap"); let params = crate::kzg::VerifierParams::with_max_trace_len(&anchors, max_domain); - let mut oversized: VerifierProfile = decode(&portable)?; + let mut oversized: VerifyingKey = decode(&portable)?; oversized.heights = [max_domain; 2]; oversized.parameters = params.to_bytes()?; for setup in &mut oversized.setup { @@ -1233,7 +1218,7 @@ mod tests { } let bytes = bincode::serde::encode_to_vec(oversized, bincode::config::standard())?; - let error = VerifierProfile::from_bytes(&bytes).err().unwrap(); + let error = VerifyingKey::from_bytes(&bytes).err().unwrap(); assert!(error.to_string().contains("quotient domains exceed")); } Ok(()) @@ -1307,7 +1292,7 @@ mod tests { let packet = fs::read(dir.join("kzg/packet.bin"))?; verify_saved(&dir, &packet, None)?; outputs.push( - ["proof.compact.bin", "packet.bin", "profile-id.bin"] + ["proof.compact.bin", "packet.bin", "vk-id.bin"] .map(|name| fs::read(dir.join("kzg").join(name)).unwrap()), ); } diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index a545a5a2c..932217183 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -10,6 +10,7 @@ use ark_serialize::CanonicalDeserialize; use ark_serialize::CanonicalSerialize; use multi_stark::{ config::ProofConfig, + prover::Proof, system::System, traits::{Algebra, Pcs}, }; @@ -47,41 +48,76 @@ pub(in crate::pipeline) fn export( if blake3::hash(&bytes).to_hex().as_str() != trusted_digest { return Err("trusted verifier digest differs".into()); } - let profile = VerifierProfile::from_bytes(&bytes)?; + let vk = VerifyingKey::from_bytes(&bytes)?; #[cfg(not(feature = "bn254"))] - profile.check_setup_class(SetupClass::Filecoin)?; + vk.check_setup_class(SetupClass::Filecoin)?; #[cfg(feature = "bn254")] { let setup = super::super::super::setup::SetupConfig::from_env(false)?; let admission = setup .ignition_admission() .ok_or("PLONK export requires admitted Ignition parameters")?; - profile.check_ignition_admission(admission)?; + vk.check_ignition_admission(admission)?; } - write_export( - &profile, - &fs::read(packet)?, - expected, - directory, - "witness", - true, - ) + write_export(&vk, &fs::read(packet)?, expected, directory, "witness", true) +} + +/// How the verifier supplies each public scalar referenced by the claim rows. +#[derive(Clone, Copy)] +enum PublicSource { + Constant(Scalar), + Prefix(usize), + DigestWord(usize), + DeferredHalf(usize), +} + +impl PublicSource { + fn json(self) -> Json { + match self { + Self::Constant(value) => json!({"constant": scalar(value)}), + Self::Prefix(index) => json!({"prefix": index}), + Self::DigestWord(index) => json!({"digestWord": index}), + Self::DeferredHalf(index) => json!({"deferredHalf": index}), + } + } +} + +fn slot_json(slot: ir::ClaimSlot) -> Json { + match slot { + ir::ClaimSlot::Constant(value) => json!({"constant": scalar(value)}), + ir::ClaimSlot::Public(index) => json!({"public": index}), + } +} + +struct Export<'a> { + system: &'a System, + proof: &'a Proof, + logs: &'a [u8], + slots: Vec>, + publics: Vec, + vk_id: [u8; 32], + deferred_keys: Vec, + deferred_bytes: &'a [u8], + packet: &'a [u8], + diagnostic: Json, } pub(super) fn write_export( - profile: &VerifierProfile, + vk: &VerifyingKey, packet: &[u8], expected: &Statement, directory: &Path, name: &str, valid_pairing: bool, ) -> Result<()> { - let system = profile.system()?; + let system = vk.system()?; if system.config.transcript_kind() != TranscriptKind::Blake3 { - return Err("PLONK wrapping requires a BLAKE3 final KZG profile".into()); + return Err( + "PLONK wrapping requires a BLAKE3 final KZG verifying key".into(), + ); } - let (claims, offset) = packet_claims(packet, &profile.id(), 2, expected)?; - let logs = profile + let (claims, offset) = packet_claims(packet, &vk.id(), 2, expected)?; + let logs = vk .heights .iter() .map(|h| u8::try_from(h.ilog2())) @@ -89,10 +125,144 @@ pub(super) fn write_export( let proof = FixedProofCodec::new(&system, &logs)?.decode(&packet[offset..])?; let diagnostic = diagnostics(&system, &proof, &claims)?; + let deferred_bytes = &packet[32 + 18 * 8..offset]; + if check_pairings(deferred_bytes, &vk.keys()?).is_ok() != valid_pairing { + return Err("unexpected deferred pairing result".into()); + } + // The recursive statement: 23 rows `[94, 1, i, public_i]` whose public + // scalars are the constant prefix, the claim digest and the deferred + // pairing operands. + if claims.len() != RECURSIVE_PUBLIC_VALUES { + return Err("recursive packet claim count".into()); + } + let slots = (0..RECURSIVE_PUBLIC_VALUES) + .map(|i| { + vec![ + ir::ClaimSlot::Constant(Scalar::from_u8(94)), + ir::ClaimSlot::Constant(Scalar::ONE), + ir::ClaimSlot::Constant(Scalar::from_usize(i)), + ir::ClaimSlot::Public(i), + ] + }) + .collect(); + let publics = std::iter::once(PublicSource::Constant(Scalar::ZERO)) + .chain((0..10).map(PublicSource::Prefix)) + .chain((0..8).map(PublicSource::DigestWord)) + .chain((0..PUBLIC_VALUES).map(PublicSource::DeferredHalf)) + .collect(); + write_vk( + &Export { + system: &system, + proof: &proof, + logs: &logs, + slots, + publics, + vk_id: vk.id(), + deferred_keys: vk.keys()?, + deferred_bytes, + packet, + diagnostic, + }, + expected, + directory, + name, + ) +} + +/// Export the relation verifying a saved first KZG stage directly: every +/// partition's circuit, the first-stage claim rows and no deferred pairing. +/// The stage's setup is selected from the environment as the worker does. +pub(in crate::pipeline) fn export_first( + stage: &Path, + expected: &Statement, + directory: &Path, + development: bool, +) -> Result<()> { + let config = super::super::super::WorkerConfig::from_env("kzg", development)?; + let input = super::super::saved::Input::load(stage, &config)?; + let system = &input.system; + if system.config.transcript_kind() != TranscriptKind::Blake3 { + return Err( + "PLONK wrapping requires a BLAKE3 first-stage verifying key".into(), + ); + } + if statement::from_rows(&input.claims)? != *expected { + return Err("first-stage claims differ from the expected statement".into()); + } + let vk_id: [u8; 32] = + fs::read(stage.join("kzg/vk-id.bin"))?.try_into().map_err( + |bytes: Vec| format!("verifying key id of {} bytes", bytes.len()), + )?; + let packet = fs::read(stage.join("kzg/packet.bin"))?; + let mut composed = vk_id.to_vec(); + for word in expected { + composed.extend(word.to_le_bytes()); + } + composed.extend(&input.proof_bytes); + if packet != composed { + return Err( + "first-stage packet differs from its verifying key, statement and proof" + .into(), + ); + } + let diagnostic = diagnostics(system, &input.proof, &input.claims)?; + // Rows 1..=18 carry the statement words at column 3; every other claim + // value, including the table and hash-trace anchors, is a constant. + let locations = + StageStatement::from_claims(&input.claims, statement::WORDS)?.locations; + let slots = input + .claims + .iter() + .enumerate() + .map(|(row, values)| { + values + .iter() + .enumerate() + .map(|(column, &value)| { + match locations.iter().position(|&l| l == (row, column)) { + Some(index) => ir::ClaimSlot::Public(index), + None => ir::ClaimSlot::Constant(value), + } + }) + .collect() + }) + .collect(); + let publics = (0..10) + .map(PublicSource::Prefix) + .chain((0..8).map(PublicSource::DigestWord)) + .collect(); + write_vk( + &Export { + system, + proof: &input.proof, + logs: &input.logs, + slots, + publics, + vk_id, + deferred_keys: vec![], + deferred_bytes: &[], + packet: &packet, + diagnostic, + }, + expected, + directory, + "witness", + ) +} + +fn write_vk( + export: &Export<'_>, + expected: &Statement, + directory: &Path, + name: &str, +) -> Result<()> { + let Export { system, proof, logs, .. } = export; + #[cfg(not(feature = "bn254"))] + let diagnostic = export.diagnostic.clone(); #[cfg(feature = "bn254")] let diagnostic = { - let mut value = diagnostic; - let encodings = proof_points(&proof) + let mut diagnostic = export.diagnostic.clone(); + let encodings = proof_points(proof) .into_iter() .map(|point| { let mut bytes = Vec::new(); @@ -103,20 +273,16 @@ pub(super) fn write_export( hex_json(&bytes) }) .collect::>(); - value["pointEncodings"] = json!(encodings); - value + diagnostic["pointEncodings"] = json!(encodings); + diagnostic }; - let deferred_bytes = &packet[32 + 18 * 8..offset]; - if check_pairings(deferred_bytes, &profile.keys()?).is_ok() != valid_pairing { - return Err("unexpected deferred pairing result".into()); - } - let layout = codegen::OpeningLayout::new(profile, &system)?; - let program = ir::build(&system, &layout)?; + let layout = codegen::OpeningLayout::new_for_system(system, logs)?; + let program = ir::build(system, &layout, &export.slots)?; let params = system.config.pcs().verifier_params(); let fixed: Vec<_> = system .preprocessed_commit .as_ref() - .unwrap() + .ok_or("missing fixed commitment")? .0 .iter() .flatten() @@ -129,7 +295,7 @@ pub(super) fn write_export( let circuits: Vec<_> = system.circuits.iter().enumerate().map(|(i, c)| { json!({ "logDegree": logs[i], - "shape": [c.constraint_count(), c.max_constraint_degree(), profile.heights[i], + "shape": [c.constraint_count(), c.max_constraint_degree(), 1usize << logs[i], c.preprocessed_width, c.main_width, c.stage_2_width, c.lookup_group_size], }) }).collect(); @@ -144,8 +310,8 @@ pub(super) fn write_export( }) .collect(); let exported = json!({ - "format": if cfg!(feature = "bn254") { "ix-bn254-plonk-profile-v1" } else { "ix-bls-plonk-profile-v1" }, - "profileId": hex_json(&profile.id()), + "format": if cfg!(feature = "bn254") { "ix-bn254-plonk-vk-v1" } else { "ix-bls-plonk-vk-v1" }, + "vkId": hex_json(&export.vk_id), "setupId": hex_json(¶ms.public_setup.id), "publicMaxDegree": params.public_setup.max_degree, "prefix": &expected[..10], @@ -156,25 +322,33 @@ pub(super) fn write_export( "fieldCount": layout.field_count, "witnessOffset": layout.commitment_count, "openingCount": layout.factors.len(), + "accumulatorCount": layout.accumulator_count, + "claims": export.slots.iter().map(|row| row.iter().copied().map(slot_json).collect::>()).collect::>(), + "publics": export.publics.iter().copied().map(PublicSource::json).collect::>(), "fixed": fixed, "generator": point(params.g1[0]), "outerKeys": [key(¶ms.g2), key(¶ms.tau_g2)], - "deferredKeys": profile.keys()?.iter().map(key).collect::>(), + "deferredKeys": export.deferred_keys.iter().map(key).collect::>(), "program": program, }); - let mut values = vec![proof.intermediate_accumulators[0]]; + let mut values: Vec = + proof.intermediate_accumulators[..layout.accumulator_count].to_vec(); for round in [ &proof.stage_1_opened_values, &proof.stage_2_opened_values, &proof.quotient_opened_values, - proof.preprocessed_opened_values.as_ref().unwrap(), + proof + .preprocessed_opened_values + .as_ref() + .ok_or("missing fixed openings")?, ] { values.extend(round.iter().flatten().flatten().copied()); } if values.len() != layout.field_count { return Err("exported scalar count differs".into()); } - let deferred = deferred_bytes + let deferred = export + .deferred_bytes .as_chunks::<{ G1_COMPRESSED_BYTES }>() .0 .iter() @@ -189,13 +363,13 @@ pub(super) fn write_export( .collect::>(); let witness = json!({ "claimDigest": hex_json(&digest), - "points": proof_points(&proof).into_iter().map(point).collect::>(), + "points": proof_points(proof).into_iter().map(point).collect::>(), "deferred": deferred, "values": values.into_iter().map(scalar).collect::>(), }); fs::create_dir_all(directory)?; storage::write_atomic( - &directory.join("profile.json"), + &directory.join("vk.json"), &serde_json::to_vec_pretty(&exported)?, )?; storage::write_atomic( @@ -206,7 +380,7 @@ pub(super) fn write_export( &directory.join(format!("{name}-diagnostic.json")), &serde_json::to_vec_pretty(&diagnostic)?, )?; - storage::write_atomic(&directory.join(format!("{name}.bin")), packet)?; + storage::write_atomic(&directory.join(format!("{name}.bin")), export.packet)?; storage::write_atomic( &directory.join(format!("{name}-statement.json")), &serde_json::to_vec_pretty(expected)?, diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs index 143fcac73..7ae67bb07 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs @@ -149,28 +149,49 @@ impl ops::MulAssign for Term<'_> { } } +/// One entry of a claim row: a circuit constant or the index of a public +/// scalar the verifier supplies. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::pipeline::wrap::outer) enum ClaimSlot { + Constant(Scalar), + Public(usize), +} + pub(super) fn build( system: &System, layout: &codegen::OpeningLayout, + claims: &[Vec], ) -> Result { + let circuits = system.circuits.len(); + if circuits != layout.accumulator_count + 1 { + return Err("layout accumulator count differs from the system".into()); + } + let public_count = claims + .iter() + .flatten() + .filter_map(|slot| match slot { + ClaimSlot::Public(index) => Some(index + 1), + ClaimSlot::Constant(_) => None, + }) + .max() + .unwrap_or(0); let arena = Mutex::new(Program::default()); let ch: [_; 6] = std::array::from_fn(|i| Term::input(&arena, "challenge", i)); let values: Vec<_> = (0..layout.field_count).map(|i| Term::input(&arena, "value", i)).collect(); let publics: Vec<_> = - (0..23).map(|i| Term::input(&arena, "public", i)).collect(); + (0..public_count).map(|i| Term::input(&arena, "public", i)).collect(); let [beta, gamma, alpha, zeta, v, r] = ch; let mut assertions = Vec::new(); let mut accumulator = Term::ZERO; - for (i, value) in publics.iter().copied().enumerate() { - let row = [ - Term::Constant(Scalar::from_u8(94)), - Term::ONE, - Term::Constant(Scalar::from_usize(i)), - value, - ]; - let message = - row.into_iter().rev().fold(Term::ZERO, |acc, value| acc * gamma + value); + for row in claims { + let message = row.iter().rev().fold(Term::ZERO, |acc, slot| { + let value = match *slot { + ClaimSlot::Constant(value) => Term::Constant(value), + ClaimSlot::Public(index) => publics[index], + }; + acc * gamma + value + }); accumulator += (beta + message).inverse(); } // An inverse node enforces the forbidden-challenge rejection even if its output is unused. @@ -185,10 +206,10 @@ pub(super) fn build( let _ = vanishing.inverse(); let first = vanishing * (zeta - Term::ONE).inverse(); let last = vanishing * (zeta * omega - Term::ONE).inverse() * omega; - let next = if i == 0 { values[0] } else { Term::ZERO }; + let next = if i + 1 < circuits { values[i] } else { Term::ZERO }; let cp = [beta, gamma, accumulator, next]; let rows = |round: usize| { - let m = &layout.matrices[round * 2 + i]; + let m = &layout.matrices[round * circuits + i]; let current = &values[m.value_offset..m.value_offset + m.width]; let following = if m.rows == 2 { &values[m.value_offset + m.width..m.value_offset + 2 * m.width] diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs index cbe498c9f..8343df0ca 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs @@ -75,6 +75,7 @@ pub(super) struct GraphInputs { pub(super) preprocessed: GraphMatrix, pub(super) stage2: GraphMatrix, pub(super) public_count: usize, + pub(super) matrix_count: usize, } impl GraphInputs { @@ -82,9 +83,7 @@ impl GraphInputs { system: &System, index: usize, ) -> Result { - if index >= 2 { - return Err("Solidity graph requires one of two circuits".into()); - } + let count = system.circuits.len(); let c = system.circuits.get(index).ok_or("missing circuit")?; Ok(Self { main: GraphMatrix { @@ -93,13 +92,18 @@ impl GraphInputs { rows: 1 + usize::from(system.opens_next_row(index, Source::Main)), }, preprocessed: GraphMatrix { - index: 6 + index, + index: 3 * count + index, width: c.preprocessed_width, rows: 1 + usize::from(system.opens_next_row(index, Source::Preprocessed)), }, - stage2: GraphMatrix { index: 2 + index, width: c.stage_2_width, rows: 2 }, + stage2: GraphMatrix { + index: count + index, + width: c.stage_2_width, + rows: 2, + }, public_count: c.num_publics, + matrix_count: 4 * count, }) } @@ -140,7 +144,7 @@ impl GraphProgram { return Err("Solidity graph requires four circuit public scalars".into()); } for m in [inputs.main, inputs.preprocessed, inputs.stage2] { - if m.index >= 8 || !(1..=2).contains(&m.rows) { + if m.index >= inputs.matrix_count || !(1..=2).contains(&m.rows) { return Err("invalid Solidity graph opening window".into()); } } diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs index 63a1a7f73..9ba32149b 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs @@ -1,6 +1,6 @@ -//! Fixed opening layouts and scalar arithmetic for authenticated profiles. +//! Fixed opening layouts and scalar arithmetic for authenticated verifying keys. -use super::{KzgConfig, Result, Scalar, System, VerifierProfile}; +use super::{KzgConfig, Result, Scalar, System}; use multi_stark::{ config::ProofConfig, expr::Source, @@ -36,6 +36,9 @@ pub(in crate::pipeline::wrap::outer) struct OpeningLayout { pub(in crate::pipeline::wrap::outer) fixed_count: usize, pub(in crate::pipeline::wrap::outer) generator_index: usize, pub(in crate::pipeline::wrap::outer) coefficient_count: usize, + /// Intermediate lookup accumulators carried in the proof: one per circuit + /// except the last, whose accumulator is fixed to zero. + pub(in crate::pipeline::wrap::outer) accumulator_count: usize, } fn add(a: usize, b: usize) -> Result { @@ -70,16 +73,17 @@ fn quotient_width(max_degree: usize) -> Result { } impl OpeningLayout { - pub(in crate::pipeline::wrap::outer) fn new( - profile: &VerifierProfile, + + pub(in crate::pipeline::wrap::outer) fn new_for_system( system: &System, + logs: &[u8], ) -> Result { - if system.circuits.len() != 2 { - return Err("Solidity opening layout requires two circuits".into()); + if logs.len() != system.circuits.len() { + return Err("trace logs must cover every circuit".into()); } - let mut shapes = Vec::with_capacity(2); + let mut shapes = Vec::with_capacity(system.circuits.len()); for (ci, circuit) in system.circuits.iter().enumerate() { - let height = profile.heights[ci]; + let height = 1usize << logs[ci]; log_degree(height)?; let domain = system.config.pcs().natural_domain_for_degree(height); shapes.push(CircuitShape { @@ -100,9 +104,11 @@ impl OpeningLayout { } fn from_shapes(shapes: &[CircuitShape]) -> Result { - if shapes.len() != 2 { - return Err("Solidity opening layout requires two circuits".into()); + let circuits = shapes.len(); + if circuits < 2 { + return Err("opening layout requires at least two circuits".into()); } + let accumulator_count = circuits - 1; for shape in shapes { let log = log_degree(shape.height)?; if shape.widths[1] == 0 @@ -117,11 +123,11 @@ impl OpeningLayout { return Err("Solidity domain generator has incorrect order".into()); } } - let mut matrices = Vec::with_capacity(8); + let mut matrices = Vec::with_capacity(4 * circuits); let mut factors = Vec::with_capacity(3); let mut commitment_count = 0; let mut fixed_count = 0; - let mut field_count = 1; + let mut field_count = accumulator_count; for round in 0..4 { for shape in shapes { let width = shape.widths[round]; @@ -160,11 +166,11 @@ impl OpeningLayout { } } } - if !(2..=3).contains(&factors.len()) { - return Err("invalid Solidity evaluation-point count".into()); + if factors.len() < 2 { + return Err("invalid evaluation-point count".into()); } let point_count = add(commitment_count, factors.len())?; - for matrix in &mut matrices[6..] { + for matrix in &mut matrices[3 * circuits..] { matrix.point_offset = add(matrix.point_offset, point_count)?; } let generator_index = add(point_count, fixed_count)?; @@ -182,6 +188,7 @@ impl OpeningLayout { fixed_count, generator_index, coefficient_count, + accumulator_count, }) } diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index d51280781..9d374fb9d 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -60,12 +60,12 @@ pub(super) fn claim_schema(claims: &[Vec]) -> Result { Ok(ClaimSchema::from_claims(claims, &public)?) } -struct Input { - system: System, - proof: Proof, - claims: Vec>, - logs: Vec, - proof_bytes: Vec, +pub(super) struct Input { + pub(super) system: System, + pub(super) proof: Proof, + pub(super) claims: Vec>, + pub(super) logs: Vec, + pub(super) proof_bytes: Vec, input_blake3: BTreeMap, setup_identity: [u8; 32], development_srs: bool, @@ -86,23 +86,26 @@ impl Input { skip_all, name = "compression/load_input" )] - fn load(dir: &Path, config: &super::super::WorkerConfig) -> Result { + pub(super) fn load( + dir: &Path, + config: &super::super::WorkerConfig, + ) -> Result { let start = Instant::now(); let mut input_blake3 = BTreeMap::new(); let manifest: Manifest = load(dir, "manifest.bin", &mut input_blake3)?; let count = manifest.heights.len(); let height = - *manifest.heights.iter().max().ok_or("empty recursive profile")?; + *manifest.heights.iter().max().ok_or("empty recursive verifying key")?; // The partition count follows the outer FRI proof's layout; the trace - // height is the supported profile's. Profile identity is bound separately - // through the profile id and the application's bundled verifier. + // height is the supported verifying key's. InnerVerifyingKey identity is bound separately + // through the verifying key id and the application's bundled verifier. if ![1 << 24, 1 << 25].contains(&height) || count < super::super::first::MIN_PARTITIONS || manifest.widths.len() != count || manifest.widths.contains(&0) || manifest.heights.iter().any(|h| *h < 2 || !h.is_power_of_two()) { - return Err("unsupported partition profile".into()); + return Err("unsupported partition verifying key".into()); } let statement = statement::from_rows(&manifest.claims)?; if manifest.claims @@ -192,7 +195,7 @@ pub(crate) fn check( let input = Input::load(dir, config)?; let build_start = Instant::now(); let plan = input.plan()?; - let fixed_profile = plan.identity(); + let plan_identity = plan.identity(); let built = tracing::info_span!("compression/build_circuit") .in_scope(|| plan.build(&input.proof, &input.claims))?; let build_seconds = build_start.elapsed().as_secs_f64(); @@ -218,7 +221,7 @@ pub(crate) fn check( "input_proof_blake3": blake3::hash(&input.proof_bytes).to_hex().to_string(), "input_blake3": input.input_blake3, "setup_id": blake3::Hash::from_bytes(input.setup_identity).to_hex().to_string(), - "frontend_identity": blake3::Hash::from_bytes(fixed_profile).to_hex().to_string(), + "frontend_identity": blake3::Hash::from_bytes(plan_identity).to_hex().to_string(), "load_and_native_verify_seconds": input.load_seconds, "build_seconds": build_seconds, "total_seconds": start.elapsed().as_secs_f64(), @@ -233,7 +236,7 @@ pub(crate) fn check( built, assignment, stage, - &fixed_profile, + &plan_identity, &statement, config, )?; diff --git a/crates/proof-compression/src/recursive/native_verifier.rs b/crates/proof-compression/src/recursive/native_verifier.rs index fa1de4e90..6a6c67010 100644 --- a/crates/proof-compression/src/recursive/native_verifier.rs +++ b/crates/proof-compression/src/recursive/native_verifier.rs @@ -1,4 +1,4 @@ -//! Fixed-profile ordinary KZG verification with publicly bound pairing inputs. +//! Fixed-verifying key ordinary KZG verification with publicly bound pairing inputs. use crate::{ kzg::{ KzgCommitment, KzgConfig, Scalar, @@ -61,7 +61,7 @@ pub struct Built { } #[derive(Clone, Copy)] -pub struct Profile<'a> { +pub struct InnerVerifyingKey<'a> { pub circuits: &'a [multi_stark::system::Circuit], pub fixed: &'a KzgCommitment, pub transcript_seed: &'a [u8], @@ -324,15 +324,15 @@ pub fn build( } /// Count the ordinary wrapper constraints without retaining an IR or generating -/// a witness. This measures a profile; it does not verify its proof or its SRS. +/// a witness. This measures a verifying key; it does not verify its proof or its SRS. pub fn count( - profile: &Profile<'_>, + vk: &InnerVerifyingKey<'_>, proof: &Proof, claims: &[Vec], public_locations: &[(usize, usize)], ) -> Result { - let plan = Plan::from_profile( - profile, + let plan = Plan::from_identity( + vk, &proof.log_degrees, ClaimSchema::from_claims(claims, public_locations)?, LayoutPolicy::legacy(), @@ -342,17 +342,17 @@ pub fn count( } fn construct( - profile: &Profile<'_>, + vk: &InnerVerifyingKey<'_>, shape: &Shape, claims: &ClaimSchema, builder: Builder, ) -> Result { - let circuits = profile.circuits; + let circuits = vk.circuits; let quotients: Vec<_> = circuits.iter().map(|c| c.quotient_degree()).collect(); let logs = &shape.logs; let mut c = Context::new(builder); - let fixed = c.fixed_commitment(profile.fixed); + let fixed = c.fixed_commitment(vk.fixed); let main = c.commitment(Round::Main, &shape.rounds[Round::Main.index()]); let stage2 = c.commitment(Round::Stage2, &shape.rounds[Round::Stage2.index()]); @@ -361,7 +361,7 @@ fn construct( let witnesses: Vec<_> = (0..shape.opening_generators.len()) .map(|index| c.point(PointSource::Witness(index))) .collect(); - let generator = c.point(PointSource::Fixed(profile.generator)); + let generator = c.point(PointSource::Fixed(vk.generator)); let prefix_blocks = c.point_ends.len(); let prefix_values = c.b.stats().values; let parallel_prefix = c.seal_point_prefix(); @@ -417,7 +417,7 @@ fn construct( .collect() }) .collect(); - let mut t = Transcript::new(&mut c.b, &c.bytes, profile.transcript_seed); + let mut t = Transcript::new(&mut c.b, &c.bytes, vk.transcript_seed); c.observe_constant(&mut t, circuits.len()); for circuit in circuits { for v in [ @@ -645,13 +645,13 @@ fn construct( let mut terms = vec![]; let mut keys = vec![]; terms.push(combine(&mut c.b, lhs)); - keys.push(profile.g2); + keys.push(vk.g2); let rhs = rhs .into_iter() .map(|(id, v)| (id, c.b.scale(v, Scalar::NEG_ONE))) .collect(); terms.push(combine(&mut c.b, rhs)); - keys.push(profile.tau_g2); + keys.push(vk.tau_g2); let mut outputs = vec![]; phases.push(PhaseCounts { name: "scalar_checks", stats: c.b.stats() }); for (i, terms) in terms.iter().enumerate() { @@ -830,7 +830,7 @@ mod tests { ); system.verify_multiple_claims(&refs, &proof).unwrap(); let mut built = build(&system, &srs, &proof, &claims, &[(1, 3)]).unwrap(); - let profile = Profile { + let vk = InnerVerifyingKey { circuits: &system.circuits, fixed: system.preprocessed_commit.as_ref().unwrap(), transcript_seed: system.config.transcript_seed(), @@ -840,14 +840,14 @@ mod tests { tau_g2: srs.tau_g2, max_log_degree: system.config.max_log_degree(), }; - let counted = count(&profile, &proof, &claims, &[(1, 3)]).unwrap(); + let counted = count(&vk, &proof, &claims, &[(1, 3)]).unwrap(); let encoded = crate::kzg::compact::FixedProofCodec::new(&system, &proof.log_degrees) .unwrap() .encode(&proof) .unwrap(); assert_eq!( - Shape::new(&profile, &proof.log_degrees) + Shape::new(&vk, &proof.log_degrees) .unwrap() .checked_compact_len() .unwrap(), diff --git a/crates/proof-compression/src/recursive/native_verifier/bindings.rs b/crates/proof-compression/src/recursive/native_verifier/bindings.rs index 2cd37be79..9654ef928 100644 --- a/crates/proof-compression/src/recursive/native_verifier/bindings.rs +++ b/crates/proof-compression/src/recursive/native_verifier/bindings.rs @@ -78,7 +78,9 @@ impl Bindings { claims: &[Vec], ) -> Result { if candidate_id != self.identity { - return Err("request uses a different recursive frontend profile".into()); + return Err( + "request uses a different recursive frontend verifying key".into(), + ); } self.shape.validate(proof)?; self.claims.validate(claims)?; @@ -111,7 +113,7 @@ impl Bindings { }) } - /// Assign one request to a circuit compiled once for its profile. + /// Assign one request to a circuit compiled once for its verifying key. #[cfg(test)] pub(crate) fn check( &self, diff --git a/crates/proof-compression/src/recursive/native_verifier/plan.rs b/crates/proof-compression/src/recursive/native_verifier/plan.rs index 1bdf4faea..75144253a 100644 --- a/crates/proof-compression/src/recursive/native_verifier/plan.rs +++ b/crates/proof-compression/src/recursive/native_verifier/plan.rs @@ -129,7 +129,7 @@ impl LayoutPolicy { } pub(crate) struct Plan<'a> { - pub(super) profile: Profile<'a>, + pub(super) vk: InnerVerifyingKey<'a>, pub(super) shape: Shape, pub(super) claims: ClaimSchema, pub(super) layout: LayoutPolicy, @@ -156,7 +156,7 @@ impl<'a> Plan<'a> { ); } let params = system.config.pcs().verifier_params(); - let profile = Profile { + let vk = InnerVerifyingKey { circuits: &system.circuits, fixed: system .preprocessed_commit @@ -169,7 +169,7 @@ impl<'a> Plan<'a> { tau_g2: params.tau_g2, max_log_degree: system.config.max_log_degree(), }; - let plan = Self::from_profile(&profile, logs, claims, layout)?; + let plan = Self::from_identity(&vk, logs, claims, layout)?; for (circuit, &log) in system.circuits.iter().zip(logs) { let quotient = circuit.quotient_degree(); if quotient > system.config.max_quotient_degree() @@ -184,31 +184,24 @@ impl<'a> Plan<'a> { Ok(plan) } - pub(super) fn from_profile( - profile: &Profile<'a>, + pub(super) fn from_identity( + vk: &InnerVerifyingKey<'a>, logs: &[u8], claims: ClaimSchema, layout: LayoutPolicy, ) -> Result { - if profile.transcript_kind != crate::kzg::TranscriptKind::Blake3 { + if vk.transcript_kind != crate::kzg::TranscriptKind::Blake3 { return Err( "recursive circuit requires a BLAKE3 inner transcript".into(), ); } layout.validate()?; - let shape = Shape::new(profile, logs)?; - if profile.generator.is_zero() - || profile.g2.is_zero() - || profile.tau_g2.is_zero() - { + let shape = Shape::new(vk, logs)?; + if vk.generator.is_zero() || vk.g2.is_zero() || vk.tau_g2.is_zero() { return Err("recursive verifier anchors must be nonidentity".into()); } - for point in profile - .fixed - .0 - .iter() - .flatten() - .chain(std::iter::once(&profile.generator)) + for point in + vk.fixed.0.iter().flatten().chain(std::iter::once(&vk.generator)) { if !point.is_on_curve() || !point.is_in_correct_subgroup_assuming_on_curve() @@ -216,15 +209,15 @@ impl<'a> Plan<'a> { return Err("invalid fixed G1 verifier point".into()); } } - for point in [profile.g2, profile.tau_g2] { + for point in [vk.g2, vk.tau_g2] { if !point.is_on_curve() || !point.is_in_correct_subgroup_assuming_on_curve() { return Err("invalid G2 verifier point".into()); } } - let identity = identity(profile, &shape, &claims, layout)?; - Ok(Self { profile: *profile, shape, claims, layout, identity }) + let identity = identity(vk, &shape, &claims, layout)?; + Ok(Self { vk: *vk, shape, claims, layout, identity }) } pub(crate) fn identity(&self) -> [u8; 32] { @@ -232,7 +225,7 @@ impl<'a> Plan<'a> { } pub(crate) fn count(&self) -> Result { - construct(&self.profile, &self.shape, &self.claims, Builder::counting()) + construct(&self.vk, &self.shape, &self.claims, Builder::counting()) .map(|construction| construction.counts()) } @@ -242,7 +235,7 @@ impl<'a> Plan<'a> { ) -> Result<(), String> { if bytes != self.shape.checked_compact_len()? { return Err( - "compact proof length differs from the verifier profile".into(), + "compact proof length differs from the verifier verifying key".into(), ); } Ok(()) @@ -271,7 +264,7 @@ impl<'a> Plan<'a> { pub(crate) fn compile(&self) -> Result { let construction = - construct(&self.profile, &self.shape, &self.claims, Builder::new())?; + construct(&self.vk, &self.shape, &self.claims, Builder::new())?; let compiled = construction.finish(self); let layout = compiled .circuit @@ -313,7 +306,7 @@ impl<'a> Plan<'a> { } fn identity( - profile: &Profile<'_>, + vk: &InnerVerifyingKey<'_>, shape: &Shape, claims: &ClaimSchema, layout: LayoutPolicy, @@ -322,18 +315,18 @@ fn identity( hash.update(FRONTEND_VERSION); hash.update(b"one-computation-and-merged-table/v1"); let parameters = ( - profile.transcript_seed, - profile.max_log_degree, + vk.transcript_seed, + vk.max_log_degree, shape, claims, layout, - (0..profile.circuits.len()).collect::>(), + (0..vk.circuits.len()).collect::>(), ); hash.update( &bincode::serde::encode_to_vec(parameters, bincode::config::standard()) .map_err(|error| error.to_string())?, ); - for circuit in profile.circuits { + for circuit in vk.circuits { let metadata = ( &circuit.graph, circuit.main_width, @@ -353,21 +346,15 @@ fn identity( hash.update(&bytes); } let mut points = vec![]; - profile - .fixed + vk.fixed .0 .serialize_compressed(&mut points) .map_err(|error| error.to_string())?; - profile - .generator - .serialize_compressed(&mut points) - .map_err(|error| error.to_string())?; - profile - .g2 + vk.generator .serialize_compressed(&mut points) .map_err(|error| error.to_string())?; - profile - .tau_g2 + vk.g2.serialize_compressed(&mut points).map_err(|error| error.to_string())?; + vk.tau_g2 .serialize_compressed(&mut points) .map_err(|error| error.to_string())?; hash.update(&(points.len() as u64).to_le_bytes()); diff --git a/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs index c0046aadf..0731fc94f 100644 --- a/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs +++ b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs @@ -280,21 +280,19 @@ fn recursive_witness_rejects_tampering_without_native_preverification() { } #[test] -fn recursive_profile_rejects_identity_anchors_and_oversized_layout_caps() { +fn recursive_vk_rejects_identity_anchors_and_oversized_layout_caps() { let fixture = Fixture::new(); let (_, claims) = fixture.proof(17); let plan = Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); for anchor in 0..3 { - let mut profile = plan.profile; + let mut vk = plan.vk; match anchor { - 0 => profile.generator = G1Affine::identity(), - 1 => profile.g2 = G2Affine::identity(), - _ => profile.tau_g2 = G2Affine::identity(), + 0 => vk.generator = G1Affine::identity(), + 1 => vk.g2 = G2Affine::identity(), + _ => vk.tau_g2 = G2Affine::identity(), } - assert!( - Plan::from_profile(&profile, &[2], schema(&claims), layout()).is_err() - ); + assert!(Plan::from_identity(&vk, &[2], schema(&claims), layout()).is_err()); } let oversized = 1usize << (Scalar::TWO_ADICITY + 1); for bad_layout in [ @@ -432,7 +430,7 @@ fn recursive_exact_shape_rejects_every_payload_dimension() { } #[test] -fn recursive_profile_identity_binds_metadata_and_schema() { +fn recursive_vk_identity_binds_metadata_and_schema() { let mut fixture = Fixture::new(); let (_, claims) = fixture.proof(17); let id = |system: &System, schema, layout| { @@ -482,8 +480,7 @@ fn recursive_profile_identity_binds_metadata_and_schema() { } #[test] -fn recursive_profile_rejects_invalid_graph_and_fixed_points_before_construction() - { +fn recursive_vk_rejects_invalid_graph_and_fixed_points_before_construction() { let mut fixture = Fixture::new(); let (_, claims) = fixture.proof(17); assert!(Plan::new(&fixture.system, &[1], schema(&claims), layout()).is_err()); @@ -513,15 +510,15 @@ fn recursive_shape_deduplicates_log_zero_opening_points() { } #[test] -fn recursive_profile_rejects_keccak_before_circuit_construction() { +fn recursive_vk_rejects_keccak_before_circuit_construction() { let mut fixture = Fixture::new(); let (_, claims) = fixture.proof(7); let plan = Plan::new(&fixture.system, &[2], schema(&claims), layout()).unwrap(); - let mut profile = plan.profile; - profile.transcript_kind = crate::kzg::TranscriptKind::Keccak256; + let mut vk = plan.vk; + vk.transcript_kind = crate::kzg::TranscriptKind::Keccak256; assert!(matches!( - Plan::from_profile(&profile, &[2], schema(&claims), layout()), + Plan::from_identity(&vk, &[2], schema(&claims), layout()), Err(error) if error.contains("requires a BLAKE3 inner transcript") )); fixture.system.config = fixture diff --git a/crates/proof-compression/src/recursive/native_verifier/shape.rs b/crates/proof-compression/src/recursive/native_verifier/shape.rs index 64195d884..366827c69 100644 --- a/crates/proof-compression/src/recursive/native_verifier/shape.rs +++ b/crates/proof-compression/src/recursive/native_verifier/shape.rs @@ -21,7 +21,7 @@ impl Round { pub(super) fn commitment(self, proof: &Proof) -> &KzgCommitment { match self { Self::Fixed => { - unreachable!("fixed points belong to the authenticated profile") + unreachable!("fixed points belong to the authenticated verifying key") }, Self::Main => &proof.commitments.stage_1_trace, Self::Stage2 => &proof.commitments.stage_2_trace, @@ -61,7 +61,7 @@ impl MatrixShape { .any(|(row, &width)| row.len() != width) { return Err( - "commitment dimensions differ from the verifier profile".into(), + "commitment dimensions differ from the verifier verifying key".into(), ); } Ok(()) @@ -78,7 +78,9 @@ impl MatrixShape { }, ) { - return Err("opening dimensions differ from the verifier profile".into()); + return Err( + "opening dimensions differ from the verifier verifying key".into(), + ); } Ok(()) } @@ -93,14 +95,14 @@ pub(super) struct Shape { impl Shape { pub(super) fn new( - profile: &Profile<'_>, + vk: &InnerVerifyingKey<'_>, logs: &[u8], ) -> Result { - if logs.is_empty() || logs.len() != profile.circuits.len() { + if logs.is_empty() || logs.len() != vk.circuits.len() { return Err("trace logs must cover every circuit".into()); } - if profile.max_log_degree > Scalar::TWO_ADICITY - || profile.max_log_degree >= usize::BITS as usize + if vk.max_log_degree > Scalar::TWO_ADICITY + || vk.max_log_degree >= usize::BITS as usize { return Err("invalid maximum trace degree".into()); } @@ -108,10 +110,10 @@ impl Shape { widths: vec![], opening_rows: vec![], }); - for (circuit, &log) in profile.circuits.iter().zip(logs) { + for (circuit, &log) in vk.circuits.iter().zip(logs) { let height = 1usize.checked_shl(u32::from(log)).ok_or("trace height overflow")?; - if usize::from(log) > profile.max_log_degree + if usize::from(log) > vk.max_log_degree || circuit.preprocessed_height != height || circuit.preprocessed_width == 0 || circuit.main_width == 0 @@ -155,7 +157,7 @@ impl Shape { shape.opening_rows.push(rows); } } - rounds[Round::Fixed.index()].check_commitment(profile.fixed)?; + rounds[Round::Fixed.index()].check_commitment(vk.fixed)?; let mut opening_generators = vec![]; for round in Round::PCS { for (&log, &rows) in logs.iter().zip(&rounds[round.index()].opening_rows) @@ -213,7 +215,7 @@ impl Shape { || proof.opening_proof.0.len() != self.opening_generators.len() { return Err( - "proof activation, degree, accumulator or witness shape differs from profile" + "proof activation, degree, accumulator or witness shape differs from verifying key" .into(), ); } diff --git a/crates/proof-compression/tests/worker_tracing.rs b/crates/proof-compression/tests/worker_tracing.rs index 6a326c3b8..12d73e00c 100644 --- a/crates/proof-compression/tests/worker_tracing.rs +++ b/crates/proof-compression/tests/worker_tracing.rs @@ -62,7 +62,7 @@ fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { } #[test] -fn worker_pipeline_requires_an_explicit_profile_digest_before_io() { +fn worker_pipeline_requires_an_explicit_vk_digest_before_io() { let output = worker(false) .args([ "pipeline", @@ -70,7 +70,7 @@ fn worker_pipeline_requires_an_explicit_profile_digest_before_io() { "missing-proof", "missing-claim", "missing-claims", - "missing-profile", + "missing-vk", "not-a-digest", "--dev-srs", ]) @@ -93,7 +93,7 @@ fn ignition_verification_requires_both_pins_and_refuses_development_mode() { "missing-proof", "missing-claim", "missing-claims", - "missing-profile", + "missing-vk", pin.as_str(), "missing-admission", ]; diff --git a/crates/proof-compression/profiles/aggregate-development-v5.bin b/crates/proof-compression/verifying-keys/aggregate-development-v5.bin similarity index 100% rename from crates/proof-compression/profiles/aggregate-development-v5.bin rename to crates/proof-compression/verifying-keys/aggregate-development-v5.bin diff --git a/crates/proof-compression/profiles/aggregate-development-v5.json b/crates/proof-compression/verifying-keys/aggregate-development-v5.json similarity index 95% rename from crates/proof-compression/profiles/aggregate-development-v5.json rename to crates/proof-compression/verifying-keys/aggregate-development-v5.json index 199bf2713..99f26f14c 100644 --- a/crates/proof-compression/profiles/aggregate-development-v5.json +++ b/crates/proof-compression/verifying-keys/aggregate-development-v5.json @@ -1,9 +1,9 @@ { "setup": "development", "known_trapdoor": true, - "profile_version": 5, + "vk_version": 5, "first_stage_log_height": 24, - "profile_id": "304340325a2fff6bbfbca27e0b58b335cd9f0731b7698ef8e9d9f2f23fe408a1", + "vk_id": "304340325a2fff6bbfbca27e0b58b335cd9f0731b7698ef8e9d9f2f23fe408a1", "verifier_blake3": "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", "packet_blake3": "6162de2ddcf6b5ac6cc783f653ead5815f8504683c4f83d2b301fd61b3cafe47", "source_fri_verifier_blake3": "5f7db8e94c52f404df1c2658d4c686b7e1ccb5d95b473d47c0efda96141f89cf", diff --git a/crates/proof-compression/profiles/aggregate-development-v6.bin b/crates/proof-compression/verifying-keys/aggregate-development-v6.bin similarity index 100% rename from crates/proof-compression/profiles/aggregate-development-v6.bin rename to crates/proof-compression/verifying-keys/aggregate-development-v6.bin diff --git a/crates/proof-compression/profiles/aggregate-development-v6.json b/crates/proof-compression/verifying-keys/aggregate-development-v6.json similarity index 61% rename from crates/proof-compression/profiles/aggregate-development-v6.json rename to crates/proof-compression/verifying-keys/aggregate-development-v6.json index 50cce6964..78ac8db9d 100644 --- a/crates/proof-compression/profiles/aggregate-development-v6.json +++ b/crates/proof-compression/verifying-keys/aggregate-development-v6.json @@ -1,12 +1,12 @@ { - "profile_version": 6, + "vk_version": 6, "transcript": "blake3", "setup": "development", "known_trapdoor": true, - "profile_id": "b0db3d47f5f9e08b3710499adaba92ad6c5bd160393d45aef1a505c5909cd580", + "vk_id": "b0db3d47f5f9e08b3710499adaba92ad6c5bd160393d45aef1a505c5909cd580", "verifier_blake3": "116c668b17db0a1bd95ea8cce9d080af14b16031db128c47a3dcd46443147678", - "source_profile": "aggregate-development-v5.bin", + "source_vk": "aggregate-development-v5.bin", "source_verifier_blake3": "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", "statement_prefix": [0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, 1176003176, 683533119, 3085659920], - "derivation": "The authenticated circuit metadata, fixed commitments and development setup anchors are unchanged; the profile version selects the BLAKE3 transcript. A fresh proof is required." + "derivation": "The authenticated circuit metadata, fixed commitments and development setup anchors are unchanged; the verifying key version selects the BLAKE3 transcript. A fresh proof is required." } diff --git a/crates/proof-compression/profiles/aggregate-filecoin-v5.bin b/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.bin similarity index 100% rename from crates/proof-compression/profiles/aggregate-filecoin-v5.bin rename to crates/proof-compression/verifying-keys/aggregate-filecoin-v5.bin diff --git a/crates/proof-compression/profiles/aggregate-filecoin-v5.json b/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json similarity index 95% rename from crates/proof-compression/profiles/aggregate-filecoin-v5.json rename to crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json index 4686dcc72..7cce6076e 100644 --- a/crates/proof-compression/profiles/aggregate-filecoin-v5.json +++ b/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json @@ -1,9 +1,9 @@ { "setup": "filecoin", "known_trapdoor": false, - "profile_version": 5, + "vk_version": 5, "first_stage_log_height": 24, - "profile_id": "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", + "vk_id": "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", "verifier_blake3": "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", "packet_blake3": "80d136cbc7496a3436a2d9d291497025742a5573ffcd565a76bf1f21024c0b4c", "source_fri_verifier_blake3": "5f7db8e94c52f404df1c2658d4c686b7e1ccb5d95b473d47c0efda96141f89cf", diff --git a/crates/proof-compression/profiles/aggregate-filecoin-v6.bin b/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.bin similarity index 100% rename from crates/proof-compression/profiles/aggregate-filecoin-v6.bin rename to crates/proof-compression/verifying-keys/aggregate-filecoin-v6.bin diff --git a/crates/proof-compression/profiles/aggregate-filecoin-v6.json b/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json similarity index 62% rename from crates/proof-compression/profiles/aggregate-filecoin-v6.json rename to crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json index c8b6c9bcd..1620096d4 100644 --- a/crates/proof-compression/profiles/aggregate-filecoin-v6.json +++ b/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json @@ -1,12 +1,12 @@ { - "profile_version": 6, + "vk_version": 6, "transcript": "blake3", "setup": "filecoin", "known_trapdoor": false, - "profile_id": "4224c47197ed387320dfb9b8bbf2369fa326f72c54e1cbc65907f5e6e8541ca0", + "vk_id": "4224c47197ed387320dfb9b8bbf2369fa326f72c54e1cbc65907f5e6e8541ca0", "verifier_blake3": "12ce28356f86ba34afb11b8e3b2c142f47ab9259e9c3f5751069bd1c41609084", - "source_profile": "aggregate-filecoin-v5.bin", + "source_vk": "aggregate-filecoin-v5.bin", "source_verifier_blake3": "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", "statement_prefix": [0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, 1176003176, 683533119, 3085659920], - "derivation": "The authenticated circuit metadata, fixed commitments and setup anchors are unchanged; the profile version selects the BLAKE3 transcript. A fresh proof is required." + "derivation": "The authenticated circuit metadata, fixed commitments and setup anchors are unchanged; the verifying key version selects the BLAKE3 transcript. A fresh proof is required." } diff --git a/flake.nix b/flake.nix index 5ece5f2d0..c43acf8f5 100644 --- a/flake.nix +++ b/flake.nix @@ -193,7 +193,7 @@ # Profiles are embedded in the verifier; compression tests also read # binary fixtures that Cargo's source filter does not retain. compressionData = pkgs.lib.fileset.unions [ - ./crates/proof-compression/profiles + ./crates/proof-compression/verifying-keys ./crates/proof-compression/tests/fixtures ]; # The Rust tests `include_bytes!` the `.ixe` files under diff --git a/lakefile.lean b/lakefile.lean index 642197c1d..53cbf9523 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -141,7 +141,7 @@ def copyNativeArtifact (source output : FilePath) (tag : String) : JobM Unit := proc { cmd := "cp", args := #["-f", source.toString, temporary.toString] } (quiet := true) IO.FS.rename temporary output -/-- Every source, kernel, manifest and profile file the Cargo builds read. -/ +/-- Every source, kernel, manifest and verifying-key file the Cargo builds read. -/ def rustSources (pkg : Package) : SpawnM (Job Unit) := do let sources ← inputDir (pkg.dir / "crates") true fun path => path.extension == some "rs" || path.extension == some "cu" || @@ -149,14 +149,14 @@ def rustSources (pkg : Package) : SpawnM (Job Unit) := do path.extension == some "h" || path.extension == some "hpp" || path.fileName == "Cargo.toml" || path.fileName == "trace-manifest.json" - let profiles ← inputDir (pkg.dir / "crates/proof-compression/profiles") false fun path => + let keys ← inputDir (pkg.dir / "crates/proof-compression/verifying-keys") false fun path => path.extension == some "bin" let manifests := Job.collectArray #[ ← inputTextFile (pkg.dir / "Cargo.toml"), ← inputTextFile (pkg.dir / "Cargo.lock"), ← inputTextFile (pkg.dir / ".cargo/config.toml") ] - let sources := sources.zipWith (fun _ _ => ()) profiles + let sources := sources.zipWith (fun _ _ => ()) keys return sources.zipWith (fun _ _ => ()) manifests /-- Cargo's archive or executable and its native library are one build output. diff --git a/tests/kzg_backend_parity.py b/tests/kzg_backend_parity.py index 96b6fa225..99166a32b 100644 --- a/tests/kzg_backend_parity.py +++ b/tests/kzg_backend_parity.py @@ -132,10 +132,10 @@ def main(): for key in ['curve', 'input_blake3', 'proof_blake3', 'verifier_blake3']: if candidate[key] != reference[key]: raise RuntimeError(f'{curve} {mode}: {key} differs from CPU') - for artifact in ['proof.bin', 'verifier.bin']: + for artifact in ['proof.bin', 'vk.bin']: if (directory / mode / artifact).read_bytes() != (directory / 'cpu' / artifact).read_bytes(): raise RuntimeError(f'{curve} {mode}: {artifact} differs from CPU') - verifier = directory / 'cpu' / 'verifier.bin' + verifier = directory / 'cpu' / 'vk.bin' pin = reference['verifier_blake3'] verified = [] for mode in runs: @@ -159,7 +159,7 @@ def main(): directory = root / curve record = run([ str(directory / 'bin' / 'cpu'), 'verify', str(root / other / 'cpu' / 'proof.bin'), - str(directory / 'cpu' / 'verifier.bin'), report['curves'][curve]['verifier_blake3'], + str(directory / 'cpu' / 'vk.bin'), report['curves'][curve]['verifier_blake3'], ], directory / 'reject-other-curve.log', dict(environment, CUDA_VISIBLE_DEVICES=''), success=False, expected_error='CompactProofError') report['cross_curve_rejections'].append(record) diff --git a/tools/bls-plonk-wrapper/README.md b/tools/bls-plonk-wrapper/README.md index a105eb18a..eaac80af6 100644 --- a/tools/bls-plonk-wrapper/README.md +++ b/tools/bls-plonk-wrapper/README.md @@ -145,7 +145,7 @@ prefix represented by that key. The generated `verifier-catalog.candidate.json` has the catalog schema and uses a path relative to its directory for `verifying-key.bin`. It records the key's -SHA-256, inner profile, program prefix, circuit hash and helper hash. Review and +SHA-256, inner verifying key, program prefix, circuit hash and helper hash. Review and install accepted entries as trusted application configuration. Proof envelopes carry only a key identifier and proof bytes; they cannot supply a trusted key. @@ -162,14 +162,14 @@ tagged and cannot be accepted as a BLS proof. ## Standalone operations -`ix-compression-worker plonk export` authenticates a portable BLAKE3 profile, +`ix-compression-worker plonk export` authenticates a portable BLAKE3 verifying key, verifies its packet against an independently expected statement, and exports the fixed scalar program and canonical witness: ```sh ix-compression-worker plonk export \ - verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input -ix-bls-plonk-wrapper compile input/profile.json artifacts + vk.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input +ix-bls-plonk-wrapper compile input/vk.json artifacts ix-bls-plonk-wrapper check-witness artifacts input/witness.json ``` @@ -196,7 +196,7 @@ monomial powers and `N` Lagrange points; the adapter requests the next power of two from ix and retains the ceremony's full public degree. Its current maximum PLONK domain is `2^26` with the provisioned `2^27` cache. -Circuit and key artifacts bind the profile, executable and parameter receipt. +Circuit and key artifacts bind the verifying key, executable and parameter receipt. The helper checks hashes before reuse. Lagrange points are cached separately by setup, domain, receipt and helper identity; `IX_PLONK_LAGRANGE_CACHE` can select the shared directory. A changed executable needs a new artifact @@ -275,7 +275,7 @@ batching measurement variant reaches 13,810,186 rows with classic GLV; batching with BN254's previous multiplication path reaches 17,530,148 rows. Batching is experimental and is not selected by the normal helper. Reproduce sizing with `IX_PLONK_SIZE=1 go test -tags bn254 -run TestOptimizationSizing -v`; -`IX_PLONK_SIZE_PROFILE` selects a profile and `IX_PLONK_SIZE_REPORT` saves JSON. +`IX_PLONK_SIZE_VK` selects a verifying key and `IX_PLONK_SIZE_REPORT` saves JSON. `IX_PLONK_SIZE_VARIANT` can select `baseline`, `batched`, `classic-glv` or `batched-classic-glv`. These are compilation results, separate from the completed 2^25 proof benchmark. diff --git a/tools/bls-plonk-wrapper/artifacts.go b/tools/bls-plonk-wrapper/artifacts.go index d4ec41c82..78c382556 100644 --- a/tools/bls-plonk-wrapper/artifacts.go +++ b/tools/bls-plonk-wrapper/artifacts.go @@ -15,7 +15,7 @@ type artifact struct { Bytes int64 } type manifest struct { - Format, ExecutableSHA256, ProfileSHA256 string + Format, ExecutableSHA256, InnerVKSHA256 string SRSBLAKE3 string KeysReady bool Domain uint64 @@ -121,11 +121,11 @@ func loadManifest(dir string) (*manifest, error) { if identity.SHA256 != m.ExecutableSHA256 { return nil, fmt.Errorf("wrapper executable changed; recompile circuit and keys") } - if err := m.checkFile(dir, "profile.json"); err != nil { + if err := m.checkFile(dir, "vk.json"); err != nil { return nil, err } - if m.Files["profile.json"].SHA256 != m.ProfileSHA256 { - return nil, fmt.Errorf("profile identity differs") + if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { + return nil, fmt.Errorf("verifying key identity differs") } return &m, nil } diff --git a/tools/bls-plonk-wrapper/backend.go b/tools/bls-plonk-wrapper/backend.go index bd4ef8bb0..2d7ece0ea 100644 --- a/tools/bls-plonk-wrapper/backend.go +++ b/tools/bls-plonk-wrapper/backend.go @@ -22,7 +22,7 @@ import ( // readSRS consumes ix's authenticated export. The expected digest comes from // the Rust parameter loader, independently of this file's contents. -func readSRS(path, expectedDigest string, domain uint64, p *profile) (*nativeSRS, error) { +func readSRS(path, expectedDigest string, domain uint64, p *verifyingKey) (*nativeSRS, error) { expected, err := unhex(expectedDigest, 32) if err != nil { return nil, err @@ -119,7 +119,7 @@ func prepare(dir, path, digest string) error { if err := m.checkFile(dir, "circuit.bin"); err != nil { return err } - p, err := loadProfile(filepath.Join(dir, "profile.json")) + p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) if err != nil { return err } diff --git a/tools/bls-plonk-wrapper/backend_bn254_test.go b/tools/bls-plonk-wrapper/backend_bn254_test.go index 434ec07b4..bdfbb9ae8 100644 --- a/tools/bls-plonk-wrapper/backend_bn254_test.go +++ b/tools/bls-plonk-wrapper/backend_bn254_test.go @@ -41,7 +41,7 @@ func (c *backendCircuit) Define(api frontend.API) error { } func TestIgnitionBackendAndProofCodec(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go b/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go index a716f4970..7e7d9c0f1 100644 --- a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go +++ b/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go @@ -24,7 +24,7 @@ func TestCUDAProvingBackendFactory(t *testing.T) { } t.Setenv("IX_PLONK_BACKEND", "cuda") t.Setenv("IX_PLONK_CUDA_MEMORY_GIB", "8") - p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/backend_test.go b/tools/bls-plonk-wrapper/backend_test.go index 5e141a821..4d2887750 100644 --- a/tools/bls-plonk-wrapper/backend_test.go +++ b/tools/bls-plonk-wrapper/backend_test.go @@ -41,7 +41,7 @@ func (c *backendCircuit) Define(api frontend.API) error { } func TestFilecoinBackendAndProofCodec(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "outer", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/circuit.go b/tools/bls-plonk-wrapper/circuit.go index 9bb3fc2e5..6146d8212 100644 --- a/tools/bls-plonk-wrapper/circuit.go +++ b/tools/bls-plonk-wrapper/circuit.go @@ -15,9 +15,9 @@ import ( type wrapperCircuit struct { Claim [2]frontend.Variable `gnark:",public"` Points []circuitG1 - Deferred [2]circuitG1 + Deferred []circuitG1 Values []frontend.Variable - Profile *profile `gnark:"-"` + VK *verifyingKey `gnark:"-"` } func scalarBytes(api frontend.API, u *uints.BinaryField[uints.U32], v frontend.Variable) []uints.U8 { @@ -44,21 +44,22 @@ func littleEndian(api frontend.API, u *uints.BinaryField[uints.U32], bytes []uin } func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints.U32], encoded [][]uints.U8, publics []frontend.Variable) ([]frontend.Variable, []uints.U8) { - p := c.Profile + p := c.VK seed, _ := unhex(p.Seed, -1) t := newTranscript(api, u, seed) field := func(v frontend.Variable) { t.observe(scalarBytes(api, u, v)) } + circuits := len(p.Circuits) commitment := func(round int) { - t.observe(constantU64(2)) - for i := 0; i < 2; i++ { - m := p.Matrices[2*round+i] + t.observe(constantU64(uint64(circuits))) + for i := 0; i < circuits; i++ { + m := p.Matrices[circuits*round+i] t.observe(constantU64(uint64(m.Width))) for j := 0; j < m.Width; j++ { t.observe(encoded[m.PointOffset+j]) } } } - field(2) + field(circuits) for _, circuit := range p.Circuits { for _, value := range circuit.Shape { field(value) @@ -71,13 +72,17 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints for _, circuit := range p.Circuits { field(circuit.LogDegree) } - field(23) - for i, value := range publics { - field(4) - field(94) - field(1) - field(i) - field(value) + rows := p.claimRows() + field(len(rows)) + for _, row := range rows { + field(len(row)) + for _, slot := range row { + if slot.Constant != nil { + field(*slot.Constant) + } else { + field(publics[*slot.Public]) + } + } } ch := make([]frontend.Variable, 6) ch[0] = t.sample() @@ -85,7 +90,10 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints ch[1] = t.sample() field(ch[1]) commitment(1) - field(c.Values[0]) + accumulators := p.accumulators() + for k := 0; k < accumulators; k++ { + field(c.Values[k]) + } field(0) ch[2] = t.sample() commitment(2) @@ -96,7 +104,7 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints t.observe(constantU64(uint64(circuit.LogDegree))) } } - for _, value := range c.Values[1:] { + for _, value := range c.Values[accumulators:] { field(value) } ch[4] = t.sample() @@ -155,9 +163,9 @@ type circuitOptions struct { } func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, options circuitOptions) error { - p := c.Profile + p := c.VK if p == nil { - return fmt.Errorf("missing trusted profile") + return fmt.Errorf("missing trusted verifying key") } if err := p.validate(); err != nil { return err @@ -203,22 +211,35 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio g, _ := p.Generator.native() generator := newCircuitG1(g) points = append(points, &generator) - publics := make([]frontend.Variable, 23) - publics[0] = 0 - for i, word := range p.Prefix { - publics[1+i] = word + if len(c.Deferred) != p.deferredCount() { + return fmt.Errorf("deferred operand count differs from the verifying key") } + var digestWords [8]frontend.Variable for half := 0; half < 2; half++ { b := bits.ToBinary(api, c.Claim[half], bits.WithNbDigits(128)) for word := 0; word < 4; word++ { - publics[11+4*half+word] = bits.FromBinary(api, b[32*word:32*word+32]) + digestWords[4*half+word] = bits.FromBinary(api, b[32*word:32*word+32]) } } + deferredHalves := make([]frontend.Variable, 0, 2*len(c.Deferred)) for i := range c.Deferred { pairing.AssertIsOnG1(&c.Deferred[i]) bytes := pointBytes(api, u, f, &c.Deferred[i]) - publics[19+2*i] = littleEndian(api, u, bytes[:pointLimbBytes]) - publics[20+2*i] = littleEndian(api, u, bytes[pointLimbBytes:]) + deferredHalves = append(deferredHalves, littleEndian(api, u, bytes[:pointLimbBytes]), littleEndian(api, u, bytes[pointLimbBytes:])) + } + sources := p.publicSources() + publics := make([]frontend.Variable, len(sources)) + for i, source := range sources { + switch { + case source.Constant != nil: + publics[i] = *source.Constant + case source.Prefix != nil: + publics[i] = p.Prefix[*source.Prefix] + case source.DigestWord != nil: + publics[i] = digestWords[*source.DigestWord] + default: + publics[i] = deferredHalves[*source.DeferredHalf] + } } if phase == 1 { return nil @@ -244,6 +265,9 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio return nil } if options.batchPairings { + if len(c.Deferred) != 2 { + return fmt.Errorf("pairing batching requires two deferred operands") + } return c.batchedPairing(api, u, f, s, curve, packetDigest, left, right) } outer, _ := g2Keys(p.OuterKeys) @@ -251,7 +275,7 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio if err := pairing.PairingCheck([]*circuitG1{left, curve.Neg(right)}, []*circuitG2{&h, &tauH}); err != nil { return err } - if phase == 5 { + if phase == 5 || len(c.Deferred) == 0 { return nil } inner, _ := g2Keys(p.DeferredKeys) @@ -261,7 +285,7 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio // Fixed-point relations are checked at circuit construction. Variable inputs // retain their curve and subgroup checks even when a scalar is constant zero. -func foldFixedBases(api frontend.API, p *profile, points []*circuitG1, scalars []frontend.Variable) ([]*circuitG1, []frontend.Variable) { +func foldFixedBases(api frontend.API, p *verifyingKey, points []*circuitG1, scalars []frontend.Variable) ([]*circuitG1, []frontend.Variable) { weights := append([]frontend.Variable(nil), scalars...) indices := make([]int, p.PointCount) for i := range indices { diff --git a/tools/bls-plonk-wrapper/circuit_test.go b/tools/bls-plonk-wrapper/circuit_test.go index 25f1d9127..2ebed580d 100644 --- a/tools/bls-plonk-wrapper/circuit_test.go +++ b/tools/bls-plonk-wrapper/circuit_test.go @@ -80,7 +80,7 @@ func TestIdentityAndCanonicalPointEncoding(t *testing.T) { } func TestFullVerifierWitness(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "outer", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go b/tools/bls-plonk-wrapper/cuda_init_bn254_test.go index 7737ce8bf..497d2a81c 100644 --- a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go +++ b/tools/bls-plonk-wrapper/cuda_init_bn254_test.go @@ -102,11 +102,11 @@ func TestBN254CUDAInit(t *testing.T) { if err := cpuProfileReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { t.Fatal(err) } - p, err := loadProfile(filepath.Join(dir, "profile.json")) + p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) if err != nil { t.Fatal(err) } - if err := m.checkFile(dir, "profile.json"); err != nil { + if err := m.checkFile(dir, "vk.json"); err != nil { t.Fatal(err) } assignment, err := witnessAssignment(p, witnessPath) @@ -210,17 +210,17 @@ func TestBN254CUDAInit(t *testing.T) { } profileThisRun := cpuProfile && len(runs) == 0 profilePath := "" - var profileFile *os.File + var vkFile *os.File var profileStartSeconds, profileStopSeconds float64 if profileThisRun { profileStarted := time.Now() profilePath = filepath.Join(output, stem+".cpu.pprof") - profileFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + vkFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } if err := pprof.StartCPUProfile(profileFile); err != nil { - profileFile.Close() + vkFile.Close() t.Fatal(err) } profileStartSeconds = time.Since(profileStarted).Seconds() @@ -230,7 +230,7 @@ func TestBN254CUDAInit(t *testing.T) { var proveSeconds float64 var profileErr error func() { - if profileFile != nil { + if vkFile != nil { defer func() { profileStopped := time.Now() pprof.StopCPUProfile() diff --git a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go b/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go index 4e3e65b6f..c4f819b2e 100644 --- a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go +++ b/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go @@ -123,12 +123,12 @@ func cpuProfileManifest(dir, expectedSHA256 string) (*manifest, error) { if m.Format != artifactFormat || !m.KeysReady || m.Domain != 1<<24 || m.Constraints != 14689440 || m.PublicInputs != 2 || m.Commitments != 1 || len(m.Files) != 5 { return nil, fmt.Errorf("profiling requires the prepared classic-GLV 2^24 BN254 wrapper") } - for _, digest := range []string{m.ExecutableSHA256, m.ProfileSHA256, m.SRSBLAKE3} { + for _, digest := range []string{m.ExecutableSHA256, m.InnerVKSHA256, m.SRSBLAKE3} { if _, err := cpuProfileDigest(digest); err != nil { return nil, fmt.Errorf("invalid profiling manifest identity: %w", err) } } - for _, name := range []string{"circuit.bin", "proving-key.bin", "verifying-key.bin", "profile.json", "PlonkVerifier.sol"} { + for _, name := range []string{"circuit.bin", "proving-key.bin", "verifying-key.bin", "vk.json", "PlonkVerifier.sol"} { want, ok := m.Files[name] if !ok || want.Bytes <= 0 { return nil, fmt.Errorf("missing profiling artifact %s", name) @@ -147,8 +147,8 @@ func cpuProfileManifest(dir, expectedSHA256 string) (*manifest, error) { return nil, err } } - if m.Files["profile.json"].SHA256 != m.ProfileSHA256 { - return nil, fmt.Errorf("profile identity differs from its artifact digest") + if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { + return nil, fmt.Errorf("verifying key identity differs from its artifact digest") } return &m, nil } @@ -238,7 +238,7 @@ func TestBN254CPUProfile(t *testing.T) { } started := time.Now() report := cpuProfileReport{ - Format: "ix-bn254-plonk-cpu-profile-v1", + Format: "ix-bn254-plonk-cpu-verifying key-v1", Scope: "Authenticated existing 2^24 artifacts; one unmeasured warmup, followed by measured proofs in one process. CS/PK/VK/witness reused; fresh gnark randomness each proof; default CPU parallelism and natural GC. No accelerator, circuit compilation, SRS conversion or key setup.", Stage: "artifact-validation", StartedUTC: started.UTC().Format(time.RFC3339Nano), Artifacts: dir, ManifestSHA256: expectedManifest, WitnessPath: witnessPath, @@ -292,14 +292,14 @@ func TestBN254CPUProfile(t *testing.T) { report.LoadSeconds[entry.name] = time.Since(loadStarted).Seconds() } loadStarted := time.Now() - p, err := loadProfile(filepath.Join(dir, "profile.json")) + p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) if err != nil { t.Fatal(err) } - if err := m.checkFile(dir, "profile.json"); err != nil { + if err := m.checkFile(dir, "vk.json"); err != nil { t.Fatal(err) } - report.LoadSeconds["profile.json"] = time.Since(loadStarted).Seconds() + report.LoadSeconds["vk.json"] = time.Since(loadStarted).Seconds() if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != m.PublicInputs || ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+ccs.GetNbPublicVariables())) != m.Domain || len(ccs.GetCommitments().CommitmentIndexes()) != m.Commitments { t.Fatal("loaded circuit metadata differs from the authenticated manifest") } @@ -325,7 +325,7 @@ func TestBN254CPUProfile(t *testing.T) { t.Fatal(err) } if !nativeVK.Kzg.G1.Equal(&g1) || !nativeVK.Kzg.G2[0].Equal(&g2[0]) || !nativeVK.Kzg.G2[1].Equal(&g2[1]) { - t.Fatal("verifying key anchors differ from the authenticated profile") + t.Fatal("verifying key anchors differ from the authenticated verifying key") } report.Stage = "witness-setup" witnessStarted := time.Now() diff --git a/tools/bls-plonk-wrapper/curve_bls.go b/tools/bls-plonk-wrapper/curve_bls.go index e653e6aca..9c436bb97 100644 --- a/tools/bls-plonk-wrapper/curve_bls.go +++ b/tools/bls-plonk-wrapper/curve_bls.go @@ -47,7 +47,7 @@ const pointLimbBytes = 24 const g2ExportBytes = 96 const anchorBytesLen = 240 const srsMagic = "IXPSRS01" -const profileFormat = "ix-bls-plonk-profile-v1" +const vkFormat = "ix-bls-plonk-vk-v1" const artifactFormat = "ix-bls-plonk-artifacts-v1" const lagrangeFormat = "ix-plonk-lagrange-v1" diff --git a/tools/bls-plonk-wrapper/curve_bn254.go b/tools/bls-plonk-wrapper/curve_bn254.go index 7554391bc..862b4a77c 100644 --- a/tools/bls-plonk-wrapper/curve_bn254.go +++ b/tools/bls-plonk-wrapper/curve_bn254.go @@ -46,7 +46,7 @@ const pointLimbBytes = 16 const g2ExportBytes = 128 const anchorBytesLen = 320 const srsMagic = "IXBPSR01" -const profileFormat = "ix-bn254-plonk-profile-v1" +const vkFormat = "ix-bn254-plonk-vk-v1" const artifactFormat = "ix-bn254-plonk-artifacts-v1" const lagrangeFormat = "ix-bn254-plonk-lagrange-v1" diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254.go b/tools/bls-plonk-wrapper/lagrange_import_bn254.go index ab552e2e6..bc62e371e 100644 --- a/tools/bls-plonk-wrapper/lagrange_import_bn254.go +++ b/tools/bls-plonk-wrapper/lagrange_import_bn254.go @@ -196,7 +196,7 @@ func importLagrangeSP1(artifacts, monomial, receipt, candidate string) error { if err != nil { return err } - p, err := loadProfile(filepath.Join(artifacts, "profile.json")) + p, err := loadVerifyingKey(filepath.Join(artifacts, "vk.json")) if err != nil { return err } diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go b/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go index a5c08373c..7968e2804 100644 --- a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go +++ b/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go @@ -17,9 +17,9 @@ import ( "github.com/consensys/gnark/frontend/cs/scs" ) -func importedFixture(t *testing.T) (*profile, *nativeSRS, string) { +func importedFixture(t *testing.T) (*verifyingKey, *nativeSRS, string) { t.Helper() - p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/main.go b/tools/bls-plonk-wrapper/main.go index c9cbda700..98982417a 100644 --- a/tools/bls-plonk-wrapper/main.go +++ b/tools/bls-plonk-wrapper/main.go @@ -20,11 +20,11 @@ import ( ) func compileProfile(path, dir string) error { - p, err := loadProfile(path) + p, err := loadVerifyingKey(path) if err != nil { return err } - profileHash, err := fileHash(path) + vkHash, err := fileHash(path) if err != nil { return err } @@ -33,8 +33,8 @@ func compileProfile(path, dir string) error { if err != nil { return err } - if m.ProfileSHA256 != profileHash.SHA256 { - return fmt.Errorf("artifact directory belongs to a different profile") + if m.InnerVKSHA256 != vkHash.SHA256 { + return fmt.Errorf("artifact directory belongs to a different verifying key") } if err := m.checkFile(dir, "circuit.bin"); err != nil { return err @@ -60,7 +60,7 @@ func compileProfile(path, dir string) error { } m := &manifest{ Format: artifactFormat, ExecutableSHA256: executable.SHA256, - ProfileSHA256: profileHash.SHA256, Constraints: ccs.GetNbConstraints(), + InnerVKSHA256: vkHash.SHA256, Constraints: ccs.GetNbConstraints(), PublicInputs: ccs.GetNbPublicVariables(), Commitments: len(ccs.GetCommitments().CommitmentIndexes()), Domain: ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())), @@ -69,7 +69,7 @@ func compileProfile(path, dir string) error { if m.PublicInputs != 2 { return fmt.Errorf("expected two public digest limbs") } - if err := atomicFile(filepath.Join(dir, "profile.json"), func(w io.Writer) error { + if err := atomicFile(filepath.Join(dir, "vk.json"), func(w io.Writer) error { f, err := os.Open(path) if err != nil { return err @@ -80,7 +80,7 @@ func compileProfile(path, dir string) error { }); err != nil { return err } - m.Files["profile.json"] = profileHash + m.Files["vk.json"] = vkHash if err := saveBinary(dir, "circuit.bin", ccs, m); err != nil { return err } @@ -91,7 +91,7 @@ func compileProfile(path, dir string) error { return nil } -func loadCircuit(dir string) (*profile, constraint.ConstraintSystem, *manifest, error) { +func loadCircuit(dir string) (*verifyingKey, constraint.ConstraintSystem, *manifest, error) { m, err := loadManifest(dir) if err != nil { return nil, nil, nil, err @@ -99,7 +99,7 @@ func loadCircuit(dir string) (*profile, constraint.ConstraintSystem, *manifest, if err := m.checkFile(dir, "circuit.bin"); err != nil { return nil, nil, nil, err } - p, err := loadProfile(filepath.Join(dir, "profile.json")) + p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) if err != nil { return nil, nil, nil, err } @@ -175,7 +175,7 @@ func run(args []string) error { if len(args) == 6 && args[0] == "verify" { return verify(args[1], args[2], args[3], args[4], args[5]) } - return fmt.Errorf("usage: PLONK helper compile | check-witness | import-lagrange-sp1 | prepare | prove | verify ") + return fmt.Errorf("usage: PLONK helper compile | check-witness | import-lagrange-sp1 | prepare | prove | verify ") } func main() { diff --git a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go b/tools/bls-plonk-wrapper/msm_capture_bn254_test.go index 295fe1ddc..0495e5543 100644 --- a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go +++ b/tools/bls-plonk-wrapper/msm_capture_bn254_test.go @@ -48,7 +48,7 @@ type msmCaptureRecord struct { type msmCaptureManifest struct { Format, ScalarFormat, Scope string Complete, StoppedBeforeQuotient bool - ArtifactManifestSHA256, ProvingKeySHA256, VerifyingKeySHA256, ProfileSHA256 string + ArtifactManifestSHA256, ProvingKeySHA256, InnerVKSHA256, VerifyingKeySHA256 string ExpectedClaim, TestBinarySHA256 string Domain uint64 Constraints, PublicInputs, NumCPU, GOMAXPROCS int @@ -205,7 +205,7 @@ func (a *recordingCPUAccelerator) MultiExp(points []curve.G1Affine, scalars []fr } func validateCapture(m *msmCaptureManifest, artifacts *manifest) error { - if m.Format != msmCaptureFormat || m.ScalarFormat != msmScalarFormat || !m.Complete || !m.StoppedBeforeQuotient || len(m.Records) != 4 || m.Domain != artifacts.Domain || m.Constraints != artifacts.Constraints || m.PublicInputs != 2 || m.ProvingKeySHA256 != artifacts.Files["proving-key.bin"].SHA256 || m.VerifyingKeySHA256 != artifacts.Files["verifying-key.bin"].SHA256 || m.ProfileSHA256 != artifacts.ProfileSHA256 { + if m.Format != msmCaptureFormat || m.ScalarFormat != msmScalarFormat || !m.Complete || !m.StoppedBeforeQuotient || len(m.Records) != 4 || m.Domain != artifacts.Domain || m.Constraints != artifacts.Constraints || m.PublicInputs != 2 || m.ProvingKeySHA256 != artifacts.Files["proving-key.bin"].SHA256 || m.InnerVKSHA256 != artifacts.Files["verifying-key.bin"].SHA256 || m.InnerVKSHA256 != artifacts.InnerVKSHA256 { return fmt.Errorf("capture identity or shape differs") } shapes := make(map[[2]uint64]int) @@ -268,11 +268,11 @@ func TestCaptureActualInitMSMs(t *testing.T) { if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != 2 || len(ccs.GetCommitments().CommitmentIndexes()) != 1 { t.Fatal("capture circuit metadata differs") } - p, err := loadProfile(filepath.Join(dir, "profile.json")) + p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) if err != nil { t.Fatal(err) } - if err := m.checkFile(dir, "profile.json"); err != nil { + if err := m.checkFile(dir, "vk.json"); err != nil { t.Fatal(err) } loadSeconds := keySeconds + time.Since(started).Seconds() @@ -314,7 +314,7 @@ func TestCaptureActualInitMSMs(t *testing.T) { if err != nil { t.Fatal(err) } - report := msmCaptureManifest{Format: msmCaptureFormat, ScalarFormat: msmScalarFormat, Scope: "Actual pinned Init witness solved on CPU; first four Lagrange MSMs captured before any quotient. CPU callbacks are serialized at default CPU parallelism. Raw local scalar captures are test artifacts, not proofs.", ArtifactManifestSHA256: pin, ProvingKeySHA256: m.Files["proving-key.bin"].SHA256, VerifyingKeySHA256: m.Files["verifying-key.bin"].SHA256, ProfileSHA256: m.ProfileSHA256, ExpectedClaim: claim, TestBinarySHA256: identity, Domain: m.Domain, Constraints: m.Constraints, PublicInputs: 2, Witness: witnessIdentity, NumCPU: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ValidationSeconds: validationSeconds, LoadSeconds: loadSeconds, WitnessSeconds: time.Since(started).Seconds()} + report := msmCaptureManifest{Format: msmCaptureFormat, ScalarFormat: msmScalarFormat, Scope: "Actual pinned Init witness solved on CPU; first four Lagrange MSMs captured before any quotient. CPU callbacks are serialized at default CPU parallelism. Raw local scalar captures are test artifacts, not proofs.", ArtifactManifestSHA256: pin, ProvingKeySHA256: m.Files["proving-key.bin"].SHA256, VerifyingKeySHA256: m.Files["verifying-key.bin"].SHA256, InnerVKSHA256: m.InnerVKSHA256, ExpectedClaim: claim, TestBinarySHA256: identity, Domain: m.Domain, Constraints: m.Constraints, PublicInputs: 2, Witness: witnessIdentity, NumCPU: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ValidationSeconds: validationSeconds, LoadSeconds: loadSeconds, WitnessSeconds: time.Since(started).Seconds()} capture := &recordingCPUAccelerator{pk: pk, out: out} pk.SetAccelerator(capture) defer pk.SetAccelerator(nil) diff --git a/tools/bls-plonk-wrapper/optimization_test.go b/tools/bls-plonk-wrapper/optimization_test.go index 4a94c9d80..96299a213 100644 --- a/tools/bls-plonk-wrapper/optimization_test.go +++ b/tools/bls-plonk-wrapper/optimization_test.go @@ -26,15 +26,15 @@ func TestOptimizationSizing(t *testing.T) { if os.Getenv("IX_PLONK_SIZE") != "1" { t.Skip("set IX_PLONK_SIZE=1 for full variant compilation") } - path := os.Getenv("IX_PLONK_SIZE_PROFILE") + path := os.Getenv("IX_PLONK_SIZE_VK") if path == "" { fixture := "outer" if curveID == ecc.BN254 { fixture = "bn254" } - path = filepath.Join("testdata", fixture, "profile.json") + path = filepath.Join("testdata", fixture, "vk.json") } - p, err := loadProfile(path) + p, err := loadVerifyingKey(path) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/pairing_batch.go b/tools/bls-plonk-wrapper/pairing_batch.go index 509cd3933..184d1d48b 100644 --- a/tools/bls-plonk-wrapper/pairing_batch.go +++ b/tools/bls-plonk-wrapper/pairing_batch.go @@ -12,7 +12,7 @@ import ( "github.com/zeebo/blake3" ) -func pairingBatchContext(p *profile) ([]byte, error) { +func pairingBatchContext(p *verifyingKey) ([]byte, error) { encoded, err := json.Marshal(p) if err != nil { return nil, err @@ -53,18 +53,18 @@ func pairingBatchScalar(api frontend.API, u *uints.BinaryField[uints.U32], wide } func (c *wrapperCircuit) batchedPairing(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], s *emulated.Field[scalarField], curve *sw_emulated.Curve[baseField, scalarField], packetDigest []uints.U8, left, right *circuitG1) error { - outer, err := g2Keys(c.Profile.OuterKeys) + outer, err := g2Keys(c.VK.OuterKeys) if err != nil { return err } - inner, err := g2Keys(c.Profile.DeferredKeys) + inner, err := g2Keys(c.VK.DeferredKeys) if err != nil { return err } if !outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1]) { return fmt.Errorf("pairing batching requires identical ordered G2 anchors") } - context, err := pairingBatchContext(c.Profile) + context, err := pairingBatchContext(c.VK) if err != nil { return err } diff --git a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go b/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go index 63921f2b5..893d7bc0d 100644 --- a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go +++ b/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go @@ -21,7 +21,7 @@ type pairingBatchCircuit struct { Operands [4]circuitG1 Claim [2]frontend.Variable PacketDigest [32]uints.U8 - Profile *profile `gnark:"-"` + VK *verifyingKey `gnark:"-"` ExpectedRho frontend.Variable ChallengeOnly bool `gnark:"-"` } @@ -51,7 +51,7 @@ func (c *pairingBatchCircuit) Define(api frontend.API) error { pairing.AssertIsOnG1(&c.Operands[i]) } if c.ChallengeOnly { - context, err := pairingBatchContext(c.Profile) + context, err := pairingBatchContext(c.VK) if err != nil { return err } @@ -59,11 +59,11 @@ func (c *pairingBatchCircuit) Define(api frontend.API) error { api.AssertIsEqual(rho, c.ExpectedRho) return nil } - w := &wrapperCircuit{Profile: c.Profile, Claim: c.Claim, Deferred: [2]circuitG1{c.Operands[2], c.Operands[3]}} + w := &wrapperCircuit{VK: c.VK, Claim: c.Claim, Deferred: []circuitG1{c.Operands[2], c.Operands[3]}} return w.batchedPairing(api, u, f, s, curve, c.PacketDigest[:], &c.Operands[0], &c.Operands[1]) } -func nativePairingBatchChallenge(p *profile, claim [2]uint64, packet [32]byte, points [4]nativeG1) *big.Int { +func nativePairingBatchChallenge(p *verifyingKey, claim [2]uint64, packet [32]byte, points [4]nativeG1) *big.Int { input, err := pairingBatchContext(p) if err != nil { panic(err) @@ -100,8 +100,8 @@ func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { valid := [4]nativeG1{T, C, T, negC} claim := [2]uint64{123, 456} packet := [32]byte{9, 8, 7} - assignment := func(points [4]nativeG1, profile *profile) *pairingBatchCircuit { - w := &pairingBatchCircuit{Profile: profile, Claim: [2]frontend.Variable{claim[0], claim[1]}, ExpectedRho: nativePairingBatchChallenge(profile, claim, packet, points)} + assignment := func(points [4]nativeG1, verifyingKey *verifyingKey) *pairingBatchCircuit { + w := &pairingBatchCircuit{VK: verifyingKey, Claim: [2]frontend.Variable{claim[0], claim[1]}, ExpectedRho: nativePairingBatchChallenge(verifyingKey, claim, packet, points)} copy(w.PacketDigest[:], uints.NewU8Array(packet[:])) for i := range points { w.Operands[i] = newCircuitG1(points[i]) @@ -110,7 +110,7 @@ func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { } check := func(name string, points [4]nativeG1, ok bool) { t.Helper() - err := test.IsSolved(&pairingBatchCircuit{Profile: p}, assignment(points, p), curveID.ScalarField()) + err := test.IsSolved(&pairingBatchCircuit{VK: p}, assignment(points, p), curveID.ScalarField()) if (err == nil) != ok { t.Fatalf("%s: expected accepted=%t: %v", name, ok, err) } @@ -155,13 +155,13 @@ func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { check("predetermined-coefficient-cancellation", cancel, false) for _, points := range [][4]nativeG1{valid, cancel, {}} { w := assignment(points, p) - if err := test.IsSolved(&pairingBatchCircuit{Profile: p, ChallengeOnly: true}, w, curveID.ScalarField()); err != nil { + if err := test.IsSolved(&pairingBatchCircuit{VK: p, ChallengeOnly: true}, w, curveID.ScalarField()); err != nil { t.Fatal(err) } } for _, kind := range []string{"left", "right", "deferred-a", "deferred-b", "claim-low", "claim-high", "packet", "context"} { w := assignment(valid, p) - profileCopy := *p + vkCopy := *p switch kind { case "left", "right", "deferred-a", "deferred-b": i := map[string]int{"left": 0, "right": 1, "deferred-a": 2, "deferred-b": 3}[kind] @@ -175,20 +175,20 @@ func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { case "packet": w.PacketDigest[0] = uints.NewU8(packet[0] ^ 1) case "context": - profileCopy.Seed = "0x01" + vkCopy.Seed = "0x01" } - if err := test.IsSolved(&pairingBatchCircuit{Profile: &profileCopy, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { + if err := test.IsSolved(&pairingBatchCircuit{VK: &vkCopy, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { t.Fatalf("unbound %s", kind) } } w := assignment(valid, p) w.ExpectedRho = 0 - if err := test.IsSolved(&pairingBatchCircuit{Profile: p, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { + if err := test.IsSolved(&pairingBatchCircuit{VK: p, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { t.Fatal("accepted zero challenge") } other := *p other.DeferredKeys = []string{p.DeferredKeys[1], p.DeferredKeys[0]} - if err := test.IsSolved(&pairingBatchCircuit{Profile: &other}, assignment(valid, &other), curveID.ScalarField()); err == nil { + if err := test.IsSolved(&pairingBatchCircuit{VK: &other}, assignment(valid, &other), curveID.ScalarField()); err == nil { t.Fatal("accepted mismatched anchors") } context, _ := pairingBatchContext(p) @@ -201,7 +201,7 @@ func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { } func TestBN254OptimizedFullVerifier(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/parameters_bn254_test.go b/tools/bls-plonk-wrapper/parameters_bn254_test.go index e807a5ea3..0821d9516 100644 --- a/tools/bls-plonk-wrapper/parameters_bn254_test.go +++ b/tools/bls-plonk-wrapper/parameters_bn254_test.go @@ -14,7 +14,7 @@ import ( ) func TestLagrangeCacheAndBasisConversion(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/parameters_test.go b/tools/bls-plonk-wrapper/parameters_test.go index 3fad12509..e6e10fb97 100644 --- a/tools/bls-plonk-wrapper/parameters_test.go +++ b/tools/bls-plonk-wrapper/parameters_test.go @@ -14,7 +14,7 @@ import ( ) func TestLagrangeCacheAndBasisConversion(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "outer", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "outer", "vk.json")) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/profile.go b/tools/bls-plonk-wrapper/profile.go index eb48213bf..96f0be5ad 100644 --- a/tools/bls-plonk-wrapper/profile.go +++ b/tools/bls-plonk-wrapper/profile.go @@ -27,8 +27,24 @@ type scalarProgram struct { Nodes []instruction Assertions, Coefficients, Witnesses []int } -type profile struct { - Format, ProfileID, SetupID, Seed string + +// One entry of a claim row: a circuit constant or an index into the +// verifier-supplied public scalars. +type claimSlot struct { + Constant *string + Public *int +} + +// Where the verifier takes one public scalar from: a constant, a word of the +// fixed statement prefix, a word of the claim digest, or a half of a deferred +// pairing operand's encoding. +type publicSource struct { + Constant *string + Prefix, DigestWord, DeferredHalf *int +} + +type verifyingKey struct { + Format, VKID, SetupID, Seed string PublicMaxDegree uint64 Prefix []uint64 Circuits []circuitShape @@ -38,7 +54,57 @@ type profile struct { Generator pointJSON OuterKeys, DeferredKeys []string Program scalarProgram + // Absent in verifying keys exported before first-stage wrapping: the + // recursive packet then implies one accumulator, 23 claim rows + // `[94, 1, i, public_i]` and two deferred operands. + AccumulatorCount *int + Claims [][]claimSlot + Publics []publicSource +} + +func (p *verifyingKey) accumulators() int { + if p.AccumulatorCount == nil { + return 1 + } + return *p.AccumulatorCount +} + +func (p *verifyingKey) deferredCount() int { + return len(p.DeferredKeys) +} + +func intPtr(v int) *int { return &v } + +func (p *verifyingKey) claimRows() [][]claimSlot { + if p.Claims != nil { + return p.Claims + } + rows := make([][]claimSlot, 23) + for i := range rows { + ns, one, index := "0x5e", "0x1", fmt.Sprintf("0x%x", i) + rows[i] = []claimSlot{{Constant: &ns}, {Constant: &one}, {Constant: &index}, {Public: intPtr(i)}} + } + return rows } + +func (p *verifyingKey) publicSources() []publicSource { + if p.Publics != nil { + return p.Publics + } + zero := "0x0" + sources := []publicSource{{Constant: &zero}} + for i := 0; i < 10; i++ { + sources = append(sources, publicSource{Prefix: intPtr(i)}) + } + for i := 0; i < 8; i++ { + sources = append(sources, publicSource{DigestWord: intPtr(i)}) + } + for i := 0; i < 4; i++ { + sources = append(sources, publicSource{DeferredHalf: intPtr(i)}) + } + return sources +} + type witnessJSON struct { ClaimDigest string Points, Deferred []pointJSON @@ -113,8 +179,8 @@ func g2Keys(encoded []string) ([2]nativeG2, error) { return result, nil } -func loadProfile(path string) (*profile, error) { - var p profile +func loadVerifyingKey(path string) (*verifyingKey, error) { + var p verifyingKey if err := decodeJSON(path, &p); err != nil { return nil, err } @@ -124,11 +190,61 @@ func loadProfile(path string) (*profile, error) { return &p, nil } -func (p *profile) validate() error { - if p.Format != profileFormat || len(p.Prefix) != 10 || len(p.Circuits) != 2 || len(p.Matrices) != 8 || p.PointCount < 1 || p.PointCount > 256 || p.FieldCount < 1 || p.FieldCount > 4096 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 3 || p.WitnessOffset+p.OpeningCount != p.PointCount { - return fmt.Errorf("invalid wrapper profile dimensions") +func (p *verifyingKey) validate() error { + if p.Format != vkFormat || len(p.Prefix) != 10 || len(p.Circuits) < 2 || len(p.Matrices) != 4*len(p.Circuits) || p.PointCount < 1 || p.PointCount > 4096 || p.FieldCount < 1 || p.FieldCount > 65536 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 16 || p.WitnessOffset+p.OpeningCount != p.PointCount || p.accumulators() != len(p.Circuits)-1 || (p.deferredCount() != 0 && p.deferredCount() != 2) { + return fmt.Errorf("invalid wrapper verifying key dimensions") + } + publicCount := len(p.publicSources()) + for i, source := range p.publicSources() { + set := 0 + if source.Constant != nil { + set++ + if _, err := canonical(*source.Constant, scalarModulus()); err != nil { + return err + } + } + if source.Prefix != nil { + set++ + if *source.Prefix < 0 || *source.Prefix >= 10 { + return fmt.Errorf("invalid prefix public %d", i) + } + } + if source.DigestWord != nil { + set++ + if *source.DigestWord < 0 || *source.DigestWord >= 8 { + return fmt.Errorf("invalid digest public %d", i) + } + } + if source.DeferredHalf != nil { + set++ + if *source.DeferredHalf < 0 || *source.DeferredHalf >= 2*p.deferredCount() { + return fmt.Errorf("invalid deferred public %d", i) + } + } + if set != 1 { + return fmt.Errorf("public source %d must have exactly one kind", i) + } } - if _, err := unhex(p.ProfileID, 32); err != nil { + for r, row := range p.claimRows() { + if len(row) == 0 || len(row) > 16 { + return fmt.Errorf("invalid claim row %d", r) + } + for _, slot := range row { + switch { + case slot.Constant != nil && slot.Public == nil: + if _, err := canonical(*slot.Constant, scalarModulus()); err != nil { + return err + } + case slot.Public != nil && slot.Constant == nil: + if *slot.Public < 0 || *slot.Public >= publicCount { + return fmt.Errorf("claim row %d references an unknown public", r) + } + default: + return fmt.Errorf("claim row %d slot must be a constant or a public", r) + } + } + } + if _, err := unhex(p.VKID, 32); err != nil { return err } if _, err := unhex(p.SetupID, 32); err != nil { @@ -160,14 +276,16 @@ func (p *profile) validate() error { if err != nil { return err } - inner, err := g2Keys(p.DeferredKeys) - if err != nil { - return err - } - if curveID == ecc.BLS12_381 && (!outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1])) { - return fmt.Errorf("Filecoin pairing anchors differ") + if p.deferredCount() == 2 { + inner, err := g2Keys(p.DeferredKeys) + if err != nil { + return err + } + if curveID == ecc.BLS12_381 && (!outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1])) { + return fmt.Errorf("Filecoin pairing anchors differ") + } } - if len(p.Program.Coefficients) != p.PointCount+len(p.Fixed)+1 || len(p.Program.Witnesses) != p.OpeningCount || len(p.Program.Assertions) != 2 { + if len(p.Program.Coefficients) != p.PointCount+len(p.Fixed)+1 || len(p.Program.Witnesses) != p.OpeningCount || len(p.Program.Assertions) != len(p.Circuits) { return fmt.Errorf("invalid scalar program outputs") } for i, n := range p.Program.Nodes { @@ -178,7 +296,7 @@ func (p *profile) validate() error { return err } case "input": - bound := map[string]int{"challenge": 6, "value": p.FieldCount, "public": 23}[n.Kind] + bound := map[string]int{"challenge": 6, "value": p.FieldCount, "public": publicCount}[n.Kind] if n.Index < 0 || n.Index >= bound { return fmt.Errorf("invalid scalar input") } @@ -204,17 +322,17 @@ func (p *profile) validate() error { return nil } -func newCircuit(p *profile) *wrapperCircuit { - return &wrapperCircuit{Profile: p, Points: make([]circuitG1, p.PointCount), Values: make([]frontend.Variable, p.FieldCount)} +func newCircuit(p *verifyingKey) *wrapperCircuit { + return &wrapperCircuit{VK: p, Points: make([]circuitG1, p.PointCount), Deferred: make([]circuitG1, p.deferredCount()), Values: make([]frontend.Variable, p.FieldCount)} } -func witnessAssignment(p *profile, path string) (*wrapperCircuit, error) { +func witnessAssignment(p *verifyingKey, path string) (*wrapperCircuit, error) { var w witnessJSON if err := decodeJSON(path, &w); err != nil { return nil, err } - if len(w.Points) != p.PointCount || len(w.Deferred) != 2 || len(w.Values) != p.FieldCount { - return nil, fmt.Errorf("witness dimensions differ from profile") + if len(w.Points) != p.PointCount || len(w.Deferred) != p.deferredCount() || len(w.Values) != p.FieldCount { + return nil, fmt.Errorf("witness dimensions differ from verifying key") } result := newCircuit(p) digest, err := unhex(w.ClaimDigest, 32) diff --git a/tools/bls-plonk-wrapper/sizing_test.go b/tools/bls-plonk-wrapper/sizing_test.go index 100ca04bc..8657e649e 100644 --- a/tools/bls-plonk-wrapper/sizing_test.go +++ b/tools/bls-plonk-wrapper/sizing_test.go @@ -27,15 +27,15 @@ func TestComponentSizing(t *testing.T) { if os.Getenv("IX_PLONK_SIZE") != "1" { t.Skip("set IX_PLONK_SIZE=1 for full component compilation") } - path := os.Getenv("IX_PLONK_SIZE_PROFILE") + path := os.Getenv("IX_PLONK_SIZE_VK") if path == "" { fixture := "outer" if curveID == ecc.BN254 { fixture = "bn254" } - path = filepath.Join("testdata", fixture, "profile.json") + path = filepath.Join("testdata", fixture, "vk.json") } - p, err := loadProfile(path) + p, err := loadVerifyingKey(path) if err != nil { t.Fatal(err) } diff --git a/tools/bls-plonk-wrapper/testdata/bn254/provenance.json b/tools/bls-plonk-wrapper/testdata/bn254/provenance.json index 901b13e4e..2cf3e6b2d 100644 --- a/tools/bls-plonk-wrapper/testdata/bn254/provenance.json +++ b/tools/bls-plonk-wrapper/testdata/bn254/provenance.json @@ -8,7 +8,7 @@ "srsPowers": 131, "scope": "The witness covers the full Init verifier relation. The small SRS is only for backend and basis-conversion tests, not for proving the full wrapper.", "files": { - "profile.json": "7f10e1e87329b57d148c915f38e28bc777988b7df8ead7e88fab5ad96b62a991", + "vk.json": "f75d771d95dd704395c83bc0c6a641f6f50a18d2f424b5a9e32aa39de1be745a", "srs.bin": "5eb78499c386ce2dd170f773eaf6043741c83b9ddbb7c2e463f20532cbac6a29", "srs.json": "2693bbb8f0c54af048915738afed28dda6ff6262550653404102e2391a947c78", "witness-diagnostic.json": "83b65ceb2b2a20de824b0e4968ba6e85ec408e47eab49ba5be9d8f4de8733787", diff --git a/tools/bls-plonk-wrapper/testdata/bn254/profile.json b/tools/bls-plonk-wrapper/testdata/bn254/vk.json similarity index 99% rename from tools/bls-plonk-wrapper/testdata/bn254/profile.json rename to tools/bls-plonk-wrapper/testdata/bn254/vk.json index 1a45b05c9..6b3758298 100644 --- a/tools/bls-plonk-wrapper/testdata/bn254/profile.json +++ b/tools/bls-plonk-wrapper/testdata/bn254/vk.json @@ -108,7 +108,7 @@ "y": "0x0f17b90800e4b2c4c3bfe6c2c596c0c896877d0f5f211850d0135874bfb08da8" } ], - "format": "ix-bn254-plonk-profile-v1", + "format": "ix-bn254-plonk-vk-v1", "generator": { "x": "0x1fa4be93b5e7f7e674d5059b63554fab99638b304ed8310e9fa44c281ac9b03b", "y": "0x1a01ae7fac6228e39d3cb5a5e71fd31160f3241e79a5f48ffb3737e6c389b721" @@ -181,7 +181,7 @@ 683533119, 3085659920 ], - "profileId": "0x28fc7d40d30aa21f1db718b93a4f471241a02f6c6cfc58989abc5f92a9b8d258", + "vkId": "0x28fc7d40d30aa21f1db718b93a4f471241a02f6c6cfc58989abc5f92a9b8d258", "program": { "assertions": [ 663, diff --git a/tools/bls-plonk-wrapper/testdata/outer/verifier.bin b/tools/bls-plonk-wrapper/testdata/outer/vk.bin similarity index 100% rename from tools/bls-plonk-wrapper/testdata/outer/verifier.bin rename to tools/bls-plonk-wrapper/testdata/outer/vk.bin diff --git a/tools/bls-plonk-wrapper/testdata/outer/profile.json b/tools/bls-plonk-wrapper/testdata/outer/vk.json similarity index 99% rename from tools/bls-plonk-wrapper/testdata/outer/profile.json rename to tools/bls-plonk-wrapper/testdata/outer/vk.json index 123ef835d..b7e507e08 100644 --- a/tools/bls-plonk-wrapper/testdata/outer/profile.json +++ b/tools/bls-plonk-wrapper/testdata/outer/vk.json @@ -108,7 +108,7 @@ "y": "0x0" } ], - "format": "ix-bls-plonk-profile-v1", + "format": "ix-bls-plonk-vk-v1", "generator": { "x": "0x17f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb", "y": "0x08b3f481e3aaa0f1a09e30ed741d8ae4fcf5e095d5d00af600db18cb2c04b3edd03cc744a2888ae40caa232946c5e7e1" @@ -181,7 +181,7 @@ 1008, 1009 ], - "profileId": "0xca595b587171e61538457acadb40a97c0f78cb4f395d1ea52ce1fe200782d8d1", + "vkId": "0xca595b587171e61538457acadb40a97c0f78cb4f395d1ea52ce1fe200782d8d1", "program": { "assertions": [ 642, diff --git a/tools/bls-plonk-wrapper/verifier_bn254_test.go b/tools/bls-plonk-wrapper/verifier_bn254_test.go index 48dd841ee..235e1fa14 100644 --- a/tools/bls-plonk-wrapper/verifier_bn254_test.go +++ b/tools/bls-plonk-wrapper/verifier_bn254_test.go @@ -43,7 +43,7 @@ func TestBN254RustPointEncodingVectors(t *testing.T) { } func TestBN254FullVerifierRejectsTampering(t *testing.T) { - p, err := loadProfile(filepath.Join("testdata", "bn254", "profile.json")) + p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestBN254FullVerifierRejectsTampering(t *testing.T) { } for _, kind := range []string{"claim", "evaluation", "commitment", "deferred-key", "outer-key"} { w := assignment() - profileCopy := *p + vkCopy := *p switch kind { case "claim": w.Claim[0] = new(big.Int).Add(w.Claim[0].(*big.Int), big.NewInt(1)) @@ -69,11 +69,11 @@ func TestBN254FullVerifierRejectsTampering(t *testing.T) { _, _, g, _ := bn.Generators() w.Points[0] = newCircuitG1(g) case "deferred-key": - profileCopy.DeferredKeys = []string{p.DeferredKeys[0], p.DeferredKeys[0]} + vkCopy.DeferredKeys = []string{p.DeferredKeys[0], p.DeferredKeys[0]} case "outer-key": - profileCopy.OuterKeys = []string{p.OuterKeys[0], p.OuterKeys[0]} + vkCopy.OuterKeys = []string{p.OuterKeys[0], p.OuterKeys[0]} } - if err := test.IsSolved(newCircuit(&profileCopy), w, curveID.ScalarField()); err == nil { + if err := test.IsSolved(newCircuit(&vkCopy), w, curveID.ScalarField()); err == nil { t.Fatalf("accepted %s mutation", kind) } } From 26fbb00e6f5d2499dca452d89aad9ef98550fa94 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:54:13 +0000 Subject: [PATCH 09/31] perf(proof-compression): widen emulated-field range limbs to 20 bits Range-check the emulated Fq limbs, quotients and carries in 20-bit lookups against a 2^20-row table instead of 16-bit lookups against 2^16 rows. An Fq multiplication drops from 164 to 144 rows and an affine addition from 752 to 672, about 8% off every opening-MSM term; on the saved Init first proof the recursive frontend goes from 50,628,004 to 47,662,557 rows with the witness and external pairings passing. The merged table trace grows from 2^17 to 2^20 rows. Add two ignored counting tests that attribute rows to gadget components: the opening MSM's field operations, point operations, window tables, selects and whole-gadget scaling, and the first-stage verifier's Merkle level, row hashing, extension arithmetic, FRI round and transcript sampling. They run with `--ignored --nocapture` and print JSON. --- crates/proof-compression/src/fri/pcs.rs | 137 ++++++++++++++++++ .../src/recursive/native_field.rs | 19 ++- .../src/recursive/native_field/advice.rs | 7 +- .../recursive/native_field/advice/tests.rs | 4 +- .../src/recursive/native_msm.rs | 98 +++++++++++++ 5 files changed, 256 insertions(+), 9 deletions(-) diff --git a/crates/proof-compression/src/fri/pcs.rs b/crates/proof-compression/src/fri/pcs.rs index 63108937a..8c98aac04 100644 --- a/crates/proof-compression/src/fri/pcs.rs +++ b/crates/proof-compression/src/fri/pcs.rs @@ -559,3 +559,140 @@ pub(super) fn constrain_pcs( } queries } + +#[cfg(test)] +mod cost_breakdown { + use super::*; + use multi_stark::plonkish::CircuitStats; + + fn record( + out: &mut serde_json::Map, + name: &str, + b: &CircuitBuilder, + before: CircuitStats, + ) { + let s = b.stats(); + out.insert( + name.into(), + serde_json::json!({ + "gates": s.gates - before.gates, + "lookups": s.lookups - before.lookups, + "compressions": s.hash_compressions - before.hash_compressions, + "rows": (s.gates + s.lookups) - (before.gates + before.lookups), + }), + ); + } + + fn digest(b: &mut CircuitBuilder, bytes: &ByteGadgets) -> Digest { + std::array::from_fn(|_| bytes.input(b, "digest byte")) + } + + /// Goldilocks-side row attribution of the first-stage verifier's + /// per-query pieces, printed as JSON. Run with `--ignored --nocapture`. + #[test] + #[ignore = "cost report; run explicitly"] + fn report() { + let mut b = CircuitBuilder::::counting(); + b.enable_compact_blake3(); + let bytes = ByteGadgets::new(&mut b); + let mut out = serde_json::Map::new(); + let root = digest(&mut b, &bytes); + let leaf = digest(&mut b, &bytes); + let sibling = digest(&mut b, &bytes); + let bit = { + let v = b.input("bit"); + b.assert_bool(v) + }; + let before = b.stats(); + authenticate( + &mut b, + &bytes, + &root, + leaf, + &[bit], + &[sibling], + &BTreeMap::new(), + ); + record(&mut out, "merkle_level", &b, before); + let injected = digest(&mut b, &bytes); + let before = b.stats(); + authenticate( + &mut b, + &bytes, + &root, + leaf, + &[bit], + &[sibling], + &BTreeMap::from([(0usize, injected)]), + ); + record(&mut out, "merkle_level_with_injection", &b, before); + for width in [1usize, 8, 44, 187] { + let values: Vec<_> = (0..width).map(|_| b.input("opened")).collect(); + let before = b.stats(); + hash_fields(&mut b, &bytes, values); + record(&mut out, &format!("hash_fields_{width}_values"), &b, before); + } + let value = b.input("value"); + let before = b.stats(); + bytes.encode_field64(&mut b, value); + record(&mut out, "encode_field64", &b, before); + let before = b.stats(); + bytes.constrain_byte(&mut b, value); + record(&mut out, "constrain_byte", &b, before); + let a = Q::input(&mut b, "a"); + let c = Q::input(&mut b, "c"); + let before = b.stats(); + a.mul(&mut b, c); + record(&mut out, "ext_mul", &b, before); + let before = b.stats(); + a.mul_base(&mut b, value); + record(&mut out, "ext_mul_base", &b, before); + let before = b.stats(); + a.inverse(&mut b); + record(&mut out, "ext_inverse", &b, before); + let bits: Vec = (0..26) + .map(|_| { + let v = b.input("qbit"); + b.assert_bool(v) + }) + .collect(); + let before = b.stats(); + reversed_power(&mut b, Val::two_adic_generator(26), &bits); + record(&mut out, "reversed_power_26", &b, before); + let before = b.stats(); + let lo = Q::select(&mut b, bits[0], a, c); + let hi = Q::select(&mut b, bits[0], c, a); + let leaf = hash_fields(&mut b, &bytes, lo.0.into_iter().chain(hi.0)); + let path: Vec = (0..25).map(|_| digest(&mut b, &bytes)).collect(); + authenticate( + &mut b, + &bytes, + &root, + leaf, + &bits[1..], + &path, + &BTreeMap::new(), + ); + let x = reversed_power(&mut b, Val::two_adic_generator(26), &bits[1..]); + let inv_x = b.inverse(x); + let sum = lo.add(&mut b, hi).scale(&mut b, Val::ONE.halve()); + let difference = lo + .sub(&mut b, hi) + .mul_base(&mut b, inv_x) + .scale(&mut b, Val::ONE.halve()); + let odd = difference.mul(&mut b, a); + let _folded = sum.add(&mut b, odd); + record(&mut out, "fri_round_0_total_with_25_level_path", &b, before); + let mut challenger = Blake3Challenger::new(&mut b, &bytes, b"cost"); + let before = b.stats(); + challenger.sample_bits(&mut b, &bytes, 26); + record(&mut out, "sample_bits_26", &b, before); + let before = b.stats(); + challenger.sample_extension(&mut b, &bytes); + record(&mut out, "sample_extension", &b, before); + let before = b.stats(); + challenger.observe_extension(&mut b, &bytes, a); + record(&mut out, "observe_extension", &b, before); + println!("{}", serde_json::to_string_pretty(&out).unwrap()); + } +} diff --git a/crates/proof-compression/src/recursive/native_field.rs b/crates/proof-compression/src/recursive/native_field.rs index e6e4ca979..48db5c8ee 100644 --- a/crates/proof-compression/src/recursive/native_field.rs +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -10,6 +10,8 @@ use std::collections::BTreeMap; pub type Builder = CircuitBuilder; pub(super) const W: usize = 80; +/// Width of the range-check lookup limbs; the table has `2^RANGE_BITS` rows. +pub(super) const RANGE_BITS: usize = 20; const FIELD_BITS: usize = Fq::MODULUS_BIT_SIZE as usize; pub(super) const L: usize = FIELD_BITS.div_ceil(W); const TOP_LIMB_BITS: usize = FIELD_BITS - W * (L - 1); @@ -72,15 +74,15 @@ impl FqGadget { pub fn new(b: &mut Builder) -> Self { Self { range: b.fixed_table( - "Fq u16", - (0..=u16::MAX).map(|v| vec![Scalar::from_u16(v)]).collect(), + "Fq range", + (0..1u64 << RANGE_BITS).map(|v| vec![Scalar::from_u64(v)]).collect(), ), #[cfg(test)] reference_advice: false, } } pub fn range(&self, b: &mut Builder, value: Value, bits: usize) { - match bits.div_ceil(16) { + match bits.div_ceil(RANGE_BITS) { 1 => self.range_n::<1>(b, value, bits), 2 => self.range_n::<2>(b, value, bits), 3 => self.range_n::<3>(b, value, bits), @@ -96,7 +98,7 @@ impl FqGadget { value: Value, bits: usize, ) { - assert!(bits > 0 && bits <= N * 16); + assert!(bits > 0 && bits <= N * RANGE_BITS); #[cfg(test)] let reference_advice = self.reference_advice; let limbs = b.hint_many_pure::("bounded integer", &[value], move |v| { @@ -109,14 +111,17 @@ impl FqGadget { for &limb in &limbs { b.lookup(self.range, &[limb]); } - if !bits.is_multiple_of(16) { - let top = b.scale(limbs[N - 1], Scalar::from_u64(1 << (16 - bits % 16))); + if !bits.is_multiple_of(RANGE_BITS) { + let top = b.scale( + limbs[N - 1], + Scalar::from_u64(1 << (RANGE_BITS - bits % RANGE_BITS)), + ); b.lookup(self.range, &[top]); } let terms: Vec<_> = limbs .iter() .enumerate() - .map(|(i, &v)| (scalar(&(BigUint::from(1u8) << (16 * i))), v)) + .map(|(i, &v)| (scalar(&(BigUint::from(1u8) << (RANGE_BITS * i))), v)) .collect(); let packed = b.linear_combination(&terms, Scalar::ZERO); b.assert_equal(packed, value); diff --git a/crates/proof-compression/src/recursive/native_field/advice.rs b/crates/proof-compression/src/recursive/native_field/advice.rs index 3ad0c474a..efc1b80a1 100644 --- a/crates/proof-compression/src/recursive/native_field/advice.rs +++ b/crates/proof-compression/src/recursive/native_field/advice.rs @@ -35,8 +35,13 @@ pub(super) fn constants() -> &'static Constants { pub(super) fn range_limbs(value: Scalar) -> [Scalar; N] { let words = value.canonical_limbs_le(); + let bit = |index: usize| (words[index / 64] >> (index % 64)) & 1; std::array::from_fn(|i| { - Scalar::from_u64((words[i / 4] >> (16 * (i % 4))) & 0xffff) + let limb = (0..super::RANGE_BITS).fold(0u64, |acc, j| { + let index = super::RANGE_BITS * i + j; + acc | if index < 256 { bit(index) << j } else { 0 } + }); + Scalar::from_u64(limb) }) } diff --git a/crates/proof-compression/src/recursive/native_field/advice/tests.rs b/crates/proof-compression/src/recursive/native_field/advice/tests.rs index 5ae8a7e84..1e4f35b17 100644 --- a/crates/proof-compression/src/recursive/native_field/advice/tests.rs +++ b/crates/proof-compression/src/recursive/native_field/advice/tests.rs @@ -19,7 +19,9 @@ pub(in crate::recursive::native_field) fn range_reference( value: Scalar, ) -> [Scalar; N] { let n = integer_reference(value); - std::array::from_fn(|i| scalar(&((&n >> (16 * i)) & BigUint::from(65535u32)))) + let bits = crate::recursive::native_field::RANGE_BITS; + let mask = (BigUint::from(1u8) << bits) - BigUint::from(1u8); + std::array::from_fn(|i| scalar(&((&n >> (bits * i)) & &mask))) } pub(in crate::recursive::native_field) fn relation_reference( diff --git a/crates/proof-compression/src/recursive/native_msm.rs b/crates/proof-compression/src/recursive/native_msm.rs index 034762003..48abae164 100644 --- a/crates/proof-compression/src/recursive/native_msm.rs +++ b/crates/proof-compression/src/recursive/native_msm.rs @@ -448,3 +448,101 @@ mod tests { assert!(rejects_changed_gate(&c, &a, result.point.y.0[0], Scalar::ONE)); } } + +#[cfg(test)] +mod cost_breakdown { + use super::*; + use multi_stark::plonkish::CircuitStats; + + fn record( + out: &mut serde_json::Map, + name: &str, + b: &Builder, + before: CircuitStats, + ) { + let s = b.stats(); + out.insert( + name.into(), + serde_json::json!({ + "gates": s.gates - before.gates, + "lookups": s.lookups - before.lookups, + "rows": (s.gates + s.lookups) - (before.gates + before.lookups), + }), + ); + } + + /// Row attribution of the opening-MSM gadget, printed as JSON. + /// Run with `--ignored --nocapture`. + #[test] + #[ignore = "cost report; run explicitly"] + fn report() { + let mut b = Builder::counting(); + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + let p = PointInput::new(&mut b, &f, "p").nonzero(&mut b, &f); + let q = PointInput::new(&mut b, &f, "q").nonzero(&mut b, &f); + let mut out = serde_json::Map::new(); + let before = b.stats(); + f.mul(&mut b, p.x, q.x); + record(&mut out, "fq_mul", &b, before); + let before = b.stats(); + f.hint_pure(&mut b, "h", &[p.x], |v| Ok(v[0])); + record(&mut out, "fq_hint_pure", &b, before); + let before = b.stats(); + p.add(&mut b, &f, q); + record(&mut out, "affine_add", &b, before); + let before = b.stats(); + p.double(&mut b, &f); + record(&mut out, "affine_double", &b, before); + let before = b.stats(); + p.add_complete(&mut b, &f, q); + record(&mut out, "affine_add_complete", &b, before); + let before = b.stats(); + let mut base = Base::new(PointInput::new(&mut b, &f, "v"), None); + let table_var = base.table(&mut b, &f); + record(&mut out, "table_variable_16", &b, before); + let before = b.stats(); + let mut base = + Base::new(PointInput::new(&mut b, &f, "c"), Some(G1Affine::generator())); + let table_fixed = base.table(&mut b, &f); + record(&mut out, "table_fixed_16", &b, before); + let s = b.input("s"); + let before = b.stats(); + let enc = scalar_bytes(&mut b, &bytes, s); + let bits: Vec<_> = + enc.into_iter().flat_map(|v| bytes.bits(&mut b, v)).collect(); + record(&mut out, "scalar_bytes_and_bits", &b, before); + let before = b.stats(); + select(&mut b, &table_var, &bits[..4]); + record(&mut out, "select_16_variable", &b, before); + let before = b.stats(); + select(&mut b, &table_fixed, &bits[..4]); + record(&mut out, "select_16_fixed", &b, before); + let before = b.stats(); + constant_mul(&mut b, &f, p, correction_coefficients().0); + record(&mut out, "constant_mul_correction", &b, before); + for (nv, nf) in [(1usize, 0usize), (2, 0), (0, 1), (0, 2), (1, 1)] { + let mut b = Builder::counting(); + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + let h = PointInput::new(&mut b, &f, "h").nonzero(&mut b, &f); + let mut bases: Vec = Vec::new(); + for i in 0..nv { + bases + .push(Base::new(PointInput::new(&mut b, &f, &format!("v{i}")), None)); + } + for i in 0..nf { + bases.push(Base::new( + PointInput::new(&mut b, &f, &format!("c{i}")), + Some((G1Affine::generator() * Fr::from(i as u64 + 2)).into_affine()), + )); + } + let terms: Vec<_> = + (0..nv + nf).map(|i| (i, b.input(format!("s{i}")))).collect(); + let before = b.stats(); + msm_with_offset(&mut b, &f, &bytes, &mut bases, &terms, h); + record(&mut out, &format!("msm_var{nv}_fixed{nf}"), &b, before); + } + println!("{}", serde_json::to_string_pretty(&out).unwrap()); + } +} From a2e53a00dc4f8f8fbcd05d5dff4d5abd1e3427b7 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:54:38 +0000 Subject: [PATCH 10/31] feat(compression): make BN254 the default KZG curve BN254 halves the recursive stage's padded domain relative to BLS12-381 and is the target for further proving work, so select it by default. - Cargo: ix-proof-compression defaults to `bn254`, `plonk` and `parallel`; ix-ffi defaults to `bn254` and `plonk`. BLS12-381 builds with `--no-default-features --features bls12-381,...`; enabling both curves remains a compile error. - Lake: `IX_KZG_CURVE` defaults to `bn254`, and the PLONK helper's name and build tag follow the BLS12-381 selection instead of BN254. - Go helper: BN254 is the untagged build and `bls12381` selects BLS12-381, so `go test ./...` now covers BN254. - Nix: `.#plonk-wrapper` and `.#plonk-wrapper-cuda` are BN254, BLS12-381 is `.#plonk-wrapper-bls12-381`, and the Lake build uses the BN254 helper. - CI: the workspace jobs now exercise BN254; explicit steps cover the BLS12-381 crate (clippy and tests), the BLS12-381 Go helper, a BLS12-381 CUDA check and the BLS12-381 compression CLI, whose bundled verifying keys and packets are BLS12-381. - CLI: expose the compiled curve to Lean, report Ignition-setup proofs as such instead of as Filecoin, and make the compression CLI test check that a BN254 build rejects the bundled BLS12-381 packets. The BN254 verifying-key catalog stays empty: release verification on the default build needs a caller-supplied verifying key until an Ignition key is published. --- .github/workflows/ci.yml | 55 +++++++------------ Ix/Cli/Compression.lean | 14 ++++- Tests/CompressionCli.lean | 12 ++++ crates/ffi/Cargo.toml | 2 +- crates/ffi/src/compression.rs | 5 ++ crates/proof-compression/Cargo.toml | 9 +-- crates/proof-compression/README.md | 30 ++++++---- crates/proof-compression/src/kzg/curve.rs | 4 +- crates/proof-compression/src/lib.rs | 2 +- flake.nix | 16 +++--- lakefile.lean | 6 +- tools/bls-plonk-wrapper/README.md | 23 +++++--- tools/bls-plonk-wrapper/backend_bn254_test.go | 2 +- tools/bls-plonk-wrapper/backend_cpu.go | 4 +- tools/bls-plonk-wrapper/backend_cuda_bn254.go | 2 +- .../backend_cuda_bn254_test.go | 2 +- .../bls-plonk-wrapper/backend_no_cuda_test.go | 2 +- tools/bls-plonk-wrapper/backend_test.go | 2 +- tools/bls-plonk-wrapper/build_cuda.py | 2 +- tools/bls-plonk-wrapper/circuit_bn254_test.go | 2 +- tools/bls-plonk-wrapper/circuit_test.go | 2 +- .../bls-plonk-wrapper/cuda_init_bn254_test.go | 2 +- .../cuda_msm_benchmark_bn254_test.go | 2 +- .../cuda_msm_replay_bn254_test.go | 2 +- .../cuda_profile_bn254_test.go | 2 +- tools/bls-plonk-wrapper/curve_bls.go | 2 +- tools/bls-plonk-wrapper/curve_bn254.go | 2 +- tools/bls-plonk-wrapper/internal/cuda/abi.go | 2 +- .../internal/cuda/accelerator.go | 2 +- .../internal/cuda/accelerator_test.go | 2 +- .../internal/cuda/masking_test.go | 2 +- .../internal/cuda/split_boundaries_test.go | 2 +- .../internal/cuda/structured_test.go | 2 +- .../bls-plonk-wrapper/internal/cuda/trace.go | 2 +- .../lagrange_import_bn254.go | 2 +- .../lagrange_import_bn254_test.go | 2 +- .../msm_capture_bn254_test.go | 2 +- .../pairing_batch_bn254_test.go | 2 +- .../parameters_bn254_test.go | 2 +- tools/bls-plonk-wrapper/parameters_test.go | 2 +- .../bls-plonk-wrapper/verifier_bn254_test.go | 2 +- 41 files changed, 131 insertions(+), 107 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf96e7eb..d1c32c1c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,15 @@ jobs: - name: Test Ix CLI if: github.event_name != 'push' run: lake test --wfail -- cli + # The default build is BN254; the bundled verifying keys and packets + # are BLS12-381, so the CLI's bundled-proof checks run in a BLS build. + - name: Test the BLS12-381 compression CLI + if: github.event_name != 'push' + env: + IX_KZG_CURVE: bls12-381 + run: | + lake build ix compression-cli-tests + lake exe compression-cli-tests - name: Test Ixon v4 contracts and consumers if: github.event_name != 'push' run: | @@ -92,9 +101,12 @@ jobs: with: go-version-file: tools/bls-plonk-wrapper/go.mod cache-dependency-path: tools/bls-plonk-wrapper/go.sum - - name: Test the CPU wrapper and authenticated Filecoin adapter + - name: Test the BN254 CPU wrapper working-directory: tools/bls-plonk-wrapper run: go test ./... + - name: Test the BLS12-381 CPU wrapper and authenticated Filecoin adapter + working-directory: tools/bls-plonk-wrapper + run: go test -tags bls12381 ./... - name: Check Go formatting working-directory: tools/bls-plonk-wrapper run: test -z "$(gofmt -l .)" @@ -140,39 +152,10 @@ jobs: run: cargo check --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile - name: Tests run: cargo nextest run --release --profile ci --workspace ${{ github.event_name == 'push' && '--no-run' || '' }} - - name: Check BN254 CPU KZG - run: cargo clippy --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel,plonk --lib --tests --example recursive_size --example kzg_ignition_import -- -D warnings - - name: Test BN254 CPU KZG - run: | - cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ - --test bn254_kzg --test kzg_transcripts --test kzg_sharding --test kzg_streaming \ - ${{ github.event_name == 'push' && '--no-run' || '' }} - cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ - --lib kzg:: ${{ github.event_name == 'push' && '--no-run' || '' }} -- \ - --skip development_powers_match_naive_at_chunk_boundaries \ - --skip disk_coefficients_match_resident_and_detect_changes \ - --skip chunked_commitment_matches_native_msm_with_tail \ - --skip field_stream_matches_arkworks_across_blocks - - name: Test BN254 recursive verifier - run: | - for recursive_filter in native_field:: native_curve:: native_transcript:: native_msm:: pairing:: \ - native_verifier::parallel_prefix_tests:: \ - native_verifier::reuse_tests::recursive_witness_rejects_tampering_without_native_preverification \ - native_verifier::reuse_tests::recursive_vk_rejects_identity_anchors_and_oversized_layout_caps; do - cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ - --lib "recursive::$recursive_filter" ${{ github.event_name == 'push' && '--no-run' || '' }} - done - - name: Test BN254 worker and portable verifying keys - run: | - cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ - --test worker_tracing ${{ github.event_name == 'push' && '--no-run' || '' }} - for pipeline_filter in driver:: tests:: setup:: stages::codec:: stages::verification:: stages::wrap:: \ - verifier::bn254_tests:: wrap::outer::tests::fused_vks_prove_and_portable_verifiers_round_trip; do - cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel \ - --lib "pipeline::$pipeline_filter" ${{ github.event_name == 'push' && '--no-run' || '' }} - done - - name: Test BN254 PLONK catalog - run: "cargo test --release --locked -p ix-proof-compression --no-default-features --features bn254,parallel,plonk --lib pipeline::plonk:: ${{ github.event_name == 'push' && '--no-run' || '' }}" + - name: Check BLS12-381 KZG + run: cargo clippy --release --locked -p ix-proof-compression --no-default-features --features bls12-381,parallel,plonk --all-targets -- -D warnings + - name: Test BLS12-381 KZG + run: cargo nextest run --release --locked --profile ci -p ix-proof-compression --no-default-features --features bls12-381,parallel,plonk ${{ github.event_name == 'push' && '--no-run' || '' }} - name: Get Rust version if: github.event_name != 'push' run: | @@ -234,7 +217,9 @@ jobs: auto-config: false use-github-cache: false - name: Compile CUDA Rust targets - run: cargo check --release --locked --workspace --all-targets --features ix-ffi/parallel,ix-ffi/cuda,ix-proof-compression/cuda + run: | + cargo check --release --locked --workspace --all-targets --features ix-ffi/parallel,ix-ffi/cuda,ix-proof-compression/cuda + cargo check --release --locked -p ix-proof-compression --all-targets --no-default-features --features bls12-381,parallel,plonk,cuda - name: Check CPU runtime linkage without a CUDA compiler run: IX_CUDA=0 IX_CUDA_TRACE_CODEGEN=0 NVCC=/nonexistent/nvcc lake build ix_cuda_runtime - name: Link CUDA-enabled Lean executables diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 16a2cee68..0bed69a09 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -9,6 +9,10 @@ namespace Ix.Cli.Compression @[extern "rs_compression_check_claim"] opaque checkClaim (claim statement : @& ByteArray) : IO Unit +/-- The KZG curve compiled into this executable: `"bn254"` or `"bls12-381"`. -/ +@[extern "rs_compression_curve"] +opaque curve : IO String + /-- Select development parameters when `devSrs` is set; otherwise require authenticated production parameters for the compiled curve. -/ @[extern "rs_compression_setup"] @@ -22,7 +26,8 @@ opaque compress (vk proof claim statement : @& ByteArray) (dir cacheRoot : @& St /-- Verify with the verifying key compiled in under the packet's id, whose pinned aggregation program fixes the statement for `claim`, and return the setup -class: `"filecoin"` or `"development"`. Development proofs need `devSrs`. +class: `"filecoin"`, `"ignition"` or `"development"`. Development proofs need +`devSrs`. Needs neither the aggregation backend nor a GPU. -/ @[extern "rs_compression_verify"] opaque verify (proof claim : @& ByteArray) (devSrs : Bool) : IO String @@ -49,9 +54,12 @@ def statementBytes (backend : Aggr.VerificationBackend) (claim : Ix.Claim) : Byt def verifyProof (addr : Address) (wrapper : Ixon.Proof) (devSrs : Bool) : IO UInt32 := do let claim := Ix.Claim.ser wrapper.claim let setup ← verify wrapper.proof claim devSrs - if setup == "development" then + match setup with + | "development" => IO.println s!"ok: development proof {addr} verifies claim {Address.blake3 claim} (not production-secure)" - else + | "ignition" => + IO.println s!"ok: proof {addr} verifies claim {Address.blake3 claim} under the Aztec Ignition powers of tau" + | _ => IO.println s!"ok: proof {addr} verifies claim {Address.blake3 claim} under the Filecoin powers of tau" return 0 diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index c4f2f5846..54b6c08d8 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -60,6 +60,18 @@ public def run : IO Unit := do throw <| IO.userError "external verifier import remains available" let fixtures : System.FilePath := "crates/proof-compression/tests/fixtures/aggregate-kzg" + let curve ← Compression.curve + if curve != "bls12-381" then + -- The bundled packets are BLS12-381; another curve's build must refuse them. + let expected ← IO.ofExcept (Ix.Claim.de (← IO.FS.readBinFile (fixtures / "filecoin-claim.bin"))) + let packet ← IO.FS.readBinFile (fixtures / "filecoin-v5-packet.bin") + let proof := ByteArray.mk #[73, 88, 67, 77, 80, 0, 0, 4] ++ packet + let blsAddr ← putProof home (Ixon.Proof.ser { claim := expected, proof }) + rejects home #["verify", toString blsAddr] "unsupported compressed proof version" + rejects home #["verify", toString blsAddr, "--dev-srs"] "unsupported compressed proof version" + IO.println s!"compression CLI ({curve}): claim binding and BLS12-381 packet rejection passed" + return + let fixtures : System.FilePath := "crates/proof-compression/tests/fixtures/aggregate-kzg" let expected ← IO.ofExcept (Ix.Claim.de (← IO.FS.readBinFile (fixtures / "filecoin-claim.bin"))) let packet ← IO.FS.readBinFile (fixtures / "filecoin-v5-packet.bin") let tag := ByteArray.mk #[73, 88, 67, 77, 80, 0, 0, 4] diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 6a44c38be..a86389a9d 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -47,7 +47,7 @@ bincode = { version = "2.0.1", optional = true } serde = { version = "1.0.219", features = ["derive"], optional = true } [features] -default = ["bls12-381", "plonk"] +default = ["bn254", "plonk"] bls12-381 = ["ix-proof-compression/bls12-381"] bn254 = ["ix-proof-compression/bn254"] plonk = ["ix-proof-compression/plonk"] diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index a383b6ed6..96adcab37 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -46,6 +46,11 @@ extern "C" fn rs_compression_verify( } } +#[unsafe(no_mangle)] +extern "C" fn rs_compression_curve() -> LeanIOResult { + LeanIOResult::ok(LeanString::new(ix_proof_compression::kzg::curve::NAME)) +} + #[unsafe(no_mangle)] extern "C" fn rs_compression_setup(dev_srs: bool) -> LeanIOResult { match pipeline::setup_description(dev_srs) { diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index 11f42a2d0..bd78645d1 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -60,12 +60,13 @@ name = "kzg_ignition_import" required-features = ["bn254"] [features] -bls12-381 = [] -# BN254 accepts admitted Ignition or development setup; CUDA support is experimental. -# Production parameters and terminal proof backends require separate vks. +# The KZG curve. BN254 is the default; BLS12-381 builds with +# `--no-default-features --features bls12-381,...`. Enabling both is a +# compile error. bn254 = ["dep:ark-bn254"] +bls12-381 = [] plonk = [] -default = ["parallel", "bls12-381", "plonk"] +default = ["parallel", "bn254", "plonk"] # GPU proving: multi-stark's Goldilocks backend for FRI compression and the # KZG kernels here. Needs the CUDA toolkit at build time and a visible GPU # at runtime; CPU-only builds neither compile sppark nor link CUDA. diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 62d72f09d..100fc7ff2 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -1,17 +1,25 @@ # Proof compression -## Experimental BN254 backend +## Curve selection -The `bn254` feature selects BN254 for the KZG library, including scalar -arithmetic, FFTs, commitments, batched openings, BLAKE3 transcripts, compact -proof encoding, recursive verifier gadgets and the standalone compression -worker. Use it independently of the BLS12-381 terminal-backend features: +BN254 is the default KZG curve, for the library, the compression worker and +the `ix` CLI. The `bn254` feature selects it for scalar arithmetic, FFTs, +commitments, batched openings, BLAKE3 transcripts, compact proof encoding, +recursive verifier gadgets and the worker. BLS12-381 remains available and +is selected without the default features: ```sh +cargo test -p ix-proof-compression --test bn254_kzg cargo test -p ix-proof-compression --no-default-features \ - --features bn254,parallel --test bn254_kzg + --features bls12-381,parallel,solidity,plonk ``` +The bundled verifying keys, Filecoin setup and Solidity KZG packet verifier +are BLS12-381; `solidity` therefore selects `bls12-381`. Enabling both curves +is a compile error. + +## BN254 backend + BN254 supports an explicit known-trapdoor development SRS and caller-admitted Ignition parameters. The importer, admission and pipeline passed crate tests, real transcript authentication/import, and cold/warm Init GPU proofs with @@ -20,10 +28,10 @@ key is bundled yet. Filecoin environment variables cannot select a BN254 setup. Admission of parameters and authorization of a verifying key are separate trust decisions. The `ix` library and its worker select the same curve through Lake's -`IX_KZG_CURVE=bls12-381|bn254` setting; BLS12-381 remains the default. -The Solidity exporter and gnark wrapper remain BLS12-381. Combining `bn254` -with the default features, `bls12-381`, `solidity` or `plonk` is unsupported -and rejected. The `bn254,cuda` integration passes CUDA component checks and +`IX_KZG_CURVE=bn254|bls12-381` setting; BN254 is the default. The PLONK +wrapper supports both curves; the Solidity KZG packet verifier is BLS12-381 +only. Combining `bn254` with `bls12-381` or `solidity` is rejected. The +`bn254,cuda` integration passes CUDA component checks and ordinary KZG proof-byte parity on a small fixture. Saved Init development GPU proofs match CPU output; Ignition GPU proofs pass independent CPU verification and produce identical bytes with cold/warm fixed preprocessing. @@ -652,7 +660,7 @@ it does not change the proof into a standard PLONK format. ### Curve selection and validation status -Lake uses `IX_KZG_CURVE=bls12-381|bn254` for both `ix` and the single +Lake uses `IX_KZG_CURVE=bn254|bls12-381`, defaulting to BN254, for both `ix` and the single `ix-compression-worker` target. `IX_CUDA=1` enables the selected KZG CUDA backend and Goldilocks CUDA. BN254 builds its matching PLONK helper and selects explicitly admitted Ignition parameters or `--dev-srs`. Curve features diff --git a/crates/proof-compression/src/kzg/curve.rs b/crates/proof-compression/src/kzg/curve.rs index ddac3eafe..914815d44 100644 --- a/crates/proof-compression/src/kzg/curve.rs +++ b/crates/proof-compression/src/kzg/curve.rs @@ -1,7 +1,7 @@ //! Curve types and canonical encoding widths for the selected KZG backend. //! -//! BN254 is selected with `--no-default-features --features bn254`. -//! The default backend is BLS12-381. Encodings are arkworks canonical +//! BN254 is the default backend; BLS12-381 is selected with +//! `--no-default-features --features bls12-381`. Encodings are arkworks canonical //! encodings; these widths do not describe in-memory or EVM representations. use ark_serialize::{ diff --git a/crates/proof-compression/src/lib.rs b/crates/proof-compression/src/lib.rs index ac0e8e283..c9ff74830 100644 --- a/crates/proof-compression/src/lib.rs +++ b/crates/proof-compression/src/lib.rs @@ -1,7 +1,7 @@ //! FRI and KZG proof compression for Ix. #[cfg(all(feature = "bn254", feature = "bls12-381"))] compile_error!( - "BN254 KZG requires --no-default-features --features bn254; the BLS12-381 feature cannot be combined with it" + "select one KZG curve: BN254 is the default; BLS12-381 needs --no-default-features --features bls12-381" ); mod circuit_metadata; diff --git a/flake.nix b/flake.nix index c43acf8f5..3b3935545 100644 --- a/flake.nix +++ b/flake.nix @@ -156,7 +156,7 @@ mkPlonkHelper = { bn254, cuda ? false }: (cudaPkgs.buildGoModule.override { go = plonkGo; }) { pname = (if bn254 then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper") + pkgs.lib.optionalString cuda "-cuda"; - tags = pkgs.lib.optional bn254 "bn254" ++ pkgs.lib.optional cuda "cuda"; + tags = pkgs.lib.optional (!bn254) "bls12381" ++ pkgs.lib.optional cuda "cuda"; version = "0.1.0"; src = pkgs.lib.fileset.toSource { root = ./tools/bls-plonk-wrapper; @@ -182,9 +182,9 @@ ''; }; - plonkHelper = mkPlonkHelper { bn254 = false; }; - bn254PlonkHelper = mkPlonkHelper { bn254 = true; }; - bn254PlonkCudaHelper = mkPlonkHelper { bn254 = true; cuda = true; }; + plonkHelper = mkPlonkHelper { bn254 = true; }; + plonkCudaHelper = mkPlonkHelper { bn254 = true; cuda = true; }; + blsPlonkHelper = mkPlonkHelper { bn254 = false; }; # Rust package craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; @@ -351,7 +351,7 @@ mkLakeBuildArgs = rustLib: { inherit lakeDeps; src = leanSrc; - IX_PLONK_PREBUILT = "${plonkHelper}/bin/ix-bls-plonk-wrapper"; + IX_PLONK_PREBUILT = "${plonkHelper}/bin/ix-bn254-plonk-wrapper"; # Don't build the `ix_rs` static lib with Lake, since we build it with Crane postPatch = '' substituteInPlace lakefile.lean --replace-fail 'proc { cmd := "cargo"' '--proc { cmd := "cargo"' @@ -450,8 +450,8 @@ default = ixLib; ix = ixCLI; plonk-wrapper = plonkHelper; - plonk-wrapper-bn254 = bn254PlonkHelper; - plonk-wrapper-bn254-cuda = bn254PlonkCudaHelper; + plonk-wrapper-cuda = plonkCudaHelper; + plonk-wrapper-bls12-381 = blsPlonkHelper; # `checks` are built by `nix flake check`; exposing this derivation # as a package keeps the ignored suite available on demand without # adding it to the default check set. @@ -470,7 +470,7 @@ checks = { plonk-wrapper = plonkHelper; - plonk-wrapper-bn254 = bn254PlonkHelper; + plonk-wrapper-bls12-381 = blsPlonkHelper; # Lint the host workspace; warnings are errors. clippy = craneLib.cargoClippy ( craneArgs diff --git a/lakefile.lean b/lakefile.lean index 53cbf9523..f9fab8784 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -53,7 +53,7 @@ section FFI /-- Curve selection shared by the CLI library and compression worker. -/ def compressionFeatures : IO (Array String) := do - match (← IO.getEnv "IX_KZG_CURVE").getD "bls12-381" with + match (← IO.getEnv "IX_KZG_CURVE").getD "bn254" with | "bls12-381" => return #["bls12-381", "plonk"] | "bn254" => return #["bn254", "plonk"] | other => throw <| IO.userError s!"IX_KZG_CURVE must be bls12-381 or bn254, got {other}" @@ -225,11 +225,11 @@ target ix_compression_worker pkg : FilePath := do /-- The terminal prover is a native Go executable with a pinned module graph. -/ def compressionHelperName (features : Array String) : String := - if features.contains "bn254" then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper" + if features.contains "bls12-381" then "ix-bls-plonk-wrapper" else "ix-bn254-plonk-wrapper" def compressionHelperArgs (features : Array String) (output : FilePath) : Array String := #["build", "-buildvcs=false", "-trimpath", "-mod=readonly"] ++ - (if features.contains "bn254" then #["-tags", "bn254"] else #[]) ++ + (if features.contains "bls12-381" then #["-tags", "bls12381"] else #[]) ++ #["-o", output.toString, "."] /-- PLONK arithmetic selection is independent of the inner KZG CUDA backend. -/ diff --git a/tools/bls-plonk-wrapper/README.md b/tools/bls-plonk-wrapper/README.md index eaac80af6..9f5663bdf 100644 --- a/tools/bls-plonk-wrapper/README.md +++ b/tools/bls-plonk-wrapper/README.md @@ -19,11 +19,15 @@ all three. `nix build .#ix` packages them, and `nix build .#plonk-wrapper` build and tests only this helper. Runtime proving and verification do not need Go or Cargo installed. -`IX_KZG_CURVE=bn254 lake build ix` selects BN254 for the CLI, compression -worker and `ix-bn254-plonk-wrapper` helper. Set the same selection for -`lake run install`. Both helpers share this module; the `bn254` Go build tag -selects BN254, and the default build selects BLS12-381. The standalone Nix -helper package is `.#plonk-wrapper-bn254`; the default `.#ix` remains BLS. +`lake build ix` selects BN254 for the CLI, compression worker and +`ix-bn254-plonk-wrapper` helper; `IX_KZG_CURVE=bls12-381` selects BLS12-381 +and `ix-bls-plonk-wrapper`. Set the same selection for `lake run install`. +Both helpers share this module; the default Go build selects BN254 and the +`bls12381` build tag selects BLS12-381. The standalone Nix helper packages +are `.#plonk-wrapper` (BN254) and `.#plonk-wrapper-bls12-381`; `.#ix` is +BN254. + +A BLS12-381 build proves under the Filecoin powers of tau: ```sh export IX_KZG_FILECOIN_CACHE=/path/to/filecoin-prefix-2p27.bin @@ -61,7 +65,7 @@ export IX_PLONK_BLST_SOURCE=/path/to/blst-0.3.16/blst export CUDA_HOME=/usr/local/cuda export NVCC="$CUDA_HOME/bin/nvcc" export IX_PLONK_CUDA_ARCHS=120 -IX_KZG_CURVE=bn254 IX_PLONK_CUDA=1 lake build ix_plonk_wrapper +IX_PLONK_CUDA=1 lake build ix_plonk_wrapper ``` The standalone equivalent is: @@ -79,7 +83,7 @@ to private build copies, and records executable/library hashes. It leaves in `../lib` relative to the helper. Keep both artifacts together when installing; `lake run install` does this when `IX_PLONK_CUDA=1`. The optional Nix package is -`.#plonk-wrapper-bn254-cuda`, targeting SM120. CPU packages remain independent of +`.#plonk-wrapper-cuda`, targeting SM120. CPU packages remain independent of the CUDA toolkit. Select the runtime backend separately from building it: @@ -239,7 +243,8 @@ The import command requires an exact domain match. For the current `2^24` circuit, `prepare` computes and caches a matching basis from the authenticated monomial powers unless an independently validated matching cache is present. -Ordinary BN254 tests run with `go test -tags bn254 ./...`. They cover the real +Ordinary BN254 tests run with `go test ./...`; BLS12-381 tests with +`go test -tags bls12381 ./...`. They cover the real Init verifier witness and tampering, Arkworks-compatible transcript encodings, exceptional MSM inputs, small PLONK proofs, parameter caches and rejection of incorrect imported bases and malformed SP1 key containers. @@ -274,7 +279,7 @@ BN254 also selects classic GLV, reducing its actual Init wrapper to batching measurement variant reaches 13,810,186 rows with classic GLV; batching with BN254's previous multiplication path reaches 17,530,148 rows. Batching is experimental and is not selected by the normal helper. Reproduce sizing -with `IX_PLONK_SIZE=1 go test -tags bn254 -run TestOptimizationSizing -v`; +with `IX_PLONK_SIZE=1 go test -run TestOptimizationSizing -v`; `IX_PLONK_SIZE_VK` selects a verifying key and `IX_PLONK_SIZE_REPORT` saves JSON. `IX_PLONK_SIZE_VARIANT` can select `baseline`, `batched`, `classic-glv` or `batched-classic-glv`. These are compilation results, separate from the diff --git a/tools/bls-plonk-wrapper/backend_bn254_test.go b/tools/bls-plonk-wrapper/backend_bn254_test.go index bdfbb9ae8..4d000f0fd 100644 --- a/tools/bls-plonk-wrapper/backend_bn254_test.go +++ b/tools/bls-plonk-wrapper/backend_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/backend_cpu.go b/tools/bls-plonk-wrapper/backend_cpu.go index 870d3dfed..9093202e0 100644 --- a/tools/bls-plonk-wrapper/backend_cpu.go +++ b/tools/bls-plonk-wrapper/backend_cpu.go @@ -1,4 +1,4 @@ -//go:build !bn254 || !cuda +//go:build bls12381 || !cuda package main @@ -14,7 +14,7 @@ func validateProvingBackend() error { case "", "cpu": return nil case "cuda": - return fmt.Errorf("CUDA PLONK requires a helper built with bn254,cuda and cgo") + return fmt.Errorf("CUDA PLONK requires a BN254 helper built with the cuda tag and cgo") default: return fmt.Errorf("IX_PLONK_BACKEND must be cpu or cuda") } diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254.go b/tools/bls-plonk-wrapper/backend_cuda_bn254.go index 915ae2421..8cf0dc8e7 100644 --- a/tools/bls-plonk-wrapper/backend_cuda_bn254.go +++ b/tools/bls-plonk-wrapper/backend_cuda_bn254.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package main diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go b/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go index 7e7d9c0f1..ad28e8017 100644 --- a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go +++ b/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package main diff --git a/tools/bls-plonk-wrapper/backend_no_cuda_test.go b/tools/bls-plonk-wrapper/backend_no_cuda_test.go index da805a536..1da5e9ddd 100644 --- a/tools/bls-plonk-wrapper/backend_no_cuda_test.go +++ b/tools/bls-plonk-wrapper/backend_no_cuda_test.go @@ -1,4 +1,4 @@ -//go:build !bn254 || !cuda +//go:build bls12381 || !cuda package main diff --git a/tools/bls-plonk-wrapper/backend_test.go b/tools/bls-plonk-wrapper/backend_test.go index 4d2887750..6eab7015f 100644 --- a/tools/bls-plonk-wrapper/backend_test.go +++ b/tools/bls-plonk-wrapper/backend_test.go @@ -1,4 +1,4 @@ -//go:build !bn254 +//go:build bls12381 package main diff --git a/tools/bls-plonk-wrapper/build_cuda.py b/tools/bls-plonk-wrapper/build_cuda.py index 54c4a52bf..e6efe8e22 100644 --- a/tools/bls-plonk-wrapper/build_cuda.py +++ b/tools/bls-plonk-wrapper/build_cuda.py @@ -200,7 +200,7 @@ def build_helper(args, output, build): command = [args.go, "test", "-c"] if args.test_binary else [args.go, "build"] temporary_output = output.with_suffix(output.suffix + ".tmp") command += ["-buildvcs=false", "-trimpath", "-mod=readonly", - "-modfile=" + str(modfile), "-tags", "bn254,cuda", + "-modfile=" + str(modfile), "-tags", "cuda", "-o", str(temporary_output), args.package] run(command, cwd=HERE, env=environment) temporary_output.replace(output) diff --git a/tools/bls-plonk-wrapper/circuit_bn254_test.go b/tools/bls-plonk-wrapper/circuit_bn254_test.go index 32b6dded3..d7b73897d 100644 --- a/tools/bls-plonk-wrapper/circuit_bn254_test.go +++ b/tools/bls-plonk-wrapper/circuit_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/circuit_test.go b/tools/bls-plonk-wrapper/circuit_test.go index 2ebed580d..f2d86985f 100644 --- a/tools/bls-plonk-wrapper/circuit_test.go +++ b/tools/bls-plonk-wrapper/circuit_test.go @@ -1,4 +1,4 @@ -//go:build !bn254 +//go:build bls12381 package main diff --git a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go b/tools/bls-plonk-wrapper/cuda_init_bn254_test.go index 497d2a81c..760f9558e 100644 --- a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go +++ b/tools/bls-plonk-wrapper/cuda_init_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package main diff --git a/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go b/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go index 5ada43682..b71c34524 100644 --- a/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go +++ b/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package main diff --git a/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go b/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go index 4c5ed3231..1e2cc37f7 100644 --- a/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go +++ b/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package main diff --git a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go b/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go index c4f819b2e..ce115e7a9 100644 --- a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go +++ b/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/curve_bls.go b/tools/bls-plonk-wrapper/curve_bls.go index 9c436bb97..37114a202 100644 --- a/tools/bls-plonk-wrapper/curve_bls.go +++ b/tools/bls-plonk-wrapper/curve_bls.go @@ -1,4 +1,4 @@ -//go:build !bn254 +//go:build bls12381 package main diff --git a/tools/bls-plonk-wrapper/curve_bn254.go b/tools/bls-plonk-wrapper/curve_bn254.go index 862b4a77c..c3fd5fa79 100644 --- a/tools/bls-plonk-wrapper/curve_bn254.go +++ b/tools/bls-plonk-wrapper/curve_bn254.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/internal/cuda/abi.go b/tools/bls-plonk-wrapper/internal/cuda/abi.go index baf5ee43e..97675edad 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/abi.go +++ b/tools/bls-plonk-wrapper/internal/cuda/abi.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/accelerator.go b/tools/bls-plonk-wrapper/internal/cuda/accelerator.go index 656c857cc..33d9eebb8 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/accelerator.go +++ b/tools/bls-plonk-wrapper/internal/cuda/accelerator.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go b/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go index 0c704634e..f8fbbce69 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go +++ b/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/masking_test.go b/tools/bls-plonk-wrapper/internal/cuda/masking_test.go index 340d8afe5..b03811c60 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/masking_test.go +++ b/tools/bls-plonk-wrapper/internal/cuda/masking_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go b/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go index e1a030599..73a70d034 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go +++ b/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/structured_test.go b/tools/bls-plonk-wrapper/internal/cuda/structured_test.go index 7ddbf9537..c43fdb4f4 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/structured_test.go +++ b/tools/bls-plonk-wrapper/internal/cuda/structured_test.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/trace.go b/tools/bls-plonk-wrapper/internal/cuda/trace.go index d45f39cf0..a14291e59 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/trace.go +++ b/tools/bls-plonk-wrapper/internal/cuda/trace.go @@ -1,4 +1,4 @@ -//go:build bn254 && cuda +//go:build !bls12381 && cuda package cuda diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254.go b/tools/bls-plonk-wrapper/lagrange_import_bn254.go index bc62e371e..1bd95ae26 100644 --- a/tools/bls-plonk-wrapper/lagrange_import_bn254.go +++ b/tools/bls-plonk-wrapper/lagrange_import_bn254.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go b/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go index 7968e2804..a4a420c50 100644 --- a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go +++ b/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go b/tools/bls-plonk-wrapper/msm_capture_bn254_test.go index 0495e5543..a5ed5ea57 100644 --- a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go +++ b/tools/bls-plonk-wrapper/msm_capture_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go b/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go index 893d7bc0d..888be5536 100644 --- a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go +++ b/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/parameters_bn254_test.go b/tools/bls-plonk-wrapper/parameters_bn254_test.go index 0821d9516..45dec5cb0 100644 --- a/tools/bls-plonk-wrapper/parameters_bn254_test.go +++ b/tools/bls-plonk-wrapper/parameters_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main diff --git a/tools/bls-plonk-wrapper/parameters_test.go b/tools/bls-plonk-wrapper/parameters_test.go index e6e10fb97..520837c8d 100644 --- a/tools/bls-plonk-wrapper/parameters_test.go +++ b/tools/bls-plonk-wrapper/parameters_test.go @@ -1,4 +1,4 @@ -//go:build !bn254 +//go:build bls12381 package main diff --git a/tools/bls-plonk-wrapper/verifier_bn254_test.go b/tools/bls-plonk-wrapper/verifier_bn254_test.go index 235e1fa14..9d1c46490 100644 --- a/tools/bls-plonk-wrapper/verifier_bn254_test.go +++ b/tools/bls-plonk-wrapper/verifier_bn254_test.go @@ -1,4 +1,4 @@ -//go:build bn254 +//go:build !bls12381 package main From 1bee86381e9e51b4ec9da23282276411c95c1565 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:55:00 +0000 Subject: [PATCH 11/31] fix(compression): refresh optimized BN254 PLONK verification Include the zero-anchor claim row when exporting recursive packets so all 22 public values and their anchor are represented in the wrapper circuit. Add a small-proof regression covering the complete schema and changed claims. Refresh the BN254 PlonkVerifier constants, proof fixture and provenance in contracts/plonk for the optimized Init recursive proof using admitted Ignition parameters, and record its GPU proving, independent CPU verification and local EVM results. As recorded, the GPU proof took 11.557 s in plonk.Prove and the ix adapter verification transaction used 320,091 gas. --- contracts/plonk/README.md | 34 +++++++--- contracts/plonk/bn254/provenance.json | 64 +++++++++++++------ contracts/plonk/bn254/src/PlonkVerifier.sol | 24 +++---- contracts/plonk/bn254/testdata/init.json | 12 ++-- .../src/pipeline/wrap/outer.rs | 37 +++++++++++ .../src/pipeline/wrap/outer/plonk_export.rs | 10 +-- docs/compression-stage-collapse.md | 19 ++++++ 7 files changed, 150 insertions(+), 50 deletions(-) diff --git a/contracts/plonk/README.md b/contracts/plonk/README.md index 1c6b6f9cc..531ad331d 100644 --- a/contracts/plonk/README.md +++ b/contracts/plonk/README.md @@ -32,8 +32,15 @@ optimized direct verifier. ## Actual BN254 Init proof on Anvil The actual `2^24` BN254 Init proof passes the adapted SP1 v6.1.0 PLONK verifier -and the ix claim-digest adapter on local Anvil. This uses the 14,689,440-constraint -classic-GLV circuit with both inner pairing checks, not the tiny backend fixture. +and the ix claim-digest adapter on local Anvil. The fixture was refreshed on +2026-10-19 for the recursive KZG packet after the opening-reduction, lazy-byte- +nibble and 20-bit range-limb optimizations, using admitted Ignition parameters. +It uses the 14,689,440-constraint classic-GLV circuit with both inner pairing +checks and the original independently expected Init claim digest. + +The matching PLONK verifying-key SHA-256 is +`32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac`. +The older October 17 key does not identify this optimized circuit. The untouched SP1 verifier rejects the proof. Its fixed circuit key uses five public inputs and domain `2^25`; ix uses two inputs and domain `2^24`. The test @@ -58,14 +65,14 @@ benchmark circuit, not an interchangeable SP1 verification key. ### Receipt measurements -Measured on 2026-10-17 with Foundry/Anvil 1.8.5, solc 0.8.37, via-IR, optimizer +Measured on 2026-10-19 with Foundry/Anvil 1.8.5, solc 0.8.37, via-IR, optimizer 200 runs, Prague bytecode and Osaka execution rules. Deployment-size limits were enabled. These are mined transactions on a fresh local chain: | Path | Execution gas | Intrinsic/calldata gas | Total receipt gas | ABI calldata | | --- | ---: | ---: | ---: | ---: | -| Adapted SP1 `Verify(bytes,uint256[])` | 279,915 | 36,088 | **316,003** | 1,060 bytes | -| ix `verify(bytes,bytes32)` adapter | 284,435 | 35,668 | **320,103** | 964 bytes | +| Adapted SP1 `Verify(bytes,uint256[])` | 279,915 | 36,076 | **315,991** | 1,060 bytes | +| ix `verify(bytes,bytes32)` adapter | 284,435 | 35,656 | **320,091** | 964 bytes | The Solidity proof is **864 bytes**. The native 584-byte encoding and 624-byte ix envelope are storage/interchange formats and are rejected by this Solidity @@ -73,8 +80,8 @@ interface. Both successful calls return `true`; transaction traces confirm the return value as well as the receipt status. The totals exceed the EIP-7623 calldata floor, so the execution/intrinsic split above applies. -The adapted verifier has **7,531 bytes** of runtime code and **7,557 bytes** of -initcode; deployment used **1,681,283 gas**. The adapter has **913 runtime bytes** +The adapted verifier has **7,533 bytes** of runtime code and **7,559 bytes** of +initcode; deployment used **1,681,703 gas**. The adapter has **913 runtime bytes** and **1,159 initcode bytes including its constructor argument**; deployment used **255,356 gas**. All fit ordinary EIP-170/EIP-3860 limits. These deployment costs are excluded from verification gas. @@ -92,6 +99,14 @@ mutations of all 27 proof words, and noncanonical point/scalar encodings. Every call uses the same 2M gas bound, including the positive controls. The test uses only an isolated local Anvil chain and shuts down its own node after completion. +The GPU proof took **11.557 s** inside `plonk.Prove` and **33.872 s** for the +complete fresh prover process, with **20.64 GiB** peak host RSS. This is one +run on 32 logical Xeon Platinum 8559C CPUs and one RTX PRO 6000 Blackwell, +using prepared keys and default parallelism. One-time key preparation took +**495.843 s**, separately from proving. An independent CPU helper accepted +the proof with pinned Ignition anchors and rejected an altered claim and +corrupted proof. + ### Reproduce Use the hash-pinned Foundry and Solidity binaries documented in @@ -106,6 +121,9 @@ python3 contracts/plonk/bn254/bench.py \ The output directory must be new. Compilation input/output, deployment and verification receipts, call traces, negative-call results and the Anvil log are -saved there. The completed run is `target/plonk-evm/run-final/report.json`. +saved there. The refreshed run is +`target/plonk-refresh-20261019/anvil/report.json`. Matching keys, proof, +exported witness and frozen CPU/CUDA helpers are retained at +`/opt/dlami/nvme/ix-bn254/plonk/optimized-init-20261019/`. This validates the exported circuit key and fixture; production applications still select their intended key and obtain the expected digest independently. diff --git a/contracts/plonk/bn254/provenance.json b/contracts/plonk/bn254/provenance.json index b3314008a..f0142e9c3 100644 --- a/contracts/plonk/bn254/provenance.json +++ b/contracts/plonk/bn254/provenance.json @@ -36,27 +36,27 @@ }, "VK_QL_COM_X": { "sp1": "9496857109770518741292064488682135861248962674494967861604446614769298140801", - "ix": "5309045740510031945991932551018364072006311610820055731927692072309170001839" + "ix": "2247446810280598997078512583076010463705556275328790190283284160986145432419" }, "VK_QL_COM_Y": { "sp1": "13306697158707148893187289359474517489309092662132847111865615083807847780686", - "ix": "15178165910664754266902111074643904424063340409370666177336989271314478243182" + "ix": "17899293397787521469498438166384916767131308403173136158957583810720457160067" }, "VK_QR_COM_X": { "sp1": "15168106396031721196861946917417531048941395918951726120971774887904231224033", - "ix": "4333128755228398984050452785484293868085136011195057857131447951644517246358" + "ix": "17261453585169625555680195078041304151068282529357212908112765392770894534278" }, "VK_QR_COM_Y": { "sp1": "17067422783799132011484353829344785446718705273302151466551760455276544179973", - "ix": "451943038604306803478917644270289509587617153611512448210003017134715864094" + "ix": "5032802837544509306758328961405050308554210384548103718736125522125179924100" }, "VK_QM_COM_X": { "sp1": "2062503189072358517228813627364112801206468481541880929627061212524046867500", - "ix": "13654492804778691259164665408002942238210394757487282492782630714578010550105" + "ix": "3584607594627111430618022243188257181559026512530565511559511908334574961735" }, "VK_QM_COM_Y": { "sp1": "16383264006058748048241814683452691377147195760917894158624764699848260181996", - "ix": "4170689851748144627682006991866933098380421530894955798140164640479630857422" + "ix": "11088732376897191683720631537870533628246508861445120171445562729184158960707" }, "VK_QO_COM_X": { "sp1": "11276993554321744761007348475396375004546042576992247443165667159541648019615", @@ -68,27 +68,27 @@ }, "VK_QK_COM_X": { "sp1": "3509076513963323471275614870100244642947645425705684850560588726111445186717", - "ix": "21087794933144987813778627740507349690142120785431442678765829888195147140486" + "ix": "8638199322136519747148286291472634149689894578550046327199061687921842786694" }, "VK_QK_COM_Y": { "sp1": "15082251669986564403690950092410766520944967428195940447691297074393449013243", - "ix": "18664134873073965629410443508646780836583853861541542336381733986376705446547" + "ix": "10330219132359379321952244687961679343297956293983744809309690374608570754593" }, "VK_S1_COM_X": { "sp1": "20702610951114261233096944021168607816381642857429919420395478944329365232425", - "ix": "3160083449751345232768572700367928453171558982969385372902272237051111945683" + "ix": "8374572668059626572024474755514158947558130423615005095635236580751301971603" }, "VK_S1_COM_Y": { "sp1": "6287924284852772991087729385363871923831267251255140888671281832443192104403", - "ix": "3173893476425843604675313553504367870971306498389983578189512126041225303974" + "ix": "20107543125503712563986728692881849476263382184844757010316687939070575623448" }, "VK_S2_COM_X": { "sp1": "645470009383750670501092774958765334436477314405650592677994973080307449345", - "ix": "12972998826842100335667323871952459614736937618220985729849619557275074897278" + "ix": "7806919962685161745616169473075389877111993406540456889644868872676349445654" }, "VK_S2_COM_Y": { "sp1": "4256397297118091181646823265799511434474377494065149067487129126410465669924", - "ix": "15584446085400045812352698340044777040401645716047298734682398815882560235895" + "ix": "19683449971971340047606283183514389334699079190813975475424665498017446086377" }, "VK_S3_COM_X": { "sp1": "7492538199234778964992190797161279888333633666731339557560747528992222778140", @@ -112,14 +112,40 @@ } }, "algorithm_unchanged": true, - "generated_gnark_source_sha256": "6f3b0d598d3b08a925575b1494c58a4267852bb9fbd29cb1e9179005f45edfd4", - "adapted_source_sha256": "feb3dc9770417b30ac16e99ffe592f4b94126108fae29263c2d36d102720d65a", - "verifying_key_sha256": "4f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff368", - "proof_solidity_sha256": "9eb6e46e1d89e72b6983d1c5b2c36e68c5085b4d42e3cfa47e81b4e3a8690100", - "native_proof_sha256": "06084a415648171e93bc638e49c111f75e67ef24c24186543d91e9398f42b438", + "generated_gnark_source_sha256": "62634b3b8a0944462778fc745d28168008b34ab36cc7716739dbd0ef07bf7a13", + "adapted_source_sha256": "cd4096aa71fcf21aea567b9e700ed12903625ba9df9a2733daa4f652ced458f8", + "verifying_key_sha256": "32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac", + "proof_solidity_sha256": "0944e5b3bb3cb51b4fb8b1bca7e0d16cb48f2d729e9fcf05a64fe2539c5790b7", + "native_proof_sha256": "7dc47b6f1bd26d31c64cf5ca7abc7a469d90c1ac0294f2c90d050a29546f7751", "claim_blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", - "fixture_sha256": "b195b17be039346e2b47bdbde57c380a32e58193c2e6bfc498b406e2d68c65ed", + "fixture_sha256": "13f469ab83f3eaf285e87dc201dd5c8d2b538137f6646dae2fa8400afc33ab39", "gnark": "262cf453c107d147a228b6b7418c5cf791c3b79b", - "benchmark_report": "target/bn254-plonk-2p24/report.json" + "benchmark_report": "target/plonk-refresh-20261019/proof-report.json", + "anvil_report": "target/plonk-refresh-20261019/anvil/report.json", + "refresh": { + "date": "2026-10-19", + "inner_verifying_key_blake3": "1c613f8cfb94399f75687735cbb07e4e91bd4d23e7a58c35388fdfafc67f50e6", + "inner_verifying_key_id": "0x40553b0a35b10e8efcca761b87d7db712728cfd92e0d342893def16282692626", + "inner_packet_sha256": "276362cda2b3de03f2bdeb5494135c3537725550076938f4d09c17c46dcb6544", + "recursive_trace_heights": [ + 67108864, + 2097152 + ], + "ignition_admission_blake3": "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", + "setup_id": "07f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b", + "helper_source_revision": "dd2511fc29966aa13df8351ff62e9f840d39e0e0", + "helper_sha256": "f6bc8b1bac015df09444473544be42bd634d576991055913b0196b3ec2dbf0b6", + "cpu_verifier_sha256": "b19338ff6c09d8810cce79eba1225e2eda565deae186da5e2ff14f94059f6ab1", + "worker_sha256": "df6e9ebeffd1ee3e89ed13dc24aa37d9152cf91db0a90a3a305166feb8007aa3", + "worker_source_revision": "7d69f01787ac6aae7fabf45212ede36503a18ce2", + "exporter_patch_sha256": "8361804f676a152cb99291efe4fe92c9e40d90ed0eabe11954209890988331f6", + "compiled_circuit_sha256": "82c8473b89526488c06cac26606bb8f3e6544beca916ffa48163b1b7ab604e36", + "proving_backend": "cuda", + "native_cpu_verification_passed": true, + "native_negative_checks": [ + "wrong-claim", + "corrupt-proof" + ] + } } } diff --git a/contracts/plonk/bn254/src/PlonkVerifier.sol b/contracts/plonk/bn254/src/PlonkVerifier.sol index e9e029b1e..b5a423ec5 100644 --- a/contracts/plonk/bn254/src/PlonkVerifier.sol +++ b/contracts/plonk/bn254/src/PlonkVerifier.sol @@ -42,22 +42,22 @@ contract PlonkVerifier { uint256 private constant VK_DOMAIN_SIZE = 16777216; uint256 private constant VK_INV_DOMAIN_SIZE = 21888241567198334088790460357988866238279339518792980768180410072331574733841; uint256 private constant VK_OMEGA = 5709868443893258075976348696661355716898495876243883251619397131511003808859; - uint256 private constant VK_QL_COM_X = 5309045740510031945991932551018364072006311610820055731927692072309170001839; - uint256 private constant VK_QL_COM_Y = 15178165910664754266902111074643904424063340409370666177336989271314478243182; - uint256 private constant VK_QR_COM_X = 4333128755228398984050452785484293868085136011195057857131447951644517246358; - uint256 private constant VK_QR_COM_Y = 451943038604306803478917644270289509587617153611512448210003017134715864094; - uint256 private constant VK_QM_COM_X = 13654492804778691259164665408002942238210394757487282492782630714578010550105; - uint256 private constant VK_QM_COM_Y = 4170689851748144627682006991866933098380421530894955798140164640479630857422; + uint256 private constant VK_QL_COM_X = 2247446810280598997078512583076010463705556275328790190283284160986145432419; + uint256 private constant VK_QL_COM_Y = 17899293397787521469498438166384916767131308403173136158957583810720457160067; + uint256 private constant VK_QR_COM_X = 17261453585169625555680195078041304151068282529357212908112765392770894534278; + uint256 private constant VK_QR_COM_Y = 5032802837544509306758328961405050308554210384548103718736125522125179924100; + uint256 private constant VK_QM_COM_X = 3584607594627111430618022243188257181559026512530565511559511908334574961735; + uint256 private constant VK_QM_COM_Y = 11088732376897191683720631537870533628246508861445120171445562729184158960707; uint256 private constant VK_QO_COM_X = 2970551836734429030743170272625326962021756571268061179291098770692736928725; uint256 private constant VK_QO_COM_Y = 14253370184471031778663230296712970012512019999228359328162740821710798273297; - uint256 private constant VK_QK_COM_X = 21087794933144987813778627740507349690142120785431442678765829888195147140486; - uint256 private constant VK_QK_COM_Y = 18664134873073965629410443508646780836583853861541542336381733986376705446547; + uint256 private constant VK_QK_COM_X = 8638199322136519747148286291472634149689894578550046327199061687921842786694; + uint256 private constant VK_QK_COM_Y = 10330219132359379321952244687961679343297956293983744809309690374608570754593; - uint256 private constant VK_S1_COM_X = 3160083449751345232768572700367928453171558982969385372902272237051111945683; - uint256 private constant VK_S1_COM_Y = 3173893476425843604675313553504367870971306498389983578189512126041225303974; + uint256 private constant VK_S1_COM_X = 8374572668059626572024474755514158947558130423615005095635236580751301971603; + uint256 private constant VK_S1_COM_Y = 20107543125503712563986728692881849476263382184844757010316687939070575623448; - uint256 private constant VK_S2_COM_X = 12972998826842100335667323871952459614736937618220985729849619557275074897278; - uint256 private constant VK_S2_COM_Y = 15584446085400045812352698340044777040401645716047298734682398815882560235895; + uint256 private constant VK_S2_COM_X = 7806919962685161745616169473075389877111993406540456889644868872676349445654; + uint256 private constant VK_S2_COM_Y = 19683449971971340047606283183514389334699079190813975475424665498017446086377; uint256 private constant VK_S3_COM_X = 11850267738493914463571045673465636487389201930732259686382873306579288151723; uint256 private constant VK_S3_COM_Y = 12366451455213994943132905838640349406977921177620674929449191152457830777150; diff --git a/contracts/plonk/bn254/testdata/init.json b/contracts/plonk/bn254/testdata/init.json index 361f58e68..dff0ff648 100644 --- a/contracts/plonk/bn254/testdata/init.json +++ b/contracts/plonk/bn254/testdata/init.json @@ -1,15 +1,15 @@ { "format": "ix-bn254-plonk-solidity-init-v1", - "scope": "Actual Init recursive KZG proof wrapped with classic GLV and two separate pairing checks", - "proof": "0x10dd966bbaebed3b2898d68e08d39d32d11d22856e2cebfbef62d50440d6750503d361f6fcc09ff2f2adced99fb3ef92dedc4097db58729dea58b4d2ab583ba8004839298956fc8e9cd685113211353ec4f73dd0064e8a214b21b66d78a64217073a1d2cfa6b7f228d0acec710a744461b3bedce2dc39b2afbf451dc6002e2f5073a6df5b606a53a87c37970f43b33a9d3ca052f9adad2a43a1bf25ab4f981492ec0a499b16e23b81822fc87cba0f5c7832273e1ab7b50f86d1bf619b4c945ef1c13c4e90d7701f38e596a8d54a4405add02fc2c307ff25a8114714f84b64c91245dd79f72ea95f236dbd2b6a35cbf6e6cb4e5a5a96d9924295754535605b1eb1b31b55edeeec3677908e3a3ed3085aee8d1d35961c2724b07a1eb24b6bd49aa1e36a96b730b86d4c7537aa66786a922927d1dea993eaba35bd1d5fb4f8609ac1b518a87a55609410f22bb24fc59f9f513bd08ce49da5904fa48bef99bf24e091b405adf29d7885abbab2e2b44b14ed5ecd111fc39888026b143408eb8ced6312c74fc410f7eef302152066dc66c128f90462c54009d177372614b44389011a203bd5d6e165893dcc7c45b0569450b7bcf9ce6716d6959e07d193c716240b27229ac1e0fce3d91f4b8a2506906e3eca6203e433a5a928e2bcfad43fee2f2922a01eebf2b1701db7f9548975d42f447d0b59d257ddcb69eaa99f4f8c0571945062c7ff40d5ee2833ea81f834e26b7a48f7d399af35a961c018b236ad234dc71b626255dd9299a0a4c2d1e148e014fb144eca6cd43b8acec93caf2ada143c5bdcc04407221f13938f04c5f7d9e3958340da51c3a7f5d60b79d20c662dbf961a8f52d5c6770f25be2fddde439dc43fd071afc2f3fca65e551dc7438ae7c6d1c3061155a3c899ad5ef97b8e5547ae5f33dd85323fc973a22278e058e2cb1a9ab9f832a625c37ff03784549d827bdf9febbdf8fada2a7f028774118afe8b71c87949513407c2ecf7d3d2e1c9376704ceb69206c2ec1d11882e7da031ba1dd2a93ac3e2d388c7412d4efcd2d7988ef3d1447aa4cd2fcd93e511e5a372ff60802378c35210e4715adbebcb506c45ac95cd577118b4180a3a087f95626a2329fc51fadb32f574c2f40fbc2d655ad35f4529380e69a541695c712e317ae2da2a4a0892ad51bb21e30aeba60746ebf5fedbf1cc5d61a733629a4a7c63bc62334947a371b32", - "nativeProof": "0x90dd966bbaebed3b2898d68e08d39d32d11d22856e2cebfbef62d50440d67505804839298956fc8e9cd685113211353ec4f73dd0064e8a214b21b66d78a64217c73a6df5b606a53a87c37970f43b33a9d3ca052f9adad2a43a1bf25ab4f98149a6255dd9299a0a4c2d1e148e014fb144eca6cd43b8acec93caf2ada143c5bdccdc13c4e90d7701f38e596a8d54a4405add02fc2c307ff25a8114714f84b64c91db31b55edeeec3677908e3a3ed3085aee8d1d35961c2724b07a1eb24b6bd49aadb518a87a55609410f22bb24fc59f9f513bd08ce49da5904fa48bef99bf24e09d55a3c899ad5ef97b8e5547ae5f33dd85323fc973a22278e058e2cb1a9ab9f83000000071422800092d527f07d96b568456c47aa53094847bd7faebcf68bec915473b2df2c74fc410f7eef302152066dc66c128f90462c54009d177372614b44389011a203bd5d6e165893dcc7c45b0569450b7bcf9ce6716d6959e07d193c716240b27229ac1e0fce3d91f4b8a2506906e3eca6203e433a5a928e2bcfad43fee2f2922a01eebf2b1701db7f9548975d42f447d0b59d257ddcb69eaa99f4f8c0571945062c7ff40d5ee2833ea81f834e26b7a48f7d399af35a961c018b236ad234dc71b6210e4715adbebcb506c45ac95cd577118b4180a3a087f95626a2329fc51fadb3d3407c2ecf7d3d2e1c9376704ceb69206c2ec1d11882e7da031ba1dd2a93ac3e2d5c6770f25be2fddde439dc43fd071afc2f3fca65e551dc7438ae7c6d1c306100000001ef574c2f40fbc2d655ad35f4529380e69a541695c712e317ae2da2a4a0892ad5", - "envelope": "0x4958424e000000024f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff36890dd966bbaebed3b2898d68e08d39d32d11d22856e2cebfbef62d50440d67505804839298956fc8e9cd685113211353ec4f73dd0064e8a214b21b66d78a64217c73a6df5b606a53a87c37970f43b33a9d3ca052f9adad2a43a1bf25ab4f98149a6255dd9299a0a4c2d1e148e014fb144eca6cd43b8acec93caf2ada143c5bdccdc13c4e90d7701f38e596a8d54a4405add02fc2c307ff25a8114714f84b64c91db31b55edeeec3677908e3a3ed3085aee8d1d35961c2724b07a1eb24b6bd49aadb518a87a55609410f22bb24fc59f9f513bd08ce49da5904fa48bef99bf24e09d55a3c899ad5ef97b8e5547ae5f33dd85323fc973a22278e058e2cb1a9ab9f83000000071422800092d527f07d96b568456c47aa53094847bd7faebcf68bec915473b2df2c74fc410f7eef302152066dc66c128f90462c54009d177372614b44389011a203bd5d6e165893dcc7c45b0569450b7bcf9ce6716d6959e07d193c716240b27229ac1e0fce3d91f4b8a2506906e3eca6203e433a5a928e2bcfad43fee2f2922a01eebf2b1701db7f9548975d42f447d0b59d257ddcb69eaa99f4f8c0571945062c7ff40d5ee2833ea81f834e26b7a48f7d399af35a961c018b236ad234dc71b6210e4715adbebcb506c45ac95cd577118b4180a3a087f95626a2329fc51fadb3d3407c2ecf7d3d2e1c9376704ceb69206c2ec1d11882e7da031ba1dd2a93ac3e2d5c6770f25be2fddde439dc43fd071afc2f3fca65e551dc7438ae7c6d1c306100000001ef574c2f40fbc2d655ad35f4529380e69a541695c712e317ae2da2a4a0892ad5", + "scope": "Init recursive KZG proof with shared opening reductions, lazy byte nibbles and 20-bit range limbs; admitted Ignition setup; classic GLV and two separate pairing checks", + "proof": "0x0a8d04287f83f02b9b207b4019c99ea72e6b4d8dea38d9d27d7dc7da9b50285a13d2e2e9ab4febc135e31cb35f70bcb805cd097af96b10bf8db20160a813686d0106a3475c4c60e5466b1cab0cfb9d725d7424a2cd8e09c1f6a3cf8035713e22203151923c43eb6797b223dfb96606f378cd678abae105d67c312621a45d1417005b822f429d016d540fad509c4b01cdbb7d93446ee9b43b95e50394303aa52a22ee7bee74613277b15abf423ff94d7613def1683780045b7593677f55d2b16b0a483499d255949e63d973514117afb52b18d38bd939cd4e7b4b68ac24ba4d2f0f83044ac903d628ef4074dd7f68c04f80a99d5492f708d3b8897af968cd1cd818724456db485c59d46e7dd7648c52651953fa036441f9d16c3463c03ca86d8e2921bd39512f811c7c195af012c04aeeaf1f6fb88b6d4a3640b5ad3c42c6b5ad23d5ce2f369aa7e338276d43903d998f8b4fd73a6a070e4bb03f20191288eec624613cc109e419779787722d7ebc2ef9d490636c13fe85aec94782797fc0ee7513870a6e481e9c9fa657db3832dfff89c2c61ba6a7187f460235f9a797fe6846038efd5004a342e6e71cbbce71044137725f5868a31141b82fdd96edf2826a3119b06576dc7a7562d9cb2dbfc6492b96f5829f6612b9e28768f8a7c735a32bce1c7bab29ba7c20efc935126f3d7afe98650ecc7dc5abeb0633da7698cd5c2cae24bc00b336b6d770a4777d315f345be33161a39952ac0f7eeda2fe950e061e1412346a821b8ed09dbd1406fb754e740e3ee539a35ea38d2cf2c1180db40927971c6d5734bb90fc1d51ec060883388511e1f1fbefa2d06c9a0030aa1eda1159b3061cbbd70fba46b931d683cda96dc29b6f8afb2122a97717827d24edbecfd9532b8b7d97910ccde2c9650387175327ffefa6f0b7c15edc02e86555ee499dafd7091576af3505b56d6e222693319bb6c080aee92b6e7796bb48cd9f8145af65fa2d52a8f9a25d6782055636c29e30790d307c8fba283bc35309af1777e12f429d1c0f7528697eebf4f78eda0e5f581f14316493d03029800a9ac151468417afa707ba1af369625a9c27f08ca3236ca1f608fd28664cd755d3b27290b55da368ac166f095fb5310148afecb2457cf1eb21cc3984a307d4a15d60900145bdf49a200d04df6c4c42d604d721774c117cb04574be16f3f423bb5c89875ac615432ac4", + "nativeProof": "0x8a8d04287f83f02b9b207b4019c99ea72e6b4d8dea38d9d27d7dc7da9b50285ac106a3475c4c60e5466b1cab0cfb9d725d7424a2cd8e09c1f6a3cf8035713e22c05b822f429d016d540fad509c4b01cdbb7d93446ee9b43b95e50394303aa52ad2346a821b8ed09dbd1406fb754e740e3ee539a35ea38d2cf2c1180db40927978a483499d255949e63d973514117afb52b18d38bd939cd4e7b4b68ac24ba4d2fd8724456db485c59d46e7dd7648c52651953fa036441f9d16c3463c03ca86d8ee3d5ce2f369aa7e338276d43903d998f8b4fd73a6a070e4bb03f20191288eec6ab8b7d97910ccde2c9650387175327ffefa6f0b7c15edc02e86555ee499dafd70000000722bbd152d1b1e1091e061f7171e1286c4ba8fea5caf8bc15ecde56aac520550c13870a6e481e9c9fa657db3832dfff89c2c61ba6a7187f460235f9a797fe6846038efd5004a342e6e71cbbce71044137725f5868a31141b82fdd96edf2826a3119b06576dc7a7562d9cb2dbfc6492b96f5829f6612b9e28768f8a7c735a32bce1c7bab29ba7c20efc935126f3d7afe98650ecc7dc5abeb0633da7698cd5c2cae24bc00b336b6d770a4777d315f345be33161a39952ac0f7eeda2fe950e061e1407ba1af369625a9c27f08ca3236ca1f608fd28664cd755d3b27290b55da368aced52a8f9a25d6782055636c29e30790d307c8fba283bc35309af1777e12f429d061cbbd70fba46b931d683cda96dc29b6f8afb2122a97717827d24edbecfd95300000001966f095fb5310148afecb2457cf1eb21cc3984a307d4a15d60900145bdf49a20", + "envelope": "0x4958424e0000000232b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac8a8d04287f83f02b9b207b4019c99ea72e6b4d8dea38d9d27d7dc7da9b50285ac106a3475c4c60e5466b1cab0cfb9d725d7424a2cd8e09c1f6a3cf8035713e22c05b822f429d016d540fad509c4b01cdbb7d93446ee9b43b95e50394303aa52ad2346a821b8ed09dbd1406fb754e740e3ee539a35ea38d2cf2c1180db40927978a483499d255949e63d973514117afb52b18d38bd939cd4e7b4b68ac24ba4d2fd8724456db485c59d46e7dd7648c52651953fa036441f9d16c3463c03ca86d8ee3d5ce2f369aa7e338276d43903d998f8b4fd73a6a070e4bb03f20191288eec6ab8b7d97910ccde2c9650387175327ffefa6f0b7c15edc02e86555ee499dafd70000000722bbd152d1b1e1091e061f7171e1286c4ba8fea5caf8bc15ecde56aac520550c13870a6e481e9c9fa657db3832dfff89c2c61ba6a7187f460235f9a797fe6846038efd5004a342e6e71cbbce71044137725f5868a31141b82fdd96edf2826a3119b06576dc7a7562d9cb2dbfc6492b96f5829f6612b9e28768f8a7c735a32bce1c7bab29ba7c20efc935126f3d7afe98650ecc7dc5abeb0633da7698cd5c2cae24bc00b336b6d770a4777d315f345be33161a39952ac0f7eeda2fe950e061e1407ba1af369625a9c27f08ca3236ca1f608fd28664cd755d3b27290b55da368aced52a8f9a25d6782055636c29e30790d307c8fba283bc35309af1777e12f429d061cbbd70fba46b931d683cda96dc29b6f8afb2122a97717827d24edbecfd95300000001966f095fb5310148afecb2457cf1eb21cc3984a307d4a15d60900145bdf49a20", "claim": "0xe50401f8912c57f41e1b38ebd63e53cf05b0775cff02b42185084a014c07b18d0e2b0c00", "claimDigest": "0xc019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", - "verifyingKeySHA256": "4f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff368", + "verifyingKeySHA256": "32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac", "domain": 16777216, "constraints": 14689440, "publicInputs": 2, "commitments": 1, - "innerProfileBLAKE3": "a4cf1b3b2489af6c939847d5060452bccb1f87ed8cff0fb90658e477549eafcd" + "innerProfileBLAKE3": "1c613f8cfb94399f75687735cbb07e4e91bd4d23e7a58c35388fdfafc67f50e6" } diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 44911eca2..f86a01932 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -767,6 +767,43 @@ mod tests { Ok((admission, vk, packet)) } + #[cfg(feature = "bn254")] + #[test] + #[cfg(feature = "plonk")] + fn plonk_recursive_export_includes_anchor_and_deferred_publics() + -> storage::Result<()> { + let (_, vk, packet) = ignition_vk_fixture()?; + let expected = init_claim::INIT_PUBLIC_WORDS; + let directory = std::env::temp_dir() + .join(format!("plonk-recursive-claims-{}", std::process::id())); + fs::create_dir(&directory)?; + plonk_export::write_export( + &vk, &packet, &expected, &directory, "witness", true, + )?; + let exported: serde_json::Value = + serde_json::from_slice(&fs::read(directory.join("vk.json"))?)?; + let claims = exported["claims"].as_array().unwrap(); + assert_eq!(claims.len(), 23); + assert_eq!(exported["publics"].as_array().unwrap().len(), 23); + for (index, claim) in claims.iter().enumerate() { + assert_eq!(claim[3]["public"], index); + } + assert_eq!(exported["publics"][22]["deferredHalf"], 3); + let witness: serde_json::Value = + serde_json::from_slice(&fs::read(directory.join("witness.json"))?)?; + assert_eq!(witness["deferred"].as_array().unwrap().len(), 2); + let mut wrong = expected; + wrong[10] ^= 1; + assert!( + plonk_export::write_export( + &vk, &packet, &wrong, &directory, "wrong", true, + ) + .is_err() + ); + fs::remove_dir_all(directory)?; + Ok(()) + } + #[cfg(feature = "bn254")] #[test] fn ignition_staging_rejects_other_inner_keys_before_creating_files() diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index 932217183..8f9ec0011 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -129,13 +129,13 @@ pub(super) fn write_export( if check_pairings(deferred_bytes, &vk.keys()?).is_ok() != valid_pairing { return Err("unexpected deferred pairing result".into()); } - // The recursive statement: 23 rows `[94, 1, i, public_i]` whose public - // scalars are the constant prefix, the claim digest and the deferred - // pairing operands. - if claims.len() != RECURSIVE_PUBLIC_VALUES { + // The zero-anchor row precedes the public prefix, claim digest and + // deferred pairing operands in the recursive statement. + let claim_count = RECURSIVE_PUBLIC_VALUES + 1; + if claims.len() != claim_count { return Err("recursive packet claim count".into()); } - let slots = (0..RECURSIVE_PUBLIC_VALUES) + let slots = (0..claim_count) .map(|i| { vec![ ir::ClaimSlot::Constant(Scalar::from_u8(94)), diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md index cb970fdb1..a1ddae7fb 100644 --- a/docs/compression-stage-collapse.md +++ b/docs/compression-stage-collapse.md @@ -559,6 +559,25 @@ trace is now `2^21` rows because of the wider range table. All FRI, first-stage and recursive gadget tests pass against the patched dependency. +## Refreshed terminal PLONK proof and Solidity verification + +The optimized Ignition recursive packet now has a regenerated PLONK key and +GPU proof. The wrapper remains at 14,689,440 constraints and domain `2^24`, +with both pairing checks retained. One fresh prover process took 33.872 s, +including 11.557 s of core proving, on the same 32-CPU GPU host. One-time +key preparation took 495.843 s and is excluded from proving time. + +An independent CPU helper verified the proof and rejected a changed claim +and corrupted proof. The refreshed Solidity verifier and claim-digest adapter +passed all 47 Anvil checks; the adapter transaction used 320,091 gas. Proof +sizes remain 584 bytes native, 624 bytes enveloped and 864 bytes in Solidity +encoding. The PLONK verifying-key SHA-256 is +`32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac`. + +Combining the separately measured 120.9 s KZG stages +with this 33.9 s wrapper gives about 155 s from saved FRI proof to PLONK; +this is a sum of stage measurements, not a new end-to-end run. + ## Upstream Plonky3 since the pin The pin is 31 August 2026; `main` is 18 October 2026, 362 commits later. From 2bf1b9385a56403259c4f5379f2a09d944319ee2 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:55:52 +0000 Subject: [PATCH 12/31] perf(plonkish): pin multi-stark with lazy byte nibbles Pin multi-stark to d8c950be (sb/kzg-core), which makes ByteValue nibbles optional: byte selects in the compact BLAKE3 lowering produce value-only bytes, and the paths that read nibbles materialize them on demand with the same constraints. Only the multi-stark source entry changes in Cargo.lock. Recorded results (docs/compression-stage-collapse.md): the first KZG stage drops from 77.07M to 60.59M used rows and from five to four 2^24 computation partitions, with 57.23M to 40.75M translated gates at 100 queries. On a warm GPU run the first stage went from 93.4 s to 80.6 s and its peak RSS from 114.4 GiB to 99.3 GiB. --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/compression-stage-collapse.md | 19 +++++++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42b3c7242..206dfbddd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "multi-stark" version = "0.1.0" -source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=416be66cf6f128a41be085b3cd129569c45e8c3e#416be66cf6f128a41be085b3cd129569c45e8c3e" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=d8c950be3141f77253992348cc8278e32476abde#d8c950be3141f77253992348cc8278e32476abde" dependencies = [ "bincode", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 412c37d0d..31bc7e809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" memmap2 = "0.9" # Bundle v3.5.1 to fix large-arena failures (microsoft/mimalloc#1309). mimalloc = { git = "https://github.com/argumentcomputer/mimalloc_rust.git", rev = "616cc1f130d9ea2a1111a46b4e98c140e078f882", default-features = false } -multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "416be66cf6f128a41be085b3cd129569c45e8c3e" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "d8c950be3141f77253992348cc8278e32476abde" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md index a1ddae7fb..c50a6fc96 100644 --- a/docs/compression-stage-collapse.md +++ b/docs/compression-stage-collapse.md @@ -32,7 +32,7 @@ otherwise (October 2026, one RTX PRO 6000 Blackwell, 48 logical CPUs). parameters; the warm recursive stage stays at about 41 s and verifies the patched proof. The warm Ignition pipeline from saved FRI proof to recursive packet is 134 s against the README's 181 s, and 121 s with the - lazy-nibble multi-stark patch measured below. The 40-query + lazy-nibble multi-stark change measured below. The 40-query configuration lowers the proven bound and is therefore excluded; its sizing results are diagnostic only. Fewer rows do not by themselves make the first proof wrappable: KZG commits per column, and the @@ -521,12 +521,12 @@ after the opening reduction fix. ### Lazy byte nibbles in multi-stark -A candidate multi-stark change on top of the pinned revision makes -`ByteValue`'s nibble decomposition optional: `select` +multi-stark `d8c950be` (branch `sb/kzg-core`, on top of `416be66`) +makes `ByteValue`'s nibble decomposition optional: `select` produces a value-only byte, and the generic paths that read nibbles (XOR, word packing, comparisons) materialize them on demand with the same nibble-hint-and-lookup constraints they always had. Measured with the sizing -example against the patched dependency: +example against the same change on the previous `sb/kzg-core` base: | First stage, 100 queries | Before | Lazy nibbles | | --- | ---: | ---: | @@ -536,9 +536,8 @@ example against the patched dependency: | Used rows | 77,070,160 | 60,590,416 | | Computation partitions at `2^24` | 5 | **4** | -The patch is not applied in this repository; it needs an upstream -multi-stark change and a pin bump. A CUDA worker built against the patched -clone, with the 20-bit range limbs, ran the same warm Ignition sequence: +This repository pins that revision. A CUDA worker built against the +change, with the 20-bit range limbs, ran the same warm Ignition sequence: | Stage, warm, Ignition | README reference | Reduction patch | Plus lazy nibbles | | --- | ---: | ---: | ---: | @@ -556,7 +555,7 @@ setup; the recursive packet is unchanged at 1,669 B. Warm first-stage spans: translate 16.6 s, lower 10.7 s, prepare 20.3 s, prove 24.1 s. The recursion still pads to `2^26`, so its time did not move; its merged table trace is now `2^21` rows because of the wider range table. All FRI, -first-stage and recursive gadget tests pass against the patched dependency. +first-stage and recursive gadget tests passed against the change. ## Refreshed terminal PLONK proof and Solidity verification @@ -630,5 +629,5 @@ Relevant to this pipeline: Sources: `crates/proof-compression/README.md`, `docs/proof-benchmark-comparison.md`, the pipeline, recursive verifier and -wrapper sources, multi-stark `416be66`, and Plonky3 `3152b14a` and `main` at -`4615dcd`. +wrapper sources, multi-stark `416be66` and `d8c950be`, and Plonky3 +`3152b14a` and `main` at `4615dcd`. From b9f6b82b381b7da0d95271b6f4fa5afebfd69771 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:40:20 +0000 Subject: [PATCH 13/31] fix(proof-compression): repair the ported FRI test and isolate the tracing test - Raise the FRI blowup in fri_memory_reduction_preserves_the_dense_proof to 2, matching the neighbouring stage tests: the lowering now produces degree-4 constraints, which a blowup of 1 cannot support. - Run the partitioned-proving tracing test on a private rayon pool. While blocked inside the examined span, a thread could steal rayon jobs from concurrently running tests and count their spans as its own children. - Drop a redundant std::io qualification in the SRS cache constructor. --- crates/proof-compression/Cargo.toml | 3 +++ crates/proof-compression/src/kzg/srs.rs | 2 +- crates/proof-compression/src/pipeline/prover.rs | 10 ++++++++-- crates/proof-compression/src/pipeline/stages/tests.rs | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index bd78645d1..f3339f6b3 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -41,6 +41,9 @@ p3-maybe-rayon = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89 p3-symmetric = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +[dev-dependencies] +rayon = { workspace = true } + # The CUDA transform engine for the KZG backend. The fork supplies column # batching, borrowed streams, the device-selecting synchronization and the # MSM profiling hooks, and a runtime compatible with the Lean executable's diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs index 242cc4b78..a5d3a443f 100644 --- a/crates/proof-compression/src/kzg/srs.rs +++ b/crates/proof-compression/src/kzg/srs.rs @@ -81,7 +81,7 @@ impl Srs { public_max_degree: usize, seed: &[u8], directory: impl AsRef, - ) -> std::io::Result { + ) -> io::Result { super::srs_cache::cached_in_directory( max_len, public_max_degree, diff --git a/crates/proof-compression/src/pipeline/prover.rs b/crates/proof-compression/src/pipeline/prover.rs index 3774dd327..590edbc4a 100644 --- a/crates/proof-compression/src/pipeline/prover.rs +++ b/crates/proof-compression/src/pipeline/prover.rs @@ -378,9 +378,15 @@ mod tests { tracing_texray::TeXRayLayer::new().streaming().writer(output.clone()), ); tracing::subscriber::set_global_default(subscriber)?; - let partitions = + // A thread blocked inside the examined span can steal rayon jobs from + // concurrently running tests, whose spans would then be counted as its + // children. A private pool only runs this fixture's jobs. + let pool = rayon::ThreadPoolBuilder::new().build()?; + let partitions = pool.install(|| { tracing_texray::examine(tracing::info_span!("compression/test")) - .in_scope(parity_fixture)?; + .in_scope(parity_fixture) + .map_err(|e| e.to_string()) + })?; let text = String::from_utf8(output.0.lock().unwrap().clone())?; for name in ["compression/trace", "compression/fixed", "compression/main_commit"] diff --git a/crates/proof-compression/src/pipeline/stages/tests.rs b/crates/proof-compression/src/pipeline/stages/tests.rs index 678f63e2b..a72d648f5 100644 --- a/crates/proof-compression/src/pipeline/stages/tests.rs +++ b/crates/proof-compression/src/pipeline/stages/tests.rs @@ -101,7 +101,7 @@ fn fri_stage_preserves_publics_through_foreign_translation() -> Result<()> { fn fri_memory_reduction_preserves_the_dense_proof() -> Result<()> { let config = || { GoldilocksBlake3Config::new( - CommitmentParameters { log_blowup: 1, cap_height: 0 }, + CommitmentParameters { log_blowup: 2, cap_height: 0 }, FriParameters { log_final_poly_len: 0, max_log_arity: 1, From 5f52c06a00ad7ab302b47e8844e7bc12f892060c Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:40:20 +0000 Subject: [PATCH 14/31] style(proof-compression): satisfy clippy in the CUDA tests Replace truncating usize-to-u32 casts with checked conversions, borrow the lookup values as a one-element slice instead of cloning them, and gate the full-scale primitive benchmark on the `parallel` feature instead of asserting a constant. --- crates/proof-compression/src/kzg/cuda/tests.rs | 12 +++++++++--- .../src/kzg/cuda/tests/constraints.rs | 2 +- .../src/kzg/cuda/tests/plonk_scale.rs | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/proof-compression/src/kzg/cuda/tests.rs b/crates/proof-compression/src/kzg/cuda/tests.rs index 91e8e52ea..d39b1ec1a 100644 --- a/crates/proof-compression/src/kzg/cuda/tests.rs +++ b/crates/proof-compression/src/kzg/cuda/tests.rs @@ -24,7 +24,8 @@ fn native_ntt_parameters_match_scalar_roots_without_large_allocations() { use multi_stark::traits::TwoAdicField; for lg in 0..=MAX_FFT_LOG { let mut output = [[0; 4]; 3]; - let status = unsafe { ix_kzg_ntt_parameters(lg as u32, &mut output) }; + let status = + unsafe { ix_kzg_ntt_parameters(u32::try_from(lg).unwrap(), &mut output) }; assert_eq!(status, 0); let [root, inverse, scale] = output.map(|limbs| Fr::new_unchecked(BigInt(limbs))); @@ -34,7 +35,12 @@ fn native_ntt_parameters_match_scalar_roots_without_large_allocations() { } let mut output = [[0; 4]; 3]; assert_eq!( - unsafe { ix_kzg_ntt_parameters(MAX_FFT_LOG as u32 + 1, &mut output) }, + unsafe { + ix_kzg_ntt_parameters( + u32::try_from(MAX_FFT_LOG).unwrap() + 1, + &mut output, + ) + }, 1 ); } @@ -138,7 +144,7 @@ fn oversized_fft_domains_fail_before_allocation_or_device_access() { let original = value; const INVALID_VALUE: i32 = 1; // Invalid domains must fail before the invalid ordinal is resolved. - for lg in [unsupported as u32, u32::MAX] { + for lg in [u32::try_from(unsupported).unwrap(), u32::MAX] { let code = unsafe { ix_kzg_fft(-1, &mut value, lg, false, &shift) }; assert_eq!(code, INVALID_VALUE); let job = FftJob { diff --git a/crates/proof-compression/src/kzg/cuda/tests/constraints.rs b/crates/proof-compression/src/kzg/cuda/tests/constraints.rs index fcf2f8faa..41f6d2f68 100644 --- a/crates/proof-compression/src/kzg/cuda/tests/constraints.rs +++ b/crates/proof-compression/src/kzg/cuda/tests/constraints.rs @@ -170,7 +170,7 @@ fn compare_paths(log_size: usize) { 0..rows, ); let (traces, totals) = LookupValues::stage_2_traces( - &[lookup_values.clone()], + std::slice::from_ref(&lookup_values), &[circuit.lookup_group_size], beta, &gamma, diff --git a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs index ba22c5864..181f75951 100644 --- a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs +++ b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs @@ -536,10 +536,11 @@ fn fft_benchmark(report: &mut Report, log_size: usize, repeats: usize) { } } +// The CPU references are only representative with the `parallel` feature. +#[cfg(feature = "parallel")] #[test] #[ignore = "full-scale CUDA benchmark; set IX_PLONK_PRIMITIVE_OP and IX_PLONK_PRIMITIVE_REPORT and run this test alone"] fn bn254_plonk_scale_primitives() { - assert!(cfg!(feature = "parallel"), "build with parallel CPU references"); let operation = std::env::var("IX_PLONK_PRIMITIVE_OP") .expect("explicit primitive operation required"); assert!(matches!( From f015e89a479318b3f19db17ec6578a7c26266c1c Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:18:45 -0400 Subject: [PATCH 15/31] refactor(compression): remove BLS12-381 support Make BN254 unconditional for KZG and terminal PLONK. Remove Filecoin setup, BLS verifier catalogs, legacy contracts, fixtures, and curve build options. Rename the PLONK helper and update Lake, Nix, CI, and current documentation. Retain shared gnark/BLST support and fail-closed rejection of legacy formats. Validation: focused KZG and pipeline tests, BN254 tests without default features, CPU PLONK helper tests, all-target Clippy, Rust/Go formatting, Lean typechecks, seven Lake configurations, and Nix helper evaluation. GPU and full Init proving were not rerun. Rust checks used the separately updated multi-stark revision fd22b637 in the working tree. --- .github/workflows/ci.yml | 34 +- Cargo.lock | 13 - Ix/Cli/CompressCmd.lean | 2 +- Ix/Cli/Compression.lean | 6 +- Tests/CompressionCli.lean | 113 +- contracts/plonk/README.md | 11 +- contracts/plonk/foundry.toml | 2 +- contracts/plonk/test/IxPlonkVerifier.t.sol | 8 +- crates/ffi/Cargo.toml | 4 +- crates/proof-compression/Cargo.toml | 18 +- crates/proof-compression/README.md | 891 +-- crates/proof-compression/build.rs | 15 +- crates/proof-compression/cuda/kzg_curve.cuh | 15 - .../examples/kzg_filecoin_import.rs | 69 - .../examples/recursive_size.rs | 2 +- crates/proof-compression/src/kzg/compact.rs | 3 - crates/proof-compression/src/kzg/config.rs | 8 +- crates/proof-compression/src/kzg/cuda.rs | 4 +- crates/proof-compression/src/kzg/cuda/abi.rs | 3 - .../proof-compression/src/kzg/cuda/tests.rs | 1 - crates/proof-compression/src/kzg/curve.rs | 17 +- crates/proof-compression/src/kzg/field.rs | 3 - crates/proof-compression/src/kzg/pcs.rs | 6 +- crates/proof-compression/src/kzg/srs.rs | 32 +- .../proof-compression/src/kzg/srs/filecoin.rs | 1008 ---- crates/proof-compression/src/kzg/srs_cache.rs | 10 +- .../proof-compression/src/kzg/transcript.rs | 44 - .../src/kzg/verifier_params.rs | 9 +- crates/proof-compression/src/lib.rs | 4 - .../proof-compression/src/pipeline/driver.rs | 11 - .../proof-compression/src/pipeline/first.rs | 5 +- .../src/pipeline/fixed_cache.rs | 3 - crates/proof-compression/src/pipeline/mod.rs | 18 +- .../proof-compression/src/pipeline/plonk.rs | 155 +- crates/proof-compression/src/pipeline/root.rs | 2 +- .../proof-compression/src/pipeline/setup.rs | 267 +- .../src/pipeline/stages/codec.rs | 11 +- .../src/pipeline/stages/tests.rs | 6 +- .../src/pipeline/stages/verification.rs | 4 - .../src/pipeline/stages/wrap.rs | 6 +- .../src/pipeline/verifier.rs | 242 +- .../src/pipeline/wrap/outer.rs | 121 +- .../src/pipeline/wrap/outer/plonk_export.rs | 14 +- .../src/pipeline/wrap/outer/relation.rs | 28 +- .../pipeline/wrap/outer/relation/codegen.rs | 2 - .../src/recursive/native_curve.rs | 40 +- .../src/recursive/native_field.rs | 2 +- .../src/recursive/native_transcript.rs | 17 +- .../src/recursive/native_verifier/plan.rs | 3 - .../src/recursive/pairing.rs | 16 +- crates/proof-compression/tests/bn254_kzg.rs | 2 - .../aggregate-kzg/development-v5-packet.bin | Bin 2181 -> 0 bytes .../aggregate-kzg/filecoin-v5-packet.bin | Bin 2181 -> 0 bytes .../tests/fixtures/filecoin-prefix-2p8.bin | Bin 25342 -> 0 bytes .../filecoin-claim.bin => init-fri/claim.bin} | Bin .../tests/kzg_transcripts.rs | 52 - .../proof-compression/tests/worker_tracing.rs | 3 +- .../aggregate-development-v5.bin | Bin 2008 -> 0 bytes .../aggregate-development-v5.json | 47 - .../aggregate-development-v6.bin | Bin 2008 -> 0 bytes .../aggregate-development-v6.json | 12 - .../verifying-keys/aggregate-filecoin-v5.bin | Bin 2008 -> 0 bytes .../verifying-keys/aggregate-filecoin-v5.json | 47 - .../verifying-keys/aggregate-filecoin-v6.bin | Bin 2008 -> 0 bytes .../verifying-keys/aggregate-filecoin-v6.json | 12 - docs/compression-stage-collapse.md | 2 +- flake.nix | 24 +- lakefile.lean | 39 +- tests/check_compression_build.py | 18 +- tests/compression_build_config.lean | 6 +- tests/kzg_backend_parity.py | 15 +- tools/bls-plonk-wrapper/README.md | 356 -- tools/bls-plonk-wrapper/backend_test.go | 154 - tools/bls-plonk-wrapper/circuit_test.go | 109 - tools/bls-plonk-wrapper/curve_bls.go | 98 - tools/bls-plonk-wrapper/parameters_test.go | 90 - .../outer/fixture-bad-pairing-diagnostic.json | 21 - .../outer/fixture-bad-pairing-statement.json | 20 - .../testdata/outer/fixture-bad-pairing.bin | Bin 2181 -> 0 bytes .../testdata/outer/fixture-bad-pairing.json | 114 - .../testdata/outer/fixture-diagnostic.json | 21 - .../fixture-other-statement-diagnostic.json | 21 - .../fixture-other-statement-statement.json | 20 - .../outer/fixture-other-statement.bin | Bin 2181 -> 0 bytes .../outer/fixture-other-statement.json | 114 - .../testdata/outer/fixture-statement.json | 20 - .../testdata/outer/fixture.bin | Bin 2181 -> 0 bytes .../testdata/outer/fixture.json | 114 - tools/bls-plonk-wrapper/testdata/outer/vk.bin | Bin 1992 -> 0 bytes .../bls-plonk-wrapper/testdata/outer/vk.json | 4878 ----------------- tools/bls-plonk-wrapper/testdata/srs.bin | Bin 12856 -> 0 bytes tools/bls-plonk-wrapper/testdata/srs.json | 8 - tools/plonk-wrapper/README.md | 230 + .../artifacts.go | 0 .../backend.go | 0 .../backend_bn254_test.go | 10 +- .../backend_cpu.go | 2 +- .../backend_cuda_bn254.go | 4 +- .../backend_cuda_bn254_test.go | 4 +- .../backend_no_cuda_test.go | 2 +- .../backend_selection_test.go | 0 .../blake3.go | 0 .../blake3_test.go | 0 .../build_cuda.py | 0 .../circuit.go | 0 .../circuit_bn254_test.go | 46 +- .../cuda_init_bn254_test.go | 4 +- .../cuda_msm_benchmark_bn254_test.go | 4 +- .../cuda_msm_replay_bn254_test.go | 4 +- .../cuda_profile_bn254_test.go | 2 - .../curve_bn254.go | 2 - .../go.mod | 2 +- .../go.sum | 0 .../internal/cuda/abi.go | 2 +- .../internal/cuda/accelerator.go | 2 +- .../internal/cuda/accelerator_test.go | 2 +- .../internal/cuda/masking_test.go | 2 +- .../internal/cuda/split_boundaries_test.go | 2 +- .../internal/cuda/structured_test.go | 2 +- .../internal/cuda/trace.go | 2 +- .../lagrange_import_bn254.go | 2 - .../lagrange_import_bn254_test.go | 2 - .../main.go | 0 .../msm_capture_bn254_test.go | 2 - .../optimization_test.go | 6 +- .../pairing_batch.go | 0 .../pairing_batch_bn254_test.go | 2 - .../parameters.go | 0 .../parameters_bn254_test.go | 2 - .../patches/gnark-cuda-generator-v1.patch | 0 .../patches/gnark-cuda-v1.json | 0 .../patches/gnark-cuda-v1.patch | 0 .../profile.go | 8 +- .../sizing_test.go | 6 +- .../testdata/bn254/provenance.json | 0 .../testdata/bn254/srs.bin | Bin .../testdata/bn254/srs.json | 0 .../testdata/bn254/vk.json | 0 .../testdata/bn254/witness-diagnostic.json | 0 .../testdata/bn254/witness.json | 0 .../verifier_bn254_test.go | 2 - 141 files changed, 501 insertions(+), 9626 deletions(-) delete mode 100644 crates/proof-compression/examples/kzg_filecoin_import.rs delete mode 100644 crates/proof-compression/src/kzg/srs/filecoin.rs delete mode 100644 crates/proof-compression/tests/fixtures/aggregate-kzg/development-v5-packet.bin delete mode 100644 crates/proof-compression/tests/fixtures/aggregate-kzg/filecoin-v5-packet.bin delete mode 100644 crates/proof-compression/tests/fixtures/filecoin-prefix-2p8.bin rename crates/proof-compression/tests/fixtures/{aggregate-kzg/filecoin-claim.bin => init-fri/claim.bin} (100%) delete mode 100644 crates/proof-compression/verifying-keys/aggregate-development-v5.bin delete mode 100644 crates/proof-compression/verifying-keys/aggregate-development-v5.json delete mode 100644 crates/proof-compression/verifying-keys/aggregate-development-v6.bin delete mode 100644 crates/proof-compression/verifying-keys/aggregate-development-v6.json delete mode 100644 crates/proof-compression/verifying-keys/aggregate-filecoin-v5.bin delete mode 100644 crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json delete mode 100644 crates/proof-compression/verifying-keys/aggregate-filecoin-v6.bin delete mode 100644 crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json delete mode 100644 tools/bls-plonk-wrapper/README.md delete mode 100644 tools/bls-plonk-wrapper/backend_test.go delete mode 100644 tools/bls-plonk-wrapper/circuit_test.go delete mode 100644 tools/bls-plonk-wrapper/curve_bls.go delete mode 100644 tools/bls-plonk-wrapper/parameters_test.go delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing.bin delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-diagnostic.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-other-statement-diagnostic.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-other-statement-statement.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-other-statement.bin delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-other-statement.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture-statement.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture.bin delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/fixture.json delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/vk.bin delete mode 100644 tools/bls-plonk-wrapper/testdata/outer/vk.json delete mode 100644 tools/bls-plonk-wrapper/testdata/srs.bin delete mode 100644 tools/bls-plonk-wrapper/testdata/srs.json create mode 100644 tools/plonk-wrapper/README.md rename tools/{bls-plonk-wrapper => plonk-wrapper}/artifacts.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend_bn254_test.go (95%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend_cpu.go (95%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend_cuda_bn254.go (96%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend_cuda_bn254_test.go (96%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend_no_cuda_test.go (92%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/backend_selection_test.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/blake3.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/blake3_test.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/build_cuda.py (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/circuit.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/circuit_bn254_test.go (81%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/cuda_init_bn254_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/cuda_msm_benchmark_bn254_test.go (98%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/cuda_msm_replay_bn254_test.go (98%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/cuda_profile_bn254_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/curve_bn254.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/go.mod (93%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/go.sum (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/abi.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/accelerator.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/accelerator_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/masking_test.go (98%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/split_boundaries_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/structured_test.go (98%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/internal/cuda/trace.go (98%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/lagrange_import_bn254.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/lagrange_import_bn254_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/main.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/msm_capture_bn254_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/optimization_test.go (94%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/pairing_batch.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/pairing_batch_bn254_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/parameters.go (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/parameters_bn254_test.go (99%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/patches/gnark-cuda-generator-v1.patch (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/patches/gnark-cuda-v1.json (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/patches/gnark-cuda-v1.patch (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/profile.go (97%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/sizing_test.go (94%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/testdata/bn254/provenance.json (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/testdata/bn254/srs.bin (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/testdata/bn254/srs.json (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/testdata/bn254/vk.json (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/testdata/bn254/witness-diagnostic.json (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/testdata/bn254/witness.json (100%) rename tools/{bls-plonk-wrapper => plonk-wrapper}/verifier_bn254_test.go (99%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1c32c1c0..bbafb1913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,8 +44,8 @@ jobs: use-github-cache: "false" - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/bls-plonk-wrapper/go.mod - cache-dependency-path: tools/bls-plonk-wrapper/go.sum + go-version-file: tools/plonk-wrapper/go.mod + cache-dependency-path: tools/plonk-wrapper/go.sum # lean-action runs `lake build`, then `lake test`, then `lake lint`. # The build-all lint driver compiles every lib/exe target with --wfail, # so a warning in any target (exes, benchmarks, Apps) — not just the @@ -76,12 +76,8 @@ jobs: - name: Test Ix CLI if: github.event_name != 'push' run: lake test --wfail -- cli - # The default build is BN254; the bundled verifying keys and packets - # are BLS12-381, so the CLI's bundled-proof checks run in a BLS build. - - name: Test the BLS12-381 compression CLI + - name: Test the compression CLI if: github.event_name != 'push' - env: - IX_KZG_CURVE: bls12-381 run: | lake build ix compression-cli-tests lake exe compression-cli-tests @@ -99,16 +95,13 @@ jobs: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/bls-plonk-wrapper/go.mod - cache-dependency-path: tools/bls-plonk-wrapper/go.sum + go-version-file: tools/plonk-wrapper/go.mod + cache-dependency-path: tools/plonk-wrapper/go.sum - name: Test the BN254 CPU wrapper - working-directory: tools/bls-plonk-wrapper + working-directory: tools/plonk-wrapper run: go test ./... - - name: Test the BLS12-381 CPU wrapper and authenticated Filecoin adapter - working-directory: tools/bls-plonk-wrapper - run: go test -tags bls12381 ./... - name: Check Go formatting - working-directory: tools/bls-plonk-wrapper + working-directory: tools/plonk-wrapper run: test -z "$(gofmt -l .)" rust-test: @@ -152,10 +145,6 @@ jobs: run: cargo check --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile - name: Tests run: cargo nextest run --release --profile ci --workspace ${{ github.event_name == 'push' && '--no-run' || '' }} - - name: Check BLS12-381 KZG - run: cargo clippy --release --locked -p ix-proof-compression --no-default-features --features bls12-381,parallel,plonk --all-targets -- -D warnings - - name: Test BLS12-381 KZG - run: cargo nextest run --release --locked --profile ci -p ix-proof-compression --no-default-features --features bls12-381,parallel,plonk ${{ github.event_name == 'push' && '--no-run' || '' }} - name: Get Rust version if: github.event_name != 'push' run: | @@ -204,8 +193,8 @@ jobs: cache-key: cuda-13.3.1-ubuntu26 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/bls-plonk-wrapper/go.mod - cache-dependency-path: tools/bls-plonk-wrapper/go.sum + go-version-file: tools/plonk-wrapper/go.mod + cache-dependency-path: tools/plonk-wrapper/go.sum # S3 caching keeps the container independent of host bind mounts. - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -219,7 +208,6 @@ jobs: - name: Compile CUDA Rust targets run: | cargo check --release --locked --workspace --all-targets --features ix-ffi/parallel,ix-ffi/cuda,ix-proof-compression/cuda - cargo check --release --locked -p ix-proof-compression --all-targets --no-default-features --features bls12-381,parallel,plonk,cuda - name: Check CPU runtime linkage without a CUDA compiler run: IX_CUDA=0 IX_CUDA_TRACE_CODEGEN=0 NVCC=/nonexistent/nvcc lake build ix_cuda_runtime - name: Link CUDA-enabled Lean executables @@ -233,9 +221,9 @@ jobs: set -o pipefail cc -std=gnu17 -Wall -Wextra -Werror -shared -fPIC tests/cuda_link_smoke.c -ldl \ -o .lake/build/lib/cuda_link_smoke.so - CUDA_VISIBLE_DEVICES= LD_PRELOAD="$PWD/.lake/build/lib/cuda_link_smoke.so" \ + CUDA_VISIBLE_DEVICES='' LD_PRELOAD="$PWD/.lake/build/lib/cuda_link_smoke.so" \ .lake/build/bin/ix compress --help - CUDA_VISIBLE_DEVICES= .lake/build/bin/ix-compression-worker fri capabilities | \ + CUDA_VISIBLE_DEVICES='' .lake/build/bin/ix-compression-worker fri capabilities | \ python3 -c 'import json, sys; assert json.load(sys.stdin)["cuda"] is True' lints: diff --git a/Cargo.lock b/Cargo.lock index 206dfbddd..2b1443d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,18 +109,6 @@ dependencies = [ "object", ] -[[package]] -name = "ark-bls12-381" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" -dependencies = [ - "ark-ec", - "ark-ff", - "ark-serialize", - "ark-std", -] - [[package]] name = "ark-bn254" version = "0.5.0" @@ -2096,7 +2084,6 @@ name = "ix-proof-compression" version = "0.1.0" dependencies = [ "aiur", - "ark-bls12-381", "ark-bn254", "ark-ec", "ark-ff", diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index 37854a04c..7ee859f9b 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -53,7 +53,7 @@ def compressCmd : Cli.Cmd := `[Cli| "log-height" : Nat; "First KZG partition height: 24 (default) or 25." "root-vk" : String; "Trusted root verifier key; omit to use the current aggregate backend." "dev-srs"; "Use known-trapdoor development parameters; results are not production-secure." - "plonk"; "Add a terminal BLS12-381 PLONK proof using the Filecoin parameters." + "plonk"; "Add a terminal PLONK proof using the compiled curve and selected setup." ARGS: proof : String; "Address of an aggregate Ixon.Proof in the ix store." ] diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 0bed69a09..23f60324f 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -9,7 +9,7 @@ namespace Ix.Cli.Compression @[extern "rs_compression_check_claim"] opaque checkClaim (claim statement : @& ByteArray) : IO Unit -/-- The KZG curve compiled into this executable: `"bn254"` or `"bls12-381"`. -/ +/-- The KZG curve compiled into this executable: `"bn254"`. -/ @[extern "rs_compression_curve"] opaque curve : IO String @@ -26,7 +26,7 @@ opaque compress (vk proof claim statement : @& ByteArray) (dir cacheRoot : @& St /-- Verify with the verifying key compiled in under the packet's id, whose pinned aggregation program fixes the statement for `claim`, and return the setup -class: `"filecoin"`, `"ignition"` or `"development"`. Development proofs need +class: `"ignition"` or `"development"`. Development proofs need `devSrs`. Needs neither the aggregation backend nor a GPU. -/ @[extern "rs_compression_verify"] @@ -60,7 +60,7 @@ def verifyProof (addr : Address) (wrapper : Ixon.Proof) (devSrs : Bool) : IO UIn | "ignition" => IO.println s!"ok: proof {addr} verifies claim {Address.blake3 claim} under the Aztec Ignition powers of tau" | _ => - IO.println s!"ok: proof {addr} verifies claim {Address.blake3 claim} under the Filecoin powers of tau" + throw <| IO.userError s!"unsupported compression setup: {setup}" return 0 end Ix.Cli.Compression diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index 54b6c08d8..9423fbb24 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -14,20 +14,23 @@ private def putProof (home : System.FilePath) (bytes : ByteArray) : IO Address : return addr private def invoke (home : System.FilePath) (args : Array String) - (filecoinCache : Option String := none) : IO IO.Process.Output := do + (setupEnv : Array (String × Option String) := #[]) : IO IO.Process.Output := do let ix ← IO.FS.realPath ".lake/build/bin/ix" IO.Process.output { cmd := ix.toString, args, env := #[("HOME", some home.toString), - ("IX_KZG_FILECOIN_CACHE", filecoinCache), ("IX_KZG_FILECOIN_MANIFEST", none)] } + ("IX_KZG_IGNITION_CACHE", none), ("IX_KZG_IGNITION_CACHE_BLAKE3", none), + ("IX_KZG_IGNITION_ADMISSION", none), ("IX_KZG_IGNITION_ADMISSION_BLAKE3", none)] ++ setupEnv } private def rejects (home : System.FilePath) (args : Array String) (message : String) - (filecoinCache : Option String := none) : IO Unit := do - let out ← invoke home args filecoinCache + (setupEnv : Array (String × Option String) := #[]) : IO Unit := do + let out ← invoke home args setupEnv unless out.exitCode != 0 && (out.stderr.splitOn message).length > 1 do throw <| IO.userError s!"expected rejection ({message}): {args}\n{out.stdout}\n{out.stderr}" public def run : IO Unit := do + unless (← Ix.Cli.Compression.curve) == "bn254" do + throw <| IO.userError "unexpected compression curve" for tag in ["IXCMP", "IXBN"] do - unless Compression.isCompressed tag.toUTF8 do + unless Ix.Cli.Compression.isCompressed tag.toUTF8 do throw <| IO.userError s!"unrecognized compressed proof family: {tag}" let home ← IO.FS.createTempDir try @@ -50,8 +53,10 @@ public def run : IO Unit := do unless !(← (home / "run").pathExists) do throw <| IO.userError "unsupported input created output" rejects home #["compress", toString addr, "--dev-srs"] "already compressed" - let missingCache := some (home / "missing-filecoin-cache").toString - rejects home #["compress", toString addr] "does not name a file" missingCache + let cacheVar := "IX_KZG_IGNITION_CACHE" + let setupError := "incomplete BN254 Ignition configuration" + let missingCache := #[(cacheVar, some (home / "missing-setup-cache").toString)] + rejects home #["compress", toString addr] setupError missingCache rejects home #["compress", toString addr, "--dev-srs"] "already compressed" missingCache rejects home #["verify", toString addr] "unsupported compressed proof version" rejects home #["verify", toString addr, "--dev-srs"] "unsupported compressed proof version" @@ -59,94 +64,12 @@ public def run : IO Unit := do unless removed.exitCode != 0 do throw <| IO.userError "external verifier import remains available" - let fixtures : System.FilePath := "crates/proof-compression/tests/fixtures/aggregate-kzg" - let curve ← Compression.curve - if curve != "bls12-381" then - -- The bundled packets are BLS12-381; another curve's build must refuse them. - let expected ← IO.ofExcept (Ix.Claim.de (← IO.FS.readBinFile (fixtures / "filecoin-claim.bin"))) - let packet ← IO.FS.readBinFile (fixtures / "filecoin-v5-packet.bin") - let proof := ByteArray.mk #[73, 88, 67, 77, 80, 0, 0, 4] ++ packet - let blsAddr ← putProof home (Ixon.Proof.ser { claim := expected, proof }) - rejects home #["verify", toString blsAddr] "unsupported compressed proof version" - rejects home #["verify", toString blsAddr, "--dev-srs"] "unsupported compressed proof version" - IO.println s!"compression CLI ({curve}): claim binding and BLS12-381 packet rejection passed" - return - let fixtures : System.FilePath := "crates/proof-compression/tests/fixtures/aggregate-kzg" - let expected ← IO.ofExcept (Ix.Claim.de (← IO.FS.readBinFile (fixtures / "filecoin-claim.bin"))) - let packet ← IO.FS.readBinFile (fixtures / "filecoin-v5-packet.bin") - let tag := ByteArray.mk #[73, 88, 67, 77, 80, 0, 0, 4] - let proof := tag ++ packet - let wrapped : Ixon.Proof := { claim := expected, proof } - let compressedAddr ← putProof home (Ixon.Proof.ser wrapped) - let metadata ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile - "crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json")) - let expectedAddr ← IO.ofExcept (metadata.getObjValAs? String "compressed_proof_address") - unless toString compressedAddr == expectedAddr do - throw <| IO.userError "bundled proof address differs from its metadata" - let args := #["verify", toString compressedAddr] - let out ← invoke home args - unless out.exitCode == 0 && (out.stdout.splitOn "Filecoin").length > 1 do - throw <| IO.userError s!"bundled Filecoin proof failed: {out.stdout} {out.stderr}" - let aggregateArgs := #["verify", "--aggregate", toString compressedAddr] - let aggregate ← invoke home aggregateArgs - unless aggregate.exitCode == 0 && (aggregate.stdout.splitOn "Filecoin").length > 1 do - throw <| IO.userError s!"bundled aggregate proof failed: {aggregate.stdout} {aggregate.stderr}" - unless (aggregate.stdout.splitOn "aggregate backend setup").length == 1 do - throw <| IO.userError "compressed aggregate verification constructed the native backend" - rejects home (aggregateArgs ++ #["--ixe", "Tests/Fixtures/ixon-v4/handoff/accepted.ixe"]) - "does not match expected" - unless !(← (home / ".ix/verifiers").pathExists) do - throw <| IO.userError "verification created a filesystem verifier registry" - - -- A local registry cannot replace a verifier carried by the application. - let alias := toString (Address.mk (packet.extract 0 32)) - let keys := home / ".ix/verifiers/filecoin/verifying-keys" - IO.FS.createDirAll keys - IO.FS.writeBinFile (keys / alias) "invalid registry entry".toUTF8 - let independent ← invoke home args - unless independent.exitCode == 0 do - throw <| IO.userError s!"verification read an external registry: {independent.stderr}" - IO.FS.removeDirAll (home / ".ix/verifiers") - - let badClaim ← putProof home (Ixon.Proof.ser { wrapped with claim }) - rejects home (args.set! 1 (toString badClaim)) "packet statement differs" - let unknown := proof.set! 8 ((proof.get! 8) ^^^ 1) - let unknownAddr ← putProof home (Ixon.Proof.ser { wrapped with proof := unknown }) - rejects home (args.set! 1 (toString unknownAddr)) "not bundled with this application" - for bad in [proof.set! 7 2, proof.set! (proof.size - 1) ((proof.get! (proof.size - 1)) ^^^ 1)] do - let badAddr ← putProof home (Ixon.Proof.ser { wrapped with proof := bad }) - let result ← invoke home (args.set! 1 (toString badAddr)) - unless result.exitCode != 0 do throw <| IO.userError "modified compact proof accepted" - - let devPacket ← IO.FS.readBinFile (fixtures / "development-v5-packet.bin") - let devProof := tag ++ devPacket - let devWrapped : Ixon.Proof := { claim := expected, proof := devProof } - let devAddr ← putProof home (Ixon.Proof.ser devWrapped) - let devMetadata ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile - "crates/proof-compression/verifying-keys/aggregate-development-v5.json")) - let expectedDevAddr ← IO.ofExcept (devMetadata.getObjValAs? String "compressed_proof_address") - unless toString devAddr == expectedDevAddr do - throw <| IO.userError "bundled development proof address differs from its metadata" - for aggregateFlag in [#[], #["--aggregate"]] do - let devArgs := #["verify", toString devAddr] ++ aggregateFlag - rejects home devArgs "--dev-srs" - let accepted ← invoke home (devArgs.push "--dev-srs") - unless accepted.exitCode == 0 && (accepted.stdout.splitOn "development proof").length > 1 do - throw <| IO.userError s!"development proof failed: {accepted.stdout} {accepted.stderr}" - unless (accepted.stdout.splitOn "aggregate backend setup").length == 1 do - throw <| IO.userError "development verification constructed the native backend" - let badDevClaim ← putProof home (Ixon.Proof.ser { devWrapped with claim }) - rejects home #["verify", toString badDevClaim, "--dev-srs"] "packet statement differs" - let badDevPrefix ← putProof home (Ixon.Proof.ser - { devWrapped with proof := devProof.set! 40 ((devProof.get! 40) ^^^ 1) }) - rejects home #["verify", toString badDevPrefix, "--dev-srs"] "packet statement differs" - for index in [8 + 32 + 18 * 8, devProof.size - 1] do - let bad ← putProof home (Ixon.Proof.ser - { devWrapped with proof := devProof.set! index ((devProof.get! index) ^^^ 1) }) - let result ← invoke home #["verify", toString bad, "--dev-srs"] - unless result.exitCode != 0 do - throw <| IO.userError "development opt-in accepted a modified proof" - IO.println "compression CLI: bundled proofs, claim binding and development opt-in passed" + let unknownKey := ByteArray.mk #[73, 88, 66, 78, 0, 0, 0, 1] ++ + ByteArray.mk (Array.replicate 32 0) + let unknownAddr ← putProof home (Ixon.Proof.ser { claim, proof := unknownKey }) + rejects home #["verify", toString unknownAddr] "not bundled" + rejects home #["verify", toString unknownAddr, "--dev-srs"] "not bundled" + IO.println "compression CLI: BN254 setup and proof rejection checks passed" finally IO.FS.removeDirAll home diff --git a/contracts/plonk/README.md b/contracts/plonk/README.md index 531ad331d..69be59024 100644 --- a/contracts/plonk/README.md +++ b/contracts/plonk/README.md @@ -3,7 +3,7 @@ `IxPlonkVerifier` binds a caller-supplied expected claim digest to two little-endian 128-bit public inputs and calls a fixed gnark verifier address. The gnark key fixes the complete wrapper circuit, including both inner pairing -obligations. BLS12-381 uses EIP-2537; BN254 uses Ethereum's BN254 precompiles. +obligations. BN254 uses Ethereum's BN254 precompiles. Use `PlonkVerifier.sol` and `proof.solidity.bin` emitted by the helper for the reviewed wrapper key. Deploy the generated verifier and pass its address to @@ -11,15 +11,10 @@ the adapter constructor. Applications must select the intended adapter and derive the expected digest independently of the proof. A successful call must return `true`. -The conformance tests use a tiny Filecoin-backed PLONK circuit to test the -backend, public-input packing and precompiles. They do not prove an ix claim. -Generate those fixtures explicitly, then run Foundry: +The tests use the checked-in BN254 Init verifier and proof, with acceptance, +changed-digest, malformed-proof, and deployment-size checks: ```sh -cd tools/bls-plonk-wrapper -IX_PLONK_TEST_OUTPUT=../../contracts/plonk/generated \ - go test -run TestFilecoinBackendAndProofCodec -count=1 -cd ../.. forge test --root contracts/plonk -vv ``` diff --git a/contracts/plonk/foundry.toml b/contracts/plonk/foundry.toml index 1b6f41f1b..be22e90dc 100644 --- a/contracts/plonk/foundry.toml +++ b/contracts/plonk/foundry.toml @@ -10,4 +10,4 @@ hardfork = "osaka" optimizer = true optimizer_runs = 200 via_ir = true -fs_permissions = [{ access = "read", path = "./generated" }] +fs_permissions = [{ access = "read", path = "./bn254/testdata" }] diff --git a/contracts/plonk/test/IxPlonkVerifier.t.sol b/contracts/plonk/test/IxPlonkVerifier.t.sol index 18898301e..38f1a0c3a 100644 --- a/contracts/plonk/test/IxPlonkVerifier.t.sol +++ b/contracts/plonk/test/IxPlonkVerifier.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.28; import {IxPlonkVerifier, IPlonkVerifier} from "../src/IxPlonkVerifier.sol"; -import {PlonkVerifier} from "../generated/PlonkVerifier.sol"; +import {PlonkVerifier} from "../bn254/src/PlonkVerifier.sol"; interface Vm { function projectRoot() external view returns (string memory); @@ -20,14 +20,14 @@ contract IxPlonkVerifierTest { event log_named_uint(string key, uint256 value); function setUp() public { - string memory fixture = vm.readFile(string.concat(vm.projectRoot(), "/generated/proof.json")); + string memory fixture = vm.readFile(string.concat(vm.projectRoot(), "/bn254/testdata/init.json")); proof = vm.parseJsonBytes(fixture, ".proof"); - digest = vm.parseJsonBytes32(fixture, ".digest"); + digest = vm.parseJsonBytes32(fixture, ".claimDigest"); plonk = new PlonkVerifier(); adapter = new IxPlonkVerifier(IPlonkVerifier(address(plonk))); } - function testValidFilecoinBackendProof() public { + function testValidInitProof() public { bytes memory bytes_ = proof; uint256 start = gasleft(); require(adapter.verify{gas: 2_000_000}(bytes_, digest), "valid proof rejected"); diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index a86389a9d..ff36c86b8 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -47,9 +47,7 @@ bincode = { version = "2.0.1", optional = true } serde = { version = "1.0.219", features = ["derive"], optional = true } [features] -default = ["bn254", "plonk"] -bls12-381 = ["ix-proof-compression/bls12-381"] -bn254 = ["ix-proof-compression/bn254"] +default = ["plonk"] plonk = ["ix-proof-compression/plonk"] parallel = ["aiur/parallel", "ix-proof-compression/parallel"] # The KZG prover's CUDA code is compiled into the ix-compression-worker diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index f3339f6b3..abffc7088 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -12,8 +12,7 @@ path = "src/bin/ix_compression_worker.rs" [dependencies] aiur = { workspace = true } multi-stark = { workspace = true } -ark-bls12-381 = { version = "0.5", features = ["curve"] } -ark-bn254 = { version = "0.5", features = ["curve"], optional = true } +ark-bn254 = { version = "0.5", features = ["curve"] } ark-ec = "0.5" ark-ff = { version = "0.5", features = ["asm"] } ark-poly = "0.5" @@ -54,22 +53,9 @@ rev = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8" optional = true features = ["cuda"] -[[example]] -name = "kzg_filecoin_import" -required-features = ["bls12-381"] - -[[example]] -name = "kzg_ignition_import" -required-features = ["bn254"] - [features] -# The KZG curve. BN254 is the default; BLS12-381 builds with -# `--no-default-features --features bls12-381,...`. Enabling both is a -# compile error. -bn254 = ["dep:ark-bn254"] -bls12-381 = [] plonk = [] -default = ["parallel", "bn254", "plonk"] +default = ["parallel", "plonk"] # GPU proving: multi-stark's Goldilocks backend for FRI compression and the # KZG kernels here. Needs the CUDA toolkit at build time and a visible GPU # at runtime; CPU-only builds neither compile sppark nor link CUDA. diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 100fc7ff2..9a5e5cbc2 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -1,160 +1,36 @@ -# Proof compression +# Ix proof compression -## Curve selection +The pipeline is Goldilocks/Blake3 FRI → BN254 KZG → recursive BN254 KZG, +with an optional terminal BN254 PLONK proof. FRI uses Goldilocks and its +quadratic extension with Blake3 Merkle commitments. Curve arithmetic starts +when the FRI verifier is translated into BN254 scalar-field constraints. -BN254 is the default KZG curve, for the library, the compression worker and -the `ix` CLI. The `bn254` feature selects it for scalar arithmetic, FFTs, -commitments, batched openings, BLAKE3 transcripts, compact proof encoding, -recursive verifier gadgets and the worker. BLS12-381 remains available and -is selected without the default features: +BN254 is unconditional, including builds with `--no-default-features`. +The remaining Cargo features are `parallel`, `plonk`, and `cuda`. +Lake builds the CLI, worker, and matching `ix-bn254-plonk-wrapper` together. +An explicit `IX_KZG_CURVE=bn254` is accepted; other values are rejected. ```sh -cargo test -p ix-proof-compression --test bn254_kzg -cargo test -p ix-proof-compression --no-default-features \ - --features bls12-381,parallel,solidity,plonk +lake build ix +IX_CUDA=1 lake build ix +IX_PLONK_CUDA=1 lake build ix ``` -The bundled verifying keys, Filecoin setup and Solidity KZG packet verifier -are BLS12-381; `solidity` therefore selects `bls12-381`. Enabling both curves -is a compile error. - -## BN254 backend - -BN254 supports an explicit known-trapdoor development SRS and caller-admitted -Ignition parameters. The importer, admission and pipeline passed crate tests, -real transcript authentication/import, and cold/warm Init GPU proofs with -independent CPU verification. No production ceremony pin or BN254 verifying -key is bundled yet. Filecoin environment variables cannot select a BN254 -setup. Admission of parameters and authorization of a verifying key are -separate trust decisions. -The `ix` library and its worker select the same curve through Lake's -`IX_KZG_CURVE=bn254|bls12-381` setting; BN254 is the default. The PLONK -wrapper supports both curves; the Solidity KZG packet verifier is BLS12-381 -only. Combining `bn254` with `bls12-381` or `solidity` is rejected. The -`bn254,cuda` integration passes CUDA component checks and -ordinary KZG proof-byte parity on a small fixture. Saved Init development -GPU proofs match CPU output; Ignition GPU proofs pass independent CPU -verification and produce identical bytes with cold/warm fixed preprocessing. - -BN254's scalar field permits radix-2 domains through `2^28`; the trace size -multiplied by the quotient factor must fit that limit. Compressed G1/G2 points -occupy 32/64 bytes in arkworks' canonical encoding. Proofs, checkpoints, -portable verifier parameters, transcript seeds and development setup/cache -identities distinguish the curve. Existing BLS12-381 formats retain their -identities. - -Curve selection is per build. Separate target directories, such as -`--target-dir target/bls12-381` and `--target-dir target/bn254`, keep both -executables available for comparison. The shared `kzg_transcripts`, -`kzg_sharding` and `kzg_streaming` test targets run on either curve; enable -`parallel` for both. These are correctness checks, not performance benchmarks. - -The recursive source port uses four 80-bit base-field limbs for BN254, a -257-bit biased quotient and the existing bounded carry construction. G1 -validation uses BN254's cofactor-one property after enforcing the curve -equation. Its canonical 32-byte point encoding is exposed as two 128-bit -little-endian public limbs per deferred pairing operand; BLS retains two -192-bit limbs per point. The native assignment path checks that these public -values match the constrained points and verifies the deferred pairing. - -The CPU baseline at `52c61479` records focused debug validation with Rust -1.98.1 passing 132 BN254 tests and 114 BLS regression tests using locked, -offline dependencies. Coverage includes KZG -proofs, recursive field/curve/transcript/MSM constraints, malformed witnesses, -deferred pairings, verifying key authentication, worker selection and existing BLS -catalog fixtures. BN254 also passed verifier reuse, FRI-to-KZG stage checks, -saved Init FRI input verification and the small root-stage integration tests. -Clippy with `-D warnings` passed for both configurations, including the sizing -example. These baseline checks excluded ignored proving/benchmark tests and -larger KZG component cases; they were not the complete repository suite. - -Init's complete BN254 development pipeline passed, including independent -envelope verification and rejection of substituted claims, statements, -verifying key digests and missing development opt-in. Mathlib passed native FRI, -sizing and source/translated witness checks; no complete mathlib KZG proof -was produced. The complete saved Init CUDA pipeline now passes with both -development and admitted Ignition parameters. Terminal BN254 PLONK is -available with admitted Ignition parameters: CPU and CUDA Init proofs pass -independent native verification and the adapted Solidity verifier. - -### Real-input measurements - -The saved mathlib aggregate FRI proof was verified and compressed through one -additional Goldilocks FRI layer using the existing CUDA worker. The run took -60.0 seconds end to end, including 50.2 seconds for outer proving and -verification, with 186.3 GiB peak host RSS. The raw input proof was 5,554,329 -bytes and the compressed FRI proof was 1,441,095 bytes. These measurements -used a 48-logical-CPU Xeon Platinum 8559C host with two RTX PRO 6000 Blackwell -Server GPUs; they measure FRI, not BN254 KZG. - -For the same saved Init FRI proof and first-stage log-height 24, the BN254 -frontend and padded main traces match the historical BLS12-381 run exactly: - -| First KZG metric | BN254 and BLS12-381 | -| --- | ---: | -| Gates | 113,913,480 | -| Lookups | 31,689,479 | -| Witness values | 132,837,040 | -| Partitions | 19 | -| Padded main-trace cells | 1,071,842,048 | -| Main scalar storage only | 31.943 GiB | - -The mathlib frontend has eight more gates and four more values than Init, -with the same lookup count. Main-trace storage excludes the frontend, fixed, -lookup and quotient columns, and scratch space. The historical BLS recursive -verifier and measured BN254 verifier compare as follows: - -| Recursive verifier metric | BLS12-381 reference | BN254 Init | -| --- | ---: | ---: | -| Gates | 85,902,077 | 44,895,563 | -| Lookups | 32,487,842 | 16,255,419 | -| Frontend rows | 118,389,942 | 61,151,005 | -| Witness values | 115,263,448 | 59,590,881 | -| Padded computation height | `2^27` | `2^26` | -| Opening MSM terms | 583 and 9 | 583 and 9 | - -BN254 has about 48% fewer recursive frontend rows and halves the padded -computation trace. Its full recursive witness and external pairing checks -passed against the real Init first KZG proof. That first proof is 32,869 bytes, -or 33,045 bytes including its packet header and statement. The final recursive -proof is 1,669 bytes, its packet is 1,909 bytes and the complete envelope is -1,917 bytes. Independent verification against the original Init claim and -root statement passed without a proving SRS or cache. Negative checks rejected -the mathlib claim, its root statement, both together, an incorrect verifying key -pin and omitted development opt-in. The verifying key pin came from the locally -generated run; it is not an independently authorized production verifying key. - -At the recursive frontend boundaries, point validation accounts for 36.47 -million BLS rows versus 0.41 million BN254 rows; BN254's cofactor-one G1 -removes the BLS subgroup-check machinery. The two opening MSMs add 62.80 -million versus 45.45 million rows. Transcript processing adds 19.12 million -versus 15.28 million rows, with shorter point encodings on BN254. These -increments include newly exposed public values and are not operation counts -or gnark PLONK constraint counts. - -The complete Init CPU run took 3,193.0 seconds (53.2 minutes) and peaked at -96.0 GiB host RSS. Fixed preprocessing was cold and both development SRS -prefixes were generated during the run. Stage spans from TeXRay are rounded: - -| Init CPU phase | First KZG | Recursive KZG | -| --- | ---: | ---: | -| Preparation and main commitments | 16.17 min | 6.44 min | -| Lookup construction | 7.04 min | 2.35 min | -| Quotient construction | 11.02 min | 4.11 min | -| Openings | 1.98 min | 1.27 min | -| Complete stage | 38.34 min | 14.87 min | - -Complete stages also include input handling, circuit/witness construction, -parameter preparation, verification and output. Nested spans and prefetched -work overlap; do not sum arbitrary child spans. Native final packet -verification took 3.96 ms inside the prover; the separate verification process -took 15.5 ms including startup and file loading. Main trace shapes and phase -logs do not provide complete FFT-call, field-operation or prover-MSM totals. - -These are structural comparisons. Historical BLS timings use CUDA, Filecoin -parameters and an older build with a Keccak final transcript; current BN254 -runs use CPU, development parameters and BLAKE3. A curve-speed comparison -requires matching those conditions. +`IX_CUDA` selects the Aiur/FRI and KZG CUDA backends. `IX_PLONK_CUDA` +independently selects the terminal PLONK CUDA helper. Native BN254 arithmetic +uses shared sppark/BLST support, and the Go helper uses gnark. Those libraries +may contain other curves; Ix exposes only BN254. + +KZG uses authenticated Ignition parameters or explicit known-trapdoor +development parameters. Terminal PLONK requires authenticated Ignition. +The BN254 KZG release catalog is empty: local verification requires an +independently pinned verifying key and expected statement. PLONK verification +requires an independently authorized `IX_PLONK_VERIFIER_CATALOG`. + +BN254 supports radix-2 domains through `2^28`; traces and quotient domains +must both fit. Existing BN254 proof, verifier, and SRS encodings are retained. + +## Ignition setup ### Ignition parameter import @@ -164,7 +40,7 @@ and authenticated cache loads passed on 2026-10-16. To build the tool: ```sh cargo build --release --locked -p ix-proof-compression \ - --no-default-features --features bn254,parallel --example kzg_ignition_import + --no-default-features --features parallel --example kzg_ignition_import IGNITION_IMPORTER="$PWD/target/release/examples/kzg_ignition_import" "$IGNITION_IMPORTER" info "$IGNITION_IMPORTER" inspect \ @@ -228,7 +104,7 @@ or establish erasure of their secret randomness. The separate `sealed` snapshot has different anchors and is not part of this import. The prepared cache, all forty source transcripts, admission and authentication -evidence are stored in the same S3 bucket as the BLS parameters: +evidence are stored in the S3 bucket: `s3://argument-kzg-params-063002298335-us-east-1-an/bn254/ignition/main-197/`. All 116 objects (17,197,946,185 bytes) passed size and SHA-256 verification after upload, including multipart checksum verification. The local validation @@ -268,8 +144,7 @@ commands above configure replacement local paths with the verified pins. Times are the tool's operation timings; resource logs are under `/opt/dlami/nvme/ix-bn254/logs/ignition-{import,verify}-26/`. CPU crate tests -passed: 249 for BN254 and 238 for BLS12-381, with five ignored on each. -The CUDA module suites also passed all thirty checks on each curve. +passed for the imported Ignition parameters; see the historical measurement records for the exact revisions and test counts. The complete cold and warm Init runs under Ignition also passed; see [the real-input validation](#real-init-with-ignition). @@ -341,54 +216,9 @@ Ordinary application verification continues to use its release-bundled verifying key catalog. A future BN254 production entry must bundle both the verifying key digest and the admitted setup pin; generating either artifact does not add that entry. The BN254 catalog remains empty and admits -no development entries. Existing BLS interfaces/catalogs are unchanged. - -### Real Init with Ignition +no development entries. -The saved Init FRI proof completed both KZG stages using admitted Ignition -parameters, first with cold fixed preprocessing and then with it reused. -The ceremony import was already prepared and is excluded from these timings. -Independent CPU verification passed for both runs after removing -proving-cache and setup environment variables. Seven negative checks per run -rejected a corrupt proof, wrong claim, wrong expected statement, wrong -verifying key digest, wrong admission pin, corrupt admission and a development -verifying key. Each verification report includes a CPU capability check as -its ninth check. - -| Measurement | Cold fixed preprocessing | Warm fixed preprocessing | -| --- | ---: | ---: | -| First KZG wall time | 210.89 s | 135.94 s | -| Recursive KZG wall time | 72.69 s | 44.56 s | -| Complete pipeline wall time | 284.30 s | 181.13 s | -| First KZG peak process RSS | 175.283 GiB | 175.287 GiB | -| Recursive KZG peak process RSS | 81.155 GiB | 63.161 GiB | -| First KZG sampled peak GPU memory | 28,954 MiB | 28,952 MiB | -| Recursive KZG sampled peak GPU memory | 60,696 MiB | 58,648 MiB | - -Both stages use the same admitted setup identity and full public degree -100,799,999. The first stage retains nineteen circuits, 113,913,480 gates and -31,689,479 lookups. The recursive frontend has 61,151,377 rows, 372 more than -the development setup, and still pads to `2^26`. These are single runs with -uncontrolled OS page cache. Different binaries and setup parameters prevent -attributing differences from the development benchmark to the ceremony. - -The envelope is **1,917 bytes** and the portable verifying key is **1,542 -bytes**. Both are byte-identical between the cold and warm runs, as are the -compact proofs from both KZG stages. The verifying key uses the same -structure as BLS12-381, implemented by `VerifyingKey`, with BN254 version 7, -circuit metadata and both opening equations. Its locally pinned BLAKE3 -digest is -`a4cf1b3b2489af6c939847d5060452bccb1f87ed8cff0fb90658e477549eafcd`. -This key has not been added to the verifying key catalog. - -Artifacts are under `/opt/dlami/nvme/ix-bn254/ignition-validation/`: -`experiment.json` records both runs and byte comparisons; `cold/run/` and -`warm/run/` contain their envelopes and verifying keys. Each run's -`verification/verification-report.json` records all independent checks and -`stages/` contains resource logs. The admitted parameter settings -remain in `/opt/dlami/nvme/ix-bn254/parameters/ignition/setup.env`. - -### Saved Init pipeline +## Proving and verification The worker has two entry points for a complete KZG run. `pipeline run` reads `root-vk.bin`, `root-proof.bin` and `root-claims.bin`, performs FRI compression, @@ -404,7 +234,7 @@ local storage. ```sh cargo build --release --locked -p ix-proof-compression --bin ix-compression-worker \ - --no-default-features --features bn254,parallel --target-dir target/bn254 + --no-default-features --features parallel --target-dir target/bn254 export IX_COMPRESSION_WORKER="$PWD/target/bn254/release/ix-compression-worker" "$IX_COMPRESSION_WORKER" fri capabilities @@ -445,7 +275,7 @@ their checkpoints. Shared cache environment variables override the default per-run `cache/` directory. BN254 has no bundled verifying key catalog entry. Its packet and verifying -key versions are separate from BLS, and generating a key does not authorize +key formats identify BN254; generating a key does not authorize it. For local verification, set `BN254_VK_DIGEST` to the independently reviewed BLAKE3 digest of `final-kzg/vk.bin`, then supply that pin, the original claim and independently expected root claims: @@ -473,338 +303,28 @@ Focused entry points include the field primitives and complete verifier witness, with additional curve, transcript, MSM and public-pairing modules: ```sh -cargo test -p ix-proof-compression --no-default-features --features bn254,parallel \ +cargo test -p ix-proof-compression --no-default-features --features parallel \ --lib recursive::native_field:: -cargo test -p ix-proof-compression --no-default-features --features bn254,parallel \ +cargo test -p ix-proof-compression --no-default-features --features parallel \ --lib recursive::native_verifier::reuse_tests::recursive_witness_rejects_tampering_without_native_preverification -- --exact ``` -The sizing example uses the same four-row inner circuit on both curves. It -proves that small development fixture, then counts the recursive frontend -without retaining the full recursive IR, generating its witness or producing -an outer proof: +## Terminal PLONK ```sh -cargo run -p ix-proof-compression --no-default-features --features parallel \ - --example recursive_size > target/recursive-bls12-381.json -cargo run -p ix-proof-compression --no-default-features --features bn254,parallel \ - --example recursive_size > target/recursive-bn254.json +ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output ``` -The reports contain cumulative and incremental gates, lookups and values at -the curve-input, transcript, scalar-check and opening-MSM boundaries. Shared -tables and scalar preparation contribute to these phases; these frontend -counts are not gnark PLONK constraint counts. The real Init comparison above -uses saved pipeline artifacts; this smaller example provides another -controlled input for comparing the two curves. - -CPU/GPU parity and real Init pipeline measurements are recorded below. The -BN254 gnark wrapper now proves and verifies the actual Ignition Init packet, -including both pairing obligations and the two public claim-digest limbs. -The classic-GLV helper with separate pairing checks uses 14,689,442 rows and -domain 2^24. Core proving took 53.79 seconds, the fresh prover process took -73.70 seconds, and peak RSS was 25.19 GiB. The native proof is 584 bytes, or -624 bytes with its ix envelope. The matching basis and keys are now cached; -their 5m 58s preparation cost is excluded from proof timing. Independent native -and catalog verification passed. - -The same proof passes local Anvil verification through a constants-only SP1 -verifier adaptation and ix's claim adapter at 320,103 total transaction gas. -See the [Solidity result](../../contracts/plonk/README.md#actual-bn254-init-proof-on-anvil). -Remaining work includes complete CLI packaging. -Review the authenticated BN254 -ceremony provisioning, verifying key/catalog authorization and regenerated -fixtures before enabling production releases. - -## BLS12-381 compression pipeline - -Compress an aggregate `CheckEnv` root proof through FRI → KZG → recursive -KZG. The statement the compressed proof exposes is the root's public claim -row, derived from the aggregation backend as `ix verify --aggregate` derives -it and bound to the claim by its packed digest; nothing in the pipeline or -its caches depends on which environment was proved. - -The KZG stages prove under the Filecoin phase-one powers of tau when -`IX_KZG_FILECOIN_CACHE` names a prefix cache produced by the authenticated -import in `kzg::srs::filecoin` (`examples/kzg_filecoin_import.rs` drives it -from the 72 GiB `challenge_19`). The cache is pinned by the manifest receipt -its import printed: the receipt of the complete `2^27` cache is built in, and -`IX_KZG_FILECOIN_MANIFEST` names the receipt of a cache with another prefix. -The recursive stage needs the `2^27` prefix, about 12 GiB. - -The provisioned parameters live in the S3 bucket -`argument-kzg-params-063002298335-us-east-1-an` in `us-east-1`, under their -file names: the `2^27` prover cache `filecoin-prefix-2p27.bin` and the -complete 72 GiB `challenge_19` it was imported from. Fetch the cache onto -fast local storage (see [Disk placement](#disk-placement)) and point -the pipeline at it; `challenge_19` is only needed to re-run the import. +Use the four Ignition environment variables described above. The result +includes the proof envelope, native and Solidity proof encodings, generated +Solidity verifier, verifying key, admission, and candidate verifier catalog. +The candidate must be reviewed independently before configuring it for +`ix verify`. See the [helper guide](../../tools/plonk-wrapper/README.md) +for standalone proving, validated Lagrange import, CUDA configuration, and +artifact cache rules. On-chain verification uses the terminal PLONK verifier +and [claim adapter](../../contracts/plonk/README.md). -```sh -IX_COMPRESSION_DATA=/path/to/local-nvme/ix-compression -mkdir -p "$IX_COMPRESSION_DATA/parameters" "$IX_COMPRESSION_DATA/runs" -export IX_KZG_FILECOIN_CACHE="$IX_COMPRESSION_DATA/parameters/filecoin-prefix-2p27.bin" -export IX_KZG_FIXED_CACHE="$IX_COMPRESSION_DATA/cache/fixed-preprocessing" -export IX_KZG_SRS_CACHE="$IX_COMPRESSION_DATA/cache/development-srs" -aws s3 cp s3://argument-kzg-params-063002298335-us-east-1-an/filecoin-prefix-2p27.bin \ - "$IX_KZG_FILECOIN_CACHE" -ix compress ROOT_PROOF_ADDRESS --out "$IX_COMPRESSION_DATA/runs/run-001" -ix verify COMPRESSED_PROOF_ADDRESS -``` - -Choose an existing local NVMe mount for `IX_COMPRESSION_DATA`; these commands -create directories on its filesystem. `--out` changes the run directory only. -The explicit cache variables keep preprocessing off the default home volume. -`IX_KZG_SRS_CACHE` is used only for development parameters. See -[KZG benchmarking](#kzg-benchmarking) for cold/warm runs and timing boundaries. - -Neither download needs to be trusted: the importer checks the -source against the ceremony's published BLAKE2b-512 digest, and loading -checks the cache against its built-in receipt and the pinned tau anchors. A -256-power cache from the same import is checked in at -`tests/fixtures/filecoin-prefix-2p8.bin` for tests of the real format. - -| Artifact | Size | Pin | -| --- | ---: | --- | -| `challenge_19` | 77,309,411,488 bytes | BLAKE2b-512 `5a26015b…4df76b`, `FILECOIN_CHALLENGE_19_BLAKE2B` | -| `filecoin-prefix-2p27.bin` | 12,884,919,006 bytes | BLAKE3 receipt `14423860…37f10a`, `FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3` | -| `tests/fixtures/filecoin-prefix-2p8.bin` | 25,342 bytes | BLAKE3 receipt `185c556e…a0507d`, `FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3` | - -`--dev-srs` selects a **known-trapdoor development SRS**, including when a -Filecoin cache is configured. Its proofs are **not production-secure**. -Verification requires the corresponding bundled development verifying key and -explicit `--dev-srs` opt-in; proof, claim and pairing checks still apply. - -```sh -ix compress ROOT_PROOF_ADDRESS --dev-srs --out "$IX_COMPRESSION_DATA/runs/dev-001" -ix verify COMPRESSED_PROOF_ADDRESS --dev-srs -``` - -Compression preserves the claim address and prints a new proof address. -It runs the three stages in sequence, each as a child process, into an empty -directory, `~/.ix/compression/PROOF_ADDRESS/HEIGHT` unless `--out DIR` names -one. Each stage writes its proof and verifying metadata; a rerun starts over. -Fixed preprocessing and development SRS files are cached across runs under -`~/.ix/compression/cache`. Use `--log-height 25` for fewer partitions -(default: 24) and `--root-vk PATH` for a trusted root key instead of the -current aggregate key. Full proving needs hundreds of GiB of RAM. - -Verification uses the verifying keys compiled into the application from -`verifying-keys/`. `ix verify` selects a verifying key by the packet's ID and derives -the expected statement from that key's pinned aggregation-program prefix and -the hash of the caller's serialized claim. It checks the compact proof and -both pairing obligations without an external key file, registry, proving SRS -or GPU. - -Ordinary `ix verify` does not import external verifying keys, and local files -under `~/.ix/verifiers` have no effect. Unknown verifying key IDs are rejected; -adding an accepted key requires an application update. The standalone -worker's `pipeline verify` command instead requires an explicit verifying key -digest and expected statement, as described above. The bundled BLS verifying -keys are: - -| Verifying key | Setup | First-stage log-height | -| --- | --- | --- | -| `aggregate-filecoin-v5.bin` | Filecoin | 24 | -| `aggregate-development-v5.bin` | Development, requires `--dev-srs` | 24 | - -The adjacent JSON files record the setup, root key and artifact identities. -The verifying key catalog pins each key and its aggregation-program prefix. -The public claim is supplied by the caller rather than copied from the -packet, so a bundled key verifies any aggregate root proved by the same -recursive circuit. `ix compress` derives the statement from the aggregation -backend instead and fails if a bundled key pins a different program than -the build it runs in, so a stale bundle is caught where the backend is available. - -Compression does not require a verifying key to be bundled before proving. It -constructs its circuit, verifies its output and writes `final-kzg/vk.bin` -as an output of the run. That file can be reviewed and bundled in a later -application release; generating it does not register it for verification. - -The packet carries the 18-word public claim, a verifying key ID, two compressed G1 -opening-pairing operands and the compact `KQP4` KZG proof. Generic Serde -serialization of complete KZG proofs is unsupported; callers use -`FixedProofCodec` with the expected proof shape. Internal commitment metadata -retains its canonical encoding and checks nested counts before allocating. - -Every setup declares its complete public polynomial degree. Loading a shorter -proving prefix or only verifier anchors preserves that degree and the setup -identity. The Filecoin identity derives from the ceremony digest and its -`2^28 - 2` public degree, so every prefix cache of it binds the same -checkpoints and verifying keys. Development identities derive from the -seed and declared degree; the trapdoor remains known. - -## Final proof transcript - -Portable BLS verifying key version 5 uses Keccak-256 for recursive KZG proofs. -The FRI proof, first KZG proof and the recursive circuit's verification of -that first proof retain BLAKE3. The compact proof layout and SRS are unchanged. -The verifying key version selects the transcript without trying alternatives; -application verification also requires that exact key to be bundled. -Setup bindings include the transcript choice. Metadata checksums and key -identities remain BLAKE3. - -The Keccak transcript starts with a zero 32-byte state. For counters 0, 1 and -2 it computes Keccak-256 over -`"multi-stark/kzg/keccak256/v1" || counter:u8 || state:bytes32 || observations`. -The first two digests concatenate into a 512-bit **big-endian** integer reduced -modulo the BLS12-381 scalar field; the third digest becomes the next state. -All three hashes use the old state, then the observation buffer is cleared. -Observations keep the existing canonical field/point and vector encodings. -The parameter seed has its own Keccak protocol domain and binds the same -public setup anchors and degree limits as the BLAKE3 configuration. - -This transcript lets a custom Ethereum verifier use native Keccak; -it does not change the proof into a standard PLONK format. - -## GPU proving - -### Curve selection and validation status - -Lake uses `IX_KZG_CURVE=bn254|bls12-381`, defaulting to BN254, for both `ix` and the single -`ix-compression-worker` target. `IX_CUDA=1` enables the selected KZG CUDA -backend and Goldilocks CUDA. BN254 builds its matching PLONK helper and selects -explicitly admitted Ignition parameters or `--dev-srs`. Curve features -participate in the Cargo arguments and -Lake artifact traces, so switching the curve rebuilds both the CLI library -and worker. Keep complete builds in separate worktrees for side-by-side -comparisons; both curves use the usual executable names and runtime layout. - -The source changes select sppark's BN254 field instantiation, use four-limb -MSM outputs and 72-byte affine uploads, derive SRS-cache allocation estimates -from the selected point types, and limit BN254 FFT domains to `2^28`. -Rust and C++ layout assertions cover the bridge. A host-only check compares -Fq/Fr moduli, Montgomery one and R-squared before CUDA device discovery. - -The lookup, quotient and constraint-pass descriptors have paired Rust/C++ -layout assertions. Dense and pass execution retain their existing supported -trace ranges. `IX_KZG_CUDA_PASSES=force` requires the pass path for an eligible -kernel invocation and fails if it cannot run; it cannot silently use the -dense path. Unsupported lookup/quotient shapes and storage report CPU -construction in the logs. MSM scratch keeps the conservative 384-byte -per-point allowance on both curves, with separate bucket headroom and native -cache estimates; it does not scale every buffer by the point-size reduction. -Pinned transfer rings remain bounded at 256 MiB per device when both lanes -are used. FFT batches publish resident handles only after every job succeeds; -native destruction checks device ownership, and transfer rings clean up in -their owning context. - -Small or disk-backed FFT, MSM and opening work reports CPU dispatch at debug -level; use `RUST_LOG=ix_proof_compression=debug` when collecting parity logs. - -On October 16, 2026, the BN254 CUDA library test target compiled with Rust -1.98.1, CUDA 13.3.73 and NVIDIA driver 595.91.07 for SM 120. The focused -`kzg::cuda::tests::cuda_base_and_scalar_arithmetic_match_arkworks` test passed -on GPU 0, an RTX PRO 6000 Blackwell Server Edition. It checks six device -operations on 32 inputs in each of Fq and Fr against Arkworks, including zero, -modulus-boundary and high-limb values. The host field-representation guard -also passed during device initialization. Compilation took 1m 02s and the -test runner took 0.34s; these are test timings, not proving benchmarks. - -The command used the working-tree CUDA port atop CPU baseline `52c61479`: - -```sh -CARGO_HOME="$PWD/target/cargo-home" CUDA_HOME=/usr/local/cuda-13.3 \ -NVCC=/usr/local/cuda-13.3/bin/nvcc IX_KZG_CUDA_ARCHS=120 \ -MULTI_STARK_CUDA_ARCHS=120 CUDA_VISIBLE_DEVICES=0 \ -cargo test --locked -p ix-proof-compression --no-default-features \ - --features bn254,parallel,cuda --lib \ - kzg::cuda::tests::cuda_base_and_scalar_arithmetic_match_arkworks \ - -- --exact --ignored --nocapture -``` - -Follow-up validation on the same source completed every CUDA-module test on -both curves, using normal build and test parallelism: - -| Configuration | Host/ABI checks | GPU checks | Failures | -| --- | ---: | ---: | ---: | -| `bn254,parallel,cuda` | 12 | 18 | 0 | -| `bls12-381,parallel,cuda` | 12 | 18 | 0 | - -The 60 checks cover native field parameters and their rejection cases, every -supported NTT root and inverse-size factor, oversized-domain rejection, -point transport, subgroup/coset FFTs, zero-padding and batched order. MSM and -polynomial cases cover exceptional points, chunk tails, structured-scalar -normalization, resident inputs, evaluation and division. Cached MSM checks -cover offsets, owner isolation, eviction/re-upload and rejected requests. -Dense and forced-pass lookup/quotient output matches CPU calculations on -1,024-row host and 65,536-row resident fixtures, including next-row access, -constant columns, zero denominators and quotient cosets. - -The single-device concurrency checks also passed, including the `2^20`-point -MSM case. Each curve's two-device admission check passed separately with -`CUDA_VISIBLE_DEVICES=0,1`; other GPU checks used device 0. This validates -resident-buffer selection, not multi-GPU proving performance. The BLS CUDA -test target compiled in 42.90s. Test timings are not curve-speed benchmarks. -Local per-run commands, logs and source hashes are recorded in -`target/gpu-parity/cuda-component-parity.json`. - -The [proof fixture](examples/kzg_backend_parity.rs) also passed exact byte -comparison between separate CPU and CUDA binaries on both curves. It proves -two ordinary KZG circuits with 4,096 and 8,192 rows, five grouped lookups, -next-row openings and a quotient-factor cap of four, using an 8,192-power -development SRS. The CPU, dense GPU, forced-pass GPU and cached-SRS GPU modes -produce identical proof and verifier bytes within each curve: - -| Curve | Compact proof bytes | Modes with identical bytes | -| --- | ---: | --- | -| BN254 | 1,861 | CPU, dense GPU, forced passes, cached SRS | -| BLS12-381 | 2,197 | CPU, dense GPU, forced passes, cached SRS | - -GPU dispatch is checked in the logs. CPU binaries independently verify every -mode with the CPU-generated, digest-pinned verifier; CUDA binaries also -verify the CPU proofs with no GPU visible. Changed claims, evaluations, -verifier pins and proofs from the other curve are rejected. These checks -exclude FRI compression, recursive KZG proving and production setup trust. -Peak process RSS across the fixture runs was under 480 MiB; peak VRAM was -not recorded. This is not a production resource or performance measurement. -The [runner](../../tests/kzg_backend_parity.py) retains separate binaries, -native libraries, proofs, verifier pins, command logs and resource records. -The local result is `target/gpu-parity/proof-matrix-v2/report.json`, with -strict rejection checks in `strict-negative-report.json` alongside it. - -The complete Lake configuration typechecked with Lean 4.34.1. -[`tests/check_compression_build.py`](../../tests/check_compression_build.py) -passed seven argument-selection cases covering both curves, CPU/CUDA, -trace-codegen implying CUDA, the default, and the nonparallel configuration; -an invalid curve is rejected. These evaluate build arguments without -starting a project build or constraining the parallelism of proving runs. -Cargo feature-tree inspection also confirms the matching curve selection in -`ix-ffi`. The local configuration report is -`target/gpu-parity/cli-config/report.json`. Narrow Clippy checks with -`-D warnings` passed for the parity example under BN254/CUDA and BLS/CPU. - -The full saved Init first-KZG and recursive-KZG stages also pass on both -curves. BN254 produces exactly the CPU run's proof bytes at both boundaries, -and its final envelope passes the original CPU worker's verifier. The -[matched Init measurement](#matched-init-gpu-comparison) records timings, -memory, setup and cache conditions. GPU FRI compression, full-size BLS -CPU/GPU byte comparison, injected asynchronous failure recovery, complete -CLI linking and larger inputs still require validation. - -After authorizing machine time, reproduce the small proof comparison with -the CUDA toolkit and locked Cargo dependencies available: - -```sh -python3 tests/kzg_backend_parity.py --out target/kzg-proof-parity -python3 tests/check_compression_build.py \ - --toolchain /path/to/lean-4.34.1-linux --out target/kzg-build-config -``` - -After machine time is authorized, the BN254 build selection is: - -```sh -IX_KZG_CURVE=bn254 IX_CUDA=1 MULTI_STARK_CUDA_ARCHS=120 lake build ix -``` - -Omit `IX_CUDA=1` for a CPU build. Direct Cargo builds select -`--no-default-features --features bn254,parallel,plonk` for both `ix-ffi` and -`ix-proof-compression`; add `cuda` to each for the GPU configuration. -KZG kernels remain in the worker, while `ix-ffi/cuda` enables Aiur CUDA. -Default Cargo and Lake builds retain BLS12-381 and its terminal backends. -Terminal BN254 PLONK requires authenticated Ignition parameters and an -independently authorized verifier catalog. See the -[helper documentation](../../tools/bls-plonk-wrapper/README.md) for parameter -export, validated SP1 Lagrange import and the selected helper executable. +## CUDA and profiling ### Backend configuration @@ -888,291 +408,7 @@ overlap commitment, so child durations should not be summed as wall time. per-device transfer/kernel timings. Use both switches to relate whole-stage costs to CUDA execution; GPU operation detail stays out of the TeXRay tree. -## Disk placement - -The recorded GPU runs used local NVMe. Other filesystems are supported, but -parameter loading, checkpoint writes, integrity scans, and cache publication -are part of proving wall time. Put stage output and fixed preprocessing on -the **same filesystem** so cache publication and restoration can hard-link -the files. Across filesystems the implementation copies them, adding I/O and -duplicate storage even if both filesystems are on NVMe. - -| Data | Location | Storage behavior | -| --- | --- | --- | -| Filecoin parameters | `IX_KZG_FILECOIN_CACHE` | Each KZG worker reads and authenticates its required prefix from the provisioned file. | -| Fixed coefficients and circuit metadata | `/first-kzg/kzg/` and `/final-kzg/kzg/`; reused through `IX_KZG_FIXED_CACHE` | Cold runs write and sync checkpoints, then hash and publish them after proof verification. Warm runs hash the cached files before linking or copying them into the new run. | -| Development parameters | `IX_KZG_SRS_CACHE` | Generated and cached on a miss; loaded and checked on reuse. Unused with Filecoin. | -| Stage proofs, claims, verifier metadata, and reports | `--out` | Retained between worker processes and after the run. | -| Stored `Ixon.Proof` containing the final proof and claim | `~/.ix/store` | Written by `ix compress`; `--out` does not relocate the store. | - -CUDA proving retains committed coefficients in host RAM and selected columns -in VRAM. Witness columns stay in memory. Fixed checkpoints are still written -on a cold run; a warm CUDA worker loads them into RAM after the cache integrity -scan. CPU workers instead index fixed checkpoints and stream their columns -during proving. OS page-cache hits can reduce physical reads, so a warm -preprocessing cache does not imply zero storage work. - -### Capacity and retention - -The 12 GiB Filecoin file is only one part of the footprint. Fixed checkpoint -payloads use 32 bytes per coefficient: - -```text -fixed coefficient bytes = 32 × sum(preprocessed_width × trace_height) -``` - -For the bundled v5 recursive verifying keys, the computation trace has 15 fixed -columns at `2^27` rows and the table trace has four at `2^17` rows. Their -combined coefficient payload is **60.015625 GiB**, excluding checkpoint -metadata. Together with the Filecoin file, this already exceeds 72 GiB before -first-stage preprocessing, intermediate artifacts, and filesystem headroom. -This is a calculation from those verifying keys, not a measured disk high-water mark -for the current code. First-stage size depends on the layout; record the -actual checkpoint sizes when provisioning a new verifying key. - -On one filesystem, cache entries and run checkpoints share storage through -hard links. Deleting one directory does not free those bytes while another -link remains. Cache keys include the worker executable digest, plan, and setup; -a changed executable creates a new cache lineage. There is no automatic -eviction of old runs or cache entries. Retire them explicitly once no worker -uses them. Use `df -h` on the selected mount to check available space. - -Before discarding ephemeral storage, preserve the stored proof with its claim -and `final-kzg/vk.bin` on durable storage. Keep intermediate proofs and -caches when they are useful for later runs. The 72 GiB `challenge_19` source -is needed only for parameter import; normal proving uses the prefix cache. - -## Stage entries and caches - -`ix compress` invokes `ix-compression-worker ` for each stage. -Each invocation runs one stage and exits, releasing its host memory and -CUDA state. The separate executable keeps the KZG CUDA runtime outside -Lean's process. Reuse between invocations comes from the disk caches below. -The worker must be installed beside `ix`, or named by `IX_COMPRESSION_WORKER`. -Maintainers can invoke a stage directly when diagnosing a saved input: - -```sh -ix-compression-worker fri root/ fri/ --prove-outer -ix-compression-worker kzg run fri/ first-kzg/ 24 -ix-compression-worker wrap stage-and-prove first-kzg/ final-kzg/ -``` - -The KZG stages generate traces directly into commitment and proving. -Every KZG stage requires an admitted production setup or explicit `--dev-srs`. -BLS uses Filecoin configuration; BN254 uses the four Ignition settings above. - -`ix compress` sets the fixed-preprocessing and development SRS cache -directories under `~/.ix/compression/cache` unless the environment already -names them. Direct stage invocations use the defaults below. - -| Variable | Meaning | Default | -| --- | --- | --- | -| `IX_KZG_FIXED_CACHE` | directory of fixed-preprocessing checkpoints, bound to the executable digest, plan and setup identity | `/kzg/fixed-cache` | -| `IX_KZG_FILECOIN_CACHE` | receipt-pinned Filecoin prefix cache, used unless `--dev-srs` selects development parameters | unset: `--dev-srs` required | -| `IX_KZG_FILECOIN_MANIFEST` | BLAKE3 manifest receipt of that cache | the built-in receipt of the complete `2^27` cache | -| `IX_KZG_SRS_CACHE` | directory holding block-checksummed development SRS files shared across stages | unset: regenerated per stage | -| `IX_KZG_PREFETCH_GIB` | bound on the next partition's witness generated while the current one is committed | `32` | -| `IX_ROOT_GENERIC_HASHES` | set to build the FRI stage's circuit with generic rather than compact BLAKE3; `ix compress` rejects it | unset | - -## KZG benchmarking - -State the input boundary with every timing: - -| Scope | Command boundary | Included work | -| --- | --- | --- | -| Aggregate root → compressed packet | Whole `ix compress` process | Backend setup, root-to-FRI compression, both KZG workers, verification, cache publication, and final store write. Initial proving and aggregation are excluded. | -| Saved compressed FRI → KZG packet | Both sequential KZG worker processes | First KZG stage and recursive KZG stage, including parameter loading, verification, output, and cache publication. Root-to-FRI compression is excluded. | - -Use whole-process wall time for either scope. Internal -`kzg/prove-report.json.total_seconds` starts inside the proving phase and is -captured before fixed-cache publication; it excludes some preparation and -storage work. `IX_TEXRAY=1` provides the phase breakdown described under -[Profiling](#profiling). Do not sum overlapping child spans. - -For a Filecoin cold/warm pair, use the NVMe layout and provisioned parameters -from the quickstart. `IX_FRI_INPUT` must contain `outer-vk.bin`, -`outer-proof.bin`, and `outer-claims.bin` from the saved FRI stage. Keep the -input, binary, features, setup, and runtime settings identical between runs: - -```sh -set -eu -IX_FRI_INPUT=/path/to/saved-fri -IX_KZG_WORKER=.lake/build/bin/ix-compression-worker -IX_KZG_BENCH=$(mktemp -d "$IX_COMPRESSION_DATA/runs/kzg-bench.XXXXXX") -export IX_KZG_FIXED_CACHE="$IX_KZG_BENCH/fixed-preprocessing" -: "${IX_KZG_FILECOIN_CACHE:?set the provisioned Filecoin cache path}" -sha256sum "$IX_KZG_WORKER" "$IX_FRI_INPUT/outer-vk.bin" \ - "$IX_FRI_INPUT/outer-proof.bin" "$IX_FRI_INPUT/outer-claims.bin" \ - > "$IX_KZG_BENCH/inputs.sha256" -findmnt -T "$IX_KZG_BENCH" > "$IX_KZG_BENCH/storage.txt" -df -h "$IX_KZG_BENCH" >> "$IX_KZG_BENCH/storage.txt" -for mode in cold warm; do - run="$IX_KZG_BENCH/$mode" - mkdir -p "$run" - /usr/bin/time -v -o "$run/first-kzg.time" \ - "$IX_KZG_WORKER" kzg run "$IX_FRI_INPUT" "$run/first-kzg" 24 \ - > "$run/first-kzg.log" 2>&1 - /usr/bin/time -v -o "$run/recursive-kzg.time" \ - "$IX_KZG_WORKER" wrap stage-and-prove "$run/first-kzg" "$run/final-kzg" \ - > "$run/recursive-kzg.log" 2>&1 -done -``` - -The first run populates the initially empty fixed cache; the second reuses it -with fresh output directories and fresh worker processes. Sum the two elapsed -worker times for each saved-FRI-to-packet result. Parameter download and import -happen before timing. For development runs, report the development SRS cache -state separately: generating parameters changes the measured workload. - -Record the revisions, hardware and driver, environment settings, parameter -receipt, filesystem/device placement, checkpoint sizes, and each process's -exit status alongside these logs. Report OS page-cache state separately from -preprocessing-cache state; if uncontrolled, say so. Rebuilding the worker -between the pair invalidates preprocessing reuse. For full compression from -an aggregate root, time `ix compress` with a fresh `--out` directory and the -same explicit cache placement instead. - -### Matched Init GPU comparison - -On October 16, 2026, both optimized CUDA workers completed the same saved -Init compressed FRI input, using the working tree above CPU baseline -`52c61479`. Both used BLAKE3 transcripts and known-trapdoor development -parameters, one RTX PRO 6000 Blackwell Server Edition, all 48 logical CPUs -of the Xeon Platinum 8559C host, 499 GiB RAM, and local NVMe. Toolchain: -Rust 1.98.1, CUDA 13.3.73, driver 595.91.07, SM 120. - -These are **cold fixed-preprocessing** measurements with **prepared SRS -caches** and fresh worker processes. Each curve started with an empty fixed -cache. OS page caches were uncontrolled. Whole-process wall times include -SRS loading, frontend construction, witness generation, fixed preprocessing, -proving, verification, output and cache publication. Builds, SRS generation, -FRI compression and gaps between worker invocations are excluded. There is -one cold measurement per curve. A subsequent warm BN254 rerun is recorded -below; BLS warm timing has not been measured for these builds. - -| Stage | BN254 | BLS12-381 | BLS time / BN254 time | -| --- | ---: | ---: | ---: | -| First KZG | 206.66 s | 225.18 s | 1.09× | -| Recursive KZG | 69.34 s | 153.01 s | 2.21× | -| **Total** | **276.00 s** | **378.19 s** | **1.37×** | - -BN254 used 27.0% less total wall time in this run. Its recursive computation -trace has `2^26` rows, versus BLS's `2^27`; this measures the complete -curve-specific pipelines, not an isolated field or MSM speedup. Both used -`IX_KZG_CUDA_DEVICES=0`, `CUDA_VISIBLE_DEVICES=0`, -`IX_KZG_CUDA_RESIDENT_GIB=8`, `IX_KZG_MSM_CHUNK_POINTS=16777216`, -`IX_KZG_CUDA_PASSES=1`, `IX_KZG_CUDA_SRS_CACHE=0`, and `IX_TEXRAY=1`. -Neither run capped CPU thread counts. First-stage constraints ran densely; -both recursive lookups used GPU passes. The BN254 recursive quotient fit -densely, while BLS used passes. - -| Curve / stage | Peak process RSS | Sampled peak GPU memory | -| --- | ---: | ---: | -| BN254 first | 175.28 GiB | 27.73 GiB | -| BN254 recursive | 81.16 GiB | 58.73 GiB | -| BLS first | 175.80 GiB | 27.73 GiB | -| BLS recursive | 164.52 GiB | 64.70 GiB | - -GPU memory was sampled every two seconds and includes allocator -reservations. Both stages verified natively, including the deferred -pairings. BN254's compact proofs, packets and verifying key match the completed -CPU run byte for byte; its 1,917-byte envelope also verifies with that CPU -worker and its prior verifying key pin. The BLS envelope is 2,189 bytes and its -verifying key matches the existing bundled development-v6 verifying key. Both portable -envelopes verify with no GPU visible, and wrong verifying key pins and missing -development opt-in are rejected. - -The local record is `target/gpu-parity/init-comparison.json`. Frozen workers, -native libraries, proofs, fixed caches, command logs and resource samples -are under `/opt/dlami/nvme/ix-gpu-compare-52c61479/`; `experiment.json` records -input and source hashes, and `report.json` contains the comparison. -The [stage supervisor](../../tests/run_gpu_stage.py) measures whole-process -wall time and memory. [SRS preparation](examples/kzg_dev_srs.rs) can be run -before timing, and the [metadata inspector](examples/kzg_stage_resources.rs) -estimates column/pass memory without allocating proving matrices. - -BN254's fixed checkpoints total 114.79 GiB (84.77 GiB first stage and -30.02 GiB recursive). Generating and writing them took 122.39 seconds in -the fixed-preparation spans. These figures favor local generation and reuse -on this hardware; downloading that volume is not necessarily cheaper. -The current cache is trusted local data keyed by executable and verifying key. -Distributing checkpoints would additionally require authenticated manifests -and an explicit compatibility contract. Ceremony SRS distribution is a -separate concern. - -### Warm BN254 Init rerun - -A second BN254 run reused the exact frozen worker, native library, input, -GPU settings, SRS cache and fixed-preprocessing entries from the matched -comparison. Both stages used fresh processes and output directories, and -both logged a fixed-cache hit. OS page caches remained uncontrolled. - -| Stage | Cold | Warm | Saved | -| --- | ---: | ---: | ---: | -| First KZG | 206.66 s | 153.16 s | 53.49 s | -| Recursive KZG | 69.34 s | 48.48 s | 20.86 s | -| **Total** | **276.00 s** | **201.64 s** | **74.36 s** | - -Warm proving used 26.9% less total wall time (1.37× speedup). Simply -subtracting the cold fixed-preparation spans predicted 153.61 seconds, -48.03 seconds below the measured warm total. Cache validation and restore -took 10.60 seconds for the first stage and 5.32 seconds for recursion; -loading the saved coefficients during preparation adds further work. - -The compact proofs, packets, verifying keys and final 1,917-byte envelope match -the cold run exactly. The original CPU worker independently verifies the -warm envelope with the original claim, expected root claims and verifying key -pin, with no GPU visible. Peak process RSS was 175.28 GiB for the first -stage and 63.17 GiB for recursion. - -The local report is `target/gpu-parity/init-bn254-warm.json`; the NVMe copy -is `/opt/dlami/nvme/ix-gpu-compare-52c61479/bn254/warm-report.json`. Proofs -are under that curve's `warm/` directory, with stage logs and resource -samples in `logs/warm-first-kzg/` and `logs/warm-recursive-kzg/`. These -measurements use the same saved-FRI-to-KZG scope as the cold comparison. - -### Recorded Init KZG run - -One cold/warm pair used a saved compressed Init FRI proof, Filecoin parameters, -one RTX PRO 6000 Blackwell with 96 GB VRAM, 32 vCPUs, 256 GiB RAM, and local -NVMe. Cold means empty fixed preprocessing; warm means reused preprocessing. -Both used fresh worker processes and already-provisioned parameters. OS caches -were uncontrolled. This historical result used the Init FRI fixture from -`40c990ca`; the current fixture is regenerated by the current CLI. Use identical -input fixtures when comparing binaries or verifying keys. - -| Stage | Cold | Warm | -| --- | ---: | ---: | -| Compressed FRI → KZG | 300.33 s | 189.93 s | -| Recursive KZG | 171.51 s | 119.11 s | -| **Total** | **471.87 s** | **309.04 s** | - -The recorded scope includes SRS loading/authentication and verification, and -excludes builds, parameter download, initial aggregation, and root-to-FRI -compression. No SRS generation was performed. Warm was **1.53× faster**; -both runs produced identical **2,181-byte packets**. Verification including -pairings took **6.67 ms**, excluding loading and startup; peak host RSS was -**236.51 GiB**. The record does not give a total disk high-water mark or a -comparison against another storage device. - -## Shared stage construction - -The public stage API and the worker use the same production circuit builders, -statement schemas, lowering namespaces, and recursive Keccak transcript. -The file adapters load artifacts, select setup and cache locations, and report -results. Multi-shard roots and the normalized `FixedRoot` remain supported; -`setup` constructs its verifier without a proof or witness. - -KZG commitment consumes the lowered circuit and assignment before proving. -The remaining phase owns committed columns and verifier metadata, allowing -frontend memory to be released before lookup and quotient allocation. -Bundled and library recursive verification share validation and pairing checks. -A compiled-in verifying key retains its prepared system, pairing keys and compact codec. - -The library's `IXKZGV02` key encoding binds the transcript explicitly. -The bundled v5 verifying key and packet encodings are unchanged. - -## Code and tests +## Library interfaces and tests The library also exposes `pipeline::prepare_root` for aggregate roots with multiple Aiur trace shards, `FixedRoot` for a bounded normalized root, and @@ -1189,21 +425,22 @@ into scalar-field constraints, `kzg` implements the commitment backend, and `pipeline` runs the stages through the Plonkish API, exposed by `ix-compression-worker`. +Focused CPU checks use the small BN254 fixtures: + ```sh -cargo test --release -p ix-proof-compression -lake build ix compression-cli-tests -.lake/build/bin/compression-cli-tests +cargo test --release --locked -p ix-proof-compression --test bn254_kzg +cargo test --release --locked -p ix-proof-compression --test kzg_transcripts +python3 tests/check_compression_build.py \ + --toolchain /path/to/lean-4.34.1-linux --out /tmp/ix-build-config ``` -The lakefile compiles Cargo's C units as C17 so they stay compatible with -Lean's bundled libc on hosts whose C compiler defaults to C23, and tracks -that flag and the embedded verifying key bytes when deciding whether to rebuild -the Rust archive. - -Tests cover small proving, tampering, CLI validation and proof identity. -The saved Init FRI fixture checks native verification and verifier planning. -The CLI test verifies both checked-in packets in a fresh home without a proving -SRS or verifier import. Development proofs require `--dev-srs`; that flag also -selects development parameters when a Filecoin cache is configured. Tests reject -modified claims, proofs and pairing data even with development opt-in, and -confirm that a local verifier registry cannot override the application's verifying keys. +`tests/kzg_backend_parity.py` compares independent CPU/CUDA builds on small +ordinary KZG proofs. Complete Init proofs, GPU tests, and sizing runs have +substantial resource costs and are run separately from the small checks. +The CLI tests cover setup selection, claim type checks, unsupported proof +versions, and rejection of untrusted verifying keys. + +Sizing and benchmark context is retained in the +[benchmark comparison](../../docs/proof-benchmark-comparison.md) and +[stage construction notes](../../docs/compression-stage-collapse.md). +The retired BLS implementation is preserved on `sb/bn254-bls-preserved`. diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs index 00b141010..d2e8e1b0d 100644 --- a/crates/proof-compression/build.rs +++ b/crates/proof-compression/build.rs @@ -25,11 +25,6 @@ const SOURCES: &[&str] = &[ ]; fn main() { - assert!( - env::var_os("CARGO_FEATURE_BN254").is_none() - || env::var_os("CARGO_FEATURE_BLS12_381").is_none(), - "BN254 KZG requires --no-default-features --features bn254; BLS12-381 and direct Solidity features cannot be combined with it" - ); println!("cargo:rerun-if-changed=build.rs"); for source in SOURCES { println!("cargo:rerun-if-changed={source}"); @@ -93,12 +88,6 @@ fn build_kzg_cuda() { println!("cargo:rerun-if-changed={}", root.join(directory).display()); } let mut objects = Vec::new(); - let (curve_flag, domain_flag) = - if env::var_os("CARGO_FEATURE_BN254").is_some() { - ("-DFEATURE_BN254", "-DMAX_LG_DOMAIN_SIZE=28") - } else { - ("-DFEATURE_BLS12_381", "-DMAX_LG_DOMAIN_SIZE=32") - }; for (source, name) in [("cuda/kzg.cu", "kzg.o"), ("cuda/kzg_runtime.cpp", "kzg_runtime.o")] { @@ -106,9 +95,9 @@ fn build_kzg_cuda() { let mut command = cuda_compile_command(&nvcc, &architectures); command.args([ "-c", - curve_flag, + "-DFEATURE_BN254", "-D__BLST_PORTABLE__", - domain_flag, + "-DMAX_LG_DOMAIN_SIZE=28", "-include", "cuda/kzg_sppark.cuh", ]); diff --git a/crates/proof-compression/cuda/kzg_curve.cuh b/crates/proof-compression/cuda/kzg_curve.cuh index 98957888b..7c4f35682 100644 --- a/crates/proof-compression/cuda/kzg_curve.cuh +++ b/crates/proof-compression/cuda/kzg_curve.cuh @@ -4,17 +4,9 @@ #include #include -#if defined(FEATURE_BN254) && !defined(FEATURE_BLS12_381) #include constexpr size_t KZG_FQ_LIMBS = 4; static_assert(MAX_LG_DOMAIN_SIZE == 28); -#elif defined(FEATURE_BLS12_381) && !defined(FEATURE_BN254) -#include -constexpr size_t KZG_FQ_LIMBS = 6; -static_assert(MAX_LG_DOMAIN_SIZE == 32); -#else -#error "Select exactly one KZG curve" -#endif // The NTT domain stride is 32 bits even when the field has a 2^32 root. constexpr uint32_t KZG_MAX_FFT_LOG = MAX_LG_DOMAIN_SIZE < 31 ? MAX_LG_DOMAIN_SIZE : 31; @@ -32,17 +24,10 @@ extern "C" int ix_kzg_check_field_abi(const uint64_t* fq, size_t fq_bytes, const uint64_t* fr, size_t fr_bytes) { if (!fq || !fr || fq_bytes != 3 * sizeof(fp_t) || fr_bytes != 3 * sizeof(fr_t)) return 1; -#if defined(FEATURE_BN254) const auto& fq_modulus = alt_bn128::ALT_BN128_P; const auto& fq_r2 = alt_bn128::ALT_BN128_RR; const auto& fr_modulus = alt_bn128::ALT_BN128_r; const auto& fr_r2 = alt_bn128::ALT_BN128_rRR; -#else - const auto& fq_modulus = bls12_381::BLS12_381_P; - const auto& fq_r2 = bls12_381::BLS12_381_RR; - const auto& fr_modulus = bls12_381::BLS12_381_r; - const auto& fr_r2 = bls12_381::BLS12_381_rRR; -#endif const fp_t fq_one = fp_t::one(); const fr_t fr_one = fr_t::one(); return std::memcmp(fq, fq_modulus, sizeof(fp_t)) != 0 diff --git a/crates/proof-compression/examples/kzg_filecoin_import.rs b/crates/proof-compression/examples/kzg_filecoin_import.rs deleted file mode 100644 index 3ffdc9984..000000000 --- a/crates/proof-compression/examples/kzg_filecoin_import.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Provision an authenticated cache from stock Filecoin `challenge_19`. - -use ix_proof_compression::kzg::srs::filecoin::{ - FILECOIN_CHALLENGE_19_BLAKE2B, FILECOIN_CHALLENGE_19_MIRROR_URL, - FILECOIN_CHALLENGE_19_URL, FILECOIN_MAX_TRACE_LEN, - FILECOIN_PUBLIC_MAX_DEGREE, filecoin_setup_id, import_filecoin_challenge, - load_filecoin_cache, -}; -use std::{path::Path, time::Instant}; - -fn main() -> Result<(), Box> { - fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() - } - - fn parse_digest(value: &str) -> Result<[u8; 32], Box> { - if value.len() != 64 || !value.is_ascii() { - return Err("manifest digest must have 64 hexadecimal characters".into()); - } - let mut digest = [0; 32]; - for (i, byte) in digest.iter_mut().enumerate() { - *byte = u8::from_str_radix(&value[2 * i..2 * i + 2], 16)?; - } - Ok(digest) - } - - fn prefix(log: &str) -> Result> { - let log: u32 = log.parse()?; - if !(1..=27).contains(&log) { - return Err("prefix log must be in 1..=27".into()); - } - Ok(1usize << log) - } - - let args: Vec<_> = std::env::args().skip(1).collect(); - let start = Instant::now(); - match args.iter().map(String::as_str).collect::>().as_slice() { - ["info"] => { - println!("source_url={FILECOIN_CHALLENGE_19_URL}"); - println!("mirror_url={FILECOIN_CHALLENGE_19_MIRROR_URL}"); - println!("source_blake2b={}", hex(&FILECOIN_CHALLENGE_19_BLAKE2B)); - println!("public_max_degree={FILECOIN_PUBLIC_MAX_DEGREE}"); - println!("max_trace_len={FILECOIN_MAX_TRACE_LEN}"); - println!("setup_id={}", hex(&filecoin_setup_id())); - } - ["import", source, cache, log] => { - let prefix_len = prefix(log)?; - eprintln!("Reading the complete 72 GiB source and validating {prefix_len} retained G1 powers; this is one-time provisioning."); - let receipt = import_filecoin_challenge(Path::new(source), Path::new(cache), prefix_len)?; - println!("prefix_len={}", receipt.prefix_len); - println!("manifest_blake3={}", hex(&receipt.digest)); - println!("setup_id={}", hex(&filecoin_setup_id())); - eprintln!("Import completed in {:.3}s. Pin manifest_blake3 in trusted configuration; a digest read from an untrusted sidecar is not authentication.", start.elapsed().as_secs_f64()); - } - ["verify", cache, digest, log] => { - let srs = load_filecoin_cache(Path::new(cache), parse_digest(digest)?, prefix(log)?)?; - println!("loaded_prefix_len={}", srs.g1.len()); - println!("public_max_degree={FILECOIN_PUBLIC_MAX_DEGREE}"); - println!("setup_id={}", hex(&filecoin_setup_id())); - eprintln!("Authenticated prefix loaded in {:.3}s", start.elapsed().as_secs_f64()); - } - _ => return Err(concat!( - "usage: kzg_filecoin_import info\n", - " kzg_filecoin_import import \n", - " kzg_filecoin_import verify ", - ).into()), - } - Ok(()) -} diff --git a/crates/proof-compression/examples/recursive_size.rs b/crates/proof-compression/examples/recursive_size.rs index 78eff53dc..2207ad47f 100644 --- a/crates/proof-compression/examples/recursive_size.rs +++ b/crates/proof-compression/examples/recursive_size.rs @@ -131,7 +131,7 @@ fn main() -> Result<(), Box> { .map(|&(rows, width)| json!({ "rows": rows, "width": width })) .collect(); let report = json!({ - "curve": if cfg!(feature = "bn254") { "bn254" } else { "bls12-381" }, + "curve": "bn254", "scope": "Four-row development fixture; frontend gates and lookups, not gnark PLONK constraints or production performance.", "recursive_proof_generated": false, "frontend": { diff --git a/crates/proof-compression/src/kzg/compact.rs b/crates/proof-compression/src/kzg/compact.rs index 686c99c5a..b367ddcf5 100644 --- a/crates/proof-compression/src/kzg/compact.rs +++ b/crates/proof-compression/src/kzg/compact.rs @@ -17,9 +17,6 @@ use multi_stark::prover::Proof; use multi_stark::system::System; use multi_stark::traits::Algebra; -#[cfg(not(feature = "bn254"))] -const MAGIC: &[u8; 4] = b"KQP4"; -#[cfg(feature = "bn254")] const MAGIC: &[u8; 4] = b"KBN1"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/proof-compression/src/kzg/config.rs b/crates/proof-compression/src/kzg/config.rs index 927b77888..533310b7d 100644 --- a/crates/proof-compression/src/kzg/config.rs +++ b/crates/proof-compression/src/kzg/config.rs @@ -1,4 +1,4 @@ -//! [`ProofConfig`] instantiation for the KZG backend: the selected curve's +//! [`ProofConfig`] instantiation for the KZG backend: BN254's //! scalar field (its own challenge field, `D = 1`), a selected transcript, //! monomial KZG commitments. @@ -100,12 +100,6 @@ impl KzgConfig { max_quotient_degree: usize, kind: TranscriptKind, ) -> Vec { - #[cfg(not(feature = "bn254"))] - let domain: &[u8] = match kind { - TranscriptKind::Blake3 => b"multi-stark/kzg/v4", - TranscriptKind::Keccak256 => b"multi-stark/kzg/keccak256/v1", - }; - #[cfg(feature = "bn254")] let domain: &[u8] = match kind { TranscriptKind::Blake3 => b"multi-stark/kzg/bn254/blake3/v1", TranscriptKind::Keccak256 => b"multi-stark/kzg/bn254/keccak256/v1", diff --git a/crates/proof-compression/src/kzg/cuda.rs b/crates/proof-compression/src/kzg/cuda.rs index bc3cbd240..4c7e618aa 100644 --- a/crates/proof-compression/src/kzg/cuda.rs +++ b/crates/proof-compression/src/kzg/cuda.rs @@ -27,8 +27,8 @@ pub(super) mod srs_cache; mod tests; // Conservative bound for point storage, upload staging, scalars, signed -// digits and sorting scratch on either curve. Buckets use separate headroom; -// smaller Fq coordinates do not shrink scalar and sorting allocations. +// digits and sorting scratch. Buckets use separate headroom; Fq coordinate +// widths do not determine scalar and sorting allocation sizes. const MSM_BYTES_PER_POINT: usize = 384; /// When the constraint-pass layout replaces dense device admission. diff --git a/crates/proof-compression/src/kzg/cuda/abi.rs b/crates/proof-compression/src/kzg/cuda/abi.rs index c432e6bb4..6b703ca06 100644 --- a/crates/proof-compression/src/kzg/cuda/abi.rs +++ b/crates/proof-compression/src/kzg/cuda/abi.rs @@ -3,10 +3,7 @@ use super::{BigInt, FftJob, Fq, Fr, G1Affine}; use ark_ff::{FftField, Field, PrimeField}; -#[cfg(feature = "bn254")] pub(super) const FQ_LIMBS: usize = 4; -#[cfg(not(feature = "bn254"))] -pub(super) const FQ_LIMBS: usize = 6; pub(super) type FqLimbs = [u64; FQ_LIMBS]; pub(super) type ProjectiveLimbs = [FqLimbs; 3]; diff --git a/crates/proof-compression/src/kzg/cuda/tests.rs b/crates/proof-compression/src/kzg/cuda/tests.rs index d39b1ec1a..14719f658 100644 --- a/crates/proof-compression/src/kzg/cuda/tests.rs +++ b/crates/proof-compression/src/kzg/cuda/tests.rs @@ -4,7 +4,6 @@ use ark_ff::PrimeField; use ark_poly::{EvaluationDomain, Radix2EvaluationDomain}; mod constraints; -#[cfg(feature = "bn254")] mod plonk_scale; unsafe extern "C" { diff --git a/crates/proof-compression/src/kzg/curve.rs b/crates/proof-compression/src/kzg/curve.rs index 914815d44..f48079cbd 100644 --- a/crates/proof-compression/src/kzg/curve.rs +++ b/crates/proof-compression/src/kzg/curve.rs @@ -1,31 +1,18 @@ //! Curve types and canonical encoding widths for the selected KZG backend. //! -//! BN254 is the default backend; BLS12-381 is selected with -//! `--no-default-features --features bls12-381`. Encodings are arkworks canonical +//! BN254 encodings are arkworks canonical //! encodings; these widths do not describe in-memory or EVM representations. use ark_serialize::{ CanonicalDeserialize, CanonicalSerialize, Read, SerializationError, }; -#[cfg(not(feature = "bn254"))] -pub use ark_bls12_381::{ - Bls12_381 as PairingCurve, Fq, Fr, G1Affine, G1Projective, G2Affine, - G2Projective, -}; -#[cfg(feature = "bn254")] pub use ark_bn254::{ Bn254 as PairingCurve, Fq, Fr, G1Affine, G1Projective, G2Affine, G2Projective, }; -#[cfg(not(feature = "bn254"))] -pub const NAME: &str = "bls12-381"; -#[cfg(feature = "bn254")] pub const NAME: &str = "bn254"; -#[cfg(not(feature = "bn254"))] -pub const G1_COMPRESSED_BYTES: usize = 48; -#[cfg(feature = "bn254")] pub const G1_COMPRESSED_BYTES: usize = 32; pub const G1_UNCOMPRESSED_BYTES: usize = G1_COMPRESSED_BYTES * 2; @@ -104,7 +91,6 @@ mod tests { ); } - #[cfg(feature = "bn254")] #[test] fn bn254_compressed_point_vectors() { let mut positive = [0; 32]; @@ -125,7 +111,6 @@ mod tests { } } - #[cfg(feature = "bn254")] #[test] fn bn254_point_reader_rejects_infinity_alias_and_noncanonical_x() { use ark_ff::{BigInteger, PrimeField}; diff --git a/crates/proof-compression/src/kzg/field.rs b/crates/proof-compression/src/kzg/field.rs index 8465b07b7..62bb07f3a 100644 --- a/crates/proof-compression/src/kzg/field.rs +++ b/crates/proof-compression/src/kzg/field.rs @@ -285,10 +285,7 @@ mod tests { .exp_power_of_2(Scalar::TWO_ADICITY - 1), Scalar::NEG_ONE ); - #[cfg(feature = "bn254")] assert_eq!(Scalar::TWO_ADICITY, 28); - #[cfg(not(feature = "bn254"))] - assert_eq!(Scalar::TWO_ADICITY, 32); } #[test] diff --git a/crates/proof-compression/src/kzg/pcs.rs b/crates/proof-compression/src/kzg/pcs.rs index c867c565a..9f222e342 100644 --- a/crates/proof-compression/src/kzg/pcs.rs +++ b/crates/proof-compression/src/kzg/pcs.rs @@ -53,9 +53,6 @@ use super::field::Scalar; use super::srs::Srs; use super::transcript::KzgTranscript; -#[cfg(not(feature = "bn254"))] -const CHECKPOINT_MAGIC: &[u8; 8] = b"IXKZGCP4"; -#[cfg(feature = "bn254")] const CHECKPOINT_MAGIC: &[u8; 8] = b"IXBNCP01"; /// Per-matrix column commitments. @@ -1432,8 +1429,7 @@ mod tests { assert_eq!(decoded.commitment, commitment); let mut wrong_curve = bytes.clone(); - let other_magic = - if cfg!(feature = "bn254") { b"IXKZGCP4" } else { b"IXBNCP01" }; + let other_magic = b"IXKZGCP4"; wrong_curve[..8].copy_from_slice(other_magic); assert!(KzgProverData::read_checkpoint(wrong_curve.as_slice()).is_err()); diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs index a5d3a443f..f7ed0ed4f 100644 --- a/crates/proof-compression/src/kzg/srs.rs +++ b/crates/proof-compression/src/kzg/srs.rs @@ -15,24 +15,11 @@ use ark_serialize::CanonicalSerialize; use p3_maybe_rayon::prelude::*; use std::io::{self, Write}; -#[cfg(not(feature = "bn254"))] -pub mod filecoin; -#[cfg(feature = "bn254")] pub mod ignition; -#[cfg(not(feature = "bn254"))] -const PUBLIC_SETUP_DOMAIN: &[u8] = - b"multi-stark/kzg/known-trapdoor-public-degree/v1"; -#[cfg(feature = "bn254")] const PUBLIC_SETUP_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/known-trapdoor-public-degree/v1"; -#[cfg(not(feature = "bn254"))] -const DEV_TAU_DOMAIN: &[u8] = b"multi-stark/kzg/dev-srs"; -#[cfg(feature = "bn254")] const DEV_TAU_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/dev-srs"; -#[cfg(not(feature = "bn254"))] -const VALIDATION_DOMAIN: &[u8] = b"multi-stark/kzg/srs-validate/v4"; -#[cfg(feature = "bn254")] const VALIDATION_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/srs-validate/v4"; /// Identity and maximum polynomial degree of the complete public parameter set. @@ -278,10 +265,7 @@ mod tests { legacy_id.update(b"multi-stark/kzg/known-trapdoor-public-degree/v1"); legacy_id.update(&3u64.to_le_bytes()); legacy_id.update(blake3::hash(seed).as_bytes()); - assert_eq!( - srs.public_setup.id == *legacy_id.finalize().as_bytes(), - !cfg!(feature = "bn254") - ); + assert_ne!(srs.public_setup.id, *legacy_id.finalize().as_bytes()); let mut wide = [0; 64]; blake3::Hasher::new() @@ -290,14 +274,14 @@ mod tests { .finalize_xof() .fill(&mut wide); let legacy_tau = Fr::from_le_bytes_mod_order(&wide); - assert_eq!(dev_tau(seed) == legacy_tau, !cfg!(feature = "bn254")); - assert_eq!( - srs.g1[1] == (G1Projective::generator() * legacy_tau).into_affine(), - !cfg!(feature = "bn254") + assert_ne!(dev_tau(seed), legacy_tau); + assert_ne!( + srs.g1[1], + (G1Projective::generator() * legacy_tau).into_affine() ); - assert_eq!( - srs.tau_g2 == (G2Projective::generator() * legacy_tau).into_affine(), - !cfg!(feature = "bn254") + assert_ne!( + srs.tau_g2, + (G2Projective::generator() * legacy_tau).into_affine() ); srs.validate().unwrap(); } diff --git a/crates/proof-compression/src/kzg/srs/filecoin.rs b/crates/proof-compression/src/kzg/srs/filecoin.rs deleted file mode 100644 index 8a4c268cc..000000000 --- a/crates/proof-compression/src/kzg/srs/filecoin.rs +++ /dev/null @@ -1,1008 +0,0 @@ -//! Authenticated import of Filecoin's stock phase-one `challenge_19`. -//! -//! The source pin is the final Golem participant's published BLAKE2b-512 digest: -//! . -//! Filecoin identifies this artifact, without an additional randomness beacon, at -//! . -//! -//! Import verifies the entire source digest, and checks canonical coordinates, -//! curve membership, subgroup membership and the power progression of the retained -//! G1 prefix against the G2 anchors. It does not repeat the ceremony transcript. -//! The normalized cache has an independently pinned manifest and authenticated -//! chunks, so subsequent loads need read only the requested prefix. Its manifest -//! digest must come from the successful import through trusted configuration; -//! reading a digest supplied alongside an untrusted cache would not authenticate it. - -use std::{ - fs::{self, File, OpenOptions}, - io::{self, Read, Seek, SeekFrom, Write}, - path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, -}; - -use ark_bls12_381::{Bls12_381, Fr, G1Affine, G1Projective, G2Affine}; -use ark_ec::{AffineRepr, CurveGroup, VariableBaseMSM, pairing::Pairing}; -use ark_ff::{Field, PrimeField, Zero}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use p3_maybe_rayon::prelude::*; - -use super::{PublicSetup, Srs}; - -/// Official uncompressed phase-one output, approximately 72 GiB. -pub const FILECOIN_CHALLENGE_19_URL: &str = - "https://trusted-setup.filecoin.io/phase1/challenge_19"; -/// Avail's complete copy of the same file. Any mirror is acceptable: the import -/// authenticates the bytes against the published digest. -pub const FILECOIN_CHALLENGE_19_MIRROR_URL: &str = - "https://srs.availproject.org/challenge_19"; -/// Maximum supported honest polynomial length; this is not a public degree bound. -pub const FILECOIN_MAX_TRACE_LEN: usize = 1 << 27; -/// The ceremony publishes G1 powers with exponents `0..=2^28-2`. -pub const FILECOIN_PUBLIC_MAX_DEGREE: usize = (1 << 28) - 2; -/// BLAKE2b-512 of the complete artifact, including its 64-byte transcript header. -pub const FILECOIN_CHALLENGE_19_BLAKE2B: [u8; 64] = [ - 0x5a, 0x26, 0x01, 0x5b, 0xa2, 0x7d, 0x81, 0x64, 0x15, 0x24, 0x07, 0xda, 0x8f, - 0x9b, 0x87, 0xe4, 0x75, 0x93, 0xf1, 0x7a, 0xe4, 0xc2, 0x60, 0xe4, 0x67, 0xba, - 0xc2, 0xba, 0x9d, 0xda, 0x6f, 0x66, 0xc1, 0x5f, 0xa3, 0x52, 0x48, 0x76, 0x04, - 0xd1, 0x35, 0x0e, 0xf3, 0x3a, 0x3b, 0xfe, 0xdb, 0x0d, 0x99, 0xe3, 0x7b, 0x61, - 0x91, 0x61, 0xe2, 0x75, 0x45, 0x01, 0x73, 0x66, 0x27, 0x4d, 0xf7, 0x6b, -]; - -/// Manifest receipt of the complete `2^27` prefix cache that -/// [`import_filecoin_challenge`] produces from the authenticated source. The -/// import is deterministic, so an independent import reproduces this digest. -pub const FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3: [u8; 32] = [ - 0x14, 0x42, 0x38, 0x60, 0x05, 0xc4, 0xe4, 0xa2, 0xeb, 0xc2, 0x3c, 0x10, 0x96, - 0x94, 0xeb, 0x9b, 0xb1, 0x6f, 0xe5, 0x4b, 0xc5, 0x70, 0x8f, 0x03, 0x80, 0x65, - 0x8a, 0xc9, 0x9a, 0x37, 0xf1, 0x0a, -]; -/// Manifest receipt of the checked-in 256-power prefix cache at -/// [`FILECOIN_PREFIX_2P8_FIXTURE`]. -pub const FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3: [u8; 32] = [ - 0x18, 0x5c, 0x55, 0x6e, 0x5f, 0x5f, 0x0c, 0x67, 0xdd, 0xc0, 0xc8, 0xac, 0x21, - 0x0c, 0x8b, 0x11, 0xd1, 0xa4, 0x85, 0x74, 0xde, 0xfb, 0xc4, 0x42, 0x99, 0xdc, - 0x71, 0x27, 0x47, 0xa0, 0x50, 0x7d, -]; -/// The 256-power prefix cache imported from the authenticated source, enough -/// for tests of the real cache format without the 12 GiB `2^27` cache. -pub const FILECOIN_PREFIX_2P8_FIXTURE: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/fixtures/filecoin-prefix-2p8.bin" -); - -/// `τ·G` of the ceremony, big-endian uncompressed `x || y`, as read from the -/// authenticated source. Avail's independent extraction publishes the same -/// x-coordinate. -pub const FILECOIN_TAU_G1: [u8; 96] = [ - 0x0e, 0x3f, 0x32, 0x1e, 0x2b, 0xa2, 0x4e, 0x30, 0x25, 0x42, 0xa3, 0xec, 0xf8, - 0x84, 0x7e, 0x8e, 0x82, 0x0f, 0x9c, 0xa1, 0xb3, 0xa6, 0xa6, 0x3b, 0xae, 0x25, - 0x14, 0x0f, 0x21, 0x53, 0x44, 0x31, 0x4e, 0x1b, 0xfc, 0x9c, 0x36, 0xbf, 0x2c, - 0x3d, 0xcb, 0xe9, 0x4d, 0xec, 0x33, 0xd7, 0x1f, 0x3c, 0x16, 0x3a, 0x59, 0xba, - 0x33, 0xf7, 0x4f, 0xe5, 0x3c, 0xe0, 0x12, 0x18, 0x1a, 0xe8, 0xb9, 0xf2, 0x03, - 0x83, 0xeb, 0xd2, 0x89, 0xfb, 0x12, 0x72, 0x31, 0x49, 0x2e, 0x94, 0x9d, 0x3f, - 0xd7, 0x64, 0x0a, 0x2b, 0x49, 0xd0, 0x82, 0x24, 0xf6, 0x25, 0xec, 0x38, 0x35, - 0xa5, 0xff, 0x8f, 0xc1, 0x0d, -]; -/// `τ·H` of the ceremony, big-endian uncompressed `x || y` with each -/// coordinate as `c1 || c0`, as read from the authenticated source. Avail's -/// independent extraction publishes the same x-coordinate. -pub const FILECOIN_TAU_G2: [u8; 192] = [ - 0x11, 0x60, 0x10, 0xf1, 0xb3, 0xd3, 0x3a, 0xa5, 0xf3, 0xf6, 0x08, 0xc3, 0x85, - 0x40, 0x09, 0x79, 0xed, 0x36, 0xdc, 0x3f, 0xab, 0x53, 0xd3, 0x62, 0x6f, 0x51, - 0x9c, 0x74, 0xd7, 0xe1, 0xeb, 0xef, 0x62, 0xc9, 0xad, 0x35, 0x79, 0x64, 0x04, - 0xce, 0x2f, 0xc3, 0xeb, 0xb4, 0x21, 0x67, 0xb5, 0xa1, 0x05, 0x0a, 0xc2, 0x7c, - 0x35, 0x27, 0x07, 0x2b, 0x81, 0xaf, 0x0f, 0xa9, 0xa6, 0x83, 0xe2, 0x26, 0xda, - 0x79, 0x27, 0x5f, 0x29, 0x85, 0xc9, 0xe8, 0x9a, 0x0d, 0xb9, 0x69, 0xb7, 0x6e, - 0x0b, 0x6b, 0xe9, 0x4f, 0xee, 0x87, 0x4c, 0x48, 0x58, 0x67, 0x37, 0xad, 0x55, - 0x88, 0xcf, 0x1e, 0x10, 0x41, 0x19, 0x93, 0xb9, 0xf2, 0x9f, 0x67, 0xc4, 0x8d, - 0x2c, 0xbc, 0xb7, 0x6a, 0x6e, 0x31, 0xc3, 0x41, 0xc9, 0xda, 0x84, 0x6f, 0x9f, - 0xc9, 0x9c, 0xa4, 0x13, 0x0e, 0x2b, 0xea, 0x5f, 0x95, 0x80, 0x5b, 0x4b, 0x42, - 0xa4, 0x04, 0xa9, 0x9b, 0x9e, 0x3b, 0x08, 0x35, 0xa7, 0x25, 0xbd, 0x90, 0xae, - 0x66, 0x04, 0x4e, 0xda, 0x01, 0x71, 0x4f, 0x35, 0x7c, 0xb8, 0xc4, 0x1b, 0x01, - 0xf8, 0x9b, 0x03, 0x98, 0x13, 0x00, 0x96, 0xfb, 0x65, 0x4a, 0x9d, 0x23, 0xb0, - 0x35, 0x28, 0xba, 0x2d, 0x79, 0xa2, 0xe3, 0x76, 0x03, 0x8a, 0xa7, 0x0d, 0xbf, - 0xc9, 0x79, 0xb0, 0x81, 0x49, 0x72, 0x6e, 0xb1, 0x84, 0x1d, -]; - -const MAGIC: &[u8] = b"multi-stark/bls12-381/filecoin-srs/v1\0"; -const G1_BYTES: usize = 96; -const G2_BYTES: usize = 192; -const BLOCK_POINTS: usize = 1 << 18; -const HEADER_BYTES: usize = - MAGIC.len() + 64 + 3 * 8 + 2 * G1_BYTES + 2 * G2_BYTES; -static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); - -#[derive(Clone, Copy)] -struct ChallengeSpec { - power: u32, - digest: [u8; 64], - block_points: usize, - /// Whether the tau anchors must equal the pinned ceremony values. - pinned_anchors: bool, -} - -const FILECOIN: ChallengeSpec = ChallengeSpec { - power: 27, - digest: FILECOIN_CHALLENGE_19_BLAKE2B, - block_points: BLOCK_POINTS, - pinned_anchors: true, -}; - -impl ChallengeSpec { - fn max_len(self) -> usize { - 1usize << self.power - } - - fn max_degree(self) -> usize { - 2 * self.max_len() - 2 - } - - fn g2_offset(self) -> u64 { - 64 + (self.max_degree() as u64 + 1) * G1_BYTES as u64 - } - - fn source_bytes(self) -> u64 { - // Header, tau G1, tau G2, alpha G1, beta G1, and beta G2. - 64 + (self.max_degree() as u64 + 1) * G1_BYTES as u64 - + self.max_len() as u64 * (G2_BYTES + 2 * G1_BYTES) as u64 - + G2_BYTES as u64 - } - - fn setup_id(self) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"multi-stark/kzg/filecoin-challenge19/v1"); - hasher.update(&self.digest); - hasher.update(&(self.max_degree() as u64).to_le_bytes()); - *hasher.finalize().as_bytes() - } -} - -/// Stable ceremony identity, independent of retained prefix or cache layout. -pub fn filecoin_setup_id() -> [u8; 32] { - FILECOIN.setup_id() -} - -/// The verifier's view of the ceremony: `[G, τG]`, `H`, `τH` and the public -/// setup, built from the pinned constants without any cache file. Prover -/// prefixes loaded from a cache must agree with these anchors. -pub fn filecoin_anchors() -> Srs { - let tau_g1 = decode_g1(&FILECOIN_TAU_G1, true).expect("pinned tau G1"); - let tau_g2 = decode_g2(&FILECOIN_TAU_G2).expect("pinned tau G2"); - Srs { - g1: vec![G1Affine::generator(), tau_g1], - g2: G2Affine::generator(), - tau_g2, - public_setup: PublicSetup { - max_degree: FILECOIN.max_degree(), - id: FILECOIN.setup_id(), - }, - } -} - -/// A receipt for a successfully authenticated import. -/// -/// Pin `digest` in trusted prover/verifier configuration. It authenticates the -/// manifest, including the anchors and every G1 chunk digest, not merely a file -/// checksum declared by the cache itself. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct FilecoinCacheIdentity { - pub prefix_len: usize, - pub digest: [u8; 32], -} - -/// Validate stock `challenge_19` and atomically create a normalized prefix cache. -/// -/// This is one-time provisioning: it reads all 72 GiB of the source and performs -/// subgroup and batched progression checks for `prefix_len` G1 powers. Working -/// memory is bounded by a chunk, independent of the retained prefix length. The -/// destination must not exist. A failed import never publishes a usable cache. -pub fn import_filecoin_challenge( - source: &Path, - cache: &Path, - prefix_len: usize, -) -> io::Result { - import_challenge(source, cache, prefix_len, FILECOIN) -} - -/// Load a requested prefix using an externally pinned import receipt. -/// -/// `prefix_len == 2` reads only the small authenticated manifest. Larger prefixes -/// authenticate their chunks before decoding. Unrequested chunks are not read. -/// Subgroup and progression checks are inherited from the authenticated import; -/// reloads check canonical coordinates and curve membership, plus all four -/// manifest anchors. This avoids repeating an expensive provisioning operation. -pub fn load_filecoin_cache( - cache: &Path, - expected_manifest_digest: [u8; 32], - prefix_len: usize, -) -> io::Result { - load_cache(cache, expected_manifest_digest, prefix_len, FILECOIN) -} - -fn invalid( - message: impl Into>, -) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) -} - -fn validate_prefix(prefix_len: usize, spec: ChallengeSpec) -> io::Result<()> { - if prefix_len < 2 - || !prefix_len.is_power_of_two() - || prefix_len > spec.max_len() - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "Filecoin prefix must be a power of two in 2..={}", - spec.max_len() - ), - )); - } - Ok(()) -} - -fn decode_g1(bytes: &[u8], subgroup: bool) -> io::Result { - // Both Filecoin's pairing 0.16.2 and arkworks 0.5 use big-endian x || y, - // with the standard compression/infinity/sign flags in the first byte. - let point = - G1Affine::deserialize_uncompressed_unchecked(bytes).map_err(invalid)?; - if point.is_zero() - || !point.is_on_curve() - || (subgroup && !point.is_in_correct_subgroup_assuming_on_curve()) - { - return Err(invalid("invalid Filecoin G1 point")); - } - Ok(point) -} - -fn decode_g2(bytes: &[u8]) -> io::Result { - // The component order is x.c1 || x.c0 || y.c1 || y.c0, not Fq2 serde order. - let point = - G2Affine::deserialize_uncompressed_unchecked(bytes).map_err(invalid)?; - if point.is_zero() - || !point.is_on_curve() - || !point.is_in_correct_subgroup_assuming_on_curve() - { - return Err(invalid("invalid Filecoin G2 point")); - } - Ok(point) -} - -struct Anchors { - g1: [G1Affine; 2], - g2: [G2Affine; 2], -} - -impl Anchors { - fn read(source: &mut File, spec: ChallengeSpec) -> io::Result { - let mut g1 = [0; 2 * G1_BYTES]; - source.seek(SeekFrom::Start(64))?; - source.read_exact(&mut g1)?; - let mut g2 = [0; 2 * G2_BYTES]; - source.seek(SeekFrom::Start(spec.g2_offset()))?; - source.read_exact(&mut g2)?; - Self::decode(&g1, &g2, spec) - } - - fn decode(g1: &[u8], g2: &[u8], spec: ChallengeSpec) -> io::Result { - let anchors = Self { - g1: [ - decode_g1(&g1[..G1_BYTES], true)?, - decode_g1(&g1[G1_BYTES..], true)?, - ], - g2: [decode_g2(&g2[..G2_BYTES])?, decode_g2(&g2[G2_BYTES..])?], - }; - if anchors.g1[0] != G1Affine::generator() - || anchors.g2[0] != G2Affine::generator() - { - return Err(invalid("Filecoin generators do not match BLS12-381")); - } - if spec.pinned_anchors { - let pinned = filecoin_anchors(); - if anchors.g1[1] != pinned.g1[1] || anchors.g2[1] != pinned.tau_g2 { - return Err(invalid( - "Filecoin tau anchors differ from the pinned ceremony values", - )); - } - } - if !Bls12_381::multi_pairing( - [anchors.g1[1], -anchors.g1[0]], - [anchors.g2[0], anchors.g2[1]], - ) - .is_zero() - { - return Err(invalid("Filecoin G1/G2 tau anchors disagree")); - } - Ok(anchors) - } -} - -fn encode_header( - spec: ChallengeSpec, - prefix_len: usize, - anchors: &Anchors, -) -> io::Result> { - let mut header = Vec::with_capacity(HEADER_BYTES); - header.extend_from_slice(MAGIC); - header.extend_from_slice(&spec.digest); - header.extend_from_slice(&(spec.max_degree() as u64).to_le_bytes()); - header.extend_from_slice(&(prefix_len as u64).to_le_bytes()); - header.extend_from_slice(&(spec.block_points as u64).to_le_bytes()); - for point in &anchors.g1 { - point.serialize_uncompressed(&mut header).map_err(invalid)?; - } - for point in &anchors.g2 { - point.serialize_uncompressed(&mut header).map_err(invalid)?; - } - Ok(header) -} - -fn block_digest(index: usize, bytes: &[u8]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"multi-stark/kzg/filecoin-cache-block/v1"); - hasher.update(&(index as u64).to_le_bytes()); - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(bytes); - *hasher.finalize().as_bytes() -} - -fn manifest_digest(header: &[u8], index: &[u8]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"multi-stark/kzg/filecoin-cache-manifest/v1"); - hasher.update(header); - hasher.update(index); - *hasher.finalize().as_bytes() -} - -struct ProgressionCheck { - r: Fr, - next_power: Fr, - previous: Option, - low: G1Projective, - high: G1Projective, -} - -impl ProgressionCheck { - fn new(spec: ChallengeSpec, prefix_len: usize) -> Self { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"multi-stark/kzg/filecoin-prefix-progression/v1"); - hasher.update(&spec.digest); - hasher.update(&(prefix_len as u64).to_le_bytes()); - let mut reader = hasher.finalize_xof(); - let r = loop { - let mut wide = [0; 64]; - reader.fill(&mut wide); - let r = Fr::from_le_bytes_mod_order(&wide); - if !r.is_zero() { - break r; - } - }; - Self { - r, - next_power: Fr::ONE, - previous: None, - low: G1Projective::zero(), - high: G1Projective::zero(), - } - } - - fn absorb(&mut self, points: &[G1Affine]) { - if let Some(previous) = self.previous { - // This pair crosses chunk boundaries and cannot be omitted. - self.low += previous * self.next_power; - self.high += points[0] * self.next_power; - self.next_power *= self.r; - } - let scalars: Vec<_> = (0..points.len() - 1) - .map(|_| { - let scalar = self.next_power; - self.next_power *= self.r; - scalar - }) - .collect(); - self.low += G1Projective::msm(&points[..points.len() - 1], &scalars) - .expect("equal lengths"); - self.high += - G1Projective::msm(&points[1..], &scalars).expect("equal lengths"); - self.previous = points.last().copied(); - } - - fn verify(self, anchors: &Anchors) -> io::Result<()> { - if !Bls12_381::multi_pairing( - [self.high.into_affine(), (-self.low).into_affine()], - anchors.g2, - ) - .is_zero() - { - return Err(invalid("Filecoin G1 prefix is not one tau progression")); - } - Ok(()) - } -} - -struct Temporary(PathBuf); - -impl Temporary { - fn create(destination: &Path) -> io::Result<(Self, File)> { - if destination.try_exists()? { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "Filecoin cache already exists", - )); - } - loop { - let path = destination.with_extension(format!( - "{}.{}.partial", - std::process::id(), - NEXT_TEMP.fetch_add(1, Ordering::Relaxed) - )); - match OpenOptions::new().write(true).create_new(true).open(&path) { - Ok(file) => return Ok((Self(path), file)), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, - Err(error) => return Err(error), - } - } - } - - fn publish(&self, destination: &Path) -> io::Result<()> { - // A hard link is atomic and refuses to overwrite an existing receipt's file. - fs::hard_link(&self.0, destination) - } -} - -impl Drop for Temporary { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } -} - -fn hash_exact( - source: &mut File, - hasher: &mut blake2b_simd::State, - mut remaining: u64, - buffer: &mut [u8], -) -> io::Result<()> { - while remaining != 0 { - let count = usize::try_from(remaining.min(buffer.len() as u64)) - .expect("read length is bounded by the buffer"); - source.read_exact(&mut buffer[..count])?; - hasher.update(&buffer[..count]); - remaining -= count as u64; - } - Ok(()) -} - -fn import_challenge( - source: &Path, - cache: &Path, - prefix_len: usize, - spec: ChallengeSpec, -) -> io::Result { - validate_prefix(prefix_len, spec)?; - let mut source = File::open(source)?; - if source.metadata()?.len() != spec.source_bytes() { - return Err(invalid("Filecoin challenge has the wrong length")); - } - let anchors = Anchors::read(&mut source, spec)?; - let header = encode_header(spec, prefix_len, &anchors)?; - let (temporary, mut output) = Temporary::create(cache)?; - output.write_all(&header)?; - let mut index = - Vec::with_capacity(prefix_len.div_ceil(spec.block_points) * 32); - let mut hasher = blake2b_simd::State::new(); - let mut previous_digest = [0; 64]; - source.seek(SeekFrom::Start(0))?; - source.read_exact(&mut previous_digest)?; - hasher.update(&previous_digest); - let mut buffer = - vec![0; (prefix_len.min(spec.block_points) * G1_BYTES).max(1 << 20)]; - let mut progression = ProgressionCheck::new(spec, prefix_len); - for (block_index, start) in - (0..prefix_len).step_by(spec.block_points).enumerate() - { - let count = (prefix_len - start).min(spec.block_points); - let bytes = &mut buffer[..count * G1_BYTES]; - source.read_exact(bytes)?; - hasher.update(bytes); - let points = bytes - .par_chunks_exact(G1_BYTES) - .map(|bytes| decode_g1(bytes, true)) - .collect::>>()?; - if start == 0 && points[..2] != anchors.g1 { - return Err(invalid("Filecoin source anchors changed during import")); - } - progression.absorb(&points); - index.extend_from_slice(&block_digest(block_index, bytes)); - output.write_all(bytes)?; - } - let remaining_g1_bytes = - (spec.max_degree() + 1 - prefix_len) as u64 * G1_BYTES as u64; - hash_exact(&mut source, &mut hasher, remaining_g1_bytes, &mut buffer)?; - let mut g2_bytes = [0; 2 * G2_BYTES]; - source.read_exact(&mut g2_bytes)?; - hasher.update(&g2_bytes); - if g2_bytes != header[HEADER_BYTES - 2 * G2_BYTES..] { - return Err(invalid("Filecoin source G2 anchors changed during import")); - } - let remaining = - spec.source_bytes() - spec.g2_offset() - g2_bytes.len() as u64; - hash_exact(&mut source, &mut hasher, remaining, &mut buffer)?; - if source.read(&mut [0])? != 0 { - return Err(invalid("trailing bytes in Filecoin challenge")); - } - if hasher.finalize().as_bytes() != spec.digest { - return Err(invalid( - "Filecoin challenge does not match the published BLAKE2b digest", - )); - } - progression.verify(&anchors)?; - let digest = manifest_digest(&header, &index); - output.write_all(&index)?; - output.write_all(&digest)?; - output.sync_all()?; - drop(output); - temporary.publish(cache)?; - Ok(FilecoinCacheIdentity { prefix_len, digest }) -} - -fn load_cache( - cache: &Path, - expected_manifest_digest: [u8; 32], - prefix_len: usize, - spec: ChallengeSpec, -) -> io::Result { - validate_prefix(prefix_len, spec)?; - let mut file = File::open(cache)?; - let mut header = vec![0; HEADER_BYTES]; - file.read_exact(&mut header)?; - if !header.starts_with(MAGIC) - || header[MAGIC.len()..MAGIC.len() + 64] != spec.digest - { - return Err(invalid("Filecoin cache format or ceremony identity mismatch")); - } - let numbers = MAGIC.len() + 64; - let number = |offset: usize| { - u64::from_le_bytes( - header[numbers + offset..numbers + offset + 8].try_into().unwrap(), - ) - }; - if number(0) != spec.max_degree() as u64 - || number(16) != spec.block_points as u64 - { - return Err(invalid("Filecoin cache degree range or block size mismatch")); - } - let stored_len = usize::try_from(number(8)).map_err(invalid)?; - validate_prefix(stored_len, spec).map_err(invalid)?; - if prefix_len > stored_len { - return Err(invalid("Filecoin cache prefix is smaller than requested")); - } - let index_bytes = stored_len.div_ceil(spec.block_points) * 32; - let index_offset = HEADER_BYTES as u64 + stored_len as u64 * G1_BYTES as u64; - if file.metadata()?.len() != index_offset + index_bytes as u64 + 32 { - return Err(invalid("Filecoin cache has the wrong length")); - } - file.seek(SeekFrom::Start(index_offset))?; - let mut index = vec![0; index_bytes]; - file.read_exact(&mut index)?; - let mut declared_digest = [0; 32]; - file.read_exact(&mut declared_digest)?; - if declared_digest != expected_manifest_digest - || manifest_digest(&header, &index) != expected_manifest_digest - { - return Err(invalid( - "Filecoin cache does not match the pinned manifest digest", - )); - } - let g1_offset = numbers + 24; - let g2_offset = g1_offset + 2 * G1_BYTES; - let anchors = - Anchors::decode(&header[g1_offset..g2_offset], &header[g2_offset..], spec)?; - let mut g1 = Vec::new(); - g1.try_reserve_exact(prefix_len).map_err(io::Error::other)?; - if prefix_len == 2 { - g1.extend_from_slice(&anchors.g1); - } else { - file.seek(SeekFrom::Start(HEADER_BYTES as u64))?; - let mut buffer = vec![0; stored_len.min(spec.block_points) * G1_BYTES]; - for block_index in 0..prefix_len.div_ceil(spec.block_points) { - let start = block_index * spec.block_points; - let count = (stored_len - start).min(spec.block_points); - let bytes = &mut buffer[..count * G1_BYTES]; - file.read_exact(bytes)?; - if block_digest(block_index, bytes) - != index[block_index * 32..(block_index + 1) * 32] - { - return Err(invalid(format!( - "Filecoin cache chunk {block_index} digest mismatch" - ))); - } - let needed = (prefix_len - start).min(count); - let points = bytes[..needed * G1_BYTES] - .par_chunks_exact(G1_BYTES) - .map(|bytes| decode_g1(bytes, false)) - .collect::>>()?; - g1.extend(points); - } - if g1[..2] != anchors.g1 { - return Err(invalid( - "Filecoin cache G1 anchors disagree with its prefix", - )); - } - } - Ok(Srs { - g1, - g2: anchors.g2[0], - tau_g2: anchors.g2[1], - public_setup: PublicSetup { - max_degree: spec.max_degree(), - id: spec.setup_id(), - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use ark_bls12_381::{Fq, Fq2, G2Projective}; - use ark_ec::PrimeGroup; - use ark_ff::BigInteger; - - struct Fixture { - directory: PathBuf, - source: PathBuf, - cache: PathBuf, - spec: ChallengeSpec, - points: Vec, - } - - impl Fixture { - fn new() -> Self { - let directory = std::env::temp_dir().join(format!( - "ix-filecoin-import-{}-{}", - std::process::id(), - NEXT_TEMP.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&directory).unwrap(); - let mut spec = ChallengeSpec { - power: 3, - digest: [0; 64], - block_points: 4, - pinned_anchors: false, - }; - let source = directory.join("challenge"); - let cache = directory.join("cache"); - let tau = Fr::from(7u64); - let mut bytes = vec![42; 64]; - let points: Vec<_> = (0..=spec.max_degree()) - .map(|i| { - (G1Projective::generator() * tau.pow([i as u64])).into_affine() - }) - .collect(); - for point in &points { - point.serialize_uncompressed(&mut bytes).unwrap(); - } - for i in 0..spec.max_len() { - (G2Projective::generator() * tau.pow([i as u64])) - .into_affine() - .serialize_uncompressed(&mut bytes) - .unwrap(); - } - for scale in [Fr::from(11u64), Fr::from(13u64)] { - for point in points.iter().take(spec.max_len()) { - (*point * scale) - .into_affine() - .serialize_uncompressed(&mut bytes) - .unwrap(); - } - } - (G2Projective::generator() * Fr::from(13u64)) - .into_affine() - .serialize_uncompressed(&mut bytes) - .unwrap(); - assert_eq!(bytes.len() as u64, spec.source_bytes()); - spec.digest = *blake2b_simd::blake2b(&bytes).as_array(); - fs::write(&source, bytes).unwrap(); - Self { directory, source, cache, spec, points } - } - - fn repin(&mut self) { - self.spec.digest = - *blake2b_simd::blake2b(&fs::read(&self.source).unwrap()).as_array(); - } - - fn import(&self) -> io::Result { - import_challenge( - &self.source, - &self.cache, - self.spec.max_len(), - self.spec, - ) - } - } - - impl Drop for Fixture { - fn drop(&mut self) { - fs::remove_dir_all(&self.directory).unwrap(); - } - } - - fn hex(input: &str) -> [u8; N] { - assert_eq!(input.len(), N * 2); - std::array::from_fn(|i| { - u8::from_str_radix(&input[2 * i..2 * i + 2], 16).unwrap() - }) - } - - #[test] - fn checked_in_prefix_cache_matches_its_pinned_receipt() { - let path = Path::new(FILECOIN_PREFIX_2P8_FIXTURE); - let srs = - load_filecoin_cache(path, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, 256) - .unwrap(); - let anchors = filecoin_anchors(); - assert_eq!(srs.max_len(), 256); - assert_eq!(srs.g1[..2], anchors.g1[..]); - assert_eq!(srs.g2, anchors.g2); - assert_eq!(srs.tau_g2, anchors.tau_g2); - assert_eq!(srs.public_setup, anchors.public_setup); - srs.validate().unwrap(); - let two = load_filecoin_cache(path, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, 2) - .unwrap(); - assert_eq!(two.g1, anchors.g1); - assert!( - load_filecoin_cache(path, FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3, 256) - .is_err() - ); - assert!( - load_filecoin_cache(path, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, 512) - .is_err() - ); - } - - #[test] - fn pinned_tau_anchors_are_one_tau_progression() { - let anchors = filecoin_anchors(); - assert_eq!(anchors.max_len(), 2); - assert!( - Bls12_381::multi_pairing( - [anchors.g1[1], -anchors.g1[0]], - [anchors.g2, anchors.tau_g2], - ) - .is_zero() - ); - assert_eq!(anchors.public_setup.id, filecoin_setup_id()); - assert_eq!(anchors.public_setup.max_degree, FILECOIN_PUBLIC_MAX_DEGREE); - let mut altered = FILECOIN_TAU_G1; - altered[95] ^= 1; - assert!(decode_g1(&altered, true).is_err()); - } - - #[test] - fn pinned_filecoin_encoding_matches_pairing_generator_vectors() { - let g1 = hex::<96>(concat!( - "17f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb", - "08b3f481e3aaa0f1a09e30ed741d8ae4fcf5e095d5d00af600db18cb2c04b3edd03cc744a2888ae40caa232946c5e7e1", - )); - let g2 = hex::<192>(concat!( - "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e", - "024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8", - "0606c4a02ea734cc32acd2b02bc28b99cb3e287e85a763af267492ab572e99ab3f370d275cec1da1aaa9075ff05f79be", - "0ce5d527727d6e118cc9cdc6da2e351aadfd9baa8cbdd3a76d429a695160d12c923ac9cc3baca289e193548608b82801", - )); - assert_eq!(decode_g1(&g1, true).unwrap(), G1Affine::generator()); - assert_eq!(decode_g2(&g2).unwrap(), G2Affine::generator()); - let mut serialized = Vec::new(); - G1Affine::generator().serialize_uncompressed(&mut serialized).unwrap(); - assert_eq!(serialized, g1); - serialized.clear(); - G2Affine::generator().serialize_uncompressed(&mut serialized).unwrap(); - assert_eq!(serialized, g2); - let mut swapped = g2; - swapped[..48].copy_from_slice(&g2[48..96]); - swapped[48..96].copy_from_slice(&g2[..48]); - assert!(decode_g2(&swapped).is_err()); - } - - #[test] - fn source_pin_and_public_range_are_exact() { - assert_eq!( - FILECOIN_CHALLENGE_19_BLAKE2B, - hex::<64>(concat!( - "5a26015ba27d8164152407da8f9b87e47593f17ae4c260e467bac2ba9dda6f66c", - "15fa352487604d1350ef33a3bfedb0d99e37b619161e27545017366274df76b", - )) - ); - assert_eq!(FILECOIN.max_degree(), FILECOIN_PUBLIC_MAX_DEGREE); - assert_eq!(FILECOIN.source_bytes(), 77_309_411_488); - assert_eq!(FILECOIN.max_len(), FILECOIN_MAX_TRACE_LEN); - } - - #[test] - fn authenticated_prefix_and_manifest_only_loads_preserve_public_degree() { - let fixture = Fixture::new(); - let receipt = fixture.import().unwrap(); - for prefix_len in [2, 4, 8] { - let srs = - load_cache(&fixture.cache, receipt.digest, prefix_len, fixture.spec) - .unwrap(); - assert_eq!(srs.g1, fixture.points[..prefix_len]); - let setup = srs.public_setup; - assert_eq!(setup.max_degree, 14); - assert_eq!(setup.id, fixture.spec.setup_id()); - } - assert!(fixture.import().is_err()); - assert!(load_filecoin_cache(&fixture.cache, receipt.digest, 2).is_err()); - } - - #[test] - fn incorrect_source_pin_and_changed_unused_tail_never_publish_cache() { - let fixture = Fixture::new(); - let mut bytes = fs::read(&fixture.source).unwrap(); - *bytes.last_mut().unwrap() ^= 1; - fs::write(&fixture.source, bytes).unwrap(); - assert!(fixture.import().unwrap_err().to_string().contains("BLAKE2b")); - assert!(!fixture.cache.exists()); - assert_eq!(fs::read_dir(&fixture.directory).unwrap().count(), 1); - } - - #[test] - fn malformed_source_length_is_rejected_before_allocation() { - let fixture = Fixture::new(); - let original = fs::read(&fixture.source).unwrap(); - for bad in [ - original[..original.len() - 1].to_vec(), - [original.as_slice(), &[0]].concat(), - ] { - fs::write(&fixture.source, bad).unwrap(); - assert!( - fixture.import().unwrap_err().to_string().contains("wrong length") - ); - assert!(!fixture.cache.exists()); - } - for prefix in [0, 1, 3, 16, usize::MAX] { - assert!( - import_challenge(&fixture.source, &fixture.cache, prefix, fixture.spec) - .is_err() - ); - } - } - - #[test] - fn checked_decoding_rejects_flags_noncanonical_coordinates_curve_and_subgroup_errors() - { - let mut generator = Vec::new(); - G1Affine::generator().serialize_uncompressed(&mut generator).unwrap(); - for flag in [0x20, 0x40, 0x80] { - let mut bytes = generator.clone(); - bytes[0] |= flag; - assert!(decode_g1(&bytes, true).is_err()); - } - let mut noncanonical = generator; - noncanonical[..48].copy_from_slice(&Fq::MODULUS.to_bytes_be()); - assert!(decode_g1(&noncanonical, true).is_err()); - let mut identity = [0; G1_BYTES]; - identity[0] = 0x40; - assert!(decode_g1(&identity, true).is_err()); - let mut invalid_curve = [0; G1_BYTES]; - invalid_curve[47] = 1; - invalid_curve[95] = 1; - assert!(decode_g1(&invalid_curve, true).is_err()); - let torsion = G1Affine::new_unchecked(Fq::zero(), Fq::from(2u64)); - assert!(torsion.is_on_curve()); - assert!(!torsion.is_in_correct_subgroup_assuming_on_curve()); - let mut bytes = Vec::new(); - torsion.serialize_uncompressed(&mut bytes).unwrap(); - assert!(decode_g1(&bytes, true).is_err()); - assert_eq!(decode_g1(&bytes, false).unwrap(), torsion); - } - - #[test] - fn source_interior_subgroup_failure_never_publishes_cache() { - let mut fixture = Fixture::new(); - let mut bytes = fs::read(&fixture.source).unwrap(); - let mut encoded = Vec::new(); - G1Affine::new_unchecked(Fq::zero(), Fq::from(2u64)) - .serialize_uncompressed(&mut encoded) - .unwrap(); - bytes[64 + 3 * G1_BYTES..64 + 4 * G1_BYTES].copy_from_slice(&encoded); - fs::write(&fixture.source, bytes).unwrap(); - fixture.repin(); - assert!(fixture.import().unwrap_err().to_string().contains("G1 point")); - assert!(!fixture.cache.exists()); - } - - #[test] - fn g2_decoding_rejects_identity_flags_noncanonical_and_wrong_subgroup() { - let mut bytes = Vec::new(); - G2Affine::generator().serialize_uncompressed(&mut bytes).unwrap(); - for flag in [0x20, 0x40, 0x80] { - let mut invalid = bytes.clone(); - invalid[0] |= flag; - assert!(decode_g2(&invalid).is_err()); - } - bytes[..48].copy_from_slice(&Fq::MODULUS.to_bytes_be()); - assert!(decode_g2(&bytes).is_err()); - let mut identity = [0; G2_BYTES]; - identity[0] = 0x40; - assert!(decode_g2(&identity).is_err()); - let outside_subgroup = (0u64..) - .filter_map(|x| G2Affine::get_point_from_x_unchecked(Fq2::from(x), false)) - .find(|point| !point.is_in_correct_subgroup_assuming_on_curve()) - .unwrap(); - assert!(outside_subgroup.is_on_curve()); - bytes.clear(); - outside_subgroup.serialize_uncompressed(&mut bytes).unwrap(); - assert!(decode_g2(&bytes).is_err()); - } - - #[test] - fn progression_includes_chunk_boundaries() { - let mut fixture = Fixture::new(); - let mut bytes = fs::read(&fixture.source).unwrap(); - for i in 4..8 { - let mut encoded = Vec::new(); - (fixture.points[i] * Fr::from(2u64)) - .into_affine() - .serialize_uncompressed(&mut encoded) - .unwrap(); - bytes[64 + i * G1_BYTES..64 + (i + 1) * G1_BYTES] - .copy_from_slice(&encoded); - } - fs::write(&fixture.source, bytes).unwrap(); - fixture.repin(); - assert!(fixture.import().unwrap_err().to_string().contains("progression")); - assert!(!fixture.cache.exists()); - } - - #[test] - fn cache_rejects_wrong_receipt_header_index_and_requested_chunk() { - let fixture = Fixture::new(); - let receipt = fixture.import().unwrap(); - assert!(load_cache(&fixture.cache, [0; 32], 8, fixture.spec).is_err()); - let original = fs::read(&fixture.cache).unwrap(); - for offset in [ - 0, - MAGIC.len(), - MAGIC.len() + 64, - HEADER_BYTES - 1, - HEADER_BYTES, - HEADER_BYTES + 8 * G1_BYTES, - original.len() - 1, - ] { - let mut bytes = original.clone(); - bytes[offset] ^= 1; - fs::write(&fixture.cache, bytes).unwrap(); - assert!( - load_cache(&fixture.cache, receipt.digest, 8, fixture.spec).is_err(), - "offset {offset}" - ); - } - for bad in [ - original[..original.len() - 1].to_vec(), - [original.as_slice(), &[0]].concat(), - ] { - fs::write(&fixture.cache, bad).unwrap(); - assert!( - load_cache(&fixture.cache, receipt.digest, 8, fixture.spec).is_err() - ); - } - } - - #[test] - fn prefix_reads_authenticate_needed_chunks_without_reading_unused_tail() { - let fixture = Fixture::new(); - let receipt = fixture.import().unwrap(); - let mut bytes = fs::read(&fixture.cache).unwrap(); - bytes[HEADER_BYTES + 4 * G1_BYTES] ^= 1; - fs::write(&fixture.cache, bytes).unwrap(); - assert!( - load_cache(&fixture.cache, receipt.digest, 2, fixture.spec).is_ok() - ); - assert!( - load_cache(&fixture.cache, receipt.digest, 4, fixture.spec).is_ok() - ); - assert!( - load_cache(&fixture.cache, receipt.digest, 8, fixture.spec).is_err() - ); - } -} diff --git a/crates/proof-compression/src/kzg/srs_cache.rs b/crates/proof-compression/src/kzg/srs_cache.rs index 51fa07b15..f98b44327 100644 --- a/crates/proof-compression/src/kzg/srs_cache.rs +++ b/crates/proof-compression/src/kzg/srs_cache.rs @@ -21,9 +21,6 @@ use std::{ time::Instant, }; -#[cfg(not(feature = "bn254"))] -const MAGIC: &[u8] = b"ix/bls12-381/dev-srs/v1\0"; -#[cfg(feature = "bn254")] const MAGIC: &[u8] = b"ix/bn254/dev-srs/v1\0"; const BLOCK_POINTS: usize = 1 << 18; static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); @@ -265,11 +262,8 @@ mod directory_tests { let mut legacy_header = b"ix/bls12-381/dev-srs/v1\0".to_vec(); legacy_header.extend_from_slice(&16u64.to_le_bytes()); legacy_header.extend_from_slice(blake3::hash(seed).as_bytes()); - assert_eq!(header(16, seed) == legacy_header, !cfg!(feature = "bn254")); - assert_eq!( - blake3::hash(&header(16, seed)) == blake3::hash(&legacy_header), - !cfg!(feature = "bn254") - ); + assert_ne!(header(16, seed), legacy_header); + assert_ne!(blake3::hash(&header(16, seed)), blake3::hash(&legacy_header)); } #[test] diff --git a/crates/proof-compression/src/kzg/transcript.rs b/crates/proof-compression/src/kzg/transcript.rs index 183fed576..ca7b680c2 100644 --- a/crates/proof-compression/src/kzg/transcript.rs +++ b/crates/proof-compression/src/kzg/transcript.rs @@ -169,9 +169,6 @@ impl Transcript for Blake3Transcript { } } -#[cfg(not(feature = "bn254"))] -const KECCAK256_DOMAIN: &[u8] = b"multi-stark/kzg/keccak256/v1"; -#[cfg(feature = "bn254")] const KECCAK256_DOMAIN: &[u8] = b"ix/kzg/bn254/keccak256/v1"; fn keccak256(parts: &[&[u8]]) -> [u8; 32] { @@ -357,47 +354,6 @@ mod tests { ); } - #[test] - #[cfg(not(feature = "bn254"))] - fn keccak_transcript_matches_independent_vectors() { - // PyCryptodome 3.23.0 Crypto.Hash.keccak, digest_bits=256, independently - // generates each digest from the documented concatenation. Challenges - // are int.from_bytes(digest0 + digest1, "big") % Fr::MODULUS; the field - // observations 7 and 42 are 32-byte little-endian canonical encodings. - let mut t = Keccak256Transcript::new(); - t.observe_bytes(b"kzg-vector-v1"); - t.observe_field(Scalar::from_u64(7)); - t.observe_bytes(&(0u8..160).collect::>()); - - let vectors = [ - ( - "2e3429e71d42335d9ea681622711b7ef2468c1db8c0245dd805deed360bbcf25", - "c400066185add659881d303d8b0cd914429b2b1ba9e0addc4a50dacaf049d608", - ), - ( - "1f261b89afb3b219e30be06c7518ccb5038337b3168ed83bdba29a36699b5c27", - "510f0c6412d976b7224d04cac918bd5fcbef2ca2dfe08f7aa371be709e9a71e5", - ), - ( - "67e1777e4d2a526c9c905e84eaf3cc6b6e0764c42dc7bcff1c8e6dee350e6fdc", - "64c9ce0b0dc5ff29a58487ebd39827848fae8c0e46e7850c64e2e2f5cbbd2475", - ), - ]; - for (round, (challenge, state)) in vectors.iter().enumerate() { - if round == 2 { - t.observe_bytes(b"after-squeeze"); - t.observe_canonical(&Fr::from(42u64)); - } - assert_eq!( - hex(&t.sample_challenge().0.into_bigint().to_bytes_be()), - *challenge, - "round {round} challenge" - ); - assert_eq!(hex(&t.state), *state, "round {round} state"); - assert!(t.buffer.is_empty()); - } - } - #[test] fn hash_kinds_and_expansion_domains_are_distinct() { let mut blake3 = KzgTranscript::new(TranscriptKind::Blake3); diff --git a/crates/proof-compression/src/kzg/verifier_params.rs b/crates/proof-compression/src/kzg/verifier_params.rs index 0b54e86ff..5c5a40ebc 100644 --- a/crates/proof-compression/src/kzg/verifier_params.rs +++ b/crates/proof-compression/src/kzg/verifier_params.rs @@ -8,9 +8,6 @@ use ark_serialize::{ }; use multi_stark::traits::TwoAdicField; -#[cfg(not(feature = "bn254"))] -const VERSION: u64 = 4; -#[cfg(feature = "bn254")] const VERSION: u64 = 0x424e0001; #[derive(Clone)] @@ -114,10 +111,7 @@ mod tests { #[test] fn trace_cap_respects_scalar_two_adicity() { - #[cfg(feature = "bn254")] assert_eq!(Scalar::TWO_ADICITY, 28); - #[cfg(not(feature = "bn254"))] - assert_eq!(Scalar::TWO_ADICITY, 32); let Some(max_cap) = 1usize.checked_shl(u32::try_from(Scalar::TWO_ADICITY).unwrap()) else { @@ -150,8 +144,7 @@ mod tests { 3 * 8 + 32 + 2 * G1_COMPRESSED_BYTES + 2 * G2_COMPRESSED_BYTES ); assert_eq!(&encoded[..8], &VERSION.to_le_bytes()); - let other_curve_version: u64 = - if cfg!(feature = "bn254") { 4 } else { 0x424e0001 }; + let other_curve_version: u64 = 4; encoded[..8].copy_from_slice(&other_curve_version.to_le_bytes()); assert!(VerifierParams::from_bytes(&encoded).is_err()); } diff --git a/crates/proof-compression/src/lib.rs b/crates/proof-compression/src/lib.rs index c9ff74830..ab9cbe72f 100644 --- a/crates/proof-compression/src/lib.rs +++ b/crates/proof-compression/src/lib.rs @@ -1,8 +1,4 @@ //! FRI and KZG proof compression for Ix. -#[cfg(all(feature = "bn254", feature = "bls12-381"))] -compile_error!( - "select one KZG curve: BN254 is the default; BLS12-381 needs --no-default-features --features bls12-381" -); mod circuit_metadata; pub mod foreign; diff --git a/crates/proof-compression/src/pipeline/driver.rs b/crates/proof-compression/src/pipeline/driver.rs index b34136608..f5bf7da24 100644 --- a/crates/proof-compression/src/pipeline/driver.rs +++ b/crates/proof-compression/src/pipeline/driver.rs @@ -62,7 +62,6 @@ fn publish( let vk_path = out.join("final-kzg/vk.bin"); let vk = fs::read(&vk_path)?; let vk_digest = *blake3::hash(&vk).as_bytes(); - #[cfg(feature = "bn254")] let verified_setup = if let Some(admission) = setup.ignition_admission() { super::verify_with_ignition_admission( envelope, claim, expected, &vk, &vk_digest, admission, @@ -77,15 +76,6 @@ fn publish( setup.class() == SetupClass::Development, )? }; - #[cfg(not(feature = "bn254"))] - let verified_setup = super::verify_with_vk( - envelope, - claim, - expected, - &vk, - &vk_digest, - setup.class() == SetupClass::Development, - )?; if verified_setup != setup.class() { return Err("completed proof differs from the selected setup class".into()); } @@ -157,7 +147,6 @@ pub(super) fn run(args: &[String]) -> Result<()> { )?; publish(out, &envelope, &claim, &expected, "fri", &setup) }, - #[cfg(feature = "bn254")] ( "verify-ignition", [proof, claim, expected, vk, trusted, admission, admitted], diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index 7a62b6dab..0205a893a 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -231,11 +231,8 @@ fn prove( .prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); // The label is part of every published verifying key id and predates the - // Filecoin setup; the setup class is bound through the transcript seed. + // Ignition setup; the setup class is bound through the transcript seed. let mut vk = blake3::Hasher::new(); - #[cfg(not(feature = "bn254"))] - vk.update(b"init-fri-kzg-ordinary-v1/development-srs/quotient2"); - #[cfg(feature = "bn254")] vk.update(b"init-fri-kzg-bn254/v1/development-srs/quotient2"); vk.update(system.config.transcript_seed()); vk.update(&plan_id); diff --git a/crates/proof-compression/src/pipeline/fixed_cache.rs b/crates/proof-compression/src/pipeline/fixed_cache.rs index 31ff5d963..72d2a6526 100644 --- a/crates/proof-compression/src/pipeline/fixed_cache.rs +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -59,9 +59,6 @@ impl FixedCache { /// dependency versions, SRS recipe, and all circuit implementation choices. pub(crate) fn from_identity(root: &Path, vk: &[u8]) -> Result { let mut hasher = blake3::Hasher::new(); - #[cfg(not(feature = "bn254"))] - hasher.update(b"ix/kzg-fixed-cache/v5"); - #[cfg(feature = "bn254")] hasher.update(b"ix/kzg-fixed-cache/bn254/v1"); hasher.update(&executable_digest()?); hasher.update(vk); diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index e3ae4da5d..042998af8 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -28,9 +28,6 @@ use std::{ process::{Command, Stdio}, }; -#[cfg(not(feature = "bn254"))] -pub const PROOF_TAG: &[u8; 8] = b"IXCMP\0\0\x04"; -#[cfg(feature = "bn254")] pub const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x01"; pub type Result = std::result::Result>; @@ -322,7 +319,6 @@ pub fn verify_with_vk( /// Verify with an independently authorized verifying key and Ignition admission. /// Both outer and deferred pairing keys must match the admitted parameters. /// No proving cache or setup environment variables are read. -#[cfg(feature = "bn254")] pub fn verify_with_ignition_admission( proof: &[u8], claim: &[u8], @@ -398,10 +394,7 @@ fn validate_compression_options( .into(), ); } - #[cfg(feature = "bn254")] let ignition = setup.ignition_admission().is_some(); - #[cfg(not(feature = "bn254"))] - let ignition = false; check_worker_curve(ignition)?; Ok(setup) } @@ -511,14 +504,11 @@ fn finish_kzg( let packet = &result[PROOF_TAG.len()..]; let vk = wrap::VerifyingKey::from_bytes(&fs::read(final_dir.join("vk.bin"))?)?; - #[cfg(feature = "bn254")] if let Some(admission) = setup.ignition_admission() { vk.check_ignition_admission(admission)?; } else { vk.check_setup_class(setup.class())?; } - #[cfg(not(feature = "bn254"))] - vk.check_setup_class(setup.class())?; vk.verify(packet, words)?; // A bundled verifying key pins the aggregation program its proofs attest; the // backend this run proved under must be that program, or the bundle is @@ -574,7 +564,7 @@ mod tests { .unwrap() }; check_worker_capabilities(&capabilities(crate::kzg::curve::NAME)).unwrap(); - let other = if cfg!(feature = "bn254") { "bls12-381" } else { "bn254" }; + let other = "bls12-381"; assert!(check_worker_capabilities(&capabilities(other)).is_err()); assert!(check_worker_capabilities(b"{}").is_err()); assert!(check_worker_capabilities(b"not-json").is_err()); @@ -602,11 +592,7 @@ mod tests { fn explicit_vk_verification_rejects_other_curve_envelopes() { let expected = statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); - let other = if cfg!(feature = "bn254") { - b"IXCMP\0\0\x04" - } else { - b"IXBN\0\0\0\x01" - }; + let other = b"IXCMP\0\0\x04"; let error = verify_with_vk(other, b"claim", &expected, b"", &[0; 32], true) .unwrap_err(); assert!(error.to_string().contains("curve or version")); diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 6f42f04ba..e412ce2cd 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -5,8 +5,6 @@ use super::{ statement, storage, wrap, }; use ark_ff::{BigInteger, PrimeField}; -#[cfg(not(feature = "bn254"))] -use ark_serialize::CanonicalSerialize; use sha2::{Digest, Sha256}; use std::{ fs, @@ -15,22 +13,10 @@ use std::{ process::{Command, Stdio}, }; -#[cfg(not(feature = "bn254"))] -pub(super) const PROOF_TAG: &[u8; 8] = b"IXCMP\0\0\x05"; -#[cfg(feature = "bn254")] pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; -const HELPER: &str = if cfg!(feature = "bn254") { - "ix-bn254-plonk-wrapper" -} else { - "ix-bls-plonk-wrapper" -}; -const CATALOG_FORMAT: &str = if cfg!(feature = "bn254") { - "ix-bn254-plonk-verifiers-v1" -} else { - "ix-bls-plonk-verifiers-v1" -}; -const SETUP_NAME: &str = - if cfg!(feature = "bn254") { "ignition" } else { "filecoin" }; +const HELPER: &str = "ix-bn254-plonk-wrapper"; +const CATALOG_FORMAT: &str = "ix-bn254-plonk-verifiers-v1"; +const SETUP_NAME: &str = "ignition"; const CATALOG_VAR: &str = "IX_PLONK_VERIFIER_CATALOG"; const MAX_PROOF_BYTES: usize = 1 << 20; @@ -145,9 +131,6 @@ fn checked(command: &mut Command) -> Result<()> { fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { match config { - #[cfg(not(feature = "bn254"))] - SetupConfig::Filecoin { manifest, .. } => Ok(*manifest), - #[cfg(feature = "bn254")] SetupConfig::Ignition { manifest, .. } => Ok(*manifest), _ => Err("PLONK requires authenticated ceremony parameters".into()), } @@ -155,17 +138,11 @@ fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { fn anchors(config: &SetupConfig) -> Result { setup_manifest(config)?; - #[cfg(not(feature = "bn254"))] - return Ok(crate::kzg::srs::filecoin::filecoin_anchors()); - #[cfg(feature = "bn254")] Ok(config.ignition_admission().ok_or("missing Ignition admission")?.anchors()) } fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { let mut bytes = Vec::new(); - #[cfg(not(feature = "bn254"))] - point.serialize_compressed(&mut bytes).expect("Vec serialization"); - #[cfg(feature = "bn254")] for value in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { bytes.extend(value.into_bigint().to_bytes_be()); } @@ -174,9 +151,6 @@ fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { fn anchor_encoding(srs: &crate::kzg::Srs) -> String { let mut bytes = Vec::new(); - #[cfg(not(feature = "bn254"))] - srs.g1[0].serialize_compressed(&mut bytes).expect("Vec serialization"); - #[cfg(feature = "bn254")] for value in [srs.g1[0].x, srs.g1[0].y] { bytes.extend(value.into_bigint().to_bytes_be()); } @@ -185,14 +159,7 @@ fn anchor_encoding(srs: &crate::kzg::Srs) -> String { hex(&bytes) } -#[cfg_attr(not(feature = "bn254"), allow(clippy::unnecessary_wraps))] fn key_anchors(key: &TrustedKey) -> Result { - #[cfg(not(feature = "bn254"))] - { - let _ = key; - Ok(crate::kzg::srs::filecoin::filecoin_anchors()) - } - #[cfg(feature = "bn254")] { Ok( crate::kzg::srs::ignition::IgnitionAdmission::read( @@ -227,7 +194,6 @@ fn select_key(path: &Path, id: &[u8; 32]) -> Result { if key.setup != SETUP_NAME { return Err("PLONK catalog setup class differs".into()); } - #[cfg(feature = "bn254")] { digest( key.admission_blake3.as_ref().ok_or("missing trusted admission pin")?, @@ -285,9 +251,6 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { .arg(blake3::hash(claim).to_hex().as_str()) .arg(anchor_encoding(&key_anchors(&key)?)), )?; - #[cfg(not(feature = "bn254"))] - return Ok(SetupClass::Filecoin); - #[cfg(feature = "bn254")] Ok(SetupClass::Ignition) } @@ -311,15 +274,6 @@ pub(crate) fn run(args: &[String]) -> Result<()> { wrap::plonk_export_first(Path::new(stage), &expected, Path::new(out), development) }, ["srs", domain, out] => export_srs(domain.parse()?, Path::new(out)), - #[cfg(not(feature = "bn254"))] - ["vk-blake3", source, trusted, out] => { - let source = fs::read(source)?; - if *blake3::hash(&source).as_bytes() != digest(trusted)? { return Err("trusted source verifying key digest differs".into()); } - let (bytes, id) = wrap::VerifyingKey::reencode_blake3(&source)?; - storage::write_atomic(Path::new(out), &bytes)?; - eprintln!("vk_id={} verifier_blake3={}", hex(&id), blake3::hash(&bytes).to_hex()); - Ok(()) - }, ["wrap", verifier, packet, trusted, expected, cache, out] => { let expected = load_json(Path::new(expected))?; wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(cache), Path::new(out)) @@ -344,18 +298,14 @@ fn export_parameters( let prefix = count.checked_next_power_of_two().ok_or("PLONK SRS length overflow")?; let manifest = setup_manifest(config)?; - let source = config.for_stage(b"ix/bls-plonk/srs/v1", prefix)?; + let source = config.for_stage(b"ix/bn254-plonk/srs/v1", prefix)?; let srs = source.srs(prefix, false)?; let temporary = storage::temporary_path(path); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut out = std::io::BufWriter::new(fs::File::create(&temporary)?); - out.write_all(if cfg!(feature = "bn254") { - b"IXBPSR01" - } else { - b"IXPSRS01" - })?; + out.write_all(b"IXBPSR01")?; out.write_all(&srs.public_setup.id)?; out.write_all(&(srs.public_setup.max_degree as u64).to_le_bytes())?; out.write_all(&manifest)?; @@ -372,10 +322,10 @@ fn export_parameters( let mut hash = blake3::Hasher::new(); hash.update_mmap_rayon(path)?; let receipt = serde_json::json!({ - "format": if cfg!(feature = "bn254") { "ix-bn254-plonk-srs-export-v1" } else { "ix-plonk-srs-export-v1" }, "blake3": hash.finalize().to_hex().as_str(), + "format": "ix-bn254-plonk-srs-export-v1", "blake3": hash.finalize().to_hex().as_str(), "setupId": blake3::Hash::from_bytes(srs.public_setup.id).to_hex().as_str(), "publicMaxDegree": srs.public_setup.max_degree, "powers": count, - (if cfg!(feature = "bn254") { "ignitionManifest" } else { "filecoinManifest" }): blake3::Hash::from_bytes(manifest).to_hex().as_str(), + ("ignitionManifest"): blake3::Hash::from_bytes(manifest).to_hex().as_str(), }); storage::write_atomic( &path.with_extension("json"), @@ -392,7 +342,7 @@ struct ParameterReceipt { setup_id: String, public_max_degree: usize, powers: usize, - #[serde(rename = "filecoinManifest", alias = "ignitionManifest")] + #[serde(rename = "ignitionManifest")] setup_manifest: String, } @@ -414,12 +364,7 @@ fn cached_parameters( export_parameters(domain, &path, config)?; } let receipt: ParameterReceipt = load_json(&receipt_path)?; - if receipt.format - != if cfg!(feature = "bn254") { - "ix-bn254-plonk-srs-export-v1" - } else { - "ix-plonk-srs-export-v1" - } + if receipt.format != "ix-bn254-plonk-srs-export-v1" || digest(&receipt.setup_id)? != anchors.public_setup.id || digest(&receipt.setup_manifest)? != manifest || receipt.public_max_degree != anchors.public_setup.max_degree @@ -478,7 +423,7 @@ fn wrap_packet( let vk_path = input.join("vk.json"); let exported: serde_json::Value = load_json(&vk_path)?; let mut identity = blake3::Hasher::new(); - identity.update(b"ix/bls-plonk-cache/v1"); + identity.update(b"ix/bn254-plonk-cache/v1"); identity.update(&fixed_cache::executable_digest()?); identity.update(&sha256(&helper)?); identity.update(&sha256(&vk_path)?); @@ -547,12 +492,10 @@ fn wrap_packet( &out.join("PlonkVerifier.sol"), &fs::read(cache.join("PlonkVerifier.sol"))?, )?; - #[cfg(feature = "bn254")] let admission_bytes = setup .ignition_admission() .ok_or("missing Ignition admission")? .to_bytes()?; - #[cfg(feature = "bn254")] storage::write_atomic(&out.join("admission.json"), &admission_bytes)?; let candidate = TrustedCatalog { format: CATALOG_FORMAT.into(), @@ -568,16 +511,10 @@ fn wrap_packet( .into(), helper_sha256: metadata_string(&metadata, "ExecutableSHA256")?.into(), setup: SETUP_NAME.into(), - #[cfg(feature = "bn254")] admission: Some("admission.json".into()), - #[cfg(feature = "bn254")] admission_blake3: Some( blake3::hash(&admission_bytes).to_hex().to_string(), ), - #[cfg(not(feature = "bn254"))] - admission: None, - #[cfg(not(feature = "bn254"))] - admission_blake3: None, }], }; storage::write_atomic( @@ -587,77 +524,7 @@ fn wrap_packet( Ok(()) } -#[cfg(all(test, not(feature = "bn254")))] -mod tests { - use super::*; - - fn key() -> TrustedKey { - TrustedKey { - sha256: hex(&[1; 32]), - path: "vk.bin".into(), - inner_vk_id: hex(&[2; 32]), - statement_prefix: [0; 10], - circuit_sha256: hex(&[3; 32]), - helper_sha256: hex(&[4; 32]), - setup: "filecoin".into(), - admission: None, - admission_blake3: None, - } - } - - #[test] - fn catalog_requires_an_independently_authorized_key() -> Result<()> { - let scratch = Scratch::new()?; - let path = scratch.0.join("catalog.json"); - let mut catalog = - TrustedCatalog { format: CATALOG_FORMAT.into(), keys: vec![key()] }; - let write = |catalog: &TrustedCatalog| { - storage::write_atomic(&path, &serde_json::to_vec(catalog).unwrap()) - }; - write(&catalog)?; - assert_eq!(select_key(&path, &[1; 32])?.path, scratch.0.join("vk.bin")); - assert!(select_key(&path, &[5; 32]).is_err()); - catalog.keys.push(key()); - write(&catalog)?; - assert!(select_key(&path, &[1; 32]).is_err()); - catalog.keys.pop(); - catalog.keys[0].setup = "development".into(); - write(&catalog)?; - assert!(select_key(&path, &[1; 32]).is_err()); - catalog.keys[0].setup = "filecoin".into(); - catalog.keys[0].statement_prefix[0] = u64::MAX; - write(&catalog)?; - assert!(select_key(&path, &[1; 32]).is_err()); - assert!(digest("é").is_err()); - assert!(verify(&[], b"claim").is_err()); - Ok(()) - } - - #[test] - fn parameter_cache_preserves_filecoin_receipt_and_rejects_corruption() - -> Result<()> { - use crate::kzg::srs::filecoin::{ - FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, - }; - let scratch = Scratch::new()?; - let setup = SetupConfig::Filecoin { - cache: FILECOIN_PREFIX_2P8_FIXTURE.into(), - manifest: FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, - }; - let (path, receipt) = cached_parameters(8, &scratch.0, &setup)?; - assert_eq!(fs::metadata(&path)?.len(), 280 + 96 * 11); - let (again, same_receipt) = cached_parameters(8, &scratch.0, &setup)?; - assert_eq!(again, path); - assert_eq!(same_receipt, receipt); - let mut bytes = fs::read(&path)?; - *bytes.last_mut().unwrap() ^= 1; - fs::write(&path, bytes)?; - assert!(cached_parameters(8, &scratch.0, &setup).is_err()); - Ok(()) - } -} - -#[cfg(all(test, feature = "bn254"))] +#[cfg(test)] mod bn254_tests { use super::*; diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index f0104167e..32fe9c046 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -22,7 +22,7 @@ fn print_capabilities() { "cuda": cfg!(feature = "cuda"), "parallel": cfg!(feature = "parallel"), "development_setup_only": false, - "ignition_setup": cfg!(feature = "bn254"), + "ignition_setup": true, "terminal_plonk": cfg!(feature = "plonk"), }) ); diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 76dcd1686..a40956a55 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -1,26 +1,16 @@ //! Setup selection and binding for compression checkpoints. //! //! Every setup declares a public identity and full public degree. The -//! Filecoin policy loads an authenticated prefix of the phase-one powers of -//! tau from the cache named by `IX_KZG_FILECOIN_CACHE`, pinned by the manifest -//! receipt in `IX_KZG_FILECOIN_MANIFEST` or, when that is unset, the built-in -//! receipt of the complete `2^27` cache. Filecoin is available only with -//! BLS12-381. BN254's Ignition policy requires independently pinned admission +//! Ignition policy requires independently pinned admission //! metadata and an authenticated prefix cache. The development policy derives a //! known trapdoor from the stage seed, with parameters from a per-stage cache //! file or from the shared directory named by `IX_KZG_SRS_CACHE`. use super::storage; -#[cfg(not(feature = "bn254"))] -use crate::kzg::srs::filecoin::{ - FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3, filecoin_anchors, load_filecoin_cache, -}; -#[cfg(feature = "bn254")] use crate::kzg::srs::ignition::{ IgnitionAdmission, load_ignition_cache_with_identity, }; use crate::kzg::{KzgConfig, PublicSetup, Srs, TranscriptKind, VerifierParams}; use multi_stark::traits::TwoAdicField; -#[cfg(feature = "bn254")] use std::ffi::{OsStr, OsString}; use std::{ fs, @@ -30,21 +20,12 @@ use std::{ pub(crate) const FIRST_SEED: &[u8] = b"init-fri-kzg-ordinary-v1"; pub(crate) const BINDING_FILE: &str = "kzg-setup-id.bin"; -#[cfg(not(feature = "bn254"))] -pub(crate) const FILECOIN_CACHE_VAR: &str = "IX_KZG_FILECOIN_CACHE"; -#[cfg(not(feature = "bn254"))] -pub(crate) const FILECOIN_MANIFEST_VAR: &str = "IX_KZG_FILECOIN_MANIFEST"; -#[cfg(feature = "bn254")] pub(crate) const IGNITION_CACHE_VAR: &str = "IX_KZG_IGNITION_CACHE"; -#[cfg(feature = "bn254")] pub(crate) const IGNITION_CACHE_MANIFEST_VAR: &str = "IX_KZG_IGNITION_CACHE_BLAKE3"; -#[cfg(feature = "bn254")] pub(crate) const IGNITION_ADMISSION_VAR: &str = "IX_KZG_IGNITION_ADMISSION"; -#[cfg(feature = "bn254")] pub(crate) const IGNITION_ADMISSION_DIGEST_VAR: &str = "IX_KZG_IGNITION_ADMISSION_BLAKE3"; -#[cfg(feature = "bn254")] const IGNITION_ENV_VARS: [&str; 4] = [ IGNITION_CACHE_VAR, IGNITION_CACHE_MANIFEST_VAR, @@ -55,43 +36,20 @@ const IGNITION_ENV_VARS: [&str; 4] = [ /// The trust class of verifier parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SetupClass { - /// Filecoin's phase-one powers of tau. The trapdoor is unknown unless - /// every ceremony participant colluded. No BN254 parameters have this class. - Filecoin, /// Ignition parameters whose identity, degree and anchors match an /// independently pinned admission of the ceremony source. - #[cfg(feature = "bn254")] Ignition, /// A trapdoor derived from a seed; anyone can forge proofs. Development, } impl SetupClass { - /// Filecoin only when the identity, public degree and all four anchor - /// points equal the pinned ceremony values; an identity alone is just - /// bytes any verifying key could carry. - #[cfg(not(feature = "bn254"))] - pub fn of(params: &VerifierParams) -> Self { - let pinned = filecoin_anchors(); - if params.public_setup == pinned.public_setup - && params.g1 == [pinned.g1[0], pinned.g1[1]] - && params.g2 == pinned.g2 - && params.tau_g2 == pinned.tau_g2 - { - Self::Filecoin - } else { - Self::Development - } - } - /// Production classification requires independently admitted Ignition /// metadata; verifier parameters alone do not establish provenance. - #[cfg(feature = "bn254")] pub fn of(_params: &VerifierParams) -> Self { Self::Development } - #[cfg(feature = "bn254")] pub fn of_with_ignition( params: &VerifierParams, admission: &IgnitionAdmission, @@ -102,8 +60,6 @@ impl SetupClass { pub fn label(self) -> &'static str { match self { - Self::Filecoin => "filecoin", - #[cfg(feature = "bn254")] Self::Ignition => "ignition", Self::Development => "development", } @@ -111,8 +67,6 @@ impl SetupClass { pub fn description(self) -> &'static str { match self { - Self::Filecoin => "the Filecoin phase-one powers of tau", - #[cfg(feature = "bn254")] Self::Ignition => "the admitted Aztec Ignition BN254 powers of tau", Self::Development => { "a known-trapdoor development SRS (not production-secure)" @@ -122,7 +76,6 @@ impl SetupClass { } fn development_refused() -> storage::Result { - #[cfg(feature = "bn254")] let message = format!( "no admitted BN254 Ignition setup is configured; set \ {IGNITION_CACHE_VAR}, {IGNITION_CACHE_MANIFEST_VAR}, \ @@ -130,24 +83,12 @@ fn development_refused() -> storage::Result { --dev-srs to use the known-trapdoor development setup \ (not production-secure)" ); - #[cfg(not(feature = "bn254"))] - let message = format!( - "no Filecoin SRS cache is configured; set {FILECOIN_CACHE_VAR} to an \ - authenticated prefix cache, or pass --dev-srs to use the \ - known-trapdoor development setup (not production-secure)" - ); Err(message.into()) } /// Setup policy captured for one worker invocation. #[derive(Clone)] pub(crate) enum SetupConfig { - #[cfg(not(feature = "bn254"))] - Filecoin { - cache: PathBuf, - manifest: [u8; 32], - }, - #[cfg(feature = "bn254")] Ignition { cache: PathBuf, manifest: [u8; 32], @@ -160,43 +101,9 @@ pub(crate) enum SetupConfig { impl SetupConfig { pub(crate) fn from_env(development: bool) -> storage::Result { - #[cfg(feature = "bn254")] - { - Self::from_ignition_env(development, |name| std::env::var_os(name)) - } - #[cfg(not(feature = "bn254"))] - { - if development { - return Ok(Self::Development { - cache_directory: std::env::var_os("IX_KZG_SRS_CACHE") - .map(PathBuf::from), - }); - } - if let Some(cache) = std::env::var_os(FILECOIN_CACHE_VAR) { - let cache = PathBuf::from(cache); - if !cache.is_file() { - return Err( - format!( - "{FILECOIN_CACHE_VAR} does not name a file: {}", - cache.display() - ) - .into(), - ); - } - let manifest = match std::env::var(FILECOIN_MANIFEST_VAR) { - Ok(text) => parse_digest(text.trim())?, - Err(std::env::VarError::NotPresent) => { - FILECOIN_PREFIX_2P27_MANIFEST_BLAKE3 - }, - Err(error) => return Err(error.into()), - }; - return Ok(Self::Filecoin { cache, manifest }); - } - development_refused() - } + Self::from_ignition_env(development, |name| std::env::var_os(name)) } - #[cfg(feature = "bn254")] fn from_ignition_env( development: bool, mut lookup: impl FnMut(&str) -> Option, @@ -235,7 +142,6 @@ impl SetupConfig { Ok(Self::Ignition { cache, manifest, admission }) } - #[cfg(feature = "bn254")] pub(crate) fn ignition_admission(&self) -> Option<&IgnitionAdmission> { match self { Self::Ignition { admission, .. } => Some(admission), @@ -245,9 +151,6 @@ impl SetupConfig { pub(crate) fn class(&self) -> SetupClass { match self { - #[cfg(not(feature = "bn254"))] - Self::Filecoin { .. } => SetupClass::Filecoin, - #[cfg(feature = "bn254")] Self::Ignition { .. } => SetupClass::Ignition, Self::Development { .. } => SetupClass::Development, } @@ -265,11 +168,6 @@ impl SetupConfig { return Err("invalid trace height".into()); } match self { - #[cfg(not(feature = "bn254"))] - Self::Filecoin { cache, manifest } => { - Ok(SetupSource::filecoin(cache.clone(), *manifest)) - }, - #[cfg(feature = "bn254")] Self::Ignition { cache, manifest, admission } => { let source = SetupSource::ignition(cache.clone(), *manifest, admission.clone()); @@ -293,12 +191,6 @@ enum Policy { seed: Vec, cache_directory: Option, }, - #[cfg(not(feature = "bn254"))] - Filecoin { - cache: PathBuf, - manifest: [u8; 32], - }, - #[cfg(feature = "bn254")] Ignition { cache: PathBuf, manifest: [u8; 32], @@ -313,22 +205,6 @@ pub(crate) struct SetupSource { transcript_kind: TranscriptKind, } -#[cfg(not(feature = "bn254"))] -fn parse_digest(text: &str) -> storage::Result<[u8; 32]> { - if text.len() != 64 || !text.is_ascii() { - return Err( - format!("{FILECOIN_MANIFEST_VAR} must be a 64-character BLAKE3 digest") - .into(), - ); - } - let mut digest = [0; 32]; - for (i, byte) in digest.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; - } - Ok(digest) -} - -#[cfg(feature = "bn254")] fn parse_ignition_digest( variable: &str, value: &OsStr, @@ -369,20 +245,8 @@ impl SetupSource { }) } - /// Filecoin parameters: pinned anchors for verification, and proving - /// prefixes from an authenticated cache pinned by its import receipt. - #[cfg(not(feature = "bn254"))] - pub(crate) fn filecoin(cache: PathBuf, manifest: [u8; 32]) -> Self { - Self { - policy: Policy::Filecoin { cache, manifest }, - public_setup: filecoin_anchors().public_setup, - transcript_kind: TranscriptKind::Blake3, - } - } - /// Verification uses admitted anchors directly. Proving additionally /// authenticates the requested cache prefix and matches its provenance. - #[cfg(feature = "bn254")] pub(crate) fn ignition( cache: PathBuf, manifest: [u8; 32], @@ -395,7 +259,6 @@ impl SetupSource { } } - #[cfg(feature = "bn254")] pub(crate) fn ignition_admission(&self) -> Option<&IgnitionAdmission> { match &self.policy { Policy::Ignition { admission, .. } => Some(admission), @@ -416,9 +279,6 @@ impl SetupSource { Policy::Development { cache_directory, .. } => { *cache_directory = Some(directory); }, - #[cfg(not(feature = "bn254"))] - Policy::Filecoin { .. } => {}, - #[cfg(feature = "bn254")] Policy::Ignition { .. } => {}, } self @@ -427,19 +287,12 @@ impl SetupSource { pub(crate) fn class(&self) -> SetupClass { match self.policy { Policy::Development { .. } => SetupClass::Development, - #[cfg(not(feature = "bn254"))] - Policy::Filecoin { .. } => SetupClass::Filecoin, - #[cfg(feature = "bn254")] Policy::Ignition { .. } => SetupClass::Ignition, } } pub(crate) fn security_description(&self) -> &'static str { match self.class() { - SetupClass::Filecoin => { - "Filecoin phase-one powers of tau (challenge_19), authenticated against the published BLAKE2b-512 digest and loaded from a receipt-pinned prefix cache. Public ceremony parameters; the trapdoor is unknown unless every participant colluded.\n" - }, - #[cfg(feature = "bn254")] SetupClass::Ignition => { "Aztec Ignition BN254 powers of tau, with setup identity, full public degree and shifted-basis anchors authorized by an independently pinned admission. Proving prefixes are authenticated against a separate cache receipt. Ceremony provenance depends on the admission's trusted source pins.\n" }, @@ -469,17 +322,9 @@ impl SetupSource { self.check_height(height)?; let length = if verify_only { 2 } else { height }; match &self.policy { - #[cfg(not(feature = "bn254"))] - Policy::Filecoin { .. } if verify_only => Ok(filecoin_anchors()), - #[cfg(not(feature = "bn254"))] - Policy::Filecoin { cache, manifest } => { - Ok(load_filecoin_cache(cache, *manifest, length)?) - }, - #[cfg(feature = "bn254")] Policy::Ignition { admission, .. } if verify_only => { Ok(admission.anchors()) }, - #[cfg(feature = "bn254")] Policy::Ignition { cache, manifest, admission } => { let (srs, identity) = load_ignition_cache_with_identity(cache, *manifest, length)?; @@ -585,10 +430,6 @@ impl SetupSource { #[cfg(test)] mod tests { use super::*; - #[cfg(not(feature = "bn254"))] - use crate::kzg::srs::filecoin::{ - FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, - }; #[test] fn setup_binding_preserves_public_identity_and_rejects_mismatch() @@ -658,62 +499,6 @@ mod tests { Ok(()) } - #[cfg(not(feature = "bn254"))] - #[test] - fn setup_class_requires_the_pinned_anchors_and_identity() { - let pinned = filecoin_anchors(); - assert_eq!( - SetupClass::of(&VerifierParams::from_srs(&pinned)), - SetupClass::Filecoin - ); - let development = Srs::unsafe_dev_setup(8, FIRST_SEED); - assert_eq!( - SetupClass::of(&VerifierParams::from_srs(&development)), - SetupClass::Development - ); - // The Filecoin identity over development anchors is not Filecoin. - let forged = Srs { - g1: development.g1[..2].to_vec(), - g2: development.g2, - tau_g2: development.tau_g2, - public_setup: pinned.public_setup, - }; - assert_eq!( - SetupClass::of(&VerifierParams::from_srs(&forged)), - SetupClass::Development - ); - // The pinned anchors under another identity are not Filecoin either. - let relabeled = Srs { - g1: pinned.g1.clone(), - g2: pinned.g2, - tau_g2: pinned.tau_g2, - public_setup: PublicSetup { - max_degree: pinned.public_setup.max_degree - 1, - id: pinned.public_setup.id, - }, - }; - assert_eq!( - SetupClass::of(&VerifierParams::from_srs(&relabeled)), - SetupClass::Development - ); - let filecoin = SetupSource::filecoin(PathBuf::from("/missing"), [0; 32]); - assert_eq!(filecoin.class(), SetupClass::Filecoin); - assert_eq!(filecoin.public_setup, pinned.public_setup); - // Verification needs no cache file; proving does. - assert_eq!(filecoin.srs(1 << 27, true).unwrap().g1, pinned.g1); - assert!(filecoin.srs(1 << 28, true).is_err()); - assert!(filecoin.srs(1 << 27, false).is_err()); - assert_eq!( - filecoin.identity(1 << 27, 2).unwrap(), - SetupSource::filecoin(PathBuf::from("/other"), [1; 32]) - .identity(1 << 27, 2) - .unwrap() - ); - assert!(parse_digest(&"0".repeat(63)).is_err()); - assert!(parse_digest(&"g".repeat(64)).is_err()); - assert_eq!(parse_digest(&"00".repeat(32)).unwrap(), [0; 32]); - } - #[test] fn development_flag_selects_development_parameters() -> storage::Result<()> { let config = SetupConfig::from_env(true)?; @@ -722,55 +507,9 @@ mod tests { config.for_stage(FIRST_SEED, 64)?.identity(64, 2)?, SetupSource::development(FIRST_SEED, 63)?.identity(64, 2)? ); - #[cfg(not(feature = "bn254"))] - if std::env::var_os(FILECOIN_CACHE_VAR).is_none() { - assert!( - SetupConfig::from_env(false) - .err() - .unwrap() - .to_string() - .contains("--dev-srs") - ); - } Ok(()) } - #[cfg(not(feature = "bn254"))] - #[test] - fn checked_in_filecoin_cache_proves_and_binds_under_its_identity() - -> storage::Result<()> { - let setup = SetupSource::filecoin( - PathBuf::from(FILECOIN_PREFIX_2P8_FIXTURE), - FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, - ); - assert!(setup.srs(512, false).is_err()); - let verifier = setup.srs(256, true)?; - assert_eq!(verifier.max_len(), 2); - assert_eq!( - SetupClass::of(&VerifierParams::from_srs(&verifier)), - SetupClass::Filecoin - ); - let prover = setup.srs(256, false)?; - assert_eq!(prover.max_len(), 256); - assert_eq!(prover.public_setup, verifier.public_setup); - assert_eq!(prover.g1[..2], verifier.g1[..]); - assert_eq!(prover.tau_g2, verifier.tau_g2); - assert_eq!( - SetupClass::of(&VerifierParams::with_max_trace_len(&prover, 256)), - SetupClass::Filecoin - ); - assert_eq!( - setup.config(256, 2, false)?.transcript_seed(), - setup.config(256, 2, true)?.transcript_seed() - ); - assert_ne!( - setup.identity(256, 2)?, - SetupSource::development(FIRST_SEED, 255)?.identity(256, 2)? - ); - Ok(()) - } - - #[cfg(feature = "bn254")] #[test] fn bn254_requires_an_admission_or_explicit_development_setup() -> storage::Result<()> { @@ -808,5 +547,5 @@ mod tests { } } -#[cfg(all(test, feature = "bn254"))] +#[cfg(test)] mod ignition_tests; diff --git a/crates/proof-compression/src/pipeline/stages/codec.rs b/crates/proof-compression/src/pipeline/stages/codec.rs index 497717b65..aa8695b63 100644 --- a/crates/proof-compression/src/pipeline/stages/codec.rs +++ b/crates/proof-compression/src/pipeline/stages/codec.rs @@ -10,9 +10,6 @@ use multi_stark::traits::TwoAdicField; use multi_stark::types::Val; use serde::{Serialize, de::DeserializeOwned}; -#[cfg(not(feature = "bn254"))] -const KZG_VERIFIER_MAGIC: &[u8; 8] = b"IXKZGV02"; -#[cfg(feature = "bn254")] const KZG_VERIFIER_MAGIC: &[u8; 8] = b"IXBNKV02"; pub(super) fn encode(tag: &[u8; 8], value: impl Serialize) -> Result> { @@ -82,13 +79,7 @@ impl KzgVerifier { Vec>, Option, Statement, - ) = if !cfg!(feature = "bn254") && bytes.starts_with(b"IXKZGV01") { - let (params, circuits, preprocessing, statement) = - decode(b"IXKZGV01", bytes)?; - (params, 0, circuits, preprocessing, statement) - } else { - decode(KZG_VERIFIER_MAGIC, bytes)? - }; + ) = decode(KZG_VERIFIER_MAGIC, bytes)?; let params = VerifierParams::from_bytes(¶ms)?; if params.max_trace_len().ilog2() as usize + 1 > Scalar::TWO_ADICITY { return Err("quotient domain exceeds scalar field two-adicity".into()); diff --git a/crates/proof-compression/src/pipeline/stages/tests.rs b/crates/proof-compression/src/pipeline/stages/tests.rs index a72d648f5..2c5fccc12 100644 --- a/crates/proof-compression/src/pipeline/stages/tests.rs +++ b/crates/proof-compression/src/pipeline/stages/tests.rs @@ -154,11 +154,7 @@ fn kzg_stages_derive_claims_for_different_layouts_and_parameters() -> Result<()> let verifier = KzgVerifier::from_bytes(&key_bytes)?; assert_eq!(verifier.to_bytes()?, key_bytes); let mut other_curve = key_bytes.clone(); - other_curve[..8].copy_from_slice(if cfg!(feature = "bn254") { - b"IXKZGV02" - } else { - b"IXBNKV02" - }); + other_curve[..8].copy_from_slice(b"IXKZGV02"); assert!(KzgVerifier::from_bytes(&other_curve).is_err()); assert!( KzgVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]).is_err() diff --git a/crates/proof-compression/src/pipeline/stages/verification.rs b/crates/proof-compression/src/pipeline/stages/verification.rs index ab1f74f00..5f02b74bb 100644 --- a/crates/proof-compression/src/pipeline/stages/verification.rs +++ b/crates/proof-compression/src/pipeline/stages/verification.rs @@ -16,9 +16,6 @@ use ark_ff::Zero; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use multi_stark::{system::System, types::Val}; -#[cfg(not(feature = "bn254"))] -const RECURSIVE_VERIFIER_MAGIC: &[u8; 8] = b"IXRECV02"; -#[cfg(feature = "bn254")] const RECURSIVE_VERIFIER_MAGIC: &[u8; 8] = b"IXBNRV02"; pub(in crate::pipeline) const PAIRING_BYTES: usize = 2 * G1_COMPRESSED_BYTES; @@ -261,7 +258,6 @@ mod tests { Ok(()) } - #[cfg(feature = "bn254")] #[test] fn pairing_transport_rejects_infinity_coordinate_aliases() -> Result<()> { let values = pairing::encode([G1Affine::identity(); 2]); diff --git a/crates/proof-compression/src/pipeline/stages/wrap.rs b/crates/proof-compression/src/pipeline/stages/wrap.rs index f393b93a1..562ef9053 100644 --- a/crates/proof-compression/src/pipeline/stages/wrap.rs +++ b/crates/proof-compression/src/pipeline/stages/wrap.rs @@ -196,11 +196,7 @@ mod tests { old_key[..8].copy_from_slice(b"IXRECV01"); assert!(RecursiveVerifier::from_bytes(&old_key).is_err()); let mut other_curve = key_bytes.clone(); - other_curve[..8].copy_from_slice(if cfg!(feature = "bn254") { - b"IXRECV02" - } else { - b"IXBNRV02" - }); + other_curve[..8].copy_from_slice(b"IXRECV02"); assert!(RecursiveVerifier::from_bytes(&other_curve).is_err()); assert!( RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs index a7b727ab9..05df1e5dd 100644 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -16,19 +16,16 @@ struct BundledVerifier { /// this verifying key compresses. statement_prefix: [u64; 10], setup: SetupClass, - #[cfg(feature = "bn254")] ignition_admission: Option, bytes: &'static [u8], decoded: OnceLock>, } -#[cfg(feature = "bn254")] struct BundledIgnitionAdmission { bytes: &'static [u8], digest: [u8; 32], } -#[cfg(feature = "bn254")] impl BundledIgnitionAdmission { fn decode(&self) -> Result { Ok(crate::kzg::srs::ignition::IgnitionAdmission::from_bytes( @@ -38,53 +35,9 @@ impl BundledIgnitionAdmission { } } -#[cfg(not(feature = "bn254"))] -const AGGREGATE_PREFIX: [u64; 10] = [ - 0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, - 1176003176, 683533119, 3085659920, -]; - -#[cfg(not(feature = "bn254"))] -static VERIFIERS: [BundledVerifier; 4] = [ - BundledVerifier { - id: "4224c47197ed387320dfb9b8bbf2369fa326f72c54e1cbc65907f5e6e8541ca0", - digest: "12ce28356f86ba34afb11b8e3b2c142f47ab9259e9c3f5751069bd1c41609084", - statement_prefix: AGGREGATE_PREFIX, - setup: SetupClass::Filecoin, - bytes: include_bytes!("../../verifying-keys/aggregate-filecoin-v6.bin"), - decoded: OnceLock::new(), - }, - BundledVerifier { - id: "b0db3d47f5f9e08b3710499adaba92ad6c5bd160393d45aef1a505c5909cd580", - digest: "116c668b17db0a1bd95ea8cce9d080af14b16031db128c47a3dcd46443147678", - statement_prefix: AGGREGATE_PREFIX, - setup: SetupClass::Development, - bytes: include_bytes!("../../verifying-keys/aggregate-development-v6.bin"), - decoded: OnceLock::new(), - }, - BundledVerifier { - id: "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", - digest: "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", - statement_prefix: AGGREGATE_PREFIX, - setup: SetupClass::Filecoin, - bytes: include_bytes!("../../verifying-keys/aggregate-filecoin-v5.bin"), - decoded: OnceLock::new(), - }, - BundledVerifier { - id: "304340325a2fff6bbfbca27e0b58b335cd9f0731b7698ef8e9d9f2f23fe408a1", - digest: "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", - statement_prefix: AGGREGATE_PREFIX, - setup: SetupClass::Development, - bytes: include_bytes!("../../verifying-keys/aggregate-development-v5.bin"), - decoded: OnceLock::new(), - }, -]; - -#[cfg(feature = "bn254")] static VERIFIERS: [BundledVerifier; 0] = []; impl BundledVerifier { - #[cfg(feature = "bn254")] fn check_release_class(&self) -> Result<()> { if self.setup != SetupClass::Ignition { return Err( @@ -98,16 +51,12 @@ impl BundledVerifier { self .decoded .get_or_init(|| { - #[cfg(feature = "bn254")] self.check_release_class().map_err(|error| error.to_string())?; if blake3::hash(self.bytes).to_hex().as_str() != self.digest { return Err("bundled compression verifier digest differs".into()); } let vk = VerifyingKey::from_bytes(self.bytes) .map_err(|error| error.to_string())?; - #[cfg(not(feature = "bn254"))] - vk.check_setup_class(self.setup).map_err(|error| error.to_string())?; - #[cfg(feature = "bn254")] { let admission = self .ignition_admission @@ -148,7 +97,6 @@ pub(super) fn verify( let verifier = bundled(&id).ok_or( "compressed proof uses a verifying key not bundled with this application", )?; - #[cfg(feature = "bn254")] verifier.check_release_class()?; if verifier.setup == SetupClass::Development && !allow_development { return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); @@ -185,7 +133,6 @@ pub(super) fn verify_with_vk( /// Verify with an independently authenticated verifying key and ceremony admission. /// The admission contains the verifier anchors; no proving cache is required. -#[cfg(feature = "bn254")] pub(super) fn verify_with_ignition_admission( packet: &[u8], claim: &[u8], @@ -206,188 +153,18 @@ pub(super) fn verify_with_ignition_admission( Ok(SetupClass::Ignition) } -#[cfg(all(test, not(feature = "bn254")))] -mod tests { - use super::*; - use crate::pipeline::{self, PROOF_TAG}; - const PACKET: &[u8] = - include_bytes!("../../tests/fixtures/aggregate-kzg/filecoin-v5-packet.bin"); - const DEVELOPMENT_PACKET: &[u8] = include_bytes!( - "../../tests/fixtures/aggregate-kzg/development-v5-packet.bin" - ); - const CLAIM: &[u8] = - include_bytes!("../../tests/fixtures/aggregate-kzg/filecoin-claim.bin"); - - fn fixtures() -> [(&'static [u8], SetupClass, &'static str); 2] { - [ - ( - PACKET, - SetupClass::Filecoin, - include_str!("../../verifying-keys/aggregate-filecoin-v5.json"), - ), - ( - DEVELOPMENT_PACKET, - SetupClass::Development, - include_str!("../../verifying-keys/aggregate-development-v5.json"), - ), - ] - } - - fn envelope(packet: &[u8]) -> Vec { - [PROOF_TAG.as_slice(), packet].concat() - } - - fn verify( - proof: &[u8], - claim: &[u8], - development: bool, - ) -> Result { - pipeline::verify(proof, claim, development) - } - - #[test] - fn blake3_vks_derive_from_authenticated_circuit_metadata() { - for (source, target) in [(2, 0), (3, 1)] { - let (bytes, id) = - VerifyingKey::reencode_blake3(VERIFIERS[source].bytes).unwrap(); - assert_eq!(bytes, VERIFIERS[target].bytes); - assert_eq!( - blake3::Hash::from_bytes(id).to_hex().as_str(), - VERIFIERS[target].id - ); - VERIFIERS[target].vk().unwrap(); - } - } - - #[test] - fn bundled_packets_verify_without_a_registry_or_srs() { - for (packet, setup, _) in fixtures() { - assert_eq!( - verify(&envelope(packet), CLAIM, setup == SetupClass::Development) - .unwrap(), - setup - ); - } - } - - #[test] - fn development_vk_requires_explicit_opt_in() { - assert!( - verify(&envelope(DEVELOPMENT_PACKET), CLAIM, false) - .unwrap_err() - .to_string() - .contains("--dev-srs") - ); - assert_eq!( - verify(&envelope(DEVELOPMENT_PACKET), CLAIM, true).unwrap(), - SetupClass::Development - ); - } - - #[test] - fn bundled_metadata_matches_artifacts() { - let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/init-fri"); - for (packet, setup, json) in fixtures() { - let metadata: serde_json::Value = serde_json::from_str(json).unwrap(); - let id = packet[..32].try_into().unwrap(); - let verifier = bundled(&id).unwrap(); - assert_eq!(verifier.setup, setup); - assert_eq!(metadata["setup"], setup.label()); - assert_eq!(metadata["known_trapdoor"], setup == SetupClass::Development); - assert_eq!(metadata["vk_id"], verifier.id); - assert_eq!(metadata["verifier_blake3"], verifier.digest); - assert_eq!( - metadata["packet_blake3"], - blake3::hash(packet).to_hex().as_str() - ); - assert_eq!( - metadata["claim"]["blake3"], - blake3::hash(CLAIM).to_hex().as_str() - ); - assert_eq!( - metadata["statement_prefix"], - serde_json::json!(verifier.statement_prefix) - ); - for (file, key) in [ - ("outer-vk.bin", "source_fri_verifier_blake3"), - ("outer-proof.bin", "source_fri_proof_blake3"), - ] { - let bytes = std::fs::read(source.join(file)).unwrap(); - assert_eq!(metadata[key], blake3::hash(&bytes).to_hex().as_str()); - } - } - } - - #[test] - fn bundled_packets_bind_all_statement_words() { - for (packet, _, _) in fixtures() { - let id = packet[..32].try_into().unwrap(); - let verifier = bundled(&id).unwrap(); - let statement = statement::with_claim(&verifier.statement_prefix, CLAIM); - let vk = verifier.vk().unwrap(); - vk.verify(packet, &statement).unwrap(); - for index in 0..statement.len() { - let mut wrong = statement; - wrong[index] ^= 1; - assert!(vk.verify(packet, &wrong).is_err()); - } - for index in [0, 32 + 18 * 8, packet.len() - 1] { - let mut packet = packet.to_vec(); - packet[index] ^= 1; - assert!(vk.verify(&packet, &statement).is_err()); - } - } - } - - #[test] - fn claim_is_supplied_independently_of_the_packet() { - for (packet, setup, _) in fixtures() { - let development = setup == SetupClass::Development; - let mut wrong = CLAIM.to_vec(); - wrong[3] ^= 1; - let error = - verify(&envelope(packet), &wrong, development).unwrap_err().to_string(); - assert!(error.contains("packet statement differs"), "{error}"); - let mut wrong_prefix = packet.to_vec(); - wrong_prefix[32] ^= 1; - assert!(verify(&envelope(&wrong_prefix), CLAIM, development).is_err()); - } - } - - #[test] - fn changed_vks_points_and_proof_lengths_are_rejected() { - for (packet, _, _) in fixtures() { - for index in [0, 32 + 10 * 8, 32 + 18 * 8, packet.len() - 1] { - let mut packet = packet.to_vec(); - packet[index] ^= 1; - assert!(verify(&envelope(&packet), CLAIM, true).is_err()); - } - for end in [0, 31, 32, 32 + 18 * 8, packet.len() - 1] { - assert!(verify(&envelope(&packet[..end]), CLAIM, true).is_err()); - } - let mut extra = packet.to_vec(); - extra.push(0); - assert!(verify(&envelope(&extra), CLAIM, true).is_err()); - let mut wrong_version = envelope(packet); - wrong_version[7] ^= 1; - assert!(verify(&wrong_version, CLAIM, true).is_err()); - } - } -} - -#[cfg(all(test, feature = "bn254"))] +#[cfg(test)] mod bn254_tests { use super::*; #[test] fn bn254_release_catalog_rejects_non_ignition_classes() { - for setup in [SetupClass::Development, SetupClass::Filecoin] { + { let verifier = BundledVerifier { id: "", digest: "", statement_prefix: [0; 10], - setup, + setup: SetupClass::Development, ignition_admission: None, bytes: &[], decoded: OnceLock::new(), @@ -427,15 +204,12 @@ mod bn254_tests { #[test] fn bn254_requires_an_explicit_trusted_vk() { assert!(bundled(&[0; 32]).is_none()); - let bls_packet = include_bytes!( - "../../tests/fixtures/aggregate-kzg/development-v5-packet.bin" - ); - assert!(verify(bls_packet, b"claim", true).is_err()); + let packet = [0; 32]; assert!( - VerifyingKey::from_bytes(include_bytes!( - "../../verifying-keys/aggregate-development-v6.bin" - )) - .is_err() + verify(&packet, b"claim", true) + .unwrap_err() + .to_string() + .contains("not bundled") ); } } diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index f86a01932..315f62272 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -37,13 +37,7 @@ use multi_stark::{ }; use std::{fs, path::Path}; -#[cfg(not(feature = "bn254"))] -pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-v1"; -#[cfg(feature = "bn254")] pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-bn254-v1"; -#[cfg(not(feature = "bn254"))] -const OUTER_VK_VERSION: u32 = 6; -#[cfg(feature = "bn254")] const OUTER_VK_VERSION: u32 = 7; const PAIRING_BYTES: usize = 2 * G1_COMPRESSED_BYTES; const RECURSIVE_PUBLIC_VALUES: usize = statement::WORDS + PUBLIC_VALUES; @@ -123,7 +117,6 @@ impl Frontend { .setup .for_stage(OUTER_SEED, height)? .with_transcript(TranscriptKind::Blake3); - #[cfg(feature = "bn254")] if let Some(admission) = setup.ignition_admission() && decode_pairing_keys(&self.pairings)? != admission.pairing_keys() { @@ -419,17 +412,6 @@ fn decode(bytes: &[u8]) -> storage::Result { } impl VerifyingKey { - #[cfg(all(not(feature = "bn254"), any(test, feature = "plonk")))] - pub(crate) fn reencode_blake3( - bytes: &[u8], - ) -> PipelineResult<(Vec, [u8; 32])> { - let mut vk = Self::from_bytes(bytes)?; - vk.version = OUTER_VK_VERSION; - vk.prepare()?; - let id = vk.id(); - Ok((bincode::serde::encode_to_vec(vk, bincode::config::standard())?, id)) - } - pub(crate) fn from_bytes(bytes: &[u8]) -> PipelineResult { let vk: Self = decode(bytes)?; vk.prepare()?; @@ -440,9 +422,6 @@ impl VerifyingKey { let mut hash = blake3::Hasher::new(); // The label predates the verifying-key naming and is part of every // published id; changing it would re-key every bundled verifying key. - #[cfg(not(feature = "bn254"))] - let namespace = "init-kzg-recursive-profile"; - #[cfg(feature = "bn254")] let namespace = "init-kzg-recursive-bn254-profile"; hash.update(format!("{namespace}/v{}", self.version).as_bytes()); hash.update(OUTER_SEED); @@ -466,13 +445,12 @@ impl VerifyingKey { Ok(crate::kzg::VerifierParams::from_bytes(&self.parameters)?) } - /// Filecoin classification covers both the outer proof and the deferred + /// Setup classification covers both the outer proof and the deferred /// inner opening equation. Development stages may use different seeds. pub(crate) fn check_setup_class( &self, expected: SetupClass, ) -> PipelineResult<()> { - #[cfg(feature = "bn254")] if expected == SetupClass::Ignition { return Err( "Ignition verification requires an explicit trusted admission".into(), @@ -482,18 +460,10 @@ impl VerifyingKey { if SetupClass::of(¶meters) != expected { return Err("bundled verifier setup class differs".into()); } - if expected == SetupClass::Filecoin - && self.keys()? != [parameters.g2, parameters.tau_g2] - { - return Err( - "Filecoin verifier has different inner pairing anchors".into(), - ); - } Ok(()) } /// Admit both opening equations against independently pinned ceremony data. - #[cfg(feature = "bn254")] pub(crate) fn check_ignition_admission( &self, admission: &crate::kzg::srs::ignition::IgnitionAdmission, @@ -510,9 +480,6 @@ impl VerifyingKey { fn system(&self) -> storage::Result> { use crate::kzg::VerifierParams; use multi_stark::system::{Circuit, System}; - #[cfg(not(feature = "bn254"))] - let supported = matches!(self.version, 5 | OUTER_VK_VERSION); - #[cfg(feature = "bn254")] let supported = self.version == OUTER_VK_VERSION; if !supported { return Err("unsupported recursive verifying key".into()); @@ -548,13 +515,8 @@ impl VerifyingKey { fixed.0.append(&mut commit.0); } Ok(System { - config: KzgConfig::verifier_only(params, 2).with_transcript( - if self.version == 5 { - TranscriptKind::Keccak256 - } else { - TranscriptKind::Blake3 - }, - ), + config: KzgConfig::verifier_only(params, 2) + .with_transcript(TranscriptKind::Blake3), circuits, preprocessed_commit: Some(fixed), preprocessed_indices: vec![Some(0), Some(1)], @@ -638,68 +600,6 @@ mod tests { }; use std::sync::Arc; - #[cfg(not(feature = "bn254"))] - #[test] - fn bundled_and_public_recursive_verifiers_share_transport_validation() - -> storage::Result<()> { - let wire = VerifyingKey::from_bytes(include_bytes!( - "../../../verifying-keys/aggregate-filecoin-v5.bin" - ))?; - let prepared = wire.prepare()?; - let public = super::super::super::stages::RecursiveVerifier::from_bytes( - &prepared.verifier.to_bytes()?, - )?; - let packet = include_bytes!( - "../../../tests/fixtures/aggregate-kzg/filecoin-v5-packet.bin" - ); - let words = init_claim::INIT_PUBLIC_WORDS; - let expected = words.map(Scalar::from_u64); - prepared.verify(packet, &words)?; - public.verify_scalar_bytes(&packet[32 + 18 * 8..], &expected)?; - let mut wrong = expected; - wrong[0] += Scalar::ONE; - assert!( - public.verify_scalar_bytes(&packet[32 + 18 * 8..], &wrong).is_err() - ); - let mut corrupt = packet.to_vec(); - let last = corrupt.len() - 1; - corrupt[last] ^= 1; - assert!(prepared.verify(&corrupt, &words).is_err()); - assert!( - public.verify_scalar_bytes(&corrupt[32 + 18 * 8..], &expected).is_err() - ); - assert!( - public - .verify_scalar_bytes(&packet[32 + 18 * 8..packet.len() - 1], &expected) - .is_err() - ); - Ok(()) - } - - #[cfg(not(feature = "bn254"))] - #[test] - fn filecoin_bundle_requires_filecoin_inner_pairing_anchors() { - use ark_serialize::CanonicalSerialize; - let mut vk = VerifyingKey::from_bytes(include_bytes!( - "../../../verifying-keys/aggregate-filecoin-v5.bin" - )) - .unwrap(); - vk.check_setup_class(SetupClass::Filecoin).unwrap(); - assert!(vk.check_setup_class(SetupClass::Development).is_err()); - let development = Srs::unsafe_dev_setup(2, b"mixed-pairing-setup"); - let mut keys = Vec::new(); - vec![development.g2, development.tau_g2] - .serialize_compressed(&mut keys) - .unwrap(); - vk.pairings = bincode::serde::encode_to_vec( - Pairings { keys }, - bincode::config::standard(), - ) - .unwrap(); - assert!(vk.check_setup_class(SetupClass::Filecoin).is_err()); - } - - #[cfg(feature = "bn254")] fn ignition_vk_fixture() -> storage::Result<( crate::kzg::srs::ignition::IgnitionAdmission, VerifyingKey, @@ -767,7 +667,6 @@ mod tests { Ok((admission, vk, packet)) } - #[cfg(feature = "bn254")] #[test] #[cfg(feature = "plonk")] fn plonk_recursive_export_includes_anchor_and_deferred_publics() @@ -804,7 +703,6 @@ mod tests { Ok(()) } - #[cfg(feature = "bn254")] #[test] fn ignition_staging_rejects_other_inner_keys_before_creating_files() -> storage::Result<()> { @@ -852,7 +750,6 @@ mod tests { Ok(()) } - #[cfg(feature = "bn254")] #[test] fn ignition_admission_authenticates_both_openings_without_a_cache() -> storage::Result<()> { @@ -862,9 +759,7 @@ mod tests { srs::ignition::{IgnitionAdmission, test_admission}, }; let (admission, vk, packet) = ignition_vk_fixture()?; - let claim = include_bytes!( - "../../../tests/fixtures/aggregate-kzg/filecoin-claim.bin" - ); + let claim = include_bytes!("../../../tests/fixtures/init-fri/claim.bin"); let expected = init_claim::INIT_PUBLIC_WORDS; let portable = bincode::serde::encode_to_vec(&vk, bincode::config::standard())?; @@ -1178,9 +1073,7 @@ mod tests { assert!(staged_publics(&bad, keys.len()).is_err()); assert!(staged_publics(&claims[..claims.len() - 1], keys.len()).is_err()); let portable = fs::read(first.join("vk.bin"))?; - let claim = include_bytes!( - "../../../tests/fixtures/aggregate-kzg/filecoin-claim.bin" - ); + let claim = include_bytes!("../../../tests/fixtures/init-fri/claim.bin"); let trusted = *blake3::hash(&portable).as_bytes(); let verify_local = |claim: &[u8], expected, trusted: &[u8; 32], allow| { super::super::super::verifier::verify_with_vk( @@ -1212,9 +1105,6 @@ mod tests { assert_eq!(vk.version, OUTER_VK_VERSION); assert_eq!(vk.system()?.config.transcript_kind(), TranscriptKind::Blake3); - #[cfg(not(feature = "bn254"))] - let unsupported_versions = [0, 4, 7]; - #[cfg(feature = "bn254")] let unsupported_versions = [0, 5, 6]; for version in unsupported_versions { let mut unsupported: VerifyingKey = decode(&portable)?; @@ -1232,7 +1122,6 @@ mod tests { let mut wrong_range: VerifyingKey = decode(&portable)?; wrong_range.heights[0] *= 2; assert!(wrong_range.system().is_err()); - #[cfg(feature = "bn254")] { let max_domain = 1usize << Scalar::TWO_ADICITY; let anchors = diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index 8f9ec0011..0282563a9 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -3,11 +3,8 @@ use super::relation::{codegen, *}; use super::*; use crate::kzg::curve::G1Affine; use ark_ec::AffineRepr; -#[cfg(feature = "bn254")] use ark_ff::{BigInteger, PrimeField}; use ark_serialize::CanonicalDeserialize; -#[cfg(not(feature = "bn254"))] -use ark_serialize::CanonicalSerialize; use multi_stark::{ config::ProofConfig, prover::Proof, @@ -28,9 +25,6 @@ fn point(point: G1Affine) -> Json { fn key(point: &G2Affine) -> String { let mut bytes = vec![]; - #[cfg(not(feature = "bn254"))] - point.serialize_compressed(&mut bytes).unwrap(); - #[cfg(feature = "bn254")] for value in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { bytes.extend(value.into_bigint().to_bytes_be()); } @@ -49,9 +43,6 @@ pub(in crate::pipeline) fn export( return Err("trusted verifier digest differs".into()); } let vk = VerifyingKey::from_bytes(&bytes)?; - #[cfg(not(feature = "bn254"))] - vk.check_setup_class(SetupClass::Filecoin)?; - #[cfg(feature = "bn254")] { let setup = super::super::super::setup::SetupConfig::from_env(false)?; let admission = setup @@ -257,9 +248,6 @@ fn write_vk( name: &str, ) -> Result<()> { let Export { system, proof, logs, .. } = export; - #[cfg(not(feature = "bn254"))] - let diagnostic = export.diagnostic.clone(); - #[cfg(feature = "bn254")] let diagnostic = { let mut diagnostic = export.diagnostic.clone(); let encodings = proof_points(proof) @@ -310,7 +298,7 @@ fn write_vk( }) .collect(); let exported = json!({ - "format": if cfg!(feature = "bn254") { "ix-bn254-plonk-vk-v1" } else { "ix-bls-plonk-vk-v1" }, + "format": "ix-bn254-plonk-vk-v1", "vkId": hex_json(&export.vk_id), "setupId": hex_json(¶ms.public_setup.id), "publicMaxDegree": params.public_setup.max_degree, diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs index 8343df0ca..20bc5203a 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs @@ -38,15 +38,9 @@ pub(super) fn fq(value: Fq) -> Vec { pub(super) fn g1(point: G1Affine) -> Vec { if point.is_zero() { - return vec![0; if cfg!(feature = "bn254") { 64 } else { 128 }]; + return vec![0; 64]; } - [ - vec![0; if cfg!(feature = "bn254") { 0 } else { 16 }], - fq(point.x), - vec![0; if cfg!(feature = "bn254") { 0 } else { 16 }], - fq(point.y), - ] - .concat() + [fq(point.x), fq(point.y)].concat() } pub(super) fn proof_points(proof: &Proof) -> Vec { @@ -148,9 +142,8 @@ impl GraphProgram { return Err("invalid Solidity graph opening window".into()); } } - let mut program = Self { - instructions: Vec::with_capacity(graph.nodes.len()), - }; + let mut program = + Self { instructions: Vec::with_capacity(graph.nodes.len()) }; for (index, node) in graph.nodes.iter().enumerate() { let child = |id: multi_stark::graph::NodeId| -> Result { let child = id.index(); @@ -181,15 +174,9 @@ impl GraphProgram { } GraphInstruction::Public(index) }, - Node::IsFirstRow => { - GraphInstruction::First - }, - Node::IsLastRow => { - GraphInstruction::Last - }, - Node::IsTransition => { - GraphInstruction::Transition - }, + Node::IsFirstRow => GraphInstruction::First, + Node::IsLastRow => GraphInstruction::Last, + Node::IsTransition => GraphInstruction::Transition, Node::Add(a, b) => GraphInstruction::Add(child(a)?, child(b)?), Node::Sub(a, b) => GraphInstruction::Sub(child(a)?, child(b)?), Node::Mul(a, b) => GraphInstruction::Mul(child(a)?, child(b)?), @@ -235,7 +222,6 @@ impl GraphProgram { } values } - } struct Batch { diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs index 9ba32149b..a433b99c3 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs @@ -73,7 +73,6 @@ fn quotient_width(max_degree: usize) -> Result { } impl OpeningLayout { - pub(in crate::pipeline::wrap::outer) fn new_for_system( system: &System, logs: &[u8], @@ -191,7 +190,6 @@ impl OpeningLayout { accumulator_count, }) } - } #[cfg(test)] diff --git a/crates/proof-compression/src/recursive/native_curve.rs b/crates/proof-compression/src/recursive/native_curve.rs index af505d47d..47efca072 100644 --- a/crates/proof-compression/src/recursive/native_curve.rs +++ b/crates/proof-compression/src/recursive/native_curve.rs @@ -35,7 +35,7 @@ impl Affine { } pub fn on_curve(self, b: &mut Builder, f: &FqGadget) { let xx = f.mul(b, self.x, self.x); - let coefficient = if cfg!(feature = "bn254") { 3 } else { 4 }; + let coefficient = 3; f.relation(b, &[(1, xx, self.x), (-1, self.y, self.y)], &[], coefficient); } pub fn double(self, b: &mut Builder, f: &FqGadget) -> Self { @@ -144,25 +144,8 @@ impl Affine { f.equal(b, self.x, rhs.x); f.equal(b, self.y, rhs.y); } - #[cfg(not(feature = "bn254"))] - pub fn subgroup(self, b: &mut Builder, f: &FqGadget) { - // Same endomorphism criterion as ark-bls12-381's native checker. - let xp = self.constant_mul(b, f, 0xd201000000010000); - let inv = f.hint_pure( - b, - "reject exceptional subgroup point", - &[xp.x, self.x], - |v| (v[0] - v[1]).inverse().ok_or("exceptional subgroup point".into()), - ); - f.relation(b, &[(1, xp.x, inv), (-1, self.x, inv)], &[], -1); - let x2p = xp.constant_mul(b, f, 0xd201000000010000); - let beta = f.constant(b, ark_bls12_381::g1::BETA); - f.relation(b, &[(1, beta, self.x)], &[(-1, x2p.x)], 0); - f.relation(b, &[], &[(1, self.y), (1, x2p.y)], 0); - } /// BN254 G1 has cofactor one, so `on_curve` already proves membership. - #[cfg(feature = "bn254")] pub fn subgroup(self, _b: &mut Builder, _f: &FqGadget) {} } @@ -288,27 +271,6 @@ mod tests { run(point).unwrap(); run(-point).unwrap(); } - #[cfg(not(feature = "bn254"))] - { - let torsion = G1Affine::new_unchecked(Fq::from(0u8), Fq::from(2u8)); - assert!(torsion.is_on_curve()); - assert!(!torsion.is_in_correct_subgroup_assuming_on_curve()); - assert!(run(torsion).is_err()); - let mut checked = 0; - for x in 1..100u64 { - if let Some(point) = - G1Affine::get_point_from_x_unchecked(Fq::from(x), false) - { - assert!(!point.is_in_correct_subgroup_assuming_on_curve()); - assert!(run(point).is_err()); - checked += 1; - if checked == 8 { - break; - } - } - } - assert_eq!(checked, 8); - } assert!( run(G1Affine::new_unchecked(Fq::from(7u8), Fq::from(9u8))).is_err() ); diff --git a/crates/proof-compression/src/recursive/native_field.rs b/crates/proof-compression/src/recursive/native_field.rs index 48db5c8ee..e176efd71 100644 --- a/crates/proof-compression/src/recursive/native_field.rs +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -218,7 +218,7 @@ impl FqGadget { /// coefficient has magnitude below A=4*L*B^2+3*B+4. Pairing coefficients /// bounds an integer equality by (B+1)*A+(B^2+1)*2^88 < Fr, so a zero /// field equation is also an integer equality. The integer-bound test - /// checks these inequalities against the selected curve's exact moduli. + /// checks these inequalities against BN254's exact moduli. pub fn relation( &self, b: &mut Builder, diff --git a/crates/proof-compression/src/recursive/native_transcript.rs b/crates/proof-compression/src/recursive/native_transcript.rs index 00876168d..daebe6dbf 100644 --- a/crates/proof-compression/src/recursive/native_transcript.rs +++ b/crates/proof-compression/src/recursive/native_transcript.rs @@ -146,9 +146,6 @@ pub fn point_bytes( let mut half = ((modulus() - 1u8) / 2u8).to_bytes_le(); half.resize(G1_COMPRESSED_BYTES, 0); let sign = greater_than(b, bytes, &y, &half); - #[cfg(not(feature = "bn254"))] - let (sign_mask, compressed_mask) = (32, 128); - #[cfg(feature = "bn254")] let (sign_mask, compressed_mask) = (128, 0); let flagged = b.linear_combination( &[ @@ -159,15 +156,11 @@ pub fn point_bytes( Scalar::from_u8(compressed_mask), ); let flagged = bytes.constrain_byte(b, flagged); - #[cfg(not(feature = "bn254"))] - let result = std::array::from_fn(|i| { - if i == 0 { flagged } else { x[G1_COMPRESSED_BYTES - 1 - i] } - }); - #[cfg(feature = "bn254")] - let result = std::array::from_fn(|i| { - if i == G1_COMPRESSED_BYTES - 1 { flagged } else { x[i] } - }); - result + std::array::from_fn( + |i| { + if i == G1_COMPRESSED_BYTES - 1 { flagged } else { x[i] } + }, + ) } pub struct Transcript { diff --git a/crates/proof-compression/src/recursive/native_verifier/plan.rs b/crates/proof-compression/src/recursive/native_verifier/plan.rs index 75144253a..d0a6ab519 100644 --- a/crates/proof-compression/src/recursive/native_verifier/plan.rs +++ b/crates/proof-compression/src/recursive/native_verifier/plan.rs @@ -2,9 +2,6 @@ use super::*; use ark_ec::AffineRepr; use shape::Shape; -#[cfg(not(feature = "bn254"))] -const FRONTEND_VERSION: &[u8] = b"init-kzg-recursive/frontend/v1"; -#[cfg(feature = "bn254")] const FRONTEND_VERSION: &[u8] = b"init-kzg-recursive/bn254/frontend/v1"; #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] diff --git a/crates/proof-compression/src/recursive/pairing.rs b/crates/proof-compression/src/recursive/pairing.rs index f22d6c065..eee4aef4f 100644 --- a/crates/proof-compression/src/recursive/pairing.rs +++ b/crates/proof-compression/src/recursive/pairing.rs @@ -1,7 +1,7 @@ //! Canonical public encoding of the two deferred KZG pairing operands. //! -//! Each compressed point occupies two little-endian scalar limbs: 24 bytes -//! per limb for BLS12-381 and 16 for BN254. Both fit injectively in Fr. +//! Each compressed point occupies two little-endian scalar limbs: 16 bytes +//! per limb. Each limb fits injectively in the BN254 scalar field. use crate::kzg::{ Scalar, @@ -74,7 +74,6 @@ mod tests { let identity = encode([G1Affine::identity(); 2]); assert_ne!(positive, negative); assert_ne!(positive, identity); - #[cfg(feature = "bn254")] { assert_eq!(positive[0], Scalar::ONE); assert_eq!(positive[1], Scalar::ZERO); @@ -82,16 +81,6 @@ mod tests { assert_eq!(identity[1], Scalar::from_limbs_le([0, 1 << 62, 0, 0])); assert_eq!(negative[1], Scalar::from_limbs_le([0, 1 << 63, 0, 0])); } - #[cfg(not(feature = "bn254"))] - assert_eq!( - identity, - [ - Scalar(Fr::from(0xc0u8)), - Scalar::ZERO, - Scalar(Fr::from(0xc0u8)), - Scalar::ZERO - ] - ); } #[test] @@ -108,7 +97,6 @@ mod tests { } } - #[cfg(feature = "bn254")] #[test] fn bn254_public_pairing_encoding_rejects_noncanonical_infinity() { let mut values = encode([G1Affine::identity(); 2]); diff --git a/crates/proof-compression/tests/bn254_kzg.rs b/crates/proof-compression/tests/bn254_kzg.rs index 1178cd1b0..2bbc7d373 100644 --- a/crates/proof-compression/tests/bn254_kzg.rs +++ b/crates/proof-compression/tests/bn254_kzg.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "bn254")] - use std::sync::Arc; use ark_bn254::Fr; diff --git a/crates/proof-compression/tests/fixtures/aggregate-kzg/development-v5-packet.bin b/crates/proof-compression/tests/fixtures/aggregate-kzg/development-v5-packet.bin deleted file mode 100644 index 2c36925740dbd8c204c2ecf4b0ad5768aaf345f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2181 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 diff --git a/crates/proof-compression/tests/fixtures/aggregate-kzg/filecoin-claim.bin b/crates/proof-compression/tests/fixtures/init-fri/claim.bin similarity index 100% rename from crates/proof-compression/tests/fixtures/aggregate-kzg/filecoin-claim.bin rename to crates/proof-compression/tests/fixtures/init-fri/claim.bin diff --git a/crates/proof-compression/tests/kzg_transcripts.rs b/crates/proof-compression/tests/kzg_transcripts.rs index 6d94749e8..3f81490fc 100644 --- a/crates/proof-compression/tests/kzg_transcripts.rs +++ b/crates/proof-compression/tests/kzg_transcripts.rs @@ -179,55 +179,3 @@ fn default_transcript_preserves_explicit_blake3_proof_bytes() { system.verify_multiple_claims(&refs, &explicit).unwrap(); assert_eq!(codec.encode(&default).unwrap(), codec.encode(&explicit).unwrap()); } - -#[test] -#[cfg(not(feature = "bn254"))] -fn filecoin_keccak_proof_verifies_with_portable_authenticated_parameters() { - use ix_proof_compression::kzg::VerifierParams; - use ix_proof_compression::kzg::srs::filecoin::{ - FILECOIN_PREFIX_2P8_FIXTURE, FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3, - filecoin_setup_id, load_filecoin_cache, - }; - - let path = std::path::Path::new(FILECOIN_PREFIX_2P8_FIXTURE); - let receipt = FILECOIN_PREFIX_2P8_MANIFEST_BLAKE3; - let srs = load_filecoin_cache(path, receipt, 8).unwrap(); - assert_eq!(srs.public_setup.id, filecoin_setup_id()); - let config = KzgConfig::new(Arc::new(srs), MAX_QUOTIENT_DEGREE) - .with_transcript(TranscriptKind::Keccak256); - let (mut system, key) = system(config); - let claims = claims(); - let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); - let proof = prove(&system, &key); - system.verify_multiple_claims(&refs, &proof).unwrap(); - - let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); - let compact = codec.encode(&proof).unwrap(); - let decoded = codec.decode(&compact).unwrap(); - assert_eq!(codec.encode(&decoded).unwrap(), compact); - - let anchors = load_filecoin_cache(path, receipt, 2).unwrap(); - let params = VerifierParams::with_max_trace_len(&anchors, 8); - let params_bytes = params.to_bytes().unwrap(); - assert_eq!( - params_bytes, - system.config.pcs().verifier_params().to_bytes().unwrap() - ); - let verifier = KzgConfig::verifier_only( - VerifierParams::from_bytes(¶ms_bytes).unwrap(), - MAX_QUOTIENT_DEGREE, - ) - .with_transcript(TranscriptKind::Keccak256); - assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); - system.config = verifier.clone(); - system.verify_multiple_claims(&refs, &proof).unwrap(); - system.verify_multiple_claims(&refs, &decoded).unwrap(); - - system.config = verifier.with_transcript(TranscriptKind::Blake3); - assert!(system.verify_multiple_claims(&refs, &proof).is_err()); - assert!(system.verify_multiple_claims(&refs, &decoded).is_err()); - - let mut wrong_receipt = receipt; - wrong_receipt[0] ^= 1; - assert!(load_filecoin_cache(path, wrong_receipt, 8).is_err()); -} diff --git a/crates/proof-compression/tests/worker_tracing.rs b/crates/proof-compression/tests/worker_tracing.rs index 12d73e00c..b0c2806e7 100644 --- a/crates/proof-compression/tests/worker_tracing.rs +++ b/crates/proof-compression/tests/worker_tracing.rs @@ -28,7 +28,7 @@ fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { assert_eq!(capabilities["binary"], "ix-compression-worker"); assert_eq!(capabilities["curve"], ix_proof_compression::kzg::curve::NAME); assert_eq!(capabilities["development_setup_only"], false); - assert_eq!(capabilities["ignition_setup"], cfg!(feature = "bn254")); + assert_eq!(capabilities["ignition_setup"], true); assert_eq!( String::from_utf8_lossy(&output.stderr) .contains("[texray] compression/fri:"), @@ -83,7 +83,6 @@ fn worker_pipeline_requires_an_explicit_vk_digest_before_io() { ); } -#[cfg(feature = "bn254")] #[test] fn ignition_verification_requires_both_pins_and_refuses_development_mode() { let pin = "00".repeat(32); diff --git a/crates/proof-compression/verifying-keys/aggregate-development-v5.bin b/crates/proof-compression/verifying-keys/aggregate-development-v5.bin deleted file mode 100644 index 39621fec89a7176e56367042e9f3f5baa1cbd377..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2008 zcmcIlc~ld35Z+C8lO>2jt{fzhGst08pq^C_wE|U6MQnryxkMurftn(y-~lKtL5?Vt zBNV&{3KSuTu_%&4O9dq$mqkHR5HNut0@2b9ueSf${?mCoGvDm*dpo-`Z)V}Q001DU z!2l{>$OHh<@Pp$2!PEb6h-QGn_i5%)`GfVxJf>Le4R;ANkgj!h&hdGc-dDz>P!57B z4kfGk8mQt?fI&S0RZR^o)Gk6*OG6ud{SOODTZ}4zKzazGk0KC(NH_yBiZBR5#Ze4V zghe#zI2r>*5N@fF2})nc4gM`?jKThMVL@XAfY2C%QHz7r7>}s2#=?DqrW!BOTnvE# z5n6%+b#x&_QxA&;hzn&$AP56vRnOF)v>?R4eA#aUqmM5`_M)C-dh|#tr$Xx#R`rze zGq-m9$w&ctck z0iWhayL3~Me9v1@no{1#YU0WQ*@oqA)lBlHgZ}$#?zGs^N&d5`M8oQHF-)ODN2LXE zaJ~Qngzdb#+YZ{HMKUQ*t#0i2oeUI;He?VnkP=k z_OSK6pOmj|P39=?&R`z7t*ELRKmLL?B;E04F?ZcA>Y;(dY)bByiq*H^{(4#tKZ7K2 z*XB3`rug+~w>_0+OU&)PsK4%}e=C{jTlOMp<*nx(!x2A?@7BU3etYhf-J9OK8|KQE z1uZSL@A<-|M1vmT=4e}K;8a%4W=-ka@GpAwNV$ooYxL)yDL`Q+y>9AzjlFNE}an)sU+$GoCLi*9HNH{?2cu<8YxAwJj;NBIL*%fCS z$BN|Q>Fx3Tg4Fxp7mmjKq&Hibi(7KqX_^=IN~#FG(`Q?OS9;PU5$i1&Tg7IyC)DZW zq`dg@;q#shhDs}W!u9JC$rxcq?zBI4#gQ||<}$ABcz4f7v9pMT+OV(gjv@i4pBPOPi+2)$3o zZE*VWkSyXxL+qR@(%i7Q#^p;zKz>0yFm6LfE)+P=*4fKt%~ip2-ll%oaNiiK{)T}3 zZhuCgd2DN@!(?W0`{yQW4}0M!SbDj4m#m^t2~&;_^Wrc*;YPW#NZ*kozj&94UKd}_ z$*YB}Vp61M9K(wkgOZOzm1!PcQj&hhRE`8*ZlJuP0C^JX<)E3AI~m3&YMWk9#}HF< zdH&5!?~9c2%$n^LS-_4LwihN_xCie&)Yf*%sG54KC!ntHgolgWjtL#zOus!t!g$yZ zvObP+Evi(c5z?6mY_Ysi+f(u)Ryyan0aN9v85BGC=%}U;%e)HL$)zWKn(i5kMf|o( zK_zD;De4;{%3)qL{j7eGo%Cmi?&&VjJJkF7{jBGcvOxS<#I5>_oU&^*46ZoR4Jx7l zQVV-y>w}8zMS2|4zsaTxoW+F&}$;8yD*QtMpzCJI$) z{4(Homm;jp+M)TusRPU(V{ZB+j*hwi{(qOYJiscUC+u4Pc+#)6L$g~(oj%Em9=rJ5 zFYMGiZIAUPr(b@tJ#D9Uyx!;g6-JS!0+F7msw<#{&TIV0>ZwlTK++D@zK6FN5Bx?N a;P5q@h^{Zayk;2gBHrljK|Zh3+5S%ibmI&F diff --git a/crates/proof-compression/verifying-keys/aggregate-development-v5.json b/crates/proof-compression/verifying-keys/aggregate-development-v5.json deleted file mode 100644 index 99f26f14c..000000000 --- a/crates/proof-compression/verifying-keys/aggregate-development-v5.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "setup": "development", - "known_trapdoor": true, - "vk_version": 5, - "first_stage_log_height": 24, - "vk_id": "304340325a2fff6bbfbca27e0b58b335cd9f0731b7698ef8e9d9f2f23fe408a1", - "verifier_blake3": "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", - "packet_blake3": "6162de2ddcf6b5ac6cc783f653ead5815f8504683c4f83d2b301fd61b3cafe47", - "source_fri_verifier_blake3": "5f7db8e94c52f404df1c2658d4c686b7e1ccb5d95b473d47c0efda96141f89cf", - "source_fri_proof_blake3": "c4c19524f0f633eccab88164cb1dd08e2fc877467ec7d0b45821f0c6791a11a6", - "source_fri_fixture": "tests/fixtures/init-fri", - "producer_ix_revision": "45cb4b7a4120f1d44f94248369045c8be56431ff", - "producer_multi_stark_revision": "8b6c3fbdd8575bfeb818a32acf06509bac81ff3b", - "statement_prefix": [ - 0, - 293, - 3008464260, - 3936129970, - 1088962797, - 1141056675, - 2347219926, - 1176003176, - 683533119, - 3085659920 - ], - "scope": "Aggregate CheckEnv recursive circuit for the current CLI FRI verifier. The caller supplies the claim; its digest occupies the last eight public words. Development parameters require --dev-srs.", - "root_verifier_blake3": "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc", - "claim": { - "kind": "CheckEnv", - "root": "f8912c57f41e1b38ebd63e53cf05b0775cff02b42185084a014c07b18d0e2b0c", - "assumptions": null, - "blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80" - }, - "claim_recovery": { - "import": "Init", - "lean_toolchain": "4.34.1", - "environment_root": "3a3b14ba432d397260d10436147314fe1de01614e7c3906dea2fa5b0623744dd", - "constants": 56810, - "partition": "ix shard --shards 16, default static min-cut and balance", - "structural_above": 4096, - "aggregate_proof_address": "779713f2027576d9f8bbbb5573e237a057e0bdc18b8599571feff20fcadb814f", - "aggregate_proof_source": "multi-stark sb/kzg:experiments/kzg-cuda-validation/resume-20261009/root-artifacts/root-proof.bin", - "aggregate_verification_passed": true - }, - "compressed_proof_address": "b6084ba2941da8a991afc978946ef63326148378addb6c1cd9d929a0e4a692c3", - "producer_worker_sha256": "ab7403a0f222b1098c51622614693c0e3ff7a243ec6cd3323a65bf7febcc5626" -} diff --git a/crates/proof-compression/verifying-keys/aggregate-development-v6.bin b/crates/proof-compression/verifying-keys/aggregate-development-v6.bin deleted file mode 100644 index 2501142e35c830b7748b803973b651fea72c2d38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2008 zcmcIlc~ld35Z+C8lO>2jt{fzhGst08pq^C_wE|U6MQnryxkMurftn(y-~lKtL5?Vt zBNV&{3KSuTu_%&4O9dq$mqkHR5HNut0@2b9ueSf${?mCoGvDm*dpo-`Z)S0C0RTWy zg8@{&kO=^y;RnV4gQx%D5X}IC@6*hq@(1gWc}%g`8}1TlAYJS1oa6H>y|0W%p&SHN z97sAlygYmzMju~>>_t7v^yraPPKDMftm-M_ zXKwF!PD0psEv+9bM3mpQ`hINGHWJZM9#gW-I9T|VWUBjos&R|WM!S;b3foTUor%-7 z13t}iH6nZVwggQj!FyS zgt%*$*KB$^8lMkWo~|A9R_NxP#Hdc+iTM; zPLl?z4q9!y(WPseWLTIE`p137yY16BYD2bb6xXj?Yieyqv2``35hM&a!Baz!HBX$7 z?P2SCKPg|`n#@t&oxwbETTxXte*6V(NV?<8V(z+K)I$S@*_7NZ6{~N-{q?jQeg;Y4 zuFY`>O!4c}ZhI=tmYCanQGeY{|5h^5x9mmI%3IGnh9iC&->rp7{Px@{yEnaeH_VkS z3tC!g-}8k_i3UBw&C#~fz^Sa7&6?7;;a~LVk#ZAF*XYxK*b@^SXn*$2EFisC8q`*8 zxkqv7T7+plldxUiT(HeQ?F@!&DI!mEXXf!Q$FX9gb}r|z+w~L2XdxEak5>w}TJFlp z$abk>Z3)ZPKJn_Y6E~|-*WRKt#MZQ@W~t+8-yXA!QCW%!;u8LFaOc{Dd_Fd~*Y<1I zdkEjatdPTQh>ilY(_zW5C71Q|;;PHyxJ$0Nh4iCYk#K<4@t_J*ZtZL5z`ZLfvn$Rv zjupwp)7#_w1*!MHFC2~eNpH3=7q{fJ(=;#al~fUWr_Z(muk@rzBGy|lwu;SYPpH$$ zNqO<*!{$gzMKOk}<-J+-ZO8iX&%^&1GEM@$R0FVr%K`lTLf{cFyUU-4n?-1dgU_T;$e2Lomf}t5qh7F z+u-!$Az8$YhS)h*q`6^pjmwvcfc%1ZVBCg|Tqtm!t+SWQnyZ54yiNVE;l43e{S5*6 z-TsU~^Vrr*hsn(1_Rme$9`?ddu=H~8E?GsP5~ds<=EY%r!i{oek-j5Ge(^37y)M3< zlUEB{#iU5jIEEK71|=VbD$_i?q$K^0sT>Ks+(3Cn0rDi&%Rw_KcQTAm)Hc1Ijv=Pz z^8A~d-WMt3nKj!hvVa{gY%ffN+ zWPKdtT2!e>BcwAC*kXC3wx{GptaQ$C1E$JTGbncO(NRqymU$JflS@zfG~F{6i}-Do zf=bRxQq(s@l*7Dg`dR%VJL%63-P2v5cc}OE`&rK?Wr6s!h+Fj;Ic3*s7+i6r8&pIA zq!#wZ)(1h=<|!rlaDRAc&beDj?cZ##=O{x$I3P`UNl~XN@87KQ1>ZLX?G2*t5BfjA zt9_H^k^DYdtkkEXBkIM)0v>rYxqN7Hq|+?qpfu-#;}CzM{mLr-lT2n-V5<8yA+g~= zsyjcr78bazcOV%GeL1^_9cE*xa@BeTq1QxGcVQlz-8NHTLu%qa$O@pQ*TICbO%$rq z_+`NFE=5?GwL|lRQwNwo#@zHt936B2{r@g)d4N?yPuR8o@uXjChi12oI(?EAJ$CWA zU)ZU4+8*moPQUzOd)iLzc)ic}D~uvd1tL9BRaZa@o!9u0)l;3wfutR*eGhLl9{7zk az~O5)5nW$;dCf4~MZD45gM416v;Ch8Oydmz diff --git a/crates/proof-compression/verifying-keys/aggregate-development-v6.json b/crates/proof-compression/verifying-keys/aggregate-development-v6.json deleted file mode 100644 index 78ac8db9d..000000000 --- a/crates/proof-compression/verifying-keys/aggregate-development-v6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "vk_version": 6, - "transcript": "blake3", - "setup": "development", - "known_trapdoor": true, - "vk_id": "b0db3d47f5f9e08b3710499adaba92ad6c5bd160393d45aef1a505c5909cd580", - "verifier_blake3": "116c668b17db0a1bd95ea8cce9d080af14b16031db128c47a3dcd46443147678", - "source_vk": "aggregate-development-v5.bin", - "source_verifier_blake3": "e3e06165a20328dbd48964a0539341dd54b2c3e89accba313a09f4321d2f48b9", - "statement_prefix": [0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, 1176003176, 683533119, 3085659920], - "derivation": "The authenticated circuit metadata, fixed commitments and development setup anchors are unchanged; the verifying key version selects the BLAKE3 transcript. A fresh proof is required." -} diff --git a/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.bin b/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.bin deleted file mode 100644 index 57802a7f184d344a0f6821ac2fb778666f4f5c85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2008 zcmdT^dpuNm6rabPnaj{f9z{bFMnWlKJx7z2XwcN=QKA$+*c2@zuY}b2urp<61_B7k%R-8kk%z1tr1CNf(C2?&ptMzx!azU?1eGBK2LV;I%325^Ku`_6P8~u> z5Q|5z*MJa!*4(fWN?)lB|3{!0f&BMD!U-S@fMNgzSu`NU7*L93R?cOyQY?pE1;AlA zU^N=9xCQ{RN=PIOx6*bH01zmoWLWx3D}wv#(M z++p6oXbDfNYn!Bss;V=O*f59W2ku_3E4Gh4E((fXd?`z^M~|zS-tZm`ex_!%>!Wfq zVtvE0t0{JWPHn0+++%J0WqVs|YN=)T?_te(>TaxiKFoX!tN40^j_i?N&7v_G7cY|; z6WLzPB?}&==99Mdp-Ro0IH`HDnbbF1oYz(i++Gw1*B~wLyFSio?Pe zpO3F?2_REGS}1EtuBNZTc2cxpDLsw}xFxY38fCuo+B#b__RzDfN|NJZoiQ9o z?24m`6Q?aL%)Ca%#$2_^=N3revCCRwX#wHN0b88YwS7K(r6op2k(>C7zxDFq^S`9Pr`-gEYJHWbxwDe=twNi z=Q@?HGt+M#eBZ!8G4~F66ML}c@w)iaAxmHIiTVqU(X?0Lg;V%3{BAQt-QPUs?b$(A zfr%7liaVg@5uUL3l)hViP3C5g^;bLt-L(%2L#DMN%3d)&iRDpsg04%&H)ia}$$C+G zb@>W*b73~fsJx8x7zLWKAENjjAM1kG_gL!&r$g36JVu|_`ACvKZlz*&M7N=Q7 z-S9ZW#%@GIVO4!v+r2vVF1y!wa>UN8@n>EJ z&l8BfURs{I!i!sHmLxlYDB0FZsJaX5E!zov6Os3k>RqYB4xA2soBZY5Vc zwE+j2bd$@X0mkYPJ?5V34bOe}_IFFl+jie zp$b>Vx%iSOqUj#fOY9uGF5ehX{|h$ot@G3+r@d}|W)+8$A89D;l(@ma4F#GFD4oyJ z^76_O+*@xiBl7Ajdy*r;J7qHl*D4nRrMz-_p~r_M_-?IS)@)zNHrBv3V)}^`=Q1u% z;Gg2m3Ts5=Q!GfT>Rh*=Lt~`BBGslP7BjNXHnulalD2E;7f_6J$b}P&2{EbhxUBqI j4#%=mM+rxu+HIq#f6g+!V_@C=cK?L&fTq>|y|sS>YiaBV diff --git a/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json b/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json deleted file mode 100644 index 7cce6076e..000000000 --- a/crates/proof-compression/verifying-keys/aggregate-filecoin-v5.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "setup": "filecoin", - "known_trapdoor": false, - "vk_version": 5, - "first_stage_log_height": 24, - "vk_id": "4b033424efd56486ed972b04b773349149d9df6159d753e303f11aed89928302", - "verifier_blake3": "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", - "packet_blake3": "80d136cbc7496a3436a2d9d291497025742a5573ffcd565a76bf1f21024c0b4c", - "source_fri_verifier_blake3": "5f7db8e94c52f404df1c2658d4c686b7e1ccb5d95b473d47c0efda96141f89cf", - "source_fri_proof_blake3": "c4c19524f0f633eccab88164cb1dd08e2fc877467ec7d0b45821f0c6791a11a6", - "source_fri_fixture": "tests/fixtures/init-fri", - "producer_ix_revision": "45cb4b7a4120f1d44f94248369045c8be56431ff", - "producer_multi_stark_revision": "8b6c3fbdd8575bfeb818a32acf06509bac81ff3b", - "statement_prefix": [ - 0, - 293, - 3008464260, - 3936129970, - 1088962797, - 1141056675, - 2347219926, - 1176003176, - 683533119, - 3085659920 - ], - "scope": "Aggregate CheckEnv recursive circuit for the current CLI FRI verifier. The caller supplies the claim; its digest occupies the last eight public words.", - "root_verifier_blake3": "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc", - "claim": { - "kind": "CheckEnv", - "root": "f8912c57f41e1b38ebd63e53cf05b0775cff02b42185084a014c07b18d0e2b0c", - "assumptions": null, - "blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80" - }, - "claim_recovery": { - "import": "Init", - "lean_toolchain": "4.34.1", - "environment_root": "3a3b14ba432d397260d10436147314fe1de01614e7c3906dea2fa5b0623744dd", - "constants": 56810, - "partition": "ix shard --shards 16, default static min-cut and balance", - "structural_above": 4096, - "aggregate_proof_address": "779713f2027576d9f8bbbb5573e237a057e0bdc18b8599571feff20fcadb814f", - "aggregate_proof_source": "multi-stark sb/kzg:experiments/kzg-cuda-validation/resume-20261009/root-artifacts/root-proof.bin", - "aggregate_verification_passed": true - }, - "compressed_proof_address": "14cb1679032f43cba067ea52e9b8899e2e57f5a1cf8581bc4309dc07f30afa22", - "producer_worker_sha256": "ab7403a0f222b1098c51622614693c0e3ff7a243ec6cd3323a65bf7febcc5626" -} diff --git a/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.bin b/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.bin deleted file mode 100644 index 4cfe1d36bf25ce246dc67db5e4d5c71e0d166ebc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2008 zcmdT^dpuNm6rabPnQMkd-jSH(6_rJp3gn!e9!N9KKI;v&gXm3pJ6Z<2pI%S z;s&gT!J+WG;`!a%AGm}npy1!iEJL#7v9yfH7f?|H92sz6oB8hOn}*cF$uKAfgcJ=W zOY>zQ#XtZ7c`T$j89B(xLMkUC4}Jdk1xi~5DGUUZKu{S%a1c;MtEfQ;0fOr2H5w2? zf;dg|+I0{D&|2E-q4bs7@P7n~5y*cZB%A=k04N4fkVOMhi~*%sX5}1FXUrllNbIPD{(=I#nFJ?`Rj9WGlzlYc7Xt=Q-db2nfcER;XUF^|ct)ej*7cY~T z6E{8U3m4oiw@unthbh$?a2YwVH>htmyQr1)-&-6BsX*Gaxb|cx>odoPeH1_T=RvOmeD)$q0> zO7>3RNAB3isC+rYe$c*Q-ysV@qk5LzBcAqQT4curVYzQ7qb90!??I5^&<0Y6(lzJO zh`i?2dFGu4MGP%UzJL8z6VW}9ch`P?uToD;;9ZH`;3(^z=a$+0u}+VsGD+5cyNsbY zz4kcjNaD1OwUy`a*qEzM5xqIL8~1=GV99gqA3lj3dU^|!J(u%#lKEoe<_xcq6Q6y< zOG8F-DnG^=TiV*HfWu0G8Wm*jwN%>rp@9~nqKR;1nm832@JY1robAzq(%^Qqi;u?Q zy{}X0x-)&-LRyTC6|(P>4AKWGde+3B3SIi5nP{}IH=6b;B5z7_Ow-Y7lm2h+^W^kk z+n_{>GUW)M?jDh_`=pUub;XU1?rX1j1RdFMP!u|?6It|%<)K#`RV8e{RB(I7fs||* zWmv_LcbE&G$z|&DEa+K=`SES+XJeU)=M#m7{Y=)W!9mYPc!Led zjJ}0oUK!GE7N*5Vzb5zWLwWY5pKTxn@=!bt_m1<4+`l)5n3e{PQ-;IVW^Nna*q4HB~wNNEZ!3MmoM z7zhF@3k?njAij1%87l&O^#z!M#z6w7INyQQI*;7>;c&{8?G{r_X{rvnn>O?7Qb*#| zxvgj6<#r@3H!-lL~<=W4^1>^^X#ZBFWN-&In?66iz(nRt7H(e#7+Vclq_?~H3 zZEOA3q^mdMgmL;bGUyKXRczmvK1tb*dTV~1byqEW{H zqZq`V+=`i0EK?6e>2GS1cWoX$*Z0ZZ22tgT^E)@v9f9K^^=_f+ru*W4Zhc4))RcS4 z&sO$Y7rb8pg^Bhuv`NcBVllPX=ag$^c>nObX`i-|tu#NNbBpj@6~X^52Ze2j+NzH+ zx=@BM;$D2IC#LBiH%x3ByDnE7Sp5qw=&j4tCFk94{#GT2k{_>=-yv~>e;W!k8&Eo* zrRC+7CAhnqEF<=8D|(V8(R9Aa98gov18(t)>3QxSmf(&$+3eZgLQ{7CHN7<76qh0% zUKo(#!Va%RaVXYARSlk7@S!o{U+2`PCDt?8XX~5mN{MFcjB+U^x}>~`#e^6}JU)|C j$>rLV>MG$0R0mUv#^+4S`^I)1Z}&}@_iNey-&^}Pb3yD0 diff --git a/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json b/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json deleted file mode 100644 index 1620096d4..000000000 --- a/crates/proof-compression/verifying-keys/aggregate-filecoin-v6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "vk_version": 6, - "transcript": "blake3", - "setup": "filecoin", - "known_trapdoor": false, - "vk_id": "4224c47197ed387320dfb9b8bbf2369fa326f72c54e1cbc65907f5e6e8541ca0", - "verifier_blake3": "12ce28356f86ba34afb11b8e3b2c142f47ab9259e9c3f5751069bd1c41609084", - "source_vk": "aggregate-filecoin-v5.bin", - "source_verifier_blake3": "8779b34ad026a9e7075c253f036f50e553348b9d3f5c7baab21ddf487301d7cb", - "statement_prefix": [0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, 1176003176, 683533119, 3085659920], - "derivation": "The authenticated circuit metadata, fixed commitments and setup anchors are unchanged; the verifying key version selects the BLAKE3 transcript. A fresh proof is required." -} diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md index c50a6fc96..d2741210d 100644 --- a/docs/compression-stage-collapse.md +++ b/docs/compression-stage-collapse.md @@ -122,7 +122,7 @@ what result to expect from it. eleven partitions (`pipeline/first.rs`, `MIN_PARTITIONS`; `pipeline/wrap/saved.rs`), and the PLONK exporter and Go circuit hardcode the two-circuit recursive packet with its deferred pairing operands - (`wrap/outer/relation/codegen.rs`, `tools/bls-plonk-wrapper/circuit.go`). + (`wrap/outer/relation/codegen.rs`, `tools/plonk-wrapper/circuit.go`). Every change re-derives the verifying keys and fixed preprocessing. Every candidate must preserve the current proven security bound under the same assumptions and accounting as the baseline. diff --git a/flake.nix b/flake.nix index 3b3935545..2ba4aebf0 100644 --- a/flake.nix +++ b/flake.nix @@ -109,12 +109,12 @@ plonkSources = pkgs.lib.fileset.fileFilter ( file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" || file.name == "go.mod" || file.name == "go.sum" - ) ./tools/bls-plonk-wrapper; + ) ./tools/plonk-wrapper; plonkCudaSources = pkgs.lib.fileset.fileFilter ( file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" || file.hasExt "py" || file.hasExt "patch" || file.name == "go.mod" || file.name == "go.sum" - ) ./tools/bls-plonk-wrapper; + ) ./tools/plonk-wrapper; plonkSppark = pkgs.fetchFromGitHub { owner = "argumentcomputer"; repo = "sppark"; @@ -153,16 +153,16 @@ ''; meta.platforms = [ "x86_64-linux" ]; }; - mkPlonkHelper = { bn254, cuda ? false }: (cudaPkgs.buildGoModule.override { go = plonkGo; }) { - pname = (if bn254 then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper") + mkPlonkHelper = { cuda ? false }: (cudaPkgs.buildGoModule.override { go = plonkGo; }) { + pname = "ix-bn254-plonk-wrapper" + pkgs.lib.optionalString cuda "-cuda"; - tags = pkgs.lib.optional (!bn254) "bls12381" ++ pkgs.lib.optional cuda "cuda"; + tags = pkgs.lib.optional cuda "cuda"; version = "0.1.0"; src = pkgs.lib.fileset.toSource { - root = ./tools/bls-plonk-wrapper; + root = ./tools/plonk-wrapper; fileset = if cuda then plonkCudaSources else plonkSources; }; - vendorHash = "sha256-bmRyQn7iMLGRATQStghs1KmlXwln70Sxzu5oSWR5pRU="; + vendorHash = "sha256-atTpFnSORqh5A+LzECBF9gR0qCr7G2lNXpVuFFSS5to="; subPackages = [ "." ]; env.CGO_ENABLED = if cuda then "1" else "0"; env.GOTOOLCHAIN = "local"; @@ -175,16 +175,15 @@ python3 build_cuda.py --patch-vendor vendor/github.com/consensys/gnark ''; postInstall = '' - mv "$out/bin/bls-plonk-wrapper" "$out/bin/${if bn254 then "ix-bn254-plonk-wrapper" else "ix-bls-plonk-wrapper"}" + mv "$out/bin/plonk-wrapper" "$out/bin/ix-bn254-plonk-wrapper" '' + pkgs.lib.optionalString cuda '' mkdir -p "$out/lib" ln -s ${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so "$out/lib/" ''; }; - plonkHelper = mkPlonkHelper { bn254 = true; }; - plonkCudaHelper = mkPlonkHelper { bn254 = true; cuda = true; }; - blsPlonkHelper = mkPlonkHelper { bn254 = false; }; + plonkHelper = mkPlonkHelper { }; + plonkCudaHelper = mkPlonkHelper { cuda = true; }; # Rust package craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; @@ -193,7 +192,6 @@ # Profiles are embedded in the verifier; compression tests also read # binary fixtures that Cargo's source filter does not retain. compressionData = pkgs.lib.fileset.unions [ - ./crates/proof-compression/verifying-keys ./crates/proof-compression/tests/fixtures ]; # The Rust tests `include_bytes!` the `.ixe` files under @@ -451,7 +449,6 @@ ix = ixCLI; plonk-wrapper = plonkHelper; plonk-wrapper-cuda = plonkCudaHelper; - plonk-wrapper-bls12-381 = blsPlonkHelper; # `checks` are built by `nix flake check`; exposing this derivation # as a package keeps the ignored suite available on demand without # adding it to the default check set. @@ -470,7 +467,6 @@ checks = { plonk-wrapper = plonkHelper; - plonk-wrapper-bls12-381 = blsPlonkHelper; # Lint the host workspace; warnings are errors. clippy = craneLib.cargoClippy ( craneArgs diff --git a/lakefile.lean b/lakefile.lean index f9fab8784..cbaa73c89 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -54,9 +54,8 @@ section FFI /-- Curve selection shared by the CLI library and compression worker. -/ def compressionFeatures : IO (Array String) := do match (← IO.getEnv "IX_KZG_CURVE").getD "bn254" with - | "bls12-381" => return #["bls12-381", "plonk"] - | "bn254" => return #["bn254", "plonk"] - | other => throw <| IO.userError s!"IX_KZG_CURVE must be bls12-381 or bn254, got {other}" + | "bn254" => return #["plonk"] + | other => throw <| IO.userError s!"IX_KZG_CURVE must be bn254, got {other}" /-- Rust feature selection shared by compilation and native runtime linkage. -/ def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : IO (Array String) := do @@ -108,7 +107,7 @@ def usesCuda (features : Array String) : Bool := /-- Worker features follow the CLI's curve while omitting CLI-only features. -/ def compressionWorkerArgs (features : Array String) : Array String := Id.run do let mut selected := features.filter fun name => - #["bls12-381", "bn254", "plonk", "parallel"].contains name + #["plonk", "parallel"].contains name if usesCuda features then selected := selected.push "cuda" return #["build", "--release", "-p", "ix-proof-compression", "--bin", "ix-compression-worker", "--no-default-features", @@ -141,7 +140,7 @@ def copyNativeArtifact (source output : FilePath) (tag : String) : JobM Unit := proc { cmd := "cp", args := #["-f", source.toString, temporary.toString] } (quiet := true) IO.FS.rename temporary output -/-- Every source, kernel, manifest and verifying-key file the Cargo builds read. -/ +/-- Every source, kernel, manifest and fixture file the Cargo builds read. -/ def rustSources (pkg : Package) : SpawnM (Job Unit) := do let sources ← inputDir (pkg.dir / "crates") true fun path => path.extension == some "rs" || path.extension == some "cu" || @@ -149,14 +148,14 @@ def rustSources (pkg : Package) : SpawnM (Job Unit) := do path.extension == some "h" || path.extension == some "hpp" || path.fileName == "Cargo.toml" || path.fileName == "trace-manifest.json" - let keys ← inputDir (pkg.dir / "crates/proof-compression/verifying-keys") false fun path => + let fixtures ← inputDir (pkg.dir / "crates/proof-compression/tests/fixtures") false fun path => path.extension == some "bin" let manifests := Job.collectArray #[ ← inputTextFile (pkg.dir / "Cargo.toml"), ← inputTextFile (pkg.dir / "Cargo.lock"), ← inputTextFile (pkg.dir / ".cargo/config.toml") ] - let sources := sources.zipWith (fun _ _ => ()) keys + let sources := sources.zipWith (fun _ _ => ()) fixtures return sources.zipWith (fun _ _ => ()) manifests /-- Cargo's archive or executable and its native library are one build output. @@ -224,33 +223,29 @@ target ix_compression_worker pkg : FilePath := do (pkg.buildDir / "bin" / "ix-compression-worker") before /-- The terminal prover is a native Go executable with a pinned module graph. -/ -def compressionHelperName (features : Array String) : String := - if features.contains "bls12-381" then "ix-bls-plonk-wrapper" else "ix-bn254-plonk-wrapper" +def compressionHelperName : String := "ix-bn254-plonk-wrapper" -def compressionHelperArgs (features : Array String) (output : FilePath) : Array String := +def compressionHelperArgs (output : FilePath) : Array String := #["build", "-buildvcs=false", "-trimpath", "-mod=readonly"] ++ - (if features.contains "bls12-381" then #["-tags", "bls12381"] else #[]) ++ #["-o", output.toString, "."] /-- PLONK arithmetic selection is independent of the inner KZG CUDA backend. -/ -def compressionHelperCuda (features : Array String) : IO Bool := do +def compressionHelperCuda : IO Bool := do let value := (← IO.getEnv "IX_PLONK_CUDA").getD "0" let enabled ← match value with | "1" | "true" | "yes" => pure true | "0" | "false" | "no" | "" => pure false | _ => throw <| IO.userError "IX_PLONK_CUDA must be 0/1, false/true or no/yes" - if enabled && !features.contains "bn254" then - throw <| IO.userError "IX_PLONK_CUDA requires IX_KZG_CURVE=bn254" return enabled def plonkCudaLibraryName := "libix_plonk_bn254_cuda.so" target ix_plonk_wrapper pkg : Array FilePath := do let features ← compressionFeatures - let cuda ← compressionHelperCuda features + let cuda ← compressionHelperCuda unless features.contains "plonk" do return pure #[] - let mut sources ← inputDir (pkg.dir / "tools/bls-plonk-wrapper") true fun path => + let mut sources ← inputDir (pkg.dir / "tools/plonk-wrapper") false fun path => path.extension == some "go" || path.extension == some "json" || path.extension == some "py" || path.extension == some "patch" || path.extension == some "h" || @@ -259,7 +254,7 @@ target ix_plonk_wrapper pkg : Array FilePath := do let native ← inputDir (pkg.dir / "crates/proof-compression/cuda") true fun path => #["cu", "cuh", "cpp", "h", "hpp", "py", "map", "json", "patch"].contains (path.extension.getD "") sources := sources.zipWith (fun paths _ => paths) native - let helperName := compressionHelperName features + let helperName := compressionHelperName let output := pkg.buildDir / "bin" / helperName let library := pkg.buildDir / "lib" / plonkCudaLibraryName let outputs := if cuda then #[output, library] else #[output] @@ -295,7 +290,7 @@ target ix_plonk_wrapper pkg : Array FilePath := do | error "IX_PLONK_BLST_SOURCE must name the pinned blst source tree" proc { cmd := "python3" - args := #["tools/bls-plonk-wrapper/build_cuda.py", "--out", temporary.toString, + args := #["tools/plonk-wrapper/build_cuda.py", "--out", temporary.toString, "--build-dir", (pkg.buildDir / "plonk-cuda").toString, "--sppark", sppark, "--blst", blst] cwd := pkg.dir @@ -303,8 +298,8 @@ target ix_plonk_wrapper pkg : Array FilePath := do else proc { cmd := "go" - args := compressionHelperArgs features temporary - cwd := pkg.dir / "tools/bls-plonk-wrapper" + args := compressionHelperArgs temporary + cwd := pkg.dir / "tools/plonk-wrapper" } IO.FS.rename temporary output setTrace (← computeTrace outputs) @@ -503,7 +498,7 @@ script install := do -- Compression resolves its worker and terminal prover beside the CLI. let mut binaries := #["ix", "ix-compression-worker"] if (← compressionFeatures).contains "plonk" then - binaries := binaries.push (compressionHelperName (← compressionFeatures)) + binaries := binaries.push compressionHelperName for name in binaries do let destination := binDir / name FS.writeBinFile destination (← FS.readBinFile $ ".lake" / "build" / "bin" / name) @@ -513,7 +508,7 @@ script install := do let libraryDir := binDir.parent.getD "." / "lib" FS.createDirAll libraryDir FS.writeBinFile (libraryDir / cudaLibraryName) (← FS.readBinFile cudaLibrary) - if ← compressionHelperCuda (← compressionFeatures) then + if ← compressionHelperCuda then let libraryDir := binDir.parent.getD "." / "lib" FS.createDirAll libraryDir let source : FilePath := ".lake" / "build" / "lib" / plonkCudaLibraryName diff --git a/tests/check_compression_build.py b/tests/check_compression_build.py index 6568556e3..1a5bc1b5a 100644 --- a/tests/check_compression_build.py +++ b/tests/check_compression_build.py @@ -35,8 +35,8 @@ def main(): (out / 'typecheck.log').write_text(result.stdout) if result.returncode: raise RuntimeError(f'Lake configuration typecheck failed: {out / "typecheck.log"}') - cases = [('default', {}, 'bls12-381', False, True)] - for curve in ['bn254', 'bls12-381']: + cases = [('default', {}, 'bn254', False, True)] + for curve in ['bn254']: for cuda in [False, True]: cases.append((f'{curve}-{"cuda" if cuda else "cpu"}', {'IX_KZG_CURVE': curve, 'IX_CUDA': str(int(cuda))}, curve, cuda, True)) @@ -58,7 +58,7 @@ def main(): if result.returncode: raise RuntimeError(f'configuration evaluation failed: {name}') actual = json.loads(result.stdout.splitlines()[-1]) - required = {curve, 'plonk'} | ({'solidity'} if curve == 'bls12-381' else set()) + required = {'plonk'} required |= {'parallel'} if parallel else set() worker = required | ({'cuda'} if cuda else set()) ffi = required | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) @@ -79,24 +79,24 @@ def main(): raise RuntimeError(f'{name}: wrong terminal backend selection') if actual['plonk_cuda'] != (selection.get('IX_PLONK_CUDA') == '1'): raise RuntimeError(f'{name}: PLONK CUDA selection depends on inner CUDA selection') - helper = 'ix-bn254-plonk-wrapper' if curve == 'bn254' else 'ix-bls-plonk-wrapper' - if actual['plonk_helper'] != helper or ('-tags' in actual['plonk_build']) != (curve == 'bn254'): + helper = 'ix-bn254-plonk-wrapper' + if actual['plonk_helper'] != helper or '-tags' in actual['plonk_build']: raise RuntimeError(f'{name}: wrong PLONK helper or Go build tags') report['cases'].append({'name': name, 'environment': selection, 'arguments': actual, 'passed': True}) environment.update(IX_KZG_CURVE='invalid') result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) (out / 'invalid.log').write_text(result.stdout) - if result.returncode != 1 or 'IX_KZG_CURVE must be bls12-381 or bn254' not in result.stdout: + if result.returncode != 1 or 'IX_KZG_CURVE must be bn254' not in result.stdout: raise RuntimeError('invalid curve was not rejected as expected') report['invalid_curve_rejected'] = True environment.update(IX_KZG_CURVE='bls12-381', IX_PLONK_CUDA='1') result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) (out / 'invalid-plonk-cuda.log').write_text(result.stdout) - if result.returncode != 1 or 'IX_PLONK_CUDA requires IX_KZG_CURVE=bn254' not in result.stdout: - raise RuntimeError('BLS CUDA PLONK configuration was not rejected') - report['unsupported_plonk_cuda_rejected'] = True + if result.returncode != 1 or 'IX_KZG_CURVE must be bn254' not in result.stdout: + raise RuntimeError('retired curve configuration was not rejected') + report['retired_curve_rejected'] = True report['source_sha256'] = {p: hashlib.sha256(Path(p).read_bytes()).hexdigest() for p in [ 'lakefile.lean', 'tests/compression_build_config.lean', 'tests/check_compression_build.py', 'crates/ffi/Cargo.toml', 'crates/proof-compression/Cargo.toml', 'lean-toolchain', diff --git a/tests/compression_build_config.lean b/tests/compression_build_config.lean index e30068373..ade825905 100644 --- a/tests/compression_build_config.lean +++ b/tests/compression_build_config.lean @@ -11,7 +11,7 @@ def main : IO Unit := do ("test", Lean.toJson test), ("worker", Lean.toJson (compressionWorkerArgs features)), ("terminal_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), - ("plonk_helper", Lean.toJson (compressionHelperName features)), - ("plonk_cuda", Lean.toJson (← compressionHelperCuda features)), - ("plonk_build", Lean.toJson (compressionHelperArgs features "helper-output")) + ("plonk_helper", Lean.toJson (compressionHelperName)), + ("plonk_cuda", Lean.toJson (← compressionHelperCuda)), + ("plonk_build", Lean.toJson (compressionHelperArgs "helper-output")) ]).compress diff --git a/tests/kzg_backend_parity.py b/tests/kzg_backend_parity.py index 99166a32b..c9de61424 100644 --- a/tests/kzg_backend_parity.py +++ b/tests/kzg_backend_parity.py @@ -39,7 +39,7 @@ def run(command, log, environment, *, success=True, expected_error=None): def build(curve, cuda, directory, environment): mode = 'cuda' if cuda else 'cpu' - features = f'{curve},parallel' + (',cuda' if cuda else '') + features = 'parallel' + (',cuda' if cuda else '') command = [ 'cargo', 'build', '--offline', '--locked', '-p', 'ix-proof-compression', '--no-default-features', '--features', features, @@ -92,14 +92,13 @@ def check_dispatch(log, mode): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--out', type=Path, required=True) - parser.add_argument('--curve', choices=['bn254', 'bls12-381', 'both'], default='both') args = parser.parse_args() root = args.out.resolve() if root.exists() and any(root.iterdir()): parser.error('output directory must be empty') root.mkdir(parents=True, exist_ok=True) environment = dict(os.environ, RUST_LOG='ix_proof_compression=debug') - curves = ['bn254', 'bls12-381'] if args.curve == 'both' else [args.curve] + curves = ['bn254'] report = { 'baseline_revision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), 'scope': 'Two ordinary KZG circuits at 4096/8192 rows with development parameters; no FRI or recursive KZG proving.', @@ -153,16 +152,6 @@ def main(): 'input_blake3': reference['input_blake3'], 'exact_byte_parity': True, } (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') - if len(curves) == 2: - report['cross_curve_rejections'] = [] - for curve, other in [('bn254', 'bls12-381'), ('bls12-381', 'bn254')]: - directory = root / curve - record = run([ - str(directory / 'bin' / 'cpu'), 'verify', str(root / other / 'cpu' / 'proof.bin'), - str(directory / 'cpu' / 'vk.bin'), report['curves'][curve]['verifier_blake3'], - ], directory / 'reject-other-curve.log', dict(environment, CUDA_VISIBLE_DEVICES=''), - success=False, expected_error='CompactProofError') - report['cross_curve_rejections'].append(record) (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') print(json.dumps({'report': str(root / 'report.json'), 'curves': curves, 'exact_byte_parity': True}), flush=True) diff --git a/tools/bls-plonk-wrapper/README.md b/tools/bls-plonk-wrapper/README.md deleted file mode 100644 index 9f5663bdf..000000000 --- a/tools/bls-plonk-wrapper/README.md +++ /dev/null @@ -1,356 +0,0 @@ -# BLS12-381 and BN254 PLONK wrappers - -This helper proves verification of an ix BLAKE3 KZG packet, including its -outer opening equation and its deferred pairing equation. Its only public -inputs are the expected claim digest, packed as two little-endian 128-bit -limbs. The trusted circuit fixes the inner verifying key and aggregation-program -prefix. - -The implementation is experimental. The BLS conformance packets exercise small -circuits; the BN254 fixture comes from an authenticated Ignition Init proof. -Production verification keys need review of the complete circuit and its -measurements before authorization. - -## Build and use through ix - -Go 1.26.8 or newer is required for source builds. `lake build ix` builds the -native helper beside ix and its compression worker; `lake run install` installs -all three. `nix build .#ix` packages them, and `nix build .#plonk-wrapper` builds -and tests only this helper. Runtime proving and verification do not need Go or -Cargo installed. - -`lake build ix` selects BN254 for the CLI, compression worker and -`ix-bn254-plonk-wrapper` helper; `IX_KZG_CURVE=bls12-381` selects BLS12-381 -and `ix-bls-plonk-wrapper`. Set the same selection for `lake run install`. -Both helpers share this module; the default Go build selects BN254 and the -`bls12381` build tag selects BLS12-381. The standalone Nix helper packages -are `.#plonk-wrapper` (BN254) and `.#plonk-wrapper-bls12-381`; `.#ix` is -BN254. - -A BLS12-381 build proves under the Filecoin powers of tau: - -```sh -export IX_KZG_FILECOIN_CACHE=/path/to/filecoin-prefix-2p27.bin -ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output -``` - -The existing ix setup configuration owns Filecoin selection and authentication. -`IX_KZG_FILECOIN_MANIFEST` selects an explicitly pinned cache receipt when using -a cache other than the standard `2^27` cache. Terminal PLONK rejects `--dev-srs`. -Key generation and Lagrange conversion are deterministic operations on these -public powers; they do not require a circuit-specific trusted ceremony. - -The output's `plonk` directory contains the native proof, Solidity proof bytes, -generated verifier, public key and a candidate verifier catalog. `ix compress` -preserves the claim and stores a new proof address. Override the helper location -with `IX_PLONK_HELPER` if it is not beside the CLI/worker. - -## Optional BN254 CUDA backend - -CPU is the default. The experimental CUDA helper supports BN254 domains through -`2^24`, including the current Init circuit, with GPU MSMs, FFTs and a fused -PLONK quotient. Gnark retains witness solving, randomness, transcript challenges, -opening construction and verification. Proof encoding and the verifying key -are independent of the selected arithmetic backend. - -The source build uses explicit copies of sppark revision -`114feb69ebfe029e2caa5e6c2d372c695e5d4da8` and blst 0.3.16. The blst source root -must contain `src/server.c` and `build/assembly.S`. No Rust build archive is -required. Provide these source roots and a CUDA toolkit supporting the chosen -GPU architecture: - -```sh -export IX_PLONK_SPPARK_SOURCE=/path/to/pinned-sppark -export IX_PLONK_BLST_SOURCE=/path/to/blst-0.3.16/blst -export CUDA_HOME=/usr/local/cuda -export NVCC="$CUDA_HOME/bin/nvcc" -export IX_PLONK_CUDA_ARCHS=120 -IX_PLONK_CUDA=1 lake build ix_plonk_wrapper -``` - -The standalone equivalent is: - -```sh -python3 tools/bls-plonk-wrapper/build_cuda.py \ - --out .lake/build/bin/ix-bn254-plonk-wrapper \ - --build-dir .lake/build/plonk-cuda \ - --sppark "$IX_PLONK_SPPARK_SOURCE" --blst "$IX_PLONK_BLST_SOURCE" -``` - -The build checks source receipts, applies the tracked gnark and sppark patches -to private build copies, and records executable/library hashes. It leaves -`go.mod` and the dependency caches unchanged. The native library is installed -in `../lib` relative -to the helper. Keep both artifacts together when installing; `lake run install` -does this when `IX_PLONK_CUDA=1`. The optional Nix package is -`.#plonk-wrapper-cuda`, targeting SM120. CPU packages remain independent of -the CUDA toolkit. - -Select the runtime backend separately from building it: - -```sh -CUDA_VISIBLE_DEVICES=0 IX_PLONK_BACKEND=cuda \ - .lake/build/bin/ix-bn254-plonk-wrapper prove artifacts input/witness.json proof-output -``` - -`IX_PLONK_CUDA_DEVICE` selects the visible ordinal (default 0), and -`IX_PLONK_CUDA_MEMORY_GIB` sets the allocation budget (default 64 GiB), with -8 GiB additional device headroom. Use one visible physical GPU per helper. -`IX_PLONK_CUDA` does not imply the inner prover's `IX_CUDA`, or vice versa. -Requesting CUDA from a CPU-only helper fails explicitly. CUDA errors abort that -proof; they do not silently switch its arithmetic to CPU. - -The helper emits callback times, dispatch counts, transferred bytes, allocation -accounting and CUDA pool high-water marks. Callback times include transfers; -quotient timing includes its internal transforms and host restoration. Pool -high-water marks exclude allocations outside the pool. Complete-process timing -also includes artifact authentication/loading and backend setup. -Queue wait and nested native timings overlap callback timings; they are not -independent stages to add to the total. -Set `IX_PLONK_CUDA_TRACE=1` for operation start/end diagnostics without scalar -values. Transfer counters cover explicit adapter transfers; internal sppark -result transfers may add traffic. - -ABI7 reports per-callback transfer byte/call deltas and separates inclusive -transfer wall time, host staging and DMA event time. Wall time can include -previously queued kernels; host staging and DMA overlap. DMA timings are valid -only when `TransferTimingEnabled` is true. The shared transfer implementation -currently enables those events with `IX_KZG_CUDA_PROFILE=1`. - -The opt-in full Init test supports `IX_PLONK_CUDA_INIT_MODES`, -`IX_PLONK_CUDA_INIT_REPEATS`, `IX_PLONK_CUDA_INIT_STAGE_PROFILE=1` and -`IX_PLONK_CUDA_INIT_CPU_PROFILE=1`, in addition to its required artifact/claim -pins. CPU sampling covers only the first selected proof; stage observation -covers every selected proof. Reports separate backend setup and profiler -startup/shutdown from core proving and record overlapping stage intervals. - -`IX_PLONK_CUDA_MSM_STRATEGY=split` is the default: large Pippenger buckets are -split into bounded tasks while retaining the zero and ±1 fast paths. The -explicit `mask` comparison strategy computes `MSM(s+r)-MSM(r)` and caches its -correction points by registered range. Neither path applies whole-scalar -affine normalization. Both preserve the original commitment exactly. - -Artifact manifests still bind their producer executable. Compile and prepare -artifacts with the frozen helper used for proving; CPU/CUDA mode changes within -that helper do not require different keys. A changed helper does not authorize -editing an old manifest. The full Init test harness accepts an independently -pinned existing manifest without changing production loading rules. - -The persistent worker protocol, immutable selector caching across proofs, -shared admission with KZG workers, and cancellation/restart policy are subsequent -service work. - -## Authorize verification keys independently - -Set `IX_PLONK_VERIFIER_CATALOG` to an independently trusted JSON catalog before -verifying a PLONK proof with `ix verify`. Generating a proof does not activate -its candidate catalog. Approving a key approves the circuit and fixed program -prefix represented by that key. - -The generated `verifier-catalog.candidate.json` has the catalog schema and uses -a path relative to its directory for `verifying-key.bin`. It records the key's -SHA-256, inner verifying key, program prefix, circuit hash and helper hash. Review and -install accepted entries as trusted application configuration. Proof envelopes -carry only a key identifier and proof bytes; they cannot supply a trusted key. - -Verification checks the selected key's digest and Filecoin G1/G2 anchors and -derives the expected public digest from the independently supplied ix claim. -It requires no proving SRS or GPU. - -For BN254, the catalog format is `ix-bn254-plonk-verifiers-v1` and each key -has `setup: "ignition"`, an `admission` path and its independently trusted -`admission_blake3`. Verification authenticates that admission and checks the -key's Ignition anchors without loading the proving powers. Relative key and -admission paths resolve beside the catalog. The BN254 envelope is separately -tagged and cannot be accepted as a BLS proof. - -## Standalone operations - -`ix-compression-worker plonk export` authenticates a portable BLAKE3 verifying key, -verifies its packet against an independently expected statement, and exports -the fixed scalar program and canonical witness: - -```sh -ix-compression-worker plonk export \ - vk.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input -ix-bls-plonk-wrapper compile input/vk.json artifacts -ix-bls-plonk-wrapper check-witness artifacts input/witness.json -``` - -`check-witness` is an assignment diagnostic, not a proof. It uses diagnostic -commitment challenges. Production `prove` uses gnark's real KZG commitment -computation and verifies the resulting PLONK proof before publishing it. - -Read the padded `Domain` from `artifacts/manifest.json`, then export the required -parameters through ix: - -```sh -ix-compression-worker plonk srs DOMAIN parameters.bin -ix-bls-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 -ix-bls-plonk-wrapper prove artifacts input/witness.json proof-output -ix-bls-plonk-wrapper verify \ - artifacts/verifying-key.bin TRUSTED_KEY_SHA256 \ - proof-output/proof.bin EXPECTED_CLAIM_DIGEST -``` - -The parameter export receipt is written beside `parameters.bin` as -`parameters.json`. Obtain `TRUSTED_EXPORT_BLAKE3` from that authenticated Rust -operation, independently of an untrusted parameter file. Gnark requires `N+3` -monomial powers and `N` Lagrange points; the adapter requests the next power of -two from ix and retains the ceremony's full public degree. Its current maximum -PLONK domain is `2^26` with the provisioned `2^27` cache. - -Circuit and key artifacts bind the verifying key, executable and parameter receipt. -The helper checks hashes before reuse. Lagrange points are cached separately -by setup, domain, receipt and helper identity; `IX_PLONK_LAGRANGE_CACHE` can -select the shared directory. A changed executable needs a new artifact -directory. Corrupt artifacts fail closed. - -## BN254 Ignition parameters and Lagrange import - -BN254 uses the same commands with `ix-bn254-plonk-wrapper`. Export requires -`IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, -`IX_KZG_IGNITION_ADMISSION` and `IX_KZG_IGNITION_ADMISSION_BLAKE3`, as described -in the [compression setup documentation](../../crates/proof-compression/README.md). -The provisioned `2^26` monomial cache supports PLONK domain `2^25`, which -requires `2^25+3` powers. The normal helper's classic-GLV Init wrapper has -14,689,440 constraints plus two public rows, fitting `2^24` with 2,087,774 -rows to spare. The completed full proof benchmark below used the earlier -17,996,081-constraint configuration and domain `2^25`. - -SP1 v6.1.0 publishes a legacy gnark `plonk_pk.bin` containing a `2^25` -Lagrange basis for the same Ignition anchors. It is an untrusted source of -candidate points. After compilation and authenticated monomial export, import -it before `prepare`: - -```sh -ix-bn254-plonk-wrapper import-lagrange-sp1 \ - artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 /path/to/plonk_pk.bin -ix-bn254-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 -``` - -The importer bounds lengths, validates point encodings, compares the setup -anchors and domain, and checks the complete candidate basis against the -authenticated monomial powers with a transcript-bound random linear relation. -That relation requires a scalar inverse FFT and two MSMs. The cache is -published only after it passes, with the candidate-vector digest and validation -evidence in `validation.json`. The error bound for a fixed incorrect basis is -at most `(N-1)/Fr` in the random-oracle model. SP1 circuit keys are discarded; -`prepare` generates ix's own proving and verifying keys. A basis for `2^25` -cannot be truncated into a `2^24` basis. - -The import command requires an exact domain match. For the current `2^24` -circuit, `prepare` computes and caches a matching basis from the authenticated -monomial powers unless an independently validated matching cache is present. - -Ordinary BN254 tests run with `go test ./...`; BLS12-381 tests with -`go test -tags bls12381 ./...`. They cover the real -Init verifier witness and tampering, Arkworks-compatible transcript encodings, -exceptional MSM inputs, small PLONK proofs, parameter caches and rejection of -incorrect imported bases and malformed SP1 key containers. - -## Tests and sizing - -```sh -cd tools/bls-plonk-wrapper -go test ./... -IX_PLONK_SIZE=1 go test -run TestComponentSizing -v -``` - -The ordinary tests cover BLAKE3 block/chunk/XOF boundaries, canonical curve -encodings, subgroup rejection, complete verifier witnesses, cached-parameter -integrity, basis conversion and small PLONK proofs using real Filecoin powers. -The small PLONK backend proof tests exercise `N=8`, separately from the full -verifier circuit. - -The measured BLS full conformance verifier has 20,665,582 constraints, a `2^25` -domain and one extra committed polynomial. Point validation and public binding -account for about 3.21 million constraints, BLAKE3 adds 6.29 million, scalar -identities add 441, opening MSMs add 7.77 million, and the pairing checks add -about 1.70 million each. These are cumulative-compilation differences; shared -range checks can affect component accounting. - -For that BLS fixture, even eliminating the second pairing check for free would -leave more than `2^24` constraints. BLS retains classic GLV; its tested -alternative increased the full count to 25,429,821. - -BN254 also selects classic GLV, reducing its actual Init wrapper to -14,689,442 total rows while retaining separate pairings. The randomized -batching measurement variant reaches 13,810,186 rows with classic GLV; batching -with BN254's previous multiplication path reaches 17,530,148 rows. Batching -is experimental and is not selected by the normal helper. Reproduce sizing -with `IX_PLONK_SIZE=1 go test -run TestOptimizationSizing -v`; -`IX_PLONK_SIZE_VK` selects a verifying key and `IX_PLONK_SIZE_REPORT` saves JSON. -`IX_PLONK_SIZE_VARIANT` can select `baseline`, `batched`, `classic-glv` or -`batched-classic-glv`. These are compilation results, separate from the -completed 2^25 proof benchmark. -`TestComponentSizing` accepts `baseline` or `classic-glv` for the component -breakdown; its default follows the normal helper's classic-GLV selection. - -The backend is native CPU gnark at `262cf453c107d147a228b6b7418c5cf791c3b79b`. -Upstream experimental WebGPU/WASM PLONK acceleration exists, but this helper -does not activate it. The packaged native ICICLE/CUDA integration is for -Groth16. - -## Full Init proof measurement - -A full Filecoin/BLAKE3 Init packet was wrapped and verified on October 16, -2026, using 48 logical CPUs of a Xeon Platinum 8559C and 499 GiB RAM. The -actual Init wrapper has 21,184,618 constraints, a `2^25` domain, two public -inputs and one extra commitment. - -With keys prepared, `plonk.Prove` took **127.17 seconds**; the fresh prove -process took **293.87 seconds**, including artifact authentication/loading, -verification and output. Peak prover RSS was **53.63 GiB**. In-memory native -verification took **2.75 ms**. Across seven fresh verifier processes, -decode/public-witness/verification took a median **4.20 ms** and complete -process wall time was **25.51 ms**. Wrong claims and a modified proof were -rejected. - -The native proof is **744 bytes**, its ix envelope **784 bytes**, and the -Solidity encoding **1,184 bytes**. Solidity transaction gas was not measured. -One-time parameter/key preparation took **20m 17s**, including **18m 59s** -for Lagrange conversion and cache publication. Those costs, compilation, -SRS export and inner KZG proving are excluded from the proving figure. - -The matching circuit, keys, parameter representations, frozen tools and proof -are archived in `s3://argument-kzg-params-063002298335-us-east-1-an/` -(`us-east-1`) under -`bls12-381/plonk/init/2p25/73d8ebbad3df955310fcf7c8b7d35cf04f6ecd5be513f55af25036b7349e5299/`. -Its `MANIFEST.json` has SHA-256 -`6be357f686599d283acca68e6eb4a9aa9f29c31d0010e7c8190f5767a04daaee`; the -archive's `verify_bundle.py --manifest-sha256` authenticates a restored copy. - -The earlier 2^25 configuration of the actual Ignition/BLAKE3 Init packet -proved and verified with BN254: -**104.33 seconds** core proving, **143.59 seconds** for the fresh prover process, -**47.18 GiB** peak RSS and **1.20 ms** in-memory native verification. Seven fresh -verifier processes had medians of **1.92 ms** for decode/public-witness/verify -and **21.70 ms** including startup and key loading. The native proof is -**584 bytes**, its ix envelope **624 bytes**, and the Solidity encoding -**864 bytes**. Prepared keys were excluded from these proof timings. - -Full validation and cache publication of SP1's `2^25` Lagrange basis took -**27.70 seconds**, followed by **63.55 seconds** for parameter loading and ix -circuit-key preparation. This avoids computing the group FFT locally; the -setup-time difference from the BLS run is not a curve-speed comparison. - -The current **2^24 classic-GLV configuration**, retaining separate pairing -checks, proves the same Init packet in **53.79 seconds** for core proving and -**73.70 seconds** for the complete fresh process. Peak prover RSS is -**25.19 GiB**. Native in-memory verification took **1.49 ms**; seven fresh -verifier processes had medians of **1.81 ms** for decode/public-witness/verify -and **22.52 ms** including startup and key loading. Proof sizes remain -**584 bytes native**, **624 bytes enveloped** and **864 bytes for Solidity**. - -Local 2^24 parameter/key preparation took **5m 58s**, including **5m 25s** for -Lagrange conversion/cache publication. These artifacts are now cached; all -preparation is excluded from proof timings. The proof and independent native -and catalog verification passed, including negative-input checks. - -The same BN254 proof passes Solidity verification on local Anvil using SP1's -v6.1.0 verifier with only its 23 circuit-key constants changed. Total receipt -gas is **316,003** directly or **320,103** through ix's expected-claim adapter, -including transaction intrinsic/calldata gas. All 47 compatibility and -negative-input checks passed. See the -[Anvil reproduction and gas breakdown](../../contracts/plonk/README.md#actual-bn254-init-proof-on-anvil). diff --git a/tools/bls-plonk-wrapper/backend_test.go b/tools/bls-plonk-wrapper/backend_test.go deleted file mode 100644 index 6eab7015f..000000000 --- a/tools/bls-plonk-wrapper/backend_test.go +++ /dev/null @@ -1,154 +0,0 @@ -//go:build bls12381 - -package main - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "io" - "os" - "path/filepath" - "testing" - - "github.com/consensys/gnark-crypto/ecc" - "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" - "github.com/consensys/gnark/backend" - "github.com/consensys/gnark/backend/plonk" - plonkbls "github.com/consensys/gnark/backend/plonk/bls12-381" - "github.com/consensys/gnark/backend/solidity" - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/frontend/cs/scs" -) - -type backendCircuit struct { - Claim [2]frontend.Variable `gnark:",public"` - X frontend.Variable - Commit bool `gnark:"-"` -} - -func (c *backendCircuit) Define(api frontend.API) error { - api.AssertIsEqual(api.Mul(c.X, c.X), c.Claim[0]) - api.AssertIsEqual(c.Claim[1], 1) - if c.Commit { - challenge, err := api.(frontend.Committer).Commit(c.X) - if err != nil { - return err - } - api.AssertIsDifferent(challenge, 0) - } - return nil -} - -func TestFilecoinBackendAndProofCodec(t *testing.T) { - p, err := loadVerifyingKey(filepath.Join("testdata", "outer", "vk.json")) - if err != nil { - t.Fatal(err) - } - var receipt struct { - Format, Blake3, SetupID, FilecoinManifest string - PublicMaxDegree, Powers uint64 - } - if err := decodeJSON(filepath.Join("testdata", "srs.json"), &receipt); err != nil { - t.Fatal(err) - } - srsPath := filepath.Join("testdata", "srs.bin") - srs, err := readSRS(srsPath, receipt.Blake3, 128, p) - if err != nil { - t.Fatal(err) - } - if _, err := readSRS(srsPath, receipt.Blake3, 64, p); err == nil { - t.Fatal("accepted wrong power count") - } - if _, err := readSRS(srsPath, string(bytes.Repeat([]byte{'0'}, 64)), 128, p); err == nil { - t.Fatal("accepted wrong export receipt") - } - for _, commit := range []bool{false, true} { - ccs, err := frontend.Compile(ecc.BLS12_381.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: commit}) - if err != nil { - t.Fatal(err) - } - n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + 2)) - if n > 128 { - t.Fatalf("tiny circuit exceeded fixture SRS: %d", n) - } - lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) - if err != nil { - t.Fatal(err) - } - pk, vk, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) - if err != nil { - t.Fatal(err) - } - w, err := frontend.NewWitness(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7}, ecc.BLS12_381.ScalarField()) - if err != nil { - t.Fatal(err) - } - proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) - if err != nil { - t.Fatal(err) - } - public, _ := w.Public() - if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { - t.Fatal(err) - } - var encoded bytes.Buffer - proof.WriteTo(&encoded) - m := len(vk.(*plonkbls.VerifyingKey).Qcp) - decoded, err := decodeProof(encoded.Bytes(), m) - if err != nil { - t.Fatal(err) - } - if err := plonk.Verify(decoded, vk, public); err != nil { - t.Fatal(err) - } - for _, offset := range []int{0, 384, 660 + 32*m} { - bad := append([]byte(nil), encoded.Bytes()...) - bad[offset] = 0xff - if _, err := decodeProof(bad, m); err == nil { - t.Fatalf("accepted malformed proof at %d", offset) - } - } - if _, err := decodeProof(append(encoded.Bytes(), 0), m); err == nil { - t.Fatal("accepted trailing proof bytes") - } - if commit { - dir := os.Getenv("IX_PLONK_TEST_OUTPUT") - if dir == "" { - dir = t.TempDir() - } - keyPath := filepath.Join(dir, "verifying-key.bin") - if err := atomicFile(keyPath, func(w io.Writer) error { _, err := vk.WriteTo(w); return err }); err != nil { - t.Fatal(err) - } - keyHash, _ := fileHash(keyPath) - proofPath := filepath.Join(dir, "proof.bin") - if err := atomicFile(proofPath, func(w io.Writer) error { _, err := w.Write(encoded.Bytes()); return err }); err != nil { - t.Fatal(err) - } - var digest [32]byte - digest[0], digest[16] = 49, 1 - if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err != nil { - t.Fatal(err) - } - digest[31] = 1 - if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err == nil { - t.Fatal("accepted changed public digest") - } - digest[31] = 0 - if err := atomicFile(filepath.Join(dir, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }); err != nil { - t.Fatal(err) - } - payload := proof.(*plonkbls.Proof).MarshalSolidity() - if len(payload) != 1056+128*m { - t.Fatal("unexpected Solidity proof length") - } - vector := map[string]any{"scope": "tiny-backend-test-not-an-ix-wrapper", "proof": "0x" + hex.EncodeToString(payload), "digest": "0x" + hex.EncodeToString(digest[:]), "commitments": m, "domain": n} - data, _ := json.MarshalIndent(vector, "", " ") - if err := atomicFile(filepath.Join(dir, "proof.json"), func(w io.Writer) error { _, err := w.Write(data); return err }); err != nil { - t.Fatal(err) - } - } - t.Logf("Filecoin PLONK backend: N=%d commitments=%d native_bytes=%d solidity_bytes=%d", n, m, encoded.Len(), len(proof.(*plonkbls.Proof).MarshalSolidity())) - } -} diff --git a/tools/bls-plonk-wrapper/circuit_test.go b/tools/bls-plonk-wrapper/circuit_test.go deleted file mode 100644 index f2d86985f..000000000 --- a/tools/bls-plonk-wrapper/circuit_test.go +++ /dev/null @@ -1,109 +0,0 @@ -//go:build bls12381 - -package main - -import ( - "math/big" - "path/filepath" - "testing" - - "github.com/consensys/gnark-crypto/ecc" - bls "github.com/consensys/gnark-crypto/ecc/bls12-381" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" - "github.com/consensys/gnark/frontend" - sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" - "github.com/consensys/gnark/std/math/emulated" - "github.com/consensys/gnark/std/math/uints" - "github.com/consensys/gnark/test" -) - -type encodingCircuit struct { - Point sw.G1Affine - Encoded [48]uints.U8 -} - -func (c *encodingCircuit) Define(api frontend.API) error { - u, err := uints.NewBinaryField[uints.U32](api) - if err != nil { - return err - } - f, err := emulated.NewField[sw.BaseField](api) - if err != nil { - return err - } - pairing, err := sw.NewPairing(api) - if err != nil { - return err - } - pairing.AssertIsOnG1(&c.Point) - for i, b := range pointBytes(api, u, f, &c.Point) { - u.Bytes.AssertIsEqual(b, c.Encoded[i]) - } - return nil -} - -func TestIdentityAndCanonicalPointEncoding(t *testing.T) { - _, _, g, _ := bls.Generators() - var negative bls.G1Affine - negative.Neg(&g) - for _, point := range []bls.G1Affine{{}, g, negative} { - assignment := &encodingCircuit{Point: sw.NewG1Affine(point)} - bytes := point.Bytes() - copy(assignment.Encoded[:], uints.NewU8Array(bytes[:])) - if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BLS12_381.ScalarField()); err != nil { - t.Fatal(err) - } - assignment.Encoded[0] = uints.NewU8(bytes[0] ^ 0x20) - if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BLS12_381.ScalarField()); err == nil { - t.Fatal("accepted wrong sign bit") - } - } - var torsion bls.G1Affine - torsion.Y.SetUint64(2) - assignment := &encodingCircuit{Point: sw.NewG1Affine(torsion)} - bytes := torsion.Bytes() - copy(assignment.Encoded[:], uints.NewU8Array(bytes[:])) - if err := test.IsSolved(&encodingCircuit{}, assignment, ecc.BLS12_381.ScalarField()); err == nil { - t.Fatal("accepted wrong-subgroup point") - } - identity := new(bls.G1Affine).Bytes() - alias := &encodingCircuit{Point: sw.NewG1Affine(bls.G1Affine{})} - copy(alias.Encoded[:], uints.NewU8Array(identity[:])) - alias.Point.X.Limbs = make([]frontend.Variable, 6) - modulus := fp.Modulus() - for i := range alias.Point.X.Limbs { - alias.Point.X.Limbs[i] = new(big.Int).And(new(big.Int).Rsh(new(big.Int).Set(modulus), uint(64*i)), new(big.Int).SetUint64(^uint64(0))) - } - if err := test.IsSolved(&encodingCircuit{}, alias, ecc.BLS12_381.ScalarField()); err == nil { - t.Fatal("accepted noncanonical identity coordinates") - } -} - -func TestFullVerifierWitness(t *testing.T) { - p, err := loadVerifyingKey(filepath.Join("testdata", "outer", "vk.json")) - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"fixture", "fixture-other-statement", "fixture-bad-pairing"} { - assignment, err := witnessAssignment(p, filepath.Join("testdata", "outer", name+".json")) - if err != nil { - t.Fatal(err) - } - err = test.IsSolved(newCircuit(p), assignment, ecc.BLS12_381.ScalarField()) - if name == "fixture-bad-pairing" { - if err == nil { - t.Fatal("accepted valid outer proof with invalid deferred pairing") - } - } else if err != nil { - t.Fatalf("%s: %v", name, err) - } - } - assignment, err := witnessAssignment(p, filepath.Join("testdata", "outer", "fixture.json")) - if err != nil { - t.Fatal(err) - } - assignment.Claim[0] = 123 - if err := test.IsSolved(newCircuit(p), assignment, ecc.BLS12_381.ScalarField()); err == nil { - t.Fatal("accepted a changed claim") - } -} diff --git a/tools/bls-plonk-wrapper/curve_bls.go b/tools/bls-plonk-wrapper/curve_bls.go deleted file mode 100644 index 37114a202..000000000 --- a/tools/bls-plonk-wrapper/curve_bls.go +++ /dev/null @@ -1,98 +0,0 @@ -//go:build bls12381 - -package main - -import ( - "bytes" - "fmt" - "github.com/consensys/gnark-crypto/ecc" - native "github.com/consensys/gnark-crypto/ecc/bls12-381" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fp" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" - "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" - backend "github.com/consensys/gnark/backend/plonk/bls12-381" - "github.com/consensys/gnark/frontend" - sw "github.com/consensys/gnark/std/algebra/emulated/sw_bls12381" - "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" - "github.com/consensys/gnark/std/math/cmp" - "github.com/consensys/gnark/std/math/emulated" - "github.com/consensys/gnark/std/math/uints" - "math/big" -) - -type nativeG1 = native.G1Affine -type nativeG2 = native.G2Affine -type nativeSRS = kzg.SRS -type nativeKzgProvingKey = kzg.ProvingKey -type nativeVerifyingKey = backend.VerifyingKey -type nativeProof = backend.Proof -type circuitG1 = sw.G1Affine -type circuitG2 = sw.G2Affine -type circuitScalar = sw.Scalar -type baseField = sw.BaseField -type scalarField = sw.ScalarField - -var baseModulus = fp.Modulus -var scalarModulus = fr.Modulus -var precomputeLines = native.PrecomputeLines -var toLagrange = kzg.ToLagrangeG1 -var newCircuitG1 = sw.NewG1Affine -var newCircuitG2Fixed = sw.NewG2AffineFixed -var newPairing = sw.NewPairing - -const curveID = ecc.BLS12_381 -const baseBytes = 48 -const pointBytesLen = 48 -const pointLimbBytes = 24 -const g2ExportBytes = 96 -const anchorBytesLen = 240 -const srsMagic = "IXPSRS01" -const vkFormat = "ix-bls-plonk-vk-v1" -const artifactFormat = "ix-bls-plonk-artifacts-v1" -const lagrangeFormat = "ix-plonk-lagrange-v1" - -var curveParameters = sw_emulated.GetBLS12381Params - -func isCompressedPoint(b byte) bool { return b&0x80 != 0 } -func encodePoint(p nativeG1) []byte { b := p.Bytes(); return b[:] } -func decodeG2(b []byte) (nativeG2, error) { - var p nativeG2 - if _, err := p.SetBytes(b); err != nil { - return p, err - } - canonical := p.Bytes() - if p.IsInfinity() || !bytes.Equal(canonical[:], b) { - return p, fmt.Errorf("invalid G2 anchor") - } - return p, nil -} -func encodeAnchors(vk *nativeVerifyingKey) []byte { - g, h, t := vk.Kzg.G1.Bytes(), vk.Kzg.G2[0].Bytes(), vk.Kzg.G2[1].Bytes() - return append(append(append([]byte{}, g[:]...), h[:]...), t[:]...) -} -func pointBytes(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], point *circuitG1) []uints.U8 { - f.AssertIsInRange(&point.X) - f.AssertIsInRange(&point.Y) - x := f.ToBitsCanonical(&point.X) - y := f.ToBitsCanonical(&point.Y) - half := new(big.Int).Rsh(new(big.Int).Set(baseModulus()), 1) - bound := make([]frontend.Variable, len(y)) - for i := range bound { - bound[i] = half.Bit(i) - } - sign := cmp.IsLessBinary(api, bound, y) - infinity := api.And(f.IsZero(&point.X), f.IsZero(&point.Y)) - for len(x) < 384 { - x = append(x, 0) - } - out := make([]uints.U8, 48) - for i := range out { - out[47-i] = u.Bytes.FromBits(x[8*i : 8*i+8]...) - } - out[0] = u.ByteValueOf(api.Add(u.Value(out[0]), 128, api.Mul(64, infinity), api.Mul(32, sign))) - return out -} - -func importLagrangeSP1(_, _, _, _ string) error { - return fmt.Errorf("SP1 Lagrange import requires the BN254 build") -} diff --git a/tools/bls-plonk-wrapper/parameters_test.go b/tools/bls-plonk-wrapper/parameters_test.go deleted file mode 100644 index 520837c8d..000000000 --- a/tools/bls-plonk-wrapper/parameters_test.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build bls12381 - -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" - "github.com/consensys/gnark-crypto/ecc/bls12-381/fr/fft" - "github.com/consensys/gnark-crypto/ecc/bls12-381/kzg" -) - -func TestLagrangeCacheAndBasisConversion(t *testing.T) { - p, err := loadVerifyingKey(filepath.Join("testdata", "outer", "vk.json")) - if err != nil { - t.Fatal(err) - } - var receipt struct { - Format, Blake3, SetupID, FilecoinManifest string - PublicMaxDegree, Powers uint64 - } - if err := decodeJSON(filepath.Join("testdata", "srs.json"), &receipt); err != nil { - t.Fatal(err) - } - srs, err := readSRS(filepath.Join("testdata", "srs.bin"), receipt.Blake3, 128, p) - if err != nil { - t.Fatal(err) - } - root := t.TempDir() - executable := strings.Repeat("a", 64) - first, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) - if err != nil || reused { - t.Fatalf("initial derivation: reused=%t error=%v", reused, err) - } - second, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) - if err != nil || !reused { - t.Fatalf("cached derivation: reused=%t error=%v", reused, err) - } - for i := range first.Pk.G1 { - if !first.Pk.G1[i].Equal(&second.Pk.G1[i]) { - t.Fatal("cache changed Lagrange points") - } - } - coefficients := make([]fr.Element, 8) - for i := 0; i < 3; i++ { - coefficients[i].SetUint64(uint64(2*i + 1)) - } - evaluations := append([]fr.Element(nil), coefficients...) - fft.NewDomain(8).FFT(evaluations, fft.DIF) - fft.BitReverse(evaluations) - a, err := kzg.Commit(coefficients, srs.Pk) - if err != nil { - t.Fatal(err) - } - b, err := kzg.Commit(evaluations, second.Pk) - if err != nil { - t.Fatal(err) - } - if !a.Equal(&b) { - t.Fatal("monomial and Lagrange commitments differ") - } - files, err := filepath.Glob(filepath.Join(root, "8", "*", "points.bin")) - if err != nil || len(files) != 1 { - t.Fatalf("cache files: %v %v", files, err) - } - f, err := os.OpenFile(files[0], os.O_RDWR, 0) - if err != nil { - t.Fatal(err) - } - var changed [1]byte - if _, err := f.ReadAt(changed[:], 30); err != nil { - t.Fatal(err) - } - changed[0] ^= 1 - if _, err := f.WriteAt(changed[:], 30); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - if _, _, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable); err == nil { - t.Fatal("accepted corrupted Lagrange cache") - } - if _, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, strings.Repeat("b", 64)); err != nil || reused { - t.Fatalf("new backend reused old cache: %t %v", reused, err) - } -} diff --git a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json deleted file mode 100644 index 35edb4eaa..000000000 --- a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-diagnostic.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "challenges": [ - "0x31913d370d61ac6d88ae01d97a96131b747484b1fd32388d885a43cb58a2cf3b", - "0x23c20c79e623feed525eac4849b9389e5f95e8ba1abcaec6e13a0b890416543e", - "0x09a363993a3bb6f0656c7744b2e9176b0c7828548a47bb1044fc1cf9135c84b3", - "0x450fff7a6dadb61b94e98091ad38fb2b4b6cf7f4a557709a136e5b542364a36a", - "0x3febfac0e5d86cf2a609cd0a9481e2431387daf511c24d5fd9bf239e9f990518", - "0x60686bd8591bd918c0b0242c7ccf2e2e3b846017a72968d03e8d475adb4f5e16" - ], - "compositions": [ - "0x084dcc720dbc90ae6e64559a7eb310509cbf69aad2749dce120a396671ab6e6d", - "0x051d960e1836ea26f783f640223a3aaa9b9a35635cbb43a43a7911ae3469418b" - ], - "outerL": "0x000000000000000000000000000000000dc247410774f8792f52266dbe30b3d482d3b7b672dc985af306964493299433eac15cf108f21a00f9228ce490f192560000000000000000000000000000000002e753e033ff8c32ac3f1de1a5d7349029878a221bb577ede30acf28f8a787cc313bab83d5586719c4fe7ba25a00e98e", - "outerR": "0x0000000000000000000000000000000019ba2c8e06a137b1a1bce6e197508bc7374836efdcaf77f984a3b75347544c8b81cc73057b4f5eec0515d2454e8cafd800000000000000000000000000000000095ac1da17393b74beefa32643c0399604e6a001d44ab14e609ca01dd5a66c608afc24f13913f6cc53b701767e6b2e5b", - "publicAccumulator": "0x046a10b9480962b677090634e7e256f61dcae10b2c4f0988bb1f1ac3c8936647", - "quotients": [ - "0x0eac21a8d75b378484e5e1f64699532187387756794963c90aad23ebb0201155", - "0x3a99ea48de8ef1189fc74fbcc4eadf3f781b83e74954003222cbd3798fa9b7f3" - ] -} \ No newline at end of file diff --git a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json deleted file mode 100644 index 49b014f71..000000000 --- a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing-statement.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - 0, - 17, - 1002, - 1003, - 1004, - 1005, - 1006, - 1007, - 1008, - 1009, - 1010, - 1011, - 1012, - 1013, - 1014, - 1015, - 1016, - 1017 -] \ No newline at end of file diff --git a/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing.bin b/tools/bls-plonk-wrapper/testdata/outer/fixture-bad-pairing.bin deleted file mode 100644 index 32ee9aeb38390db10f5b8d14e5becaeaee54f377..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2181 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 diff --git a/tools/bls-plonk-wrapper/testdata/outer/vk.json b/tools/bls-plonk-wrapper/testdata/outer/vk.json deleted file mode 100644 index b7e507e08..000000000 --- a/tools/bls-plonk-wrapper/testdata/outer/vk.json +++ /dev/null @@ -1,4878 +0,0 @@ -{ - "circuits": [ - { - "logDegree": 5, - "shape": [ - 5, - 3, - 32, - 15, - 3, - 4, - 2 - ] - }, - { - "logDegree": 4, - "shape": [ - 1, - 2, - 16, - 4, - 1, - 1, - 1 - ] - } - ], - "deferredKeys": [ - "0x93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8", - "0xb16010f1b3d33aa5f3f608c385400979ed36dc3fab53d3626f519c74d7e1ebef62c9ad35796404ce2fc3ebb42167b5a1050ac27c3527072b81af0fa9a683e226da79275f2985c9e89a0db969b76e0b6be94fee874c48586737ad5588cf1e1041" - ], - "fieldCount": 37, - "fixed": [ - { - "x": "0x023222a72009debb870a4830d1f575665569f38d3030cf3e858a0ed5d292670975f87a6782dd13694b35ded2aa7b1efa", - "y": "0x059a5cd6a6c06eb274060f8163d32ce1e8d20a93281c3509aee2286a8a628be43aa4f9a4b736ba6fca4b4c904e6a3620" - }, - { - "x": "0x093b681bc453d6d641af75548dd948d53b02f4285fcd99928df36f3fc34a778ad6ec52eb4f87cb945b135fa3cf04cbf6", - "y": "0x0663f77b4b3ccd0c9798fbf487ffa5557e8f20a3488c8eea002b5755ce4e57633723393543b26bcb88e27961cd076fb9" - }, - { - "x": "0x0bba37ac925569b9f873a311358c021843481ba455d85838893703cedf84f94119ceb6127977d3720047fdbf5864c19f", - "y": "0x19167223bf1539e9313b1eb613671e1a26f2577f34dba9d69bed058be5288d60e0c403af470ba7ea3efe5a450c0df0a1" - }, - { - "x": "0x023222a72009debb870a4830d1f575665569f38d3030cf3e858a0ed5d292670975f87a6782dd13694b35ded2aa7b1efa", - "y": "0x1466b51392bf77e7d7159834df787ff57ba540f1cb68ddb5b84eaa366c4e6a3fe4070659fa1d458fefb3b36fb195748b" - }, - { - "x": "0x1802d2cc7d74da1faf3968a046aa6197e0b909d29eb20fdb1c6f64c137e06b35ff15e27ba8bba7b26a84d8918ac96c96", - "y": "0x105179ec7364256efd4c5027cacb8bf0f2a31d1ffa4495932cc1c6d747acd96b3cd885084176380bbb6c38b54dee0ab6" - }, - { - "x": "0x0906b8c938bff29972815c38a95fa420b11d8aee2a238e4731cb4c3dd2a40c3c68f0eb8efb4e160ef586bda285cda9ca", - "y": "0x0c5d5001cf20a09f34e516ab738849f41a57c6f00f5973d308a3bce44c96023541d8bc243756068e7ee85c4c00392178" - }, - { - "x": "0x00703bbc36c7da9cbc446661ccd0dcd04e77d9ab140eba09f541ef30d9ea589ecbcdd332f507ad907abb4b4aec6cde38", - "y": "0x01f05e929a402b0931531cf4f69d4021f7015d1a61ef0b8963fad53bdd1f865e348c8ffdb7f3f6db658f2660d778d60b" - }, - { - "x": "0x1338c799859ef33570c600e98a3c5c9f206f6f32b42d56daa2495f70a799c1ff5ffd57a8610ad0196409711852f361a5", - "y": "0x18a2847bff0940919da2b8e454db85f017bf161ef743a139d41744179ffe4123d3ea2cf989ff0374c97fc93646f8831e" - }, - { - "x": "0x007a4def0a190d3633050f0324d1c84a45c71dc9323f7f3bc710705075ebb4d2c5dfdf44a7b7cceaa2170171897e97ec", - "y": "0x09f0c2d419d483817f7c17a298c36e79273d151d36cb306fc0da721a10ebbbab0098719c486a748ed3963fbb3d87529a" - }, - { - "x": "0x0a88b437486141d78116be238ab3799408e4e29d2c9d717562d3e3afec74f347851c8b9d6332ad98cf19186b53299a1c", - "y": "0x0173fdab5ff8e932752eebd75a444f824dadf786de1d4c9f8c94083f5450ae828ade841ad893c056666d24d00f403dfd" - }, - { - "x": "0x096590a931e18e5597187a0ae5e2bab8263471b3d6ef25f2dbdb96f2c016c56dd57b1a70428a9462f5ab1296d8b04c13", - "y": "0x153e9400d0aed3b6e396fe72fea540304a27ef2f649923e359383705dfdde860e6cce8714d39f10ab3b454644c4b3acd" - }, - { - "x": "0x0640c13a05c48547bfdf83485a2c9ba5f0fda9506fa6f63bc5183550d8f0cee8046dc13bd6396eec200b626f130eb44e", - "y": "0x0c01f6de10de2001f7903ed92dacf7ea9c6e6376e607ecf274c27dd0ca8f84734e4b7d7b0d367b181303a408e6912fc5" - }, - { - "x": "0x131b89882a84baee00dcb44c42e27dcff024e1dafaebd919f939a8c4a8a1e140d8a0886aea9c0067f868591d44383a5c", - "y": "0x0744b1babe423ba9aa5755ae1a4fa2352e3325b3ed8ec5972a8e810bc9daa443617673570494ebe2060c29191f10ec48" - }, - { - "x": "0x0f0c818c2064ed5736e915a0a4ab5fee73c0ebd837fa82e9d5915531de15fe6b0eedd2525a429e00d1fcc0b0fe01919d", - "y": "0x18b62234d86e87a88a141b9ada6e0cc97af1c4de346cf50cb240838438f95fb3a91748ebf0be81c52c89f3e54eb96847" - }, - { - "x": "0x0", - "y": "0x0" - }, - { - "x": "0x0", - "y": "0x0" - }, - { - "x": "0x0f72ed474f2e94bc70a03fa7a44b8ecc1cac4a0160c242b5be45ca2072e9c1928915e7cdaa9043c24e4e0de71efadb76", - "y": "0x09fb2f189de5c25026eb0288625fc6bb2cb714732cd1bb515b5c1d5a0a201ffa7cdbbeabe25e4cb4ca1870aa825593d9" - }, - { - "x": "0x0", - "y": "0x0" - }, - { - "x": "0x0", - "y": "0x0" - } - ], - "format": "ix-bls-plonk-vk-v1", - "generator": { - "x": "0x17f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb", - "y": "0x08b3f481e3aaa0f1a09e30ed741d8ae4fcf5e095d5d00af600db18cb2c04b3edd03cc744a2888ae40caa232946c5e7e1" - }, - "matrices": [ - { - "pointOffset": 0, - "rows": 1, - "valueOffset": 1, - "width": 3 - }, - { - "pointOffset": 3, - "rows": 1, - "valueOffset": 4, - "width": 1 - }, - { - "pointOffset": 4, - "rows": 2, - "valueOffset": 5, - "width": 4 - }, - { - "pointOffset": 8, - "rows": 2, - "valueOffset": 13, - "width": 1 - }, - { - "pointOffset": 9, - "rows": 1, - "valueOffset": 15, - "width": 2 - }, - { - "pointOffset": 11, - "rows": 1, - "valueOffset": 17, - "width": 1 - }, - { - "pointOffset": 15, - "rows": 1, - "valueOffset": 18, - "width": 15 - }, - { - "pointOffset": 30, - "rows": 1, - "valueOffset": 33, - "width": 4 - } - ], - "openingCount": 3, - "outerKeys": [ - "0x93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8", - "0xb16010f1b3d33aa5f3f608c385400979ed36dc3fab53d3626f519c74d7e1ebef62c9ad35796404ce2fc3ebb42167b5a1050ac27c3527072b81af0fa9a683e226da79275f2985c9e89a0db969b76e0b6be94fee874c48586737ad5588cf1e1041" - ], - "pointCount": 15, - "prefix": [ - 0, - 17, - 1002, - 1003, - 1004, - 1005, - 1006, - 1007, - 1008, - 1009 - ], - "vkId": "0xca595b587171e61538457acadb40a97c0f78cb4f395d1ea52ce1fe200782d8d1", - "program": { - "assertions": [ - 642, - 704 - ], - "coefficients": [ - 983, - 714, - 721, - 728, - 936, - 944, - 949, - 954, - 968, - 770, - 777, - 784, - 930, - 964, - 981, - 791, - 798, - 805, - 812, - 819, - 826, - 833, - 840, - 847, - 854, - 861, - 868, - 875, - 882, - 889, - 896, - 903, - 910, - 917, - 976 - ], - "nodes": [ - { - "index": 0, - "kind": "challenge", - "op": "input" - }, - { - "index": 1, - "kind": "challenge", - "op": "input" - }, - { - "index": 2, - "kind": "challenge", - "op": "input" - }, - { - "index": 3, - "kind": "challenge", - "op": "input" - }, - { - "index": 4, - "kind": "challenge", - "op": "input" - }, - { - "index": 5, - "kind": "challenge", - "op": "input" - }, - { - "index": 0, - "kind": "value", - "op": "input" - }, - { - "index": 1, - "kind": "value", - "op": "input" - }, - { - "index": 2, - "kind": "value", - "op": "input" - }, - { - "index": 3, - "kind": "value", - "op": "input" - }, - { - "index": 4, - "kind": "value", - "op": "input" - }, - { - "index": 5, - "kind": "value", - "op": "input" - }, - { - "index": 6, - "kind": "value", - "op": "input" - }, - { - "index": 7, - "kind": "value", - "op": "input" - }, - { - "index": 8, - "kind": "value", - "op": "input" - }, - { - "index": 9, - "kind": "value", - "op": "input" - }, - { - "index": 10, - "kind": "value", - "op": "input" - }, - { - "index": 11, - "kind": "value", - "op": "input" - }, - { - "index": 12, - "kind": "value", - "op": "input" - }, - { - "index": 13, - "kind": "value", - "op": "input" - }, - { - "index": 14, - "kind": "value", - "op": "input" - }, - { - "index": 15, - "kind": "value", - "op": "input" - }, - { - "index": 16, - "kind": "value", - "op": "input" - }, - { - "index": 17, - "kind": "value", - "op": "input" - }, - { - "index": 18, - "kind": "value", - "op": "input" - }, - { - "index": 19, - "kind": "value", - "op": "input" - }, - { - "index": 20, - "kind": "value", - "op": "input" - }, - { - "index": 21, - "kind": "value", - "op": "input" - }, - { - "index": 22, - "kind": "value", - "op": "input" - }, - { - "index": 23, - "kind": "value", - "op": "input" - }, - { - "index": 24, - "kind": "value", - "op": "input" - }, - { - "index": 25, - "kind": "value", - "op": "input" - }, - { - "index": 26, - "kind": "value", - "op": "input" - }, - { - "index": 27, - "kind": "value", - "op": "input" - }, - { - "index": 28, - "kind": "value", - "op": "input" - }, - { - "index": 29, - "kind": "value", - "op": "input" - }, - { - "index": 30, - "kind": "value", - "op": "input" - }, - { - "index": 31, - "kind": "value", - "op": "input" - }, - { - "index": 32, - "kind": "value", - "op": "input" - }, - { - "index": 33, - "kind": "value", - "op": "input" - }, - { - "index": 34, - "kind": "value", - "op": "input" - }, - { - "index": 35, - "kind": "value", - "op": "input" - }, - { - "index": 36, - "kind": "value", - "op": "input" - }, - { - "index": 0, - "kind": "public", - "op": "input" - }, - { - "index": 1, - "kind": "public", - "op": "input" - }, - { - "index": 2, - "kind": "public", - "op": "input" - }, - { - "index": 3, - "kind": "public", - "op": "input" - }, - { - "index": 4, - "kind": "public", - "op": "input" - }, - { - "index": 5, - "kind": "public", - "op": "input" - }, - { - "index": 6, - "kind": "public", - "op": "input" - }, - { - "index": 7, - "kind": "public", - "op": "input" - }, - { - "index": 8, - "kind": "public", - "op": "input" - }, - { - "index": 9, - "kind": "public", - "op": "input" - }, - { - "index": 10, - "kind": "public", - "op": "input" - }, - { - "index": 11, - "kind": "public", - "op": "input" - }, - { - "index": 12, - "kind": "public", - "op": "input" - }, - { - "index": 13, - "kind": "public", - "op": "input" - }, - { - "index": 14, - "kind": "public", - "op": "input" - }, - { - "index": 15, - "kind": "public", - "op": "input" - }, - { - "index": 16, - "kind": "public", - "op": "input" - }, - { - "index": 17, - "kind": "public", - "op": "input" - }, - { - "index": 18, - "kind": "public", - "op": "input" - }, - { - "index": 19, - "kind": "public", - "op": "input" - }, - { - "index": 20, - "kind": "public", - "op": "input" - }, - { - "index": 21, - "kind": "public", - "op": "input" - }, - { - "index": 22, - "kind": "public", - "op": "input" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 66, - "b": 1, - "op": "mul" - }, - { - "a": 67, - "b": 43, - "op": "add" - }, - { - "a": 68, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 69, - "b": 70, - "op": "add" - }, - { - "a": 71, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 72, - "b": 73, - "op": "add" - }, - { - "a": 74, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 75, - "b": 76, - "op": "add" - }, - { - "a": 0, - "b": 77, - "op": "add" - }, - { - "a": 78, - "op": "inverse" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 80, - "b": 79, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 82, - "b": 1, - "op": "mul" - }, - { - "a": 83, - "b": 44, - "op": "add" - }, - { - "a": 84, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 85, - "b": 86, - "op": "add" - }, - { - "a": 87, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 88, - "b": 89, - "op": "add" - }, - { - "a": 90, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 91, - "b": 92, - "op": "add" - }, - { - "a": 0, - "b": 93, - "op": "add" - }, - { - "a": 94, - "op": "inverse" - }, - { - "a": 81, - "b": 95, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 97, - "b": 1, - "op": "mul" - }, - { - "a": 98, - "b": 45, - "op": "add" - }, - { - "a": 99, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000002" - }, - { - "a": 100, - "b": 101, - "op": "add" - }, - { - "a": 102, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 103, - "b": 104, - "op": "add" - }, - { - "a": 105, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 106, - "b": 107, - "op": "add" - }, - { - "a": 0, - "b": 108, - "op": "add" - }, - { - "a": 109, - "op": "inverse" - }, - { - "a": 96, - "b": 110, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 112, - "b": 1, - "op": "mul" - }, - { - "a": 113, - "b": 46, - "op": "add" - }, - { - "a": 114, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000003" - }, - { - "a": 115, - "b": 116, - "op": "add" - }, - { - "a": 117, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 118, - "b": 119, - "op": "add" - }, - { - "a": 120, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 121, - "b": 122, - "op": "add" - }, - { - "a": 0, - "b": 123, - "op": "add" - }, - { - "a": 124, - "op": "inverse" - }, - { - "a": 111, - "b": 125, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 127, - "b": 1, - "op": "mul" - }, - { - "a": 128, - "b": 47, - "op": "add" - }, - { - "a": 129, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000004" - }, - { - "a": 130, - "b": 131, - "op": "add" - }, - { - "a": 132, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 133, - "b": 134, - "op": "add" - }, - { - "a": 135, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 136, - "b": 137, - "op": "add" - }, - { - "a": 0, - "b": 138, - "op": "add" - }, - { - "a": 139, - "op": "inverse" - }, - { - "a": 126, - "b": 140, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 142, - "b": 1, - "op": "mul" - }, - { - "a": 143, - "b": 48, - "op": "add" - }, - { - "a": 144, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000005" - }, - { - "a": 145, - "b": 146, - "op": "add" - }, - { - "a": 147, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 148, - "b": 149, - "op": "add" - }, - { - "a": 150, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 151, - "b": 152, - "op": "add" - }, - { - "a": 0, - "b": 153, - "op": "add" - }, - { - "a": 154, - "op": "inverse" - }, - { - "a": 141, - "b": 155, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 157, - "b": 1, - "op": "mul" - }, - { - "a": 158, - "b": 49, - "op": "add" - }, - { - "a": 159, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000006" - }, - { - "a": 160, - "b": 161, - "op": "add" - }, - { - "a": 162, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 163, - "b": 164, - "op": "add" - }, - { - "a": 165, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 166, - "b": 167, - "op": "add" - }, - { - "a": 0, - "b": 168, - "op": "add" - }, - { - "a": 169, - "op": "inverse" - }, - { - "a": 156, - "b": 170, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 172, - "b": 1, - "op": "mul" - }, - { - "a": 173, - "b": 50, - "op": "add" - }, - { - "a": 174, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000007" - }, - { - "a": 175, - "b": 176, - "op": "add" - }, - { - "a": 177, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 178, - "b": 179, - "op": "add" - }, - { - "a": 180, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 181, - "b": 182, - "op": "add" - }, - { - "a": 0, - "b": 183, - "op": "add" - }, - { - "a": 184, - "op": "inverse" - }, - { - "a": 171, - "b": 185, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 187, - "b": 1, - "op": "mul" - }, - { - "a": 188, - "b": 51, - "op": "add" - }, - { - "a": 189, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000008" - }, - { - "a": 190, - "b": 191, - "op": "add" - }, - { - "a": 192, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 193, - "b": 194, - "op": "add" - }, - { - "a": 195, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 196, - "b": 197, - "op": "add" - }, - { - "a": 0, - "b": 198, - "op": "add" - }, - { - "a": 199, - "op": "inverse" - }, - { - "a": 186, - "b": 200, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 202, - "b": 1, - "op": "mul" - }, - { - "a": 203, - "b": 52, - "op": "add" - }, - { - "a": 204, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000009" - }, - { - "a": 205, - "b": 206, - "op": "add" - }, - { - "a": 207, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 208, - "b": 209, - "op": "add" - }, - { - "a": 210, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 211, - "b": 212, - "op": "add" - }, - { - "a": 0, - "b": 213, - "op": "add" - }, - { - "a": 214, - "op": "inverse" - }, - { - "a": 201, - "b": 215, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 217, - "b": 1, - "op": "mul" - }, - { - "a": 218, - "b": 53, - "op": "add" - }, - { - "a": 219, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000000a" - }, - { - "a": 220, - "b": 221, - "op": "add" - }, - { - "a": 222, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 223, - "b": 224, - "op": "add" - }, - { - "a": 225, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 226, - "b": 227, - "op": "add" - }, - { - "a": 0, - "b": 228, - "op": "add" - }, - { - "a": 229, - "op": "inverse" - }, - { - "a": 216, - "b": 230, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 232, - "b": 1, - "op": "mul" - }, - { - "a": 233, - "b": 54, - "op": "add" - }, - { - "a": 234, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000000b" - }, - { - "a": 235, - "b": 236, - "op": "add" - }, - { - "a": 237, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 238, - "b": 239, - "op": "add" - }, - { - "a": 240, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 241, - "b": 242, - "op": "add" - }, - { - "a": 0, - "b": 243, - "op": "add" - }, - { - "a": 244, - "op": "inverse" - }, - { - "a": 231, - "b": 245, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 247, - "b": 1, - "op": "mul" - }, - { - "a": 248, - "b": 55, - "op": "add" - }, - { - "a": 249, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000000c" - }, - { - "a": 250, - "b": 251, - "op": "add" - }, - { - "a": 252, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 253, - "b": 254, - "op": "add" - }, - { - "a": 255, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 256, - "b": 257, - "op": "add" - }, - { - "a": 0, - "b": 258, - "op": "add" - }, - { - "a": 259, - "op": "inverse" - }, - { - "a": 246, - "b": 260, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 262, - "b": 1, - "op": "mul" - }, - { - "a": 263, - "b": 56, - "op": "add" - }, - { - "a": 264, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000000d" - }, - { - "a": 265, - "b": 266, - "op": "add" - }, - { - "a": 267, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 268, - "b": 269, - "op": "add" - }, - { - "a": 270, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 271, - "b": 272, - "op": "add" - }, - { - "a": 0, - "b": 273, - "op": "add" - }, - { - "a": 274, - "op": "inverse" - }, - { - "a": 261, - "b": 275, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 277, - "b": 1, - "op": "mul" - }, - { - "a": 278, - "b": 57, - "op": "add" - }, - { - "a": 279, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000000e" - }, - { - "a": 280, - "b": 281, - "op": "add" - }, - { - "a": 282, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 283, - "b": 284, - "op": "add" - }, - { - "a": 285, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 286, - "b": 287, - "op": "add" - }, - { - "a": 0, - "b": 288, - "op": "add" - }, - { - "a": 289, - "op": "inverse" - }, - { - "a": 276, - "b": 290, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 292, - "b": 1, - "op": "mul" - }, - { - "a": 293, - "b": 58, - "op": "add" - }, - { - "a": 294, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000000f" - }, - { - "a": 295, - "b": 296, - "op": "add" - }, - { - "a": 297, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 298, - "b": 299, - "op": "add" - }, - { - "a": 300, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 301, - "b": 302, - "op": "add" - }, - { - "a": 0, - "b": 303, - "op": "add" - }, - { - "a": 304, - "op": "inverse" - }, - { - "a": 291, - "b": 305, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 307, - "b": 1, - "op": "mul" - }, - { - "a": 308, - "b": 59, - "op": "add" - }, - { - "a": 309, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000010" - }, - { - "a": 310, - "b": 311, - "op": "add" - }, - { - "a": 312, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 313, - "b": 314, - "op": "add" - }, - { - "a": 315, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 316, - "b": 317, - "op": "add" - }, - { - "a": 0, - "b": 318, - "op": "add" - }, - { - "a": 319, - "op": "inverse" - }, - { - "a": 306, - "b": 320, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 322, - "b": 1, - "op": "mul" - }, - { - "a": 323, - "b": 60, - "op": "add" - }, - { - "a": 324, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000011" - }, - { - "a": 325, - "b": 326, - "op": "add" - }, - { - "a": 327, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 328, - "b": 329, - "op": "add" - }, - { - "a": 330, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 331, - "b": 332, - "op": "add" - }, - { - "a": 0, - "b": 333, - "op": "add" - }, - { - "a": 334, - "op": "inverse" - }, - { - "a": 321, - "b": 335, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 337, - "b": 1, - "op": "mul" - }, - { - "a": 338, - "b": 61, - "op": "add" - }, - { - "a": 339, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000012" - }, - { - "a": 340, - "b": 341, - "op": "add" - }, - { - "a": 342, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 343, - "b": 344, - "op": "add" - }, - { - "a": 345, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 346, - "b": 347, - "op": "add" - }, - { - "a": 0, - "b": 348, - "op": "add" - }, - { - "a": 349, - "op": "inverse" - }, - { - "a": 336, - "b": 350, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 352, - "b": 1, - "op": "mul" - }, - { - "a": 353, - "b": 62, - "op": "add" - }, - { - "a": 354, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000013" - }, - { - "a": 355, - "b": 356, - "op": "add" - }, - { - "a": 357, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 358, - "b": 359, - "op": "add" - }, - { - "a": 360, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 361, - "b": 362, - "op": "add" - }, - { - "a": 0, - "b": 363, - "op": "add" - }, - { - "a": 364, - "op": "inverse" - }, - { - "a": 351, - "b": 365, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 367, - "b": 1, - "op": "mul" - }, - { - "a": 368, - "b": 63, - "op": "add" - }, - { - "a": 369, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000014" - }, - { - "a": 370, - "b": 371, - "op": "add" - }, - { - "a": 372, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 373, - "b": 374, - "op": "add" - }, - { - "a": 375, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 376, - "b": 377, - "op": "add" - }, - { - "a": 0, - "b": 378, - "op": "add" - }, - { - "a": 379, - "op": "inverse" - }, - { - "a": 366, - "b": 380, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 382, - "b": 1, - "op": "mul" - }, - { - "a": 383, - "b": 64, - "op": "add" - }, - { - "a": 384, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000015" - }, - { - "a": 385, - "b": 386, - "op": "add" - }, - { - "a": 387, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 388, - "b": 389, - "op": "add" - }, - { - "a": 390, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 391, - "b": 392, - "op": "add" - }, - { - "a": 0, - "b": 393, - "op": "add" - }, - { - "a": 394, - "op": "inverse" - }, - { - "a": 381, - "b": 395, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 397, - "b": 1, - "op": "mul" - }, - { - "a": 398, - "b": 65, - "op": "add" - }, - { - "a": 399, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000016" - }, - { - "a": 400, - "b": 401, - "op": "add" - }, - { - "a": 402, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 403, - "b": 404, - "op": "add" - }, - { - "a": 405, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 406, - "b": 407, - "op": "add" - }, - { - "a": 0, - "b": 408, - "op": "add" - }, - { - "a": 409, - "op": "inverse" - }, - { - "a": 396, - "b": 410, - "op": "add" - }, - { - "a": 3, - "op": "inverse" - }, - { - "a": 3, - "b": 3, - "op": "mul" - }, - { - "a": 413, - "b": 413, - "op": "mul" - }, - { - "a": 414, - "b": 414, - "op": "mul" - }, - { - "a": 415, - "b": 415, - "op": "mul" - }, - { - "a": 416, - "b": 416, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 417, - "b": 418, - "op": "sub" - }, - { - "a": 419, - "op": "inverse" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 3, - "b": 421, - "op": "sub" - }, - { - "a": 422, - "op": "inverse" - }, - { - "a": 419, - "b": 423, - "op": "mul" - }, - { - "op": "constant", - "value": "0x50e0903a157988bab4bcd40e22f55448bf6e88fb4c38fb8a360c60997369df4e" - }, - { - "a": 3, - "b": 425, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 426, - "b": 427, - "op": "sub" - }, - { - "a": 428, - "op": "inverse" - }, - { - "a": 419, - "b": 429, - "op": "mul" - }, - { - "op": "constant", - "value": "0x50e0903a157988bab4bcd40e22f55448bf6e88fb4c38fb8a360c60997369df4e" - }, - { - "a": 430, - "b": 431, - "op": "mul" - }, - { - "op": "constant", - "value": "0x5e73ed8c432405d0ae25b21df6c52b2a6c7876bf0928e68fae34a81e1cfca2ec" - }, - { - "a": 3, - "b": 433, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 435, - "b": 35, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 437, - "b": 37, - "op": "sub" - }, - { - "a": 7, - "b": 24, - "op": "mul" - }, - { - "a": 8, - "b": 439, - "op": "mul" - }, - { - "a": 7, - "b": 25, - "op": "mul" - }, - { - "a": 440, - "b": 441, - "op": "add" - }, - { - "a": 8, - "b": 26, - "op": "mul" - }, - { - "a": 442, - "b": 443, - "op": "add" - }, - { - "a": 9, - "b": 27, - "op": "mul" - }, - { - "a": 444, - "b": 445, - "op": "add" - }, - { - "a": 446, - "b": 28, - "op": "add" - }, - { - "a": 6, - "b": 411, - "op": "sub" - }, - { - "op": "constant", - "value": "0x4b6828005c1b8e7ba5755495f5bb505ca7ba4a37d84840b3dd71a54050e7e518" - }, - { - "a": 448, - "b": 449, - "op": "mul" - }, - { - "a": 432, - "b": 450, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 452, - "b": 1, - "op": "mul" - }, - { - "a": 453, - "b": 7, - "op": "add" - }, - { - "a": 454, - "b": 1, - "op": "mul" - }, - { - "a": 455, - "b": 29, - "op": "add" - }, - { - "a": 456, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 457, - "b": 458, - "op": "add" - }, - { - "a": 459, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 460, - "b": 461, - "op": "add" - }, - { - "a": 462, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 464, - "b": 1, - "op": "mul" - }, - { - "a": 465, - "b": 7, - "op": "add" - }, - { - "a": 466, - "b": 1, - "op": "mul" - }, - { - "a": 467, - "b": 32, - "op": "add" - }, - { - "a": 468, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 469, - "b": 470, - "op": "add" - }, - { - "a": 471, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 472, - "b": 473, - "op": "add" - }, - { - "a": 474, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 476, - "b": 463, - "op": "mul" - }, - { - "a": 477, - "b": 475, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 475, - "b": 479, - "op": "mul" - }, - { - "a": 463, - "b": 480, - "op": "mul" - }, - { - "a": 12, - "b": 11, - "op": "sub" - }, - { - "a": 478, - "b": 482, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 484, - "b": 480, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 485, - "b": 486, - "op": "mul" - }, - { - "a": 483, - "b": 487, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 477, - "b": 489, - "op": "mul" - }, - { - "op": "constant", - "value": "0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000" - }, - { - "a": 490, - "b": 491, - "op": "mul" - }, - { - "a": 488, - "b": 492, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 494, - "b": 1, - "op": "mul" - }, - { - "a": 495, - "b": 8, - "op": "add" - }, - { - "a": 496, - "b": 1, - "op": "mul" - }, - { - "a": 497, - "b": 30, - "op": "add" - }, - { - "a": 498, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 499, - "b": 500, - "op": "add" - }, - { - "a": 501, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 502, - "b": 503, - "op": "add" - }, - { - "a": 504, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 506, - "b": 1, - "op": "mul" - }, - { - "a": 507, - "b": 8, - "op": "add" - }, - { - "a": 508, - "b": 1, - "op": "mul" - }, - { - "a": 509, - "b": 33, - "op": "add" - }, - { - "a": 510, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 511, - "b": 512, - "op": "add" - }, - { - "a": 513, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 514, - "b": 515, - "op": "add" - }, - { - "a": 516, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 518, - "b": 505, - "op": "mul" - }, - { - "a": 519, - "b": 517, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 517, - "b": 521, - "op": "mul" - }, - { - "a": 505, - "b": 522, - "op": "mul" - }, - { - "a": 13, - "b": 12, - "op": "sub" - }, - { - "a": 520, - "b": 524, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 526, - "b": 522, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 527, - "b": 528, - "op": "mul" - }, - { - "a": 525, - "b": 529, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 519, - "b": 531, - "op": "mul" - }, - { - "op": "constant", - "value": "0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000" - }, - { - "a": 532, - "b": 533, - "op": "mul" - }, - { - "a": 530, - "b": 534, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 536, - "b": 1, - "op": "mul" - }, - { - "a": 537, - "b": 9, - "op": "add" - }, - { - "a": 538, - "b": 1, - "op": "mul" - }, - { - "a": 539, - "b": 31, - "op": "add" - }, - { - "a": 540, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 541, - "b": 542, - "op": "add" - }, - { - "a": 543, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 544, - "b": 545, - "op": "add" - }, - { - "a": 546, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 548, - "b": 1, - "op": "mul" - }, - { - "a": 549, - "b": 9, - "op": "add" - }, - { - "a": 550, - "b": 1, - "op": "mul" - }, - { - "a": 551, - "b": 34, - "op": "add" - }, - { - "a": 552, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 553, - "b": 554, - "op": "add" - }, - { - "a": 555, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 556, - "b": 557, - "op": "add" - }, - { - "a": 558, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 560, - "b": 547, - "op": "mul" - }, - { - "a": 561, - "b": 559, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 559, - "b": 563, - "op": "mul" - }, - { - "a": 547, - "b": 564, - "op": "mul" - }, - { - "a": 14, - "b": 13, - "op": "sub" - }, - { - "a": 562, - "b": 566, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 568, - "b": 564, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 569, - "b": 570, - "op": "mul" - }, - { - "a": 567, - "b": 571, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 561, - "b": 573, - "op": "mul" - }, - { - "op": "constant", - "value": "0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000" - }, - { - "a": 574, - "b": 575, - "op": "mul" - }, - { - "a": 572, - "b": 576, - "op": "sub" - }, - { - "a": 15, - "b": 451, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 579, - "b": 1, - "op": "mul" - }, - { - "a": 580, - "b": 7, - "op": "add" - }, - { - "a": 581, - "b": 1, - "op": "mul" - }, - { - "a": 582, - "b": 36, - "op": "add" - }, - { - "a": 583, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 584, - "b": 585, - "op": "add" - }, - { - "a": 586, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 587, - "b": 588, - "op": "add" - }, - { - "a": 589, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 591, - "b": 1, - "op": "mul" - }, - { - "a": 592, - "b": 9, - "op": "add" - }, - { - "a": 593, - "b": 1, - "op": "mul" - }, - { - "a": 594, - "b": 8, - "op": "add" - }, - { - "a": 595, - "b": 1, - "op": "mul" - }, - { - "a": 596, - "b": 7, - "op": "add" - }, - { - "a": 597, - "b": 1, - "op": "mul" - }, - { - "a": 598, - "b": 38, - "op": "add" - }, - { - "a": 599, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000002" - }, - { - "a": 600, - "b": 601, - "op": "add" - }, - { - "a": 602, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 603, - "b": 604, - "op": "add" - }, - { - "a": 605, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 607, - "b": 590, - "op": "mul" - }, - { - "a": 608, - "b": 606, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 606, - "b": 610, - "op": "mul" - }, - { - "a": 590, - "b": 611, - "op": "mul" - }, - { - "a": 578, - "b": 14, - "op": "sub" - }, - { - "a": 609, - "b": 613, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 615, - "b": 611, - "op": "mul" - }, - { - "a": 616, - "b": 436, - "op": "mul" - }, - { - "a": 614, - "b": 617, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 608, - "b": 619, - "op": "mul" - }, - { - "a": 620, - "b": 438, - "op": "mul" - }, - { - "a": 618, - "b": 621, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 623, - "b": 2, - "op": "mul" - }, - { - "a": 624, - "b": 447, - "op": "add" - }, - { - "a": 625, - "b": 2, - "op": "mul" - }, - { - "a": 626, - "b": 493, - "op": "add" - }, - { - "a": 627, - "b": 2, - "op": "mul" - }, - { - "a": 628, - "b": 535, - "op": "add" - }, - { - "a": 629, - "b": 2, - "op": "mul" - }, - { - "a": 630, - "b": 577, - "op": "add" - }, - { - "a": 631, - "b": 2, - "op": "mul" - }, - { - "a": 632, - "b": 622, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 419, - "b": 634, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 636, - "b": 635, - "op": "mul" - }, - { - "a": 637, - "b": 22, - "op": "add" - }, - { - "a": 638, - "b": 635, - "op": "mul" - }, - { - "a": 639, - "b": 21, - "op": "add" - }, - { - "a": 419, - "b": 640, - "op": "mul" - }, - { - "a": 633, - "b": 641, - "op": "sub" - }, - { - "a": 3, - "b": 3, - "op": "mul" - }, - { - "a": 643, - "b": 643, - "op": "mul" - }, - { - "a": 644, - "b": 644, - "op": "mul" - }, - { - "a": 645, - "b": 645, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 646, - "b": 647, - "op": "sub" - }, - { - "a": 648, - "op": "inverse" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 3, - "b": 650, - "op": "sub" - }, - { - "a": 651, - "op": "inverse" - }, - { - "a": 648, - "b": 652, - "op": "mul" - }, - { - "op": "constant", - "value": "0x20b1ce9140267af9dd1c0af834cec32c17beb312f20b6f7653ea61d87742bcce" - }, - { - "a": 3, - "b": 654, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 655, - "b": 656, - "op": "sub" - }, - { - "a": 657, - "op": "inverse" - }, - { - "a": 648, - "b": 658, - "op": "mul" - }, - { - "op": "constant", - "value": "0x20b1ce9140267af9dd1c0af834cec32c17beb312f20b6f7653ea61d87742bcce" - }, - { - "a": 659, - "b": 660, - "op": "mul" - }, - { - "op": "constant", - "value": "0x3b25b475ab91194b687a73c92f188612fc010d53ccb225425e544cdf4c887948" - }, - { - "a": 3, - "b": 662, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 664, - "b": 6, - "op": "sub" - }, - { - "op": "constant", - "value": "0x3da92ef0ef87d038d024934097c27463d99ee2d6bcca5053a5e544cd74c88795" - }, - { - "a": 665, - "b": 666, - "op": "mul" - }, - { - "a": 661, - "b": 667, - "op": "mul" - }, - { - "a": 20, - "b": 668, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 670, - "b": 1, - "op": "mul" - }, - { - "a": 671, - "b": 42, - "op": "add" - }, - { - "a": 672, - "b": 1, - "op": "mul" - }, - { - "a": 673, - "b": 41, - "op": "add" - }, - { - "a": 674, - "b": 1, - "op": "mul" - }, - { - "a": 675, - "b": 40, - "op": "add" - }, - { - "a": 676, - "b": 1, - "op": "mul" - }, - { - "a": 677, - "b": 39, - "op": "add" - }, - { - "a": 678, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000002" - }, - { - "a": 679, - "b": 680, - "op": "add" - }, - { - "a": 681, - "b": 1, - "op": "mul" - }, - { - "op": "constant", - "value": "0x000000000000000000000000000000000000000000000000000000000000005e" - }, - { - "a": 682, - "b": 683, - "op": "add" - }, - { - "a": 684, - "b": 0, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 686, - "b": 685, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 685, - "b": 688, - "op": "mul" - }, - { - "a": 669, - "b": 19, - "op": "sub" - }, - { - "a": 687, - "b": 690, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 692, - "b": 10, - "op": "mul" - }, - { - "a": 691, - "b": 693, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 695, - "b": 2, - "op": "mul" - }, - { - "a": 696, - "b": 694, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 648, - "b": 698, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 700, - "b": 699, - "op": "mul" - }, - { - "a": 701, - "b": 23, - "op": "add" - }, - { - "a": 648, - "b": 702, - "op": "mul" - }, - { - "a": 697, - "b": 703, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 7, - "b": 705, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 707, - "b": 706, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 709, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 711, - "b": 710, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 713, - "b": 712, - "op": "add" - }, - { - "a": 8, - "b": 710, - "op": "mul" - }, - { - "a": 708, - "b": 715, - "op": "add" - }, - { - "a": 710, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 718, - "b": 717, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 720, - "b": 719, - "op": "add" - }, - { - "a": 9, - "b": 717, - "op": "mul" - }, - { - "a": 716, - "b": 722, - "op": "add" - }, - { - "a": 717, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 725, - "b": 724, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 727, - "b": 726, - "op": "add" - }, - { - "a": 10, - "b": 724, - "op": "mul" - }, - { - "a": 723, - "b": 729, - "op": "add" - }, - { - "a": 724, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 732, - "b": 731, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 734, - "b": 733, - "op": "add" - }, - { - "a": 11, - "b": 731, - "op": "mul" - }, - { - "a": 730, - "b": 736, - "op": "add" - }, - { - "a": 731, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 739, - "b": 738, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 741, - "b": 740, - "op": "add" - }, - { - "a": 12, - "b": 738, - "op": "mul" - }, - { - "a": 737, - "b": 743, - "op": "add" - }, - { - "a": 738, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 746, - "b": 745, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 748, - "b": 747, - "op": "add" - }, - { - "a": 13, - "b": 745, - "op": "mul" - }, - { - "a": 744, - "b": 750, - "op": "add" - }, - { - "a": 745, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 753, - "b": 752, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 755, - "b": 754, - "op": "add" - }, - { - "a": 14, - "b": 752, - "op": "mul" - }, - { - "a": 751, - "b": 757, - "op": "add" - }, - { - "a": 752, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 760, - "b": 759, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 762, - "b": 761, - "op": "add" - }, - { - "a": 19, - "b": 759, - "op": "mul" - }, - { - "a": 758, - "b": 764, - "op": "add" - }, - { - "a": 759, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 767, - "b": 766, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 769, - "b": 768, - "op": "add" - }, - { - "a": 21, - "b": 766, - "op": "mul" - }, - { - "a": 765, - "b": 771, - "op": "add" - }, - { - "a": 766, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 774, - "b": 773, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 776, - "b": 775, - "op": "add" - }, - { - "a": 22, - "b": 773, - "op": "mul" - }, - { - "a": 772, - "b": 778, - "op": "add" - }, - { - "a": 773, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 781, - "b": 780, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 783, - "b": 782, - "op": "add" - }, - { - "a": 23, - "b": 780, - "op": "mul" - }, - { - "a": 779, - "b": 785, - "op": "add" - }, - { - "a": 780, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 788, - "b": 787, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 790, - "b": 789, - "op": "add" - }, - { - "a": 24, - "b": 787, - "op": "mul" - }, - { - "a": 786, - "b": 792, - "op": "add" - }, - { - "a": 787, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 795, - "b": 794, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 797, - "b": 796, - "op": "add" - }, - { - "a": 25, - "b": 794, - "op": "mul" - }, - { - "a": 793, - "b": 799, - "op": "add" - }, - { - "a": 794, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 802, - "b": 801, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 804, - "b": 803, - "op": "add" - }, - { - "a": 26, - "b": 801, - "op": "mul" - }, - { - "a": 800, - "b": 806, - "op": "add" - }, - { - "a": 801, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 809, - "b": 808, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 811, - "b": 810, - "op": "add" - }, - { - "a": 27, - "b": 808, - "op": "mul" - }, - { - "a": 807, - "b": 813, - "op": "add" - }, - { - "a": 808, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 816, - "b": 815, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 818, - "b": 817, - "op": "add" - }, - { - "a": 28, - "b": 815, - "op": "mul" - }, - { - "a": 814, - "b": 820, - "op": "add" - }, - { - "a": 815, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 823, - "b": 822, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 825, - "b": 824, - "op": "add" - }, - { - "a": 29, - "b": 822, - "op": "mul" - }, - { - "a": 821, - "b": 827, - "op": "add" - }, - { - "a": 822, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 830, - "b": 829, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 832, - "b": 831, - "op": "add" - }, - { - "a": 30, - "b": 829, - "op": "mul" - }, - { - "a": 828, - "b": 834, - "op": "add" - }, - { - "a": 829, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 837, - "b": 836, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 839, - "b": 838, - "op": "add" - }, - { - "a": 31, - "b": 836, - "op": "mul" - }, - { - "a": 835, - "b": 841, - "op": "add" - }, - { - "a": 836, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 844, - "b": 843, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 846, - "b": 845, - "op": "add" - }, - { - "a": 32, - "b": 843, - "op": "mul" - }, - { - "a": 842, - "b": 848, - "op": "add" - }, - { - "a": 843, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 851, - "b": 850, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 853, - "b": 852, - "op": "add" - }, - { - "a": 33, - "b": 850, - "op": "mul" - }, - { - "a": 849, - "b": 855, - "op": "add" - }, - { - "a": 850, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 858, - "b": 857, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 860, - "b": 859, - "op": "add" - }, - { - "a": 34, - "b": 857, - "op": "mul" - }, - { - "a": 856, - "b": 862, - "op": "add" - }, - { - "a": 857, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 865, - "b": 864, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 867, - "b": 866, - "op": "add" - }, - { - "a": 35, - "b": 864, - "op": "mul" - }, - { - "a": 863, - "b": 869, - "op": "add" - }, - { - "a": 864, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 872, - "b": 871, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 874, - "b": 873, - "op": "add" - }, - { - "a": 36, - "b": 871, - "op": "mul" - }, - { - "a": 870, - "b": 876, - "op": "add" - }, - { - "a": 871, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 879, - "b": 878, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 881, - "b": 880, - "op": "add" - }, - { - "a": 37, - "b": 878, - "op": "mul" - }, - { - "a": 877, - "b": 883, - "op": "add" - }, - { - "a": 878, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 886, - "b": 885, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 888, - "b": 887, - "op": "add" - }, - { - "a": 38, - "b": 885, - "op": "mul" - }, - { - "a": 884, - "b": 890, - "op": "add" - }, - { - "a": 885, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 893, - "b": 892, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 895, - "b": 894, - "op": "add" - }, - { - "a": 39, - "b": 892, - "op": "mul" - }, - { - "a": 891, - "b": 897, - "op": "add" - }, - { - "a": 892, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 900, - "b": 899, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 902, - "b": 901, - "op": "add" - }, - { - "a": 40, - "b": 899, - "op": "mul" - }, - { - "a": 898, - "b": 904, - "op": "add" - }, - { - "a": 899, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 907, - "b": 906, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 909, - "b": 908, - "op": "add" - }, - { - "a": 41, - "b": 906, - "op": "mul" - }, - { - "a": 905, - "b": 911, - "op": "add" - }, - { - "a": 906, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 914, - "b": 913, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 916, - "b": 915, - "op": "add" - }, - { - "a": 42, - "b": 913, - "op": "mul" - }, - { - "a": 912, - "b": 918, - "op": "add" - }, - { - "a": 913, - "b": 4, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 921, - "b": 919, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 923, - "b": 922, - "op": "sub" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 925, - "b": 3, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 926, - "b": 927, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 929, - "b": 928, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 932, - "b": 5, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 933, - "b": 934, - "op": "mul" - }, - { - "a": 735, - "b": 935, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 15, - "b": 937, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 939, - "b": 938, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 941, - "b": 4, - "op": "mul" - }, - { - "a": 933, - "b": 942, - "op": "mul" - }, - { - "a": 742, - "b": 943, - "op": "add" - }, - { - "a": 16, - "b": 942, - "op": "mul" - }, - { - "a": 940, - "b": 945, - "op": "add" - }, - { - "a": 942, - "b": 4, - "op": "mul" - }, - { - "a": 933, - "b": 947, - "op": "mul" - }, - { - "a": 749, - "b": 948, - "op": "add" - }, - { - "a": 17, - "b": 947, - "op": "mul" - }, - { - "a": 946, - "b": 950, - "op": "add" - }, - { - "a": 947, - "b": 4, - "op": "mul" - }, - { - "a": 933, - "b": 952, - "op": "mul" - }, - { - "a": 756, - "b": 953, - "op": "add" - }, - { - "a": 18, - "b": 952, - "op": "mul" - }, - { - "a": 951, - "b": 955, - "op": "add" - }, - { - "a": 952, - "b": 4, - "op": "mul" - }, - { - "a": 933, - "b": 956, - "op": "mul" - }, - { - "a": 924, - "b": 958, - "op": "sub" - }, - { - "a": 933, - "b": 3, - "op": "mul" - }, - { - "op": "constant", - "value": "0x50e0903a157988bab4bcd40e22f55448bf6e88fb4c38fb8a360c60997369df4e" - }, - { - "a": 960, - "b": 961, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 963, - "b": 962, - "op": "add" - }, - { - "a": 933, - "b": 5, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 965, - "b": 966, - "op": "mul" - }, - { - "a": 763, - "b": 967, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 20, - "b": 969, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 971, - "b": 970, - "op": "add" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - { - "a": 973, - "b": 4, - "op": "mul" - }, - { - "a": 965, - "b": 972, - "op": "mul" - }, - { - "a": 959, - "b": 975, - "op": "sub" - }, - { - "a": 965, - "b": 3, - "op": "mul" - }, - { - "op": "constant", - "value": "0x20b1ce9140267af9dd1c0af834cec32c17beb312f20b6f7653ea61d87742bcce" - }, - { - "a": 977, - "b": 978, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "a": 980, - "b": 979, - "op": "add" - }, - { - "a": 965, - "b": 5, - "op": "mul" - }, - { - "op": "constant", - "value": "0x0000000000000000000000000000000000000000000000000000000000000001" - } - ], - "witnesses": [ - 931, - 933, - 965 - ] - }, - "publicMaxDegree": 268435454, - "seed": "0x6d756c74692d737461726b2f6b7a672f763405000000000000000200000000000000feffff0f0000000048b74609da5ec9a9d59efb1d4c9d91d81815a304a8baab8ba35af4fe0145239f97f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8ae3f321e2ba24e302542a3ecf8847e8e820f9ca1b3a6a63bae25140f215344314e1bfc9c36bf2c3dcbe94dec33d71f3cb16010f1b3d33aa5f3f608c385400979ed36dc3fab53d3626f519c74d7e1ebef62c9ad35796404ce2fc3ebb42167b5a1050ac27c3527072b81af0fa9a683e226da79275f2985c9e89a0db969b76e0b6be94fee874c48586737ad5588cf1e1041", - "setupId": "0x48b74609da5ec9a9d59efb1d4c9d91d81815a304a8baab8ba35af4fe0145239f", - "witnessOffset": 12 -} \ No newline at end of file diff --git a/tools/bls-plonk-wrapper/testdata/srs.bin b/tools/bls-plonk-wrapper/testdata/srs.bin deleted file mode 100644 index 1646a4d8d9285b37a10b56a279342cbbd72e47e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12856 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 Wa?F|WF`9dF%#~aD3(^7f1YNcskd9{n diff --git a/tools/bls-plonk-wrapper/testdata/srs.json b/tools/bls-plonk-wrapper/testdata/srs.json deleted file mode 100644 index 39c742487..000000000 --- a/tools/bls-plonk-wrapper/testdata/srs.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "blake3": "187d35e08e47a1a33117a26a1bde25b0b9dfc2c0651c617d5226ec5096acc6fd", - "filecoinManifest": "185c556e5f5f0c67ddc0c8ac210c8b11d1a48574defbc44299dc712747a0507d", - "format": "ix-plonk-srs-export-v1", - "powers": 131, - "publicMaxDegree": 268435454, - "setupId": "48b74609da5ec9a9d59efb1d4c9d91d81815a304a8baab8ba35af4fe0145239f" -} \ No newline at end of file diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md new file mode 100644 index 000000000..49683dfd5 --- /dev/null +++ b/tools/plonk-wrapper/README.md @@ -0,0 +1,230 @@ +# BN254 PLONK wrapper + +This helper proves the exported Ix KZG verifier using BN254 PLONK. It binds +the expected claim digest and checks both outer and deferred pairing +obligations. The checked-in verifier witness comes from an authenticated +Ignition Init proof. + +## Build and use through ix + +`lake build ix` builds the CLI, compression worker, and +`ix-bn254-plonk-wrapper`. The Go module builds BN254 unconditionally; +the optional `cuda` tag enables GPU arithmetic. Nix provides +`.#plonk-wrapper`, `.#plonk-wrapper-cuda`, and the complete `.#ix` CLI. + +Configure `IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, +`IX_KZG_IGNITION_ADMISSION`, and `IX_KZG_IGNITION_ADMISSION_BLAKE3` using the +[compression setup guide](../../crates/proof-compression/README.md). + +```sh +ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output +``` + +Terminal PLONK requires authenticated ceremony parameters. The output's +`plonk` directory contains native/Solidity proofs, the generated verifier, +verifying key, admission, and candidate verifier catalog. `IX_PLONK_HELPER` +can override the executable location. + +## Optional BN254 CUDA backend + +CPU is the default. The experimental CUDA helper supports BN254 domains through +`2^24`, including the current Init circuit, with GPU MSMs, FFTs and a fused +PLONK quotient. Gnark retains witness solving, randomness, transcript challenges, +opening construction and verification. Proof encoding and the verifying key +are independent of the selected arithmetic backend. + +The source build uses explicit copies of sppark revision +`114feb69ebfe029e2caa5e6c2d372c695e5d4da8` and blst 0.3.16. The blst source root +must contain `src/server.c` and `build/assembly.S`. No Rust build archive is +required. Provide these source roots and a CUDA toolkit supporting the chosen +GPU architecture: + +```sh +export IX_PLONK_SPPARK_SOURCE=/path/to/pinned-sppark +export IX_PLONK_BLST_SOURCE=/path/to/blst-0.3.16/blst +export CUDA_HOME=/usr/local/cuda +export NVCC="$CUDA_HOME/bin/nvcc" +export IX_PLONK_CUDA_ARCHS=120 +IX_PLONK_CUDA=1 lake build ix_plonk_wrapper +``` + +The standalone equivalent is: + +```sh +python3 tools/plonk-wrapper/build_cuda.py \ + --out .lake/build/bin/ix-bn254-plonk-wrapper \ + --build-dir .lake/build/plonk-cuda \ + --sppark "$IX_PLONK_SPPARK_SOURCE" --blst "$IX_PLONK_BLST_SOURCE" +``` + +The build checks source receipts, applies the tracked gnark and sppark patches +to private build copies, and records executable/library hashes. It leaves +`go.mod` and the dependency caches unchanged. The native library is installed +in `../lib` relative +to the helper. Keep both artifacts together when installing; `lake run install` +does this when `IX_PLONK_CUDA=1`. The optional Nix package is +`.#plonk-wrapper-cuda`, targeting SM120. CPU packages remain independent of +the CUDA toolkit. + +Select the runtime backend separately from building it: + +```sh +CUDA_VISIBLE_DEVICES=0 IX_PLONK_BACKEND=cuda \ + .lake/build/bin/ix-bn254-plonk-wrapper prove artifacts input/witness.json proof-output +``` + +`IX_PLONK_CUDA_DEVICE` selects the visible ordinal (default 0), and +`IX_PLONK_CUDA_MEMORY_GIB` sets the allocation budget (default 64 GiB), with +8 GiB additional device headroom. Use one visible physical GPU per helper. +`IX_PLONK_CUDA` does not imply the inner prover's `IX_CUDA`, or vice versa. +Requesting CUDA from a CPU-only helper fails explicitly. CUDA errors abort that +proof; they do not silently switch its arithmetic to CPU. + +The helper emits callback times, dispatch counts, transferred bytes, allocation +accounting and CUDA pool high-water marks. Callback times include transfers; +quotient timing includes its internal transforms and host restoration. Pool +high-water marks exclude allocations outside the pool. Complete-process timing +also includes artifact authentication/loading and backend setup. +Queue wait and nested native timings overlap callback timings; they are not +independent stages to add to the total. +Set `IX_PLONK_CUDA_TRACE=1` for operation start/end diagnostics without scalar +values. Transfer counters cover explicit adapter transfers; internal sppark +result transfers may add traffic. + +ABI7 reports per-callback transfer byte/call deltas and separates inclusive +transfer wall time, host staging and DMA event time. Wall time can include +previously queued kernels; host staging and DMA overlap. DMA timings are valid +only when `TransferTimingEnabled` is true. The shared transfer implementation +currently enables those events with `IX_KZG_CUDA_PROFILE=1`. + +The opt-in full Init test supports `IX_PLONK_CUDA_INIT_MODES`, +`IX_PLONK_CUDA_INIT_REPEATS`, `IX_PLONK_CUDA_INIT_STAGE_PROFILE=1` and +`IX_PLONK_CUDA_INIT_CPU_PROFILE=1`, in addition to its required artifact/claim +pins. CPU sampling covers only the first selected proof; stage observation +covers every selected proof. Reports separate backend setup and profiler +startup/shutdown from core proving and record overlapping stage intervals. + +`IX_PLONK_CUDA_MSM_STRATEGY=split` is the default: large Pippenger buckets are +split into bounded tasks while retaining the zero and ±1 fast paths. The +explicit `mask` comparison strategy computes `MSM(s+r)-MSM(r)` and caches its +correction points by registered range. Neither path applies whole-scalar +affine normalization. Both preserve the original commitment exactly. + +Artifact manifests still bind their producer executable. Compile and prepare +artifacts with the frozen helper used for proving; CPU/CUDA mode changes within +that helper do not require different keys. A changed helper does not authorize +editing an old manifest. The full Init test harness accepts an independently +pinned existing manifest without changing production loading rules. + +The persistent worker protocol, immutable selector caching across proofs, +shared admission with KZG workers, and cancellation/restart policy remain +future service work. + +## Authorize verification keys independently + +`IX_PLONK_VERIFIER_CATALOG` names an independently trusted JSON catalog. +Generating a proof does not authorize its candidate catalog. The format is +`ix-bn254-plonk-verifiers-v1`; entries use `setup: "ignition"` and pin the +verifying-key SHA-256, inner verifying-key identity, program prefix, circuit +hash, helper hash, and Ignition admission BLAKE3 digest. Relative paths are +resolved beside the catalog. Verification needs neither a proving SRS nor a +GPU, and derives the expected public digest from the caller's claim. + +## Standalone operations + +`ix-compression-worker plonk export` authenticates a portable BLAKE3 verifying key, +verifies its packet against an independently expected statement, and exports +the fixed scalar program and canonical witness: + +```sh +ix-compression-worker plonk export \ + vk.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input +ix-bn254-plonk-wrapper compile input/vk.json artifacts +ix-bn254-plonk-wrapper check-witness artifacts input/witness.json +``` + +`check-witness` is an assignment diagnostic, not a proof. It uses diagnostic +commitment challenges. Production `prove` uses gnark's real KZG commitment +computation and verifies the resulting PLONK proof before publishing it. + +Read the padded `Domain` from `artifacts/manifest.json`, then export the required +parameters through ix: + +```sh +ix-compression-worker plonk srs DOMAIN parameters.bin +ix-bn254-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 +ix-bn254-plonk-wrapper prove artifacts input/witness.json proof-output +ix-bn254-plonk-wrapper verify \ + artifacts/verifying-key.bin TRUSTED_KEY_SHA256 \ + proof-output/proof.bin EXPECTED_CLAIM_DIGEST +``` + +The parameter export receipt is written beside `parameters.bin` as +`parameters.json`. Obtain `TRUSTED_EXPORT_BLAKE3` from that authenticated Rust +operation, independently of an untrusted parameter file. Gnark requires `N+3` +monomial powers and `N` Lagrange points; the adapter requests the next power of +two from ix and retains the ceremony's full public degree. Its current maximum +PLONK domain is `2^26` with the provisioned `2^27` cache. + +Circuit and key artifacts bind the verifying key, executable and parameter receipt. +The helper checks hashes before reuse. Lagrange points are cached separately +by setup, domain, receipt and helper identity; `IX_PLONK_LAGRANGE_CACHE` can +select the shared directory. A changed executable needs a new artifact +directory. Corrupt artifacts fail closed. + +## BN254 Ignition parameters and Lagrange import + +BN254 uses the same commands with `ix-bn254-plonk-wrapper`. Export requires +`IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, +`IX_KZG_IGNITION_ADMISSION` and `IX_KZG_IGNITION_ADMISSION_BLAKE3`, as described +in the [compression setup documentation](../../crates/proof-compression/README.md). +The provisioned `2^26` monomial cache supports PLONK domain `2^25`, which +requires `2^25+3` powers. The normal helper's classic-GLV Init wrapper has +14,689,440 constraints plus two public rows, fitting `2^24` with 2,087,774 +rows to spare. The completed full proof benchmark below used the earlier +17,996,081-constraint configuration and domain `2^25`. + +SP1 v6.1.0 publishes a legacy gnark `plonk_pk.bin` containing a `2^25` +Lagrange basis for the same Ignition anchors. It is an untrusted source of +candidate points. After compilation and authenticated monomial export, import +it before `prepare`: + +```sh +ix-bn254-plonk-wrapper import-lagrange-sp1 \ + artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 /path/to/plonk_pk.bin +ix-bn254-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 +``` + +The importer bounds lengths, validates point encodings, compares the setup +anchors and domain, and checks the complete candidate basis against the +authenticated monomial powers with a transcript-bound random linear relation. +That relation requires a scalar inverse FFT and two MSMs. The cache is +published only after it passes, with the candidate-vector digest and validation +evidence in `validation.json`. The error bound for a fixed incorrect basis is +at most `(N-1)/Fr` in the random-oracle model. SP1 circuit keys are discarded; +`prepare` generates ix's own proving and verifying keys. A basis for `2^25` +cannot be truncated into a `2^24` basis. + +The import command requires an exact domain match. For the current `2^24` +circuit, `prepare` computes and caches a matching basis from the authenticated +monomial powers unless an independently validated matching cache is present. + +## Tests and sizing + +```sh +cd tools/plonk-wrapper +go test ./... +``` + +The ordinary tests cover Blake3 boundaries, canonical point encodings, +the actual Init verifier witness and tampering, exceptional MSM inputs, +small PLONK proofs, parameter caches, and malformed Lagrange imports. +Sizing is opt-in with `IX_PLONK_SIZE=1`; the component and optimization tests +compile the full circuit and require separate time and memory budgeting. +`IX_PLONK_SIZE_VK` selects a verifier and `IX_PLONK_SIZE_REPORT` saves results. + +Current Init proofs use a `2^24` PLONK domain. Recorded native, envelope, and +Solidity proof sizes are 584, 624, and 864 bytes. See the +[benchmark comparison](../../docs/proof-benchmark-comparison.md) for hardware, +timings, and measurement boundaries, and the +[contract guide](../../contracts/plonk/README.md) for on-chain validation. diff --git a/tools/bls-plonk-wrapper/artifacts.go b/tools/plonk-wrapper/artifacts.go similarity index 100% rename from tools/bls-plonk-wrapper/artifacts.go rename to tools/plonk-wrapper/artifacts.go diff --git a/tools/bls-plonk-wrapper/backend.go b/tools/plonk-wrapper/backend.go similarity index 100% rename from tools/bls-plonk-wrapper/backend.go rename to tools/plonk-wrapper/backend.go diff --git a/tools/bls-plonk-wrapper/backend_bn254_test.go b/tools/plonk-wrapper/backend_bn254_test.go similarity index 95% rename from tools/bls-plonk-wrapper/backend_bn254_test.go rename to tools/plonk-wrapper/backend_bn254_test.go index 4d000f0fd..a6f3d141d 100644 --- a/tools/bls-plonk-wrapper/backend_bn254_test.go +++ b/tools/plonk-wrapper/backend_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( @@ -15,7 +13,7 @@ import ( "github.com/consensys/gnark-crypto/ecc/bn254/kzg" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/plonk" - plonkbls "github.com/consensys/gnark/backend/plonk/bn254" + plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" "github.com/consensys/gnark/backend/solidity" "github.com/consensys/gnark/frontend" "github.com/consensys/gnark/frontend/cs/scs" @@ -94,7 +92,7 @@ func TestIgnitionBackendAndProofCodec(t *testing.T) { } var encoded bytes.Buffer proof.WriteTo(&encoded) - m := len(vk.(*plonkbls.VerifyingKey).Qcp) + m := len(vk.(*plonkbn254.VerifyingKey).Qcp) decoded, err := decodeProof(encoded.Bytes(), m) if err != nil { t.Fatal(err) @@ -139,7 +137,7 @@ func TestIgnitionBackendAndProofCodec(t *testing.T) { if err := atomicFile(filepath.Join(dir, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }); err != nil { t.Fatal(err) } - payload := proof.(*plonkbls.Proof).MarshalSolidity() + payload := proof.(*plonkbn254.Proof).MarshalSolidity() if len(payload) != 768+96*m { t.Fatal("unexpected Solidity proof length") } @@ -149,6 +147,6 @@ func TestIgnitionBackendAndProofCodec(t *testing.T) { t.Fatal(err) } } - t.Logf("Ignition PLONK backend: N=%d commitments=%d native_bytes=%d solidity_bytes=%d", n, m, encoded.Len(), len(proof.(*plonkbls.Proof).MarshalSolidity())) + t.Logf("Ignition PLONK backend: N=%d commitments=%d native_bytes=%d solidity_bytes=%d", n, m, encoded.Len(), len(proof.(*plonkbn254.Proof).MarshalSolidity())) } } diff --git a/tools/bls-plonk-wrapper/backend_cpu.go b/tools/plonk-wrapper/backend_cpu.go similarity index 95% rename from tools/bls-plonk-wrapper/backend_cpu.go rename to tools/plonk-wrapper/backend_cpu.go index 9093202e0..70e789c62 100644 --- a/tools/bls-plonk-wrapper/backend_cpu.go +++ b/tools/plonk-wrapper/backend_cpu.go @@ -1,4 +1,4 @@ -//go:build bls12381 || !cuda +//go:build !cuda package main diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254.go b/tools/plonk-wrapper/backend_cuda_bn254.go similarity index 96% rename from tools/bls-plonk-wrapper/backend_cuda_bn254.go rename to tools/plonk-wrapper/backend_cuda_bn254.go index 8cf0dc8e7..852a0960f 100644 --- a/tools/bls-plonk-wrapper/backend_cuda_bn254.go +++ b/tools/plonk-wrapper/backend_cuda_bn254.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package main @@ -10,7 +10,7 @@ import ( "strconv" "sync" - "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" "github.com/consensys/gnark/backend/plonk" plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" ) diff --git a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go b/tools/plonk-wrapper/backend_cuda_bn254_test.go similarity index 96% rename from tools/bls-plonk-wrapper/backend_cuda_bn254_test.go rename to tools/plonk-wrapper/backend_cuda_bn254_test.go index ad28e8017..1fa9ea8a0 100644 --- a/tools/bls-plonk-wrapper/backend_cuda_bn254_test.go +++ b/tools/plonk-wrapper/backend_cuda_bn254_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package main @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark-crypto/ecc/bn254/kzg" "github.com/consensys/gnark/backend" diff --git a/tools/bls-plonk-wrapper/backend_no_cuda_test.go b/tools/plonk-wrapper/backend_no_cuda_test.go similarity index 92% rename from tools/bls-plonk-wrapper/backend_no_cuda_test.go rename to tools/plonk-wrapper/backend_no_cuda_test.go index 1da5e9ddd..5d0f554c1 100644 --- a/tools/bls-plonk-wrapper/backend_no_cuda_test.go +++ b/tools/plonk-wrapper/backend_no_cuda_test.go @@ -1,4 +1,4 @@ -//go:build bls12381 || !cuda +//go:build !cuda package main diff --git a/tools/bls-plonk-wrapper/backend_selection_test.go b/tools/plonk-wrapper/backend_selection_test.go similarity index 100% rename from tools/bls-plonk-wrapper/backend_selection_test.go rename to tools/plonk-wrapper/backend_selection_test.go diff --git a/tools/bls-plonk-wrapper/blake3.go b/tools/plonk-wrapper/blake3.go similarity index 100% rename from tools/bls-plonk-wrapper/blake3.go rename to tools/plonk-wrapper/blake3.go diff --git a/tools/bls-plonk-wrapper/blake3_test.go b/tools/plonk-wrapper/blake3_test.go similarity index 100% rename from tools/bls-plonk-wrapper/blake3_test.go rename to tools/plonk-wrapper/blake3_test.go diff --git a/tools/bls-plonk-wrapper/build_cuda.py b/tools/plonk-wrapper/build_cuda.py similarity index 100% rename from tools/bls-plonk-wrapper/build_cuda.py rename to tools/plonk-wrapper/build_cuda.py diff --git a/tools/bls-plonk-wrapper/circuit.go b/tools/plonk-wrapper/circuit.go similarity index 100% rename from tools/bls-plonk-wrapper/circuit.go rename to tools/plonk-wrapper/circuit.go diff --git a/tools/bls-plonk-wrapper/circuit_bn254_test.go b/tools/plonk-wrapper/circuit_bn254_test.go similarity index 81% rename from tools/bls-plonk-wrapper/circuit_bn254_test.go rename to tools/plonk-wrapper/circuit_bn254_test.go index d7b73897d..6a2e6c6ed 100644 --- a/tools/bls-plonk-wrapper/circuit_bn254_test.go +++ b/tools/plonk-wrapper/circuit_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( @@ -7,7 +5,7 @@ import ( "testing" "github.com/consensys/gnark-crypto/ecc" - bls "github.com/consensys/gnark-crypto/ecc/bn254" + bn254 "github.com/consensys/gnark-crypto/ecc/bn254" "github.com/consensys/gnark-crypto/ecc/bn254/fp" "github.com/consensys/gnark/frontend" sw "github.com/consensys/gnark/std/algebra/emulated/sw_bn254" @@ -43,10 +41,10 @@ func (c *encodingCircuit) Define(api frontend.API) error { } func TestIdentityAndCanonicalPointEncoding(t *testing.T) { - _, _, g, _ := bls.Generators() - var negative bls.G1Affine + _, _, g, _ := bn254.Generators() + var negative bn254.G1Affine negative.Neg(&g) - for _, point := range []bls.G1Affine{{}, g, negative} { + for _, point := range []bn254.G1Affine{{}, g, negative} { assignment := &encodingCircuit{Point: sw.NewG1Affine(point)} bytes := encodePoint(point) copy(assignment.Encoded[:], uints.NewU8Array(bytes[:])) @@ -58,8 +56,8 @@ func TestIdentityAndCanonicalPointEncoding(t *testing.T) { t.Fatal("accepted wrong sign bit") } } - identity := encodePoint(bls.G1Affine{}) - alias := &encodingCircuit{Point: sw.NewG1Affine(bls.G1Affine{})} + identity := encodePoint(bn254.G1Affine{}) + alias := &encodingCircuit{Point: sw.NewG1Affine(bn254.G1Affine{})} copy(alias.Encoded[:], uints.NewU8Array(identity[:])) alias.Point.X.Limbs = make([]frontend.Variable, 4) modulus := fp.Modulus() @@ -72,13 +70,13 @@ func TestIdentityAndCanonicalPointEncoding(t *testing.T) { } func TestBN254CanonicalVectors(t *testing.T) { - _, _, g, _ := bls.Generators() - var negative bls.G1Affine + _, _, g, _ := bn254.Generators() + var negative bn254.G1Affine negative.Neg(&g) cases := []struct { - p bls.G1Affine + p bn254.G1Affine first, last byte - }{{g, 1, 0}, {negative, 1, 0x80}, {bls.G1Affine{}, 0, 0x40}} + }{{g, 1, 0}, {negative, 1, 0x80}, {bn254.G1Affine{}, 0, 0x40}} for _, c := range cases { b := encodePoint(c.p) for i, v := range b { @@ -123,19 +121,19 @@ func (c *completeMSMCircuit) Define(api frontend.API) error { } func TestBN254CompleteMSMExceptionalCases(t *testing.T) { - _, _, g, _ := bls.Generators() - var neg, twice bls.G1Affine + _, _, g, _ := bn254.Generators() + var neg, twice bn254.G1Affine neg.Neg(&g) twice.Add(&g, &g) for _, c := range []struct { - points [2]bls.G1Affine + points [2]bn254.G1Affine scalars [2]frontend.Variable - expected bls.G1Affine + expected bn254.G1Affine }{ - {[2]bls.G1Affine{g, g}, [2]frontend.Variable{0, 0}, bls.G1Affine{}}, - {[2]bls.G1Affine{g, neg}, [2]frontend.Variable{1, 1}, bls.G1Affine{}}, - {[2]bls.G1Affine{{}, g}, [2]frontend.Variable{7, 0}, bls.G1Affine{}}, - {[2]bls.G1Affine{g, g}, [2]frontend.Variable{1, 1}, twice}, + {[2]bn254.G1Affine{g, g}, [2]frontend.Variable{0, 0}, bn254.G1Affine{}}, + {[2]bn254.G1Affine{g, neg}, [2]frontend.Variable{1, 1}, bn254.G1Affine{}}, + {[2]bn254.G1Affine{{}, g}, [2]frontend.Variable{7, 0}, bn254.G1Affine{}}, + {[2]bn254.G1Affine{g, g}, [2]frontend.Variable{1, 1}, twice}, } { w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(c.points[0]), newCircuitG1(c.points[1])}, Scalars: c.scalars, Expected: newCircuitG1(c.expected)} if err := test.IsSolved(&completeMSMCircuit{}, w, curveID.ScalarField()); err != nil { @@ -145,18 +143,18 @@ func TestBN254CompleteMSMExceptionalCases(t *testing.T) { } func TestBN254ClassicGLVMatchesNativeMSM(t *testing.T) { - _, _, g, _ := bls.Generators() - var neg, twice, other bls.G1Affine + _, _, g, _ := bn254.Generators() + var neg, twice, other bn254.G1Affine neg.Neg(&g) twice.Add(&g, &g) other.ScalarMultiplication(&g, big.NewInt(1234567)) minusOne := new(big.Int).Sub(scalarModulus(), big.NewInt(1)) scalars := []*big.Int{new(big.Int), big.NewInt(1), big.NewInt(2), minusOne, new(big.Int).Rsh(new(big.Int).Set(scalarModulus()), 1), new(big.Int).Lsh(big.NewInt(1), 200)} - pairs := [][2]bls.G1Affine{{g, g}, {g, neg}, {g, twice}, {other, g}, {{}, g}, {{}, {}}} + pairs := [][2]bn254.G1Affine{{g, g}, {g, neg}, {g, twice}, {other, g}, {{}, g}, {{}, {}}} for i, pair := range pairs { for j, s1 := range scalars { s2 := scalars[(j+i)%len(scalars)] - var a, b, want bls.G1Affine + var a, b, want bn254.G1Affine a.ScalarMultiplication(&pair[0], s1) b.ScalarMultiplication(&pair[1], s2) want.Add(&a, &b) diff --git a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go b/tools/plonk-wrapper/cuda_init_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/cuda_init_bn254_test.go rename to tools/plonk-wrapper/cuda_init_bn254_test.go index 760f9558e..fb2231231 100644 --- a/tools/bls-plonk-wrapper/cuda_init_bn254_test.go +++ b/tools/plonk-wrapper/cuda_init_bn254_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package main @@ -17,7 +17,7 @@ import ( "testing" "time" - "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/plonk" native "github.com/consensys/gnark/backend/plonk/bn254" diff --git a/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go b/tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go similarity index 98% rename from tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go rename to tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go index b71c34524..9e7b74071 100644 --- a/tools/bls-plonk-wrapper/cuda_msm_benchmark_bn254_test.go +++ b/tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package main @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" "github.com/consensys/gnark-crypto/ecc" curve "github.com/consensys/gnark-crypto/ecc/bn254" "github.com/consensys/gnark-crypto/ecc/bn254/fr" diff --git a/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go b/tools/plonk-wrapper/cuda_msm_replay_bn254_test.go similarity index 98% rename from tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go rename to tools/plonk-wrapper/cuda_msm_replay_bn254_test.go index 1e2cc37f7..4a5b73abb 100644 --- a/tools/bls-plonk-wrapper/cuda_msm_replay_bn254_test.go +++ b/tools/plonk-wrapper/cuda_msm_replay_bn254_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package main @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/argumentcomputer/ix/tools/bls-plonk-wrapper/internal/cuda" + "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" curve "github.com/consensys/gnark-crypto/ecc/bn254" native "github.com/consensys/gnark/backend/plonk/bn254" ) diff --git a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go b/tools/plonk-wrapper/cuda_profile_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/cuda_profile_bn254_test.go rename to tools/plonk-wrapper/cuda_profile_bn254_test.go index ce115e7a9..dd9d44a00 100644 --- a/tools/bls-plonk-wrapper/cuda_profile_bn254_test.go +++ b/tools/plonk-wrapper/cuda_profile_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/curve_bn254.go b/tools/plonk-wrapper/curve_bn254.go similarity index 99% rename from tools/bls-plonk-wrapper/curve_bn254.go rename to tools/plonk-wrapper/curve_bn254.go index c3fd5fa79..1cca2d1f1 100644 --- a/tools/bls-plonk-wrapper/curve_bn254.go +++ b/tools/plonk-wrapper/curve_bn254.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/go.mod b/tools/plonk-wrapper/go.mod similarity index 93% rename from tools/bls-plonk-wrapper/go.mod rename to tools/plonk-wrapper/go.mod index d9381645d..e9c0f3c44 100644 --- a/tools/bls-plonk-wrapper/go.mod +++ b/tools/plonk-wrapper/go.mod @@ -1,4 +1,4 @@ -module github.com/argumentcomputer/ix/tools/bls-plonk-wrapper +module github.com/argumentcomputer/ix/tools/plonk-wrapper go 1.26.8 diff --git a/tools/bls-plonk-wrapper/go.sum b/tools/plonk-wrapper/go.sum similarity index 100% rename from tools/bls-plonk-wrapper/go.sum rename to tools/plonk-wrapper/go.sum diff --git a/tools/bls-plonk-wrapper/internal/cuda/abi.go b/tools/plonk-wrapper/internal/cuda/abi.go similarity index 99% rename from tools/bls-plonk-wrapper/internal/cuda/abi.go rename to tools/plonk-wrapper/internal/cuda/abi.go index 97675edad..91af770c1 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/abi.go +++ b/tools/plonk-wrapper/internal/cuda/abi.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/accelerator.go b/tools/plonk-wrapper/internal/cuda/accelerator.go similarity index 99% rename from tools/bls-plonk-wrapper/internal/cuda/accelerator.go rename to tools/plonk-wrapper/internal/cuda/accelerator.go index 33d9eebb8..b555b73b3 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/accelerator.go +++ b/tools/plonk-wrapper/internal/cuda/accelerator.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go b/tools/plonk-wrapper/internal/cuda/accelerator_test.go similarity index 99% rename from tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go rename to tools/plonk-wrapper/internal/cuda/accelerator_test.go index f8fbbce69..4e10471dc 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/accelerator_test.go +++ b/tools/plonk-wrapper/internal/cuda/accelerator_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/masking_test.go b/tools/plonk-wrapper/internal/cuda/masking_test.go similarity index 98% rename from tools/bls-plonk-wrapper/internal/cuda/masking_test.go rename to tools/plonk-wrapper/internal/cuda/masking_test.go index b03811c60..5cffc683f 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/masking_test.go +++ b/tools/plonk-wrapper/internal/cuda/masking_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go b/tools/plonk-wrapper/internal/cuda/split_boundaries_test.go similarity index 99% rename from tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go rename to tools/plonk-wrapper/internal/cuda/split_boundaries_test.go index 73a70d034..434ce0eb2 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/split_boundaries_test.go +++ b/tools/plonk-wrapper/internal/cuda/split_boundaries_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/structured_test.go b/tools/plonk-wrapper/internal/cuda/structured_test.go similarity index 98% rename from tools/bls-plonk-wrapper/internal/cuda/structured_test.go rename to tools/plonk-wrapper/internal/cuda/structured_test.go index c43fdb4f4..b58a83cdb 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/structured_test.go +++ b/tools/plonk-wrapper/internal/cuda/structured_test.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/internal/cuda/trace.go b/tools/plonk-wrapper/internal/cuda/trace.go similarity index 98% rename from tools/bls-plonk-wrapper/internal/cuda/trace.go rename to tools/plonk-wrapper/internal/cuda/trace.go index a14291e59..315a440d7 100644 --- a/tools/bls-plonk-wrapper/internal/cuda/trace.go +++ b/tools/plonk-wrapper/internal/cuda/trace.go @@ -1,4 +1,4 @@ -//go:build !bls12381 && cuda +//go:build cuda package cuda diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254.go b/tools/plonk-wrapper/lagrange_import_bn254.go similarity index 99% rename from tools/bls-plonk-wrapper/lagrange_import_bn254.go rename to tools/plonk-wrapper/lagrange_import_bn254.go index 1bd95ae26..f137b5b5d 100644 --- a/tools/bls-plonk-wrapper/lagrange_import_bn254.go +++ b/tools/plonk-wrapper/lagrange_import_bn254.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go b/tools/plonk-wrapper/lagrange_import_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/lagrange_import_bn254_test.go rename to tools/plonk-wrapper/lagrange_import_bn254_test.go index a4a420c50..0dc78affd 100644 --- a/tools/bls-plonk-wrapper/lagrange_import_bn254_test.go +++ b/tools/plonk-wrapper/lagrange_import_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/main.go b/tools/plonk-wrapper/main.go similarity index 100% rename from tools/bls-plonk-wrapper/main.go rename to tools/plonk-wrapper/main.go diff --git a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go b/tools/plonk-wrapper/msm_capture_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/msm_capture_bn254_test.go rename to tools/plonk-wrapper/msm_capture_bn254_test.go index a5ed5ea57..bf99b3949 100644 --- a/tools/bls-plonk-wrapper/msm_capture_bn254_test.go +++ b/tools/plonk-wrapper/msm_capture_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/optimization_test.go b/tools/plonk-wrapper/optimization_test.go similarity index 94% rename from tools/bls-plonk-wrapper/optimization_test.go rename to tools/plonk-wrapper/optimization_test.go index 96299a213..16e634534 100644 --- a/tools/bls-plonk-wrapper/optimization_test.go +++ b/tools/plonk-wrapper/optimization_test.go @@ -28,11 +28,7 @@ func TestOptimizationSizing(t *testing.T) { } path := os.Getenv("IX_PLONK_SIZE_VK") if path == "" { - fixture := "outer" - if curveID == ecc.BN254 { - fixture = "bn254" - } - path = filepath.Join("testdata", fixture, "vk.json") + path = filepath.Join("testdata", "bn254", "vk.json") } p, err := loadVerifyingKey(path) if err != nil { diff --git a/tools/bls-plonk-wrapper/pairing_batch.go b/tools/plonk-wrapper/pairing_batch.go similarity index 100% rename from tools/bls-plonk-wrapper/pairing_batch.go rename to tools/plonk-wrapper/pairing_batch.go diff --git a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go b/tools/plonk-wrapper/pairing_batch_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/pairing_batch_bn254_test.go rename to tools/plonk-wrapper/pairing_batch_bn254_test.go index 888be5536..acc76399c 100644 --- a/tools/bls-plonk-wrapper/pairing_batch_bn254_test.go +++ b/tools/plonk-wrapper/pairing_batch_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/parameters.go b/tools/plonk-wrapper/parameters.go similarity index 100% rename from tools/bls-plonk-wrapper/parameters.go rename to tools/plonk-wrapper/parameters.go diff --git a/tools/bls-plonk-wrapper/parameters_bn254_test.go b/tools/plonk-wrapper/parameters_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/parameters_bn254_test.go rename to tools/plonk-wrapper/parameters_bn254_test.go index 45dec5cb0..3255fe3ac 100644 --- a/tools/bls-plonk-wrapper/parameters_bn254_test.go +++ b/tools/plonk-wrapper/parameters_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( diff --git a/tools/bls-plonk-wrapper/patches/gnark-cuda-generator-v1.patch b/tools/plonk-wrapper/patches/gnark-cuda-generator-v1.patch similarity index 100% rename from tools/bls-plonk-wrapper/patches/gnark-cuda-generator-v1.patch rename to tools/plonk-wrapper/patches/gnark-cuda-generator-v1.patch diff --git a/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.json b/tools/plonk-wrapper/patches/gnark-cuda-v1.json similarity index 100% rename from tools/bls-plonk-wrapper/patches/gnark-cuda-v1.json rename to tools/plonk-wrapper/patches/gnark-cuda-v1.json diff --git a/tools/bls-plonk-wrapper/patches/gnark-cuda-v1.patch b/tools/plonk-wrapper/patches/gnark-cuda-v1.patch similarity index 100% rename from tools/bls-plonk-wrapper/patches/gnark-cuda-v1.patch rename to tools/plonk-wrapper/patches/gnark-cuda-v1.patch diff --git a/tools/bls-plonk-wrapper/profile.go b/tools/plonk-wrapper/profile.go similarity index 97% rename from tools/bls-plonk-wrapper/profile.go rename to tools/plonk-wrapper/profile.go index 96f0be5ad..46a77fbad 100644 --- a/tools/bls-plonk-wrapper/profile.go +++ b/tools/plonk-wrapper/profile.go @@ -9,7 +9,6 @@ import ( "os" "strings" - "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark/frontend" ) @@ -272,18 +271,15 @@ func (p *verifyingKey) validate() error { if g.IsInfinity() { return fmt.Errorf("identity G1 anchor") } - outer, err := g2Keys(p.OuterKeys) + _, err := g2Keys(p.OuterKeys) if err != nil { return err } if p.deferredCount() == 2 { - inner, err := g2Keys(p.DeferredKeys) + _, err := g2Keys(p.DeferredKeys) if err != nil { return err } - if curveID == ecc.BLS12_381 && (!outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1])) { - return fmt.Errorf("Filecoin pairing anchors differ") - } } if len(p.Program.Coefficients) != p.PointCount+len(p.Fixed)+1 || len(p.Program.Witnesses) != p.OpeningCount || len(p.Program.Assertions) != len(p.Circuits) { return fmt.Errorf("invalid scalar program outputs") diff --git a/tools/bls-plonk-wrapper/sizing_test.go b/tools/plonk-wrapper/sizing_test.go similarity index 94% rename from tools/bls-plonk-wrapper/sizing_test.go rename to tools/plonk-wrapper/sizing_test.go index 8657e649e..8bdae3171 100644 --- a/tools/bls-plonk-wrapper/sizing_test.go +++ b/tools/plonk-wrapper/sizing_test.go @@ -29,11 +29,7 @@ func TestComponentSizing(t *testing.T) { } path := os.Getenv("IX_PLONK_SIZE_VK") if path == "" { - fixture := "outer" - if curveID == ecc.BN254 { - fixture = "bn254" - } - path = filepath.Join("testdata", fixture, "vk.json") + path = filepath.Join("testdata", "bn254", "vk.json") } p, err := loadVerifyingKey(path) if err != nil { diff --git a/tools/bls-plonk-wrapper/testdata/bn254/provenance.json b/tools/plonk-wrapper/testdata/bn254/provenance.json similarity index 100% rename from tools/bls-plonk-wrapper/testdata/bn254/provenance.json rename to tools/plonk-wrapper/testdata/bn254/provenance.json diff --git a/tools/bls-plonk-wrapper/testdata/bn254/srs.bin b/tools/plonk-wrapper/testdata/bn254/srs.bin similarity index 100% rename from tools/bls-plonk-wrapper/testdata/bn254/srs.bin rename to tools/plonk-wrapper/testdata/bn254/srs.bin diff --git a/tools/bls-plonk-wrapper/testdata/bn254/srs.json b/tools/plonk-wrapper/testdata/bn254/srs.json similarity index 100% rename from tools/bls-plonk-wrapper/testdata/bn254/srs.json rename to tools/plonk-wrapper/testdata/bn254/srs.json diff --git a/tools/bls-plonk-wrapper/testdata/bn254/vk.json b/tools/plonk-wrapper/testdata/bn254/vk.json similarity index 100% rename from tools/bls-plonk-wrapper/testdata/bn254/vk.json rename to tools/plonk-wrapper/testdata/bn254/vk.json diff --git a/tools/bls-plonk-wrapper/testdata/bn254/witness-diagnostic.json b/tools/plonk-wrapper/testdata/bn254/witness-diagnostic.json similarity index 100% rename from tools/bls-plonk-wrapper/testdata/bn254/witness-diagnostic.json rename to tools/plonk-wrapper/testdata/bn254/witness-diagnostic.json diff --git a/tools/bls-plonk-wrapper/testdata/bn254/witness.json b/tools/plonk-wrapper/testdata/bn254/witness.json similarity index 100% rename from tools/bls-plonk-wrapper/testdata/bn254/witness.json rename to tools/plonk-wrapper/testdata/bn254/witness.json diff --git a/tools/bls-plonk-wrapper/verifier_bn254_test.go b/tools/plonk-wrapper/verifier_bn254_test.go similarity index 99% rename from tools/bls-plonk-wrapper/verifier_bn254_test.go rename to tools/plonk-wrapper/verifier_bn254_test.go index 9d1c46490..7cf7f98c7 100644 --- a/tools/bls-plonk-wrapper/verifier_bn254_test.go +++ b/tools/plonk-wrapper/verifier_bn254_test.go @@ -1,5 +1,3 @@ -//go:build !bls12381 - package main import ( From 73f0653d8b0fb2422502fd643ae0b311890a16d7 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:22:11 -0400 Subject: [PATCH 16/31] chore(deps): pin multi-stark to merged main Pin multi-stark to ea4cf6976462612ccd03255eacc1f8cc1cfe43fb after PR #93 merged into main. Its source tree is identical to the previously tested fd22b637 revision (tree 5fe11c46cbfed3d59a800f00e198023ec3ab6460). Update the manifest and matching lockfile source without changing any package versions. Locked workspace metadata and git diff --check pass; no rebuild is needed for the identical upstream source tree. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b1443d80..1084024b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2358,7 +2358,7 @@ dependencies = [ [[package]] name = "multi-stark" version = "0.1.0" -source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=d8c950be3141f77253992348cc8278e32476abde#d8c950be3141f77253992348cc8278e32476abde" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=ea4cf6976462612ccd03255eacc1f8cc1cfe43fb#ea4cf6976462612ccd03255eacc1f8cc1cfe43fb" dependencies = [ "bincode", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 31bc7e809..240e09e07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" memmap2 = "0.9" # Bundle v3.5.1 to fix large-arena failures (microsoft/mimalloc#1309). mimalloc = { git = "https://github.com/argumentcomputer/mimalloc_rust.git", rev = "616cc1f130d9ea2a1111a46b4e98c140e078f882", default-features = false } -multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "d8c950be3141f77253992348cc8278e32476abde" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "ea4cf6976462612ccd03255eacc1f8cc1cfe43fb" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" From cfc35206eed4502ef171e6892debb145e1be5aea Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:37:53 -0400 Subject: [PATCH 17/31] Fix CI --- Cargo.lock | 4 ++-- flake.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5fb61723..b6e5609b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4190,9 +4190,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", diff --git a/flake.nix b/flake.nix index 2ba4aebf0..f7d8a0ea5 100644 --- a/flake.nix +++ b/flake.nix @@ -352,7 +352,7 @@ IX_PLONK_PREBUILT = "${plonkHelper}/bin/ix-bn254-plonk-wrapper"; # Don't build the `ix_rs` static lib with Lake, since we build it with Crane postPatch = '' - substituteInPlace lakefile.lean --replace-fail 'proc { cmd := "cargo"' '--proc { cmd := "cargo"' + substituteInPlace lakefile.lean --replace-fail 'cmd := "cargo"' 'cmd := "true"' ''; # Preserve workspace-relative paths instead of shadowing local packages. preConfigure = '' From ca8ed5b5521346133412393ff794e2bfeb4947b1 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:22:04 +0000 Subject: [PATCH 18/31] refactor(plonk): use stock gnark and remove unused tooling Build CPU and CUDA helpers from the same pinned upstream gnark module. Remove gnark patches, private module copies, vendor patching, fused quotient dispatch, and native version plumbing. Init benchmarks showed only a 1% median proving-time improvement from fusion. Keep classic GLV and separate pairing checks. Remove unused circuit variants, the legacy SP1 Lagrange importer, and capture/replay and sizing experiments. Consolidate profiling in the full Init test, require explicit verifier schema fields, and key Lagrange caches by setup, domain, and monomial receipt. Validation: CPU/CUDA builds and focused tests passed. The compiled Init constraint system is byte-identical, and a full CUDA Init proof passed independent CPU verification and altered-claim rejection. The Nix CUDA helper derivation evaluates successfully. --- .../cuda/plonk/ix_plonk_bn254.h | 10 +- crates/proof-compression/cuda/plonk/plonk.cu | 24 +- docs/compression-stage-collapse.md | 6 +- docs/proof-benchmark-comparison.md | 12 + flake.nix | 12 +- lakefile.lean | 3 +- tools/plonk-wrapper/README.md | 90 +- tools/plonk-wrapper/backend.go | 2 +- tools/plonk-wrapper/backend_cuda_bn254.go | 2 +- .../plonk-wrapper/backend_cuda_bn254_test.go | 4 +- tools/plonk-wrapper/build_cuda.py | 115 +- tools/plonk-wrapper/circuit.go | 49 +- tools/plonk-wrapper/circuit_bn254_test.go | 17 +- tools/plonk-wrapper/cuda_init_bn254_test.go | 178 ++- .../cuda_msm_benchmark_bn254_test.go | 176 --- .../cuda_msm_replay_bn254_test.go | 149 -- .../plonk-wrapper/cuda_profile_bn254_test.go | 434 ------ tools/plonk-wrapper/internal/cuda/abi.go | 12 +- .../internal/cuda/accelerator.go | 78 +- .../internal/cuda/accelerator_test.go | 153 +- .../internal/cuda/masking_test.go | 2 +- .../internal/cuda/split_boundaries_test.go | 2 +- .../internal/cuda/structured_test.go | 4 +- tools/plonk-wrapper/lagrange_import_bn254.go | 235 --- .../lagrange_import_bn254_test.go | 136 -- tools/plonk-wrapper/main.go | 5 +- tools/plonk-wrapper/msm_capture_bn254_test.go | 371 ----- tools/plonk-wrapper/optimization_test.go | 77 - tools/plonk-wrapper/pairing_batch.go | 83 -- .../plonk-wrapper/pairing_batch_bn254_test.go | 265 ---- tools/plonk-wrapper/parameters.go | 14 +- tools/plonk-wrapper/parameters_bn254_test.go | 20 +- .../patches/gnark-cuda-generator-v1.patch | 801 ----------- .../plonk-wrapper/patches/gnark-cuda-v1.json | 26 - .../plonk-wrapper/patches/gnark-cuda-v1.patch | 1261 ----------------- tools/plonk-wrapper/profile.go | 56 +- tools/plonk-wrapper/sizing_test.go | 74 - .../testdata/bn254/provenance.json | 4 +- tools/plonk-wrapper/testdata/bn254/vk.json | 400 +++++- tools/plonk-wrapper/verifier_bn254_test.go | 38 +- 40 files changed, 776 insertions(+), 4624 deletions(-) delete mode 100644 tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go delete mode 100644 tools/plonk-wrapper/cuda_msm_replay_bn254_test.go delete mode 100644 tools/plonk-wrapper/cuda_profile_bn254_test.go delete mode 100644 tools/plonk-wrapper/lagrange_import_bn254.go delete mode 100644 tools/plonk-wrapper/lagrange_import_bn254_test.go delete mode 100644 tools/plonk-wrapper/msm_capture_bn254_test.go delete mode 100644 tools/plonk-wrapper/optimization_test.go delete mode 100644 tools/plonk-wrapper/pairing_batch.go delete mode 100644 tools/plonk-wrapper/pairing_batch_bn254_test.go delete mode 100644 tools/plonk-wrapper/patches/gnark-cuda-generator-v1.patch delete mode 100644 tools/plonk-wrapper/patches/gnark-cuda-v1.json delete mode 100644 tools/plonk-wrapper/patches/gnark-cuda-v1.patch delete mode 100644 tools/plonk-wrapper/sizing_test.go diff --git a/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h b/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h index 82c500866..0727335cf 100644 --- a/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h +++ b/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h @@ -10,7 +10,6 @@ extern "C" { #endif -#define IX_PLONK_BN254_ABI_VERSION 7u #define IX_PLONK_BN254_MAX_MSM_POINTS (UINT64_C(1) << 24) typedef struct { uint64_t limbs[4]; } ix_plonk_bn254_fr; @@ -38,8 +37,6 @@ typedef struct { } ix_plonk_bn254_error; typedef struct { - uint32_t abi_version; - uint32_t struct_bytes; int32_t device; uint32_t msm_strategy; uint64_t budget_bytes; @@ -65,7 +62,7 @@ typedef struct { uint64_t pool_used_bytes, pool_used_high_bytes; uint64_t pool_reserved_bytes, pool_reserved_high_bytes; uint64_t accounted_bytes, accounted_high_bytes, budget_bytes; - uint64_t msm_calls, fft_calls, numerator_calls, quotient_calls; + uint64_t msm_calls, fft_calls, numerator_calls; uint64_t upload_bytes, download_bytes; /* Successful logical transfers; each can contain multiple DMA chunks. */ uint64_t upload_calls, download_calls; @@ -128,10 +125,9 @@ int ix_plonk_bn254_quotient_put(ix_plonk_bn254_context *context, ix_plonk_bn254_quotient quotient, uint32_t slot, ix_plonk_bn254_fr *values, uint64_t count, ix_plonk_bn254_error *error); -/* fused=0 yields numerator LagrangeCoset/BitReverse; fused=1 yields canonical/ - * regular H, including division by ZH and the inverse coset transform. */ +/* Yields the numerator in LagrangeCoset/BitReverse form. */ int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context *context, - ix_plonk_bn254_quotient quotient, uint32_t fused, + ix_plonk_bn254_quotient quotient, ix_plonk_bn254_fr *output, uint64_t count, ix_plonk_bn254_error *error); int ix_plonk_bn254_quotient_destroy(ix_plonk_bn254_context *context, diff --git a/crates/proof-compression/cuda/plonk/plonk.cu b/crates/proof-compression/cuda/plonk/plonk.cu index 71c7721d7..bbaa775af 100644 --- a/crates/proof-compression/cuda/plonk/plonk.cu +++ b/crates/proof-compression/cuda/plonk/plonk.cu @@ -32,7 +32,6 @@ static_assert(sizeof(affine_t) == 64 && sizeof(point_t) == 96); static_assert(sizeof(ix_plonk_bn254_fr) == sizeof(fr_t)); static_assert(sizeof(ix_plonk_bn254_affine) == sizeof(affine_t)); static_assert(sizeof(ix_plonk_bn254_jacobian) == sizeof(point_t)); -static_assert(sizeof(ix_plonk_bn254_config) == 32); static_assert(MAX_LG_DOMAIN_SIZE == 28); namespace { @@ -274,13 +273,13 @@ struct Workspace { }; struct alignas(32) NumeratorParameters { - fr_t omega, coset, identity_coset, alpha, beta, gamma, vanishing, vanishing_inverse; + fr_t omega, coset, identity_coset, alpha, beta, gamma, vanishing; fr_t bl[2], br[2], bo[2], bz[3]; const fr_t* columns[MAX_SLOTS]; const fr_t* boundary; fr_t* output; uint64_t n; - uint32_t commitments, ratio, coset_index, log_m, fused; + uint32_t commitments, ratio, coset_index, log_m; }; struct Quotient { @@ -415,7 +414,6 @@ __global__ void numerator(NumeratorParameters p) { * (o + u * u * id + p.gamma); fr_t boundary = (z - fr_t::one()) * p.boundary[j]; fr_t value = gate + p.alpha * (left - right + p.alpha * boundary); - if (p.fused) value *= p.vanishing_inverse; uint32_t destination = __brev(static_cast(p.ratio*j + p.coset_index)) >> (32-p.log_m); p.output[destination] = value; x *= step; @@ -511,10 +509,8 @@ extern "C" int ix_plonk_bn254_context_create(const ix_plonk_bn254_config* config int ordinal = -1; try { require(config && out, "missing CUDA context configuration"); - require(config->abi_version == IX_PLONK_BN254_ABI_VERSION - && config->struct_bytes == sizeof(*config) - && config->msm_strategy <= IX_PLONK_BN254_MSM_MASK, - "CUDA context ABI mismatch"); + require(config->msm_strategy <= IX_PLONK_BN254_MSM_MASK, + "unsupported CUDA MSM strategy"); require(config->budget_bytes > NTT_BYTES, "CUDA budget is too small"); const gpu_t* gpu = nullptr; for (const auto* candidate : all_gpus()) @@ -770,11 +766,11 @@ extern "C" int ix_plonk_bn254_quotient_put(ix_plonk_bn254_context* context, } extern "C" int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context* context, - ix_plonk_bn254_quotient id, uint32_t fused, ix_plonk_bn254_fr* output, + ix_plonk_bn254_quotient id, ix_plonk_bn254_fr* output, uint64_t count, ix_plonk_bn254_error* error) { return guarded(context, error, [&] { auto& q = session(*context, id); - require(output && !q.finished && fused <= 1 && count == q.config.m + require(output && !q.finished && count == q.config.m && std::all_of(q.present.begin(), q.present.end(), [](bool v) { return v; }), "incomplete quotient session"); NumeratorParameters p{}; @@ -787,7 +783,7 @@ extern "C" int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context* context, for (unsigned i = 0; i < 3; ++i) p.bz[i] = field(q.config.bz[i]); p.n = q.config.n; p.commitments = q.config.commitments; p.ratio = static_cast(q.config.m / q.config.n); - p.log_m = log_size(q.config.m); p.fused = fused; + p.log_m = log_size(q.config.m); p.output = q.output.as(); p.boundary = q.boundary.as(); for (uint32_t i = 0; i < q.present.size(); ++i) p.columns[i] = q.evaluations.as() + uint64_t(i) * p.n; @@ -797,7 +793,6 @@ extern "C" int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context* context, context->ready(); p.coset_index = i; p.coset = coset; p.vanishing = (coset ^ static_cast(p.n)) - fr_t::one(); require(!p.vanishing.is_zero(), "quotient coset intersects the base domain"); - p.vanishing_inverse = p.vanishing.reciprocal(); for (uint32_t slot = 0; slot < q.present.size(); ++slot) { context->ready(); fr_t* destination = q.evaluations.as() + uint64_t(slot) * p.n; @@ -811,10 +806,7 @@ extern "C" int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context* context, CUDA_OK(cudaGetLastError()); coset *= omega_big; } - if (fused) { - transform(*context, q.output.as(), q.config.m, true, true, field(q.config.coset)); - ++context->stats.quotient_calls; - } else { ++context->stats.numerator_calls; } + ++context->stats.numerator_calls; context->download(output, q.output.as(), q.config.m * 32); q.finished = true; }); diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md index d2741210d..b0d0f9cc0 100644 --- a/docs/compression-stage-collapse.md +++ b/docs/compression-stage-collapse.md @@ -45,7 +45,11 @@ otherwise (October 2026, one RTX PRO 6000 Blackwell, 48 logical CPUs). ## The pipeline as it stands -Warm BN254 Init figures with admitted Ignition parameters. Each stage is a +The terminal helper now uses stock gnark with GPU numerator evaluation; see the +[stock gnark comparison](proof-benchmark-comparison.md#stock-gnark-comparison). +The timings below precede that change and include the fused quotient extension. + +Historical BN254 Init figures with admitted Ignition parameters. Each stage is a separate process. | Stage | Prover | Output | Wall time | Share | diff --git a/docs/proof-benchmark-comparison.md b/docs/proof-benchmark-comparison.md index 89183112e..d7b1d493e 100644 --- a/docs/proof-benchmark-comparison.md +++ b/docs/proof-benchmark-comparison.md @@ -103,3 +103,15 @@ It uses the historical gas average rather than a live gas quote. Implementation context is in the [KZG compression README](../crates/proof-compression/README.md). + +## Stock gnark comparison + +On October 28, 2026, the same `2^24` Init circuit was measured on 32 logical +Xeon Platinum 8559C CPUs and one RTX PRO 6000 Blackwell GPU, with Go 1.26.8 +and CUDA 13.3. Five proofs per mode after one warmup gave medians of 11.550 s +for stock gnark with CUDA, 11.671 s patched/unfused, and 11.439 s patched/fused. +All 18 proofs verified and rejected altered claims. Fusion saved 4 GiB of +transfers but only 0.96% versus stock gnark; stock gnark is now the supported +integration. The implementation was `cfc35206e`, matching `sb/bn254` at +`0a713fbf`; detailed reports and archive pins are in `target/quotient-benchmark/` +in the measurement worktree. diff --git a/flake.nix b/flake.nix index f7d8a0ea5..ae15f7eb1 100644 --- a/flake.nix +++ b/flake.nix @@ -110,11 +110,6 @@ file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" || file.name == "go.mod" || file.name == "go.sum" ) ./tools/plonk-wrapper; - plonkCudaSources = pkgs.lib.fileset.fileFilter ( - file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" - || file.hasExt "py" || file.hasExt "patch" - || file.name == "go.mod" || file.name == "go.sum" - ) ./tools/plonk-wrapper; plonkSppark = pkgs.fetchFromGitHub { owner = "argumentcomputer"; repo = "sppark"; @@ -160,7 +155,7 @@ version = "0.1.0"; src = pkgs.lib.fileset.toSource { root = ./tools/plonk-wrapper; - fileset = if cuda then plonkCudaSources else plonkSources; + fileset = plonkSources; }; vendorHash = "sha256-atTpFnSORqh5A+LzECBF9gR0qCr7G2lNXpVuFFSS5to="; subPackages = [ "." ]; @@ -168,12 +163,7 @@ env.GOTOOLCHAIN = "local"; env.CGO_CFLAGS = pkgs.lib.optionalString cuda "-I${plonkCudaNative}/include"; env.CGO_LDFLAGS = pkgs.lib.optionalString cuda "-L${plonkCudaNative}/lib -Wl,-rpath,${plonkCudaNative}/lib -lix_plonk_bn254_cuda"; - nativeBuildInputs = pkgs.lib.optionals cuda [ pkgs.python3 pkgs.patch ]; buildInputs = pkgs.lib.optional cuda plonkCudaNative; - postConfigure = pkgs.lib.optionalString cuda '' - chmod -R u+w vendor/github.com/consensys/gnark - python3 build_cuda.py --patch-vendor vendor/github.com/consensys/gnark - ''; postInstall = '' mv "$out/bin/plonk-wrapper" "$out/bin/ix-bn254-plonk-wrapper" '' + pkgs.lib.optionalString cuda '' diff --git a/lakefile.lean b/lakefile.lean index cbaa73c89..e088b2261 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -247,8 +247,7 @@ target ix_plonk_wrapper pkg : Array FilePath := do return pure #[] let mut sources ← inputDir (pkg.dir / "tools/plonk-wrapper") false fun path => path.extension == some "go" || path.extension == some "json" || - path.extension == some "py" || path.extension == some "patch" || - path.extension == some "h" || + path.extension == some "py" || path.extension == some "h" || path.extension == some "bin" || path.fileName == "go.mod" || path.fileName == "go.sum" if cuda then let native ← inputDir (pkg.dir / "crates/proof-compression/cuda") true fun path => diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index 49683dfd5..1589279cd 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -28,10 +28,12 @@ can override the executable location. ## Optional BN254 CUDA backend CPU is the default. The experimental CUDA helper supports BN254 domains through -`2^24`, including the current Init circuit, with GPU MSMs, FFTs and a fused -PLONK quotient. Gnark retains witness solving, randomness, transcript challenges, -opening construction and verification. Proof encoding and the verifying key -are independent of the selected arithmetic backend. +`2^24`, including the current Init circuit, with GPU MSMs, FFTs and PLONK +numerator evaluation through stock gnark's `Accelerator` interface. CPU and CUDA +builds use the same pinned `go.mod` and `go.sum`. Gnark retains quotient division, +witness solving, randomness, transcript challenges, opening construction and +verification. Proof encoding and the verifying key are independent of the +selected arithmetic backend. The source build uses explicit copies of sppark revision `114feb69ebfe029e2caa5e6c2d372c695e5d4da8` and blst 0.3.16. The blst source root @@ -57,11 +59,10 @@ python3 tools/plonk-wrapper/build_cuda.py \ --sppark "$IX_PLONK_SPPARK_SOURCE" --blst "$IX_PLONK_BLST_SOURCE" ``` -The build checks source receipts, applies the tracked gnark and sppark patches -to private build copies, and records executable/library hashes. It leaves -`go.mod` and the dependency caches unchanged. The native library is installed -in `../lib` relative -to the helper. Keep both artifacts together when installing; `lake run install` +The native build checks source receipts and applies the tracked sppark patch +to a private build copy. The helper uses ordinary `go build -tags=cuda` against +the pinned upstream modules and records executable/library hashes. The native +library is installed in `../lib` relative to the helper. Keep both artifacts together when installing; `lake run install` does this when `IX_PLONK_CUDA=1`. The optional Nix package is `.#plonk-wrapper-cuda`, targeting SM120. CPU packages remain independent of the CUDA toolkit. @@ -82,7 +83,7 @@ proof; they do not silently switch its arithmetic to CPU. The helper emits callback times, dispatch counts, transferred bytes, allocation accounting and CUDA pool high-water marks. Callback times include transfers; -quotient timing includes its internal transforms and host restoration. Pool +numerator timing includes its internal transforms and host restoration. Pool high-water marks exclude allocations outside the pool. Complete-process timing also includes artifact authentication/loading and backend setup. Queue wait and nested native timings overlap callback timings; they are not @@ -91,18 +92,17 @@ Set `IX_PLONK_CUDA_TRACE=1` for operation start/end diagnostics without scalar values. Transfer counters cover explicit adapter transfers; internal sppark result transfers may add traffic. -ABI7 reports per-callback transfer byte/call deltas and separates inclusive +Transfer metrics report per-callback byte/call deltas and separate inclusive transfer wall time, host staging and DMA event time. Wall time can include previously queued kernels; host staging and DMA overlap. DMA timings are valid only when `TransferTimingEnabled` is true. The shared transfer implementation currently enables those events with `IX_KZG_CUDA_PROFILE=1`. -The opt-in full Init test supports `IX_PLONK_CUDA_INIT_MODES`, -`IX_PLONK_CUDA_INIT_REPEATS`, `IX_PLONK_CUDA_INIT_STAGE_PROFILE=1` and -`IX_PLONK_CUDA_INIT_CPU_PROFILE=1`, in addition to its required artifact/claim -pins. CPU sampling covers only the first selected proof; stage observation -covers every selected proof. Reports separate backend setup and profiler -startup/shutdown from core proving and record overlapping stage intervals. +The opt-in full Init test supports `IX_PLONK_CUDA_INIT_MODES=cpu,cuda`, +`IX_PLONK_CUDA_INIT_REPEATS` and `IX_PLONK_CUDA_INIT_CPU_PROFILE=1`, in addition +to its required artifact/claim pins. CPU sampling covers only the first selected +proof. Reports separate backend setup and profiler startup/shutdown from core +proving. `IX_PLONK_CUDA_MSM_STRATEGY=split` is the default: large Pippenger buckets are split into bounded tasks while retaining the zero and ±1 fast paths. The @@ -116,6 +116,9 @@ that helper do not require different keys. A changed helper does not authorize editing an old manifest. The full Init test harness accepts an independently pinned existing manifest without changing production loading rules. +The [stock gnark comparison](../../docs/proof-benchmark-comparison.md#stock-gnark-comparison) +records the Init measurements behind the upstream integration choice. + The persistent worker protocol, immutable selector caching across proofs, shared admission with KZG workers, and cancellation/restart policy remain future service work. @@ -164,52 +167,27 @@ The parameter export receipt is written beside `parameters.bin` as operation, independently of an untrusted parameter file. Gnark requires `N+3` monomial powers and `N` Lagrange points; the adapter requests the next power of two from ix and retains the ceremony's full public degree. Its current maximum -PLONK domain is `2^26` with the provisioned `2^27` cache. +PLONK domain is `2^26` when a sufficient authenticated parameter prefix is available. Circuit and key artifacts bind the verifying key, executable and parameter receipt. The helper checks hashes before reuse. Lagrange points are cached separately -by setup, domain, receipt and helper identity; `IX_PLONK_LAGRANGE_CACHE` can +by setup, domain and monomial receipt; `IX_PLONK_LAGRANGE_CACHE` can select the shared directory. A changed executable needs a new artifact directory. Corrupt artifacts fail closed. -## BN254 Ignition parameters and Lagrange import +## BN254 Ignition parameters -BN254 uses the same commands with `ix-bn254-plonk-wrapper`. Export requires -`IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, +Export requires `IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, `IX_KZG_IGNITION_ADMISSION` and `IX_KZG_IGNITION_ADMISSION_BLAKE3`, as described in the [compression setup documentation](../../crates/proof-compression/README.md). The provisioned `2^26` monomial cache supports PLONK domain `2^25`, which -requires `2^25+3` powers. The normal helper's classic-GLV Init wrapper has -14,689,440 constraints plus two public rows, fitting `2^24` with 2,087,774 -rows to spare. The completed full proof benchmark below used the earlier -17,996,081-constraint configuration and domain `2^25`. - -SP1 v6.1.0 publishes a legacy gnark `plonk_pk.bin` containing a `2^25` -Lagrange basis for the same Ignition anchors. It is an untrusted source of -candidate points. After compilation and authenticated monomial export, import -it before `prepare`: - -```sh -ix-bn254-plonk-wrapper import-lagrange-sp1 \ - artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 /path/to/plonk_pk.bin -ix-bn254-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 -``` - -The importer bounds lengths, validates point encodings, compares the setup -anchors and domain, and checks the complete candidate basis against the -authenticated monomial powers with a transcript-bound random linear relation. -That relation requires a scalar inverse FFT and two MSMs. The cache is -published only after it passes, with the candidate-vector digest and validation -evidence in `validation.json`. The error bound for a fixed incorrect basis is -at most `(N-1)/Fr` in the random-oracle model. SP1 circuit keys are discarded; -`prepare` generates ix's own proving and verifying keys. A basis for `2^25` -cannot be truncated into a `2^24` basis. - -The import command requires an exact domain match. For the current `2^24` -circuit, `prepare` computes and caches a matching basis from the authenticated -monomial powers unless an independently validated matching cache is present. +requires `2^25+3` powers. The Init wrapper uses classic GLV and separate pairing +checks: 14,689,440 constraints plus two public rows fit a `2^24` domain. +`prepare` derives and caches its Lagrange basis from the authenticated monomial +powers. The exported verifier must explicitly supply `accumulatorCount`, +`claims`, and `publics`. -## Tests and sizing +## Tests ```sh cd tools/plonk-wrapper @@ -218,10 +196,10 @@ go test ./... The ordinary tests cover Blake3 boundaries, canonical point encodings, the actual Init verifier witness and tampering, exceptional MSM inputs, -small PLONK proofs, parameter caches, and malformed Lagrange imports. -Sizing is opt-in with `IX_PLONK_SIZE=1`; the component and optimization tests -compile the full circuit and require separate time and memory budgeting. -`IX_PLONK_SIZE_VK` selects a verifier and `IX_PLONK_SIZE_REPORT` saves results. +small PLONK proofs, explicit verifier schemas, and parameter-cache integrity. +The opt-in full Init test above is the common CPU/CUDA profiling and integration +entry point. Its loader validates the supplied artifact dimensions instead of +assuming a fixed historical constraint count. Current Init proofs use a `2^24` PLONK domain. Recorded native, envelope, and Solidity proof sizes are 584, 624, and 864 bytes. See the diff --git a/tools/plonk-wrapper/backend.go b/tools/plonk-wrapper/backend.go index 2d7ece0ea..7c8fe4647 100644 --- a/tools/plonk-wrapper/backend.go +++ b/tools/plonk-wrapper/backend.go @@ -136,7 +136,7 @@ func prepare(dir, path, digest string) error { cacheRoot = filepath.Join(filepath.Dir(dir), "lagrange") } fmt.Fprintln(os.Stderr, "stage=lagrange") - lagrangeSRS, reused, err := lagrangeParameters(cacheRoot, srs, m.Domain, p.SetupID, digest, m.ExecutableSHA256) + lagrangeSRS, reused, err := lagrangeParameters(cacheRoot, srs, m.Domain, p.SetupID, digest) if err != nil { return err } diff --git a/tools/plonk-wrapper/backend_cuda_bn254.go b/tools/plonk-wrapper/backend_cuda_bn254.go index 852a0960f..af927566e 100644 --- a/tools/plonk-wrapper/backend_cuda_bn254.go +++ b/tools/plonk-wrapper/backend_cuda_bn254.go @@ -16,7 +16,7 @@ import ( ) func provingCUDAConfig() (cuda.Config, error) { - config := cuda.Config{MemoryBytes: 64 << 30, HeadroomBytes: 8 << 30, Features: cuda.AllFeatures} + config := cuda.Config{MemoryBytes: 64 << 30, HeadroomBytes: 8 << 30} switch os.Getenv("IX_PLONK_CUDA_MSM_STRATEGY") { case "", "split": config.MSMStrategy = cuda.MSMSplit diff --git a/tools/plonk-wrapper/backend_cuda_bn254_test.go b/tools/plonk-wrapper/backend_cuda_bn254_test.go index 1fa9ea8a0..3a280fd39 100644 --- a/tools/plonk-wrapper/backend_cuda_bn254_test.go +++ b/tools/plonk-wrapper/backend_cuda_bn254_test.go @@ -85,8 +85,8 @@ func TestCUDAProvingBackendFactory(t *testing.T) { if err != nil { t.Fatal(err) } - if stats.MSMCalls == 0 || stats.QuotientCalls != 1 || stats.NumeratorCalls != 0 { - t.Fatalf("factory proof did not execute fused CUDA operations: %+v", stats) + if stats.MSMCalls == 0 || stats.NumeratorCalls != 1 { + t.Fatalf("factory proof did not execute CUDA operations: %+v", stats) } for range 2 { if err := cleanup(); err != nil { diff --git a/tools/plonk-wrapper/build_cuda.py b/tools/plonk-wrapper/build_cuda.py index e6efe8e22..2ab7378cb 100644 --- a/tools/plonk-wrapper/build_cuda.py +++ b/tools/plonk-wrapper/build_cuda.py @@ -9,13 +9,11 @@ import os from pathlib import Path import shutil -import stat import subprocess import sys import tempfile -GNARK_VERSION = "v0.16.5-0.20261015185324-262cf453c107" HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent @@ -28,110 +26,17 @@ def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() -def patch_bytes(): - return b"".join((HERE / "patches" / name).read_bytes() for name in ( - "gnark-cuda-v1.patch", "gnark-cuda-generator-v1.patch")) - - -def tree_hashes(root): - result = {} - for directory, directories, files in os.walk(root): - for name in directories + files: - if (Path(directory) / name).is_symlink(): - raise ValueError("symlink in gnark module tree") - for name in files: - path = Path(directory) / name - result[str(path.relative_to(root))] = digest(path) - return result - - -def apply_patch(source, vendored=False): - receipt = json.loads((HERE / "patches/gnark-cuda-v1.json").read_text()) - if receipt["version"] != GNARK_VERSION: - raise ValueError("CUDA patch targets a different gnark version") - selected = {} - for relative, hashes in receipt["files"].items(): - path = source / relative - if Path(relative).is_absolute() or ".." in Path(relative).parts: - raise ValueError("invalid path in gnark patch receipt") - if vendored and not path.exists() and not relative.startswith("backend/plonk/bn254/"): - continue - before = hashes["before"] - if (before is None and path.exists()) or (before is not None and (not path.is_file() or digest(path) != before)): - raise ValueError(f"gnark source differs from patch receipt: {relative}") - selected[relative] = hashes - text = patch_bytes().decode() - blocks = text.split("diff --git ") - if blocks[0].strip(): - raise ValueError("gnark patch must use git diff headers") - filtered = [] - for block in blocks[1:]: - line = block.splitlines()[0] - relative = line.split(" b/", 1)[1] - if relative in selected: - filtered.append("diff --git " + block) - if not filtered: - raise ValueError("no authenticated gnark patch files selected") - run(["patch", "--batch", "--fuzz=0", "-p1"], cwd=source, - input="".join(filtered), text=True) - for relative, hashes in selected.items(): - if digest(source / relative) != hashes["after"]: - raise ValueError(f"patched gnark digest differs: {relative}") - - -def patched_module(go, build, environment): - info = json.loads(subprocess.check_output( - [go, "list", "-m", "-json", "github.com/consensys/gnark"], - cwd=HERE, env=environment, text=True)) - if info.get("Version") != GNARK_VERSION or info.get("Replace"): - raise ValueError("CUDA build requires the pinned, unreplaced gnark module") - patch_hash = hashlib.sha256( - patch_bytes() - + (HERE / "patches/gnark-cuda-v1.json").read_bytes()).hexdigest() - expected = tree_hashes(Path(info["Dir"])) - receipt = json.loads((HERE / "patches/gnark-cuda-v1.json").read_text()) - for relative, hashes in receipt["files"].items(): - if expected.get(relative) != hashes["before"]: - raise ValueError(f"gnark module differs from the pinned patch input: {relative}") - expected[relative] = hashes["after"] - destination = build / ("gnark-" + patch_hash[:20]) - if not destination.exists(): - temporary = Path(tempfile.mkdtemp(prefix=".gnark-", dir=build)) - try: - shutil.copytree(info["Dir"], temporary, dirs_exist_ok=True) - for directory, _, files in os.walk(temporary): - Path(directory).chmod(Path(directory).stat().st_mode | stat.S_IWUSR) - for name in files: - path = Path(directory) / name - path.chmod(path.stat().st_mode | stat.S_IWUSR) - apply_patch(temporary) - temporary.rename(destination) - finally: - if temporary.exists(): - shutil.rmtree(temporary) - else: - if tree_hashes(destination) != expected: - raise ValueError("private gnark build copy differs from the complete pinned source tree") - return destination - - def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", type=Path) - parser.add_argument("--build-dir", type=Path) - parser.add_argument("--sppark", type=Path) - parser.add_argument("--blst", type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--build-dir", required=True, type=Path) + parser.add_argument("--sppark", required=True, type=Path) + parser.add_argument("--blst", required=True, type=Path) parser.add_argument("--go", default=os.environ.get("GO", "go")) parser.add_argument("--test-binary", action="store_true") parser.add_argument("--package", choices=[".", "./internal/cuda"], default=".") parser.add_argument("--reuse-native", action="store_true") - parser.add_argument("--patch-vendor", type=Path) args = parser.parse_args() - if args.patch_vendor: - apply_patch(args.patch_vendor.resolve(), vendored=True) - return - if not all([args.out, args.build_dir, args.sppark, args.blst]): - parser.error("--out, --build-dir, --sppark and --blst are required") output = args.out.resolve() build = args.build_dir.resolve() build.mkdir(parents=True, exist_ok=True) @@ -143,7 +48,6 @@ def main(): def build_helper(args, output, build): environment = dict(os.environ, GOTOOLCHAIN="local", CGO_ENABLED="1") - source = patched_module(args.go, build, environment) native = build / "native" if args.reuse_native: specification = importlib.util.spec_from_file_location("ix_plonk_native_build", @@ -174,11 +78,6 @@ def build_helper(args, output, build): run(["python3", ROOT / "crates/proof-compression/cuda/plonk/build.py", "--sppark", args.sppark.resolve(), "--blst", args.blst.resolve(), "--out", native], env=environment) - modfile = build / "cuda.mod" - modfile.write_text((HERE / "go.mod").read_text() - + "\nreplace github.com/consensys/gnark => " - + json.dumps(str(source)) + "\n") - shutil.copyfile(HERE / "go.sum", modfile.with_suffix(".sum")) header = ROOT / "crates/proof-compression/cuda/plonk" native_identity = hashlib.sha256((digest(native / "libix_plonk_bn254_cuda.so") + digest(header / "ix_plonk_bn254.h")).encode()).hexdigest() @@ -200,13 +99,13 @@ def build_helper(args, output, build): command = [args.go, "test", "-c"] if args.test_binary else [args.go, "build"] temporary_output = output.with_suffix(output.suffix + ".tmp") command += ["-buildvcs=false", "-trimpath", "-mod=readonly", - "-modfile=" + str(modfile), "-tags", "cuda", + "-tags", "cuda", "-o", str(temporary_output), args.package] run(command, cwd=HERE, env=environment) temporary_output.replace(output) record = { - "format": "ix-bn254-plonk-cuda-build-v1", "gnark_version": GNARK_VERSION, - "gnark_patch_sha256": hashlib.sha256(patch_bytes()).hexdigest(), + "go_mod_sha256": digest(HERE / "go.mod"), + "go_sum_sha256": digest(HERE / "go.sum"), "go_version": subprocess.check_output([args.go, "version"], text=True).strip(), "binary": str(output), "binary_sha256": digest(output), "library": str(library), "library_sha256": digest(library), diff --git a/tools/plonk-wrapper/circuit.go b/tools/plonk-wrapper/circuit.go index 6146d8212..a80495ff5 100644 --- a/tools/plonk-wrapper/circuit.go +++ b/tools/plonk-wrapper/circuit.go @@ -43,7 +43,7 @@ func littleEndian(api frontend.API, u *uints.BinaryField[uints.U32], bytes []uin return value } -func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints.U32], encoded [][]uints.U8, publics []frontend.Variable) ([]frontend.Variable, []uints.U8) { +func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints.U32], encoded [][]uints.U8, publics []frontend.Variable) []frontend.Variable { p := c.VK seed, _ := unhex(p.Seed, -1) t := newTranscript(api, u, seed) @@ -72,7 +72,7 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints for _, circuit := range p.Circuits { field(circuit.LogDegree) } - rows := p.claimRows() + rows := p.Claims field(len(rows)) for _, row := range rows { field(len(row)) @@ -90,7 +90,7 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints ch[1] = t.sample() field(ch[1]) commitment(1) - accumulators := p.accumulators() + accumulators := p.AccumulatorCount for k := 0; k < accumulators; k++ { field(c.Values[k]) } @@ -112,7 +112,7 @@ func (c *wrapperCircuit) challenges(api frontend.API, u *uints.BinaryField[uints t.observe(point) } ch[5] = t.sample() - return ch, t.state + return ch } func (p *scalarProgram) constrain(api frontend.API, challenges, values, publics []frontend.Variable) ([]frontend.Variable, []frontend.Variable) { @@ -150,19 +150,6 @@ func (p *scalarProgram) constrain(api frontend.API, challenges, values, publics } func (c *wrapperCircuit) Define(api frontend.API) error { - return c.constrain(api, 6) -} - -func (c *wrapperCircuit) constrain(api frontend.API, phase int) error { - return c.constrainWithOptions(api, phase, circuitOptions{classicGLV: true}) -} - -type circuitOptions struct { - batchPairings bool - classicGLV bool -} - -func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, options circuitOptions) error { p := c.VK if p == nil { return fmt.Errorf("missing trusted verifying key") @@ -183,9 +170,7 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio return err } parameters := curveParameters() - if options.classicGLV { - parameters.PreferClassicGLV = true - } + parameters.PreferClassicGLV = true curve, err := sw_emulated.New[baseField, scalarField](api, parameters) if err != nil { return err @@ -227,7 +212,7 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio bytes := pointBytes(api, u, f, &c.Deferred[i]) deferredHalves = append(deferredHalves, littleEndian(api, u, bytes[:pointLimbBytes]), littleEndian(api, u, bytes[pointLimbBytes:])) } - sources := p.publicSources() + sources := p.Publics publics := make([]frontend.Variable, len(sources)) for i, source := range sources { switch { @@ -241,17 +226,8 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio publics[i] = deferredHalves[*source.DeferredHalf] } } - if phase == 1 { - return nil - } - challenges, packetDigest := c.challenges(api, u, encoded, publics) - if phase == 2 { - return nil - } + challenges := c.challenges(api, u, encoded, publics) coefficients, witnessCoefficients := p.Program.constrain(api, challenges, c.Values, publics) - if phase == 3 { - return nil - } leftPoints, leftCoefficients := foldFixedBases(api, p, points, coefficients) left, err := completeMSM(api, curve, s, leftPoints, leftCoefficients) if err != nil { @@ -261,21 +237,12 @@ func (c *wrapperCircuit) constrainWithOptions(api frontend.API, phase int, optio if err != nil { return err } - if phase == 4 { - return nil - } - if options.batchPairings { - if len(c.Deferred) != 2 { - return fmt.Errorf("pairing batching requires two deferred operands") - } - return c.batchedPairing(api, u, f, s, curve, packetDigest, left, right) - } outer, _ := g2Keys(p.OuterKeys) h, tauH := newCircuitG2Fixed(outer[0]), newCircuitG2Fixed(outer[1]) if err := pairing.PairingCheck([]*circuitG1{left, curve.Neg(right)}, []*circuitG2{&h, &tauH}); err != nil { return err } - if phase == 5 || len(c.Deferred) == 0 { + if len(c.Deferred) == 0 { return nil } inner, _ := g2Keys(p.DeferredKeys) diff --git a/tools/plonk-wrapper/circuit_bn254_test.go b/tools/plonk-wrapper/circuit_bn254_test.go index 6a2e6c6ed..3d3c25376 100644 --- a/tools/plonk-wrapper/circuit_bn254_test.go +++ b/tools/plonk-wrapper/circuit_bn254_test.go @@ -95,10 +95,9 @@ func TestBN254CanonicalVectors(t *testing.T) { } type completeMSMCircuit struct { - Points [2]circuitG1 - Scalars [2]frontend.Variable - Expected circuitG1 - ClassicGLV bool `gnark:"-"` + Points [2]circuitG1 + Scalars [2]frontend.Variable + Expected circuitG1 } func (c *completeMSMCircuit) Define(api frontend.API) error { @@ -107,7 +106,7 @@ func (c *completeMSMCircuit) Define(api frontend.API) error { return err } parameters := curveParameters() - parameters.PreferClassicGLV = c.ClassicGLV + parameters.PreferClassicGLV = true curve, err := sw_emulated.New[baseField, scalarField](api, parameters) if err != nil { return err @@ -158,14 +157,14 @@ func TestBN254ClassicGLVMatchesNativeMSM(t *testing.T) { a.ScalarMultiplication(&pair[0], s1) b.ScalarMultiplication(&pair[1], s2) want.Add(&a, &b) - w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(pair[0]), newCircuitG1(pair[1])}, Scalars: [2]frontend.Variable{s1, s2}, Expected: newCircuitG1(want), ClassicGLV: true} - if err := test.IsSolved(&completeMSMCircuit{ClassicGLV: true}, w, curveID.ScalarField()); err != nil { + w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(pair[0]), newCircuitG1(pair[1])}, Scalars: [2]frontend.Variable{s1, s2}, Expected: newCircuitG1(want)} + if err := test.IsSolved(&completeMSMCircuit{}, w, curveID.ScalarField()); err != nil { t.Fatalf("pair %d scalar %d: %v", i, j, err) } } } - w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(g), newCircuitG1(neg)}, Scalars: [2]frontend.Variable{1, 1}, Expected: newCircuitG1(g), ClassicGLV: true} - if err := test.IsSolved(&completeMSMCircuit{ClassicGLV: true}, w, curveID.ScalarField()); err == nil { + w := &completeMSMCircuit{Points: [2]circuitG1{newCircuitG1(g), newCircuitG1(neg)}, Scalars: [2]frontend.Variable{1, 1}, Expected: newCircuitG1(g)} + if err := test.IsSolved(&completeMSMCircuit{}, w, curveID.ScalarField()); err == nil { t.Fatal("accepted incorrect MSM result") } } diff --git a/tools/plonk-wrapper/cuda_init_bn254_test.go b/tools/plonk-wrapper/cuda_init_bn254_test.go index fb2231231..937c62022 100644 --- a/tools/plonk-wrapper/cuda_init_bn254_test.go +++ b/tools/plonk-wrapper/cuda_init_bn254_test.go @@ -6,14 +6,15 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" + "io" "os" "path/filepath" "runtime" "runtime/pprof" "strconv" "strings" - "sync" "testing" "time" @@ -37,13 +38,13 @@ func TestBN254CUDAInit(t *testing.T) { } repeats = parsed } - modes := []string{"cpu", "cuda-unfused", "cuda-fused"} + modes := []string{"cpu", "cuda"} if value := os.Getenv("IX_PLONK_CUDA_INIT_MODES"); value != "" { selected := make(map[string]bool) for _, mode := range strings.Split(value, ",") { mode = strings.TrimSpace(mode) - if (mode != "cpu" && mode != "cuda-unfused" && mode != "cuda-fused") || selected[mode] { - t.Fatal("IX_PLONK_CUDA_INIT_MODES must be a unique comma-separated subset of cpu,cuda-unfused,cuda-fused") + if (mode != "cpu" && mode != "cuda") || selected[mode] { + t.Fatal("IX_PLONK_CUDA_INIT_MODES must be a unique comma-separated subset of cpu,cuda") } selected[mode] = true } @@ -67,7 +68,6 @@ func TestBN254CUDAInit(t *testing.T) { } } cpuProfile := flag("IX_PLONK_CUDA_INIT_CPU_PROFILE") - stageProfile := flag("IX_PLONK_CUDA_INIT_STAGE_PROFILE") required := func(name string) string { value := os.Getenv("IX_PLONK_CUDA_INIT_" + name) if value == "" { @@ -76,11 +76,11 @@ func TestBN254CUDAInit(t *testing.T) { return value } dir, witnessPath, output := required("ARTIFACTS"), required("WITNESS"), required("OUTPUT") - manifestPin, err := cpuProfileDigest(required("MANIFEST_SHA256")) + manifestPin, err := initDigest(required("MANIFEST_SHA256")) if err != nil { t.Fatal(err) } - claim, err := cpuProfileDigest(required("EXPECTED_CLAIM")) + claim, err := initDigest(required("EXPECTED_CLAIM")) if err != nil { t.Fatal(err) } @@ -88,18 +88,18 @@ func TestBN254CUDAInit(t *testing.T) { t.Fatal(err) } started := time.Now() - m, err := cpuProfileManifest(dir, manifestPin) + m, err := initManifest(dir, manifestPin) if err != nil { t.Fatal(err) } ccs, pk, vk := plonk.NewCS(curveID), plonk.NewProvingKey(curveID), plonk.NewVerifyingKey(curveID) - if err := cpuProfileReadBinary(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { + if err := initReadBinary(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { t.Fatal(err) } - if err := cpuProfileReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { + if err := initReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { t.Fatal(err) } - if err := cpuProfileReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { + if err := initReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { t.Fatal(err) } p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) @@ -122,6 +122,12 @@ func TestBN254CUDAInit(t *testing.T) { if err != nil { t.Fatal(err) } + wrongDigest := append([]byte(nil), digest...) + wrongDigest[0] ^= 1 + wrongPublic, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(wrongDigest)}, curveID.ScalarField(), frontend.PublicOnly()) + if err != nil { + t.Fatal(err) + } actualPublic, err := witness.Public() if err != nil { t.Fatal(err) @@ -135,6 +141,9 @@ func TestBN254CUDAInit(t *testing.T) { t.Fatal("witness differs from independently expected claim") } nativePK := pk.(*native.ProvingKey) + if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != m.PublicInputs || len(ccs.GetCommitments().CommitmentIndexes()) != m.Commitments || nativePK.Vk.Size != m.Domain { + t.Fatal("decoded circuit or key dimensions differ from the manifest") + } var embedded bytes.Buffer if _, err := nativePK.Vk.WriteTo(&embedded); err != nil { t.Fatal(err) @@ -151,19 +160,15 @@ func TestBN254CUDAInit(t *testing.T) { t.Fatal(err) } report := map[string]any{ - "format": "ix-bn254-plonk-cuda-init-v1", "complete": false, + "complete": false, "scope": "Externally pinned existing Init artifacts; same loaded circuit, keys and witness for CPU and CUDA, fresh gnark blinding. No compilation or key preparation.", "manifestSHA256": manifestPin, "manifest": m, "expectedClaim": claim, "testBinary": identity, "artifactAndWitnessSeconds": time.Since(started).Seconds(), "repeats": repeats, "modes": modes, "executionOrder": "iteration-major: " + strings.Join(modes, ", "), "numCPU": runtime.NumCPU(), "gomaxprocs": runtime.GOMAXPROCS(0), "freshCUDAContextPerProof": true, "forcedGC": false, - "cpuProfile": cpuProfile, "stageProfile": stageProfile, - "cpuProfileScope": "Only the first selected run: CPU samples immediately around plonk.Prove, excluding artifact/witness loading, backend setup, serialization and verification. Profiler startup/shutdown are timed separately; subsequent runs are unprofiled.", - "stageProfileScope": "Optional gnark spans from one monotonic Prove origin. Task, compute and wait intervals may nest or overlap and must not be summed as independent wall-clock stages.", - } - if stageProfile { - t.Cleanup(func() { nativePK.SetStageObserverV1(nil) }) + "cpuProfile": cpuProfile, + "cpuProfileScope": "Only the first selected run: CPU samples immediately around plonk.Prove, excluding artifact/witness loading, backend setup, serialization and verification. Profiler startup/shutdown are timed separately; subsequent runs are unprofiled.", } var runs []map[string]any for iteration := 1; iteration <= repeats; iteration++ { @@ -181,9 +186,6 @@ func TestBN254CUDAInit(t *testing.T) { if err != nil { t.Fatal(err) } - if mode == "cuda-unfused" { - config.Features &^= native.AcceleratorFeatureQuotientV1 - } cudaConfig = &config accelerator, err = cuda.Attach(nativePK, config) if err != nil { @@ -199,28 +201,19 @@ func TestBN254CUDAInit(t *testing.T) { t.Fatal(err) } } - var stageMu sync.Mutex - var stageEvents []native.StageEventV1 - if stageProfile { - nativePK.SetStageObserverV1(func(event native.StageEventV1) { - stageMu.Lock() - stageEvents = append(stageEvents, event) - stageMu.Unlock() - }) - } profileThisRun := cpuProfile && len(runs) == 0 profilePath := "" - var vkFile *os.File + var profileFile *os.File var profileStartSeconds, profileStopSeconds float64 if profileThisRun { profileStarted := time.Now() profilePath = filepath.Join(output, stem+".cpu.pprof") - vkFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + profileFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } if err := pprof.StartCPUProfile(profileFile); err != nil { - vkFile.Close() + profileFile.Close() t.Fatal(err) } profileStartSeconds = time.Since(profileStarted).Seconds() @@ -230,7 +223,7 @@ func TestBN254CUDAInit(t *testing.T) { var proveSeconds float64 var profileErr error func() { - if vkFile != nil { + if profileFile != nil { defer func() { profileStopped := time.Now() pprof.StopCPUProfile() @@ -241,24 +234,12 @@ func TestBN254CUDAInit(t *testing.T) { proof, err = plonk.Prove(ccs, pk, witness, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) proveSeconds = time.Since(proveStarted).Seconds() }() - var spans []map[string]any - if stageProfile { - nativePK.SetStageObserverV1(nil) - stageMu.Lock() - for _, event := range stageEvents { - spans = append(spans, map[string]any{"stage": event.Stage, "phase": event.Phase, "startSeconds": event.Start.Seconds(), "endSeconds": event.End.Seconds(), "durationSeconds": (event.End - event.Start).Seconds(), "failed": event.Failed}) - } - stageMu.Unlock() - } if err != nil { t.Fatal(err) } if profileErr != nil { t.Fatal(profileErr) } - if stageProfile && len(spans) == 0 { - t.Fatal("enabled stage observer produced no spans") - } var stats cuda.Stats if accelerator != nil { stats, err = accelerator.Stats() @@ -271,11 +252,8 @@ func TestBN254CUDAInit(t *testing.T) { if stats.MSMCalls == 0 || stats.FFTCalls == 0 { t.Fatal("CUDA arithmetic was not dispatched") } - if mode == "cuda-fused" && (stats.QuotientCalls != 1 || stats.NumeratorCalls != 0) { - t.Fatal("fused quotient was not dispatched exactly once") - } - if mode == "cuda-unfused" && (stats.QuotientCalls != 0 || stats.NumeratorCalls != 1) { - t.Fatal("unfused numerator was not dispatched exactly once") + if stats.NumeratorCalls != 1 { + t.Fatal("CUDA numerator was not dispatched exactly once") } } var encoded bytes.Buffer @@ -291,6 +269,9 @@ func TestBN254CUDAInit(t *testing.T) { t.Fatal(err) } verifySeconds := time.Since(verifyStarted).Seconds() + if err := plonk.Verify(decoded, vk, wrongPublic, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err == nil { + t.Fatal("accepted an altered claim") + } proofPath := filepath.Join(output, stem+".proof.bin") if err := os.WriteFile(proofPath, encoded.Bytes(), 0600); err != nil { t.Fatal(err) @@ -307,7 +288,6 @@ func TestBN254CUDAInit(t *testing.T) { "cudaSetupStats": setupStats} run["cpuProfiled"], run["cpuProfilePath"] = profileThisRun, profilePath run["profileStartSeconds"], run["profileStopSeconds"] = profileStartSeconds, profileStopSeconds - run["stageProfiled"], run["stageSpans"] = stageProfile, spans if profileThisRun { profileIdentity, err := fileHash(profilePath) if err != nil { @@ -332,3 +312,97 @@ func TestBN254CUDAInit(t *testing.T) { t.Fatal(err) } } + +func initDigest(text string) (string, error) { + value, err := unhex(text, sha256.Size) + if err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +// Test executables have their own identity. This benchmark entry point trusts +// an externally pinned manifest; production loading retains its executable pin. +func initManifest(dir, expectedSHA256 string) (*manifest, error) { + f, err := os.Open(filepath.Join(dir, "manifest.json")) + if err != nil { + return nil, err + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + return nil, err + } + if !stat.Mode().IsRegular() || stat.Size() > 1<<20 { + return nil, fmt.Errorf("Init manifest must be a regular file of at most 1 MiB") + } + data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) + if err != nil { + return nil, err + } + if len(data) > 1<<20 || fmt.Sprintf("%x", sha256.Sum256(data)) != expectedSHA256 { + return nil, fmt.Errorf("Init manifest differs from the explicitly trusted SHA-256") + } + var m manifest + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&m); err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("trailing Init manifest data") + } + if m.Format != artifactFormat || !m.KeysReady || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.PublicInputs != 2 || m.Commitments < 0 || m.Commitments > 1024 || len(m.Files) != 5 { + return nil, fmt.Errorf("Init requires prepared BN254 wrapper artifacts") + } + for _, digest := range []string{m.ExecutableSHA256, m.InnerVKSHA256, m.SRSBLAKE3} { + if _, err := initDigest(digest); err != nil { + return nil, fmt.Errorf("invalid Init manifest identity: %w", err) + } + } + for _, name := range []string{"circuit.bin", "proving-key.bin", "verifying-key.bin", "vk.json", "PlonkVerifier.sol"} { + want, ok := m.Files[name] + if !ok || want.Bytes <= 0 { + return nil, fmt.Errorf("missing Init artifact %s", name) + } + if _, err := initDigest(want.SHA256); err != nil { + return nil, fmt.Errorf("invalid Init artifact digest for %s", name) + } + stat, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + return nil, err + } + if !stat.Mode().IsRegular() || stat.Size() != want.Bytes { + return nil, fmt.Errorf("Init artifact %s has an invalid type or size", name) + } + if err := m.checkFile(dir, name); err != nil { + return nil, err + } + } + if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { + return nil, fmt.Errorf("verifying key identity differs from its artifact digest") + } + return &m, nil +} + +// Hash the bytes actually decoded as well as the prior authentication pass. +func initReadBinary(path string, want artifact, value io.ReaderFrom) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + n, err := value.ReadFrom(io.TeeReader(io.LimitReader(f, want.Bytes+1), h)) + if err != nil { + return err + } + if n != want.Bytes || hex.EncodeToString(h.Sum(nil)) != want.SHA256 { + return fmt.Errorf("decoded artifact %s differs from its pinned bytes", path) + } + var extra [1]byte + if n, err := f.Read(extra[:]); n != 0 || err != io.EOF { + return fmt.Errorf("trailing artifact data in %s", path) + } + return nil +} diff --git a/tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go b/tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go deleted file mode 100644 index 9e7b74071..000000000 --- a/tools/plonk-wrapper/cuda_msm_benchmark_bn254_test.go +++ /dev/null @@ -1,176 +0,0 @@ -//go:build cuda - -package main - -import ( - "crypto/sha256" - "encoding/binary" - "fmt" - "os" - "path/filepath" - "slices" - "testing" - "time" - - "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" - "github.com/consensys/gnark-crypto/ecc" - curve "github.com/consensys/gnark-crypto/ecc/bn254" - "github.com/consensys/gnark-crypto/ecc/bn254/fr" - native "github.com/consensys/gnark/backend/plonk/bn254" -) - -func TestCUDAStructuredMSMBenchmark(t *testing.T) { - if os.Getenv("IX_PLONK_CUDA_MSM_BENCH") != "1" { - t.Skip("set IX_PLONK_CUDA_MSM_BENCH=1 and explicit artifact pins for 2^20 structured MSM comparisons") - } - if os.Getenv("GOMAXPROCS") != "" { - t.Fatal("unset GOMAXPROCS to keep default CPU parallelism") - } - required := func(name string) string { - value := os.Getenv("IX_PLONK_CUDA_MSM_BENCH_" + name) - if value == "" { - t.Fatalf("IX_PLONK_CUDA_MSM_BENCH_%s must be explicit", name) - } - return value - } - dir, out := required("ARTIFACTS"), required("OUTPUT") - pin, err := cpuProfileDigest(required("MANIFEST_SHA256")) - if err != nil { - t.Fatal(err) - } - if err := os.Mkdir(out, 0700); err != nil { - t.Fatal(err) - } - started := time.Now() - m, err := cpuProfileManifest(dir, pin) - if err != nil { - t.Fatal(err) - } - validationSeconds := time.Since(started).Seconds() - started = time.Now() - pk := new(native.ProvingKey) - if err := cpuProfileReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { - t.Fatal(err) - } - loadSeconds := time.Since(started).Seconds() - config, err := provingCUDAConfig() - if err != nil { - t.Fatal(err) - } - config.Features = native.AcceleratorFeatureMSMV1 - started = time.Now() - a, err := cuda.Attach(pk, config) - if err != nil { - t.Fatal(err) - } - setupSeconds := time.Since(started).Seconds() - defer func() { - if err := a.Close(); err != nil { - t.Error(err) - } - }() - const count = 1 << 20 - const offset = 2 - points := pk.KzgLagrange.G1[offset : offset+count] - report := map[string]any{"format": "ix-bn254-cuda-structured-msm-v1", "complete": false, "scope": "Synthetic structured scalars and authenticated production Lagrange bases; exact CPU/GPU MSM comparison, not a PLONK proof or full Init benchmark.", "manifestSHA256": pin, "provingKeySHA256": m.Files["proving-key.bin"].SHA256, "count": count, "offset": offset, "strategy": config.MSMStrategy.String(), "validationSeconds": validationSeconds, "keyLoadSeconds": loadSeconds, "gpuSetupSeconds": setupSeconds} - var rows []map[string]any - defer func() { - report["rows"] = rows - if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { - t.Error(err) - } - }() - var c, d, minusC fr.Element - if _, err := c.SetString("837628908879347212334541733913009433"); err != nil { - t.Fatal(err) - } - d.SetUint64(7919) - minusC.Neg(&c) - one := fr.One() - for _, kind := range []string{"shifted-binary", "signed-zero", "three-hot", "distinct-shifted", "narrow-shifted", "narrow-small"} { - started = time.Now() - values := make([]fr.Element, count) - for i := range values { - switch kind { - case "shifted-binary": - values[i] = minusC - if i%4 == 0 { - values[i].Add(&values[i], &one) - } - case "signed-zero": - switch i % 3 { - case 0: - values[i] = c - case 1: - values[i] = minusC - } - case "three-hot": - values[i] = c - switch i % 3 { - case 0: - values[i].Sub(&values[i], &d) - case 1: - values[i].Add(&values[i], &d) - } - case "distinct-shifted": - values[i].SetUint64(uint64(i)).Add(&values[i], &c) - case "narrow-shifted": - values[i].SetUint64(uint64(i%65536)).Add(&values[i], &c) - case "narrow-small": - values[i].SetUint64(uint64(i % 65536)) - } - if i%17 == 0 { - var seed [8]byte - binary.LittleEndian.PutUint64(seed[:], uint64(i)) - digest := sha256.Sum256(seed[:]) - values[i].SetBytes(digest[:]) - } - } - generationSeconds := time.Since(started).Seconds() - original := append([]fr.Element(nil), values...) - started = time.Now() - var want curve.G1Affine - if _, err := want.MultiExp(points, values, ecc.MultiExpConfig{}); err != nil { - t.Fatal(err) - } - cpuSeconds := time.Since(started).Seconds() - before, err := a.Stats() - if err != nil { - t.Fatal(err) - } - var gpuSeconds []float64 - for iteration := range 2 { - started = time.Now() - got, err := a.MultiExp(points, values) - elapsed := time.Since(started).Seconds() - if err != nil || !got.Equal(&want) { - t.Fatalf("%s iteration %d differs from CPU: %v", kind, iteration, err) - } - if !slices.Equal(original, values) { - t.Fatal("structured MSM changed caller scalars") - } - gpuSeconds = append(gpuSeconds, elapsed) - t.Logf("kind=%s count=%d iteration=%d cpu_seconds=%.6f gpu_seconds=%.6f", kind, count, iteration, cpuSeconds, elapsed) - } - after, err := a.Stats() - if err != nil { - t.Fatal(err) - } - if config.MSMStrategy == cuda.MSMMask { - if after.MaskedMSMCalls-before.MaskedMSMCalls != 2 || after.MaskCacheHits-before.MaskCacheHits < 1 { - t.Fatal("mask comparison did not exercise mask cache") - } - } else if after.MaskedMSMCalls != before.MaskedMSMCalls { - t.Fatal("split strategy applied masking") - } - rows = append(rows, map[string]any{"kind": kind, "generationSeconds": generationSeconds, "cpuSeconds": cpuSeconds, "gpuSeconds": gpuSeconds, "before": before, "after": after, "resultSHA256": fmt.Sprintf("%x", sha256.Sum256(want.Marshal())), "matchesCPU": true, "hostScalarsUnchanged": true}) - report["rows"] = rows - if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { - t.Fatal(err) - } - } - if err := a.Close(); err != nil { - t.Fatal(err) - } - report["complete"] = true -} diff --git a/tools/plonk-wrapper/cuda_msm_replay_bn254_test.go b/tools/plonk-wrapper/cuda_msm_replay_bn254_test.go deleted file mode 100644 index 4a5b73abb..000000000 --- a/tools/plonk-wrapper/cuda_msm_replay_bn254_test.go +++ /dev/null @@ -1,149 +0,0 @@ -//go:build cuda - -package main - -import ( - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" - curve "github.com/consensys/gnark-crypto/ecc/bn254" - native "github.com/consensys/gnark/backend/plonk/bn254" -) - -func TestReplayActualInitMSMs(t *testing.T) { - if os.Getenv("IX_PLONK_CUDA_MSM_REPLAY") != "1" { - t.Skip("set IX_PLONK_CUDA_MSM_REPLAY=1 with explicit artifact and capture pins") - } - if os.Getenv("GOMAXPROCS") != "" { - t.Fatal("unset GOMAXPROCS for default CPU parallelism") - } - if err := checkRawMontgomeryABI(); err != nil { - t.Fatal(err) - } - const prefix = "IX_PLONK_CUDA_MSM_REPLAY_" - dir, out, source := msmTestRequired(t, prefix, "ARTIFACTS"), msmTestRequired(t, prefix, "OUTPUT"), msmTestRequired(t, prefix, "CAPTURES") - pin, err := cpuProfileDigest(msmTestRequired(t, prefix, "MANIFEST_SHA256")) - if err != nil { - t.Fatal(err) - } - capturePin, err := cpuProfileDigest(msmTestRequired(t, prefix, "CAPTURE_SHA256")) - if err != nil { - t.Fatal(err) - } - claim, err := cpuProfileDigest(msmTestRequired(t, prefix, "EXPECTED_CLAIM")) - if err != nil { - t.Fatal(err) - } - if err := os.Mkdir(out, 0700); err != nil { - t.Fatal(err) - } - capture, err := readCaptureManifest(filepath.Join(source, "capture.json"), capturePin) - if err != nil { - t.Fatal(err) - } - m, pk, validationSeconds, loadSeconds := authenticatedMSMKey(t, dir, pin) - if err := validateCapture(capture, m); err != nil { - t.Fatal(err) - } - if capture.ArtifactManifestSHA256 != pin || capture.ExpectedClaim != claim { - t.Fatal("capture artifact/claim binding differs") - } - config, err := provingCUDAConfig() - if err != nil { - t.Fatal(err) - } - config.Features = native.AcceleratorFeatureMSMV1 - replayBinary, err := executableHash() - if err != nil { - t.Fatal(err) - } - report := map[string]any{"format": "ix-bn254-actual-init-msm-replay-v1", "complete": false, "scope": "Authenticated captured Init MSMs, compared with captured CPU results; one fresh GPU context per vector, two invocations per context; no PLONK proof produced.", "captureSHA256": capturePin, "artifactManifestSHA256": pin, "expectedClaim": claim, "strategy": config.MSMStrategy.String(), "validationSeconds": validationSeconds, "keyLoadSeconds": loadSeconds} - report["testBinarySHA256"], report["goVersion"] = replayBinary, runtime.Version() - report["numCPU"], report["gomaxprocs"] = runtime.NumCPU(), runtime.GOMAXPROCS(0) - var rows []map[string]any - defer func() { - report["rows"] = rows - if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { - t.Error(err) - } - }() - for _, row := range capture.Records { - started := time.Now() - path := filepath.Join(source, row.ScalarsFile) - identity, err := fileHash(path) - if err != nil || identity != row.Scalars { - t.Fatalf("scalar capture %d failed authentication", row.Index) - } - values := make(rawScalarVector, int(row.Count)) - if err := cpuProfileReadBinary(path, row.Scalars, values); err != nil { - t.Fatal(err) - } - decodeSeconds := time.Since(started).Seconds() - encoded, err := unhex(row.CPUResult, 32) - if err != nil { - t.Fatal(err) - } - var expected curve.G1Affine - n, err := expected.SetBytes(encoded) - if err != nil || n != len(encoded) || !expected.IsOnCurve() || !expected.IsInSubGroup() { - t.Fatal("invalid captured CPU result") - } - started = time.Now() - a, err := cuda.Attach(pk, config) - if err != nil { - t.Fatal(err) - } - setupSeconds := time.Since(started).Seconds() - var operations []map[string]any - func() { - defer func() { - if err := a.Close(); err != nil { - t.Error(err) - } - }() - for iteration := range 2 { - before, err := a.Stats() - if err != nil { - t.Fatal(err) - } - started = time.Now() - got, err := a.MultiExp(pk.KzgLagrange.G1[row.Offset:row.Offset+row.Count], values) - elapsed := time.Since(started).Seconds() - if err != nil || !got.Equal(&expected) { - t.Fatalf("capture %d iteration %d differs from CPU: %v", row.Index, iteration, err) - } - after, err := a.Stats() - if err != nil { - t.Fatal(err) - } - if config.MSMStrategy == cuda.MSMSplit { - if after.MaskedMSMCalls != 0 || after.SplitMSMCalls-before.SplitMSMCalls != 1 { - t.Fatal("default replay did not execute the split MSM path") - } - } else if after.MaskedMSMCalls-before.MaskedMSMCalls != 1 { - t.Fatal("mask replay did not execute masked MSM") - } - operations = append(operations, map[string]any{"iteration": iteration, "seconds": elapsed, "before": before, "after": after, "matchesCPU": true}) - t.Logf("capture=%d caller=%s count=%d strategy=%s iteration=%d seconds=%.6f matches_cpu=true", row.Index, row.Caller, row.Count, config.MSMStrategy.String(), iteration, elapsed) - } - }() - if t.Failed() { - return - } - if fmt.Sprintf("%x", sha256.Sum256(rawMontgomery(values))) != row.Scalars.SHA256 { - t.Fatal("replay changed caller scalar bytes") - } - rows = append(rows, map[string]any{"capture": row.Index, "caller": row.Caller, "basis": row.Basis, "offset": row.Offset, "count": row.Count, "scalarSHA256": row.Scalars.SHA256, "cpuResultSHA256": row.CPUResultSHA256, "captureCPUSeconds": row.CPUSeconds, "scalarLoadSeconds": decodeSeconds, "gpuSetupSeconds": setupSeconds, "operations": operations}) - report["rows"] = rows - if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { - t.Fatal(err) - } - } - report["complete"] = true -} diff --git a/tools/plonk-wrapper/cuda_profile_bn254_test.go b/tools/plonk-wrapper/cuda_profile_bn254_test.go deleted file mode 100644 index dd9d44a00..000000000 --- a/tools/plonk-wrapper/cuda_profile_bn254_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "runtime/pprof" - "strconv" - "testing" - "time" - - "github.com/consensys/gnark-crypto/ecc" - "github.com/consensys/gnark/backend" - "github.com/consensys/gnark/backend/plonk" - plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" - "github.com/consensys/gnark/backend/solidity" - "github.com/consensys/gnark/backend/witness" - "github.com/consensys/gnark/constraint" - "github.com/consensys/gnark/frontend" -) - -const cpuProfileEnvPrefix = "IX_PLONK_CPU_PROFILE_" - -type cpuProfileMemory struct { - HeapAlloc, HeapSys, TotalAlloc, Sys uint64 - NumGC uint32 -} - -func cpuProfileMemorySnapshot() cpuProfileMemory { - var m runtime.MemStats - runtime.ReadMemStats(&m) - return cpuProfileMemory{m.HeapAlloc, m.HeapSys, m.TotalAlloc, m.Sys, m.NumGC} -} - -type cpuProfileRun struct { - Kind string - Index int - CPUProfiled bool - StartedUTC string - ProveSeconds, SerializeSeconds, VerifySeconds float64 - ProfileStartSeconds, ProfileStopSeconds, WallSeconds float64 - Proof, SolidityProof artifact - ProofPath, SolidityProofPath string - Before, After cpuProfileMemory -} - -type cpuProfileReport struct { - Format, Scope, Stage string - StartedUTC string - Complete bool - Artifacts, ManifestSHA256, WitnessPath string - ExpectedClaim string - SourceManifest *manifest - Witness, TestBinary, CPUProfile artifact - TestBinaryPath, CPUProfilePath, CPUProfileScope string - GoVersion string - NumCPU, GOMAXPROCS, WarmupRuns, MeasuredRuns int - BuildInfo *debug.BuildInfo - Environment map[string]string - ValidationSeconds, WitnessSetupSeconds, WallSeconds float64 - LoadSeconds map[string]float64 - Runs []cpuProfileRun -} - -func cpuProfileRequired(t *testing.T, name string) string { - t.Helper() - value := os.Getenv(cpuProfileEnvPrefix + name) - if value == "" { - t.Fatalf("%s%s must be explicitly configured", cpuProfileEnvPrefix, name) - } - return value -} - -func cpuProfileDigest(text string) (string, error) { - value, err := unhex(text, sha256.Size) - if err != nil { - return "", err - } - return hex.EncodeToString(value), nil -} - -// Test executables have their own identity. This benchmark entry point trusts -// an externally pinned manifest; production loading retains its executable pin. -func cpuProfileManifest(dir, expectedSHA256 string) (*manifest, error) { - f, err := os.Open(filepath.Join(dir, "manifest.json")) - if err != nil { - return nil, err - } - defer f.Close() - stat, err := f.Stat() - if err != nil { - return nil, err - } - if !stat.Mode().IsRegular() || stat.Size() > 1<<20 { - return nil, fmt.Errorf("profiling manifest must be a regular file of at most 1 MiB") - } - data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) - if err != nil { - return nil, err - } - if len(data) > 1<<20 || fmt.Sprintf("%x", sha256.Sum256(data)) != expectedSHA256 { - return nil, fmt.Errorf("profiling manifest differs from the explicitly trusted SHA-256") - } - var m manifest - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&m); err != nil { - return nil, err - } - if err := d.Decode(new(any)); err != io.EOF { - return nil, fmt.Errorf("trailing profiling manifest data") - } - if m.Format != artifactFormat || !m.KeysReady || m.Domain != 1<<24 || m.Constraints != 14689440 || m.PublicInputs != 2 || m.Commitments != 1 || len(m.Files) != 5 { - return nil, fmt.Errorf("profiling requires the prepared classic-GLV 2^24 BN254 wrapper") - } - for _, digest := range []string{m.ExecutableSHA256, m.InnerVKSHA256, m.SRSBLAKE3} { - if _, err := cpuProfileDigest(digest); err != nil { - return nil, fmt.Errorf("invalid profiling manifest identity: %w", err) - } - } - for _, name := range []string{"circuit.bin", "proving-key.bin", "verifying-key.bin", "vk.json", "PlonkVerifier.sol"} { - want, ok := m.Files[name] - if !ok || want.Bytes <= 0 { - return nil, fmt.Errorf("missing profiling artifact %s", name) - } - if _, err := cpuProfileDigest(want.SHA256); err != nil { - return nil, fmt.Errorf("invalid profiling artifact digest for %s", name) - } - stat, err := os.Stat(filepath.Join(dir, name)) - if err != nil { - return nil, err - } - if !stat.Mode().IsRegular() || stat.Size() != want.Bytes { - return nil, fmt.Errorf("profiling artifact %s has an invalid type or size", name) - } - if err := m.checkFile(dir, name); err != nil { - return nil, err - } - } - if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { - return nil, fmt.Errorf("verifying key identity differs from its artifact digest") - } - return &m, nil -} - -// Hash the bytes actually decoded as well as the prior authentication pass. -func cpuProfileReadBinary(path string, want artifact, value io.ReaderFrom) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - h := sha256.New() - n, err := value.ReadFrom(io.TeeReader(io.LimitReader(f, want.Bytes+1), h)) - if err != nil { - return err - } - if n != want.Bytes || hex.EncodeToString(h.Sum(nil)) != want.SHA256 { - return fmt.Errorf("decoded artifact %s differs from its pinned bytes", path) - } - var extra [1]byte - if n, err := f.Read(extra[:]); n != 0 || err != io.EOF { - return fmt.Errorf("trailing artifact data in %s", path) - } - return nil -} - -func cpuProfileProve(ccs constraint.ConstraintSystem, pk plonk.ProvingKey, w witness.Witness, profilePath string, index int) (proof plonk.Proof, seconds, startSeconds, stopSeconds float64, err error) { - var profileFile *os.File - if profilePath != "" { - started := time.Now() - profileFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - return - } - if err = pprof.StartCPUProfile(profileFile); err != nil { - profileFile.Close() - return - } - startSeconds = time.Since(started).Seconds() - defer func() { - started := time.Now() - pprof.StopCPUProfile() - if closeErr := profileFile.Close(); err == nil { - err = closeErr - } - stopSeconds = time.Since(started).Seconds() - }() - } - started := time.Now() - pprof.Do(context.Background(), pprof.Labels("workload", "bn254-plonk-2p24", "run", strconv.Itoa(index)), func(context.Context) { - proof, err = plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) - }) - seconds = time.Since(started).Seconds() - return -} - -func TestBN254CPUProfile(t *testing.T) { - if os.Getenv("IX_PLONK_CPU_PROFILE") != "1" { - t.Skip("set IX_PLONK_CPU_PROFILE=1 and explicit artifact/claim pins for full 2^24 proving") - } - if os.Getenv("GOMAXPROCS") != "" { - t.Fatal("unset GOMAXPROCS to retain the default CPU parallelism") - } - dir := cpuProfileRequired(t, "ARTIFACTS") - expectedManifest, err := cpuProfileDigest(cpuProfileRequired(t, "MANIFEST_SHA256")) - if err != nil { - t.Fatal(err) - } - expectedClaim, err := cpuProfileDigest(cpuProfileRequired(t, "EXPECTED_CLAIM")) - if err != nil { - t.Fatal(err) - } - witnessPath := cpuProfileRequired(t, "WITNESS") - out := cpuProfileRequired(t, "OUTPUT") - measured := 3 - if value := os.Getenv(cpuProfileEnvPrefix + "RUNS"); value != "" { - measured, err = strconv.Atoi(value) - if err != nil || (measured != 2 && measured != 3) { - t.Fatal("IX_PLONK_CPU_PROFILE_RUNS must be 2 or 3") - } - } - if err := os.MkdirAll(filepath.Dir(out), 0700); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(out, 0700); err != nil { - t.Fatalf("profiling output directory must be new: %v", err) - } - started := time.Now() - report := cpuProfileReport{ - Format: "ix-bn254-plonk-cpu-verifying key-v1", - Scope: "Authenticated existing 2^24 artifacts; one unmeasured warmup, followed by measured proofs in one process. CS/PK/VK/witness reused; fresh gnark randomness each proof; default CPU parallelism and natural GC. No accelerator, circuit compilation, SRS conversion or key setup.", - Stage: "artifact-validation", StartedUTC: started.UTC().Format(time.RFC3339Nano), - Artifacts: dir, ManifestSHA256: expectedManifest, WitnessPath: witnessPath, - ExpectedClaim: expectedClaim, GoVersion: runtime.Version(), - NumCPU: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), - WarmupRuns: 1, MeasuredRuns: measured, - CPUProfilePath: filepath.Join(out, "cpu.pprof"), - CPUProfileScope: "Measured run 1 only: StartCPUProfile immediately before plonk.Prove, StopCPUProfile immediately after it. Artifact/witness preparation, verification, proof serialization, warmup and other measured runs are excluded. ProveSeconds excludes profiler startup/shutdown.", - LoadSeconds: make(map[string]float64), Environment: make(map[string]string), - } - report.BuildInfo, _ = debug.ReadBuildInfo() - for _, name := range []string{"GOMAXPROCS", "GOGC", "GOMEMLIMIT", "GODEBUG", "CUDA_VISIBLE_DEVICES"} { - if value, present := os.LookupEnv(name); present { - report.Environment[name] = value - } - } - defer func() { - report.WallSeconds = time.Since(started).Seconds() - report.Complete = report.Stage == "complete" && !t.Failed() - if err := writeJSON(filepath.Join(out, "report.json"), &report); err != nil { - t.Error(err) - } - }() - report.TestBinaryPath, err = os.Executable() - if err != nil { - t.Fatal(err) - } - report.TestBinary, err = fileHash(report.TestBinaryPath) - if err != nil { - t.Fatal(err) - } - validationStarted := time.Now() - m, err := cpuProfileManifest(dir, expectedManifest) - if err != nil { - t.Fatal(err) - } - report.SourceManifest = m - report.ValidationSeconds = time.Since(validationStarted).Seconds() - report.Stage = "artifact-load" - ccs := plonk.NewCS(curveID) - pk := plonk.NewProvingKey(curveID) - vk := plonk.NewVerifyingKey(curveID) - for _, entry := range []struct { - name string - value io.ReaderFrom - }{{"circuit.bin", ccs}, {"proving-key.bin", pk}, {"verifying-key.bin", vk}} { - loadStarted := time.Now() - if err := cpuProfileReadBinary(filepath.Join(dir, entry.name), m.Files[entry.name], entry.value); err != nil { - t.Fatal(err) - } - report.LoadSeconds[entry.name] = time.Since(loadStarted).Seconds() - } - loadStarted := time.Now() - p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) - if err != nil { - t.Fatal(err) - } - if err := m.checkFile(dir, "vk.json"); err != nil { - t.Fatal(err) - } - report.LoadSeconds["vk.json"] = time.Since(loadStarted).Seconds() - if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != m.PublicInputs || ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+ccs.GetNbPublicVariables())) != m.Domain || len(ccs.GetCommitments().CommitmentIndexes()) != m.Commitments { - t.Fatal("loaded circuit metadata differs from the authenticated manifest") - } - nativePK := pk.(*plonkbn254.ProvingKey) - nativeVK := vk.(*plonkbn254.VerifyingKey) - if nativePK.Accelerator() != nil || nativePK.Vk == nil || uint64(len(nativePK.Kzg.G1)) != m.Domain+3 || uint64(len(nativePK.KzgLagrange.G1)) != m.Domain || nativeVK.Size != m.Domain || nativeVK.NbPublicVariables != 2 || len(nativeVK.Qcp) != m.Commitments { - t.Fatal("loaded key shape differs or an accelerator is attached") - } - pkVerifierHash := sha256.New() - pkVerifierBytes, err := nativePK.Vk.WriteTo(pkVerifierHash) - if err != nil { - t.Fatal(err) - } - if pkVerifierBytes != m.Files["verifying-key.bin"].Bytes || hex.EncodeToString(pkVerifierHash.Sum(nil)) != m.Files["verifying-key.bin"].SHA256 { - t.Fatal("proving key embeds a different verifying key") - } - g1, err := p.Generator.native() - if err != nil { - t.Fatal(err) - } - g2, err := g2Keys(p.OuterKeys) - if err != nil { - t.Fatal(err) - } - if !nativeVK.Kzg.G1.Equal(&g1) || !nativeVK.Kzg.G2[0].Equal(&g2[0]) || !nativeVK.Kzg.G2[1].Equal(&g2[1]) { - t.Fatal("verifying key anchors differ from the authenticated verifying key") - } - report.Stage = "witness-setup" - witnessStarted := time.Now() - stat, err := os.Stat(witnessPath) - if err != nil || !stat.Mode().IsRegular() || stat.Size() > 16<<20 { - t.Fatal("profiling witness must be a regular file of at most 16 MiB") - } - report.Witness, err = fileHash(witnessPath) - if err != nil { - t.Fatal(err) - } - assignment, err := witnessAssignment(p, witnessPath) - if err != nil { - t.Fatal(err) - } - if after, err := fileHash(witnessPath); err != nil || after != report.Witness { - t.Fatal("profiling witness changed during loading") - } - w, err := frontend.NewWitness(assignment, curveID.ScalarField()) - if err != nil { - t.Fatal(err) - } - digest, _ := hex.DecodeString(expectedClaim) - expectedPublic, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) - if err != nil { - t.Fatal(err) - } - actualPublic, err := w.Public() - if err != nil { - t.Fatal(err) - } - actualBytes, err := actualPublic.MarshalBinary() - if err != nil { - t.Fatal(err) - } - expectedBytes, err := expectedPublic.MarshalBinary() - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(actualBytes, expectedBytes) { - t.Fatal("witness claim differs from the independently expected claim") - } - report.WitnessSetupSeconds = time.Since(witnessStarted).Seconds() - seenProofs := make(map[string]bool) - for index := 0; index <= measured; index++ { - kind := "measured" - if index == 0 { - kind = "warmup" - } - report.Stage = fmt.Sprintf("%s-%d", kind, index) - t.Logf("stage=%s retained_cs_pk_vk=true accelerator=none gomaxprocs=%d", report.Stage, runtime.GOMAXPROCS(0)) - runStarted := time.Now() - row := cpuProfileRun{Kind: kind, Index: index, StartedUTC: runStarted.UTC().Format(time.RFC3339Nano), CPUProfiled: index == 1, Before: cpuProfileMemorySnapshot()} - profilePath := "" - if row.CPUProfiled { - profilePath = report.CPUProfilePath - } - proof, proveSeconds, profileStart, profileStop, err := cpuProfileProve(ccs, pk, w, profilePath, index) - if err != nil { - t.Fatal(err) - } - row.ProveSeconds, row.ProfileStartSeconds, row.ProfileStopSeconds = proveSeconds, profileStart, profileStop - serializeStarted := time.Now() - var encoded bytes.Buffer - if _, err := proof.WriteTo(&encoded); err != nil { - t.Fatal(err) - } - decoded, err := decodeProof(encoded.Bytes(), m.Commitments) - if err != nil { - t.Fatal(err) - } - solidityBytes := decoded.MarshalSolidity() - row.SerializeSeconds = time.Since(serializeStarted).Seconds() - verifyStarted := time.Now() - if err := plonk.Verify(decoded, vk, expectedPublic, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { - t.Fatal(err) - } - row.VerifySeconds = time.Since(verifyStarted).Seconds() - row.Proof = artifact{SHA256: fmt.Sprintf("%x", sha256.Sum256(encoded.Bytes())), Bytes: int64(encoded.Len())} - row.SolidityProof = artifact{SHA256: fmt.Sprintf("%x", sha256.Sum256(solidityBytes)), Bytes: int64(len(solidityBytes))} - if seenProofs[row.Proof.SHA256] { - t.Fatal("repeated proof bytes despite fresh gnark blinding") - } - seenProofs[row.Proof.SHA256] = true - row.ProofPath = filepath.Join(out, fmt.Sprintf("%s-%d.proof.bin", kind, index)) - row.SolidityProofPath = filepath.Join(out, fmt.Sprintf("%s-%d.proof.solidity.bin", kind, index)) - if err := os.WriteFile(row.ProofPath, encoded.Bytes(), 0600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(row.SolidityProofPath, solidityBytes, 0600); err != nil { - t.Fatal(err) - } - row.After = cpuProfileMemorySnapshot() - row.WallSeconds = time.Since(runStarted).Seconds() - report.Runs = append(report.Runs, row) - if row.CPUProfiled { - report.CPUProfile, err = fileHash(report.CPUProfilePath) - if err != nil { - t.Fatal(err) - } - } - if err := writeJSON(filepath.Join(out, "report.json"), &report); err != nil { - t.Fatal(err) - } - t.Logf("stage=%s prove_seconds=%.6f verify_seconds=%.6f cpu_profiled=%t proof_sha256=%s", report.Stage, row.ProveSeconds, row.VerifySeconds, row.CPUProfiled, row.Proof.SHA256) - } - report.Stage = "complete" -} diff --git a/tools/plonk-wrapper/internal/cuda/abi.go b/tools/plonk-wrapper/internal/cuda/abi.go index 91af770c1..b54805581 100644 --- a/tools/plonk-wrapper/internal/cuda/abi.go +++ b/tools/plonk-wrapper/internal/cuda/abi.go @@ -86,8 +86,6 @@ func getNativeParameters(logSize int) (nativeParameters, error) { func createNativeContext(config Config) (*nativeContext, error) { c := C.ix_plonk_bn254_config{ - abi_version: C.IX_PLONK_BN254_ABI_VERSION, - struct_bytes: C.uint32_t(C.sizeof_ix_plonk_bn254_config), device: C.int32_t(config.Device), msm_strategy: C.uint32_t(config.MSMStrategy), budget_bytes: C.uint64_t(config.MemoryBytes), @@ -196,13 +194,9 @@ func (c *nativeContext) putQuotient(handle quotientHandle, slot int, values []fr return nativeError("quotient polynomial upload", status, &detail) } -func (c *nativeContext) finishQuotient(handle quotientHandle, fused bool, values []fr.Element) error { - var flag C.uint32_t - if fused { - flag = 1 - } +func (c *nativeContext) finishQuotient(handle quotientHandle, values []fr.Element) error { var detail C.ix_plonk_bn254_error - status := C.ix_plonk_bn254_quotient_finish(c.pointer, C.ix_plonk_bn254_quotient(handle), flag, + status := C.ix_plonk_bn254_quotient_finish(c.pointer, C.ix_plonk_bn254_quotient(handle), (*C.ix_plonk_bn254_fr)(unsafe.Pointer(&values[0])), C.uint64_t(len(values)), &detail) runtime.KeepAlive(values) return nativeError("quotient computation", status, &detail) @@ -222,7 +216,7 @@ func (c *nativeContext) stats() (Stats, error) { PoolUsedBytes: uint64(native.pool_used_bytes), PoolUsedHighBytes: uint64(native.pool_used_high_bytes), PoolReservedBytes: uint64(native.pool_reserved_bytes), PoolReservedHighBytes: uint64(native.pool_reserved_high_bytes), AccountedBytes: uint64(native.accounted_bytes), AccountedHighBytes: uint64(native.accounted_high_bytes), BudgetBytes: uint64(native.budget_bytes), - MSMCalls: uint64(native.msm_calls), FFTCalls: uint64(native.fft_calls), NumeratorCalls: uint64(native.numerator_calls), QuotientCalls: uint64(native.quotient_calls), + MSMCalls: uint64(native.msm_calls), FFTCalls: uint64(native.fft_calls), NumeratorCalls: uint64(native.numerator_calls), UploadBytes: uint64(native.upload_bytes), DownloadBytes: uint64(native.download_bytes), UploadCalls: uint64(native.upload_calls), DownloadCalls: uint64(native.download_calls), UploadWallMS: float64(native.upload_wall_ms), DownloadWallMS: float64(native.download_wall_ms), diff --git a/tools/plonk-wrapper/internal/cuda/accelerator.go b/tools/plonk-wrapper/internal/cuda/accelerator.go index b555b73b3..03b24789a 100644 --- a/tools/plonk-wrapper/internal/cuda/accelerator.go +++ b/tools/plonk-wrapper/internal/cuda/accelerator.go @@ -22,8 +22,6 @@ import ( native "github.com/consensys/gnark/backend/plonk/bn254" ) -const AllFeatures = native.AcceleratorFeatureMSMV1 | native.AcceleratorFeatureToCanonicalV1 | native.AcceleratorFeatureNumeratorV1 | native.AcceleratorFeatureQuotientV1 - type MSMStrategy uint32 const ( @@ -42,14 +40,13 @@ type Config struct { MSMStrategy MSMStrategy Device int MemoryBytes, HeadroomBytes uint64 - Features native.AcceleratorFeatureFlagsV1 } type Stats struct { DriverFreeBytes, DriverTotalBytes uint64 PoolUsedBytes, PoolUsedHighBytes, PoolReservedBytes, PoolReservedHighBytes uint64 AccountedBytes, AccountedHighBytes, BudgetBytes uint64 - MSMCalls, FFTCalls, NumeratorCalls, QuotientCalls uint64 + MSMCalls, FFTCalls, NumeratorCalls uint64 UploadBytes, DownloadBytes uint64 UploadCalls, DownloadCalls uint64 // Wall times include waiting for queued predecessor GPU work. @@ -58,7 +55,7 @@ type Stats struct { TransferTimingEnabled bool SmallCPUCalls, CPUTailPoints uint64 MSMCallbackSeconds, CanonicalCallbackSeconds float64 - NumeratorCallbackSeconds, QuotientCallbackSeconds float64 + NumeratorCallbackSeconds float64 QueueWaitSeconds float64 MaskedMSMCalls, MaskSumCalls, MaskCacheHits uint64 SplitMSMCalls, SplitUnitPoints uint64 @@ -77,27 +74,26 @@ type registeredBasis struct { // Accelerator serializes mutable native workspaces. The proving key keeps both // registered host arrays alive until every borrowed native call has returned. type Accelerator struct { - mu sync.Mutex - pk *native.ProvingKey - context *nativeContext - bases [2]registeredBasis - features native.AcceleratorFeatureFlagsV1 - domain uint64 - parameters [27]nativeParameters - closed bool - closeErr error - finalStats Stats - smallCPUCalls, cpuTailPoints uint64 - msmSeconds, canonicalSeconds, numeratorSeconds, quotientSeconds float64 - queueWaitSeconds float64 - trace bool - strategy MSMStrategy + mu sync.Mutex + pk *native.ProvingKey + context *nativeContext + bases [2]registeredBasis + domain uint64 + parameters [27]nativeParameters + closed bool + closeErr error + finalStats Stats + smallCPUCalls, cpuTailPoints uint64 + msmSeconds, canonicalSeconds, numeratorSeconds float64 + queueWaitSeconds float64 + trace bool + strategy MSMStrategy } var _ native.Accelerator = (*Accelerator)(nil) -var _ native.AcceleratorFeaturesV1 = (*Accelerator)(nil) -var _ native.QuotientAcceleratorV1 = (*Accelerator)(nil) +// Attach installs the GPU implementation. The caller must keep the attachment +// unchanged until every proof using pk has returned. func Attach(pk *native.ProvingKey, config Config) (*Accelerator, error) { if pk == nil || pk.Vk == nil || pk.Accelerator() != nil { return nil, fmt.Errorf("CUDA PLONK needs an unattached proving key") @@ -106,8 +102,8 @@ func Attach(pk *native.ProvingKey, config Config) (*Accelerator, error) { if n < 2 || n > 1<<24 || n&(n-1) != 0 || uint64(len(pk.Kzg.G1)) != n+3 || uint64(len(pk.KzgLagrange.G1)) != n { return nil, fmt.Errorf("CUDA PLONK requires N<=2^24 and exact N+3/N proving bases") } - if config.Device < 0 || config.Device > math.MaxInt32 || config.Features & ^AllFeatures != 0 || config.MSMStrategy > MSMMask { - return nil, fmt.Errorf("invalid CUDA PLONK device or features") + if config.Device < 0 || config.Device > math.MaxInt32 || config.MSMStrategy > MSMMask { + return nil, fmt.Errorf("invalid CUDA PLONK device or MSM strategy") } if config.MemoryBytes == 0 { config.MemoryBytes = 64 << 30 @@ -120,7 +116,7 @@ func Attach(pk *native.ProvingKey, config Config) (*Accelerator, error) { if err := checkFieldABI(); err != nil { return nil, err } - a := &Accelerator{pk: pk, features: config.Features, domain: n, trace: os.Getenv("IX_PLONK_CUDA_TRACE") == "1", strategy: config.MSMStrategy} + a := &Accelerator{pk: pk, domain: n, trace: os.Getenv("IX_PLONK_CUDA_TRACE") == "1", strategy: config.MSMStrategy} for logSize := range a.parameters { parameters, err := getNativeParameters(logSize) if err != nil { @@ -159,8 +155,6 @@ func Attach(pk *native.ProvingKey, config Config) (*Accelerator, error) { return a, nil } -func (a *Accelerator) FeaturesV1() native.AcceleratorFeatureFlagsV1 { return a.features } - func (a *Accelerator) withContext(fn func(*nativeContext) error) error { queued := time.Now() a.mu.Lock() @@ -179,7 +173,7 @@ func (a *Accelerator) withContext(fn func(*nativeContext) error) error { func (a *Accelerator) hostStats(stats Stats) Stats { stats.SmallCPUCalls, stats.CPUTailPoints = a.smallCPUCalls, a.cpuTailPoints stats.MSMCallbackSeconds, stats.CanonicalCallbackSeconds = a.msmSeconds, a.canonicalSeconds - stats.NumeratorCallbackSeconds, stats.QuotientCallbackSeconds = a.numeratorSeconds, a.quotientSeconds + stats.NumeratorCallbackSeconds = a.numeratorSeconds stats.QueueWaitSeconds = a.queueWaitSeconds return stats } @@ -395,7 +389,7 @@ func (a *Accelerator) numeratorConfig(in *native.NumeratorInput) (quotientConfig return config, nil } -func (a *Accelerator) compute(in *native.NumeratorInput, fused bool) (*iop.Polynomial, error) { +func (a *Accelerator) ComputeNumerator(in *native.NumeratorInput) (*iop.Polynomial, error) { config, err := a.numeratorConfig(in) if err != nil { return nil, err @@ -404,23 +398,15 @@ func (a *Accelerator) compute(in *native.NumeratorInput, fused bool) (*iop.Polyn err = a.withContext(func(context *nativeContext) (err error) { transfers := a.startTransferTrace(context) started := time.Now() - operation := "numerator" - if fused { - operation = "quotient" - } fields := map[string]any{"n": config.n, "m": config.m, "commitments": config.commitments} - a.traceEvent("start", operation, fields) + a.traceEvent("start", "numerator", fields) defer func() { elapsed := time.Since(started).Seconds() fields["elapsed_seconds"] = elapsed fields["failed"] = err != nil - if fused { - a.quotientSeconds += elapsed - } else { - a.numeratorSeconds += elapsed - } + a.numeratorSeconds += elapsed transfers.finish(context, fields) - a.traceEvent("end", operation, fields) + a.traceEvent("end", "numerator", fields) }() handle, err := context.beginQuotient(config) if err != nil { @@ -434,21 +420,11 @@ func (a *Accelerator) compute(in *native.NumeratorInput, fused bool) (*iop.Polyn polynomial.Basis, polynomial.Layout = iop.Canonical, iop.Regular } output = make([]fr.Element, int(config.m)) - return context.finishQuotient(handle, fused, output) + return context.finishQuotient(handle, output) }) if err != nil { return nil, err } form := iop.Form{Basis: iop.LagrangeCoset, Layout: iop.BitReverse} - if fused { - form = iop.Form{Basis: iop.Canonical, Layout: iop.Regular} - } return iop.NewPolynomial(&output, form), nil } - -func (a *Accelerator) ComputeNumerator(in *native.NumeratorInput) (*iop.Polynomial, error) { - return a.compute(in, false) -} -func (a *Accelerator) ComputeQuotientV1(in *native.NumeratorInput) (*iop.Polynomial, error) { - return a.compute(in, true) -} diff --git a/tools/plonk-wrapper/internal/cuda/accelerator_test.go b/tools/plonk-wrapper/internal/cuda/accelerator_test.go index 4e10471dc..2cc393c9c 100644 --- a/tools/plonk-wrapper/internal/cuda/accelerator_test.go +++ b/tools/plonk-wrapper/internal/cuda/accelerator_test.go @@ -25,12 +25,12 @@ import ( "github.com/consensys/gnark/frontend/cs/scs" ) -func gpuConfig(t *testing.T, features native.AcceleratorFeatureFlagsV1) Config { +func gpuConfig(t *testing.T) Config { t.Helper() if os.Getenv("IX_PLONK_CUDA_TEST") != "1" { t.Skip("set IX_PLONK_CUDA_TEST=1 for bounded native CUDA tests") } - config := Config{MemoryBytes: 8 << 30, HeadroomBytes: 8 << 30, Features: features} + config := Config{MemoryBytes: 8 << 30, HeadroomBytes: 8 << 30} if value := os.Getenv("IX_PLONK_CUDA_DEVICE"); value != "" { device, err := strconv.Atoi(value) if err != nil { @@ -72,9 +72,9 @@ func kernelKey(t *testing.T, n int) *native.ProvingKey { return &native.ProvingKey{Kzg: srs.Pk, KzgLagrange: kzg.ProvingKey{G1: lagrange}, Vk: &native.VerifyingKey{Size: uint64(n), Kzg: srs.Vk}} } -func attachForTest(t *testing.T, pk *native.ProvingKey, features native.AcceleratorFeatureFlagsV1) *Accelerator { +func attachForTest(t *testing.T, pk *native.ProvingKey) *Accelerator { t.Helper() - accelerator, err := Attach(pk, gpuConfig(t, features)) + accelerator, err := Attach(pk, gpuConfig(t)) if err != nil { t.Fatal(err) } @@ -87,9 +87,9 @@ func attachForTest(t *testing.T, pk *native.ProvingKey, features native.Accelera } func TestCUDAMSMRangesTailsConcurrencyAndLifecycle(t *testing.T) { - gpuConfig(t, AllFeatures) + gpuConfig(t) pk := kernelKey(t, 64) - a := attachForTest(t, pk, AllFeatures) + a := attachForTest(t, pk) for _, basis := range [][]curve.G1Affine{pk.Kzg.G1, pk.KzgLagrange.G1} { for _, span := range [][2]int{{0, len(basis)}, {2, 17}, {len(basis) - 4, 4}, {len(basis) - 3, 3}, {0, 0}} { points := basis[span[0] : span[0]+span[1]] @@ -182,8 +182,8 @@ func TestCUDAMSMRangesTailsConcurrencyAndLifecycle(t *testing.T) { } func TestCUDAFFTCanonicalFormsAndRoots(t *testing.T) { - gpuConfig(t, AllFeatures) - a := attachForTest(t, kernelKey(t, 64), AllFeatures) + gpuConfig(t) + a := attachForTest(t, kernelKey(t, 64)) for _, n := range []int{2, 8, 64, 512, 4096} { domain := fft.NewDomain(uint64(n)) coefficients := make([]fr.Element, n) @@ -336,73 +336,51 @@ func numeratorOracle(in *native.NumeratorInput, canonical [][]fr.Element) []fr.E return result } -func TestCUDANumeratorAndFusedQuotientAgainstDirectOracle(t *testing.T) { - gpuConfig(t, AllFeatures) +func TestCUDANumeratorAgainstDirectOracle(t *testing.T) { + gpuConfig(t) for _, shape := range [][3]int{{2, 8, 0}, {4, 8, 1}, {8, 4, 0}, {16, 4, 1}, {32, 4, 2}} { n, rho, commitments := shape[0], shape[1], shape[2] t.Run(fmt.Sprint(shape), func(t *testing.T) { - a := attachForTest(t, kernelKey(t, n), AllFeatures) - for _, fused := range []bool{false, true} { - in, canonical := numeratorFixture(n, rho, commitments) - blinds := [][]fr.Element{append([]fr.Element(nil), in.Bl.Coefficients()...), append([]fr.Element(nil), in.Br.Coefficients()...), append([]fr.Element(nil), in.Bo.Coefficients()...), append([]fr.Element(nil), in.Bz.Coefficients()...)} - want := numeratorOracle(in, canonical) - var got *iop.Polynomial - var err error - if fused { - x := in.Domain1.FrMultiplicativeGen - one := fr.One() - for i := range want { - var zh fr.Element - zh.Exp(x, new(big.Int).SetUint64(uint64(n))).Sub(&zh, &one) - want[i].Div(&want[i], &zh) - x.Mul(&x, &in.Domain1.Generator) - } - p := iop.NewPolynomial(&want, iop.Form{Basis: iop.LagrangeCoset, Layout: iop.Regular}).ToCanonical(in.Domain1).ToRegular() - want = p.Coefficients() - got, err = a.ComputeQuotientV1(in) - } else { - fft.BitReverse(want) - got, err = a.ComputeNumerator(in) - } - if err != nil { - t.Fatal(err) - } - if !slices.Equal(got.Coefficients(), want) { - t.Fatalf("fused=%t numerator/quotient differs", fused) - } - form := iop.Form{Basis: iop.LagrangeCoset, Layout: iop.BitReverse} - if fused { - form = iop.Form{Basis: iop.Canonical, Layout: iop.Regular} - } - if got.Form != form { - t.Fatalf("false result form: %+v", got.Form) - } - for i, p := range numeratorPolynomials(in) { - if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), canonical[i]) { - t.Fatalf("input %d not restored", i) - } + a := attachForTest(t, kernelKey(t, n)) + in, canonical := numeratorFixture(n, rho, commitments) + blinds := [][]fr.Element{append([]fr.Element(nil), in.Bl.Coefficients()...), append([]fr.Element(nil), in.Br.Coefficients()...), append([]fr.Element(nil), in.Bo.Coefficients()...), append([]fr.Element(nil), in.Bz.Coefficients()...)} + want := numeratorOracle(in, canonical) + fft.BitReverse(want) + got, err := a.ComputeNumerator(in) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got.Coefficients(), want) { + t.Fatal("numerator differs from direct oracle") + } + if got.Form != (iop.Form{Basis: iop.LagrangeCoset, Layout: iop.BitReverse}) { + t.Fatalf("false result form: %+v", got.Form) + } + for i, p := range numeratorPolynomials(in) { + if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), canonical[i]) { + t.Fatalf("input %d not restored", i) } - for i, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { - if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), blinds[i]) { - t.Fatalf("blinding polynomial %d changed", i) - } + } + for i, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { + if p.Basis != iop.Canonical || p.Layout != iop.Regular || !slices.Equal(p.Coefficients(), blinds[i]) { + t.Fatalf("blinding polynomial %d changed", i) } } stats, err := a.Stats() if err != nil { t.Fatal(err) } - if stats.NumeratorCalls != 1 || stats.QuotientCalls != 1 { - t.Fatalf("wrong quotient dispatch: %+v", stats) + if stats.NumeratorCalls != 1 { + t.Fatalf("wrong numerator dispatch: %+v", stats) } }) } } func TestCUDARejectsMalformedInputBeforeMutation(t *testing.T) { - config := gpuConfig(t, AllFeatures) + config := gpuConfig(t) pk := kernelKey(t, 16) - a := attachForTest(t, pk, AllFeatures) + a := attachForTest(t, pk) before, err := a.Stats() if err != nil { t.Fatal(err) @@ -420,7 +398,7 @@ func TestCUDARejectsMalformedInputBeforeMutation(t *testing.T) { in.Domain1 = &domainCopy mutate(in) original := append([]fr.Element(nil), in.L.Coefficients()...) - if _, err := a.ComputeQuotientV1(in); err == nil { + if _, err := a.ComputeNumerator(in); err == nil { t.Fatal("accepted malformed numerator input") } if !slices.Equal(original, in.L.Coefficients()) { @@ -431,7 +409,7 @@ func TestCUDARejectsMalformedInputBeforeMutation(t *testing.T) { if err != nil { t.Fatal(err) } - if after.MSMCalls != before.MSMCalls || after.FFTCalls != before.FFTCalls || after.NumeratorCalls != before.NumeratorCalls || after.QuotientCalls != before.QuotientCalls { + if after.MSMCalls != before.MSMCalls || after.FFTCalls != before.FFTCalls || after.NumeratorCalls != before.NumeratorCalls { t.Fatal("malformed input reached a native computation") } if err := a.Close(); err != nil { @@ -465,7 +443,7 @@ func (c *proofCircuit) Define(api frontend.API) error { } func TestCUDAShiftedPLONKProofsVerifyOnCPU(t *testing.T) { - gpuConfig(t, AllFeatures) + gpuConfig(t) for commitments := range 3 { ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &proofCircuit{Commitments: commitments}) if err != nil { @@ -508,39 +486,26 @@ func TestCUDAShiftedPLONKProofsVerifyOnCPU(t *testing.T) { if err := plonk.Verify(cpu, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { t.Fatal(err) } - for _, features := range []native.AcceleratorFeatureFlagsV1{native.AcceleratorFeatureMSMV1, AllFeatures &^ native.AcceleratorFeatureQuotientV1, AllFeatures} { - t.Run(fmt.Sprintf("commitments%d-zk%t-features%d", commitments, zk, features), func(t *testing.T) { - a := attachForTest(t, pk.(*native.ProvingKey), features) - proof, err := plonk.Prove(ccs, pk, w, opts...) - if err != nil { - t.Fatal(err) - } - if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { - t.Fatal(err) - } - if err := plonk.Verify(proof, vk, wrong, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err == nil { - t.Fatal("accepted wrong expected public inputs") - } - stats, err := a.Stats() - if err != nil { - t.Fatal(err) - } - if stats.MSMCalls == 0 { - t.Fatal("proof dispatched no CUDA MSMs") - } - if features&native.AcceleratorFeatureQuotientV1 != 0 { - if stats.QuotientCalls != 1 || stats.NumeratorCalls != 0 { - t.Fatalf("fused proof dispatched incorrectly: %+v", stats) - } - } else if features&native.AcceleratorFeatureNumeratorV1 != 0 { - if stats.NumeratorCalls != 1 || stats.QuotientCalls != 0 { - t.Fatalf("numerator proof dispatched incorrectly: %+v", stats) - } - } else if stats.NumeratorCalls != 0 || stats.QuotientCalls != 0 || stats.FFTCalls != 0 { - t.Fatalf("MSM-only proof dispatched extra operations: %+v", stats) - } - }) - } + t.Run(fmt.Sprintf("commitments%d-zk%t", commitments, zk), func(t *testing.T) { + a := attachForTest(t, pk.(*native.ProvingKey)) + proof, err := plonk.Prove(ccs, pk, w, opts...) + if err != nil { + t.Fatal(err) + } + if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + t.Fatal(err) + } + if err := plonk.Verify(proof, vk, wrong, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err == nil { + t.Fatal("accepted wrong expected public inputs") + } + stats, err := a.Stats() + if err != nil { + t.Fatal(err) + } + if stats.MSMCalls == 0 || stats.FFTCalls == 0 || stats.NumeratorCalls != 1 { + t.Fatalf("proof dispatched incorrectly: %+v", stats) + } + }) } } } diff --git a/tools/plonk-wrapper/internal/cuda/masking_test.go b/tools/plonk-wrapper/internal/cuda/masking_test.go index 5cffc683f..b41dc8a91 100644 --- a/tools/plonk-wrapper/internal/cuda/masking_test.go +++ b/tools/plonk-wrapper/internal/cuda/masking_test.go @@ -14,7 +14,7 @@ import ( ) func TestCUDAMaskedMSMTransparencyAndRangeCache(t *testing.T) { - config := gpuConfig(t, AllFeatures) + config := gpuConfig(t) config.MSMStrategy = MSMMask pk := kernelKey(t, 8192) a, err := Attach(pk, config) diff --git a/tools/plonk-wrapper/internal/cuda/split_boundaries_test.go b/tools/plonk-wrapper/internal/cuda/split_boundaries_test.go index 434ce0eb2..fbba5ee7d 100644 --- a/tools/plonk-wrapper/internal/cuda/split_boundaries_test.go +++ b/tools/plonk-wrapper/internal/cuda/split_boundaries_test.go @@ -71,7 +71,7 @@ func checkSplitMSM(t *testing.T, accelerator *Accelerator, points []curve.G1Affi } func TestCUDASplitBucketBoundariesAndSignedDigits(t *testing.T) { - config := gpuConfig(t, AllFeatures) + config := gpuConfig(t) if config.MSMStrategy != MSMSplit { t.Fatal("default CUDA MSM policy is not split-bucket accumulation") } diff --git a/tools/plonk-wrapper/internal/cuda/structured_test.go b/tools/plonk-wrapper/internal/cuda/structured_test.go index b58a83cdb..0b43d9f93 100644 --- a/tools/plonk-wrapper/internal/cuda/structured_test.go +++ b/tools/plonk-wrapper/internal/cuda/structured_test.go @@ -54,12 +54,12 @@ func structuredScalars(count int, kind string) []fr.Element { } func TestCUDAStructuredMSMMatchesRegisteredArrays(t *testing.T) { - gpuConfig(t, AllFeatures) + gpuConfig(t) pk := kernelKey(t, 128) // The generic MSM contract applies to the registered arrays independently of // whether their labels satisfy a proving-key basis relation. pk.KzgLagrange.G1[0].Neg(&pk.KzgLagrange.G1[0]) - a := attachForTest(t, pk, AllFeatures) + a := attachForTest(t, pk) ranges := [][]curve.G1Affine{pk.KzgLagrange.G1, pk.Kzg.G1, pk.KzgLagrange.G1[:64], pk.KzgLagrange.G1[1:65], pk.KzgLagrange.G1[:96]} for _, points := range ranges { for _, kind := range []string{"shifted-binary", "signed-zero", "three-hot"} { diff --git a/tools/plonk-wrapper/lagrange_import_bn254.go b/tools/plonk-wrapper/lagrange_import_bn254.go deleted file mode 100644 index f137b5b5d..000000000 --- a/tools/plonk-wrapper/lagrange_import_bn254.go +++ /dev/null @@ -1,235 +0,0 @@ -package main - -import ( - "bytes" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "fmt" - "io" - "math/big" - "os" - "path/filepath" - "time" - - bn "github.com/consensys/gnark-crypto/ecc/bn254" - "github.com/consensys/gnark-crypto/ecc/bn254/fr" - "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" - "github.com/consensys/gnark-crypto/ecc/bn254/kzg" - "github.com/zeebo/blake3" -) - -// readSP1Lagrange accepts the legacy gnark BN254 key layout used by SP1 -// v6.1.0. Circuit-specific key data is discarded. Vector lengths are bounded -// before invoking gnark's decoder, and only compressed G1 arrays are accepted. -func readSP1Lagrange(path string, domain uint64, srs *nativeSRS) ([]nativeG1, [32]byte, error) { - var digest [32]byte - f, err := os.Open(path) - if err != nil { - return nil, digest, err - } - defer f.Close() - if stat, err := f.Stat(); err != nil || !stat.Mode().IsRegular() { - return nil, digest, fmt.Errorf("candidate must be a regular file") - } - if domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 { - return nil, digest, fmt.Errorf("invalid candidate domain") - } - header := make([]byte, 112+8*32+4) - if _, err := io.ReadFull(f, header); err != nil { - return nil, digest, err - } - if binary.BigEndian.Uint64(header[:8]) != domain || binary.BigEndian.Uint64(header[72:80]) > 64 { - return nil, digest, fmt.Errorf("SP1 key domain or public count differs") - } - commitments := binary.BigEndian.Uint32(header[len(header)-4:]) - if commitments > 64 { - return nil, digest, fmt.Errorf("oversized SP1 commitment count") - } - for i := 0; i < 8; i++ { - if !isCompressedPoint(header[112+32*i]) { - return nil, digest, fmt.Errorf("uncompressed SP1 verifier point") - } - } - // The fixed-G2 line table contains 2*2*66 lines with two Fp2 coefficients. - tail := make([]byte, int(commitments)*32+32+2*64+2*2*66*2*2*32+4) - if _, err := io.ReadFull(f, tail); err != nil { - return nil, digest, err - } - for i := uint32(0); i <= commitments; i++ { - if !isCompressedPoint(tail[32*i]) { - return nil, digest, fmt.Errorf("uncompressed SP1 commitment or generator") - } - } - for i := 0; i < 2; i++ { - if !isCompressedPoint(tail[int(commitments)*32+32+64*i]) { - return nil, digest, fmt.Errorf("uncompressed SP1 G2 anchor") - } - } - indexes := binary.BigEndian.Uint32(tail[len(tail)-4:]) - if indexes != commitments { - return nil, digest, fmt.Errorf("SP1 commitment index count differs") - } - header = append(header, tail...) - last := make([]byte, int(indexes)*8) - if _, err := io.ReadFull(f, last); err != nil { - return nil, digest, err - } - header = append(header, last...) - vk := new(nativeVerifyingKey) - r := bytes.NewReader(header) - if _, err := vk.ReadFrom(r); err != nil { - return nil, digest, err - } - if r.Len() != 0 || !bytes.Equal(encodeAnchors(vk), encodeAnchors(&nativeVerifyingKey{Kzg: srs.Vk})) { - return nil, digest, fmt.Errorf("SP1 setup anchors differ from admitted SRS") - } - d := fft.NewDomain(domain, fft.WithoutPrecompute()) - if !vk.Generator.Equal(&d.Generator) || !vk.SizeInv.Equal(&d.CardinalityInv) { - return nil, digest, fmt.Errorf("SP1 evaluation domain differs") - } - var count [4]byte - if _, err := io.ReadFull(f, count[:]); err != nil { - return nil, digest, err - } - if uint64(binary.BigEndian.Uint32(count[:])) != domain+3 { - return nil, digest, fmt.Errorf("SP1 monomial length differs") - } - offset, err := f.Seek(int64(32*(domain+3)), io.SeekCurrent) - if err != nil { - return nil, digest, err - } - stat, err := f.Stat() - if err != nil { - return nil, digest, err - } - if stat.Size() != offset+4+int64(32*domain) { - return nil, digest, fmt.Errorf("SP1 key length differs") - } - encoded := make([]byte, 4+32*domain) - if _, err := io.ReadFull(f, encoded); err != nil { - return nil, digest, err - } - if uint64(binary.BigEndian.Uint32(encoded[:4])) != domain { - return nil, digest, fmt.Errorf("SP1 Lagrange count differs") - } - for i := uint64(0); i < domain; i++ { - if !isCompressedPoint(encoded[4+32*i]) { - return nil, digest, fmt.Errorf("uncompressed SP1 Lagrange point") - } - } - digest = sha256.Sum256(encoded) - var points []nativeG1 - if err := bn.NewDecoder(bytes.NewReader(encoded)).Decode(&points); err != nil { - return nil, digest, err - } - for i := range points { - canonical := points[i].Bytes() - if !bytes.Equal(canonical[:], encoded[4+32*i:4+32*(i+1)]) { - return nil, digest, fmt.Errorf("noncanonical Lagrange point") - } - } - return points, digest, nil -} - -// A nonzero difference between two N-point prime-order bases is a group -// polynomial of degree below N. A challenge sampled after binding every -// candidate point detects it except with probability at most (N-1)/Fr. -// Interpolating the challenge powers uses scalar FFTs, avoiding a group FFT. -func validateLagrange(srs *nativeSRS, points []nativeG1, setup, receipt string, digest [32]byte) (string, error) { - n := len(points) - if n < 2 || n&(n-1) != 0 || len(srs.Pk.G1) < n { - return "", fmt.Errorf("invalid Lagrange validation dimensions") - } - h := blake3.New() - h.Write([]byte("ix/bn254/plonk/lagrange-validation/v1")) - for _, text := range []string{setup, receipt} { - value, err := unhex(text, 32) - if err != nil { - return "", err - } - h.Write(value) - } - var length [8]byte - binary.LittleEndian.PutUint64(length[:], uint64(n)) - h.Write(length[:]) - h.Write(digest[:]) - var wide [64]byte - if _, err := io.ReadFull(h.Digest(), wide[:]); err != nil { - return "", err - } - var challenge, order fr.Element - challenge.SetBytes(wide[:]) - order.Exp(challenge, new(big.Int).SetUint64(uint64(n))) - if challenge.IsZero() || order.IsOne() { - return "", fmt.Errorf("degenerate validation challenge") - } - powers := make([]fr.Element, n) - powers[0].SetOne() - for i := 1; i < n; i++ { - powers[i].Mul(&powers[i-1], &challenge) - } - coefficients := append([]fr.Element(nil), powers...) - domain := fft.NewDomain(uint64(n)) - domain.FFTInverse(coefficients, fft.DIF) - fft.BitReverse(coefficients) - expected, err := kzg.Commit(coefficients, srs.Pk) - if err != nil { - return "", err - } - actual, err := kzg.Commit(powers, nativeKzgProvingKey{G1: points}) - if err != nil { - return "", err - } - if !actual.Equal(&expected) { - return "", fmt.Errorf("candidate Lagrange basis does not match authenticated monomial SRS") - } - value := challenge.Bytes() - return hex.EncodeToString(value[:]), nil -} - -func importLagrangeSP1(artifacts, monomial, receipt, candidate string) error { - started := time.Now() - m, err := loadManifest(artifacts) - if err != nil { - return err - } - p, err := loadVerifyingKey(filepath.Join(artifacts, "vk.json")) - if err != nil { - return err - } - srs, err := readSRS(monomial, receipt, m.Domain, p) - if err != nil { - return err - } - points, digest, err := readSP1Lagrange(candidate, m.Domain, srs) - if err != nil { - return err - } - challenge, err := validateLagrange(srs, points, p.SetupID, receipt, digest) - if err != nil { - return err - } - root := os.Getenv("IX_PLONK_LAGRANGE_CACHE") - if root == "" { - root = filepath.Join(filepath.Dir(artifacts), "lagrange") - } - want, dir, err := lagrangeLocation(root, m.Domain, p.SetupID, receipt, m.ExecutableSHA256) - if err != nil { - return err - } - if _, err := os.Stat(filepath.Join(dir, "manifest.json")); !os.IsNotExist(err) { - return fmt.Errorf("Lagrange cache already exists or cannot be inspected") - } - validation := map[string]any{"format": "ix-bn254-lagrange-validation-v1", "domain": m.Domain, - "setupID": p.SetupID, "monomialBLAKE3": receipt, "candidateVectorSHA256": hex.EncodeToString(digest[:]), - "challenge": challenge, "basisRelationVerified": true, "elapsedSeconds": time.Since(started).Seconds()} - if err := writeJSON(filepath.Join(dir, "validation.json"), validation); err != nil { - return err - } - if err := publishLagrange(dir, want, points); err != nil { - return err - } - fmt.Printf("validated_lagrange_import=true domain=%d elapsed_seconds=%.3f\n", m.Domain, time.Since(started).Seconds()) - return nil -} diff --git a/tools/plonk-wrapper/lagrange_import_bn254_test.go b/tools/plonk-wrapper/lagrange_import_bn254_test.go deleted file mode 100644 index 0dc78affd..000000000 --- a/tools/plonk-wrapper/lagrange_import_bn254_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "bytes" - "crypto/sha256" - "encoding/binary" - "os" - "path/filepath" - "testing" - - bn "github.com/consensys/gnark-crypto/ecc/bn254" - "github.com/consensys/gnark-crypto/ecc/bn254/kzg" - "github.com/consensys/gnark/backend/plonk" - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/frontend/cs/scs" -) - -func importedFixture(t *testing.T) (*verifyingKey, *nativeSRS, string) { - t.Helper() - p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) - if err != nil { - t.Fatal(err) - } - var receipt struct { - Format, Blake3, SetupID, IgnitionManifest string - PublicMaxDegree, Powers uint64 - } - if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { - t.Fatal(err) - } - srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128, p) - if err != nil { - t.Fatal(err) - } - return p, srs, receipt.Blake3 -} - -func candidateDigest(points []nativeG1) [32]byte { - var out bytes.Buffer - if err := bn.NewEncoder(&out).Encode(points); err != nil { - panic(err) - } - return sha256.Sum256(out.Bytes()) -} - -func TestImportedLagrangeBasisRejectsAlteredAndWrongDomainPoints(t *testing.T) { - p, srs, receipt := importedFixture(t) - points, err := toLagrange(srs.Pk.G1[:8]) - if err != nil { - t.Fatal(err) - } - if _, err := validateLagrange(srs, points, p.SetupID, receipt, candidateDigest(points)); err != nil { - t.Fatal(err) - } - for _, kind := range []string{"changed", "permuted", "truncated"} { - bad := append([]nativeG1(nil), points...) - switch kind { - case "changed": - bad[0].Add(&bad[0], &srs.Pk.G1[0]) - case "permuted": - bad[0], bad[1] = bad[1], bad[0] - case "truncated": - bad = bad[:4] - } - if _, err := validateLagrange(srs, bad, p.SetupID, receipt, candidateDigest(bad)); err == nil { - t.Fatalf("accepted %s basis", kind) - } - } -} - -func TestLegacySP1ReaderBoundsAndAnchors(t *testing.T) { - _, srs, _ := importedFixture(t) - ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: true}) - if err != nil { - t.Fatal(err) - } - points, err := toLagrange(srs.Pk.G1[:8]) - if err != nil { - t.Fatal(err) - } - pk, _, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: points}, Vk: srs.Vk}) - if err != nil { - t.Fatal(err) - } - var encoded bytes.Buffer - if _, err := pk.WriteTo(&encoded); err != nil { - t.Fatal(err) - } - legacy := append([]byte(nil), encoded.Bytes()[16:]...) - if binary.BigEndian.Uint64(legacy[:8]) != 8 { - t.Fatal("unexpected key version framing") - } - path := filepath.Join(t.TempDir(), "key.bin") - if err := os.WriteFile(path, legacy, 0600); err != nil { - t.Fatal(err) - } - got, digest, err := readSP1Lagrange(path, 8, srs) - if err != nil { - t.Fatal(err) - } - if digest != candidateDigest(points) { - t.Fatal("candidate digest differs") - } - for i := range points { - if !got[i].Equal(&points[i]) { - t.Fatal("extraction changed points") - } - } - for _, kind := range []string{"count", "domain", "truncated", "trailing"} { - bad := append([]byte(nil), legacy...) - switch kind { - case "count": - binary.BigEndian.PutUint32(bad[112+8*32:], ^uint32(0)) - case "domain": - binary.BigEndian.PutUint64(bad[:8], 16) - case "truncated": - bad = bad[:len(bad)-1] - case "trailing": - bad = append(bad, 0) - } - if err := os.WriteFile(path, bad, 0600); err != nil { - t.Fatal(err) - } - if _, _, err := readSP1Lagrange(path, 8, srs); err == nil { - t.Fatalf("accepted %s key", kind) - } - } - if err := os.WriteFile(path, legacy, 0600); err != nil { - t.Fatal(err) - } - other := *srs - other.Vk.G1 = srs.Pk.G1[1] - if _, _, err := readSP1Lagrange(path, 8, &other); err == nil { - t.Fatal("accepted different setup anchor") - } -} diff --git a/tools/plonk-wrapper/main.go b/tools/plonk-wrapper/main.go index 98982417a..75e908f4d 100644 --- a/tools/plonk-wrapper/main.go +++ b/tools/plonk-wrapper/main.go @@ -154,9 +154,6 @@ func checkWitness(dir, path string) error { } func run(args []string) error { - if len(args) == 5 && args[0] == "import-lagrange-sp1" { - return importLagrangeSP1(args[1], args[2], args[3], args[4]) - } if len(args) == 3 && args[0] == "compile" { return compileProfile(args[1], args[2]) } @@ -175,7 +172,7 @@ func run(args []string) error { if len(args) == 6 && args[0] == "verify" { return verify(args[1], args[2], args[3], args[4], args[5]) } - return fmt.Errorf("usage: PLONK helper compile | check-witness | import-lagrange-sp1 | prepare | prove | verify ") + return fmt.Errorf("usage: PLONK helper compile | check-witness | prepare | prove | verify ") } func main() { diff --git a/tools/plonk-wrapper/msm_capture_bn254_test.go b/tools/plonk-wrapper/msm_capture_bn254_test.go deleted file mode 100644 index bf99b3949..000000000 --- a/tools/plonk-wrapper/msm_capture_bn254_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package main - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" - "unsafe" - - "github.com/consensys/gnark-crypto/ecc" - curve "github.com/consensys/gnark-crypto/ecc/bn254" - "github.com/consensys/gnark-crypto/ecc/bn254/fr" - "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" - "github.com/consensys/gnark-crypto/ecc/bn254/fr/iop" - "github.com/consensys/gnark/backend" - "github.com/consensys/gnark/backend/plonk" - native "github.com/consensys/gnark/backend/plonk/bn254" - "github.com/consensys/gnark/backend/solidity" - "github.com/consensys/gnark/frontend" -) - -const msmCaptureFormat = "ix-bn254-init-msm-capture-v1" -const msmScalarFormat = "bn254-fr-u64le-montgomery-v1" - -var errMSMCaptureComplete = errors.New("required Init MSM captures are complete") - -type msmCaptureRecord struct { - Index int - Caller, Basis, ScalarsFile string - Offset, Count uint64 - Scalars artifact - CPUResult, CPUResultSHA256 string - CPUSeconds, WriteSeconds float64 -} - -type msmCaptureManifest struct { - Format, ScalarFormat, Scope string - Complete, StoppedBeforeQuotient bool - ArtifactManifestSHA256, ProvingKeySHA256, InnerVKSHA256, VerifyingKeySHA256 string - ExpectedClaim, TestBinarySHA256 string - Domain uint64 - Constraints, PublicInputs, NumCPU, GOMAXPROCS int - Witness artifact - ValidationSeconds, LoadSeconds, WitnessSeconds, CaptureSeconds float64 - Records []msmCaptureRecord -} - -func msmTestRequired(t *testing.T, prefix, name string) string { - t.Helper() - value := os.Getenv(prefix + name) - if value == "" { - t.Fatalf("%s%s must be explicit", prefix, name) - } - return value -} - -func rawMontgomery(values []fr.Element) []byte { - if len(values) == 0 { - return nil - } - return unsafe.Slice((*byte)(unsafe.Pointer(&values[0])), len(values)*32) -} - -func checkRawMontgomeryABI() error { - var one uint64 = 1 - if unsafe.Sizeof(fr.Element{}) != 32 || *(*byte)(unsafe.Pointer(&one)) != 1 { - return fmt.Errorf("MSM capture requires the 32-byte little-endian field ABI") - } - return nil -} - -func authenticatedMSMKey(t *testing.T, dir, pin string) (*manifest, *native.ProvingKey, float64, float64) { - t.Helper() - started := time.Now() - m, err := cpuProfileManifest(dir, pin) - if err != nil { - t.Fatal(err) - } - validation := time.Since(started).Seconds() - started = time.Now() - pk := new(native.ProvingKey) - if err := cpuProfileReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { - t.Fatal(err) - } - if pk.Vk == nil || pk.Vk.Size != m.Domain || uint64(len(pk.Kzg.G1)) != m.Domain+3 || uint64(len(pk.KzgLagrange.G1)) != m.Domain { - t.Fatal("authenticated proving-key dimensions differ") - } - h := sha256.New() - n, err := pk.Vk.WriteTo(h) - if err != nil { - t.Fatal(err) - } - if n != m.Files["verifying-key.bin"].Bytes || hex.EncodeToString(h.Sum(nil)) != m.Files["verifying-key.bin"].SHA256 { - t.Fatal("proving key embeds a different verifying key") - } - return m, pk, validation, time.Since(started).Seconds() -} - -type recordingCPUAccelerator struct { - mu sync.Mutex - pk *native.ProvingKey - out string - records []msmCaptureRecord -} - -func (*recordingCPUAccelerator) ToCanonical(*fft.Domain, *iop.Polynomial) error { - return fmt.Errorf("unexpected FFT during MSM-only capture") -} -func (*recordingCPUAccelerator) ComputeNumerator(*native.NumeratorInput) (*iop.Polynomial, error) { - return nil, fmt.Errorf("unexpected numerator during MSM-only capture") -} - -func captureBasisRange(full, points []curve.G1Affine) (uint64, bool) { - if len(full) == 0 || len(points) == 0 { - return 0, false - } - width := unsafe.Sizeof(curve.G1Affine{}) - start := uintptr(unsafe.Pointer(&full[0])) - end := start + uintptr(len(full))*width - pointStart := uintptr(unsafe.Pointer(&points[0])) - pointEnd := pointStart + uintptr(len(points))*width - if end < start || pointEnd < pointStart || pointStart < start || pointEnd > end || (pointStart-start)%width != 0 { - return 0, false - } - return uint64((pointStart - start) / width), true -} - -func captureCaller() string { - var pcs [20]uintptr - n := runtime.Callers(2, pcs[:]) - frames := runtime.CallersFrames(pcs[:n]) - for { - frame, more := frames.Next() - if strings.Contains(frame.Function, "commitToLRO.func") || strings.HasSuffix(frame.Function, ".bsb22Hint") { - return frame.Function - } - if !more { - return "unknown" - } - } -} - -func (a *recordingCPUAccelerator) MultiExp(points []curve.G1Affine, scalars []fr.Element) (curve.G1Affine, error) { - a.mu.Lock() - defer a.mu.Unlock() - if len(a.records) >= 4 { - return curve.G1Affine{}, errMSMCaptureComplete - } - if len(points) != len(scalars) { - return curve.G1Affine{}, fmt.Errorf("capture MSM length mismatch") - } - offset, ok := captureBasisRange(a.pk.KzgLagrange.G1, points) - if !ok { - return curve.G1Affine{}, fmt.Errorf("capture expected the initial Lagrange MSMs") - } - row := msmCaptureRecord{Index: len(a.records), Caller: captureCaller(), Basis: "lagrange", Offset: offset, Count: uint64(len(scalars))} - row.ScalarsFile = fmt.Sprintf("msm-%02d.scalars.bin", row.Index) - started := time.Now() - var result curve.G1Affine - if _, err := result.MultiExp(points, scalars, ecc.MultiExpConfig{}); err != nil { - return result, err - } - row.CPUSeconds = time.Since(started).Seconds() - started = time.Now() - f, err := os.OpenFile(filepath.Join(a.out, row.ScalarsFile), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - return result, err - } - h := sha256.New() - n, writeErr := io.Copy(io.MultiWriter(f, h), bytes.NewReader(rawMontgomery(scalars))) - if writeErr == nil { - writeErr = f.Sync() - } - writeErr = errors.Join(writeErr, f.Close()) - if writeErr != nil { - return result, writeErr - } - if n != int64(row.Count*32) { - return result, fmt.Errorf("incomplete scalar capture") - } - row.Scalars = artifact{SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: n} - encoded := result.Bytes() - row.CPUResult = hex.EncodeToString(encoded[:]) - row.CPUResultSHA256 = fmt.Sprintf("%x", sha256.Sum256(encoded[:])) - row.WriteSeconds = time.Since(started).Seconds() - a.records = append(a.records, row) - runtime.KeepAlive(points) - runtime.KeepAlive(scalars) - if len(a.records) == 4 { - return result, errMSMCaptureComplete - } - return result, nil -} - -func validateCapture(m *msmCaptureManifest, artifacts *manifest) error { - if m.Format != msmCaptureFormat || m.ScalarFormat != msmScalarFormat || !m.Complete || !m.StoppedBeforeQuotient || len(m.Records) != 4 || m.Domain != artifacts.Domain || m.Constraints != artifacts.Constraints || m.PublicInputs != 2 || m.ProvingKeySHA256 != artifacts.Files["proving-key.bin"].SHA256 || m.InnerVKSHA256 != artifacts.Files["verifying-key.bin"].SHA256 || m.InnerVKSHA256 != artifacts.InnerVKSHA256 { - return fmt.Errorf("capture identity or shape differs") - } - shapes := make(map[[2]uint64]int) - for i, row := range m.Records { - if row.Index != i || row.Basis != "lagrange" || row.ScalarsFile != fmt.Sprintf("msm-%02d.scalars.bin", i) || row.Count == 0 || row.Offset > m.Domain || row.Count > m.Domain-row.Offset || row.Scalars.Bytes != int64(row.Count*32) { - return fmt.Errorf("invalid capture range or filename") - } - if _, err := cpuProfileDigest(row.Scalars.SHA256); err != nil { - return err - } - encoded, err := unhex(row.CPUResult, 32) - if err != nil { - return err - } - if fmt.Sprintf("%x", sha256.Sum256(encoded)) != row.CPUResultSHA256 { - return fmt.Errorf("captured CPU result digest differs") - } - shapes[[2]uint64{row.Offset, row.Count}]++ - } - rows := uint64(m.Constraints + m.PublicInputs) - if len(shapes) != 3 || shapes[[2]uint64{0, m.Domain}] != 1 || shapes[[2]uint64{0, rows}] != 1 || shapes[[2]uint64{2, uint64(m.Constraints)}] != 2 { - return fmt.Errorf("capture does not contain BSB22 and all three LRO MSMs") - } - return nil -} - -func TestCaptureActualInitMSMs(t *testing.T) { - if os.Getenv("IX_PLONK_CUDA_MSM_CAPTURE") != "1" { - t.Skip("set IX_PLONK_CUDA_MSM_CAPTURE=1 with explicit artifact and claim pins") - } - if os.Getenv("GOMAXPROCS") != "" { - t.Fatal("unset GOMAXPROCS for default CPU parallelism") - } - if err := checkRawMontgomeryABI(); err != nil { - t.Fatal(err) - } - const prefix = "IX_PLONK_CUDA_MSM_CAPTURE_" - dir, out, witnessPath := msmTestRequired(t, prefix, "ARTIFACTS"), msmTestRequired(t, prefix, "OUTPUT"), msmTestRequired(t, prefix, "WITNESS") - pin, err := cpuProfileDigest(msmTestRequired(t, prefix, "MANIFEST_SHA256")) - if err != nil { - t.Fatal(err) - } - claim, err := cpuProfileDigest(msmTestRequired(t, prefix, "EXPECTED_CLAIM")) - if err != nil { - t.Fatal(err) - } - if err := os.Mkdir(out, 0700); err != nil { - t.Fatal(err) - } - m, pk, validationSeconds, keySeconds := authenticatedMSMKey(t, dir, pin) - started := time.Now() - ccs := plonk.NewCS(curveID) - if err := cpuProfileReadBinary(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { - t.Fatal(err) - } - vk := new(native.VerifyingKey) - if err := cpuProfileReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { - t.Fatal(err) - } - if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != 2 || len(ccs.GetCommitments().CommitmentIndexes()) != 1 { - t.Fatal("capture circuit metadata differs") - } - p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) - if err != nil { - t.Fatal(err) - } - if err := m.checkFile(dir, "vk.json"); err != nil { - t.Fatal(err) - } - loadSeconds := keySeconds + time.Since(started).Seconds() - started = time.Now() - witnessIdentity, err := fileHash(witnessPath) - if err != nil { - t.Fatal(err) - } - assignment, err := witnessAssignment(p, witnessPath) - if err != nil { - t.Fatal(err) - } - after, err := fileHash(witnessPath) - if err != nil || after != witnessIdentity { - t.Fatal("witness changed during loading") - } - w, err := frontend.NewWitness(assignment, curveID.ScalarField()) - if err != nil { - t.Fatal(err) - } - digest, _ := hex.DecodeString(claim) - expected, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) - if err != nil { - t.Fatal(err) - } - actual, err := w.Public() - if err != nil { - t.Fatal(err) - } - wantBytes, err := expected.MarshalBinary() - if err != nil { - t.Fatal(err) - } - actualBytes, err := actual.MarshalBinary() - if err != nil || !bytes.Equal(actualBytes, wantBytes) { - t.Fatal("capture witness differs from independently expected claim") - } - identity, err := executableHash() - if err != nil { - t.Fatal(err) - } - report := msmCaptureManifest{Format: msmCaptureFormat, ScalarFormat: msmScalarFormat, Scope: "Actual pinned Init witness solved on CPU; first four Lagrange MSMs captured before any quotient. CPU callbacks are serialized at default CPU parallelism. Raw local scalar captures are test artifacts, not proofs.", ArtifactManifestSHA256: pin, ProvingKeySHA256: m.Files["proving-key.bin"].SHA256, VerifyingKeySHA256: m.Files["verifying-key.bin"].SHA256, InnerVKSHA256: m.InnerVKSHA256, ExpectedClaim: claim, TestBinarySHA256: identity, Domain: m.Domain, Constraints: m.Constraints, PublicInputs: 2, Witness: witnessIdentity, NumCPU: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ValidationSeconds: validationSeconds, LoadSeconds: loadSeconds, WitnessSeconds: time.Since(started).Seconds()} - capture := &recordingCPUAccelerator{pk: pk, out: out} - pk.SetAccelerator(capture) - defer pk.SetAccelerator(nil) - started = time.Now() - _, err = plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) - report.CaptureSeconds = time.Since(started).Seconds() - report.Records = capture.records - if !errors.Is(err, errMSMCaptureComplete) || len(report.Records) != 4 { - t.Fatalf("capture did not reach the expected stop; completed MSM count=%d", len(report.Records)) - } - report.Complete, report.StoppedBeforeQuotient = true, true - if err := validateCapture(&report, m); err != nil { - t.Fatal(err) - } - path := filepath.Join(out, "capture.json") - if err := writeJSON(path, &report); err != nil { - t.Fatal(err) - } - receipt, err := fileHash(path) - if err != nil { - t.Fatal(err) - } - t.Logf("capture_manifest_sha256=%s msm_count=%d stopped_before_quotient=true", receipt.SHA256, len(report.Records)) -} - -type rawScalarVector []fr.Element - -func (values rawScalarVector) ReadFrom(reader io.Reader) (int64, error) { - n, err := io.ReadFull(reader, rawMontgomery(values)) - return int64(n), err -} - -func readCaptureManifest(path, pin string) (*msmCaptureManifest, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) - if err != nil { - return nil, err - } - if len(data) > 1<<20 || fmt.Sprintf("%x", sha256.Sum256(data)) != pin { - return nil, fmt.Errorf("capture manifest differs from trusted SHA-256") - } - var m msmCaptureManifest - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&m); err != nil { - return nil, err - } - if err := d.Decode(new(any)); err != io.EOF { - return nil, fmt.Errorf("trailing capture manifest data") - } - return &m, nil -} diff --git a/tools/plonk-wrapper/optimization_test.go b/tools/plonk-wrapper/optimization_test.go deleted file mode 100644 index 16e634534..000000000 --- a/tools/plonk-wrapper/optimization_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/consensys/gnark-crypto/ecc" - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/frontend/cs/scs" -) - -type optimizedCircuit struct { - Inner wrapperCircuit - Options circuitOptions `gnark:"-"` -} - -func (c *optimizedCircuit) Define(api frontend.API) error { - return c.Inner.constrainWithOptions(api, 6, c.Options) -} - -func TestOptimizationSizing(t *testing.T) { - if os.Getenv("IX_PLONK_SIZE") != "1" { - t.Skip("set IX_PLONK_SIZE=1 for full variant compilation") - } - path := os.Getenv("IX_PLONK_SIZE_VK") - if path == "" { - path = filepath.Join("testdata", "bn254", "vk.json") - } - p, err := loadVerifyingKey(path) - if err != nil { - t.Fatal(err) - } - type measurement struct { - Variant string - Constraints, Rows, GapTo24, Commitments int - Domain uint64 - Seconds float64 - } - var measurements []measurement - for _, variant := range []struct { - name string - options circuitOptions - }{ - {"baseline", circuitOptions{}}, - {"batched", circuitOptions{batchPairings: true}}, - {"classic-glv", circuitOptions{classicGLV: true}}, - {"batched-classic-glv", circuitOptions{batchPairings: true, classicGLV: true}}, - } { - if selectVariant := os.Getenv("IX_PLONK_SIZE_VARIANT"); selectVariant != "" && selectVariant != variant.name { - continue - } - runtime.GC() - start := time.Now() - ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, - &optimizedCircuit{Inner: *newCircuit(p), Options: variant.options}) - if err != nil { - t.Fatal(err) - } - rows := ccs.GetNbConstraints() + ccs.GetNbPublicVariables() - row := measurement{variant.name, ccs.GetNbConstraints(), rows, rows - (1 << 24), len(ccs.GetCommitments().CommitmentIndexes()), ecc.NextPowerOfTwo(uint64(rows)), time.Since(start).Seconds()} - measurements = append(measurements, row) - t.Logf("%s: constraints=%d rows=%d gap_to_2p24=%d domain=%d seconds=%.2f", row.Variant, row.Constraints, row.Rows, row.GapTo24, row.Domain, row.Seconds) - if path := os.Getenv("IX_PLONK_SIZE_REPORT"); path != "" { - encoded, err := json.MarshalIndent(measurements, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, encoded, 0600); err != nil { - t.Fatal(err) - } - } - } -} diff --git a/tools/plonk-wrapper/pairing_batch.go b/tools/plonk-wrapper/pairing_batch.go deleted file mode 100644 index 184d1d48b..000000000 --- a/tools/plonk-wrapper/pairing_batch.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" - "github.com/consensys/gnark/std/math/bits" - "github.com/consensys/gnark/std/math/emulated" - "github.com/consensys/gnark/std/math/uints" - "github.com/zeebo/blake3" -) - -func pairingBatchContext(p *verifyingKey) ([]byte, error) { - encoded, err := json.Marshal(p) - if err != nil { - return nil, err - } - digest := blake3.Sum256(encoded) - return append([]byte("ix/plonk/pairing-batch/"+curveID.String()+"/v1\x00"), digest[:]...), nil -} - -// The final inner transcript state commits to all packet scalars and points, -// including the deferred public operands and the final opening witnesses. -// The fresh challenge also binds the computed MSM outputs after they are fixed. -func pairingBatchChallenge(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], context []byte, claim [2]frontend.Variable, packetDigest []uints.U8, operands [4]*circuitG1) frontend.Variable { - if len(packetDigest) != 32 { - panic("invalid packet transcript digest length") - } - input := uints.NewU8Array(context) - for _, limb := range claim { - b := bits.ToBinary(api, limb, bits.WithNbDigits(128)) - for j := 0; j < 16; j++ { - input = append(input, u.Bytes.FromBits(b[8*j:8*j+8]...)) - } - } - input = append(input, packetDigest...) - for _, point := range operands { - input = append(input, pointBytes(api, u, f, point)...) - } - out := blakeXOF(u, input, 64) - return pairingBatchScalar(api, u, out) -} - -func pairingBatchScalar(api frontend.API, u *uints.BinaryField[uints.U32], wide []uints.U8) frontend.Variable { - if len(wide) != 64 { - panic("batching challenge requires 512 bits") - } - rho := littleEndian(api, u, wide) - api.AssertIsDifferent(rho, 0) - return rho -} - -func (c *wrapperCircuit) batchedPairing(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], s *emulated.Field[scalarField], curve *sw_emulated.Curve[baseField, scalarField], packetDigest []uints.U8, left, right *circuitG1) error { - outer, err := g2Keys(c.VK.OuterKeys) - if err != nil { - return err - } - inner, err := g2Keys(c.VK.DeferredKeys) - if err != nil { - return err - } - if !outer[0].Equal(&inner[0]) || !outer[1].Equal(&inner[1]) { - return fmt.Errorf("pairing batching requires identical ordered G2 anchors") - } - context, err := pairingBatchContext(c.VK) - if err != nil { - return err - } - leftCanonical := circuitG1{X: *f.ReduceStrict(&left.X), Y: *f.ReduceStrict(&left.Y)} - rightCanonical := circuitG1{X: *f.ReduceStrict(&right.X), Y: *f.ReduceStrict(&right.Y)} - rho := pairingBatchChallenge(api, u, f, context, c.Claim, packetDigest, [4]*circuitG1{&leftCanonical, &rightCanonical, &c.Deferred[0], &c.Deferred[1]}) - scalar := s.FromBits(bits.ToBinary(api, rho)...) - a := curve.AddUnified(left, curve.ScalarMul(&c.Deferred[0], scalar)) - b := curve.AddUnified(curve.Neg(right), curve.ScalarMul(&c.Deferred[1], scalar)) - pairing, err := newPairing(api) - if err != nil { - return err - } - h, tauH := newCircuitG2Fixed(outer[0]), newCircuitG2Fixed(outer[1]) - return pairing.PairingCheck([]*circuitG1{a, b}, []*circuitG2{&h, &tauH}) -} diff --git a/tools/plonk-wrapper/pairing_batch_bn254_test.go b/tools/plonk-wrapper/pairing_batch_bn254_test.go deleted file mode 100644 index acc76399c..000000000 --- a/tools/plonk-wrapper/pairing_batch_bn254_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package main - -import ( - "bytes" - "math/big" - "path/filepath" - "testing" - - bn "github.com/consensys/gnark-crypto/ecc/bn254" - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" - "github.com/consensys/gnark/std/math/emulated" - "github.com/consensys/gnark/std/math/uints" - "github.com/consensys/gnark/test" - "github.com/zeebo/blake3" -) - -type pairingBatchCircuit struct { - Operands [4]circuitG1 - Claim [2]frontend.Variable - PacketDigest [32]uints.U8 - VK *verifyingKey `gnark:"-"` - ExpectedRho frontend.Variable - ChallengeOnly bool `gnark:"-"` -} - -func (c *pairingBatchCircuit) Define(api frontend.API) error { - u, err := uints.NewBinaryField[uints.U32](api) - if err != nil { - return err - } - f, err := emulated.NewField[baseField](api) - if err != nil { - return err - } - s, err := emulated.NewField[scalarField](api) - if err != nil { - return err - } - curve, err := sw_emulated.New[baseField, scalarField](api, curveParameters()) - if err != nil { - return err - } - pairing, err := newPairing(api) - if err != nil { - return err - } - for i := range c.Operands { - pairing.AssertIsOnG1(&c.Operands[i]) - } - if c.ChallengeOnly { - context, err := pairingBatchContext(c.VK) - if err != nil { - return err - } - rho := pairingBatchChallenge(api, u, f, context, c.Claim, c.PacketDigest[:], [4]*circuitG1{&c.Operands[0], &c.Operands[1], &c.Operands[2], &c.Operands[3]}) - api.AssertIsEqual(rho, c.ExpectedRho) - return nil - } - w := &wrapperCircuit{VK: c.VK, Claim: c.Claim, Deferred: []circuitG1{c.Operands[2], c.Operands[3]}} - return w.batchedPairing(api, u, f, s, curve, c.PacketDigest[:], &c.Operands[0], &c.Operands[1]) -} - -func nativePairingBatchChallenge(p *verifyingKey, claim [2]uint64, packet [32]byte, points [4]nativeG1) *big.Int { - input, err := pairingBatchContext(p) - if err != nil { - panic(err) - } - for _, limb := range claim { - for i := 0; i < 16; i++ { - var b byte - if i < 8 { - b = byte(limb >> uint(8*i)) - } - input = append(input, b) - } - } - input = append(input, packet[:]...) - for _, point := range points { - input = append(input, encodePoint(point)...) - } - h := blake3.New() - h.Write(input) - wide := make([]byte, 64) - h.Digest().Read(wide) - for i, j := 0, len(wide)-1; i < j; i, j = i+1, j-1 { - wide[i], wide[j] = wide[j], wide[i] - } - return new(big.Int).Mod(new(big.Int).SetBytes(wide), scalarModulus()) -} - -func TestPairingBatchSoundnessAndChallengeBinding(t *testing.T) { - p, srs, _ := importedFixture(t) - C, T := srs.Pk.G1[0], srs.Pk.G1[1] - _, _, g, _ := bn.Generators() - var negC nativeG1 - negC.Neg(&C) - valid := [4]nativeG1{T, C, T, negC} - claim := [2]uint64{123, 456} - packet := [32]byte{9, 8, 7} - assignment := func(points [4]nativeG1, verifyingKey *verifyingKey) *pairingBatchCircuit { - w := &pairingBatchCircuit{VK: verifyingKey, Claim: [2]frontend.Variable{claim[0], claim[1]}, ExpectedRho: nativePairingBatchChallenge(verifyingKey, claim, packet, points)} - copy(w.PacketDigest[:], uints.NewU8Array(packet[:])) - for i := range points { - w.Operands[i] = newCircuitG1(points[i]) - } - return w - } - check := func(name string, points [4]nativeG1, ok bool) { - t.Helper() - err := test.IsSolved(&pairingBatchCircuit{VK: p}, assignment(points, p), curveID.ScalarField()) - if (err == nil) != ok { - t.Fatalf("%s: expected accepted=%t: %v", name, ok, err) - } - } - check("both-valid", valid, true) - check("all-infinity", [4]nativeG1{}, true) - check("one-identity-equation", [4]nativeG1{T, C, {}, {}}, true) - bad := valid - bad[0].Add(&bad[0], &g) - check("invalid-outer", bad, false) - bad = valid - bad[2].Add(&bad[2], &g) - check("invalid-deferred", bad, false) - bad = [4]nativeG1{} - bad[0] = g - check("invalid-outer-identity-deferred", bad, false) - bad = [4]nativeG1{} - bad[2] = g - check("valid-outer-invalid-deferred", bad, false) - var weighted nativeG1 - weighted.ScalarMultiplication(&g, big.NewInt(7)) - cancel := [4]nativeG1{T, C, g, g} - cancel[0].Sub(&T, &weighted) - cancel[1].Add(&C, &weighted) - if nativePairingBatchChallenge(p, claim, packet, cancel).Cmp(big.NewInt(7)) == 0 { - t.Fatal("cancellation fixture has accidental challenge collision") - } - anchors, _ := g2Keys(p.OuterKeys) - var a, b, neg nativeG1 - neg.Neg(&cancel[1]) - for _, equation := range [][2]nativeG1{{cancel[0], neg}, {cancel[2], cancel[3]}} { - if ok, err := bn.PairingCheck(equation[:], anchors[:]); err != nil || ok { - t.Fatal("cancellation fixture must have two invalid equations", err) - } - } - a.Add(&cancel[0], &weighted) - neg.Neg(&cancel[1]) - b.Add(&neg, &weighted) - if ok, err := bn.PairingCheck([]nativeG1{a, b}, anchors[:]); err != nil || !ok { - t.Fatal("predetermined-coefficient cancellation fixture is invalid", err) - } - check("predetermined-coefficient-cancellation", cancel, false) - for _, points := range [][4]nativeG1{valid, cancel, {}} { - w := assignment(points, p) - if err := test.IsSolved(&pairingBatchCircuit{VK: p, ChallengeOnly: true}, w, curveID.ScalarField()); err != nil { - t.Fatal(err) - } - } - for _, kind := range []string{"left", "right", "deferred-a", "deferred-b", "claim-low", "claim-high", "packet", "context"} { - w := assignment(valid, p) - vkCopy := *p - switch kind { - case "left", "right", "deferred-a", "deferred-b": - i := map[string]int{"left": 0, "right": 1, "deferred-a": 2, "deferred-b": 3}[kind] - point := valid[i] - point.Add(&point, &g) - w.Operands[i] = newCircuitG1(point) - case "claim-low": - w.Claim[0] = claim[0] + 1 - case "claim-high": - w.Claim[1] = claim[1] + 1 - case "packet": - w.PacketDigest[0] = uints.NewU8(packet[0] ^ 1) - case "context": - vkCopy.Seed = "0x01" - } - if err := test.IsSolved(&pairingBatchCircuit{VK: &vkCopy, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { - t.Fatalf("unbound %s", kind) - } - } - w := assignment(valid, p) - w.ExpectedRho = 0 - if err := test.IsSolved(&pairingBatchCircuit{VK: p, ChallengeOnly: true}, w, curveID.ScalarField()); err == nil { - t.Fatal("accepted zero challenge") - } - other := *p - other.DeferredKeys = []string{p.DeferredKeys[1], p.DeferredKeys[0]} - if err := test.IsSolved(&pairingBatchCircuit{VK: &other}, assignment(valid, &other), curveID.ScalarField()); err == nil { - t.Fatal("accepted mismatched anchors") - } - context, _ := pairingBatchContext(p) - other = *p - other.SetupID = "0x" + string(bytes.Repeat([]byte{'0'}, 64)) - changed, _ := pairingBatchContext(&other) - if bytes.Equal(context, changed) { - t.Fatal("setup identity absent from context") - } -} - -func TestBN254OptimizedFullVerifier(t *testing.T) { - p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) - if err != nil { - t.Fatal(err) - } - for _, options := range []circuitOptions{{batchPairings: true}, {classicGLV: true}, {batchPairings: true, classicGLV: true}} { - c := &optimizedCircuit{Inner: *newCircuit(p), Options: options} - for _, kind := range []string{"valid", "claim", "evaluation", "commitment", "deferred"} { - w, err := witnessAssignment(p, filepath.Join("testdata", "bn254", "witness.json")) - if err != nil { - t.Fatal(err) - } - switch kind { - case "claim": - w.Claim[0] = new(big.Int).Add(w.Claim[0].(*big.Int), big.NewInt(1)) - case "evaluation": - w.Values[1] = new(big.Int).Add(w.Values[1].(*big.Int), big.NewInt(1)) - case "commitment": - _, _, g, _ := bn.Generators() - w.Points[0] = newCircuitG1(g) - case "deferred": - _, _, g, _ := bn.Generators() - w.Deferred[0] = newCircuitG1(g) - } - err = test.IsSolved(c, &optimizedCircuit{Inner: *w, Options: options}, curveID.ScalarField()) - if (err == nil) != (kind == "valid") { - t.Fatalf("options=%+v mutation=%s: %v", options, kind, err) - } - } - } -} - -type pairingBatchReductionCircuit struct { - Wide [64]uints.U8 - Expected frontend.Variable -} - -func (c *pairingBatchReductionCircuit) Define(api frontend.API) error { - u, err := uints.NewBinaryField[uints.U32](api) - if err != nil { - return err - } - wide := make([]uints.U8, len(c.Wide)) - for i, b := range c.Wide { - wide[i] = u.ByteValueOf(u.Value(b)) - } - api.AssertIsEqual(pairingBatchScalar(api, u, wide), c.Expected) - return nil -} - -func TestPairingBatchReductionRejectsZero(t *testing.T) { - for _, value := range []*big.Int{new(big.Int), new(big.Int).Set(scalarModulus()), big.NewInt(1), new(big.Int).Add(scalarModulus(), big.NewInt(9)), new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 512), big.NewInt(1))} { - wide := value.FillBytes(make([]byte, 64)) - for i, j := 0, len(wide)-1; i < j; i, j = i+1, j-1 { - wide[i], wide[j] = wide[j], wide[i] - } - expected := new(big.Int).Mod(value, scalarModulus()) - w := &pairingBatchReductionCircuit{Expected: expected} - copy(w.Wide[:], uints.NewU8Array(wide)) - err := test.IsSolved(&pairingBatchReductionCircuit{}, w, curveID.ScalarField()) - if (err == nil) != (expected.Sign() != 0) { - t.Fatalf("challenge reduction %s: %v", value, err) - } - } -} diff --git a/tools/plonk-wrapper/parameters.go b/tools/plonk-wrapper/parameters.go index 17ab81401..1b1d4175e 100644 --- a/tools/plonk-wrapper/parameters.go +++ b/tools/plonk-wrapper/parameters.go @@ -13,9 +13,9 @@ import ( ) type lagrangeManifest struct { - Format, SetupID, MonomialBLAKE3, ExecutableSHA256 string - Domain uint64 - Points artifact + Format, SetupID, MonomialBLAKE3 string + Domain uint64 + Points artifact } func readAffinePoints(r io.Reader, count uint64) ([]nativeG1, error) { @@ -49,8 +49,8 @@ func writeAffinePoints(w io.Writer, points []nativeG1) error { return b.Flush() } -func lagrangeLocation(root string, domain uint64, setupID, receipt, executable string) (lagrangeManifest, string, error) { - want := lagrangeManifest{Format: lagrangeFormat, SetupID: setupID, MonomialBLAKE3: receipt, ExecutableSHA256: executable, Domain: domain} +func lagrangeLocation(root string, domain uint64, setupID, receipt string) (lagrangeManifest, string, error) { + want := lagrangeManifest{Format: lagrangeFormat, SetupID: setupID, MonomialBLAKE3: receipt, Domain: domain} identity, err := json.Marshal(want) if err != nil { return want, "", err @@ -76,11 +76,11 @@ func publishLagrange(dir string, want lagrangeManifest, points []nativeG1) error return nil } -func lagrangeParameters(root string, srs *nativeSRS, domain uint64, setupID, receipt, executable string) (*nativeSRS, bool, error) { +func lagrangeParameters(root string, srs *nativeSRS, domain uint64, setupID, receipt string) (*nativeSRS, bool, error) { if domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || uint64(len(srs.Pk.G1)) < domain+3 { return nil, false, fmt.Errorf("invalid Lagrange domain or monomial prefix") } - want, dir, err := lagrangeLocation(root, domain, setupID, receipt, executable) + want, dir, err := lagrangeLocation(root, domain, setupID, receipt) if err != nil { return nil, false, err } diff --git a/tools/plonk-wrapper/parameters_bn254_test.go b/tools/plonk-wrapper/parameters_bn254_test.go index 3255fe3ac..a92557537 100644 --- a/tools/plonk-wrapper/parameters_bn254_test.go +++ b/tools/plonk-wrapper/parameters_bn254_test.go @@ -28,12 +28,11 @@ func TestLagrangeCacheAndBasisConversion(t *testing.T) { t.Fatal(err) } root := t.TempDir() - executable := strings.Repeat("a", 64) - first, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) + first, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3) if err != nil || reused { t.Fatalf("initial derivation: reused=%t error=%v", reused, err) } - second, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable) + second, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3) if err != nil || !reused { t.Fatalf("cached derivation: reused=%t error=%v", reused, err) } @@ -79,10 +78,19 @@ func TestLagrangeCacheAndBasisConversion(t *testing.T) { if err := f.Close(); err != nil { t.Fatal(err) } - if _, _, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, executable); err == nil { + if _, _, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3); err == nil { t.Fatal("accepted corrupted Lagrange cache") } - if _, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3, strings.Repeat("b", 64)); err != nil || reused { - t.Fatalf("new backend reused old cache: %t %v", reused, err) + for _, identity := range []struct { + domain uint64 + setup, receipt string + }{ + {16, p.SetupID, receipt.Blake3}, + {8, strings.Repeat("b", 64), receipt.Blake3}, + {8, p.SetupID, strings.Repeat("c", 64)}, + } { + if _, reused, err := lagrangeParameters(root, srs, identity.domain, identity.setup, identity.receipt); err != nil || reused { + t.Fatalf("different setup, receipt or domain reused old cache: %t %v", reused, err) + } } } diff --git a/tools/plonk-wrapper/patches/gnark-cuda-generator-v1.patch b/tools/plonk-wrapper/patches/gnark-cuda-generator-v1.patch deleted file mode 100644 index a8d12ef37..000000000 --- a/tools/plonk-wrapper/patches/gnark-cuda-generator-v1.patch +++ /dev/null @@ -1,801 +0,0 @@ -diff --git a/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl b/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl ---- a/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl -+++ b/internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl -@@ -111,6 +111,93 @@ - ComputeNumerator(in *NumeratorInput) (*iop.Polynomial, error) - } - -+// AcceleratorFeatureFlagsV1 selects operations before a proof mutates its polynomials. -+type AcceleratorFeatureFlagsV1 uint64 -+ -+const ( -+ AcceleratorFeatureMSMV1 AcceleratorFeatureFlagsV1 = 1 << iota -+ AcceleratorFeatureToCanonicalV1 -+ AcceleratorFeatureNumeratorV1 -+ AcceleratorFeatureQuotientV1 -+) -+ -+const legacyAcceleratorFeaturesV1 = AcceleratorFeatureMSMV1 | AcceleratorFeatureToCanonicalV1 | AcceleratorFeatureNumeratorV1 -+const allAcceleratorFeaturesV1 = legacyAcceleratorFeaturesV1 | AcceleratorFeatureQuotientV1 -+ -+// AcceleratorFeaturesV1 optionally selects accelerated operations. FeaturesV1 -+// is called exactly once per Prove; its result is frozen for that proof. -+// Implementations must make this method safe for concurrent proofs. -+type AcceleratorFeaturesV1 interface { -+ FeaturesV1() AcceleratorFeatureFlagsV1 -+} -+ -+// QuotientAcceleratorV1 computes H after division by X^N-1 and the inverse FFT. -+// Its result has Domain1.Cardinality coefficients in Canonical/Regular form. -+// Every nonblinding input must be restored to Canonical/Regular on Domain0; -+// blinding polynomials must remain unchanged. The corresponding feature bit -+// must be set explicitly; implementing this interface alone does not enable it. -+type QuotientAcceleratorV1 interface { -+ ComputeQuotientV1(*NumeratorInput) (*iop.Polynomial, error) -+} -+ -+type acceleratorState struct { -+ implementation Accelerator -+ features AcceleratorFeatureFlagsV1 -+ quotient QuotientAcceleratorV1 -+} -+ -+// StageEventV1 is a monotonic wall-clock interval relative to proof initialization. -+// Task spans include dependency waits and nested compute spans. Compute spans may -+// include backend queueing; overlapping intervals must not be summed. -+type StageEventV1 struct { -+ Stage, Phase string -+ Start, End time.Duration -+ Failed bool -+} -+ -+type stageObserverStateV1 struct { callback func(StageEventV1) } -+ -+// SetStageObserverV1 installs a runtime-only diagnostic callback. Prove freezes -+// the callback at entry. It may be called concurrently and must not panic or -+// block. Passing nil disables observation; key serialization is unchanged. -+func (pk *ProvingKey) SetStageObserverV1(observer func(StageEventV1)) { -+ if observer == nil { pk.stageObserver.Store(nil); return } -+ pk.stageObserver.Store(&stageObserverStateV1{callback: observer}) -+} -+ -+// proofKey snapshots runtime dispatch while retaining the immutable key arrays. -+// Concurrent SetAccelerator calls cannot change an already-started proof. -+func (pk *ProvingKey) proofKey() (*ProvingKey, error) { -+ view := &ProvingKey{Kzg: pk.Kzg, KzgLagrange: pk.KzgLagrange, Vk: pk.Vk} -+ view.stageObserver.Store(pk.stageObserver.Load()) -+ configured := pk.accelerator.Load() -+ if configured == nil { -+ return view, nil -+ } -+ features := legacyAcceleratorFeaturesV1 -+ if selectable, ok := configured.implementation.(AcceleratorFeaturesV1); ok { -+ features = selectable.FeaturesV1() -+ } -+ if features & ^allAcceleratorFeaturesV1 != 0 { -+ return nil, fmt.Errorf("unsupported accelerator feature bits: %#x", features & ^allAcceleratorFeaturesV1) -+ } -+ state := &acceleratorState{implementation: configured.implementation, features: features} -+ if features&AcceleratorFeatureQuotientV1 != 0 { -+ var ok bool -+ state.quotient, ok = configured.implementation.(QuotientAcceleratorV1) -+ if !ok { -+ return nil, errors.New("quotient feature requires QuotientAcceleratorV1") -+ } -+ } -+ view.accelerator.Store(state) -+ return view, nil -+} -+ -+func (pk *ProvingKey) accelerated(feature AcceleratorFeatureFlagsV1) bool { -+ state := pk.accelerator.Load() -+ return state != nil && state.features&feature != 0 -+} -+ - // NumeratorInput is the input of Accelerator.ComputeNumerator. All polynomials - // have Domain0.Cardinality coefficients except the blinding polynomials, which - // are small-degree canonical polynomials. -@@ -133,39 +220,47 @@ - // SetAccelerator attaches an Accelerator to the proving key. Passing nil restores - // the CPU implementation. - func (pk *ProvingKey) SetAccelerator(a Accelerator) { -- pk.accelerator = a -+ if a == nil { -+ pk.accelerator.Store(nil) -+ return -+ } -+ pk.accelerator.Store(&acceleratorState{implementation: a, features: legacyAcceleratorFeaturesV1}) - } - - // Accelerator returns the accelerator attached to the proving key, or nil. - func (pk *ProvingKey) Accelerator() Accelerator { -- return pk.accelerator -+ state := pk.accelerator.Load() -+ if state == nil { -+ return nil -+ } -+ return state.implementation - } - - // commit computes the KZG commitment of p with key. nbTasks is a parallelism - // hint for the CPU implementation. - func (pk *ProvingKey) commit(key kzg.ProvingKey, p []fr.Element, nbTasks ...int) (kzg.Digest, error) { -- if pk.accelerator == nil { -+ if !pk.accelerated(AcceleratorFeatureMSMV1) { - return kzg.Commit(p, key, nbTasks...) - } - if len(p) > len(key.G1) { - return kzg.Digest{}, kzg.ErrInvalidPolynomialSize - } -- return pk.accelerator.MultiExp(key.G1[:len(p)], p) -+ return pk.accelerator.Load().implementation.MultiExp(key.G1[:len(p)], p) - } - - func (pk *ProvingKey) multiExp(res *curve.G1Affine, bases []curve.G1Affine, scalars []fr.Element) error { -- if pk.accelerator == nil { -+ if !pk.accelerated(AcceleratorFeatureMSMV1) { - _, err := res.MultiExp(bases, scalars, ecc.MultiExpConfig{}) - return err - } - var err error -- *res, err = pk.accelerator.MultiExp(bases, scalars) -+ *res, err = pk.accelerator.Load().implementation.MultiExp(bases, scalars) - return err - } - - // committer returns the kzg.Committer used for KZG openings with key. - func (pk *ProvingKey) committer(key kzg.ProvingKey) kzg.Committer { -- if pk.accelerator == nil { -+ if !pk.accelerated(AcceleratorFeatureMSMV1) { - return key - } - return acceleratedCommitter{pk: pk, key: key} -@@ -180,7 +275,12 @@ - return c.pk.commit(c.key, p) - } - --func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (*Proof, error) { -+func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (_ *Proof, err error) { -+ -+ pk, err = pk.proofKey() -+ if err != nil { -+ return nil, fmt.Errorf("accelerator configuration: %w", err) -+ } - - log := logger.Logger().With(). - Str("curve", spr.CurveID().String()). -@@ -197,37 +297,48 @@ - - // init instance - g, ctx := errgroup.WithContext(context.Background()) -+ observer := pk.stageObserver.Load() -+ if observer != nil { -+ defer func() { -+ observer.callback(StageEventV1{Stage: "prove", Phase: "task", End: time.Since(start), Failed: err != nil}) -+ }() -+ } - instance, err := newInstance(ctx, spr, pk, fullWitness, &opt) -+ if observer != nil { -+ observer.callback(StageEventV1{Stage: "initialization", Phase: "compute", End: time.Since(start), Failed: err != nil}) -+ } - if err != nil { - return nil, fmt.Errorf("new instance: %w", err) - } - -+ instance.stageObserver, instance.stageOrigin = observer, start -+ - // solve constraints -- g.Go(instance.solveConstraints) -+ g.Go(instance.observeTaskV1("solver-lro", instance.solveConstraints)) - - // complete qk -- g.Go(instance.completeQk) -+ g.Go(instance.observeTaskV1("qk", instance.completeQk)) - - // init blinding polynomials -- g.Go(instance.initBlindingPolynomials) -+ g.Go(instance.observeTaskV1("blinding", instance.initBlindingPolynomials)) - - // derive gamma, beta (copy constraint) -- g.Go(instance.deriveGammaAndBeta) -+ g.Go(instance.observeTaskV1("gamma-beta", instance.deriveGammaAndBeta)) - - // compute accumulating ratio for the copy constraint -- g.Go(instance.buildRatioCopyConstraint) -+ g.Go(instance.observeTaskV1("z-permutation", instance.buildRatioCopyConstraint)) - - // compute h -- g.Go(instance.computeQuotient) -+ g.Go(instance.observeTaskV1("quotient-h", instance.computeQuotient)) - - // open Z (blinded) at ωζ (proof.ZShiftedOpening) -- g.Go(instance.openZ) -+ g.Go(instance.observeTaskV1("z-opening", instance.openZ)) - - // linearized polynomial -- g.Go(instance.computeLinearizedPolynomial) -+ g.Go(instance.observeTaskV1("evaluations-linearization", instance.computeLinearizedPolynomial)) - - // Batch opening -- g.Go(instance.batchOpening) -+ g.Go(instance.observeTaskV1("batch-opening", instance.batchOpening)) - - if err := g.Wait(); err != nil { - return nil, err -@@ -240,6 +351,8 @@ - // represents a Prover instance - type instance struct { - ctx context.Context -+ stageObserver *stageObserverStateV1 -+ stageOrigin time.Time - - pk *ProvingKey - proof *Proof -@@ -285,6 +398,37 @@ - domain0, domain1 *fft.Domain - - trace *Trace -+} -+ -+func (s *instance) beginStageV1() time.Time { -+ if s.stageObserver == nil { return time.Time{} } -+ return time.Now() -+} -+ -+func (s *instance) endStageV1(stage, phase string, start time.Time, failed bool) { -+ if s.stageObserver == nil { return } -+ s.stageObserver.callback(StageEventV1{Stage: stage, Phase: phase, -+ Start: start.Sub(s.stageOrigin), End: time.Since(s.stageOrigin), Failed: failed}) -+} -+ -+func (s *instance) observeTaskV1(stage string, fn func() error) func() error { -+ if s.stageObserver == nil { return fn } -+ return func() (err error) { -+ start := s.beginStageV1() -+ defer func() { s.endStageV1(stage, "task", start, err != nil) }() -+ return fn() -+ } -+} -+ -+func (s *instance) waitStageV1(stage string, ready <-chan struct{}) (err error) { -+ start := s.beginStageV1() -+ defer func() { s.endStageV1(stage, "wait", start, err != nil) }() -+ select { -+ case <-s.ctx.Done(): -+ return errContextDone -+ case <-ready: -+ return nil -+ } - } - - func newInstance(ctx context.Context, spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts *backend.ProverConfig) (*instance, error) { -@@ -402,10 +546,13 @@ - // solveConstraints computes the evaluation of the polynomials L, R, O - // and sets x[id_L], x[id_R], x[id_O] in Lagrange form - func (s *instance) solveConstraints() error { -+ solverStart := s.beginStageV1() - _solution, err := s.spr.Solve(s.fullWitness, s.opt.SolverOpts...) -+ s.endStageV1("solver", "compute", solverStart, err != nil) - if err != nil { - return err - } -+ polynomialsStart := s.beginStageV1() - solution := _solution.(*cs.SparseR1CSSolution) - evaluationLDomainSmall := []fr.Element(solution.L) - evaluationRDomainSmall := []fr.Element(solution.R) -@@ -424,6 +571,7 @@ - s.x[id_O] = iop.NewPolynomial(&evaluationODomainSmall, iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}) - - wg.Wait() -+ s.endStageV1("witness-polynomials", "compute", polynomialsStart, false) - - // commit to l, r, o and add blinding factors - if err := s.commitToLRO(); err != nil { -@@ -445,10 +593,8 @@ - copy(qkCoeffs, wWitness[:len(s.spr.Public)]) - - // wait for solver to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLRO: -+ if err := s.waitStageV1("qk/lro", s.chLRO); err != nil { -+ return err - } - - for i := range s.commitmentInfo { -@@ -486,14 +632,14 @@ - // The (P[i]-s0) terms are zero in the padding region, so the MSM only needs - // the non-padding entries. For a 2.2M-constraint circuit on a 4M domain, - // this nearly halves each MSM. --func (s *instance) commitToLRO() error { -+func (s *instance) commitToLRO() (err error) { - // wait for blinding polynomials to be initialized or context to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chbp: -- } -- -+ if err := s.waitStageV1("lro/blinding", s.chbp); err != nil { -+ return err -+ } -+ -+ lroStart := s.beginStageV1() -+ defer func() { s.endStageV1("lro", "compute", lroStart, err != nil) }() - n := int(s.domain0.Cardinality) - nbPublic := len(s.spr.Public) - offset := nbPublic + s.spr.GetNbConstraints() -@@ -515,6 +661,8 @@ - - // L: subtract s0, MSM on [0:offset], add correction + blinding, restore - g.Go(func() (err error) { -+ commitStart := s.beginStageV1() -+ defer func() { s.endStageV1("l-commitment", "compute", commitStart, err != nil) }() - coeffs := s.x[id_L].Coefficients() - for i := 0; i < offset; i++ { - coeffs[i].Sub(&coeffs[i], &s0) -@@ -534,6 +682,8 @@ - - // R: subtract s0, MSM on [nbPublic:offset], add correction + blinding, restore - g.Go(func() (err error) { -+ commitStart := s.beginStageV1() -+ defer func() { s.endStageV1("r-commitment", "compute", commitStart, err != nil) }() - coeffs := s.x[id_R].Coefficients() - for i := nbPublic; i < offset; i++ { - coeffs[i].Sub(&coeffs[i], &s0) -@@ -553,6 +703,8 @@ - - // O: same as R - g.Go(func() (err error) { -+ commitStart := s.beginStageV1() -+ defer func() { s.endStageV1("o-commitment", "compute", commitStart, err != nil) }() - coeffs := s.x[id_O].Coefficients() - for i := nbPublic; i < offset; i++ { - coeffs[i].Sub(&coeffs[i], &s0) -@@ -585,10 +737,8 @@ - } - - // wait for LRO to be committed -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLRO: -+ if err := s.waitStageV1("gamma-beta/lro", s.chLRO); err != nil { -+ return err - } - - gamma, err := deriveRandomness(s.fs, "gamma", &s.proof.LRO[0], &s.proof.LRO[1], &s.proof.LRO[2]) -@@ -641,6 +791,7 @@ - - // computeQuotient computes H - func (s *instance) computeQuotient() (err error) { -+ prepareStart := s.beginStageV1() - s.x[id_Ql] = s.trace.Ql - s.x[id_Qr] = s.trace.Qr - s.x[id_Qm] = s.trace.Qm -@@ -657,11 +808,11 @@ - lone := make([]fr.Element, n) - lone[0].SetOne() - -+ s.endStageV1("quotient-preparation", "compute", prepareStart, false) -+ - // wait for solver to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLRO: -+ if err := s.waitStageV1("quotient/lro", s.chLRO); err != nil { -+ return err - } - - for i := 0; i < len(s.commitmentInfo); i++ { -@@ -669,10 +820,8 @@ - } - - // wait for Z to be committed or context done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chZ: -+ if err := s.waitStageV1("quotient/z", s.chZ); err != nil { -+ return err - } - - // derive alpha -@@ -686,24 +835,31 @@ - - s.x[id_ZS] = s.x[id_Z].ShallowClone().Shift(1) - -- var numerator *iop.Polynomial -- if s.pk.accelerator == nil { -- numerator, err = s.computeNumerator() -+ if s.pk.accelerated(AcceleratorFeatureQuotientV1) { -+ s.h, err = s.computeQuotientAccelerated() - } else { -- numerator, err = s.computeNumeratorAccelerated() -+ var numerator *iop.Polynomial -+ if s.pk.accelerated(AcceleratorFeatureNumeratorV1) { -+ numerator, err = s.computeNumeratorAccelerated() -+ } else { -+ numerator, err = s.computeNumerator() -+ } -+ if err == nil { -+ divideStart := s.beginStageV1() -+ s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) -+ s.endStageV1("quotient-division", "compute", divideStart, err != nil) -+ } - } - if err != nil { - return err - } - -- s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) -- if err != nil { -- return err -- } -- - // commit to h -- if err := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk); err != nil { -- return err -+ commitStart := s.beginStageV1() -+ commitErr := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk) -+ s.endStageV1("h-commitments", "compute", commitStart, commitErr != nil) -+ if commitErr != nil { -+ return commitErr - } - - if err := s.deriveZeta(); err != nil { -@@ -711,10 +867,8 @@ - } - - // wait for clean up tasks to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chRestoreLRO: -+ if err := s.waitStageV1("quotient/restore", s.chRestoreLRO); err != nil { -+ return err - } - - close(s.chH) -@@ -724,14 +878,13 @@ - - func (s *instance) buildRatioCopyConstraint() (err error) { - // wait for gamma and beta to be derived (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chGammaBeta: -+ if err := s.waitStageV1("z-permutation/gamma-beta", s.chGammaBeta); err != nil { -+ return err - } - - // TODO @gbotrel having iop.BuildRatioCopyConstraint return something - // with capacity = len() + 4 would avoid extra alloc / copy during openZ -+ permutationStart := s.beginStageV1() - s.x[id_Z], err = iop.BuildRatioCopyConstraint( - []*iop.Polynomial{ - s.x[id_L], -@@ -744,12 +897,15 @@ - iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}, - s.domain0, - ) -+ s.endStageV1("permutation", "compute", permutationStart, err != nil) - if err != nil { - return err - } - - // commit to the blinded version of z -+ commitStart := s.beginStageV1() - s.proof.Z, err = s.commitToPolyAndBlinding(s.x[id_Z], s.bp[id_Bz]) -+ s.endStageV1("z-commitment", "compute", commitStart, err != nil) - - close(s.chZ) - -@@ -759,11 +915,11 @@ - // open Z (blinded) at ωζ - func (s *instance) openZ() (err error) { - // wait for H to be committed and zeta to be derived (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chH: -- } -+ if err := s.waitStageV1("z-opening/h", s.chH); err != nil { -+ return err -+ } -+ openingStart := s.beginStageV1() -+ defer func() { s.endStageV1("z-opening", "compute", openingStart, err != nil) }() - var zetaShifted fr.Element - zetaShifted.Mul(&s.zeta, &s.pk.Vk.Generator) - s.blindedZ = getBlindedCoefficients(s.x[id_Z], s.bp[id_Bz]) -@@ -816,10 +972,8 @@ - func (s *instance) computeLinearizedPolynomial() error { - - // wait for H to be committed and zeta to be derived (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chH: -+ if err := s.waitStageV1("linearization/h", s.chH); err != nil { -+ return err - } - - qcpzeta := make([]fr.Element, len(s.commitmentInfo)) -@@ -829,35 +983,44 @@ - - for i := 0; i < len(s.commitmentInfo); i++ { - go func(i int) { -+ evaluationStart := s.beginStageV1() - qcpzeta[i] = s.trace.Qcp[i].Evaluate(s.zeta) -+ s.endStageV1("evaluation-qcp", "compute", evaluationStart, false) - wg.Done() - }(i) - } - - go func() { -+ evaluationStart := s.beginStageV1() - blzeta = evaluateBlinded(s.x[id_L], s.bp[id_Bl], s.zeta) -+ s.endStageV1("evaluation-l", "compute", evaluationStart, false) - wg.Done() - }() - - go func() { -+ evaluationStart := s.beginStageV1() - brzeta = evaluateBlinded(s.x[id_R], s.bp[id_Br], s.zeta) -+ s.endStageV1("evaluation-r", "compute", evaluationStart, false) - wg.Done() - }() - - go func() { -+ evaluationStart := s.beginStageV1() - bozeta = evaluateBlinded(s.x[id_O], s.bp[id_Bo], s.zeta) -+ s.endStageV1("evaluation-o", "compute", evaluationStart, false) - wg.Done() - }() - - // wait for Z to be opened at zeta (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chZOpening: -+ if err := s.waitStageV1("linearization/z-opening", s.chZOpening); err != nil { -+ return err - } - bzuzeta := s.proof.ZShiftedOpening.ClaimedValue - -+ evaluationsWait := s.beginStageV1() - wg.Wait() -+ s.endStageV1("linearization/evaluations", "wait", evaluationsWait, false) -+ linearizationStart := s.beginStageV1() - - s.linearizedPolynomial = s.innerComputeLinearizedPoly( - blzeta, -@@ -874,8 +1037,11 @@ - s.pk, - ) - -+ s.endStageV1("linearization", "compute", linearizationStart, false) -+ commitStart := s.beginStageV1() - var err error - s.linearizedPolynomialDigest, err = s.pk.commit(s.pk.Kzg, s.linearizedPolynomial, runtime.NumCPU()*2) -+ s.endStageV1("linearization-commitment", "compute", commitStart, err != nil) - if err != nil { - return err - } -@@ -883,15 +1049,15 @@ - return nil - } - --func (s *instance) batchOpening() error { -+func (s *instance) batchOpening() (err error) { - - // wait for linearizedPolynomial to be computed (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLinearizedPolynomial: -- } -- -+ if err := s.waitStageV1("batch-opening/linearization", s.chLinearizedPolynomial); err != nil { -+ return err -+ } -+ -+ openingStart := s.beginStageV1() -+ defer func() { s.endStageV1("batch-opening", "compute", openingStart, err != nil) }() - polysQcp := coefficients(s.trace.Qcp) - polysToOpen := make([][]fr.Element, 6+len(polysQcp)) - copy(polysToOpen[6:], polysQcp) -@@ -913,7 +1079,6 @@ - digestsToOpen[4] = s.pk.Vk.S[0] - digestsToOpen[5] = s.pk.Vk.S[1] - -- var err error - s.proof.BatchedProof, err = kzg.BatchOpenSinglePointWithCommitter( - polysToOpen, - digestsToOpen, -@@ -930,13 +1095,21 @@ - // proving key's Accelerator. Like computeNumerator, it leaves the polynomials - // in x in canonical regular form; unlike it, the restoration is synchronous. - func (s *instance) computeNumeratorAccelerated() (*iop.Polynomial, error) { -+ return s.computeAcceleratedQuotient(false) -+} -+ -+func (s *instance) computeQuotientAccelerated() (*iop.Polynomial, error) { -+ return s.computeAcceleratedQuotient(true) -+} -+ -+func (s *instance) computeAcceleratedQuotient(fused bool) (_ *iop.Polynomial, err error) { - // wait for chQk to be closed (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return nil, errContextDone -- case <-s.chQk: -- } -- -+ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { -+ return nil, err -+ } -+ -+ computeStart := s.beginStageV1() -+ defer func() { s.endStageV1("quotient", "compute", computeStart, err != nil) }() - in := &NumeratorInput{ - Domain0: s.domain0, - Domain1: s.domain1, -@@ -967,8 +1140,18 @@ - in.Pi[i] = s.x[id_Qci+2*i+1] - } - -- res, err := s.pk.accelerator.ComputeNumerator(in) -+ blindings := snapshotAcceleratorBlindings(in) -+ state := s.pk.accelerator.Load() -+ var res *iop.Polynomial -+ if fused { -+ res, err = state.quotient.ComputeQuotientV1(in) -+ } else { -+ res, err = state.implementation.ComputeNumerator(in) -+ } - if err != nil { -+ return nil, err -+ } -+ if err := validateAcceleratorResult(in, res, fused, blindings); err != nil { - return nil, err - } - -@@ -979,9 +1162,63 @@ - return res, nil - } - -+type acceleratorBlindingSnapshot struct { -+ polynomial *iop.Polynomial -+ basis iop.Basis -+ layout iop.Layout -+ size int -+ coefficients []fr.Element -+} -+ -+func snapshotAcceleratorBlindings(in *NumeratorInput) []acceleratorBlindingSnapshot { -+ result := make([]acceleratorBlindingSnapshot, 0, 4) -+ for _, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { -+ result = append(result, acceleratorBlindingSnapshot{ -+ polynomial: p, basis: p.Basis, layout: p.Layout, size: p.Size(), -+ coefficients: append([]fr.Element(nil), p.Coefficients()...), -+ }) -+ } -+ return result -+} -+ -+func validateAcceleratorResult(in *NumeratorInput, result *iop.Polynomial, fused bool, blindings []acceleratorBlindingSnapshot) error { -+ basis, layout := iop.LagrangeCoset, iop.BitReverse -+ if fused { -+ basis, layout = iop.Canonical, iop.Regular -+ } -+ if result == nil || result.Basis != basis || result.Layout != layout || -+ uint64(result.Size()) != in.Domain1.Cardinality || -+ uint64(len(result.Coefficients())) != in.Domain1.Cardinality { -+ return errors.New("accelerator returned an invalid quotient-domain polynomial") -+ } -+ inputs := []*iop.Polynomial{in.L, in.R, in.O, in.Z, in.Qk, in.Ql, in.Qr, in.Qm, in.Qo, in.S1, in.S2, in.S3} -+ inputs = append(inputs, in.Qcp...) -+ inputs = append(inputs, in.Pi...) -+ for _, p := range inputs { -+ if p == nil || p.Basis != iop.Canonical || p.Layout != iop.Regular || -+ uint64(p.Size()) != in.Domain0.Cardinality || -+ uint64(len(p.Coefficients())) != in.Domain0.Cardinality { -+ return errors.New("accelerator did not restore canonical input polynomials") -+ } -+ } -+ for _, saved := range blindings { -+ p := saved.polynomial -+ if p.Basis != saved.basis || p.Layout != saved.layout || p.Size() != saved.size || -+ len(p.Coefficients()) != len(saved.coefficients) { -+ return errors.New("accelerator changed a blinding polynomial") -+ } -+ for i, coefficient := range saved.coefficients { -+ if coefficient != p.Coefficients()[i] { -+ return errors.New("accelerator changed a blinding polynomial") -+ } -+ } -+ } -+ return nil -+} -+ - // evaluate the full set of constraints, all polynomials in x are back in - // canonical regular form at the end --func (s *instance) computeNumerator() (*iop.Polynomial, error) { -+func (s *instance) computeNumerator() (_ *iop.Polynomial, err error) { - // init vectors that are used multiple times throughout the computation - n := s.domain0.Cardinality - twiddles0 := make([]fr.Element, n) -@@ -1001,12 +1238,12 @@ - } - - // wait for chQk to be closed (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return nil, errContextDone -- case <-s.chQk: -- } -- -+ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { -+ return nil, err -+ } -+ -+ computeStart := s.beginStageV1() -+ defer func() { s.endStageV1("quotient-numerator", "compute", computeStart, err != nil) }() - nbBsbGates := len(s.proof.Bsb22Commitments) - - gateConstraint := func(u ...fr.Element) fr.Element { -@@ -1231,6 +1468,7 @@ - - // scale everything back - go func() { -+ restoreStart := s.beginStageV1() - s.x[id_ZS] = nil - s.x[id_Qk] = nil - -@@ -1253,6 +1491,7 @@ - scalePowers(q, cs) - } - -+ s.endStageV1("polynomial-restoration", "compute", restoreStart, false) - close(s.chRestoreLRO) - }() - -@@ -1459,9 +1698,12 @@ - } - }) - -- if pk.accelerator != nil { -- if err := pk.accelerator.ToCanonical(bigDomain, a); err != nil { -+ if pk.accelerated(AcceleratorFeatureToCanonicalV1) { -+ if err := pk.accelerator.Load().implementation.ToCanonical(bigDomain, a); err != nil { - return nil, err -+ } -+ if a.Basis != iop.Canonical || a.Layout != iop.Regular || uint64(len(a.Coefficients())) != bigDomain.Cardinality { -+ return nil, errors.New("accelerator canonical conversion returned an invalid polynomial") - } - return a, nil - } -diff --git a/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl b/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl ---- a/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl -+++ b/internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl -@@ -4,6 +4,7 @@ - {{- template "import_fft" . }} - {{- template "import_backend_cs" . }} - "fmt" -+ "sync/atomic" - "github.com/consensys/gnark-crypto/ecc/{{toLower .Curve}}/fr/iop" - "github.com/consensys/gnark/backend/plonk/internal" - "github.com/consensys/gnark/constraint" -@@ -86,7 +87,8 @@ - // accelerator, when set, offloads the multi-scalar multiplications, the - // quotient numerator and the large inverse FFT of Prove (see Accelerator). - // Runtime-only, not serialized. -- accelerator Accelerator -+ accelerator atomic.Pointer[acceleratorState] -+ stageObserver atomic.Pointer[stageObserverStateV1] - } - - func Setup(spr *cs.SparseR1CS, srs, srsLagrange kzg.SRS) (*ProvingKey, *VerifyingKey, error) { diff --git a/tools/plonk-wrapper/patches/gnark-cuda-v1.json b/tools/plonk-wrapper/patches/gnark-cuda-v1.json deleted file mode 100644 index 30dbae555..000000000 --- a/tools/plonk-wrapper/patches/gnark-cuda-v1.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": "v0.16.5-0.20261015185324-262cf453c107", - "commit": "262cf453c107d147a228b6b7418c5cf791c3b79b", - "files": { - "backend/plonk/bn254/prove.go": { - "before": "d83326a2676352d1d00efa3260128fd094d7250400a91f4d765a2a64a2e5ac57", - "after": "36add5ba1f06a4d23552f80045912d161bbc89378a9797439f8c3099eb3545cd" - }, - "backend/plonk/bn254/setup.go": { - "before": "c4585b77aaabd6b14cdb163d100b39c6e4d390cc1b6a6bbcb79d08dab3cfb343", - "after": "51ba9060e878f28488fda677163f225f5dda68c122a3e6df1aec499ae08e1a44" - }, - "backend/plonk/bn254/accelerator_v1_test.go": { - "before": null, - "after": "1b4b120fa5e32f7e65c2bbee10ed9ea70ebdc89ed3cd0ef84f80268c240d5ab6" - }, - "internal/generator/backend/template/zkpschemes/plonk/plonk.prove.go.tmpl": { - "before": "48c5fc2246266e30aa4be92d4827b987e57b5c0207269684ca0ea22374027b06", - "after": "b92c0d0f86aac9fb6fc261d3e83581f4539a93014928a2c1aaa474635342b5cc" - }, - "internal/generator/backend/template/zkpschemes/plonk/plonk.setup.go.tmpl": { - "before": "de5e7b25954070babc2b968159cd15c0cc22cef1b6904ae3a11f8391ba48d991", - "after": "fdca533c84ab307c95a3556f65f4254207ad62b23a1bfdb92993f658d73ae3d5" - } - } -} diff --git a/tools/plonk-wrapper/patches/gnark-cuda-v1.patch b/tools/plonk-wrapper/patches/gnark-cuda-v1.patch deleted file mode 100644 index a72384f64..000000000 --- a/tools/plonk-wrapper/patches/gnark-cuda-v1.patch +++ /dev/null @@ -1,1261 +0,0 @@ -diff --git a/backend/plonk/bn254/prove.go b/backend/plonk/bn254/prove.go ---- a/backend/plonk/bn254/prove.go -+++ b/backend/plonk/bn254/prove.go -@@ -123,6 +123,96 @@ - ComputeNumerator(in *NumeratorInput) (*iop.Polynomial, error) - } - -+// AcceleratorFeatureFlagsV1 selects operations before a proof mutates its polynomials. -+type AcceleratorFeatureFlagsV1 uint64 -+ -+const ( -+ AcceleratorFeatureMSMV1 AcceleratorFeatureFlagsV1 = 1 << iota -+ AcceleratorFeatureToCanonicalV1 -+ AcceleratorFeatureNumeratorV1 -+ AcceleratorFeatureQuotientV1 -+) -+ -+const legacyAcceleratorFeaturesV1 = AcceleratorFeatureMSMV1 | AcceleratorFeatureToCanonicalV1 | AcceleratorFeatureNumeratorV1 -+const allAcceleratorFeaturesV1 = legacyAcceleratorFeaturesV1 | AcceleratorFeatureQuotientV1 -+ -+// AcceleratorFeaturesV1 optionally selects accelerated operations. FeaturesV1 -+// is called exactly once per Prove; its result is frozen for that proof. -+// Implementations must make this method safe for concurrent proofs. -+type AcceleratorFeaturesV1 interface { -+ FeaturesV1() AcceleratorFeatureFlagsV1 -+} -+ -+// QuotientAcceleratorV1 computes H after division by X^N-1 and the inverse FFT. -+// Its result has Domain1.Cardinality coefficients in Canonical/Regular form. -+// Every nonblinding input must be restored to Canonical/Regular on Domain0; -+// blinding polynomials must remain unchanged. The corresponding feature bit -+// must be set explicitly; implementing this interface alone does not enable it. -+type QuotientAcceleratorV1 interface { -+ ComputeQuotientV1(*NumeratorInput) (*iop.Polynomial, error) -+} -+ -+type acceleratorState struct { -+ implementation Accelerator -+ features AcceleratorFeatureFlagsV1 -+ quotient QuotientAcceleratorV1 -+} -+ -+// StageEventV1 is a monotonic wall-clock interval relative to proof initialization. -+// Task spans include dependency waits and nested compute spans. Compute spans may -+// include backend queueing; overlapping intervals must not be summed. -+type StageEventV1 struct { -+ Stage, Phase string -+ Start, End time.Duration -+ Failed bool -+} -+ -+type stageObserverStateV1 struct{ callback func(StageEventV1) } -+ -+// SetStageObserverV1 installs a runtime-only diagnostic callback. Prove freezes -+// the callback at entry. It may be called concurrently and must not panic or -+// block. Passing nil disables observation; key serialization is unchanged. -+func (pk *ProvingKey) SetStageObserverV1(observer func(StageEventV1)) { -+ if observer == nil { -+ pk.stageObserver.Store(nil) -+ return -+ } -+ pk.stageObserver.Store(&stageObserverStateV1{callback: observer}) -+} -+ -+// proofKey snapshots runtime dispatch while retaining the immutable key arrays. -+// Concurrent SetAccelerator calls cannot change an already-started proof. -+func (pk *ProvingKey) proofKey() (*ProvingKey, error) { -+ view := &ProvingKey{Kzg: pk.Kzg, KzgLagrange: pk.KzgLagrange, Vk: pk.Vk} -+ view.stageObserver.Store(pk.stageObserver.Load()) -+ configured := pk.accelerator.Load() -+ if configured == nil { -+ return view, nil -+ } -+ features := legacyAcceleratorFeaturesV1 -+ if selectable, ok := configured.implementation.(AcceleratorFeaturesV1); ok { -+ features = selectable.FeaturesV1() -+ } -+ if features & ^allAcceleratorFeaturesV1 != 0 { -+ return nil, fmt.Errorf("unsupported accelerator feature bits: %#x", features & ^allAcceleratorFeaturesV1) -+ } -+ state := &acceleratorState{implementation: configured.implementation, features: features} -+ if features&AcceleratorFeatureQuotientV1 != 0 { -+ var ok bool -+ state.quotient, ok = configured.implementation.(QuotientAcceleratorV1) -+ if !ok { -+ return nil, errors.New("quotient feature requires QuotientAcceleratorV1") -+ } -+ } -+ view.accelerator.Store(state) -+ return view, nil -+} -+ -+func (pk *ProvingKey) accelerated(feature AcceleratorFeatureFlagsV1) bool { -+ state := pk.accelerator.Load() -+ return state != nil && state.features&feature != 0 -+} -+ - // NumeratorInput is the input of Accelerator.ComputeNumerator. All polynomials - // have Domain0.Cardinality coefficients except the blinding polynomials, which - // are small-degree canonical polynomials. -@@ -145,39 +235,47 @@ - // SetAccelerator attaches an Accelerator to the proving key. Passing nil restores - // the CPU implementation. - func (pk *ProvingKey) SetAccelerator(a Accelerator) { -- pk.accelerator = a -+ if a == nil { -+ pk.accelerator.Store(nil) -+ return -+ } -+ pk.accelerator.Store(&acceleratorState{implementation: a, features: legacyAcceleratorFeaturesV1}) - } - - // Accelerator returns the accelerator attached to the proving key, or nil. - func (pk *ProvingKey) Accelerator() Accelerator { -- return pk.accelerator -+ state := pk.accelerator.Load() -+ if state == nil { -+ return nil -+ } -+ return state.implementation - } - - // commit computes the KZG commitment of p with key. nbTasks is a parallelism - // hint for the CPU implementation. - func (pk *ProvingKey) commit(key kzg.ProvingKey, p []fr.Element, nbTasks ...int) (kzg.Digest, error) { -- if pk.accelerator == nil { -+ if !pk.accelerated(AcceleratorFeatureMSMV1) { - return kzg.Commit(p, key, nbTasks...) - } - if len(p) > len(key.G1) { - return kzg.Digest{}, kzg.ErrInvalidPolynomialSize - } -- return pk.accelerator.MultiExp(key.G1[:len(p)], p) -+ return pk.accelerator.Load().implementation.MultiExp(key.G1[:len(p)], p) - } - - func (pk *ProvingKey) multiExp(res *curve.G1Affine, bases []curve.G1Affine, scalars []fr.Element) error { -- if pk.accelerator == nil { -+ if !pk.accelerated(AcceleratorFeatureMSMV1) { - _, err := res.MultiExp(bases, scalars, ecc.MultiExpConfig{}) - return err - } - var err error -- *res, err = pk.accelerator.MultiExp(bases, scalars) -+ *res, err = pk.accelerator.Load().implementation.MultiExp(bases, scalars) - return err - } - - // committer returns the kzg.Committer used for KZG openings with key. - func (pk *ProvingKey) committer(key kzg.ProvingKey) kzg.Committer { -- if pk.accelerator == nil { -+ if !pk.accelerated(AcceleratorFeatureMSMV1) { - return key - } - return acceleratedCommitter{pk: pk, key: key} -@@ -192,7 +290,12 @@ - return c.pk.commit(c.key, p) - } - --func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (*Proof, error) { -+func Prove(spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts ...backend.ProverOption) (_ *Proof, err error) { -+ -+ pk, err = pk.proofKey() -+ if err != nil { -+ return nil, fmt.Errorf("accelerator configuration: %w", err) -+ } - - log := logger.Logger().With(). - Str("curve", spr.CurveID().String()). -@@ -209,37 +312,48 @@ - - // init instance - g, ctx := errgroup.WithContext(context.Background()) -+ observer := pk.stageObserver.Load() -+ if observer != nil { -+ defer func() { -+ observer.callback(StageEventV1{Stage: "prove", Phase: "task", End: time.Since(start), Failed: err != nil}) -+ }() -+ } - instance, err := newInstance(ctx, spr, pk, fullWitness, &opt) -+ if observer != nil { -+ observer.callback(StageEventV1{Stage: "initialization", Phase: "compute", End: time.Since(start), Failed: err != nil}) -+ } - if err != nil { - return nil, fmt.Errorf("new instance: %w", err) - } - -+ instance.stageObserver, instance.stageOrigin = observer, start -+ - // solve constraints -- g.Go(instance.solveConstraints) -+ g.Go(instance.observeTaskV1("solver-lro", instance.solveConstraints)) - - // complete qk -- g.Go(instance.completeQk) -+ g.Go(instance.observeTaskV1("qk", instance.completeQk)) - - // init blinding polynomials -- g.Go(instance.initBlindingPolynomials) -+ g.Go(instance.observeTaskV1("blinding", instance.initBlindingPolynomials)) - - // derive gamma, beta (copy constraint) -- g.Go(instance.deriveGammaAndBeta) -+ g.Go(instance.observeTaskV1("gamma-beta", instance.deriveGammaAndBeta)) - - // compute accumulating ratio for the copy constraint -- g.Go(instance.buildRatioCopyConstraint) -+ g.Go(instance.observeTaskV1("z-permutation", instance.buildRatioCopyConstraint)) - - // compute h -- g.Go(instance.computeQuotient) -+ g.Go(instance.observeTaskV1("quotient-h", instance.computeQuotient)) - - // open Z (blinded) at ωζ (proof.ZShiftedOpening) -- g.Go(instance.openZ) -+ g.Go(instance.observeTaskV1("z-opening", instance.openZ)) - - // linearized polynomial -- g.Go(instance.computeLinearizedPolynomial) -+ g.Go(instance.observeTaskV1("evaluations-linearization", instance.computeLinearizedPolynomial)) - - // Batch opening -- g.Go(instance.batchOpening) -+ g.Go(instance.observeTaskV1("batch-opening", instance.batchOpening)) - - if err := g.Wait(); err != nil { - return nil, err -@@ -251,7 +365,9 @@ - - // represents a Prover instance - type instance struct { -- ctx context.Context -+ ctx context.Context -+ stageObserver *stageObserverStateV1 -+ stageOrigin time.Time - - pk *ProvingKey - proof *Proof -@@ -297,6 +413,43 @@ - domain0, domain1 *fft.Domain - - trace *Trace -+} -+ -+func (s *instance) beginStageV1() time.Time { -+ if s.stageObserver == nil { -+ return time.Time{} -+ } -+ return time.Now() -+} -+ -+func (s *instance) endStageV1(stage, phase string, start time.Time, failed bool) { -+ if s.stageObserver == nil { -+ return -+ } -+ s.stageObserver.callback(StageEventV1{Stage: stage, Phase: phase, -+ Start: start.Sub(s.stageOrigin), End: time.Since(s.stageOrigin), Failed: failed}) -+} -+ -+func (s *instance) observeTaskV1(stage string, fn func() error) func() error { -+ if s.stageObserver == nil { -+ return fn -+ } -+ return func() (err error) { -+ start := s.beginStageV1() -+ defer func() { s.endStageV1(stage, "task", start, err != nil) }() -+ return fn() -+ } -+} -+ -+func (s *instance) waitStageV1(stage string, ready <-chan struct{}) (err error) { -+ start := s.beginStageV1() -+ defer func() { s.endStageV1(stage, "wait", start, err != nil) }() -+ select { -+ case <-s.ctx.Done(): -+ return errContextDone -+ case <-ready: -+ return nil -+ } - } - - func newInstance(ctx context.Context, spr *cs.SparseR1CS, pk *ProvingKey, fullWitness witness.Witness, opts *backend.ProverConfig) (*instance, error) { -@@ -414,10 +567,13 @@ - // solveConstraints computes the evaluation of the polynomials L, R, O - // and sets x[id_L], x[id_R], x[id_O] in Lagrange form - func (s *instance) solveConstraints() error { -+ solverStart := s.beginStageV1() - _solution, err := s.spr.Solve(s.fullWitness, s.opt.SolverOpts...) -+ s.endStageV1("solver", "compute", solverStart, err != nil) - if err != nil { - return err - } -+ polynomialsStart := s.beginStageV1() - solution := _solution.(*cs.SparseR1CSSolution) - evaluationLDomainSmall := []fr.Element(solution.L) - evaluationRDomainSmall := []fr.Element(solution.R) -@@ -436,6 +592,7 @@ - s.x[id_O] = iop.NewPolynomial(&evaluationODomainSmall, iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}) - - wg.Wait() -+ s.endStageV1("witness-polynomials", "compute", polynomialsStart, false) - - // commit to l, r, o and add blinding factors - if err := s.commitToLRO(); err != nil { -@@ -457,10 +614,8 @@ - copy(qkCoeffs, wWitness[:len(s.spr.Public)]) - - // wait for solver to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLRO: -+ if err := s.waitStageV1("qk/lro", s.chLRO); err != nil { -+ return err - } - - for i := range s.commitmentInfo { -@@ -498,14 +653,14 @@ - // The (P[i]-s0) terms are zero in the padding region, so the MSM only needs - // the non-padding entries. For a 2.2M-constraint circuit on a 4M domain, - // this nearly halves each MSM. --func (s *instance) commitToLRO() error { -+func (s *instance) commitToLRO() (err error) { - // wait for blinding polynomials to be initialized or context to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chbp: -- } -- -+ if err := s.waitStageV1("lro/blinding", s.chbp); err != nil { -+ return err -+ } -+ -+ lroStart := s.beginStageV1() -+ defer func() { s.endStageV1("lro", "compute", lroStart, err != nil) }() - n := int(s.domain0.Cardinality) - nbPublic := len(s.spr.Public) - offset := nbPublic + s.spr.GetNbConstraints() -@@ -527,6 +682,8 @@ - - // L: subtract s0, MSM on [0:offset], add correction + blinding, restore - g.Go(func() (err error) { -+ commitStart := s.beginStageV1() -+ defer func() { s.endStageV1("l-commitment", "compute", commitStart, err != nil) }() - coeffs := s.x[id_L].Coefficients() - for i := 0; i < offset; i++ { - coeffs[i].Sub(&coeffs[i], &s0) -@@ -546,6 +703,8 @@ - - // R: subtract s0, MSM on [nbPublic:offset], add correction + blinding, restore - g.Go(func() (err error) { -+ commitStart := s.beginStageV1() -+ defer func() { s.endStageV1("r-commitment", "compute", commitStart, err != nil) }() - coeffs := s.x[id_R].Coefficients() - for i := nbPublic; i < offset; i++ { - coeffs[i].Sub(&coeffs[i], &s0) -@@ -565,6 +724,8 @@ - - // O: same as R - g.Go(func() (err error) { -+ commitStart := s.beginStageV1() -+ defer func() { s.endStageV1("o-commitment", "compute", commitStart, err != nil) }() - coeffs := s.x[id_O].Coefficients() - for i := nbPublic; i < offset; i++ { - coeffs[i].Sub(&coeffs[i], &s0) -@@ -597,10 +758,8 @@ - } - - // wait for LRO to be committed -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLRO: -+ if err := s.waitStageV1("gamma-beta/lro", s.chLRO); err != nil { -+ return err - } - - gamma, err := deriveRandomness(s.fs, "gamma", &s.proof.LRO[0], &s.proof.LRO[1], &s.proof.LRO[2]) -@@ -653,6 +812,7 @@ - - // computeQuotient computes H - func (s *instance) computeQuotient() (err error) { -+ prepareStart := s.beginStageV1() - s.x[id_Ql] = s.trace.Ql - s.x[id_Qr] = s.trace.Qr - s.x[id_Qm] = s.trace.Qm -@@ -669,11 +829,11 @@ - lone := make([]fr.Element, n) - lone[0].SetOne() - -+ s.endStageV1("quotient-preparation", "compute", prepareStart, false) -+ - // wait for solver to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLRO: -+ if err := s.waitStageV1("quotient/lro", s.chLRO); err != nil { -+ return err - } - - for i := 0; i < len(s.commitmentInfo); i++ { -@@ -681,10 +841,8 @@ - } - - // wait for Z to be committed or context done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chZ: -+ if err := s.waitStageV1("quotient/z", s.chZ); err != nil { -+ return err - } - - // derive alpha -@@ -698,24 +856,31 @@ - - s.x[id_ZS] = s.x[id_Z].ShallowClone().Shift(1) - -- var numerator *iop.Polynomial -- if s.pk.accelerator == nil { -- numerator, err = s.computeNumerator() -+ if s.pk.accelerated(AcceleratorFeatureQuotientV1) { -+ s.h, err = s.computeQuotientAccelerated() - } else { -- numerator, err = s.computeNumeratorAccelerated() -+ var numerator *iop.Polynomial -+ if s.pk.accelerated(AcceleratorFeatureNumeratorV1) { -+ numerator, err = s.computeNumeratorAccelerated() -+ } else { -+ numerator, err = s.computeNumerator() -+ } -+ if err == nil { -+ divideStart := s.beginStageV1() -+ s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) -+ s.endStageV1("quotient-division", "compute", divideStart, err != nil) -+ } - } - if err != nil { - return err - } - -- s.h, err = divideByZH(numerator, [2]*fft.Domain{s.domain0, s.domain1}, s.pk) -- if err != nil { -- return err -- } -- - // commit to h -- if err := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk); err != nil { -- return err -+ commitStart := s.beginStageV1() -+ commitErr := commitToQuotient(s.h1(), s.h2(), s.h3(), s.proof, s.pk) -+ s.endStageV1("h-commitments", "compute", commitStart, commitErr != nil) -+ if commitErr != nil { -+ return commitErr - } - - if err := s.deriveZeta(); err != nil { -@@ -723,10 +888,8 @@ - } - - // wait for clean up tasks to be done -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chRestoreLRO: -+ if err := s.waitStageV1("quotient/restore", s.chRestoreLRO); err != nil { -+ return err - } - - close(s.chH) -@@ -736,14 +899,13 @@ - - func (s *instance) buildRatioCopyConstraint() (err error) { - // wait for gamma and beta to be derived (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chGammaBeta: -+ if err := s.waitStageV1("z-permutation/gamma-beta", s.chGammaBeta); err != nil { -+ return err - } - - // TODO @gbotrel having iop.BuildRatioCopyConstraint return something - // with capacity = len() + 4 would avoid extra alloc / copy during openZ -+ permutationStart := s.beginStageV1() - s.x[id_Z], err = iop.BuildRatioCopyConstraint( - []*iop.Polynomial{ - s.x[id_L], -@@ -756,12 +918,15 @@ - iop.Form{Basis: iop.Lagrange, Layout: iop.Regular}, - s.domain0, - ) -+ s.endStageV1("permutation", "compute", permutationStart, err != nil) - if err != nil { - return err - } - - // commit to the blinded version of z -+ commitStart := s.beginStageV1() - s.proof.Z, err = s.commitToPolyAndBlinding(s.x[id_Z], s.bp[id_Bz]) -+ s.endStageV1("z-commitment", "compute", commitStart, err != nil) - - close(s.chZ) - -@@ -771,11 +936,11 @@ - // open Z (blinded) at ωζ - func (s *instance) openZ() (err error) { - // wait for H to be committed and zeta to be derived (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chH: -- } -+ if err := s.waitStageV1("z-opening/h", s.chH); err != nil { -+ return err -+ } -+ openingStart := s.beginStageV1() -+ defer func() { s.endStageV1("z-opening", "compute", openingStart, err != nil) }() - var zetaShifted fr.Element - zetaShifted.Mul(&s.zeta, &s.pk.Vk.Generator) - s.blindedZ = getBlindedCoefficients(s.x[id_Z], s.bp[id_Bz]) -@@ -828,10 +993,8 @@ - func (s *instance) computeLinearizedPolynomial() error { - - // wait for H to be committed and zeta to be derived (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chH: -+ if err := s.waitStageV1("linearization/h", s.chH); err != nil { -+ return err - } - - qcpzeta := make([]fr.Element, len(s.commitmentInfo)) -@@ -841,35 +1004,44 @@ - - for i := 0; i < len(s.commitmentInfo); i++ { - go func(i int) { -+ evaluationStart := s.beginStageV1() - qcpzeta[i] = s.trace.Qcp[i].Evaluate(s.zeta) -+ s.endStageV1("evaluation-qcp", "compute", evaluationStart, false) - wg.Done() - }(i) - } - - go func() { -+ evaluationStart := s.beginStageV1() - blzeta = evaluateBlinded(s.x[id_L], s.bp[id_Bl], s.zeta) -+ s.endStageV1("evaluation-l", "compute", evaluationStart, false) - wg.Done() - }() - - go func() { -+ evaluationStart := s.beginStageV1() - brzeta = evaluateBlinded(s.x[id_R], s.bp[id_Br], s.zeta) -+ s.endStageV1("evaluation-r", "compute", evaluationStart, false) - wg.Done() - }() - - go func() { -+ evaluationStart := s.beginStageV1() - bozeta = evaluateBlinded(s.x[id_O], s.bp[id_Bo], s.zeta) -+ s.endStageV1("evaluation-o", "compute", evaluationStart, false) - wg.Done() - }() - - // wait for Z to be opened at zeta (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chZOpening: -+ if err := s.waitStageV1("linearization/z-opening", s.chZOpening); err != nil { -+ return err - } - bzuzeta := s.proof.ZShiftedOpening.ClaimedValue - -+ evaluationsWait := s.beginStageV1() - wg.Wait() -+ s.endStageV1("linearization/evaluations", "wait", evaluationsWait, false) -+ linearizationStart := s.beginStageV1() - - s.linearizedPolynomial = s.innerComputeLinearizedPoly( - blzeta, -@@ -886,8 +1058,11 @@ - s.pk, - ) - -+ s.endStageV1("linearization", "compute", linearizationStart, false) -+ commitStart := s.beginStageV1() - var err error - s.linearizedPolynomialDigest, err = s.pk.commit(s.pk.Kzg, s.linearizedPolynomial, runtime.NumCPU()*2) -+ s.endStageV1("linearization-commitment", "compute", commitStart, err != nil) - if err != nil { - return err - } -@@ -895,15 +1070,15 @@ - return nil - } - --func (s *instance) batchOpening() error { -+func (s *instance) batchOpening() (err error) { - - // wait for linearizedPolynomial to be computed (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return errContextDone -- case <-s.chLinearizedPolynomial: -- } -- -+ if err := s.waitStageV1("batch-opening/linearization", s.chLinearizedPolynomial); err != nil { -+ return err -+ } -+ -+ openingStart := s.beginStageV1() -+ defer func() { s.endStageV1("batch-opening", "compute", openingStart, err != nil) }() - polysQcp := coefficients(s.trace.Qcp) - polysToOpen := make([][]fr.Element, 6+len(polysQcp)) - copy(polysToOpen[6:], polysQcp) -@@ -925,7 +1100,6 @@ - digestsToOpen[4] = s.pk.Vk.S[0] - digestsToOpen[5] = s.pk.Vk.S[1] - -- var err error - s.proof.BatchedProof, err = kzg.BatchOpenSinglePointWithCommitter( - polysToOpen, - digestsToOpen, -@@ -942,13 +1116,21 @@ - // proving key's Accelerator. Like computeNumerator, it leaves the polynomials - // in x in canonical regular form; unlike it, the restoration is synchronous. - func (s *instance) computeNumeratorAccelerated() (*iop.Polynomial, error) { -+ return s.computeAcceleratedQuotient(false) -+} -+ -+func (s *instance) computeQuotientAccelerated() (*iop.Polynomial, error) { -+ return s.computeAcceleratedQuotient(true) -+} -+ -+func (s *instance) computeAcceleratedQuotient(fused bool) (_ *iop.Polynomial, err error) { - // wait for chQk to be closed (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return nil, errContextDone -- case <-s.chQk: -- } -- -+ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { -+ return nil, err -+ } -+ -+ computeStart := s.beginStageV1() -+ defer func() { s.endStageV1("quotient", "compute", computeStart, err != nil) }() - in := &NumeratorInput{ - Domain0: s.domain0, - Domain1: s.domain1, -@@ -979,8 +1161,18 @@ - in.Pi[i] = s.x[id_Qci+2*i+1] - } - -- res, err := s.pk.accelerator.ComputeNumerator(in) -+ blindings := snapshotAcceleratorBlindings(in) -+ state := s.pk.accelerator.Load() -+ var res *iop.Polynomial -+ if fused { -+ res, err = state.quotient.ComputeQuotientV1(in) -+ } else { -+ res, err = state.implementation.ComputeNumerator(in) -+ } - if err != nil { -+ return nil, err -+ } -+ if err := validateAcceleratorResult(in, res, fused, blindings); err != nil { - return nil, err - } - -@@ -991,9 +1183,63 @@ - return res, nil - } - -+type acceleratorBlindingSnapshot struct { -+ polynomial *iop.Polynomial -+ basis iop.Basis -+ layout iop.Layout -+ size int -+ coefficients []fr.Element -+} -+ -+func snapshotAcceleratorBlindings(in *NumeratorInput) []acceleratorBlindingSnapshot { -+ result := make([]acceleratorBlindingSnapshot, 0, 4) -+ for _, p := range []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz} { -+ result = append(result, acceleratorBlindingSnapshot{ -+ polynomial: p, basis: p.Basis, layout: p.Layout, size: p.Size(), -+ coefficients: append([]fr.Element(nil), p.Coefficients()...), -+ }) -+ } -+ return result -+} -+ -+func validateAcceleratorResult(in *NumeratorInput, result *iop.Polynomial, fused bool, blindings []acceleratorBlindingSnapshot) error { -+ basis, layout := iop.LagrangeCoset, iop.BitReverse -+ if fused { -+ basis, layout = iop.Canonical, iop.Regular -+ } -+ if result == nil || result.Basis != basis || result.Layout != layout || -+ uint64(result.Size()) != in.Domain1.Cardinality || -+ uint64(len(result.Coefficients())) != in.Domain1.Cardinality { -+ return errors.New("accelerator returned an invalid quotient-domain polynomial") -+ } -+ inputs := []*iop.Polynomial{in.L, in.R, in.O, in.Z, in.Qk, in.Ql, in.Qr, in.Qm, in.Qo, in.S1, in.S2, in.S3} -+ inputs = append(inputs, in.Qcp...) -+ inputs = append(inputs, in.Pi...) -+ for _, p := range inputs { -+ if p == nil || p.Basis != iop.Canonical || p.Layout != iop.Regular || -+ uint64(p.Size()) != in.Domain0.Cardinality || -+ uint64(len(p.Coefficients())) != in.Domain0.Cardinality { -+ return errors.New("accelerator did not restore canonical input polynomials") -+ } -+ } -+ for _, saved := range blindings { -+ p := saved.polynomial -+ if p.Basis != saved.basis || p.Layout != saved.layout || p.Size() != saved.size || -+ len(p.Coefficients()) != len(saved.coefficients) { -+ return errors.New("accelerator changed a blinding polynomial") -+ } -+ for i, coefficient := range saved.coefficients { -+ if coefficient != p.Coefficients()[i] { -+ return errors.New("accelerator changed a blinding polynomial") -+ } -+ } -+ } -+ return nil -+} -+ - // evaluate the full set of constraints, all polynomials in x are back in - // canonical regular form at the end --func (s *instance) computeNumerator() (*iop.Polynomial, error) { -+func (s *instance) computeNumerator() (_ *iop.Polynomial, err error) { - // init vectors that are used multiple times throughout the computation - n := s.domain0.Cardinality - twiddles0 := make([]fr.Element, n) -@@ -1013,12 +1259,12 @@ - } - - // wait for chQk to be closed (or ctx.Done()) -- select { -- case <-s.ctx.Done(): -- return nil, errContextDone -- case <-s.chQk: -- } -- -+ if err := s.waitStageV1("quotient/qk", s.chQk); err != nil { -+ return nil, err -+ } -+ -+ computeStart := s.beginStageV1() -+ defer func() { s.endStageV1("quotient-numerator", "compute", computeStart, err != nil) }() - nbBsbGates := len(s.proof.Bsb22Commitments) - - gateConstraint := func(u ...fr.Element) fr.Element { -@@ -1243,6 +1489,7 @@ - - // scale everything back - go func() { -+ restoreStart := s.beginStageV1() - s.x[id_ZS] = nil - s.x[id_Qk] = nil - -@@ -1265,6 +1512,7 @@ - scalePowers(q, cs) - } - -+ s.endStageV1("polynomial-restoration", "compute", restoreStart, false) - close(s.chRestoreLRO) - }() - -@@ -1471,9 +1719,12 @@ - } - }) - -- if pk.accelerator != nil { -- if err := pk.accelerator.ToCanonical(bigDomain, a); err != nil { -+ if pk.accelerated(AcceleratorFeatureToCanonicalV1) { -+ if err := pk.accelerator.Load().implementation.ToCanonical(bigDomain, a); err != nil { - return nil, err -+ } -+ if a.Basis != iop.Canonical || a.Layout != iop.Regular || uint64(len(a.Coefficients())) != bigDomain.Cardinality { -+ return nil, errors.New("accelerator canonical conversion returned an invalid polynomial") - } - return a, nil - } -diff --git a/backend/plonk/bn254/setup.go b/backend/plonk/bn254/setup.go ---- a/backend/plonk/bn254/setup.go -+++ b/backend/plonk/bn254/setup.go -@@ -7,6 +7,7 @@ - - import ( - "fmt" -+ "sync/atomic" - - "github.com/consensys/gnark-crypto/ecc/bn254/fr" - "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" -@@ -94,7 +95,8 @@ - // accelerator, when set, offloads the multi-scalar multiplications, the - // quotient numerator and the large inverse FFT of Prove (see Accelerator). - // Runtime-only, not serialized. -- accelerator Accelerator -+ accelerator atomic.Pointer[acceleratorState] -+ stageObserver atomic.Pointer[stageObserverStateV1] - } - - func Setup(spr *cs.SparseR1CS, srs, srsLagrange kzg.SRS) (*ProvingKey, *VerifyingKey, error) { -diff --git a/backend/plonk/bn254/accelerator_v1_test.go b/backend/plonk/bn254/accelerator_v1_test.go -new file mode 100644 ---- /dev/null -+++ b/backend/plonk/bn254/accelerator_v1_test.go -@@ -0,0 +1,444 @@ -+package plonk -+ -+import ( -+ "context" -+ "errors" -+ "math/big" -+ "strings" -+ "sync" -+ "sync/atomic" -+ "testing" -+ "time" -+ -+ "github.com/consensys/gnark-crypto/ecc" -+ curve "github.com/consensys/gnark-crypto/ecc/bn254" -+ "github.com/consensys/gnark-crypto/ecc/bn254/fr" -+ "github.com/consensys/gnark-crypto/ecc/bn254/fr/fft" -+ "github.com/consensys/gnark-crypto/ecc/bn254/fr/iop" -+ "github.com/consensys/gnark-crypto/ecc/bn254/kzg" -+ "github.com/consensys/gnark/backend" -+ cs "github.com/consensys/gnark/constraint/bn254" -+ "github.com/consensys/gnark/frontend" -+ "github.com/consensys/gnark/frontend/cs/scs" -+) -+ -+type testAcceleratorV1 struct { -+ msm, canonical, numerator atomic.Int32 -+ failure AcceleratorFeatureFlagsV1 -+} -+ -+var testAcceleratorFailureV1 = errors.New("accelerator sentinel failure") -+ -+func (a *testAcceleratorV1) MultiExp(bases []curve.G1Affine, scalars []fr.Element) (curve.G1Affine, error) { -+ a.msm.Add(1) -+ if a.failure == AcceleratorFeatureMSMV1 { -+ return curve.G1Affine{}, testAcceleratorFailureV1 -+ } -+ var result curve.G1Affine -+ _, err := result.MultiExp(bases, scalars, ecc.MultiExpConfig{}) -+ return result, err -+} -+ -+func (a *testAcceleratorV1) ToCanonical(domain *fft.Domain, p *iop.Polynomial) error { -+ a.canonical.Add(1) -+ if a.failure == AcceleratorFeatureToCanonicalV1 { -+ return testAcceleratorFailureV1 -+ } -+ p.ToCanonical(domain).ToRegular() -+ return nil -+} -+ -+func (a *testAcceleratorV1) ComputeNumerator(in *NumeratorInput) (*iop.Polynomial, error) { -+ a.numerator.Add(1) -+ if a.failure == AcceleratorFeatureNumeratorV1 { -+ return nil, testAcceleratorFailureV1 -+ } -+ return testNumeratorReferenceV1(in) -+} -+ -+type testSelectiveAcceleratorV1 struct { -+ *testAcceleratorV1 -+ flags atomic.Uint64 -+ featureCalls atomic.Int32 -+} -+ -+func (a *testSelectiveAcceleratorV1) FeaturesV1() AcceleratorFeatureFlagsV1 { -+ a.featureCalls.Add(1) -+ return AcceleratorFeatureFlagsV1(a.flags.Load()) -+} -+ -+type testFusedAcceleratorV1 struct { -+ *testSelectiveAcceleratorV1 -+ quotient atomic.Int32 -+ corrupt func(*NumeratorInput, *iop.Polynomial) -+} -+ -+func (a *testFusedAcceleratorV1) ComputeQuotientV1(in *NumeratorInput) (*iop.Polynomial, error) { -+ a.quotient.Add(1) -+ if a.failure == AcceleratorFeatureQuotientV1 { -+ return nil, testAcceleratorFailureV1 -+ } -+ numerator, err := testNumeratorReferenceV1(in) -+ if err != nil { -+ return nil, err -+ } -+ result, err := divideByZH(numerator, [2]*fft.Domain{in.Domain0, in.Domain1}, new(ProvingKey)) -+ if err == nil && a.corrupt != nil { -+ a.corrupt(in, result) -+ } -+ return result, err -+} -+ -+// The reference uses the unchanged CPU numerator and waits for its restoration -+// goroutine. Completed Qk is unused by CPU proving but is part of the adapter's -+// restoration contract, so it is restored explicitly as well. -+func testNumeratorReferenceV1(in *NumeratorInput) (*iop.Polynomial, error) { -+ s := &instance{ -+ ctx: context.Background(), domain0: in.Domain0, domain1: in.Domain1, -+ proof: &Proof{Bsb22Commitments: make([]curve.G1Affine, len(in.Qcp))}, -+ x: make([]*iop.Polynomial, id_Qci+2*len(in.Qcp)), -+ bp: []*iop.Polynomial{in.Bl, in.Br, in.Bo, in.Bz}, -+ alpha: in.Alpha, beta: in.Beta, gamma: in.Gamma, -+ chQk: make(chan struct{}), chRestoreLRO: make(chan struct{}), -+ } -+ copy(s.x, []*iop.Polynomial{ -+ in.L, in.R, in.O, in.Z, in.Z.ShallowClone().Shift(1), -+ in.Ql, in.Qr, in.Qm, in.Qo, in.Qk, in.S1, in.S2, in.S3, -+ }) -+ for i := range in.Qcp { -+ s.x[id_Qci+2*i], s.x[id_Qci+2*i+1] = in.Qcp[i], in.Pi[i] -+ } -+ close(s.chQk) -+ result, err := s.computeNumerator() -+ if err != nil { -+ return nil, err -+ } -+ <-s.chRestoreLRO -+ rho := in.Domain1.Cardinality / in.Domain0.Cardinality -+ var scale fr.Element -+ scale.Exp(in.Domain1.Generator, new(big.Int).SetUint64(rho-1)) -+ scale.Mul(&scale, &in.Domain1.FrMultiplicativeGen).Inverse(&scale) -+ in.Qk.ToCanonical(in.Domain0).ToRegular() -+ scalePowers(in.Qk, scale) -+ return result, nil -+} -+ -+type acceleratorCircuitV1 struct { -+ Public frontend.Variable `gnark:",public"` -+ Secret frontend.Variable -+ Commit bool `gnark:"-"` -+} -+ -+func (c *acceleratorCircuitV1) Define(api frontend.API) error { -+ x := c.Secret -+ for i := 0; i < 4; i++ { -+ x = api.Mul(x, c.Secret) -+ } -+ api.AssertIsEqual(x, c.Public) -+ if c.Commit { -+ challenge, err := api.(frontend.Committer).Commit(c.Secret) -+ if err != nil { -+ return err -+ } -+ api.AssertIsDifferent(challenge, 0) -+ } -+ return nil -+} -+ -+func TestAcceleratorV1CapabilitiesAreFrozen(t *testing.T) { -+ pk := new(ProvingKey) -+ legacy := new(testAcceleratorV1) -+ pk.SetAccelerator(legacy) -+ view, err := pk.proofKey() -+ if err != nil || view.accelerator.Load().features != legacyAcceleratorFeaturesV1 { -+ t.Fatalf("legacy accelerator: %v", err) -+ } -+ selective := &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)} -+ selective.flags.Store(uint64(AcceleratorFeatureMSMV1)) -+ pk.SetAccelerator(selective) -+ view, err = pk.proofKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ selective.flags.Store(uint64(AcceleratorFeatureNumeratorV1)) -+ pk.SetAccelerator(nil) -+ if !view.accelerated(AcceleratorFeatureMSMV1) || view.accelerated(AcceleratorFeatureNumeratorV1) || selective.featureCalls.Load() != 1 { -+ t.Fatal("feature selection changed after proof entry") -+ } -+ if pk.Accelerator() != nil || view.Accelerator() != selective { -+ t.Fatal("proof view did not retain its selected accelerator") -+ } -+ plain, err := pk.proofKey() -+ if err != nil || plain.Accelerator() != nil { -+ t.Fatalf("CPU view: %v", err) -+ } -+ pk.SetAccelerator(legacy) -+ if plain.Accelerator() != nil { -+ t.Fatal("CPU proof acquired a later accelerator") -+ } -+} -+ -+func TestAcceleratorV1RejectsConfigurationBeforeProofWork(t *testing.T) { -+ for _, flags := range []AcceleratorFeatureFlagsV1{1 << 63, AcceleratorFeatureQuotientV1} { -+ a := &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)} -+ a.flags.Store(uint64(flags)) -+ pk := new(ProvingKey) -+ pk.SetAccelerator(a) -+ if _, err := Prove(nil, pk, nil); err == nil { -+ t.Fatal("invalid capabilities were accepted") -+ } -+ if a.msm.Load()+a.canonical.Load()+a.numerator.Load() != 0 { -+ t.Fatal("an operation ran before capability validation") -+ } -+ } -+} -+ -+func TestAcceleratorV1ConcurrentSelection(t *testing.T) { -+ pk := new(ProvingKey) -+ a := new(testAcceleratorV1) -+ var wg sync.WaitGroup -+ for i := 0; i < 4; i++ { -+ wg.Add(1) -+ go func() { -+ defer wg.Done() -+ for j := 0; j < 64; j++ { -+ pk.SetAccelerator(a) -+ view, err := pk.proofKey() -+ if err != nil { -+ t.Error(err) -+ } -+ pk.SetAccelerator(nil) -+ if selected := view.Accelerator(); selected != nil && selected != a { -+ t.Error("unexpected accelerator snapshot") -+ } -+ } -+ }() -+ } -+ wg.Wait() -+} -+ -+func TestAcceleratorV1ProvingDispatch(t *testing.T) { -+ for _, commit := range []bool{false, true} { -+ ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &acceleratorCircuitV1{Commit: commit}) -+ if err != nil { -+ t.Fatal(err) -+ } -+ n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())) -+ srs, err := kzg.NewSRS(n+3, big.NewInt(7)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ pk, vk, err := Setup(ccs.(*cs.SparseR1CS), *srs, kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) -+ if err != nil { -+ t.Fatal(err) -+ } -+ w, err := frontend.NewWitness(&acceleratorCircuitV1{Public: 32, Secret: 2}, ecc.BN254.ScalarField()) -+ if err != nil { -+ t.Fatal(err) -+ } -+ public, err := w.Public() -+ if err != nil { -+ t.Fatal(err) -+ } -+ for _, flags := range []AcceleratorFeatureFlagsV1{ -+ 0, AcceleratorFeatureMSMV1, AcceleratorFeatureToCanonicalV1, -+ AcceleratorFeatureNumeratorV1, legacyAcceleratorFeaturesV1, -+ AcceleratorFeatureQuotientV1, allAcceleratorFeaturesV1, -+ } { -+ a := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)}} -+ a.flags.Store(uint64(flags)) -+ pk.SetAccelerator(a) -+ proof, err := Prove(ccs.(*cs.SparseR1CS), pk, w) -+ if err != nil { -+ t.Fatalf("commit=%v flags=%x: %v", commit, flags, err) -+ } -+ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { -+ t.Fatalf("commit=%v flags=%x proof: %v", commit, flags, err) -+ } -+ fused := flags&AcceleratorFeatureQuotientV1 != 0 -+ if (a.msm.Load() > 0) != (flags&AcceleratorFeatureMSMV1 != 0) || -+ (a.canonical.Load() > 0) != (!fused && flags&AcceleratorFeatureToCanonicalV1 != 0) || -+ (a.numerator.Load() > 0) != (!fused && flags&AcceleratorFeatureNumeratorV1 != 0) || -+ (a.quotient.Load() > 0) != fused || a.featureCalls.Load() != 1 { -+ t.Fatalf("incorrect dispatch for flags=%x", flags) -+ } -+ } -+ legacy := new(testAcceleratorV1) -+ pk.SetAccelerator(legacy) -+ proof, err := Prove(ccs.(*cs.SparseR1CS), pk, w) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { -+ t.Fatal(err) -+ } -+ statistical := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)}} -+ statistical.flags.Store(uint64(AcceleratorFeatureMSMV1 | AcceleratorFeatureQuotientV1)) -+ pk.SetAccelerator(statistical) -+ proof, err = Prove(ccs.(*cs.SparseR1CS), pk, w, backend.WithStatisticalZeroKnowledge()) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { -+ t.Fatal(err) -+ } -+ for _, failure := range []AcceleratorFeatureFlagsV1{ -+ AcceleratorFeatureMSMV1, AcceleratorFeatureToCanonicalV1, -+ AcceleratorFeatureNumeratorV1, AcceleratorFeatureQuotientV1, -+ } { -+ a := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: &testAcceleratorV1{failure: failure}}} -+ a.flags.Store(uint64(failure)) -+ pk.SetAccelerator(a) -+ if _, err := Prove(ccs.(*cs.SparseR1CS), pk, w); !errors.Is(err, testAcceleratorFailureV1) { -+ t.Fatalf("accelerator error fell back to CPU: flags=%x error=%v", failure, err) -+ } -+ } -+ for _, corrupt := range []func(*NumeratorInput, *iop.Polynomial){ -+ func(_ *NumeratorInput, result *iop.Polynomial) { result.Layout = iop.BitReverse }, -+ func(in *NumeratorInput, _ *iop.Polynomial) { in.L.Basis = iop.Lagrange }, -+ func(in *NumeratorInput, _ *iop.Polynomial) { -+ var one fr.Element -+ one.SetOne() -+ in.Bl.Coefficients()[0].Add(&in.Bl.Coefficients()[0], &one) -+ }, -+ } { -+ a := &testFusedAcceleratorV1{testSelectiveAcceleratorV1: &testSelectiveAcceleratorV1{testAcceleratorV1: new(testAcceleratorV1)}, corrupt: corrupt} -+ a.flags.Store(uint64(AcceleratorFeatureQuotientV1)) -+ pk.SetAccelerator(a) -+ if _, err := Prove(ccs.(*cs.SparseR1CS), pk, w); err == nil || !strings.Contains(err.Error(), "accelerator") { -+ t.Fatalf("invalid fused output was accepted: %v", err) -+ } -+ } -+ } -+} -+ -+func TestStageObserverV1FreezeNilAndWaitFailure(t *testing.T) { -+ pk := new(ProvingKey) -+ plain, err := pk.proofKey() -+ if err != nil || plain.stageObserver.Load() != nil { -+ t.Fatalf("unexpected default observer: %v", err) -+ } -+ var first, second atomic.Int32 -+ pk.SetStageObserverV1(func(StageEventV1) { first.Add(1) }) -+ view, err := pk.proofKey() -+ if err != nil { -+ t.Fatal(err) -+ } -+ pk.SetStageObserverV1(func(StageEventV1) { second.Add(1) }) -+ view.stageObserver.Load().callback(StageEventV1{}) -+ if first.Load() != 1 || second.Load() != 0 || plain.stageObserver.Load() != nil { -+ t.Fatal("observer selection changed after proof entry") -+ } -+ pk.SetStageObserverV1(nil) -+ if pk.stageObserver.Load() != nil { -+ t.Fatal("nil observer did not disable diagnostics") -+ } -+ quiet := &instance{ctx: context.Background()} -+ if !quiet.beginStageV1().IsZero() { -+ t.Fatal("disabled observation acquired a clock timestamp") -+ } -+ ready := make(chan struct{}) -+ close(ready) -+ if err := quiet.waitStageV1("ready", ready); err != nil { -+ t.Fatal(err) -+ } -+ ctx, cancel := context.WithCancel(context.Background()) -+ cancel() -+ var failure StageEventV1 -+ failed := &instance{ctx: ctx, stageOrigin: time.Now(), -+ stageObserver: &stageObserverStateV1{callback: func(event StageEventV1) { failure = event }}} -+ if err := failed.waitStageV1("cancelled", make(chan struct{})); !errors.Is(err, errContextDone) { -+ t.Fatalf("cancelled dependency wait: %v", err) -+ } -+ if failure.Stage != "cancelled" || failure.Phase != "wait" || !failure.Failed || failure.Start < 0 || failure.End < failure.Start { -+ t.Fatalf("invalid failed wait event: %+v", failure) -+ } -+} -+ -+func TestStageObserverV1ProofIntervals(t *testing.T) { -+ ccs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &acceleratorCircuitV1{Commit: true}) -+ if err != nil { -+ t.Fatal(err) -+ } -+ n := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())) -+ srs, err := kzg.NewSRS(n+3, big.NewInt(7)) -+ if err != nil { -+ t.Fatal(err) -+ } -+ lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:n]) -+ if err != nil { -+ t.Fatal(err) -+ } -+ pk, vk, err := Setup(ccs.(*cs.SparseR1CS), *srs, kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) -+ if err != nil { -+ t.Fatal(err) -+ } -+ w, err := frontend.NewWitness(&acceleratorCircuitV1{Public: 32, Secret: 2}, ecc.BN254.ScalarField()) -+ if err != nil { -+ t.Fatal(err) -+ } -+ public, err := w.Public() -+ if err != nil { -+ t.Fatal(err) -+ } -+ var lock sync.Mutex -+ var once sync.Once -+ var events []StageEventV1 -+ pk.SetStageObserverV1(func(event StageEventV1) { -+ once.Do(func() { pk.SetStageObserverV1(nil) }) -+ lock.Lock() -+ defer lock.Unlock() -+ events = append(events, event) -+ }) -+ started := time.Now() -+ proof, err := Prove(ccs.(*cs.SparseR1CS), pk, w) -+ elapsed := time.Since(started) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { -+ t.Fatal(err) -+ } -+ if len(events) == 0 || events[len(events)-1].Stage != "prove" { -+ t.Fatal("missing final proof interval") -+ } -+ phases := make(map[string]bool) -+ computed := make(map[string]StageEventV1) -+ for _, event := range events { -+ if event.Failed || event.Start < 0 || event.End < event.Start || event.End > elapsed { -+ t.Fatalf("invalid proof interval: %+v", event) -+ } -+ phases[event.Phase] = true -+ if event.Phase == "compute" { -+ computed[event.Stage] = event -+ } -+ } -+ for _, phase := range []string{"task", "compute", "wait"} { -+ if !phases[phase] { -+ t.Fatalf("missing %s intervals", phase) -+ } -+ } -+ for _, stage := range []string{"initialization", "solver", "lro", "permutation", "z-commitment", "quotient-numerator", "quotient-division", "h-commitments", "evaluation-l", "evaluation-r", "evaluation-o", "linearization", "linearization-commitment", "z-opening", "batch-opening"} { -+ if _, ok := computed[stage]; !ok { -+ t.Fatalf("missing %s compute interval", stage) -+ } -+ } -+ if computed["solver"].End > computed["lro"].Start || computed["h-commitments"].End > computed["z-opening"].Start { -+ t.Fatal("stage timing violates proof dependencies") -+ } -+ count := len(events) -+ proof, err = Prove(ccs.(*cs.SparseR1CS), pk, w) -+ if err != nil { -+ t.Fatal(err) -+ } -+ if err := Verify(proof, vk, public.Vector().(fr.Vector)); err != nil { -+ t.Fatal(err) -+ } -+ if len(events) != count { -+ t.Fatal("later proof retained a disabled observer") -+ } -+} diff --git a/tools/plonk-wrapper/profile.go b/tools/plonk-wrapper/profile.go index 46a77fbad..2cb1fbf3f 100644 --- a/tools/plonk-wrapper/profile.go +++ b/tools/plonk-wrapper/profile.go @@ -53,57 +53,15 @@ type verifyingKey struct { Generator pointJSON OuterKeys, DeferredKeys []string Program scalarProgram - // Absent in verifying keys exported before first-stage wrapping: the - // recursive packet then implies one accumulator, 23 claim rows - // `[94, 1, i, public_i]` and two deferred operands. - AccumulatorCount *int - Claims [][]claimSlot - Publics []publicSource -} - -func (p *verifyingKey) accumulators() int { - if p.AccumulatorCount == nil { - return 1 - } - return *p.AccumulatorCount + AccumulatorCount int + Claims [][]claimSlot + Publics []publicSource } func (p *verifyingKey) deferredCount() int { return len(p.DeferredKeys) } -func intPtr(v int) *int { return &v } - -func (p *verifyingKey) claimRows() [][]claimSlot { - if p.Claims != nil { - return p.Claims - } - rows := make([][]claimSlot, 23) - for i := range rows { - ns, one, index := "0x5e", "0x1", fmt.Sprintf("0x%x", i) - rows[i] = []claimSlot{{Constant: &ns}, {Constant: &one}, {Constant: &index}, {Public: intPtr(i)}} - } - return rows -} - -func (p *verifyingKey) publicSources() []publicSource { - if p.Publics != nil { - return p.Publics - } - zero := "0x0" - sources := []publicSource{{Constant: &zero}} - for i := 0; i < 10; i++ { - sources = append(sources, publicSource{Prefix: intPtr(i)}) - } - for i := 0; i < 8; i++ { - sources = append(sources, publicSource{DigestWord: intPtr(i)}) - } - for i := 0; i < 4; i++ { - sources = append(sources, publicSource{DeferredHalf: intPtr(i)}) - } - return sources -} - type witnessJSON struct { ClaimDigest string Points, Deferred []pointJSON @@ -190,11 +148,11 @@ func loadVerifyingKey(path string) (*verifyingKey, error) { } func (p *verifyingKey) validate() error { - if p.Format != vkFormat || len(p.Prefix) != 10 || len(p.Circuits) < 2 || len(p.Matrices) != 4*len(p.Circuits) || p.PointCount < 1 || p.PointCount > 4096 || p.FieldCount < 1 || p.FieldCount > 65536 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 16 || p.WitnessOffset+p.OpeningCount != p.PointCount || p.accumulators() != len(p.Circuits)-1 || (p.deferredCount() != 0 && p.deferredCount() != 2) { + if p.Claims == nil || p.Publics == nil || p.Format != vkFormat || len(p.Prefix) != 10 || len(p.Circuits) < 2 || len(p.Matrices) != 4*len(p.Circuits) || p.PointCount < 1 || p.PointCount > 4096 || p.FieldCount < 1 || p.FieldCount > 65536 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 16 || p.WitnessOffset+p.OpeningCount != p.PointCount || p.AccumulatorCount != len(p.Circuits)-1 || (p.deferredCount() != 0 && p.deferredCount() != 2) { return fmt.Errorf("invalid wrapper verifying key dimensions") } - publicCount := len(p.publicSources()) - for i, source := range p.publicSources() { + publicCount := len(p.Publics) + for i, source := range p.Publics { set := 0 if source.Constant != nil { set++ @@ -224,7 +182,7 @@ func (p *verifyingKey) validate() error { return fmt.Errorf("public source %d must have exactly one kind", i) } } - for r, row := range p.claimRows() { + for r, row := range p.Claims { if len(row) == 0 || len(row) > 16 { return fmt.Errorf("invalid claim row %d", r) } diff --git a/tools/plonk-wrapper/sizing_test.go b/tools/plonk-wrapper/sizing_test.go deleted file mode 100644 index 8bdae3171..000000000 --- a/tools/plonk-wrapper/sizing_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/consensys/gnark-crypto/ecc" - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/frontend/cs/scs" -) - -type componentCircuit struct { - Inner wrapperCircuit - Phase int `gnark:"-"` - Options circuitOptions `gnark:"-"` -} - -func (c *componentCircuit) Define(api frontend.API) error { - return c.Inner.constrainWithOptions(api, c.Phase, c.Options) -} - -func TestComponentSizing(t *testing.T) { - if os.Getenv("IX_PLONK_SIZE") != "1" { - t.Skip("set IX_PLONK_SIZE=1 for full component compilation") - } - path := os.Getenv("IX_PLONK_SIZE_VK") - if path == "" { - path = filepath.Join("testdata", "bn254", "vk.json") - } - p, err := loadVerifyingKey(path) - if err != nil { - t.Fatal(err) - } - options := circuitOptions{classicGLV: true} - switch variant := os.Getenv("IX_PLONK_SIZE_VARIANT"); variant { - case "", "classic-glv": - case "baseline": - options.classicGLV = false - default: - t.Fatalf("component sizing requires baseline or classic-glv, got %q", variant) - } - type measurement struct { - Phase string - Constraints, Commitments int - Domain uint64 - Seconds float64 - } - var measurements []measurement - for phase, name := range []string{"point validation and public binding", "transcript", "scalar relation", "opening MSMs", "outer pairing", "deferred pairing"} { - runtime.GC() - start := time.Now() - ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, - &componentCircuit{Inner: *newCircuit(p), Phase: phase + 1, Options: options}, frontend.IgnoreUnconstrainedInputs()) - if err != nil { - t.Fatal(err) - } - row := measurement{name, ccs.GetNbConstraints(), len(ccs.GetCommitments().CommitmentIndexes()), ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + 2)), time.Since(start).Seconds()} - measurements = append(measurements, row) - t.Logf("through %s: constraints=%d N=%d commitments=%d seconds=%.2f", row.Phase, row.Constraints, row.Domain, row.Commitments, row.Seconds) - } - if path := os.Getenv("IX_PLONK_SIZE_REPORT"); path != "" { - data, err := json.MarshalIndent(measurements, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatal(err) - } - } -} diff --git a/tools/plonk-wrapper/testdata/bn254/provenance.json b/tools/plonk-wrapper/testdata/bn254/provenance.json index 2cf3e6b2d..71484512f 100644 --- a/tools/plonk-wrapper/testdata/bn254/provenance.json +++ b/tools/plonk-wrapper/testdata/bn254/provenance.json @@ -6,9 +6,9 @@ "originalClaimBLAKE3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", "srsDomain": 128, "srsPowers": 131, - "scope": "The witness covers the full Init verifier relation. The small SRS is only for backend and basis-conversion tests, not for proving the full wrapper.", + "scope": "The witness covers the full Init verifier relation. The small SRS is only for backend and basis-conversion tests, not for proving the full wrapper. The verifying-key JSON explicitly encodes one accumulator, 23 claim rows, and their public-input mappings.", "files": { - "vk.json": "f75d771d95dd704395c83bc0c6a641f6f50a18d2f424b5a9e32aa39de1be745a", + "vk.json": "b86e203bcc2dcd87a67c1e325e24328e12534676ada7ea9602ac319568139c29", "srs.bin": "5eb78499c386ce2dd170f773eaf6043741c83b9ddbb7c2e463f20532cbac6a29", "srs.json": "2693bbb8f0c54af048915738afed28dda6ff6262550653404102e2391a947c78", "witness-diagnostic.json": "83b65ceb2b2a20de824b0e4968ba6e85ec408e47eab49ba5be9d8f4de8733787", diff --git a/tools/plonk-wrapper/testdata/bn254/vk.json b/tools/plonk-wrapper/testdata/bn254/vk.json index 6b3758298..ced455ec9 100644 --- a/tools/plonk-wrapper/testdata/bn254/vk.json +++ b/tools/plonk-wrapper/testdata/bn254/vk.json @@ -5044,5 +5044,401 @@ "publicMaxDegree": 100799999, "seed": "0x6d756c74692d737461726b2f6b7a672f626e3235342f626c616b65332f76311a000000000000000200000000000000ff1502060000000007f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b3bb0c91a284ca49f0e31d84e308b6399ab4f55639b05d574e6f7e7b593bea49fedf692d95cbdde46ddda5ef7d422436779445c5e66006a42761e1f12efde0018c212f3aeb785e49712e7a9353349aaf1255dfb31b7bf60723a480d9293938e19098493b799904a0b1919123a0c9434d2c133c78e655f1588a6a8c2faf1eb689fcbe108231cc23c1041b9f6f3c615a5ae9e425217ae4231d70eedc201ad4f342a484a95db8d70c3dc4f502412ebf3f9cde76558f30a43c2608750c403bbacf122", "setupId": "0x07f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b", - "witnessOffset": 12 -} \ No newline at end of file + "witnessOffset": 12, + "accumulatorCount": 1, + "claims": [ + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x0" + }, + { + "public": 0 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x1" + }, + { + "public": 1 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x2" + }, + { + "public": 2 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x3" + }, + { + "public": 3 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x4" + }, + { + "public": 4 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x5" + }, + { + "public": 5 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x6" + }, + { + "public": 6 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x7" + }, + { + "public": 7 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x8" + }, + { + "public": 8 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x9" + }, + { + "public": 9 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0xa" + }, + { + "public": 10 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0xb" + }, + { + "public": 11 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0xc" + }, + { + "public": 12 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0xd" + }, + { + "public": 13 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0xe" + }, + { + "public": 14 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0xf" + }, + { + "public": 15 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x10" + }, + { + "public": 16 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x11" + }, + { + "public": 17 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x12" + }, + { + "public": 18 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x13" + }, + { + "public": 19 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x14" + }, + { + "public": 20 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x15" + }, + { + "public": 21 + } + ], + [ + { + "constant": "0x5e" + }, + { + "constant": "0x1" + }, + { + "constant": "0x16" + }, + { + "public": 22 + } + ] + ], + "publics": [ + { + "constant": "0x0" + }, + { + "prefix": 0 + }, + { + "prefix": 1 + }, + { + "prefix": 2 + }, + { + "prefix": 3 + }, + { + "prefix": 4 + }, + { + "prefix": 5 + }, + { + "prefix": 6 + }, + { + "prefix": 7 + }, + { + "prefix": 8 + }, + { + "prefix": 9 + }, + { + "digestWord": 0 + }, + { + "digestWord": 1 + }, + { + "digestWord": 2 + }, + { + "digestWord": 3 + }, + { + "digestWord": 4 + }, + { + "digestWord": 5 + }, + { + "digestWord": 6 + }, + { + "digestWord": 7 + }, + { + "deferredHalf": 0 + }, + { + "deferredHalf": 1 + }, + { + "deferredHalf": 2 + }, + { + "deferredHalf": 3 + } + ] +} diff --git a/tools/plonk-wrapper/verifier_bn254_test.go b/tools/plonk-wrapper/verifier_bn254_test.go index 7cf7f98c7..097f672ad 100644 --- a/tools/plonk-wrapper/verifier_bn254_test.go +++ b/tools/plonk-wrapper/verifier_bn254_test.go @@ -2,7 +2,9 @@ package main import ( "bytes" + "encoding/json" "math/big" + "os" "path/filepath" "testing" @@ -55,7 +57,7 @@ func TestBN254FullVerifierRejectsTampering(t *testing.T) { if err := test.IsSolved(newCircuit(p), assignment(), curveID.ScalarField()); err != nil { t.Fatal(err) } - for _, kind := range []string{"claim", "evaluation", "commitment", "deferred-key", "outer-key"} { + for _, kind := range []string{"claim", "evaluation", "commitment", "deferred", "deferred-key", "outer-key"} { w := assignment() vkCopy := *p switch kind { @@ -66,6 +68,9 @@ func TestBN254FullVerifierRejectsTampering(t *testing.T) { case "commitment": _, _, g, _ := bn.Generators() w.Points[0] = newCircuitG1(g) + case "deferred": + _, _, g, _ := bn.Generators() + w.Deferred[0] = newCircuitG1(g) case "deferred-key": vkCopy.DeferredKeys = []string{p.DeferredKeys[0], p.DeferredKeys[0]} case "outer-key": @@ -76,3 +81,34 @@ func TestBN254FullVerifierRejectsTampering(t *testing.T) { } } } + +func TestVerifyingKeyRequiresExplicitSchema(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "bn254", "vk.json")) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"accumulatorCount", "claims", "publics"} { + for _, null := range []bool{false, true} { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if null { + fields[field] = json.RawMessage("null") + } else { + delete(fields, field) + } + encoded, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "vk.json") + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatal(err) + } + if _, err := loadVerifyingKey(path); err == nil { + t.Fatalf("accepted missing or null %s", field) + } + } + } +} From 22160e90050b0975fe76a5f91601b381bcde0513 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:34:44 +0000 Subject: [PATCH 19/31] feat(compression): restore fixed-root normalization of aggregate roots Wrap an aggregate root once more through ix_aggr and prove the wrap in a fixed, all-active, single-shard layout, so every root that fits the layout compresses under the same FRI verifier regardless of its shard count or trace heights. - aiur: per-circuit execution rows, layout sizing with headroom, and memory circuit order for normalized proving. - FixedRoot: canonical JSON layout, accessors and a backend-plus-layout identity. - FRI worker: verify through FixedRoot when the root directory carries fixed-root.json, taking memory ends as inputs instead of constants. - CLI: `ix compression layout` sizes one layout over several roots, `ix compression normalize` writes a normalized worker input, and `ix compress --fixed-root` normalizes before compressing. Init (3 shards) and Nat.add_comm (1 shard) roots normalized to one layout produce identical FRI verifier circuits (49,303,266 gates, 4 x 2^24 + 2^23 partitions), and both satisfy every constraint in FRI check-only mode. --- Ix/Aggr/Backend.lean | 2 + Ix/Cli/CompressCmd.lean | 11 +- Ix/Cli/Compression.lean | 32 ++++- Ix/Cli/CompressionCmd.lean | 110 +++++++++++++++ Main.lean | 2 + crates/aiur/src/synthesis/normalization.rs | 54 ++++++++ crates/ffi/src/aiur/aggregate.rs | 1 + crates/ffi/src/aiur/aggregate/normalize.rs | 126 ++++++++++++++++++ crates/ffi/src/compression.rs | 126 +++++++++++++++++- .../proof-compression/src/pipeline/driver.rs | 4 + crates/proof-compression/src/pipeline/mod.rs | 44 +++++- crates/proof-compression/src/pipeline/root.rs | 37 +++-- .../src/pipeline/stages/fixed.rs | 57 ++++++++ 13 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 Ix/Cli/CompressionCmd.lean create mode 100644 crates/ffi/src/aiur/aggregate/normalize.rs diff --git a/Ix/Aggr/Backend.lean b/Ix/Aggr/Backend.lean index ece08287a..81b8a461c 100644 --- a/Ix/Aggr/Backend.lean +++ b/Ix/Aggr/Backend.lean @@ -46,6 +46,7 @@ structure VerificationBackend where system : Aiur.AiurSystem aggrIdx : Aiur.Bytecode.FunIdx allowed : ByteArray + ixvmVk : ByteArray /-- Build the two deterministic systems whose identities are committed by an aggregate root: the IxVM vk and the single-entrypoint recursion vk. -/ @@ -67,6 +68,7 @@ def buildVerificationBackend system := aggr.system aggrIdx allowed + ixvmVk := ixvm.vk } end Aggr diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index 7ee859f9b..08b09ea72 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -31,9 +31,15 @@ def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do let vk ← match (p.flag? "root-vk").map (·.as! String) with | some path => IO.FS.readBinFile path | none => pure backend.system.vkBytes + let (root, layout) ← match (p.flag? "fixed-root").map (·.as! String) with + | some path => + let layout ← IO.FS.readFile path + IO.eprintln s!"Normalizing aggregate root {addr} to {path}." + pure (← Compression.normalize backend wrapper layout, layout) + | none => pure (wrapper.proof, "") IO.eprintln s!"Compressing aggregate root {addr} with {setup}." - let proof ← Compression.compress vk wrapper.proof (Ix.Claim.ser wrapper.claim) statement - out (← Compression.cacheDir).toString + let proof ← Compression.compress vk root (Ix.Claim.ser wrapper.claim) statement + layout out (← Compression.cacheDir).toString (p.hasFlag "dev-srs") height.toUInt32 (p.hasFlag "plonk") let compressed : Ixon.Proof := { claim := wrapper.claim, proof } let result ← StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) @@ -52,6 +58,7 @@ def compressCmd : Cli.Cmd := `[Cli| "out" : String; "Empty output directory for the stage proofs; defaults to a new directory under ~/.ix/compression." "log-height" : Nat; "First KZG partition height: 24 (default) or 25." "root-vk" : String; "Trusted root verifier key; omit to use the current aggregate backend." + "fixed-root" : String; "Normalize the root to this layout (from `ix compression layout`) first, so the verifier does not depend on the root's shape." "dev-srs"; "Use known-trapdoor development parameters; results are not production-secure." "plonk"; "Add a terminal PLONK proof using the compiled curve and selected setup." ARGS: diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 23f60324f..938658ebb 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -19,11 +19,39 @@ authenticated production parameters for the compiled curve. -/ opaque describeSetup (devSrs : Bool) : IO String /-- Run every stage into the empty directory `dir`, reusing the fixed -preprocessing and development SRS caches under `cacheRoot`. -/ +preprocessing and development SRS caches under `cacheRoot`. A nonempty +`fixedRoot` is the JSON layout `proof` was normalized to. -/ @[extern "rs_compression_run"] -opaque compress (vk proof claim statement : @& ByteArray) (dir cacheRoot : @& String) +opaque compress (vk proof claim statement : @& ByteArray) (fixedRoot dir cacheRoot : @& String) (devSrs : Bool) (logHeight : UInt32) (terminalPlonk : Bool) : IO ByteArray +/-- Per-circuit rows of one root wrap execution of `proof`, as a JSON array. -/ +@[extern "rs_compression_root_rows"] +opaque rootRows (system : @& Aiur.AiurSystem) (aggrIdx : @& Nat) + (allowed ixvmVk claim proof : @& ByteArray) : IO String + +/-- The fixed root layout holding every row-count array in the JSON array +`rows`, with `headroom` extra doublings per circuit. -/ +@[extern "rs_compression_root_layout"] +opaque rootLayout (system : @& Aiur.AiurSystem) (rows : @& String) + (headroom claimLen : UInt32) : IO String + +/-- Wrap the aggregate root `proof` once more and prove the wrap in the fixed +shape of `layout`; returns the normalized proof bytes. -/ +@[extern "rs_compression_normalize_root"] +opaque normalizeRoot (system : @& Aiur.AiurSystem) (aggrIdx : @& Nat) + (allowed ixvmVk claim proof : @& ByteArray) (layout : @& String) : IO ByteArray + +/-- Write the FRI worker's root input directory `dir` without compressing. -/ +@[extern "rs_compression_write_root"] +opaque writeRoot (vk proof claim statement : @& ByteArray) (fixedRoot dir : @& String) : IO Unit + +/-- Normalize `wrapper`'s aggregate root to `layout` under `backend`. -/ +def normalize (backend : Aggr.VerificationBackend) (wrapper : Ixon.Proof) + (layout : String) : IO ByteArray := + normalizeRoot backend.system backend.aggrIdx backend.allowed backend.ixvmVk + (Ix.Claim.ser wrapper.claim) wrapper.proof layout + /-- Verify with the verifying key compiled in under the packet's id, whose pinned aggregation program fixes the statement for `claim`, and return the setup class: `"ignition"` or `"development"`. Development proofs need diff --git a/Ix/Cli/CompressionCmd.lean b/Ix/Cli/CompressionCmd.lean new file mode 100644 index 000000000..5c3dba8c0 --- /dev/null +++ b/Ix/Cli/CompressionCmd.lean @@ -0,0 +1,110 @@ +/- + `ix compression layout|normalize`: fixed-root normalization of aggregate + roots. `layout` executes a root wrap of each given aggregate proof and sizes + one fixed layout that holds all of them; `normalize` proves a root's wrap in + that layout and writes the FRI worker's root input. Every root normalized to + one layout compresses under the same FRI verifier. +-/ +module +public import Cli +public import Ix.Cli.Compression +public import Ix.Aggr.Backend + +public section +namespace Ix.Cli.CompressionCmd + +private def loadRoot (hex : String) : IO (Address × Ixon.Proof) := do + let some addr := Address.fromString hex + | throw <| IO.userError s!"expected a 64-character proof address: {hex}" + let wrapper ← Compression.readProof addr + if Compression.isCompressed wrapper.proof then + throw <| IO.userError s!"proof {addr} is already compressed" + let .checkEnv _ _ := wrapper.claim + | throw <| IO.userError s!"proof {addr} is not an aggregate CheckEnv proof" + return (addr, wrapper) + +private def backend : IO Aggr.VerificationBackend := do + IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) + +def runLayout (p : Cli.Parsed) : IO UInt32 := do + try + let out := p.positionalArg! "out" |>.as! String + let proofs := (p.variableArgsAs! String).toList + if proofs.isEmpty then + throw <| IO.userError "expected at least one aggregate proof address" + let headroom := ((p.flag? "headroom").map (·.as! Nat)).getD 0 + let backend ← backend + let mut rows := #[] + let mut claimLen := 0 + for hex in proofs do + let (addr, wrapper) ← loadRoot hex + IO.eprintln s!"Executing the root wrap of {addr}." + rows := rows.push (← Compression.rootRows backend.system backend.aggrIdx + backend.allowed backend.ixvmVk (Ix.Claim.ser wrapper.claim) wrapper.proof) + claimLen := (Aggr.aggregateOuterClaim backend.allowed backend.aggrIdx wrapper.claim).size + let layout ← Compression.rootLayout backend.system + ("[" ++ ",".intercalate rows.toList ++ "]") headroom.toUInt32 claimLen.toUInt32 + IO.FS.writeFile out layout + IO.println out + return 0 + catch e => + IO.eprintln s!"compression layout: {e}" + return 1 + +def runNormalize (p : Cli.Parsed) : IO UInt32 := do + try + let (addr, wrapper) ← loadRoot (p.positionalArg! "proof" |>.as! String) + let layoutPath := p.positionalArg! "layout" |>.as! String + let out := p.positionalArg! "out" |>.as! String + let layout ← IO.FS.readFile layoutPath + let backend ← backend + IO.eprintln s!"Normalizing aggregate root {addr} to {layoutPath}." + let root ← Compression.normalize backend wrapper layout + let claim := Ix.Claim.ser wrapper.claim + Compression.writeRoot backend.system.vkBytes root claim + (Compression.statementBytes backend wrapper.claim) layout out + IO.println out + return 0 + catch e => + IO.eprintln s!"compression normalize: {e}" + return 1 + +def layoutCmd : Cli.Cmd := `[Cli| + layout VIA runLayout; + "Size one fixed root layout that holds the root wrap of every given aggregate proof." + + FLAGS: + headroom : Nat; "Extra doublings of every circuit's height beyond the largest execution (default 0)." + + ARGS: + out : String; "Output layout JSON file." + ...proofs : String; "Addresses of aggregate Ixon.Proof roots in the ix store." +] + +def normalizeCmd : Cli.Cmd := `[Cli| + normalize VIA runNormalize; + "Prove a root's wrap in a fixed layout and write the FRI worker's root input directory." + + ARGS: + proof : String; "Address of an aggregate Ixon.Proof in the ix store." + layout : String; "Layout JSON from `ix compression layout`." + out : String; "Empty output directory for root-vk.bin, root-proof.bin, root-claims.bin and fixed-root.json." +] + +def runCompression (p : Cli.Parsed) : IO UInt32 := do + p.printHelp + return 0 + +end Ix.Cli.CompressionCmd + +open Ix.Cli.CompressionCmd in +def compressionCmd : Cli.Cmd := `[Cli| + compression VIA runCompression; + "Fixed-root normalization of aggregate roots for compression" + + SUBCOMMANDS: + layoutCmd; + normalizeCmd +] + +end diff --git a/Main.lean b/Main.lean index 22d7f26f0..ddc36e153 100644 --- a/Main.lean +++ b/Main.lean @@ -19,6 +19,7 @@ import Ix.Cli.NameOfCmd import Ix.Cli.PackCmd import Ix.Cli.ProfileCmd import Ix.Cli.CompressCmd +import Ix.Cli.CompressionCmd import Ix.Cli.ProveCmd import Ix.Cli.ShardCmd import Ix.Cli.TreeCmd @@ -54,6 +55,7 @@ def ixCmd : Cli.Cmd := `[Cli| profileCmd; proveCmd; compressCmd; + compressionCmd; shardCmd; codegenCmd; verifyCmd; diff --git a/crates/aiur/src/synthesis/normalization.rs b/crates/aiur/src/synthesis/normalization.rs index feb7b66c9..c25f3b44e 100644 --- a/crates/aiur/src/synthesis/normalization.rs +++ b/crates/aiur/src/synthesis/normalization.rs @@ -4,6 +4,60 @@ use super::*; use multi_stark::{batch::ShardInput, system::SystemWitness}; impl AiurSystem { + /// Rows each circuit needs to hold `record` in one shard, in system order. + pub fn execution_rows(&self, record: &QueryRecord) -> Vec { + let plan = self.single_shard_plan(record); + plan.shards[0].rows.iter().map(|rows| rows.len()).collect() + } + + /// The smallest normalization log-heights holding `rows` in each circuit, + /// raised by `headroom` doublings and capped at `2^26`. Fixed byte tables + /// keep their native heights. + pub fn normalized_log_heights( + &self, + rows: &[usize], + headroom: u8, + ) -> Result, String> { + if rows.len() != self.system.circuits.len() { + return Err("normalization needs one row count per circuit".into()); + } + self + .system + .circuits + .iter() + .zip(rows) + .enumerate() + .map(|(i, (circuit, &rows))| { + if circuit.preprocessed_height != 0 { + return Ok(u8::try_from(circuit.preprocessed_height.ilog2()) + .expect("log of a usize fits in u8")); + } + let log = rows.max(2).next_power_of_two().ilog2() + u32::from(headroom); + if log > 26 { + return Err(format!( + "normalization circuit {i} needs 2^{log} rows; at most 2^26 is supported" + )); + } + Ok(u8::try_from(log).expect("log is at most 26")) + }) + .collect() + } + + /// `(width, circuit index)` of every memory circuit, ascending by width. + pub fn memory_circuits(&self) -> Vec<(usize, usize)> { + let mut memories: Vec<_> = self + .circuit_types() + .iter() + .enumerate() + .filter_map(|(i, ct)| match ct { + CircuitType::Memory { width } => Some((*width, i)), + _ => None, + }) + .collect(); + memories.sort_unstable(); + memories + } + /// Prove one invocation in a fixed, all-active trace layout. Function and /// memory tables have their configured power-of-two heights; fixed byte /// tables retain their native heights. Empty tables are zero-padded. Memory diff --git a/crates/ffi/src/aiur/aggregate.rs b/crates/ffi/src/aiur/aggregate.rs index f74226459..a489e362b 100644 --- a/crates/ffi/src/aiur/aggregate.rs +++ b/crates/ffi/src/aiur/aggregate.rs @@ -36,6 +36,7 @@ use store::{load_input_proofs, persist_wrapper, wrapper_address}; mod ffi; pub mod lanes; +pub(crate) mod normalize; mod plan; mod prepare; pub(crate) mod protocol; diff --git a/crates/ffi/src/aiur/aggregate/normalize.rs b/crates/ffi/src/aiur/aggregate/normalize.rs new file mode 100644 index 000000000..549938086 --- /dev/null +++ b/crates/ffi/src/aiur/aggregate/normalize.rs @@ -0,0 +1,126 @@ +//! Root normalization: one more `ix_aggr` wrap of an aggregate root, proved +//! in a fixed trace layout. Every root that fits the layout then compresses +//! under the same FRI verifier, whatever the shape of the original root. +use super::protocol::{ + ChildKind, aggregate_outer_claim, serialize_claims, shape_code, +}; +use aiur::{ + G, + execute::{IOBuffer, QueryRecord}, + synthesis::{AiurProof, AiurSystem}, + vk_codec::aiur_system_to_bytes, +}; +use ix_proof_compression::pipeline::FixedRoot; +use ixvm_codegen::aiur_ix_aggr_runner::{ + AggrAdvice, aggr_io_buffer, execute_ix_aggr, +}; + +/// The trusted aggregation backend and the root claim being wrapped. +pub(crate) struct RootWrap<'a> { + pub(crate) system: &'a AiurSystem, + pub(crate) aggr_idx: usize, + pub(crate) allowed: &'a [u8], + pub(crate) ixvm_vk: &'a [u8], + pub(crate) claim: &'a [u8], +} + +struct Executed { + expected: Vec, + io: IOBuffer, + record: QueryRecord, + output: Vec, +} + +impl RootWrap<'_> { + /// Execute a wrap of `proof`: shape 1 verifies one `ix_aggr` proof and + /// passes its claim through. The root proof is verified natively first. + fn execute(&self, proof: &AiurProof) -> Result { + let expected = aggregate_outer_claim(self.aggr_idx, self.allowed, self.claim); + let advice = self + .system + .proof_to_advice_bytes(&expected, proof) + .map_err(|e| format!("aggregate root: {e}"))?; + let self_vk = aiur_system_to_bytes(self.system)?; + let claims = serialize_claims(&[&expected]); + let mut io = aggr_io_buffer(&AggrAdvice { + shape: shape_code(ChildKind::Aggr, None), + proof_advice: [&advice, &[]], + ixvm_vk: self.ixvm_vk, + self_vk: &self_vk, + child_claims: [&claims, &[]], + output_claim: self.claim, + allowed: self.allowed, + preimages: &[], + trees: &[], + paths: &[], + }); + drop(advice); + // The aggregate call is [channel, entrypoint, inputs…], with no outputs. + let (record, output) = execute_ix_aggr( + self.system.toplevel(), + self.aggr_idx, + expected[2..].to_vec(), + &mut io, + ) + .map_err(|e| format!("root wrap execution: {e}"))?; + if !output.is_empty() { + return Err("root wrap returned unexpected outputs".into()); + } + Ok(Executed { expected, io, record, output }) + } + + /// Rows each circuit of the wrap execution needs, in system order. + pub(crate) fn rows(&self, proof: &[u8]) -> Result, String> { + let proof = decode(proof)?; + let executed = self.execute(&proof)?; + Ok(self.system.execution_rows(&executed.record)) + } + + /// Prove the wrap in `layout`'s fixed shape and return the serialized proof. + pub(crate) fn normalize( + &self, + proof: &[u8], + layout: &FixedRoot, + ) -> Result, String> { + if layout.memory_traces() != self.system.memory_circuits() { + return Err("fixed root layout belongs to a different backend".into()); + } + let proof = decode(proof)?; + let executed = self.execute(&proof)?; + drop(proof); + let Executed { expected, io, record, output } = executed; + let (claim, normalized) = self.system.prove_from_execution_normalized( + self.aggr_idx, + &expected[2..], + &io, + record, + &output, + layout.log_heights(), + )?; + if claim != expected { + return Err("normalization changed the root claim".into()); + } + normalized.to_bytes().map_err(|e| e.to_string()) + } +} + +/// The fixed layout holding every execution in `rows` (elementwise maxima of +/// per-circuit rows), with `headroom` extra doublings per circuit. +pub(crate) fn layout( + system: &AiurSystem, + rows: &[usize], + headroom: u8, + claim_len: usize, +) -> Result { + let logs = system.normalized_log_heights(rows, headroom)?; + FixedRoot::new(logs, system.memory_circuits(), claim_len) + .map_err(|e| e.to_string()) +} + +fn decode(proof: &[u8]) -> Result { + let decoded = AiurProof::from_bytes(proof).map_err(|e| e.to_string())?; + if decoded.to_bytes().map_err(|e| e.to_string())? != proof { + return Err("noncanonical aggregate root proof".into()); + } + Ok(decoded) +} diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index 96adcab37..05b245d78 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -1,15 +1,25 @@ //! Main CLI bridge; compression is linked into the ix executable. -use ix_proof_compression::pipeline; +use crate::aiur::aggregate::normalize::{self, RootWrap}; +use crate::aiur::lean_unbox_nat_as_usize; +use aiur::synthesis::AiurSystem; +use ix_proof_compression::pipeline::{self, FixedRoot}; use lean_ffi::object::{ - LeanBorrowed, LeanByteArray, LeanIOResult, LeanOwned, LeanString, + LeanBorrowed, LeanByteArray, LeanExternal, LeanIOResult, LeanNat, LeanOwned, + LeanString, }; +fn optional(text: &LeanString>) -> Option { + let text = text.to_string(); + (!text.is_empty()).then_some(text) +} + #[unsafe(no_mangle)] extern "C" fn rs_compression_run( vk: LeanByteArray>, proof: LeanByteArray>, claim: LeanByteArray>, statement: LeanByteArray>, + fixed_root: LeanString>, dir: LeanString>, cache_root: LeanString>, dev_srs: bool, @@ -23,6 +33,7 @@ extern "C" fn rs_compression_run( proof.as_bytes(), claim.as_bytes(), statement.as_bytes(), + optional(&fixed_root).as_deref(), std::path::Path::new(&dir), std::path::Path::new(&cache_root), dev_srs, @@ -69,3 +80,114 @@ extern "C" fn rs_compression_check_claim( Err(e) => LeanIOResult::error_string(&e.to_string()), } } + +fn root_wrap<'a>( + system: &'a LeanExternal>, + aggr_idx: &LeanNat>, + allowed: &'a LeanByteArray>, + ixvm_vk: &'a LeanByteArray>, + claim: &'a LeanByteArray>, +) -> RootWrap<'a> { + RootWrap { + system: system.get(), + aggr_idx: lean_unbox_nat_as_usize(aggr_idx.inner()), + allowed: allowed.as_bytes(), + ixvm_vk: ixvm_vk.as_bytes(), + claim: claim.as_bytes(), + } +} + +/// Per-circuit rows of one root wrap execution, as a JSON array. +#[unsafe(no_mangle)] +extern "C" fn rs_compression_root_rows( + system: LeanExternal>, + aggr_idx: LeanNat>, + allowed: LeanByteArray>, + ixvm_vk: LeanByteArray>, + claim: LeanByteArray>, + proof: LeanByteArray>, +) -> LeanIOResult { + let wrap = root_wrap(&system, &aggr_idx, &allowed, &ixvm_vk, &claim); + match wrap.rows(proof.as_bytes()) { + Ok(rows) => LeanIOResult::ok(LeanString::new( + &serde_json::to_string(&rows).expect("row counts serialize"), + )), + Err(e) => LeanIOResult::error_string(&e), + } +} + +/// The fixed layout holding every row-count array in the JSON array `rows`. +#[unsafe(no_mangle)] +extern "C" fn rs_compression_root_layout( + system: LeanExternal>, + rows: LeanString>, + headroom: u32, + claim_len: u32, +) -> LeanIOResult { + let result = (|| -> Result { + let executions: Vec> = + serde_json::from_str(&rows.to_string()).map_err(|e| e.to_string())?; + let first = executions.first().ok_or("no root executions to size")?; + if executions.iter().any(|rows| rows.len() != first.len()) { + return Err("root executions have different circuit counts".into()); + } + let maxima: Vec<_> = (0..first.len()) + .map(|i| executions.iter().map(|rows| rows[i]).max().unwrap_or(0)) + .collect(); + let layout = normalize::layout( + system.get(), + &maxima, + u8::try_from(headroom).map_err(|e| e.to_string())?, + claim_len as usize, + )?; + Ok(layout.to_json()) + })(); + match result { + Ok(json) => LeanIOResult::ok(LeanString::new(&json)), + Err(e) => LeanIOResult::error_string(&e), + } +} + +/// Wrap and prove the root in `layout`'s fixed shape. +#[unsafe(no_mangle)] +extern "C" fn rs_compression_normalize_root( + system: LeanExternal>, + aggr_idx: LeanNat>, + allowed: LeanByteArray>, + ixvm_vk: LeanByteArray>, + claim: LeanByteArray>, + proof: LeanByteArray>, + layout: LeanString>, +) -> LeanIOResult { + let wrap = root_wrap(&system, &aggr_idx, &allowed, &ixvm_vk, &claim); + let result = FixedRoot::from_json(&layout.to_string()) + .map_err(|e| e.to_string()) + .and_then(|layout| wrap.normalize(proof.as_bytes(), &layout)); + match result { + Ok(bytes) => LeanIOResult::ok(LeanByteArray::from_bytes(&bytes)), + Err(e) => LeanIOResult::error_string(&e), + } +} + +/// Write the FRI worker's root input directory without compressing. +#[unsafe(no_mangle)] +extern "C" fn rs_compression_write_root( + vk: LeanByteArray>, + proof: LeanByteArray>, + claim: LeanByteArray>, + statement: LeanByteArray>, + fixed_root: LeanString>, + dir: LeanString>, +) -> LeanIOResult { + match pipeline::write_root_input( + std::path::Path::new(&dir.to_string()), + vk.as_bytes(), + proof.as_bytes(), + claim.as_bytes(), + statement.as_bytes(), + optional(&fixed_root).as_deref(), + ) { + Ok(()) => LeanIOResult::ok(LeanOwned::box_usize(0)), + Err(e) => LeanIOResult::error_string(&e.to_string()), + } +} diff --git a/crates/proof-compression/src/pipeline/driver.rs b/crates/proof-compression/src/pipeline/driver.rs index f5bf7da24..decc9c419 100644 --- a/crates/proof-compression/src/pipeline/driver.rs +++ b/crates/proof-compression/src/pipeline/driver.rs @@ -117,11 +117,15 @@ pub(super) fn run(args: &[String]) -> Result<()> { let out = Path::new(out); let claim = fs::read(claim)?; let expected = fs::read(root.join("root-claims.bin"))?; + let layout = root.join(super::root::FIXED_ROOT_LAYOUT); + let fixed_root = + layout.exists().then(|| fs::read_to_string(&layout)).transpose()?; let envelope = super::compress( &fs::read(root.join("root-vk.bin"))?, &fs::read(root.join("root-proof.bin"))?, &claim, &expected, + fixed_root.as_deref(), out, &out.join("cache"), development, diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 042998af8..cb5531751 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -409,17 +409,55 @@ fn prepare_output(out: &Path) -> Result<()> { Ok(()) } +/// Write the FRI worker's root input for `claim` without compressing it. +pub fn write_root_input( + dir: &Path, + vk: &[u8], + proof: &[u8], + claim: &[u8], + statement: &[u8], + fixed_root: Option<&str>, +) -> Result<()> { + let words = check_claim(claim, statement)?; + prepare_output(dir)?; + write_root(dir, vk, proof, &words, fixed_root) +} + +/// Write the FRI worker's root input: the backend key, root proof, expected +/// statement and, for a normalized root, its fixed layout. +fn write_root( + dir: &Path, + vk: &[u8], + proof: &[u8], + words: &statement::Statement, + fixed_root: Option<&str>, +) -> Result<()> { + fs::create_dir_all(dir)?; + fs::write(dir.join("root-vk.bin"), vk)?; + fs::write(dir.join("root-proof.bin"), proof)?; + fs::write(dir.join("root-claims.bin"), statement::to_claims_bytes(words))?; + if let Some(layout) = fixed_root { + let fixed = FixedRoot::from_json(layout)?; + fs::write(dir.join(root::FIXED_ROOT_LAYOUT), fixed.to_json())?; + } + Ok(()) +} + fn path_string(path: &Path) -> Result { Ok(path.to_str().ok_or("non-UTF8 artifact path")?.to_owned()) } /// Run FRI, first KZG and recursive KZG in separate worker processes. /// The output directory must be empty; interrupted stages retain checkpoints. +/// With `fixed_root`, the root must be normalized to that layout, and the FRI +/// verifier depends only on the backend and layout. +#[allow(clippy::too_many_arguments)] pub fn compress( vk: &[u8], proof: &[u8], claim: &[u8], statement: &[u8], + fixed_root: Option<&str>, out: &Path, cache_root: &Path, development: bool, @@ -432,10 +470,7 @@ pub fn compress( prepare_output(out)?; let caches = Caches::under(cache_root); let root = out.join("root"); - fs::create_dir_all(&root)?; - fs::write(root.join("root-vk.bin"), vk)?; - fs::write(root.join("root-proof.bin"), proof)?; - fs::write(root.join("root-claims.bin"), statement::to_claims_bytes(&words))?; + write_root(&root, vk, proof, &words, fixed_root)?; let fri = out.join("fri"); child( "fri", @@ -606,6 +641,7 @@ mod tests { b"", b"wrong", &statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"wrong")), + None, Path::new("/does-not-exist"), Path::new("/does-not-exist"), true, diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index 32fe9c046..75976082b 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -11,6 +11,9 @@ use p3_field::TwoAdicField; use p3_symmetric::CryptographicHasher; use std::{fs, path::PathBuf, time::Instant}; +/// Layout file a normalized root carries beside its proof. +pub(super) const FIXED_ROOT_LAYOUT: &str = "fixed-root.json"; + /// Compiled worker features for deployment checks. fn print_capabilities() { println!( @@ -113,19 +116,30 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { return Ok(()); } let verifier_key = VerifierKey::from_system(system); - let plan = super::stages::root::RootPlan::new( - &verifier_key, - &proof, - VerifierLimits::default(), - )?; - let compact = std::env::var_os("IX_ROOT_GENERIC_HASHES").is_none(); - let options = ImplementationOptions { compact_blake3: compact }; fs::write(out.join("inner-vk.bin"), verifier_key.to_bytes()?)?; - fs::write(out.join("plan-id.bin"), plan.identity()?)?; - fs::write(out.join("build-id.bin"), plan.build_identity(options)?)?; - fs::write(out.join("statement-schema.txt"), plan.description())?; + // A normalized root carries its layout; the verifier circuit then depends + // only on the backend and layout, never on this proof's geometry. + let layout_path = dir.join(FIXED_ROOT_LAYOUT); let start = Instant::now(); - let prepared = plan.prepare(&proof, &claims[0], options)?; + let prepared = if layout_path.exists() { + let fixed = + super::stages::FixedRoot::from_json(&fs::read_to_string(&layout_path)?)?; + fs::write(out.join(FIXED_ROOT_LAYOUT), fixed.to_json())?; + fs::write(out.join("plan-id.bin"), fixed.identity(&native_key))?; + fixed.prepare(&native_key, &proof, &claims[0], VerifierLimits::default())? + } else { + let plan = super::stages::root::RootPlan::new( + &verifier_key, + &proof, + VerifierLimits::default(), + )?; + let compact = std::env::var_os("IX_ROOT_GENERIC_HASHES").is_none(); + let options = ImplementationOptions { compact_blake3: compact }; + fs::write(out.join("plan-id.bin"), plan.identity()?)?; + fs::write(out.join("build-id.bin"), plan.build_identity(options)?)?; + fs::write(out.join("statement-schema.txt"), plan.description())?; + plan.prepare(&proof, &claims[0], options)? + }; let max_trace_height = 1usize << 24; let layout = prepared.circuit().multi_stark_layout_with_max_height(max_trace_height)?; @@ -176,7 +190,6 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { ); } drop(proof); - drop(plan); drop(verifier_key); let public = prepared.public_values().to_vec(); let start = Instant::now(); diff --git a/crates/proof-compression/src/pipeline/stages/fixed.rs b/crates/proof-compression/src/pipeline/stages/fixed.rs index e441b6c0a..4bdbd6d9e 100644 --- a/crates/proof-compression/src/pipeline/stages/fixed.rs +++ b/crates/proof-compression/src/pipeline/stages/fixed.rs @@ -10,6 +10,8 @@ use multi_stark::{ }; use p3_field::{PrimeCharacteristicRing, PrimeField64}; +const LAYOUT_FORMAT: &str = "ix-fixed-root-layout-v1"; + #[derive(Clone)] pub struct FixedRoot { log_heights: Vec, @@ -55,6 +57,53 @@ impl FixedRoot { Ok(Self { log_heights, memory_traces, claim_len }) } + /// Log-heights in system circuit order. + pub fn log_heights(&self) -> &[u8] { + &self.log_heights + } + + /// `(memory width, circuit index)` pairs, ascending by width. + pub fn memory_traces(&self) -> &[(usize, usize)] { + &self.memory_traces + } + + /// Canonical JSON layout, readable by [`Self::from_json`]. + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(&serde_json::json!({ + "format": LAYOUT_FORMAT, + "log_heights": self.log_heights, + "memory_traces": self.memory_traces, + "claim_len": self.claim_len, + })) + .expect("layout JSON serializes") + } + + pub fn from_json(text: &str) -> Result { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Layout { + format: String, + log_heights: Vec, + memory_traces: Vec<(usize, usize)>, + claim_len: usize, + } + let layout: Layout = serde_json::from_str(text)?; + if layout.format != LAYOUT_FORMAT { + return Err("unsupported fixed root layout format".into()); + } + Self::new(layout.log_heights, layout.memory_traces, layout.claim_len) + } + + /// Identity of the fixed verifier under `key`. It covers the backend and + /// layout only: no claim, proof or memory end affects it. + pub fn identity(&self, key: &AiurVerifyingKey) -> [u8; 32] { + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/fixed-root/v1"); + hash.update(blake3::hash(&key.to_bytes()).as_bytes()); + hash.update(self.to_json().as_bytes()); + *hash.finalize().as_bytes() + } + fn preamble( &self, key: &AiurVerifyingKey, @@ -315,7 +364,11 @@ mod tests { for memories in [vec![(1, 4)], vec![(1, 1), (2, 1)], vec![(2, 1), (1, 0)]] { assert!(FixedRoot::new(vec![4, 1, 8, 16], memories, 5).is_err()); } + assert_eq!(system.memory_circuits(), fixed.memory_traces()); + assert_eq!(FixedRoot::from_json(&fixed.to_json())?.to_json(), fixed.to_json()); + assert!(FixedRoot::from_json(&fixed.to_json().replace("-v1", "-v0")).is_err()); let other = FixedRoot::new(vec![4, 2, 8, 16], vec![(1, 1)], 5)?; + assert_ne!(fixed.identity(&key), other.identity(&key)); let config = || GoldilocksBlake3Config::new(cp, fp); let local = setup::fri(fixed.circuit(&key, Default::default())?, config(), 1 << 16)?; @@ -325,6 +378,10 @@ mod tests { IOBuffer { data: Default::default(), map: Default::default() }; let (record, output) = system.toplevel().execute(0, input.clone(), &mut io)?; + let logs = + system.normalized_log_heights(&system.execution_rows(&record), 0)?; + assert_eq!(&logs[2..], &[8, 16]); + assert!(logs.iter().zip(fixed.log_heights()).all(|(a, b)| a <= b)); let (claim, proof) = system.prove_from_execution_normalized( 0, &input, From a5ddac438cedb856a29122d2576787ffa7c96010 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:38:44 +0000 Subject: [PATCH 20/31] feat(compression): add BN254 Groth16 terminal backend Add CPU and ICICLE CUDA proving, explicit development setup and admitted key catalogs, and Solidity verification for the existing KZG relation. Wire terminal selection through Lean and Rust while preserving fixed-root normalization. Include proof fixtures and the matched Groth16/PLONK comparison. Focused Rust clippy, Go regression tests, and Solidity tests passed after the port; Lean/FFI rechecks remain deferred. --- Ix/Cli/CompressCmd.lean | 11 +- Ix/Cli/Compression.lean | 4 +- Tests/CompressionCli.lean | 4 + contracts/groth16/.gitignore | 2 + contracts/groth16/foundry.toml | 12 + contracts/groth16/src/IxGroth16Verifier.sol | 46 ++ .../groth16/test/IxGroth16Verifier.t.sol | 79 ++ .../groth16/testdata/committed/Verifier.sol | 712 ++++++++++++++++++ .../groth16/testdata/committed/proof.json | 6 + contracts/groth16/testdata/plain/Verifier.sol | 563 ++++++++++++++ contracts/groth16/testdata/plain/proof.json | 6 + .../groth16/testdata/wrapper/Verifier.sol | 712 ++++++++++++++++++ contracts/groth16/testdata/wrapper/proof.json | 5 + crates/ffi/Cargo.toml | 3 +- crates/ffi/src/compression.rs | 10 +- crates/proof-compression/Cargo.toml | 3 +- .../src/bin/ix_compression_worker.rs | 2 + .../proof-compression/src/pipeline/groth16.rs | 497 ++++++++++++ crates/proof-compression/src/pipeline/mod.rs | 135 +++- .../proof-compression/src/pipeline/plonk.rs | 103 +-- crates/proof-compression/src/pipeline/root.rs | 1 + .../proof-compression/src/pipeline/setup.rs | 10 +- .../src/pipeline/stages/verification.rs | 2 +- .../src/pipeline/terminal.rs | 181 +++++ .../src/pipeline/wrap/mod.rs | 4 +- .../src/pipeline/wrap/outer.rs | 14 +- .../src/pipeline/wrap/outer/plonk_export.rs | 5 +- .../src/pipeline/wrap/outer/relation.rs | 2 +- .../tests/fixtures/groth16/proof.bin | Bin 0 -> 196 bytes .../tests/fixtures/groth16/provenance.json | 29 + .../tests/fixtures/groth16/verifying-key.bin | Bin 0 -> 560 bytes flake.nix | 42 +- lakefile.lean | 43 +- tests/check_compression_build.py | 12 +- tests/compression_build_config.lean | 2 + tools/plonk-wrapper/README.md | 185 +++++ tools/plonk-wrapper/build_cuda.py | 13 +- tools/plonk-wrapper/build_groth16_cuda.py | 39 + tools/plonk-wrapper/go.mod | 1 + tools/plonk-wrapper/go.sum | 2 + tools/plonk-wrapper/groth16.go | 557 ++++++++++++++ tools/plonk-wrapper/groth16_cuda.go | 111 +++ tools/plonk-wrapper/groth16_cuda_test.go | 66 ++ tools/plonk-wrapper/groth16_no_cuda.go | 11 + tools/plonk-wrapper/groth16_setup.go | 211 ++++++ tools/plonk-wrapper/groth16_setup_test.go | 152 ++++ tools/plonk-wrapper/groth16_test.go | 175 +++++ tools/plonk-wrapper/main.go | 3 + tools/plonk-wrapper/terminal_compare_test.go | 337 +++++++++ .../testdata/terminal-comparison.json | 198 +++++ 50 files changed, 5168 insertions(+), 155 deletions(-) create mode 100644 contracts/groth16/.gitignore create mode 100644 contracts/groth16/foundry.toml create mode 100644 contracts/groth16/src/IxGroth16Verifier.sol create mode 100644 contracts/groth16/test/IxGroth16Verifier.t.sol create mode 100644 contracts/groth16/testdata/committed/Verifier.sol create mode 100644 contracts/groth16/testdata/committed/proof.json create mode 100644 contracts/groth16/testdata/plain/Verifier.sol create mode 100644 contracts/groth16/testdata/plain/proof.json create mode 100644 contracts/groth16/testdata/wrapper/Verifier.sol create mode 100644 contracts/groth16/testdata/wrapper/proof.json create mode 100644 crates/proof-compression/src/pipeline/groth16.rs create mode 100644 crates/proof-compression/src/pipeline/terminal.rs create mode 100644 crates/proof-compression/tests/fixtures/groth16/proof.bin create mode 100644 crates/proof-compression/tests/fixtures/groth16/provenance.json create mode 100644 crates/proof-compression/tests/fixtures/groth16/verifying-key.bin create mode 100644 tools/plonk-wrapper/build_groth16_cuda.py create mode 100644 tools/plonk-wrapper/groth16.go create mode 100644 tools/plonk-wrapper/groth16_cuda.go create mode 100644 tools/plonk-wrapper/groth16_cuda_test.go create mode 100644 tools/plonk-wrapper/groth16_no_cuda.go create mode 100644 tools/plonk-wrapper/groth16_setup.go create mode 100644 tools/plonk-wrapper/groth16_setup_test.go create mode 100644 tools/plonk-wrapper/groth16_test.go create mode 100644 tools/plonk-wrapper/terminal_compare_test.go create mode 100644 tools/plonk-wrapper/testdata/terminal-comparison.json diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index 08b09ea72..a68cdf6ff 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -8,6 +8,13 @@ namespace Ix.Cli.CompressCmd def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do try + if p.hasFlag "plonk" && p.hasFlag "groth16" then + throw <| IO.userError "choose at most one of --plonk and --groth16" + if p.hasFlag "dev-groth16" && !p.hasFlag "groth16" then + throw <| IO.userError "--dev-groth16 requires --groth16" + let terminal : UInt8 := if p.hasFlag "groth16" then + (if p.hasFlag "dev-groth16" then 3 else 2) + else if p.hasFlag "plonk" then 1 else 0 let setup ← Compression.describeSetup (p.hasFlag "dev-srs") let hex := p.positionalArg! "proof" |>.as! String let some addr := Address.fromString hex | throw <| IO.userError "expected a 64-character proof address" @@ -40,7 +47,7 @@ def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do IO.eprintln s!"Compressing aggregate root {addr} with {setup}." let proof ← Compression.compress vk root (Ix.Claim.ser wrapper.claim) statement layout out (← Compression.cacheDir).toString - (p.hasFlag "dev-srs") height.toUInt32 (p.hasFlag "plonk") + (p.hasFlag "dev-srs") height.toUInt32 terminal let compressed : Ixon.Proof := { claim := wrapper.claim, proof } let result ← StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) IO.println (toString result) @@ -61,6 +68,8 @@ def compressCmd : Cli.Cmd := `[Cli| "fixed-root" : String; "Normalize the root to this layout (from `ix compression layout`) first, so the verifier does not depend on the root's shape." "dev-srs"; "Use known-trapdoor development parameters; results are not production-secure." "plonk"; "Add a terminal PLONK proof using the compiled curve and selected setup." + "groth16"; "Add a terminal BN254 Groth16 proof using prepared, independently admitted circuit keys." + "dev-groth16"; "Allow development Groth16 keys; inner KZG still requires admitted Ignition parameters." ARGS: proof : String; "Address of an aggregate Ixon.Proof in the ix store." ] diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 938658ebb..5a45cde76 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -23,7 +23,7 @@ preprocessing and development SRS caches under `cacheRoot`. A nonempty `fixedRoot` is the JSON layout `proof` was normalized to. -/ @[extern "rs_compression_run"] opaque compress (vk proof claim statement : @& ByteArray) (fixedRoot dir cacheRoot : @& String) - (devSrs : Bool) (logHeight : UInt32) (terminalPlonk : Bool) : IO ByteArray + (devSrs : Bool) (logHeight : UInt32) (terminal : UInt8) : IO ByteArray /-- Per-circuit rows of one root wrap execution of `proof`, as a JSON array. -/ @[extern "rs_compression_root_rows"] @@ -87,6 +87,8 @@ def verifyProof (addr : Address) (wrapper : Ixon.Proof) (devSrs : Bool) : IO UIn IO.println s!"ok: development proof {addr} verifies claim {Address.blake3 claim} (not production-secure)" | "ignition" => IO.println s!"ok: proof {addr} verifies claim {Address.blake3 claim} under the Aztec Ignition powers of tau" + | "groth16" => + IO.println s!"ok: Groth16 proof {addr} verifies claim {Address.blake3 claim} under the admitted circuit-specific setup" | _ => throw <| IO.userError s!"unsupported compression setup: {setup}" return 0 diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index 9423fbb24..02667cefb 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -45,6 +45,10 @@ public def run : IO Unit := do let old ← putProof home (Ixon.Proof.ser native) let addr ← putProof home (Ixon.Proof.ser compressed) let otherAddr ← putProof home (Ixon.Proof.ser other) + rejects home #["compress", toString old, "--groth16", "--plonk"] + "choose at most one" + rejects home #["compress", toString old, "--dev-groth16"] + "--dev-groth16 requires --groth16" unless old != addr && native.claim == compressed.claim do throw <| IO.userError "compression must preserve claim identity and change proof identity" rejects home #["compress", toString old] "--dev-srs" diff --git a/contracts/groth16/.gitignore b/contracts/groth16/.gitignore new file mode 100644 index 000000000..f49938ec9 --- /dev/null +++ b/contracts/groth16/.gitignore @@ -0,0 +1,2 @@ +/cache/ +/out/ diff --git a/contracts/groth16/foundry.toml b/contracts/groth16/foundry.toml new file mode 100644 index 000000000..4b40ad8d2 --- /dev/null +++ b/contracts/groth16/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +cache_path = "cache" +libs = [] +solc = "0.8.33" +evm_version = "prague" +optimizer = true +optimizer_runs = 200 +via_ir = true +fs_permissions = [{ access = "read", path = "./testdata" }] diff --git a/contracts/groth16/src/IxGroth16Verifier.sol b/contracts/groth16/src/IxGroth16Verifier.sol new file mode 100644 index 000000000..19b17cc3c --- /dev/null +++ b/contracts/groth16/src/IxGroth16Verifier.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.28; + +interface IGroth16Verifier { + function verifyProof(bytes calldata proof, uint256[2] calldata publicInputs) external view; +} + +/// The verifier address and code hash fix the circuit, setup and aggregation program. +contract IxGroth16Verifier { + // Malformed pairing inputs can consume all gas forwarded to a precompile. + uint256 public constant VERIFICATION_GAS = 1_000_000; + IGroth16Verifier public immutable verifier; + bytes32 public immutable verifierCodeHash; + + constructor(IGroth16Verifier trustedVerifier, bytes32 trustedCodeHash) { + require(address(trustedVerifier).code.length != 0, "missing verifier code"); + require(address(trustedVerifier).codehash == trustedCodeHash, "verifier code differs"); + verifier = trustedVerifier; + verifierCodeHash = trustedCodeHash; + } + + /// The caller derives expectedClaimDigest independently of the proof. + function verify(bytes calldata proof, bytes32 expectedClaimDigest) external view returns (bool) { + if (proof.length != 256 && proof.length != 384) return false; + if (address(verifier).codehash != verifierCodeHash) return false; + uint256[2] memory publicInputs = [ + uint256(_reverse128(uint128(uint256(expectedClaimDigest) >> 128))), + uint256(_reverse128(uint128(uint256(expectedClaimDigest)))) + ]; + try verifier.verifyProof{gas: VERIFICATION_GAS}(proof, publicInputs) { + return true; + } catch { + return false; + } + } + + function _reverse128(uint128 value) private pure returns (uint128) { + value = ((value >> 8) & 0x00ff00ff00ff00ff00ff00ff00ff00ff) | + ((value & 0x00ff00ff00ff00ff00ff00ff00ff00ff) << 8); + value = ((value >> 16) & 0x0000ffff0000ffff0000ffff0000ffff) | + ((value & 0x0000ffff0000ffff0000ffff0000ffff) << 16); + value = ((value >> 32) & 0x00000000ffffffff00000000ffffffff) | + ((value & 0x00000000ffffffff00000000ffffffff) << 32); + return (value >> 64) | (value << 64); + } +} diff --git a/contracts/groth16/test/IxGroth16Verifier.t.sol b/contracts/groth16/test/IxGroth16Verifier.t.sol new file mode 100644 index 000000000..5107f8b85 --- /dev/null +++ b/contracts/groth16/test/IxGroth16Verifier.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.28; + +import {IxGroth16Verifier, IGroth16Verifier} from "../src/IxGroth16Verifier.sol"; +import {Verifier as PlainVerifier} from "../testdata/plain/Verifier.sol"; +import {Verifier as CommittedVerifier} from "../testdata/committed/Verifier.sol"; +import {Verifier as WrapperVerifier} from "../testdata/wrapper/Verifier.sol"; + +interface Vm { + function readFile(string calldata path) external view returns (string memory); + function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory); + function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32); +} + +contract IxGroth16VerifierTest { + event log_named_uint(string key, uint256 val); + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function vector(string memory name) private view returns (bytes memory proof, bytes32 claim) { + string memory data = vm.readFile(string.concat("testdata/", name, "/proof.json")); + return (vm.parseJsonBytes(data, ".proof"), vm.parseJsonBytes32(data, ".digest")); + } + + function facade(address verifier) private returns (IxGroth16Verifier) { + return new IxGroth16Verifier(IGroth16Verifier(verifier), verifier.codehash); + } + + function testPlainProofAndClaimBinding() public { + IxGroth16Verifier verifier = facade(address(new PlainVerifier())); + (bytes memory proof, bytes32 claim) = vector("plain"); + require(proof.length == 256, "unexpected plain encoding"); + require(verifier.verify(proof, claim), "valid plain proof rejected"); + require(!verifier.verify(proof, bytes32(uint256(claim) ^ 1)), "wrong claim accepted"); + require(!verifier.verify(bytes.concat(proof, hex"00"), claim), "trailing bytes accepted"); + require(!verifier.verify(hex"00", claim), "short proof accepted"); + proof[0] = 0xff; + require(!verifier.verify(proof, claim), "invalid point accepted"); + } + + function testCommittedProofAndCommitmentBinding() public { + IxGroth16Verifier verifier = facade(address(new CommittedVerifier())); + (bytes memory proof, bytes32 claim) = vector("committed"); + require(proof.length == 384, "unexpected committed encoding"); + require(verifier.verify(proof, claim), "valid committed proof rejected"); + require(!verifier.verify(proof, bytes32(uint256(claim) ^ (1 << 128))), "wrong claim half accepted"); + proof[256] ^= bytes1(0x01); + require(!verifier.verify(proof, claim), "changed commitment accepted"); + proof[256] ^= bytes1(0x01); + proof[383] ^= bytes1(0x01); + require(!verifier.verify(proof, claim), "changed commitment proof accepted"); + } + + function testDifferentVerifierRejectsProof() public { + IxGroth16Verifier verifier = facade(address(new PlainVerifier())); + (bytes memory proof, bytes32 claim) = vector("committed"); + require(!verifier.verify(proof, claim), "proof for another key accepted"); + } + + function testRealKzgWrapperProof() public { + IxGroth16Verifier verifier = facade(address(new WrapperVerifier())); + (bytes memory proof, bytes32 claim) = vector("wrapper"); + uint256 before = gasleft(); + require(verifier.verify(proof, claim), "valid KZG wrapper proof rejected"); + uint256 used = before - gasleft(); + emit log_named_uint("KZG wrapper verification gas", used); + require(used < verifier.VERIFICATION_GAS(), "verification exceeds its gas budget"); + require(!verifier.verify(proof, bytes32(uint256(claim) ^ 1)), "wrong KZG claim accepted"); + } + + function testMissingOrDifferentVerifierCodeRejected() public { + try new IxGroth16Verifier(IGroth16Verifier(address(1)), bytes32(0)) { + revert("missing verifier accepted"); + } catch {} + address verifier = address(new PlainVerifier()); + try new IxGroth16Verifier(IGroth16Verifier(verifier), bytes32(0)) { + revert("different code accepted"); + } catch {} + } +} diff --git a/contracts/groth16/testdata/committed/Verifier.sol b/contracts/groth16/testdata/committed/Verifier.sol new file mode 100644 index 000000000..009dff023 --- /dev/null +++ b/contracts/groth16/testdata/committed/Verifier.sol @@ -0,0 +1,712 @@ + +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.28; + +/// @title Groth16 verifier template. +/// @author Remco Bloemen +/// @notice Supports verifying Groth16 proofs. Proofs can be in uncompressed +/// (256 bytes + optional commitments) and compressed (128 bytes) format. +/// Uncompressed proofs are passed as bytes calldata matching the output of +/// MarshalSolidity(). A view function is provided to compress proofs. +/// @notice See for further explanation. +contract Verifier { + + /// Some of the provided public input values are larger than the field modulus. + /// @dev Public input elements are not automatically reduced, as this is can be + /// a dangerous source of bugs. + error PublicInputNotInField(); + + /// The proof is invalid. + /// @dev This can mean that provided Groth16 proof points are not on their + /// curves, that pairing equation fails, or that the proof is not for the + /// provided public input. + error ProofInvalid(); + /// The commitment is invalid + /// @dev This can mean that provided commitment points and/or proof of knowledge are not on their + /// curves, that pairing equation fails, or that the commitment and/or proof of knowledge is not for the + /// commitment key. + error CommitmentInvalid(); + + // Addresses of precompiles + uint256 constant PRECOMPILE_MODEXP = 0x05; + uint256 constant PRECOMPILE_ADD = 0x06; + uint256 constant PRECOMPILE_MUL = 0x07; + uint256 constant PRECOMPILE_VERIFY = 0x08; + + // Base field Fp order P and scalar field Fr order R. + // For BN254 these are computed as follows: + // t = 4965661367192848881 + // P = 36⋅t⁴ + 36⋅t³ + 24⋅t² + 6⋅t + 1 + // R = 36⋅t⁴ + 36⋅t³ + 18⋅t² + 6⋅t + 1 + uint256 constant P = 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47; + uint256 constant R = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001; + + // Extension field Fp2 = Fp[i] / (i² + 1) + // Note: This is the complex extension field of Fp with i² = -1. + // Values in Fp2 are represented as a pair of Fp elements (a₀, a₁) as a₀ + a₁⋅i. + // Note: The order of Fp2 elements is *opposite* that of the pairing contract, which + // expects Fp2 elements in order (a₁, a₀). This is also the order in which + // Fp2 elements are encoded in the public interface as this became convention. + + // Constants in Fp + uint256 constant FRACTION_1_2_FP = 0x183227397098d014dc2822db40c0ac2ecbc0b548b438e5469e10460b6c3e7ea4; + uint256 constant FRACTION_27_82_FP = 0x2b149d40ceb8aaae81be18991be06ac3b5b4c5e559dbefa33267e6dc24a138e5; + uint256 constant FRACTION_3_82_FP = 0x2fcd3ac2a640a154eb23960892a85a68f031ca0c8344b23a577dcf1052b9e775; + + // Exponents for inversions and square roots mod P + uint256 constant EXP_INVERSE_FP = 0x30644E72E131A029B85045B68181585D97816A916871CA8D3C208C16D87CFD45; // P - 2 + uint256 constant EXP_SQRT_FP = 0xC19139CB84C680A6E14116DA060561765E05AA45A1C72A34F082305B61F3F52; // (P + 1) / 4; + + // Groth16 alpha point in G1 + uint256 constant ALPHA_X = 7828453201079832787741943236878950802432263443788436202034617928270201247641; + uint256 constant ALPHA_Y = 14255666985536596615592574588014779615656954681710491014089598027800790096749; + + // Groth16 beta point in G2 in powers of i + uint256 constant BETA_NEG_X_0 = 13207796800844159736991558829741024144350398059195558993930564542425648197183; + uint256 constant BETA_NEG_X_1 = 5562769691646014155903006538144500853811770131821955943491515825150107172017; + uint256 constant BETA_NEG_Y_0 = 8329093844409018926055428997212602963062219101789747890078174631438711991819; + uint256 constant BETA_NEG_Y_1 = 6975001982279568060670735408419413625776518258044989994379077548899369489894; + + // Groth16 gamma point in G2 in powers of i + uint256 constant GAMMA_NEG_X_0 = 5319119594649083035954077304699542490548608855808494871892290037370115570306; + uint256 constant GAMMA_NEG_X_1 = 8869150356442081652601624009554283055648262713669957942968344052186591156649; + uint256 constant GAMMA_NEG_Y_0 = 612568215963750390215976459655070115112209733412555183226850610639561770995; + uint256 constant GAMMA_NEG_Y_1 = 289509783331058742687045946268491500449099944169905865684122452693270933927; + + // Groth16 delta point in G2 in powers of i + uint256 constant DELTA_NEG_X_0 = 16910630396140037516481764176990593541585820638037470922337237333629952377462; + uint256 constant DELTA_NEG_X_1 = 15983544030216061968578018550514356993980238379809194129082890701170910052539; + uint256 constant DELTA_NEG_Y_0 = 4070693252652498844401126380683854598463896965020476222491793733691487946057; + uint256 constant DELTA_NEG_Y_1 = 7027148964577472698453990033418833455979329434633480551636010673630473865563; + // Pedersen G point in G2 in powers of i + uint256 constant PEDERSEN_G_X_0 = 20203264194858960482942528482684256535008310789754843830374999989727899690955; + uint256 constant PEDERSEN_G_X_1 = 8382908087252191233922903745298331907848741644388140952904734700438170107285; + uint256 constant PEDERSEN_G_Y_0 = 5546536256985687781255535208468721562114136907696702742981375928720727067233; + uint256 constant PEDERSEN_G_Y_1 = 15707789199779850463104407371475661377516643807672443755496733136705086998504; + + // Pedersen GSigmaNeg point in G2 in powers of i + uint256 constant PEDERSEN_GSIGMANEG_X_0 = 10192936832924620000566749992451340342179632899786430363478971532562789881097; + uint256 constant PEDERSEN_GSIGMANEG_X_1 = 19834842068758686654749624770883035837004476290916342446400967143640803279483; + uint256 constant PEDERSEN_GSIGMANEG_Y_0 = 19900910017079582115964424143640650853092219437495849195340189948080722260674; + uint256 constant PEDERSEN_GSIGMANEG_Y_1 = 5682933495873789872178741437241151981569204376908677251638317451499025736718; + + // Constant and public input points + uint256 constant CONSTANT_X = 15461909684414393037893004347544623071090233761159761059407566106390910469051; + uint256 constant CONSTANT_Y = 6701120498237355317221615283877069232680687209327960779643002943517161679837; + uint256 constant PUB_0_X = 6956891633129828870727903991584170543533574457764776891080307104547113842030; + uint256 constant PUB_0_Y = 8545373461165545755743195387772946139227724845719286818333583185639464631015; + uint256 constant PUB_1_X = 21097821209774993074543405356203237046294791601223309733595628196667638179787; + uint256 constant PUB_1_Y = 2435275676198758235523058150176492747615897942991796716991910709931421959209; + uint256 constant PUB_2_X = 14169900240262919740982593268047469149044687675736554278503791450553299993545; + uint256 constant PUB_2_Y = 8424757811983826375615643382947233504590941374102225679100532005257177651021; + + /// Negation in Fp. + /// @notice Returns a number x such that a + x = 0 in Fp. + /// @notice The input does not need to be reduced. + /// @param a the base + /// @return x the result + function negate(uint256 a) internal pure returns (uint256 x) { + unchecked { + x = (P - (a % P)) % P; // Modulo is cheaper than branching + } + } + + /// Exponentiation in Fp. + /// @notice Returns a number x such that a ^ e = x in Fp. + /// @notice The input does not need to be reduced. + /// @param a the base + /// @param e the exponent + /// @return x the result + function exp(uint256 a, uint256 e) internal view returns (uint256 x) { + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + mstore(f, 0x20) + mstore(add(f, 0x20), 0x20) + mstore(add(f, 0x40), 0x20) + mstore(add(f, 0x60), a) + mstore(add(f, 0x80), e) + mstore(add(f, 0xa0), P) + success := staticcall(gas(), PRECOMPILE_MODEXP, f, 0xc0, f, 0x20) + x := mload(f) + } + if (!success) { + // Exponentiation failed. + // Should not happen. + revert ProofInvalid(); + } + } + + /// Invertsion in Fp. + /// @notice Returns a number x such that a * x = 1 in Fp. + /// @notice The input does not need to be reduced. + /// @notice Reverts with ProofInvalid() if the inverse does not exist + /// @param a the input + /// @return x the solution + function invert_Fp(uint256 a) internal view returns (uint256 x) { + x = exp(a, EXP_INVERSE_FP); + if (mulmod(a, x, P) != 1) { + // Inverse does not exist. + // Can only happen during G2 point decompression. + revert ProofInvalid(); + } + } + + /// Square root in Fp. + /// @notice Returns a number x such that x * x = a in Fp. + /// @notice Will revert with InvalidProof() if the input is not a square + /// or not reduced. + /// @param a the square + /// @return x the solution + function sqrt_Fp(uint256 a) internal view returns (uint256 x) { + x = exp(a, EXP_SQRT_FP); + if (mulmod(x, x, P) != a) { + // Square root does not exist or a is not reduced. + // Happens when G1 point is not on curve. + revert ProofInvalid(); + } + } + + /// Square test in Fp. + /// @notice Returns whether a number x exists such that x * x = a in Fp. + /// @notice Will revert with InvalidProof() if the input is not a square + /// or not reduced. + /// @param a the square + /// @return x the solution + function isSquare_Fp(uint256 a) internal view returns (bool) { + uint256 x = exp(a, EXP_SQRT_FP); + return mulmod(x, x, P) == a; + } + + /// Square root in Fp2. + /// @notice Fp2 is the complex extension Fp[i]/(i^2 + 1). The input is + /// a0 + a1 ⋅ i and the result is x0 + x1 ⋅ i. + /// @notice Will revert with InvalidProof() if + /// * the input is not a square, + /// * the hint is incorrect, or + /// * the input coefficients are not reduced. + /// @param a0 The real part of the input. + /// @param a1 The imaginary part of the input. + /// @param hint A hint which of two possible signs to pick in the equation. + /// @return x0 The real part of the square root. + /// @return x1 The imaginary part of the square root. + function sqrt_Fp2(uint256 a0, uint256 a1, bool hint) internal view returns (uint256 x0, uint256 x1) { + // If this square root reverts there is no solution in Fp2. + uint256 d = sqrt_Fp(addmod(mulmod(a0, a0, P), mulmod(a1, a1, P), P)); + if (hint) { + d = negate(d); + } + // If this square root reverts there is no solution in Fp2. + x0 = sqrt_Fp(mulmod(addmod(a0, d, P), FRACTION_1_2_FP, P)); + x1 = mulmod(a1, invert_Fp(mulmod(x0, 2, P)), P); + + // Check result to make sure we found a root. + // Note: this also fails if a0 or a1 is not reduced. + if (a0 != addmod(mulmod(x0, x0, P), negate(mulmod(x1, x1, P)), P) + || a1 != mulmod(2, mulmod(x0, x1, P), P)) { + revert ProofInvalid(); + } + } + + /// Compress a G1 point. + /// @notice Reverts with InvalidProof if the coordinates are not reduced + /// or if the point is not on the curve. + /// @notice The point at infinity is encoded as (0,0) and compressed to 0. + /// @param x The X coordinate in Fp. + /// @param y The Y coordinate in Fp. + /// @return c The compresed point (x with one signal bit). + function compress_g1(uint256 x, uint256 y) internal view returns (uint256 c) { + if (x >= P || y >= P) { + // G1 point not in field. + revert ProofInvalid(); + } + if (x == 0 && y == 0) { + // Point at infinity + return 0; + } + + // Note: sqrt_Fp reverts if there is no solution, i.e. the x coordinate is invalid. + uint256 y_pos = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); + if (y == y_pos) { + return (x << 1) | 0; + } else if (y == negate(y_pos)) { + return (x << 1) | 1; + } else { + // G1 point not on curve. + revert ProofInvalid(); + } + } + + /// Decompress a G1 point. + /// @notice Reverts with InvalidProof if the input does not represent a valid point. + /// @notice The point at infinity is encoded as (0,0) and compressed to 0. + /// @param c The compresed point (x with one signal bit). + /// @return x The X coordinate in Fp. + /// @return y The Y coordinate in Fp. + function decompress_g1(uint256 c) internal view returns (uint256 x, uint256 y) { + // Note that X = 0 is not on the curve since 0³ + 3 = 3 is not a square. + // so we can use it to represent the point at infinity. + if (c == 0) { + // Point at infinity as encoded in EIP196 and EIP197. + return (0, 0); + } + bool negate_point = c & 1 == 1; + x = c >> 1; + if (x >= P) { + // G1 x coordinate not in field. + revert ProofInvalid(); + } + + // Note: (x³ + 3) is irreducible in Fp, so it can not be zero and therefore + // y can not be zero. + // Note: sqrt_Fp reverts if there is no solution, i.e. the point is not on the curve. + y = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); + if (negate_point) { + y = negate(y); + } + } + + /// Compress a G2 point. + /// @notice Reverts with InvalidProof if the coefficients are not reduced + /// or if the point is not on the curve. + /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) + /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). + /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). + /// @param x0 The real part of the X coordinate. + /// @param x1 The imaginary poart of the X coordinate. + /// @param y0 The real part of the Y coordinate. + /// @param y1 The imaginary part of the Y coordinate. + /// @return c0 The first half of the compresed point (x0 with two signal bits). + /// @return c1 The second half of the compressed point (x1 unmodified). + function compress_g2(uint256 x0, uint256 x1, uint256 y0, uint256 y1) + internal view returns (uint256 c0, uint256 c1) { + if (x0 >= P || x1 >= P || y0 >= P || y1 >= P) { + // G2 point not in field. + revert ProofInvalid(); + } + if ((x0 | x1 | y0 | y1) == 0) { + // Point at infinity + return (0, 0); + } + + // Compute y^2 + // Note: shadowing variables and scoping to avoid stack-to-deep. + uint256 y0_pos; + uint256 y1_pos; + { + uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); + uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); + uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); + y0_pos = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); + y1_pos = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); + } + + // Determine hint bit + // If this sqrt fails the x coordinate is not on the curve. + bool hint; + { + uint256 d = sqrt_Fp(addmod(mulmod(y0_pos, y0_pos, P), mulmod(y1_pos, y1_pos, P), P)); + hint = !isSquare_Fp(mulmod(addmod(y0_pos, d, P), FRACTION_1_2_FP, P)); + } + + // Recover y + (y0_pos, y1_pos) = sqrt_Fp2(y0_pos, y1_pos, hint); + if (y0 == y0_pos && y1 == y1_pos) { + c0 = (x0 << 2) | (hint ? 2 : 0) | 0; + c1 = x1; + } else if (y0 == negate(y0_pos) && y1 == negate(y1_pos)) { + c0 = (x0 << 2) | (hint ? 2 : 0) | 1; + c1 = x1; + } else { + // G1 point not on curve. + revert ProofInvalid(); + } + } + + /// Decompress a G2 point. + /// @notice Reverts with InvalidProof if the input does not represent a valid point. + /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) + /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). + /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). + /// @param c0 The first half of the compresed point (x0 with two signal bits). + /// @param c1 The second half of the compressed point (x1 unmodified). + /// @return x0 The real part of the X coordinate. + /// @return x1 The imaginary poart of the X coordinate. + /// @return y0 The real part of the Y coordinate. + /// @return y1 The imaginary part of the Y coordinate. + function decompress_g2(uint256 c0, uint256 c1) + internal view returns (uint256 x0, uint256 x1, uint256 y0, uint256 y1) { + // Note that X = (0, 0) is not on the curve since 0³ + 3/(9 + i) is not a square. + // so we can use it to represent the point at infinity. + if (c0 == 0 && c1 == 0) { + // Point at infinity as encoded in EIP197. + return (0, 0, 0, 0); + } + bool negate_point = c0 & 1 == 1; + bool hint = c0 & 2 == 2; + x0 = c0 >> 2; + x1 = c1; + if (x0 >= P || x1 >= P) { + // G2 x0 or x1 coefficient not in field. + revert ProofInvalid(); + } + + uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); + uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); + uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); + + y0 = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); + y1 = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); + + // Note: sqrt_Fp2 reverts if there is no solution, i.e. the point is not on the curve. + // Note: (X³ + 3/(9 + i)) is irreducible in Fp2, so y can not be zero. + // But y0 or y1 may still independently be zero. + (y0, y1) = sqrt_Fp2(y0, y1, hint); + if (negate_point) { + y0 = negate(y0); + y1 = negate(y1); + } + } + + /// Compute the public input linear combination. + /// @notice Reverts with PublicInputNotInField if the input is not in the field. + /// @notice Computes the multi-scalar-multiplication of the public input + /// elements and the verification key including the constant term. + /// @param input The public inputs. These are elements of the scalar field Fr. + /// @param publicCommitments public inputs generated from pedersen commitments. + /// @param commitments The Pedersen commitments from the proof. + /// @return x The X coordinate of the resulting G1 point. + /// @return y The Y coordinate of the resulting G1 point. + function publicInputMSM( + uint256[2] calldata input, + uint256[1] memory publicCommitments, + uint256[2] memory commitments + ) + internal view returns (uint256 x, uint256 y) { + // Note: The ECMUL precompile does not reject unreduced values, so we check this. + // Note: Unrolling this loop does not cost much extra in code-size, the bulk of the + // code-size is in the PUB_ constants. + // ECMUL has input (x, y, scalar) and output (x', y'). + // ECADD has input (x1, y1, x2, y2) and output (x', y'). + // We reduce commitments(if any) with constants as the first point argument to ECADD. + // We call them such that ecmul output is already in the second point + // argument to ECADD so we can have a tight loop. + bool success = true; + assembly ("memory-safe") { + let f := mload(0x40) + let g := add(f, 0x40) + let s + mstore(f, CONSTANT_X) + mstore(add(f, 0x20), CONSTANT_Y) + mstore(g, mload(commitments)) + mstore(add(g, 0x20), mload(add(commitments, 0x20))) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_0_X) + mstore(add(g, 0x20), PUB_0_Y) + s := calldataload(input) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_1_X) + mstore(add(g, 0x20), PUB_1_Y) + s := calldataload(add(input, 32)) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_2_X) + mstore(add(g, 0x20), PUB_2_Y) + s := mload(publicCommitments) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + + x := mload(f) + y := mload(add(f, 0x20)) + } + if (!success) { + // Either Public input not in field, or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert PublicInputNotInField(); + } + } + + /// Compress a proof. + /// @notice Will revert with InvalidProof if the curve points are invalid, + /// but does not verify the proof itself. + /// @param proof The uncompressed Groth16 proof. Points (A, B, C) encoded as in EIP-197 + /// (256 bytes total). + /// Followed by Pedersen commitments (1 × 64 bytes) and proof of knowledge + /// (64 bytes) = 384 bytes total. + /// @return compressed The compressed proof. Elements are in the same order as for + /// verifyCompressedProof. I.e. points (A, B, C) in compressed format. + /// @return compressedCommitments compressed Pedersen commitments from the proof. + /// @return compressedCommitmentPok compressed proof of knowledge for the Pedersen commitments. + function compressProof(bytes calldata proof) + public view returns ( + uint256[4] memory compressed, + uint256[1] memory compressedCommitments, + uint256 compressedCommitmentPok + ) { + require(proof.length == 384, "invalid proof length"); + uint256 a0; + uint256 a1; + assembly ("memory-safe") { + a0 := calldataload(proof.offset) + a1 := calldataload(add(proof.offset, 0x20)) + } + compressed[0] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x60)) + a1 := calldataload(add(proof.offset, 0x40)) + } + uint256 b0; + uint256 b1; + assembly ("memory-safe") { + b0 := calldataload(add(proof.offset, 0xa0)) + b1 := calldataload(add(proof.offset, 0x80)) + } + (compressed[2], compressed[1]) = compress_g2(a0, a1, b0, b1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0xc0)) + a1 := calldataload(add(proof.offset, 0xe0)) + } + compressed[3] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x100)) + a1 := calldataload(add(proof.offset, 0x120)) + } + compressedCommitments[0] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x140)) + a1 := calldataload(add(proof.offset, 0x160)) + } + compressedCommitmentPok = compress_g1(a0, a1); + } + + /// Verify a Groth16 proof with compressed points. + /// @notice Reverts with InvalidProof if the proof is invalid or + /// with PublicInputNotInField the public input is not reduced. + /// @notice There is no return value. If the function does not revert, the + /// proof was successfully verified. + /// @param compressedProof the points (A, B, C) in compressed format + /// matching the output of compressProof. + /// @param compressedCommitments compressed Pedersen commitments from the proof. + /// @param compressedCommitmentPok compressed proof of knowledge for the Pedersen commitments. + /// @param input the public input field elements in the scalar field Fr. + /// Elements must be reduced. + function verifyCompressedProof( + uint256[4] calldata compressedProof, + uint256[1] calldata compressedCommitments, + uint256 compressedCommitmentPok, + uint256[2] calldata input + ) public view { + uint256[1] memory publicCommitments; + uint256[2] memory commitments; + uint256[24] memory pairings; + { + (commitments[0], commitments[1]) = decompress_g1(compressedCommitments[0]); + (uint256 Px, uint256 Py) = decompress_g1(compressedCommitmentPok); + + uint256[] memory publicAndCommitmentCommitted; + + publicCommitments[0] = uint256( + sha256( + abi.encodePacked( + commitments[0], + commitments[1], + publicAndCommitmentCommitted + ) + ) + ) % R; + // Commitments + pairings[ 0] = commitments[0]; + pairings[ 1] = commitments[1]; + pairings[ 2] = PEDERSEN_GSIGMANEG_X_1; + pairings[ 3] = PEDERSEN_GSIGMANEG_X_0; + pairings[ 4] = PEDERSEN_GSIGMANEG_Y_1; + pairings[ 5] = PEDERSEN_GSIGMANEG_Y_0; + pairings[ 6] = Px; + pairings[ 7] = Py; + pairings[ 8] = PEDERSEN_G_X_1; + pairings[ 9] = PEDERSEN_G_X_0; + pairings[10] = PEDERSEN_G_Y_1; + pairings[11] = PEDERSEN_G_Y_0; + + // Verify pedersen commitments + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + + success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x180, f, 0x20) + success := and(success, mload(f)) + } + if (!success) { + revert CommitmentInvalid(); + } + } + + { + (uint256 Ax, uint256 Ay) = decompress_g1(compressedProof[0]); + (uint256 Bx0, uint256 Bx1, uint256 By0, uint256 By1) = decompress_g2(compressedProof[2], compressedProof[1]); + (uint256 Cx, uint256 Cy) = decompress_g1(compressedProof[3]); + (uint256 Lx, uint256 Ly) = publicInputMSM( + input, + publicCommitments, + commitments + ); + + // Verify the pairing + // Note: The precompile expects the F2 coefficients in big-endian order. + // Note: The pairing precompile rejects unreduced values, so we won't check that here. + // e(A, B) + pairings[ 0] = Ax; + pairings[ 1] = Ay; + pairings[ 2] = Bx1; + pairings[ 3] = Bx0; + pairings[ 4] = By1; + pairings[ 5] = By0; + // e(C, -δ) + pairings[ 6] = Cx; + pairings[ 7] = Cy; + pairings[ 8] = DELTA_NEG_X_1; + pairings[ 9] = DELTA_NEG_X_0; + pairings[10] = DELTA_NEG_Y_1; + pairings[11] = DELTA_NEG_Y_0; + // e(α, -β) + pairings[12] = ALPHA_X; + pairings[13] = ALPHA_Y; + pairings[14] = BETA_NEG_X_1; + pairings[15] = BETA_NEG_X_0; + pairings[16] = BETA_NEG_Y_1; + pairings[17] = BETA_NEG_Y_0; + // e(L_pub, -γ) + pairings[18] = Lx; + pairings[19] = Ly; + pairings[20] = GAMMA_NEG_X_1; + pairings[21] = GAMMA_NEG_X_0; + pairings[22] = GAMMA_NEG_Y_1; + pairings[23] = GAMMA_NEG_Y_0; + + // Check pairing equation. + bool success; + uint256[1] memory output; + assembly ("memory-safe") { + success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x300, output, 0x20) + } + if (!success || output[0] != 1) { + // Either proof or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert ProofInvalid(); + } + } + } + + /// Verify an uncompressed Groth16 proof. + /// @notice Reverts with InvalidProof if the proof is invalid or + /// with PublicInputNotInField the public input is not reduced. + /// @notice There is no return value. If the function does not revert, the + /// proof was successfully verified. + /// @param proof the serialized proof, containing the points (A, B, C) in EIP-197 format + /// (256 bytes total). + /// Followed by Pedersen commitments (1 × 64 bytes) and proof of knowledge + /// (64 bytes) = 384 bytes total. + /// @param input the public input field elements in the scalar field Fr. + /// Elements must be reduced. + function verifyProof( + bytes calldata proof, + uint256[2] calldata input + ) public view { + require(proof.length == 384, "invalid proof length"); + // Copy commitment points from proof bytes into memory for publicInputMSM + uint256[2] memory commitments; + assembly ("memory-safe") { + calldatacopy(commitments, add(proof.offset, 0x100), 64) + } + + // HashToField + uint256[1] memory publicCommitments; + uint256[] memory publicAndCommitmentCommitted; + + { + bytes memory hashInput = abi.encodePacked( + proof[0x100:0x140], + publicAndCommitmentCommitted + ); + publicCommitments[0] = uint256(sha256(hashInput)) % R; + } + + // Verify pedersen commitments + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + + calldatacopy(f, add(proof.offset, 0x100), 0x40) // Copy first commitment + mstore(add(f, 0x40), PEDERSEN_GSIGMANEG_X_1) + mstore(add(f, 0x60), PEDERSEN_GSIGMANEG_X_0) + mstore(add(f, 0x80), PEDERSEN_GSIGMANEG_Y_1) + mstore(add(f, 0xa0), PEDERSEN_GSIGMANEG_Y_0) + calldatacopy(add(f, 0xc0), add(proof.offset, 0x140), 0x40) // Copy PoK + mstore(add(f, 0x100), PEDERSEN_G_X_1) + mstore(add(f, 0x120), PEDERSEN_G_X_0) + mstore(add(f, 0x140), PEDERSEN_G_Y_1) + mstore(add(f, 0x160), PEDERSEN_G_Y_0) + + success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x180, f, 0x20) + success := and(success, mload(f)) + } + if (!success) { + revert CommitmentInvalid(); + } + + (uint256 x, uint256 y) = publicInputMSM( + input, + publicCommitments, + commitments + ); + + // Note: The precompile expects the F2 coefficients in big-endian order. + // Note: The pairing precompile rejects unreduced values, so we won't check that here. + assembly ("memory-safe") { + let f := mload(0x40) // Free memory pointer. + + // Copy points (A, B, C) to memory. They are already in correct encoding. + // This is pairing e(A, B) and G1 of e(C, -δ). + calldatacopy(f, proof.offset, 0x100) + + // Complete e(C, -δ) and write e(α, -β), e(L_pub, -γ) to memory. + // OPT: This could be better done using a single codecopy, but + // Solidity (unlike standalone Yul) doesn't provide a way to + // to do this. + mstore(add(f, 0x100), DELTA_NEG_X_1) + mstore(add(f, 0x120), DELTA_NEG_X_0) + mstore(add(f, 0x140), DELTA_NEG_Y_1) + mstore(add(f, 0x160), DELTA_NEG_Y_0) + mstore(add(f, 0x180), ALPHA_X) + mstore(add(f, 0x1a0), ALPHA_Y) + mstore(add(f, 0x1c0), BETA_NEG_X_1) + mstore(add(f, 0x1e0), BETA_NEG_X_0) + mstore(add(f, 0x200), BETA_NEG_Y_1) + mstore(add(f, 0x220), BETA_NEG_Y_0) + mstore(add(f, 0x240), x) + mstore(add(f, 0x260), y) + mstore(add(f, 0x280), GAMMA_NEG_X_1) + mstore(add(f, 0x2a0), GAMMA_NEG_X_0) + mstore(add(f, 0x2c0), GAMMA_NEG_Y_1) + mstore(add(f, 0x2e0), GAMMA_NEG_Y_0) + + // Check pairing equation. + success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x300, f, 0x20) + // Also check returned value (both are either 1 or 0). + success := and(success, mload(f)) + } + if (!success) { + // Either proof or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert ProofInvalid(); + } + } +} diff --git a/contracts/groth16/testdata/committed/proof.json b/contracts/groth16/testdata/committed/proof.json new file mode 100644 index 000000000..983b22a3e --- /dev/null +++ b/contracts/groth16/testdata/committed/proof.json @@ -0,0 +1,6 @@ +{ + "commitments": 1, + "digest": "0x3100000000000000000000000000000001000000000000000000000000000000", + "proof": "0x21815841963b9d341e4a1eba060d56b5acbeced95f2888631d2f6eb3cb993ab82e58e1b0b9a3dec787438d1099a7c223e02c6259c8692d3a49dd6f7625fe08e80dd1b8ecccaa5abce4aedefa7bf014df53b26fa980b232c32041c322a56ed45117956c42510d35350949000f673d724a571833f6356465ff0226edc4a3e9421d2a64cd3315bb12e9f36c7b374eae721dade9385097c996f0d548f078de0ad8fc304abafe9d3b79be4cc76788bf0a06b9ba1d1a937ab989eb992fc2f99869996a13e5bda34f7d9a6706cc0bbc5a73660e53660bd67c0811dcd02a949e4244ac7e11f1c3c83b753d1b661118ba2bfca305e02f7521be50f002a5db36c884ddcccc1b9ef2757ddf8d98f5252087ad87ed19fdc9d0e0ee50b7bfd21733feea90084c1ab45c5973c780d928475f8a0a008a7b72f169309b70d9612ee704aa4a0c41c722123b6b9da458bc71182079bb6dbda343494de95cf06c2049ef1ab474c9cca5110d2a2c2f7f2c65e520970fe81d4ed54948c58cf53600accf8bd2523c1932da", + "scope": "development-backend-fixture-not-an-ix-proof" +} diff --git a/contracts/groth16/testdata/plain/Verifier.sol b/contracts/groth16/testdata/plain/Verifier.sol new file mode 100644 index 000000000..5e0e4d11d --- /dev/null +++ b/contracts/groth16/testdata/plain/Verifier.sol @@ -0,0 +1,563 @@ + +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.28; + +/// @title Groth16 verifier template. +/// @author Remco Bloemen +/// @notice Supports verifying Groth16 proofs. Proofs can be in uncompressed +/// (256 bytes + optional commitments) and compressed (128 bytes) format. +/// Uncompressed proofs are passed as bytes calldata matching the output of +/// MarshalSolidity(). A view function is provided to compress proofs. +/// @notice See for further explanation. +contract Verifier { + + /// Some of the provided public input values are larger than the field modulus. + /// @dev Public input elements are not automatically reduced, as this is can be + /// a dangerous source of bugs. + error PublicInputNotInField(); + + /// The proof is invalid. + /// @dev This can mean that provided Groth16 proof points are not on their + /// curves, that pairing equation fails, or that the proof is not for the + /// provided public input. + error ProofInvalid(); + + // Addresses of precompiles + uint256 constant PRECOMPILE_MODEXP = 0x05; + uint256 constant PRECOMPILE_ADD = 0x06; + uint256 constant PRECOMPILE_MUL = 0x07; + uint256 constant PRECOMPILE_VERIFY = 0x08; + + // Base field Fp order P and scalar field Fr order R. + // For BN254 these are computed as follows: + // t = 4965661367192848881 + // P = 36⋅t⁴ + 36⋅t³ + 24⋅t² + 6⋅t + 1 + // R = 36⋅t⁴ + 36⋅t³ + 18⋅t² + 6⋅t + 1 + uint256 constant P = 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47; + uint256 constant R = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001; + + // Extension field Fp2 = Fp[i] / (i² + 1) + // Note: This is the complex extension field of Fp with i² = -1. + // Values in Fp2 are represented as a pair of Fp elements (a₀, a₁) as a₀ + a₁⋅i. + // Note: The order of Fp2 elements is *opposite* that of the pairing contract, which + // expects Fp2 elements in order (a₁, a₀). This is also the order in which + // Fp2 elements are encoded in the public interface as this became convention. + + // Constants in Fp + uint256 constant FRACTION_1_2_FP = 0x183227397098d014dc2822db40c0ac2ecbc0b548b438e5469e10460b6c3e7ea4; + uint256 constant FRACTION_27_82_FP = 0x2b149d40ceb8aaae81be18991be06ac3b5b4c5e559dbefa33267e6dc24a138e5; + uint256 constant FRACTION_3_82_FP = 0x2fcd3ac2a640a154eb23960892a85a68f031ca0c8344b23a577dcf1052b9e775; + + // Exponents for inversions and square roots mod P + uint256 constant EXP_INVERSE_FP = 0x30644E72E131A029B85045B68181585D97816A916871CA8D3C208C16D87CFD45; // P - 2 + uint256 constant EXP_SQRT_FP = 0xC19139CB84C680A6E14116DA060561765E05AA45A1C72A34F082305B61F3F52; // (P + 1) / 4; + + // Groth16 alpha point in G1 + uint256 constant ALPHA_X = 7250539544628203902103377088189733063354798326468932417960029645674868343720; + uint256 constant ALPHA_Y = 431382949410043658398035651502638672073095407815758496581501037056087890239; + + // Groth16 beta point in G2 in powers of i + uint256 constant BETA_NEG_X_0 = 9859909040171341936890727633260978010752722655743662109412834425277423223656; + uint256 constant BETA_NEG_X_1 = 3537800814916064661991404048795078649440916014597006242913999017750939977340; + uint256 constant BETA_NEG_Y_0 = 6299457863444977233634282960165528728351005106020189559089164420691277060043; + uint256 constant BETA_NEG_Y_1 = 12956080922328325611270125541175233617778266392562601816509526617956958941218; + + // Groth16 gamma point in G2 in powers of i + uint256 constant GAMMA_NEG_X_0 = 1450103104773049202633444974146302370010123290060060132634021402635619650996; + uint256 constant GAMMA_NEG_X_1 = 1417176000108035812188900482048171141513044930995074866360620842569336915227; + uint256 constant GAMMA_NEG_Y_0 = 15623404919296675837470597600466281864095640257644669472233039603075463528704; + uint256 constant GAMMA_NEG_Y_1 = 10117299915291576304640238187425820763361498761181257566096420957799275740843; + + // Groth16 delta point in G2 in powers of i + uint256 constant DELTA_NEG_X_0 = 16700377596825529848915768409538896903458193461834061791489973450512033111271; + uint256 constant DELTA_NEG_X_1 = 12756870859179344165653260264307426270494926130130373655264378859890954955051; + uint256 constant DELTA_NEG_Y_0 = 15679570708441728583067000325605287779780179906184759016960901645145000197463; + uint256 constant DELTA_NEG_Y_1 = 6820356557937923340165381559753394410314392532792845467560713216303056372818; + + // Constant and public input points + uint256 constant CONSTANT_X = 21486861081689998453512046165211607563686729021962352454314455916291567272532; + uint256 constant CONSTANT_Y = 15790524674301646652435138685465444315588333641384968764442669784055731210135; + uint256 constant PUB_0_X = 18813688759688164648403795728830009692596893027983989236775101736568856180747; + uint256 constant PUB_0_Y = 14471415387521918610681847090077014897298223415328371512113377862665961709941; + uint256 constant PUB_1_X = 21724856760456682589588610276846948665673605871114640237361938858290551017032; + uint256 constant PUB_1_Y = 4130447549821278453921507373726733653590109610780592616218111434091309361991; + + /// Negation in Fp. + /// @notice Returns a number x such that a + x = 0 in Fp. + /// @notice The input does not need to be reduced. + /// @param a the base + /// @return x the result + function negate(uint256 a) internal pure returns (uint256 x) { + unchecked { + x = (P - (a % P)) % P; // Modulo is cheaper than branching + } + } + + /// Exponentiation in Fp. + /// @notice Returns a number x such that a ^ e = x in Fp. + /// @notice The input does not need to be reduced. + /// @param a the base + /// @param e the exponent + /// @return x the result + function exp(uint256 a, uint256 e) internal view returns (uint256 x) { + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + mstore(f, 0x20) + mstore(add(f, 0x20), 0x20) + mstore(add(f, 0x40), 0x20) + mstore(add(f, 0x60), a) + mstore(add(f, 0x80), e) + mstore(add(f, 0xa0), P) + success := staticcall(gas(), PRECOMPILE_MODEXP, f, 0xc0, f, 0x20) + x := mload(f) + } + if (!success) { + // Exponentiation failed. + // Should not happen. + revert ProofInvalid(); + } + } + + /// Invertsion in Fp. + /// @notice Returns a number x such that a * x = 1 in Fp. + /// @notice The input does not need to be reduced. + /// @notice Reverts with ProofInvalid() if the inverse does not exist + /// @param a the input + /// @return x the solution + function invert_Fp(uint256 a) internal view returns (uint256 x) { + x = exp(a, EXP_INVERSE_FP); + if (mulmod(a, x, P) != 1) { + // Inverse does not exist. + // Can only happen during G2 point decompression. + revert ProofInvalid(); + } + } + + /// Square root in Fp. + /// @notice Returns a number x such that x * x = a in Fp. + /// @notice Will revert with InvalidProof() if the input is not a square + /// or not reduced. + /// @param a the square + /// @return x the solution + function sqrt_Fp(uint256 a) internal view returns (uint256 x) { + x = exp(a, EXP_SQRT_FP); + if (mulmod(x, x, P) != a) { + // Square root does not exist or a is not reduced. + // Happens when G1 point is not on curve. + revert ProofInvalid(); + } + } + + /// Square test in Fp. + /// @notice Returns whether a number x exists such that x * x = a in Fp. + /// @notice Will revert with InvalidProof() if the input is not a square + /// or not reduced. + /// @param a the square + /// @return x the solution + function isSquare_Fp(uint256 a) internal view returns (bool) { + uint256 x = exp(a, EXP_SQRT_FP); + return mulmod(x, x, P) == a; + } + + /// Square root in Fp2. + /// @notice Fp2 is the complex extension Fp[i]/(i^2 + 1). The input is + /// a0 + a1 ⋅ i and the result is x0 + x1 ⋅ i. + /// @notice Will revert with InvalidProof() if + /// * the input is not a square, + /// * the hint is incorrect, or + /// * the input coefficients are not reduced. + /// @param a0 The real part of the input. + /// @param a1 The imaginary part of the input. + /// @param hint A hint which of two possible signs to pick in the equation. + /// @return x0 The real part of the square root. + /// @return x1 The imaginary part of the square root. + function sqrt_Fp2(uint256 a0, uint256 a1, bool hint) internal view returns (uint256 x0, uint256 x1) { + // If this square root reverts there is no solution in Fp2. + uint256 d = sqrt_Fp(addmod(mulmod(a0, a0, P), mulmod(a1, a1, P), P)); + if (hint) { + d = negate(d); + } + // If this square root reverts there is no solution in Fp2. + x0 = sqrt_Fp(mulmod(addmod(a0, d, P), FRACTION_1_2_FP, P)); + x1 = mulmod(a1, invert_Fp(mulmod(x0, 2, P)), P); + + // Check result to make sure we found a root. + // Note: this also fails if a0 or a1 is not reduced. + if (a0 != addmod(mulmod(x0, x0, P), negate(mulmod(x1, x1, P)), P) + || a1 != mulmod(2, mulmod(x0, x1, P), P)) { + revert ProofInvalid(); + } + } + + /// Compress a G1 point. + /// @notice Reverts with InvalidProof if the coordinates are not reduced + /// or if the point is not on the curve. + /// @notice The point at infinity is encoded as (0,0) and compressed to 0. + /// @param x The X coordinate in Fp. + /// @param y The Y coordinate in Fp. + /// @return c The compresed point (x with one signal bit). + function compress_g1(uint256 x, uint256 y) internal view returns (uint256 c) { + if (x >= P || y >= P) { + // G1 point not in field. + revert ProofInvalid(); + } + if (x == 0 && y == 0) { + // Point at infinity + return 0; + } + + // Note: sqrt_Fp reverts if there is no solution, i.e. the x coordinate is invalid. + uint256 y_pos = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); + if (y == y_pos) { + return (x << 1) | 0; + } else if (y == negate(y_pos)) { + return (x << 1) | 1; + } else { + // G1 point not on curve. + revert ProofInvalid(); + } + } + + /// Decompress a G1 point. + /// @notice Reverts with InvalidProof if the input does not represent a valid point. + /// @notice The point at infinity is encoded as (0,0) and compressed to 0. + /// @param c The compresed point (x with one signal bit). + /// @return x The X coordinate in Fp. + /// @return y The Y coordinate in Fp. + function decompress_g1(uint256 c) internal view returns (uint256 x, uint256 y) { + // Note that X = 0 is not on the curve since 0³ + 3 = 3 is not a square. + // so we can use it to represent the point at infinity. + if (c == 0) { + // Point at infinity as encoded in EIP196 and EIP197. + return (0, 0); + } + bool negate_point = c & 1 == 1; + x = c >> 1; + if (x >= P) { + // G1 x coordinate not in field. + revert ProofInvalid(); + } + + // Note: (x³ + 3) is irreducible in Fp, so it can not be zero and therefore + // y can not be zero. + // Note: sqrt_Fp reverts if there is no solution, i.e. the point is not on the curve. + y = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); + if (negate_point) { + y = negate(y); + } + } + + /// Compress a G2 point. + /// @notice Reverts with InvalidProof if the coefficients are not reduced + /// or if the point is not on the curve. + /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) + /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). + /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). + /// @param x0 The real part of the X coordinate. + /// @param x1 The imaginary poart of the X coordinate. + /// @param y0 The real part of the Y coordinate. + /// @param y1 The imaginary part of the Y coordinate. + /// @return c0 The first half of the compresed point (x0 with two signal bits). + /// @return c1 The second half of the compressed point (x1 unmodified). + function compress_g2(uint256 x0, uint256 x1, uint256 y0, uint256 y1) + internal view returns (uint256 c0, uint256 c1) { + if (x0 >= P || x1 >= P || y0 >= P || y1 >= P) { + // G2 point not in field. + revert ProofInvalid(); + } + if ((x0 | x1 | y0 | y1) == 0) { + // Point at infinity + return (0, 0); + } + + // Compute y^2 + // Note: shadowing variables and scoping to avoid stack-to-deep. + uint256 y0_pos; + uint256 y1_pos; + { + uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); + uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); + uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); + y0_pos = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); + y1_pos = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); + } + + // Determine hint bit + // If this sqrt fails the x coordinate is not on the curve. + bool hint; + { + uint256 d = sqrt_Fp(addmod(mulmod(y0_pos, y0_pos, P), mulmod(y1_pos, y1_pos, P), P)); + hint = !isSquare_Fp(mulmod(addmod(y0_pos, d, P), FRACTION_1_2_FP, P)); + } + + // Recover y + (y0_pos, y1_pos) = sqrt_Fp2(y0_pos, y1_pos, hint); + if (y0 == y0_pos && y1 == y1_pos) { + c0 = (x0 << 2) | (hint ? 2 : 0) | 0; + c1 = x1; + } else if (y0 == negate(y0_pos) && y1 == negate(y1_pos)) { + c0 = (x0 << 2) | (hint ? 2 : 0) | 1; + c1 = x1; + } else { + // G1 point not on curve. + revert ProofInvalid(); + } + } + + /// Decompress a G2 point. + /// @notice Reverts with InvalidProof if the input does not represent a valid point. + /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) + /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). + /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). + /// @param c0 The first half of the compresed point (x0 with two signal bits). + /// @param c1 The second half of the compressed point (x1 unmodified). + /// @return x0 The real part of the X coordinate. + /// @return x1 The imaginary poart of the X coordinate. + /// @return y0 The real part of the Y coordinate. + /// @return y1 The imaginary part of the Y coordinate. + function decompress_g2(uint256 c0, uint256 c1) + internal view returns (uint256 x0, uint256 x1, uint256 y0, uint256 y1) { + // Note that X = (0, 0) is not on the curve since 0³ + 3/(9 + i) is not a square. + // so we can use it to represent the point at infinity. + if (c0 == 0 && c1 == 0) { + // Point at infinity as encoded in EIP197. + return (0, 0, 0, 0); + } + bool negate_point = c0 & 1 == 1; + bool hint = c0 & 2 == 2; + x0 = c0 >> 2; + x1 = c1; + if (x0 >= P || x1 >= P) { + // G2 x0 or x1 coefficient not in field. + revert ProofInvalid(); + } + + uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); + uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); + uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); + + y0 = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); + y1 = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); + + // Note: sqrt_Fp2 reverts if there is no solution, i.e. the point is not on the curve. + // Note: (X³ + 3/(9 + i)) is irreducible in Fp2, so y can not be zero. + // But y0 or y1 may still independently be zero. + (y0, y1) = sqrt_Fp2(y0, y1, hint); + if (negate_point) { + y0 = negate(y0); + y1 = negate(y1); + } + } + + /// Compute the public input linear combination. + /// @notice Reverts with PublicInputNotInField if the input is not in the field. + /// @notice Computes the multi-scalar-multiplication of the public input + /// elements and the verification key including the constant term. + /// @param input The public inputs. These are elements of the scalar field Fr. + /// @return x The X coordinate of the resulting G1 point. + /// @return y The Y coordinate of the resulting G1 point. + function publicInputMSM(uint256[2] calldata input) + internal view returns (uint256 x, uint256 y) { + // Note: The ECMUL precompile does not reject unreduced values, so we check this. + // Note: Unrolling this loop does not cost much extra in code-size, the bulk of the + // code-size is in the PUB_ constants. + // ECMUL has input (x, y, scalar) and output (x', y'). + // ECADD has input (x1, y1, x2, y2) and output (x', y'). + // We reduce commitments(if any) with constants as the first point argument to ECADD. + // We call them such that ecmul output is already in the second point + // argument to ECADD so we can have a tight loop. + bool success = true; + assembly ("memory-safe") { + let f := mload(0x40) + let g := add(f, 0x40) + let s + mstore(f, CONSTANT_X) + mstore(add(f, 0x20), CONSTANT_Y) + mstore(g, PUB_0_X) + mstore(add(g, 0x20), PUB_0_Y) + s := calldataload(input) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_1_X) + mstore(add(g, 0x20), PUB_1_Y) + s := calldataload(add(input, 32)) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + + x := mload(f) + y := mload(add(f, 0x20)) + } + if (!success) { + // Either Public input not in field, or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert PublicInputNotInField(); + } + } + + /// Compress a proof. + /// @notice Will revert with InvalidProof if the curve points are invalid, + /// but does not verify the proof itself. + /// @param proof The uncompressed Groth16 proof. Points (A, B, C) encoded as in EIP-197 + /// (256 bytes total). + /// @return compressed The compressed proof. Elements are in the same order as for + /// verifyCompressedProof. I.e. points (A, B, C) in compressed format. + function compressProof(bytes calldata proof) + public view returns (uint256[4] memory compressed) { + require(proof.length == 256, "invalid proof length"); + uint256 a0; + uint256 a1; + assembly ("memory-safe") { + a0 := calldataload(proof.offset) + a1 := calldataload(add(proof.offset, 0x20)) + } + compressed[0] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x60)) + a1 := calldataload(add(proof.offset, 0x40)) + } + uint256 b0; + uint256 b1; + assembly ("memory-safe") { + b0 := calldataload(add(proof.offset, 0xa0)) + b1 := calldataload(add(proof.offset, 0x80)) + } + (compressed[2], compressed[1]) = compress_g2(a0, a1, b0, b1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0xc0)) + a1 := calldataload(add(proof.offset, 0xe0)) + } + compressed[3] = compress_g1(a0, a1); + } + + /// Verify a Groth16 proof with compressed points. + /// @notice Reverts with InvalidProof if the proof is invalid or + /// with PublicInputNotInField the public input is not reduced. + /// @notice There is no return value. If the function does not revert, the + /// proof was successfully verified. + /// @param compressedProof the points (A, B, C) in compressed format + /// matching the output of compressProof. + /// @param input the public input field elements in the scalar field Fr. + /// Elements must be reduced. + function verifyCompressedProof( + uint256[4] calldata compressedProof, + uint256[2] calldata input + ) public view { + uint256[24] memory pairings; + + { + (uint256 Ax, uint256 Ay) = decompress_g1(compressedProof[0]); + (uint256 Bx0, uint256 Bx1, uint256 By0, uint256 By1) = decompress_g2(compressedProof[2], compressedProof[1]); + (uint256 Cx, uint256 Cy) = decompress_g1(compressedProof[3]); + (uint256 Lx, uint256 Ly) = publicInputMSM(input); + + // Verify the pairing + // Note: The precompile expects the F2 coefficients in big-endian order. + // Note: The pairing precompile rejects unreduced values, so we won't check that here. + // e(A, B) + pairings[ 0] = Ax; + pairings[ 1] = Ay; + pairings[ 2] = Bx1; + pairings[ 3] = Bx0; + pairings[ 4] = By1; + pairings[ 5] = By0; + // e(C, -δ) + pairings[ 6] = Cx; + pairings[ 7] = Cy; + pairings[ 8] = DELTA_NEG_X_1; + pairings[ 9] = DELTA_NEG_X_0; + pairings[10] = DELTA_NEG_Y_1; + pairings[11] = DELTA_NEG_Y_0; + // e(α, -β) + pairings[12] = ALPHA_X; + pairings[13] = ALPHA_Y; + pairings[14] = BETA_NEG_X_1; + pairings[15] = BETA_NEG_X_0; + pairings[16] = BETA_NEG_Y_1; + pairings[17] = BETA_NEG_Y_0; + // e(L_pub, -γ) + pairings[18] = Lx; + pairings[19] = Ly; + pairings[20] = GAMMA_NEG_X_1; + pairings[21] = GAMMA_NEG_X_0; + pairings[22] = GAMMA_NEG_Y_1; + pairings[23] = GAMMA_NEG_Y_0; + + // Check pairing equation. + bool success; + uint256[1] memory output; + assembly ("memory-safe") { + success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x300, output, 0x20) + } + if (!success || output[0] != 1) { + // Either proof or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert ProofInvalid(); + } + } + } + + /// Verify an uncompressed Groth16 proof. + /// @notice Reverts with InvalidProof if the proof is invalid or + /// with PublicInputNotInField the public input is not reduced. + /// @notice There is no return value. If the function does not revert, the + /// proof was successfully verified. + /// @param proof the serialized proof, containing the points (A, B, C) in EIP-197 format + /// (256 bytes total). + /// @param input the public input field elements in the scalar field Fr. + /// Elements must be reduced. + function verifyProof( + bytes calldata proof, + uint256[2] calldata input + ) public view { + require(proof.length == 256, "invalid proof length"); + (uint256 x, uint256 y) = publicInputMSM(input); + + // Note: The precompile expects the F2 coefficients in big-endian order. + // Note: The pairing precompile rejects unreduced values, so we won't check that here. + bool success; + assembly ("memory-safe") { + let f := mload(0x40) // Free memory pointer. + + // Copy points (A, B, C) to memory. They are already in correct encoding. + // This is pairing e(A, B) and G1 of e(C, -δ). + calldatacopy(f, proof.offset, 0x100) + + // Complete e(C, -δ) and write e(α, -β), e(L_pub, -γ) to memory. + // OPT: This could be better done using a single codecopy, but + // Solidity (unlike standalone Yul) doesn't provide a way to + // to do this. + mstore(add(f, 0x100), DELTA_NEG_X_1) + mstore(add(f, 0x120), DELTA_NEG_X_0) + mstore(add(f, 0x140), DELTA_NEG_Y_1) + mstore(add(f, 0x160), DELTA_NEG_Y_0) + mstore(add(f, 0x180), ALPHA_X) + mstore(add(f, 0x1a0), ALPHA_Y) + mstore(add(f, 0x1c0), BETA_NEG_X_1) + mstore(add(f, 0x1e0), BETA_NEG_X_0) + mstore(add(f, 0x200), BETA_NEG_Y_1) + mstore(add(f, 0x220), BETA_NEG_Y_0) + mstore(add(f, 0x240), x) + mstore(add(f, 0x260), y) + mstore(add(f, 0x280), GAMMA_NEG_X_1) + mstore(add(f, 0x2a0), GAMMA_NEG_X_0) + mstore(add(f, 0x2c0), GAMMA_NEG_Y_1) + mstore(add(f, 0x2e0), GAMMA_NEG_Y_0) + + // Check pairing equation. + success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x300, f, 0x20) + // Also check returned value (both are either 1 or 0). + success := and(success, mload(f)) + } + if (!success) { + // Either proof or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert ProofInvalid(); + } + } +} diff --git a/contracts/groth16/testdata/plain/proof.json b/contracts/groth16/testdata/plain/proof.json new file mode 100644 index 000000000..23ffbddec --- /dev/null +++ b/contracts/groth16/testdata/plain/proof.json @@ -0,0 +1,6 @@ +{ + "commitments": 0, + "digest": "0x3100000000000000000000000000000001000000000000000000000000000000", + "proof": "0x194739921960c079eea6166a97862c752b8a71104ad50df7374063d64879ebb609abc9cfc0b4d20f67180a1bf5d502037f478c3060c2ebdb119932152d020def27410ff33a19ae08088a6b5a866551a88479ced77d7af63af80f37b7818d5cff0d0a88227a58f3bf1ad8a935a2abfee8ebf205824a1fb50f363ef72e698b9ca41430ab29ec677b512d45f84c89d785104bc05eceb122c2f251ab38aa229a6300182aad7f6d180662cff6634b9bfc2922020c66a22f3aebcc4be7ef76eb72c72f031159b7b123067beafbcb23b1d17e4e41d137952e299161a1915993c266b95e27c8c996f14866a7dc709055993d462632b03cd1de9fd1abcd0e2aee17f1a4d8", + "scope": "development-backend-fixture-not-an-ix-proof" +} diff --git a/contracts/groth16/testdata/wrapper/Verifier.sol b/contracts/groth16/testdata/wrapper/Verifier.sol new file mode 100644 index 000000000..4d5e9c1b1 --- /dev/null +++ b/contracts/groth16/testdata/wrapper/Verifier.sol @@ -0,0 +1,712 @@ + +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.28; + +/// @title Groth16 verifier template. +/// @author Remco Bloemen +/// @notice Supports verifying Groth16 proofs. Proofs can be in uncompressed +/// (256 bytes + optional commitments) and compressed (128 bytes) format. +/// Uncompressed proofs are passed as bytes calldata matching the output of +/// MarshalSolidity(). A view function is provided to compress proofs. +/// @notice See for further explanation. +contract Verifier { + + /// Some of the provided public input values are larger than the field modulus. + /// @dev Public input elements are not automatically reduced, as this is can be + /// a dangerous source of bugs. + error PublicInputNotInField(); + + /// The proof is invalid. + /// @dev This can mean that provided Groth16 proof points are not on their + /// curves, that pairing equation fails, or that the proof is not for the + /// provided public input. + error ProofInvalid(); + /// The commitment is invalid + /// @dev This can mean that provided commitment points and/or proof of knowledge are not on their + /// curves, that pairing equation fails, or that the commitment and/or proof of knowledge is not for the + /// commitment key. + error CommitmentInvalid(); + + // Addresses of precompiles + uint256 constant PRECOMPILE_MODEXP = 0x05; + uint256 constant PRECOMPILE_ADD = 0x06; + uint256 constant PRECOMPILE_MUL = 0x07; + uint256 constant PRECOMPILE_VERIFY = 0x08; + + // Base field Fp order P and scalar field Fr order R. + // For BN254 these are computed as follows: + // t = 4965661367192848881 + // P = 36⋅t⁴ + 36⋅t³ + 24⋅t² + 6⋅t + 1 + // R = 36⋅t⁴ + 36⋅t³ + 18⋅t² + 6⋅t + 1 + uint256 constant P = 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47; + uint256 constant R = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001; + + // Extension field Fp2 = Fp[i] / (i² + 1) + // Note: This is the complex extension field of Fp with i² = -1. + // Values in Fp2 are represented as a pair of Fp elements (a₀, a₁) as a₀ + a₁⋅i. + // Note: The order of Fp2 elements is *opposite* that of the pairing contract, which + // expects Fp2 elements in order (a₁, a₀). This is also the order in which + // Fp2 elements are encoded in the public interface as this became convention. + + // Constants in Fp + uint256 constant FRACTION_1_2_FP = 0x183227397098d014dc2822db40c0ac2ecbc0b548b438e5469e10460b6c3e7ea4; + uint256 constant FRACTION_27_82_FP = 0x2b149d40ceb8aaae81be18991be06ac3b5b4c5e559dbefa33267e6dc24a138e5; + uint256 constant FRACTION_3_82_FP = 0x2fcd3ac2a640a154eb23960892a85a68f031ca0c8344b23a577dcf1052b9e775; + + // Exponents for inversions and square roots mod P + uint256 constant EXP_INVERSE_FP = 0x30644E72E131A029B85045B68181585D97816A916871CA8D3C208C16D87CFD45; // P - 2 + uint256 constant EXP_SQRT_FP = 0xC19139CB84C680A6E14116DA060561765E05AA45A1C72A34F082305B61F3F52; // (P + 1) / 4; + + // Groth16 alpha point in G1 + uint256 constant ALPHA_X = 14965427800760605266329270366657230452184066114276557223963678304576492889297; + uint256 constant ALPHA_Y = 12930565846102334685828037374106895242346431319718097442106674987606076685582; + + // Groth16 beta point in G2 in powers of i + uint256 constant BETA_NEG_X_0 = 5815330628735851132956425551310503493316264586515354429141438180827647232389; + uint256 constant BETA_NEG_X_1 = 21250470887902739443117845182433289742915948318749888512131662797980574660540; + uint256 constant BETA_NEG_Y_0 = 20242340585150383685724900064817132159285241132696146919463847844455909235679; + uint256 constant BETA_NEG_Y_1 = 2119149196327629600847995699443264578076315337150552308702765500888388041626; + + // Groth16 gamma point in G2 in powers of i + uint256 constant GAMMA_NEG_X_0 = 15699223729310618648383544856327347575095322690727552431715907542071323764292; + uint256 constant GAMMA_NEG_X_1 = 8700333011571643841992791119947620256780847951033324573601414831701000084293; + uint256 constant GAMMA_NEG_Y_0 = 2611745781618068977603952266921294182011524546512874743516999994587543006661; + uint256 constant GAMMA_NEG_Y_1 = 10777210279907195968411450817700576021459415203256727226334713461336946784722; + + // Groth16 delta point in G2 in powers of i + uint256 constant DELTA_NEG_X_0 = 19578610775327083742318600299900315644308978455211563769201114570835320312565; + uint256 constant DELTA_NEG_X_1 = 18812473047931095055328257896092445838947195448228858274199345087624716915861; + uint256 constant DELTA_NEG_Y_0 = 12598020031407455924272478648105537624233977178774564989646141824050537969844; + uint256 constant DELTA_NEG_Y_1 = 9741946647500589226336953504777491722077480961512744422227353798964114967096; + // Pedersen G point in G2 in powers of i + uint256 constant PEDERSEN_G_X_0 = 1411055693564393497394283415359562452219855922005213755825751954177179688061; + uint256 constant PEDERSEN_G_X_1 = 1508996509214639424472211170286496539029814881462469381650252333869229976064; + uint256 constant PEDERSEN_G_Y_0 = 4483412031818699122300990233818895880575176607514641850666547533875304790376; + uint256 constant PEDERSEN_G_Y_1 = 21643613478763546744118397963270043910244782413493785541558233639552777135359; + + // Pedersen GSigmaNeg point in G2 in powers of i + uint256 constant PEDERSEN_GSIGMANEG_X_0 = 5291991913674647665987972364590616201502298117728714615745496853869789094208; + uint256 constant PEDERSEN_GSIGMANEG_X_1 = 8920148211549484754930112761027381640535298601166223729309225527552107258338; + uint256 constant PEDERSEN_GSIGMANEG_Y_0 = 12485142791764449114842906916149851977078888936606389671323717105509846772023; + uint256 constant PEDERSEN_GSIGMANEG_Y_1 = 4759433203208374856491979539756768074749673826857932166320474222821146329951; + + // Constant and public input points + uint256 constant CONSTANT_X = 17107934889106374664950300316687301254137119716541669685948082874054028849743; + uint256 constant CONSTANT_Y = 808269621944922919129239948005465827153395826205253652605495563514811316704; + uint256 constant PUB_0_X = 2721755659223955042360194439307383245168305097142462318138353802348253861051; + uint256 constant PUB_0_Y = 21218406476029959732314346618287623050726115699775792572337878753784407596803; + uint256 constant PUB_1_X = 672480886186751165727969550909930415904297989732230843494768446435588750929; + uint256 constant PUB_1_Y = 13600167614904828222946818676345374586070415513368519357054904021129274805327; + uint256 constant PUB_2_X = 13230506980003624878817510633349514965967488939089108415497715759388228417586; + uint256 constant PUB_2_Y = 7239954860073898002842354379321669262411017425727573026488689982253269630359; + + /// Negation in Fp. + /// @notice Returns a number x such that a + x = 0 in Fp. + /// @notice The input does not need to be reduced. + /// @param a the base + /// @return x the result + function negate(uint256 a) internal pure returns (uint256 x) { + unchecked { + x = (P - (a % P)) % P; // Modulo is cheaper than branching + } + } + + /// Exponentiation in Fp. + /// @notice Returns a number x such that a ^ e = x in Fp. + /// @notice The input does not need to be reduced. + /// @param a the base + /// @param e the exponent + /// @return x the result + function exp(uint256 a, uint256 e) internal view returns (uint256 x) { + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + mstore(f, 0x20) + mstore(add(f, 0x20), 0x20) + mstore(add(f, 0x40), 0x20) + mstore(add(f, 0x60), a) + mstore(add(f, 0x80), e) + mstore(add(f, 0xa0), P) + success := staticcall(gas(), PRECOMPILE_MODEXP, f, 0xc0, f, 0x20) + x := mload(f) + } + if (!success) { + // Exponentiation failed. + // Should not happen. + revert ProofInvalid(); + } + } + + /// Invertsion in Fp. + /// @notice Returns a number x such that a * x = 1 in Fp. + /// @notice The input does not need to be reduced. + /// @notice Reverts with ProofInvalid() if the inverse does not exist + /// @param a the input + /// @return x the solution + function invert_Fp(uint256 a) internal view returns (uint256 x) { + x = exp(a, EXP_INVERSE_FP); + if (mulmod(a, x, P) != 1) { + // Inverse does not exist. + // Can only happen during G2 point decompression. + revert ProofInvalid(); + } + } + + /// Square root in Fp. + /// @notice Returns a number x such that x * x = a in Fp. + /// @notice Will revert with InvalidProof() if the input is not a square + /// or not reduced. + /// @param a the square + /// @return x the solution + function sqrt_Fp(uint256 a) internal view returns (uint256 x) { + x = exp(a, EXP_SQRT_FP); + if (mulmod(x, x, P) != a) { + // Square root does not exist or a is not reduced. + // Happens when G1 point is not on curve. + revert ProofInvalid(); + } + } + + /// Square test in Fp. + /// @notice Returns whether a number x exists such that x * x = a in Fp. + /// @notice Will revert with InvalidProof() if the input is not a square + /// or not reduced. + /// @param a the square + /// @return x the solution + function isSquare_Fp(uint256 a) internal view returns (bool) { + uint256 x = exp(a, EXP_SQRT_FP); + return mulmod(x, x, P) == a; + } + + /// Square root in Fp2. + /// @notice Fp2 is the complex extension Fp[i]/(i^2 + 1). The input is + /// a0 + a1 ⋅ i and the result is x0 + x1 ⋅ i. + /// @notice Will revert with InvalidProof() if + /// * the input is not a square, + /// * the hint is incorrect, or + /// * the input coefficients are not reduced. + /// @param a0 The real part of the input. + /// @param a1 The imaginary part of the input. + /// @param hint A hint which of two possible signs to pick in the equation. + /// @return x0 The real part of the square root. + /// @return x1 The imaginary part of the square root. + function sqrt_Fp2(uint256 a0, uint256 a1, bool hint) internal view returns (uint256 x0, uint256 x1) { + // If this square root reverts there is no solution in Fp2. + uint256 d = sqrt_Fp(addmod(mulmod(a0, a0, P), mulmod(a1, a1, P), P)); + if (hint) { + d = negate(d); + } + // If this square root reverts there is no solution in Fp2. + x0 = sqrt_Fp(mulmod(addmod(a0, d, P), FRACTION_1_2_FP, P)); + x1 = mulmod(a1, invert_Fp(mulmod(x0, 2, P)), P); + + // Check result to make sure we found a root. + // Note: this also fails if a0 or a1 is not reduced. + if (a0 != addmod(mulmod(x0, x0, P), negate(mulmod(x1, x1, P)), P) + || a1 != mulmod(2, mulmod(x0, x1, P), P)) { + revert ProofInvalid(); + } + } + + /// Compress a G1 point. + /// @notice Reverts with InvalidProof if the coordinates are not reduced + /// or if the point is not on the curve. + /// @notice The point at infinity is encoded as (0,0) and compressed to 0. + /// @param x The X coordinate in Fp. + /// @param y The Y coordinate in Fp. + /// @return c The compresed point (x with one signal bit). + function compress_g1(uint256 x, uint256 y) internal view returns (uint256 c) { + if (x >= P || y >= P) { + // G1 point not in field. + revert ProofInvalid(); + } + if (x == 0 && y == 0) { + // Point at infinity + return 0; + } + + // Note: sqrt_Fp reverts if there is no solution, i.e. the x coordinate is invalid. + uint256 y_pos = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); + if (y == y_pos) { + return (x << 1) | 0; + } else if (y == negate(y_pos)) { + return (x << 1) | 1; + } else { + // G1 point not on curve. + revert ProofInvalid(); + } + } + + /// Decompress a G1 point. + /// @notice Reverts with InvalidProof if the input does not represent a valid point. + /// @notice The point at infinity is encoded as (0,0) and compressed to 0. + /// @param c The compresed point (x with one signal bit). + /// @return x The X coordinate in Fp. + /// @return y The Y coordinate in Fp. + function decompress_g1(uint256 c) internal view returns (uint256 x, uint256 y) { + // Note that X = 0 is not on the curve since 0³ + 3 = 3 is not a square. + // so we can use it to represent the point at infinity. + if (c == 0) { + // Point at infinity as encoded in EIP196 and EIP197. + return (0, 0); + } + bool negate_point = c & 1 == 1; + x = c >> 1; + if (x >= P) { + // G1 x coordinate not in field. + revert ProofInvalid(); + } + + // Note: (x³ + 3) is irreducible in Fp, so it can not be zero and therefore + // y can not be zero. + // Note: sqrt_Fp reverts if there is no solution, i.e. the point is not on the curve. + y = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); + if (negate_point) { + y = negate(y); + } + } + + /// Compress a G2 point. + /// @notice Reverts with InvalidProof if the coefficients are not reduced + /// or if the point is not on the curve. + /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) + /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). + /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). + /// @param x0 The real part of the X coordinate. + /// @param x1 The imaginary poart of the X coordinate. + /// @param y0 The real part of the Y coordinate. + /// @param y1 The imaginary part of the Y coordinate. + /// @return c0 The first half of the compresed point (x0 with two signal bits). + /// @return c1 The second half of the compressed point (x1 unmodified). + function compress_g2(uint256 x0, uint256 x1, uint256 y0, uint256 y1) + internal view returns (uint256 c0, uint256 c1) { + if (x0 >= P || x1 >= P || y0 >= P || y1 >= P) { + // G2 point not in field. + revert ProofInvalid(); + } + if ((x0 | x1 | y0 | y1) == 0) { + // Point at infinity + return (0, 0); + } + + // Compute y^2 + // Note: shadowing variables and scoping to avoid stack-to-deep. + uint256 y0_pos; + uint256 y1_pos; + { + uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); + uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); + uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); + y0_pos = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); + y1_pos = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); + } + + // Determine hint bit + // If this sqrt fails the x coordinate is not on the curve. + bool hint; + { + uint256 d = sqrt_Fp(addmod(mulmod(y0_pos, y0_pos, P), mulmod(y1_pos, y1_pos, P), P)); + hint = !isSquare_Fp(mulmod(addmod(y0_pos, d, P), FRACTION_1_2_FP, P)); + } + + // Recover y + (y0_pos, y1_pos) = sqrt_Fp2(y0_pos, y1_pos, hint); + if (y0 == y0_pos && y1 == y1_pos) { + c0 = (x0 << 2) | (hint ? 2 : 0) | 0; + c1 = x1; + } else if (y0 == negate(y0_pos) && y1 == negate(y1_pos)) { + c0 = (x0 << 2) | (hint ? 2 : 0) | 1; + c1 = x1; + } else { + // G1 point not on curve. + revert ProofInvalid(); + } + } + + /// Decompress a G2 point. + /// @notice Reverts with InvalidProof if the input does not represent a valid point. + /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) + /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). + /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). + /// @param c0 The first half of the compresed point (x0 with two signal bits). + /// @param c1 The second half of the compressed point (x1 unmodified). + /// @return x0 The real part of the X coordinate. + /// @return x1 The imaginary poart of the X coordinate. + /// @return y0 The real part of the Y coordinate. + /// @return y1 The imaginary part of the Y coordinate. + function decompress_g2(uint256 c0, uint256 c1) + internal view returns (uint256 x0, uint256 x1, uint256 y0, uint256 y1) { + // Note that X = (0, 0) is not on the curve since 0³ + 3/(9 + i) is not a square. + // so we can use it to represent the point at infinity. + if (c0 == 0 && c1 == 0) { + // Point at infinity as encoded in EIP197. + return (0, 0, 0, 0); + } + bool negate_point = c0 & 1 == 1; + bool hint = c0 & 2 == 2; + x0 = c0 >> 2; + x1 = c1; + if (x0 >= P || x1 >= P) { + // G2 x0 or x1 coefficient not in field. + revert ProofInvalid(); + } + + uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); + uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); + uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); + + y0 = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); + y1 = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); + + // Note: sqrt_Fp2 reverts if there is no solution, i.e. the point is not on the curve. + // Note: (X³ + 3/(9 + i)) is irreducible in Fp2, so y can not be zero. + // But y0 or y1 may still independently be zero. + (y0, y1) = sqrt_Fp2(y0, y1, hint); + if (negate_point) { + y0 = negate(y0); + y1 = negate(y1); + } + } + + /// Compute the public input linear combination. + /// @notice Reverts with PublicInputNotInField if the input is not in the field. + /// @notice Computes the multi-scalar-multiplication of the public input + /// elements and the verification key including the constant term. + /// @param input The public inputs. These are elements of the scalar field Fr. + /// @param publicCommitments public inputs generated from pedersen commitments. + /// @param commitments The Pedersen commitments from the proof. + /// @return x The X coordinate of the resulting G1 point. + /// @return y The Y coordinate of the resulting G1 point. + function publicInputMSM( + uint256[2] calldata input, + uint256[1] memory publicCommitments, + uint256[2] memory commitments + ) + internal view returns (uint256 x, uint256 y) { + // Note: The ECMUL precompile does not reject unreduced values, so we check this. + // Note: Unrolling this loop does not cost much extra in code-size, the bulk of the + // code-size is in the PUB_ constants. + // ECMUL has input (x, y, scalar) and output (x', y'). + // ECADD has input (x1, y1, x2, y2) and output (x', y'). + // We reduce commitments(if any) with constants as the first point argument to ECADD. + // We call them such that ecmul output is already in the second point + // argument to ECADD so we can have a tight loop. + bool success = true; + assembly ("memory-safe") { + let f := mload(0x40) + let g := add(f, 0x40) + let s + mstore(f, CONSTANT_X) + mstore(add(f, 0x20), CONSTANT_Y) + mstore(g, mload(commitments)) + mstore(add(g, 0x20), mload(add(commitments, 0x20))) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_0_X) + mstore(add(g, 0x20), PUB_0_Y) + s := calldataload(input) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_1_X) + mstore(add(g, 0x20), PUB_1_Y) + s := calldataload(add(input, 32)) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + mstore(g, PUB_2_X) + mstore(add(g, 0x20), PUB_2_Y) + s := mload(publicCommitments) + mstore(add(g, 0x40), s) + success := and(success, lt(s, R)) + success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) + success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) + + x := mload(f) + y := mload(add(f, 0x20)) + } + if (!success) { + // Either Public input not in field, or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert PublicInputNotInField(); + } + } + + /// Compress a proof. + /// @notice Will revert with InvalidProof if the curve points are invalid, + /// but does not verify the proof itself. + /// @param proof The uncompressed Groth16 proof. Points (A, B, C) encoded as in EIP-197 + /// (256 bytes total). + /// Followed by Pedersen commitments (1 × 64 bytes) and proof of knowledge + /// (64 bytes) = 384 bytes total. + /// @return compressed The compressed proof. Elements are in the same order as for + /// verifyCompressedProof. I.e. points (A, B, C) in compressed format. + /// @return compressedCommitments compressed Pedersen commitments from the proof. + /// @return compressedCommitmentPok compressed proof of knowledge for the Pedersen commitments. + function compressProof(bytes calldata proof) + public view returns ( + uint256[4] memory compressed, + uint256[1] memory compressedCommitments, + uint256 compressedCommitmentPok + ) { + require(proof.length == 384, "invalid proof length"); + uint256 a0; + uint256 a1; + assembly ("memory-safe") { + a0 := calldataload(proof.offset) + a1 := calldataload(add(proof.offset, 0x20)) + } + compressed[0] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x60)) + a1 := calldataload(add(proof.offset, 0x40)) + } + uint256 b0; + uint256 b1; + assembly ("memory-safe") { + b0 := calldataload(add(proof.offset, 0xa0)) + b1 := calldataload(add(proof.offset, 0x80)) + } + (compressed[2], compressed[1]) = compress_g2(a0, a1, b0, b1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0xc0)) + a1 := calldataload(add(proof.offset, 0xe0)) + } + compressed[3] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x100)) + a1 := calldataload(add(proof.offset, 0x120)) + } + compressedCommitments[0] = compress_g1(a0, a1); + assembly ("memory-safe") { + a0 := calldataload(add(proof.offset, 0x140)) + a1 := calldataload(add(proof.offset, 0x160)) + } + compressedCommitmentPok = compress_g1(a0, a1); + } + + /// Verify a Groth16 proof with compressed points. + /// @notice Reverts with InvalidProof if the proof is invalid or + /// with PublicInputNotInField the public input is not reduced. + /// @notice There is no return value. If the function does not revert, the + /// proof was successfully verified. + /// @param compressedProof the points (A, B, C) in compressed format + /// matching the output of compressProof. + /// @param compressedCommitments compressed Pedersen commitments from the proof. + /// @param compressedCommitmentPok compressed proof of knowledge for the Pedersen commitments. + /// @param input the public input field elements in the scalar field Fr. + /// Elements must be reduced. + function verifyCompressedProof( + uint256[4] calldata compressedProof, + uint256[1] calldata compressedCommitments, + uint256 compressedCommitmentPok, + uint256[2] calldata input + ) public view { + uint256[1] memory publicCommitments; + uint256[2] memory commitments; + uint256[24] memory pairings; + { + (commitments[0], commitments[1]) = decompress_g1(compressedCommitments[0]); + (uint256 Px, uint256 Py) = decompress_g1(compressedCommitmentPok); + + uint256[] memory publicAndCommitmentCommitted; + + publicCommitments[0] = uint256( + sha256( + abi.encodePacked( + commitments[0], + commitments[1], + publicAndCommitmentCommitted + ) + ) + ) % R; + // Commitments + pairings[ 0] = commitments[0]; + pairings[ 1] = commitments[1]; + pairings[ 2] = PEDERSEN_GSIGMANEG_X_1; + pairings[ 3] = PEDERSEN_GSIGMANEG_X_0; + pairings[ 4] = PEDERSEN_GSIGMANEG_Y_1; + pairings[ 5] = PEDERSEN_GSIGMANEG_Y_0; + pairings[ 6] = Px; + pairings[ 7] = Py; + pairings[ 8] = PEDERSEN_G_X_1; + pairings[ 9] = PEDERSEN_G_X_0; + pairings[10] = PEDERSEN_G_Y_1; + pairings[11] = PEDERSEN_G_Y_0; + + // Verify pedersen commitments + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + + success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x180, f, 0x20) + success := and(success, mload(f)) + } + if (!success) { + revert CommitmentInvalid(); + } + } + + { + (uint256 Ax, uint256 Ay) = decompress_g1(compressedProof[0]); + (uint256 Bx0, uint256 Bx1, uint256 By0, uint256 By1) = decompress_g2(compressedProof[2], compressedProof[1]); + (uint256 Cx, uint256 Cy) = decompress_g1(compressedProof[3]); + (uint256 Lx, uint256 Ly) = publicInputMSM( + input, + publicCommitments, + commitments + ); + + // Verify the pairing + // Note: The precompile expects the F2 coefficients in big-endian order. + // Note: The pairing precompile rejects unreduced values, so we won't check that here. + // e(A, B) + pairings[ 0] = Ax; + pairings[ 1] = Ay; + pairings[ 2] = Bx1; + pairings[ 3] = Bx0; + pairings[ 4] = By1; + pairings[ 5] = By0; + // e(C, -δ) + pairings[ 6] = Cx; + pairings[ 7] = Cy; + pairings[ 8] = DELTA_NEG_X_1; + pairings[ 9] = DELTA_NEG_X_0; + pairings[10] = DELTA_NEG_Y_1; + pairings[11] = DELTA_NEG_Y_0; + // e(α, -β) + pairings[12] = ALPHA_X; + pairings[13] = ALPHA_Y; + pairings[14] = BETA_NEG_X_1; + pairings[15] = BETA_NEG_X_0; + pairings[16] = BETA_NEG_Y_1; + pairings[17] = BETA_NEG_Y_0; + // e(L_pub, -γ) + pairings[18] = Lx; + pairings[19] = Ly; + pairings[20] = GAMMA_NEG_X_1; + pairings[21] = GAMMA_NEG_X_0; + pairings[22] = GAMMA_NEG_Y_1; + pairings[23] = GAMMA_NEG_Y_0; + + // Check pairing equation. + bool success; + uint256[1] memory output; + assembly ("memory-safe") { + success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x300, output, 0x20) + } + if (!success || output[0] != 1) { + // Either proof or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert ProofInvalid(); + } + } + } + + /// Verify an uncompressed Groth16 proof. + /// @notice Reverts with InvalidProof if the proof is invalid or + /// with PublicInputNotInField the public input is not reduced. + /// @notice There is no return value. If the function does not revert, the + /// proof was successfully verified. + /// @param proof the serialized proof, containing the points (A, B, C) in EIP-197 format + /// (256 bytes total). + /// Followed by Pedersen commitments (1 × 64 bytes) and proof of knowledge + /// (64 bytes) = 384 bytes total. + /// @param input the public input field elements in the scalar field Fr. + /// Elements must be reduced. + function verifyProof( + bytes calldata proof, + uint256[2] calldata input + ) public view { + require(proof.length == 384, "invalid proof length"); + // Copy commitment points from proof bytes into memory for publicInputMSM + uint256[2] memory commitments; + assembly ("memory-safe") { + calldatacopy(commitments, add(proof.offset, 0x100), 64) + } + + // HashToField + uint256[1] memory publicCommitments; + uint256[] memory publicAndCommitmentCommitted; + + { + bytes memory hashInput = abi.encodePacked( + proof[0x100:0x140], + publicAndCommitmentCommitted + ); + publicCommitments[0] = uint256(sha256(hashInput)) % R; + } + + // Verify pedersen commitments + bool success; + assembly ("memory-safe") { + let f := mload(0x40) + + calldatacopy(f, add(proof.offset, 0x100), 0x40) // Copy first commitment + mstore(add(f, 0x40), PEDERSEN_GSIGMANEG_X_1) + mstore(add(f, 0x60), PEDERSEN_GSIGMANEG_X_0) + mstore(add(f, 0x80), PEDERSEN_GSIGMANEG_Y_1) + mstore(add(f, 0xa0), PEDERSEN_GSIGMANEG_Y_0) + calldatacopy(add(f, 0xc0), add(proof.offset, 0x140), 0x40) // Copy PoK + mstore(add(f, 0x100), PEDERSEN_G_X_1) + mstore(add(f, 0x120), PEDERSEN_G_X_0) + mstore(add(f, 0x140), PEDERSEN_G_Y_1) + mstore(add(f, 0x160), PEDERSEN_G_Y_0) + + success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x180, f, 0x20) + success := and(success, mload(f)) + } + if (!success) { + revert CommitmentInvalid(); + } + + (uint256 x, uint256 y) = publicInputMSM( + input, + publicCommitments, + commitments + ); + + // Note: The precompile expects the F2 coefficients in big-endian order. + // Note: The pairing precompile rejects unreduced values, so we won't check that here. + assembly ("memory-safe") { + let f := mload(0x40) // Free memory pointer. + + // Copy points (A, B, C) to memory. They are already in correct encoding. + // This is pairing e(A, B) and G1 of e(C, -δ). + calldatacopy(f, proof.offset, 0x100) + + // Complete e(C, -δ) and write e(α, -β), e(L_pub, -γ) to memory. + // OPT: This could be better done using a single codecopy, but + // Solidity (unlike standalone Yul) doesn't provide a way to + // to do this. + mstore(add(f, 0x100), DELTA_NEG_X_1) + mstore(add(f, 0x120), DELTA_NEG_X_0) + mstore(add(f, 0x140), DELTA_NEG_Y_1) + mstore(add(f, 0x160), DELTA_NEG_Y_0) + mstore(add(f, 0x180), ALPHA_X) + mstore(add(f, 0x1a0), ALPHA_Y) + mstore(add(f, 0x1c0), BETA_NEG_X_1) + mstore(add(f, 0x1e0), BETA_NEG_X_0) + mstore(add(f, 0x200), BETA_NEG_Y_1) + mstore(add(f, 0x220), BETA_NEG_Y_0) + mstore(add(f, 0x240), x) + mstore(add(f, 0x260), y) + mstore(add(f, 0x280), GAMMA_NEG_X_1) + mstore(add(f, 0x2a0), GAMMA_NEG_X_0) + mstore(add(f, 0x2c0), GAMMA_NEG_Y_1) + mstore(add(f, 0x2e0), GAMMA_NEG_Y_0) + + // Check pairing equation. + success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x300, f, 0x20) + // Also check returned value (both are either 1 or 0). + success := and(success, mload(f)) + } + if (!success) { + // Either proof or verification key invalid. + // We assume the contract is correctly generated, so the verification key is valid. + revert ProofInvalid(); + } + } +} diff --git a/contracts/groth16/testdata/wrapper/proof.json b/contracts/groth16/testdata/wrapper/proof.json new file mode 100644 index 000000000..3518e2a5c --- /dev/null +++ b/contracts/groth16/testdata/wrapper/proof.json @@ -0,0 +1,5 @@ +{ + "scope": "development-proof-of-the-BN254-KZG-verifier-fixture", + "proof": "0x174256403dc1a8573d94a44dc6eaaf9c25982e077b2c7d2a5ac4c133eb08ff3900c905e1fd731b5911cb76f52ad804e2576dde574ee963511bc5be978268d26430405bc8c04a83f4679aee60374310769aaab124ec62e2c637e1ae2271b4d757107be8700efeb2b29bbe342384b2c25d02563e75d2677b0ae43331606ddf645e0dbc82b4e8e4ee4b23b09ca20df9b232e718b605506fe39a50722b1da0dae7da0c80b4d0b78f747bbcbc40a71aebab94294444c5f27699e6d3cffdab4330588228a5ff108b33c1ac8709f8e451fe4d955e45f6a02c14f34e5f01a78d1aba17a10f1ece08315dff8a446bd3515be2ebfbaf3551e2d72020083393e5eb60ef1418213e6a875f5fa2f7cb2e4ca581ca60f281fab9a2abd6e87440aaba1c822be457175099d2da467277f261eb1a187aec47fd447a4dfb5266b08087277f47907dde2beb560ba9e1cb27463fd35a428ceb4dd6a937947af87123623a5a406db65bca2191902ee0a6932f2db212acf09d935f5e93e4d819a0132f97053128e1ab1445", + "digest": "0xc019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80" +} diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index ff36c86b8..865f229d2 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -47,8 +47,9 @@ bincode = { version = "2.0.1", optional = true } serde = { version = "1.0.219", features = ["derive"], optional = true } [features] -default = ["plonk"] +default = ["plonk", "groth16"] plonk = ["ix-proof-compression/plonk"] +groth16 = ["ix-proof-compression/groth16"] parallel = ["aiur/parallel", "ix-proof-compression/parallel"] # The KZG prover's CUDA code is compiled into the ix-compression-worker # executable, not into the library the Lean executable links. diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index 05b245d78..04692bfec 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -24,11 +24,15 @@ extern "C" fn rs_compression_run( cache_root: LeanString>, dev_srs: bool, log_height: u32, - terminal_plonk: bool, + terminal: u8, ) -> LeanIOResult { let dir = dir.to_string(); let cache_root = cache_root.to_string(); - match pipeline::compress( + let terminal = match pipeline::TerminalOptions::try_from(terminal) { + Ok(system) => system, + Err(error) => return LeanIOResult::error_string(error), + }; + match pipeline::compress_with_terminal( vk.as_bytes(), proof.as_bytes(), claim.as_bytes(), @@ -38,7 +42,7 @@ extern "C" fn rs_compression_run( std::path::Path::new(&cache_root), dev_srs, log_height, - terminal_plonk, + terminal, ) { Ok(bytes) => LeanIOResult::ok(LeanByteArray::from_bytes(bytes.as_slice())), Err(e) => LeanIOResult::error_string(&e.to_string()), diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index abffc7088..a3a703b6c 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -55,7 +55,8 @@ features = ["cuda"] [features] plonk = [] -default = ["parallel", "plonk"] +groth16 = [] +default = ["parallel", "plonk", "groth16"] # GPU proving: multi-stark's Goldilocks backend for FRI compression and the # KZG kernels here. Needs the CUDA toolkit at build time and a visible GPU # at runtime; CPU-only builds neither compile sppark nor link CUDA. diff --git a/crates/proof-compression/src/bin/ix_compression_worker.rs b/crates/proof-compression/src/bin/ix_compression_worker.rs index a90495b06..5de986c13 100644 --- a/crates/proof-compression/src/bin/ix_compression_worker.rs +++ b/crates/proof-compression/src/bin/ix_compression_worker.rs @@ -46,6 +46,8 @@ fn main() -> Result<(), Box> { "pipeline" => tracing::info_span!("compression/pipeline"), #[cfg(feature = "plonk")] "plonk" => tracing::info_span!("compression/plonk"), + #[cfg(feature = "groth16")] + "groth16" => tracing::info_span!("compression/groth16"), _ => return Err("unknown compression stage".into()), }; let span = if texray { tracing_texray::examine(span) } else { span }; diff --git a/crates/proof-compression/src/pipeline/groth16.rs b/crates/proof-compression/src/pipeline/groth16.rs new file mode 100644 index 000000000..4d801693e --- /dev/null +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -0,0 +1,497 @@ +//! Terminal BN254 Groth16 proofs under independently admitted circuit keys. +use super::{ + Result, + setup::SetupClass, + statement, storage, + terminal::{Scratch, checked, claim_digest, digest, hex, load_json, sha256}, + wrap, +}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x03"; +const CATALOG_FORMAT: &str = "ix-bn254-groth16-verifiers-v1"; +const CATALOG_VAR: &str = "IX_GROTH16_VERIFIER_CATALOG"; +const ARTIFACTS_VAR: &str = "IX_GROTH16_ARTIFACTS"; +const MAX_PROOF_BYTES: usize = 196; + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TrustedCatalog { + format: String, + keys: Vec, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TrustedKey { + sha256: String, + path: PathBuf, + inner_vk_id: String, + inner_schema_sha256: String, + statement_prefix: [u64; 10], + circuit_sha256: String, + setup: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + ceremony_sha256: Option, +} + +impl TrustedKey { + fn setup_class(&self, allow_development: bool) -> Result { + match self.setup.as_str() { + "mpc" => { + digest(self.ceremony_sha256.as_deref().ok_or("missing Groth16 ceremony identity")?)?; + Ok(SetupClass::Groth16) + }, + "development" if allow_development && self.ceremony_sha256.is_none() => Ok(SetupClass::Development), + "development" => Err("development Groth16 keys require explicit --allow-development or --dev-srs verification".into()), + _ => Err("unsupported Groth16 setup class".into()), + } + } +} + +fn catalog_path() -> Result { + std::env::var_os(CATALOG_VAR).map(PathBuf::from).ok_or_else(|| { + format!( + "set {CATALOG_VAR} to an independently trusted Groth16 verifier catalog" + ) + .into() + }) +} + +fn select_key( + path: &Path, + id: &[u8; 32], + allow_development: bool, +) -> Result { + let catalog: TrustedCatalog = load_json(path)?; + if catalog.format != CATALOG_FORMAT { + return Err("unsupported Groth16 verifier catalog".into()); + } + let mut selected = None; + let mut seen = std::collections::BTreeSet::new(); + for mut key in catalog.keys { + let key_id = digest(&key.sha256)?; + key.sha256 = hex(&key_id); + key.inner_vk_id = hex(&digest(&key.inner_vk_id)?); + key.inner_schema_sha256 = hex(&digest(&key.inner_schema_sha256)?); + key.circuit_sha256 = hex(&digest(&key.circuit_sha256)?); + if let Some(value) = &mut key.ceremony_sha256 { + *value = hex(&digest(value)?); + } + if !seen.insert(key_id) { + return Err("duplicate Groth16 verifying key in catalog".into()); + } + let words = statement::with_claim(&key.statement_prefix, b""); + statement::from_claims_bytes(&statement::to_claims_bytes(&words))?; + key.setup_class(true)?; + if key_id == *id { + key.setup_class(allow_development)?; + if key.path.is_relative() { + key.path = + path.parent().unwrap_or_else(|| Path::new(".")).join(&key.path); + } + selected = Some(key); + } + } + selected.ok_or( + "Groth16 proof uses a verifying key not authorized by the catalog".into(), + ) +} + +pub(super) fn verify( + packet: &[u8], + claim: &[u8], + allow_development: bool, +) -> Result { + if !matches!(packet.len(), 196 | 228) { + return Err("invalid Groth16 packet length".into()); + } + verify_with_catalog( + packet, + claim, + &catalog_path()?, + &super::terminal::helper_executable("IX_GROTH16_HELPER")?, + allow_development, + ) +} + +fn verify_with_catalog( + packet: &[u8], + claim: &[u8], + catalog: &Path, + helper: &Path, + allow_development: bool, +) -> Result { + if !matches!(packet.len(), 196 | 228) { + return Err("invalid Groth16 packet length".into()); + } + let id = packet[..32].try_into()?; + let key = select_key(catalog, id, allow_development)?; + if fs::metadata(&key.path)?.len() > 1 << 20 || sha256(&key.path)? != *id { + return Err( + "Groth16 verifying key differs from the trusted catalog".into(), + ); + } + let scratch = Scratch::new()?; + let proof = scratch.0.join("proof.bin"); + storage::write_atomic(&proof, &packet[32..])?; + checked( + Command::new(helper) + .arg("groth16") + .arg("verify") + .env("CUDA_VISIBLE_DEVICES", "") + .arg(&key.path) + .arg(hex(id)) + .arg(proof) + .arg(blake3::hash(claim).to_hex().as_str()), + )?; + key.setup_class(allow_development) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Metadata { + format: String, + curve: String, + #[serde(rename = "innerVKSHA256")] + inner_vk: String, + commitment_hash: String, + constraints: usize, + domain: u64, + public_inputs: usize, + commitments: usize, + setup: String, + #[serde(default, rename = "ceremonySHA256")] + ceremony: Option, + keys_ready: bool, + files: std::collections::BTreeMap, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Artifact { + #[serde(rename = "SHA256")] + sha256: String, + #[serde(rename = "Bytes")] + bytes: u64, +} + +impl Metadata { + fn artifact(&self, name: &str) -> Result<&Artifact> { + let value = self + .files + .get(name) + .ok_or_else(|| format!("missing Groth16 artifact {name}"))?; + digest(&value.sha256)?; + if value.bytes == 0 || value.bytes > 1 << 42 { + return Err("invalid Groth16 artifact size".into()); + } + Ok(value) + } +} + +fn prepared(allow_development: bool) -> Result<(PathBuf, TrustedKey)> { + let dir = + std::env::var_os(ARTIFACTS_VAR).map(PathBuf::from).ok_or_else(|| { + format!("set {ARTIFACTS_VAR} to prepared Groth16 circuit artifacts") + })?; + let m: Metadata = load_json(&dir.join("manifest.json"))?; + if m.format != "ix-bn254-groth16-artifacts-v1" + || m.curve != "bn254" + || m.commitment_hash != "sha256" + || !m.keys_ready + || m.public_inputs != 2 + || m.commitments > 1 + || m.constraints == 0 + || m.constraints > 1 << 27 + || m.domain != u64::try_from(m.constraints)?.next_power_of_two() + { + return Err("unsupported or unprepared Groth16 artifact metadata".into()); + } + let key = select_key( + &catalog_path()?, + &digest(&m.artifact("verifying-key.bin")?.sha256)?, + allow_development, + )?; + if m.inner_vk != key.inner_schema_sha256 + || m.artifact("vk.json")?.sha256 != key.inner_schema_sha256 + || m.artifact("circuit.bin")?.sha256 != key.circuit_sha256 + || m.setup != key.setup + || m.ceremony != key.ceremony_sha256 + { + return Err( + "Groth16 artifacts differ from the admitted circuit and setup".into(), + ); + } + for name in ["proving-key.bin", "Groth16Verifier.sol"] { + m.artifact(name)?; + } + Ok((dir, key)) +} + +pub(super) fn preflight(allow_development: bool) -> Result<()> { + let helper = super::terminal::helper_executable("IX_GROTH16_HELPER")?; + let output = + Command::new(helper).args(["groth16", "capabilities"]).output()?; + if !output.status.success() { + return Err("terminal helper does not support Groth16".into()); + } + let capabilities: serde_json::Value = serde_json::from_slice(&output.stdout)?; + if capabilities["system"] != "groth16" || capabilities["curve"] != "bn254" { + return Err( + "terminal helper reports another Groth16 system or curve".into(), + ); + } + match std::env::var("IX_GROTH16_BACKEND").as_deref() { + Err(_) | Ok("" | "cpu") => {}, + Ok("cuda") if capabilities["cuda"] == true => {}, + Ok("cuda") => { + return Err( + "terminal helper was built without CUDA Groth16 support".into(), + ); + }, + _ => return Err("IX_GROTH16_BACKEND must be cpu or cuda".into()), + } + prepared(allow_development)?; + Ok(()) +} + +pub(super) fn check_statement( + expected: &statement::Statement, + allow_development: bool, +) -> Result<()> { + let (_, key) = prepared(allow_development)?; + if expected[..10] != key.statement_prefix { + return Err( + "Groth16 catalog authorizes another aggregation program".into(), + ); + } + Ok(()) +} + +fn wrap_packet( + verifier: &Path, + packet: &Path, + trusted: &str, + expected: &statement::Statement, + out: &Path, + allow_development: bool, +) -> Result<()> { + statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; + let (artifacts, key) = prepared(allow_development)?; + if expected[..10] != key.statement_prefix { + return Err("Groth16 key does not authorize this inner verifier and aggregation program".into()); + } + fs::create_dir_all(out)?; + if fs::read_dir(out)?.next().is_some() { + return Err("Groth16 output directory must be empty".into()); + } + let input = out.join("input"); + wrap::plonk_export(verifier, packet, trusted, expected, &input)?; + let exported: serde_json::Value = load_json(&input.join("vk.json"))?; + let inner_id = + exported["vkId"].as_str().ok_or("missing exported inner key identity")?; + if digest(inner_id)? != digest(&key.inner_vk_id)? + || sha256(&input.join("vk.json"))? != digest(&key.inner_schema_sha256)? + { + return Err( + "Groth16 circuit was compiled for a different verifier description" + .into(), + ); + } + let claim = claim_digest(expected)?; + let helper = super::terminal::helper_executable("IX_GROTH16_HELPER")?; + checked( + Command::new(&helper) + .arg("groth16") + .arg("prove") + .arg(&artifacts) + .arg(input.join("witness.json")) + .arg(hex(&claim)) + .arg(out), + )?; + checked( + Command::new(&helper) + .arg("groth16") + .arg("verify") + .arg(&key.path) + .arg(&key.sha256) + .arg(out.join("proof.bin")) + .arg(hex(&claim)), + )?; + let proof = fs::read(out.join("proof.bin"))?; + if !matches!(proof.len(), 164 | MAX_PROOF_BYTES) { + return Err("invalid native Groth16 proof length".into()); + } + let mut envelope = PROOF_TAG.to_vec(); + envelope.extend(digest(&key.sha256)?); + envelope.extend(proof); + for name in ["verifying-key.bin", "Groth16Verifier.sol"] { + storage::write_atomic(&out.join(name), &fs::read(artifacts.join(name))?)?; + } + storage::write_atomic(&out.join("proof.envelope.bin"), &envelope)?; + Ok(()) +} + +pub(crate) fn run(args: &[String]) -> Result<()> { + let development_flags = + args.iter().filter(|s| s.as_str() == "--allow-development").count(); + let production_flags = + args.iter().filter(|s| s.as_str() == "--production").count(); + if development_flags > 1 + || production_flags > 1 + || development_flags + production_flags > 1 + { + return Err("conflicting Groth16 setup policy flags".into()); + } + let allow_development = development_flags == 1; + let positional: Vec<_> = args + .iter() + .filter(|s| !matches!(s.as_str(), "--allow-development" | "--production")) + .map(String::as_str) + .collect(); + match positional.as_slice() { + ["export", verifier, packet, trusted, expected, out] => { + let expected = load_json(Path::new(expected))?; + wrap::plonk_export(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(out)) + }, + ["verify", proof, claim] => { + let bytes = fs::read(proof)?; + let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported Groth16 proof curve or version")?; + let setup = verify(packet, &fs::read(claim)?, allow_development)?; + println!("{}", serde_json::json!({"verified": true, "curve": "bn254", "setup": setup.label()})); + Ok(()) + }, + ["wrap", verifier, packet, trusted, expected, out] => { + let expected = load_json(Path::new(expected))?; + wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(out), allow_development) + }, + _ => Err("usage: ix-compression-worker groth16 export | wrap [--allow-development] | verify [--allow-development]".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "requires a compiled terminal helper in IX_GROTH16_HELPER"] + fn saved_init_groth16_proof_verifies_without_gpu() -> Result<()> { + let helper = + super::super::terminal::helper_executable("IX_GROTH16_HELPER")?; + let dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/groth16"); + let provenance: serde_json::Value = + load_json(&dir.join("provenance.json"))?; + let bytes = fs::read(dir.join("proof.bin"))?; + let vk = dir.join("verifying-key.bin"); + let id = sha256(&vk)?; + let claim = include_bytes!("../../tests/fixtures/init-fri/claim.bin"); + assert_eq!( + blake3::hash(claim).to_hex().as_str(), + provenance["claim_digest"].as_str().unwrap() + ); + let mut packet = id.to_vec(); + packet.extend(bytes); + let key = TrustedKey { + sha256: hex(&id), + path: vk, + inner_vk_id: provenance["inner_vk_id"].as_str().unwrap().into(), + inner_schema_sha256: provenance["inner_schema_sha256"] + .as_str() + .unwrap() + .into(), + statement_prefix: serde_json::from_value( + provenance["statement_prefix"].clone(), + )?, + circuit_sha256: provenance["circuit_sha256"].as_str().unwrap().into(), + setup: "development".into(), + ceremony_sha256: None, + }; + let scratch = Scratch::new()?; + let catalog = scratch.0.join("catalog.json"); + storage::write_atomic( + &catalog, + &serde_json::to_vec(&TrustedCatalog { + format: CATALOG_FORMAT.into(), + keys: vec![key], + })?, + )?; + assert!( + verify_with_catalog(&packet, claim, &catalog, &helper, false).is_err() + ); + assert_eq!( + verify_with_catalog(&packet, claim, &catalog, &helper, true)?, + SetupClass::Development + ); + assert!( + verify_with_catalog(&packet, b"another claim", &catalog, &helper, true) + .is_err() + ); + packet[32] = 0; + assert!( + verify_with_catalog(&packet, claim, &catalog, &helper, true).is_err() + ); + Ok(()) + } + + #[test] + fn catalog_requires_exact_key_identity_and_explicit_development() -> Result<()> + { + let scratch = Scratch::new()?; + let path = scratch.0.join("catalog.json"); + let mut catalog = TrustedCatalog { + format: CATALOG_FORMAT.into(), + keys: vec![TrustedKey { + sha256: hex(&[1; 32]), + path: "key.bin".into(), + inner_vk_id: hex(&[2; 32]), + inner_schema_sha256: hex(&[3; 32]), + statement_prefix: [0; 10], + circuit_sha256: hex(&[4; 32]), + setup: "development".into(), + ceremony_sha256: None, + }], + }; + let save = |value: &TrustedCatalog| { + storage::write_atomic(&path, &serde_json::to_vec(value).unwrap()) + }; + save(&catalog)?; + assert!(select_key(&path, &[1; 32], false).is_err()); + assert_eq!( + select_key(&path, &[1; 32], true)?.path, + scratch.0.join("key.bin") + ); + assert!(select_key(&path, &[9; 32], true).is_err()); + catalog.keys[0].setup = "mpc".into(); + save(&catalog)?; + assert!(select_key(&path, &[1; 32], false).is_err()); + catalog.keys[0].ceremony_sha256 = Some(hex(&[5; 32])); + save(&catalog)?; + assert_eq!( + select_key(&path, &[1; 32], false)?.setup_class(false)?, + SetupClass::Groth16 + ); + catalog.keys.push(catalog.keys[0].clone()); + save(&catalog)?; + assert!(select_key(&path, &[1; 32], false).is_err()); + assert_eq!(PROOF_TAG, b"IXBN\0\0\0\x03"); + Ok(()) + } + + #[test] + fn malformed_envelopes_fail_before_key_or_helper_access() { + for length in [0, 32, 195, 197, 227, 229, 1 << 20] { + assert!( + verify(&vec![0; length], b"claim", false) + .unwrap_err() + .to_string() + .contains("packet length") + ); + } + } +} diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index cb5531751..cb9de4d9c 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -9,11 +9,15 @@ pub use stages::{ mod driver; mod first; mod fixed_cache; +#[cfg(feature = "groth16")] +mod groth16; #[cfg(test)] mod init_claim; mod init_fri; #[cfg(feature = "plonk")] mod plonk; +mod terminal; +pub use terminal::{TerminalOptions, TerminalSystem}; mod prover; mod root; mod setup; @@ -32,13 +36,15 @@ pub const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x01"; pub type Result = std::result::Result>; pub fn command(stage: &str, args: &[String]) -> Result<()> { - if !matches!(stage, "plonk" | "pipeline") { + if !matches!(stage, "plonk" | "groth16" | "pipeline") { configure_backend(stage)?; } match stage { "pipeline" => driver::run(args), #[cfg(feature = "plonk")] "plonk" => plonk::run(args), + #[cfg(feature = "groth16")] + "groth16" => groth16::run(args), "fri" => root::run(args), "kzg" | "wrap" => { let config = WorkerConfig::from_env( @@ -224,7 +230,7 @@ fn check_worker_setup_capabilities(bytes: &[u8], ignition: bool) -> Result<()> { Ok(()) } -fn check_worker_curve(ignition: bool) -> Result<()> { +fn check_worker_curve(ignition: bool, terminal: TerminalSystem) -> Result<()> { let output = Command::new(worker_executable()?) .args(["fri", "capabilities"]) .output()?; @@ -237,7 +243,17 @@ fn check_worker_curve(ignition: bool) -> Result<()> { .into(), ); } - check_worker_setup_capabilities(&output.stdout, ignition) + check_worker_setup_capabilities(&output.stdout, ignition)?; + let capabilities: serde_json::Value = serde_json::from_slice(&output.stdout)?; + let flag = match terminal { + TerminalSystem::None => return Ok(()), + TerminalSystem::Plonk => "terminal_plonk", + TerminalSystem::Groth16 => "terminal_groth16", + }; + if capabilities[flag] != true { + return Err(format!("compression worker does not support {flag}").into()); + } + Ok(()) } fn child(stage: &str, args: &[&str], caches: &Caches) -> Result<()> { @@ -282,6 +298,10 @@ pub fn verify( claim: &[u8], allow_development: bool, ) -> Result { + #[cfg(feature = "groth16")] + if let Some(packet) = proof.strip_prefix(groth16::PROOF_TAG) { + return groth16::verify(packet, claim, allow_development); + } #[cfg(feature = "plonk")] if let Some(packet) = proof.strip_prefix(plonk::PROOF_TAG) { return plonk::verify(packet, claim); @@ -364,9 +384,25 @@ impl Caches { fn validate_compression_options( development: bool, log_height: u32, - terminal_plonk: bool, + terminal: TerminalOptions, ) -> Result { - if terminal_plonk { + if terminal.allow_development && terminal.system != TerminalSystem::Groth16 { + return Err( + "development terminal keys are supported only for Groth16".into(), + ); + } + if terminal.system == TerminalSystem::Groth16 { + #[cfg(not(feature = "groth16"))] + return Err("this build does not include terminal Groth16 support".into()); + #[cfg(feature = "groth16")] + { + if development { + return Err("terminal Groth16 requires admitted inner KZG parameters; omit --dev-srs".into()); + } + groth16::preflight(terminal.allow_development)?; + } + } + if terminal.system == TerminalSystem::Plonk { #[cfg(not(feature = "plonk"))] return Err("this build does not include terminal PLONK support".into()); #[cfg(feature = "plonk")] @@ -395,7 +431,7 @@ fn validate_compression_options( ); } let ignition = setup.ignition_admission().is_some(); - check_worker_curve(ignition)?; + check_worker_curve(ignition, terminal.system)?; Ok(setup) } @@ -463,10 +499,43 @@ pub fn compress( development: bool, log_height: u32, terminal_plonk: bool, +) -> Result> { + compress_with_terminal( + vk, + proof, + claim, + statement, + fixed_root, + out, + cache_root, + development, + log_height, + if terminal_plonk { TerminalSystem::Plonk } else { TerminalSystem::None } + .into(), + ) +} + +/// Compress an aggregate proof with an explicitly selected terminal proof system. +/// With `fixed_root`, the root must be normalized to that layout. +#[allow(clippy::too_many_arguments)] +pub fn compress_with_terminal( + vk: &[u8], + proof: &[u8], + claim: &[u8], + statement: &[u8], + fixed_root: Option<&str>, + out: &Path, + cache_root: &Path, + development: bool, + log_height: u32, + terminal: TerminalOptions, ) -> Result> { let words = check_claim(claim, statement)?; - let setup = - validate_compression_options(development, log_height, terminal_plonk)?; + let setup = validate_compression_options(development, log_height, terminal)?; + #[cfg(feature = "groth16")] + if terminal.system == TerminalSystem::Groth16 { + groth16::check_statement(&words, terminal.allow_development)?; + } prepare_output(out)?; let caches = Caches::under(cache_root); let root = out.join("root"); @@ -477,7 +546,7 @@ pub fn compress( &[&path_string(&root)?, &path_string(&fri)?, "--prove-outer"], &caches, )?; - finish_kzg(&fri, &words, out, cache_root, &setup, log_height, terminal_plonk) + finish_kzg(&fri, &words, out, cache_root, &setup, log_height, terminal) } /// Compress an already saved FRI stage while binding its statement to a @@ -493,7 +562,11 @@ pub fn compress_fri( log_height: u32, ) -> Result> { let words = check_claim(claim, expected_claims)?; - let setup = validate_compression_options(development, log_height, false)?; + let setup = validate_compression_options( + development, + log_height, + TerminalSystem::None.into(), + )?; prepare_output(out)?; { let fixture = init_fri::load(fri)?; @@ -503,7 +576,15 @@ pub fn compress_fri( return Err("saved FRI statement differs from the independently expected root statement".into()); } } - finish_kzg(fri, &words, out, cache_root, &setup, log_height, false) + finish_kzg( + fri, + &words, + out, + cache_root, + &setup, + log_height, + TerminalSystem::None.into(), + ) } fn finish_kzg( @@ -513,7 +594,7 @@ fn finish_kzg( cache_root: &Path, setup: &setup::SetupConfig, log_height: u32, - terminal_plonk: bool, + terminal: TerminalOptions, ) -> Result> { let development = setup.class() == setup::SetupClass::Development; let caches = Caches::under(cache_root); @@ -557,7 +638,35 @@ fn finish_kzg( .into(), ); } - if terminal_plonk { + if terminal.system == TerminalSystem::Groth16 { + #[cfg(feature = "groth16")] + { + let terminal_out = out.join("groth16"); + let expected_path = final_dir.join("expected-statement.json"); + storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; + let verifier = final_dir.join("vk.bin"); + let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); + child( + "groth16", + &[ + "wrap", + &path(&verifier)?, + &path(&final_dir.join("kzg/packet.bin"))?, + trusted.as_str(), + &path(&expected_path)?, + &path(&terminal_out)?, + if terminal.allow_development { + "--allow-development" + } else { + "--production" + }, + ], + &caches, + )?; + return Ok(fs::read(terminal_out.join("proof.envelope.bin"))?); + } + } + if terminal.system == TerminalSystem::Plonk { #[cfg(feature = "plonk")] { let out_plonk = out.join("plonk"); diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index e412ce2cd..834c647fa 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -1,20 +1,21 @@ //! Terminal PLONK worker operations and authenticated parameter interchange. +use super::terminal::{ + Scratch, checked, claim_digest, digest, hex, load_json, sha256, +}; use super::{ Result, fixed_cache, setup::{SetupClass, SetupConfig}, statement, storage, wrap, }; use ark_ff::{BigInteger, PrimeField}; -use sha2::{Digest, Sha256}; use std::{ fs, - io::{Read, Write}, + io::Write, path::{Path, PathBuf}, - process::{Command, Stdio}, + process::Command, }; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; -const HELPER: &str = "ix-bn254-plonk-wrapper"; const CATALOG_FORMAT: &str = "ix-bn254-plonk-verifiers-v1"; const SETUP_NAME: &str = "ignition"; const CATALOG_VAR: &str = "IX_PLONK_VERIFIER_CATALOG"; @@ -43,90 +44,8 @@ struct TrustedKey { admission_blake3: Option, } -struct Scratch(PathBuf); -impl Scratch { - fn new() -> Result { - let path = storage::temporary_path(&std::env::temp_dir().join("ix-plonk")); - fs::create_dir(&path)?; - Ok(Self(path)) - } -} -impl Drop for Scratch { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} - -fn hex(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut text = String::with_capacity(bytes.len() * 2); - for byte in bytes { - write!(text, "{byte:02x}").unwrap(); - } - text -} - -fn digest(text: &str) -> Result<[u8; 32]> { - let text = text.strip_prefix("0x").unwrap_or(text); - if text.len() != 64 || !text.is_ascii() { - return Err("expected a 32-byte hexadecimal digest".into()); - } - let mut bytes = [0; 32]; - for (i, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; - } - Ok(bytes) -} - -fn sha256(path: &Path) -> Result<[u8; 32]> { - let mut file = fs::File::open(path)?; - let mut hash = Sha256::new(); - let mut buffer = vec![0; 65536]; - loop { - let count = file.read(&mut buffer)?; - if count == 0 { - break; - } - hash.update(&buffer[..count]); - } - Ok(hash.finalize().into()) -} - -fn load_json(path: &Path) -> Result { - let mut bytes = Vec::new(); - fs::File::open(path)?.take((1 << 20) + 1).read_to_end(&mut bytes)?; - if bytes.len() > 1 << 20 { - return Err("PLONK metadata exceeds its size limit".into()); - } - Ok(serde_json::from_slice(&bytes)?) -} - pub(super) fn helper_executable() -> Result { - let path = if let Some(path) = std::env::var_os("IX_PLONK_HELPER") { - PathBuf::from(path) - } else { - std::env::current_exe()?.with_file_name(HELPER) - }; - if !path.is_file() { - return Err( - format!( - "{HELPER} is missing; install it beside ix or set IX_PLONK_HELPER" - ) - .into(), - ); - } - Ok(path) -} - -fn checked(command: &mut Command) -> Result<()> { - let status = command - .stdout(Stdio::from(std::io::stderr())) - .stderr(Stdio::inherit()) - .status()?; - if !status.success() { - return Err(format!("PLONK helper failed ({status})").into()); - } - Ok(()) + super::terminal::helper_executable("IX_PLONK_HELPER") } fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { @@ -391,16 +310,6 @@ fn metadata_string<'a>( .ok_or_else(|| format!("missing PLONK metadata field {field}").into()) } -fn claim_digest(expected: &statement::Statement) -> Result<[u8; 32]> { - let mut digest = [0; 32]; - for (chunk, value) in - digest.as_chunks_mut::<4>().0.iter_mut().zip(&expected[10..]) - { - *chunk = u32::try_from(*value)?.to_le_bytes(); - } - Ok(digest) -} - fn wrap_packet( verifier: &Path, packet: &Path, diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index 75976082b..41d637306 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -27,6 +27,7 @@ fn print_capabilities() { "development_setup_only": false, "ignition_setup": true, "terminal_plonk": cfg!(feature = "plonk"), + "terminal_groth16": cfg!(feature = "groth16"), }) ); } diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index a40956a55..dd4b7cdd7 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -41,6 +41,8 @@ pub enum SetupClass { Ignition, /// A trapdoor derived from a seed; anyone can forge proofs. Development, + /// A circuit-specific Groth16 key admitted independently of the proof. + Groth16, } impl SetupClass { @@ -61,6 +63,7 @@ impl SetupClass { pub fn label(self) -> &'static str { match self { Self::Ignition => "ignition", + Self::Groth16 => "groth16", Self::Development => "development", } } @@ -68,6 +71,7 @@ impl SetupClass { pub fn description(self) -> &'static str { match self { Self::Ignition => "the admitted Aztec Ignition BN254 powers of tau", + Self::Groth16 => "an admitted BN254 Groth16 circuit-specific ceremony", Self::Development => { "a known-trapdoor development SRS (not production-secure)" }, @@ -292,11 +296,11 @@ impl SetupSource { } pub(crate) fn security_description(&self) -> &'static str { - match self.class() { - SetupClass::Ignition => { + match self.policy { + Policy::Ignition { .. } => { "Aztec Ignition BN254 powers of tau, with setup identity, full public degree and shifted-basis anchors authorized by an independently pinned admission. Proving prefixes are authenticated against a separate cache receipt. Ceremony provenance depends on the admission's trusted source pins.\n" }, - SetupClass::Development => { + Policy::Development { .. } => { "Known-trapdoor development public-degree KZG. Not ceremony parameters. For development and testing only.\n" }, } diff --git a/crates/proof-compression/src/pipeline/stages/verification.rs b/crates/proof-compression/src/pipeline/stages/verification.rs index 5f02b74bb..a8ca8c429 100644 --- a/crates/proof-compression/src/pipeline/stages/verification.rs +++ b/crates/proof-compression/src/pipeline/stages/verification.rs @@ -174,7 +174,7 @@ fn pairing_points(mut bytes: &[u8]) -> Result<[G1Affine; 2]> { Ok([read_g1_compressed(&mut bytes)?, read_g1_compressed(&mut bytes)?]) } -#[cfg(any(test, feature = "plonk"))] +#[cfg(any(test, feature = "plonk", feature = "groth16"))] pub(in crate::pipeline) fn unpack_pairings( bytes: &[u8], ) -> Result> { diff --git a/crates/proof-compression/src/pipeline/terminal.rs b/crates/proof-compression/src/pipeline/terminal.rs new file mode 100644 index 000000000..a59bb0b3c --- /dev/null +++ b/crates/proof-compression/src/pipeline/terminal.rs @@ -0,0 +1,181 @@ +//! Shared transport and artifact checks for terminal proof backends. +#![cfg_attr( + not(any(feature = "plonk", feature = "groth16")), + allow(dead_code, unused_imports) +)] +use super::{Result, statement, storage}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TerminalSystem { + #[default] + None = 0, + Plonk = 1, + Groth16 = 2, +} + +impl TryFrom for TerminalSystem { + type Error = &'static str; + fn try_from(value: u8) -> std::result::Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Plonk), + 2 => Ok(Self::Groth16), + _ => Err("unknown terminal proof system"), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TerminalOptions { + pub system: TerminalSystem, + pub allow_development: bool, +} + +impl From for TerminalOptions { + fn from(system: TerminalSystem) -> Self { + Self { system, allow_development: false } + } +} + +impl TryFrom for TerminalOptions { + type Error = &'static str; + fn try_from(value: u8) -> std::result::Result { + if value == 3 { + Ok(Self { system: TerminalSystem::Groth16, allow_development: true }) + } else { + Ok(TerminalSystem::try_from(value)?.into()) + } + } +} + +pub(super) struct Scratch(pub(super) PathBuf); +impl Scratch { + pub(super) fn new() -> Result { + let path = + storage::temporary_path(&std::env::temp_dir().join("ix-terminal")); + fs::create_dir(&path)?; + Ok(Self(path)) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +pub(super) fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(output, "{byte:02x}").unwrap(); + } + output +} + +pub(super) fn digest(text: &str) -> Result<[u8; 32]> { + let text = text.strip_prefix("0x").unwrap_or(text); + if text.len() != 64 || !text.is_ascii() { + return Err("expected a 32-byte hexadecimal digest".into()); + } + let mut bytes = [0; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * index..2 * index + 2], 16)?; + } + Ok(bytes) +} + +pub(super) fn sha256(path: &Path) -> Result<[u8; 32]> { + let mut file = fs::File::open(path)?; + let mut hash = Sha256::new(); + let mut buffer = vec![0; 65536]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Ok(hash.finalize().into()) +} + +pub(super) fn load_json( + path: &Path, +) -> Result { + let mut bytes = Vec::new(); + fs::File::open(path)?.take((1 << 20) + 1).read_to_end(&mut bytes)?; + if bytes.len() > 1 << 20 { + return Err("terminal metadata exceeds its size limit".into()); + } + Ok(serde_json::from_slice(&bytes)?) +} + +pub(super) fn helper_executable(variable: &str) -> Result { + let path = if let Some(path) = std::env::var_os(variable) { + PathBuf::from(path) + } else if let Some(path) = std::env::var_os("IX_PLONK_HELPER") { + PathBuf::from(path) + } else { + std::env::current_exe()?.with_file_name("ix-bn254-plonk-wrapper") + }; + if !path.is_file() { + return Err( + format!("gnark terminal helper is missing; set {variable}").into(), + ); + } + Ok(path) +} + +pub(super) fn checked(command: &mut Command) -> Result<()> { + let status = command + .stdout(Stdio::from(std::io::stderr())) + .stderr(Stdio::inherit()) + .status()?; + if !status.success() { + return Err(format!("terminal helper failed ({status})").into()); + } + Ok(()) +} + +pub(super) fn claim_digest( + expected: &statement::Statement, +) -> Result<[u8; 32]> { + let mut result = [0; 32]; + for (chunk, value) in + result.as_chunks_mut::<4>().0.iter_mut().zip(&expected[10..]) + { + *chunk = u32::try_from(*value)?.to_le_bytes(); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_selection_keeps_development_explicit() { + assert_eq!( + TerminalOptions::try_from(0).unwrap().system, + TerminalSystem::None + ); + assert_eq!( + TerminalOptions::try_from(1).unwrap().system, + TerminalSystem::Plonk + ); + let production = TerminalOptions::try_from(2).unwrap(); + let development = TerminalOptions::try_from(3).unwrap(); + assert_eq!(production.system, TerminalSystem::Groth16); + assert!(!production.allow_development); + assert!(development.allow_development); + assert_eq!(development.system, TerminalSystem::Groth16); + assert!(TerminalOptions::try_from(4).is_err()); + } +} diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index ba8e1d010..ed4f99ec7 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -1,7 +1,9 @@ mod outer; mod saved; +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub(super) use outer::plonk_export; #[cfg(feature = "plonk")] -pub(super) use outer::{plonk_export, plonk_export_first}; +pub(super) use outer::plonk_export_first; /// Device bytes the recursive prover keeps resident per device unless /// `IX_KZG_CUDA_RESIDENT_GIB` overrides it. Its traces are the largest diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 315f62272..32208a3ee 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -1,13 +1,13 @@ //! Recursive KZG wrapper: staging and proving in one process, and the //! portable verifier its packets are checked with. -#[cfg(feature = "plonk")] +#[cfg(any(feature = "plonk", feature = "groth16"))] mod plonk_export; -#[cfg(feature = "plonk")] +#[cfg(any(feature = "plonk", feature = "groth16"))] mod relation; +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub(in crate::pipeline) use plonk_export::export as plonk_export; #[cfg(feature = "plonk")] -pub(in crate::pipeline) use plonk_export::{ - export as plonk_export, export_first as plonk_export_first, -}; +pub(in crate::pipeline) use plonk_export::export_first as plonk_export_first; use super::super::stages::{ construct::{self, Lowered}, @@ -207,7 +207,7 @@ fn packet_body<'a>( Ok(&packet[offset..]) } -#[cfg(any(test, feature = "plonk"))] +#[cfg(any(test, feature = "plonk", feature = "groth16"))] fn packet_claims( packet: &[u8], vk: &[u8; 32], @@ -668,7 +668,7 @@ mod tests { } #[test] - #[cfg(feature = "plonk")] + #[cfg(any(feature = "plonk", feature = "groth16"))] fn plonk_recursive_export_includes_anchor_and_deferred_publics() -> storage::Result<()> { let (_, vk, packet) = ignition_vk_fixture()?; diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index 0282563a9..f37b2c339 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -1,4 +1,4 @@ -//! Canonical verifier programs and witnesses for terminal PLONK circuits. +//! Canonical verifier programs and witnesses for terminal circuits. use super::relation::{codegen, *}; use super::*; use crate::kzg::curve::G1Affine; @@ -47,7 +47,7 @@ pub(in crate::pipeline) fn export( let setup = super::super::super::setup::SetupConfig::from_env(false)?; let admission = setup .ignition_admission() - .ok_or("PLONK export requires admitted Ignition parameters")?; + .ok_or("terminal export requires admitted Ignition parameters")?; vk.check_ignition_admission(admission)?; } write_export(&vk, &fs::read(packet)?, expected, directory, "witness", true) @@ -163,6 +163,7 @@ pub(super) fn write_export( /// Export the relation verifying a saved first KZG stage directly: every /// partition's circuit, the first-stage claim rows and no deferred pairing. /// The stage's setup is selected from the environment as the worker does. +#[cfg(feature = "plonk")] pub(in crate::pipeline) fn export_first( stage: &Path, expected: &Statement, diff --git a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs index 20bc5203a..4ab26f9bb 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/relation.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs @@ -196,7 +196,7 @@ impl GraphProgram { Ok(program) } - #[cfg(feature = "plonk")] + #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) fn evaluate + Copy + From>( &self, opening: impl Fn(usize, usize) -> A, diff --git a/crates/proof-compression/tests/fixtures/groth16/proof.bin b/crates/proof-compression/tests/fixtures/groth16/proof.bin new file mode 100644 index 0000000000000000000000000000000000000000..5678e0330f47ef010b54907e87c1fbb0990dbba1 GIT binary patch literal 196 zcmV;#06YJeLRLUM!Kha~l%!3@>aUz7m@Wr&^-KwHSbN`v%gn(kmX zLlAbFs<9;OV&cX(;jSWawAWVgRz7voXL}0dGcjOo-(+5> zrT-9%Gr_Ef3Hanu{!Nu$MfRX96!T7B0jG@`x)-4U0004@K5BsAY?;mapRKhs)5jO$I-sW+5*_;Dj*I$A(&wp+^VgJA3c literal 0 HcmV?d00001 diff --git a/crates/proof-compression/tests/fixtures/groth16/provenance.json b/crates/proof-compression/tests/fixtures/groth16/provenance.json new file mode 100644 index 000000000..ca0152605 --- /dev/null +++ b/crates/proof-compression/tests/fixtures/groth16/provenance.json @@ -0,0 +1,29 @@ +{ + "scope": "development Groth16 proof of the actual Init recursive KZG verifier fixture", + "setup": "development", + "curve": "bn254", + "claim_digest": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", + "constraints": 4340068, + "domain": 8388608, + "commitments": 1, + "inner_schema_sha256": "b86e203bcc2dcd87a67c1e325e24328e12534676ada7ea9602ac319568139c29", + "circuit_sha256": "03b7949ca0e45fdb82c583a0c313d1eb8787ced42d74ad1ec1d81cce3b16a6cd", + "gnark": "262cf453c107d147a228b6b7418c5cf791c3b79b", + "files": { + "proof.bin": "5759441e1acfc5c86d7bdd33699a2d93aa21983b7b2c71f5971b9a0437a9a56a", + "verifying-key.bin": "0bc6191175529fad61a4c27909d22928a72215b03fbac8b36d17e1ee7cb39e9a" + }, + "inner_vk_id": "0x28fc7d40d30aa21f1db718b93a4f471241a02f6c6cfc58989abc5f92a9b8d258", + "statement_prefix": [ + 0, + 293, + 3008464260, + 3936129970, + 1088962797, + 1141056675, + 2347219926, + 1176003176, + 683533119, + 3085659920 + ] +} diff --git a/crates/proof-compression/tests/fixtures/groth16/verifying-key.bin b/crates/proof-compression/tests/fixtures/groth16/verifying-key.bin new file mode 100644 index 0000000000000000000000000000000000000000..7db88d4e8413e50b2e48c9f90cc1e651f520fa95 GIT binary patch literal 560 zcmV-00?+;779q%XW)rzdD?PSr=ftXC*>_N0PYVzf_ zGYj1aqKC9E`&S(~V0hu&Y_Yl&Eyc<1jIFkfCOn^W`MQ}FE4RE1 z+gwRqCpZo058f$f14I(Bo#~uZ#0Ek~^g7LKhAnAgeWF=!rKUuQOuAGKg2yX5 zo|#~(kLxJ?WZ>I-*8ab1Z+ls9Yh$(PmwSI>==e(c(F^f!a~>hP_;E*BSAN^$8zVPo zo|KgWP_*2NLxF000D~(!YHvj;5;H@)6}0 zD1)Qn9dU$;FQ>W|q^4Bt>~c@W1a*i}knxQF%z+>6yJ-y-%p1b>J8;Lje+W#y2GqO3 ze4JKfgU>cLWulN@?QlOz-wiA#gvCn5(YbJ#2EI|9Kr@=FUr#_5Nc8YQz}YW%OW&YE zaV#ft)mD%~g?KUm00031000000005QRu9irT-P~Uwk{BPjr&-?9-$PIN8ts_ae2UGIb-2q#q@vRa%u_dOl5@~3&v(v#0wC8+NXzM return #["plonk"] + | "bn254" => return #["plonk", "groth16"] | other => throw <| IO.userError s!"IX_KZG_CURVE must be bn254, got {other}" /-- Rust feature selection shared by compilation and native runtime linkage. -/ @@ -107,7 +107,7 @@ def usesCuda (features : Array String) : Bool := /-- Worker features follow the CLI's curve while omitting CLI-only features. -/ def compressionWorkerArgs (features : Array String) : Array String := Id.run do let mut selected := features.filter fun name => - #["plonk", "parallel"].contains name + #["plonk", "groth16", "parallel"].contains name if usesCuda features then selected := selected.push "cuda" return #["build", "--release", "-p", "ix-proof-compression", "--bin", "ix-compression-worker", "--no-default-features", @@ -229,21 +229,25 @@ def compressionHelperArgs (output : FilePath) : Array String := #["build", "-buildvcs=false", "-trimpath", "-mod=readonly"] ++ #["-o", output.toString, "."] +private def compressionCudaFlag (name : String) : IO Bool := do + match (← IO.getEnv name).getD "0" with + | "1" | "true" | "yes" => pure true + | "0" | "false" | "no" | "" => pure false + | _ => throw <| IO.userError s!"{name} must be 0/1, false/true or no/yes" + /-- PLONK arithmetic selection is independent of the inner KZG CUDA backend. -/ -def compressionHelperCuda : IO Bool := do - let value := (← IO.getEnv "IX_PLONK_CUDA").getD "0" - let enabled ← match value with - | "1" | "true" | "yes" => pure true - | "0" | "false" | "no" | "" => pure false - | _ => throw <| IO.userError "IX_PLONK_CUDA must be 0/1, false/true or no/yes" - return enabled +def compressionHelperCuda : IO Bool := compressionCudaFlag "IX_PLONK_CUDA" + +/-- Groth16 uses the pinned ICICLE backend when explicitly enabled. -/ +def compressionGroth16Cuda : IO Bool := compressionCudaFlag "IX_GROTH16_CUDA" def plonkCudaLibraryName := "libix_plonk_bn254_cuda.so" target ix_plonk_wrapper pkg : Array FilePath := do let features ← compressionFeatures let cuda ← compressionHelperCuda - unless features.contains "plonk" do + let groth16Cuda ← compressionGroth16Cuda + unless features.contains "plonk" || features.contains "groth16" do return pure #[] let mut sources ← inputDir (pkg.dir / "tools/plonk-wrapper") false fun path => path.extension == some "go" || path.extension == some "json" || @@ -265,13 +269,13 @@ target ix_plonk_wrapper pkg : Array FilePath := do let native ← inputBinFile ((FilePath.mk path).parent.get!.parent.get! / "lib" / plonkCudaLibraryName) sources := sources.zipWith (fun paths _ => paths) native let environment ← #["IX_PLONK_CUDA_ARCHS", "IX_PLONK_SPPARK_SOURCE", "IX_PLONK_BLST_SOURCE", - "NVCC", "CUDA_HOME", "NVCC_CCBIN", "CC", "CXX"].mapM fun name => + "NVCC", "CUDA_HOME", "NVCC_CCBIN", "CC", "CXX", "IX_GROTH16_ICICLE_ROOT"].mapM fun name => return (name, ← IO.getEnv name) sources.mapM fun _ => do let compiler ← match prebuilt with | some path => pure path | none => (·.stdout) <$> captureProc' { cmd := "go", args := #["version"] } - addPureTrace (prebuilt, compiler, features, cuda, environment) s!"PLONK helper compiler: {compiler}" + addPureTrace (prebuilt, compiler, features, cuda, groth16Cuda, environment) s!"PLONK helper compiler: {compiler}" buildUnlessUpToDate outputs (← getTrace) (output.addExtension "trace") do match prebuilt with | some path => @@ -287,11 +291,24 @@ target ix_plonk_wrapper pkg : Array FilePath := do | error "IX_PLONK_SPPARK_SOURCE must name the pinned sppark source tree" let some blst ← IO.getEnv "IX_PLONK_BLST_SOURCE" | error "IX_PLONK_BLST_SOURCE must name the pinned blst source tree" + let grothArgs ← if groth16Cuda then do + let some icicle ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" + | error "IX_GROTH16_ICICLE_ROOT must name the pinned ICICLE v3.2.2 installation" + pure #["--groth16-icicle-root", icicle] + else pure #[] proc { cmd := "python3" args := #["tools/plonk-wrapper/build_cuda.py", "--out", temporary.toString, "--build-dir", (pkg.buildDir / "plonk-cuda").toString, - "--sppark", sppark, "--blst", blst] + "--sppark", sppark, "--blst", blst] ++ grothArgs + cwd := pkg.dir + } + else if groth16Cuda then + let some icicle ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" + | error "IX_GROTH16_ICICLE_ROOT must name the pinned ICICLE v3.2.2 installation" + proc { + cmd := "python3" + args := #["tools/plonk-wrapper/build_groth16_cuda.py", "--out", temporary.toString, "--icicle-root", icicle] cwd := pkg.dir } else diff --git a/tests/check_compression_build.py b/tests/check_compression_build.py index 1a5bc1b5a..bf65b75d8 100644 --- a/tests/check_compression_build.py +++ b/tests/check_compression_build.py @@ -46,10 +46,14 @@ def main(): ('plonk-cuda-only', {'IX_KZG_CURVE': 'bn254', 'IX_PLONK_CUDA': '1'}, 'bn254', False, True), ('both-cuda', {'IX_KZG_CURVE': 'bn254', 'IX_PLONK_CUDA': '1', 'IX_CUDA': '1'}, 'bn254', True, True), ] + cases += [ + ('groth16-cuda-only', {'IX_GROTH16_CUDA': '1'}, 'bn254', False, True), + ('all-cuda', {'IX_GROTH16_CUDA': '1', 'IX_PLONK_CUDA': '1', 'IX_CUDA': '1'}, 'bn254', True, True), + ] report = {'lean': version, 'typecheck_passed': True, 'cases': []} for name, selection, curve, cuda, parallel in cases: environment = dict(os.environ, LEAN_PATH=str(out)) - for variable in ['IX_KZG_CURVE', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR', 'IX_PLONK_CUDA']: + for variable in ['IX_KZG_CURVE', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR', 'IX_PLONK_CUDA', 'IX_GROTH16_CUDA']: environment.pop(variable, None) environment.update(selection) result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], @@ -58,7 +62,7 @@ def main(): if result.returncode: raise RuntimeError(f'configuration evaluation failed: {name}') actual = json.loads(result.stdout.splitlines()[-1]) - required = {'plonk'} + required = {'plonk', 'groth16'} required |= {'parallel'} if parallel else set() worker = required | ({'cuda'} if cuda else set()) ffi = required | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) @@ -75,6 +79,10 @@ def main(): raise RuntimeError(f'{name}: unexpected {target} features: {arguments}') if actual['worker'][actual['worker'].index('--bin') + 1] != 'ix-compression-worker': raise RuntimeError(f'{name}: wrong worker executable') + if actual['terminal_groth16'] is not True: + raise RuntimeError(f'{name}: Groth16 terminal is missing') + if actual['groth16_cuda'] != (selection.get('IX_GROTH16_CUDA') == '1'): + raise RuntimeError(f'{name}: Groth16 CUDA selection depends on another backend') if actual['terminal_plonk'] is not True: raise RuntimeError(f'{name}: wrong terminal backend selection') if actual['plonk_cuda'] != (selection.get('IX_PLONK_CUDA') == '1'): diff --git a/tests/compression_build_config.lean b/tests/compression_build_config.lean index ade825905..ce954d9ed 100644 --- a/tests/compression_build_config.lean +++ b/tests/compression_build_config.lean @@ -11,6 +11,8 @@ def main : IO Unit := do ("test", Lean.toJson test), ("worker", Lean.toJson (compressionWorkerArgs features)), ("terminal_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), + ("terminal_groth16", Lean.toJson ((← compressionFeatures).contains "groth16")), + ("groth16_cuda", Lean.toJson (← compressionGroth16Cuda)), ("plonk_helper", Lean.toJson (compressionHelperName)), ("plonk_cuda", Lean.toJson (← compressionHelperCuda)), ("plonk_build", Lean.toJson (compressionHelperArgs "helper-output")) diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index 1589279cd..5b9f4a40c 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -206,3 +206,188 @@ Solidity proof sizes are 584, 624, and 864 bytes. See the [benchmark comparison](../../docs/proof-benchmark-comparison.md) for hardware, timings, and measurement boundaries, and the [contract guide](../../contracts/plonk/README.md) for on-chain validation. + +## BN254 Groth16 terminal + +The helper also implements `groth16` over the same KZG verifier circuit and +witness schema. It compiles R1CS instead of PLONK's sparse constraints. The two +public inputs remain the little-endian 128-bit halves of the expected BLAKE3 +claim digest. The circuit retains the outer and deferred KZG pairing checks. + +The checked-in Init fixture compiles to 4,340,068 R1CS constraints, a `2^23` +QAP domain, and one commitment. Its native proof is 196 bytes, its Ix envelope +is 236 bytes, and its uncompressed Solidity payload is 384 bytes. The extra +commitment and its proof are part of the verified encoding. Development +verifying keys and a proof of this fixture are retained in +`crates/proof-compression/tests/fixtures/groth16`; they are not production keys. + +### Preparing and using development keys + +Export an already verified recursive KZG packet with the existing shared +exporter, then compile the Groth16 circuit: + +```sh +ix-compression-worker groth16 export verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input +ix-bn254-plonk-wrapper groth16 compile input/vk.json groth16-keys +ix-bn254-plonk-wrapper groth16 setup-development groth16-keys +ix-bn254-plonk-wrapper groth16 prove groth16-keys input/witness.json EXPECTED_CLAIM_DIGEST proof +``` + +`TRUSTED_VERIFIER_BLAKE3` authenticates the serialized KZG verifier file. +`EXPECTED_CLAIM_DIGEST` is obtained independently of the witness. The exporter +requires admitted Ignition parameters for the inner KZG proof; local Groth16 +setup does not change the inner setup policy. Explicit compilation checks a +fresh circuit digest before reusing an existing artifact directory. + +The CLI uses prepared keys and an independently approved catalog: + +```json +{ + "format": "ix-bn254-groth16-verifiers-v1", + "keys": [{ + "sha256": "SHA256_OF_GROTH16_VERIFYING_KEY", + "path": "groth16-keys/verifying-key.bin", + "inner_vk_id": "VK_ID_FROM_EXPORTED_vkId", + "inner_schema_sha256": "SHA256_OF_EXPORTED_vk.json", + "statement_prefix": [0, 293, 0, 0, 0, 0, 0, 0, 0, 0], + "circuit_sha256": "SHA256_OF_circuit.bin", + "setup": "development" + }] +} +``` + +The hashes above are placeholders. Copy the complete ten-word `prefix` from +the exported verifier description; the example prefix is not an approved +program. `inner_vk_id` is the description's `vkId`, which differs from the +BLAKE3 digest of the serialized verifier file. Relative key paths resolve against +the catalog directory. A proof or generated manifest never authorizes its own +verifying key. + +```sh +export IX_GROTH16_ARTIFACTS=/path/to/groth16-keys +export IX_GROTH16_VERIFIER_CATALOG=/path/to/approved-catalog.json +ix compress ROOT --groth16 --dev-groth16 +ix verify COMPRESSED_PROOF --dev-srs +``` + +`--dev-groth16` permits development outer keys and requires `--groth16`. +`--dev-srs` on verification is the existing permission for development proofs. +A saved recursive packet can be wrapped directly, avoiding earlier stages: + +```sh +ix-compression-worker groth16 wrap verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json output --allow-development +ix-compression-worker groth16 verify output/proof.envelope.bin claim.bin --allow-development +``` + +Groth16 uses envelope tag `IXBN\0\0\0\x03`, followed by the 32-byte approved +verifying-key SHA-256 and canonical native proof. Verification is CPU-only and +requires neither proving parameters nor a visible GPU. + +### Ceremony verification and key import + +Production keys require a Groth16-compatible phase-one setup and a +circuit-specific phase two. Ignition's KZG cache is not a Groth16 setup. + +`groth16 setup-mpc ` verifies each +phase-one and phase-two contribution against its predecessor, binds phase two +to the R1CS digest, applies the recorded beacons, and writes the resulting keys. +The descriptor includes the curve, domain, circuit digest, two 32-byte beacons, +and contribution paths with byte counts and SHA-256 receipts. Its exact schema +is defined by `groth16Ceremony` in `groth16_setup.go`. + +`groth16 import ` imports an already +reviewed key bundle. The release binds both keys, the circuit and verifier-schema +hashes, the commitment hash, and the ceremony digest. Import relies on the +independently trusted release digest; it does not re-run the contribution chain. + +Production catalog entries use `"setup": "mpc"` and a `ceremony_sha256` field. +The circuit, setup transcript provenance, and resulting key must be independently +admitted before adding that entry. Contributions generated by one local test +process are useful tests of the setup machinery, not a production ceremony. + +### CUDA + +Groth16 uses stock gnark's BN254 ICICLE implementation, pinned to +`icicle-gnark` v3.2.2 (`b62bbbe518a73214da10ece26969ad55e6fa0cd0`). It accelerates +G1/G2 MSMs and QAP polynomial work. CPU and CUDA use the same serialized keys, +proof format, SHA-256 commitment hash, and CPU verifier. + +```sh +nix build .#groth16-wrapper-cuda +export IX_GROTH16_HELPER="$PWD/result/bin/ix-bn254-plonk-wrapper" +export IX_GROTH16_BACKEND=cuda +export IX_GROTH16_DEVICE=0 +export IX_GROTH16_PIN_KEY=1 +``` + +`IX_GROTH16_PIN_KEY` defaults to `0`, streaming key vectors as needed. Set it to +`1` only when persistent key vectors and working memory fit on the selected GPU. +Each prover handle stays on one device; operations and cleanup are serialized +per device. The helper does not warm other GPUs or silently fall back to CPU. + +For a manual ICICLE installation, build only BN254 with MSM, NTT and G2 enabled, +and use the native source revision above. Then: + +```sh +python3 tools/plonk-wrapper/build_groth16_cuda.py --out /path/to/bin/ix-bn254-plonk-wrapper --icicle-root /persistent/icicle-installation +``` + +The installation remains a runtime dependency. `IX_GROTH16_ICICLE_ROOT` supplies +it to Lake when `IX_GROTH16_CUDA=1`. The terminal CUDA choice is independent of +`IX_CUDA` and `IX_PLONK_CUDA`. `.#terminal-wrapper-cuda` includes both terminal +accelerators; `.#icicle-bn254` builds the native dependency alone. These CUDA +packages target Linux x86-64 and SM 120. CPU-only builds require no CUDA libraries. + +### Solidity and tests + +`Groth16Verifier.sol` is generated from the exact verifying key. The adapter in +`contracts/groth16/src/IxGroth16Verifier.sol` fixes its address and code hash, +converts the independently expected digest, and returns false for malformed +proofs. Verification calls have a bounded gas budget. The contract fixtures +cover plain Groth16, commitment-augmented Groth16, and the actual Init wrapper. +The commitment hash must remain SHA-256 in proving, native verification and +Solidity export. + +```sh +go test -run '^TestGroth16(BackendInterop|BackendSelection|MPCAndAdmission)$' . +IX_GROTH16_CUDA_TEST=1 go test -tags icicle -run '^TestGroth16CUDAInterop$' . +cd contracts/groth16 && forge test +``` + +The full CPU fixture check is opt-in with `IX_GROTH16_WRAPPER_TEST=1` and +`IX_GROTH16_WRAPPER_PROVE=1`; it includes local setup and takes several minutes. +The matched performance harness is `TestBN254TerminalComparison`. It accepts +`IX_TERMINAL_COMPARE=1`, `IX_TERMINAL_COMPARE_OUT`, +`IX_TERMINAL_GROTH16_ARTIFACTS`, and optionally +`IX_TERMINAL_PLONK_ARTIFACTS` plus `IX_TERMINAL_PLONK_MANIFEST_SHA256` to reuse +an authenticated PLONK cache for the identical verifier schema. Select systems +and devices with `IX_TERMINAL_COMPARE_SYSTEMS` and +`IX_TERMINAL_COMPARE_DEVICES`; `IX_TERMINAL_COMPARE_REPEATS` defaults to three. +It separates opening the retained key/circuit, the first proof, warm proofs, +and verification. Proving includes gnark's witness solving; earlier Ix/KZG +stages and witness export are excluded. Compare CPU and GPU proofs by acceptance +and statement, not by bytes, because proofs are randomized. + +### Matched Init terminal measurements + +Measured on 2026-10-29 with Go 1.26.8, 32 logical CPUs and an RTX PRO 6000 +Blackwell Server Edition. Both systems use exactly the same checked-in Init +verifier description and witness. Values are medians of three warm proofs in +separate processes at default parallelism; no other GPU compute process was +present when either CUDA run started. + +| Backend | CPU proving | CUDA proving | Native proof | Ix envelope | Solidity payload | +| --- | ---: | ---: | ---: | ---: | ---: | +| Groth16 | 7.35 s | 2.61 s | 196 B | 236 B | 384 B | +| PLONK | 66.07 s | 11.59 s | 584 B | 624 B | 864 B | + +Groth16 CUDA uses ICICLE with pinned key vectors; PLONK CUDA uses the existing +sppark adapter and retained bases. Proving includes gnark witness solving. +Opening the key/circuit takes about 27 seconds for Groth16 and 22 seconds for +PLONK, separately from the table. Earlier compression stages and witness export +are excluded. Groth16 uses development keys; PLONK reuses its pinned Ignition +cache. These are one-workload measurements, not predictions for every circuit. +Raw samples, file identities, opening times and peak process RSS are recorded +in `testdata/terminal-comparison.json`. The actual Groth16 Init proof also passes +the Solidity adapter test, at 318,490 execution gas excluding transaction +intrinsic gas and calldata. diff --git a/tools/plonk-wrapper/build_cuda.py b/tools/plonk-wrapper/build_cuda.py index 2ab7378cb..2ef4524cd 100644 --- a/tools/plonk-wrapper/build_cuda.py +++ b/tools/plonk-wrapper/build_cuda.py @@ -9,6 +9,7 @@ import os from pathlib import Path import shutil +import shlex import subprocess import sys import tempfile @@ -36,6 +37,7 @@ def main(): parser.add_argument("--test-binary", action="store_true") parser.add_argument("--package", choices=[".", "./internal/cuda"], default=".") parser.add_argument("--reuse-native", action="store_true") + parser.add_argument("--groth16-icicle-root", type=Path) args = parser.parse_args() output = args.out.resolve() build = args.build_dir.resolve() @@ -98,8 +100,17 @@ def build_helper(args, output, build): + f" -L{library_dir} -Wl,-rpath,$ORIGIN/../lib -lix_plonk_bn254_cuda").strip() command = [args.go, "test", "-c"] if args.test_binary else [args.go, "build"] temporary_output = output.with_suffix(output.suffix + ".tmp") + tags = "cuda" + if args.groth16_icicle_root: + icicle = args.groth16_icicle_root.resolve(strict=True) / "lib" + for name in ["libicicle_device.so", "libicicle_field_bn254.so", "libicicle_curve_bn254.so"]: + if not (icicle / name).is_file(): + raise ValueError(f"missing ICICLE v3.2.2 library: {icicle / name}") + environment["CGO_LDFLAGS"] += f" -L{shlex.quote(str(icicle))} -Wl,-rpath,{shlex.quote(str(icicle))}" + command += ["-ldflags", "-X " + shlex.quote("main.groth16LibraryDefault=" + str(icicle / "backend"))] + tags += ",icicle" command += ["-buildvcs=false", "-trimpath", "-mod=readonly", - "-tags", "cuda", + "-tags", tags, "-o", str(temporary_output), args.package] run(command, cwd=HERE, env=environment) temporary_output.replace(output) diff --git a/tools/plonk-wrapper/build_groth16_cuda.py b/tools/plonk-wrapper/build_groth16_cuda.py new file mode 100644 index 000000000..09298e2bb --- /dev/null +++ b/tools/plonk-wrapper/build_groth16_cuda.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Build the terminal helper with BN254 ICICLE acceleration from an explicit installation.""" + +import argparse +import os +from pathlib import Path +import shlex +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--icicle-root", required=True, type=Path) + parser.add_argument("--go", default=os.environ.get("GO", "go")) + parser.add_argument("--plonk-cuda", action="store_true") + args = parser.parse_args() + root = args.icicle_root.resolve(strict=True) + library = root / "lib" + for name in ["libicicle_device.so", "libicicle_field_bn254.so", "libicicle_curve_bn254.so"]: + if not (library / name).is_file(): + parser.error(f"missing ICICLE v3.2.2 library: {library / name}") + backend = library / "backend" + if not backend.is_dir(): + parser.error("the ICICLE installation has no backend directory") + output = args.out.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + env = dict(os.environ, CGO_ENABLED="1", GOTOOLCHAIN="local") + env["CGO_LDFLAGS"] = " ".join(filter(None, [env.get("CGO_LDFLAGS", ""), + f"-L{shlex.quote(str(library))} -Wl,-rpath,{shlex.quote(str(library))}"])) + tags = "icicle,cuda" if args.plonk_cuda else "icicle" + linker = "-X " + shlex.quote("main.groth16LibraryDefault=" + str(backend)) + subprocess.run([args.go, "build", "-buildvcs=false", "-trimpath", "-mod=readonly", + "-tags", tags, "-ldflags", linker, "-o", str(output), "."], + cwd=Path(__file__).resolve().parent, env=env, check=True) + + +if __name__ == "__main__": + main() diff --git a/tools/plonk-wrapper/go.mod b/tools/plonk-wrapper/go.mod index e9c0f3c44..d13999f5c 100644 --- a/tools/plonk-wrapper/go.mod +++ b/tools/plonk-wrapper/go.mod @@ -5,6 +5,7 @@ go 1.26.8 require ( github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107 github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b + github.com/ingonyama-zk/icicle-gnark/v3 v3.2.2 github.com/zeebo/blake3 v0.2.4 ) diff --git a/tools/plonk-wrapper/go.sum b/tools/plonk-wrapper/go.sum index 32dcd1fc5..6ab36002c 100644 --- a/tools/plonk-wrapper/go.sum +++ b/tools/plonk-wrapper/go.sum @@ -12,6 +12,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/ingonyama-zk/icicle-gnark/v3 v3.2.2 h1:B+aWVgAx+GlFLhtYjIaF0uGjU3rzpl99Wf9wZWt+Mq8= +github.com/ingonyama-zk/icicle-gnark/v3 v3.2.2/go.mod h1:CH/cwcr21pPWH+9GtK/PFaa4OGTv4CtfkCKro6GpbRE= github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= diff --git a/tools/plonk-wrapper/groth16.go b/tools/plonk-wrapper/groth16.go new file mode 100644 index 000000000..718482b7e --- /dev/null +++ b/tools/plonk-wrapper/groth16.go @@ -0,0 +1,557 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/groth16" + g16 "github.com/consensys/gnark/backend/groth16/bn254" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/backend/witness" + "github.com/consensys/gnark/constraint" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/r1cs" +) + +const groth16ArtifactFormat = "ix-bn254-groth16-artifacts-v1" +const groth16MaxProofBytes = 196 + +func decodeGroth16JSON(data []byte, out any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("trailing Groth16 JSON data") + } + return nil +} + +type groth16Manifest struct { + Format string `json:"format"` + Curve string `json:"curve"` + InnerVK string `json:"innerVKSHA256"` + CommitmentHash string `json:"commitmentHash"` + Constraints int `json:"constraints"` + Domain uint64 `json:"domain"` + PublicInputs int `json:"publicInputs"` + Commitments int `json:"commitments"` + Setup string `json:"setup"` + Ceremony string `json:"ceremonySHA256,omitempty"` + KeysReady bool `json:"keysReady"` + Files map[string]artifact `json:"files"` +} + +func groth16Shape(ccs constraint.ConstraintSystem) (uint64, int, error) { + commitments := len(ccs.GetCommitments().CommitmentIndexes()) + if ccs.Field().Cmp(curveID.ScalarField()) != 0 || ccs.GetNbPublicVariables() != 3 { + return 0, 0, fmt.Errorf("Groth16 requires BN254 and two public digest limbs plus the implicit one") + } + if ccs.GetNbConstraints() < 1 || ccs.GetNbConstraints() > 1<<27 || commitments > 1 { + return 0, 0, fmt.Errorf("Groth16 circuit exceeds the supported domain or Solidity commitment count") + } + return ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints())), commitments, nil +} + +func (m *groth16Manifest) validate() error { + if m.Format != groth16ArtifactFormat || m.Curve != "bn254" || m.CommitmentHash != "sha256" || + m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<27 || + m.Domain != ecc.NextPowerOfTwo(uint64(m.Constraints)) || m.Commitments < 0 || m.Commitments > 1 || m.Files == nil { + return fmt.Errorf("invalid Groth16 artifact manifest") + } + if _, err := unhex(m.InnerVK, 32); err != nil { + return err + } + if m.KeysReady { + switch m.Setup { + case "development": + if m.Ceremony != "" { + return fmt.Errorf("development keys cannot claim an MPC receipt") + } + case "mpc": + if _, err := unhex(m.Ceremony, 32); err != nil { + return err + } + default: + return fmt.Errorf("unknown Groth16 setup class") + } + } else if m.Setup != "" || m.Ceremony != "" { + return fmt.Errorf("unprepared Groth16 circuit carries setup metadata") + } + return nil +} + +func (m *groth16Manifest) checkFile(dir, name string) error { + want, ok := m.Files[name] + if !ok || want.Bytes < 1 { + return fmt.Errorf("missing Groth16 artifact %s", name) + } + if _, err := unhex(want.SHA256, 32); err != nil { + return err + } + got, err := fileHash(filepath.Join(dir, name)) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("Groth16 artifact %s differs from its receipt", name) + } + return nil +} + +func groth16Load(dir string) (*verifyingKey, constraint.ConstraintSystem, *groth16Manifest, error) { + var m groth16Manifest + if err := decodeJSON(filepath.Join(dir, "manifest.json"), &m); err != nil { + return nil, nil, nil, err + } + if err := m.validate(); err != nil { + return nil, nil, nil, err + } + for _, name := range []string{"vk.json", "circuit.bin"} { + if err := m.checkFile(dir, name); err != nil { + return nil, nil, nil, err + } + } + if m.Files["vk.json"].SHA256 != m.InnerVK { + return nil, nil, nil, fmt.Errorf("Groth16 inner verifier identity differs") + } + p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) + if err != nil { + return nil, nil, nil, err + } + ccs := groth16.NewCS(curveID) + if err := readBinary(filepath.Join(dir, "circuit.bin"), ccs); err != nil { + return nil, nil, nil, err + } + domain, commitments, err := groth16Shape(ccs) + if err != nil { + return nil, nil, nil, err + } + if domain != m.Domain || commitments != m.Commitments || ccs.GetNbConstraints() != m.Constraints { + return nil, nil, nil, fmt.Errorf("Groth16 circuit dimensions differ") + } + return p, ccs, &m, nil +} + +func groth16Compile(path, dir string) error { + p, err := loadVerifyingKey(path) + if err != nil { + return err + } + identity, err := fileHash(path) + if err != nil { + return err + } + var existing *groth16Manifest + if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil { + _, _, m, err := groth16Load(dir) + if err != nil { + return err + } + if m.InnerVK != identity.SHA256 { + return fmt.Errorf("Groth16 circuit belongs to another inner verifier") + } + existing = m + } else if !os.IsNotExist(err) { + return err + } + ccs, err := frontend.Compile(curveID.ScalarField(), r1cs.NewBuilder, newCircuit(p)) + if err != nil { + return err + } + domain, commitments, err := groth16Shape(ccs) + if err != nil { + return err + } + if existing != nil { + hash := sha256.New() + if _, err := ccs.WriteTo(hash); err != nil { + return err + } + if hex.EncodeToString(hash.Sum(nil)) != existing.Files["circuit.bin"].SHA256 { + return fmt.Errorf("compiled Groth16 circuit changed; use a new artifact directory and setup") + } + return nil + } + m := groth16Manifest{Format: groth16ArtifactFormat, Curve: "bn254", InnerVK: identity.SHA256, + CommitmentHash: "sha256", Domain: domain, Constraints: ccs.GetNbConstraints(), PublicInputs: 2, + Commitments: commitments, Files: make(map[string]artifact)} + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := atomicFile(filepath.Join(dir, "vk.json"), func(w io.Writer) error { _, err := w.Write(data); return err }); err != nil { + return err + } + if err := atomicFile(filepath.Join(dir, "circuit.bin"), func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { + return err + } + for _, name := range []string{"vk.json", "circuit.bin"} { + m.Files[name], err = fileHash(filepath.Join(dir, name)) + if err != nil { + return err + } + } + return writeJSON(filepath.Join(dir, "manifest.json"), m) +} + +func groth16CheckKey(vk *g16.VerifyingKey, commitments int) error { + if commitments < 0 || commitments > 1 || len(vk.PublicAndCommitmentCommitted) != commitments || + len(vk.CommitmentKeys) != commitments || len(vk.G1.K) != 3+commitments { + return fmt.Errorf("Groth16 key has an unsupported public-input or commitment schema") + } + return nil +} + +func groth16CheckPair(pk *g16.ProvingKey, vk *g16.VerifyingKey, domain uint64) error { + if pk.Domain.Cardinality != domain || !pk.G1.Alpha.Equal(&vk.G1.Alpha) || + !pk.G1.Beta.Equal(&vk.G1.Beta) || !pk.G1.Delta.Equal(&vk.G1.Delta) || + !pk.G2.Beta.Equal(&vk.G2.Beta) || !pk.G2.Delta.Equal(&vk.G2.Delta) { + return fmt.Errorf("Groth16 proving and verifying keys differ") + } + return nil +} + +func groth16SaveKeys(dir string, m *groth16Manifest, pk groth16.ProvingKey, vk groth16.VerifyingKey, setup, ceremony string) error { + native := vk.(*g16.VerifyingKey) + if err := groth16CheckKey(native, m.Commitments); err != nil { + return err + } + if err := groth16CheckPair(pk.(*g16.ProvingKey), native, m.Domain); err != nil { + return err + } + for _, item := range []struct { + name string + value io.WriterTo + }{{"proving-key.bin", pk}, {"verifying-key.bin", vk}} { + if err := atomicFile(filepath.Join(dir, item.name), func(w io.Writer) error { _, err := item.value.WriteTo(w); return err }); err != nil { + return err + } + } + if err := atomicFile(filepath.Join(dir, "Groth16Verifier.sol"), func(w io.Writer) error { + return vk.ExportSolidity(w, solidity.WithHashToFieldFunction(sha256.New()), solidity.WithPragmaVersion("^0.8.28")) + }); err != nil { + return err + } + for _, name := range []string{"proving-key.bin", "verifying-key.bin", "Groth16Verifier.sol"} { + entry, err := fileHash(filepath.Join(dir, name)) + if err != nil { + return err + } + m.Files[name] = entry + } + m.Setup, m.Ceremony, m.KeysReady = setup, ceremony, true + if err := m.validate(); err != nil { + return err + } + return writeJSON(filepath.Join(dir, "manifest.json"), m) +} + +func groth16DevelopmentSetup(dir string) error { + _, ccs, m, err := groth16Load(dir) + if err != nil { + return err + } + if m.KeysReady { + return fmt.Errorf("Groth16 keys are already prepared") + } + pk, vk, err := groth16.Setup(ccs) + if err != nil { + return err + } + return groth16SaveKeys(dir, m, pk, vk, "development", "") +} + +type groth16Engine struct { + key groth16.ProvingKey + native *g16.ProvingKey + prove func(constraint.ConstraintSystem, witness.Witness) (groth16.Proof, error) + close func() +} + +func newGroth16Engine() (*groth16Engine, error) { + switch os.Getenv("IX_GROTH16_BACKEND") { + case "", "cpu": + pk := groth16.NewProvingKey(curveID) + return &groth16Engine{key: pk, native: pk.(*g16.ProvingKey), close: func() {}, prove: func(ccs constraint.ConstraintSystem, w witness.Witness) (groth16.Proof, error) { + return groth16.Prove(ccs, pk, w, backend.WithProverHashToFieldFunction(sha256.New())) + }}, nil + case "cuda": + return newGroth16CUDAEngine() + default: + return nil, fmt.Errorf("IX_GROTH16_BACKEND must be cpu or cuda") + } +} + +type groth16Session struct { + mu sync.Mutex + key *verifyingKey + ccs constraint.ConstraintSystem + vk *g16.VerifyingKey + engine *groth16Engine + manifest *groth16Manifest + closed, failed bool +} + +func openGroth16(dir string) (*groth16Session, error) { + p, ccs, m, err := groth16Load(dir) + if err != nil { + return nil, err + } + if !m.KeysReady { + return nil, fmt.Errorf("Groth16 keys are not prepared") + } + for _, name := range []string{"proving-key.bin", "verifying-key.bin", "Groth16Verifier.sol"} { + if err := m.checkFile(dir, name); err != nil { + return nil, err + } + } + engine, err := newGroth16Engine() + if err != nil { + return nil, err + } + vk := new(g16.VerifyingKey) + for _, item := range []struct { + name string + value io.ReaderFrom + }{{"proving-key.bin", engine.key}, {"verifying-key.bin", vk}} { + if err := readBinary(filepath.Join(dir, item.name), item.value); err != nil { + engine.close() + return nil, err + } + } + if err := groth16CheckKey(vk, m.Commitments); err != nil { + engine.close() + return nil, err + } + if err := groth16CheckPair(engine.native, vk, m.Domain); err != nil { + engine.close() + return nil, err + } + return &groth16Session{key: p, ccs: ccs, vk: vk, engine: engine, manifest: m}, nil +} + +func (p *groth16Session) close() { + p.mu.Lock() + defer p.mu.Unlock() + if !p.closed { + engine := p.engine + p.closed = true + p.engine, p.ccs, p.key, p.vk = nil, nil, nil, nil + engine.close() + } +} + +func groth16Public(digest []byte) (witness.Witness, error) { + if len(digest) != 32 { + return nil, fmt.Errorf("expected a 32-byte claim digest") + } + return frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) +} + +func (p *groth16Session) prove(assignment frontend.Circuit, expected []byte) (native, solidityBytes []byte, err error) { + p.mu.Lock() + defer p.mu.Unlock() + defer func() { + if err != nil { + p.failed = true + } + if failure := recover(); failure != nil { + p.failed = true + panic(failure) + } + }() + if p.closed || p.failed { + return nil, nil, fmt.Errorf("Groth16 prover is closed or failed") + } + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + return nil, nil, err + } + public, err := groth16Public(expected) + if err != nil { + return nil, nil, err + } + actual, err := w.Public() + if err != nil { + return nil, nil, err + } + got, err := actual.MarshalBinary() + if err != nil { + return nil, nil, err + } + want, err := public.MarshalBinary() + if err != nil { + return nil, nil, err + } + if !bytes.Equal(got, want) { + return nil, nil, fmt.Errorf("Groth16 witness claim differs from the independently expected digest") + } + proof, err := p.engine.prove(p.ccs, w) + if err != nil { + return nil, nil, err + } + if err := groth16.Verify(proof, p.vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())); err != nil { + return nil, nil, err + } + var encoded bytes.Buffer + if _, err := proof.WriteTo(&encoded); err != nil { + return nil, nil, err + } + if _, err := decodeGroth16Proof(encoded.Bytes(), p.manifest.Commitments); err != nil { + return nil, nil, err + } + return encoded.Bytes(), proof.(*g16.Proof).MarshalSolidity(), nil +} + +func decodeGroth16Proof(data []byte, commitments int) (*g16.Proof, error) { + if commitments < 0 || commitments > 1 || len(data) != 164+32*commitments { + return nil, fmt.Errorf("invalid Groth16 proof length") + } + if binary.BigEndian.Uint32(data[128:132]) != uint32(commitments) { + return nil, fmt.Errorf("Groth16 commitment count differs from the trusted key") + } + for _, offset := range []int{0, 32, 96, 132, 132 + 32*commitments} { + if !isCompressedPoint(data[offset]) { + return nil, fmt.Errorf("noncanonical Groth16 point encoding") + } + } + proof := new(g16.Proof) + n, err := proof.ReadFrom(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if n != int64(len(data)) { + return nil, fmt.Errorf("trailing Groth16 proof data") + } + if commitments == 0 && !proof.CommitmentPok.IsInfinity() { + return nil, fmt.Errorf("unexpected unused Groth16 commitment proof") + } + var canonical bytes.Buffer + if _, err := proof.WriteTo(&canonical); err != nil { + return nil, err + } + if !bytes.Equal(data, canonical.Bytes()) { + return nil, fmt.Errorf("noncanonical Groth16 proof") + } + return proof, nil +} + +func groth16Verify(keyPath, keyHash, proofPath, claim string) error { + if _, err := unhex(keyHash, 32); err != nil { + return err + } + key, err := fileHash(keyPath) + if err != nil { + return err + } + if key.Bytes > 1<<20 || key.SHA256 != keyHash { + return fmt.Errorf("Groth16 key differs from the trusted digest") + } + vk := new(g16.VerifyingKey) + if err := readBinary(keyPath, vk); err != nil { + return err + } + commitments := len(vk.PublicAndCommitmentCommitted) + if err := groth16CheckKey(vk, commitments); err != nil { + return err + } + digest, err := unhex(claim, 32) + if err != nil { + return err + } + f, err := os.Open(proofPath) + if err != nil { + return err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, groth16MaxProofBytes+1)) + if err != nil { + return err + } + proof, err := decodeGroth16Proof(data, commitments) + if err != nil { + return err + } + public, err := groth16Public(digest) + if err != nil { + return err + } + return groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())) +} + +func groth16Prove(dir, path, claim, out string) error { + expected, err := unhex(claim, 32) + if err != nil { + return err + } + p, err := openGroth16(dir) + if err != nil { + return err + } + defer p.close() + assignment, err := witnessAssignment(p.key, path) + if err != nil { + return err + } + started := time.Now() + native, payload, err := p.prove(assignment, expected) + if err != nil { + return err + } + for _, item := range []struct { + name string + data []byte + }{{"proof.bin", native}, {"proof.solidity.bin", payload}} { + if err := atomicFile(filepath.Join(out, item.name), func(w io.Writer) error { _, err := w.Write(item.data); return err }); err != nil { + return err + } + } + selected := os.Getenv("IX_GROTH16_BACKEND") + if selected == "" { + selected = "cpu" + } + return writeJSON(filepath.Join(out, "report.json"), map[string]any{ + "system": "groth16", "curve": "bn254", "backend": selected, "setup": p.manifest.Setup, + "proveVerifySeconds": time.Since(started).Seconds(), "nativeBytes": len(native), "solidityBytes": len(payload), + "claimDigest": hex.EncodeToString(expected), "verifyingKeySHA256": p.manifest.Files["verifying-key.bin"].SHA256, + }) +} + +func runGroth16(args []string) error { + if len(args) == 4 && args[0] == "setup-mpc" { + return groth16MPCSetup(args[1], args[2], args[3]) + } + if len(args) == 4 && args[0] == "import" { + return groth16Import(args[1], args[2], args[3]) + } + if len(args) == 1 && args[0] == "capabilities" { + fmt.Printf("{\"system\":\"groth16\",\"curve\":\"bn254\",\"cuda\":%t}\n", groth16CUDAAvailable()) + return nil + } + if len(args) == 3 && args[0] == "compile" { + return groth16Compile(args[1], args[2]) + } + if len(args) == 2 && args[0] == "setup-development" { + return groth16DevelopmentSetup(args[1]) + } + if len(args) == 5 && args[0] == "prove" { + return groth16Prove(args[1], args[2], args[3], args[4]) + } + if len(args) == 5 && args[0] == "verify" { + return groth16Verify(args[1], args[2], args[3], args[4]) + } + return fmt.Errorf("usage: groth16 compile | setup-development | setup-mpc | import | prove | verify | capabilities") +} diff --git a/tools/plonk-wrapper/groth16_cuda.go b/tools/plonk-wrapper/groth16_cuda.go new file mode 100644 index 000000000..5aa6c9fb9 --- /dev/null +++ b/tools/plonk-wrapper/groth16_cuda.go @@ -0,0 +1,111 @@ +//go:build icicle + +package main + +import ( + "crypto/sha256" + "fmt" + "os" + "runtime" + "strconv" + "sync" + + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/accelerated/icicle" + accelerated "github.com/consensys/gnark/backend/accelerated/icicle/groth16/bn254" + "github.com/consensys/gnark/backend/groth16" + "github.com/consensys/gnark/backend/witness" + "github.com/consensys/gnark/constraint" + cs "github.com/consensys/gnark/constraint/bn254" + cr "github.com/ingonyama-zk/icicle-gnark/v3/wrappers/golang/runtime" +) + +var groth16CUDAInit sync.Once +var groth16CUDAInitError error +var groth16CUDADevices sync.Map +var groth16LibraryDefault string + +func groth16CUDAAvailable() bool { return true } + +func onGroth16Device(device *cr.Device, call func() error) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + previous, previousStatus := cr.GetActiveDevice() + if status := cr.SetDevice(device); status != cr.Success { + return fmt.Errorf("select Groth16 CUDA device %d: %s", device.Id, status.AsString()) + } + if previousStatus == cr.Success { + defer cr.SetDevice(previous) + } + return call() +} + +func newGroth16CUDAEngine() (*groth16Engine, error) { + deviceID := 0 + if value := os.Getenv("IX_GROTH16_DEVICE"); value != "" { + id, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return nil, fmt.Errorf("invalid IX_GROTH16_DEVICE: %w", err) + } + deviceID = int(id) + } + var pin bool + switch os.Getenv("IX_GROTH16_PIN_KEY") { + case "", "0": + case "1": + pin = true + default: + return nil, fmt.Errorf("IX_GROTH16_PIN_KEY must be 0 or 1") + } + groth16CUDAInit.Do(func() { + var status cr.EIcicleError + if os.Getenv("ICICLE_BACKEND_INSTALL_DIR") == "" && groth16LibraryDefault != "" { + status = cr.LoadBackend(groth16LibraryDefault, true) + } else { + status = cr.LoadBackendFromEnvOrDefault() + } + if status != cr.Success { + groth16CUDAInitError = fmt.Errorf("load Groth16 CUDA backend: %s", status.AsString()) + } + }) + if groth16CUDAInitError != nil { + return nil, groth16CUDAInitError + } + device := cr.CreateDevice("CUDA", deviceID) + if err := onGroth16Device(&device, func() error { + _, status := cr.GetAvailableMemory() + if status != cr.Success { + return fmt.Errorf("Groth16 CUDA memory query: %s", status.AsString()) + } + return nil + }); err != nil { + return nil, err + } + value, _ := groth16CUDADevices.LoadOrStore(deviceID, new(sync.Mutex)) + lock := value.(*sync.Mutex) + pk := new(accelerated.ProvingKey) + engine := &groth16Engine{key: pk, native: &pk.ProvingKey} + engine.prove = func(ccs constraint.ConstraintSystem, w witness.Witness) (proof groth16.Proof, err error) { + lock.Lock() + defer lock.Unlock() + config, err := icicle.NewConfig(icicle.WithDeviceID(deviceID), icicle.WithPinKeysToGPU(pin), + icicle.WithProverOptions(backend.WithProverHashToFieldFunction(sha256.New()))) + if err != nil { + return nil, err + } + err = onGroth16Device(&device, func() error { + var failure error + proof, failure = accelerated.Prove(ccs.(*cs.R1CS), pk, w, config) + return failure + }) + return proof, err + } + engine.close = func() { + lock.Lock() + defer lock.Unlock() + if err := onGroth16Device(&device, func() error { pk.FreeGPUResources(); return nil }); err != nil { + panic(err) + } + } + return engine, nil +} diff --git a/tools/plonk-wrapper/groth16_cuda_test.go b/tools/plonk-wrapper/groth16_cuda_test.go new file mode 100644 index 000000000..9555e648c --- /dev/null +++ b/tools/plonk-wrapper/groth16_cuda_test.go @@ -0,0 +1,66 @@ +//go:build icicle + +package main + +import ( + "bytes" + "crypto/sha256" + "os" + "testing" + + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/groth16" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/r1cs" +) + +func TestGroth16CUDAInterop(t *testing.T) { + if os.Getenv("IX_GROTH16_CUDA_TEST") != "1" { + t.Skip("set IX_GROTH16_CUDA_TEST=1 on a CUDA host") + } + t.Setenv("IX_GROTH16_BACKEND", "cuda") + for _, committed := range []bool{false, true} { + ccs, err := frontend.Compile(curveID.ScalarField(), r1cs.NewBuilder, &backendCircuit{Commit: committed}) + if err != nil { + t.Fatal(err) + } + pk, vk, err := groth16.Setup(ccs) + if err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + if _, err := pk.WriteTo(&encoded); err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7, Commit: committed}, curveID.ScalarField()) + if err != nil { + t.Fatal(err) + } + public, err := w.Public() + if err != nil { + t.Fatal(err) + } + for _, pin := range []string{"0", "1"} { + t.Setenv("IX_GROTH16_PIN_KEY", pin) + engine, err := newGroth16Engine() + if err != nil { + t.Fatal(err) + } + func() { + defer engine.close() + if _, err := engine.key.ReadFrom(bytes.NewReader(encoded.Bytes())); err != nil { + t.Fatal(err) + } + for range 2 { + proof, err := engine.prove(ccs, w) + if err != nil { + t.Fatal(err) + } + if err := groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())); err != nil { + t.Fatal(err) + } + } + }() + } + } +} diff --git a/tools/plonk-wrapper/groth16_no_cuda.go b/tools/plonk-wrapper/groth16_no_cuda.go new file mode 100644 index 000000000..a74f3d198 --- /dev/null +++ b/tools/plonk-wrapper/groth16_no_cuda.go @@ -0,0 +1,11 @@ +//go:build !icicle + +package main + +import "fmt" + +func groth16CUDAAvailable() bool { return false } + +func newGroth16CUDAEngine() (*groth16Engine, error) { + return nil, fmt.Errorf("CUDA Groth16 requires the helper built with the icicle tag and BN254 ICICLE libraries") +} diff --git a/tools/plonk-wrapper/groth16_setup.go b/tools/plonk-wrapper/groth16_setup.go new file mode 100644 index 000000000..b62cca1fd --- /dev/null +++ b/tools/plonk-wrapper/groth16_setup.go @@ -0,0 +1,211 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/consensys/gnark/backend/groth16" + g16 "github.com/consensys/gnark/backend/groth16/bn254" + "github.com/consensys/gnark/backend/groth16/bn254/mpcsetup" + cs "github.com/consensys/gnark/constraint/bn254" +) + +type groth16CeremonyFile struct { + Path string `json:"path"` + Artifact artifact `json:"artifact"` +} + +type groth16Ceremony struct { + Format string `json:"format"` + Curve string `json:"curve"` + Circuit string `json:"circuitSHA256"` + Domain uint64 `json:"domain"` + Phase1Beacon string `json:"phase1Beacon"` + Phase2Beacon string `json:"phase2Beacon"` + Phase1 []groth16CeremonyFile `json:"phase1"` + Phase2 []groth16CeremonyFile `json:"phase2"` +} + +type groth16Release struct { + Format string `json:"format"` + Curve string `json:"curve"` + Circuit string `json:"circuitSHA256"` + InnerVK string `json:"innerVKSHA256"` + Ceremony string `json:"ceremonySHA256"` + CommitmentHash string `json:"commitmentHash"` + ProvingKey groth16CeremonyFile `json:"provingKey"` + VerifyingKey groth16CeremonyFile `json:"verifyingKey"` +} + +func readGroth16Artifact(path string, expected artifact, value io.ReaderFrom) error { + if expected.Bytes < 1 || expected.Bytes > 1<<42 { + return fmt.Errorf("invalid Groth16 artifact size") + } + if _, err := unhex(expected.SHA256, 32); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, io.LimitReader(f, expected.Bytes+1)) + if err != nil { + return err + } + if n != expected.Bytes || hex.EncodeToString(h.Sum(nil)) != expected.SHA256 { + return fmt.Errorf("Groth16 artifact identity differs: %s", path) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + n, err = value.ReadFrom(f) + if err != nil { + return err + } + if n != expected.Bytes { + return fmt.Errorf("Groth16 artifact has trailing data: %s", path) + } + return nil +} + +func groth16BundlePath(metadata string, item groth16CeremonyFile) (string, error) { + if item.Path == "" || filepath.IsAbs(item.Path) || strings.Contains(item.Path, "\\") { + return "", fmt.Errorf("invalid Groth16 bundle path") + } + for _, part := range strings.Split(item.Path, "/") { + if part == ".." { + return "", fmt.Errorf("Groth16 bundle path escapes its directory") + } + } + return filepath.Join(filepath.Dir(metadata), item.Path), nil +} + +func groth16PinnedJSON(path, pin string, out any) error { + if _, err := unhex(pin, 32); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) + if err != nil { + return err + } + if len(data) > 1<<20 { + return fmt.Errorf("Groth16 setup metadata exceeds its size limit") + } + h := sha256.Sum256(data) + if hex.EncodeToString(h[:]) != pin { + return fmt.Errorf("Groth16 setup metadata differs from the independently trusted digest") + } + return decodeGroth16JSON(data, out) +} + +func groth16MPCSetup(dir, path, trusted string) error { + _, ccs, m, err := groth16Load(dir) + if err != nil { + return err + } + if m.KeysReady { + return fmt.Errorf("Groth16 keys are already prepared") + } + var ceremony groth16Ceremony + if err := groth16PinnedJSON(path, trusted, &ceremony); err != nil { + return err + } + if ceremony.Format != "ix-bn254-groth16-ceremony-v1" || ceremony.Curve != "bn254" || + ceremony.Circuit != m.Files["circuit.bin"].SHA256 || ceremony.Domain != m.Domain || + len(ceremony.Phase1) == 0 || len(ceremony.Phase2) == 0 || len(ceremony.Phase1) > 1024 || len(ceremony.Phase2) > 1024 { + return fmt.Errorf("Groth16 ceremony does not match this circuit") + } + b1, err := unhex(ceremony.Phase1Beacon, 32) + if err != nil { + return err + } + b2, err := unhex(ceremony.Phase2Beacon, 32) + if err != nil { + return err + } + previous1 := mpcsetup.NewPhase1(m.Domain) + for i, contribution := range ceremony.Phase1 { + next := new(mpcsetup.Phase1) + name, err := groth16BundlePath(path, contribution) + if err != nil { + return err + } + if err := readGroth16Artifact(name, contribution.Artifact, next); err != nil { + return err + } + if err := previous1.Verify(next); err != nil { + return fmt.Errorf("Groth16 phase one contribution %d: %w", i, err) + } + previous1 = next + } + commons := previous1.Seal(b1) + previous2 := new(mpcsetup.Phase2) + evaluations := previous2.Initialize(ccs.(*cs.R1CS), &commons) + for i, contribution := range ceremony.Phase2 { + next := new(mpcsetup.Phase2) + name, err := groth16BundlePath(path, contribution) + if err != nil { + return err + } + if err := readGroth16Artifact(name, contribution.Artifact, next); err != nil { + return err + } + if err := previous2.Verify(next); err != nil { + return fmt.Errorf("Groth16 phase two contribution %d: %w", i, err) + } + previous2 = next + } + pk, vk := previous2.Seal(&commons, &evaluations, b2) + return groth16SaveKeys(dir, m, pk, vk, "mpc", trusted) +} + +func groth16Import(dir, path, trusted string) error { + _, _, m, err := groth16Load(dir) + if err != nil { + return err + } + if m.KeysReady { + return fmt.Errorf("Groth16 keys are already prepared") + } + var release groth16Release + if err := groth16PinnedJSON(path, trusted, &release); err != nil { + return err + } + if release.Format != "ix-bn254-groth16-release-v1" || release.Curve != "bn254" || + release.Circuit != m.Files["circuit.bin"].SHA256 || release.InnerVK != m.InnerVK || release.CommitmentHash != m.CommitmentHash { + return fmt.Errorf("Groth16 release does not match this circuit") + } + if _, err := unhex(release.Ceremony, 32); err != nil { + return err + } + pk := groth16.NewProvingKey(curveID) + vk := new(g16.VerifyingKey) + for _, item := range []struct { + source groth16CeremonyFile + target io.ReaderFrom + }{{release.ProvingKey, pk}, {release.VerifyingKey, vk}} { + name, err := groth16BundlePath(path, item.source) + if err != nil { + return err + } + if err := readGroth16Artifact(name, item.source.Artifact, item.target); err != nil { + return err + } + } + if pk.(*g16.ProvingKey).Domain.Cardinality != m.Domain { + return fmt.Errorf("Groth16 proving key domain differs") + } + return groth16SaveKeys(dir, m, pk, vk, "mpc", release.Ceremony) +} diff --git a/tools/plonk-wrapper/groth16_setup_test.go b/tools/plonk-wrapper/groth16_setup_test.go new file mode 100644 index 000000000..5021953bd --- /dev/null +++ b/tools/plonk-wrapper/groth16_setup_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "encoding/hex" + "io" + "os" + "path/filepath" + "testing" + + "github.com/consensys/gnark/backend/groth16/bn254/mpcsetup" + cs "github.com/consensys/gnark/constraint/bn254" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/r1cs" +) + +func groth16TestArtifacts(t *testing.T, dir string) (*cs.R1CS, *groth16Manifest) { + t.Helper() + ccs, err := frontend.Compile(curveID.ScalarField(), r1cs.NewBuilder, &backendCircuit{Commit: true}) + if err != nil { + t.Fatal(err) + } + domain, commitments, err := groth16Shape(ccs) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join("testdata", "bn254", "vk.json")) + if err != nil { + t.Fatal(err) + } + if err := atomicFile(filepath.Join(dir, "vk.json"), func(w io.Writer) error { _, err := w.Write(data); return err }); err != nil { + t.Fatal(err) + } + if err := atomicFile(filepath.Join(dir, "circuit.bin"), func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + m := &groth16Manifest{Format: groth16ArtifactFormat, Curve: "bn254", CommitmentHash: "sha256", + Constraints: ccs.GetNbConstraints(), Domain: domain, PublicInputs: 2, Commitments: commitments, Files: make(map[string]artifact)} + for _, name := range []string{"vk.json", "circuit.bin"} { + m.Files[name], err = fileHash(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + } + m.InnerVK = m.Files["vk.json"].SHA256 + if err := writeJSON(filepath.Join(dir, "manifest.json"), m); err != nil { + t.Fatal(err) + } + return ccs.(*cs.R1CS), m +} + +func TestGroth16MPCAndAdmission(t *testing.T) { + dir := t.TempDir() + ccs, m := groth16TestArtifacts(t, dir) + firstBeacon, secondBeacon := bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32) + ceremony := groth16Ceremony{Format: "ix-bn254-groth16-ceremony-v1", Curve: "bn254", Circuit: m.Files["circuit.bin"].SHA256, + Domain: m.Domain, Phase1Beacon: hex.EncodeToString(firstBeacon), Phase2Beacon: hex.EncodeToString(secondBeacon)} + save := func(name string, object io.WriterTo) groth16CeremonyFile { + t.Helper() + path := filepath.Join(dir, name) + if err := atomicFile(path, func(w io.Writer) error { _, err := object.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + entry, err := fileHash(path) + if err != nil { + t.Fatal(err) + } + return groth16CeremonyFile{Path: name, Artifact: entry} + } + phase1 := mpcsetup.NewPhase1(m.Domain) + phase1.Contribute() + ceremony.Phase1 = []groth16CeremonyFile{save("phase1.bin", phase1)} + commons, err := mpcsetup.VerifyPhase1(m.Domain, firstBeacon, phase1) + if err != nil { + t.Fatal(err) + } + phase2 := new(mpcsetup.Phase2) + phase2.Initialize(ccs, &commons) + phase2.Contribute() + ceremony.Phase2 = []groth16CeremonyFile{save("phase2.bin", phase2)} + metadata := filepath.Join(dir, "ceremony.json") + publish := func() string { + t.Helper() + if err := writeJSON(metadata, ceremony); err != nil { + t.Fatal(err) + } + entry, err := fileHash(metadata) + if err != nil { + t.Fatal(err) + } + return entry.SHA256 + } + pin := publish() + if err := groth16MPCSetup(dir, metadata, hex.EncodeToString(make([]byte, 32))); err == nil { + t.Fatal("accepted untrusted ceremony metadata") + } + ceremony.Circuit = hex.EncodeToString(make([]byte, 32)) + if err := groth16MPCSetup(dir, metadata, publish()); err == nil { + t.Fatal("accepted ceremony for another circuit") + } + ceremony.Circuit = m.Files["circuit.bin"].SHA256 + ceremony.Phase2 = append(ceremony.Phase2, ceremony.Phase2[0]) + if err := groth16MPCSetup(dir, metadata, publish()); err == nil { + t.Fatal("accepted duplicated contribution") + } + ceremony.Phase2 = ceremony.Phase2[:1] + pin = publish() + if err := groth16MPCSetup(dir, metadata, pin); err != nil { + t.Fatal(err) + } + _, _, m, err = groth16Load(dir) + if err != nil { + t.Fatal(err) + } + if m.Setup != "mpc" || m.Ceremony != pin { + t.Fatal("missing ceremony binding") + } + release := groth16Release{Format: "ix-bn254-groth16-release-v1", Curve: "bn254", Circuit: m.Files["circuit.bin"].SHA256, + InnerVK: m.InnerVK, Ceremony: pin, CommitmentHash: "sha256", + ProvingKey: groth16CeremonyFile{Path: "proving-key.bin", Artifact: m.Files["proving-key.bin"]}, + VerifyingKey: groth16CeremonyFile{Path: "verifying-key.bin", Artifact: m.Files["verifying-key.bin"]}} + releasePath := filepath.Join(dir, "release.json") + if err := writeJSON(releasePath, release); err != nil { + t.Fatal(err) + } + releaseHash, err := fileHash(releasePath) + if err != nil { + t.Fatal(err) + } + imported := t.TempDir() + groth16TestArtifacts(t, imported) + if err := groth16Import(imported, releasePath, releaseHash.SHA256); err != nil { + t.Fatal(err) + } + t.Setenv("IX_GROTH16_BACKEND", "cpu") + p, err := openGroth16(imported) + if err != nil { + t.Fatal(err) + } + defer p.close() + var digest [32]byte + digest[0], digest[16] = 49, 1 + if _, _, err := p.prove(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7, Commit: true}, digest[:]); err != nil { + t.Fatal(err) + } + if p.manifest.Files["verifying-key.bin"].SHA256 != m.Files["verifying-key.bin"].SHA256 { + t.Fatal("import changed verifying key") + } + if _, err := groth16BundlePath(metadata, groth16CeremonyFile{Path: "../secret"}); err == nil { + t.Fatal("accepted escaping bundle path") + } +} diff --git a/tools/plonk-wrapper/groth16_test.go b/tools/plonk-wrapper/groth16_test.go new file mode 100644 index 000000000..8c0e33e2b --- /dev/null +++ b/tools/plonk-wrapper/groth16_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/groth16" + g16 "github.com/consensys/gnark/backend/groth16/bn254" + "github.com/consensys/gnark/backend/witness" + "github.com/consensys/gnark/constraint" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/r1cs" +) + +func TestGroth16BackendInterop(t *testing.T) { + for _, committed := range []bool{false, true} { + t.Run(fmt.Sprintf("commitment_%t", committed), func(t *testing.T) { + ccs, err := frontend.Compile(curveID.ScalarField(), r1cs.NewBuilder, &backendCircuit{Commit: committed}) + if err != nil { + t.Fatal(err) + } + domain, commitments, err := groth16Shape(ccs) + if err != nil { + t.Fatal(err) + } + if (commitments == 1) != committed { + t.Fatal("commitment census differs") + } + pk, vk, err := groth16.Setup(ccs) + if err != nil { + t.Fatal(err) + } + m := &groth16Manifest{Format: groth16ArtifactFormat, Curve: "bn254", InnerVK: hex.EncodeToString(make([]byte, 32)), + CommitmentHash: "sha256", Domain: domain, Constraints: ccs.GetNbConstraints(), PublicInputs: 2, + Commitments: commitments, Files: make(map[string]artifact)} + engine := &groth16Engine{key: pk, close: func() {}, prove: func(c constraint.ConstraintSystem, w witness.Witness) (groth16.Proof, error) { + return groth16.Prove(c, pk, w, backend.WithProverHashToFieldFunction(sha256.New())) + }} + p := &groth16Session{ccs: ccs, vk: vk.(*g16.VerifyingKey), engine: engine, manifest: m} + defer p.close() + var digest [32]byte + digest[0], digest[16] = 49, 1 + assignment := &backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7, Commit: committed} + var native, payload []byte + for range 2 { + native, payload, err = p.prove(assignment, digest[:]) + if err != nil { + t.Fatal(err) + } + } + wantSolidity := 256 + if committed { + wantSolidity = 384 + } + if len(payload) != wantSolidity || len(native) != 164+32*commitments { + t.Fatal("unexpected proof encoding") + } + dir := t.TempDir() + if root := os.Getenv("IX_GROTH16_TEST_OUTPUT"); root != "" { + dir = filepath.Join(root, fmt.Sprintf("commitment_%t", committed)) + } + if err := groth16SaveKeys(dir, m, pk, vk, "development", ""); err != nil { + t.Fatal(err) + } + proofPath := filepath.Join(dir, "proof.bin") + if err := atomicFile(proofPath, func(w io.Writer) error { _, err := w.Write(native); return err }); err != nil { + t.Fatal(err) + } + keyPath, keyHash := filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"].SHA256 + if err := groth16Verify(keyPath, keyHash, proofPath, hex.EncodeToString(digest[:])); err != nil { + t.Fatal(err) + } + wrong := digest + wrong[31] ^= 1 + if err := groth16Verify(keyPath, keyHash, proofPath, hex.EncodeToString(wrong[:])); err == nil { + t.Fatal("accepted another claim") + } + if err := groth16Verify(keyPath, hex.EncodeToString(make([]byte, 32)), proofPath, hex.EncodeToString(digest[:])); err == nil { + t.Fatal("accepted another key") + } + for _, malformed := range [][]byte{native[:len(native)-1], append(append([]byte(nil), native...), 0)} { + if _, err := decodeGroth16Proof(malformed, commitments); err == nil { + t.Fatal("accepted malformed length") + } + } + bad := append([]byte(nil), native...) + binary.BigEndian.PutUint32(bad[128:132], ^uint32(0)) + if _, err := decodeGroth16Proof(bad, commitments); err == nil { + t.Fatal("accepted unbounded commitment count") + } + bad = append([]byte(nil), native...) + copy(bad[:32], bytes.Repeat([]byte{0xff}, 32)) + if _, err := decodeGroth16Proof(bad, commitments); err == nil { + t.Fatal("accepted invalid curve point") + } + if err := writeJSON(filepath.Join(dir, "proof.json"), map[string]any{ + "scope": "development-backend-fixture-not-an-ix-proof", "proof": "0x" + hex.EncodeToString(payload), + "digest": "0x" + hex.EncodeToString(digest[:]), "commitments": commitments, + }); err != nil { + t.Fatal(err) + } + if _, _, err := p.prove(assignment, wrong[:]); err == nil { + t.Fatal("accepted mismatched independently expected claim") + } + if _, _, err := p.prove(assignment, digest[:]); err == nil { + t.Fatal("reused failed prover") + } + t.Logf("constraints=%d domain=%d commitments=%d native=%d solidity=%d", m.Constraints, domain, commitments, len(native), len(payload)) + }) + } +} + +func TestGroth16WrapperCircuit(t *testing.T) { + if os.Getenv("IX_GROTH16_WRAPPER_TEST") != "1" { + t.Skip("set IX_GROTH16_WRAPPER_TEST=1 for the complete KZG verifier circuit") + } + dir := t.TempDir() + if root := os.Getenv("IX_GROTH16_TEST_OUTPUT"); root != "" { + dir = filepath.Join(root, "wrapper") + } + if err := groth16Compile(filepath.Join("testdata", "bn254", "vk.json"), dir); err != nil { + t.Fatal(err) + } + _, _, m, err := groth16Load(dir) + if err != nil { + t.Fatal(err) + } + t.Logf("wrapper constraints=%d domain=%d commitments=%d", m.Constraints, m.Domain, m.Commitments) + if os.Getenv("IX_GROTH16_WRAPPER_PROVE") != "1" { + return + } + if !m.KeysReady { + if err := groth16DevelopmentSetup(dir); err != nil { + t.Fatal(err) + } + } else if m.Setup != "development" { + t.Fatal("wrapper test requires development keys") + } + var input witnessJSON + witness := filepath.Join("testdata", "bn254", "witness.json") + if err := decodeJSON(witness, &input); err != nil { + t.Fatal(err) + } + if err := groth16Prove(dir, witness, input.ClaimDigest, dir); err != nil { + t.Fatal(err) + } + mData := new(groth16Manifest) + if err := decodeJSON(filepath.Join(dir, "manifest.json"), mData); err != nil { + t.Fatal(err) + } + if err := groth16Verify(filepath.Join(dir, "verifying-key.bin"), mData.Files["verifying-key.bin"].SHA256, filepath.Join(dir, "proof.bin"), input.ClaimDigest); err != nil { + t.Fatal(err) + } +} + +func TestGroth16BackendSelection(t *testing.T) { + t.Setenv("IX_GROTH16_BACKEND", "invalid") + if _, err := newGroth16Engine(); err == nil { + t.Fatal("accepted invalid backend") + } + if !groth16CUDAAvailable() { + t.Setenv("IX_GROTH16_BACKEND", "cuda") + if _, err := newGroth16Engine(); err == nil { + t.Fatal("silently accepted unavailable CUDA") + } + } +} diff --git a/tools/plonk-wrapper/main.go b/tools/plonk-wrapper/main.go index 75e908f4d..715ff5f28 100644 --- a/tools/plonk-wrapper/main.go +++ b/tools/plonk-wrapper/main.go @@ -154,6 +154,9 @@ func checkWitness(dir, path string) error { } func run(args []string) error { + if len(args) > 0 && args[0] == "groth16" { + return runGroth16(args[1:]) + } if len(args) == 3 && args[0] == "compile" { return compileProfile(args[1], args[2]) } diff --git a/tools/plonk-wrapper/terminal_compare_test.go b/tools/plonk-wrapper/terminal_compare_test.go new file mode 100644 index 000000000..020e353ef --- /dev/null +++ b/tools/plonk-wrapper/terminal_compare_test.go @@ -0,0 +1,337 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/groth16" + g16 "github.com/consensys/gnark/backend/groth16/bn254" + "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" + "github.com/consensys/gnark/test/unsafekzg" +) + +type terminalSample struct { + ProveSeconds float64 `json:"proveSeconds"` + VerifySeconds float64 `json:"verifySeconds"` + NativeBytes int64 `json:"nativeBytes"` + SolidityBytes int `json:"solidityBytes"` + EnvelopeBytes int64 `json:"envelopeBytes"` +} + +type terminalMeasurement struct { + System string `json:"system"` + Device string `json:"device"` + PinKeys bool `json:"pinKeys"` + Constraints int `json:"constraints"` + Domain uint64 `json:"domain"` + Commitments int `json:"commitments"` + LoadSeconds float64 `json:"openSeconds"` + First terminalSample `json:"first"` + Warm []terminalSample `json:"warm"` + MedianProveSeconds float64 `json:"medianProveSeconds"` +} + +type terminalComparison struct { + Scope string `json:"scope"` + Setup string `json:"setup"` + Schema artifact `json:"schema"` + Witness artifact `json:"witness"` + GoVersion string `json:"goVersion"` + CPUs int `json:"cpus"` + GOMAXPROCS int `json:"gomaxprocs"` + PlonkCompileSeconds float64 `json:"plonkCompileSeconds"` + PlonkSRSSeconds float64 `json:"plonkSRSSeconds"` + PlonkSetupSeconds float64 `json:"plonkSetupSeconds"` + Groth16KeysReused bool `json:"groth16KeysReused"` + PlonkKeysReused bool `json:"plonkKeysReused"` + Results []terminalMeasurement `json:"results"` +} + +func TestBN254TerminalComparison(t *testing.T) { + if os.Getenv("IX_TERMINAL_COMPARE") != "1" { + t.Skip("set IX_TERMINAL_COMPARE=1 for the matched terminal benchmark") + } + out := os.Getenv("IX_TERMINAL_COMPARE_OUT") + grothDir := os.Getenv("IX_TERMINAL_GROTH16_ARTIFACTS") + if out == "" || grothDir == "" { + t.Fatal("set IX_TERMINAL_COMPARE_OUT and IX_TERMINAL_GROTH16_ARTIFACTS") + } + input := os.Getenv("IX_TERMINAL_COMPARE_INPUT") + if input == "" { + input = filepath.Join("testdata", "bn254") + } + repeats := 3 + if value := os.Getenv("IX_TERMINAL_COMPARE_REPEATS"); value != "" { + var err error + repeats, err = strconv.Atoi(value) + if err != nil || repeats < 1 || repeats > 10 { + t.Fatal("repeats must be in 1..10") + } + } + devices := strings.Split(os.Getenv("IX_TERMINAL_COMPARE_DEVICES"), ",") + if devices[0] == "" { + devices = []string{"cpu"} + } + for _, device := range devices { + if device != "cpu" && device != "cuda" { + t.Fatal("devices must be cpu or cuda") + } + } + keyPath, witnessPath := filepath.Join(input, "vk.json"), filepath.Join(input, "witness.json") + p, err := loadVerifyingKey(keyPath) + if err != nil { + t.Fatal(err) + } + assignment, err := witnessAssignment(p, witnessPath) + if err != nil { + t.Fatal(err) + } + w, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + t.Fatal(err) + } + public, err := w.Public() + if err != nil { + t.Fatal(err) + } + report := terminalComparison{Scope: "actual Init recursive KZG verifier and witness; terminal proving only", Setup: "Groth16 development keys; PLONK setup is recorded by the selected cache", + GoVersion: runtime.Version(), CPUs: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), Groth16KeysReused: true} + report.Schema, err = fileHash(keyPath) + if err != nil { + t.Fatal(err) + } + report.Witness, err = fileHash(witnessPath) + if err != nil { + t.Fatal(err) + } + gm := new(groth16Manifest) + if err := decodeJSON(filepath.Join(grothDir, "manifest.json"), gm); err != nil { + t.Fatal(err) + } + if err := gm.validate(); err != nil { + t.Fatal(err) + } + if gm.InnerVK != report.Schema.SHA256 || gm.Setup != "development" { + t.Fatal("Groth16 cache does not match the comparison fixture") + } + plonkDir := filepath.Join(out, "plonk") + externalPlonk := os.Getenv("IX_TERMINAL_PLONK_ARTIFACTS") + if externalPlonk != "" { + plonkDir = externalPlonk + metadata, err := fileHash(filepath.Join(plonkDir, "manifest.json")) + if err != nil { + t.Fatal(err) + } + if metadata.SHA256 != os.Getenv("IX_TERMINAL_PLONK_MANIFEST_SHA256") { + t.Fatal("PLONK manifest differs from the comparison pin") + } + var m manifest + if err := decodeJSON(filepath.Join(plonkDir, "manifest.json"), &m); err != nil { + t.Fatal(err) + } + if m.InnerVKSHA256 != report.Schema.SHA256 || !m.KeysReady || m.PublicInputs != 2 { + t.Fatal("PLONK artifacts describe another circuit") + } + for _, name := range []string{"vk.json", "circuit.bin", "proving-key.bin", "verifying-key.bin"} { + if err := m.checkFile(plonkDir, name); err != nil { + t.Fatal(err) + } + } + report.PlonkKeysReused = true + } else if _, err := os.Stat(filepath.Join(plonkDir, "ready.json")); os.IsNotExist(err) { + start := time.Now() + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, newCircuit(p)) + if err != nil { + t.Fatal(err) + } + report.PlonkCompileSeconds = time.Since(start).Seconds() + if err := atomicFile(filepath.Join(plonkDir, "circuit.bin"), func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + t.Logf("PLONK constraints=%d domain=%d; Groth16 constraints=%d domain=%d", ccs.GetNbConstraints(), ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+ccs.GetNbPublicVariables())), gm.Constraints, gm.Domain) + if os.Getenv("IX_TERMINAL_COMPARE_COMPILE_ONLY") == "1" { + return + } + start = time.Now() + srs, lagrange, err := unsafekzg.NewSRS(ccs) + if err != nil { + t.Fatal(err) + } + report.PlonkSRSSeconds = time.Since(start).Seconds() + start = time.Now() + pk, vk, err := plonk.Setup(ccs, srs, lagrange) + if err != nil { + t.Fatal(err) + } + report.PlonkSetupSeconds = time.Since(start).Seconds() + for _, item := range []struct { + name string + value io.WriterTo + }{{"proving-key.bin", pk}, {"verifying-key.bin", vk}} { + if err := atomicFile(filepath.Join(plonkDir, item.name), func(w io.Writer) error { _, err := item.value.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + } + if err := writeJSON(filepath.Join(plonkDir, "ready.json"), report); err != nil { + t.Fatal(err) + } + } else if err != nil { + t.Fatal(err) + } + if externalPlonk == "" { + var cached terminalComparison + if err := decodeJSON(filepath.Join(plonkDir, "ready.json"), &cached); err != nil { + t.Fatal(err) + } + if cached.Schema != report.Schema || cached.Witness != report.Witness { + t.Fatal("PLONK cache belongs to another fixture") + } + report.PlonkCompileSeconds, report.PlonkSRSSeconds, report.PlonkSetupSeconds = cached.PlonkCompileSeconds, cached.PlonkSRSSeconds, cached.PlonkSetupSeconds + } + if os.Getenv("IX_TERMINAL_COMPARE_PREPARE_ONLY") == "1" { + return + } + systems := strings.Split(os.Getenv("IX_TERMINAL_COMPARE_SYSTEMS"), ",") + if systems[0] == "" { + systems = []string{"groth16", "plonk"} + } + for _, system := range systems { + if system != "groth16" && system != "plonk" { + t.Fatal("systems must be groth16 or plonk") + } + } + debug.FreeOSMemory() + for _, device := range devices { + for _, system := range systems { + measurement := terminalMeasurement{System: system, Device: device, PinKeys: system == "groth16" && device == "cuda" && os.Getenv("IX_GROTH16_PIN_KEY") == "1"} + start := time.Now() + var prove func() (terminalSample, error) + var close func() + if system == "groth16" { + t.Setenv("IX_GROTH16_BACKEND", device) + engine, err := newGroth16Engine() + if err != nil { + t.Fatal(err) + } + ccs, vk := groth16.NewCS(curveID), new(g16.VerifyingKey) + for _, item := range []struct { + name string + value io.ReaderFrom + }{{"circuit.bin", ccs}, {"proving-key.bin", engine.key}, {"verifying-key.bin", vk}} { + if err := readBinary(filepath.Join(grothDir, item.name), item.value); err != nil { + engine.close() + t.Fatal(err) + } + } + close = engine.close + measurement.Constraints, measurement.Domain, measurement.Commitments = gm.Constraints, gm.Domain, gm.Commitments + prove = func() (terminalSample, error) { + start := time.Now() + proof, err := engine.prove(ccs, w) + if err != nil { + return terminalSample{}, err + } + result := terminalSample{ProveSeconds: time.Since(start).Seconds()} + start = time.Now() + err = groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())) + result.VerifySeconds = time.Since(start).Seconds() + if err != nil { + return result, err + } + result.NativeBytes, err = proof.WriteTo(io.Discard) + result.SolidityBytes = len(proof.(*g16.Proof).MarshalSolidity()) + result.EnvelopeBytes = 40 + result.NativeBytes + return result, err + } + } else { + t.Setenv("IX_PLONK_BACKEND", device) + ccs := plonk.NewCS(curveID) + pk, vk := plonk.NewProvingKey(curveID), plonk.NewVerifyingKey(curveID) + for _, item := range []struct { + name string + value io.ReaderFrom + }{{"circuit.bin", ccs}, {"proving-key.bin", pk}, {"verifying-key.bin", vk}} { + if err := readBinary(filepath.Join(plonkDir, item.name), item.value); err != nil { + t.Fatal(err) + } + } + cleanup, err := configureProvingBackend(pk) + if err != nil { + t.Fatal(err) + } + close = func() { + if err := cleanup(); err != nil { + t.Error(err) + } + } + measurement.Constraints, measurement.Domain, measurement.Commitments = ccs.GetNbConstraints(), vk.(*nativeVerifyingKey).Size, len(vk.(*nativeVerifyingKey).Qcp) + prove = func() (terminalSample, error) { + start := time.Now() + proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + if err != nil { + return terminalSample{}, err + } + result := terminalSample{ProveSeconds: time.Since(start).Seconds()} + start = time.Now() + err = plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)) + result.VerifySeconds = time.Since(start).Seconds() + if err != nil { + return result, err + } + result.NativeBytes, err = proof.WriteTo(io.Discard) + result.SolidityBytes = len(proof.(*nativeProof).MarshalSolidity()) + result.EnvelopeBytes = 40 + result.NativeBytes + return result, err + } + } + measurement.LoadSeconds = time.Since(start).Seconds() + func() { + defer close() + var err error + measurement.First, err = prove() + if err != nil { + t.Fatal(err) + } + for range repeats { + sample, err := prove() + if err != nil { + t.Fatal(err) + } + measurement.Warm = append(measurement.Warm, sample) + } + }() + times := make([]float64, len(measurement.Warm)) + for i, sample := range measurement.Warm { + times[i] = sample.ProveSeconds + } + sort.Float64s(times) + measurement.MedianProveSeconds = times[len(times)/2] + if len(times)%2 == 0 { + measurement.MedianProveSeconds = (times[len(times)/2-1] + times[len(times)/2]) / 2 + } + report.Results = append(report.Results, measurement) + if err := writeJSON(filepath.Join(out, "comparison.json"), report); err != nil { + t.Fatal(err) + } + t.Logf("%s %s: median=%.3fs native=%d envelope=%d Solidity=%d", system, device, measurement.MedianProveSeconds, measurement.First.NativeBytes, measurement.First.EnvelopeBytes, measurement.First.SolidityBytes) + prove, close = nil, nil + debug.FreeOSMemory() + } + } + fmt.Println("Matched comparison:", filepath.Join(out, "comparison.json")) +} diff --git a/tools/plonk-wrapper/testdata/terminal-comparison.json b/tools/plonk-wrapper/testdata/terminal-comparison.json new file mode 100644 index 000000000..b7f4c06b0 --- /dev/null +++ b/tools/plonk-wrapper/testdata/terminal-comparison.json @@ -0,0 +1,198 @@ +{ + "scope": "actual Init recursive KZG verifier and witness; terminal proving only", + "setup": "Groth16 development setup; PLONK cached Ignition parameters", + "schema": { + "SHA256": "b86e203bcc2dcd87a67c1e325e24328e12534676ada7ea9602ac319568139c29", + "Bytes": 100463 + }, + "witness": { + "SHA256": "2034aff3138764b878280bb210dc15dadff693f8753f35a5be1fcc73d9ac06b3", + "Bytes": 5841 + }, + "goVersion": "go1.26.8", + "cpus": 32, + "gomaxprocs": 32, + "plonkCompileSeconds": 0, + "plonkSRSSeconds": 0, + "plonkSetupSeconds": 0, + "groth16KeysReused": true, + "plonkKeysReused": true, + "results": [ + { + "system": "groth16", + "device": "cpu", + "pinKeys": false, + "constraints": 4340068, + "domain": 8388608, + "commitments": 1, + "openSeconds": 26.581690542, + "first": { + "proveSeconds": 7.396492302, + "verifySeconds": 0.001264361, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + }, + "warm": [ + { + "proveSeconds": 7.351981283, + "verifySeconds": 0.001336093, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + }, + { + "proveSeconds": 7.360199898, + "verifySeconds": 0.001261378, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + }, + { + "proveSeconds": 7.252605044, + "verifySeconds": 0.00125139, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + } + ], + "medianProveSeconds": 7.351981283, + "processWallSeconds": 57.01165986061096, + "peakRSSKiB": 11072360 + }, + { + "system": "plonk", + "device": "cpu", + "pinKeys": false, + "constraints": 14689440, + "domain": 16777216, + "commitments": 1, + "openSeconds": 21.671423315, + "first": { + "proveSeconds": 66.134217717, + "verifySeconds": 0.001294276, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + }, + "warm": [ + { + "proveSeconds": 65.649812204, + "verifySeconds": 0.001343556, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + }, + { + "proveSeconds": 66.074696389, + "verifySeconds": 0.001293747, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + }, + { + "proveSeconds": 66.305890957, + "verifySeconds": 0.0012877, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + } + ], + "medianProveSeconds": 66.074696389, + "processWallSeconds": 286.9556610584259, + "peakRSSKiB": 35717024 + }, + { + "system": "groth16", + "device": "cuda", + "pinKeys": true, + "constraints": 4340068, + "domain": 8388608, + "commitments": 1, + "openSeconds": 26.868449825, + "first": { + "proveSeconds": 3.019795109, + "verifySeconds": 0.00127077, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + }, + "warm": [ + { + "proveSeconds": 2.611417335, + "verifySeconds": 0.001242067, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + }, + { + "proveSeconds": 2.601822482, + "verifySeconds": 0.001300448, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + }, + { + "proveSeconds": 2.622148637, + "verifySeconds": 0.001263595, + "nativeBytes": 196, + "solidityBytes": 384, + "envelopeBytes": 236 + } + ], + "medianProveSeconds": 2.611417335, + "processWallSeconds": 38.89270997047424, + "peakRSSKiB": 9981472 + }, + { + "system": "plonk", + "device": "cuda", + "pinKeys": false, + "constraints": 14689440, + "domain": 16777216, + "commitments": 1, + "openSeconds": 21.971979662, + "first": { + "proveSeconds": 11.797483120999999, + "verifySeconds": 0.001282142, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + }, + "warm": [ + { + "proveSeconds": 11.589227596, + "verifySeconds": 0.001343848, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + }, + { + "proveSeconds": 11.491523968, + "verifySeconds": 0.001277949, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + }, + { + "proveSeconds": 11.747093688, + "verifySeconds": 0.001288847, + "nativeBytes": 584, + "solidityBytes": 864, + "envelopeBytes": 624 + } + ], + "medianProveSeconds": 11.589227596, + "processWallSeconds": 69.80385398864746, + "peakRSSKiB": 32730092 + } + ], + "hardware": { + "gpu": "NVIDIA RTX PRO 6000 Blackwell Server Edition", + "logicalCPUs": 32, + "gpuOtherProcessesAtRunStart": false + }, + "date": "2026-10-29", + "samplePolicy": "one first proof and three warm proofs per case, separate processes, default parallelism", + "limits": "Terminal proving only. Prove includes gnark witness solving. Key opening, previous Ix/KZG stages and witness export are excluded from warm proving. Groth16 CUDA pins key vectors; PLONK CUDA uses its existing sppark adapter." +} From 7b9a7f21b207ad7395704013d30e32cd7da958ff Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:41:26 +0000 Subject: [PATCH 21/31] refactor(compression): embed gnark in the Rust terminal worker Call stock gnark through one C archive for PLONK and Groth16, with retained prover handles and Rust-owned artifact preparation and publication. Keep the Go runtime inside ix-compression-worker and select acceleration with the terminal backend features plus cuda. Remove the Go helper builders, package ICICLE with the worker, and trace native libraries, headers, and builder inputs in Lake. --- .github/workflows/ci.yml | 21 +- Cargo.lock | 1 + crates/ffi/Cargo.toml | 4 +- crates/proof-compression/Cargo.toml | 12 +- crates/proof-compression/README.md | 27 +- crates/proof-compression/build.rs | 10 +- crates/proof-compression/build/gnark.rs | 242 ++++++++++ .../src/bin/ix_compression_worker.rs | 10 +- .../proof-compression/src/pipeline/gnark.rs | 446 ++++++++++++++++++ .../proof-compression/src/pipeline/groth16.rs | 435 ++++++++++++----- crates/proof-compression/src/pipeline/mod.rs | 140 +++--- .../proof-compression/src/pipeline/plonk.rs | 360 ++++++++++---- crates/proof-compression/src/pipeline/root.rs | 2 + .../src/pipeline/terminal.rs | 91 ++-- .../tests/fixtures/gnark/proof.bin | Bin 0 -> 584 bytes .../tests/fixtures/gnark/provenance.json | 8 + .../tests/fixtures/gnark/verifying-key.bin | Bin 0 -> 34384 bytes .../proof-compression/tests/worker_tracing.rs | 10 + docs/ffi.md | 16 + flake.nix | 102 ++-- lakefile.lean | 207 ++++---- tests/check_compression_build.py | 29 +- tests/compression_build_config.lean | 7 +- tools/plonk-wrapper/README.md | 300 +++++------- tools/plonk-wrapper/artifacts.go | 113 +---- tools/plonk-wrapper/backend.go | 215 +-------- tools/plonk-wrapper/backend_bn254_test.go | 101 +++- tools/plonk-wrapper/backend_cpu.go | 19 +- tools/plonk-wrapper/backend_cuda_bn254.go | 54 +-- .../plonk-wrapper/backend_cuda_bn254_test.go | 4 +- tools/plonk-wrapper/backend_no_cuda_test.go | 6 +- tools/plonk-wrapper/backend_selection_test.go | 13 +- tools/plonk-wrapper/build_cuda.py | 132 ------ tools/plonk-wrapper/build_groth16_cuda.py | 39 -- tools/plonk-wrapper/cuda_init_bn254_test.go | 9 +- tools/plonk-wrapper/curve_bn254.go | 2 - tools/plonk-wrapper/ffi.go | 181 +++++++ tools/plonk-wrapper/ffi.h | 14 + tools/plonk-wrapper/ffi_groth16.go | 107 +++++ tools/plonk-wrapper/ffi_test.go | 17 + tools/plonk-wrapper/groth16.go | 305 +++++------- tools/plonk-wrapper/groth16_cuda.go | 27 +- tools/plonk-wrapper/groth16_cuda_test.go | 2 +- tools/plonk-wrapper/groth16_no_cuda.go | 4 +- tools/plonk-wrapper/groth16_setup.go | 38 +- tools/plonk-wrapper/groth16_setup_test.go | 25 +- tools/plonk-wrapper/groth16_test.go | 67 +-- tools/plonk-wrapper/main.go | 186 +------- tools/plonk-wrapper/parameters.go | 95 +--- tools/plonk-wrapper/parameters_bn254_test.go | 40 +- tools/plonk-wrapper/profile.go | 42 +- tools/plonk-wrapper/prover.go | 227 +++++++++ tools/plonk-wrapper/terminal_compare_test.go | 4 +- tools/plonk-wrapper/test_helpers_test.go | 86 ++++ 54 files changed, 2755 insertions(+), 1899 deletions(-) create mode 100644 crates/proof-compression/build/gnark.rs create mode 100644 crates/proof-compression/src/pipeline/gnark.rs create mode 100644 crates/proof-compression/tests/fixtures/gnark/proof.bin create mode 100644 crates/proof-compression/tests/fixtures/gnark/provenance.json create mode 100644 crates/proof-compression/tests/fixtures/gnark/verifying-key.bin delete mode 100644 tools/plonk-wrapper/build_cuda.py delete mode 100644 tools/plonk-wrapper/build_groth16_cuda.py create mode 100644 tools/plonk-wrapper/ffi.go create mode 100644 tools/plonk-wrapper/ffi.h create mode 100644 tools/plonk-wrapper/ffi_groth16.go create mode 100644 tools/plonk-wrapper/ffi_test.go create mode 100644 tools/plonk-wrapper/prover.go create mode 100644 tools/plonk-wrapper/test_helpers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbafb1913..04a7ab503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,10 @@ jobs: with: auto-config: false use-github-cache: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: tools/plonk-wrapper/go.mod + cache-dependency-path: tools/plonk-wrapper/go.sum # A push to main already passed the lints and tests in the merge # queue; that run only builds, so the sticky disk holds the check, # clippy and test-binary artifacts the next PR run will fingerprint. @@ -171,6 +175,7 @@ jobs: # link without paying for a GPU runner. One explicit architecture # is sufficient for this downstream integration gate. MULTI_STARK_CUDA_ARCHS: "80" + IX_PLONK_CUDA_ARCHS: "80" steps: # No sccache here yet. Once the GPU benchmark lands after #644, wire # it for both compilers: `sccache: s3` plus the sccache action for @@ -186,7 +191,7 @@ jobs: run: | apt-get update apt-get install --yes --no-install-recommends \ - build-essential ca-certificates curl git libclang-dev python3 sudo + build-essential ca-certificates cmake curl git libclang-dev ninja-build python3 sudo - uses: $/.github/actions/setup-rust-toolchain with: codegen: portable @@ -205,9 +210,21 @@ jobs: with: auto-config: false use-github-cache: false + - name: Build pinned BN254 ICICLE libraries + run: | + go -C tools/plonk-wrapper mod download github.com/ingonyama-zk/icicle-gnark/v3 + icicleSource=$(go -C tools/plonk-wrapper list -m -f '{{.Dir}}' github.com/ingonyama-zk/icicle-gnark/v3) + cmake -S "$icicleSource/icicle" -B "$RUNNER_TEMP/icicle-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/icicle" \ + -DCURVE=bn254 -DMSM=ON -DNTT=ON -DG2=ON -DCPU_BACKEND=OFF \ + -DCUDA_BACKEND=ON -DBUILD_TESTS=OFF -DCUDA_ARCH=80-real \ + -DCMAKE_CUDA_ARCHITECTURES=80-real + cmake --build "$RUNNER_TEMP/icicle-build" --parallel + cmake --install "$RUNNER_TEMP/icicle-build" + echo "IX_GROTH16_ICICLE_ROOT=$RUNNER_TEMP/icicle" >> "$GITHUB_ENV" - name: Compile CUDA Rust targets run: | - cargo check --release --locked --workspace --all-targets --features ix-ffi/parallel,ix-ffi/cuda,ix-proof-compression/cuda + cargo check --release --locked --workspace --all-targets --features ix-ffi/parallel,ix-ffi/cuda,ix-proof-compression/cuda,ix-proof-compression/plonk,ix-proof-compression/groth16 - name: Check CPU runtime linkage without a CUDA compiler run: IX_CUDA=0 IX_CUDA_TRACE_CODEGEN=0 NVCC=/nonexistent/nvcc lake build ix_cuda_runtime - name: Link CUDA-enabled Lean executables diff --git a/Cargo.lock b/Cargo.lock index b6e5609b6..cf278a8fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2090,6 +2090,7 @@ dependencies = [ "ark-poly", "ark-serialize", "bincode", + "bindgen", "blake2b_simd", "blake3", "blst", diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 865f229d2..f11bedb49 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -47,9 +47,7 @@ bincode = { version = "2.0.1", optional = true } serde = { version = "1.0.219", features = ["derive"], optional = true } [features] -default = ["plonk", "groth16"] -plonk = ["ix-proof-compression/plonk"] -groth16 = ["ix-proof-compression/groth16"] +default = [] parallel = ["aiur/parallel", "ix-proof-compression/parallel"] # The KZG prover's CUDA code is compiled into the ix-compression-worker # executable, not into the library the Lean executable links. diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index a3a703b6c..7f6a57599 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -40,6 +40,9 @@ p3-maybe-rayon = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89 p3-symmetric = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c83775a8076cc262ffc48a1fd7c" } +[build-dependencies] +bindgen = { version = "=0.72.1", optional = true } + [dev-dependencies] rayon = { workspace = true } @@ -54,12 +57,13 @@ optional = true features = ["cuda"] [features] -plonk = [] -groth16 = [] +plonk = ["dep:bindgen"] +groth16 = ["dep:bindgen"] default = ["parallel", "plonk", "groth16"] # GPU proving: multi-stark's Goldilocks backend for FRI compression and the -# KZG kernels here. Needs the CUDA toolkit at build time and a visible GPU -# at runtime; CPU-only builds neither compile sppark nor link CUDA. +# KZG kernels here, plus acceleration for the enabled gnark backends. Needs +# the CUDA toolkit at build time and a visible GPU at runtime; CPU-only +# builds neither compile sppark nor link CUDA. cuda = ["multi-stark/cuda", "dep:rayon", "dep:sppark", "dep:blst"] parallel = ["multi-stark/parallel", "aiur/parallel", "p3-maybe-rayon/parallel", "ark-ec/parallel", "ark-ff/parallel", "ark-poly/parallel"] diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 9a5e5cbc2..5a04e6bec 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -1,31 +1,35 @@ # Ix proof compression The pipeline is Goldilocks/Blake3 FRI → BN254 KZG → recursive BN254 KZG, -with an optional terminal BN254 PLONK proof. FRI uses Goldilocks and its +with an optional terminal BN254 PLONK or Groth16 proof. FRI uses Goldilocks and its quadratic extension with Blake3 Merkle commitments. Curve arithmetic starts when the FRI verifier is translated into BN254 scalar-field constraints. BN254 is unconditional, including builds with `--no-default-features`. -The remaining Cargo features are `parallel`, `plonk`, and `cuda`. -Lake builds the CLI, worker, and matching `ix-bn254-plonk-wrapper` together. +The main Cargo features are `parallel`, `plonk`, `groth16`, and `cuda`. Either +terminal feature builds one shared gnark C archive. Combining a terminal +feature with `cuda` enables its GPU backend. +Lake builds the CLI and worker separately so `ix-ffi` does not link Go. An explicit `IX_KZG_CURVE=bn254` is accepted; other values are rejected. ```sh lake build ix IX_CUDA=1 lake build ix -IX_PLONK_CUDA=1 lake build ix ``` -`IX_CUDA` selects the Aiur/FRI and KZG CUDA backends. `IX_PLONK_CUDA` -independently selects the terminal PLONK CUDA helper. Native BN254 arithmetic -uses shared sppark/BLST support, and the Go helper uses gnark. Those libraries -may contain other curves; Ix exposes only BN254. +`IX_CUDA` enables CUDA for Aiur, FRI, KZG, and both terminal backends. Native BN254 +arithmetic uses shared sppark/BLST support, and Rust calls stock gnark through +its C archive. Groth16 CUDA additionally requires the pinned ICICLE installation +via `IX_GROTH16_ICICLE_ROOT`; Nix provides it in `.#compression-worker-cuda`. +Those libraries may contain other curves; Ix exposes only BN254. KZG uses authenticated Ignition parameters or explicit known-trapdoor development parameters. Terminal PLONK requires authenticated Ignition. The BN254 KZG release catalog is empty: local verification requires an independently pinned verifying key and expected statement. PLONK verification -requires an independently authorized `IX_PLONK_VERIFIER_CATALOG`. +requires an independently authorized `IX_PLONK_VERIFIER_CATALOG`. Groth16 +requires its own circuit-specific setup and `IX_GROTH16_VERIFIER_CATALOG`; +see the [terminal backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-terminal). BN254 supports radix-2 domains through `2^28`; traces and quotient domains must both fit. Existing BN254 proof, verifier, and SRS encodings are retained. @@ -319,9 +323,8 @@ Use the four Ignition environment variables described above. The result includes the proof envelope, native and Solidity proof encodings, generated Solidity verifier, verifying key, admission, and candidate verifier catalog. The candidate must be reviewed independently before configuring it for -`ix verify`. See the [helper guide](../../tools/plonk-wrapper/README.md) -for standalone proving, validated Lagrange import, CUDA configuration, and -artifact cache rules. On-chain verification uses the terminal PLONK verifier +`ix verify`. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) +for worker commands, Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the terminal PLONK verifier and [claim adapter](../../contracts/plonk/README.md). ## CUDA and profiling diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs index d2e8e1b0d..db462d796 100644 --- a/crates/proof-compression/build.rs +++ b/crates/proof-compression/build.rs @@ -1,6 +1,6 @@ -//! KZG CUDA kernels for the compression crate. +//! Native proving backends for the compression worker. //! -//! Without the `cuda` feature this script does nothing, so CPU builds need +//! Without the `cuda` feature KZG builds need //! neither nvcc nor the CUDA headers and libraries. With it, the selected //! sppark instantiation and the KZG kernels are compiled into their own //! archive, isolated from multi-stark's Goldilocks instantiation by the @@ -24,7 +24,13 @@ const SOURCES: &[&str] = &[ "cuda/kzg_sppark.cuh", ]; +#[cfg(any(feature = "plonk", feature = "groth16"))] +#[path = "build/gnark.rs"] +mod gnark; + fn main() { + #[cfg(any(feature = "plonk", feature = "groth16"))] + gnark::build(); println!("cargo:rerun-if-changed=build.rs"); for source in SOURCES { println!("cargo:rerun-if-changed={source}"); diff --git a/crates/proof-compression/build/gnark.rs b/crates/proof-compression/build/gnark.rs new file mode 100644 index 000000000..8d2420686 --- /dev/null +++ b/crates/proof-compression/build/gnark.rs @@ -0,0 +1,242 @@ +use std::{ + env, + ffi::OsString, + fs, + hash::{Hash, Hasher}, + path::PathBuf, + process::Command, +}; + +pub(super) fn build() { + let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let source = + manifest.join("../../tools/plonk-wrapper").canonicalize().unwrap(); + let out = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + println!("cargo:rerun-if-changed={}", source.display()); + for variable in [ + "GO", + "PATH", + "GOROOT", + "IX_GNARK_GO_VERSION", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "CC", + "CXX", + "IX_PLONK_CUDA_LIBRARY", + "IX_PLONK_SPPARK_SOURCE", + "IX_PLONK_BLST_SOURCE", + "IX_PLONK_CUDA_ARCHS", + "IX_GROTH16_ICICLE_ROOT", + "NVCC", + "CUDA_HOME", + "CUDA_PATH", + "NVCC_CCBIN", + "NVCC_PREPEND_FLAGS", + "NVCC_APPEND_FLAGS", + ] { + println!("cargo:rerun-if-env-changed={variable}"); + } + let go = env::var_os("GO").unwrap_or_else(|| OsString::from("go")); + let selected = PathBuf::from(&go); + let executable = if selected.components().count() > 1 { + source.join(&selected) + } else { + env::split_paths(&env::var_os("PATH").unwrap_or_default()) + .map(|directory| source.join(directory).join(&selected)) + .find(|path| path.is_file()) + .unwrap_or_else(|| panic!("cannot find {go:?} in PATH")) + }; + println!("cargo:rerun-if-changed={}", executable.display()); + let toolchain = Command::new(&go) + .current_dir(&source) + .env("GOTOOLCHAIN", "local") + .args(["env", "GOROOT", "GOTOOLDIR"]) + .output() + .expect("inspect Go toolchain"); + assert!( + toolchain.status.success(), + "Go toolchain query failed: {}", + String::from_utf8_lossy(&toolchain.stderr) + ); + let toolchain = String::from_utf8(toolchain.stdout) + .expect("Go toolchain paths must be UTF-8"); + let mut paths = toolchain.lines(); + let root = PathBuf::from(paths.next().expect("Go did not report GOROOT")); + let tools = PathBuf::from(paths.next().expect("Go did not report GOTOOLDIR")); + for path in [ + root.join("bin/go"), + root.join("VERSION"), + tools.join("compile"), + tools.join("link"), + tools.join("cgo"), + ] { + println!("cargo:rerun-if-changed={}", path.display()); + } + let mut command = Command::new(&go); + command + .current_dir(&source) + .env("CGO_ENABLED", "1") + .env("GOTOOLCHAIN", "local") + .env("GOCACHE", out.join("go-cache")); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + command.env( + "GOOS", + match target_os.as_str() { + "linux" => "linux", + "macos" => "darwin", + _ => panic!("gnark supports Linux and macOS"), + }, + ); + command.env( + "GOARCH", + match target_arch.as_str() { + "x86_64" => "amd64", + "aarch64" => "arm64", + _ => panic!("gnark supports x86_64 and aarch64"), + }, + ); + command.args([ + "build", + "-buildvcs=false", + "-trimpath", + "-buildmode=c-archive", + ]); + command.arg(if source.join("vendor").is_dir() { + "-mod=vendor" + } else { + "-mod=readonly" + }); + let cuda = env::var_os("CARGO_FEATURE_CUDA").is_some(); + let mut tags = Vec::new(); + let mut linker_flags = env::var("CGO_LDFLAGS").unwrap_or_default(); + if cuda && env::var_os("CARGO_FEATURE_PLONK").is_some() { + assert_eq!(target_os, "linux", "PLONK CUDA requires Linux"); + assert_eq!(target_arch, "x86_64", "PLONK CUDA requires x86_64"); + let native = manifest.join("cuda/plonk"); + let library = match env::var_os("IX_PLONK_CUDA_LIBRARY") { + Some(path) => PathBuf::from(path), + None => { + let build = out.join("plonk-native"); + let sppark = env::var_os("IX_PLONK_SPPARK_SOURCE") + .or_else(|| env::var_os("DEP_SPPARK_ROOT")) + .expect("set IX_PLONK_SPPARK_SOURCE or IX_PLONK_CUDA_LIBRARY"); + let blst = env::var_os("IX_PLONK_BLST_SOURCE") + .or_else(|| { + env::var_os("DEP_BLST_C_SRC").and_then(|path| { + PathBuf::from(path).parent().map(|p| p.as_os_str().to_owned()) + }) + }) + .expect("set IX_PLONK_BLST_SOURCE or IX_PLONK_CUDA_LIBRARY"); + let status = Command::new("python3") + .arg(native.join("build.py")) + .arg("--sppark") + .arg(sppark) + .arg("--blst") + .arg(blst) + .arg("--out") + .arg(&build) + .status() + .expect("native PLONK build"); + assert!(status.success(), "native PLONK build failed"); + build.join("libix_plonk_bn254_cuda.so") + }, + } + .canonicalize() + .expect("PLONK CUDA library is missing"); + let directory = library.parent().unwrap(); + let packaged_header = + directory.parent().unwrap().join("include/ix_plonk_bn254.h"); + let header = if packaged_header.is_file() { + packaged_header + } else { + native.join("ix_plonk_bn254.h") + }; + println!("cargo:rerun-if-changed={}", native.display()); + println!("cargo:rerun-if-changed={}", library.display()); + println!("cargo:rerun-if-changed={}", header.display()); + // cgo does not include headers outside its package in its cache key. + let mut hash = std::collections::hash_map::DefaultHasher::new(); + fs::read(&header).unwrap().hash(&mut hash); + let cflags = format!( + "{} -I{} -DIX_PLONK_HEADER_{:x}=1", + env::var("CGO_CFLAGS").unwrap_or_default(), + header.parent().unwrap().display(), + hash.finish() + ); + tags.push("cuda"); + command.env("CGO_CFLAGS", cflags); + linker_flags + .push_str(&format!(" -L{} -lix_plonk_bn254_cuda", directory.display())); + println!("cargo:rustc-link-search=native={}", directory.display()); + println!("cargo:rustc-link-lib=dylib=ix_plonk_bn254_cuda"); + println!("cargo:rustc-link-lib=dylib=stdc++"); + println!( + "cargo:rustc-link-arg-bin=ix-compression-worker=-Wl,-rpath,$ORIGIN/../lib" + ); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", directory.display()); + // A library outside OUT_DIR needs an explicit path for packaging. + println!("cargo:rustc-env=IX_PLONK_CUDA_LIBRARY={}", library.display()); + } + if cuda && env::var_os("CARGO_FEATURE_GROTH16").is_some() { + assert_eq!(target_os, "linux", "Groth16 CUDA requires Linux"); + assert_eq!(target_arch, "x86_64", "Groth16 CUDA requires x86_64"); + let root = PathBuf::from( + env::var_os("IX_GROTH16_ICICLE_ROOT") + .expect("set IX_GROTH16_ICICLE_ROOT to the pinned ICICLE installation"), + ) + .canonicalize() + .expect("ICICLE installation is missing"); + let library = root.join("lib"); + for name in ["icicle_device", "icicle_field_bn254", "icicle_curve_bn254"] { + let file = library.join(format!("lib{name}.so")); + assert!(file.is_file(), "missing ICICLE library {}", file.display()); + println!("cargo:rerun-if-changed={}", file.display()); + println!("cargo:rustc-link-lib=dylib={name}"); + } + let backend = library.join("backend"); + assert!(backend.is_dir(), "ICICLE backend directory is missing"); + println!("cargo:rerun-if-changed={}", backend.display()); + println!("cargo:rustc-link-search=native={}", library.display()); + println!("cargo:rustc-link-lib=dylib=stdc++"); + println!("cargo:rustc-link-lib=dylib=m"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", library.display()); + println!("cargo:rustc-env=IX_GROTH16_ICICLE_ROOT={}", root.display()); + println!("cargo:rustc-env=IX_GROTH16_ICICLE_BACKEND={}", backend.display()); + linker_flags.push_str(&format!(" -L{}", library.display())); + tags.push("icicle"); + } + if !tags.is_empty() { + command.arg(format!("-tags={}", tags.join(","))); + } + command.env("CGO_LDFLAGS", linker_flags); + let archive = out.join("libix_gnark.a"); + let status = command + .arg("-o") + .arg(&archive) + .arg(".") + .status() + .unwrap_or_else(|e| panic!("cannot run {go:?}: {e}")); + assert!(status.success(), "gnark archive build failed"); + let header = out.join("libix_gnark.h"); + bindgen::Builder::default() + .header(header.to_string_lossy()) + .clang_arg(format!("-I{}", source.display())) + .allowlist_function("ix_gnark_.*") + .allowlist_type("ix_gnark_.*") + .layout_tests(false) + .generate_comments(false) + .generate() + .expect("gnark C bindings") + .write_to_file(out.join("gnark_bindings.rs")) + .expect("write gnark bindings"); + println!("cargo:rustc-link-search=native={}", out.display()); + println!("cargo:rustc-link-lib=static=ix_gnark"); + if target_os == "macos" { + println!("cargo:rustc-link-lib=framework=CoreFoundation"); + println!("cargo:rustc-link-lib=framework=Security"); + } else { + println!("cargo:rustc-link-lib=dylib=pthread"); + println!("cargo:rustc-link-lib=dylib=dl"); + } +} diff --git a/crates/proof-compression/src/bin/ix_compression_worker.rs b/crates/proof-compression/src/bin/ix_compression_worker.rs index 5de986c13..459580655 100644 --- a/crates/proof-compression/src/bin/ix_compression_worker.rs +++ b/crates/proof-compression/src/bin/ix_compression_worker.rs @@ -1,7 +1,6 @@ -//! The stage worker `ix compress` spawns: `ix-compression-worker -//! ` runs one FRI, first KZG or recursive KZG stage. Stages run in -//! their own executable so the Lean executable links no KZG proving code or -//! its CUDA runtime, and so each heavy phase exits before the next starts. +//! Compression stages run in separate processes so each heavy phase releases +//! its memory before the next starts. Native gnark and CUDA support belong to +//! this worker; the Lean-facing Rust library launches it without those runtimes. use tracing_subscriber::{ Layer, filter::FilterFn, layer::SubscriberExt, util::SubscriberInitExt, }; @@ -10,7 +9,8 @@ fn main() -> Result<(), Box> { let args: Vec = std::env::args().skip(1).collect(); let Some((stage, rest)) = args.split_first() else { return Err( - "usage: ix-compression-worker ".into(), + "usage: ix-compression-worker " + .into(), ); }; let texray = std::env::var("IX_TEXRAY").as_deref() == Ok("1"); diff --git a/crates/proof-compression/src/pipeline/gnark.rs b/crates/proof-compression/src/pipeline/gnark.rs new file mode 100644 index 000000000..f697c090b --- /dev/null +++ b/crates/proof-compression/src/pipeline/gnark.rs @@ -0,0 +1,446 @@ +//! Owned gnark objects behind the worker's C ABI. +use super::Result; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, ffi::c_void, marker::PhantomData, path::Path, rc::Rc, +}; + +#[allow( + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + dead_code, + unreachable_pub +)] +mod raw { + include!(concat!(env!("OUT_DIR"), "/gnark_bindings.rs")); +} + +pub use super::terminal::Artifact; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", deny_unknown_fields)] +pub struct Manifest { + #[serde(rename = "InnerVKSHA256")] + pub inner_vk_sha256: String, + #[serde(rename = "SRSBLAKE3")] + pub srs_blake3: String, + pub keys_ready: bool, + pub domain: u64, + pub constraints: usize, + pub public_inputs: usize, + pub commitments: usize, + pub files: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase", deny_unknown_fields)] +pub struct CircuitInfo { + pub domain: u64, + pub constraints: usize, + pub public_inputs: usize, + pub commitments: usize, +} + +#[derive(Clone, Copy, Debug, Default)] +pub enum Backend { + #[default] + Cpu, + Cuda { + device: i32, + memory_bytes: u64, + mask_msm: bool, + }, +} + +pub struct Proof { + pub native: Vec, + pub solidity: Vec, +} + +fn view(data: &[u8]) -> raw::ix_gnark_view { + raw::ix_gnark_view { data: data.as_ptr(), len: data.len() } +} +fn path(path: &Path) -> Result<&[u8]> { + Ok(path.to_str().ok_or("gnark paths must be UTF-8")?.as_bytes()) +} +struct Response(raw::ix_gnark_result); +impl Drop for Response { + fn drop(&mut self) { + // All three buffers come from the Go adapter's C allocator. + unsafe { + raw::ix_gnark_free(self.0.data.cast::()); + raw::ix_gnark_free(self.0.solidity.cast::()); + raw::ix_gnark_free(self.0.error.cast::()); + } + } +} +impl Response { + fn checked(result: raw::ix_gnark_result) -> Result { + let response = Self(result); + if !response.0.error.is_null() { + // The adapter owns a bounded error buffer until Response drops. + let bytes = unsafe { + std::slice::from_raw_parts(response.0.error, response.0.error_len) + }; + return Err(String::from_utf8_lossy(bytes).into_owned().into()); + } + Ok(response) + } + fn bytes(&self) -> &[u8] { + if self.0.len == 0 { + return &[]; + } + // The response keeps this allocation alive for the returned borrow. + unsafe { std::slice::from_raw_parts(self.0.data, self.0.len) } + } +} + +pub fn compile(schema: &[u8], circuit_path: &Path) -> Result { + // Inputs remain borrowed until the synchronous C call returns. + let result = + unsafe { raw::ix_gnark_compile(view(schema), view(path(circuit_path)?)) }; + Ok(serde_json::from_slice(Response::checked(result)?.bytes())?) +} + +pub struct Preparation<'a> { + pub schema: &'a [u8], + pub circuit: &'a Path, + pub circuit_hash: &'a Artifact, + pub domain: u64, + pub srs: &'a Path, + pub srs_digest: &'a str, + pub lagrange: &'a Path, + pub lagrange_digest: &'a str, + pub output: &'a Path, +} + +pub fn prepare(request: &Preparation<'_>) -> Result<()> { + // The adapter retains no Rust pointers after this call. + let result = unsafe { + raw::ix_gnark_prepare( + view(request.schema), + view(path(request.circuit)?), + view(request.circuit_hash.sha256.as_bytes()), + request.circuit_hash.bytes, + request.domain, + view(path(request.srs)?), + view(request.srs_digest.as_bytes()), + view(path(request.lagrange)?), + view(request.lagrange_digest.as_bytes()), + view(path(request.output)?), + ) + }; + Response::checked(result)?; + Ok(()) +} + +/// A loaded circuit and keys. Calls are exclusive; Drop releases the Go handle. +/// Artifact digests must come from trusted preparation or cache metadata. +pub struct Prover { + handle: Option, + _thread: PhantomData>, +} +impl Prover { + pub fn open( + directory: &Path, + manifest: &Manifest, + backend: Backend, + ) -> Result { + let (cuda, device, memory_bytes, mask_msm) = match backend { + Backend::Cpu => (0, 0, 0, 0), + Backend::Cuda { device, memory_bytes, mask_msm } => { + (1, device, memory_bytes, u32::from(mask_msm)) + }, + }; + let options = + raw::ix_gnark_backend { cuda, device, memory_bytes, mask_msm }; + let metadata = serde_json::to_vec(manifest)?; + // The handle returned by Go is an integer, never a Go heap pointer. + let response = Response::checked(unsafe { + raw::ix_gnark_open(view(path(directory)?), view(&metadata), options) + })?; + if response.0.handle == 0 { + return Err("gnark returned an empty prover handle".into()); + } + Ok(Self { handle: Some(response.0.handle), _thread: PhantomData }) + } + + pub fn prove( + &mut self, + witness: &[u8], + expected_claim: &[u8; 32], + ) -> Result { + let handle = self.handle.ok_or("prover is closed")?; + // &mut self excludes close or another proof while Go uses this handle. + let response = Response::checked(unsafe { + raw::ix_gnark_prove(handle, view(witness), view(expected_claim)) + })?; + let solidity = if response.0.solidity_len == 0 { + Vec::new() + } else { + // Copy both outputs before releasing the C-owned response. + unsafe { + std::slice::from_raw_parts(response.0.solidity, response.0.solidity_len) + } + .to_vec() + }; + Ok(Proof { native: response.bytes().to_vec(), solidity }) + } + + pub fn close(mut self) -> Result<()> { + self.release() + } + fn release(&mut self) -> Result<()> { + if let Some(handle) = self.handle.take() { + // Taking the handle makes explicit close and Drop mutually exclusive. + Response::checked(unsafe { raw::ix_gnark_close(handle) })?; + } + Ok(()) + } +} +impl Drop for Prover { + fn drop(&mut self) { + let _ = self.release(); + } +} + +pub fn verify( + key: &[u8], + trusted_digest: &[u8; 32], + proof: &[u8], + claim: &[u8; 32], + anchors: &[u8], +) -> Result<()> { + // All inputs are borrowed only for the duration of the call. + Response::checked(unsafe { + raw::ix_gnark_verify( + view(key), + view(trusted_digest), + view(proof), + view(claim), + view(anchors), + ) + })?; + Ok(()) +} + +#[cfg(all(test, feature = "plonk"))] +mod tests { + use super::*; + use crate::pipeline::terminal::{decode_hex, digest}; + const KEY: &[u8] = + include_bytes!("../../tests/fixtures/gnark/verifying-key.bin"); + const PROOF: &[u8] = include_bytes!("../../tests/fixtures/gnark/proof.bin"); + fn fixture() -> ([u8; 32], [u8; 32], Vec) { + let p: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/gnark/provenance.json" + )) + .unwrap(); + ( + digest(p["key_sha256"].as_str().unwrap()).unwrap(), + digest(p["claim_digest"].as_str().unwrap()).unwrap(), + decode_hex(p["anchors"].as_str().unwrap()).unwrap(), + ) + } + #[test] + fn verifies_init_and_rejects_changed_claim_key_and_proof() { + let (hash, claim, anchors) = fixture(); + verify(KEY, &hash, PROOF, &claim, &anchors).unwrap(); + let mut wrong = claim; + wrong[0] ^= 1; + assert!(verify(KEY, &hash, PROOF, &wrong, &anchors).is_err()); + let mut wrong = hash; + wrong[0] ^= 1; + assert!(verify(KEY, &wrong, PROOF, &claim, &anchors).is_err()); + let mut proof = PROOF.to_vec(); + proof[0] ^= 1; + assert!(verify(KEY, &hash, &proof, &claim, &anchors).is_err()); + assert!( + verify(KEY, &hash, &PROOF[..PROOF.len() - 1], &claim, &anchors).is_err() + ); + let mut wrong = anchors.clone(); + wrong[0] ^= 1; + assert!(verify(KEY, &hash, PROOF, &claim, &wrong).is_err()); + } + #[test] + fn invalid_handles_and_buffers_return_errors() { + // Deliberately violate the raw ABI contract to exercise its validation. + assert!(Response::checked(unsafe { raw::ix_gnark_close(0) }).is_err()); + let invalid = raw::ix_gnark_view { data: std::ptr::null(), len: 1 }; + assert!( + Response::checked(unsafe { + raw::ix_gnark_compile(invalid, view(b"unused")) + }) + .is_err() + ); + let oversized = raw::ix_gnark_view { data: b"x".as_ptr(), len: usize::MAX }; + assert!( + Response::checked(unsafe { + raw::ix_gnark_compile(oversized, view(b"unused")) + }) + .is_err() + ); + } + #[test] + #[ignore = "compiles the full Init verifier circuit"] + fn compiles_init_without_changing_constraints() -> Result<()> { + use sha2::Digest; + let directory = crate::pipeline::terminal::Scratch::new()?; + let schema = + include_bytes!("../../../../tools/plonk-wrapper/testdata/bn254/vk.json"); + let path = directory.0.join("circuit.bin"); + let info = compile(schema, &path)?; + assert_eq!(info.constraints, 14689440); + assert_eq!(info.domain, 1 << 24); + assert_eq!(info.public_inputs, 2); + let digest = sha2::Sha256::digest(std::fs::read(&path)?); + assert_eq!( + crate::pipeline::terminal::hex(&digest), + "a684964f61296fcda0df427980275764df242590373ce5680f70b0ac5dcfbded" + ); + Ok(()) + } + + #[test] + #[ignore = "requires the authenticated Init proving artifacts and tens of GiB of memory"] + fn loaded_prover_reuses_keys() -> Result<()> { + let dir = + std::path::PathBuf::from(std::env::var("IX_GNARK_INIT_ARTIFACTS")?); + let data = std::fs::read(dir.join("manifest.json"))?; + let expected = std::env::var("IX_GNARK_INIT_MANIFEST_SHA256")?; + use sha2::Digest; + assert_eq!( + crate::pipeline::terminal::hex(&sha2::Sha256::digest(&data)), + expected + ); + let metadata: Manifest = serde_json::from_slice(&data)?; + let witness = std::fs::read(std::env::var("IX_GNARK_INIT_WITNESS")?)?; + let backend = crate::pipeline::plonk::proving_backend()?; + let (hash, claim, anchors) = fixture(); + let mut prover = Prover::open(&dir, &metadata, backend)?; + let mut wrong = claim; + wrong[0] ^= 1; + assert!(prover.prove(&witness, &wrong).is_err()); + let first = prover.prove(&witness, &claim)?; + verify(KEY, &hash, &first.native, &claim, &anchors)?; + let second = prover.prove(&witness, &claim)?; + verify(KEY, &hash, &second.native, &claim, &anchors)?; + assert_ne!(first.native, second.native); + let handle = prover.handle.unwrap(); + prover.close()?; + assert!(Response::checked(unsafe { raw::ix_gnark_close(handle) }).is_err()); + Ok(()) + } +} + +#[cfg(feature = "groth16")] +pub(super) fn compile_groth16( + schema: &[u8], + output: &Path, +) -> Result { + // Both views are borrowed only until the native call returns. + let response = Response::checked(unsafe { + raw::ix_gnark_groth16_compile(view(schema), view(path(output)?)) + })?; + Ok(serde_json::from_slice(response.bytes())?) +} + +#[cfg(feature = "groth16")] +#[derive(Clone, Copy)] +pub(super) enum Groth16Setup<'a> { + Development, + Ceremony { path: &'a Path, digest: &'a str }, + Import { path: &'a Path, digest: &'a str }, +} + +#[cfg(feature = "groth16")] +pub(super) fn setup_groth16( + directory: &Path, + metadata: &super::groth16::Metadata, + setup: Groth16Setup<'_>, +) -> Result { + let (kind, descriptor, pin) = match setup { + Groth16Setup::Development => (0, b"".as_slice(), ""), + Groth16Setup::Ceremony { path: file, digest } => (1, path(file)?, digest), + Groth16Setup::Import { path: file, digest } => (2, path(file)?, digest), + }; + let encoded = serde_json::to_vec(metadata)?; + // The adapter retains no Rust pointers while writing native keys. + let response = Response::checked(unsafe { + raw::ix_gnark_groth16_setup( + view(path(directory)?), + view(&encoded), + kind, + view(descriptor), + view(pin.as_bytes()), + ) + })?; + Ok(serde_json::from_slice(response.bytes())?) +} + +#[cfg(feature = "groth16")] +#[derive(Debug, Default)] +pub(super) enum Groth16Backend { + #[default] + Cpu, + Cuda { + device: i32, + pin_keys: bool, + library: String, + }, +} + +#[cfg(feature = "groth16")] +impl Prover { + pub(super) fn open_groth16( + directory: &Path, + metadata: &super::groth16::Metadata, + backend: &Groth16Backend, + ) -> Result { + let (cuda, device, pin_keys, library) = match backend { + Groth16Backend::Cpu => (0, 0, 0, ""), + Groth16Backend::Cuda { device, pin_keys, library } => { + (1, *device, u32::from(*pin_keys), library.as_str()) + }, + }; + let options = raw::ix_gnark_groth16_backend { + cuda, + device, + pin_keys, + library: view(library.as_bytes()), + }; + let encoded = serde_json::to_vec(metadata)?; + // The native handle owns the circuit, keys, and optional device allocations. + let response = Response::checked(unsafe { + raw::ix_gnark_groth16_open( + view(path(directory)?), + view(&encoded), + options, + ) + })?; + if response.0.handle == 0 { + return Err("gnark returned an empty prover handle".into()); + } + Ok(Self { handle: Some(response.0.handle), _thread: PhantomData }) + } +} + +#[cfg(feature = "groth16")] +pub(super) fn verify_groth16( + key: &[u8], + trusted: &[u8; 32], + proof: &[u8], + claim: &[u8; 32], +) -> Result<()> { + // Input buffers remain alive throughout native verification. + Response::checked(unsafe { + raw::ix_gnark_groth16_verify( + view(key), + view(trusted), + view(proof), + view(claim), + ) + })?; + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/groth16.rs b/crates/proof-compression/src/pipeline/groth16.rs index 4d801693e..14d3db1fe 100644 --- a/crates/proof-compression/src/pipeline/groth16.rs +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -1,16 +1,29 @@ //! Terminal BN254 Groth16 proofs under independently admitted circuit keys. +#[cfg(feature = "groth16")] +use super::gnark; +use super::storage; +#[cfg(any(not(feature = "groth16"), test))] +use super::terminal::Scratch; +#[cfg(not(feature = "groth16"))] +use super::terminal::checked; +#[cfg(any(feature = "groth16", test))] +use super::terminal::sha256; use super::{ Result, setup::SetupClass, - statement, storage, - terminal::{Scratch, checked, claim_digest, digest, hex, load_json, sha256}, - wrap, + statement, + terminal::{Artifact, digest, hex, load_json}, }; -use std::{ - fs, - path::{Path, PathBuf}, - process::Command, +#[cfg(feature = "groth16")] +use super::{ + terminal::{claim_digest, read_bounded}, + wrap, }; +#[cfg(any(feature = "groth16", test))] +use std::fs; +use std::path::{Path, PathBuf}; +#[cfg(not(feature = "groth16"))] +use std::process::Command; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x03"; const CATALOG_FORMAT: &str = "ix-bn254-groth16-verifiers-v1"; @@ -107,80 +120,107 @@ pub(super) fn verify( claim: &[u8], allow_development: bool, ) -> Result { - if !matches!(packet.len(), 196 | 228) { + if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { return Err("invalid Groth16 packet length".into()); } - verify_with_catalog( - packet, - claim, - &catalog_path()?, - &super::terminal::helper_executable("IX_GROTH16_HELPER")?, - allow_development, - ) + verify_with_catalog(packet, claim, &catalog_path()?, allow_development) } fn verify_with_catalog( packet: &[u8], claim: &[u8], catalog: &Path, - helper: &Path, allow_development: bool, ) -> Result { - if !matches!(packet.len(), 196 | 228) { + if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { return Err("invalid Groth16 packet length".into()); } let id = packet[..32].try_into()?; let key = select_key(catalog, id, allow_development)?; - if fs::metadata(&key.path)?.len() > 1 << 20 || sha256(&key.path)? != *id { - return Err( - "Groth16 verifying key differs from the trusted catalog".into(), - ); - } - let scratch = Scratch::new()?; - let proof = scratch.0.join("proof.bin"); - storage::write_atomic(&proof, &packet[32..])?; - checked( - Command::new(helper) - .arg("groth16") - .arg("verify") - .env("CUDA_VISIBLE_DEVICES", "") - .arg(&key.path) - .arg(hex(id)) - .arg(proof) - .arg(blake3::hash(claim).to_hex().as_str()), + let expected = *blake3::hash(claim).as_bytes(); + #[cfg(feature = "groth16")] + gnark::verify_groth16( + &read_bounded(&key.path, 1 << 20)?, + id, + &packet[32..], + &expected, )?; + #[cfg(not(feature = "groth16"))] + { + let scratch = Scratch::new()?; + let proof = scratch.0.join("proof.bin"); + storage::write_atomic(&proof, &packet[32..])?; + checked( + Command::new(super::worker_executable()?) + .args(["groth16", "verify-key"]) + .arg(&key.path) + .arg(hex(id)) + .arg(proof) + .arg(hex(&expected)), + )?; + } key.setup_class(allow_development) } -#[derive(serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct Metadata { - format: String, - curve: String, +pub(super) struct Metadata { #[serde(rename = "innerVKSHA256")] inner_vk: String, - commitment_hash: String, constraints: usize, domain: u64, public_inputs: usize, commitments: usize, setup: String, - #[serde(default, rename = "ceremonySHA256")] + #[serde( + default, + rename = "ceremonySHA256", + skip_serializing_if = "Option::is_none" + )] ceremony: Option, keys_ready: bool, files: std::collections::BTreeMap, } -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct Artifact { - #[serde(rename = "SHA256")] - sha256: String, - #[serde(rename = "Bytes")] - bytes: u64, -} - impl Metadata { + fn validate(&self) -> Result<()> { + if self.public_inputs != 2 + || self.commitments > 1 + || self.constraints == 0 + || self.constraints > 1 << 27 + || self.domain != u64::try_from(self.constraints)?.next_power_of_two() + { + return Err("unsupported Groth16 circuit metadata".into()); + } + digest(&self.inner_vk)?; + if self.artifact("vk.json")?.sha256 != self.inner_vk { + return Err("Groth16 schema identity differs".into()); + } + self.artifact("circuit.bin")?; + if self.keys_ready { + match self.setup.as_str() { + "mpc" => { + digest( + self + .ceremony + .as_deref() + .ok_or("missing Groth16 ceremony identity")?, + )?; + }, + "development" if self.ceremony.is_none() => {}, + _ => return Err("invalid Groth16 setup metadata".into()), + } + for name in + ["proving-key.bin", "verifying-key.bin", "Groth16Verifier.sol"] + { + self.artifact(name)?; + } + } else if !self.setup.is_empty() || self.ceremony.is_some() { + return Err("unprepared Groth16 circuit carries setup metadata".into()); + } + Ok(()) + } + fn artifact(&self, name: &str) -> Result<&Artifact> { let value = self .files @@ -200,17 +240,9 @@ fn prepared(allow_development: bool) -> Result<(PathBuf, TrustedKey)> { format!("set {ARTIFACTS_VAR} to prepared Groth16 circuit artifacts") })?; let m: Metadata = load_json(&dir.join("manifest.json"))?; - if m.format != "ix-bn254-groth16-artifacts-v1" - || m.curve != "bn254" - || m.commitment_hash != "sha256" - || !m.keys_ready - || m.public_inputs != 2 - || m.commitments > 1 - || m.constraints == 0 - || m.constraints > 1 << 27 - || m.domain != u64::try_from(m.constraints)?.next_power_of_two() - { - return Err("unsupported or unprepared Groth16 artifact metadata".into()); + m.validate()?; + if !m.keys_ready { + return Err("Groth16 keys are not prepared".into()); } let key = select_key( &catalog_path()?, @@ -234,26 +266,8 @@ fn prepared(allow_development: bool) -> Result<(PathBuf, TrustedKey)> { } pub(super) fn preflight(allow_development: bool) -> Result<()> { - let helper = super::terminal::helper_executable("IX_GROTH16_HELPER")?; - let output = - Command::new(helper).args(["groth16", "capabilities"]).output()?; - if !output.status.success() { - return Err("terminal helper does not support Groth16".into()); - } - let capabilities: serde_json::Value = serde_json::from_slice(&output.stdout)?; - if capabilities["system"] != "groth16" || capabilities["curve"] != "bn254" { - return Err( - "terminal helper reports another Groth16 system or curve".into(), - ); - } match std::env::var("IX_GROTH16_BACKEND").as_deref() { - Err(_) | Ok("" | "cpu") => {}, - Ok("cuda") if capabilities["cuda"] == true => {}, - Ok("cuda") => { - return Err( - "terminal helper was built without CUDA Groth16 support".into(), - ); - }, + Err(std::env::VarError::NotPresent) | Ok("" | "cpu" | "cuda") => {}, _ => return Err("IX_GROTH16_BACKEND must be cpu or cuda".into()), } prepared(allow_development)?; @@ -273,6 +287,7 @@ pub(super) fn check_statement( Ok(()) } +#[cfg(feature = "groth16")] fn wrap_packet( verifier: &Path, packet: &Path, @@ -304,24 +319,12 @@ fn wrap_packet( ); } let claim = claim_digest(expected)?; - let helper = super::terminal::helper_executable("IX_GROTH16_HELPER")?; - checked( - Command::new(&helper) - .arg("groth16") - .arg("prove") - .arg(&artifacts) - .arg(input.join("witness.json")) - .arg(hex(&claim)) - .arg(out), - )?; - checked( - Command::new(&helper) - .arg("groth16") - .arg("verify") - .arg(&key.path) - .arg(&key.sha256) - .arg(out.join("proof.bin")) - .arg(hex(&claim)), + prove(&artifacts, &input.join("witness.json"), &claim, out)?; + gnark::verify_groth16( + &read_bounded(&key.path, 1 << 20)?, + &digest(&key.sha256)?, + &read_bounded(&out.join("proof.bin"), MAX_PROOF_BYTES)?, + &claim, )?; let proof = fs::read(out.join("proof.bin"))?; if !matches!(proof.len(), 164 | MAX_PROOF_BYTES) { @@ -337,6 +340,7 @@ fn wrap_packet( Ok(()) } +#[cfg(feature = "groth16")] pub(crate) fn run(args: &[String]) -> Result<()> { let development_flags = args.iter().filter(|s| s.as_str() == "--allow-development").count(); @@ -355,12 +359,18 @@ pub(crate) fn run(args: &[String]) -> Result<()> { .map(String::as_str) .collect(); match positional.as_slice() { + ["compile", schema, out] => compile(Path::new(schema), Path::new(out)), + ["setup-development", dir] => setup(Path::new(dir), gnark::Groth16Setup::Development), + ["setup-mpc", dir, descriptor, pin] => { digest(pin)?; setup(Path::new(dir), gnark::Groth16Setup::Ceremony { path:Path::new(descriptor),digest:pin }) }, + ["import", dir, descriptor, pin] => { digest(pin)?; setup(Path::new(dir), gnark::Groth16Setup::Import { path:Path::new(descriptor),digest:pin }) }, + ["prove", dir, witness, claim, out] => prove(Path::new(dir),Path::new(witness),&digest(claim)?,Path::new(out)), + ["verify-key", key, pin, proof, claim] => gnark::verify_groth16(&read_bounded(Path::new(key), 1<<20)?, &digest(pin)?, &read_bounded(Path::new(proof), MAX_PROOF_BYTES)?, &digest(claim)?), ["export", verifier, packet, trusted, expected, out] => { let expected = load_json(Path::new(expected))?; wrap::plonk_export(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(out)) }, ["verify", proof, claim] => { - let bytes = fs::read(proof)?; + let bytes = read_bounded(Path::new(proof), MAX_PROOF_BYTES + 32 + PROOF_TAG.len())?; let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported Groth16 proof curve or version")?; let setup = verify(packet, &fs::read(claim)?, allow_development)?; println!("{}", serde_json::json!({"verified": true, "curve": "bn254", "setup": setup.label()})); @@ -370,19 +380,143 @@ pub(crate) fn run(args: &[String]) -> Result<()> { let expected = load_json(Path::new(expected))?; wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(out), allow_development) }, - _ => Err("usage: ix-compression-worker groth16 export | wrap [--allow-development] | verify [--allow-development]".into()), + _ => Err("usage: ix-compression-worker groth16 compile | setup-development | setup-mpc | import | prove | export | wrap [--allow-development] | verify [--allow-development]".into()), } } +#[cfg(feature = "groth16")] +fn artifact(path: &Path) -> Result { + Ok(Artifact { sha256: hex(&sha256(path)?), bytes: fs::metadata(path)?.len() }) +} + +#[cfg(feature = "groth16")] +fn compile(schema: &Path, out: &Path) -> Result<()> { + super::prepare_output(out)?; + let schema = read_bounded(schema, 16 << 20)?; + let info = gnark::compile_groth16(&schema, &out.join("circuit.bin"))?; + storage::write_atomic(&out.join("vk.json"), &schema)?; + let mut files = std::collections::BTreeMap::new(); + for name in ["vk.json", "circuit.bin"] { + files.insert(name.into(), artifact(&out.join(name))?); + } + let metadata = Metadata { + inner_vk: files["vk.json"].sha256.clone(), + constraints: info.constraints, + domain: info.domain, + public_inputs: info.public_inputs, + commitments: info.commitments, + setup: String::new(), + ceremony: None, + keys_ready: false, + files, + }; + metadata.validate()?; + storage::write_atomic( + &out.join("manifest.json"), + &serde_json::to_vec_pretty(&metadata)?, + )?; + Ok(()) +} + +#[cfg(feature = "groth16")] +fn setup(dir: &Path, request: gnark::Groth16Setup<'_>) -> Result<()> { + let metadata: Metadata = load_json(&dir.join("manifest.json"))?; + metadata.validate()?; + if metadata.keys_ready { + return Err("Groth16 keys are already prepared".into()); + } + let prepared = gnark::setup_groth16(dir, &metadata, request)?; + prepared.validate()?; + if !prepared.keys_ready { + return Err("Groth16 setup did not return prepared keys".into()); + } + storage::write_atomic( + &dir.join("manifest.json"), + &serde_json::to_vec_pretty(&prepared)?, + )?; + Ok(()) +} + +#[cfg(feature = "groth16")] +fn proving_backend() -> Result { + match std::env::var("IX_GROTH16_BACKEND").as_deref() { + Err(std::env::VarError::NotPresent) | Ok("" | "cpu") => { + Ok(gnark::Groth16Backend::Cpu) + }, + Ok("cuda") => { + if !cfg!(feature = "cuda") { + return Err("Groth16 CUDA requires groth16 and cuda features".into()); + } + let device = std::env::var("IX_GROTH16_DEVICE") + .unwrap_or_else(|_| "0".into()) + .parse()?; + if device < 0 { + return Err("invalid Groth16 device".into()); + } + let pin_keys = match std::env::var("IX_GROTH16_PIN_KEY").as_deref() { + Err(std::env::VarError::NotPresent) | Ok("" | "0") => false, + Ok("1") => true, + _ => return Err("IX_GROTH16_PIN_KEY must be 0 or 1".into()), + }; + let library = std::env::var("ICICLE_BACKEND_INSTALL_DIR") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| { + let installed = std::env::current_exe() + .ok()? + .parent()? + .parent()? + .join("lib/backend"); + installed.is_dir().then(|| installed.to_string_lossy().into_owned()) + }) + .or_else(|| option_env!("IX_GROTH16_ICICLE_BACKEND").map(str::to_owned)) + .unwrap_or_default(); + Ok(gnark::Groth16Backend::Cuda { device, pin_keys, library }) + }, + _ => Err("IX_GROTH16_BACKEND must be cpu or cuda".into()), + } +} + +#[cfg(feature = "groth16")] +fn prove( + dir: &Path, + witness: &Path, + claim: &[u8; 32], + out: &Path, +) -> Result<()> { + let metadata: Metadata = load_json(&dir.join("manifest.json"))?; + metadata.validate()?; + let backend = proving_backend()?; + let start = std::time::Instant::now(); + let mut prover = gnark::Prover::open_groth16(dir, &metadata, &backend)?; + let load_seconds = start.elapsed().as_secs_f64(); + let witness = read_bounded(witness, 16 << 20)?; + let start = std::time::Instant::now(); + let proof = prover.prove(&witness, claim)?; + let prove_seconds = start.elapsed().as_secs_f64(); + prover.close()?; + fs::create_dir_all(out)?; + storage::write_atomic(&out.join("proof.bin"), &proof.native)?; + storage::write_atomic(&out.join("proof.solidity.bin"), &proof.solidity)?; + storage::write_atomic( + &out.join("report.json"), + &serde_json::to_vec_pretty(&serde_json::json!({ + "system":"groth16", "curve":"bn254", "backend":match backend { gnark::Groth16Backend::Cpu=>"cpu",gnark::Groth16Backend::Cuda{..}=>"cuda" }, + "setup":metadata.setup, "loadSeconds":load_seconds, "proveVerifySeconds":prove_seconds, + "nativeBytes":proof.native.len(), "solidityBytes":proof.solidity.len(), "claimDigest":hex(claim), + "verifyingKeySHA256":metadata.artifact("verifying-key.bin")?.sha256, + }))?, + )?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] - #[ignore = "requires a compiled terminal helper in IX_GROTH16_HELPER"] + #[cfg(feature = "groth16")] fn saved_init_groth16_proof_verifies_without_gpu() -> Result<()> { - let helper = - super::super::terminal::helper_executable("IX_GROTH16_HELPER")?; let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/groth16"); let provenance: serde_json::Value = @@ -390,6 +524,10 @@ mod tests { let bytes = fs::read(dir.join("proof.bin"))?; let vk = dir.join("verifying-key.bin"); let id = sha256(&vk)?; + assert_eq!( + hex(&id), + provenance["files"]["verifying-key.bin"].as_str().unwrap() + ); let claim = include_bytes!("../../tests/fixtures/init-fri/claim.bin"); assert_eq!( blake3::hash(claim).to_hex().as_str(), @@ -399,7 +537,7 @@ mod tests { packet.extend(bytes); let key = TrustedKey { sha256: hex(&id), - path: vk, + path: vk.clone(), inner_vk_id: provenance["inner_vk_id"].as_str().unwrap().into(), inner_schema_sha256: provenance["inner_schema_sha256"] .as_str() @@ -421,21 +559,27 @@ mod tests { keys: vec![key], })?, )?; - assert!( - verify_with_catalog(&packet, claim, &catalog, &helper, false).is_err() - ); + assert!(verify_with_catalog(&packet, claim, &catalog, false).is_err()); assert_eq!( - verify_with_catalog(&packet, claim, &catalog, &helper, true)?, + verify_with_catalog(&packet, claim, &catalog, true)?, SetupClass::Development ); assert!( - verify_with_catalog(&packet, b"another claim", &catalog, &helper, true) - .is_err() + verify_with_catalog(&packet, b"another claim", &catalog, true).is_err() ); - packet[32] = 0; + let mut wrong_key = fs::read(&vk)?; + wrong_key[0] ^= 1; assert!( - verify_with_catalog(&packet, claim, &catalog, &helper, true).is_err() + gnark::verify_groth16( + &wrong_key, + &id, + &packet[32..], + &digest(provenance["claim_digest"].as_str().unwrap())? + ) + .is_err() ); + packet[32] = 0; + assert!(verify_with_catalog(&packet, claim, &catalog, true).is_err()); Ok(()) } @@ -484,7 +628,7 @@ mod tests { } #[test] - fn malformed_envelopes_fail_before_key_or_helper_access() { + fn malformed_envelopes_fail_before_key_access() { for length in [0, 32, 195, 197, 227, 229, 1 << 20] { assert!( verify(&vec![0; length], b"claim", false) @@ -495,3 +639,70 @@ mod tests { } } } + +#[cfg(all(test, feature = "groth16"))] +mod native_tests { + use super::*; + + #[test] + #[ignore = "compiles the full Init verifier circuit"] + fn compiles_init_without_changing_constraints() -> Result<()> { + let directory = Scratch::new()?; + let schema = + include_bytes!("../../../../tools/plonk-wrapper/testdata/bn254/vk.json"); + let path = directory.0.join("circuit.bin"); + let info = gnark::compile_groth16(schema, &path)?; + let expected: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/groth16/provenance.json" + ))?; + assert_eq!( + u64::try_from(info.constraints)?, + expected["constraints"].as_u64().unwrap() + ); + assert_eq!(info.domain, expected["domain"].as_u64().unwrap()); + assert_eq!(info.commitments, 1); + assert_eq!(info.public_inputs, 2); + assert_eq!( + hex(&sha256(&path)?), + expected["circuit_sha256"].as_str().unwrap() + ); + Ok(()) + } + + #[test] + #[ignore = "requires prepared Init proving artifacts and several GiB of memory"] + fn retained_init_prover_reuses_keys() -> Result<()> { + let dir = PathBuf::from(std::env::var("IX_GROTH16_ARTIFACTS")?); + let metadata: Metadata = load_json(&dir.join("manifest.json"))?; + metadata.validate()?; + let provenance: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/groth16/provenance.json" + ))?; + assert_eq!( + metadata.artifact("verifying-key.bin")?.sha256, + provenance["files"]["verifying-key.bin"].as_str().unwrap() + ); + let key = include_bytes!("../../tests/fixtures/groth16/verifying-key.bin"); + let expected_key = digest(&metadata.artifact("verifying-key.bin")?.sha256)?; + let claim = digest(provenance["claim_digest"].as_str().unwrap())?; + let witness = include_bytes!( + "../../../../tools/plonk-wrapper/testdata/bn254/witness.json" + ); + let mut prover = + gnark::Prover::open_groth16(&dir, &metadata, &proving_backend()?)?; + let first = prover.prove(witness, &claim)?; + let second = prover.prove(witness, &claim)?; + for proof in [&first, &second] { + gnark::verify_groth16(key, &expected_key, &proof.native, &claim)?; + assert_eq!(proof.native.len(), 196); + assert_eq!(proof.solidity.len(), 384); + } + assert_ne!(first.native, second.native); + let mut wrong = claim; + wrong[0] ^= 1; + assert!(prover.prove(witness, &wrong).is_err()); + assert!(prover.prove(witness, &claim).is_err()); + prover.close()?; + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index cb9de4d9c..1caa57e83 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -9,15 +9,15 @@ pub use stages::{ mod driver; mod first; mod fixed_cache; -#[cfg(feature = "groth16")] +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub mod gnark; mod groth16; #[cfg(test)] mod init_claim; mod init_fri; -#[cfg(feature = "plonk")] mod plonk; mod terminal; -pub use terminal::{TerminalOptions, TerminalSystem}; +pub use terminal::{Artifact, TerminalOptions, TerminalSystem}; mod prover; mod root; mod setup; @@ -253,6 +253,18 @@ fn check_worker_curve(ignition: bool, terminal: TerminalSystem) -> Result<()> { if capabilities[flag] != true { return Err(format!("compression worker does not support {flag}").into()); } + let backend = match terminal { + TerminalSystem::Plonk => "IX_PLONK_BACKEND", + TerminalSystem::Groth16 => "IX_GROTH16_BACKEND", + TerminalSystem::None => unreachable!(), + }; + if std::env::var(backend).as_deref() == Ok("cuda") + && capabilities[format!("{flag}_cuda")] != true + { + return Err( + format!("compression worker lacks CUDA support for {flag}").into(), + ); + } Ok(()) } @@ -298,11 +310,9 @@ pub fn verify( claim: &[u8], allow_development: bool, ) -> Result { - #[cfg(feature = "groth16")] if let Some(packet) = proof.strip_prefix(groth16::PROOF_TAG) { return groth16::verify(packet, claim, allow_development); } - #[cfg(feature = "plonk")] if let Some(packet) = proof.strip_prefix(plonk::PROOF_TAG) { return plonk::verify(packet, claim); } @@ -391,29 +401,14 @@ fn validate_compression_options( "development terminal keys are supported only for Groth16".into(), ); } - if terminal.system == TerminalSystem::Groth16 { - #[cfg(not(feature = "groth16"))] - return Err("this build does not include terminal Groth16 support".into()); - #[cfg(feature = "groth16")] - { - if development { - return Err("terminal Groth16 requires admitted inner KZG parameters; omit --dev-srs".into()); - } - groth16::preflight(terminal.allow_development)?; - } + if terminal.system != TerminalSystem::None && development { + return Err( + "terminal proofs require admitted inner KZG parameters; omit --dev-srs" + .into(), + ); } - if terminal.system == TerminalSystem::Plonk { - #[cfg(not(feature = "plonk"))] - return Err("this build does not include terminal PLONK support".into()); - #[cfg(feature = "plonk")] - { - if development { - return Err( - "terminal PLONK requires admitted ceremony parameters; omit --dev-srs".into(), - ); - } - plonk::helper_executable()?; - } + if terminal.system == TerminalSystem::Groth16 { + groth16::preflight(terminal.allow_development)?; } if !matches!(log_height, 24 | 25) { return Err( @@ -532,7 +527,6 @@ pub fn compress_with_terminal( ) -> Result> { let words = check_claim(claim, statement)?; let setup = validate_compression_options(development, log_height, terminal)?; - #[cfg(feature = "groth16")] if terminal.system == TerminalSystem::Groth16 { groth16::check_statement(&words, terminal.allow_development)?; } @@ -639,56 +633,50 @@ fn finish_kzg( ); } if terminal.system == TerminalSystem::Groth16 { - #[cfg(feature = "groth16")] - { - let terminal_out = out.join("groth16"); - let expected_path = final_dir.join("expected-statement.json"); - storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; - let verifier = final_dir.join("vk.bin"); - let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); - child( - "groth16", - &[ - "wrap", - &path(&verifier)?, - &path(&final_dir.join("kzg/packet.bin"))?, - trusted.as_str(), - &path(&expected_path)?, - &path(&terminal_out)?, - if terminal.allow_development { - "--allow-development" - } else { - "--production" - }, - ], - &caches, - )?; - return Ok(fs::read(terminal_out.join("proof.envelope.bin"))?); - } + let terminal_out = out.join("groth16"); + let expected_path = final_dir.join("expected-statement.json"); + storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; + let verifier = final_dir.join("vk.bin"); + let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); + child( + "groth16", + &[ + "wrap", + &path(&verifier)?, + &path(&final_dir.join("kzg/packet.bin"))?, + trusted.as_str(), + &path(&expected_path)?, + &path(&terminal_out)?, + if terminal.allow_development { + "--allow-development" + } else { + "--production" + }, + ], + &caches, + )?; + return Ok(fs::read(terminal_out.join("proof.envelope.bin"))?); } if terminal.system == TerminalSystem::Plonk { - #[cfg(feature = "plonk")] - { - let out_plonk = out.join("plonk"); - let expected_path = final_dir.join("expected-statement.json"); - storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; - let verifier = final_dir.join("vk.bin"); - let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); - child( - "plonk", - &[ - "wrap", - &path(&verifier)?, - &path(&final_dir.join("kzg/packet.bin"))?, - trusted.as_str(), - &path(&expected_path)?, - &path(&cache_root.join("plonk"))?, - &path(&out_plonk)?, - ], - &caches, - )?; - return Ok(fs::read(out_plonk.join("proof.envelope.bin"))?); - } + let out_plonk = out.join("plonk"); + let expected_path = final_dir.join("expected-statement.json"); + storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; + let verifier = final_dir.join("vk.bin"); + let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); + child( + "plonk", + &[ + "wrap", + &path(&verifier)?, + &path(&final_dir.join("kzg/packet.bin"))?, + trusted.as_str(), + &path(&expected_path)?, + &path(&cache_root.join("plonk"))?, + &path(&out_plonk)?, + ], + &caches, + )?; + return Ok(fs::read(out_plonk.join("proof.envelope.bin"))?); } Ok(result) } diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 834c647fa..29e26f95a 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -1,18 +1,28 @@ //! Terminal PLONK worker operations and authenticated parameter interchange. -use super::terminal::{ - Scratch, checked, claim_digest, digest, hex, load_json, sha256, -}; -use super::{ - Result, fixed_cache, - setup::{SetupClass, SetupConfig}, - statement, storage, wrap, -}; +#[cfg(feature = "plonk")] +use super::fixed_cache; +#[cfg(feature = "plonk")] +use super::gnark; +#[cfg(any(not(feature = "plonk"), test))] +use super::terminal::Scratch; +#[cfg(not(feature = "plonk"))] +use super::terminal::checked; +#[cfg(feature = "plonk")] +use super::terminal::{claim_digest, decode_hex, read_bounded, sha256}; +use super::terminal::{digest, hex, load_json}; +use super::{Result, setup::SetupClass, statement, storage}; +#[cfg(feature = "plonk")] +use super::{setup::SetupConfig, wrap}; use ark_ff::{BigInteger, PrimeField}; +#[cfg(feature = "plonk")] +use sha2::{Digest, Sha256}; +#[cfg(feature = "plonk")] +use std::io::Write; +#[cfg(not(feature = "plonk"))] +use std::process::Command; use std::{ fs, - io::Write, path::{Path, PathBuf}, - process::Command, }; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; @@ -36,7 +46,6 @@ struct TrustedKey { inner_vk_id: String, statement_prefix: [u64; 10], circuit_sha256: String, - helper_sha256: String, setup: String, #[serde(default, skip_serializing_if = "Option::is_none")] admission: Option, @@ -44,10 +53,7 @@ struct TrustedKey { admission_blake3: Option, } -pub(super) fn helper_executable() -> Result { - super::terminal::helper_executable("IX_PLONK_HELPER") -} - +#[cfg(feature = "plonk")] fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { match config { SetupConfig::Ignition { manifest, .. } => Ok(*manifest), @@ -55,6 +61,7 @@ fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { } } +#[cfg(feature = "plonk")] fn anchors(config: &SetupConfig) -> Result { setup_manifest(config)?; Ok(config.ignition_admission().ok_or("missing Ignition admission")?.anchors()) @@ -68,14 +75,14 @@ fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { bytes } -fn anchor_encoding(srs: &crate::kzg::Srs) -> String { +fn anchor_encoding(srs: &crate::kzg::Srs) -> Vec { let mut bytes = Vec::new(); for value in [srs.g1[0].x, srs.g1[0].y] { bytes.extend(value.into_bigint().to_bytes_be()); } bytes.extend(encode_g2(srs.g2)); bytes.extend(encode_g2(srs.tau_g2)); - hex(&bytes) + bytes } fn key_anchors(key: &TrustedKey) -> Result { @@ -106,7 +113,6 @@ fn select_key(path: &Path, id: &[u8; 32]) -> Result { let key_id = digest(&key.sha256)?; digest(&key.inner_vk_id)?; digest(&key.circuit_sha256)?; - digest(&key.helper_sha256)?; if !seen.insert(key_id) { return Err("duplicate PLONK verifier key in catalog".into()); } @@ -157,28 +163,44 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { if fs::metadata(&key.path)?.len() > 1 << 20 { return Err("PLONK verification key is too large".into()); } - let helper = helper_executable()?; - let scratch = Scratch::new()?; - let proof = scratch.0.join("proof.bin"); - storage::write_atomic(&proof, &packet[32..])?; - checked( - Command::new(helper) - .arg("verify") - .arg(&key.path) - .arg(hex(id)) - .arg(&proof) - .arg(blake3::hash(claim).to_hex().as_str()) - .arg(anchor_encoding(&key_anchors(&key)?)), + let expected = *blake3::hash(claim).as_bytes(); + let anchor_bytes = anchor_encoding(&key_anchors(&key)?); + #[cfg(feature = "plonk")] + gnark::verify( + &read_bounded(&key.path, 1 << 20)?, + id, + &packet[32..], + &expected, + &anchor_bytes, )?; + #[cfg(not(feature = "plonk"))] + { + let scratch = Scratch::new()?; + let proof = scratch.0.join("proof.bin"); + storage::write_atomic(&proof, &packet[32..])?; + checked( + Command::new(super::worker_executable()?) + .args(["plonk", "verify-key"]) + .arg(&key.path) + .arg(hex(id)) + .arg(&proof) + .arg(hex(&expected)) + .arg(hex(&anchor_bytes)), + )?; + } Ok(SetupClass::Ignition) } +#[cfg(feature = "plonk")] pub(crate) fn run(args: &[String]) -> Result<()> { let development = args.iter().any(|arg| arg == "--dev-srs"); let args: Vec<_> = args.iter().filter(|arg| *arg != "--dev-srs").collect(); match args.iter().map(|s| s.as_str()).collect::>().as_slice() { + ["verify-key", key, trusted, proof, claim, anchors] => { + gnark::verify(&read_bounded(Path::new(key), 1 << 20)?, &digest(trusted)?, &read_bounded(Path::new(proof), MAX_PROOF_BYTES)?, &digest(claim)?, &decode_hex(anchors)?) + }, ["verify", proof, claim] => { - let bytes = fs::read(proof)?; + let bytes = read_bounded(Path::new(proof), MAX_PROOF_BYTES + 32 + PROOF_TAG.len())?; let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported PLONK proof curve or version")?; let setup = verify(packet, &fs::read(claim)?)?; println!("{}", serde_json::json!({"verified": true, "curve": crate::kzg::curve::NAME, "setup": setup.label()})); @@ -201,10 +223,12 @@ pub(crate) fn run(args: &[String]) -> Result<()> { } } +#[cfg(feature = "plonk")] fn export_srs(domain: usize, path: &Path) -> Result<()> { export_parameters(domain, path, &SetupConfig::from_env(false)?) } +#[cfg(feature = "plonk")] fn export_parameters( domain: usize, path: &Path, @@ -253,6 +277,7 @@ fn export_parameters( Ok(()) } +#[cfg(feature = "plonk")] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ParameterReceipt { @@ -265,6 +290,7 @@ struct ParameterReceipt { setup_manifest: String, } +#[cfg(feature = "plonk")] fn cached_parameters( domain: usize, root: &Path, @@ -300,6 +326,7 @@ fn cached_parameters( Ok((path, receipt.blake3)) } +#[cfg(feature = "plonk")] fn metadata_string<'a>( value: &'a serde_json::Value, field: &str, @@ -310,6 +337,166 @@ fn metadata_string<'a>( .ok_or_else(|| format!("missing PLONK metadata field {field}").into()) } +#[cfg(feature = "plonk")] +fn artifact(path: &Path) -> Result { + Ok(gnark::Artifact { + sha256: hex(&sha256(path)?), + bytes: fs::metadata(path)?.len(), + }) +} + +#[cfg(feature = "plonk")] +pub(super) fn proving_backend() -> Result { + match std::env::var("IX_PLONK_BACKEND").as_deref().unwrap_or("cpu") { + "" | "cpu" => Ok(gnark::Backend::Cpu), + "cuda" => { + let device: i32 = std::env::var("IX_PLONK_CUDA_DEVICE") + .unwrap_or_else(|_| "0".into()) + .parse()?; + if device < 0 { + return Err("IX_PLONK_CUDA_DEVICE must be nonnegative".into()); + } + let gib: u64 = std::env::var("IX_PLONK_CUDA_MEMORY_GIB") + .unwrap_or_else(|_| "64".into()) + .parse()?; + let memory_bytes = gib + .checked_mul(1 << 30) + .filter(|n| *n > 0) + .ok_or("invalid PLONK CUDA memory budget")?; + let mask_msm = match std::env::var("IX_PLONK_CUDA_MSM_STRATEGY") + .as_deref() + .unwrap_or("split") + { + "" | "split" => false, + "mask" => true, + _ => { + return Err( + "IX_PLONK_CUDA_MSM_STRATEGY must be split or mask".into(), + ); + }, + }; + Ok(gnark::Backend::Cuda { device, memory_bytes, mask_msm }) + }, + _ => Err("IX_PLONK_BACKEND must be cpu or cuda".into()), + } +} + +#[cfg(feature = "plonk")] +fn prepare_artifacts( + vk: &Path, + setup: &SetupConfig, + cache_root: &Path, +) -> Result<(PathBuf, gnark::Manifest)> { + let schema = fs::read(vk)?; + let schema_hash = hex(&Sha256::digest(&schema)); + let mut identity = blake3::Hasher::new(); + identity.update(&fixed_cache::executable_digest()?); + identity.update(schema_hash.as_bytes()); + identity.update(&setup_manifest(setup)?); + let dir = + cache_root.join("circuits").join(identity.finalize().to_hex().as_str()); + fs::create_dir_all(&dir)?; + let metadata_path = dir.join("manifest.json"); + let mut metadata: gnark::Manifest = if metadata_path.is_file() { + let metadata: gnark::Manifest = load_json(&metadata_path)?; + // Go authenticates the large native artifacts while decoding them. + if metadata.inner_vk_sha256 != schema_hash + || metadata.files.get("vk.json") != Some(&artifact(&dir.join("vk.json"))?) + { + return Err("PLONK circuit cache identity differs".into()); + } + metadata + } else { + storage::write_atomic(&dir.join("vk.json"), &schema)?; + let info = gnark::compile(&schema, &dir.join("circuit.bin"))?; + let metadata = gnark::Manifest { + inner_vk_sha256: schema_hash, + srs_blake3: String::new(), + keys_ready: false, + domain: info.domain, + constraints: info.constraints, + public_inputs: info.public_inputs, + commitments: info.commitments, + files: std::collections::BTreeMap::from([ + ("vk.json".into(), artifact(&dir.join("vk.json"))?), + ("circuit.bin".into(), artifact(&dir.join("circuit.bin"))?), + ]), + }; + storage::write_atomic( + &metadata_path, + &serde_json::to_vec_pretty(&metadata)?, + )?; + metadata + }; + let domain = usize::try_from(metadata.domain)?; + if !(2..=1 << 26).contains(&domain) + || !domain.is_power_of_two() + || metadata.public_inputs != 2 + { + return Err("invalid PLONK circuit dimensions".into()); + } + if !metadata.keys_ready { + let (srs, receipt) = + cached_parameters(domain, &cache_root.join("parameters"), setup)?; + let mut identity = Sha256::new(); + identity.update(setup_manifest(setup)?); + identity.update(domain.to_le_bytes()); + identity.update(receipt.as_bytes()); + let lagrange_root = + cache_root.join("lagrange").join(hex(&identity.finalize())); + fs::create_dir_all(&lagrange_root)?; + let lagrange = lagrange_root.join("points.bin"); + let lagrange_metadata = lagrange_root.join("manifest.json"); + let cached_lagrange = lagrange_metadata.is_file(); + let lagrange_hash = if cached_lagrange { + let saved: gnark::Artifact = load_json(&lagrange_metadata)?; + if saved.bytes != 64 * metadata.domain { + return Err("Lagrange cache identity differs".into()); + } + digest(&saved.sha256)?; + saved.sha256 + } else { + String::new() + }; + gnark::prepare(&gnark::Preparation { + schema: &schema, + circuit: &dir.join("circuit.bin"), + circuit_hash: metadata + .files + .get("circuit.bin") + .ok_or("missing circuit hash")?, + domain: metadata.domain, + srs: &srs, + srs_digest: &receipt, + lagrange: &lagrange, + lagrange_digest: &lagrange_hash, + output: &dir, + })?; + if !cached_lagrange { + storage::write_atomic( + &lagrange_metadata, + &serde_json::to_vec(&artifact(&lagrange)?)?, + )?; + } + for name in ["proving-key.bin", "verifying-key.bin", "PlonkVerifier.sol"] { + metadata.files.insert(name.into(), artifact(&dir.join(name))?); + } + metadata.srs_blake3 = receipt; + metadata.keys_ready = true; + storage::write_atomic( + &metadata_path, + &serde_json::to_vec_pretty(&metadata)?, + )?; + } + if metadata.files.get("PlonkVerifier.sol") + != Some(&artifact(&dir.join("PlonkVerifier.sol"))?) + { + return Err("Solidity verifier cache identity differs".into()); + } + Ok((dir, metadata)) +} + +#[cfg(feature = "plonk")] fn wrap_packet( verifier: &Path, packet: &Path, @@ -318,11 +505,12 @@ fn wrap_packet( cache_root: &Path, out: &Path, ) -> Result<()> { + use std::time::Instant; statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; let expected_digest = claim_digest(expected)?; let setup = SetupConfig::from_env(false)?; - let manifest = setup_manifest(&setup)?; - let helper = helper_executable()?; + setup_manifest(&setup)?; + let backend = proving_backend()?; fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { return Err("PLONK output directory must be empty".into()); @@ -331,71 +519,37 @@ fn wrap_packet( wrap::plonk_export(verifier, packet, trusted_digest, expected, &input)?; let vk_path = input.join("vk.json"); let exported: serde_json::Value = load_json(&vk_path)?; - let mut identity = blake3::Hasher::new(); - identity.update(b"ix/bn254-plonk-cache/v1"); - identity.update(&fixed_cache::executable_digest()?); - identity.update(&sha256(&helper)?); - identity.update(&sha256(&vk_path)?); - identity.update(&manifest); - let cache = - cache_root.join("circuits").join(identity.finalize().to_hex().as_str()); - checked(Command::new(&helper).arg("compile").arg(&vk_path).arg(&cache))?; - let mut metadata: serde_json::Value = - load_json(&cache.join("manifest.json"))?; - let domain = usize::try_from( - metadata - .get("Domain") - .and_then(serde_json::Value::as_u64) - .ok_or("missing PLONK domain")?, - )?; - if domain < 2 || !domain.is_power_of_two() || domain > 1 << 26 { - return Err( - "PLONK domain exceeds the authenticated adapter capacity".into(), - ); - } - if metadata.get("KeysReady").and_then(serde_json::Value::as_bool) - != Some(true) - { - let (srs, receipt) = - cached_parameters(domain, &cache_root.join("parameters"), &setup)?; - checked( - Command::new(&helper) - .arg("prepare") - .arg(&cache) - .arg(srs) - .arg(receipt) - .env("IX_PLONK_LAGRANGE_CACHE", cache_root.join("lagrange")), - )?; - metadata = load_json(&cache.join("manifest.json"))?; - } - checked( - Command::new(&helper) - .arg("prove") - .arg(&cache) - .arg(input.join("witness.json")) - .arg(out), - )?; - let key_hash = - metadata_string(&metadata["Files"]["verifying-key.bin"], "SHA256")?; - let key_id = digest(key_hash)?; + let (cache, metadata) = prepare_artifacts(&vk_path, &setup, cache_root)?; + let started = Instant::now(); + let mut prover = gnark::Prover::open(&cache, &metadata, backend)?; + let load_seconds = started.elapsed().as_secs_f64(); + let witness = fs::read(input.join("witness.json"))?; + let started = Instant::now(); + let proof = prover.prove(&witness, &expected_digest)?; + let prove_seconds = started.elapsed().as_secs_f64(); + prover.close()?; let key = cache.join("verifying-key.bin"); - checked( - Command::new(&helper) - .arg("verify") - .arg(&key) - .arg(key_hash) - .arg(out.join("proof.bin")) - .arg(hex(&expected_digest)) - .arg(anchor_encoding(&anchors(&setup)?)), + let key_hash = &metadata + .files + .get("verifying-key.bin") + .ok_or("missing verifying key hash")? + .sha256; + let key_id = digest(key_hash)?; + gnark::verify( + &fs::read(&key)?, + &key_id, + &proof.native, + &expected_digest, + &anchor_encoding(&anchors(&setup)?), )?; - let proof = fs::read(out.join("proof.bin"))?; - if proof.is_empty() || proof.len() > MAX_PROOF_BYTES { + if proof.native.is_empty() || proof.native.len() > MAX_PROOF_BYTES { return Err("invalid native PLONK proof length".into()); } let mut envelope = PROOF_TAG.to_vec(); envelope.extend(key_id); - envelope.extend(proof); - storage::write_atomic(&out.join("proof.envelope.bin"), &envelope)?; + envelope.extend(&proof.native); + storage::write_atomic(&out.join("proof.bin"), &proof.native)?; + storage::write_atomic(&out.join("proof.solidity.bin"), &proof.solidity)?; storage::write_atomic(&out.join("verifying-key.bin"), &fs::read(&key)?)?; storage::write_atomic( &out.join("PlonkVerifier.sol"), @@ -409,16 +563,16 @@ fn wrap_packet( let candidate = TrustedCatalog { format: CATALOG_FORMAT.into(), keys: vec![TrustedKey { - sha256: key_hash.into(), + sha256: key_hash.clone(), path: "verifying-key.bin".into(), inner_vk_id: metadata_string(&exported, "vkId")?.into(), statement_prefix: expected[..10].try_into()?, - circuit_sha256: metadata_string( - &metadata["Files"]["circuit.bin"], - "SHA256", - )? - .into(), - helper_sha256: metadata_string(&metadata, "ExecutableSHA256")?.into(), + circuit_sha256: metadata + .files + .get("circuit.bin") + .ok_or("missing circuit hash")? + .sha256 + .clone(), setup: SETUP_NAME.into(), admission: Some("admission.json".into()), admission_blake3: Some( @@ -430,6 +584,13 @@ fn wrap_packet( &out.join("verifier-catalog.candidate.json"), &serde_json::to_vec_pretty(&candidate)?, )?; + storage::write_atomic( + &out.join("report.json"), + &serde_json::to_vec_pretty( + &serde_json::json!({"backend":match backend { gnark::Backend::Cpu => "cpu", gnark::Backend::Cuda { .. } => "cuda" },"loadSeconds":load_seconds,"proveSeconds":prove_seconds,"verifyingKeySHA256":key_hash,"solidityBytes":proof.solidity.len()}), + )?, + )?; + storage::write_atomic(&out.join("proof.envelope.bin"), &envelope)?; Ok(()) } @@ -453,7 +614,6 @@ mod bn254_tests { inner_vk_id: hex(&[2; 32]), statement_prefix: [0; 10], circuit_sha256: hex(&[3; 32]), - helper_sha256: hex(&[4; 32]), setup: "ignition".into(), admission: Some("admission.json".into()), admission_blake3: Some(pin), diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index 41d637306..44920a0c8 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -28,6 +28,8 @@ fn print_capabilities() { "ignition_setup": true, "terminal_plonk": cfg!(feature = "plonk"), "terminal_groth16": cfg!(feature = "groth16"), + "terminal_plonk_cuda": cfg!(all(feature = "plonk", feature = "cuda")), + "terminal_groth16_cuda": cfg!(all(feature = "groth16", feature = "cuda")), }) ); } diff --git a/crates/proof-compression/src/pipeline/terminal.rs b/crates/proof-compression/src/pipeline/terminal.rs index a59bb0b3c..4f0eb0f81 100644 --- a/crates/proof-compression/src/pipeline/terminal.rs +++ b/crates/proof-compression/src/pipeline/terminal.rs @@ -1,16 +1,26 @@ //! Shared transport and artifact checks for terminal proof backends. -#![cfg_attr( - not(any(feature = "plonk", feature = "groth16")), - allow(dead_code, unused_imports) -)] -use super::{Result, statement, storage}; +use super::Result; +#[cfg(any(feature = "plonk", feature = "groth16"))] +use super::statement; +#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +use super::storage; +use serde::{Deserialize, Serialize}; +#[cfg(any(feature = "plonk", feature = "groth16", test))] use sha2::{Digest, Sha256}; -use std::{ - fs, - io::Read, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; +use std::{fs, io::Read, path::Path}; + +#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +use std::path::PathBuf; +#[cfg(any(not(feature = "plonk"), not(feature = "groth16")))] +use std::process::{Command, Stdio}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase", deny_unknown_fields)] +pub struct Artifact { + #[serde(rename = "SHA256")] + pub sha256: String, + pub bytes: u64, +} #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(u8)] @@ -56,7 +66,9 @@ impl TryFrom for TerminalOptions { } } +#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] pub(super) struct Scratch(pub(super) PathBuf); +#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] impl Scratch { pub(super) fn new() -> Result { let path = @@ -65,6 +77,7 @@ impl Scratch { Ok(Self(path)) } } +#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); @@ -80,18 +93,24 @@ pub(super) fn hex(bytes: &[u8]) -> String { output } -pub(super) fn digest(text: &str) -> Result<[u8; 32]> { +pub(super) fn decode_hex(text: &str) -> Result> { let text = text.strip_prefix("0x").unwrap_or(text); - if text.len() != 64 || !text.is_ascii() { - return Err("expected a 32-byte hexadecimal digest".into()); - } - let mut bytes = [0; 32]; - for (index, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * index..2 * index + 2], 16)?; + if !text.is_ascii() || !text.len().is_multiple_of(2) { + return Err("invalid hexadecimal bytes".into()); } - Ok(bytes) + (0..text.len()) + .step_by(2) + .map(|i| Ok(u8::from_str_radix(&text[i..i + 2], 16)?)) + .collect() +} + +pub(super) fn digest(text: &str) -> Result<[u8; 32]> { + decode_hex(text)? + .try_into() + .map_err(|_bytes| "expected a 32-byte hexadecimal digest".into()) } +#[cfg(any(feature = "plonk", feature = "groth16", test))] pub(super) fn sha256(path: &Path) -> Result<[u8; 32]> { let mut file = fs::File::open(path)?; let mut hash = Sha256::new(); @@ -106,33 +125,22 @@ pub(super) fn sha256(path: &Path) -> Result<[u8; 32]> { Ok(hash.finalize().into()) } +pub(super) fn read_bounded(path: &Path, limit: usize) -> Result> { + let mut bytes = Vec::new(); + fs::File::open(path)?.take((limit + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err("terminal input exceeds its size limit".into()); + } + Ok(bytes) +} + pub(super) fn load_json( path: &Path, ) -> Result { - let mut bytes = Vec::new(); - fs::File::open(path)?.take((1 << 20) + 1).read_to_end(&mut bytes)?; - if bytes.len() > 1 << 20 { - return Err("terminal metadata exceeds its size limit".into()); - } - Ok(serde_json::from_slice(&bytes)?) -} - -pub(super) fn helper_executable(variable: &str) -> Result { - let path = if let Some(path) = std::env::var_os(variable) { - PathBuf::from(path) - } else if let Some(path) = std::env::var_os("IX_PLONK_HELPER") { - PathBuf::from(path) - } else { - std::env::current_exe()?.with_file_name("ix-bn254-plonk-wrapper") - }; - if !path.is_file() { - return Err( - format!("gnark terminal helper is missing; set {variable}").into(), - ); - } - Ok(path) + Ok(serde_json::from_slice(&read_bounded(path, 1 << 20)?)?) } +#[cfg(any(not(feature = "plonk"), not(feature = "groth16")))] pub(super) fn checked(command: &mut Command) -> Result<()> { let status = command .stdout(Stdio::from(std::io::stderr())) @@ -144,6 +152,7 @@ pub(super) fn checked(command: &mut Command) -> Result<()> { Ok(()) } +#[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) fn claim_digest( expected: &statement::Statement, ) -> Result<[u8; 32]> { diff --git a/crates/proof-compression/tests/fixtures/gnark/proof.bin b/crates/proof-compression/tests/fixtures/gnark/proof.bin new file mode 100644 index 0000000000000000000000000000000000000000..822c3af6d114ddaefe81a6c63c7276de2f5e87ca GIT binary patch literal 584 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 literal 0 HcmV?d00001 diff --git a/crates/proof-compression/tests/worker_tracing.rs b/crates/proof-compression/tests/worker_tracing.rs index b0c2806e7..7c39b1ac5 100644 --- a/crates/proof-compression/tests/worker_tracing.rs +++ b/crates/proof-compression/tests/worker_tracing.rs @@ -29,6 +29,16 @@ fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { assert_eq!(capabilities["curve"], ix_proof_compression::kzg::curve::NAME); assert_eq!(capabilities["development_setup_only"], false); assert_eq!(capabilities["ignition_setup"], true); + assert_eq!(capabilities["terminal_plonk"], cfg!(feature = "plonk")); + assert_eq!(capabilities["terminal_groth16"], cfg!(feature = "groth16")); + assert_eq!( + capabilities["terminal_groth16_cuda"], + cfg!(all(feature = "groth16", feature = "cuda")) + ); + assert_eq!( + capabilities["terminal_plonk_cuda"], + cfg!(all(feature = "plonk", feature = "cuda")) + ); assert_eq!( String::from_utf8_lossy(&output.stderr) .contains("[texray] compression/fri:"), diff --git a/docs/ffi.md b/docs/ffi.md index cdd086984..81cdc26e2 100644 --- a/docs/ffi.md +++ b/docs/ffi.md @@ -36,3 +36,19 @@ There is a deprecated API for passing mutable objects between Lean and Rust in ` This code path is unused for now as the Rust FFI is designed to clone if mutation is needed. However, the `linear.h` file is well-documented in case we want to revisit it later for performance-critical applications. + +## Rust to gnark + +`ix-proof-compression::pipeline::gnark` wraps a Go C archive inside the +compression worker. The worker enables `plonk` and/or `groth16`; both +are excluded from the Lean-facing `ix-ffi` build. Rust owns setup admission, +artifact policy and publication; Go owns gnark objects and the verifier circuit. + +The private C interface exchanges byte views, owned output buffers and integer +`runtime/cgo.Handle` values. `Prover` is neither `Send` nor `Sync`; its mutable +proof method excludes concurrent use, and drop releases the handle. Returned +C allocations are copied and freed through the adapter. Go panics on the exported +call's goroutine become errors; fatal runtime and device failures remain isolated +by the worker process. There is no ABI version or compatibility dispatcher. + +The [backend guide](../tools/plonk-wrapper/README.md) describes builds and tests. diff --git a/flake.nix b/flake.nix index f7cc4194a..4edf9b1a8 100644 --- a/flake.nix +++ b/flake.nix @@ -107,7 +107,7 @@ }; }); plonkSources = pkgs.lib.fileset.fileFilter ( - file: file.hasExt "go" || file.hasExt "json" || file.hasExt "bin" + file: file.hasExt "go" || file.hasExt "h" || file.hasExt "json" || file.hasExt "bin" || file.name == "go.mod" || file.name == "go.sum" ) ./tools/plonk-wrapper; plonkSppark = pkgs.fetchFromGitHub { @@ -168,12 +168,8 @@ ]; meta.platforms = [ "x86_64-linux" ]; }; - mkPlonkHelper = { cuda ? false, groth16Cuda ? false }: (cudaPkgs.buildGoModule.override { go = plonkGo; }) { - pname = "ix-bn254-plonk-wrapper" - + pkgs.lib.optionalString cuda "-cuda" - + pkgs.lib.optionalString groth16Cuda "-groth16-cuda"; - tags = pkgs.lib.optional cuda "cuda" ++ pkgs.lib.optional groth16Cuda "icicle"; - ldflags = pkgs.lib.optional groth16Cuda "-X=main.groth16LibraryDefault=${icicleBn254}/lib/backend"; + gnarkGo = (cudaPkgs.buildGoModule.override { go = plonkGo; }) { + pname = "ix-gnark-tests"; version = "0.1.0"; src = pkgs.lib.fileset.toSource { root = ./tools/plonk-wrapper; @@ -181,25 +177,15 @@ }; vendorHash = "sha256-RJNclxTYY6UQBG6f7Kwt0XspL7DZruLdsYKubHlUOvg="; subPackages = [ "." ]; - env.CGO_ENABLED = if cuda || groth16Cuda then "1" else "0"; - env.GOTOOLCHAIN = "local"; - env.CGO_CFLAGS = pkgs.lib.optionalString cuda "-I${plonkCudaNative}/include"; - env.CGO_LDFLAGS = pkgs.lib.optionalString cuda "-L${plonkCudaNative}/lib -Wl,-rpath,${plonkCudaNative}/lib -lix_plonk_bn254_cuda " - + pkgs.lib.optionalString groth16Cuda "-L${icicleBn254}/lib -Wl,-rpath,${icicleBn254}/lib"; - buildInputs = pkgs.lib.optional cuda plonkCudaNative ++ pkgs.lib.optional groth16Cuda icicleBn254; - postInstall = '' - mv "$out/bin/plonk-wrapper" "$out/bin/ix-bn254-plonk-wrapper" - '' + pkgs.lib.optionalString cuda '' - mkdir -p "$out/lib" - ln -s ${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so "$out/lib/" + buildPhase = "runHook preBuild; runHook postBuild"; + installPhase = '' + mkdir -p "$out" + touch "$out/checked" ''; + env.CGO_ENABLED = "0"; + env.GOTOOLCHAIN = "local"; }; - plonkHelper = mkPlonkHelper { }; - plonkCudaHelper = mkPlonkHelper { cuda = true; }; - groth16CudaHelper = mkPlonkHelper { groth16Cuda = true; }; - terminalCudaHelper = mkPlonkHelper { cuda = true; groth16Cuda = true; }; - # Rust package craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; # The Cargo files the workspace builds from. @@ -271,6 +257,23 @@ pkgs.libiconv ]; }; + gnarkArgs = craneArgs // { + src = pkgs.lib.fileset.toSource { + root = ./.; + fileset = pkgs.lib.fileset.unions [ + cargoSource + compressionData + ./Tests/Fixtures/ixon-v4 + plonkSources + ./crates/proof-compression/cuda + ]; + }; + nativeBuildInputs = [ plonkGo ]; + GO = "${plonkGo}/bin/go"; + postPatch = '' + cp -r ${gnarkGo.goModules} tools/plonk-wrapper/vendor + ''; + }; # Build dependencies once with every host feature enabled so the # `net` stack (tokio/iroh) is compiled and cached here, then shared # by the package builds and clippy. CUDA remains opt-in and is @@ -278,7 +281,7 @@ cargoArtifacts = craneLib.buildDepsOnly ( craneArgs // { - cargoExtraArgs = "--locked --features parallel,test-ffi,net"; + cargoExtraArgs = "--locked -p ix-ffi --features parallel,test-ffi,net"; } ); @@ -289,7 +292,7 @@ craneArgs // { inherit cargoArtifacts; - cargoExtraArgs = "--locked --features parallel,test-ffi"; + cargoExtraArgs = "--locked -p ix-ffi --features parallel,test-ffi"; doCheck = false; } ); @@ -299,7 +302,7 @@ craneArgs // { inherit cargoArtifacts; - cargoExtraArgs = "--locked --features parallel"; + cargoExtraArgs = "--locked -p ix-ffi --features parallel"; doCheck = false; } ); @@ -312,10 +315,35 @@ // { inherit cargoArtifacts; cargoExtraArgs = - "--locked --features parallel" + pkgs.lib.optionalString (!pkgs.stdenv.isDarwin) ",net"; + "--locked -p ix-ffi --features parallel" + pkgs.lib.optionalString (!pkgs.stdenv.isDarwin) ",net"; + doCheck = false; + } + ); + + mkCompressionWorker = { cuda ? false }: craneLib.buildPackage ( + gnarkArgs // { + inherit cargoArtifacts; + pname = "ix-compression-worker"; + cargoExtraArgs = "--locked -p ix-proof-compression --bin ix-compression-worker --no-default-features --features parallel,plonk,groth16" + + pkgs.lib.optionalString cuda ",cuda"; doCheck = false; + nativeBuildInputs = [ plonkGo ] ++ pkgs.lib.optional cuda cudaToolkit; + CUDA_HOME = pkgs.lib.optionalString cuda "${cudaToolkit}"; + NVCC = pkgs.lib.optionalString cuda "${cudaToolkit}/bin/nvcc"; + IX_GROTH16_ICICLE_ROOT = pkgs.lib.optionalString cuda "${icicleBn254}"; + IX_PLONK_CUDA_LIBRARY = pkgs.lib.optionalString cuda "${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so"; + postInstall = pkgs.lib.optionalString cuda '' + mkdir -p "$out/lib" + ln -s ${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so "$out/lib/" + cudaOutput=$(${pkgs.jq}/bin/jq -r 'select(.reason == "build-script-executed" and (.linked_libs | index("dylib=multi_stark_cuda"))) | .out_dir' "$cargoBuildLog") + cp "$cudaOutput/libmulti_stark_cuda.so" "$out/lib/" + ln -s ${icicleBn254}/lib/libicicle_{device,field_bn254,curve_bn254}.so "$out/lib/" + ln -s ${icicleBn254}/lib/backend "$out/lib/backend" + ''; } ); + compressionWorker = mkCompressionWorker { }; + compressionWorkerCuda = mkCompressionWorker { cuda = true; }; # Lake package lake2nix = pkgs.callPackage lean4-nix.lake { inherit lean; }; @@ -363,8 +391,8 @@ # Shared Lake build args: patches out the Cargo build (Crane handles it) mkLakeBuildArgs = rustLib: { inherit lakeDeps; + GO = "${plonkGo}/bin/go"; src = leanSrc; - IX_PLONK_PREBUILT = "${plonkHelper}/bin/ix-bn254-plonk-wrapper"; # Don't build the `ix_rs` static lib with Lake, since we build it with Crane postPatch = '' substituteInPlace lakefile.lean --replace-fail 'cmd := "cargo"' 'cmd := "true"' @@ -379,7 +407,7 @@ postConfigure = '' mkdir -p target/release ln -s ${rustLib}/lib/libix_ffi.a target/release/ - ln -s ${rustLib}/bin/ix-compression-worker target/release/ + ln -s ${compressionWorker}/bin/ix-compression-worker target/release/ ''; buildInputs = [ pkgs.gmp @@ -462,16 +490,14 @@ packages = { default = ixLib; ix = ixCLI; - plonk-wrapper = plonkHelper; - plonk-wrapper-cuda = plonkCudaHelper; - groth16-wrapper-cuda = groth16CudaHelper; - terminal-wrapper-cuda = terminalCudaHelper; icicle-bn254 = icicleBn254; + compression-worker = compressionWorker; + compression-worker-cuda = compressionWorkerCuda; # `checks` are built by `nix flake check`; exposing this derivation # as a package keeps the ignored suite available on demand without # adding it to the default check set. nextest-ignored = craneLib.cargoNextest ( - craneArgs + gnarkArgs // { inherit cargoArtifacts; cargoExtraArgs = "--locked --workspace"; @@ -484,19 +510,19 @@ }; checks = { - plonk-wrapper = plonkHelper; + gnark = gnarkGo; # Lint the host workspace; warnings are errors. clippy = craneLib.cargoClippy ( - craneArgs + gnarkArgs // { inherit cargoArtifacts; - cargoExtraArgs = "--locked --features parallel,test-ffi,net"; + cargoExtraArgs = "--locked --workspace --features ix-ffi/parallel,ix-ffi/test-ffi,ix-ffi/net"; cargoClippyExtraArgs = "--all-targets -- -D warnings"; } ); # Non-ignored Rust unit tests across the host workspace. nextest = craneLib.cargoNextest ( - craneArgs + gnarkArgs // { inherit cargoArtifacts; cargoExtraArgs = "--locked --workspace"; diff --git a/lakefile.lean b/lakefile.lean index 283fe09ed..d3f73927a 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -77,7 +77,7 @@ def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : IO (Array St /-- Build args for `cargo build --release` with opt-in feature overrides. Cargo output is visible with `lake -v build`. -/ def cargoArgs (testFfi : Bool := false) (net : Bool := false) : IO (Array String) := do - let features ← cargoFeatures testFfi net + let features := (← cargoFeatures testFfi net).filter (fun name => name != "plonk" && name != "groth16") IO.println s!"Ix Rust features: {if features.isEmpty then "none" else ",".intercalate features.toList}" let buildArgs := #["build", "--release", "--no-default-features", "-p", "ix-ffi"] if features.isEmpty then return buildArgs @@ -98,12 +98,16 @@ def cargoEnvironment (environment : Array (String × Option String)) : def nativeToolchainVariables : Array String := #["CFLAGS", "NVCC", "CUDA_HOME", "CUDA_PATH", "NVCC_CCBIN", "NVCC_PREPEND_FLAGS", "NVCC_APPEND_FLAGS", "MULTI_STARK_CUDA_ARCHS", - "IX_KZG_CUDA_ARCHS"] + "IX_KZG_CUDA_ARCHS", "GO", "CC", "CXX", "CGO_CFLAGS", "CGO_LDFLAGS", + "IX_PLONK_CUDA_LIBRARY", "IX_PLONK_SPPARK_SOURCE", "IX_PLONK_BLST_SOURCE", + "IX_PLONK_CUDA_ARCHS", "IX_GROTH16_ICICLE_ROOT"] def usesCuda (features : Array String) : Bool := features.any fun feature => (feature.splitOn ",").any fun name => name == "cuda" || name == "cuda-trace-codegen" +def plonkCudaLibraryName := "libix_plonk_bn254_cuda.so" + /-- Worker features follow the CLI's curve while omitting CLI-only features. -/ def compressionWorkerArgs (features : Array String) : Array String := Id.run do let mut selected := features.filter fun name => @@ -125,6 +129,30 @@ def cargoCudaLibrary (messages : String) : Except String FilePath := do return System.FilePath.mk directory / cudaLibraryName throw "Cargo did not report multi-stark's CUDA shared library" +def cargoPlonkLibrary (messages : String) : Except String FilePath := do + for line in messages.splitOn "\n" do + let .ok message := Lean.Json.parse line | continue + let .ok variables := message.getObjValAs? (Array (Array String)) "env" | continue + for entry in variables do + if entry.size == 2 && entry[0]! == "IX_PLONK_CUDA_LIBRARY" then + return System.FilePath.mk entry[1]! + throw "Cargo did not report the PLONK CUDA shared library" + +def icicleLibraries : Array String := + #["libicicle_device.so", "libicicle_field_bn254.so", "libicicle_curve_bn254.so", + "backend/cuda/libicicle_backend_cuda_device.so", + "backend/bn254/cuda/libicicle_backend_cuda_field_bn254.so", + "backend/bn254/cuda/libicicle_backend_cuda_curve_bn254.so"] + +def cargoIcicleRoot (messages : String) : Except String FilePath := do + for line in messages.splitOn "\n" do + let .ok message := Lean.Json.parse line | continue + let .ok variables := message.getObjValAs? (Array (Array String)) "env" | continue + for entry in variables do + if entry.size == 2 && entry[0]! == "IX_GROTH16_ICICLE_ROOT" then + return System.FilePath.mk entry[1]! + throw "Cargo did not report the Groth16 ICICLE installation" + local instance : CheckExists (Array FilePath) where checkExists paths := paths.allM (·.pathExists) @@ -163,16 +191,56 @@ Both must exist before a cached result can be used. -/ def buildCargoArtifact (pkg : Package) (args : Array String) (tag : String) (built output : FilePath) (before : Option (Job FilePath) := none) : SpawnM (Job FilePath) := do - let deps ← rustSources pkg - let deps := match before with + let mut deps ← rustSources pkg + let plonk := args.any fun argument => (argument.splitOn ",").contains "plonk" + let groth16 := args.any fun argument => (argument.splitOn ",").contains "groth16" + let gnark := plonk || groth16 + let plonkCuda := plonk && usesCuda args + let groth16Cuda := groth16 && usesCuda args + if gnark then + let go ← inputDir (pkg.dir / "tools/plonk-wrapper") false fun path => + #["go", "h", "json", "bin"].contains (path.extension.getD "") || + path.fileName == "go.mod" || path.fileName == "go.sum" + deps := deps.zipWith (fun _ _ => ()) go + if plonkCuda then + let native ← inputDir (pkg.dir / "crates/proof-compression/cuda/plonk") true fun path => + #["cu", "cuh", "cpp", "h", "hpp", "py", "map", "json", "patch"].contains (path.extension.getD "") + deps := deps.zipWith (fun _ _ => ()) native + if let some path ← IO.getEnv "IX_PLONK_CUDA_LIBRARY" then + -- The build script resolves the library from its crate directory before selecting the header. + let resolved ← Job.async do + return ← IO.FS.realPath (pkg.dir / "crates/proof-compression" / FilePath.mk path) + let inputs ← resolved.bindM fun libraryPath => do + let mut inputs := #[← inputBinFile libraryPath] + let packagedHeader := libraryPath.parent.get!.parent.get! / "include/ix_plonk_bn254.h" + if let .ok { type := .file, .. } ← packagedHeader.metadata.toBaseIO then + inputs := inputs.push (← inputTextFile packagedHeader) + return Job.collectArray inputs + deps := deps.zipWith (fun _ _ => ()) inputs + if groth16Cuda then + if let some root ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" then + for name in icicleLibraries do + let library ← inputBinFile (FilePath.mk root / "lib" / name) + deps := deps.zipWith (fun _ _ => ()) library + let orderedDeps := match before with | some before => deps.zipWith (fun _ _ => ()) before | none => deps let environment ← nativeToolchainVariables.mapM fun name => return (name, ← IO.getEnv name) let cargoEnv := cargoEnvironment environment let native := pkg.buildDir / "lib" / cudaLibraryName let cuda := usesCuda args - let outputs := if cuda then #[output, native] else #[output] - deps.mapM fun _ => do + let plonkNative := pkg.buildDir / "lib" / plonkCudaLibraryName + let mut outputs := if cuda then #[output, native] else #[output] + if plonkCuda then outputs := outputs.push plonkNative + if groth16Cuda then outputs := outputs ++ icicleLibraries.map (fun name => pkg.buildDir / "lib" / FilePath.mk name) + orderedDeps.mapM fun _ => do + let cargoEnv ← if gnark then do + let go := (← IO.getEnv "GO").getD "go" + let version ← captureProc' { + cmd := go, args := #["version"], env := #[("GOTOOLCHAIN", some "local")] + } + pure <| cargoEnv.push ("IX_GNARK_GO_VERSION", some version.stdout) + else pure cargoEnv addPureTrace (args, environment, cargoEnv) s!"cargo args: {args}; native toolchain: {environment}" buildUnlessUpToDate outputs (← getTrace) (output.addExtension "trace") do let result ← captureProc' { @@ -187,6 +255,17 @@ def buildCargoArtifact (pkg : Package) (args : Array String) (tag : String) | .ok source => pure source | .error message => error message copyNativeArtifact source native tag + if plonkCuda then + let source ← match cargoPlonkLibrary result.stdout with + | .ok source => pure source + | .error message => error message + copyNativeArtifact source plonkNative tag + if groth16Cuda then + let root ← match cargoIcicleRoot result.stdout with + | .ok source => pure source + | .error message => error message + for name in icicleLibraries do + copyNativeArtifact (root / "lib" / name) (pkg.buildDir / "lib" / name) tag copyNativeArtifact built output tag setTrace (← computeTrace outputs) return output @@ -222,105 +301,6 @@ target ix_compression_worker pkg : FilePath := do (pkg.dir / "target" / "release" / "ix-compression-worker") (pkg.buildDir / "bin" / "ix-compression-worker") before -/-- The terminal prover is a native Go executable with a pinned module graph. -/ -def compressionHelperName : String := "ix-bn254-plonk-wrapper" - -def compressionHelperArgs (output : FilePath) : Array String := - #["build", "-buildvcs=false", "-trimpath", "-mod=readonly"] ++ - #["-o", output.toString, "."] - -private def compressionCudaFlag (name : String) : IO Bool := do - match (← IO.getEnv name).getD "0" with - | "1" | "true" | "yes" => pure true - | "0" | "false" | "no" | "" => pure false - | _ => throw <| IO.userError s!"{name} must be 0/1, false/true or no/yes" - -/-- PLONK arithmetic selection is independent of the inner KZG CUDA backend. -/ -def compressionHelperCuda : IO Bool := compressionCudaFlag "IX_PLONK_CUDA" - -/-- Groth16 uses the pinned ICICLE backend when explicitly enabled. -/ -def compressionGroth16Cuda : IO Bool := compressionCudaFlag "IX_GROTH16_CUDA" - -def plonkCudaLibraryName := "libix_plonk_bn254_cuda.so" - -target ix_plonk_wrapper pkg : Array FilePath := do - let features ← compressionFeatures - let cuda ← compressionHelperCuda - let groth16Cuda ← compressionGroth16Cuda - unless features.contains "plonk" || features.contains "groth16" do - return pure #[] - let mut sources ← inputDir (pkg.dir / "tools/plonk-wrapper") false fun path => - path.extension == some "go" || path.extension == some "json" || - path.extension == some "py" || path.extension == some "h" || - path.extension == some "bin" || path.fileName == "go.mod" || path.fileName == "go.sum" - if cuda then - let native ← inputDir (pkg.dir / "crates/proof-compression/cuda") true fun path => - #["cu", "cuh", "cpp", "h", "hpp", "py", "map", "json", "patch"].contains (path.extension.getD "") - sources := sources.zipWith (fun paths _ => paths) native - let helperName := compressionHelperName - let output := pkg.buildDir / "bin" / helperName - let library := pkg.buildDir / "lib" / plonkCudaLibraryName - let outputs := if cuda then #[output, library] else #[output] - let prebuilt ← IO.getEnv "IX_PLONK_PREBUILT" - if let some path := prebuilt then - let executable ← inputBinFile (FilePath.mk path) - sources := sources.zipWith (fun paths _ => paths) executable - if cuda then - let native ← inputBinFile ((FilePath.mk path).parent.get!.parent.get! / "lib" / plonkCudaLibraryName) - sources := sources.zipWith (fun paths _ => paths) native - let environment ← #["IX_PLONK_CUDA_ARCHS", "IX_PLONK_SPPARK_SOURCE", "IX_PLONK_BLST_SOURCE", - "NVCC", "CUDA_HOME", "NVCC_CCBIN", "CC", "CXX", "IX_GROTH16_ICICLE_ROOT"].mapM fun name => - return (name, ← IO.getEnv name) - sources.mapM fun _ => do - let compiler ← match prebuilt with - | some path => pure path - | none => (·.stdout) <$> captureProc' { cmd := "go", args := #["version"] } - addPureTrace (prebuilt, compiler, features, cuda, groth16Cuda, environment) s!"PLONK helper compiler: {compiler}" - buildUnlessUpToDate outputs (← getTrace) (output.addExtension "trace") do - match prebuilt with - | some path => - copyNativeArtifact (FilePath.mk path) output "plonk" - if cuda then - let source := (FilePath.mk path).parent.get!.parent.get! / "lib" / plonkCudaLibraryName - copyNativeArtifact source library "plonk-cuda" - | none => - IO.FS.createDirAll output.parent.get! - let temporary := ((← IO.FS.realPath output.parent.get!) / helperName).addExtension "tmp" - if cuda then - let some sppark ← IO.getEnv "IX_PLONK_SPPARK_SOURCE" - | error "IX_PLONK_SPPARK_SOURCE must name the pinned sppark source tree" - let some blst ← IO.getEnv "IX_PLONK_BLST_SOURCE" - | error "IX_PLONK_BLST_SOURCE must name the pinned blst source tree" - let grothArgs ← if groth16Cuda then do - let some icicle ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" - | error "IX_GROTH16_ICICLE_ROOT must name the pinned ICICLE v3.2.2 installation" - pure #["--groth16-icicle-root", icicle] - else pure #[] - proc { - cmd := "python3" - args := #["tools/plonk-wrapper/build_cuda.py", "--out", temporary.toString, - "--build-dir", (pkg.buildDir / "plonk-cuda").toString, - "--sppark", sppark, "--blst", blst] ++ grothArgs - cwd := pkg.dir - } - else if groth16Cuda then - let some icicle ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" - | error "IX_GROTH16_ICICLE_ROOT must name the pinned ICICLE v3.2.2 installation" - proc { - cmd := "python3" - args := #["tools/plonk-wrapper/build_groth16_cuda.py", "--out", temporary.toString, "--icicle-root", icicle] - cwd := pkg.dir - } - else - proc { - cmd := "go" - args := compressionHelperArgs temporary - cwd := pkg.dir / "tools/plonk-wrapper" - } - IO.FS.rename temporary output - setTrace (← computeTrace outputs) - return outputs - /-- Cargo feature variants wait for the default archive before building. -/ target ix_rs_test pkg : FilePath := do buildRustStatic pkg (← cargoArgs (testFfi := true)) "test" (some (← ix_rs.fetch)) @@ -354,7 +334,7 @@ lean_lib Ix where lean_exe ix where root := `Main supportInterpreter := true - needs := #[`@/ix_compression_worker, `@/ix_plonk_wrapper] + needs := #[`@/ix_compression_worker] moreLinkObjs := #[ix_rs_net] section Tests @@ -511,10 +491,8 @@ script install := do let noWriteAccess := { fullAccess with write := false } let fileRight := { user := fullAccess, group := fullAccess, other := noWriteAccess } FS.createDirAll binDir - -- Compression resolves its worker and terminal prover beside the CLI. - let mut binaries := #["ix", "ix-compression-worker"] - if (← compressionFeatures).contains "plonk" then - binaries := binaries.push compressionHelperName + -- Compression resolves its worker beside the CLI. + let binaries := #["ix", "ix-compression-worker"] for name in binaries do let destination := binDir / name FS.writeBinFile destination (← FS.readBinFile $ ".lake" / "build" / "bin" / name) @@ -524,11 +502,18 @@ script install := do let libraryDir := binDir.parent.getD "." / "lib" FS.createDirAll libraryDir FS.writeBinFile (libraryDir / cudaLibraryName) (← FS.readBinFile cudaLibrary) - if ← compressionHelperCuda then + let features ← cargoFeatures + if features.contains "plonk" && usesCuda features then let libraryDir := binDir.parent.getD "." / "lib" FS.createDirAll libraryDir let source : FilePath := ".lake" / "build" / "lib" / plonkCudaLibraryName FS.writeBinFile (libraryDir / plonkCudaLibraryName) (← FS.readBinFile source) + if features.contains "groth16" && usesCuda features then + let libraryDir := binDir.parent.getD "." / "lib" + FS.createDirAll libraryDir + for name in icicleLibraries do + FS.createDirAll (libraryDir / name).parent.get! + FS.writeBinFile (libraryDir / name) (← FS.readBinFile $ ".lake" / "build" / "lib" / name) return 0 script "get-exe-targets" := do diff --git a/tests/check_compression_build.py b/tests/check_compression_build.py index bf65b75d8..8ef7cb4fd 100644 --- a/tests/check_compression_build.py +++ b/tests/check_compression_build.py @@ -11,6 +11,8 @@ def features(arguments): + if '--features' not in arguments: + return set() index = arguments.index('--features') return set(arguments[index + 1].split(',')) @@ -43,17 +45,11 @@ def main(): cases += [ ('trace-codegen', {'IX_KZG_CURVE': 'bn254', 'IX_CUDA_TRACE_CODEGEN': '1'}, 'bn254', True, True), ('no-parallel', {'IX_KZG_CURVE': 'bn254', 'IX_NO_PAR': '1'}, 'bn254', False, False), - ('plonk-cuda-only', {'IX_KZG_CURVE': 'bn254', 'IX_PLONK_CUDA': '1'}, 'bn254', False, True), - ('both-cuda', {'IX_KZG_CURVE': 'bn254', 'IX_PLONK_CUDA': '1', 'IX_CUDA': '1'}, 'bn254', True, True), - ] - cases += [ - ('groth16-cuda-only', {'IX_GROTH16_CUDA': '1'}, 'bn254', False, True), - ('all-cuda', {'IX_GROTH16_CUDA': '1', 'IX_PLONK_CUDA': '1', 'IX_CUDA': '1'}, 'bn254', True, True), ] report = {'lean': version, 'typecheck_passed': True, 'cases': []} for name, selection, curve, cuda, parallel in cases: environment = dict(os.environ, LEAN_PATH=str(out)) - for variable in ['IX_KZG_CURVE', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR', 'IX_PLONK_CUDA', 'IX_GROTH16_CUDA']: + for variable in ['IX_KZG_CURVE', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR']: environment.pop(variable, None) environment.update(selection) result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], @@ -65,7 +61,7 @@ def main(): required = {'plonk', 'groth16'} required |= {'parallel'} if parallel else set() worker = required | ({'cuda'} if cuda else set()) - ffi = required | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) + ffi = (required - {'plonk', 'groth16'}) | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) for target, expected in [ ('worker', worker), ('ffi', ffi), ('net', ffi | ({'net'} if sys.platform != 'darwin' else set())), @@ -79,17 +75,10 @@ def main(): raise RuntimeError(f'{name}: unexpected {target} features: {arguments}') if actual['worker'][actual['worker'].index('--bin') + 1] != 'ix-compression-worker': raise RuntimeError(f'{name}: wrong worker executable') - if actual['terminal_groth16'] is not True: - raise RuntimeError(f'{name}: Groth16 terminal is missing') - if actual['groth16_cuda'] != (selection.get('IX_GROTH16_CUDA') == '1'): - raise RuntimeError(f'{name}: Groth16 CUDA selection depends on another backend') - if actual['terminal_plonk'] is not True: + if actual['terminal_plonk'] is not True or actual['terminal_groth16'] is not True: raise RuntimeError(f'{name}: wrong terminal backend selection') - if actual['plonk_cuda'] != (selection.get('IX_PLONK_CUDA') == '1'): - raise RuntimeError(f'{name}: PLONK CUDA selection depends on inner CUDA selection') - helper = 'ix-bn254-plonk-wrapper' - if actual['plonk_helper'] != helper or '-tags' in actual['plonk_build']: - raise RuntimeError(f'{name}: wrong PLONK helper or Go build tags') + if actual['plonk_cuda'] != cuda: + raise RuntimeError(f'{name}: PLONK CUDA requires both plonk and cuda') report['cases'].append({'name': name, 'environment': selection, 'arguments': actual, 'passed': True}) environment.update(IX_KZG_CURVE='invalid') result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], @@ -98,10 +87,10 @@ def main(): if result.returncode != 1 or 'IX_KZG_CURVE must be bn254' not in result.stdout: raise RuntimeError('invalid curve was not rejected as expected') report['invalid_curve_rejected'] = True - environment.update(IX_KZG_CURVE='bls12-381', IX_PLONK_CUDA='1') + environment.update(IX_KZG_CURVE='bls12-381') result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - (out / 'invalid-plonk-cuda.log').write_text(result.stdout) + (out / 'retired-curve.log').write_text(result.stdout) if result.returncode != 1 or 'IX_KZG_CURVE must be bn254' not in result.stdout: raise RuntimeError('retired curve configuration was not rejected') report['retired_curve_rejected'] = True diff --git a/tests/compression_build_config.lean b/tests/compression_build_config.lean index ce954d9ed..78c6600ed 100644 --- a/tests/compression_build_config.lean +++ b/tests/compression_build_config.lean @@ -10,10 +10,7 @@ def main : IO Unit := do ("net", Lean.toJson net), ("test", Lean.toJson test), ("worker", Lean.toJson (compressionWorkerArgs features)), - ("terminal_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), ("terminal_groth16", Lean.toJson ((← compressionFeatures).contains "groth16")), - ("groth16_cuda", Lean.toJson (← compressionGroth16Cuda)), - ("plonk_helper", Lean.toJson (compressionHelperName)), - ("plonk_cuda", Lean.toJson (← compressionHelperCuda)), - ("plonk_build", Lean.toJson (compressionHelperArgs "helper-output")) + ("terminal_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), + ("plonk_cuda", Lean.toJson (features.contains "plonk" && usesCuda features)) ]).compress diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index 5b9f4a40c..a1e7576cd 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -1,191 +1,98 @@ -# BN254 PLONK wrapper +# BN254 terminal gnark backends -This helper proves the exported Ix KZG verifier using BN254 PLONK. It binds -the expected claim digest and checks both outer and deferred pairing -obligations. The checked-in verifier witness comes from an authenticated -Ignition Init proof. +This Go module implements the terminal Ix verifier circuit and calls stock +gnark. Cargo builds it as a C archive inside `ix-compression-worker`; +`pipeline/gnark.rs` owns the Rust bindings and loaded prover handles. +There is no separate Go executable. -## Build and use through ix +The circuit binds the expected claim digest and checks outer and deferred +pairing obligations. It uses classic GLV, separate pairing checks, and explicit +`accumulatorCount`, `claims`, and `publics` in the verifier description. -`lake build ix` builds the CLI, compression worker, and -`ix-bn254-plonk-wrapper`. The Go module builds BN254 unconditionally; -the optional `cuda` tag enables GPU arithmetic. Nix provides -`.#plonk-wrapper`, `.#plonk-wrapper-cuda`, and the complete `.#ix` CLI. +## Build and use -Configure `IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, -`IX_KZG_IGNITION_ADMISSION`, and `IX_KZG_IGNITION_ADMISSION_BLAKE3` using the -[compression setup guide](../../crates/proof-compression/README.md). +`lake build ix` builds the Lean CLI and native Rust compression worker. The +worker enables `plonk` and `groth16`; the Lean-facing `ix-ffi` library does not. CPU +builds need Go and a C compiler, but no CUDA toolkit. A direct worker build is: ```sh -ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output +cargo build --release -p ix-proof-compression --bin ix-compression-worker \ + --no-default-features --features parallel,plonk,groth16 ``` -Terminal PLONK requires authenticated ceremony parameters. The output's -`plonk` directory contains native/Solidity proofs, the generated verifier, -verifying key, admission, and candidate verifier catalog. `IX_PLONK_HELPER` -can override the executable location. - -## Optional BN254 CUDA backend - -CPU is the default. The experimental CUDA helper supports BN254 domains through -`2^24`, including the current Init circuit, with GPU MSMs, FFTs and PLONK -numerator evaluation through stock gnark's `Accelerator` interface. CPU and CUDA -builds use the same pinned `go.mod` and `go.sum`. Gnark retains quotient division, -witness solving, randomness, transcript challenges, opening construction and -verification. Proof encoding and the verifying key are independent of the -selected arithmetic backend. - -The source build uses explicit copies of sppark revision -`114feb69ebfe029e2caa5e6c2d372c695e5d4da8` and blst 0.3.16. The blst source root -must contain `src/server.c` and `build/assembly.S`. No Rust build archive is -required. Provide these source roots and a CUDA toolkit supporting the chosen -GPU architecture: +Configure the four Ignition environment variables from the +[compression setup guide](../../crates/proof-compression/README.md), then run: ```sh -export IX_PLONK_SPPARK_SOURCE=/path/to/pinned-sppark -export IX_PLONK_BLST_SOURCE=/path/to/blst-0.3.16/blst -export CUDA_HOME=/usr/local/cuda -export NVCC="$CUDA_HOME/bin/nvcc" -export IX_PLONK_CUDA_ARCHS=120 -IX_PLONK_CUDA=1 lake build ix_plonk_wrapper +ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output ``` -The standalone equivalent is: +Rust authenticates the setup and inner proof, selects the cache, calls gnark, +and publishes the native proof, Solidity payload, key, verifier contract, +admission, and candidate catalog. The proof envelope is written last, after +verification succeeds. `IX_COMPRESSION_WORKER` overrides the worker location. +Nix provides `.#compression-worker`, `.#compression-worker-cuda`, and +`.#ix`. -```sh -python3 tools/plonk-wrapper/build_cuda.py \ - --out .lake/build/bin/ix-bn254-plonk-wrapper \ - --build-dir .lake/build/plonk-cuda \ - --sppark "$IX_PLONK_SPPARK_SOURCE" --blst "$IX_PLONK_BLST_SOURCE" -``` - -The native build checks source receipts and applies the tracked sppark patch -to a private build copy. The helper uses ordinary `go build -tags=cuda` against -the pinned upstream modules and records executable/library hashes. The native -library is installed in `../lib` relative to the helper. Keep both artifacts together when installing; `lake run install` -does this when `IX_PLONK_CUDA=1`. The optional Nix package is -`.#plonk-wrapper-cuda`, targeting SM120. CPU packages remain independent of -the CUDA toolkit. +## CUDA -Select the runtime backend separately from building it: +Enabling both `plonk` and `cuda` Cargo features enables GPU MSMs, FFTs and numerator +evaluation through gnark's upstream `Accelerator` interface. Gnark retains +quotient division, witness solving, randomness, transcript challenges, opening +construction, and verification. CPU and CUDA use the same module graph and +proof encodings. ```sh -CUDA_VISIBLE_DEVICES=0 IX_PLONK_BACKEND=cuda \ - .lake/build/bin/ix-bn254-plonk-wrapper prove artifacts input/witness.json proof-output +export CUDA_HOME=/usr/local/cuda +export IX_PLONK_CUDA_ARCHS=120 +IX_CUDA=1 lake build ix ``` -`IX_PLONK_CUDA_DEVICE` selects the visible ordinal (default 0), and -`IX_PLONK_CUDA_MEMORY_GIB` sets the allocation budget (default 64 GiB), with -8 GiB additional device headroom. Use one visible physical GPU per helper. -`IX_PLONK_CUDA` does not imply the inner prover's `IX_CUDA`, or vice versa. -Requesting CUDA from a CPU-only helper fails explicitly. CUDA errors abort that -proof; they do not silently switch its arithmetic to CPU. - -The helper emits callback times, dispatch counts, transferred bytes, allocation -accounting and CUDA pool high-water marks. Callback times include transfers; -numerator timing includes its internal transforms and host restoration. Pool -high-water marks exclude allocations outside the pool. Complete-process timing -also includes artifact authentication/loading and backend setup. -Queue wait and nested native timings overlap callback timings; they are not -independent stages to add to the total. -Set `IX_PLONK_CUDA_TRACE=1` for operation start/end diagnostics without scalar -values. Transfer counters cover explicit adapter transfers; internal sppark -result transfers may add traffic. - -Transfer metrics report per-callback byte/call deltas and separate inclusive -transfer wall time, host staging and DMA event time. Wall time can include -previously queued kernels; host staging and DMA overlap. DMA timings are valid -only when `TransferTimingEnabled` is true. The shared transfer implementation -currently enables those events with `IX_KZG_CUDA_PROFILE=1`. - -The opt-in full Init test supports `IX_PLONK_CUDA_INIT_MODES=cpu,cuda`, -`IX_PLONK_CUDA_INIT_REPEATS` and `IX_PLONK_CUDA_INIT_CPU_PROFILE=1`, in addition -to its required artifact/claim pins. CPU sampling covers only the first selected -proof. Reports separate backend setup and profiler startup/shutdown from core -proving. - -`IX_PLONK_CUDA_MSM_STRATEGY=split` is the default: large Pippenger buckets are -split into bounded tasks while retaining the zero and ±1 fast paths. The -explicit `mask` comparison strategy computes `MSM(s+r)-MSM(r)` and caches its -correction points by registered range. Neither path applies whole-scalar -affine normalization. Both preserve the original commitment exactly. - -Artifact manifests still bind their producer executable. Compile and prepare -artifacts with the frozen helper used for proving; CPU/CUDA mode changes within -that helper do not require different keys. A changed helper does not authorize -editing an old manifest. The full Init test harness accepts an independently -pinned existing manifest without changing production loading rules. +Cargo obtains the pinned sppark and BLST sources from its `cuda` dependencies. `IX_PLONK_SPPARK_SOURCE` and +`IX_PLONK_BLST_SOURCE` optionally override those roots. Alternatively, +`IX_PLONK_CUDA_LIBRARY` names an already-built `libix_plonk_bn254_cuda.so`. +Lake installs that library beside the worker in `../lib`. Nix supplies its +pinned native library directly. Primitive source ownership and build details +are in `crates/proof-compression/cuda/plonk`. + +Select execution separately with `IX_PLONK_BACKEND=cpu` or `cuda`. +`IX_PLONK_CUDA_DEVICE` selects the visible GPU ordinal; the default is zero. +`IX_PLONK_CUDA_MEMORY_GIB` sets the budget, defaulting to 64 GiB with 8 GiB +additional headroom. `IX_PLONK_CUDA_MSM_STRATEGY` accepts `split` (default) or +`mask`. `IX_CUDA` enables CUDA for Aiur, FRI, KZG, and terminal PLONK. + +A loaded prover retains its keys and optional accelerator across calls. Rust +requires exclusive access for each proof and releases the Go handle on drop. +CUDA failures abort the proof; they do not select CPU fallback. Metrics on +stderr report arithmetic calls, transfers and memory use when the accelerator +closes. `IX_PLONK_CUDA_TRACE=1` enables callback diagnostics; DMA event timing +uses `IX_KZG_CUDA_PROFILE=1`. The [stock gnark comparison](../../docs/proof-benchmark-comparison.md#stock-gnark-comparison) -records the Init measurements behind the upstream integration choice. - -The persistent worker protocol, immutable selector caching across proofs, -shared admission with KZG workers, and cancellation/restart policy remain -future service work. +records why the backend uses the upstream interface without fused extensions. -## Authorize verification keys independently +## Trust and artifacts -`IX_PLONK_VERIFIER_CATALOG` names an independently trusted JSON catalog. -Generating a proof does not authorize its candidate catalog. The format is -`ix-bn254-plonk-verifiers-v1`; entries use `setup: "ignition"` and pin the -verifying-key SHA-256, inner verifying-key identity, program prefix, circuit -hash, helper hash, and Ignition admission BLAKE3 digest. Relative paths are -resolved beside the catalog. Verification needs neither a proving SRS nor a -GPU, and derives the expected public digest from the caller's claim. +Rust owns artifact and Lagrange-cache metadata. Circuit caches bind the worker +build, verifier description and admitted setup. Lagrange caches bind the setup, +domain and monomial receipt. Go checks the sizes and digests of decoded native +artifacts and rejects a witness whose claim differs from the independent digest +supplied by Rust. Retaining a prover avoids loading its circuit and keys again. -## Standalone operations +`IX_PLONK_VERIFIER_CATALOG` selects an independently authorized catalog. A proof's +candidate catalog is not automatically trusted. Entries bind the verifying-key +SHA-256, inner verifier identity, program prefix, circuit hash, and Ignition +admission. Verification requires neither a proving SRS nor a GPU. -`ix-compression-worker plonk export` authenticates a portable BLAKE3 verifying key, -verifies its packet against an independently expected statement, and exports -the fixed scalar program and canonical witness: +The worker retains the `plonk export`, `export-first`, `srs`, `wrap`, and +`verify` commands. For example: ```sh -ix-compression-worker plonk export \ - vk.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input -ix-bn254-plonk-wrapper compile input/vk.json artifacts -ix-bn254-plonk-wrapper check-witness artifacts input/witness.json +ix-compression-worker plonk verify proof.envelope.bin claim.bin ``` -`check-witness` is an assignment diagnostic, not a proof. It uses diagnostic -commitment challenges. Production `prove` uses gnark's real KZG commitment -computation and verifies the resulting PLONK proof before publishing it. - -Read the padded `Domain` from `artifacts/manifest.json`, then export the required -parameters through ix: - -```sh -ix-compression-worker plonk srs DOMAIN parameters.bin -ix-bn254-plonk-wrapper prepare artifacts parameters.bin TRUSTED_EXPORT_BLAKE3 -ix-bn254-plonk-wrapper prove artifacts input/witness.json proof-output -ix-bn254-plonk-wrapper verify \ - artifacts/verifying-key.bin TRUSTED_KEY_SHA256 \ - proof-output/proof.bin EXPECTED_CLAIM_DIGEST -``` - -The parameter export receipt is written beside `parameters.bin` as -`parameters.json`. Obtain `TRUSTED_EXPORT_BLAKE3` from that authenticated Rust -operation, independently of an untrusted parameter file. Gnark requires `N+3` -monomial powers and `N` Lagrange points; the adapter requests the next power of -two from ix and retains the ceremony's full public degree. Its current maximum -PLONK domain is `2^26` when a sufficient authenticated parameter prefix is available. - -Circuit and key artifacts bind the verifying key, executable and parameter receipt. -The helper checks hashes before reuse. Lagrange points are cached separately -by setup, domain and monomial receipt; `IX_PLONK_LAGRANGE_CACHE` can -select the shared directory. A changed executable needs a new artifact -directory. Corrupt artifacts fail closed. - -## BN254 Ignition parameters - -Export requires `IX_KZG_IGNITION_CACHE`, `IX_KZG_IGNITION_CACHE_BLAKE3`, -`IX_KZG_IGNITION_ADMISSION` and `IX_KZG_IGNITION_ADMISSION_BLAKE3`, as described -in the [compression setup documentation](../../crates/proof-compression/README.md). -The provisioned `2^26` monomial cache supports PLONK domain `2^25`, which -requires `2^25+3` powers. The Init wrapper uses classic GLV and separate pairing -checks: 14,689,440 constraints plus two public rows fit a `2^24` domain. -`prepare` derives and caches its Lagrange basis from the authenticated monomial -powers. The exported verifier must explicitly supply `accumulatorCount`, -`claims`, and `publics`. +Internal metadata has no helper-executable identity. Rebuild old artifact caches +and regenerate candidate catalogs instead of editing or translating them. ## Tests @@ -194,22 +101,26 @@ cd tools/plonk-wrapper go test ./... ``` -The ordinary tests cover Blake3 boundaries, canonical point encodings, -the actual Init verifier witness and tampering, exceptional MSM inputs, -small PLONK proofs, explicit verifier schemas, and parameter-cache integrity. -The opt-in full Init test above is the common CPU/CUDA profiling and integration -entry point. Its loader validates the supplied artifact dimensions instead of -assuming a fixed historical constraint count. +Go tests cover circuit tampering, canonical encodings, exceptional MSM inputs, +small PLONK proofs, SRS/basis conversion and cache corruption. Rust FFI tests use +the authenticated Init proof and exercise bad claims, keys, proofs, buffers and +handles: + +```sh +cargo test --release -p ix-proof-compression --no-default-features \ + --features parallel,plonk --lib pipeline::gnark::tests +``` -Current Init proofs use a `2^24` PLONK domain. Recorded native, envelope, and -Solidity proof sizes are 584, 624, and 864 bytes. See the -[benchmark comparison](../../docs/proof-benchmark-comparison.md) for hardware, -timings, and measurement boundaries, and the -[contract guide](../../contracts/plonk/README.md) for on-chain validation. +The full compile and retained-prover tests are ignored by default. The latter +requires `IX_GNARK_INIT_ARTIFACTS`, `IX_GNARK_INIT_MANIFEST_SHA256`, and +`IX_GNARK_INIT_WITNESS`; run it explicitly only after budgeting memory and time. +The Go `TestBN254CUDAInit` remains an opt-in arithmetic integration/profiling +entry point. The [contract guide](../../contracts/plonk/README.md) covers +Solidity verification of the unchanged proof encoding. ## BN254 Groth16 terminal -The helper also implements `groth16` over the same KZG verifier circuit and +The worker also implements `groth16` over the same KZG verifier circuit and witness schema. It compiles R1CS instead of PLONK's sparse constraints. The two public inputs remain the little-endian 128-bit halves of the expected BLAKE3 claim digest. The circuit retains the outer and deferred KZG pairing checks. @@ -228,16 +139,16 @@ exporter, then compile the Groth16 circuit: ```sh ix-compression-worker groth16 export verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input -ix-bn254-plonk-wrapper groth16 compile input/vk.json groth16-keys -ix-bn254-plonk-wrapper groth16 setup-development groth16-keys -ix-bn254-plonk-wrapper groth16 prove groth16-keys input/witness.json EXPECTED_CLAIM_DIGEST proof +ix-compression-worker groth16 compile input/vk.json groth16-keys +ix-compression-worker groth16 setup-development groth16-keys +ix-compression-worker groth16 prove groth16-keys input/witness.json EXPECTED_CLAIM_DIGEST proof ``` `TRUSTED_VERIFIER_BLAKE3` authenticates the serialized KZG verifier file. `EXPECTED_CLAIM_DIGEST` is obtained independently of the witness. The exporter requires admitted Ignition parameters for the inner KZG proof; local Groth16 -setup does not change the inner setup policy. Explicit compilation checks a -fresh circuit digest before reusing an existing artifact directory. +setup does not change the inner setup policy. Compilation writes a fresh +circuit into an empty artifact directory. The CLI uses prepared keys and an independently approved catalog: @@ -313,8 +224,8 @@ G1/G2 MSMs and QAP polynomial work. CPU and CUDA use the same serialized keys, proof format, SHA-256 commitment hash, and CPU verifier. ```sh -nix build .#groth16-wrapper-cuda -export IX_GROTH16_HELPER="$PWD/result/bin/ix-bn254-plonk-wrapper" +nix build .#compression-worker-cuda +export IX_COMPRESSION_WORKER="$PWD/result/bin/ix-compression-worker" export IX_GROTH16_BACKEND=cuda export IX_GROTH16_DEVICE=0 export IX_GROTH16_PIN_KEY=1 @@ -323,20 +234,20 @@ export IX_GROTH16_PIN_KEY=1 `IX_GROTH16_PIN_KEY` defaults to `0`, streaming key vectors as needed. Set it to `1` only when persistent key vectors and working memory fit on the selected GPU. Each prover handle stays on one device; operations and cleanup are serialized -per device. The helper does not warm other GPUs or silently fall back to CPU. +per device. The worker does not warm other GPUs or silently fall back to CPU. For a manual ICICLE installation, build only BN254 with MSM, NTT and G2 enabled, -and use the native source revision above. Then: - -```sh -python3 tools/plonk-wrapper/build_groth16_cuda.py --out /path/to/bin/ix-bn254-plonk-wrapper --icicle-root /persistent/icicle-installation -``` - -The installation remains a runtime dependency. `IX_GROTH16_ICICLE_ROOT` supplies -it to Lake when `IX_GROTH16_CUDA=1`. The terminal CUDA choice is independent of -`IX_CUDA` and `IX_PLONK_CUDA`. `.#terminal-wrapper-cuda` includes both terminal -accelerators; `.#icicle-bn254` builds the native dependency alone. These CUDA -packages target Linux x86-64 and SM 120. CPU-only builds require no CUDA libraries. +using the pinned revision above. Set `IX_GROTH16_ICICLE_ROOT` to its installation +prefix, then build with `IX_CUDA=1 lake build ix` or Cargo features +`parallel,plonk,groth16,cuda`. The native build checks the libraries and packages +both the core libraries and CUDA backend modules beside the worker. Nix supplies +these dependencies in `.#compression-worker-cuda`; `.#icicle-bn254` builds ICICLE +alone. Verification remains CPU-only and does not initialize a device. + +Rust parses device selection and key-pinning options and passes them explicitly +through FFI. The retained handle owns its native circuit, keys and device memory. +Artifact manifests are internal metadata; regenerate them with the worker's +compile/setup commands when changing the implementation. ### Solidity and tests @@ -354,8 +265,11 @@ IX_GROTH16_CUDA_TEST=1 go test -tags icicle -run '^TestGroth16CUDAInterop$' . cd contracts/groth16 && forge test ``` -The full CPU fixture check is opt-in with `IX_GROTH16_WRAPPER_TEST=1` and -`IX_GROTH16_WRAPPER_PROVE=1`; it includes local setup and takes several minutes. +Rust tests verify the checked-in Init proof through FFI by default. The ignored +`pipeline::groth16::native_tests::compiles_init_without_changing_constraints` +checks the full circuit identity. `retained_init_prover_reuses_keys` uses prepared +keys in `IX_GROTH16_ARTIFACTS`, proves twice, checks independent verification, +and tests rejection and cleanup; it requires several GiB of memory. The matched performance harness is `TestBN254TerminalComparison`. It accepts `IX_TERMINAL_COMPARE=1`, `IX_TERMINAL_COMPARE_OUT`, `IX_TERMINAL_GROTH16_ARTIFACTS`, and optionally diff --git a/tools/plonk-wrapper/artifacts.go b/tools/plonk-wrapper/artifacts.go index 78c382556..61a8c47b1 100644 --- a/tools/plonk-wrapper/artifacts.go +++ b/tools/plonk-wrapper/artifacts.go @@ -3,7 +3,6 @@ package main import ( "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "io" "os" @@ -15,13 +14,12 @@ type artifact struct { Bytes int64 } type manifest struct { - Format, ExecutableSHA256, InnerVKSHA256 string - SRSBLAKE3 string - KeysReady bool - Domain uint64 - Constraints, PublicInputs, Commitments int - Files map[string]artifact - Seconds map[string]float64 + InnerVKSHA256 string + SRSBLAKE3 string + KeysReady bool + Domain uint64 + Constraints, PublicInputs, Commitments int + Files map[string]artifact } func fileHash(path string) (artifact, error) { @@ -65,108 +63,41 @@ func atomicFile(path string, write func(io.Writer) error) error { return d.Sync() } -func writeJSON(path string, value any) error { - return atomicFile(path, func(w io.Writer) error { enc := json.NewEncoder(w); enc.SetIndent("", " "); return enc.Encode(value) }) -} - -func saveBinary(dir, name string, value io.WriterTo, m *manifest) error { - path := filepath.Join(dir, name) - if err := atomicFile(path, func(w io.Writer) error { _, err := value.WriteTo(w); return err }); err != nil { - return err - } - entry, err := fileHash(path) - if err != nil { - return err - } - m.Files[name] = entry - return nil -} - -func (m *manifest) checkFile(dir, name string) error { - want, ok := m.Files[name] - if !ok { - return fmt.Errorf("artifact %s missing from manifest", name) - } - got, err := fileHash(filepath.Join(dir, name)) - if err != nil { - return err - } - if got != want { - return fmt.Errorf("artifact %s checksum or size differs", name) - } - return nil -} - -func loadManifest(dir string) (*manifest, error) { - var m manifest - if err := decodeJSON(filepath.Join(dir, "manifest.json"), &m); err != nil { - return nil, err - } - if m.Format != artifactFormat || m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil || m.Seconds == nil { - return nil, fmt.Errorf("invalid wrapper artifact manifest") +func (m *manifest) validate() error { + if m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil { + return fmt.Errorf("invalid wrapper artifact metadata") } if m.KeysReady { if _, err := unhex(m.SRSBLAKE3, 32); err != nil { - return nil, fmt.Errorf("invalid parameter receipt: %w", err) + return err } } - executable, err := os.Executable() - if err != nil { - return nil, err - } - identity, err := fileHash(executable) - if err != nil { - return nil, err - } - if identity.SHA256 != m.ExecutableSHA256 { - return nil, fmt.Errorf("wrapper executable changed; recompile circuit and keys") - } - if err := m.checkFile(dir, "vk.json"); err != nil { - return nil, err - } if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { - return nil, fmt.Errorf("verifying key identity differs") - } - return &m, nil -} - -func executableHash() (string, error) { - path, err := os.Executable() - if err != nil { - return "", err - } - entry, err := fileHash(path) - return entry.SHA256, err -} - -func (m *manifest) checkKeys(dir string) error { - if !m.KeysReady { - return fmt.Errorf("PLONK keys are not prepared") - } - for _, name := range []string{"proving-key.bin", "verifying-key.bin", "PlonkVerifier.sol"} { - if err := m.checkFile(dir, name); err != nil { - return err - } + return fmt.Errorf("verifying key identity differs") } return nil } -func readBinary(path string, value io.ReaderFrom) error { +func readVerified(path string, expected artifact, value io.ReaderFrom) error { + if expected.Bytes < 1 { + return fmt.Errorf("missing artifact %s", path) + } f, err := os.Open(path) if err != nil { return err } defer f.Close() - n, err := value.ReadFrom(f) + hash := sha256.New() + n, err := value.ReadFrom(io.TeeReader(io.LimitReader(f, expected.Bytes+1), hash)) if err != nil { return err } - stat, err := f.Stat() - if err != nil { - return err + if n != expected.Bytes || hex.EncodeToString(hash.Sum(nil)) != expected.SHA256 { + return fmt.Errorf("decoded artifact differs: %s", path) } - if n != stat.Size() { - return fmt.Errorf("trailing binary data in %s", path) + var extra [1]byte + if n, err := f.Read(extra[:]); n != 0 || err != io.EOF { + return fmt.Errorf("trailing artifact bytes: %s", path) } return nil } diff --git a/tools/plonk-wrapper/backend.go b/tools/plonk-wrapper/backend.go index 7c8fe4647..72add9827 100644 --- a/tools/plonk-wrapper/backend.go +++ b/tools/plonk-wrapper/backend.go @@ -3,15 +3,11 @@ package main import ( "bufio" "bytes" + "crypto/sha256" "encoding/binary" - "encoding/hex" "fmt" "io" "os" - "path/filepath" - "runtime/debug" - "strings" - "time" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/plonk" @@ -95,162 +91,6 @@ func readSRS(path, expectedDigest string, domain uint64, p *verifyingKey) (*nati return srs, nil } -func prepare(dir, path, digest string) error { - decodedDigest, err := unhex(digest, 32) - if err != nil { - return err - } - digest = hex.EncodeToString(decodedDigest) - metadata, err := loadManifest(dir) - if err != nil { - return err - } - if metadata.KeysReady { - if metadata.SRSBLAKE3 != digest { - return fmt.Errorf("prepared keys belong to a different SRS export") - } - if err := metadata.checkKeys(dir); err != nil { - return err - } - fmt.Printf("reused_keys=true verifying_key_sha256=%s\n", metadata.Files["verifying-key.bin"].SHA256) - return nil - } - m := metadata - if err := m.checkFile(dir, "circuit.bin"); err != nil { - return err - } - p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) - if err != nil { - return err - } - fmt.Fprintf(os.Stderr, "stage=srs_import domain=%d\n", m.Domain) - start := time.Now() - srs, err := readSRS(path, digest, m.Domain, p) - if err != nil { - return err - } - m.Seconds["srsImport"] = time.Since(start).Seconds() - start = time.Now() - cacheRoot := os.Getenv("IX_PLONK_LAGRANGE_CACHE") - if cacheRoot == "" { - cacheRoot = filepath.Join(filepath.Dir(dir), "lagrange") - } - fmt.Fprintln(os.Stderr, "stage=lagrange") - lagrangeSRS, reused, err := lagrangeParameters(cacheRoot, srs, m.Domain, p.SetupID, digest) - if err != nil { - return err - } - m.Seconds["lagrange"] = time.Since(start).Seconds() - // The group FFT's temporary arrays must not overlap circuit deserialization. - debug.FreeOSMemory() - fmt.Fprintln(os.Stderr, "stage=load_circuit") - _, ccs, _, err := loadCircuit(dir) - if err != nil { - return err - } - fmt.Fprintln(os.Stderr, "stage=index") - start = time.Now() - pk, vk, err := plonk.Setup(ccs, srs, lagrangeSRS) - if err != nil { - return err - } - m.Seconds["index"] = time.Since(start).Seconds() - m.Commitments = len(vk.(*nativeVerifyingKey).Qcp) - debug.FreeOSMemory() - fmt.Fprintln(os.Stderr, "stage=write_keys") - if err := saveBinary(dir, "proving-key.bin", pk, m); err != nil { - return err - } - if err := saveBinary(dir, "verifying-key.bin", vk, m); err != nil { - return err - } - if err := atomicFile(filepath.Join(dir, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }); err != nil { - return err - } - m.Files["PlonkVerifier.sol"], err = fileHash(filepath.Join(dir, "PlonkVerifier.sol")) - if err != nil { - return err - } - m.SRSBLAKE3, m.KeysReady = digest, true - if err := writeJSON(filepath.Join(dir, "manifest.json"), m); err != nil { - return err - } - fmt.Printf("domain=%d extra_commitments=%d reused_lagrange=%t verifying_key_sha256=%s\n", m.Domain, m.Commitments, reused, m.Files["verifying-key.bin"].SHA256) - return nil -} - -func prove(dir, path, out string) error { - if err := validateProvingBackend(); err != nil { - return err - } - p, ccs, m, err := loadCircuit(dir) - if err != nil { - return err - } - if err := m.checkKeys(dir); err != nil { - return err - } - pk := plonk.NewProvingKey(curveID) - if err := readBinary(filepath.Join(dir, "proving-key.bin"), pk); err != nil { - return err - } - vk := plonk.NewVerifyingKey(curveID) - if err := readBinary(filepath.Join(dir, "verifying-key.bin"), vk); err != nil { - return err - } - assignment, err := witnessAssignment(p, path) - if err != nil { - return err - } - w, err := frontend.NewWitness(assignment, curveID.ScalarField()) - if err != nil { - return err - } - setupStarted := time.Now() - cleanup, err := configureProvingBackend(pk) - if err != nil { - return err - } - defer func() { _ = cleanup() }() - backendSetupSeconds := time.Since(setupStarted).Seconds() - start := time.Now() - proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) - seconds := time.Since(start).Seconds() - cleanupErr := cleanup() - if err != nil { - return err - } - if cleanupErr != nil { - return cleanupErr - } - public, err := w.Public() - if err != nil { - return err - } - start = time.Now() - if err := plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { - return err - } - verifySeconds := time.Since(start).Seconds() - if err := atomicFile(filepath.Join(out, "proof.bin"), func(w io.Writer) error { _, err := proof.WriteTo(w); return err }); err != nil { - return err - } - solidityBytes := proof.(*nativeProof).MarshalSolidity() - if err := atomicFile(filepath.Join(out, "proof.solidity.bin"), func(w io.Writer) error { _, err := w.Write(solidityBytes); return err }); err != nil { - return err - } - selectedBackend := os.Getenv("IX_PLONK_BACKEND") - if selectedBackend == "" { - selectedBackend = "cpu" - } - report := map[string]any{"backend": selectedBackend, "backendSetupSeconds": backendSetupSeconds, "proveSeconds": seconds, "verifySeconds": verifySeconds, "solidityBytes": len(solidityBytes), "verifyingKeySHA256": m.Files["verifying-key.bin"].SHA256} - if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { - return err - } - fmt.Printf("prove_seconds=%.3f verify_seconds=%.6f solidity_bytes=%d\n", seconds, verifySeconds, len(solidityBytes)) - return nil -} - func decodeProof(data []byte, commitments int) (*nativeProof, error) { if commitments < 0 || commitments > 1024 || len(data) != 9*pointBytesLen+232+(32+pointBytesLen)*commitments { return nil, fmt.Errorf("invalid PLONK proof length") @@ -293,59 +133,32 @@ type publicClaim struct { func (*publicClaim) Define(frontend.API) error { return nil } -func verify(keyPath, trustedDigest, proofPath, expectedClaim string, expectedAnchors ...string) error { - key, err := fileHash(keyPath) - if err != nil { - return err +func verifyProof(key, trustedDigest, data, digest, expectedAnchors []byte) error { + if len(key) > 1<<20 || len(data) > 1<<20 || len(trustedDigest) != 32 || len(digest) != 32 || len(expectedAnchors) != anchorBytesLen { + return fmt.Errorf("invalid verification input length") } - if key.SHA256 != strings.TrimPrefix(trustedDigest, "0x") { + actual := sha256.Sum256(key) + if !bytes.Equal(actual[:], trustedDigest) { return fmt.Errorf("trusted wrapper verification key digest differs") } vk := new(nativeVerifyingKey) - if err := readBinary(keyPath, vk); err != nil { + reader := bytes.NewReader(key) + if _, err := vk.ReadFrom(reader); err != nil { return err } - if vk.NbPublicVariables != 2 { - return fmt.Errorf("wrapper key has wrong public-input schema") - } - if len(expectedAnchors) > 1 { - return fmt.Errorf("expected one anchor encoding") + if reader.Len() != 0 || vk.NbPublicVariables != 2 { + return fmt.Errorf("invalid wrapper verifying key") } - if len(expectedAnchors) == 1 { - expected, err := unhex(expectedAnchors[0], anchorBytesLen) - if err != nil { - return err - } - actual := encodeAnchors(vk) - if !bytes.Equal(actual, expected) { - return fmt.Errorf("wrapper verification key setup anchors differ") - } + if !bytes.Equal(encodeAnchors(vk), expectedAnchors) { + return fmt.Errorf("wrapper verification key setup anchors differ") } - digest, err := unhex(expectedClaim, 32) - if err != nil { - return err - } - f, err := os.Open(proofPath) - if err != nil { - return err - } - defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, 1<<20)) - if err != nil { - return err - } - start := time.Now() proof, err := decodeProof(data, len(vk.Qcp)) if err != nil { return err } - w, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) + public, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) if err != nil { return err } - if err := plonk.Verify(proof, vk, w, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { - return err - } - fmt.Printf("verified claim=%s verify_seconds=%.6f\n", hex.EncodeToString(digest), time.Since(start).Seconds()) - return nil + return plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)) } diff --git a/tools/plonk-wrapper/backend_bn254_test.go b/tools/plonk-wrapper/backend_bn254_test.go index a6f3d141d..708c3b5a8 100644 --- a/tools/plonk-wrapper/backend_bn254_test.go +++ b/tools/plonk-wrapper/backend_bn254_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/binary" "encoding/hex" "encoding/json" "io" @@ -17,6 +18,7 @@ import ( "github.com/consensys/gnark/backend/solidity" "github.com/consensys/gnark/frontend" "github.com/consensys/gnark/frontend/cs/scs" + "github.com/zeebo/blake3" ) type backendCircuit struct { @@ -126,11 +128,11 @@ func TestIgnitionBackendAndProofCodec(t *testing.T) { } var digest [32]byte digest[0], digest[16] = 49, 1 - if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err != nil { + if err := verifyFixture(keyPath, keyHash.SHA256, proofPath, digest[:]); err != nil { t.Fatal(err) } digest[31] = 1 - if err := verify(keyPath, keyHash.SHA256, proofPath, hex.EncodeToString(digest[:])); err == nil { + if err := verifyFixture(keyPath, keyHash.SHA256, proofPath, digest[:]); err == nil { t.Fatal("accepted changed public digest") } digest[31] = 0 @@ -150,3 +152,98 @@ func TestIgnitionBackendAndProofCodec(t *testing.T) { t.Logf("Ignition PLONK backend: N=%d commitments=%d native_bytes=%d solidity_bytes=%d", n, m, encoded.Len(), len(proof.(*plonkbn254.Proof).MarshalSolidity())) } } + +func verifyFixture(keyPath, trusted, proofPath string, claim []byte) error { + key, err := os.ReadFile(keyPath) + if err != nil { + return err + } + proof, err := os.ReadFile(proofPath) + if err != nil { + return err + } + hash, err := hex.DecodeString(trusted) + if err != nil { + return err + } + vk := new(nativeVerifyingKey) + if _, err := vk.ReadFrom(bytes.NewReader(key)); err != nil { + return err + } + return verifyProof(key, hash, proof, claim, encodeAnchors(vk)) +} + +func TestPrepareCircuitMatchesGnarkSetup(t *testing.T) { + schema, err := os.ReadFile(filepath.Join("testdata", "bn254", "vk.json")) + if err != nil { + t.Fatal(err) + } + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, &backendCircuit{Commit: true}) + if err != nil { + t.Fatal(err) + } + domain := ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + 2)) + dir := t.TempDir() + circuit := filepath.Join(dir, "circuit.bin") + if err := atomicFile(circuit, func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { + t.Fatal(err) + } + circuitHash, err := fileHash(circuit) + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(filepath.Join("testdata", "bn254", "srs.bin")) + if err != nil { + t.Fatal(err) + } + powers := domain + 3 + source = source[:88+2*g2ExportBytes+2*baseBytes*powers] + binary.LittleEndian.PutUint64(source[80:88], powers) + receipt := blake3.Sum256(source) + srsPath := filepath.Join(dir, "srs.bin") + if err := os.WriteFile(srsPath, source, 0600); err != nil { + t.Fatal(err) + } + lagrangePath := filepath.Join(dir, "lagrange.bin") + if err := prepareCircuit(schema, circuit, circuitHash, domain, srsPath, hex.EncodeToString(receipt[:]), lagrangePath, "", dir); err != nil { + t.Fatal(err) + } + key, err := decodeVerifyingKey(schema) + if err != nil { + t.Fatal(err) + } + srs, err := readSRS(srsPath, hex.EncodeToString(receipt[:]), domain, key) + if err != nil { + t.Fatal(err) + } + lagrange, err := kzg.ToLagrangeG1(srs.Pk.G1[:domain]) + if err != nil { + t.Fatal(err) + } + _, expected, err := plonk.Setup(ccs, srs, &kzg.SRS{Pk: kzg.ProvingKey{G1: lagrange}, Vk: srs.Vk}) + if err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + if _, err := expected.WriteTo(&encoded); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(dir, "verifying-key.bin")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(actual, encoded.Bytes()) { + t.Fatal("prepared verifying key differs from gnark Setup") + } + cached, err := fileHash(lagrangePath) + if err != nil { + t.Fatal(err) + } + if err := prepareCircuit(schema, circuit, circuitHash, domain, srsPath, hex.EncodeToString(receipt[:]), lagrangePath, cached.SHA256, dir); err != nil { + t.Fatal(err) + } + circuitHash.SHA256 = hex.EncodeToString(make([]byte, 32)) + if err := prepareCircuit(schema, circuit, circuitHash, domain, srsPath, hex.EncodeToString(receipt[:]), lagrangePath, cached.SHA256, dir); err == nil { + t.Fatal("accepted a different circuit digest") + } +} diff --git a/tools/plonk-wrapper/backend_cpu.go b/tools/plonk-wrapper/backend_cpu.go index 70e789c62..de4fffbd3 100644 --- a/tools/plonk-wrapper/backend_cpu.go +++ b/tools/plonk-wrapper/backend_cpu.go @@ -4,25 +4,12 @@ package main import ( "fmt" - "os" - "github.com/consensys/gnark/backend/plonk" ) -func validateProvingBackend() error { - switch os.Getenv("IX_PLONK_BACKEND") { - case "", "cpu": - return nil - case "cuda": - return fmt.Errorf("CUDA PLONK requires a BN254 helper built with the cuda tag and cgo") - default: - return fmt.Errorf("IX_PLONK_BACKEND must be cpu or cuda") - } -} - -func configureProvingBackend(_ plonk.ProvingKey) (func() error, error) { - if err := validateProvingBackend(); err != nil { - return nil, err +func configureBackend(_ plonk.ProvingKey, config backendConfig) (func() error, error) { + if config.CUDA { + return nil, fmt.Errorf("this worker was built without PLONK CUDA support") } return func() error { return nil }, nil } diff --git a/tools/plonk-wrapper/backend_cuda_bn254.go b/tools/plonk-wrapper/backend_cuda_bn254.go index af927566e..09ffc0596 100644 --- a/tools/plonk-wrapper/backend_cuda_bn254.go +++ b/tools/plonk-wrapper/backend_cuda_bn254.go @@ -5,9 +5,7 @@ package main import ( "encoding/json" "fmt" - "math" "os" - "strconv" "sync" "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" @@ -15,55 +13,13 @@ import ( plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" ) -func provingCUDAConfig() (cuda.Config, error) { - config := cuda.Config{MemoryBytes: 64 << 30, HeadroomBytes: 8 << 30} - switch os.Getenv("IX_PLONK_CUDA_MSM_STRATEGY") { - case "", "split": - config.MSMStrategy = cuda.MSMSplit - case "mask": - config.MSMStrategy = cuda.MSMMask - default: - return config, fmt.Errorf("IX_PLONK_CUDA_MSM_STRATEGY must be split or mask") - } - if value := os.Getenv("IX_PLONK_CUDA_DEVICE"); value != "" { - device, err := strconv.ParseUint(value, 10, 31) - if err != nil { - return config, fmt.Errorf("invalid IX_PLONK_CUDA_DEVICE: %w", err) - } - config.Device = int(device) - } - if value := os.Getenv("IX_PLONK_CUDA_MEMORY_GIB"); value != "" { - gib, err := strconv.ParseUint(value, 10, 64) - if err != nil || gib == 0 || gib > math.MaxUint64>>30 { - return config, fmt.Errorf("IX_PLONK_CUDA_MEMORY_GIB must be a positive bounded integer") - } - config.MemoryBytes = gib << 30 - } - return config, nil -} - -func validateProvingBackend() error { - switch os.Getenv("IX_PLONK_BACKEND") { - case "", "cpu": - return nil - case "cuda": - _, err := provingCUDAConfig() - return err - default: - return fmt.Errorf("IX_PLONK_BACKEND must be cpu or cuda") - } -} - -func configureProvingBackend(pk plonk.ProvingKey) (func() error, error) { - if err := validateProvingBackend(); err != nil { - return nil, err - } - if os.Getenv("IX_PLONK_BACKEND") != "cuda" { +func configureBackend(pk plonk.ProvingKey, options backendConfig) (func() error, error) { + if !options.CUDA { return func() error { return nil }, nil } - config, err := provingCUDAConfig() - if err != nil { - return nil, err + config := cuda.Config{Device: options.Device, MemoryBytes: options.MemoryBytes, HeadroomBytes: 8 << 30} + if options.MaskMSM { + config.MSMStrategy = cuda.MSMMask } nativePK, ok := pk.(*plonkbn254.ProvingKey) if !ok { diff --git a/tools/plonk-wrapper/backend_cuda_bn254_test.go b/tools/plonk-wrapper/backend_cuda_bn254_test.go index 3a280fd39..3cb87627b 100644 --- a/tools/plonk-wrapper/backend_cuda_bn254_test.go +++ b/tools/plonk-wrapper/backend_cuda_bn254_test.go @@ -22,8 +22,6 @@ func TestCUDAProvingBackendFactory(t *testing.T) { if os.Getenv("IX_PLONK_CUDA_TEST") != "1" { t.Skip("set IX_PLONK_CUDA_TEST=1 for the native CUDA backend factory proof test") } - t.Setenv("IX_PLONK_BACKEND", "cuda") - t.Setenv("IX_PLONK_CUDA_MEMORY_GIB", "8") p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) @@ -60,7 +58,7 @@ func TestCUDAProvingBackendFactory(t *testing.T) { if err != nil { t.Fatal(err) } - cleanup, err := configureProvingBackend(pk) + cleanup, err := configureBackend(pk, backendConfig{CUDA: true, MemoryBytes: 8 << 30}) if err != nil { t.Fatal(err) } diff --git a/tools/plonk-wrapper/backend_no_cuda_test.go b/tools/plonk-wrapper/backend_no_cuda_test.go index 5d0f554c1..d2f966474 100644 --- a/tools/plonk-wrapper/backend_no_cuda_test.go +++ b/tools/plonk-wrapper/backend_no_cuda_test.go @@ -5,11 +5,7 @@ package main import "testing" func TestCUDARequestRequiresBuiltBackend(t *testing.T) { - t.Setenv("IX_PLONK_BACKEND", "cuda") - if err := validateProvingBackend(); err == nil { + if _, err := configureBackend(nil, backendConfig{CUDA: true}); err == nil { t.Fatal("accepted unavailable CUDA backend") } - if _, err := configureProvingBackend(nil); err == nil { - t.Fatal("configured unavailable CUDA backend") - } } diff --git a/tools/plonk-wrapper/backend_selection_test.go b/tools/plonk-wrapper/backend_selection_test.go index 91d975c94..6d625a4ee 100644 --- a/tools/plonk-wrapper/backend_selection_test.go +++ b/tools/plonk-wrapper/backend_selection_test.go @@ -3,14 +3,7 @@ package main import "testing" func TestCPUBackendSelectionNeedsNoGPU(t *testing.T) { - t.Setenv("IX_PLONK_BACKEND", "cpu") - t.Setenv("IX_PLONK_CUDA_DEVICE", "invalid") - t.Setenv("IX_PLONK_CUDA_MEMORY_GIB", "invalid") - t.Setenv("IX_PLONK_CUDA_MSM_STRATEGY", "invalid") - if err := validateProvingBackend(); err != nil { - t.Fatal(err) - } - close, err := configureProvingBackend(nil) + close, err := configureBackend(nil, backendConfig{}) if err != nil { t.Fatal(err) } @@ -20,8 +13,4 @@ func TestCPUBackendSelectionNeedsNoGPU(t *testing.T) { if err := close(); err != nil { t.Fatal(err) } - t.Setenv("IX_PLONK_BACKEND", "unknown") - if err := validateProvingBackend(); err == nil { - t.Fatal("accepted unknown backend") - } } diff --git a/tools/plonk-wrapper/build_cuda.py b/tools/plonk-wrapper/build_cuda.py deleted file mode 100644 index 2ef4524cd..000000000 --- a/tools/plonk-wrapper/build_cuda.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""Build the optional BN254 CUDA helper against authenticated local sources.""" - -import argparse -import fcntl -import hashlib -import importlib.util -import json -import os -from pathlib import Path -import shutil -import shlex -import subprocess -import sys -import tempfile - - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parent.parent - - -def run(args, **kwargs): - return subprocess.run([str(x) for x in args], check=True, **kwargs) - - -def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", required=True, type=Path) - parser.add_argument("--build-dir", required=True, type=Path) - parser.add_argument("--sppark", required=True, type=Path) - parser.add_argument("--blst", required=True, type=Path) - parser.add_argument("--go", default=os.environ.get("GO", "go")) - parser.add_argument("--test-binary", action="store_true") - parser.add_argument("--package", choices=[".", "./internal/cuda"], default=".") - parser.add_argument("--reuse-native", action="store_true") - parser.add_argument("--groth16-icicle-root", type=Path) - args = parser.parse_args() - output = args.out.resolve() - build = args.build_dir.resolve() - build.mkdir(parents=True, exist_ok=True) - output.parent.mkdir(parents=True, exist_ok=True) - with (build / ".build.lock").open("a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - build_helper(args, output, build) - - -def build_helper(args, output, build): - environment = dict(os.environ, GOTOOLCHAIN="local", CGO_ENABLED="1") - native = build / "native" - if args.reuse_native: - specification = importlib.util.spec_from_file_location("ix_plonk_native_build", - ROOT / "crates/proof-compression/cuda/plonk/build.py") - native_builder = importlib.util.module_from_spec(specification) - previous_bytecode_setting = sys.dont_write_bytecode - try: - sys.dont_write_bytecode = True - specification.loader.exec_module(native_builder) - finally: - sys.dont_write_bytecode = previous_bytecode_setting - native_builder.validate_sources(args.sppark.resolve(), args.blst.resolve()) - receipt = json.loads((native / "build-manifest.json").read_text()) - if "patched_sppark" not in receipt: - raise ValueError("native library needs rebuilding with the pinned sppark patch") - patched_receipt = native_builder.validate_patched_sppark(Path(receipt["patched_sppark"]["root"])) - if patched_receipt != receipt["patched_sppark"]: - raise ValueError("private sppark source identity differs from native build receipt") - architectures = environment.get("IX_PLONK_CUDA_ARCHS", "120").replace(",", " ").split() - if architectures != receipt["architectures"]: - raise ValueError("native library architecture selection changed") - if digest(native / "libix_plonk_bn254_cuda.so") != receipt["library_sha256"]: - raise ValueError("native library differs from its build receipt") - for path, expected in receipt["inputs_sha256"].items(): - if digest(Path(path)) != expected: - raise ValueError(f"native build input changed: {path}") - else: - run(["python3", ROOT / "crates/proof-compression/cuda/plonk/build.py", - "--sppark", args.sppark.resolve(), "--blst", args.blst.resolve(), - "--out", native], env=environment) - header = ROOT / "crates/proof-compression/cuda/plonk" - native_identity = hashlib.sha256((digest(native / "libix_plonk_bn254_cuda.so") - + digest(header / "ix_plonk_bn254.h")).encode()).hexdigest() - # cgo does not track headers outside its package as Go cache inputs. - environment["CGO_CFLAGS"] = (environment.get("CGO_CFLAGS", "") - + f" -I{header} -DIX_PLONK_NATIVE_BUILD_{native_identity[:20]}=1").strip() - library_dir = output.parent.parent / "lib" - library_dir.mkdir(parents=True, exist_ok=True) - library = library_dir / "libix_plonk_bn254_cuda.so" - with tempfile.NamedTemporaryFile(prefix=".plonk-cuda-", dir=library_dir, delete=False) as stream: - temporary_library = Path(stream.name) - try: - shutil.copy2(native / library.name, temporary_library) - temporary_library.replace(library) - finally: - temporary_library.unlink(missing_ok=True) - environment["CGO_LDFLAGS"] = (environment.get("CGO_LDFLAGS", "") - + f" -L{library_dir} -Wl,-rpath,$ORIGIN/../lib -lix_plonk_bn254_cuda").strip() - command = [args.go, "test", "-c"] if args.test_binary else [args.go, "build"] - temporary_output = output.with_suffix(output.suffix + ".tmp") - tags = "cuda" - if args.groth16_icicle_root: - icicle = args.groth16_icicle_root.resolve(strict=True) / "lib" - for name in ["libicicle_device.so", "libicicle_field_bn254.so", "libicicle_curve_bn254.so"]: - if not (icicle / name).is_file(): - raise ValueError(f"missing ICICLE v3.2.2 library: {icicle / name}") - environment["CGO_LDFLAGS"] += f" -L{shlex.quote(str(icicle))} -Wl,-rpath,{shlex.quote(str(icicle))}" - command += ["-ldflags", "-X " + shlex.quote("main.groth16LibraryDefault=" + str(icicle / "backend"))] - tags += ",icicle" - command += ["-buildvcs=false", "-trimpath", "-mod=readonly", - "-tags", tags, - "-o", str(temporary_output), args.package] - run(command, cwd=HERE, env=environment) - temporary_output.replace(output) - record = { - "go_mod_sha256": digest(HERE / "go.mod"), - "go_sum_sha256": digest(HERE / "go.sum"), - "go_version": subprocess.check_output([args.go, "version"], text=True).strip(), - "binary": str(output), "binary_sha256": digest(output), - "library": str(library), "library_sha256": digest(library), - "native_build_identity": native_identity, - "native_receipts": [str(p) for p in native.glob("*.json")], - "go_command": command, "test_binary": args.test_binary, - } - output.with_suffix(output.suffix + ".build.json").write_text(json.dumps(record, indent=2) + "\n") - print(json.dumps(record)) - - -if __name__ == "__main__": - main() diff --git a/tools/plonk-wrapper/build_groth16_cuda.py b/tools/plonk-wrapper/build_groth16_cuda.py deleted file mode 100644 index 09298e2bb..000000000 --- a/tools/plonk-wrapper/build_groth16_cuda.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""Build the terminal helper with BN254 ICICLE acceleration from an explicit installation.""" - -import argparse -import os -from pathlib import Path -import shlex -import subprocess - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", required=True, type=Path) - parser.add_argument("--icicle-root", required=True, type=Path) - parser.add_argument("--go", default=os.environ.get("GO", "go")) - parser.add_argument("--plonk-cuda", action="store_true") - args = parser.parse_args() - root = args.icicle_root.resolve(strict=True) - library = root / "lib" - for name in ["libicicle_device.so", "libicicle_field_bn254.so", "libicicle_curve_bn254.so"]: - if not (library / name).is_file(): - parser.error(f"missing ICICLE v3.2.2 library: {library / name}") - backend = library / "backend" - if not backend.is_dir(): - parser.error("the ICICLE installation has no backend directory") - output = args.out.resolve() - output.parent.mkdir(parents=True, exist_ok=True) - env = dict(os.environ, CGO_ENABLED="1", GOTOOLCHAIN="local") - env["CGO_LDFLAGS"] = " ".join(filter(None, [env.get("CGO_LDFLAGS", ""), - f"-L{shlex.quote(str(library))} -Wl,-rpath,{shlex.quote(str(library))}"])) - tags = "icicle,cuda" if args.plonk_cuda else "icicle" - linker = "-X " + shlex.quote("main.groth16LibraryDefault=" + str(backend)) - subprocess.run([args.go, "build", "-buildvcs=false", "-trimpath", "-mod=readonly", - "-tags", tags, "-ldflags", linker, "-o", str(output), "."], - cwd=Path(__file__).resolve().parent, env=env, check=True) - - -if __name__ == "__main__": - main() diff --git a/tools/plonk-wrapper/cuda_init_bn254_test.go b/tools/plonk-wrapper/cuda_init_bn254_test.go index 937c62022..0a2d92349 100644 --- a/tools/plonk-wrapper/cuda_init_bn254_test.go +++ b/tools/plonk-wrapper/cuda_init_bn254_test.go @@ -182,10 +182,7 @@ func TestBN254CUDAInit(t *testing.T) { var accelerator *cuda.Accelerator var cudaConfig *cuda.Config if mode != "cpu" { - config, err := provingCUDAConfig() - if err != nil { - t.Fatal(err) - } + config := cuda.Config{MemoryBytes: 64 << 30, HeadroomBytes: 8 << 30} cudaConfig = &config accelerator, err = cuda.Attach(nativePK, config) if err != nil { @@ -352,10 +349,10 @@ func initManifest(dir, expectedSHA256 string) (*manifest, error) { if err := d.Decode(new(any)); err != io.EOF { return nil, fmt.Errorf("trailing Init manifest data") } - if m.Format != artifactFormat || !m.KeysReady || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.PublicInputs != 2 || m.Commitments < 0 || m.Commitments > 1024 || len(m.Files) != 5 { + if !m.KeysReady || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.PublicInputs != 2 || m.Commitments < 0 || m.Commitments > 1024 || len(m.Files) != 5 { return nil, fmt.Errorf("Init requires prepared BN254 wrapper artifacts") } - for _, digest := range []string{m.ExecutableSHA256, m.InnerVKSHA256, m.SRSBLAKE3} { + for _, digest := range []string{m.InnerVKSHA256, m.SRSBLAKE3} { if _, err := initDigest(digest); err != nil { return nil, fmt.Errorf("invalid Init manifest identity: %w", err) } diff --git a/tools/plonk-wrapper/curve_bn254.go b/tools/plonk-wrapper/curve_bn254.go index 1cca2d1f1..5677c64f3 100644 --- a/tools/plonk-wrapper/curve_bn254.go +++ b/tools/plonk-wrapper/curve_bn254.go @@ -45,8 +45,6 @@ const g2ExportBytes = 128 const anchorBytesLen = 320 const srsMagic = "IXBPSR01" const vkFormat = "ix-bn254-plonk-vk-v1" -const artifactFormat = "ix-bn254-plonk-artifacts-v1" -const lagrangeFormat = "ix-bn254-plonk-lagrange-v1" var curveParameters = sw_emulated.GetBN254Params diff --git a/tools/plonk-wrapper/ffi.go b/tools/plonk-wrapper/ffi.go new file mode 100644 index 000000000..4f8369cf8 --- /dev/null +++ b/tools/plonk-wrapper/ffi.go @@ -0,0 +1,181 @@ +//go:build cgo + +package main + +/* +#include "ffi.h" +#include +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "os" + "runtime/cgo" + "unsafe" + + "github.com/consensys/gnark/logger" +) + +type retainedProver interface { + prove([]byte, []byte) ([]byte, []byte, error) + close() error +} + +func init() { logger.SetOutput(os.Stderr) } + +func borrowed(view C.ix_gnark_view, limit uint64) ([]byte, error) { + if uint64(view.len) > limit || (view.len != 0 && view.data == nil) { + return nil, fmt.Errorf("invalid FFI buffer") + } + return unsafe.Slice((*byte)(unsafe.Pointer(view.data)), int(view.len)), nil +} +func text(view C.ix_gnark_view) (string, error) { b, e := borrowed(view, 16<<10); return string(b), e } +func buffer(data []byte) (*C.uint8_t, C.size_t) { + if len(data) == 0 { + return nil, 0 + } + return (*C.uint8_t)(C.CBytes(data)), C.size_t(len(data)) +} +func failure(err error) C.ix_gnark_result { + message := []byte(err.Error()) + if len(message) == 0 { + message = []byte("gnark operation failed") + } + if len(message) > 8192 { + message = message[:8192] + } + var result C.ix_gnark_result + result.error, result.error_len = buffer(message) + return result +} +func guarded(call func() (C.ix_gnark_result, error)) (result C.ix_gnark_result) { + defer func() { + if r := recover(); r != nil { + result = failure(fmt.Errorf("gnark panic: %v", r)) + } + }() + result, err := call() + if err != nil { + return failure(err) + } + return result +} + +//export ix_gnark_compile +func ix_gnark_compile(schema C.ix_gnark_view, path C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + data, err := borrowed(schema, 16<<20) + if err != nil { + return result, err + } + output, err := text(path) + if err != nil { + return result, err + } + info, err := compileCircuit(data, output) + if err != nil { + return result, err + } + encoded, err := json.Marshal(info) + if err != nil { + return result, err + } + result.data, result.len = buffer(encoded) + return result, nil + }) +} + +//export ix_gnark_prepare +func ix_gnark_prepare(schema, circuit, circuitHash C.ix_gnark_view, circuitBytes, domain C.uint64_t, srs, receipt, lagrange, lagrangeHash, output C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + data, err := borrowed(schema, 16<<20) + if err != nil { + return result, err + } + strings := make([]string, 7) + for i, view := range []C.ix_gnark_view{circuit, circuitHash, srs, receipt, lagrange, lagrangeHash, output} { + strings[i], err = text(view) + if err != nil { + return result, err + } + } + if uint64(circuitBytes) > 1<<40 { + return result, fmt.Errorf("invalid circuit size") + } + return result, prepareCircuit(data, strings[0], artifact{SHA256: strings[1], Bytes: int64(circuitBytes)}, uint64(domain), strings[2], strings[3], strings[4], strings[5], strings[6]) + }) +} + +//export ix_gnark_open +func ix_gnark_open(directory, metadata C.ix_gnark_view, options C.ix_gnark_backend) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + dir, err := text(directory) + if err != nil { + return result, err + } + data, err := borrowed(metadata, 1<<20) + if err != nil { + return result, err + } + if options.cuda > 1 || options.mask_msm > 1 || options.device < 0 { + return result, fmt.Errorf("invalid backend configuration") + } + p, err := openProver(dir, data, backendConfig{CUDA: options.cuda == 1, Device: int(options.device), MemoryBytes: uint64(options.memory_bytes), MaskMSM: options.mask_msm == 1}) + if err != nil { + return result, err + } + result.handle = C.uintptr_t(cgo.NewHandle(p)) + return result, nil + }) +} + +//export ix_gnark_prove +func ix_gnark_prove(handle C.uintptr_t, witness, claim C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + p := cgo.Handle(handle).Value().(retainedProver) + data, err := borrowed(witness, 16<<20) + if err != nil { + return result, err + } + digest, err := borrowed(claim, 32) + if err != nil { + return result, err + } + native, solidity, err := p.prove(data, digest) + if err != nil { + return result, err + } + result.data, result.len = buffer(native) + result.solidity, result.solidity_len = buffer(solidity) + return result, nil + }) +} + +//export ix_gnark_verify +func ix_gnark_verify(key, keyHash, proof, claim, anchors C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + values := make([][]byte, 5) + for i, view := range []C.ix_gnark_view{key, keyHash, proof, claim, anchors} { + values[i], err = borrowed(view, 1<<20) + if err != nil { + return result, err + } + } + return result, verifyProof(values[0], values[1], values[2], values[3], values[4]) + }) +} + +//export ix_gnark_close +func ix_gnark_close(handle C.uintptr_t) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + h := cgo.Handle(handle) + p := h.Value().(retainedProver) + defer h.Delete() + return result, p.close() + }) +} + +//export ix_gnark_free +func ix_gnark_free(pointer unsafe.Pointer) { C.free(pointer) } diff --git a/tools/plonk-wrapper/ffi.h b/tools/plonk-wrapper/ffi.h new file mode 100644 index 000000000..647a1d56f --- /dev/null +++ b/tools/plonk-wrapper/ffi.h @@ -0,0 +1,14 @@ +#ifndef IX_GNARK_H +#define IX_GNARK_H +#include +#include +typedef struct { const uint8_t *data; size_t len; } ix_gnark_view; +typedef struct { + uint8_t *data; size_t len; + uint8_t *solidity; size_t solidity_len; + uint8_t *error; size_t error_len; + uintptr_t handle; +} ix_gnark_result; +typedef struct { uint32_t cuda; int32_t device; uint64_t memory_bytes; uint32_t mask_msm; } ix_gnark_backend; +typedef struct { uint32_t cuda; int32_t device; uint32_t pin_keys; ix_gnark_view library; } ix_gnark_groth16_backend; +#endif diff --git a/tools/plonk-wrapper/ffi_groth16.go b/tools/plonk-wrapper/ffi_groth16.go new file mode 100644 index 000000000..2b49868ce --- /dev/null +++ b/tools/plonk-wrapper/ffi_groth16.go @@ -0,0 +1,107 @@ +//go:build cgo + +package main + +/* +#include "ffi.h" +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "runtime/cgo" +) + +//export ix_gnark_groth16_compile +func ix_gnark_groth16_compile(schema, path C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + data, err := borrowed(schema, 16<<20) + if err != nil { + return result, err + } + output, err := text(path) + if err != nil { + return result, err + } + info, err := compileGroth16(data, output) + if err != nil { + return result, err + } + encoded, err := json.Marshal(info) + if err != nil { + return result, err + } + result.data, result.len = buffer(encoded) + return result, nil + }) +} + +//export ix_gnark_groth16_setup +func ix_gnark_groth16_setup(directory, metadata C.ix_gnark_view, kind C.uint32_t, descriptor, pin C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + dir, err := text(directory) + if err != nil { + return result, err + } + data, err := borrowed(metadata, 1<<20) + if err != nil { + return result, err + } + file, err := text(descriptor) + if err != nil { + return result, err + } + digest, err := text(pin) + if err != nil { + return result, err + } + encoded, err := setupGroth16(dir, data, uint32(kind), file, digest) + if err != nil { + return result, err + } + result.data, result.len = buffer(encoded) + return result, nil + }) +} + +//export ix_gnark_groth16_open +func ix_gnark_groth16_open(directory, metadata C.ix_gnark_view, options C.ix_gnark_groth16_backend) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + dir, err := text(directory) + if err != nil { + return result, err + } + data, err := borrowed(metadata, 1<<20) + if err != nil { + return result, err + } + library, err := text(options.library) + if err != nil { + return result, err + } + if options.cuda > 1 || options.pin_keys > 1 || options.device < 0 { + return result, fmt.Errorf("invalid Groth16 backend configuration") + } + p, err := openGroth16(dir, data, groth16Backend{CUDA: options.cuda == 1, Device: int(options.device), PinKeys: options.pin_keys == 1, Library: library}) + if err != nil { + return result, err + } + result.handle = C.uintptr_t(cgo.NewHandle(p)) + return result, nil + }) +} + +//export ix_gnark_groth16_verify +func ix_gnark_groth16_verify(key, digest, proof, claim C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + values := make([][]byte, 4) + for i, v := range []C.ix_gnark_view{key, digest, proof, claim} { + values[i], err = borrowed(v, 1<<20) + if err != nil { + return result, err + } + } + return result, verifyGroth16(values[0], values[1], values[2], values[3]) + }) +} diff --git a/tools/plonk-wrapper/ffi_test.go b/tools/plonk-wrapper/ffi_test.go new file mode 100644 index 000000000..fc0115034 --- /dev/null +++ b/tools/plonk-wrapper/ffi_test.go @@ -0,0 +1,17 @@ +//go:build cgo + +package main + +import ( + "errors" + "testing" + "unsafe" +) + +func TestEmptyFFIErrorIsNotSuccess(t *testing.T) { + result := failure(errors.New("")) + if result.error == nil || result.error_len == 0 { + t.Fatal("empty error message became a successful FFI result") + } + ix_gnark_free(unsafe.Pointer(result.error)) +} diff --git a/tools/plonk-wrapper/groth16.go b/tools/plonk-wrapper/groth16.go index 718482b7e..cdb3316ad 100644 --- a/tools/plonk-wrapper/groth16.go +++ b/tools/plonk-wrapper/groth16.go @@ -8,10 +8,8 @@ import ( "encoding/json" "fmt" "io" - "os" "path/filepath" "sync" - "time" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark/backend" @@ -24,7 +22,6 @@ import ( "github.com/consensys/gnark/frontend/cs/r1cs" ) -const groth16ArtifactFormat = "ix-bn254-groth16-artifacts-v1" const groth16MaxProofBytes = 196 func decodeGroth16JSON(data []byte, out any) error { @@ -40,18 +37,15 @@ func decodeGroth16JSON(data []byte, out any) error { } type groth16Manifest struct { - Format string `json:"format"` - Curve string `json:"curve"` - InnerVK string `json:"innerVKSHA256"` - CommitmentHash string `json:"commitmentHash"` - Constraints int `json:"constraints"` - Domain uint64 `json:"domain"` - PublicInputs int `json:"publicInputs"` - Commitments int `json:"commitments"` - Setup string `json:"setup"` - Ceremony string `json:"ceremonySHA256,omitempty"` - KeysReady bool `json:"keysReady"` - Files map[string]artifact `json:"files"` + InnerVK string `json:"innerVKSHA256"` + Constraints int `json:"constraints"` + Domain uint64 `json:"domain"` + PublicInputs int `json:"publicInputs"` + Commitments int `json:"commitments"` + Setup string `json:"setup"` + Ceremony string `json:"ceremonySHA256,omitempty"` + KeysReady bool `json:"keysReady"` + Files map[string]artifact `json:"files"` } func groth16Shape(ccs constraint.ConstraintSystem) (uint64, int, error) { @@ -66,8 +60,7 @@ func groth16Shape(ccs constraint.ConstraintSystem) (uint64, int, error) { } func (m *groth16Manifest) validate() error { - if m.Format != groth16ArtifactFormat || m.Curve != "bn254" || m.CommitmentHash != "sha256" || - m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<27 || + if m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<27 || m.Domain != ecc.NextPowerOfTwo(uint64(m.Constraints)) || m.Commitments < 0 || m.Commitments > 1 || m.Files == nil { return fmt.Errorf("invalid Groth16 artifact manifest") } @@ -111,100 +104,52 @@ func (m *groth16Manifest) checkFile(dir, name string) error { return nil } -func groth16Load(dir string) (*verifyingKey, constraint.ConstraintSystem, *groth16Manifest, error) { - var m groth16Manifest - if err := decodeJSON(filepath.Join(dir, "manifest.json"), &m); err != nil { - return nil, nil, nil, err - } +func groth16Load(dir string, m *groth16Manifest) (*verifyingKey, constraint.ConstraintSystem, error) { if err := m.validate(); err != nil { - return nil, nil, nil, err + return nil, nil, err } - for _, name := range []string{"vk.json", "circuit.bin"} { - if err := m.checkFile(dir, name); err != nil { - return nil, nil, nil, err - } + data, err := readDescription(filepath.Join(dir, "vk.json")) + if err != nil { + return nil, nil, err } - if m.Files["vk.json"].SHA256 != m.InnerVK { - return nil, nil, nil, fmt.Errorf("Groth16 inner verifier identity differs") + hash := sha256.Sum256(data) + if hex.EncodeToString(hash[:]) != m.InnerVK || m.Files["vk.json"].SHA256 != m.InnerVK || m.Files["vk.json"].Bytes != int64(len(data)) { + return nil, nil, fmt.Errorf("Groth16 inner verifier identity differs") } - p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) + key, err := decodeVerifyingKey(data) if err != nil { - return nil, nil, nil, err + return nil, nil, err } ccs := groth16.NewCS(curveID) - if err := readBinary(filepath.Join(dir, "circuit.bin"), ccs); err != nil { - return nil, nil, nil, err + if err := readVerified(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { + return nil, nil, err } domain, commitments, err := groth16Shape(ccs) if err != nil { - return nil, nil, nil, err + return nil, nil, err } if domain != m.Domain || commitments != m.Commitments || ccs.GetNbConstraints() != m.Constraints { - return nil, nil, nil, fmt.Errorf("Groth16 circuit dimensions differ") + return nil, nil, fmt.Errorf("Groth16 circuit dimensions differ") } - return p, ccs, &m, nil + return key, ccs, nil } -func groth16Compile(path, dir string) error { - p, err := loadVerifyingKey(path) +func compileGroth16(schema []byte, path string) (circuitInfo, error) { + var info circuitInfo + key, err := decodeVerifyingKey(schema) if err != nil { - return err + return info, err } - identity, err := fileHash(path) + ccs, err := frontend.Compile(curveID.ScalarField(), r1cs.NewBuilder, newCircuit(key)) if err != nil { - return err - } - var existing *groth16Manifest - if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil { - _, _, m, err := groth16Load(dir) - if err != nil { - return err - } - if m.InnerVK != identity.SHA256 { - return fmt.Errorf("Groth16 circuit belongs to another inner verifier") - } - existing = m - } else if !os.IsNotExist(err) { - return err - } - ccs, err := frontend.Compile(curveID.ScalarField(), r1cs.NewBuilder, newCircuit(p)) - if err != nil { - return err + return info, err } domain, commitments, err := groth16Shape(ccs) if err != nil { - return err - } - if existing != nil { - hash := sha256.New() - if _, err := ccs.WriteTo(hash); err != nil { - return err - } - if hex.EncodeToString(hash.Sum(nil)) != existing.Files["circuit.bin"].SHA256 { - return fmt.Errorf("compiled Groth16 circuit changed; use a new artifact directory and setup") - } - return nil - } - m := groth16Manifest{Format: groth16ArtifactFormat, Curve: "bn254", InnerVK: identity.SHA256, - CommitmentHash: "sha256", Domain: domain, Constraints: ccs.GetNbConstraints(), PublicInputs: 2, - Commitments: commitments, Files: make(map[string]artifact)} - data, err := os.ReadFile(path) - if err != nil { - return err - } - if err := atomicFile(filepath.Join(dir, "vk.json"), func(w io.Writer) error { _, err := w.Write(data); return err }); err != nil { - return err + return info, err } - if err := atomicFile(filepath.Join(dir, "circuit.bin"), func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { - return err - } - for _, name := range []string{"vk.json", "circuit.bin"} { - m.Files[name], err = fileHash(filepath.Join(dir, name)) - if err != nil { - return err - } - } - return writeJSON(filepath.Join(dir, "manifest.json"), m) + info = circuitInfo{Domain: domain, Constraints: ccs.GetNbConstraints(), PublicInputs: 2, Commitments: commitments} + return info, atomicFile(path, func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }) } func groth16CheckKey(vk *g16.VerifyingKey, commitments int) error { @@ -236,16 +181,23 @@ func groth16SaveKeys(dir string, m *groth16Manifest, pk groth16.ProvingKey, vk g name string value io.WriterTo }{{"proving-key.bin", pk}, {"verifying-key.bin", vk}} { - if err := atomicFile(filepath.Join(dir, item.name), func(w io.Writer) error { _, err := item.value.WriteTo(w); return err }); err != nil { + hash := sha256.New() + var size int64 + if err := atomicFile(filepath.Join(dir, item.name), func(w io.Writer) error { + var err error + size, err = item.value.WriteTo(io.MultiWriter(w, hash)) + return err + }); err != nil { return err } + m.Files[item.name] = artifact{SHA256: hex.EncodeToString(hash.Sum(nil)), Bytes: size} } if err := atomicFile(filepath.Join(dir, "Groth16Verifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w, solidity.WithHashToFieldFunction(sha256.New()), solidity.WithPragmaVersion("^0.8.28")) }); err != nil { return err } - for _, name := range []string{"proving-key.bin", "verifying-key.bin", "Groth16Verifier.sol"} { + for _, name := range []string{"Groth16Verifier.sol"} { entry, err := fileHash(filepath.Join(dir, name)) if err != nil { return err @@ -256,11 +208,11 @@ func groth16SaveKeys(dir string, m *groth16Manifest, pk groth16.ProvingKey, vk g if err := m.validate(); err != nil { return err } - return writeJSON(filepath.Join(dir, "manifest.json"), m) + return nil } -func groth16DevelopmentSetup(dir string) error { - _, ccs, m, err := groth16Load(dir) +func groth16DevelopmentSetup(dir string, m *groth16Manifest) error { + _, ccs, err := groth16Load(dir, m) if err != nil { return err } @@ -281,18 +233,24 @@ type groth16Engine struct { close func() } -func newGroth16Engine() (*groth16Engine, error) { - switch os.Getenv("IX_GROTH16_BACKEND") { - case "", "cpu": - pk := groth16.NewProvingKey(curveID) - return &groth16Engine{key: pk, native: pk.(*g16.ProvingKey), close: func() {}, prove: func(ccs constraint.ConstraintSystem, w witness.Witness) (groth16.Proof, error) { - return groth16.Prove(ccs, pk, w, backend.WithProverHashToFieldFunction(sha256.New())) - }}, nil - case "cuda": - return newGroth16CUDAEngine() - default: - return nil, fmt.Errorf("IX_GROTH16_BACKEND must be cpu or cuda") +type groth16Backend struct { + CUDA bool + Device int + PinKeys bool + Library string +} + +func newGroth16Engine(config groth16Backend) (*groth16Engine, error) { + if config.Device < 0 { + return nil, fmt.Errorf("invalid Groth16 device") } + if config.CUDA { + return newGroth16CUDAEngine(config) + } + pk := groth16.NewProvingKey(curveID) + return &groth16Engine{key: pk, native: pk.(*g16.ProvingKey), close: func() {}, prove: func(ccs constraint.ConstraintSystem, w witness.Witness) (groth16.Proof, error) { + return groth16.Prove(ccs, pk, w, backend.WithProverHashToFieldFunction(sha256.New())) + }}, nil } type groth16Session struct { @@ -305,20 +263,24 @@ type groth16Session struct { closed, failed bool } -func openGroth16(dir string) (*groth16Session, error) { - p, ccs, m, err := groth16Load(dir) +func openGroth16(dir string, metadata []byte, config groth16Backend) (*groth16Session, error) { + var m groth16Manifest + if err := decodeGroth16JSON(metadata, &m); err != nil { + return nil, err + } + p, ccs, err := groth16Load(dir, &m) if err != nil { return nil, err } if !m.KeysReady { return nil, fmt.Errorf("Groth16 keys are not prepared") } - for _, name := range []string{"proving-key.bin", "verifying-key.bin", "Groth16Verifier.sol"} { + for _, name := range []string{"Groth16Verifier.sol"} { if err := m.checkFile(dir, name); err != nil { return nil, err } } - engine, err := newGroth16Engine() + engine, err := newGroth16Engine(config) if err != nil { return nil, err } @@ -327,7 +289,7 @@ func openGroth16(dir string) (*groth16Session, error) { name string value io.ReaderFrom }{{"proving-key.bin", engine.key}, {"verifying-key.bin", vk}} { - if err := readBinary(filepath.Join(dir, item.name), item.value); err != nil { + if err := readVerified(filepath.Join(dir, item.name), m.Files[item.name], item.value); err != nil { engine.close() return nil, err } @@ -340,10 +302,10 @@ func openGroth16(dir string) (*groth16Session, error) { engine.close() return nil, err } - return &groth16Session{key: p, ccs: ccs, vk: vk, engine: engine, manifest: m}, nil + return &groth16Session{key: p, ccs: ccs, vk: vk, engine: engine, manifest: &m}, nil } -func (p *groth16Session) close() { +func (p *groth16Session) close() error { p.mu.Lock() defer p.mu.Unlock() if !p.closed { @@ -352,6 +314,7 @@ func (p *groth16Session) close() { p.engine, p.ccs, p.key, p.vk = nil, nil, nil, nil engine.close() } + return nil } func groth16Public(digest []byte) (witness.Witness, error) { @@ -361,7 +324,7 @@ func groth16Public(digest []byte) (witness.Witness, error) { return frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) } -func (p *groth16Session) prove(assignment frontend.Circuit, expected []byte) (native, solidityBytes []byte, err error) { +func (p *groth16Session) proveAssignment(assignment frontend.Circuit, expected []byte) (native, solidityBytes []byte, err error) { p.mu.Lock() defer p.mu.Unlock() defer func() { @@ -449,109 +412,73 @@ func decodeGroth16Proof(data []byte, commitments int) (*g16.Proof, error) { return proof, nil } -func groth16Verify(keyPath, keyHash, proofPath, claim string) error { - if _, err := unhex(keyHash, 32); err != nil { - return err - } - key, err := fileHash(keyPath) - if err != nil { - return err +func verifyGroth16(key, trusted, data, claim []byte) error { + if len(key) > 1<<20 || len(trusted) != 32 { + return fmt.Errorf("invalid Groth16 verifying key") } - if key.Bytes > 1<<20 || key.SHA256 != keyHash { + hash := sha256.Sum256(key) + if !bytes.Equal(hash[:], trusted) { return fmt.Errorf("Groth16 key differs from the trusted digest") } vk := new(g16.VerifyingKey) - if err := readBinary(keyPath, vk); err != nil { - return err - } - commitments := len(vk.PublicAndCommitmentCommitted) - if err := groth16CheckKey(vk, commitments); err != nil { - return err - } - digest, err := unhex(claim, 32) + n, err := vk.ReadFrom(bytes.NewReader(key)) if err != nil { return err } - f, err := os.Open(proofPath) - if err != nil { - return err + if n != int64(len(key)) { + return fmt.Errorf("trailing Groth16 verifying key data") } - defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, groth16MaxProofBytes+1)) - if err != nil { + commitments := len(vk.PublicAndCommitmentCommitted) + if err := groth16CheckKey(vk, commitments); err != nil { return err } proof, err := decodeGroth16Proof(data, commitments) if err != nil { return err } - public, err := groth16Public(digest) + public, err := groth16Public(claim) if err != nil { return err } return groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())) } -func groth16Prove(dir, path, claim, out string) error { - expected, err := unhex(claim, 32) - if err != nil { - return err - } - p, err := openGroth16(dir) - if err != nil { - return err +func (p *groth16Session) prove(data, claim []byte) ([]byte, []byte, error) { + var witness witnessJSON + if err := decodeReader(bytes.NewReader(data), &witness); err != nil { + return nil, nil, err } - defer p.close() - assignment, err := witnessAssignment(p.key, path) - if err != nil { - return err + p.mu.Lock() + if p.closed || p.failed { + p.mu.Unlock() + return nil, nil, fmt.Errorf("Groth16 prover is closed or failed") } - started := time.Now() - native, payload, err := p.prove(assignment, expected) + assignment, err := assignWitness(p.key, &witness) + p.mu.Unlock() if err != nil { - return err - } - for _, item := range []struct { - name string - data []byte - }{{"proof.bin", native}, {"proof.solidity.bin", payload}} { - if err := atomicFile(filepath.Join(out, item.name), func(w io.Writer) error { _, err := w.Write(item.data); return err }); err != nil { - return err - } - } - selected := os.Getenv("IX_GROTH16_BACKEND") - if selected == "" { - selected = "cpu" + return nil, nil, err } - return writeJSON(filepath.Join(out, "report.json"), map[string]any{ - "system": "groth16", "curve": "bn254", "backend": selected, "setup": p.manifest.Setup, - "proveVerifySeconds": time.Since(started).Seconds(), "nativeBytes": len(native), "solidityBytes": len(payload), - "claimDigest": hex.EncodeToString(expected), "verifyingKeySHA256": p.manifest.Files["verifying-key.bin"].SHA256, - }) + return p.proveAssignment(assignment, claim) } -func runGroth16(args []string) error { - if len(args) == 4 && args[0] == "setup-mpc" { - return groth16MPCSetup(args[1], args[2], args[3]) - } - if len(args) == 4 && args[0] == "import" { - return groth16Import(args[1], args[2], args[3]) - } - if len(args) == 1 && args[0] == "capabilities" { - fmt.Printf("{\"system\":\"groth16\",\"curve\":\"bn254\",\"cuda\":%t}\n", groth16CUDAAvailable()) - return nil - } - if len(args) == 3 && args[0] == "compile" { - return groth16Compile(args[1], args[2]) - } - if len(args) == 2 && args[0] == "setup-development" { - return groth16DevelopmentSetup(args[1]) +func setupGroth16(dir string, metadata []byte, kind uint32, descriptor, pin string) ([]byte, error) { + var m groth16Manifest + if err := decodeGroth16JSON(metadata, &m); err != nil { + return nil, err } - if len(args) == 5 && args[0] == "prove" { - return groth16Prove(args[1], args[2], args[3], args[4]) + var err error + switch kind { + case 0: + err = groth16DevelopmentSetup(dir, &m) + case 1: + err = groth16MPCSetup(dir, &m, descriptor, pin) + case 2: + err = groth16Import(dir, &m, descriptor, pin) + default: + return nil, fmt.Errorf("unknown Groth16 setup operation") } - if len(args) == 5 && args[0] == "verify" { - return groth16Verify(args[1], args[2], args[3], args[4]) + if err != nil { + return nil, err } - return fmt.Errorf("usage: groth16 compile | setup-development | setup-mpc | import | prove | verify | capabilities") + return json.Marshal(m) } diff --git a/tools/plonk-wrapper/groth16_cuda.go b/tools/plonk-wrapper/groth16_cuda.go index 5aa6c9fb9..9c90e15a0 100644 --- a/tools/plonk-wrapper/groth16_cuda.go +++ b/tools/plonk-wrapper/groth16_cuda.go @@ -5,9 +5,7 @@ package main import ( "crypto/sha256" "fmt" - "os" "runtime" - "strconv" "sync" "github.com/consensys/gnark/backend" @@ -23,7 +21,6 @@ import ( var groth16CUDAInit sync.Once var groth16CUDAInitError error var groth16CUDADevices sync.Map -var groth16LibraryDefault string func groth16CUDAAvailable() bool { return true } @@ -40,27 +37,13 @@ func onGroth16Device(device *cr.Device, call func() error) error { return call() } -func newGroth16CUDAEngine() (*groth16Engine, error) { - deviceID := 0 - if value := os.Getenv("IX_GROTH16_DEVICE"); value != "" { - id, err := strconv.ParseUint(value, 10, 31) - if err != nil { - return nil, fmt.Errorf("invalid IX_GROTH16_DEVICE: %w", err) - } - deviceID = int(id) - } - var pin bool - switch os.Getenv("IX_GROTH16_PIN_KEY") { - case "", "0": - case "1": - pin = true - default: - return nil, fmt.Errorf("IX_GROTH16_PIN_KEY must be 0 or 1") - } +func newGroth16CUDAEngine(config groth16Backend) (*groth16Engine, error) { + deviceID := config.Device + pin := config.PinKeys groth16CUDAInit.Do(func() { var status cr.EIcicleError - if os.Getenv("ICICLE_BACKEND_INSTALL_DIR") == "" && groth16LibraryDefault != "" { - status = cr.LoadBackend(groth16LibraryDefault, true) + if config.Library != "" { + status = cr.LoadBackend(config.Library, true) } else { status = cr.LoadBackendFromEnvOrDefault() } diff --git a/tools/plonk-wrapper/groth16_cuda_test.go b/tools/plonk-wrapper/groth16_cuda_test.go index 9555e648c..c545bc1a6 100644 --- a/tools/plonk-wrapper/groth16_cuda_test.go +++ b/tools/plonk-wrapper/groth16_cuda_test.go @@ -42,7 +42,7 @@ func TestGroth16CUDAInterop(t *testing.T) { } for _, pin := range []string{"0", "1"} { t.Setenv("IX_GROTH16_PIN_KEY", pin) - engine, err := newGroth16Engine() + engine, err := newGroth16Engine(groth16Backend{CUDA: true, PinKeys: pin == "1", Library: os.Getenv("ICICLE_BACKEND_INSTALL_DIR")}) if err != nil { t.Fatal(err) } diff --git a/tools/plonk-wrapper/groth16_no_cuda.go b/tools/plonk-wrapper/groth16_no_cuda.go index a74f3d198..f07535345 100644 --- a/tools/plonk-wrapper/groth16_no_cuda.go +++ b/tools/plonk-wrapper/groth16_no_cuda.go @@ -6,6 +6,6 @@ import "fmt" func groth16CUDAAvailable() bool { return false } -func newGroth16CUDAEngine() (*groth16Engine, error) { - return nil, fmt.Errorf("CUDA Groth16 requires the helper built with the icicle tag and BN254 ICICLE libraries") +func newGroth16CUDAEngine(config groth16Backend) (*groth16Engine, error) { + return nil, fmt.Errorf("CUDA Groth16 requires the worker built with groth16 and cuda and BN254 ICICLE libraries") } diff --git a/tools/plonk-wrapper/groth16_setup.go b/tools/plonk-wrapper/groth16_setup.go index b62cca1fd..84f9ef6fc 100644 --- a/tools/plonk-wrapper/groth16_setup.go +++ b/tools/plonk-wrapper/groth16_setup.go @@ -46,33 +46,7 @@ func readGroth16Artifact(path string, expected artifact, value io.ReaderFrom) er if expected.Bytes < 1 || expected.Bytes > 1<<42 { return fmt.Errorf("invalid Groth16 artifact size") } - if _, err := unhex(expected.SHA256, 32); err != nil { - return err - } - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - h := sha256.New() - n, err := io.Copy(h, io.LimitReader(f, expected.Bytes+1)) - if err != nil { - return err - } - if n != expected.Bytes || hex.EncodeToString(h.Sum(nil)) != expected.SHA256 { - return fmt.Errorf("Groth16 artifact identity differs: %s", path) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - return err - } - n, err = value.ReadFrom(f) - if err != nil { - return err - } - if n != expected.Bytes { - return fmt.Errorf("Groth16 artifact has trailing data: %s", path) - } - return nil + return readVerified(path, expected, value) } func groth16BundlePath(metadata string, item groth16CeremonyFile) (string, error) { @@ -110,8 +84,8 @@ func groth16PinnedJSON(path, pin string, out any) error { return decodeGroth16JSON(data, out) } -func groth16MPCSetup(dir, path, trusted string) error { - _, ccs, m, err := groth16Load(dir) +func groth16MPCSetup(dir string, m *groth16Manifest, path, trusted string) error { + _, ccs, err := groth16Load(dir, m) if err != nil { return err } @@ -171,8 +145,8 @@ func groth16MPCSetup(dir, path, trusted string) error { return groth16SaveKeys(dir, m, pk, vk, "mpc", trusted) } -func groth16Import(dir, path, trusted string) error { - _, _, m, err := groth16Load(dir) +func groth16Import(dir string, m *groth16Manifest, path, trusted string) error { + _, _, err := groth16Load(dir, m) if err != nil { return err } @@ -184,7 +158,7 @@ func groth16Import(dir, path, trusted string) error { return err } if release.Format != "ix-bn254-groth16-release-v1" || release.Curve != "bn254" || - release.Circuit != m.Files["circuit.bin"].SHA256 || release.InnerVK != m.InnerVK || release.CommitmentHash != m.CommitmentHash { + release.Circuit != m.Files["circuit.bin"].SHA256 || release.InnerVK != m.InnerVK || release.CommitmentHash != "sha256" { return fmt.Errorf("Groth16 release does not match this circuit") } if _, err := unhex(release.Ceremony, 32); err != nil { diff --git a/tools/plonk-wrapper/groth16_setup_test.go b/tools/plonk-wrapper/groth16_setup_test.go index 5021953bd..bfd3eb661 100644 --- a/tools/plonk-wrapper/groth16_setup_test.go +++ b/tools/plonk-wrapper/groth16_setup_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/hex" + "encoding/json" "io" "os" "path/filepath" @@ -34,7 +35,7 @@ func groth16TestArtifacts(t *testing.T, dir string) (*cs.R1CS, *groth16Manifest) if err := atomicFile(filepath.Join(dir, "circuit.bin"), func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { t.Fatal(err) } - m := &groth16Manifest{Format: groth16ArtifactFormat, Curve: "bn254", CommitmentHash: "sha256", + m := &groth16Manifest{ Constraints: ccs.GetNbConstraints(), Domain: domain, PublicInputs: 2, Commitments: commitments, Files: make(map[string]artifact)} for _, name := range []string{"vk.json", "circuit.bin"} { m.Files[name], err = fileHash(filepath.Join(dir, name)) @@ -91,24 +92,24 @@ func TestGroth16MPCAndAdmission(t *testing.T) { return entry.SHA256 } pin := publish() - if err := groth16MPCSetup(dir, metadata, hex.EncodeToString(make([]byte, 32))); err == nil { + if err := groth16MPCSetup(dir, m, metadata, hex.EncodeToString(make([]byte, 32))); err == nil { t.Fatal("accepted untrusted ceremony metadata") } ceremony.Circuit = hex.EncodeToString(make([]byte, 32)) - if err := groth16MPCSetup(dir, metadata, publish()); err == nil { + if err := groth16MPCSetup(dir, m, metadata, publish()); err == nil { t.Fatal("accepted ceremony for another circuit") } ceremony.Circuit = m.Files["circuit.bin"].SHA256 ceremony.Phase2 = append(ceremony.Phase2, ceremony.Phase2[0]) - if err := groth16MPCSetup(dir, metadata, publish()); err == nil { + if err := groth16MPCSetup(dir, m, metadata, publish()); err == nil { t.Fatal("accepted duplicated contribution") } ceremony.Phase2 = ceremony.Phase2[:1] pin = publish() - if err := groth16MPCSetup(dir, metadata, pin); err != nil { + if err := groth16MPCSetup(dir, m, metadata, pin); err != nil { t.Fatal(err) } - _, _, m, err = groth16Load(dir) + _, _, err = groth16Load(dir, m) if err != nil { t.Fatal(err) } @@ -128,19 +129,23 @@ func TestGroth16MPCAndAdmission(t *testing.T) { t.Fatal(err) } imported := t.TempDir() - groth16TestArtifacts(t, imported) - if err := groth16Import(imported, releasePath, releaseHash.SHA256); err != nil { + _, importedMetadata := groth16TestArtifacts(t, imported) + if err := groth16Import(imported, importedMetadata, releasePath, releaseHash.SHA256); err != nil { t.Fatal(err) } t.Setenv("IX_GROTH16_BACKEND", "cpu") - p, err := openGroth16(imported) + encoded, err := json.Marshal(importedMetadata) + if err != nil { + t.Fatal(err) + } + p, err := openGroth16(imported, encoded, groth16Backend{}) if err != nil { t.Fatal(err) } defer p.close() var digest [32]byte digest[0], digest[16] = 49, 1 - if _, _, err := p.prove(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7, Commit: true}, digest[:]); err != nil { + if _, _, err := p.proveAssignment(&backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7, Commit: true}, digest[:]); err != nil { t.Fatal(err) } if p.manifest.Files["verifying-key.bin"].SHA256 != m.Files["verifying-key.bin"].SHA256 { diff --git a/tools/plonk-wrapper/groth16_test.go b/tools/plonk-wrapper/groth16_test.go index 8c0e33e2b..a2efb3a35 100644 --- a/tools/plonk-wrapper/groth16_test.go +++ b/tools/plonk-wrapper/groth16_test.go @@ -38,8 +38,8 @@ func TestGroth16BackendInterop(t *testing.T) { if err != nil { t.Fatal(err) } - m := &groth16Manifest{Format: groth16ArtifactFormat, Curve: "bn254", InnerVK: hex.EncodeToString(make([]byte, 32)), - CommitmentHash: "sha256", Domain: domain, Constraints: ccs.GetNbConstraints(), PublicInputs: 2, + m := &groth16Manifest{InnerVK: hex.EncodeToString(make([]byte, 32)), + Domain: domain, Constraints: ccs.GetNbConstraints(), PublicInputs: 2, Commitments: commitments, Files: make(map[string]artifact)} engine := &groth16Engine{key: pk, close: func() {}, prove: func(c constraint.ConstraintSystem, w witness.Witness) (groth16.Proof, error) { return groth16.Prove(c, pk, w, backend.WithProverHashToFieldFunction(sha256.New())) @@ -51,7 +51,7 @@ func TestGroth16BackendInterop(t *testing.T) { assignment := &backendCircuit{Claim: [2]frontend.Variable{49, 1}, X: 7, Commit: committed} var native, payload []byte for range 2 { - native, payload, err = p.prove(assignment, digest[:]) + native, payload, err = p.proveAssignment(assignment, digest[:]) if err != nil { t.Fatal(err) } @@ -75,15 +75,15 @@ func TestGroth16BackendInterop(t *testing.T) { t.Fatal(err) } keyPath, keyHash := filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"].SHA256 - if err := groth16Verify(keyPath, keyHash, proofPath, hex.EncodeToString(digest[:])); err != nil { + if err := verifyGroth16Files(keyPath, keyHash, proofPath, hex.EncodeToString(digest[:])); err != nil { t.Fatal(err) } wrong := digest wrong[31] ^= 1 - if err := groth16Verify(keyPath, keyHash, proofPath, hex.EncodeToString(wrong[:])); err == nil { + if err := verifyGroth16Files(keyPath, keyHash, proofPath, hex.EncodeToString(wrong[:])); err == nil { t.Fatal("accepted another claim") } - if err := groth16Verify(keyPath, hex.EncodeToString(make([]byte, 32)), proofPath, hex.EncodeToString(digest[:])); err == nil { + if err := verifyGroth16Files(keyPath, hex.EncodeToString(make([]byte, 32)), proofPath, hex.EncodeToString(digest[:])); err == nil { t.Fatal("accepted another key") } for _, malformed := range [][]byte{native[:len(native)-1], append(append([]byte(nil), native...), 0)} { @@ -107,10 +107,10 @@ func TestGroth16BackendInterop(t *testing.T) { }); err != nil { t.Fatal(err) } - if _, _, err := p.prove(assignment, wrong[:]); err == nil { + if _, _, err := p.proveAssignment(assignment, wrong[:]); err == nil { t.Fatal("accepted mismatched independently expected claim") } - if _, _, err := p.prove(assignment, digest[:]); err == nil { + if _, _, err := p.proveAssignment(assignment, digest[:]); err == nil { t.Fatal("reused failed prover") } t.Logf("constraints=%d domain=%d commitments=%d native=%d solidity=%d", m.Constraints, domain, commitments, len(native), len(payload)) @@ -118,57 +118,14 @@ func TestGroth16BackendInterop(t *testing.T) { } } -func TestGroth16WrapperCircuit(t *testing.T) { - if os.Getenv("IX_GROTH16_WRAPPER_TEST") != "1" { - t.Skip("set IX_GROTH16_WRAPPER_TEST=1 for the complete KZG verifier circuit") - } - dir := t.TempDir() - if root := os.Getenv("IX_GROTH16_TEST_OUTPUT"); root != "" { - dir = filepath.Join(root, "wrapper") - } - if err := groth16Compile(filepath.Join("testdata", "bn254", "vk.json"), dir); err != nil { - t.Fatal(err) - } - _, _, m, err := groth16Load(dir) - if err != nil { - t.Fatal(err) - } - t.Logf("wrapper constraints=%d domain=%d commitments=%d", m.Constraints, m.Domain, m.Commitments) - if os.Getenv("IX_GROTH16_WRAPPER_PROVE") != "1" { - return - } - if !m.KeysReady { - if err := groth16DevelopmentSetup(dir); err != nil { - t.Fatal(err) - } - } else if m.Setup != "development" { - t.Fatal("wrapper test requires development keys") - } - var input witnessJSON - witness := filepath.Join("testdata", "bn254", "witness.json") - if err := decodeJSON(witness, &input); err != nil { - t.Fatal(err) - } - if err := groth16Prove(dir, witness, input.ClaimDigest, dir); err != nil { - t.Fatal(err) - } - mData := new(groth16Manifest) - if err := decodeJSON(filepath.Join(dir, "manifest.json"), mData); err != nil { - t.Fatal(err) - } - if err := groth16Verify(filepath.Join(dir, "verifying-key.bin"), mData.Files["verifying-key.bin"].SHA256, filepath.Join(dir, "proof.bin"), input.ClaimDigest); err != nil { - t.Fatal(err) - } -} - func TestGroth16BackendSelection(t *testing.T) { - t.Setenv("IX_GROTH16_BACKEND", "invalid") - if _, err := newGroth16Engine(); err == nil { + + if _, err := newGroth16Engine(groth16Backend{Device: -1}); err == nil { t.Fatal("accepted invalid backend") } if !groth16CUDAAvailable() { - t.Setenv("IX_GROTH16_BACKEND", "cuda") - if _, err := newGroth16Engine(); err == nil { + + if _, err := newGroth16Engine(groth16Backend{CUDA: true}); err == nil { t.Fatal("silently accepted unavailable CUDA") } } diff --git a/tools/plonk-wrapper/main.go b/tools/plonk-wrapper/main.go index 715ff5f28..38dd16da6 100644 --- a/tools/plonk-wrapper/main.go +++ b/tools/plonk-wrapper/main.go @@ -1,187 +1,3 @@ package main -import ( - "crypto/rand" - "fmt" - "io" - "math/big" - "os" - "path/filepath" - "time" - - "github.com/consensys/gnark-crypto/ecc" - "github.com/consensys/gnark/backend/plonk" - "github.com/consensys/gnark/constraint" - "github.com/consensys/gnark/constraint/solver" - "github.com/consensys/gnark/frontend" - fcs "github.com/consensys/gnark/frontend/cs" - "github.com/consensys/gnark/frontend/cs/scs" - "github.com/consensys/gnark/logger" -) - -func compileProfile(path, dir string) error { - p, err := loadVerifyingKey(path) - if err != nil { - return err - } - vkHash, err := fileHash(path) - if err != nil { - return err - } - if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil { - m, err := loadManifest(dir) - if err != nil { - return err - } - if m.InnerVKSHA256 != vkHash.SHA256 { - return fmt.Errorf("artifact directory belongs to a different verifying key") - } - if err := m.checkFile(dir, "circuit.bin"); err != nil { - return err - } - fmt.Printf("reused_circuit=true constraints=%d domain=%d commitments=%d\n", m.Constraints, m.Domain, m.Commitments) - return nil - } else if !os.IsNotExist(err) { - return err - } - start := time.Now() - ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, newCircuit(p)) - if err != nil { - return err - } - seconds := time.Since(start).Seconds() - identity, err := os.Executable() - if err != nil { - return err - } - executable, err := fileHash(identity) - if err != nil { - return err - } - m := &manifest{ - Format: artifactFormat, ExecutableSHA256: executable.SHA256, - InnerVKSHA256: vkHash.SHA256, Constraints: ccs.GetNbConstraints(), - PublicInputs: ccs.GetNbPublicVariables(), - Commitments: len(ccs.GetCommitments().CommitmentIndexes()), - Domain: ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())), - Files: make(map[string]artifact), Seconds: map[string]float64{"compile": seconds}, - } - if m.PublicInputs != 2 { - return fmt.Errorf("expected two public digest limbs") - } - if err := atomicFile(filepath.Join(dir, "vk.json"), func(w io.Writer) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - _, err = io.Copy(w, f) - return err - }); err != nil { - return err - } - m.Files["vk.json"] = vkHash - if err := saveBinary(dir, "circuit.bin", ccs, m); err != nil { - return err - } - if err := writeJSON(filepath.Join(dir, "manifest.json"), m); err != nil { - return err - } - fmt.Printf("constraints=%d domain=%d public=%d commitments=%d compile_seconds=%.3f\n", m.Constraints, m.Domain, m.PublicInputs, m.Commitments, seconds) - return nil -} - -func loadCircuit(dir string) (*verifyingKey, constraint.ConstraintSystem, *manifest, error) { - m, err := loadManifest(dir) - if err != nil { - return nil, nil, nil, err - } - if err := m.checkFile(dir, "circuit.bin"); err != nil { - return nil, nil, nil, err - } - p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) - if err != nil { - return nil, nil, nil, err - } - ccs := plonk.NewCS(curveID) - if err := readBinary(filepath.Join(dir, "circuit.bin"), ccs); err != nil { - return nil, nil, nil, err - } - if ccs.GetNbPublicVariables() != 2 || ccs.GetNbConstraints() != m.Constraints || ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+2)) != m.Domain { - return nil, nil, nil, fmt.Errorf("constraint metadata differs") - } - return p, ccs, m, nil -} - -// diagnosticCommitment is only for checking assignments without a proving key. -// PLONK proving supplies its own KZG-bound commitment challenge. -func diagnosticCommitment(modulus *big.Int, _ []*big.Int, outputs []*big.Int) error { - if len(outputs) != 1 { - return fmt.Errorf("unexpected diagnostic commitment outputs") - } - for { - value, err := rand.Int(rand.Reader, modulus) - if err != nil { - return err - } - if value.Sign() != 0 { - outputs[0].Set(value) - return nil - } - } -} - -func checkWitness(dir, path string) error { - p, ccs, _, err := loadCircuit(dir) - if err != nil { - return err - } - assignment, err := witnessAssignment(p, path) - if err != nil { - return err - } - w, err := frontend.NewWitness(assignment, curveID.ScalarField()) - if err != nil { - return err - } - start := time.Now() - option := solver.OverrideHint(solver.GetHintID(fcs.Bsb22CommitmentComputePlaceholder), diagnosticCommitment) - if err := ccs.IsSolved(w, option); err != nil { - return err - } - fmt.Printf("diagnostic_witness_check=passed proof_created=false witness_seconds=%.3f\n", time.Since(start).Seconds()) - return nil -} - -func run(args []string) error { - if len(args) > 0 && args[0] == "groth16" { - return runGroth16(args[1:]) - } - if len(args) == 3 && args[0] == "compile" { - return compileProfile(args[1], args[2]) - } - if len(args) == 3 && args[0] == "check-witness" { - return checkWitness(args[1], args[2]) - } - if len(args) == 4 && args[0] == "prepare" { - return prepare(args[1], args[2], args[3]) - } - if len(args) == 4 && args[0] == "prove" { - return prove(args[1], args[2], args[3]) - } - if len(args) == 5 && args[0] == "verify" { - return verify(args[1], args[2], args[3], args[4]) - } - if len(args) == 6 && args[0] == "verify" { - return verify(args[1], args[2], args[3], args[4], args[5]) - } - return fmt.Errorf("usage: PLONK helper compile | check-witness | prepare | prove | verify ") -} - -func main() { - logger.SetOutput(os.Stderr) - if err := run(os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} +func main() {} diff --git a/tools/plonk-wrapper/parameters.go b/tools/plonk-wrapper/parameters.go index 1b1d4175e..a365aef67 100644 --- a/tools/plonk-wrapper/parameters.go +++ b/tools/plonk-wrapper/parameters.go @@ -4,20 +4,11 @@ import ( "bufio" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "io" "os" - "path/filepath" - "strconv" ) -type lagrangeManifest struct { - Format, SetupID, MonomialBLAKE3 string - Domain uint64 - Points artifact -} - func readAffinePoints(r io.Reader, count uint64) ([]nativeG1, error) { points := make([]nativeG1, count) for i := range points { @@ -49,79 +40,43 @@ func writeAffinePoints(w io.Writer, points []nativeG1) error { return b.Flush() } -func lagrangeLocation(root string, domain uint64, setupID, receipt string) (lagrangeManifest, string, error) { - want := lagrangeManifest{Format: lagrangeFormat, SetupID: setupID, MonomialBLAKE3: receipt, Domain: domain} - identity, err := json.Marshal(want) - if err != nil { - return want, "", err - } - digest := sha256.Sum256(identity) - dir := filepath.Join(root, strconv.FormatUint(domain, 10), hex.EncodeToString(digest[:])) - return want, dir, nil -} - -func publishLagrange(dir string, want lagrangeManifest, points []nativeG1) error { - pointsPath, metadata := filepath.Join(dir, "points.bin"), filepath.Join(dir, "manifest.json") - var err error - if err := atomicFile(pointsPath, func(w io.Writer) error { return writeAffinePoints(w, points) }); err != nil { - return err - } - want.Points, err = fileHash(pointsPath) - if err != nil { - return err - } - if err := writeJSON(metadata, want); err != nil { - return err - } - return nil -} - -func lagrangeParameters(root string, srs *nativeSRS, domain uint64, setupID, receipt string) (*nativeSRS, bool, error) { +func loadLagrange(srs *nativeSRS, domain uint64, path, expected string) (*nativeSRS, error) { if domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || uint64(len(srs.Pk.G1)) < domain+3 { - return nil, false, fmt.Errorf("invalid Lagrange domain or monomial prefix") + return nil, fmt.Errorf("invalid Lagrange domain") } - want, dir, err := lagrangeLocation(root, domain, setupID, receipt) - if err != nil { - return nil, false, err - } - metadata := filepath.Join(dir, "manifest.json") - pointsPath := filepath.Join(dir, "points.bin") - if _, err := os.Stat(metadata); err == nil { - var stored lagrangeManifest - if err := decodeJSON(metadata, &stored); err != nil { - return nil, false, err - } - pointsInfo := stored.Points - stored.Points = artifact{} - if stored != want || pointsInfo.Bytes != int64(2*baseBytes*domain) { - return nil, false, fmt.Errorf("Lagrange cache identity differs") + if expected != "" { + f, err := os.Open(path) + if err != nil { + return nil, err } - got, err := fileHash(pointsPath) + defer f.Close() + info, err := f.Stat() if err != nil { - return nil, false, err + return nil, err } - if got != pointsInfo { - return nil, false, fmt.Errorf("Lagrange cache checksum differs") + if info.Size() != int64(2*baseBytes*domain) { + return nil, fmt.Errorf("Lagrange cache size differs") } - f, err := os.Open(pointsPath) + hash := sha256.New() + reader := bufio.NewReaderSize(io.TeeReader(f, hash), 1<<20) + points, err := readAffinePoints(reader, domain) if err != nil { - return nil, false, err + return nil, err } - defer f.Close() - points, err := readAffinePoints(bufio.NewReaderSize(f, 1<<20), domain) - if err != nil { - return nil, false, err + if _, err := reader.ReadByte(); err != io.EOF { + return nil, fmt.Errorf("trailing Lagrange cache data") + } + if hex.EncodeToString(hash.Sum(nil)) != expected { + return nil, fmt.Errorf("Lagrange cache checksum differs") } - return &nativeSRS{Pk: nativeKzgProvingKey{G1: points}, Vk: srs.Vk}, true, nil - } else if !os.IsNotExist(err) { - return nil, false, err + return &nativeSRS{Pk: nativeKzgProvingKey{G1: points}, Vk: srs.Vk}, nil } points, err := toLagrange(srs.Pk.G1[:domain]) if err != nil { - return nil, false, err + return nil, err } - if err := publishLagrange(dir, want, points); err != nil { - return nil, false, err + if err := atomicFile(path, func(w io.Writer) error { return writeAffinePoints(w, points) }); err != nil { + return nil, err } - return &nativeSRS{Pk: nativeKzgProvingKey{G1: points}, Vk: srs.Vk}, false, nil + return &nativeSRS{Pk: nativeKzgProvingKey{G1: points}, Vk: srs.Vk}, nil } diff --git a/tools/plonk-wrapper/parameters_bn254_test.go b/tools/plonk-wrapper/parameters_bn254_test.go index a92557537..87f46f061 100644 --- a/tools/plonk-wrapper/parameters_bn254_test.go +++ b/tools/plonk-wrapper/parameters_bn254_test.go @@ -3,7 +3,6 @@ package main import ( "os" "path/filepath" - "strings" "testing" "github.com/consensys/gnark-crypto/ecc/bn254/fr" @@ -27,14 +26,18 @@ func TestLagrangeCacheAndBasisConversion(t *testing.T) { if err != nil { t.Fatal(err) } - root := t.TempDir() - first, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3) - if err != nil || reused { - t.Fatalf("initial derivation: reused=%t error=%v", reused, err) + path := filepath.Join(t.TempDir(), "points.bin") + first, err := loadLagrange(srs, 8, path, "") + if err != nil { + t.Fatal(err) } - second, reused, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3) - if err != nil || !reused { - t.Fatalf("cached derivation: reused=%t error=%v", reused, err) + identity, err := fileHash(path) + if err != nil { + t.Fatal(err) + } + second, err := loadLagrange(srs, 8, path, identity.SHA256) + if err != nil { + t.Fatal(err) } for i := range first.Pk.G1 { if !first.Pk.G1[i].Equal(&second.Pk.G1[i]) { @@ -59,11 +62,7 @@ func TestLagrangeCacheAndBasisConversion(t *testing.T) { if !a.Equal(&b) { t.Fatal("monomial and Lagrange commitments differ") } - files, err := filepath.Glob(filepath.Join(root, "8", "*", "points.bin")) - if err != nil || len(files) != 1 { - t.Fatalf("cache files: %v %v", files, err) - } - f, err := os.OpenFile(files[0], os.O_RDWR, 0) + f, err := os.OpenFile(path, os.O_RDWR, 0) if err != nil { t.Fatal(err) } @@ -78,19 +77,10 @@ func TestLagrangeCacheAndBasisConversion(t *testing.T) { if err := f.Close(); err != nil { t.Fatal(err) } - if _, _, err := lagrangeParameters(root, srs, 8, p.SetupID, receipt.Blake3); err == nil { + if _, err := loadLagrange(srs, 8, path, identity.SHA256); err == nil { t.Fatal("accepted corrupted Lagrange cache") } - for _, identity := range []struct { - domain uint64 - setup, receipt string - }{ - {16, p.SetupID, receipt.Blake3}, - {8, strings.Repeat("b", 64), receipt.Blake3}, - {8, p.SetupID, strings.Repeat("c", 64)}, - } { - if _, reused, err := lagrangeParameters(root, srs, identity.domain, identity.setup, identity.receipt); err != nil || reused { - t.Fatalf("different setup, receipt or domain reused old cache: %t %v", reused, err) - } + if _, err := loadLagrange(srs, 16, path, identity.SHA256); err == nil { + t.Fatal("accepted wrong Lagrange domain") } } diff --git a/tools/plonk-wrapper/profile.go b/tools/plonk-wrapper/profile.go index 2cb1fbf3f..eeacef5b3 100644 --- a/tools/plonk-wrapper/profile.go +++ b/tools/plonk-wrapper/profile.go @@ -1,12 +1,12 @@ package main import ( + "bytes" "encoding/hex" "encoding/json" "fmt" "io" "math/big" - "os" "strings" "github.com/consensys/gnark/frontend" @@ -68,13 +68,8 @@ type witnessJSON struct { Values []string } -func decodeJSON(path string, out any) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - d := json.NewDecoder(io.LimitReader(f, 16<<20)) +func decodeReader(reader io.Reader, out any) error { + d := json.NewDecoder(reader) d.DisallowUnknownFields() if err := d.Decode(out); err != nil { return err @@ -85,6 +80,20 @@ func decodeJSON(path string, out any) error { return nil } +func decodeVerifyingKey(data []byte) (*verifyingKey, error) { + var key verifyingKey + if len(data) > 16<<20 { + return nil, fmt.Errorf("verifying key description is too large") + } + if err := decodeReader(bytes.NewReader(data), &key); err != nil { + return nil, err + } + if err := key.validate(); err != nil { + return nil, err + } + return &key, nil +} + func unhex(s string, length int) ([]byte, error) { b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) if err != nil || (length >= 0 && len(b) != length) { @@ -136,17 +145,6 @@ func g2Keys(encoded []string) ([2]nativeG2, error) { return result, nil } -func loadVerifyingKey(path string) (*verifyingKey, error) { - var p verifyingKey - if err := decodeJSON(path, &p); err != nil { - return nil, err - } - if err := p.validate(); err != nil { - return nil, err - } - return &p, nil -} - func (p *verifyingKey) validate() error { if p.Claims == nil || p.Publics == nil || p.Format != vkFormat || len(p.Prefix) != 10 || len(p.Circuits) < 2 || len(p.Matrices) != 4*len(p.Circuits) || p.PointCount < 1 || p.PointCount > 4096 || p.FieldCount < 1 || p.FieldCount > 65536 || len(p.Fixed) > 4096 || p.WitnessOffset < 0 || p.WitnessOffset > p.PointCount || p.OpeningCount < 2 || p.OpeningCount > 16 || p.WitnessOffset+p.OpeningCount != p.PointCount || p.AccumulatorCount != len(p.Circuits)-1 || (p.deferredCount() != 0 && p.deferredCount() != 2) { return fmt.Errorf("invalid wrapper verifying key dimensions") @@ -280,11 +278,7 @@ func newCircuit(p *verifyingKey) *wrapperCircuit { return &wrapperCircuit{VK: p, Points: make([]circuitG1, p.PointCount), Deferred: make([]circuitG1, p.deferredCount()), Values: make([]frontend.Variable, p.FieldCount)} } -func witnessAssignment(p *verifyingKey, path string) (*wrapperCircuit, error) { - var w witnessJSON - if err := decodeJSON(path, &w); err != nil { - return nil, err - } +func assignWitness(p *verifyingKey, w *witnessJSON) (*wrapperCircuit, error) { if len(w.Points) != p.PointCount || len(w.Deferred) != p.deferredCount() || len(w.Values) != p.FieldCount { return nil, fmt.Errorf("witness dimensions differ from verifying key") } diff --git a/tools/plonk-wrapper/prover.go b/tools/plonk-wrapper/prover.go new file mode 100644 index 000000000..a399041ad --- /dev/null +++ b/tools/plonk-wrapper/prover.go @@ -0,0 +1,227 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime/debug" + "sync" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/constraint" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" +) + +type circuitInfo struct { + Domain uint64 + Constraints, PublicInputs, Commitments int +} + +type backendConfig struct { + CUDA bool + Device int + MemoryBytes uint64 + MaskMSM bool +} + +func compileCircuit(schema []byte, path string) (circuitInfo, error) { + var info circuitInfo + key, err := decodeVerifyingKey(schema) + if err != nil { + return info, err + } + ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, newCircuit(key)) + if err != nil { + return info, err + } + info = circuitInfo{Domain: ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints() + ccs.GetNbPublicVariables())), Constraints: ccs.GetNbConstraints(), PublicInputs: ccs.GetNbPublicVariables(), Commitments: len(ccs.GetCommitments().CommitmentIndexes())} + if info.PublicInputs != 2 { + return info, fmt.Errorf("expected two public digest limbs") + } + return info, atomicFile(path, func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }) +} + +func prepareCircuit(schema []byte, circuitPath string, circuitHash artifact, domain uint64, srsPath, receipt, lagrangePath, lagrangeDigest, output string) error { + key, err := decodeVerifyingKey(schema) + if err != nil { + return err + } + srs, err := readSRS(srsPath, receipt, domain, key) + if err != nil { + return err + } + lagrange, err := loadLagrange(srs, domain, lagrangePath, lagrangeDigest) + if err != nil { + return err + } + debug.FreeOSMemory() + ccs := plonk.NewCS(curveID) + if err := readVerified(circuitPath, circuitHash, ccs); err != nil { + return err + } + if ccs.GetNbPublicVariables() != 2 || ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+2)) != domain { + return fmt.Errorf("circuit domain differs") + } + pk, vk, err := plonk.Setup(ccs, srs, lagrange) + if err != nil { + return err + } + for _, item := range []struct { + name string + value io.WriterTo + }{{"proving-key.bin", pk}, {"verifying-key.bin", vk}} { + if err := atomicFile(filepath.Join(output, item.name), func(w io.Writer) error { _, err := item.value.WriteTo(w); return err }); err != nil { + return err + } + } + return atomicFile(filepath.Join(output, "PlonkVerifier.sol"), func(w io.Writer) error { return vk.ExportSolidity(w) }) +} + +type prover struct { + mu sync.Mutex + key *verifyingKey + ccs constraint.ConstraintSystem + pk plonk.ProvingKey + vk *nativeVerifyingKey + cleanup func() error + closed, failed bool +} + +func openProver(dir string, metadata []byte, config backendConfig) (*prover, error) { + var m manifest + if err := decodeReader(bytes.NewReader(metadata), &m); err != nil { + return nil, err + } + if err := m.validate(); err != nil { + return nil, err + } + if !m.KeysReady { + return nil, fmt.Errorf("PLONK keys are not prepared") + } + data, err := readDescription(filepath.Join(dir, "vk.json")) + if err != nil { + return nil, err + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != m.InnerVKSHA256 || int64(len(data)) != m.Files["vk.json"].Bytes { + return nil, fmt.Errorf("verifier description identity differs") + } + key, err := decodeVerifyingKey(data) + if err != nil { + return nil, err + } + p := &prover{key: key, ccs: plonk.NewCS(curveID), pk: plonk.NewProvingKey(curveID), vk: new(nativeVerifyingKey)} + for _, item := range []struct { + name string + value io.ReaderFrom + }{{"circuit.bin", p.ccs}, {"proving-key.bin", p.pk}, {"verifying-key.bin", p.vk}} { + if err := readVerified(filepath.Join(dir, item.name), m.Files[item.name], item.value); err != nil { + return nil, err + } + } + if p.ccs.GetNbConstraints() != m.Constraints || p.ccs.GetNbPublicVariables() != 2 || len(p.ccs.GetCommitments().CommitmentIndexes()) != m.Commitments || p.vk.Size != m.Domain || p.vk.NbPublicVariables != 2 || len(p.vk.Qcp) != m.Commitments { + return nil, fmt.Errorf("decoded circuit or key dimensions differ") + } + embedded := p.pk.VerifyingKey().(io.WriterTo) + var encoded bytes.Buffer + if _, err := embedded.WriteTo(&encoded); err != nil { + return nil, err + } + hash := sha256.Sum256(encoded.Bytes()) + if hex.EncodeToString(hash[:]) != m.Files["verifying-key.bin"].SHA256 { + return nil, fmt.Errorf("proving key embeds another verifying key") + } + p.cleanup, err = configureBackend(p.pk, config) + if err != nil { + return nil, err + } + return p, nil +} + +func (p *prover) prove(data, expected []byte) ([]byte, []byte, error) { + p.mu.Lock() + defer p.mu.Unlock() + defer func() { + if value := recover(); value != nil { + p.failed = true + panic(value) + } + }() + if p.closed || p.failed { + return nil, nil, fmt.Errorf("prover is closed or failed") + } + if len(expected) != 32 || len(data) > 16<<20 { + return nil, nil, fmt.Errorf("invalid witness or claim size") + } + var input witnessJSON + if err := decodeReader(bytes.NewReader(data), &input); err != nil { + return nil, nil, err + } + digest, err := unhex(input.ClaimDigest, 32) + if err != nil || !bytes.Equal(digest, expected) { + return nil, nil, fmt.Errorf("witness differs from expected claim") + } + assignment, err := assignWitness(p.key, &input) + if err != nil { + return nil, nil, err + } + witness, err := frontend.NewWitness(assignment, curveID.ScalarField()) + if err != nil { + return nil, nil, err + } + proof, err := plonk.Prove(p.ccs, p.pk, witness, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) + if err != nil { + p.failed = true + return nil, nil, err + } + public, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(expected)}, curveID.ScalarField(), frontend.PublicOnly()) + if err != nil { + p.failed = true + return nil, nil, err + } + if err := plonk.Verify(proof, p.vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { + p.failed = true + return nil, nil, err + } + var encoded bytes.Buffer + if _, err := proof.WriteTo(&encoded); err != nil { + return nil, nil, err + } + return encoded.Bytes(), proof.(*nativeProof).MarshalSolidity(), nil +} + +func (p *prover) close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return nil + } + p.closed = true + err := p.cleanup() + p.key, p.ccs, p.pk, p.vk = nil, nil, nil, nil + return err +} + +func readDescription(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, (16<<20)+1)) + if err != nil { + return nil, err + } + if len(data) > 16<<20 { + return nil, fmt.Errorf("verifier description is too large") + } + return data, nil +} diff --git a/tools/plonk-wrapper/terminal_compare_test.go b/tools/plonk-wrapper/terminal_compare_test.go index 020e353ef..42a78c484 100644 --- a/tools/plonk-wrapper/terminal_compare_test.go +++ b/tools/plonk-wrapper/terminal_compare_test.go @@ -224,7 +224,7 @@ func TestBN254TerminalComparison(t *testing.T) { var close func() if system == "groth16" { t.Setenv("IX_GROTH16_BACKEND", device) - engine, err := newGroth16Engine() + engine, err := newGroth16Engine(groth16Backend{CUDA: device == "cuda", PinKeys: measurement.PinKeys, Library: os.Getenv("ICICLE_BACKEND_INSTALL_DIR")}) if err != nil { t.Fatal(err) } @@ -270,7 +270,7 @@ func TestBN254TerminalComparison(t *testing.T) { t.Fatal(err) } } - cleanup, err := configureProvingBackend(pk) + cleanup, err := configureBackend(pk, backendConfig{CUDA: device == "cuda", MemoryBytes: 64 << 30}) if err != nil { t.Fatal(err) } diff --git a/tools/plonk-wrapper/test_helpers_test.go b/tools/plonk-wrapper/test_helpers_test.go new file mode 100644 index 000000000..bb41b71da --- /dev/null +++ b/tools/plonk-wrapper/test_helpers_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +func writeJSON(path string, value any) error { + return atomicFile(path, func(w io.Writer) error { enc := json.NewEncoder(w); enc.SetIndent("", " "); return enc.Encode(value) }) +} + +func (m *manifest) checkFile(dir, name string) error { + want, ok := m.Files[name] + if !ok { + return fmt.Errorf("artifact %s missing from manifest", name) + } + got, err := fileHash(filepath.Join(dir, name)) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("artifact %s checksum or size differs", name) + } + return nil +} + +func decodeJSON(path string, out any) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return decodeReader(io.LimitReader(f, 16<<20), out) +} + +func loadVerifyingKey(path string) (*verifyingKey, error) { + var p verifyingKey + if err := decodeJSON(path, &p); err != nil { + return nil, err + } + if err := p.validate(); err != nil { + return nil, err + } + return &p, nil +} + +func witnessAssignment(p *verifyingKey, path string) (*wrapperCircuit, error) { + var w witnessJSON + if err := decodeJSON(path, &w); err != nil { + return nil, err + } + return assignWitness(p, &w) +} + +func readBinary(path string, value io.ReaderFrom) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = value.ReadFrom(f) + return err +} + +func verifyGroth16Files(keyPath, pin, proofPath, claim string) error { + key, err := os.ReadFile(keyPath) + if err != nil { + return err + } + proof, err := os.ReadFile(proofPath) + if err != nil { + return err + } + trusted, err := unhex(pin, 32) + if err != nil { + return err + } + expected, err := unhex(claim, 32) + if err != nil { + return err + } + return verifyGroth16(key, trusted, proof, expected) +} From caa4d9bd237582afd10188bf905d119c0971174f Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:38:18 +0000 Subject: [PATCH 22/31] feat(compression): run compression inside ix and drop the worker Compression now runs in the ix process through `pipeline::compress(CompressInput, &CompressConfig)`: FRI, first KZG, recursive KZG and an optional PLONK or Groth16 terminal, keeping the on-disk stage directories. Each stage writes a `complete` marker last; rerunning `ix compress` with the same output directory reuses completed stages and refuses a directory whose root does not match. Pooled GPU memory is returned to the driver after normalization and after each stage (multi-stark `release_device_memory`). Removed: the ix-compression-worker executable and its subcommands, process spawning and capability checks, IX_COMPRESSION_WORKER, the worker Lake target, Nix packages and CI step. Release and key tooling (PLONK export and parameters, Groth16 compile/setup/import, pinned-key verification) remain library functions without a CLI. Builds without `plonk` or `groth16` reject those proofs with an error naming the switch. Native code: the KZG and PLONK CUDA kernels build into one libix_bn254_cuda.so against one sppark, with a private C++ runtime and only C exports, so it can share a process with Lean's runtime. Build scripts report runtime files (that library, and ICICLE with its backend plugins) in IX_RUNTIME_ARTIFACTS; Lake copies exactly those and links them through ld scripts. sppark_namespace.h, the standalone PLONK build, IX_PLONK_CUDA_LIBRARY and related variables are gone. Build switches mirror the Cargo features: IX_PLONK and IX_GROTH16 are off by default, so compression ends at the recursive KZG proof and the default build needs no Go; terminal features compile into ix only. IX_KZG_CURVE is removed. Groth16 CUDA links an installed ICICLE (/usr/local or IX_GROTH16_ICICLE_ROOT). CLI: `ix prove --ixe E --ixes M` proves and aggregates on every visible GPU by default (`--gpus`, `--shards-only`; `--lanes` is removed), and `--compress kzg|plonk|groth16` continues to a compressed proof, checking the setup before proving. One-shard manifests are wrapped into a root. `ix compress layout|normalize|fetch-params` replace `ix compression`. `fetch-params` downloads the pinned public Ignition files, which compression then finds without IX_KZG_IGNITION_* variables. Compression normalizes to the bundled release root layout unless `--fixed-root` overrides it. Fixed-preprocessing cache entries are keyed by format and stage verifying key, and PLONK circuit caches by format, circuit description, setup and gnark's go.sum, instead of the executable digest, so rebuilding ix keeps them. Validated on Nat.add_comm with admitted Ignition parameters: the compressed proof and recursive verifying key are byte-identical to the process-per-stage pipeline; peak host RSS 190 GB, peak GPU 95.8 GB. Pins multi-stark 1b5b65d (release_device_memory and a workaround for Plonky3's twiddle-cache deadlock) and sppark 9326ac0 (upstream-merged dev with the split-bucket MSM the PLONK kernels use). --- .github/workflows/ci.yml | 5 +- Cargo.lock | 6 +- Cargo.toml | 2 +- Ix/Aggr/FFI.lean | 7 +- Ix/Cli/AggregateCmd.lean | 2 +- Ix/Cli/CompressCmd.lean | 111 ++- Ix/Cli/Compression.lean | 16 +- Ix/Cli/CompressionCmd.lean | 77 +- Ix/Cli/ProveCmd.lean | 118 ++- Ix/Cli/ShardCmd.lean | 2 +- Main.lean | 2 - README.md | 2 +- Tests/CompressionCli.lean | 4 +- crates/ffi/Cargo.toml | 7 +- crates/ffi/src/aiur/aggregate/ffi.rs | 2 +- crates/ffi/src/aiur/aggregate/lanes.rs | 24 +- crates/ffi/src/compression.rs | 47 +- crates/ffi/src/kernel.rs | 2 +- crates/proof-compression/Cargo.toml | 6 +- crates/proof-compression/build.rs | 188 +++-- crates/proof-compression/build/gnark.rs | 108 +-- crates/proof-compression/cuda/kzg.cu | 2 +- crates/proof-compression/cuda/kzg_curve.cuh | 5 + crates/proof-compression/cuda/kzg_lookup.cuh | 2 +- crates/proof-compression/cuda/kzg_passes.cuh | 4 +- .../proof-compression/cuda/kzg_quotient.cuh | 2 +- crates/proof-compression/cuda/kzg_sppark.cuh | 24 - .../cuda/plonk/blst-source-manifest.json | 155 ---- crates/proof-compression/cuda/plonk/build.py | 241 ------ .../proof-compression/cuda/plonk/exports.map | 4 - .../patches/sppark-split-buckets-v1.json | 15 - .../patches/sppark-split-buckets-v1.patch | 360 --------- .../proof-compression/cuda/plonk/runtime.cpp | 2 - .../cuda/plonk/sppark-source-manifest.json | 103 --- .../cuda/plonk/sppark_namespace.h | 23 - .../root-layouts/ixvm-2026-10-29.json | 153 ++++ .../src/bin/ix_compression_worker.rs | 55 -- .../src/kzg/cuda/tests/plonk_scale.rs | 3 + .../src/pipeline/compress.rs | 731 ++++++++++++++++++ .../proof-compression/src/pipeline/driver.rs | 233 ------ .../proof-compression/src/pipeline/first.rs | 123 +-- .../src/pipeline/fixed_cache.rs | 29 +- .../proof-compression/src/pipeline/gnark.rs | 2 +- .../proof-compression/src/pipeline/groth16.rs | 121 ++- crates/proof-compression/src/pipeline/mod.rs | 685 +++------------- .../proof-compression/src/pipeline/plonk.rs | 145 ++-- crates/proof-compression/src/pipeline/root.rs | 94 +-- .../src/pipeline/root_layout.rs | 20 + .../proof-compression/src/pipeline/setup.rs | 101 ++- .../src/pipeline/setup/ignition_tests.rs | 31 + .../src/pipeline/terminal.rs | 31 +- .../src/pipeline/verifier.rs | 170 +--- .../src/pipeline/wrap/mod.rs | 61 +- .../src/pipeline/wrap/outer.rs | 20 +- .../src/pipeline/wrap/outer/plonk_export.rs | 94 +-- .../src/pipeline/wrap/saved.rs | 43 +- .../proof-compression/tests/pipeline_root.rs | 15 +- .../proof-compression/tests/worker_tracing.rs | 122 --- flake.nix | 71 +- lakefile.lean | 223 +++--- tools/plonk-wrapper/artifacts.go | 3 + tools/plonk-wrapper/internal/cuda/abi.go | 2 +- 62 files changed, 1965 insertions(+), 3096 deletions(-) delete mode 100644 crates/proof-compression/cuda/kzg_sppark.cuh delete mode 100644 crates/proof-compression/cuda/plonk/blst-source-manifest.json delete mode 100644 crates/proof-compression/cuda/plonk/build.py delete mode 100644 crates/proof-compression/cuda/plonk/exports.map delete mode 100644 crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json delete mode 100644 crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch delete mode 100644 crates/proof-compression/cuda/plonk/runtime.cpp delete mode 100644 crates/proof-compression/cuda/plonk/sppark-source-manifest.json delete mode 100644 crates/proof-compression/cuda/plonk/sppark_namespace.h create mode 100644 crates/proof-compression/root-layouts/ixvm-2026-10-29.json delete mode 100644 crates/proof-compression/src/bin/ix_compression_worker.rs create mode 100644 crates/proof-compression/src/pipeline/compress.rs delete mode 100644 crates/proof-compression/src/pipeline/driver.rs create mode 100644 crates/proof-compression/src/pipeline/root_layout.rs delete mode 100644 crates/proof-compression/tests/worker_tracing.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a7ab503..79e2f5a60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,7 +175,6 @@ jobs: # link without paying for a GPU runner. One explicit architecture # is sufficient for this downstream integration gate. MULTI_STARK_CUDA_ARCHS: "80" - IX_PLONK_CUDA_ARCHS: "80" steps: # No sccache here yet. Once the GPU benchmark lands after #644, wire # it for both compilers: `sccache: s3` plus the sccache action for @@ -228,7 +227,7 @@ jobs: - name: Check CPU runtime linkage without a CUDA compiler run: IX_CUDA=0 IX_CUDA_TRACE_CODEGEN=0 NVCC=/nonexistent/nvcc lake build ix_cuda_runtime - name: Link CUDA-enabled Lean executables - run: IX_CUDA=1 lake build ix bench-typecheck + run: IX_CUDA=1 IX_PLONK=1 IX_GROTH16=1 lake build ix bench-typecheck - name: Check the CUDA runtime stays private run: | ! readelf -d .lake/build/lib/libmulti_stark_cuda.so | grep -q 'libstdc++\|libc++\|libgcc_s' @@ -240,8 +239,6 @@ jobs: -o .lake/build/lib/cuda_link_smoke.so CUDA_VISIBLE_DEVICES='' LD_PRELOAD="$PWD/.lake/build/lib/cuda_link_smoke.so" \ .lake/build/bin/ix compress --help - CUDA_VISIBLE_DEVICES='' .lake/build/bin/ix-compression-worker fri capabilities | \ - python3 -c 'import json, sys; assert json.load(sys.stdin)["cuda"] is True' lints: name: Lint workflows diff --git a/Cargo.lock b/Cargo.lock index cf278a8fa..3536bab18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2359,7 +2359,7 @@ dependencies = [ [[package]] name = "multi-stark" version = "0.1.0" -source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=ea4cf6976462612ccd03255eacc1f8cc1cfe43fb#ea4cf6976462612ccd03255eacc1f8cc1cfe43fb" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=1b5b65d501bf87bfc684f682d88b996e803142ea#1b5b65d501bf87bfc684f682d88b996e803142ea" dependencies = [ "bincode", "itertools 0.14.0", @@ -3966,8 +3966,8 @@ dependencies = [ [[package]] name = "sppark" -version = "0.1.15" -source = "git+https://github.com/argumentcomputer/sppark?rev=114feb69ebfe029e2caa5e6c2d372c695e5d4da8#114feb69ebfe029e2caa5e6c2d372c695e5d4da8" +version = "0.1.16" +source = "git+https://github.com/argumentcomputer/sppark?rev=9326ac014ec5415ebc4eab24c1246276714aee57#9326ac014ec5415ebc4eab24c1246276714aee57" dependencies = [ "cc", "which", diff --git a/Cargo.toml b/Cargo.toml index 240e09e07..de566ebe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" memmap2 = "0.9" # Bundle v3.5.1 to fix large-arena failures (microsoft/mimalloc#1309). mimalloc = { git = "https://github.com/argumentcomputer/mimalloc_rust.git", rev = "616cc1f130d9ea2a1111a46b4e98c140e078f882", default-features = false } -multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "ea4cf6976462612ccd03255eacc1f8cc1cfe43fb" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "1b5b65d501bf87bfc684f682d88b996e803142ea" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" diff --git a/Ix/Aggr/FFI.lean b/Ix/Aggr/FFI.lean index 984c02bf0..60d50bdd5 100644 --- a/Ix/Aggr/FFI.lean +++ b/Ix/Aggr/FFI.lean @@ -98,7 +98,7 @@ opaque aggregateExpected (envHandle : @& EnvHandle) -/-- The Stage 2 plan cut for `ix prove --lanes`: the tree is split from the +/-- The Stage 2 plan cut for `ix prove --ixe --ixes`: the tree is split from the root while any frontier node holds more than `subtreeSize` leaves (a lone leaf may then stand as a subtree of its own). Rows in post-order: `#[0, slot, ids…]` for a subtree (its root slot, as `ix aggregate --subtree` @@ -109,12 +109,13 @@ last. -/ opaque aggregateSubtreePlan (envHandle : @& EnvHandle) (manifestPath : @& String) (structuralAbove subtreeSize : @& Nat) : Except String (Array (Array Nat)) -/-- `ix prove --lanes`: the whole multi-device run in this process, one +/-- `ix prove --ixe --ixes`: the whole multi-device run in this process, one resident prover per GPU built from these two systems (`AiurSystem.on_device` in Rust; nothing is copied across the FFI), every claim and join scheduled over them as their inputs become ready, the root wrapped, and every claim proof verified natively at the end. `maxRamBytes` is one worker's host -budget (0 detects); `execJobs` the claim executions ahead of each prover. +budget (0 detects); `execJobs` the claim executions ahead of each prover; +`lanes` the GPU count (0 uses every visible GPU). Oversized environment claims are bisected and checkpointed; completed proofs are reused. A nonempty `outManifest` receives the final partition. Returns the verified root address; progress is printed on stderr. -/ diff --git a/Ix/Cli/AggregateCmd.lean b/Ix/Cli/AggregateCmd.lean index 50678354a..133c158a6 100644 --- a/Ix/Cli/AggregateCmd.lean +++ b/Ix/Cli/AggregateCmd.lean @@ -124,7 +124,7 @@ private def runAggregateCmdNativeWith | .error e => IO.eprintln s!"aggregate failed: {e}"; return 1 | .ok address => -- The root (or subtree root) proof address, alone on stdout, is what - -- a driver such as `ix prove --lanes` reads; the log lines are on stderr. + -- a driver such as `ix prove --ixe --ixes` reads; the log lines are on stderr. if !address.isEmpty then IO.println address (← IO.getStdout).flush diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index a68cdf6ff..0a2454dcc 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -1,11 +1,74 @@ module public import Cli public import Ix.Cli.Compression +public import Ix.Cli.CompressionCmd public import Ix.Aggr.Backend public section namespace Ix.Cli.CompressCmd +/-- Selected compression output: the proof system after recursive KZG and the +setup policy. -/ +structure Options where + terminal : UInt8 := 0 + devSrs : Bool := false + logHeight : Nat := 24 + out : Option String := none + rootVk : Option String := none + fixedRoot : Option String := none + +/-- `kzg`, `plonk` or `groth16` (`dev-groth16` allows development Groth16 keys). -/ +def terminalOf : String → Except String UInt8 + | "kzg" => .ok 0 + | "plonk" => .ok 1 + | "groth16" => .ok 2 + | "dev-groth16" => .ok 3 + | other => .error s!"unknown compression output {other}: expected kzg, plonk or groth16" + +/-- Check what can be checked before proving: the setup is configured and +authenticates, and the options are consistent. Returns the setup description. -/ +def preflight (opts : Options) : IO String := do + unless opts.logHeight == 24 || opts.logHeight == 25 do + throw <| IO.userError "--log-height must be 24 or 25" + if let some path := opts.fixedRoot then + unless ← System.FilePath.pathExists path do + throw <| IO.userError s!"fixed root layout {path} does not exist" + Compression.describeSetup opts.devSrs + +/-- Compress the aggregate root `addr`, persist the compressed proof under the +same claim and return its address. -/ +def compressRoot (addr : Address) (opts : Options) : IO Address := do + let setup ← preflight opts + let wrapper ← Compression.readProof addr + if Compression.isCompressed wrapper.proof then + throw <| IO.userError "proof is already compressed" + let .checkEnv _ _ := wrapper.claim | throw <| IO.userError "compression requires an aggregate CheckEnv proof" + -- The statement comes from the same backend `ix verify --aggregate` uses; + -- a trusted root key only replaces the verifying key the FRI stage checks. + let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) + let statement := Compression.statementBytes backend wrapper.claim + Compression.checkClaim (Ix.Claim.ser wrapper.claim) statement + let out ← match opts.out with + | some path => pure path + | none => + let dir := (← StoreIO.toIO Store.getHomeDir) / ".ix/compression" + pure (dir / toString addr / toString opts.logHeight).toString + let vk ← match opts.rootVk with + | some path => IO.FS.readBinFile path + | none => pure backend.system.vkBytes + let layout ← match opts.fixedRoot with + | some path => IO.FS.readFile path + | none => Compression.releaseRootLayout vk + IO.eprintln s!"Normalizing aggregate root {addr} for compression." + let root ← Compression.normalize backend wrapper layout + Compression.releaseGpuMemory + IO.eprintln s!"Compressing aggregate root {addr} with {setup}." + let proof ← Compression.compress vk root (Ix.Claim.ser wrapper.claim) statement + layout out (← Compression.cacheDir).toString + opts.devSrs opts.logHeight.toUInt32 opts.terminal + let compressed : Ixon.Proof := { claim := wrapper.claim, proof } + StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) + def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do try if p.hasFlag "plonk" && p.hasFlag "groth16" then @@ -15,41 +78,15 @@ def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do let terminal : UInt8 := if p.hasFlag "groth16" then (if p.hasFlag "dev-groth16" then 3 else 2) else if p.hasFlag "plonk" then 1 else 0 - let setup ← Compression.describeSetup (p.hasFlag "dev-srs") let hex := p.positionalArg! "proof" |>.as! String let some addr := Address.fromString hex | throw <| IO.userError "expected a 64-character proof address" - let wrapper ← Compression.readProof addr - if Compression.isCompressed wrapper.proof then - throw <| IO.userError "proof is already compressed" - let .checkEnv _ _ := wrapper.claim | throw <| IO.userError "compression requires an aggregate CheckEnv proof" - let height := ((p.flag? "log-height").map (·.as! Nat)).getD 24 - unless height == 24 || height == 25 do - throw <| IO.userError "--log-height must be 24 or 25" - -- The statement comes from the same backend `ix verify --aggregate` uses; - -- a trusted root key only replaces the verifying key the FRI stage checks. - let backend ← IO.ofExcept (← Aggr.buildVerificationBackend Aggr.defaultRecursionParameters) - let statement := Compression.statementBytes backend wrapper.claim - Compression.checkClaim (Ix.Claim.ser wrapper.claim) statement - let out ← match (p.flag? "out").map (·.as! String) with - | some path => pure path - | none => - let dir := (← StoreIO.toIO Store.getHomeDir) / ".ix/compression" - pure (dir / toString addr / toString height).toString - let vk ← match (p.flag? "root-vk").map (·.as! String) with - | some path => IO.FS.readBinFile path - | none => pure backend.system.vkBytes - let (root, layout) ← match (p.flag? "fixed-root").map (·.as! String) with - | some path => - let layout ← IO.FS.readFile path - IO.eprintln s!"Normalizing aggregate root {addr} to {path}." - pure (← Compression.normalize backend wrapper layout, layout) - | none => pure (wrapper.proof, "") - IO.eprintln s!"Compressing aggregate root {addr} with {setup}." - let proof ← Compression.compress vk root (Ix.Claim.ser wrapper.claim) statement - layout out (← Compression.cacheDir).toString - (p.hasFlag "dev-srs") height.toUInt32 terminal - let compressed : Ixon.Proof := { claim := wrapper.claim, proof } - let result ← StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) + let result ← compressRoot addr { + terminal + devSrs := p.hasFlag "dev-srs" + logHeight := ((p.flag? "log-height").map (·.as! Nat)).getD 24 + out := (p.flag? "out").map (·.as! String) + rootVk := (p.flag? "root-vk").map (·.as! String) + fixedRoot := (p.flag? "fixed-root").map (·.as! String) } IO.println (toString result) return 0 catch e => @@ -57,6 +94,7 @@ def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do return 1 end Ix.Cli.CompressCmd + open Ix.Cli.CompressCmd in def compressCmd : Cli.Cmd := `[Cli| compress VIA runCompressCmd; @@ -65,12 +103,17 @@ def compressCmd : Cli.Cmd := `[Cli| "out" : String; "Empty output directory for the stage proofs; defaults to a new directory under ~/.ix/compression." "log-height" : Nat; "First KZG partition height: 24 (default) or 25." "root-vk" : String; "Trusted root verifier key; omit to use the current aggregate backend." - "fixed-root" : String; "Normalize the root to this layout (from `ix compression layout`) first, so the verifier does not depend on the root's shape." + "fixed-root" : String; "Override the bundled release normalization layout with a layout from `ix compress layout`." "dev-srs"; "Use known-trapdoor development parameters; results are not production-secure." "plonk"; "Add a terminal PLONK proof using the compiled curve and selected setup." "groth16"; "Add a terminal BN254 Groth16 proof using prepared, independently admitted circuit keys." "dev-groth16"; "Allow development Groth16 keys; inner KZG still requires admitted Ignition parameters." ARGS: proof : String; "Address of an aggregate Ixon.Proof in the ix store." + + SUBCOMMANDS: + Ix.Cli.CompressionCmd.layoutCmd; + Ix.Cli.CompressionCmd.normalizeCmd; + Ix.Cli.CompressionCmd.fetchParamsCmd ] end diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 5a45cde76..4976b162d 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -25,6 +25,10 @@ preprocessing and development SRS caches under `cacheRoot`. A nonempty opaque compress (vk proof claim statement : @& ByteArray) (fixedRoot dir cacheRoot : @& String) (devSrs : Bool) (logHeight : UInt32) (terminal : UInt8) : IO ByteArray +/-- The versioned normalization layout for the aggregation backend's key. -/ +@[extern "rs_compression_release_root_layout"] +opaque releaseRootLayout (vk : @& ByteArray) : IO String + /-- Per-circuit rows of one root wrap execution of `proof`, as a JSON array. -/ @[extern "rs_compression_root_rows"] opaque rootRows (system : @& Aiur.AiurSystem) (aggrIdx : @& Nat) @@ -52,14 +56,18 @@ def normalize (backend : Aggr.VerificationBackend) (wrapper : Ixon.Proof) normalizeRoot backend.system backend.aggrIdx backend.allowed backend.ixvmVk (Ix.Claim.ser wrapper.claim) wrapper.proof layout -/-- Verify with the verifying key compiled in under the packet's id, whose pinned -aggregation program fixes the statement for `claim`, and return the setup -class: `"ignition"` or `"development"`. Development proofs need -`devSrs`. +/-- Verify a terminal proof with an independently configured verifier catalog and +return its setup class. Development proofs need `devSrs`. Recursive KZG proofs +require explicit pinned-key verification and are unsupported by this entry point. Needs neither the aggregation backend nor a GPU. -/ @[extern "rs_compression_verify"] opaque verify (proof claim : @& ByteArray) (devSrs : Bool) : IO String +/-- Return the GPU memory this process's Aiur prover retains between proofs to +the driver. The compression worker is another process and cannot reuse it. -/ +@[extern "rs_compression_release_gpu_memory"] +opaque releaseGpuMemory : IO Unit + /-- Caches shared by every compression run: fixed preprocessing keyed by the executable and verifying key, and development SRS files. -/ def cacheDir : IO System.FilePath := do diff --git a/Ix/Cli/CompressionCmd.lean b/Ix/Cli/CompressionCmd.lean index 5c3dba8c0..e5cfd779e 100644 --- a/Ix/Cli/CompressionCmd.lean +++ b/Ix/Cli/CompressionCmd.lean @@ -1,9 +1,10 @@ /- - `ix compression layout|normalize`: fixed-root normalization of aggregate - roots. `layout` executes a root wrap of each given aggregate proof and sizes - one fixed layout that holds all of them; `normalize` proves a root's wrap in - that layout and writes the FRI worker's root input. Every root normalized to - one layout compresses under the same FRI verifier. + `ix compress layout|normalize|fetch-params`. `layout` executes a root wrap + of each given aggregate proof and sizes one fixed layout that holds all of + them; `normalize` proves a root's wrap in that layout and writes the FRI + worker's root input. Every root normalized to one layout compresses under + the same FRI verifier. `fetch-params` installs the pinned Ignition proving + parameters. -/ module public import Cli @@ -48,7 +49,7 @@ def runLayout (p : Cli.Parsed) : IO UInt32 := do IO.println out return 0 catch e => - IO.eprintln s!"compression layout: {e}" + IO.eprintln s!"compress layout: {e}" return 1 def runNormalize (p : Cli.Parsed) : IO UInt32 := do @@ -66,7 +67,7 @@ def runNormalize (p : Cli.Parsed) : IO UInt32 := do IO.println out return 0 catch e => - IO.eprintln s!"compression normalize: {e}" + IO.eprintln s!"compress normalize: {e}" return 1 def layoutCmd : Cli.Cmd := `[Cli| @@ -87,24 +88,62 @@ def normalizeCmd : Cli.Cmd := `[Cli| ARGS: proof : String; "Address of an aggregate Ixon.Proof in the ix store." - layout : String; "Layout JSON from `ix compression layout`." + layout : String; "Layout JSON from `ix compress layout`." out : String; "Empty output directory for root-vk.bin, root-proof.bin, root-claims.bin and fixed-root.json." ] -def runCompression (p : Cli.Parsed) : IO UInt32 := do - p.printHelp - return 0 +/-- Download `url` to `path` through a `.part` file, resuming a partial one. -/ +private def download (url : String) (path : System.FilePath) : IO Unit := do + let part := path.toString ++ ".part" + let status ← (← IO.Process.spawn { + cmd := "curl" + args := #["-fL", "--retry", "5", "--retry-all-errors", "-C", "-", + "--progress-bar", "-o", part, url] + stdin := .null, stdout := .inherit, stderr := .inherit + }).wait + if status != 0 then + throw <| IO.userError s!"curl exited with {status} downloading {url}" + IO.FS.rename part path -end Ix.Cli.CompressionCmd +/-- The published Ignition release: its directory name under the parameters +directory, base URL and files. The pins that authenticate the files live with +the setup policy in `ix-proof-compression`; this mirrors only their names. -/ +private def ignitionRelease := "bn254-ignition-main-197" +private def ignitionUrl := + "https://argument-kzg-params-063002298335-us-east-1-an.s3.us-east-1.amazonaws.com/bn254/ignition/main-197" +private def ignitionFiles := #["admission.json", "ix-prefix-26.bin"] + +/-- `IX_PARAMS_DIR`, else `~/.ix/compression/params`. -/ +private def parametersDir : IO System.FilePath := do + if let some dir ← IO.getEnv "IX_PARAMS_DIR" then return dir + return (← StoreIO.toIO Store.getHomeDir) / ".ix/compression/params" -open Ix.Cli.CompressionCmd in -def compressionCmd : Cli.Cmd := `[Cli| - compression VIA runCompression; - "Fixed-root normalization of aggregate roots for compression" +def runFetchParams (_ : Cli.Parsed) : IO UInt32 := do + try + let target := (← parametersDir) / ignitionRelease + IO.FS.createDirAll target + for file in ignitionFiles do + let path := target / file + if ← path.pathExists then + IO.eprintln s!"{path} exists; skipping download." + else + IO.eprintln s!"Downloading {ignitionUrl}/{file}." + download s!"{ignitionUrl}/{file}" path + if (← IO.getEnv "IX_KZG_IGNITION_CACHE").isSome then + IO.eprintln "IX_KZG_IGNITION_* is set and takes precedence over the downloaded release." + IO.eprintln s!"Authenticating the configured parameters." + IO.eprintln s!"Using {← Compression.describeSetup false}." + IO.println target + return 0 + catch e => + IO.eprintln s!"compress fetch-params: {e}" + return 1 - SUBCOMMANDS: - layoutCmd; - normalizeCmd +def fetchParamsCmd : Cli.Cmd := `[Cli| + "fetch-params" VIA runFetchParams; + "Download the pinned BN254 Ignition proving parameters (4.3 GB, public) into IX_PARAMS_DIR (default ~/.ix/compression/params) and authenticate them." ] +end Ix.Cli.CompressionCmd + end diff --git a/Ix/Cli/ProveCmd.lean b/Ix/Cli/ProveCmd.lean index ec8a651ae..f9c2126b4 100644 --- a/Ix/Cli/ProveCmd.lean +++ b/Ix/Cli/ProveCmd.lean @@ -6,6 +6,8 @@ ix prove --ixe arena.ixe Foo.bar # from .ixe, named target ix prove --ixe arena.ixe # iterate every named const ix prove --ixe arena.ixe --claim # against a persisted claim + ix prove --ixe env.ixe --ixes env.ixes # whole env, aggregated + ix prove --ixe env.ixe --ixes env.ixes --compress plonk Each invocation runs the same `verify_claim` Aiur witness that `ix check` does, then drives Aiur's `prove` over it and persists @@ -33,6 +35,7 @@ public import Ix.Claim public import Ix.Shard.Check public import Ix.Shard.Environment public import Ix.Cli.ShardProofIndex +public import Ix.Cli.CompressCmd public import Ix.Common public import Ix.IxVM public import Ix.IxVM.Toplevel @@ -248,6 +251,32 @@ def reportPartition (proven : Array (Array Address × Nat)) (planned : Nat) : IO ({proven.size - planned} from splits) — re-shard with this partition to \ skip the splits next run" +/-- The aggregate root of a one-shard manifest: the shard's proof, wrapped +into `ix_aggr` until it is a single trace shard. -/ +def proveOneShardRoot (ixe manifest : String) (ixvmSystem : Aiur.AiurSystem) + (ixvmCompiled : Aiur.CompiledToplevel) (aggrSystem : Aiur.AiurSystem) + (envHandle : Aiur.EnvHandle) (verifyIdx aggrIdx maxRamBytes : Nat) + (retention : Aiur.AiurSystem.ShardRetention) (cacheFriBytes : ByteArray) : + IO (Except String String) := do + let .ok (ixonEnv, shards) ← Ix.Shard.loadEnvAndShards manifest ixe + | return .error s!"{manifest}: cannot load the shard" + let some k := shards.findIdx? (!·.isEmpty) | return .error "the manifest has no shard" + let owned := (Ix.Shard.ownedConstsPer ixonEnv shards)[k]! + let index ← Ix.Cli.ShardProofIndex.indexDir + match ← proveBlocksWithinBudget envHandle ixonEnv ixvmSystem verifyIdx maxRamBytes + false true retention ixvmCompiled (some index) true s!"shard {k}" shards[k]! owned with + | .error e => return .error e + | .ok _ => pure () + let .ok (claim, _) := IxVM.ClaimHarness.shardCheckEnvClaimTrees ixonEnv owned + | return .error s!"shard {k}: cannot rebuild its claim" + let some proof ← Ix.Cli.ShardProofIndex.verifiedProof ixvmSystem ixvmCompiled index claim + | return .error s!"shard {k}: its proof is not in the shard-proof index" + let ramBudget ← if maxRamBytes == 0 then Aggr.defaultAggregateRamBudgetBytes else pure maxRamBytes + IO.lazyPure fun _ => + Aiur.AiurSystem.aggregateStage2 ixvmSystem aggrSystem envHandle manifest + (toString proof) verifyIdx aggrIdx 0 ramBudget Aggr.defaultStructuralAbove 0 + false false cacheFriBytes true true true 0 true 1 false 0 + def runProveCmd (p : Cli.Parsed) : IO UInt32 := do -- Streamed `[texray] : ── RAM Δ/peak` lines on stderr as -- each `aiur/` / `stark/` span closes: the per-phase wall + RSS @@ -291,18 +320,41 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do let ixePath : Option String := (p.flag? "ixe").map (·.as! String) let claimHex : Option String := (p.flag? "claim").map (·.as! String) let names := (p.variableArgsAs! String).toList - -- N GPUs: one child process per device over its share of the Stage 2 - -- plan, then the final joins (see `Ix.Cli.Lanes`). Dispatched before - -- any prover system is built, so this process opens no device. - if let some lanes := (p.flag? "lanes").map (·.as! Nat) then - let some ixe := ixePath | IO.eprintln "--lanes requires --ixe"; return 1 - let some manifest := (p.flag? "ixes").map (·.as! String) - | IO.eprintln "--lanes requires --ixes"; return 1 - if !traceShards then - IO.eprintln "--lanes requires --trace-shards"; return 1 - if p.hasFlag "shards" || p.hasFlag "shard" || p.hasFlag "distributed" || execOnly then - IO.eprintln "--lanes cannot be combined with --shard, --shards, --distributed or --exec-only" - return 1 + -- A whole environment (`--ixe` and `--ixes`, no leaf selection) is proven + -- and aggregated into one root on every visible GPU. `--shards-only` and the + -- leaf, distributed and audit modes below prove shard claims instead. + let aggregate := ixePath.isSome && p.hasFlag "ixes" && !p.hasFlag "shards-only" + && !p.hasFlag "shard" && !p.hasFlag "shards" && !p.hasFlag "distributed" && !execOnly + if p.hasFlag "gpus" && !aggregate then + IO.eprintln "--gpus applies only to aggregating a whole environment (--ixe and --ixes)" + return 1 + -- `--compress` continues from the aggregate root, so its parameters are + -- checked before any proving starts. + let compress? ← match (p.flag? "compress").map (·.as! String) with + | none => + if p.hasFlag "fixed-root" || p.hasFlag "dev-srs" then + IO.eprintln "--fixed-root and --dev-srs require --compress"; return 1 + pure none + | some output => + if !aggregate then + IO.eprintln "--compress requires aggregating a whole environment (--ixe and --ixes)"; return 1 + match Ix.Cli.CompressCmd.terminalOf output with + | .error e => IO.eprintln s!"--compress: {e}"; return 1 + | .ok terminal => + let opts : Ix.Cli.CompressCmd.Options := { + terminal + devSrs := p.hasFlag "dev-srs" + fixedRoot := (p.flag? "fixed-root").map (·.as! String) } + try + IO.eprintln s!"Compression will use {← Ix.Cli.CompressCmd.preflight opts}." + pure (some opts) + catch e => IO.eprintln s!"--compress: {e}"; return 1 + -- One prover per GPU over its share of the Stage 2 plan, then the final + -- joins. Dispatched before any prover system is built, so this process + -- opens no device until compression. + if let (true, some ixe, some manifest) := (aggregate, ixePath, (p.flag? "ixes").map (·.as! String)) then + -- 0 proves on every visible GPU. + let gpus := ((p.flag? "gpus").map (·.as! Nat)).getD 0 let envHandle ← match Aiur.EnvHandle.fromIxe ixe with | .error e => IO.eprintln s!"EnvHandle.fromIxe {ixe}: {e}"; return 1 | .ok h => pure h @@ -325,14 +377,30 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do | IO.eprintln "compiled toplevel has no `verify_claim` entry"; return 1 let some aggrIdx := aggrCompiled.getFuncIdx `ix_aggr | IO.eprintln "compiled ixAggr has no `ix_aggr` entry"; return 1 - let result ← IO.lazyPure fun _ => - Aiur.AiurSystem.proveLanes ixvmSystem aggrSystem envHandle manifest - verifyIdx aggrIdx lanes maxRamBytes - (((p.flag? "exec-jobs").map (·.as! Nat)).getD 0) 0 rp.cacheFriBytes - (outIxes.getD "") + let nonempty ← match Ix.Shard.parseIxesAllShards (← IO.FS.readBinFile manifest) with + | .error e => IO.eprintln s!"{manifest}: {e}"; return 1 + | .ok shards => pure (shards.filter (!·.isEmpty)).size + let result ← if nonempty == 1 then + -- One shard has no join for the GPU scheduler to run: prove it, then + -- wrap it into the aggregate root as `ix aggregate --wrap-root` does. + proveOneShardRoot ixe manifest ixvmSystem ixvmCompiled aggrSystem envHandle + verifyIdx aggrIdx maxRamBytes retention rp.cacheFriBytes + else IO.lazyPure fun _ => + (Aiur.AiurSystem.proveLanes ixvmSystem aggrSystem envHandle manifest + verifyIdx aggrIdx gpus maxRamBytes + (((p.flag? "exec-jobs").map (·.as! Nat)).getD 0) 0 rp.cacheFriBytes + (outIxes.getD "")) match result with - | .error e => IO.eprintln s!"[lanes] {e}"; return 1 - | .ok root => IO.println root; return 0 + | .error e => IO.eprintln s!"[prove] {e}"; return 1 + | .ok root => + IO.println root + let some opts := compress? | return 0 + let some addr := Address.fromString root.trimAscii.toString + | IO.eprintln s!"--compress: unexpected root address {root}"; return 1 + try + IO.println (toString (← Ix.Cli.CompressCmd.compressRoot addr opts)) + return 0 + catch e => IO.eprintln s!"compress: {e}"; return 1 let toplevel ← match IxVM.ixVM with | .error e => IO.eprintln s!"toplevel merging failed: {e}"; return 1 | .ok t => pure t @@ -530,20 +598,24 @@ def proveCmd : Cli.Cmd := `[Cli| "texray"; "Stream per-phase `[texray]` timing/RSS lines (execute, witness, STARK stages) to stderr as each span closes." "ixe" : String; "Path to a serialized `.ixe` env. When set, the binary reads the env from disk instead of using the compiled-in Lean env." "claim" : String; "32-byte hex address of a persisted `Ix.Claim` in `~/.ix/store/`. When set, proves the persisted claim against the `--ixe` env (single proof, skips per-const iteration)." - "ixes" : String; "Path to a `.ixes` shard manifest (with --ixe). With --shard K: prove shard K. Without --shard: prove every shard in the partition." + "ixes" : String; "Path to a `.ixes` shard manifest (with --ixe). By default, prove every shard and aggregate them into one root, printing its address. With --shard K: prove shard K. With --shards-only: prove every shard without aggregating." "shard" : Nat; "0-based shard index K (with --ixes and --ixe): prove that one shard's CheckEnv claim." "shards" : String; "With --ixes and no --shard: prove only these leaves — `K`, `a-b`, or a comma list of those — in one process (one env load); every other leaf is carried over unchanged by --out-ixes." "out-ixes" : String; "Write the partition this run actually proved — splits included — as a `.ixes` manifest to this path: the manifest `ix verify --ixes` checks these proofs against, and the one the next run of this env should start from. Skipped if any shard failed." "exec-only"; "Execute each shard and measure its projected prover peak, splitting over-budget shards as usual, but never start a STARK. The cheap way to audit a partition's split behavior at scale." - "trace-shards"; "Prove an over-budget shard as a batch of trace shards that each fit --max-ram, from one execution, instead of cutting it into parts; a shard no trace-shard count can fit stops the run rather than being cut. With --exec-only, reports the planned shard count and the heaviest shard's projected peak without proving." + "trace-shards"; "With --shards-only, --shard or --shards (always on when aggregating): prove an over-budget shard as a batch of trace shards that each fit --max-ram, from one execution, instead of cutting it into parts; a shard no trace-shard count can fit stops the run rather than being cut. With --exec-only, reports the planned shard count and the heaviest shard's projected peak without proving." "retention" : String; "With --trace-shards: what the batch keeps between its two rounds — `retain` (every shard's stage 1, nothing recomputed), `regenerate` (headers only; each shard rebuilt for round two), or `auto` (default: retain when the RAM model says the retained batch fits --max-ram). Fixing it lets one plan be measured under both policies." "distributed"; "With --ixes and no --shard: prove the WHOLE environment as one `CheckEnv` claim, with one worker record per chunk — each shard of the manifest is one worker's chunk, the constants it owns — executing in parallel (calls into other workers' constants cross records through the lookup argument) and every record's trace shards in one batch. Writes one proof; no manifest is refined." "cells" : Nat; "With --distributed: per-shard committed-cell budget each worker record is planned to (e.g. 1800000000 for a 96 GB device). 0 (default) proves each record as one shard." - "lanes" : Nat; "With --ixes and --trace-shards: prove on N GPUs using a shared execution pool. --max-ram and --exec-jobs are per-GPU values. Environment claims exceeding the 128 GiB record ceiling (AIUR_RECORD_MAX_BYTES overrides it) are bisected automatically. Rerunning resumes the checkpointed partition and completed proofs. --out-ixes writes the final partition. Prints the verified root address on stdout." - "exec-jobs" : Nat; "With --ixes and --trace-shards: how many shards execute at once ahead of the prover (default 0: one per core, or each worker's share of the cores with --lanes); peak host memory is the prover's budget plus the records in flight. With --distributed: how many workers execute at once (default 0: one per core). Workers are proven in an order that lets each commit as soon as its callers have executed; a committed record is dropped and re-executed for its second round." + "gpus" : Nat; "With --ixe and --ixes: the number of GPUs to prove and aggregate on (default: every visible GPU). --max-ram and --exec-jobs are per-GPU values. Environment claims exceeding the 128 GiB record ceiling (AIUR_RECORD_MAX_BYTES overrides it) are bisected automatically. Rerunning resumes the checkpointed partition and completed proofs. --out-ixes writes the final partition." + "shards-only"; "With --ixe and --ixes: prove each shard's claim and stop, instead of aggregating the environment into one root." + "exec-jobs" : Nat; "With --ixes and --trace-shards: how many shards execute at once ahead of the prover (default 0: one per core, or each GPU's share of the cores when aggregating); peak host memory is the prover's budget plus the records in flight. With --distributed: how many workers execute at once (default 0: one per core). Workers are proven in an order that lets each commit as soon as its callers have executed; a committed record is dropped and re-executed for its second round." "plan-only"; "With --distributed: report the static caller graph, the commit order and the largest group of mutually calling workers (how many records the first round holds at once) for this manifest, and stop before executing anything. The way to compare layouts without a run." "skip-proven"; "With --ixes: before executing a leaf, look its claim up in the shard-proof index (`~/.ix/cache/shard-proofs/`); a recorded proof that decodes, bundles exactly that claim and verifies natively is reused — its address printed, nothing executed — instead of proving again. How a partially proved partition resumes after a refinement." "no-index"; "Neither read nor write the shard-proof index (every persisted proof is normally recorded there under its claim digest)." + "compress" : String; "With --ixe and --ixes: compress the aggregate root to `kzg`, `plonk` or `groth16` (`dev-groth16` allows development Groth16 keys) and print the compressed proof's address after the root's. The setup is checked before proving." + "fixed-root" : String; "With --compress: override the bundled release normalization layout with a layout from `ix compress layout`." + "dev-srs"; "With --compress: use known-trapdoor development parameters; results are not production-secure." "max-ram" : Nat; "Per-shard prover-RAM budget, GiB — normally the same value the partition was sized with (`ix shard --max-ram`). Each shard is executed, its projected prover peak measured on the resulting record, and the proof attempted only if it fits; an over-budget shard is cut into the part count the peak model projects will fit, and each part re-gated, instead of being taken into the FFT phases that would exhaust the box. Omit to detect: 85% of the machine's available RAM." ARGS: diff --git a/Ix/Cli/ShardCmd.lean b/Ix/Cli/ShardCmd.lean index 02c2929df..f7f5c4bd7 100644 --- a/Ix/Cli/ShardCmd.lean +++ b/Ix/Cli/ShardCmd.lean @@ -211,7 +211,7 @@ def shardCmd : Cli.Cmd := `[Cli| shards : Nat; "Fixed number of shards N (static strategy; overrides --max-ram sizing and the profiled default budget sizing)" "max-cycles" : Nat; "Per-shard guest-cycle budget (profiled strategy only)" "max-ram" : Nat; "Per-shard prover-RAM budget, GiB. Static strategy: seed the shard count from the `.ixe` block-shape score (serialized block size + a superlinear large-body term). A CUDA build seeds for the trace-shard prover by serialized block bytes, against one execution's share of the record budget (this budget less the prover's working set at AIUR_TRACE_SHARD_MAX_CELLS, over --exec-jobs + 2), anchored at the 78-shard Mathlib cut proven on four GPUs (execution records are close to linear in owned bytes); a CPU build seeds with the CPU prover's fitted scale and budget exponents anchored at Mathlib. The record cap or the execution gate corrects every boundary. Profiled strategy: budget from measured op counters (default: detected system RAM)." - "exec-jobs" : Nat; "CUDA build, static strategy with --max-ram: the claim executions ahead of each prover the run will use (`ix prove --lanes --exec-jobs`), which sets the record share the seed fits (default 3, the calibration's)." + "exec-jobs" : Nat; "CUDA build, static strategy with --max-ram: the claim executions ahead of each prover the run will use (`ix prove --exec-jobs`), which sets the record share the seed fits (default 3, the calibration's)." balance : Nat; "Per-bisection balance tolerance, percent (default 5)" ordered; "Static strategy: lay the shards out as contiguous ranges of a dependency order instead of a min-cut, shard 0 at the top, so each shard's reference closure lies in its own shard and later ones. The layout `ix prove --distributed` wants, where each shard is one worker's chunk: the records then commit one at a time in chunk order." parallelism : Nat; "Provers assumed for the prove-time estimate (profiled strategy only; default 1 = sequential)" diff --git a/Main.lean b/Main.lean index ddc36e153..22d7f26f0 100644 --- a/Main.lean +++ b/Main.lean @@ -19,7 +19,6 @@ import Ix.Cli.NameOfCmd import Ix.Cli.PackCmd import Ix.Cli.ProfileCmd import Ix.Cli.CompressCmd -import Ix.Cli.CompressionCmd import Ix.Cli.ProveCmd import Ix.Cli.ShardCmd import Ix.Cli.TreeCmd @@ -55,7 +54,6 @@ def ixCmd : Cli.Cmd := `[Cli| profileCmd; proveCmd; compressCmd; - compressionCmd; shardCmd; codegenCmd; verifyCmd; diff --git a/README.md b/README.md index d3d66292f..54e927538 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ The backend requires an NVIDIA GPU and a CUDA toolkit with `nvcc`; build and architecture controls are documented in the multi-stark repository. It keeps the Goldilocks/BLAKE3 protocol and proof format unchanged, and GPU proofs remain verifiable by the CPU implementation. Proving a whole environment across -several GPUs (`ix prove --lanes`), generating traces on the device, and +several GPUs (`ix prove --gpus`), generating traces on the device, and benchmarking such runs are covered in [docs/aiur-gpu-proving.md](docs/aiur-gpu-proving.md). Dated hardware measurements belong in [BENCHMARKS.md](BENCHMARKS.md) and diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index 02667cefb..a1a2d7587 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -71,8 +71,8 @@ public def run : IO Unit := do let unknownKey := ByteArray.mk #[73, 88, 66, 78, 0, 0, 0, 1] ++ ByteArray.mk (Array.replicate 32 0) let unknownAddr ← putProof home (Ixon.Proof.ser { claim, proof := unknownKey }) - rejects home #["verify", toString unknownAddr] "not bundled" - rejects home #["verify", toString unknownAddr, "--dev-srs"] "not bundled" + rejects home #["verify", toString unknownAddr] "bundled recursive KZG verification is unsupported" + rejects home #["verify", toString unknownAddr, "--dev-srs"] "bundled recursive KZG verification is unsupported" IO.println "compression CLI: BN254 setup and proof rejection checks passed" finally IO.FS.removeDirAll home diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index f11bedb49..961722717 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -49,10 +49,11 @@ serde = { version = "1.0.219", features = ["derive"], optional = true } [features] default = [] parallel = ["aiur/parallel", "ix-proof-compression/parallel"] -# The KZG prover's CUDA code is compiled into the ix-compression-worker -# executable, not into the library the Lean executable links. -cuda = ["aiur/cuda"] +cuda = ["aiur/cuda", "ix-proof-compression/cuda"] cuda-trace-codegen = ["cuda", "aiur/cuda-trace-codegen"] +# Terminal proof systems, which link the gnark Go archive. +plonk = ["ix-proof-compression/plonk"] +groth16 = ["ix-proof-compression/groth16"] test-ffi = [] net = ["bytes", "tokio", "iroh", "iroh-base", "n0-error", "getrandom", "bincode", "serde"] diff --git a/crates/ffi/src/aiur/aggregate/ffi.rs b/crates/ffi/src/aiur/aggregate/ffi.rs index 0459d7803..c30617da1 100644 --- a/crates/ffi/src/aiur/aggregate/ffi.rs +++ b/crates/ffi/src/aiur/aggregate/ffi.rs @@ -62,7 +62,7 @@ extern "C" fn rs_aiur_aggregate_expected( } /// `AiurSystem.aggregateSubtreePlan`: the plan cut into `subtrees` for -/// `ix prove --lanes`. Rows in post-order: `[0, slot, ids…]` for a subtree +/// `ix prove --ixe --ixes`. Rows in post-order: `[0, slot, ids…]` for a subtree /// (its root slot, then the manifest ids of the shards under it, sorted; /// one id when the subtree is a raw leaf) and `[1, slot, left, right]` for /// each join above the frontier, the root last. A tree too small to cut diff --git a/crates/ffi/src/aiur/aggregate/lanes.rs b/crates/ffi/src/aiur/aggregate/lanes.rs index 936f2af18..1ab0d6224 100644 --- a/crates/ffi/src/aiur/aggregate/lanes.rs +++ b/crates/ffi/src/aiur/aggregate/lanes.rs @@ -1215,7 +1215,21 @@ fn run_manifest( outcome } -/// `AiurSystem.proveLanes`: the whole multi-device run. +/// The GPUs this process may use: the entries of `CUDA_VISIBLE_DEVICES` when +/// it is set, else the devices the NVIDIA driver lists. A build without CUDA +/// proves on one CPU worker. +fn visible_gpus() -> usize { + if !cfg!(feature = "cuda") { + return 1; + } + if let Ok(devices) = std::env::var("CUDA_VISIBLE_DEVICES") { + return devices.split(',').filter(|d| !d.trim().is_empty()).count(); + } + fs::read_dir("/proc/driver/nvidia/gpus").map_or(0, |gpus| gpus.count()) +} + +/// `AiurSystem.proveLanes`: the whole multi-device run; 0 lanes uses every +/// visible GPU. #[unsafe(no_mangle)] extern "C" fn rs_aiur_prove_lanes( ixvm_system: LeanExternal>, @@ -1231,8 +1245,12 @@ extern "C" fn rs_aiur_prove_lanes( cache_fri_bytes: LeanByteArray>, out_manifest: LeanString>, ) -> LeanExcept { + let lanes = match lean_unbox_nat_as_usize(lanes.inner()) { + 0 => visible_gpus(), + n => n, + }; let cfg = Config { - lanes: lean_unbox_nat_as_usize(lanes.inner()), + lanes, max_ram_bytes: lean_unbox_nat_as_usize(max_ram_bytes.inner()), exec_jobs: lean_unbox_nat_as_usize(exec_jobs.inner()), structural_above: lean_unbox_nat_as_usize(structural_above.inner()), @@ -1241,7 +1259,7 @@ extern "C" fn rs_aiur_prove_lanes( .then(|| PathBuf::from(out_manifest.as_str())), }; if cfg.lanes == 0 { - return LeanExcept::error_string("at least one lane is required"); + return LeanExcept::error_string("no visible GPU to prove on"); } let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { run( diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index 04692bfec..88d9de681 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -32,18 +32,27 @@ extern "C" fn rs_compression_run( Ok(system) => system, Err(error) => return LeanIOResult::error_string(error), }; - match pipeline::compress_with_terminal( - vk.as_bytes(), - proof.as_bytes(), - claim.as_bytes(), - statement.as_bytes(), - optional(&fixed_root).as_deref(), - std::path::Path::new(&dir), + let fixed_root = optional(&fixed_root); + let result = pipeline::CompressConfig::from_env( std::path::Path::new(&cache_root), dev_srs, log_height, terminal, - ) { + ) + .and_then(|config| { + pipeline::compress( + pipeline::CompressInput { + vk: vk.as_bytes(), + proof: proof.as_bytes(), + claim: claim.as_bytes(), + statement: statement.as_bytes(), + fixed_root: fixed_root.as_deref(), + out: std::path::Path::new(&dir), + }, + &config, + ) + }); + match result { Ok(bytes) => LeanIOResult::ok(LeanByteArray::from_bytes(bytes.as_slice())), Err(e) => LeanIOResult::error_string(&e.to_string()), } @@ -101,6 +110,17 @@ fn root_wrap<'a>( } } +/// The versioned normalization layout for the selected aggregation backend. +#[unsafe(no_mangle)] +extern "C" fn rs_compression_release_root_layout( + vk: LeanByteArray>, +) -> LeanIOResult { + match pipeline::release_root_layout(vk.as_bytes()) { + Ok(layout) => LeanIOResult::ok(LeanString::new(layout)), + Err(error) => LeanIOResult::error_string(&error.to_string()), + } +} + /// Per-circuit rows of one root wrap execution, as a JSON array. #[unsafe(no_mangle)] extern "C" fn rs_compression_root_rows( @@ -173,7 +193,7 @@ extern "C" fn rs_compression_normalize_root( } } -/// Write the FRI worker's root input directory without compressing. +/// Write the FRI stage's root input directory without compressing. #[unsafe(no_mangle)] extern "C" fn rs_compression_write_root( vk: LeanByteArray>, @@ -195,3 +215,12 @@ extern "C" fn rs_compression_write_root( Err(e) => LeanIOResult::error_string(&e.to_string()), } } + +/// Return the GPU memory the Aiur prover's allocation pool retains to the +/// driver, so compression starts with the whole device. A CPU build has none. +#[unsafe(no_mangle)] +extern "C" fn rs_compression_release_gpu_memory() -> LeanIOResult { + #[cfg(feature = "cuda")] + multi_stark::cuda::release_device_memory(); + LeanIOResult::ok(LeanOwned::box_usize(0)) +} diff --git a/crates/ffi/src/kernel.rs b/crates/ffi/src/kernel.rs index 3a1bc4ffd..d22ed1a30 100644 --- a/crates/ffi/src/kernel.rs +++ b/crates/ffi/src/kernel.rs @@ -2461,7 +2461,7 @@ pub extern "C" fn rs_shard_env_static( requested_shards } else if cfg!(feature = "cuda") { // The trace-shard prover: seed against one execution's record share, - // computed as `ix prove --lanes` computes it. + // computed as `ix prove --ixe --ixes` computes it. let cells = crate::aiur::protocol::trace_shard_max_cells(); let budget = usize::try_from(ram_gib << 30).unwrap_or(usize::MAX); let share = diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index 7f6a57599..b807076a5 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -5,10 +5,6 @@ edition.workspace = true license.workspace = true publish = false -[[bin]] -name = "ix-compression-worker" -path = "src/bin/ix_compression_worker.rs" - [dependencies] aiur = { workspace = true } multi-stark = { workspace = true } @@ -52,7 +48,7 @@ rayon = { workspace = true } # C++ linkage. The revision must match multi-stark's Goldilocks pin. [dependencies.sppark] git = "https://github.com/argumentcomputer/sppark" -rev = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8" +rev = "9326ac014ec5415ebc4eab24c1246276714aee57" optional = true features = ["cuda"] diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs index db462d796..877ef12ae 100644 --- a/crates/proof-compression/build.rs +++ b/crates/proof-compression/build.rs @@ -1,10 +1,16 @@ -//! Native proving backends for the compression worker. +//! Native proving backends. //! -//! Without the `cuda` feature KZG builds need -//! neither nvcc nor the CUDA headers and libraries. With it, the selected -//! sppark instantiation and the KZG kernels are compiled into their own -//! archive, isolated from multi-stark's Goldilocks instantiation by the -//! symbol renames in `cuda/kzg_sppark.cuh`. +//! Without the `cuda` feature the crate needs neither nvcc nor the CUDA +//! toolkit. With it, the BN254 KZG kernels and, with `plonk`, gnark's PLONK +//! kernels are linked against one sppark source into `libix_bn254_cuda.so`. +//! The library carries a private C++ runtime, unwinder and CUDA runtime and +//! exports only its C entry points, so it can join a process that links a +//! different C++ runtime (the Lean executable links LLVM's libc++). +//! +//! `IX_RUNTIME_ARTIFACTS` names every shared library an executable built from +//! this crate must ship beside itself, as a JSON array of +//! `{"source": , "path": }`. ICICLE's +//! CUDA backend plugins are loaded at runtime and appear only here. use std::env; use std::path::{Path, PathBuf}; @@ -21,16 +27,28 @@ const SOURCES: &[&str] = &[ "cuda/kzg_transfer.cuh", "cuda/kzg_profile.cuh", "cuda/kzg_runtime.cpp", - "cuda/kzg_sppark.cuh", + "cuda/plonk/plonk.cu", + "cuda/plonk/ix_plonk_bn254.h", ]; +const LIBRARY: &str = "libix_bn254_cuda.so"; + +const EXPORTS: &str = r"{ + global: ix_kzg_*; ix_plonk_bn254_*; + local: *; +};"; + +/// A shared library to install at `lib/` beside the executable. +struct RuntimeArtifact { + source: PathBuf, + path: String, +} + #[cfg(any(feature = "plonk", feature = "groth16"))] #[path = "build/gnark.rs"] mod gnark; fn main() { - #[cfg(any(feature = "plonk", feature = "groth16"))] - gnark::build(); println!("cargo:rerun-if-changed=build.rs"); for source in SOURCES { println!("cargo:rerun-if-changed={source}"); @@ -42,29 +60,70 @@ fn main() { "NVCC_CCBIN", "NVCC_PREPEND_FLAGS", "NVCC_APPEND_FLAGS", + "CC", "IX_KZG_CUDA_ARCHS", "MULTI_STARK_CUDA_ARCHS", ] { println!("cargo:rerun-if-env-changed={variable}"); } + let mut artifacts = Vec::new(); if env::var_os("CARGO_FEATURE_CUDA").is_some() { - println!( - "cargo:rustc-link-arg-bin=ix-compression-worker=-Wl,-rpath,$ORIGIN/../lib" - ); - build_kzg_cuda(); + artifacts.push(RuntimeArtifact { + source: build_bn254_cuda(), + path: LIBRARY.to_owned(), + }); } + // cgo links the PLONK entry points against the library built above. + #[cfg(any(feature = "plonk", feature = "groth16"))] + artifacts.extend(gnark::build()); + println!("cargo:rustc-env=IX_RUNTIME_ARTIFACTS={}", manifest(&artifacts)); } -fn build_kzg_cuda() { +fn manifest(artifacts: &[RuntimeArtifact]) -> String { + let entries: Vec<_> = artifacts + .iter() + .map(|artifact| { + let source = artifact.source.to_str().unwrap_or_else(|| { + panic!("runtime artifact path {:?} is not UTF-8", artifact.source) + }); + format!( + r#"{{"source":{},"path":{}}}"#, + json_string(source), + json_string(&artifact.path) + ) + }) + .collect(); + format!("[{}]", entries.join(",")) +} + +fn json_string(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('"'); + for character in value.chars() { + match character { + '"' => quoted.push_str("\\\""), + '\\' => quoted.push_str("\\\\"), + character if character.is_control() => { + quoted.push_str(&format!("\\u{:04x}", u32::from(character))); + }, + character => quoted.push(character), + } + } + quoted.push('"'); + quoted +} + +/// Builds `libix_bn254_cuda.so` in `OUT_DIR` and links it into this crate. +fn build_bn254_cuda() -> PathBuf { assert_eq!( env::var("CARGO_CFG_TARGET_OS").as_deref(), Ok("linux"), - "the KZG CUDA backend supports Linux targets only" + "the BN254 CUDA backend supports Linux targets only" ); assert_eq!( env::var("CARGO_CFG_TARGET_ARCH").as_deref(), Ok("x86_64"), - "the KZG CUDA backend supports x86_64 targets only" + "the BN254 CUDA backend supports x86_64 targets only" ); let nvcc = nvcc_path(); let architectures = cuda_architectures(&nvcc); @@ -77,7 +136,7 @@ fn build_kzg_cuda() { architectures.join(",") ), ) - .expect("write KZG CUDA architecture list"); + .expect("write BN254 CUDA architecture list"); println!( "cargo:rustc-env=IX_KZG_CUDA_COMPILED_ARCHS={}", architectures.join(",") @@ -93,47 +152,83 @@ fn build_kzg_cuda() { for directory in ["ec", "ff", "msm", "ntt", "polynomial", "util"] { println!("cargo:rerun-if-changed={}", root.join(directory).display()); } + let mut sources = vec!["cuda/kzg.cu", "cuda/kzg_runtime.cpp"]; + if env::var_os("CARGO_FEATURE_PLONK").is_some() { + sources.push("cuda/plonk/plonk.cu"); + } let mut objects = Vec::new(); - for (source, name) in - [("cuda/kzg.cu", "kzg.o"), ("cuda/kzg_runtime.cpp", "kzg_runtime.o")] - { - let object = out.join(name); + for source in sources { + let object = out.join(Path::new(source).with_extension("o").file_name().unwrap()); let mut command = cuda_compile_command(&nvcc, &architectures); command.args([ "-c", "-DFEATURE_BN254", "-D__BLST_PORTABLE__", "-DMAX_LG_DOMAIN_SIZE=28", - "-include", - "cuda/kzg_sppark.cuh", ]); command.arg(format!("-I{}", root.display())); command.arg(format!("-I{}", root.join("util").display())); command.arg(format!("-I{}", blst.display())); command.arg("-include").arg(&architecture_header); - let status = command + // sppark's MSM sort kernel is an ordinary definition in its header; + // kzg.cu supplies the library's only instance. + if source != "cuda/kzg.cu" { + command.arg("-D__MSM_SORT_DONT_IMPLEMENT__"); + } + run(command.arg(source).arg("-o").arg(&object)); + objects.push(object); + } + // The host-side field arithmetic calls blst's portable assembly. + let compiler = env::var_os("CC").unwrap_or_else(|| "cc".into()); + let blst_root = blst.parent().expect("blst sources live under src/"); + for (source, name) in [ + (blst.join("server.c"), "blst_server.o"), + (blst_root.join("build/assembly.S"), "blst_assembly.o"), + ] { + let object = out.join(name); + run(Command::new(&compiler) + .args(["-O3", "-fPIC", "-fvisibility=hidden", "-D__BLST_PORTABLE__"]) + .arg(format!("-I{}", blst.display())) + .arg("-c") .arg(source) .arg("-o") - .arg(&object) - .status() - .unwrap_or_else(|error| { - panic!("failed to execute {nvcc:?}: {error}; install the CUDA toolkit or set NVCC") - }); - assert!(status.success(), "KZG CUDA compilation failed: {source}"); + .arg(&object)); objects.push(object); } - let archive = out.join("libix_kzg_cuda.a"); - let status = Command::new(&nvcc) - .arg("--lib") - .args(&objects) + let exports = out.join("ix_bn254_cuda.map"); + std::fs::write(&exports, EXPORTS).expect("write BN254 CUDA export map"); + let library = out.join(LIBRARY); + run(Command::new(&nvcc) + .args([ + "--shared", + "--cudart=static", + "--compiler-options=-static-libstdc++,-static-libgcc", + ]) + .arg(format!( + "-Xlinker=-z,defs,-soname,{LIBRARY},--version-script,{}", + exports.display() + )) .arg("-o") - .arg(&archive) - .status() - .expect("nvcc archive"); - assert!(status.success(), "KZG CUDA archive failed"); + .arg(&library) + .args(&objects) + .args(["-ldl", "-lrt", "-lpthread"])); println!("cargo:rustc-link-search=native={}", out.display()); - println!("cargo:rustc-link-lib=static=ix_kzg_cuda"); - link_cuda_runtime(&nvcc); + println!("cargo:rustc-link-lib=dylib=ix_bn254_cuda"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", out.display()); + // Tests query the CUDA runtime directly. + for directory in cuda_library_directories(&nvcc) { + if directory.is_dir() { + println!("cargo:rustc-link-search=native={}", directory.display()); + } + } + library +} + +fn run(command: &mut Command) { + let status = command + .status() + .unwrap_or_else(|error| panic!("failed to execute {command:?}: {error}")); + assert!(status.success(), "{command:?} failed with {status}"); } fn cuda_compile_command( @@ -159,19 +254,6 @@ fn cuda_compile_command( command } -fn link_cuda_runtime(nvcc: &std::ffi::OsStr) { - // The standalone worker links the KZG backend with its host runtime. - println!("cargo:rustc-link-lib=static=cudart_static"); - for lib in ["dl", "rt", "pthread", "stdc++"] { - println!("cargo:rustc-link-lib=dylib={lib}"); - } - for directory in cuda_library_directories(nvcc) { - if directory.is_dir() { - println!("cargo:rustc-link-search=native={}", directory.display()); - } - } -} - fn nvcc_path() -> std::ffi::OsString { if let Some(nvcc) = env::var_os("NVCC") { return nvcc; diff --git a/crates/proof-compression/build/gnark.rs b/crates/proof-compression/build/gnark.rs index 8d2420686..060747ba6 100644 --- a/crates/proof-compression/build/gnark.rs +++ b/crates/proof-compression/build/gnark.rs @@ -7,7 +7,7 @@ use std::{ process::Command, }; -pub(super) fn build() { +pub(super) fn build() -> Vec { let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let source = manifest.join("../../tools/plonk-wrapper").canonicalize().unwrap(); @@ -22,17 +22,7 @@ pub(super) fn build() { "CGO_LDFLAGS", "CC", "CXX", - "IX_PLONK_CUDA_LIBRARY", - "IX_PLONK_SPPARK_SOURCE", - "IX_PLONK_BLST_SOURCE", - "IX_PLONK_CUDA_ARCHS", "IX_GROTH16_ICICLE_ROOT", - "NVCC", - "CUDA_HOME", - "CUDA_PATH", - "NVCC_CCBIN", - "NVCC_PREPEND_FLAGS", - "NVCC_APPEND_FLAGS", ] { println!("cargo:rerun-if-env-changed={variable}"); } @@ -110,50 +100,11 @@ pub(super) fn build() { let cuda = env::var_os("CARGO_FEATURE_CUDA").is_some(); let mut tags = Vec::new(); let mut linker_flags = env::var("CGO_LDFLAGS").unwrap_or_default(); + let mut artifacts = Vec::new(); if cuda && env::var_os("CARGO_FEATURE_PLONK").is_some() { assert_eq!(target_os, "linux", "PLONK CUDA requires Linux"); assert_eq!(target_arch, "x86_64", "PLONK CUDA requires x86_64"); - let native = manifest.join("cuda/plonk"); - let library = match env::var_os("IX_PLONK_CUDA_LIBRARY") { - Some(path) => PathBuf::from(path), - None => { - let build = out.join("plonk-native"); - let sppark = env::var_os("IX_PLONK_SPPARK_SOURCE") - .or_else(|| env::var_os("DEP_SPPARK_ROOT")) - .expect("set IX_PLONK_SPPARK_SOURCE or IX_PLONK_CUDA_LIBRARY"); - let blst = env::var_os("IX_PLONK_BLST_SOURCE") - .or_else(|| { - env::var_os("DEP_BLST_C_SRC").and_then(|path| { - PathBuf::from(path).parent().map(|p| p.as_os_str().to_owned()) - }) - }) - .expect("set IX_PLONK_BLST_SOURCE or IX_PLONK_CUDA_LIBRARY"); - let status = Command::new("python3") - .arg(native.join("build.py")) - .arg("--sppark") - .arg(sppark) - .arg("--blst") - .arg(blst) - .arg("--out") - .arg(&build) - .status() - .expect("native PLONK build"); - assert!(status.success(), "native PLONK build failed"); - build.join("libix_plonk_bn254_cuda.so") - }, - } - .canonicalize() - .expect("PLONK CUDA library is missing"); - let directory = library.parent().unwrap(); - let packaged_header = - directory.parent().unwrap().join("include/ix_plonk_bn254.h"); - let header = if packaged_header.is_file() { - packaged_header - } else { - native.join("ix_plonk_bn254.h") - }; - println!("cargo:rerun-if-changed={}", native.display()); - println!("cargo:rerun-if-changed={}", library.display()); + let header = manifest.join("cuda/plonk/ix_plonk_bn254.h"); println!("cargo:rerun-if-changed={}", header.display()); // cgo does not include headers outside its package in its cache key. let mut hash = std::collections::hash_map::DefaultHasher::new(); @@ -166,36 +117,46 @@ pub(super) fn build() { ); tags.push("cuda"); command.env("CGO_CFLAGS", cflags); - linker_flags - .push_str(&format!(" -L{} -lix_plonk_bn254_cuda", directory.display())); - println!("cargo:rustc-link-search=native={}", directory.display()); - println!("cargo:rustc-link-lib=dylib=ix_plonk_bn254_cuda"); - println!("cargo:rustc-link-lib=dylib=stdc++"); - println!( - "cargo:rustc-link-arg-bin=ix-compression-worker=-Wl,-rpath,$ORIGIN/../lib" - ); - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", directory.display()); - // A library outside OUT_DIR needs an explicit path for packaging. - println!("cargo:rustc-env=IX_PLONK_CUDA_LIBRARY={}", library.display()); + // build.rs places libix_bn254_cuda.so in OUT_DIR before cgo links. + linker_flags.push_str(&format!(" -L{}", out.display())); } if cuda && env::var_os("CARGO_FEATURE_GROTH16").is_some() { assert_eq!(target_os, "linux", "Groth16 CUDA requires Linux"); assert_eq!(target_arch, "x86_64", "Groth16 CUDA requires x86_64"); - let root = PathBuf::from( - env::var_os("IX_GROTH16_ICICLE_ROOT") - .expect("set IX_GROTH16_ICICLE_ROOT to the pinned ICICLE installation"), - ) - .canonicalize() - .expect("ICICLE installation is missing"); + let root = env::var_os("IX_GROTH16_ICICLE_ROOT") + .filter(|value| !value.is_empty()) + .map_or_else(|| PathBuf::from("/usr/local"), PathBuf::from); + let root = root.canonicalize().unwrap_or_else(|error| { + panic!( + "ICICLE installation {} is unavailable: {error}; install the pinned BN254 libraries under /usr/local or set IX_GROTH16_ICICLE_ROOT", + root.display() + ) + }); let library = root.join("lib"); - for name in ["icicle_device", "icicle_field_bn254", "icicle_curve_bn254"] { - let file = library.join(format!("lib{name}.so")); - assert!(file.is_file(), "missing ICICLE library {}", file.display()); + for name in [ + "libicicle_device.so", + "libicicle_field_bn254.so", + "libicicle_curve_bn254.so", + "backend/cuda/libicicle_backend_cuda_device.so", + "backend/bn254/cuda/libicicle_backend_cuda_field_bn254.so", + "backend/bn254/cuda/libicicle_backend_cuda_curve_bn254.so", + ] { + let file = library.join(name); + assert!( + file.is_file(), + "missing ICICLE library {}; install the pinned BN254 libraries under /usr/local or set IX_GROTH16_ICICLE_ROOT", + file.display() + ); println!("cargo:rerun-if-changed={}", file.display()); + artifacts.push(super::RuntimeArtifact { + source: file, + path: name.to_owned(), + }); + } + for name in ["icicle_device", "icicle_field_bn254", "icicle_curve_bn254"] { println!("cargo:rustc-link-lib=dylib={name}"); } let backend = library.join("backend"); - assert!(backend.is_dir(), "ICICLE backend directory is missing"); println!("cargo:rerun-if-changed={}", backend.display()); println!("cargo:rustc-link-search=native={}", library.display()); println!("cargo:rustc-link-lib=dylib=stdc++"); @@ -239,4 +200,5 @@ pub(super) fn build() { println!("cargo:rustc-link-lib=dylib=pthread"); println!("cargo:rustc-link-lib=dylib=dl"); } + artifacts } diff --git a/crates/proof-compression/cuda/kzg.cu b/crates/proof-compression/cuda/kzg.cu index 3d7aef83b..f7dea1a0d 100644 --- a/crates/proof-compression/cuda/kzg.cu +++ b/crates/proof-compression/cuda/kzg.cu @@ -370,7 +370,7 @@ extern "C" int ix_kzg_msm_invoke_resident(int device, void* context, #include "kzg_srs.cuh" -extern "C" int ix_kzg_fft_batch(int device, const FftJob* jobs, +IX_KZG_EXPORT int ix_kzg_fft_batch(int device, const FftJob* jobs, size_t job_count, uint32_t lg, bool inverse, const fr_t* shift, size_t slots) { ProfileRange range(inverse ? "kzg/ifft-batch" : "kzg/fft-batch"); diff --git a/crates/proof-compression/cuda/kzg_curve.cuh b/crates/proof-compression/cuda/kzg_curve.cuh index 7c4f35682..6d11abc3f 100644 --- a/crates/proof-compression/cuda/kzg_curve.cuh +++ b/crates/proof-compression/cuda/kzg_curve.cuh @@ -5,6 +5,11 @@ #include #include + +// Entry points whose parameters are ABI structs from an anonymous namespace +// would otherwise inherit hidden visibility from those types. +#define IX_KZG_EXPORT extern "C" __attribute__((visibility("default"))) + constexpr size_t KZG_FQ_LIMBS = 4; static_assert(MAX_LG_DOMAIN_SIZE == 28); diff --git a/crates/proof-compression/cuda/kzg_lookup.cuh b/crates/proof-compression/cuda/kzg_lookup.cuh index df41fcec0..12a5acc42 100644 --- a/crates/proof-compression/cuda/kzg_lookup.cuh +++ b/crates/proof-compression/cuda/kzg_lookup.cuh @@ -130,7 +130,7 @@ __global__ void lookup_exclusive_columns(fr_t* columns, const fr_t* inclusive, } // namespace -extern "C" int ix_kzg_lookup( +IX_KZG_EXPORT int ix_kzg_lookup( int device, const LookupParameters* parameters, const QuotientColumn* columns, size_t column_count, size_t evaluation_columns, const QuotientInstruction* nodes, size_t node_count, diff --git a/crates/proof-compression/cuda/kzg_passes.cuh b/crates/proof-compression/cuda/kzg_passes.cuh index 44fe9c010..64920b7b8 100644 --- a/crates/proof-compression/cuda/kzg_passes.cuh +++ b/crates/proof-compression/cuda/kzg_passes.cuh @@ -165,7 +165,7 @@ __global__ __launch_bounds__(128) void quotient_pass_sweep( } // namespace -extern "C" int ix_kzg_lookup_passes( +IX_KZG_EXPORT int ix_kzg_lookup_passes( int device, const LookupParameters* parameters, const ConstraintPass* passes, size_t pass_count, fr_t* const* outputs, fr_t* total ) { @@ -235,7 +235,7 @@ extern "C" int ix_kzg_lookup_passes( catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } } -extern "C" int ix_kzg_quotient_passes( +IX_KZG_EXPORT int ix_kzg_quotient_passes( int device, const QuotientParameters* parameters, const ConstraintPass* passes, size_t pass_count, const fr_t* publics, size_t public_count, const fr_t* shifts, const fr_t* inverse_vanishing, fr_t* const* outputs diff --git a/crates/proof-compression/cuda/kzg_quotient.cuh b/crates/proof-compression/cuda/kzg_quotient.cuh index 41507e5e4..098ef42f8 100644 --- a/crates/proof-compression/cuda/kzg_quotient.cuh +++ b/crates/proof-compression/cuda/kzg_quotient.cuh @@ -167,7 +167,7 @@ __global__ __launch_bounds__(128) void quotient_sweep( } // namespace -extern "C" int ix_kzg_quotient( +IX_KZG_EXPORT int ix_kzg_quotient( int device, const QuotientParameters* parameters, const QuotientColumn* columns, size_t column_count, size_t evaluation_columns, const QuotientInstruction* nodes, size_t node_count, diff --git a/crates/proof-compression/cuda/kzg_sppark.cuh b/crates/proof-compression/cuda/kzg_sppark.cuh deleted file mode 100644 index ef7abe1fc..000000000 --- a/crates/proof-compression/cuda/kzg_sppark.cuh +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -// Field-specific NTT singletons and the per-backend device registries must -// not alias the Goldilocks instantiation's symbols. -#define gpu_t kzg_gpu_t -#define gpus_t kzg_gpus_t -#define stream_t kzg_stream_t -#define event_t kzg_event_t -#define gpu_ptr_t kzg_gpu_ptr_t -#define dev_ptr_t kzg_dev_ptr_t -#define select_gpu kzg_select_gpu -#define gpu_props kzg_gpu_props -#define all_gpus kzg_all_gpus -#define ngpus kzg_ngpus -#define cuda_available kzg_cuda_available -#define drop_gpu_ptr_t kzg_drop_gpu_ptr_t -#define clone_gpu_ptr_t kzg_clone_gpu_ptr_t -#define drop_error_message kzg_drop_error_message -#define sppark_error kzg_sppark_error -#define cuda_error kzg_cuda_error -#define NTT KzgNTT -#define NTTParameters KzgNTTParameters -#define CT_launcher KzgCTLauncher -#define GS_launcher KzgGSLauncher diff --git a/crates/proof-compression/cuda/plonk/blst-source-manifest.json b/crates/proof-compression/cuda/plonk/blst-source-manifest.json deleted file mode 100644 index 6d95201e7..000000000 --- a/crates/proof-compression/cuda/plonk/blst-source-manifest.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "format": "ix-native-source-files/v1", - "crate": "blst", - "version": "0.3.16", - "crate_sha256": "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45", - "subdirectory": "blst", - "files_sha256": { - "bindings/blst.h": "a11731b7e3fdb84e7026186f12aa5ce93ff8ee9a0ecc7fc8f1b077b23794e988", - "bindings/blst.hpp": "73cf17dbd235ad1350513595a2e5471d0d36700cfeb680c5851180b67632da4e", - "bindings/blst_aux.h": "af001093f836f0ad258b567444e1b066cd73392d2e1e982b6de5a9659c01be37", - "build/assembly.S": "ef84dc6ef8670a1deb3f4a2a16c529b1091dd416b6a7463c9a9e8eb1c7ff6552", - "build/bindings_trim.pl": "c1ac6b4e9a834635e72a7446d25d485f8e168a0f02a2e14599e009c59ef16ef8", - "build/cheri/add_mod_256-armv8.S": "1f6b007b132720b16ba0ed979a74571c190e86c6f623cd835ead686d56f2f32e", - "build/cheri/add_mod_384-armv8.S": "f3acf39a6542115bef7518e17ad88f287e2d5c6f67a5c5773f54e9db36a54daf", - "build/cheri/ct_inverse_mod_256-armv8.S": "d7aab022ab128395870bd814c983995e9c2693257e2b5d418c42c16bbdc55196", - "build/cheri/ct_inverse_mod_384-armv8.S": "c4b8894889eabd9b03862f7e9264255542d99efff8c6700895509a9d61e9da59", - "build/cheri/ct_is_square_mod_384-armv8.S": "a209f9134f97bcd6dfb4204c740a290d3c97da9fa5c3b014e5968a91e57b0889", - "build/cheri/div3w-armv8.S": "8e5afe91747840e275f21dc1264d5873d36436170090c37afe8cb4934a49a716", - "build/cheri/mul_mont_256-armv8.S": "b4f1ab598044a178ad8a506736d65ca8ad638b9e7619df9f61d3e4ef3e78d5f2", - "build/cheri/mul_mont_384-armv8.S": "4f68450938099b0f0321acf706f0a2900442b390378215201a74151fc8a1586b", - "build/cheri/sha256-armv8.S": "ad609e2a7aed35693978426f568971e051b1f42a7da5e1c1d27cda5492a7bd25", - "build/coff/add_mod_256-armv8.S": "c52b33148c13ba3ad3a449fa7b24fc298b95ba7ce80094fd256114f1f42e9cf5", - "build/coff/add_mod_256-x86_64.s": "93653067788cb7d7c6b3696093922e35b5948436d0f405fa6bc6aaae9c26edad", - "build/coff/add_mod_384-armv8.S": "f375c083e2ebad74bec200d0464cb7a34870574fcf0d555ac8d162a1f4016c90", - "build/coff/add_mod_384-x86_64.s": "10da3a249e3590598676fe5eb9486a200995e2063284c122c03a4ddd5440db60", - "build/coff/add_mod_384x384-x86_64.s": "89e3674ba43cacbcd8c9bc7a077fd46ec686f2a930d7ce3418616fa641b53a44", - "build/coff/ct_inverse_mod_256-armv8.S": "cef66ed2c0b701511f8858722e7daacd510160d290c1bd6e4b406d8aa8d5e839", - "build/coff/ct_inverse_mod_256-x86_64.s": "d2cbcd63a463ce013beda3d5c525681712ae1618e5358d5900edd55ce6a45e0d", - "build/coff/ct_inverse_mod_384-armv8.S": "b8a13fa05dcb87a33b9a35b9c06a6039f1c03a5bedb5f30b169c6a661e5b4022", - "build/coff/ct_is_square_mod_384-armv8.S": "afc9ebba52ab4d7a6afc2a319a0e9721cc062de7657ce69c8071c35b071cef02", - "build/coff/ct_is_square_mod_384-x86_64.s": "274c98f95345a3ab266f3ff1d5e151e68fea6db25085f6417df2ea015f5ae368", - "build/coff/ctq_inverse_mod_384-x86_64.s": "609ebf85650a56c5f6152e882f90f86899cd7da9e2da7c0aa98730fb43a43986", - "build/coff/ctx_inverse_mod_384-x86_64.s": "e507d463189793d54f18800af6d24c03a0ffc0b7b28cb27b45c31d8fe05b715c", - "build/coff/div3w-armv8.S": "414447f6816afc1aba5ad99440a0f62398113a50847657f45c87284f00ad0c73", - "build/coff/div3w-x86_64.s": "767b8cde73f10b08d3bc0f19faf2e9de9675c773ff26a65e5938667e110def50", - "build/coff/mul_mont_256-armv8.S": "8bf3dc0fe7470d1b32e92bbaeb325d182f3a8b0613f8044e4046c04d405b572b", - "build/coff/mul_mont_384-armv8.S": "14672f4797d4b18ad3acf9d76c20da20808f1b4644773a21317f4a845c2a6c82", - "build/coff/mulq_mont_256-x86_64.s": "6fc9bb464399c2f6f57402a6735dfa02c63cddc111fd3e56044a86cee2a32928", - "build/coff/mulq_mont_384-x86_64.s": "6cc1563abf4bb321303ee3d341c7eba397fd66f3cafd43f81b52d34bdb4dd7d6", - "build/coff/mulx_mont_256-x86_64.s": "6dd019649ba4cd79e9c5ac311a744f1909b47e9178492e19d108d98eb8b95c77", - "build/coff/mulx_mont_384-x86_64.s": "e35847d26a0bc5a457f39242fb64cbdc5b10ecd88df776a76a30f0346c02967d", - "build/coff/sha256-armv8.S": "852ea0bb9d7e3f3752e95b37f7e510b295f02ee475b9214cb417fa5db7851b28", - "build/coff/sha256-portable-x86_64.s": "14efff9c1a246fef6298f9d8b8e0da811f4d361f50d3a82c3cce1fb10a46c73a", - "build/coff/sha256-x86_64.s": "87faafcc56e690a042f231827217d919e0441e477c4d6a26285744a429e808b3", - "build/elf/add_mod_256-armv8.S": "41db62ec43d0be76e095d17c89a8dff1c48e1c5bac817162a2b1b84645832bd9", - "build/elf/add_mod_256-x86_64.s": "d1b05c6be93e7fb1e64f0bbaf74018e97787bdba516ce38c928470e601961294", - "build/elf/add_mod_384-armv8.S": "c5a9c56ce29e8dd29d7fdbdeb268953102e9752f453c65d2bec23e07d9f5141b", - "build/elf/add_mod_384-x86_64.s": "baafa2aac9182d6082587a4ce6097d8ac201d9011d7550b7d01d83a51572410a", - "build/elf/add_mod_384x384-x86_64.s": "0ef77bd5b30ededecd865f0c2e74e8d2c97efe014fafdcb5cbd947d8e45ed02b", - "build/elf/ct_inverse_mod_256-armv8.S": "7ca734a4ec85ba4d8fb8d6cafcc335d214ebacfc96706c341aca0f550a6b206c", - "build/elf/ct_inverse_mod_256-x86_64.s": "64c37af3585d2331800c49cd52a27b55f25f3e5326e49051b2328b588246a080", - "build/elf/ct_inverse_mod_384-armv8.S": "04572708033c59bb86a4750df360eb32836f1d1064510baa7991b8e54eb94011", - "build/elf/ct_is_square_mod_384-armv8.S": "0a4962c305dbd8d34786def65575b31e38a763c764fd106f544f46629cb4494c", - "build/elf/ct_is_square_mod_384-x86_64.s": "bdbca779b340ade2cbaa2fcd898f14cd7317844255682576ffeee38ab74fee21", - "build/elf/ctq_inverse_mod_384-x86_64.s": "f816bef4ad4d437fe7a1dc14faa1717549d75df4061f8bb8c5796d0692f769e7", - "build/elf/ctx_inverse_mod_384-x86_64.s": "869469fafff1d07e6b5ead94b48401cb1f5c06a70577e59b036de980894cf654", - "build/elf/div3w-armv8.S": "55910486920d76efdd4e0cd5113d2104e38e203152b456d7274f43a110fd719e", - "build/elf/div3w-x86_64.s": "548d258efa0c46f4c5bf8020dbea448e63f15265e840a4399bc2380591470b9f", - "build/elf/mul_mont_256-armv8.S": "99df0e7bc55b07d2555831a58da775217711823713ef0074e9746c8cb0f2707b", - "build/elf/mul_mont_384-armv8.S": "97835b52fd2e219b8123d877bb86c8ac1c4bb024fac9642e8216c3878413eecf", - "build/elf/mulq_mont_256-x86_64.s": "93562f722b32885b0810b6ea15d0a6086f81433561f22447865fcd4505ec4a28", - "build/elf/mulq_mont_384-x86_64.s": "171292798aea6392ac8858a3354955a264d58ad3f95cd187e3600e944c8e6a8c", - "build/elf/mulx_mont_256-x86_64.s": "e9e3483ec6174e25fee49d27023a66e3a71a3073a3043eb5195c5b1a10662eb8", - "build/elf/mulx_mont_384-x86_64.s": "30d7c0bdd842abdd15f316e107bb5e514a7da19667208afe78dbe30e95686fe4", - "build/elf/sha256-armv8.S": "6dced131033c5a451d209a31a00f27ccd6e63b042a1b7aa7379c7fb9e85882d9", - "build/elf/sha256-portable-x86_64.s": "25b7d54b248a1ccb910d4abb8a45ea82d7bdc78e48162b80e492dd5f2100a03c", - "build/elf/sha256-x86_64.s": "0216306f90681189d9ecdcc126cca8da58497cd89f9a91b221317101755d1579", - "build/mach-o/add_mod_256-armv8.S": "803057a71553a4de247cee2e5fc3093c10a5586830a418c31c897f94569bbdea", - "build/mach-o/add_mod_256-x86_64.s": "ffc21a55fb23e2fe5e5acf9adcea836a0ddc83d6e58fc9ae748630660f9fa7a0", - "build/mach-o/add_mod_384-armv8.S": "18994e7cb09de079394891e8d5268ebe11d4a31b7e8e254a64ec647583d5f075", - "build/mach-o/add_mod_384-x86_64.s": "3a8fd579a55839f90b2ef044c33e5d0fed3bd17ded97b7c4e99f21d3a2838a0d", - "build/mach-o/add_mod_384x384-x86_64.s": "98dd0bbb0609a3a63156441c83f584de6067c8582bb4a34df4af3a2617e6af12", - "build/mach-o/ct_inverse_mod_256-armv8.S": "eece0ecacadd6fff42b6291515873af6dcdec5b513849f7ca6d95319c4b7f7b9", - "build/mach-o/ct_inverse_mod_256-x86_64.s": "b61ed05a1303e24bdb53ac39feb6b18d97aceef1b20f3a21b9781aced1fe19f4", - "build/mach-o/ct_inverse_mod_384-armv8.S": "c41ac8b59bdbf6263650118132fd306e42daf8310cbdf3f83379ef07b1601a12", - "build/mach-o/ct_is_square_mod_384-armv8.S": "0da87342965a9ea7eec68c0631d7462583553526181227e60a1355ea23f986b4", - "build/mach-o/ct_is_square_mod_384-x86_64.s": "6145d1fde1f407a3779b3b8017588fbaa74688f6745e577bffda2abe13ca1b9b", - "build/mach-o/ctq_inverse_mod_384-x86_64.s": "3de1a27f3b09dfc5cf847c77b50e8bdad36dba7f9d1736a60169d4b205162e8f", - "build/mach-o/ctx_inverse_mod_384-x86_64.s": "72af33caa9d379e171a7e243da2a8a3a362d5ff74fede1930ba5eeb17ffb7608", - "build/mach-o/div3w-armv8.S": "054bae3ea9662ba9e0b677d7db8f14d79a59a3ec791f84179f633dc5bdd72895", - "build/mach-o/div3w-x86_64.s": "143c4b2fc4279e8e45fcfff512769f25f22d635585584a0a44bddd8b1c589359", - "build/mach-o/mul_mont_256-armv8.S": "768b46ac0b0566888ee8a0933c32158fe5f9d2ea0f4439e8d250ec5c549e6097", - "build/mach-o/mul_mont_384-armv8.S": "857453526bff660e724afc4176163ef643728e83f47dd7b9bf386fa3eb29e36c", - "build/mach-o/mulq_mont_256-x86_64.s": "97bf607ea2db61fb10b733535751ed99b7682c117528fca70be5ce9276c67e79", - "build/mach-o/mulq_mont_384-x86_64.s": "c383e18bfbb6e3a22b2d4cac84f669c75b54ced23ba83a95a2c122995fe31147", - "build/mach-o/mulx_mont_256-x86_64.s": "945cc854e8c5d791add9b30f996f1243453514506039efcc3cf6b0b88e7a8439", - "build/mach-o/mulx_mont_384-x86_64.s": "34735e178f2a32495f8b60471a8f2f418a97ac83a95dfb2ace27224ff8e47752", - "build/mach-o/sha256-armv8.S": "2f11ce94c76870ea995f65b0cc360eab4a6d64f226c8ecf55886976d67219203", - "build/mach-o/sha256-portable-x86_64.s": "3063e17bd8c61f1cdeefe65b9b3f1073daafeb4d39a6d75222b4fe26c6145185", - "build/mach-o/sha256-x86_64.s": "bbd6d9feeeaa323d9d58b92c9b57cded74bd4aa617e0681a87481913e969aa3d", - "build/refresh.sh": "aa2f1332ae7644cf63fd48a2393c47c07ae22b017216b06936f0ea9ddc9ef743", - "build/srcroot.go": "2d386c6ab317195559e3fe9d70ff69f650c031cc953c933efa9cf2671990cec7", - "build/win64/add_mod_256-armv8.asm": "854ee96f3e47525a47068b673e3a66282a29647d99b4da64d490b4cd9def3c36", - "build/win64/add_mod_256-x86_64.asm": "e4172e082e9c5b168c30a838cd6750a8c440107f2ec0d353ca07607d12fc1968", - "build/win64/add_mod_384-armv8.asm": "0c523b10612a6dbe2126055bc4da5dc36cd6f413faf0299bf01564c3d6759cfc", - "build/win64/add_mod_384-x86_64.asm": "e6d51dc8154c3b00e24c1e04ee0bb2ce6d901f834bdfe30483f2b1751b659b51", - "build/win64/add_mod_384x384-x86_64.asm": "8b31dae5190ec90215248098bc27565bee28f3a554a869964c646a15b087d738", - "build/win64/blst.def": "3de9a596559f12fcc2dc575b1fa27c4b0ef41d9b316f375f8aea7cfb608cc58a", - "build/win64/ct_inverse_mod_256-armv8.asm": "eaf01bed8fcae2176e02cc8b00d25659a936c4f87e097abcaedb807707573bd7", - "build/win64/ct_inverse_mod_256-x86_64.asm": "562cff2c8c33b7a9ae1f39cb8130a6226d9d809801eb834d13ef95992b7bc1a7", - "build/win64/ct_inverse_mod_384-armv8.asm": "53577ca20e4796ffb2f55fcea4fdbc068e09409b49295993661d438e05ac535b", - "build/win64/ct_is_square_mod_384-armv8.asm": "24d73fefd592beeab3b4c1ed04fe0b9aa33eb04650b904f9e05ea796b4481c40", - "build/win64/ct_is_square_mod_384-x86_64.asm": "cfc800dcd3b6e10f95a5653492970c94f0460d7036c84b7f082ddff35188b4b1", - "build/win64/ctq_inverse_mod_384-x86_64.asm": "15dbcf3777de9a369603235d54125ba5f756c49c8ae691a4115b79f0e5c8f2ca", - "build/win64/ctx_inverse_mod_384-x86_64.asm": "a09f32fc5f19756910f0b948e6414116e05a1d94af1b97dc16c391c83aae5b7c", - "build/win64/div3w-armv8.asm": "7f841a88f067862c46b65bcbd2b7c92a32f91fe4f5b4b88c0df031aa14d5ee48", - "build/win64/div3w-x86_64.asm": "40d94d29c8ea45e3b8e19cef05e926d3a32b93688d981cdddd80900732b510c9", - "build/win64/dll.c": "91ee8d567ffb48a427b2a9eb155cdcdce1e5343d75509f400ef564c9dbd08594", - "build/win64/mul_mont_256-armv8.asm": "f4b4d494a43442a4f387a33a72c045e844fb46397128b61d9e7898e7dfd543e4", - "build/win64/mul_mont_384-armv8.asm": "166d60fc018fabbc4050d8918b0041364792adbacd79c0141cd5fe92fe642632", - "build/win64/mulq_mont_256-x86_64.asm": "602cd01285064116fca28aca99d5117db4bb3b5e81b2fb3f491679c4d2442199", - "build/win64/mulq_mont_384-x86_64.asm": "d32d5b5ba99caaf05725f5dcf9f4a5c5f35778d32799341388a3046878059773", - "build/win64/mulx_mont_256-x86_64.asm": "b36a919d12bbe7de9597f3b2038c6fe1103ad96aeb26940e86255321189b8384", - "build/win64/mulx_mont_384-x86_64.asm": "99959b4710f3e4d12189fb898ecd22d724fc7171591789371219954bfbfe3631", - "build/win64/sha256-armv8.asm": "0c8a70abf0a776a491a1ef0a39da8a521d833ba759b93006cc50ad8badb475e0", - "build/win64/sha256-x86_64.asm": "f47476003c328216a0c447eea4ce1a4c7313614cb50cfb27532f8c1881fc7a79", - "src/aggregate.c": "371b64e0cef2b52abe59291ea98600a7e6d8904c42f84266a1d7761277f06f9b", - "src/blst_t.hpp": "a156bc1374347f14f7a69e337369291ac021fa3a03c596b61c131649562e5c76", - "src/bulk_addition.c": "cc808b1fc07eea97c300fd0486b6e01fc063f462c2994f16495c20a25496b621", - "src/bytes.h": "ab19306003f4033ed522238580836bc662334df2a9ed56e2f8bfa822e79e1ae9", - "src/client_min_pk.c": "9084cdc870a3ef8fc90e92bedafda01600f43aef1fb9d8494804ed23bf4fe0dc", - "src/client_min_sig.c": "d4d9dd37b3f500a7dd65d0796f25ca541cdb6a5765b293b1d1e4aef6027f04c0", - "src/consts.c": "b5de026855b1f370a26c471abcc4f5a0debb2d195d7661844be6d87159cc0283", - "src/consts.h": "e983c6c80045adc46379ff1fd088dbf85e20c4946eb98191835eed91f7598727", - "src/cpuid.c": "60f18f97c54eaef84f47e94478e184ae6f9df23b0908f00de1f98527232d4a42", - "src/e1.c": "4939fe48680b3f3aad94501aacbfd081907a1561cb66d39559885f9d0025770e", - "src/e2.c": "ad3bd7a4d559fffffe1f4aa6377bd6a1653d0f672d111566be377ec4e3875cc5", - "src/ec_mult.h": "fd95cf849912d7801fed38902b84abd5b4aa79f7d888eb91ffad48d7695e1443", - "src/ec_ops.h": "b4ace5a1efe5354fc622a11b05eb65f4cc81aa91f6a0baa1912caa34600d4fd0", - "src/errors.h": "bc3f2136c74680046cf09a790b1eb34df02b8894cb3739626b55428f3631ba1c", - "src/exp.c": "fe1332c1480c9b95a3b46ca15f55f4086f53c365139186bd20b527b8abee854d", - "src/exports.c": "a4ed36689c06458f04388f08fb67a3aed2df09640c560cdd342a89aa2be28412", - "src/fields.h": "2151a9b8bf00d5deb6444bbbfc836f9e6bab24a1e147131a028d155dbfa5a809", - "src/fp12_tower.c": "09e63dac5fe51c62006141e48e525c2a50fa6459173be8296d1ba76cb2fd5809", - "src/hash_to_field.c": "6bfe3fa4d38a2253257c7d32ffae840047b4c6386060c9fe195496efa02b6545", - "src/keygen.c": "1f8f4f6a30bfd48607ca87696913d3cdd94d18ef420e28ffdf34b9a2b45ab268", - "src/map_to_g1.c": "7a43f77bc4f64d215d2fa38f5465ae3c04aa81d7a98754a62f6e68a28ee26684", - "src/map_to_g2.c": "57b4f880ba2c6811aea25eb1d56d30cd191c20a1e60036d51989071728c85f5d", - "src/multi_scalar.c": "26c2288aa5b24243102c5838241fb8dce9bef26b7a1ea144218422ec4ef3b2bd", - "src/no_asm.h": "a230ef097ce1b9826c3bd5b000aeb594899296329eac9635e75e9316035c3048", - "src/pairing.c": "c20f30198d8c5f723b571296a4d16d1834a507e991c95b984e9a057b6c9b006b", - "src/pentaroot-addchain.h": "5b060295eed3af6c5bfd3ca1ae7d45f01fe2598b6e9f4e33033ff9a7b545097c", - "src/pentaroot.c": "9872013bf00a3440223c819f3abfefbf89f6325751a2337f78a813f615e7dea8", - "src/point.h": "09207151264a44d0b232c5f54c56dd20b84037b9d692e952045561c49979963f", - "src/rb_tree.c": "443bd6482638664e2d336d5d96469356df6292475433f69ffe8c83d952f2c41e", - "src/recip-addchain.h": "31e3949b6cd78b8eb7590cfae7b4aedb25a70281eb2e1a0c72a83933c2cc5ff4", - "src/recip.c": "115cdb1b02f26740aec680f963422e4aa56790e8d74370e936dc346c69db203e", - "src/server.c": "fb3c3a6a4db81d90113e7ef79d7dca4de33a5b5fd0baa0d56ef987947f93f9a8", - "src/sha256.h": "ffccc1387c9cb16be34785a8b87bc37ab6e3f3f4d432d73682d573b5507df90c", - "src/sqrt-addchain.h": "2ee9dff636a330b9203c75e606d7e7eb251737d690540247e6ec2e6348f2ecad", - "src/sqrt.c": "3ee19bfc3ebdf68abd91375e6969300ccc26aec95767b8958c2fb281c12a8abb", - "src/vect.c": "b8f189510e4795aa5a2d827ca8347a672cc36b7a9d5ab36457a3c3823916090a", - "src/vect.h": "55e35561f0a6159463eb8a7ced80ad769a31f08666a4868c5bc8f808f0be3124" - } -} diff --git a/crates/proof-compression/cuda/plonk/build.py b/crates/proof-compression/cuda/plonk/build.py deleted file mode 100644 index 81fd18583..000000000 --- a/crates/proof-compression/cuda/plonk/build.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -"""Build the standalone BN254 PLONK CUDA library from explicit source roots.""" - -import argparse -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess -import tempfile - - -SPPARK_REVISION = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8" -BLST_CRATE_SHA256 = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" - - -def digest(path): - result = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - result.update(block) - return result.hexdigest() - - -def _validate_tree(root, manifest_path, expected_override=None): - receipt = json.loads(manifest_path.read_text()) - if receipt.get("format") != "ix-native-source-files/v1": - raise ValueError(f"unsupported source manifest: {manifest_path}") - expected = receipt["files_sha256"] if expected_override is None else expected_override - directories = {"."} - for relative in expected: - path = Path(relative) - if path.is_absolute() or ".." in path.parts: - raise ValueError(f"invalid source manifest path: {relative}") - directories.update(str(parent) for parent in path.parents) - actual = set() - for current, children, files in os.walk(root, followlinks=False): - current = Path(current) - for child in list(children): - path = current / child - relative = path.relative_to(root).as_posix() - if relative == ".git": - children.remove(child) - continue - if path.is_symlink() or relative not in directories: - raise ValueError(f"unexpected source directory: {path}") - for child in files: - path = current / child - relative = path.relative_to(root).as_posix() - if relative in (".git", ".cargo-ok"): - continue - if path.is_symlink() or not path.is_file() or relative not in expected: - raise ValueError(f"unexpected source file: {path}") - actual.add(relative) - if actual != set(expected): - raise ValueError(f"missing source files in {root}: {sorted(set(expected) - actual)}") - for relative, expected_hash in expected.items(): - if digest(root / relative) != expected_hash: - raise ValueError(f"source digest differs: {root / relative}") - return { - "root": str(root), "manifest": str(manifest_path), - "manifest_sha256": digest(manifest_path), - "files_sha256": expected, - }, receipt - - -def validate_sources(sppark, blst): - """Authenticate complete dependency trees for builds and native-library reuse.""" - source = Path(__file__).resolve().parent - sppark = Path(sppark).resolve(strict=True) - blst = Path(blst).resolve(strict=True) - sppark_result, sppark_receipt = _validate_tree(sppark, source / "sppark-source-manifest.json") - if sppark_receipt.get("revision") != SPPARK_REVISION: - raise ValueError("sppark source receipt revision differs") - blst_result, blst_receipt = _validate_tree(blst, source / "blst-source-manifest.json") - if (blst_receipt.get("crate") != "blst" or blst_receipt.get("version") != "0.3.16" - or blst_receipt.get("crate_sha256") != BLST_CRATE_SHA256 - or blst_receipt.get("subdirectory") != "blst"): - raise ValueError("blst source receipt identity differs") - sppark_result["revision"] = SPPARK_REVISION - blst_result.update(crate="blst", version="0.3.16", crate_sha256=BLST_CRATE_SHA256) - return {"sppark": sppark_result, "blst": blst_result} - - -def split_patch(): - source = Path(__file__).resolve().parent - patch_path = source / "patches/sppark-split-buckets-v1.patch" - receipt_path = source / "patches/sppark-split-buckets-v1.json" - patch = patch_path.read_bytes() - receipt = json.loads(receipt_path.read_text()) - if (receipt.get("format") != "ix-sppark-split-buckets/v1" - or receipt.get("revision") != SPPARK_REVISION - or receipt.get("patch_sha256") != hashlib.sha256(patch).hexdigest()): - raise ValueError("sppark split patch identity differs") - manifest_path = source / "sppark-source-manifest.json" - expected = dict(json.loads(manifest_path.read_text())["files_sha256"]) - blocks = patch.decode().split("diff --git ") - if blocks[0].strip(): - raise ValueError("sppark patch must use git diff headers") - seen = set() - for block in blocks[1:]: - lines = block.splitlines() - before, relative = lines[0].split(" b/", 1) - if (before != "a/" + relative or relative in seen - or relative not in receipt["files"] or Path(relative).is_absolute() - or ".." in Path(relative).parts): - raise ValueError("unexpected sppark patch path") - hashes = receipt["files"][relative] - if expected.get(relative) != hashes["before"]: - raise ValueError("sppark patch input differs from pinned source") - old_header = "--- /dev/null" if hashes["before"] is None else "--- a/" + relative - if [line for line in lines if line.startswith("--- ")] != [old_header] or [ - line for line in lines if line.startswith("+++ ")] != ["+++ b/" + relative]: - raise ValueError("sppark patch file headers differ") - expected[relative] = hashes["after"] - seen.add(relative) - if seen != set(receipt["files"]): - raise ValueError("sppark patch is incomplete") - return patch, receipt_path, expected - - -def validate_patched_sppark(root): - source = Path(__file__).resolve().parent - patch, receipt_path, expected = split_patch() - result, _ = _validate_tree(Path(root).resolve(strict=True), - source / "sppark-source-manifest.json", expected) - result.update(patch_sha256=hashlib.sha256(patch).hexdigest(), - patch_receipt_sha256=digest(receipt_path)) - return result - - -def prepare_sppark(sppark, output): - source = Path(__file__).resolve().parent - patch, receipt_path, expected = split_patch() - identity = hashlib.sha256(patch + receipt_path.read_bytes() - + (source / "sppark-source-manifest.json").read_bytes()).hexdigest() - destination = output / ("sppark-" + identity[:20]) - if not destination.exists(): - temporary = Path(tempfile.mkdtemp(prefix=".sppark-", dir=output)) - try: - pristine = json.loads((source / "sppark-source-manifest.json").read_text())["files_sha256"] - for relative in pristine: - path = temporary / relative - path.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(sppark / relative, path) - subprocess.run(["patch", "--batch", "--fuzz=0", "-p1"], - cwd=temporary, input=patch, check=True) - validate_patched_sppark(temporary) - temporary.rename(destination) - finally: - if temporary.exists(): - shutil.rmtree(temporary) - return destination, validate_patched_sppark(destination) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--sppark", required=True, type=Path) - parser.add_argument("--blst", required=True, type=Path, - help="blst source root containing src/server.c and build/assembly.S") - parser.add_argument("--out", required=True, type=Path) - args = parser.parse_args() - source = Path(__file__).resolve().parent - sppark = args.sppark.resolve(strict=True) - blst = args.blst.resolve(strict=True) - output = args.out.resolve() - output.mkdir(parents=True, exist_ok=True) - try: - dependencies = validate_sources(sppark, blst) - patched_sppark, patched_receipt = prepare_sppark(sppark, output) - except ValueError as error: - parser.error(str(error)) - cuda = Path(os.environ.get("CUDA_HOME", os.environ.get("CUDA_PATH", "/usr/local/cuda"))) - nvcc = os.environ.get("NVCC", str(cuda / "bin/nvcc")) - cc = os.environ.get("CC", "cc") - architectures = os.environ.get("IX_PLONK_CUDA_ARCHS", "120").replace(",", " ").split() - if not architectures or any(not item.isdigit() for item in architectures): - parser.error("IX_PLONK_CUDA_ARCHS must contain numeric CUDA architectures") - architecture_header = output / "architectures.h" - architecture_header.write_text( - "#pragma once\n#define IX_PLONK_CUDA_ARCHITECTURES {" + ",".join(architectures) + "}\n" - ) - include = [f"-I{patched_sppark}", f"-I{patched_sppark / 'util'}", f"-I{blst / 'src'}", f"-I{source}"] - common = [nvcc, "--std=c++17", "--cudart=static", "--default-stream=per-thread", - "-O3", "-lineinfo", "--compiler-options=-fPIC,-fvisibility=hidden", - "-DFEATURE_BN254", "-DMAX_LG_DOMAIN_SIZE=28", "-D__BLST_PORTABLE__", - "-include", str(source / "sppark_namespace.h"), - "-include", str(architecture_header), *include] - for architecture in architectures: - common.append(f"-gencode=arch=compute_{architecture},code=sm_{architecture}") - commands = [] - - def run(command): - commands.append(command) - print(json.dumps({"command": command}), flush=True) - subprocess.run(command, check=True) - - native_sources = [source / "plonk.cu", source / "runtime.cpp"] - objects = [] - for path in native_sources: - obj = output / (path.stem + ".o") - run([*common, "-c", str(path), "-o", str(obj)]) - objects.append(obj) - for path, name in [(blst / "src/server.c", "blst-server.o"), - (blst / "build/assembly.S", "blst-assembly.o")]: - obj = output / name - run([cc, "-O3", "-fPIC", "-fvisibility=hidden", "-D__BLST_PORTABLE__", - f"-I{blst / 'src'}", "-c", str(path), "-o", str(obj)]) - objects.append(obj) - library = output / "libix_plonk_bn254_cuda.so" - run([nvcc, "--shared", "--cudart=static", *map(str, objects), - "-Xlinker", f"--version-script={source / 'exports.map'}", - "-Xlinker", "-z", "-Xlinker", "defs", - "-Xlinker", "-soname", "-Xlinker", library.name, - "-o", str(library), "-ldl", "-lrt", "-lpthread"]) - shutil.copyfile(source / "ix_plonk_bn254.h", output / "ix_plonk_bn254.h") - files = [*native_sources, source / "ix_plonk_bn254.h", source / "sppark_namespace.h", - source / "exports.map", source / "build.py", source / "sppark-source-manifest.json", - source / "blst-source-manifest.json", source.parent / "kzg_runtime.cpp", - source.parent / "kzg_transfer.cuh", blst / "src/server.c", blst / "build/assembly.S", - source / "patches/sppark-split-buckets-v1.patch", source / "patches/sppark-split-buckets-v1.json"] - files.extend(patched_sppark / name for name in patched_receipt["files_sha256"]) - metadata = { - "format": "ix-plonk-bn254-cuda-build/v1", "sppark_revision": SPPARK_REVISION, - "architectures": architectures, "commands": commands, - "nvcc": subprocess.check_output([nvcc, "--version"], text=True), - "dependencies": dependencies, - "patched_sppark": patched_receipt, - "inputs_sha256": {str(path): digest(path) for path in files}, - "library_sha256": digest(library), - } - if validate_sources(sppark, blst) != dependencies: - raise ValueError("native dependency source identity changed during compilation") - if validate_patched_sppark(patched_sppark) != patched_receipt: - raise ValueError("patched sppark source changed during compilation") - (output / "build-manifest.json").write_text(json.dumps(metadata, indent=2) + "\n") - - -if __name__ == "__main__": - main() diff --git a/crates/proof-compression/cuda/plonk/exports.map b/crates/proof-compression/cuda/plonk/exports.map deleted file mode 100644 index 8347759ad..000000000 --- a/crates/proof-compression/cuda/plonk/exports.map +++ /dev/null @@ -1,4 +0,0 @@ -IX_PLONK_BN254_1 { - global: ix_plonk_bn254_*; - local: *; -}; diff --git a/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json b/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json deleted file mode 100644 index 6d2a0fb75..000000000 --- a/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "format": "ix-sppark-split-buckets/v1", - "revision": "114feb69ebfe029e2caa5e6c2d372c695e5d4da8", - "patch_sha256": "faec2b98ec2dcd74474c9d67eb38a10ed04a748565287a19385f804ceda2d514", - "files": { - "msm/pippenger.cuh": { - "before": "413013c734be4aabb6a5905a890e43c5f87a4e5fb3c75494a580ded8c52d5634", - "after": "20af74f72fa786447c42f36adad75c8487d1b389eea4824ccea340ec0359a11a" - }, - "msm/split_buckets.cuh": { - "before": null, - "after": "3b46697880c3e21750afe0c95e717e0edb254ab13495520cda2adee872e3b129" - } - } -} diff --git a/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch b/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch deleted file mode 100644 index 5573dc7c3..000000000 --- a/crates/proof-compression/cuda/plonk/patches/sppark-split-buckets-v1.patch +++ /dev/null @@ -1,360 +0,0 @@ -diff --git a/msm/pippenger.cuh b/msm/pippenger.cuh ---- a/msm/pippenger.cuh -+++ b/msm/pippenger.cuh -@@ -157,7 +157,8 @@ - __launch_bounds__(ACCUMULATE_NTHREADS) __global__ - void accumulate(bucket_h buckets_[], uint32_t nwins, uint32_t wbits, - /*const*/ affine_h points_[], const vec2d_t digits, -- const vec2d_t histogram, uint32_t sid = 0) -+ const vec2d_t histogram, uint32_t sid = 0, -+ uint32_t max_bucket_length = 0xffffffffu) - { - vec2d_t buckets{buckets_, 1U<<--wbits}; - const affine_h* points = points_; -@@ -199,6 +200,7 @@ - idx = h[-1]; - - if ((len -= idx) && !(x == 0 && y == 0)) { -+ len = min(len, max_bucket_length); - const uint32_t* digs_ptr = &digits[y][idx]; - uint32_t digit = *digs_ptr++; - -@@ -311,7 +313,7 @@ - /*const*/ affine_t::mem_t points_[], - const vec2d_t digits, - const vec2d_t histogram, -- uint32_t sid); -+ uint32_t sid, uint32_t max_bucket_length); - template __global__ - void batch_addition(bucket_t::mem_t buckets[], - const affine_t::mem_t points[], size_t npoints, -@@ -329,6 +331,7 @@ - #include - #include - #include -+#include "split_buckets.cuh" - - template d_hist; -+ bool split_buckets_ = false; -+ msm_split_statistics split_statistics_{}; - - template using vec_t = slice_t; - -@@ -356,6 +361,11 @@ - { int ret=0; while (n>>=1) ret++; return ret; } - - public: -+ void set_split_buckets(bool enabled) { split_buckets_ = enabled; } -+ const msm_split_statistics& split_statistics() const { return split_statistics_; } -+ static size_t split_scratch_bytes(size_t count, uint32_t window, uint32_t windows) { -+ return msm_split_geometry(count, window, windows, sizeof(bucket_h)).bytes; -+ } - msm_t(const affine_t points[], size_t np, - size_t ffi_affine_sz = sizeof(affine_t), int device_id = -1) - : gpu(select_gpu(device_id)), d_points(nullptr), d_scalars(nullptr) -@@ -460,6 +470,7 @@ - size_t ffi_affine_sz = sizeof(affine_t)) - { - assert(this->npoints == 0 || npoints <= this->npoints); -+ split_statistics_ = msm_split_statistics{}; - - uint32_t lg_npoints = lg2(npoints + npoints/2); - size_t batch = 1 << (std::max(lg_npoints, wbits) - wbits); -@@ -489,6 +500,17 @@ - size_t digits_sz = nwins * stride * sizeof(uint32_t); - - dev_ptr_t d_temp{temp_sz + digits_sz + d_point_sz, gpu[2]}; -+ const msm_split_geometry geometry(split_buckets_ ? npoints : 1, wbits, nwins, sizeof(bucket_h)); -+ dev_ptr_t split_storage{split_buckets_ ? geometry.bytes : 0, gpu}; -+ msm_unwind_completion completion(gpu.cid()); -+ if (split_buckets_) gpu.sync(); -+ auto* split_header = split_buckets_ ? reinterpret_cast(&split_storage[0]) : nullptr; -+ auto* split_ranges = split_buckets_ ? reinterpret_cast(&split_storage[geometry.ranges_offset]) : nullptr; -+ auto* split_tasks = split_buckets_ ? reinterpret_cast(&split_storage[geometry.tasks_offset]) : nullptr; -+ auto* split_heavy = split_buckets_ ? reinterpret_cast(&split_storage[geometry.heavy_offset]) : nullptr; -+ auto* split_partials = split_buckets_ ? reinterpret_cast(&split_storage[geometry.partials_offset]) : nullptr; -+ msm_split_timer classify_timer(split_buckets_), chunks_timer(split_buckets_), reduce_timer(split_buckets_); -+ msm_split_header split_sample{}; - - vec2d_t d_temps{&d_temp[0], stride}; - vec2d_t d_digits{&d_temp[temp_sz], stride}; -@@ -515,6 +537,16 @@ - for (uint32_t i = 0; i < batch; i++) { - gpu[i&1].wait(ev); - auto bucket_timer = SPPARK_MSM_PROFILE_START(gpu[i&1]); -+ if (split_buckets_) { -+ classify_timer.start(gpu[i&1]); -+ gpu[i&1].bzero(split_header, 1); -+ msm_split_classify<<>>( -+ d_hist, nwins, wbits, static_cast(num), geometry.capacity, -+ split_header, split_ranges, split_tasks, split_heavy); -+ CUDA_OK(cudaGetLastError()); -+ classify_timer.stop(gpu[i&1]); -+ chunks_timer.start(gpu[i&1]); -+ } - - batch_addition<<>>( -@@ -525,8 +557,24 @@ - - gpu[i&1].launch_coop(accumulate, - {gpu.sm_count(), 0}, -- d_buckets, nwins, wbits, &d_points[d_off], d_digits, d_hist, i&1 -+ d_buckets, nwins, wbits, &d_points[d_off], d_digits, d_hist, i&1, -+ split_buckets_ ? MSM_SPLIT_CHUNK : 0xffffffffu - ); -+ if (split_buckets_) { -+ msm_split_accumulate<<>>( -+ split_partials, &d_points[d_off], d_digits, wbits, split_tasks, -+ split_header, geometry.capacity); -+ CUDA_OK(cudaGetLastError()); -+ chunks_timer.stop(gpu[i&1]); -+ reduce_timer.start(gpu[i&1]); -+ msm_split_reduce<<>>( -+ d_buckets, split_partials, split_ranges, split_heavy, split_header, geometry.capacity); -+ CUDA_OK(cudaGetLastError()); -+ reduce_timer.stop(gpu[i&1]); -+ } -+ // The next digit pass may overwrite its histogram only after -+ // every split chunk has consumed the current sorted indices. - gpu[i&1].record(ev); - - integrate<< -+#include -+ -+constexpr uint32_t MSM_SPLIT_CHUNK = 256; -+constexpr uint32_t MSM_SPLIT_REDUCE_THREADS = 256; -+ -+struct msm_split_statistics { -+ uint64_t max_bucket[32]{}, chunk_count[32]{}; -+ uint64_t nonzero_digits[32]{}, zero_digits[32]{}; -+ uint64_t unit_points = 0; -+ double classify_ms = 0, chunks_ms = 0, reduce_ms = 0; -+}; -+ -+struct msm_split_header { -+ uint32_t tasks, heavy, overflow, reserved; -+ uint32_t max_bucket[32], chunk_count[32]; -+ uint32_t nonzero_digits[32], zero_digits[32]; -+ uint32_t unit_points; -+}; -+ -+struct msm_split_geometry { -+ size_t ranges_offset, tasks_offset, heavy_offset, partials_offset, bytes; -+ uint32_t capacity; -+ -+ msm_split_geometry(size_t count, uint32_t window, uint32_t windows, -+ size_t bucket_bytes) { -+ if (!count || count > (size_t(1) << 24) || window < 10 || window > 18 || !windows || windows > 32) -+ CUDA_OK(cudaErrorInvalidValue); -+ uint32_t bits = 0; -+ for (size_t n = count + count / 2; n >>= 1;) ++bits; -+ size_t batch = std::max(size_t(1), (size_t(1) << (std::max(bits, window) - window)) >> 6); -+ size_t stride = ((count + batch - 1) / batch + 31) & ~size_t(31); -+ // The rounded-window boundary can halve the invocation's batch count. -+ // Even then count <= 2^24 bounds each reduction lane to 256 partials. -+ if ((stride + MSM_SPLIT_CHUNK - 1) / MSM_SPLIT_CHUNK -+ > size_t(MSM_SPLIT_REDUCE_THREADS) * MSM_SPLIT_CHUNK) -+ CUDA_OK(cudaErrorInvalidValue); -+ size_t tasks = windows * ((stride + MSM_SPLIT_CHUNK - 1) / MSM_SPLIT_CHUNK); -+ if (tasks > std::numeric_limits::max()) CUDA_OK(cudaErrorInvalidValue); -+ capacity = static_cast(tasks); -+ ranges_offset = (sizeof(msm_split_header) + 31) & ~size_t(31); -+ tasks_offset = ranges_offset + size_t(windows) * (size_t(1) << (window-1)) * sizeof(uint2); -+ heavy_offset = tasks_offset + tasks * sizeof(uint4); -+ partials_offset = (heavy_offset + tasks * sizeof(uint32_t) + 31) & ~size_t(31); -+ if (tasks > (std::numeric_limits::max() - partials_offset) / bucket_bytes) -+ CUDA_OK(cudaErrorInvalidValue); -+ bytes = partials_offset + tasks * bucket_bytes; -+ } -+}; -+ -+// Cross-stream consumers must finish before asynchronous scratch frees unwind. -+class msm_unwind_completion { -+ int device_, exceptions_; -+ public: -+ explicit msm_unwind_completion(int device) noexcept -+ : device_(device), exceptions_(std::uncaught_exceptions()) {} -+ msm_unwind_completion(const msm_unwind_completion&) = delete; -+ msm_unwind_completion& operator=(const msm_unwind_completion&) = delete; -+ ~msm_unwind_completion() noexcept { -+ if (std::uncaught_exceptions() > exceptions_) { -+ (void)cudaSetDevice(device_); -+ (void)cudaDeviceSynchronize(); -+ } -+ } -+}; -+ -+class msm_split_timer { -+ cudaEvent_t begin_ = nullptr, end_ = nullptr; -+ public: -+ explicit msm_split_timer(bool enabled) { -+ if (!enabled) return; -+ CUDA_OK(cudaEventCreate(&begin_)); -+ try { CUDA_OK(cudaEventCreate(&end_)); } -+ catch (...) { cudaEventDestroy(begin_); throw; } -+ } -+ ~msm_split_timer() { -+ if (begin_) (void)cudaEventDestroy(begin_); -+ if (end_) (void)cudaEventDestroy(end_); -+ } -+ void start(cudaStream_t stream) { if (begin_) CUDA_OK(cudaEventRecord(begin_, stream)); } -+ void stop(cudaStream_t stream) { if (end_) CUDA_OK(cudaEventRecord(end_, stream)); } -+ double elapsed() { -+ if (!end_) return 0; -+ float ms = 0; -+ CUDA_OK(cudaEventElapsedTime(&ms, begin_, end_)); -+ return ms; -+ } -+}; -+ -+__device__ inline uint32_t msm_split_reserve(uint32_t* next, uint32_t count, -+ uint32_t capacity, uint32_t* overflow) { -+ uint32_t current = atomicAdd(next, 0); -+ for (;;) { -+ if (current > capacity || count > capacity - current) { -+ atomicExch(overflow, 1); return 0xffffffffu; -+ } -+ uint32_t observed = atomicCAS(next, current, current + count); -+ if (observed == current) return current; -+ current = observed; -+ } -+} -+ -+__global__ void msm_split_classify(const vec2d_t histogram, -+ uint32_t windows, uint32_t window, uint32_t num, uint32_t capacity, -+ msm_split_header* header, uint2* ranges, uint4* tasks, uint32_t* heavy) { -+ const uint32_t row = 1u << (window-1); -+ for (uint32_t id = blockIdx.x*blockDim.x + threadIdx.x; -+ id < windows*row; id += gridDim.x*blockDim.x) { -+ uint32_t w = id / row, bucket = id % row; -+ uint32_t end = histogram[w][bucket]; -+ uint32_t start = bucket ? histogram[w][bucket-1] : 0; -+ ranges[id] = uint2{0, 0}; -+ if (end < start || end > num) { atomicExch(&header->overflow, 1); continue; } -+ uint32_t length = end - start; -+ if (bucket == 0) { -+ uint32_t nonzero = histogram[w][row-1]; -+ if (nonzero > num) { atomicExch(&header->overflow, 1); continue; } -+ header->nonzero_digits[w] = nonzero; -+ header->zero_digits[w] = num - nonzero; -+ } -+ // The lowest-window unit bucket is handled by batch_addition. -+ if (id == 0) { header->unit_points = length; continue; } -+ atomicMax(&header->max_bucket[w], length); -+ if (length <= MSM_SPLIT_CHUNK) continue; -+ uint32_t extra = (length - 1) / MSM_SPLIT_CHUNK; -+ uint32_t base = msm_split_reserve(&header->tasks, extra, capacity, &header->overflow); -+ if (base == 0xffffffffu) continue; -+ uint32_t heavy_id = msm_split_reserve(&header->heavy, 1, capacity, &header->overflow); -+ if (heavy_id == 0xffffffffu) continue; -+ ranges[id] = uint2{base, extra}; -+ heavy[heavy_id] = id; -+ atomicAdd(&header->chunk_count[w], extra); -+ for (uint32_t part = 0; part < extra; ++part) { -+ uint32_t first = start + (part + 1) * MSM_SPLIT_CHUNK; -+ tasks[base + part] = uint4{id, first, min(end-first, MSM_SPLIT_CHUNK), 0}; -+ } -+ } -+} -+ -+template -+__launch_bounds__(128) __global__ -+void msm_split_accumulate(bucket_h* partials, const affine_h* points, -+ const vec2d_t digits, uint32_t window, const uint4* tasks, -+ const msm_split_header* header, uint32_t capacity) { -+ static_assert(bucket_t::degree == 1, "split buckets require a base-field G1"); -+ if (header->overflow) return; -+ uint32_t count = min(header->tasks, capacity); -+ for (uint32_t id = blockIdx.x*blockDim.x + threadIdx.x; -+ id < count; id += gridDim.x*blockDim.x) { -+ uint4 task = tasks[id]; -+ uint32_t w = task.x >> (window-1); -+ uint32_t digit = digits[w][task.y]; -+ typename bucket_t::affine_t point = points[digit & 0x7fffffff]; -+ bucket_t sum = point; -+ sum.cneg(digit >> 31); -+ for (uint32_t i = 1; i < task.z; ++i) { -+ digit = digits[w][task.y+i]; -+ point = points[digit & 0x7fffffff]; -+ sum.add(point, digit >> 31); -+ } -+ partials[id] = sum; -+ } -+} -+ -+template -+__launch_bounds__(MSM_SPLIT_REDUCE_THREADS) __global__ -+void msm_split_reduce(bucket_h* buckets, const bucket_h* partials, -+ const uint2* ranges, const uint32_t* heavy, const msm_split_header* header, -+ uint32_t capacity) { -+ static_assert(alignof(bucket_h) <= 32, "split shared-memory alignment"); -+ extern __shared__ __align__(32) unsigned char scratch_bytes[]; -+ auto* scratch = reinterpret_cast(scratch_bytes); -+ if (header->overflow) return; -+ uint32_t count = min(header->heavy, capacity); -+ for (uint32_t id = blockIdx.x; id < count; id += gridDim.x) { -+ uint32_t bucket = heavy[id]; -+ uint2 range = ranges[bucket]; -+ bucket_t sum; -+ memset(&sum, 0, sizeof(sum)); -+ if (threadIdx.x == 0) sum = buckets[bucket]; -+ for (uint32_t i = threadIdx.x; i < range.y; i += MSM_SPLIT_REDUCE_THREADS) { -+ bucket_t part = partials[range.x+i]; -+ sum.add(part); -+ } -+ scratch[threadIdx.x] = sum; -+ __syncthreads(); -+ for (uint32_t offset = MSM_SPLIT_REDUCE_THREADS/2; offset; offset >>= 1) { -+ if (threadIdx.x < offset) { -+ bucket_t other = scratch[threadIdx.x+offset]; -+ sum.add(other); -+ scratch[threadIdx.x] = sum; -+ } -+ __syncthreads(); -+ } -+ if (threadIdx.x == 0) buckets[bucket] = sum; -+ __syncthreads(); -+ } -+} -+#endif diff --git a/crates/proof-compression/cuda/plonk/runtime.cpp b/crates/proof-compression/cuda/plonk/runtime.cpp deleted file mode 100644 index d9c281a3c..000000000 --- a/crates/proof-compression/cuda/plonk/runtime.cpp +++ /dev/null @@ -1,2 +0,0 @@ -#define IX_KZG_CUDA_ARCHITECTURES IX_PLONK_CUDA_ARCHITECTURES -#include "../kzg_runtime.cpp" diff --git a/crates/proof-compression/cuda/plonk/sppark-source-manifest.json b/crates/proof-compression/cuda/plonk/sppark-source-manifest.json deleted file mode 100644 index 5487ece58..000000000 --- a/crates/proof-compression/cuda/plonk/sppark-source-manifest.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "format": "ix-native-source-files/v1", - "revision": "114feb69ebfe029e2caa5e6c2d372c695e5d4da8", - "files_sha256": { - ".github/workflows/ci.yml": "958b3723643b22b2e933a3aed90c8c1a41dc7a26927243b1742294d02def936a", - ".gitignore": "049fd316be29cbdbabba6f0e89864522b5fac1e735ec799087643a00702c1517", - "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", - "README.md": "e5e121c13e2f7928e8c477a235552c34ebafec375f944d13255b90e60ebff9a8", - "ec/affine_t.hpp": "94dff251d793c604a78a69dd858dc24b93b940803b31c9af169843dcad283d54", - "ec/jacobian_t.hpp": "92440bd6133aaec292e2a6c32daece1d25ac40e48de5e7c15dba7b46a007214a", - "ec/xyzz_t.hpp": "bc81198ba5600f6b8a392dd56ca4a6a82dbdf8c91289701eebfc6123dc6a5960", - "ff/alt_bn128-fp2.hpp": "370be2e47a337564c3fdcb6c74fb3d6dce0de6ecbcf2516c14664292684eb4cf", - "ff/alt_bn128.hpp": "4afca223a1fc4513c2b04dcb92d96fc0e739e2d777401ffbf17a09f2d813c63a", - "ff/baby_bear.hpp": "e6dc01638d24e98dbb410158975e78a9572b41347ab612f7a759ac2276cde1fd", - "ff/batch_inversion.hpp": "f7231fea765aad21a0adfaea22212ba177b5c237da273eefc775a74e98465bca", - "ff/bls12-377-fp2.hpp": "80bc99bbf50f03b391be78a8cb6949abb64dbfa07aec07e6b98fa79a495eec12", - "ff/bls12-377.hpp": "bdfe7f8a3783a67ef3d85d4b5558e2225f7b6cdb715eb412cf0501e7d976342f", - "ff/bls12-381-fp2.hpp": "bb37be5b4bedc043bed28d8663e18592f086d62944aa65e587a86409ee310ac2", - "ff/bls12-381.hpp": "4bd85884e46a9e3361cdb8afc1a34f9deb3da873edd14432b55fbe2a1d6c27ea", - "ff/gl64_t.cuh": "b9d8b1bc45c92007ee1f646e20c1328c0a03f7012eaf155d47601188d298f9d8", - "ff/gl64_t.hip": "0e7075344a0a4d15b856977775e794d559b328d8476c2f878f132a338d2a47f8", - "ff/goldilocks.hpp": "1c2bdd5149caca4f54f41a1b196a806737a7382dae54c4d7bc3c883ddcf02c16", - "ff/mersenne31.hpp": "0fc6b3803f67296ab935bac9c32cea3cfa8f183795fb28705def6ff487e1fdc5", - "ff/mont32_t.cuh": "5acd2a306044d008a159ecf5e0efa0c6fa5f75e932d1ad7cfadc05fcb8dadd03", - "ff/mont32_t.hip": "57f31f1ebd2525510c9b2b87fa1db980a75f0333583d1ff2be4e202f35f206ff", - "ff/mont_t.cuh": "f6f89b574507a6b07dec5e5f62d4d4d705a4a3b062187d7cda9d003809b96473", - "ff/mont_t.hip": "7ee6be71e2ebcb2c597f7c512f83e804622169567fd261c1f4b05251cc099b47", - "ff/pasta.hpp": "a2ee84fff357f62ba8028ccd6c60518fdb85f4885a94b9e03774ec581ecd3a6e", - "ff/pow.hpp": "487b8c43b7c64956f5ba5a45413c218c5a1848c2b56141f643b124bddb49611f", - "ff/shfl.cuh": "16e14553266d85a2b166fa565a2298d270bcadc9627310e1efe47823fc187157", - "go.mod": "cbd0a0e0cd8ba259631f0d9b940727d78ffcdd2fe70e00a355323c2f10f66521", - "go.sum": "6836d46d5b21f0de959d406ff3e89729faba54b6447416e51589e79544085842", - "go/README.md": "045d438434306bb2f57623209b6e1b2c31a045c194b7f39192db9b008bc56498", - "go/cgo_sppark.h": "b157bef8c7b30b85e623812a9a064d4741a3ebc4331195e7b238d7878d159028", - "go/sppark.go": "b2a06fa2ea4d01bd54c163ee3c53d079bba7fc6101862070a93bd386124ced91", - "msm/batch_addition.cuh": "73bac7598a34812c194e3db65bdb976e5d7236d9d6f17093275fc6743f3b2d0a", - "msm/pippenger.cuh": "413013c734be4aabb6a5905a890e43c5f87a4e5fb3c75494a580ded8c52d5634", - "msm/pippenger.hpp": "b4763b9afa0080dab4dd8ef9cac811c9ae48c890309d94fe60ffebba3152d9e1", - "msm/sort.cuh": "75bcd3c4eea0ab19f8ada599b33a906668ed791be327764148059dcbf597ea0d", - "ntt/kernels.cu": "9450f0197a9bd236c3164b606bf5057fd561aa4214ce050db4fbaba3327f1b83", - "ntt/kernels/ct_mixed_radix_narrow.cu": "b26c51c19dbc430a191f531d2b18b4c8534dd7de3bcd8955fa7ec157d6c7ba74", - "ntt/kernels/ct_mixed_radix_wide.cu": "a05717c8404148c125f205b03b9c13c5714c63dfeb9ce1c4304cc6d7867cb5df", - "ntt/kernels/gs_mixed_radix_narrow.cu": "35e1c0fb61383ffa08636845eefae12d7f8427f95cb2a1f2bd8093a6478b57b5", - "ntt/kernels/gs_mixed_radix_wide.cu": "bb49649a792216aa65d36391439ab3bedcf81345c0b2a4664aa5b8257ff27991", - "ntt/ntt.cuh": "39e840d8d2b89eaaf09df14816b7c117a0e7b7a2a175a5bd213b878cbb589fde", - "ntt/parameters.cuh": "07db8a4915359ddba20376f13dba466458131ff2aa6d6e0b95edb9c583446d67", - "ntt/parameters/.gitattributes": "454ab81e2b8651223acfdd141bbbde0fe85c3d5fa134a5a7a1c537c43033f370", - "ntt/parameters/alt_bn128.h": "e9366761b4f585e175507d49b44b36f415f0acd4aef5900377c4529415e8133e", - "ntt/parameters/baby_bear.h": "8a956fba97f9876a05cf604cb65b8c80f9162c9a57e84c699e365e4b7fb0923d", - "ntt/parameters/bls12_377.h": "4cfad166a70580372f30b62d025fced5e1a150734846f092878d0245dcef2cae", - "ntt/parameters/bls12_381.h": "42cacbb7999ccd82cdbfba0c41fd0dc0f90641db4eea47208d5f6f63d843f2c5", - "ntt/parameters/goldilocks.h": "b8bc924f19c66be6b82e0dd35cafc9284a4db25483468aff80ea2d1e26d6e5b4", - "ntt/parameters/pallas.h": "407d061cb5133e963ccc267116a79d4706e934d9fbcb41f000496b62d7710319", - "ntt/parameters/vesta.h": "41eec6b0a2e2cfd7035f63c17e4f0a3c77ae81bf0f3968131e7c0dece6117b4d", - "poc/go/cgo_sppark.h": "05f51438f4f976729814d3fef3ca442562806ed9588dfecc09fc8b92e3b7cd00", - "poc/go/go.mod": "dc2ebdf1ee4ee7fc063f1396377bbf36e1152e3129eefccd5254e0980636dcaa", - "poc/go/go.sum": "6836d46d5b21f0de959d406ff3e89729faba54b6447416e51589e79544085842", - "poc/go/poc.cu": "b10bb95f377fcbefe3b14d1180d8b8e5012c735fe5813e90bdfec2c08e63cf7e", - "poc/go/poc.go": "432e2c7e161349e3a8b3117ffe8942cf439cfeeadc1a6c58338d727c21c87e2c", - "poc/go/poc_test.go": "87491eb03348f6e48d4eb95f150a6dd15ae69bf8bc63a740a4a15b876f7dfbf2", - "poc/msm-cuda/.gitignore": "c1e953ee360e77de57f7b02f1b7880bd6a3dc22d1a69e953c2ac2c52cc52d247", - "poc/msm-cuda/Cargo.toml": "ee4928ef151eea8876978a1668ab955ce86317d94bf308048eddd73cf9543f9e", - "poc/msm-cuda/benches/msm.rs": "c5ef1df3307dd582c07bd3742d660e23bfb1b24acac3b18dc208b9ab2fd05d90", - "poc/msm-cuda/build.rs": "ea1ea7a84c9ebb248979270a9ffacfffad4ec7e1d35761fb195b8acf6dc84177", - "poc/msm-cuda/cuda/pippenger.cu": "0a0248556ff58061ec880e275ee151c6cb06f5d8ac188def8981465507ec9bcb", - "poc/msm-cuda/cuda/pippenger_inf.cu": "1f098eb7dd7e8ca09cbb0c9c88ccc2535cc87d6f6e94bc5fd0d324a7041f3a73", - "poc/msm-cuda/rustfmt.toml": "ad860a6eb3241ca19b7d0fe82a055b42998f5513883fddd889266f2bd2117dff", - "poc/msm-cuda/src/lib.c": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "poc/msm-cuda/src/lib.rs": "db829a2158542259de90086ebf5d09c42dfc3b2f2352ddcfdb66e194e0966b49", - "poc/msm-cuda/src/util.rs": "5c1c294f16950403d6758d26f5d87ea34440a207106254907d4eded459ede67e", - "poc/msm-cuda/tests/msm.rs": "25afcaca569213df0bd01c868f067c4ee2d68c946d5e90ec5bb81939a875de4a", - "poc/ntt-cuda/Cargo.toml": "343047a5df9c46f405d150755ac8d2b3909b91cd40b748e9caa282b4b213e949", - "poc/ntt-cuda/build.rs": "ae7ff9f35c30f70f95f5f7f312a3348f9a06daae0027a91c0a24846944cd6ec2", - "poc/ntt-cuda/cuda/ntt_api.cu": "2e3da799311e94607958b810c08739660bb43ca8fe935137cdc2c609a57324c5", - "poc/ntt-cuda/go.mod": "dc2ebdf1ee4ee7fc063f1396377bbf36e1152e3129eefccd5254e0980636dcaa", - "poc/ntt-cuda/go.sum": "6836d46d5b21f0de959d406ff3e89729faba54b6447416e51589e79544085842", - "poc/ntt-cuda/go/cgo_sppark.h": "05f51438f4f976729814d3fef3ca442562806ed9588dfecc09fc8b92e3b7cd00", - "poc/ntt-cuda/go/goldilocks.go": "76eeaadf3e375eb4b531772b16219400117391c0db6ca7e79452b97452d602eb", - "poc/ntt-cuda/go/goldilocks_test.go": "04fcc405e9128c24a53e0b875defd5e38a3766ea7d5839b802708d0175910054", - "poc/ntt-cuda/src/lib.rs": "e08266af058bb265a9b8dd4a1d4cfecd8ead6934bb64f1fd45ab7017afd10594", - "poc/ntt-cuda/tests/ntt.rs": "be2a48e16b1483e7778f8cd9f9bb74b69021635ab769d425b33e30dced456f7a", - "polynomial/div_by_x_minus_z.cuh": "5d167c13dc1efec1c557d2f221347735a508de0deaf79712adbcd89e54b2eaad", - "polynomial/evaluate.cuh": "23e9d073b49ef4f2d32ea6b3e74afbae916752cf1dfb876ff172999449744fc0", - "polynomial/prefix_op.cuh": "8475309a9a16e25f39d4e5989aed894cbb8679961417b9c773a00cce1f3e1cd3", - "rust/Cargo.toml": "8ed2ee1fef3fec740cbd0a13ed05bf9e9447f71938be020386587feacd6440db", - "rust/build.rs": "46fde4e59966227b57a266b582238bf6ece073d156e4dda052e17d6607527d44", - "rust/publish.sh": "bd2942dde82b3295c93f0e478e558dc929247764a4d4be9e053b526368263587", - "rust/src/build.rs": "496017dc50d77ffe5213908a969e990783b460af3b17e2f9bcdfc1010633fb16", - "rust/src/cuda_available.cpp": "4dea40a382f19a4d633dab8e22fda1a421d89028acb014c6d4a1bdeb4b1a6930", - "rust/src/lib.rs": "e5f656dce699ad7f6da2994a3bc15f7c872722676a2bad853c6c0af2077c3d78", - "util/all_gpus.cpp": "8dd47c76cab79d9a9b1b9855f75b5ee7b2058fb1f1e6c22121fb5763d2d34ff7", - "util/cuda2hip.hpp": "63fe68f78dc6d9257c56806a6490007cd34ca9573bd8793d5945283048f87d72", - "util/cuda_available.cu": "6c98730a7eb89e9fed52c8bf645b4779dc62316f0076ed79b821ced0618d205c", - "util/cuda_timer_t.cuh": "cde2feba8421c718892fa1c48bcab2066b1a8fc2bfdaf6e5e63a34b1bbbea628", - "util/exception.cuh": "eb7c6d2c30ec9fcab3b6551fa1b93a4e7b569dbfb225edc9c4a6564a3f41fa7f", - "util/exception.hpp": "3282cdbceac712613bd016a8df52fdc0071349ebdb97b840e84da2ebfdf1733b", - "util/gpu_t.cuh": "01ae74f52cc3754641b3eed40841771d2cc3efc688840ca7990c553e06251571", - "util/rusterror.h": "5b15b12c081f617cdb22dd059852a4b961fcc552315608b2bbd6ace1e4d7b0fa", - "util/slice_t.hpp": "18fd191d1c282dfda18bf03245a3352d4267d9a2a621c19f7602130f283662e9", - "util/thread_pool_t.hpp": "e37d39e7a7c4e58d8cd856bd082af70d8b3793618a9b8d89a824321dd58fbb43", - "util/vec2d_t.hpp": "d24662ca34ec438e854a9d0cc0b2e591feaf2c58ea17babb36fe6d1239ce5f41" - } -} diff --git a/crates/proof-compression/cuda/plonk/sppark_namespace.h b/crates/proof-compression/cuda/plonk/sppark_namespace.h deleted file mode 100644 index de9dbe983..000000000 --- a/crates/proof-compression/cuda/plonk/sppark_namespace.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -// Keep the standalone BN254 registry and NTT singletons separate from other -// sppark instantiations that may be linked into the same process. -#define gpu_t ix_plonk_native_gpu_t -#define stream_t ix_plonk_native_stream_t -#define event_t ix_plonk_native_event_t -#define gpu_ptr_t ix_plonk_native_gpu_ptr_t -#define dev_ptr_t ix_plonk_native_dev_ptr_t -#define select_gpu ix_plonk_native_select_gpu -#define gpu_props ix_plonk_native_gpu_props -#define all_gpus ix_plonk_native_all_gpus -#define ngpus ix_plonk_native_ngpus -#define cuda_available ix_plonk_native_cuda_available -#define drop_gpu_ptr_t ix_plonk_native_drop_gpu_ptr_t -#define clone_gpu_ptr_t ix_plonk_native_clone_gpu_ptr_t -#define drop_error_message ix_plonk_native_drop_error_message -#define sppark_error ix_plonk_native_sppark_error -#define cuda_error ix_plonk_native_cuda_error -#define NTT IxPlonkNativeNTT -#define NTTParameters IxPlonkNativeNTTParameters -#define CT_launcher IxPlonkNativeCTLauncher -#define GS_launcher IxPlonkNativeGSLauncher diff --git a/crates/proof-compression/root-layouts/ixvm-2026-10-29.json b/crates/proof-compression/root-layouts/ixvm-2026-10-29.json new file mode 100644 index 000000000..5b8da59c4 --- /dev/null +++ b/crates/proof-compression/root-layouts/ixvm-2026-10-29.json @@ -0,0 +1,153 @@ +{ + "claim_len": 18, + "format": "ix-fixed-root-layout-v1", + "log_heights": [ + 17, + 3, + 5, + 12, + 14, + 15, + 17, + 14, + 16, + 20, + 16, + 15, + 15, + 16, + 1, + 1, + 5, + 1, + 19, + 14, + 20, + 18, + 16, + 17, + 7, + 16, + 15, + 19, + 17, + 19, + 16, + 17, + 20, + 19, + 16, + 16, + 15, + 16, + 5, + 1, + 1, + 1, + 1, + 1, + 5, + 1, + 1, + 8, + 5, + 1, + 1, + 7, + 5, + 5, + 7, + 7, + 1, + 5, + 6, + 6, + 4, + 1, + 1, + 1, + 1, + 6, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 17, + 1, + 19, + 16, + 20, + 14, + 5, + 17, + 19, + 8, + 19, + 8, + 1, + 17, + 14, + 8, + 16 + ], + "memory_traces": [ + [ + 3, + 88 + ], + [ + 4, + 89 + ], + [ + 5, + 90 + ], + [ + 6, + 91 + ], + [ + 7, + 92 + ], + [ + 8, + 93 + ], + [ + 10, + 94 + ], + [ + 11, + 95 + ], + [ + 26, + 96 + ], + [ + 32, + 97 + ], + [ + 34, + 98 + ] + ] +} \ No newline at end of file diff --git a/crates/proof-compression/src/bin/ix_compression_worker.rs b/crates/proof-compression/src/bin/ix_compression_worker.rs deleted file mode 100644 index 459580655..000000000 --- a/crates/proof-compression/src/bin/ix_compression_worker.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Compression stages run in separate processes so each heavy phase releases -//! its memory before the next starts. Native gnark and CUDA support belong to -//! this worker; the Lean-facing Rust library launches it without those runtimes. -use tracing_subscriber::{ - Layer, filter::FilterFn, layer::SubscriberExt, util::SubscriberInitExt, -}; - -fn main() -> Result<(), Box> { - let args: Vec = std::env::args().skip(1).collect(); - let Some((stage, rest)) = args.split_first() else { - return Err( - "usage: ix-compression-worker " - .into(), - ); - }; - let texray = std::env::var("IX_TEXRAY").as_deref() == Ok("1"); - let timing = texray.then(|| { - tracing_texray::TeXRayLayer::new().streaming().track_ram().with_filter( - FilterFn::new(|metadata| { - metadata.is_span() - && (metadata.name().starts_with("compression/") - || matches!( - metadata.name(), - "stark/stage1_commit" - | "stark/lookup_construction" - | "stark/stage2_commit" - | "stark/quotient" - | "stark/fri_open" - )) - }), - ) - }); - tracing_subscriber::registry() - .with( - tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr) - .with_ansi(false) - .with_filter(tracing_subscriber::filter::LevelFilter::INFO), - ) - .with(timing) - .try_init()?; - let span = match stage.as_str() { - "fri" => tracing::info_span!("compression/fri"), - "kzg" => tracing::info_span!("compression/kzg"), - "wrap" => tracing::info_span!("compression/wrap"), - "pipeline" => tracing::info_span!("compression/pipeline"), - #[cfg(feature = "plonk")] - "plonk" => tracing::info_span!("compression/plonk"), - #[cfg(feature = "groth16")] - "groth16" => tracing::info_span!("compression/groth16"), - _ => return Err("unknown compression stage".into()), - }; - let span = if texray { tracing_texray::examine(span) } else { span }; - span.in_scope(|| ix_proof_compression::pipeline::command(stage, rest)) -} diff --git a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs index 181f75951..8192ec5e3 100644 --- a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs +++ b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs @@ -150,6 +150,9 @@ fn point_hex(point: G1Projective) -> String { } fn memory(device: i32) -> Value { + // libix_bn254_cuda.so keeps its CUDA runtime private; these queries read + // driver-wide state through a separate static runtime. + #[link(name = "cudart_static", kind = "static")] unsafe extern "C" { fn cudaMemGetInfo(free: *mut usize, total: *mut usize) -> i32; fn cudaDeviceGetDefaultMemPool(pool: *mut *mut c_void, device: i32) -> i32; diff --git a/crates/proof-compression/src/pipeline/compress.rs b/crates/proof-compression/src/pipeline/compress.rs new file mode 100644 index 000000000..93be310c8 --- /dev/null +++ b/crates/proof-compression/src/pipeline/compress.rs @@ -0,0 +1,731 @@ +//! Compression of a normalized aggregate root through FRI, first KZG, +//! recursive KZG and an optional terminal proof, in this process. +//! +//! Each stage reads its predecessor's directory under the output directory +//! and writes its own, ending with a `complete` marker that names the +//! configuration it was proved under. A rerun into the same directory loads +//! and checks every marked stage up to the first unmarked one, then deletes +//! and reruns that stage and all after it. +use super::{ + FixedRoot, Result, TerminalOptions, TerminalSystem, first, fixed_cache, + groth16, init_fri, plonk, root, + setup::{SetupClass, SetupConfig}, + stages, statement, storage, wrap, +}; +use std::{ + fs, io, + path::{Path, PathBuf}, + sync::mpsc, + time::Duration, +}; + +/// The proof `compress` returns, also left in its output directory. +const COMPRESSED_PROOF: &str = "compressed.bin"; +/// Written last into a stage directory, once its output has been checked. +const COMPLETE: &str = "complete"; +/// The root input directory under the output directory. +const ROOT: &str = "root"; + +/// The proof `compress` compresses and where its stages are kept. +#[derive(Clone, Copy)] +pub struct CompressInput<'a> { + /// The aggregation backend's verifying key. + pub vk: &'a [u8], + /// The aggregate root proof. + pub proof: &'a [u8], + /// The claim the root proves. + pub claim: &'a [u8], + /// The root statement the caller derived for `claim`. + pub statement: &'a [u8], + /// The fixed layout the root is normalized to, as JSON. + pub fixed_root: Option<&'a str>, + /// Stage directories: empty or missing to start, or an earlier run of the + /// same root to resume. + pub out: &'a Path, +} + +/// Everything the stages read besides their input directories. +pub struct CompressConfig { + pub setup: SetupConfig, + /// Fixed preprocessing shared between runs, keyed by executable and + /// stage verifying key. + pub fixed_cache: PathBuf, + /// Compiled PLONK circuits, keys and parameter exports. + pub plonk_cache: PathBuf, + /// Log2 of the first KZG stage's trace height: 24 or 25. + pub log_height: u32, + pub terminal: TerminalOptions, + /// Host memory the first KZG stage may spend prefetching partitions. + pub prefetch_bytes: usize, + /// KZG CUDA backend settings, installed once before the first KZG stage. + #[cfg(feature = "cuda")] + pub cuda: crate::kzg::cuda::Settings, +} + +impl CompressConfig { + /// The configuration `ix compress` runs with. `development` selects the + /// known-trapdoor setup. Cross-run caches live under `cache_root` unless + /// `IX_KZG_FIXED_CACHE` or `IX_KZG_SRS_CACHE` name other directories; the + /// setup, prefetch budget (`IX_KZG_PREFETCH_GIB`) and CUDA settings + /// (`IX_KZG_CUDA_*`, `IX_KZG_MSM_CHUNK_POINTS`) come from the environment. + pub fn from_env( + cache_root: &Path, + development: bool, + log_height: u32, + terminal: TerminalOptions, + ) -> Result { + let mut setup = SetupConfig::from_env(development)?; + if let SetupConfig::Development { cache_directory: directory @ None } = + &mut setup + { + *directory = Some(cache_root.join("development-srs")); + } + let prefetch_gib = match std::env::var("IX_KZG_PREFETCH_GIB") { + Ok(value) => value.parse::()?, + Err(std::env::VarError::NotPresent) => 32, + Err(error) => return Err(error.into()), + }; + Ok(Self { + setup, + fixed_cache: std::env::var_os("IX_KZG_FIXED_CACHE") + .map_or_else(|| cache_root.join("fixed-preprocessing"), PathBuf::from), + plonk_cache: cache_root.join("plonk"), + log_height, + terminal, + prefetch_bytes: prefetch_gib + .checked_mul(1 << 30) + .ok_or("prefetch budget overflow")?, + #[cfg(feature = "cuda")] + cuda: cuda_settings_from_env()?, + }) + } + + /// Reject unsupported combinations before any checkpoint is written. + fn validate(&self) -> Result<()> { + let terminal = self.terminal; + if terminal.allow_development && terminal.system != TerminalSystem::Groth16 + { + return Err( + "development terminal keys are supported only for Groth16".into(), + ); + } + if terminal.system != TerminalSystem::None + && self.setup.class() == SetupClass::Development + { + return Err( + "terminal proofs require admitted inner KZG parameters; omit --dev-srs" + .into(), + ); + } + match terminal.system { + TerminalSystem::Plonk if !cfg!(feature = "plonk") => { + return Err( + "this ix was built without PLONK; rebuild with IX_PLONK=1".into(), + ); + }, + TerminalSystem::Plonk + if std::env::var("IX_PLONK_BACKEND").as_deref() == Ok("cuda") + && !cfg!(feature = "cuda") => + { + return Err( + "IX_PLONK_BACKEND=cuda requires a build with IX_CUDA=1".into(), + ); + }, + TerminalSystem::Groth16 if !cfg!(feature = "groth16") => { + return Err( + "this ix was built without Groth16; rebuild with IX_GROTH16=1".into(), + ); + }, + TerminalSystem::Groth16 => { + groth16::preflight(terminal.allow_development)? + }, + _ => {}, + } + if !matches!(self.log_height, 24 | 25) { + return Err( + "--log-height must be 24 or 25 for the supported recursive verifying keys" + .into(), + ); + } + if std::env::var_os("IX_ROOT_GENERIC_HASHES").is_some() { + return Err( + "unset IX_ROOT_GENERIC_HASHES for the supported compression verifying key" + .into(), + ); + } + Ok(()) + } + + /// The setup a KZG stage's marker records: a stage proved under other + /// parameters is rerun. + fn setup_key(&self) -> String { + match &self.setup { + SetupConfig::Ignition { manifest, .. } => { + format!("ignition {}", blake3::Hash::from_bytes(*manifest).to_hex()) + }, + SetupConfig::Development { .. } => "development".into(), + } + } +} + +/// Read the KZG CUDA backend settings from the environment. The pipeline +/// proves on device 0 unless `IX_KZG_CUDA_DEVICES` lists others. +#[cfg(feature = "cuda")] +fn cuda_settings_from_env() -> Result { + use crate::kzg::cuda::{PassMode, Settings}; + let var = |name: &str| std::env::var(name).ok(); + let devices = match var("IX_KZG_CUDA_DEVICES") { + Some(list) => list + .split(',') + .map(|id| id.trim().parse()) + .collect::, _>>() + .map_err(|_error| "IX_KZG_CUDA_DEVICES must list CUDA ordinals")?, + None => vec![0], + }; + let mut settings = Settings { devices: Some(devices), ..Settings::default() }; + if let Some(value) = var("IX_KZG_MSM_CHUNK_POINTS") { + settings.msm_chunk_points = value + .parse() + .ok() + .filter(|points| (1..=1 << 28).contains(points)) + .ok_or("IX_KZG_MSM_CHUNK_POINTS must be in 1..=2^28")?; + } + if let Some(value) = var("IX_KZG_CUDA_RESIDENT_GIB") { + settings.resident_limit_bytes = Some( + value + .parse::() + .ok() + .and_then(|gib| gib.checked_mul(1 << 30)) + .ok_or("IX_KZG_CUDA_RESIDENT_GIB must be a whole number of GiB")?, + ); + } + match var("IX_KZG_CUDA_SRS_CACHE").as_deref() { + None => {}, + Some("1") => settings.srs_cache = true, + Some("0") => settings.srs_cache = false, + Some(other) => { + return Err( + format!("IX_KZG_CUDA_SRS_CACHE must be 1 or 0, not {other}").into(), + ); + }, + } + settings.passes = match var("IX_KZG_CUDA_PASSES").as_deref() { + None | Some("1") => PassMode::Fallback, + Some("0") => PassMode::Off, + Some("force") => PassMode::Force, + Some(other) => { + return Err( + format!("IX_KZG_CUDA_PASSES must be 0, 1 or force, not {other}").into(), + ); + }, + }; + Ok(settings) +} + +/// What the KZG stage functions read from a compression's configuration. +pub(crate) struct StageConfig { + pub(super) setup: SetupConfig, + /// The fixed-preprocessing cache root; `None` keeps the cache inside the + /// stage directory. + pub(super) fixed_cache_root: Option, + pub(super) prefetch_bytes: usize, +} + +impl StageConfig { + fn from_config(config: &CompressConfig) -> Self { + Self { + setup: config.setup.clone(), + fixed_cache_root: Some(config.fixed_cache.clone()), + prefetch_bytes: config.prefetch_bytes, + } + } + + pub(super) fn fixed_cache( + &self, + stage: &Path, + vk: &[u8], + ) -> fixed_cache::FixedCache { + let root = self + .fixed_cache_root + .clone() + .unwrap_or_else(|| stage.join("kzg/fixed-cache")); + fixed_cache::FixedCache::from_identity(&root, vk) + } + + #[cfg(test)] + pub(super) const fn development() -> Self { + Self { + setup: SetupConfig::Development { cache_directory: None }, + fixed_cache_root: None, + prefetch_bytes: 0, + } + } +} + +/// The files of a root input directory: the backend key, root proof, +/// expected statement and, for a normalized root, its fixed layout. +pub(super) fn root_files( + vk: &[u8], + proof: &[u8], + words: &statement::Statement, + fixed_root: Option<&str>, +) -> Result)>> { + let mut files = vec![ + ("root-vk.bin", vk.to_vec()), + ("root-proof.bin", proof.to_vec()), + ("root-claims.bin", statement::to_claims_bytes(words)), + ]; + if let Some(layout) = fixed_root { + files.push(( + root::FIXED_ROOT_LAYOUT, + FixedRoot::from_json(layout)?.to_json().into_bytes(), + )); + } + Ok(files) +} + +/// Prepare `out` to hold the stages of the root in `files`. An empty or +/// missing directory starts a new compression; any other must hold a `root/` +/// whose files match these, or it is refused unchanged. Root files an +/// interrupted run did not write are written. +fn open_output(out: &Path, files: &[(&'static str, Vec)]) -> Result<()> { + fs::create_dir_all(out)?; + let root = out.join(ROOT); + if fs::read_dir(out)?.next().is_some() { + let refused = || -> Box { + format!( + "{} holds another compression: its root/ differs from this proof, \ + layout or statement; choose another --out directory", + out.display() + ) + .into() + }; + if !root.is_dir() { + return Err(refused()); + } + for name in [ + "root-vk.bin", + "root-proof.bin", + "root-claims.bin", + root::FIXED_ROOT_LAYOUT, + ] { + let expected = files.iter().find(|(file, _)| *file == name); + match fs::read(root.join(name)) { + Ok(bytes) => match expected { + Some((_, expected)) + if blake3::hash(&bytes) == blake3::hash(expected) => {}, + _ => return Err(refused()), + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error.into()), + } + } + } + fs::create_dir_all(&root)?; + for (name, bytes) in files { + let path = root.join(name); + if !path.try_exists()? { + storage::write_atomic(&path, bytes)?; + } + } + Ok(()) +} + +/// Compress the normalized root in `input` to a recursive KZG proof, or with +/// a terminal system to that system's proof envelope, and return it. Stages +/// run in this process and keep their directories under `input.out`, so an +/// interrupted run resumes from its last complete stage when rerun with the +/// same input and output directory. +pub fn compress( + input: CompressInput<'_>, + config: &CompressConfig, +) -> Result> { + let words = super::check_claim(input.claim, input.statement)?; + config.validate()?; + if config.terminal.system == TerminalSystem::Groth16 { + groth16::check_statement(&words, config.terminal.allow_development)?; + } + let files = root_files(input.vk, input.proof, &words, input.fixed_root)?; + open_output(input.out, &files)?; + drop(files); + let _monitor = MemoryMonitor::start(); + let mut checkpoints = Checkpoints { out: input.out, rerun: false }; + let proof = run_stages(&mut checkpoints, config, &words)?; + storage::write_atomic(&input.out.join(COMPRESSED_PROOF), &proof)?; + Ok(proof) +} + +/// Every stage from the root input to the returned proof. +fn run_stages( + checkpoints: &mut Checkpoints<'_>, + config: &CompressConfig, + words: &statement::Statement, +) -> Result> { + let root = checkpoints.out.join(ROOT); + let fri = checkpoints.stage( + "fri", + "fri/v1", + &tracing::info_span!("compression/fri"), + |dir| root::run(&root, dir, true), + |dir| check_fri(dir, words), + )?; + #[cfg(feature = "cuda")] + crate::kzg::cuda::configure(config.cuda.clone()); + let stage = StageConfig::from_config(config); + let setup = config.setup_key(); + let log_height = usize::try_from(config.log_height)?; + let first = checkpoints.stage( + "first-kzg", + &format!("first-kzg/v1 log-height={log_height} setup={setup}"), + &tracing::info_span!("compression/kzg"), + |dir| first::run(&fri, dir, log_height, &stage), + |dir| check_first(dir, words), + )?; + let final_dir = checkpoints.stage( + "final-kzg", + &format!("final-kzg/v1 setup={setup}"), + &tracing::info_span!("compression/wrap"), + |dir| wrap::run(&first, dir, &stage), + |dir| check_final(dir, words, &config.setup), + )?; + drop(stage); + let terminal = config.terminal; + let key = format!( + "{:?}/v1 allow-development={} setup={setup}", + terminal.system, terminal.allow_development + ); + let dir = match terminal.system { + TerminalSystem::None => { + let mut proof = super::PROOF_TAG.to_vec(); + proof.extend(fs::read(final_dir.join("kzg/packet.bin"))?); + return Ok(proof); + }, + TerminalSystem::Plonk => checkpoints.stage( + "plonk", + &key, + &tracing::info_span!("compression/plonk"), + |dir| prove_plonk(&final_dir, dir, words, config), + |dir| check_envelope(dir, plonk::PROOF_TAG), + )?, + TerminalSystem::Groth16 => checkpoints.stage( + "groth16", + &key, + &tracing::info_span!("compression/groth16"), + |dir| prove_groth16(&final_dir, dir, words, config), + |dir| check_envelope(dir, groth16::PROOF_TAG), + )?, + }; + Ok(fs::read(dir.join("proof.envelope.bin"))?) +} + +/// The stage directories under a compression's output directory. +struct Checkpoints<'a> { + out: &'a Path, + /// Set once a stage has run, so every later stage runs too. + rerun: bool, +} + +impl Checkpoints<'_> { + /// The directory of stage `name`. When it is marked complete under `key` + /// and no earlier stage ran, it is checked and reused; otherwise it is + /// deleted, proved again, checked and marked. Pooled GPU memory is + /// returned to the driver after the stage proves, whether or not it + /// succeeded. + fn stage( + &mut self, + name: &str, + key: &str, + span: &tracing::Span, + prove: impl FnOnce(&Path) -> Result<()>, + check: impl FnOnce(&Path) -> Result<()>, + ) -> Result { + let dir = self.out.join(name); + let marker = dir.join(COMPLETE); + if !self.rerun { + match fs::read(&marker) { + Ok(recorded) if recorded == key.as_bytes() => { + check(&dir).map_err(|error| { + format!( + "completed compression {name} stage in {} fails its check: \ + {error}; delete it to prove it again", + dir.display() + ) + })?; + tracing::info!("compression {name}: reusing the completed stage"); + return Ok(dir); + }, + Ok(_) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error.into()), + } + } + self.rerun = true; + match fs::remove_dir_all(&dir) { + Ok(()) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error.into()), + } + let proved = span.in_scope(|| prove(&dir)); + #[cfg(feature = "cuda")] + multi_stark::cuda::release_device_memory(); + proved.and_then(|()| check(&dir)).map_err(|error| { + format!( + "compression {name} failed: {error}; completed stages are kept for a \ + rerun" + ) + })?; + storage::write_atomic(&marker, key.as_bytes())?; + Ok(dir) + } +} + +/// The saved FRI proof exposes the expected root statement. +fn check_fri(dir: &Path, words: &statement::Statement) -> Result<()> { + let fixture = init_fri::load(dir)?; + let actual = + statement::from_scalars(&stages::scalar_statement(&fixture.public))?; + if actual != *words { + return Err( + "saved FRI statement differs from the expected root statement".into(), + ); + } + Ok(()) +} + +/// The first KZG packet carries the expected statement after its verifying +/// key id. The recursive stage verifies its proof. +fn check_first(dir: &Path, words: &statement::Statement) -> Result<()> { + let packet = fs::read(dir.join("kzg/packet.bin"))?; + let encoded: Vec = + words.iter().flat_map(|word| word.to_le_bytes()).collect(); + if packet.get(32..32 + encoded.len()) != Some(encoded.as_slice()) { + return Err( + "first KZG packet differs from the expected root statement".into(), + ); + } + Ok(()) +} + +/// The recursive verifying key is admitted by the setup and verifies the +/// recursive packet for the expected statement. +fn check_final( + dir: &Path, + words: &statement::Statement, + setup: &SetupConfig, +) -> Result<()> { + let vk = wrap::VerifyingKey::from_bytes(&fs::read(dir.join("vk.bin"))?)?; + if let Some(admission) = setup.ignition_admission() { + vk.check_ignition_admission(admission)?; + } else { + vk.check_setup_class(setup.class())?; + } + vk.verify(&fs::read(dir.join("kzg/packet.bin"))?, words)?; + Ok(()) +} + +/// The terminal stage left a proof envelope of its system. +fn check_envelope(dir: &Path, tag: &[u8]) -> Result<()> { + let envelope = fs::read(dir.join("proof.envelope.bin"))?; + if envelope.len() <= tag.len() + 32 || !envelope.starts_with(tag) { + return Err("terminal proof envelope is malformed".into()); + } + Ok(()) +} + +#[cfg(feature = "plonk")] +fn prove_plonk( + final_dir: &Path, + out: &Path, + words: &statement::Statement, + config: &CompressConfig, +) -> Result<()> { + let verifier = final_dir.join("vk.bin"); + let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); + plonk::wrap_packet( + &verifier, + &final_dir.join("kzg/packet.bin"), + trusted.as_str(), + words, + &config.plonk_cache, + out, + &config.setup, + ) +} + +#[cfg(not(feature = "plonk"))] +fn prove_plonk( + _final_dir: &Path, + _out: &Path, + _words: &statement::Statement, + _config: &CompressConfig, +) -> Result<()> { + Err("this ix was built without PLONK; rebuild with IX_PLONK=1".into()) +} + +#[cfg(feature = "groth16")] +fn prove_groth16( + final_dir: &Path, + out: &Path, + words: &statement::Statement, + config: &CompressConfig, +) -> Result<()> { + let verifier = final_dir.join("vk.bin"); + let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); + groth16::wrap_packet( + &verifier, + &final_dir.join("kzg/packet.bin"), + trusted.as_str(), + words, + out, + config.terminal.allow_development, + &config.setup, + ) +} + +#[cfg(not(feature = "groth16"))] +fn prove_groth16( + _final_dir: &Path, + _out: &Path, + _words: &statement::Statement, + _config: &CompressConfig, +) -> Result<()> { + Err("this ix was built without Groth16; rebuild with IX_GROTH16=1".into()) +} + +/// Logs the process's resident and peak resident size every 30 seconds +/// until dropped, so a long stage's memory profile is visible in its log. +struct MemoryMonitor { + _stop: mpsc::Sender<()>, +} + +impl MemoryMonitor { + fn start() -> Self { + let (stop, stopped) = mpsc::channel::<()>(); + std::thread::spawn(move || { + while let Err(mpsc::RecvTimeoutError::Timeout) = + stopped.recv_timeout(Duration::from_secs(30)) + { + if let Ok(status) = fs::read_to_string("/proc/self/status") { + eprintln!( + "{}", + status + .lines() + .filter(|l| l.starts_with("VmRSS:") || l.starts_with("VmHWM:")) + .collect::>() + .join(" ") + ); + } + } + }); + Self { _stop: stop } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let path = std::env::temp_dir() + .join(format!("ix-compress-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + path + } + + fn files(proof: &[u8]) -> Vec<(&'static str, Vec)> { + root_files(b"vk", proof, &[7; statement::WORDS], None).unwrap() + } + + #[test] + fn output_resumes_only_the_same_root() -> Result<()> { + let out = scratch("open-output"); + open_output(&out, &files(b"proof"))?; + assert_eq!(fs::read(out.join("root/root-proof.bin"))?, b"proof"); + // An interrupted root write is completed. + fs::remove_file(out.join("root/root-claims.bin"))?; + open_output(&out, &files(b"proof"))?; + assert!(out.join("root/root-claims.bin").is_file()); + // Another proof, or a layout the earlier run did not have, is refused + // without changing the directory. + fs::create_dir(out.join("fri"))?; + assert!(open_output(&out, &files(b"other")).is_err()); + fs::write(out.join("root").join(root::FIXED_ROOT_LAYOUT), b"{}")?; + assert!(open_output(&out, &files(b"proof")).is_err()); + fs::remove_file(out.join("root").join(root::FIXED_ROOT_LAYOUT))?; + assert_eq!(fs::read(out.join("root/root-proof.bin"))?, b"proof"); + assert!(out.join("fri").is_dir()); + // A non-empty directory without a root is refused. + let other = scratch("open-output-foreign"); + fs::create_dir_all(other.join("data"))?; + assert!(open_output(&other, &files(b"proof")).is_err()); + assert!(!other.join(ROOT).exists()); + fs::remove_dir_all(other)?; + fs::remove_dir_all(out)?; + Ok(()) + } + + fn checkpoints(out: &Path) -> Checkpoints<'_> { + Checkpoints { out, rerun: false } + } + + #[test] + fn completed_stages_are_reused_until_one_reruns() -> Result<()> { + let out = scratch("checkpoints"); + let proved = std::cell::RefCell::new(Vec::new()); + let run = + |checkpoints: &mut Checkpoints<'_>, keys: [&str; 3]| -> Result<()> { + for (name, key) in ["a", "b", "c"].into_iter().zip(keys) { + checkpoints.stage( + name, + key, + &tracing::Span::none(), + |dir| { + proved.borrow_mut().push(name); + fs::create_dir_all(dir)?; + Ok(fs::write(dir.join("output"), name)?) + }, + |dir| { + if fs::read(dir.join("output"))? == name.as_bytes() { + Ok(()) + } else { + Err("damaged".into()) + } + }, + )?; + } + Ok(()) + }; + run(&mut checkpoints(&out), ["1", "1", "1"])?; + assert_eq!(proved.take(), ["a", "b", "c"]); + run(&mut checkpoints(&out), ["1", "1", "1"])?; + assert!(proved.take().is_empty()); + // A stage without its marker reruns, and so does every later stage. + fs::remove_file(out.join("b").join(COMPLETE))?; + fs::write(out.join("b/stale"), "")?; + run(&mut checkpoints(&out), ["1", "1", "1"])?; + assert_eq!(proved.take(), ["b", "c"]); + assert!(!out.join("b/stale").exists()); + // A stage marked under another configuration reruns. + run(&mut checkpoints(&out), ["1", "1", "2"])?; + assert_eq!(proved.take(), ["c"]); + // A marked stage that fails its check is reported, not overwritten. + fs::write(out.join("a/output"), "x")?; + assert!(run(&mut checkpoints(&out), ["1", "1", "2"]).is_err()); + assert!(proved.take().is_empty()); + // A failed stage is left unmarked. + let failing = checkpoints(&out).stage( + "d", + "1", + &tracing::Span::none(), + |dir| { + fs::create_dir_all(dir)?; + Err("failed".into()) + }, + |_dir| Ok(()), + ); + assert!(failing.is_err()); + assert!(!out.join("d").join(COMPLETE).exists()); + fs::remove_dir_all(out)?; + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/driver.rs b/crates/proof-compression/src/pipeline/driver.rs deleted file mode 100644 index decc9c419..000000000 --- a/crates/proof-compression/src/pipeline/driver.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Saved-proof compression and verification with an explicitly pinned verifying key. - -use super::{ - Result, - setup::{SetupClass, SetupConfig}, - storage, -}; -use crate::kzg::curve; -use std::{fs, path::Path}; - -const USAGE: &str = "usage: ix-compression-worker pipeline run [24|25] [--dev-srs]\n ix-compression-worker pipeline run-fri [24|25] [--dev-srs]\n ix-compression-worker pipeline verify [--dev-srs]\n ix-compression-worker pipeline verify-ignition "; - -fn arguments(args: &[String]) -> Result<(Vec<&str>, bool)> { - let mut positional = Vec::new(); - let mut development = false; - for arg in args { - if arg == "--dev-srs" { - if development { - return Err("duplicate --dev-srs".into()); - } - development = true; - } else if arg.starts_with("--") { - return Err(format!("unknown pipeline option: {arg}").into()); - } else { - positional.push(arg.as_str()); - } - } - Ok((positional, development)) -} - -fn height(args: &[&str]) -> Result { - let height = - args.first().map(|value| value.parse()).transpose()?.unwrap_or(24); - if args.len() > 1 || !matches!(height, 24 | 25) { - return Err("pipeline log-height must be 24 or 25".into()); - } - Ok(height) -} - -fn digest(text: &str) -> Result<[u8; 32]> { - if text.len() != 64 || !text.is_ascii() { - return Err( - "trusted verifying key BLAKE3 must contain exactly 64 hexadecimal characters" - .into(), - ); - } - let mut digest = [0; 32]; - for (i, byte) in digest.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; - } - Ok(digest) -} - -fn publish( - out: &Path, - envelope: &[u8], - claim: &[u8], - expected: &[u8], - source: &str, - setup: &SetupConfig, -) -> Result<()> { - let vk_path = out.join("final-kzg/vk.bin"); - let vk = fs::read(&vk_path)?; - let vk_digest = *blake3::hash(&vk).as_bytes(); - let verified_setup = if let Some(admission) = setup.ignition_admission() { - super::verify_with_ignition_admission( - envelope, claim, expected, &vk, &vk_digest, admission, - )? - } else { - super::verify_with_vk( - envelope, - claim, - expected, - &vk, - &vk_digest, - setup.class() == SetupClass::Development, - )? - }; - if verified_setup != setup.class() { - return Err("completed proof differs from the selected setup class".into()); - } - storage::write_atomic(&out.join("proof.envelope.bin"), envelope)?; - storage::write_atomic(&out.join("expected-root-claims.bin"), expected)?; - let report = serde_json::json!({ - "format": "ix-saved-compression-run/v1", - "curve": curve::NAME, - "input_stage": source, - "setup": verified_setup.label(), - "known_trapdoor": verified_setup == SetupClass::Development, - "cuda": cfg!(feature = "cuda"), - "parallel": cfg!(feature = "parallel"), - "native_verification_passed": true, - "proof_bytes": envelope.len(), - "proof_blake3": blake3::hash(envelope).to_hex().as_str(), - "vk": "final-kzg/vk.bin", - "vk_blake3": blake3::Hash::from_bytes(vk_digest).to_hex().as_str(), - "expected_root_claims": "expected-root-claims.bin", - }); - storage::write_atomic( - &out.join("pipeline-report.json"), - &serde_json::to_vec_pretty(&report)?, - )?; - println!("{}", serde_json::to_string_pretty(&report)?); - Ok(()) -} - -pub(super) fn run(args: &[String]) -> Result<()> { - let (positional, development) = arguments(args)?; - let Some((command, args)) = positional.split_first() else { - return Err(USAGE.into()); - }; - match (*command, args) { - ("run", [root, claim, out, log @ ..]) if log.len() <= 1 => { - let log_height = height(log)?; - let setup = SetupConfig::from_env(development)?; - let root = Path::new(root); - let out = Path::new(out); - let claim = fs::read(claim)?; - let expected = fs::read(root.join("root-claims.bin"))?; - let layout = root.join(super::root::FIXED_ROOT_LAYOUT); - let fixed_root = - layout.exists().then(|| fs::read_to_string(&layout)).transpose()?; - let envelope = super::compress( - &fs::read(root.join("root-vk.bin"))?, - &fs::read(root.join("root-proof.bin"))?, - &claim, - &expected, - fixed_root.as_deref(), - out, - &out.join("cache"), - development, - log_height, - false, - )?; - publish(out, &envelope, &claim, &expected, "root", &setup) - }, - ("run-fri", [fri, claim, expected, out, log @ ..]) if log.len() <= 1 => { - let log_height = height(log)?; - let setup = SetupConfig::from_env(development)?; - let out = Path::new(out); - let claim = fs::read(claim)?; - let expected = fs::read(expected)?; - let envelope = super::compress_fri( - Path::new(fri), - &claim, - &expected, - out, - &out.join("cache"), - development, - log_height, - )?; - publish(out, &envelope, &claim, &expected, "fri", &setup) - }, - ( - "verify-ignition", - [proof, claim, expected, vk, trusted, admission, admitted], - ) => { - if development { - return Err("verify-ignition does not accept --dev-srs".into()); - } - let trusted = digest(trusted)?; - let admitted = digest(admitted)?; - let admission = crate::kzg::srs::ignition::IgnitionAdmission::read( - Path::new(admission), - admitted, - )?; - let class = super::verify_with_ignition_admission( - &fs::read(proof)?, - &fs::read(claim)?, - &fs::read(expected)?, - &fs::read(vk)?, - &trusted, - &admission, - )?; - println!( - "{}", - serde_json::json!({ - "curve": curve::NAME, - "verified": true, - "setup": class.label(), - "vk_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), - "admission_blake3": blake3::Hash::from_bytes(admitted).to_hex().as_str(), - }) - ); - Ok(()) - }, - ("verify", [proof, claim, expected, vk, trusted]) => { - let trusted = digest(trusted)?; - let class = super::verify_with_vk( - &fs::read(proof)?, - &fs::read(claim)?, - &fs::read(expected)?, - &fs::read(vk)?, - &trusted, - development, - )?; - println!( - "{}", - serde_json::json!({ - "curve": curve::NAME, - "verified": true, - "setup": class.label(), - "vk_blake3": blake3::Hash::from_bytes(trusted).to_hex().as_str(), - }) - ); - Ok(()) - }, - _ => Err(USAGE.into()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn options_and_vk_digest_are_explicit() { - let args = ["run", "root", "claim", "out", "--dev-srs"].map(str::to_owned); - let (positional, development) = arguments(&args).unwrap(); - assert_eq!(positional, ["run", "root", "claim", "out"]); - assert!(development); - assert!(arguments(&["--dev-srs".into(), "--dev-srs".into()]).is_err()); - assert!(arguments(&["--unknown".into()]).is_err()); - assert_eq!(height(&[]).unwrap(), 24); - assert_eq!(height(&["25"]).unwrap(), 25); - assert!(height(&["28"]).is_err()); - assert!(height(&["24", "25"]).is_err()); - assert_eq!(digest(&"a5".repeat(32)).unwrap(), [0xa5; 32]); - for text in ["ab".repeat(31), "gg".repeat(32), "é".repeat(32)] { - assert!(digest(&text).is_err()); - } - } -} diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index 0205a893a..f3661a0b7 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -30,95 +30,13 @@ use super::{init_fri, prover}; pub(super) const MIN_PARTITIONS: usize = 11; pub(super) use super::stages::statement::first_claims as expected_claims; -fn log_height_arg(arg: Option<&String>) -> storage::Result { - let log_height = arg.map(|s| s.parse::()).transpose()?.unwrap_or(24); - let maximum = 28.min(Scalar::TWO_ADICITY - 1); - if !(24..=maximum).contains(&log_height) { - return Err( - format!( - "log-height must be 24..{maximum} for {} with quotient factor 2", - crate::kzg::curve::NAME - ) - .into(), - ); - } - Ok(log_height) -} - +/// Translate, lower and prove the saved FRI proof in `fri`, writing the +/// first KZG stage to `out`. pub(super) fn run( - args: &[String], - config: &super::WorkerConfig, -) -> storage::Result<()> { - const USAGE: &str = "usage: ix-compression-worker kzg run [log-height] [--dev-srs] | kzg size [--dev-srs]"; - let Some(command) = args.first() else { - return Err(USAGE.into()); - }; - let rest = &args[1..]; - match command.as_str() { - "size" if rest.len() == 2 => size(Path::new(&rest[0]), Path::new(&rest[1])), - "run" if (2..=3).contains(&rest.len()) => { - let log_height = log_height_arg(rest.get(2))?; - stage_and_prove( - Path::new(&rest[0]), - Path::new(&rest[1]), - log_height, - config, - ) - }, - _ => Err(USAGE.into()), - } -} - -fn size(fri: &Path, report: &Path) -> storage::Result<()> { - let start = Instant::now(); - let fixture = init_fri::load(fri)?; - let plan = VerifierPlan::validate( - &fixture.key, - fixture.profile, - VerifierLimits::default(), - )?; - let plan_id = plan.identity(&fixture.schema)?; - let source = FriConstruction::build(&plan, fixture.schema)?.circuit; - let translated = crate::foreign::GoldilocksCircuit::estimate(&source); - let counts = |stats: multi_stark::plonkish::CircuitStats| { - serde_json::json!({ - "values": stats.values, - "inputs": stats.inputs, - "gates": stats.gates, - "lookups": stats.lookups, - "tables": stats.tables, - "publics": stats.publics, - "hint_calls": stats.hint_calls, - "hint_outputs": stats.hint_outputs, - "hash_calls": stats.hash_calls, - "hash_compressions": stats.hash_compressions, - }) - }; - let result = serde_json::json!({ - "format": "ix-first-kzg-sizing/v1", - "curve": crate::kzg::curve::NAME, - "native_input_verified": true, - "plan_id": blake3::Hash::from_bytes(plan_id).to_hex().as_str(), - "source_frontend": counts(source.stats()), - "scalar_frontend": counts(translated), - "scalar_bytes": size_of::(), - "g1_affine_bytes": size_of::(), - "max_trace_log_with_quotient2": Scalar::TWO_ADICITY - 1, - "elapsed_seconds": start.elapsed().as_secs_f64(), - "srs_loaded": false, - "proof_generated": false, - "scope": "Exact translated frontend counts; retains the source Goldilocks IR and translation wire map, not the translated IR or proving traces. Padded layouts, custom trace widths and total prover memory require separate accounting.", - }); - storage::write_atomic(report, &serde_json::to_vec_pretty(&result)?)?; - println!("{}", serde_json::to_string_pretty(&result)?); - Ok(()) -} - -fn stage_and_prove( fri: &Path, out: &Path, log_height: usize, - config: &super::WorkerConfig, + config: &super::StageConfig, ) -> storage::Result<()> { let trace_height = 1usize << log_height; fs::create_dir_all(out)?; @@ -142,7 +60,7 @@ fn stage_and_prove( let lowered = construct::prepare_first(prepared, trace_height, construct::TABLE_HEIGHT)?; assert_eq!(lowered.assignment.public_values(), public); - println!( + eprintln!( "Scalar translation and lowering of {} circuits: {:?}; {:?}", lowered.compiled.num_circuits(), start.elapsed(), @@ -152,7 +70,7 @@ fn stage_and_prove( let setup = config.setup.for_stage(FIRST_SEED, height)?; setup.bind_stage(out, height, 2)?; let setup_id = setup.identity(height, 2)?; - let cache = config.fixed_cache(out, &[plan_id, setup_id].concat())?; + let cache = config.fixed_cache(out, &[plan_id, setup_id].concat()); let cached = cache.restore(out)?; let manifest = storage::Manifest::for_compiled( &lowered.compiled, @@ -160,9 +78,9 @@ fn stage_and_prove( cached.as_ref(), )?; storage::save(&out.join("manifest.bin"), &manifest)?; - println!("Generating fixed and witness traces directly into commitment"); + eprintln!("Generating fixed and witness traces directly into commitment"); prove(out, lowered, &cache, config)?; - println!("RUN COMPLETE: {:?}", start.elapsed()); + eprintln!("RUN COMPLETE: {:?}", start.elapsed()); Ok(()) } @@ -174,7 +92,7 @@ fn prove( dir: &Path, lowered: Lowered, cache: &FixedCache, - config: &super::WorkerConfig, + config: &super::StageConfig, ) -> storage::Result<()> { let manifest: storage::Manifest = storage::load(&dir.join("manifest.bin"))?; let count = manifest.heights.len(); @@ -224,8 +142,8 @@ fn prove( .config(height, 2, false)? .with_streaming_lookups() .with_streaming_quotient(); - println!("KZG parameters ready: {:?}", start.elapsed()); - println!("Partition prefetch budget: {} GiB", prefetch_bytes >> 30); + eprintln!("KZG parameters ready: {:?}", start.elapsed()); + eprintln!("Partition prefetch budget: {} GiB", prefetch_bytes >> 30); let prover::Proved { system, bytes, verify_seconds, .. } = lowered .commit(config, Some(&out), prefetch_bytes)? .prove(&manifest.claims)?; @@ -269,7 +187,7 @@ fn prove( fs::write(out.join("prove-report.json"), format!("{evidence}\n"))?; drop(output_span); cache.publish(dir)?; - println!( + eprintln!( "VERIFIED: proof_bytes={} packet_bytes={} verify_seconds={verify_seconds} total_seconds={} development_srs={development}", bytes.len(), packet.len(), @@ -285,16 +203,7 @@ mod tests { use multi_stark::plonkish::gadgets::blake3; use multi_stark::plonkish::{CircuitBuilder, Value}; - #[test] - fn first_stage_height_leaves_room_for_quotients() { - let maximum = 28.min(Scalar::TWO_ADICITY - 1); - assert_eq!(log_height_arg(None).unwrap(), 24); - assert_eq!(log_height_arg(Some(&maximum.to_string())).unwrap(), maximum); - assert!(log_height_arg(Some(&(maximum + 1).to_string())).is_err()); - assert!(log_height_arg(Some(&"23".to_owned())).is_err()); - } - - const OPTIONS: super::super::WorkerConfig = super::super::WorkerConfig { + const OPTIONS: super::super::StageConfig = super::super::StageConfig { prefetch_bytes: 1 << 20, setup: super::super::setup::SetupConfig::Development { cache_directory: None, @@ -357,7 +266,7 @@ mod tests { }) } - /// Write the metadata `stage_and_prove` produces before proving. + /// Write the metadata `run` produces before proving. fn stage( dir: &Path, compiled: &MultiStarkCircuit, @@ -369,7 +278,7 @@ mod tests { SetupSource::development(FIRST_SEED, height - 1)? .bind_stage(dir, height, 2)?; fs::write(dir.join("plan-id.bin"), [0u8; 32])?; - let cache = FixedCache::from_identity(cache_root, b"fixture-v1")?; + let cache = FixedCache::from_identity(cache_root, b"fixture-v1"); let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( compiled, @@ -436,7 +345,7 @@ mod tests { } prove(&warm, &cache, 9)?; assert_eq!(first, artifacts(&warm)?); - let different = FixedCache::from_identity(&cache_root, b"different-vk")?; + let different = FixedCache::from_identity(&cache_root, b"different-vk"); assert!(different.restore(&root.join("miss"))?.is_none()); // A different witness changes the proof but not the verifying key. @@ -508,7 +417,7 @@ mod tests { let [entry] = entries.as_slice() else { panic!("expected one published entry, found {entries:?}"); }; - let cache = FixedCache::from_identity(&cache_root, b"fixture-v1")?; + let cache = FixedCache::from_identity(&cache_root, b"fixture-v1"); let damaged = root.join("damaged"); for name in ["kzg/fixed-0.bin", "kzg/setup-0.bin"] { let published = entry.join(name); diff --git a/crates/proof-compression/src/pipeline/fixed_cache.rs b/crates/proof-compression/src/pipeline/fixed_cache.rs index 72d2a6526..0b7bf7bc0 100644 --- a/crates/proof-compression/src/pipeline/fixed_cache.rs +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -1,14 +1,14 @@ //! Trusted local preprocessing, independent of each proof's witness and -//! openings. Entries are keyed by the executable digest and the stage -//! verifying key, so a rebuilt binary never reuses another build's preprocessing. -//! The root is `IX_KZG_FIXED_CACHE`, or `/kzg/fixed-cache`. +//! openings. Entries are keyed by the cache format and the stage verifying +//! key, which commits to the fixed polynomials, so rebuilding the executable +//! keeps them valid. +//! The root is `CompressConfig::fixed_cache`, or `/kzg/fixed-cache`. use super::storage::{self, Manifest, Result}; use serde::{Deserialize, Serialize}; use std::{ fs::{self, File}, io, path::{Path, PathBuf}, - sync::OnceLock, }; #[derive(Serialize, Deserialize)] @@ -39,15 +39,6 @@ pub(crate) struct FixedCache { directory: PathBuf, } -pub(crate) fn executable_digest() -> Result<[u8; 32]> { - static DIGEST: OnceLock<[u8; 32]> = OnceLock::new(); - if let Some(digest) = DIGEST.get() { - return Ok(*digest); - } - let digest = file_digest(&std::env::current_exe()?)?; - Ok(*DIGEST.get_or_init(|| digest)) -} - fn file_digest(path: &Path) -> io::Result<[u8; 32]> { let mut hasher = blake3::Hasher::new(); hasher.update_mmap_rayon(path)?; @@ -55,15 +46,15 @@ fn file_digest(path: &Path) -> io::Result<[u8; 32]> { } impl FixedCache { - /// The executable digest conservatively binds the compiler, lowering, - /// dependency versions, SRS recipe, and all circuit implementation choices. - pub(crate) fn from_identity(root: &Path, vk: &[u8]) -> Result { + /// Bump the format tag whenever the cached files' layout or contents change + /// for the same verifying key; restores check each file's digest, and a + /// stale layout fails the final proof verification. + pub(crate) fn from_identity(root: &Path, vk: &[u8]) -> Self { let mut hasher = blake3::Hasher::new(); - hasher.update(b"ix/kzg-fixed-cache/bn254/v1"); - hasher.update(&executable_digest()?); + hasher.update(b"ix/kzg-fixed-cache/bn254/v2"); hasher.update(vk); let identity = *hasher.finalize().as_bytes(); - Ok(Self::new(root, identity)) + Self::new(root, identity) } fn new(root: &Path, identity: [u8; 32]) -> Self { diff --git a/crates/proof-compression/src/pipeline/gnark.rs b/crates/proof-compression/src/pipeline/gnark.rs index f697c090b..5b6d8001e 100644 --- a/crates/proof-compression/src/pipeline/gnark.rs +++ b/crates/proof-compression/src/pipeline/gnark.rs @@ -1,4 +1,4 @@ -//! Owned gnark objects behind the worker's C ABI. +//! Owned gnark objects behind the gnark archive's C ABI. use super::Result; use serde::{Deserialize, Serialize}; use std::{ diff --git a/crates/proof-compression/src/pipeline/groth16.rs b/crates/proof-compression/src/pipeline/groth16.rs index 14d3db1fe..b00a138f4 100644 --- a/crates/proof-compression/src/pipeline/groth16.rs +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -1,12 +1,11 @@ //! Terminal BN254 Groth16 proofs under independently admitted circuit keys. #[cfg(feature = "groth16")] use super::gnark; +#[cfg(any(feature = "groth16", test))] use super::storage; -#[cfg(any(not(feature = "groth16"), test))] +#[cfg(test)] use super::terminal::Scratch; -#[cfg(not(feature = "groth16"))] -use super::terminal::checked; -#[cfg(any(feature = "groth16", test))] +#[cfg(feature = "groth16")] use super::terminal::sha256; use super::{ Result, @@ -16,14 +15,13 @@ use super::{ }; #[cfg(feature = "groth16")] use super::{ + setup::SetupConfig, terminal::{claim_digest, read_bounded}, wrap, }; -#[cfg(any(feature = "groth16", test))] +#[cfg(feature = "groth16")] use std::fs; use std::path::{Path, PathBuf}; -#[cfg(not(feature = "groth16"))] -use std::process::Command; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x03"; const CATALOG_FORMAT: &str = "ix-bn254-groth16-verifiers-v1"; @@ -115,6 +113,7 @@ fn select_key( ) } +#[cfg(feature = "groth16")] pub(super) fn verify( packet: &[u8], claim: &[u8], @@ -126,6 +125,19 @@ pub(super) fn verify( verify_with_catalog(packet, claim, &catalog_path()?, allow_development) } +#[cfg(not(feature = "groth16"))] +pub(super) fn verify( + packet: &[u8], + _claim: &[u8], + _allow_development: bool, +) -> Result { + if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { + return Err("invalid Groth16 packet length".into()); + } + Err("Groth16 proofs are verified by gnark, and this ix was built without Groth16; rebuild with IX_GROTH16=1".into()) +} + +#[cfg(feature = "groth16")] fn verify_with_catalog( packet: &[u8], claim: &[u8], @@ -138,27 +150,12 @@ fn verify_with_catalog( let id = packet[..32].try_into()?; let key = select_key(catalog, id, allow_development)?; let expected = *blake3::hash(claim).as_bytes(); - #[cfg(feature = "groth16")] gnark::verify_groth16( &read_bounded(&key.path, 1 << 20)?, id, &packet[32..], &expected, )?; - #[cfg(not(feature = "groth16"))] - { - let scratch = Scratch::new()?; - let proof = scratch.0.join("proof.bin"); - storage::write_atomic(&proof, &packet[32..])?; - checked( - Command::new(super::worker_executable()?) - .args(["groth16", "verify-key"]) - .arg(&key.path) - .arg(hex(id)) - .arg(proof) - .arg(hex(&expected)), - )?; - } key.setup_class(allow_development) } @@ -287,14 +284,18 @@ pub(super) fn check_statement( Ok(()) } +/// Prove the recursive KZG packet in `packet` in Groth16 under the prepared +/// artifacts `IX_GROTH16_ARTIFACTS` names, and write its envelope, verifying +/// key and Solidity verifier to the empty directory `out`. #[cfg(feature = "groth16")] -fn wrap_packet( +pub(super) fn wrap_packet( verifier: &Path, packet: &Path, trusted: &str, expected: &statement::Statement, out: &Path, allow_development: bool, + setup: &SetupConfig, ) -> Result<()> { statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; let (artifacts, key) = prepared(allow_development)?; @@ -306,7 +307,7 @@ fn wrap_packet( return Err("Groth16 output directory must be empty".into()); } let input = out.join("input"); - wrap::plonk_export(verifier, packet, trusted, expected, &input)?; + wrap::plonk_export(verifier, packet, trusted, expected, &input, setup)?; let exported: serde_json::Value = load_json(&input.join("vk.json"))?; let inner_id = exported["vkId"].as_str().ok_or("missing exported inner key identity")?; @@ -340,57 +341,15 @@ fn wrap_packet( Ok(()) } -#[cfg(feature = "groth16")] -pub(crate) fn run(args: &[String]) -> Result<()> { - let development_flags = - args.iter().filter(|s| s.as_str() == "--allow-development").count(); - let production_flags = - args.iter().filter(|s| s.as_str() == "--production").count(); - if development_flags > 1 - || production_flags > 1 - || development_flags + production_flags > 1 - { - return Err("conflicting Groth16 setup policy flags".into()); - } - let allow_development = development_flags == 1; - let positional: Vec<_> = args - .iter() - .filter(|s| !matches!(s.as_str(), "--allow-development" | "--production")) - .map(String::as_str) - .collect(); - match positional.as_slice() { - ["compile", schema, out] => compile(Path::new(schema), Path::new(out)), - ["setup-development", dir] => setup(Path::new(dir), gnark::Groth16Setup::Development), - ["setup-mpc", dir, descriptor, pin] => { digest(pin)?; setup(Path::new(dir), gnark::Groth16Setup::Ceremony { path:Path::new(descriptor),digest:pin }) }, - ["import", dir, descriptor, pin] => { digest(pin)?; setup(Path::new(dir), gnark::Groth16Setup::Import { path:Path::new(descriptor),digest:pin }) }, - ["prove", dir, witness, claim, out] => prove(Path::new(dir),Path::new(witness),&digest(claim)?,Path::new(out)), - ["verify-key", key, pin, proof, claim] => gnark::verify_groth16(&read_bounded(Path::new(key), 1<<20)?, &digest(pin)?, &read_bounded(Path::new(proof), MAX_PROOF_BYTES)?, &digest(claim)?), - ["export", verifier, packet, trusted, expected, out] => { - let expected = load_json(Path::new(expected))?; - wrap::plonk_export(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(out)) - }, - ["verify", proof, claim] => { - let bytes = read_bounded(Path::new(proof), MAX_PROOF_BYTES + 32 + PROOF_TAG.len())?; - let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported Groth16 proof curve or version")?; - let setup = verify(packet, &fs::read(claim)?, allow_development)?; - println!("{}", serde_json::json!({"verified": true, "curve": "bn254", "setup": setup.label()})); - Ok(()) - }, - ["wrap", verifier, packet, trusted, expected, out] => { - let expected = load_json(Path::new(expected))?; - wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(out), allow_development) - }, - _ => Err("usage: ix-compression-worker groth16 compile | setup-development | setup-mpc | import | prove | export | wrap [--allow-development] | verify [--allow-development]".into()), - } -} - #[cfg(feature = "groth16")] fn artifact(path: &Path) -> Result { Ok(Artifact { sha256: hex(&sha256(path)?), bytes: fs::metadata(path)?.len() }) } +/// Compile the gnark Groth16 verifier circuit for the exported verifier +/// description `schema` into the empty directory `out`, without keys. #[cfg(feature = "groth16")] -fn compile(schema: &Path, out: &Path) -> Result<()> { +pub fn compile(schema: &Path, out: &Path) -> Result<()> { super::prepare_output(out)?; let schema = read_bounded(schema, 16 << 20)?; let info = gnark::compile_groth16(&schema, &out.join("circuit.bin"))?; @@ -418,6 +377,30 @@ fn compile(schema: &Path, out: &Path) -> Result<()> { Ok(()) } +/// Prepare known-trapdoor development keys for the circuit compiled in +/// `dir`. Proofs under them are accepted only with explicit development +/// verification. +#[cfg(feature = "groth16")] +pub fn setup_development(dir: &Path) -> Result<()> { + setup(dir, gnark::Groth16Setup::Development) +} + +/// Run the circuit-specific MPC phase for the circuit compiled in `dir` from +/// the ceremony descriptor `descriptor`, whose SHA-256 must be `pin`. +#[cfg(feature = "groth16")] +pub fn setup_mpc(dir: &Path, descriptor: &Path, pin: &str) -> Result<()> { + digest(pin)?; + setup(dir, gnark::Groth16Setup::Ceremony { path: descriptor, digest: pin }) +} + +/// Import released keys for the circuit compiled in `dir` from the release +/// descriptor `descriptor`, whose SHA-256 must be `pin`. +#[cfg(feature = "groth16")] +pub fn import(dir: &Path, descriptor: &Path, pin: &str) -> Result<()> { + digest(pin)?; + setup(dir, gnark::Groth16Setup::Import { path: descriptor, digest: pin }) +} + #[cfg(feature = "groth16")] fn setup(dir: &Path, request: gnark::Groth16Setup<'_>) -> Result<()> { let metadata: Metadata = load_json(&dir.join("manifest.json"))?; diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 1caa57e83..621a87ab2 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -6,286 +6,36 @@ pub use stages::{ prepare_root, setup, }; -mod driver; +mod compress; +pub(crate) use compress::StageConfig; +pub use compress::{CompressConfig, CompressInput, compress}; mod first; mod fixed_cache; #[cfg(any(feature = "plonk", feature = "groth16"))] pub mod gnark; -mod groth16; +pub mod groth16; #[cfg(test)] mod init_claim; mod init_fri; -mod plonk; +pub mod plonk; mod terminal; pub use terminal::{Artifact, TerminalOptions, TerminalSystem}; mod prover; mod root; +mod root_layout; +pub use root_layout::release_root_layout; mod setup; +pub use setup::{SetupClass, SetupConfig}; mod statement; mod storage; mod verifier; mod wrap; -use std::{ - fs, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; +use std::{fs, path::Path}; pub const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x01"; pub type Result = std::result::Result>; -pub fn command(stage: &str, args: &[String]) -> Result<()> { - if !matches!(stage, "plonk" | "groth16" | "pipeline") { - configure_backend(stage)?; - } - match stage { - "pipeline" => driver::run(args), - #[cfg(feature = "plonk")] - "plonk" => plonk::run(args), - #[cfg(feature = "groth16")] - "groth16" => groth16::run(args), - "fri" => root::run(args), - "kzg" | "wrap" => { - let config = WorkerConfig::from_env( - stage, - args.iter().any(|arg| arg == "--dev-srs"), - )?; - let args: Vec<_> = - args.iter().filter(|arg| *arg != "--dev-srs").cloned().collect(); - if stage == "kzg" { - first::run(&args, &config) - } else { - wrap::run(&args, &config) - } - }, - _ => Err("unknown compression stage".into()), - } -} - -pub(crate) struct WorkerConfig { - setup: setup::SetupConfig, - fixed_cache_root: Option, - prefetch_bytes: usize, -} - -impl WorkerConfig { - fn from_env(stage: &str, development: bool) -> Result { - let setup = setup::SetupConfig::from_env(development)?; - let prefetch_gib = if stage == "kzg" { - match std::env::var("IX_KZG_PREFETCH_GIB") { - Ok(value) => value.parse::()?, - Err(std::env::VarError::NotPresent) => 32, - Err(error) => return Err(error.into()), - } - } else { - 0 - }; - Ok(Self { - setup, - fixed_cache_root: std::env::var_os("IX_KZG_FIXED_CACHE") - .map(PathBuf::from), - prefetch_bytes: prefetch_gib - .checked_mul(1 << 30) - .ok_or("prefetch budget overflow")?, - }) - } - - fn fixed_cache( - &self, - stage: &Path, - vk: &[u8], - ) -> Result { - let root = self - .fixed_cache_root - .clone() - .unwrap_or_else(|| stage.join("kzg/fixed-cache")); - fixed_cache::FixedCache::from_identity(&root, vk) - } - - #[cfg(test)] - const fn development() -> Self { - Self { - setup: setup::SetupConfig::Development { cache_directory: None }, - fixed_cache_root: None, - prefetch_bytes: 0, - } - } -} - -/// Install the KZG CUDA backend settings from the environment before the -/// first proving operation. Settings already installed in this process are -/// kept. The recursive stage caps device residency unless the environment -/// sets it, so its constraint passes can be admitted beside the resident -/// columns. -/// -/// The pipeline proves on one device unless the environment selects more. -#[cfg(feature = "cuda")] -fn configure_backend(stage: &str) -> Result<()> { - use crate::kzg::cuda::{PassMode, Settings}; - let var = |name: &str| std::env::var(name).ok(); - let devices = match var("IX_KZG_CUDA_DEVICES") { - Some(list) => list - .split(',') - .map(|id| id.trim().parse()) - .collect::, _>>() - .map_err(|_error| "IX_KZG_CUDA_DEVICES must list CUDA ordinals")?, - None => vec![0], - }; - let mut settings = Settings { devices: Some(devices), ..Settings::default() }; - if let Some(value) = var("IX_KZG_MSM_CHUNK_POINTS") { - settings.msm_chunk_points = value - .parse() - .ok() - .filter(|points| (1..=1 << 28).contains(points)) - .ok_or("IX_KZG_MSM_CHUNK_POINTS must be in 1..=2^28")?; - } - if let Some(value) = var("IX_KZG_CUDA_RESIDENT_GIB") { - settings.resident_limit_bytes = Some( - value - .parse::() - .ok() - .and_then(|gib| gib.checked_mul(1 << 30)) - .ok_or("IX_KZG_CUDA_RESIDENT_GIB must be a whole number of GiB")?, - ); - } else if stage == "wrap" { - settings.resident_limit_bytes = Some(wrap::DEFAULT_CUDA_RESIDENT_BYTES); - } - let switch = |name: &str, on: &str, off: &str| -> Result> { - match var(name).as_deref() { - None => Ok(None), - Some(value) if value == on => Ok(Some(true)), - Some(value) if value == off => Ok(Some(false)), - Some(other) => { - Err(format!("{name} must be {on} or {off}, not {other}").into()) - }, - } - }; - if let Some(value) = switch("IX_KZG_CUDA_SRS_CACHE", "1", "0")? { - settings.srs_cache = value; - } - settings.passes = match var("IX_KZG_CUDA_PASSES").as_deref() { - None | Some("1") => PassMode::Fallback, - Some("0") => PassMode::Off, - Some("force") => PassMode::Force, - Some(other) => { - return Err( - format!("IX_KZG_CUDA_PASSES must be 0, 1 or force, not {other}").into(), - ); - }, - }; - crate::kzg::cuda::configure(settings); - Ok(()) -} - -#[cfg(not(feature = "cuda"))] -#[allow(clippy::unnecessary_wraps)] -fn configure_backend(_stage: &str) -> Result<()> { - Ok(()) -} - -/// The stage worker executable installed beside `ix`. -pub const WORKER_EXECUTABLE: &str = "ix-compression-worker"; - -/// The stage worker: `IX_COMPRESSION_WORKER` when set, otherwise the -/// executable of that name beside the running one. Stages run there rather -/// than in this process so the Lean executable links no KZG proving code or -/// its CUDA runtime. -fn worker_executable() -> Result { - if let Some(path) = std::env::var_os("IX_COMPRESSION_WORKER") { - return Ok(PathBuf::from(path)); - } - let current = std::env::current_exe()?; - let sibling = current.with_file_name(WORKER_EXECUTABLE); - if sibling.is_file() { - return Ok(sibling); - } - Err( - format!( - "{WORKER_EXECUTABLE} not found beside {}; build it with the ix \ - executable or name it in IX_COMPRESSION_WORKER", - current.display() - ) - .into(), - ) -} - -fn check_worker_capabilities(bytes: &[u8]) -> Result<()> { - let capabilities: serde_json::Value = serde_json::from_slice(bytes)?; - if capabilities["format"] != "ix-compression-capabilities/v1" - || capabilities["binary"] != WORKER_EXECUTABLE - || capabilities["curve"] != crate::kzg::curve::NAME - { - return Err(format!("compression worker must report curve {}; select a matching IX_COMPRESSION_WORKER", crate::kzg::curve::NAME).into()); - } - Ok(()) -} - -fn check_worker_setup_capabilities(bytes: &[u8], ignition: bool) -> Result<()> { - check_worker_capabilities(bytes)?; - let capabilities: serde_json::Value = serde_json::from_slice(bytes)?; - if ignition && capabilities["ignition_setup"] != true { - return Err("compression worker does not support admitted Ignition parameters; select a matching IX_COMPRESSION_WORKER".into()); - } - Ok(()) -} - -fn check_worker_curve(ignition: bool, terminal: TerminalSystem) -> Result<()> { - let output = Command::new(worker_executable()?) - .args(["fri", "capabilities"]) - .output()?; - if !output.status.success() { - return Err( - format!( - "compression worker capability check failed: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into(), - ); - } - check_worker_setup_capabilities(&output.stdout, ignition)?; - let capabilities: serde_json::Value = serde_json::from_slice(&output.stdout)?; - let flag = match terminal { - TerminalSystem::None => return Ok(()), - TerminalSystem::Plonk => "terminal_plonk", - TerminalSystem::Groth16 => "terminal_groth16", - }; - if capabilities[flag] != true { - return Err(format!("compression worker does not support {flag}").into()); - } - let backend = match terminal { - TerminalSystem::Plonk => "IX_PLONK_BACKEND", - TerminalSystem::Groth16 => "IX_GROTH16_BACKEND", - TerminalSystem::None => unreachable!(), - }; - if std::env::var(backend).as_deref() == Ok("cuda") - && capabilities[format!("{flag}_cuda")] != true - { - return Err( - format!("compression worker lacks CUDA support for {flag}").into(), - ); - } - Ok(()) -} - -fn child(stage: &str, args: &[&str], caches: &Caches) -> Result<()> { - let status = Command::new(worker_executable()?) - .arg(stage) - .args(args) - .env("IX_KZG_FIXED_CACHE", &caches.fixed) - .env("IX_KZG_SRS_CACHE", &caches.development_srs) - .stdout(Stdio::from(std::io::stderr())) - .stderr(Stdio::inherit()) - .status()?; - if !status.success() { - return Err( - format!("compression {stage} failed ({status}); checkpoints retained") - .into(), - ); - } - Ok(()) -} - /// The statement a compressed proof of `claim` exposes, as the caller /// derived it from its aggregation backend, checked to end in the packed /// digest of the claim itself. @@ -300,26 +50,27 @@ pub fn check_claim( /// Describe the setup a new run would prove under, or why none is allowed. pub fn setup_description(development: bool) -> Result<&'static str> { - Ok(setup::SetupConfig::from_env(development)?.class().description()) + Ok(SetupConfig::from_env(development)?.class().description()) } -/// Verify using a bundled KZG verifying key or an independently configured PLONK -/// verification key. Proving parameters are not required. +/// Verify a terminal proof using an independently configured verifier catalog. +/// Recursive KZG requires an explicitly pinned key and expected statement. +/// Proving parameters are not required. pub fn verify( proof: &[u8], claim: &[u8], allow_development: bool, -) -> Result { +) -> Result { if let Some(packet) = proof.strip_prefix(groth16::PROOF_TAG) { return groth16::verify(packet, claim, allow_development); } if let Some(packet) = proof.strip_prefix(plonk::PROOF_TAG) { return plonk::verify(packet, claim); } - let packet = proof - .strip_prefix(PROOF_TAG) - .ok_or("unsupported compressed proof version")?; - verifier::verify(packet, claim, allow_development) + if proof.starts_with(PROOF_TAG) { + return Err("bundled recursive KZG verification is unsupported in this build; use an independently pinned verifying key and expected statement".into()); + } + Err("unsupported compressed proof version".into()) } /// Verify with a caller-authorized verifying key digest and independently expected @@ -331,7 +82,7 @@ pub fn verify_with_vk( vk: &[u8], trusted_vk_digest: &[u8; 32], allow_development: bool, -) -> Result { +) -> Result { let words = check_claim(claim, expected_claims)?; let packet = proof .strip_prefix(PROOF_TAG) @@ -356,7 +107,7 @@ pub fn verify_with_ignition_admission( vk: &[u8], trusted_vk_digest: &[u8; 32], admission: &crate::kzg::srs::ignition::IgnitionAdmission, -) -> Result { +) -> Result { let words = check_claim(claim, expected_claims)?; let packet = proof .strip_prefix(PROOF_TAG) @@ -371,65 +122,6 @@ pub fn verify_with_ignition_admission( ) } -/// Cross-run caches the stages share: fixed preprocessing keyed by the -/// executable and verifying key, and development SRS files. Variables already set -/// in the environment take precedence over the directory given to `compress`. -struct Caches { - fixed: PathBuf, - development_srs: PathBuf, -} - -impl Caches { - fn under(root: &Path) -> Self { - let named = |name: &str, default: &str| { - std::env::var_os(name).map_or_else(|| root.join(default), PathBuf::from) - }; - Self { - fixed: named("IX_KZG_FIXED_CACHE", "fixed-preprocessing"), - development_srs: named("IX_KZG_SRS_CACHE", "development-srs"), - } - } -} - -fn validate_compression_options( - development: bool, - log_height: u32, - terminal: TerminalOptions, -) -> Result { - if terminal.allow_development && terminal.system != TerminalSystem::Groth16 { - return Err( - "development terminal keys are supported only for Groth16".into(), - ); - } - if terminal.system != TerminalSystem::None && development { - return Err( - "terminal proofs require admitted inner KZG parameters; omit --dev-srs" - .into(), - ); - } - if terminal.system == TerminalSystem::Groth16 { - groth16::preflight(terminal.allow_development)?; - } - if !matches!(log_height, 24 | 25) { - return Err( - "--log-height must be 24 or 25 for the supported recursive verifying keys" - .into(), - ); - } - // Child stages re-read the same environment; the policy is enforced once - // here so an unconfigured run fails before any checkpoint is written. - let setup = setup::SetupConfig::from_env(development)?; - if std::env::var_os("IX_ROOT_GENERIC_HASHES").is_some() { - return Err( - "unset IX_ROOT_GENERIC_HASHES for the supported compression verifying key" - .into(), - ); - } - let ignition = setup.ignition_admission().is_some(); - check_worker_curve(ignition, terminal.system)?; - Ok(setup) -} - fn prepare_output(out: &Path) -> Result<()> { fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { @@ -440,7 +132,7 @@ fn prepare_output(out: &Path) -> Result<()> { Ok(()) } -/// Write the FRI worker's root input for `claim` without compressing it. +/// Write the root input directory for `claim` without compressing it. pub fn write_root_input( dir: &Path, vk: &[u8], @@ -454,7 +146,14 @@ pub fn write_root_input( write_root(dir, vk, proof, &words, fixed_root) } -/// Write the FRI worker's root input: the backend key, root proof, expected +/// Verify the root input in `root`, build its FRI verifier circuit and check +/// the circuit's constraints, writing its identity and cost to `out` without +/// proving it. +pub fn check_root(root: &Path, out: &Path) -> Result<()> { + root::run(root, out, false) +} + +/// Write a root input directory: the backend key, root proof, expected /// statement and, for a normalized root, its fixed layout. fn write_root( dir: &Path, @@ -464,262 +163,16 @@ fn write_root( fixed_root: Option<&str>, ) -> Result<()> { fs::create_dir_all(dir)?; - fs::write(dir.join("root-vk.bin"), vk)?; - fs::write(dir.join("root-proof.bin"), proof)?; - fs::write(dir.join("root-claims.bin"), statement::to_claims_bytes(words))?; - if let Some(layout) = fixed_root { - let fixed = FixedRoot::from_json(layout)?; - fs::write(dir.join(root::FIXED_ROOT_LAYOUT), fixed.to_json())?; + for (name, bytes) in compress::root_files(vk, proof, words, fixed_root)? { + storage::write_atomic(&dir.join(name), &bytes)?; } Ok(()) } -fn path_string(path: &Path) -> Result { - Ok(path.to_str().ok_or("non-UTF8 artifact path")?.to_owned()) -} - -/// Run FRI, first KZG and recursive KZG in separate worker processes. -/// The output directory must be empty; interrupted stages retain checkpoints. -/// With `fixed_root`, the root must be normalized to that layout, and the FRI -/// verifier depends only on the backend and layout. -#[allow(clippy::too_many_arguments)] -pub fn compress( - vk: &[u8], - proof: &[u8], - claim: &[u8], - statement: &[u8], - fixed_root: Option<&str>, - out: &Path, - cache_root: &Path, - development: bool, - log_height: u32, - terminal_plonk: bool, -) -> Result> { - compress_with_terminal( - vk, - proof, - claim, - statement, - fixed_root, - out, - cache_root, - development, - log_height, - if terminal_plonk { TerminalSystem::Plonk } else { TerminalSystem::None } - .into(), - ) -} - -/// Compress an aggregate proof with an explicitly selected terminal proof system. -/// With `fixed_root`, the root must be normalized to that layout. -#[allow(clippy::too_many_arguments)] -pub fn compress_with_terminal( - vk: &[u8], - proof: &[u8], - claim: &[u8], - statement: &[u8], - fixed_root: Option<&str>, - out: &Path, - cache_root: &Path, - development: bool, - log_height: u32, - terminal: TerminalOptions, -) -> Result> { - let words = check_claim(claim, statement)?; - let setup = validate_compression_options(development, log_height, terminal)?; - if terminal.system == TerminalSystem::Groth16 { - groth16::check_statement(&words, terminal.allow_development)?; - } - prepare_output(out)?; - let caches = Caches::under(cache_root); - let root = out.join("root"); - write_root(&root, vk, proof, &words, fixed_root)?; - let fri = out.join("fri"); - child( - "fri", - &[&path_string(&root)?, &path_string(&fri)?, "--prove-outer"], - &caches, - )?; - finish_kzg(&fri, &words, out, cache_root, &setup, log_height, terminal) -} - -/// Compress an already saved FRI stage while binding its statement to a -/// caller-supplied claim and expected root claims. The FRI stage is verified -/// before starting the KZG workers. -pub fn compress_fri( - fri: &Path, - claim: &[u8], - expected_claims: &[u8], - out: &Path, - cache_root: &Path, - development: bool, - log_height: u32, -) -> Result> { - let words = check_claim(claim, expected_claims)?; - let setup = validate_compression_options( - development, - log_height, - TerminalSystem::None.into(), - )?; - prepare_output(out)?; - { - let fixture = init_fri::load(fri)?; - let actual = - statement::from_scalars(&stages::scalar_statement(&fixture.public))?; - if actual != words { - return Err("saved FRI statement differs from the independently expected root statement".into()); - } - } - finish_kzg( - fri, - &words, - out, - cache_root, - &setup, - log_height, - TerminalSystem::None.into(), - ) -} - -fn finish_kzg( - fri: &Path, - words: &statement::Statement, - out: &Path, - cache_root: &Path, - setup: &setup::SetupConfig, - log_height: u32, - terminal: TerminalOptions, -) -> Result> { - let development = setup.class() == setup::SetupClass::Development; - let caches = Caches::under(cache_root); - let first = out.join("first-kzg"); - let final_dir = out.join("final-kzg"); - let path = path_string; - let (fri_s, first_s, final_s) = - (path(fri)?, path(&first)?, path(&final_dir)?); - // Each KZG worker selects its setup from the forwarded development flag - // and cache configuration. - let kzg = |stage, args: &[&str]| { - let mut args = args.to_vec(); - if development { - args.push("--dev-srs"); - } - child(stage, &args, &caches) - }; - let height = log_height.to_string(); - kzg("kzg", &["run", &fri_s, &first_s, &height])?; - kzg("wrap", &["stage-and-prove", &first_s, &final_s])?; - let mut result = PROOF_TAG.to_vec(); - result.extend(fs::read(final_dir.join("kzg/packet.bin"))?); - let packet = &result[PROOF_TAG.len()..]; - let vk = - wrap::VerifyingKey::from_bytes(&fs::read(final_dir.join("vk.bin"))?)?; - if let Some(admission) = setup.ignition_admission() { - vk.check_ignition_admission(admission)?; - } else { - vk.check_setup_class(setup.class())?; - } - vk.verify(packet, words)?; - // A bundled verifying key pins the aggregation program its proofs attest; the - // backend this run proved under must be that program, or the bundle is - // stale and verification elsewhere would accept the wrong statement. - if let Some(prefix) = verifier::bundled_prefix(packet[..32].try_into()?) - && *prefix != words[..10] - { - return Err( - "the bundled verifier vk pins a different aggregation program \ - than this build's backend; update the bundle" - .into(), - ); - } - if terminal.system == TerminalSystem::Groth16 { - let terminal_out = out.join("groth16"); - let expected_path = final_dir.join("expected-statement.json"); - storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; - let verifier = final_dir.join("vk.bin"); - let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); - child( - "groth16", - &[ - "wrap", - &path(&verifier)?, - &path(&final_dir.join("kzg/packet.bin"))?, - trusted.as_str(), - &path(&expected_path)?, - &path(&terminal_out)?, - if terminal.allow_development { - "--allow-development" - } else { - "--production" - }, - ], - &caches, - )?; - return Ok(fs::read(terminal_out.join("proof.envelope.bin"))?); - } - if terminal.system == TerminalSystem::Plonk { - let out_plonk = out.join("plonk"); - let expected_path = final_dir.join("expected-statement.json"); - storage::write_atomic(&expected_path, &serde_json::to_vec(&words)?)?; - let verifier = final_dir.join("vk.bin"); - let trusted = blake3::hash(&fs::read(&verifier)?).to_hex(); - child( - "plonk", - &[ - "wrap", - &path(&verifier)?, - &path(&final_dir.join("kzg/packet.bin"))?, - trusted.as_str(), - &path(&expected_path)?, - &path(&cache_root.join("plonk"))?, - &path(&out_plonk)?, - ], - &caches, - )?; - return Ok(fs::read(out_plonk.join("proof.envelope.bin"))?); - } - Ok(result) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn worker_capabilities_reject_other_curves_and_missing_identity() { - let capabilities = |curve: &str| { - serde_json::to_vec(&serde_json::json!({ - "format": "ix-compression-capabilities/v1", - "binary": WORKER_EXECUTABLE, - "curve": curve, - })) - .unwrap() - }; - check_worker_capabilities(&capabilities(crate::kzg::curve::NAME)).unwrap(); - let other = "bls12-381"; - assert!(check_worker_capabilities(&capabilities(other)).is_err()); - assert!(check_worker_capabilities(b"{}").is_err()); - assert!(check_worker_capabilities(b"not-json").is_err()); - } - - #[test] - fn ignition_runs_require_explicit_worker_support() { - let mut capabilities = serde_json::json!({ - "format": "ix-compression-capabilities/v1", - "binary": WORKER_EXECUTABLE, - "curve": crate::kzg::curve::NAME, - }); - let bytes = serde_json::to_vec(&capabilities).unwrap(); - check_worker_setup_capabilities(&bytes, false).unwrap(); - assert!(check_worker_setup_capabilities(&bytes, true).is_err()); - capabilities["ignition_setup"] = true.into(); - check_worker_setup_capabilities( - &serde_json::to_vec(&capabilities).unwrap(), - true, - ) - .unwrap(); - } - #[test] fn explicit_vk_verification_rejects_other_curve_envelopes() { let expected = @@ -731,22 +184,66 @@ mod tests { } #[test] - fn unsupported_claim_is_rejected_before_io() { - assert!( - compress( - b"", - b"", - b"wrong", - &statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"wrong")), - None, - Path::new("/does-not-exist"), - Path::new("/does-not-exist"), - true, - 24, - false - ) - .is_err() - ); + fn bundled_kzg_verification_is_explicitly_unsupported() { + let mut proof = PROOF_TAG.to_vec(); + proof.extend([0; 32]); + for allow_development in [false, true] { + let error = verify(&proof, b"claim", allow_development).unwrap_err(); + assert!( + error + .to_string() + .contains("bundled recursive KZG verification is unsupported") + ); + } + } + + #[test] + fn invalid_requests_are_rejected_before_io() { + let statement = + statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); + let config = |log_height, terminal: TerminalSystem| CompressConfig { + setup: SetupConfig::Development { cache_directory: None }, + fixed_cache: "/does-not-exist".into(), + plonk_cache: "/does-not-exist".into(), + log_height, + terminal: terminal.into(), + prefetch_bytes: 0, + #[cfg(feature = "cuda")] + cuda: crate::kzg::cuda::Settings::default(), + }; + let input = |claim| CompressInput { + vk: b"", + proof: b"", + claim, + statement: &statement, + fixed_root: None, + out: Path::new("/does-not-exist"), + }; + let error = |claim, log_height, terminal| { + compress(input(claim), &config(log_height, terminal)) + .unwrap_err() + .to_string() + }; + assert!(error(b"other", 24, TerminalSystem::None).contains("claim")); + assert!(error(b"claim", 23, TerminalSystem::None).contains("log-height")); + assert!(error(b"claim", 24, TerminalSystem::Plonk).contains("--dev-srs")); + assert!(!Path::new("/does-not-exist").exists()); assert!(verify(PROOF_TAG, b"wrong", true).is_err()); } + + #[test] + fn terminal_proofs_without_their_feature_name_the_build_switch() { + let mut plonk = plonk::PROOF_TAG.to_vec(); + plonk.extend([0; 64]); + let mut groth16 = groth16::PROOF_TAG.to_vec(); + groth16.extend([0; 196 + 32]); + if !cfg!(feature = "plonk") { + let error = verify(&plonk, b"claim", false).unwrap_err().to_string(); + assert!(error.contains("IX_PLONK=1"), "{error}"); + } + if !cfg!(feature = "groth16") { + let error = verify(&groth16, b"claim", false).unwrap_err().to_string(); + assert!(error.contains("IX_GROTH16=1"), "{error}"); + } + } } diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 29e26f95a..961f7a777 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -1,36 +1,43 @@ -//! Terminal PLONK worker operations and authenticated parameter interchange. +//! Terminal PLONK proofs and authenticated parameter interchange. #[cfg(feature = "plonk")] -use super::fixed_cache; #[cfg(feature = "plonk")] use super::gnark; -#[cfg(any(not(feature = "plonk"), test))] +#[cfg(any(feature = "plonk", feature = "groth16", test))] +use super::statement; +#[cfg(any(feature = "plonk", test))] +use super::storage; +#[cfg(test)] use super::terminal::Scratch; -#[cfg(not(feature = "plonk"))] -use super::terminal::checked; #[cfg(feature = "plonk")] -use super::terminal::{claim_digest, decode_hex, read_bounded, sha256}; +use super::terminal::{claim_digest, read_bounded, sha256}; +#[cfg(any(feature = "plonk", test))] use super::terminal::{digest, hex, load_json}; -use super::{Result, setup::SetupClass, statement, storage}; -#[cfg(feature = "plonk")] +use super::{Result, setup::SetupClass}; +#[cfg(any(feature = "plonk", feature = "groth16"))] use super::{setup::SetupConfig, wrap}; +#[cfg(any(feature = "plonk", test))] use ark_ff::{BigInteger, PrimeField}; #[cfg(feature = "plonk")] use sha2::{Digest, Sha256}; #[cfg(feature = "plonk")] +use std::fs; +#[cfg(feature = "plonk")] use std::io::Write; -#[cfg(not(feature = "plonk"))] -use std::process::Command; -use std::{ - fs, - path::{Path, PathBuf}, -}; +#[cfg(any(feature = "plonk", feature = "groth16", test))] +use std::path::Path; +#[cfg(any(feature = "plonk", test))] +use std::path::PathBuf; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; +#[cfg(any(feature = "plonk", test))] const CATALOG_FORMAT: &str = "ix-bn254-plonk-verifiers-v1"; +#[cfg(any(feature = "plonk", test))] const SETUP_NAME: &str = "ignition"; +#[cfg(feature = "plonk")] const CATALOG_VAR: &str = "IX_PLONK_VERIFIER_CATALOG"; const MAX_PROOF_BYTES: usize = 1 << 20; +#[cfg(any(feature = "plonk", test))] #[derive(serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct TrustedCatalog { @@ -38,6 +45,7 @@ struct TrustedCatalog { keys: Vec, } +#[cfg(any(feature = "plonk", test))] #[derive(serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct TrustedKey { @@ -67,6 +75,7 @@ fn anchors(config: &SetupConfig) -> Result { Ok(config.ignition_admission().ok_or("missing Ignition admission")?.anchors()) } +#[cfg(any(feature = "plonk", test))] fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { let mut bytes = Vec::new(); for value in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { @@ -75,6 +84,7 @@ fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { bytes } +#[cfg(any(feature = "plonk", test))] fn anchor_encoding(srs: &crate::kzg::Srs) -> Vec { let mut bytes = Vec::new(); for value in [srs.g1[0].x, srs.g1[0].y] { @@ -85,6 +95,7 @@ fn anchor_encoding(srs: &crate::kzg::Srs) -> Vec { bytes } +#[cfg(any(feature = "plonk", test))] fn key_anchors(key: &TrustedKey) -> Result { { Ok( @@ -102,6 +113,7 @@ fn key_anchors(key: &TrustedKey) -> Result { } } +#[cfg(any(feature = "plonk", test))] fn select_key(path: &Path, id: &[u8; 32]) -> Result { let catalog: TrustedCatalog = load_json(path)?; if catalog.format != CATALOG_FORMAT { @@ -148,6 +160,15 @@ fn select_key(path: &Path, id: &[u8; 32]) -> Result { Ok(key) } +#[cfg(not(feature = "plonk"))] +pub(super) fn verify(packet: &[u8], _claim: &[u8]) -> Result { + if packet.len() <= 32 || packet.len() > MAX_PROOF_BYTES + 32 { + return Err("invalid PLONK packet length".into()); + } + Err("PLONK proofs are verified by gnark, and this ix was built without PLONK; rebuild with IX_PLONK=1".into()) +} + +#[cfg(feature = "plonk")] pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { if packet.len() <= 32 || packet.len() > MAX_PROOF_BYTES + 32 { return Err("invalid PLONK packet length".into()); @@ -165,7 +186,6 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { } let expected = *blake3::hash(claim).as_bytes(); let anchor_bytes = anchor_encoding(&key_anchors(&key)?); - #[cfg(feature = "plonk")] gnark::verify( &read_bounded(&key.path, 1 << 20)?, id, @@ -173,63 +193,29 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { &expected, &anchor_bytes, )?; - #[cfg(not(feature = "plonk"))] - { - let scratch = Scratch::new()?; - let proof = scratch.0.join("proof.bin"); - storage::write_atomic(&proof, &packet[32..])?; - checked( - Command::new(super::worker_executable()?) - .args(["plonk", "verify-key"]) - .arg(&key.path) - .arg(hex(id)) - .arg(&proof) - .arg(hex(&expected)) - .arg(hex(&anchor_bytes)), - )?; - } Ok(SetupClass::Ignition) } -#[cfg(feature = "plonk")] -pub(crate) fn run(args: &[String]) -> Result<()> { - let development = args.iter().any(|arg| arg == "--dev-srs"); - let args: Vec<_> = args.iter().filter(|arg| *arg != "--dev-srs").collect(); - match args.iter().map(|s| s.as_str()).collect::>().as_slice() { - ["verify-key", key, trusted, proof, claim, anchors] => { - gnark::verify(&read_bounded(Path::new(key), 1 << 20)?, &digest(trusted)?, &read_bounded(Path::new(proof), MAX_PROOF_BYTES)?, &digest(claim)?, &decode_hex(anchors)?) - }, - ["verify", proof, claim] => { - let bytes = read_bounded(Path::new(proof), MAX_PROOF_BYTES + 32 + PROOF_TAG.len())?; - let packet = bytes.strip_prefix(PROOF_TAG).ok_or("unsupported PLONK proof curve or version")?; - let setup = verify(packet, &fs::read(claim)?)?; - println!("{}", serde_json::json!({"verified": true, "curve": crate::kzg::curve::NAME, "setup": setup.label()})); - Ok(()) - }, - ["export", verifier, packet, digest, statement, out] => { - let expected = serde_json::from_slice(&fs::read(statement)?)?; - wrap::plonk_export(Path::new(verifier), Path::new(packet), digest, &expected, Path::new(out)) - }, - ["export-first", stage, statement, out] => { - let expected = serde_json::from_slice(&fs::read(statement)?)?; - wrap::plonk_export_first(Path::new(stage), &expected, Path::new(out), development) - }, - ["srs", domain, out] => export_srs(domain.parse()?, Path::new(out)), - ["wrap", verifier, packet, trusted, expected, cache, out] => { - let expected = load_json(Path::new(expected))?; - wrap_packet(Path::new(verifier), Path::new(packet), trusted, &expected, Path::new(cache), Path::new(out)) - }, - _ => Err("usage: ix-compression-worker plonk export | export-first [--dev-srs] | srs | wrap | verify ".into()), - } -} - -#[cfg(feature = "plonk")] -fn export_srs(domain: usize, path: &Path) -> Result<()> { - export_parameters(domain, path, &SetupConfig::from_env(false)?) +/// Write the gnark verifier description (`vk.json`) and witness +/// (`witness.json`) for the recursive KZG packet in `packet` to `out`. The +/// verifying key in `verifier` must have BLAKE3 digest `trusted_digest` and +/// be admitted by `setup`'s Ignition parameters. +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub fn export( + verifier: &Path, + packet: &Path, + trusted_digest: &str, + expected: &[u64; statement::WORDS], + out: &Path, + setup: &SetupConfig, +) -> Result<()> { + wrap::plonk_export(verifier, packet, trusted_digest, expected, out, setup) } +/// Write the first `domain + 3` Ignition powers a PLONK circuit of `domain` +/// rows needs to `path`, with a JSON receipt beside it. #[cfg(feature = "plonk")] -fn export_parameters( +pub fn export_parameters( domain: usize, path: &Path, config: &SetupConfig, @@ -389,8 +375,10 @@ fn prepare_artifacts( ) -> Result<(PathBuf, gnark::Manifest)> { let schema = fs::read(vk)?; let schema_hash = hex(&Sha256::digest(&schema)); + // `go.sum` pins the gnark that compiles the circuit and serializes its keys. let mut identity = blake3::Hasher::new(); - identity.update(&fixed_cache::executable_digest()?); + identity.update(b"ix/plonk-circuit-cache/v2"); + identity.update(include_bytes!("../../../../tools/plonk-wrapper/go.sum")); identity.update(schema_hash.as_bytes()); identity.update(&setup_manifest(setup)?); let dir = @@ -496,30 +484,41 @@ fn prepare_artifacts( Ok((dir, metadata)) } +/// Prove the recursive KZG packet in `packet` in PLONK and write its +/// envelope, native and Solidity proofs, verifying key and candidate catalog +/// entry to the empty directory `out`. Compiled circuits, keys and parameter +/// exports are cached under `cache_root`. #[cfg(feature = "plonk")] -fn wrap_packet( +pub(super) fn wrap_packet( verifier: &Path, packet: &Path, trusted_digest: &str, expected: &statement::Statement, cache_root: &Path, out: &Path, + setup: &SetupConfig, ) -> Result<()> { use std::time::Instant; statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; let expected_digest = claim_digest(expected)?; - let setup = SetupConfig::from_env(false)?; - setup_manifest(&setup)?; + setup_manifest(setup)?; let backend = proving_backend()?; fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { return Err("PLONK output directory must be empty".into()); } let input = out.join("input"); - wrap::plonk_export(verifier, packet, trusted_digest, expected, &input)?; + wrap::plonk_export( + verifier, + packet, + trusted_digest, + expected, + &input, + setup, + )?; let vk_path = input.join("vk.json"); let exported: serde_json::Value = load_json(&vk_path)?; - let (cache, metadata) = prepare_artifacts(&vk_path, &setup, cache_root)?; + let (cache, metadata) = prepare_artifacts(&vk_path, setup, cache_root)?; let started = Instant::now(); let mut prover = gnark::Prover::open(&cache, &metadata, backend)?; let load_seconds = started.elapsed().as_secs_f64(); @@ -540,7 +539,7 @@ fn wrap_packet( &key_id, &proof.native, &expected_digest, - &anchor_encoding(&anchors(&setup)?), + &anchor_encoding(&anchors(setup)?), )?; if proof.native.is_empty() || proof.native.len() > MAX_PROOF_BYTES { return Err("invalid native PLONK proof length".into()); diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index 44920a0c8..a21bce7b0 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -9,69 +9,20 @@ use multi_stark::types::Val; use p3_blake3::Blake3; use p3_field::TwoAdicField; use p3_symmetric::CryptographicHasher; -use std::{fs, path::PathBuf, time::Instant}; +use std::{fs, path::Path, time::Instant}; /// Layout file a normalized root carries beside its proof. pub(super) const FIXED_ROOT_LAYOUT: &str = "fixed-root.json"; -/// Compiled worker features for deployment checks. -fn print_capabilities() { - println!( - "{}", - serde_json::json!({ - "format": "ix-compression-capabilities/v1", - "binary": "ix-compression-worker", - "curve": crate::kzg::curve::NAME, - "cuda": cfg!(feature = "cuda"), - "parallel": cfg!(feature = "parallel"), - "development_setup_only": false, - "ignition_setup": true, - "terminal_plonk": cfg!(feature = "plonk"), - "terminal_groth16": cfg!(feature = "groth16"), - "terminal_plonk_cuda": cfg!(all(feature = "plonk", feature = "cuda")), - "terminal_groth16_cuda": cfg!(all(feature = "groth16", feature = "cuda")), - }) - ); -} - -/// Report the process's resident and virtual size every 30 seconds so a -/// long stage's memory profile is visible in its log. -fn spawn_memory_monitor() { - std::thread::spawn(|| { - loop { - std::thread::sleep(std::time::Duration::from_secs(30)); - if let Ok(status) = fs::read_to_string("/proc/self/status") { - eprintln!( - "{}", - status - .lines() - .filter(|l| l.starts_with("VmRSS:") || l.starts_with("VmSize:")) - .collect::>() - .join(" ") - ); - } - } - }); -} - -pub(super) fn run(args: &[String]) -> Result<(), Box> { - if args.len() == 1 && args[0] == "capabilities" { - print_capabilities(); - return Ok(()); - } - if args.len() < 2 { - return Err("usage: ix-compression-worker fri [--native-only | --check-only | --prove-outer]".into()); - } - let dir = PathBuf::from(&args[0]); - let out = PathBuf::from(&args[1]); - let mode = args.get(2).map_or("--check-only", String::as_str); - if args.len() > 3 - || !matches!(mode, "--native-only" | "--check-only" | "--prove-outer") - { - return Err("invalid mode".into()); - } - fs::create_dir_all(&out)?; - spawn_memory_monitor(); +/// Verify the root input in `dir`, build its FRI verifier circuit and check +/// its constraints, writing the circuit's identity and cost to `out`. With +/// `prove`, also prove the circuit and write the outer FRI proof. +pub(super) fn run( + dir: &Path, + out: &Path, + prove: bool, +) -> Result<(), Box> { + fs::create_dir_all(out)?; let input_span = tracing::info_span!("compression/load_input").entered(); let vk_bytes = fs::read(dir.join("root-vk.bin"))?; let native_key = aiur::vk_codec::AiurVerifyingKey::from_bytes(&vk_bytes)?; @@ -92,14 +43,14 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { tracing::info_span!("compression/verify_input") .in_scope(|| native_key.verify(&claims[0], &proof)) .map_err(|e| format!("Aiur policy: {e:?}"))?; - println!( + eprintln!( "Native Ix root batch verified in {:?}: {} bytes, {} circuits, {} shards", start.elapsed(), bytes.len(), system.circuits.len(), proof.proofs.len() ); - println!( + eprintln!( "log_blowup={} cap={} final_log={} arity={} queries={} commit_pow={} query_pow={}", cp.log_blowup, cp.cap_height, @@ -109,15 +60,12 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { fp.commit_proof_of_work_bits, fp.query_proof_of_work_bits ); - println!("Trusted VK BLAKE3: {:02x?}", Blake3.hash_iter(vk_bytes)); - println!("Expected public claims: {claims:?}"); - println!( + eprintln!("Trusted VK BLAKE3: {:02x?}", Blake3.hash_iter(vk_bytes)); + eprintln!("Expected public claims: {claims:?}"); + eprintln!( "Fixed messages (validated Aiur memory-closure policy): {}", proof.preamble.messages.len() ); - if mode == "--native-only" { - return Ok(()); - } let verifier_key = VerifierKey::from_system(system); fs::write(out.join("inner-vk.bin"), verifier_key.to_bytes()?)?; // A normalized root carries its layout; the verifier circuit then depends @@ -146,7 +94,7 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { let max_trace_height = 1usize << 24; let layout = prepared.circuit().multi_stark_layout_with_max_height(max_trace_height)?; - println!( + eprintln!( "Circuit preparation: {:?}; {:?}", start.elapsed(), prepared.circuit().stats() @@ -167,14 +115,14 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { .max() .unwrap(); let log_lde = largest_trace.ilog2() as usize + cp.log_blowup; - println!( + eprintln!( "Largest outer LDE: 2^{log_lde}; {} computation traces", layout.main_heights.len() ); if log_lde > Val::TWO_ADICITY { return Err(format!("outer LDE requires 2^{log_lde} points; Goldilocks supports at most 2^{}; a smaller/partitioned layout is required", Val::TWO_ADICITY).into()); } - if mode == "--check-only" { + if !prove { return Ok(()); } // This estimate excludes stage 2, quotients and scratch; enforce a separate @@ -183,7 +131,7 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { .trace_field_bytes .checked_mul(1usize << cp.log_blowup) .ok_or("LDE size overflow")?; - println!( + eprintln!( "Advice + fixed LDE bytes (not total prover memory): {base_lde_bytes}" ); if base_lde_bytes > (192usize << 30) { @@ -198,10 +146,10 @@ pub(super) fn run(args: &[String]) -> Result<(), Box> { let start = Instant::now(); let (verifier, outer_proof) = prepared .prove_fri(GoldilocksBlake3Config::new(cp, fp), max_trace_height)?; - println!("FRI proving and verification: {:?}", start.elapsed()); + eprintln!("FRI proving and verification: {:?}", start.elapsed()); let _output_span = tracing::info_span!("compression/write_output").entered(); let bytes = outer_proof.to_bytes()?; - println!("Outer proof: {} bytes", bytes.len()); + eprintln!("Outer proof: {} bytes", bytes.len()); fs::write(out.join("outer-proof.bin"), bytes)?; fs::write(out.join("outer-vk.bin"), verifier.key.to_bytes()?)?; fs::write( diff --git a/crates/proof-compression/src/pipeline/root_layout.rs b/crates/proof-compression/src/pipeline/root_layout.rs new file mode 100644 index 000000000..d2c25a4c6 --- /dev/null +++ b/crates/proof-compression/src/pipeline/root_layout.rs @@ -0,0 +1,20 @@ +//! Fixed normalization bounds for the released aggregation backend. +use super::{FixedRoot, Result}; + +const RELEASE: &str = "ixvm-2026-10-29"; +const LAYOUT: &str = include_str!("../../root-layouts/ixvm-2026-10-29.json"); +const ROOT_VERIFIER_BLAKE3: &str = + "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc"; + +/// The release layout for an authenticated aggregation backend's serialized key. +/// Circuit order and fixed-table heights are meaningful only under that key. +pub fn release_root_layout(root_vk: &[u8]) -> Result<&'static str> { + if blake3::hash(root_vk).to_hex().as_str() != ROOT_VERIFIER_BLAKE3 { + return Err(format!( + "aggregation backend differs from the {RELEASE} root layout; a matching release layout is required" + ) + .into()); + } + FixedRoot::from_json(LAYOUT)?; + Ok(LAYOUT) +} diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index dd4b7cdd7..b78cb0e3d 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -5,6 +5,11 @@ //! metadata and an authenticated prefix cache. The development policy derives a //! known trapdoor from the stage seed, with parameters from a per-stage cache //! file or from the shared directory named by `IX_KZG_SRS_CACHE`. +//! +//! Without explicit Ignition settings, the pinned Ignition release is used +//! when its files are present in the parameters directory (`IX_PARAMS_DIR`, +//! else `~/.ix/compression/params`), as `ix compress fetch-params` leaves +//! them. use super::storage; use crate::kzg::srs::ignition::{ IgnitionAdmission, load_ignition_cache_with_identity, @@ -32,6 +37,63 @@ const IGNITION_ENV_VARS: [&str; 4] = [ IGNITION_ADMISSION_VAR, IGNITION_ADMISSION_DIGEST_VAR, ]; +pub(crate) const PARAMETERS_DIR_VAR: &str = "IX_PARAMS_DIR"; + +/// Published BN254 Ignition proving parameters and their pins. The objects are +/// publicly readable from the URL `ix compress fetch-params` downloads; the +/// pins, not the location, authenticate them. +struct IgnitionRelease { + name: &'static str, + cache_file: &'static str, + cache_manifest_blake3: &'static str, + admission_file: &'static str, + admission_blake3: &'static str, +} + +const IGNITION_RELEASE: IgnitionRelease = IgnitionRelease { + name: "bn254-ignition-main-197", + cache_file: "ix-prefix-26.bin", + cache_manifest_blake3: + "6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b", + admission_file: "admission.json", + admission_blake3: + "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", +}; + +impl IgnitionRelease { + fn directory(&self, parameters: &Path) -> PathBuf { + parameters.join(self.name) + } + + fn config(&self, directory: &Path) -> storage::Result { + SetupConfig::ignition( + directory.join(self.cache_file), + parse_ignition_digest( + IGNITION_CACHE_MANIFEST_VAR, + OsStr::new(self.cache_manifest_blake3), + )?, + &directory.join(self.admission_file), + parse_ignition_digest( + IGNITION_ADMISSION_DIGEST_VAR, + OsStr::new(self.admission_blake3), + )?, + ) + } + + fn present(&self, directory: &Path) -> bool { + directory.join(self.cache_file).is_file() + && directory.join(self.admission_file).is_file() + } +} + +/// `IX_PARAMS_DIR`, else `$HOME/.ix/compression/params`. +fn parameters_directory_from( + mut lookup: impl FnMut(&str) -> Option, +) -> Option { + lookup(PARAMETERS_DIR_VAR).map(PathBuf::from).or_else(|| { + lookup("HOME").map(|home| PathBuf::from(home).join(".ix/compression/params")) + }) +} /// The trust class of verifier parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -83,28 +145,34 @@ fn development_refused() -> storage::Result { let message = format!( "no admitted BN254 Ignition setup is configured; set \ {IGNITION_CACHE_VAR}, {IGNITION_CACHE_MANIFEST_VAR}, \ - {IGNITION_ADMISSION_VAR} and {IGNITION_ADMISSION_DIGEST_VAR}, or pass \ + {IGNITION_ADMISSION_VAR} and {IGNITION_ADMISSION_DIGEST_VAR}, run \ + `ix compress fetch-params` to install the pinned release, or pass \ --dev-srs to use the known-trapdoor development setup \ (not production-secure)" ); Err(message.into()) } -/// Setup policy captured for one worker invocation. +/// The KZG parameters a compression proves under: admitted Ignition +/// parameters, or with `development` the known-trapdoor development setup. #[derive(Clone)] -pub(crate) enum SetupConfig { +pub enum SetupConfig { + #[non_exhaustive] Ignition { cache: PathBuf, manifest: [u8; 32], admission: Arc, }, - Development { - cache_directory: Option, - }, + #[non_exhaustive] + Development { cache_directory: Option }, } impl SetupConfig { - pub(crate) fn from_env(development: bool) -> storage::Result { + /// Select the setup from the `IX_KZG_IGNITION_*` variables or the + /// parameters directory, admitting Ignition parameters before returning. + /// With `development`, the development setup caches its SRS files in + /// `IX_KZG_SRS_CACHE` when set. + pub fn from_env(development: bool) -> storage::Result { Self::from_ignition_env(development, |name| std::env::var_os(name)) } @@ -119,7 +187,13 @@ impl SetupConfig { } let values = IGNITION_ENV_VARS.map(&mut lookup); if values.iter().all(Option::is_none) { - return development_refused(); + let release = parameters_directory_from(&mut lookup) + .map(|parameters| IGNITION_RELEASE.directory(¶meters)) + .filter(|directory| IGNITION_RELEASE.present(directory)); + return match release { + Some(directory) => IGNITION_RELEASE.config(&directory), + None => development_refused(), + }; } let [cache, manifest, admission, admission_digest] = values; let required = |name, value: Option| { @@ -138,8 +212,17 @@ impl SetupConfig { IGNITION_ADMISSION_DIGEST_VAR, &required(IGNITION_ADMISSION_DIGEST_VAR, admission_digest)?, )?; + Self::ignition(cache, manifest, &admission_path, admission_digest) + } + + fn ignition( + cache: PathBuf, + manifest: [u8; 32], + admission_path: &Path, + admission_digest: [u8; 32], + ) -> storage::Result { let admission = - Arc::new(IgnitionAdmission::read(&admission_path, admission_digest)?); + Arc::new(IgnitionAdmission::read(admission_path, admission_digest)?); let (anchors, identity) = load_ignition_cache_with_identity(&cache, manifest, 2)?; admission.check_cache(&anchors, &identity)?; diff --git a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs index 70135a65d..fa65a1b00 100644 --- a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs +++ b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs @@ -290,3 +290,34 @@ fn ignition_digest_parser_rejects_non_unicode_values() { [0xab; 32], ); } + +#[test] +fn installed_release_is_used_only_without_explicit_configuration() +-> storage::Result<()> { + let fixture = Fixture::new(13, 9); + let parameters = fixture.directory.join("parameters"); + let lookup = |name: &str| { + (name == PARAMETERS_DIR_VAR).then(|| parameters.as_os_str().to_owned()) + }; + let missing = SetupConfig::from_ignition_env(false, lookup).err().unwrap(); + assert!(missing.to_string().contains("fetch-params")); + + // Files that do not match the release pins fail closed rather than falling + // back to the development refusal. + let release = IGNITION_RELEASE.directory(¶meters); + fs::create_dir_all(&release)?; + fs::copy(&fixture.cache, release.join(IGNITION_RELEASE.cache_file))?; + fs::copy( + &fixture.admission_path, + release.join(IGNITION_RELEASE.admission_file), + )?; + let mismatched = SetupConfig::from_ignition_env(false, lookup).err().unwrap(); + assert!(!mismatched.to_string().contains("fetch-params")); + + let explicit = fixture.environment(); + let config = SetupConfig::from_ignition_env(false, |name| { + explicit.get(name).cloned().or_else(|| lookup(name)) + })?; + assert_eq!(config.class(), SetupClass::Ignition); + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/terminal.rs b/crates/proof-compression/src/pipeline/terminal.rs index 4f0eb0f81..d4088a0d0 100644 --- a/crates/proof-compression/src/pipeline/terminal.rs +++ b/crates/proof-compression/src/pipeline/terminal.rs @@ -2,18 +2,19 @@ use super::Result; #[cfg(any(feature = "plonk", feature = "groth16"))] use super::statement; -#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +#[cfg(test)] use super::storage; use serde::{Deserialize, Serialize}; -#[cfg(any(feature = "plonk", feature = "groth16", test))] +#[cfg(any(feature = "plonk", feature = "groth16"))] use sha2::{Digest, Sha256}; use std::{fs, io::Read, path::Path}; -#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +#[cfg(test)] use std::path::PathBuf; -#[cfg(any(not(feature = "plonk"), not(feature = "groth16")))] -use std::process::{Command, Stdio}; +// TODO: Decide between SHA-256 and BLAKE3 for artifact/cache metadata. +// Coordinate Rust/Go encodings and any catalog or envelope key-ID changes; +// proof-protocol hashes have separate compatibility requirements. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "PascalCase", deny_unknown_fields)] pub struct Artifact { @@ -66,9 +67,9 @@ impl TryFrom for TerminalOptions { } } -#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +#[cfg(test)] pub(super) struct Scratch(pub(super) PathBuf); -#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +#[cfg(test)] impl Scratch { pub(super) fn new() -> Result { let path = @@ -77,7 +78,7 @@ impl Scratch { Ok(Self(path)) } } -#[cfg(any(not(feature = "plonk"), not(feature = "groth16"), test))] +#[cfg(test)] impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); @@ -110,7 +111,7 @@ pub(super) fn digest(text: &str) -> Result<[u8; 32]> { .map_err(|_bytes| "expected a 32-byte hexadecimal digest".into()) } -#[cfg(any(feature = "plonk", feature = "groth16", test))] +#[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) fn sha256(path: &Path) -> Result<[u8; 32]> { let mut file = fs::File::open(path)?; let mut hash = Sha256::new(); @@ -140,18 +141,6 @@ pub(super) fn load_json( Ok(serde_json::from_slice(&read_bounded(path, 1 << 20)?)?) } -#[cfg(any(not(feature = "plonk"), not(feature = "groth16")))] -pub(super) fn checked(command: &mut Command) -> Result<()> { - let status = command - .stdout(Stdio::from(std::io::stderr())) - .stderr(Stdio::inherit()) - .status()?; - if !status.success() { - return Err(format!("terminal helper failed ({status})").into()); - } - Ok(()) -} - #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) fn claim_digest( expected: &statement::Statement, diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs index 05df1e5dd..c0f2c8d81 100644 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -1,110 +1,5 @@ -//! Verifier verifying keys authenticated by a release bundle or an independent -//! digest. Bundles pin an aggregation program; local verifying keys require its -//! independently supplied statement as well as the certificate claim. -use super::{ - Result, - setup::SetupClass, - statement, - wrap::{PreparedVerifyingKey, VerifyingKey}, -}; -use std::sync::OnceLock; - -struct BundledVerifier { - id: &'static str, - digest: &'static str, - /// Backend and entrypoint words of the aggregation program whose roots - /// this verifying key compresses. - statement_prefix: [u64; 10], - setup: SetupClass, - ignition_admission: Option, - bytes: &'static [u8], - decoded: OnceLock>, -} - -struct BundledIgnitionAdmission { - bytes: &'static [u8], - digest: [u8; 32], -} - -impl BundledIgnitionAdmission { - fn decode(&self) -> Result { - Ok(crate::kzg::srs::ignition::IgnitionAdmission::from_bytes( - self.bytes, - self.digest, - )?) - } -} - -static VERIFIERS: [BundledVerifier; 0] = []; - -impl BundledVerifier { - fn check_release_class(&self) -> Result<()> { - if self.setup != SetupClass::Ignition { - return Err( - "BN254 release verifier catalog admits only Ignition setups".into(), - ); - } - Ok(()) - } - - fn vk(&self) -> Result<&PreparedVerifyingKey> { - self - .decoded - .get_or_init(|| { - self.check_release_class().map_err(|error| error.to_string())?; - if blake3::hash(self.bytes).to_hex().as_str() != self.digest { - return Err("bundled compression verifier digest differs".into()); - } - let vk = VerifyingKey::from_bytes(self.bytes) - .map_err(|error| error.to_string())?; - { - let admission = self - .ignition_admission - .as_ref() - .ok_or("bundled Ignition verifier has no trusted admission")? - .decode() - .map_err(|error| error.to_string())?; - vk.check_ignition_admission(&admission) - .map_err(|error| error.to_string())?; - } - if blake3::Hash::from_bytes(vk.id()).to_hex().as_str() != self.id { - return Err("bundled compression verifier metadata differs".into()); - } - vk.prepare().map_err(|error| error.to_string()) - }) - .as_ref() - .map_err(|error| error.clone().into()) - } -} - -fn bundled(id: &[u8; 32]) -> Option<&'static BundledVerifier> { - let id = blake3::Hash::from_bytes(*id).to_hex(); - VERIFIERS.iter().find(|verifier| verifier.id == id.as_str()) -} - -/// The statement prefix a bundled verifying key pins, if `id` names one. -pub(super) fn bundled_prefix(id: &[u8; 32]) -> Option<&'static [u64; 10]> { - bundled(id).map(|verifier| &verifier.statement_prefix) -} - -pub(super) fn verify( - packet: &[u8], - claim: &[u8], - allow_development: bool, -) -> Result { - let id: [u8; 32] = - packet.get(..32).ok_or("truncated compressed proof")?.try_into()?; - let verifier = bundled(&id).ok_or( - "compressed proof uses a verifying key not bundled with this application", - )?; - verifier.check_release_class()?; - if verifier.setup == SetupClass::Development && !allow_development { - return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); - } - let statement = statement::with_claim(&verifier.statement_prefix, claim); - verifier.vk()?.verify(packet, &statement)?; - Ok(verifier.setup) -} +//! Recursive KZG verification with independently authenticated keys and statements. +use super::{Result, setup::SetupClass, statement, wrap::VerifyingKey}; /// Verify using an independently authenticated verifying key and expected statement. pub(super) fn verify_with_vk( @@ -152,64 +47,3 @@ pub(super) fn verify_with_ignition_admission( vk.verify(packet, expected)?; Ok(SetupClass::Ignition) } - -#[cfg(test)] -mod bn254_tests { - use super::*; - - #[test] - fn bn254_release_catalog_rejects_non_ignition_classes() { - { - let verifier = BundledVerifier { - id: "", - digest: "", - statement_prefix: [0; 10], - setup: SetupClass::Development, - ignition_admission: None, - bytes: &[], - decoded: OnceLock::new(), - }; - assert!(verifier.check_release_class().is_err()); - assert!( - verifier - .vk() - .err() - .unwrap() - .to_string() - .contains("admits only Ignition") - ); - } - } - - #[test] - fn bundled_ignition_admission_requires_its_release_pin() { - let (admission, _) = crate::kzg::srs::ignition::test_admission(7, 16, 43); - let bytes: &'static [u8] = - Box::leak(admission.to_bytes().unwrap().into_boxed_slice()); - let digest = *blake3::hash(bytes).as_bytes(); - let bundled = BundledIgnitionAdmission { bytes, digest }; - assert_eq!( - bundled.decode().unwrap().public_setup(), - admission.public_setup() - ); - let mut wrong_digest = digest; - wrong_digest[0] ^= 1; - assert!( - BundledIgnitionAdmission { bytes, digest: wrong_digest } - .decode() - .is_err() - ); - } - - #[test] - fn bn254_requires_an_explicit_trusted_vk() { - assert!(bundled(&[0; 32]).is_none()); - let packet = [0; 32]; - assert!( - verify(&packet, b"claim", true) - .unwrap_err() - .to_string() - .contains("not bundled") - ); - } -} diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index ed4f99ec7..07e574a92 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -2,61 +2,16 @@ mod outer; mod saved; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) use outer::plonk_export; -#[cfg(feature = "plonk")] -pub(super) use outer::plonk_export_first; - -/// Device bytes the recursive prover keeps resident per device unless -/// `IX_KZG_CUDA_RESIDENT_GIB` overrides it. Its traces are the largest -/// in the pipeline, so a wider reservation starves the constraint passes. -#[cfg(feature = "cuda")] -pub(super) const DEFAULT_CUDA_RESIDENT_BYTES: usize = 8 << 30; +/// Recursively verify the first KZG stage in `first` and prove the recursive +/// stage into `out`, with its circuit report beside it. pub(super) fn run( - args: &[String], - config: &super::WorkerConfig, + first: &std::path::Path, + out: &std::path::Path, + config: &super::StageConfig, ) -> Result<(), Box> { - if args - .first() - .is_some_and(|s| matches!(s.as_str(), "native-verify" | "stage-and-prove")) - { - if args.len() != 3 { - return Err("usage: ix-compression-worker wrap ".into()); - } - let dir = std::path::Path::new(&args[2]); - if args[0] == "stage-and-prove" { - spawn_memory_monitor(); - std::fs::create_dir_all(dir)?; - return saved::check( - std::path::Path::new(&args[1]), - &dir.join("circuit-report.json"), - Some(dir), - config, - ); - } - return saved::check(std::path::Path::new(&args[1]), dir, None, config); - } - Err( - "usage: ix-compression-worker wrap ..." - .into(), - ) -} - -fn spawn_memory_monitor() { - std::thread::spawn(|| { - loop { - std::thread::sleep(std::time::Duration::from_secs(30)); - if let Ok(status) = std::fs::read_to_string("/proc/self/status") { - eprintln!( - "{}", - status - .lines() - .filter(|l| l.starts_with("VmRSS:") || l.starts_with("VmHWM:")) - .collect::>() - .join(" ") - ); - } - } - }); + std::fs::create_dir_all(out)?; + saved::prove(first, out, config) } -pub(super) use outer::{PreparedVerifyingKey, VerifyingKey}; +pub(super) use outer::VerifyingKey; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 32208a3ee..bb3f4fa88 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -6,8 +6,6 @@ mod plonk_export; mod relation; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(in crate::pipeline) use plonk_export::export as plonk_export; -#[cfg(feature = "plonk")] -pub(in crate::pipeline) use plonk_export::export_first as plonk_export_first; use super::super::stages::{ construct::{self, Lowered}, @@ -56,7 +54,7 @@ pub(crate) fn stage( dir: &Path, vk: &[u8], statement: &Statement, - config: &super::super::WorkerConfig, + config: &super::super::StageConfig, ) -> storage::Result<()> { let frontend = Frontend::from_built(built, vk)?; frontend.stage(assignment, dir, statement, config) @@ -99,7 +97,7 @@ impl Frontend { assignment: Assignment, dir: &Path, statement: &Statement, - config: &super::super::WorkerConfig, + config: &super::super::StageConfig, ) -> storage::Result<()> { if assignment.public_values().len() != RECURSIVE_PUBLIC_VALUES || assignment.public_values()[..18] @@ -129,7 +127,7 @@ impl Frontend { setup.bind_stage(dir, height, 2)?; storage::save(&dir.join("pairings.bin"), &self.pairings)?; let cache_identity = [self.vk.as_slice(), &setup_id].concat(); - let cache = config.fixed_cache(dir, &cache_identity)?; + let cache = config.fixed_cache(dir, &cache_identity); let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( &self.compiled, @@ -381,7 +379,7 @@ fn prove( out.join("prove-report.json"), serde_json::to_vec_pretty(&report)?, )?; - println!("{}", serde_json::to_string_pretty(&report)?); + eprintln!("{}", serde_json::to_string_pretty(&report)?); drop(output_span); cache.publish(dir)?; Ok(()) @@ -708,7 +706,7 @@ mod tests { -> storage::Result<()> { use crate::{ kzg::srs::ignition::test_admission, - pipeline::{WorkerConfig, setup::SetupConfig}, + pipeline::{StageConfig, setup::SetupConfig}, }; let (admission, _) = test_admission(7, 256, 44); let inner = Srs::unsafe_dev_setup(2, b"ignition-stage-development-inner"); @@ -733,7 +731,7 @@ mod tests { let root = std::env::temp_dir() .join(format!("ignition-stage-admission-{}", std::process::id(),)); assert!(!root.exists()); - let config = WorkerConfig { + let config = StageConfig { setup: SetupConfig::Ignition { cache: root.join("absent-cache.bin"), manifest: [0; 32], @@ -992,7 +990,7 @@ mod tests { let mut encoded = vec![]; keys.serialize_compressed(&mut encoded)?; storage::save(&dir.join("pairings.bin"), &Pairings { keys: encoded })?; - let cache = fixed_cache::FixedCache::from_identity(cache_root, b"outer")?; + let cache = fixed_cache::FixedCache::from_identity(cache_root, b"outer"); let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( compiled, @@ -1200,7 +1198,7 @@ mod tests { &dir, b"recursive-fixture", &init_claim::INIT_PUBLIC_WORDS, - &crate::pipeline::WorkerConfig::development(), + &crate::pipeline::StageConfig::development(), )?; let report: serde_json::Value = serde_json::from_slice(&fs::read(dir.join("kzg/prove-report.json"))?)?; @@ -1319,7 +1317,7 @@ mod tests { &dir, &plan.identity(), &init_claim::INIT_PUBLIC_WORDS, - &crate::pipeline::WorkerConfig::development(), + &crate::pipeline::StageConfig::development(), )?; let packet = fs::read(dir.join("kzg/packet.bin"))?; verify_saved(&dir, &packet, None)?; diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index f37b2c339..35845c4f9 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -37,19 +37,17 @@ pub(in crate::pipeline) fn export( trusted_digest: &str, expected: &Statement, directory: &Path, + setup: &super::super::super::setup::SetupConfig, ) -> Result<()> { let bytes = fs::read(verifier)?; if blake3::hash(&bytes).to_hex().as_str() != trusted_digest { return Err("trusted verifier digest differs".into()); } let vk = VerifyingKey::from_bytes(&bytes)?; - { - let setup = super::super::super::setup::SetupConfig::from_env(false)?; - let admission = setup - .ignition_admission() - .ok_or("terminal export requires admitted Ignition parameters")?; - vk.check_ignition_admission(admission)?; - } + let admission = setup + .ignition_admission() + .ok_or("terminal export requires admitted Ignition parameters")?; + vk.check_ignition_admission(admission)?; write_export(&vk, &fs::read(packet)?, expected, directory, "witness", true) } @@ -160,88 +158,6 @@ pub(super) fn write_export( ) } -/// Export the relation verifying a saved first KZG stage directly: every -/// partition's circuit, the first-stage claim rows and no deferred pairing. -/// The stage's setup is selected from the environment as the worker does. -#[cfg(feature = "plonk")] -pub(in crate::pipeline) fn export_first( - stage: &Path, - expected: &Statement, - directory: &Path, - development: bool, -) -> Result<()> { - let config = super::super::super::WorkerConfig::from_env("kzg", development)?; - let input = super::super::saved::Input::load(stage, &config)?; - let system = &input.system; - if system.config.transcript_kind() != TranscriptKind::Blake3 { - return Err( - "PLONK wrapping requires a BLAKE3 first-stage verifying key".into(), - ); - } - if statement::from_rows(&input.claims)? != *expected { - return Err("first-stage claims differ from the expected statement".into()); - } - let vk_id: [u8; 32] = - fs::read(stage.join("kzg/vk-id.bin"))?.try_into().map_err( - |bytes: Vec| format!("verifying key id of {} bytes", bytes.len()), - )?; - let packet = fs::read(stage.join("kzg/packet.bin"))?; - let mut composed = vk_id.to_vec(); - for word in expected { - composed.extend(word.to_le_bytes()); - } - composed.extend(&input.proof_bytes); - if packet != composed { - return Err( - "first-stage packet differs from its verifying key, statement and proof" - .into(), - ); - } - let diagnostic = diagnostics(system, &input.proof, &input.claims)?; - // Rows 1..=18 carry the statement words at column 3; every other claim - // value, including the table and hash-trace anchors, is a constant. - let locations = - StageStatement::from_claims(&input.claims, statement::WORDS)?.locations; - let slots = input - .claims - .iter() - .enumerate() - .map(|(row, values)| { - values - .iter() - .enumerate() - .map(|(column, &value)| { - match locations.iter().position(|&l| l == (row, column)) { - Some(index) => ir::ClaimSlot::Public(index), - None => ir::ClaimSlot::Constant(value), - } - }) - .collect() - }) - .collect(); - let publics = (0..10) - .map(PublicSource::Prefix) - .chain((0..8).map(PublicSource::DigestWord)) - .collect(); - write_vk( - &Export { - system, - proof: &input.proof, - logs: &input.logs, - slots, - publics, - vk_id, - deferred_keys: vec![], - deferred_bytes: &[], - packet: &packet, - diagnostic, - }, - expected, - directory, - "witness", - ) -} - fn write_vk( export: &Export<'_>, expected: &Statement, diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index 9d374fb9d..ffb550f7d 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -88,7 +88,7 @@ impl Input { )] pub(super) fn load( dir: &Path, - config: &super::super::WorkerConfig, + config: &super::super::StageConfig, ) -> Result { let start = Instant::now(); let mut input_blake3 = BTreeMap::new(); @@ -185,11 +185,10 @@ impl Input { } } -pub(crate) fn check( +pub(super) fn prove( dir: &Path, - report: &Path, - stage: Option<&Path>, - config: &super::super::WorkerConfig, + stage: &Path, + config: &super::super::StageConfig, ) -> Result<()> { let start = Instant::now(); let input = Input::load(dir, config)?; @@ -228,20 +227,20 @@ pub(crate) fn check( "development_srs": input.development_srs, "scope": "Full saved first-stage KZG proof checked by recursive constraints and external pairings; no outer proof yet", }); - fs::write(report, serde_json::to_vec_pretty(&result)?)?; - println!("{}", serde_json::to_string_pretty(&result)?); - if let Some(stage) = stage { - let statement = statement::from_rows(&input.claims)?; - super::outer::stage( - built, - assignment, - stage, - &plan_identity, - &statement, - config, - )?; - } - Ok(()) + fs::write( + stage.join("circuit-report.json"), + serde_json::to_vec_pretty(&result)?, + )?; + eprintln!("{}", serde_json::to_string_pretty(&result)?); + let statement = statement::from_rows(&input.claims)?; + super::outer::stage( + built, + assignment, + stage, + &plan_identity, + &statement, + config, + ) } #[cfg(test)] @@ -400,7 +399,7 @@ mod tests { )?; fs::write(root.join("setup-0.bin"), [0xff])?; let missing = - Input::load(&root, &crate::pipeline::WorkerConfig::development()) + Input::load(&root, &crate::pipeline::StageConfig::development()) .err() .unwrap(); assert_eq!( @@ -409,7 +408,7 @@ mod tests { ); fs::write(root.join(BINDING_FILE), [0; 32])?; let wrong = - Input::load(&root, &crate::pipeline::WorkerConfig::development()) + Input::load(&root, &crate::pipeline::StageConfig::development()) .err() .unwrap(); assert!(wrong.to_string().contains("mismatch")); @@ -420,7 +419,7 @@ mod tests { .identity(1 << 24, 2)?, )?; let malformed = - Input::load(&root, &crate::pipeline::WorkerConfig::development()) + Input::load(&root, &crate::pipeline::StageConfig::development()) .err() .unwrap(); assert!(malformed.downcast_ref::().is_some()); diff --git a/crates/proof-compression/tests/pipeline_root.rs b/crates/proof-compression/tests/pipeline_root.rs index 4fcd85975..c8a7641ef 100644 --- a/crates/proof-compression/tests/pipeline_root.rs +++ b/crates/proof-compression/tests/pipeline_root.rs @@ -85,7 +85,7 @@ fn different_root_statements_and_trace_shard_counts() { let stage = prepare_root(&key, &proof, &expected, Default::default()).unwrap(); assert_eq!(stage.public_values(), expected); - check_cli_root( + check_root_directory( &key_bytes, &proof, &expected, @@ -237,7 +237,7 @@ fn shared_batch_root_preserves_single_batch_verifier_and_proof_bytes() Ok(()) } -fn check_cli_root( +fn check_root_directory( key: &[u8], proof: &aiur::synthesis::AiurProof, expected: &[Val], @@ -249,7 +249,7 @@ fn check_cli_root( use std::fs; let directory = std::env::temp_dir() - .join(format!("ix-cli-root-{}-{shards}", std::process::id())); + .join(format!("ix-root-directory-{}-{shards}", std::process::id())); fs::create_dir(&directory).unwrap(); let result = (|| -> Result<(), Box> { let input = directory.join("input"); @@ -263,14 +263,7 @@ fn check_cli_root( claims.extend(value.as_canonical_u64().to_le_bytes()); } fs::write(input.join("root-claims.bin"), claims)?; - ix_proof_compression::pipeline::command( - "fri", - &[ - input.to_string_lossy().into_owned(), - output.to_string_lossy().into_owned(), - "--check-only".into(), - ], - )?; + ix_proof_compression::pipeline::check_root(&input, &output)?; assert!(output.join("CHECKED").is_file()); assert!( fs::read_to_string(output.join("circuit-cost.txt"))? diff --git a/crates/proof-compression/tests/worker_tracing.rs b/crates/proof-compression/tests/worker_tracing.rs deleted file mode 100644 index 7c39b1ac5..000000000 --- a/crates/proof-compression/tests/worker_tracing.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::{fs, process::Command}; - -fn worker(texray: bool) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_ix-compression-worker")); - command - .env("IX_TEXRAY", if texray { "1" } else { "0" }) - .env_remove("IX_KZG_FILECOIN_CACHE") - .env_remove("IX_KZG_FILECOIN_MANIFEST") - .env_remove("IX_KZG_IGNITION_CACHE") - .env_remove("IX_KZG_IGNITION_CACHE_BLAKE3") - .env_remove("IX_KZG_IGNITION_ADMISSION") - .env_remove("IX_KZG_IGNITION_ADMISSION_BLAKE3") - .env_remove("IX_KZG_PREFETCH_GIB"); - command -} - -#[test] -fn worker_traces_early_failures_and_keeps_stdout_machine_readable() { - let directory = std::env::temp_dir() - .join(format!("ix-worker-tracing-{}", std::process::id())); - fs::create_dir(&directory).unwrap(); - for enabled in [false, true] { - let output = - worker(enabled).args(["fri", "capabilities"]).output().unwrap(); - assert!(output.status.success()); - let capabilities: serde_json::Value = - serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(capabilities["binary"], "ix-compression-worker"); - assert_eq!(capabilities["curve"], ix_proof_compression::kzg::curve::NAME); - assert_eq!(capabilities["development_setup_only"], false); - assert_eq!(capabilities["ignition_setup"], true); - assert_eq!(capabilities["terminal_plonk"], cfg!(feature = "plonk")); - assert_eq!(capabilities["terminal_groth16"], cfg!(feature = "groth16")); - assert_eq!( - capabilities["terminal_groth16_cuda"], - cfg!(all(feature = "groth16", feature = "cuda")) - ); - assert_eq!( - capabilities["terminal_plonk_cuda"], - cfg!(all(feature = "plonk", feature = "cuda")) - ); - assert_eq!( - String::from_utf8_lossy(&output.stderr) - .contains("[texray] compression/fri:"), - enabled - ); - for (stage, args) in [ - ("fri", vec!["fri", "missing", "fri-out", "--native-only"]), - ("kzg", vec!["kzg", "run", "missing", "kzg-out", "24", "--dev-srs"]), - ( - "wrap", - vec!["wrap", "native-verify", "missing", "report.json", "--dev-srs"], - ), - ] { - let output = - worker(enabled).args(args).current_dir(&directory).output().unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert_eq!( - stderr.contains(&format!("[texray] compression/{stage}:")), - enabled, - "{stderr}" - ); - assert_eq!( - stderr.contains("[texray] compression/load_input:"), - enabled, - "{stderr}" - ); - } - } - fs::remove_dir_all(directory).unwrap(); -} - -#[test] -fn worker_pipeline_requires_an_explicit_vk_digest_before_io() { - let output = worker(false) - .args([ - "pipeline", - "verify", - "missing-proof", - "missing-claim", - "missing-claims", - "missing-vk", - "not-a-digest", - "--dev-srs", - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("64 hexadecimal characters") - ); -} - -#[test] -fn ignition_verification_requires_both_pins_and_refuses_development_mode() { - let pin = "00".repeat(32); - let args = [ - "pipeline", - "verify-ignition", - "missing-proof", - "missing-claim", - "missing-claims", - "missing-vk", - pin.as_str(), - "missing-admission", - ]; - let output = worker(false).args(args).arg("not-a-digest").output().unwrap(); - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("64 hexadecimal characters") - ); - let output = - worker(false).args(args).arg(&pin).arg("--dev-srs").output().unwrap(); - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("does not accept --dev-srs") - ); -} diff --git a/flake.nix b/flake.nix index 4edf9b1a8..e2b174d16 100644 --- a/flake.nix +++ b/flake.nix @@ -110,44 +110,6 @@ file: file.hasExt "go" || file.hasExt "h" || file.hasExt "json" || file.hasExt "bin" || file.name == "go.mod" || file.name == "go.sum" ) ./tools/plonk-wrapper; - plonkSppark = pkgs.fetchFromGitHub { - owner = "argumentcomputer"; - repo = "sppark"; - rev = "114feb69ebfe029e2caa5e6c2d372c695e5d4da8"; - hash = "sha256-/KXq6RuAUpRT0xJsXCSZsT1iONhbeHnXWcmTJL4ltQs="; - }; - plonkBlst = pkgs.fetchzip { - url = "https://static.crates.io/crates/blst/blst-0.3.16.crate"; - extension = "tar.gz"; - hash = "sha256-OHEKeKwl7RhPOiqILiRTZACmRLlmIaRuCE2qwKTfiIc="; - }; - plonkCudaNative = pkgs.stdenv.mkDerivation { - pname = "ix-plonk-bn254-cuda"; - version = "0.1.0"; - src = pkgs.lib.fileset.toSource { - root = ./crates/proof-compression/cuda; - fileset = pkgs.lib.fileset.fileFilter ( - file: builtins.any file.hasExt [ "cu" "cuh" "cpp" "h" "hpp" "py" "map" "json" "patch" ] - ) ./crates/proof-compression/cuda; - }; - nativeBuildInputs = [ pkgs.python3 pkgs.patch cudaToolkit ]; - CUDA_HOME = "${cudaToolkit}"; - NVCC = "${cudaToolkit}/bin/nvcc"; - IX_PLONK_CUDA_ARCHS = "120"; - dontConfigure = true; - buildPhase = '' - runHook preBuild - python3 plonk/build.py --sppark ${plonkSppark} --blst ${plonkBlst}/blst --out native - runHook postBuild - ''; - installPhase = '' - mkdir -p "$out/lib" "$out/include" "$out/share/ix-plonk-cuda" - cp native/libix_plonk_bn254_cuda.so "$out/lib/" - cp plonk/ix_plonk_bn254.h "$out/include/" - cp native/*.json "$out/share/ix-plonk-cuda/" - ''; - meta.platforms = [ "x86_64-linux" ]; - }; icicleBn254 = pkgs.stdenv.mkDerivation { pname = "ix-icicle-bn254"; version = "3.2.2"; @@ -194,6 +156,7 @@ # binary fixtures that Cargo's source filter does not retain. compressionData = pkgs.lib.fileset.unions [ ./crates/proof-compression/tests/fixtures + ./crates/proof-compression/root-layouts ]; # The Rust tests `include_bytes!` the `.ixe` files under # `Tests/Fixtures`: environments serialized by Lean, read back to check @@ -320,31 +283,6 @@ } ); - mkCompressionWorker = { cuda ? false }: craneLib.buildPackage ( - gnarkArgs // { - inherit cargoArtifacts; - pname = "ix-compression-worker"; - cargoExtraArgs = "--locked -p ix-proof-compression --bin ix-compression-worker --no-default-features --features parallel,plonk,groth16" - + pkgs.lib.optionalString cuda ",cuda"; - doCheck = false; - nativeBuildInputs = [ plonkGo ] ++ pkgs.lib.optional cuda cudaToolkit; - CUDA_HOME = pkgs.lib.optionalString cuda "${cudaToolkit}"; - NVCC = pkgs.lib.optionalString cuda "${cudaToolkit}/bin/nvcc"; - IX_GROTH16_ICICLE_ROOT = pkgs.lib.optionalString cuda "${icicleBn254}"; - IX_PLONK_CUDA_LIBRARY = pkgs.lib.optionalString cuda "${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so"; - postInstall = pkgs.lib.optionalString cuda '' - mkdir -p "$out/lib" - ln -s ${plonkCudaNative}/lib/libix_plonk_bn254_cuda.so "$out/lib/" - cudaOutput=$(${pkgs.jq}/bin/jq -r 'select(.reason == "build-script-executed" and (.linked_libs | index("dylib=multi_stark_cuda"))) | .out_dir' "$cargoBuildLog") - cp "$cudaOutput/libmulti_stark_cuda.so" "$out/lib/" - ln -s ${icicleBn254}/lib/libicicle_{device,field_bn254,curve_bn254}.so "$out/lib/" - ln -s ${icicleBn254}/lib/backend "$out/lib/backend" - ''; - } - ); - compressionWorker = mkCompressionWorker { }; - compressionWorkerCuda = mkCompressionWorker { cuda = true; }; - # Lake package lake2nix = pkgs.callPackage lean4-nix.lake { inherit lean; }; # Restrict the Lake build inputs to files traced by the Lean and @@ -402,12 +340,11 @@ mkdir -p .lake ln -sf ${lakePackageOverrides} .lake/package-overrides.json ''; - # Symlink the Crane-built static lib and the compression worker - # executable to where Lake expects Cargo to have left them + # Symlink the Crane-built static lib to where Lake expects Cargo to + # have left it postConfigure = '' mkdir -p target/release ln -s ${rustLib}/lib/libix_ffi.a target/release/ - ln -s ${compressionWorker}/bin/ix-compression-worker target/release/ ''; buildInputs = [ pkgs.gmp @@ -491,8 +428,6 @@ default = ixLib; ix = ixCLI; icicle-bn254 = icicleBn254; - compression-worker = compressionWorker; - compression-worker-cuda = compressionWorkerCuda; # `checks` are built by `nix flake check`; exposing this derivation # as a package keeps the ignored suite available on demand without # adding it to the default check set. diff --git a/lakefile.lean b/lakefile.lean index d3f73927a..69aa8e24e 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -37,10 +37,11 @@ The Rust static libraries use `target` + `moreLinkObjs` instead of `extern_lib` - Other application targets inherit `ix_rs` (`parallel`, plus opt-in `cuda`) from the `Ix` `lean_lib`. -The compression stages run in `ix-compression-worker`, a Rust executable -built beside `ix` by `ix_compression_worker`. The KZG prover and its CUDA -code are compiled only there, so the `cuda` feature of the static libraries -covers the Aiur prover alone. +Compression runs inside `ix`. Its terminal backends (`plonk`, `groth16`) are +compiled into `ix_rs_net` only, so other executables never build Go. With +`cuda`, the native libraries (multi-stark's, the BN254 KZG/PLONK library and, +for Groth16, ICICLE) are copied to `.lake/build/lib` and found through +`$ORIGIN/../lib`. The `ix_rs_test` and `ix_rs_net` targets fetch `ix_rs` first to guarantee ordering before Cargo overwrites its release archive, then snapshot distinct Lake artifacts. @@ -51,11 +52,23 @@ The archives are built when native targets need them. Keeping Rust linkage on -/ section FFI -/-- Curve selection shared by the CLI library and compression worker. -/ +/-- `1`/`true`/`yes` or `0`/`false`/`no`; `default` when unset or empty. -/ +def envFlag (name : String) (default : Bool) : IO Bool := do + match ← IO.getEnv name with + | none | some "" => pure default + | some "1" | some "true" | some "yes" => pure true + | some "0" | some "false" | some "no" => pure false + | some _ => throw <| IO.userError s!"{name} must be 0/1, false/true or no/yes" + +/-- Terminal backends of `ix`'s compression, mirroring the Cargo features of the +same names: `IX_PLONK` and `IX_GROTH16`, both off by default, so compression +ends at the recursive KZG proof. With `IX_CUDA`, each also enables its GPU +backend; Groth16 links an installed ICICLE library. -/ def compressionFeatures : IO (Array String) := do - match (← IO.getEnv "IX_KZG_CURVE").getD "bn254" with - | "bn254" => return #["plonk", "groth16"] - | other => throw <| IO.userError s!"IX_KZG_CURVE must be bn254, got {other}" + let mut features := #[] + if ← envFlag "IX_PLONK" false then features := features.push "plonk" + if ← envFlag "IX_GROTH16" false then features := features.push "groth16" + return features /-- Rust feature selection shared by compilation and native runtime linkage. -/ def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : IO (Array String) := do @@ -64,7 +77,7 @@ def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : IO (Array St let ixNoPar ← IO.getEnv "IX_NO_PAR" let ixCuda ← IO.getEnv "IX_CUDA" let ixTraceCodegen ← IO.getEnv "IX_CUDA_TRACE_CODEGEN" - let mut features ← compressionFeatures + let mut features ← if net then compressionFeatures else pure #[] if ixNoPar != some "1" then features := features.push "parallel" if ixCuda == some "1" || ixCuda == some "true" || ixCuda == some "yes" then features := features.push "cuda" @@ -77,7 +90,7 @@ def cargoFeatures (testFfi : Bool := false) (net : Bool := false) : IO (Array St /-- Build args for `cargo build --release` with opt-in feature overrides. Cargo output is visible with `lake -v build`. -/ def cargoArgs (testFfi : Bool := false) (net : Bool := false) : IO (Array String) := do - let features := (← cargoFeatures testFfi net).filter (fun name => name != "plonk" && name != "groth16") + let features ← cargoFeatures testFfi net IO.println s!"Ix Rust features: {if features.isEmpty then "none" else ",".intercalate features.toList}" let buildArgs := #["build", "--release", "--no-default-features", "-p", "ix-ffi"] if features.isEmpty then return buildArgs @@ -99,25 +112,14 @@ def nativeToolchainVariables : Array String := #["CFLAGS", "NVCC", "CUDA_HOME", "CUDA_PATH", "NVCC_CCBIN", "NVCC_PREPEND_FLAGS", "NVCC_APPEND_FLAGS", "MULTI_STARK_CUDA_ARCHS", "IX_KZG_CUDA_ARCHS", "GO", "CC", "CXX", "CGO_CFLAGS", "CGO_LDFLAGS", - "IX_PLONK_CUDA_LIBRARY", "IX_PLONK_SPPARK_SOURCE", "IX_PLONK_BLST_SOURCE", - "IX_PLONK_CUDA_ARCHS", "IX_GROTH16_ICICLE_ROOT"] + "IX_GROTH16_ICICLE_ROOT"] def usesCuda (features : Array String) : Bool := features.any fun feature => (feature.splitOn ",").any fun name => name == "cuda" || name == "cuda-trace-codegen" -def plonkCudaLibraryName := "libix_plonk_bn254_cuda.so" - -/-- Worker features follow the CLI's curve while omitting CLI-only features. -/ -def compressionWorkerArgs (features : Array String) : Array String := Id.run do - let mut selected := features.filter fun name => - #["plonk", "groth16", "parallel"].contains name - if usesCuda features then selected := selected.push "cuda" - return #["build", "--release", "-p", "ix-proof-compression", - "--bin", "ix-compression-worker", "--no-default-features", - "--features", ",".intercalate selected.toList] - def cudaLibraryName := "libmulti_stark_cuda.so" +def bn254LibraryName := "libix_bn254_cuda.so" /-- Cargo reports native build outputs even when the build is already fresh. -/ def cargoCudaLibrary (messages : String) : Except String FilePath := do @@ -129,29 +131,34 @@ def cargoCudaLibrary (messages : String) : Except String FilePath := do return System.FilePath.mk directory / cudaLibraryName throw "Cargo did not report multi-stark's CUDA shared library" -def cargoPlonkLibrary (messages : String) : Except String FilePath := do - for line in messages.splitOn "\n" do - let .ok message := Lean.Json.parse line | continue - let .ok variables := message.getObjValAs? (Array (Array String)) "env" | continue - for entry in variables do - if entry.size == 2 && entry[0]! == "IX_PLONK_CUDA_LIBRARY" then - return System.FilePath.mk entry[1]! - throw "Cargo did not report the PLONK CUDA shared library" - def icicleLibraries : Array String := #["libicicle_device.so", "libicicle_field_bn254.so", "libicicle_curve_bn254.so", "backend/cuda/libicicle_backend_cuda_device.so", "backend/bn254/cuda/libicicle_backend_cuda_field_bn254.so", "backend/bn254/cuda/libicicle_backend_cuda_curve_bn254.so"] -def cargoIcicleRoot (messages : String) : Except String FilePath := do +/-- The other native files the build scripts report: each `source` is copied to +`lib/`, keeping ICICLE's backend plugins at the relative paths it loads +them from. -/ +def cargoRuntimeArtifacts (messages : String) : Except String (Array (FilePath × String)) := do + let mut artifacts := #[] for line in messages.splitOn "\n" do let .ok message := Lean.Json.parse line | continue let .ok variables := message.getObjValAs? (Array (Array String)) "env" | continue for entry in variables do - if entry.size == 2 && entry[0]! == "IX_GROTH16_ICICLE_ROOT" then - return System.FilePath.mk entry[1]! - throw "Cargo did not report the Groth16 ICICLE installation" + if entry.size == 2 && entry[0]! == "IX_RUNTIME_ARTIFACTS" then + let entries ← (← Lean.Json.parse entry[1]!).getArr? + for artifact in entries do + artifacts := artifacts.push + (FilePath.mk (← artifact.getObjValAs? String "source"), ← artifact.getObjValAs? String "path") + return artifacts + +/-- Native libraries `features` load at runtime, relative to `lib/`; the +executables link the top-level ones. -/ +def runtimeLibraries (features : Array String) : Array String := + if !usesCuda features then #[] else + let groth16 := features.any fun feature => (feature.splitOn ",").contains "groth16" + #[cudaLibraryName, bn254LibraryName] ++ (if groth16 then icicleLibraries else #[]) local instance : CheckExists (Array FilePath) where checkExists paths := paths.allM (·.pathExists) @@ -161,6 +168,12 @@ local instance : GetMTime (Array FilePath) where paths.foldlM (fun oldest path => return min oldest (← getMTime path)) (← getMTime paths[0]!) +/-- The installed ICICLE prefix, shared with Cargo's Groth16 build configuration. -/ +def icicleRoot : BaseIO FilePath := do + match ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" with + | none | some "" => return "/usr/local" + | some path => return FilePath.mk path + /-- Replacing the inode keeps an already-loaded shared library intact. -/ def copyNativeArtifact (source output : FilePath) (tag : String) : JobM Unit := do IO.FS.createDirAll output.parent.get! @@ -183,56 +196,48 @@ def rustSources (pkg : Package) : SpawnM (Job Unit) := do ← inputTextFile (pkg.dir / "Cargo.lock"), ← inputTextFile (pkg.dir / ".cargo/config.toml") ] + let layouts ← inputDir (pkg.dir / "crates/proof-compression/root-layouts") true fun path => + path.extension == some "json" let sources := sources.zipWith (fun _ _ => ()) fixtures + let sources := sources.zipWith (fun _ _ => ()) layouts return sources.zipWith (fun _ _ => ()) manifests /-- Cargo's archive or executable and its native library are one build output. Both must exist before a cached result can be used. -/ def buildCargoArtifact (pkg : Package) (args : Array String) (tag : String) (built output : FilePath) (before : Option (Job FilePath) := none) : - SpawnM (Job FilePath) := do + FetchM (Job FilePath) := do let mut deps ← rustSources pkg let plonk := args.any fun argument => (argument.splitOn ",").contains "plonk" let groth16 := args.any fun argument => (argument.splitOn ",").contains "groth16" let gnark := plonk || groth16 - let plonkCuda := plonk && usesCuda args let groth16Cuda := groth16 && usesCuda args if gnark then let go ← inputDir (pkg.dir / "tools/plonk-wrapper") false fun path => #["go", "h", "json", "bin"].contains (path.extension.getD "") || path.fileName == "go.mod" || path.fileName == "go.sum" deps := deps.zipWith (fun _ _ => ()) go - if plonkCuda then - let native ← inputDir (pkg.dir / "crates/proof-compression/cuda/plonk") true fun path => - #["cu", "cuh", "cpp", "h", "hpp", "py", "map", "json", "patch"].contains (path.extension.getD "") - deps := deps.zipWith (fun _ _ => ()) native - if let some path ← IO.getEnv "IX_PLONK_CUDA_LIBRARY" then - -- The build script resolves the library from its crate directory before selecting the header. - let resolved ← Job.async do - return ← IO.FS.realPath (pkg.dir / "crates/proof-compression" / FilePath.mk path) - let inputs ← resolved.bindM fun libraryPath => do - let mut inputs := #[← inputBinFile libraryPath] - let packagedHeader := libraryPath.parent.get!.parent.get! / "include/ix_plonk_bn254.h" - if let .ok { type := .file, .. } ← packagedHeader.metadata.toBaseIO then - inputs := inputs.push (← inputTextFile packagedHeader) - return Job.collectArray inputs - deps := deps.zipWith (fun _ _ => ()) inputs - if groth16Cuda then - if let some root ← IO.getEnv "IX_GROTH16_ICICLE_ROOT" then - for name in icicleLibraries do - let library ← inputBinFile (FilePath.mk root / "lib" / name) - deps := deps.zipWith (fun _ _ => ()) library + let icicle ← if groth16Cuda then do + let root ← icicleRoot + pure (some (pkg.dir / "crates/proof-compression" / root)) + else pure none + if let some root := icicle then + for name in icicleLibraries do + let library := root / "lib" / name + unless ← library.pathExists do + error s!"Missing ICICLE library {library}. Install the pinned BN254 ICICLE libraries under /usr/local or set IX_GROTH16_ICICLE_ROOT to their installation prefix." + deps := deps.zipWith (fun _ _ => ()) (← inputBinFile library) let orderedDeps := match before with | some before => deps.zipWith (fun _ _ => ()) before | none => deps let environment ← nativeToolchainVariables.mapM fun name => return (name, ← IO.getEnv name) - let cargoEnv := cargoEnvironment environment + let mut cargoEnv := cargoEnvironment environment + if let some root := icicle then + cargoEnv := cargoEnv.push ("IX_GROTH16_ICICLE_ROOT", some root.toString) let native := pkg.buildDir / "lib" / cudaLibraryName let cuda := usesCuda args - let plonkNative := pkg.buildDir / "lib" / plonkCudaLibraryName - let mut outputs := if cuda then #[output, native] else #[output] - if plonkCuda then outputs := outputs.push plonkNative - if groth16Cuda then outputs := outputs ++ icicleLibraries.map (fun name => pkg.buildDir / "lib" / FilePath.mk name) + let outputs := #[output] ++ + (runtimeLibraries args).map (pkg.buildDir / "lib" / FilePath.mk ·) orderedDeps.mapM fun _ => do let cargoEnv ← if gnark then do let go := (← IO.getEnv "GO").getD "go" @@ -255,23 +260,17 @@ def buildCargoArtifact (pkg : Package) (args : Array String) (tag : String) | .ok source => pure source | .error message => error message copyNativeArtifact source native tag - if plonkCuda then - let source ← match cargoPlonkLibrary result.stdout with - | .ok source => pure source - | .error message => error message - copyNativeArtifact source plonkNative tag - if groth16Cuda then - let root ← match cargoIcicleRoot result.stdout with - | .ok source => pure source - | .error message => error message - for name in icicleLibraries do - copyNativeArtifact (root / "lib" / name) (pkg.buildDir / "lib" / name) tag + let artifacts ← match cargoRuntimeArtifacts result.stdout with + | .ok artifacts => pure artifacts + | .error message => error message + for (source, path) in artifacts do + copyNativeArtifact source (pkg.buildDir / "lib" / FilePath.mk path) tag copyNativeArtifact built output tag setTrace (← computeTrace outputs) return output def buildRustStatic (pkg : Package) (args : Array String) (tag : String) - (before : Option (Job FilePath) := none) : SpawnM (Job FilePath) := + (before : Option (Job FilePath) := none) : FetchM (Job FilePath) := buildCargoArtifact pkg args tag (pkg.dir / "target" / "release" / nameToStaticLib "ix_ffi") (pkg.buildDir / "lib" / s!"libix_ffi_{tag}.a") before @@ -280,26 +279,23 @@ def buildRustStatic (pkg : Package) (args : Array String) (tag : String) target ix_rs pkg : FilePath := do buildRustStatic pkg (← cargoArgs) "default" -/-- The native library is built by multi-stark and packaged alongside the archive. -/ +/-- An `ld` script naming the top-level native libraries in `libraries`, or an +empty archive when there are none. -/ +def linkRuntime (pkg : Package) (name : String) (libraries : Array String) + (after : Job FilePath) : SpawnM (Job FilePath) := do + let libraries := libraries.filter (!·.contains '/') + if libraries.isEmpty then + return ← buildStaticLib (pkg.buildDir / "lib" / s!"lib{name}.a") #[] + after.mapM fun _ => do + let ldScript := pkg.buildDir / "lib" / s!"lib{name}.so" + let paths := libraries.map fun (library : String) => (pkg.buildDir / "lib" / FilePath.mk library).toString + IO.FS.writeFile ldScript s!"INPUT({" ".intercalate paths.toList})\n" + setTrace (← computeTrace ldScript) + return ldScript + +/-- multi-stark's and the BN254 library, which every CUDA executable links. -/ target ix_cuda_runtime pkg : FilePath := do - unless usesCuda (← cargoFeatures) do - return ← buildStaticLib (pkg.buildDir / "lib" / "libix_cuda_runtime.a") #[] - let base ← ix_rs.fetch - base.mapM fun _ => do - let native := pkg.buildDir / "lib" / cudaLibraryName - setTrace (← computeTrace native) - return native - -/-- The compression worker and Lean executables use the same native library. -/ -target ix_compression_worker pkg : FilePath := do - let features ← cargoFeatures - let args := compressionWorkerArgs features - let mut before := none - if usesCuda features then - before := some (← ix_rs.fetch) - buildCargoArtifact pkg args "worker" - (pkg.dir / "target" / "release" / "ix-compression-worker") - (pkg.buildDir / "bin" / "ix-compression-worker") before + linkRuntime pkg "ix_cuda_runtime" (runtimeLibraries (← cargoFeatures)) (← ix_rs.fetch) /-- Cargo feature variants wait for the default archive before building. -/ target ix_rs_test pkg : FilePath := do @@ -309,6 +305,12 @@ target ix_rs_test pkg : FilePath := do target ix_rs_net pkg : FilePath := do buildRustStatic pkg (← cargoArgs (net := true)) "net" (some (← ix_rs.fetch)) +/-- ICICLE, which only `ix` links, when it compresses to Groth16 on CUDA. -/ +target ix_terminal_runtime pkg : FilePath := do + let all := runtimeLibraries (← cargoFeatures (net := true)) + let shared := runtimeLibraries (← cargoFeatures) + linkRuntime pkg "ix_terminal_runtime" (all.filter (!shared.contains ·)) (← ix_rs_net.fetch) + end FFI lean_lib MultiStark where @@ -334,8 +336,7 @@ lean_lib Ix where lean_exe ix where root := `Main supportInterpreter := true - needs := #[`@/ix_compression_worker] - moreLinkObjs := #[ix_rs_net] + moreLinkObjs := #[ix_rs_net, ix_terminal_runtime] section Tests @@ -491,29 +492,13 @@ script install := do let noWriteAccess := { fullAccess with write := false } let fileRight := { user := fullAccess, group := fullAccess, other := noWriteAccess } FS.createDirAll binDir - -- Compression resolves its worker beside the CLI. - let binaries := #["ix", "ix-compression-worker"] - for name in binaries do - let destination := binDir / name - FS.writeBinFile destination (← FS.readBinFile $ ".lake" / "build" / "bin" / name) - setAccessRights destination fileRight - let cudaLibrary : FilePath := ".lake" / "build" / "lib" / cudaLibraryName - if ← cudaLibrary.pathExists then - let libraryDir := binDir.parent.getD "." / "lib" - FS.createDirAll libraryDir - FS.writeBinFile (libraryDir / cudaLibraryName) (← FS.readBinFile cudaLibrary) - let features ← cargoFeatures - if features.contains "plonk" && usesCuda features then - let libraryDir := binDir.parent.getD "." / "lib" - FS.createDirAll libraryDir - let source : FilePath := ".lake" / "build" / "lib" / plonkCudaLibraryName - FS.writeBinFile (libraryDir / plonkCudaLibraryName) (← FS.readBinFile source) - if features.contains "groth16" && usesCuda features then - let libraryDir := binDir.parent.getD "." / "lib" - FS.createDirAll libraryDir - for name in icicleLibraries do - FS.createDirAll (libraryDir / name).parent.get! - FS.writeBinFile (libraryDir / name) (← FS.readBinFile $ ".lake" / "build" / "lib" / name) + let destination := binDir / "ix" + FS.writeBinFile destination (← FS.readBinFile $ ".lake" / "build" / "bin" / "ix") + setAccessRights destination fileRight + let libraryDir := binDir.parent.getD "." / "lib" + for name in runtimeLibraries (← cargoFeatures (net := true)) do + FS.createDirAll (libraryDir / name).parent.get! + FS.writeBinFile (libraryDir / name) (← FS.readBinFile $ ".lake" / "build" / "lib" / name) return 0 script "get-exe-targets" := do diff --git a/tools/plonk-wrapper/artifacts.go b/tools/plonk-wrapper/artifacts.go index 61a8c47b1..285073059 100644 --- a/tools/plonk-wrapper/artifacts.go +++ b/tools/plonk-wrapper/artifacts.go @@ -9,6 +9,9 @@ import ( "path/filepath" ) +// TODO: Decide between SHA-256 and BLAKE3 for artifact/cache metadata. +// Coordinate Rust/Go encodings and any catalog or envelope key-ID changes; +// proof-protocol hashes have separate compatibility requirements. type artifact struct { SHA256 string Bytes int64 diff --git a/tools/plonk-wrapper/internal/cuda/abi.go b/tools/plonk-wrapper/internal/cuda/abi.go index b54805581..729bfa4c9 100644 --- a/tools/plonk-wrapper/internal/cuda/abi.go +++ b/tools/plonk-wrapper/internal/cuda/abi.go @@ -3,7 +3,7 @@ package cuda /* -#cgo LDFLAGS: -lix_plonk_bn254_cuda -lstdc++ +#cgo LDFLAGS: -lix_bn254_cuda #include */ import "C" From 2ccaf351887b8c770a865c63ce88e738dc4a8422 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:49:38 +0000 Subject: [PATCH 23/31] docs(compression): describe compression inside ix Replace the worker command walkthroughs with `ix compress`, `ix prove --compress` and the `pipeline::compress` library flow: resume by rerunning with the same output directory, `ix compress fetch-params` and IX_PARAMS_DIR, the IX_PLONK/IX_GROTH16 switches, and the single libix_bn254_cuda.so. Key and release tooling is described as library functions. The `--out` help now states that a rerun resumes. --- Ix/Cli/CompressCmd.lean | 2 +- crates/proof-compression/README.md | 218 ++++++++++++----------------- docs/aiur-gpu-proving.md | 31 ++-- docs/compression-stage-collapse.md | 44 +++--- docs/ffi.md | 14 +- tools/plonk-wrapper/README.md | 136 ++++++++++-------- 6 files changed, 216 insertions(+), 229 deletions(-) diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index 0a2454dcc..e368c395f 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -100,7 +100,7 @@ def compressCmd : Cli.Cmd := `[Cli| compress VIA runCompressCmd; "Compress an aggregate CheckEnv proof; preserve its claim and print the new proof address." FLAGS: - "out" : String; "Empty output directory for the stage proofs; defaults to a new directory under ~/.ix/compression." + "out" : String; "Output directory for the stage proofs (default ~/.ix/compression//). Rerunning with the same directory resumes from the last completed stage." "log-height" : Nat; "First KZG partition height: 24 (default) or 25." "root-vk" : String; "Trusted root verifier key; omit to use the current aggregate backend." "fixed-root" : String; "Override the bundled release normalization layout with a layout from `ix compress layout`." diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 5a04e6bec..4d677e8ba 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -9,24 +9,35 @@ BN254 is unconditional, including builds with `--no-default-features`. The main Cargo features are `parallel`, `plonk`, `groth16`, and `cuda`. Either terminal feature builds one shared gnark C archive. Combining a terminal feature with `cuda` enables its GPU backend. -Lake builds the CLI and worker separately so `ix-ffi` does not link Go. -An explicit `IX_KZG_CURVE=bn254` is accepted; other values are rejected. +Compression runs inside `ix`. Lake enables the terminal features only for the +`ix` executable, so other executables and tests never compile Go. ```sh lake build ix IX_CUDA=1 lake build ix +IX_CUDA=1 IX_PLONK=1 IX_GROTH16=1 lake build ix ``` -`IX_CUDA` enables CUDA for Aiur, FRI, KZG, and both terminal backends. Native BN254 -arithmetic uses shared sppark/BLST support, and Rust calls stock gnark through -its C archive. Groth16 CUDA additionally requires the pinned ICICLE installation -via `IX_GROTH16_ICICLE_ROOT`; Nix provides it in `.#compression-worker-cuda`. +Lake's switches select these Cargo features: `IX_PLONK` and +`IX_GROTH16` (both off by default, so compression ends at the ~2.1 KB +recursive KZG proof and the default build needs no Go) select the terminal +backends, and `IX_CUDA` +enables CUDA for Aiur, FRI, KZG, and each selected terminal backend. Native +BN254 arithmetic uses shared sppark/BLST support, and Rust calls stock gnark +through its C archive. Groth16 CUDA links a separately installed, pinned ICICLE. +Lake and Cargo default to `/usr/local`; `IX_GROTH16_ICICLE_ROOT` overrides the +installation prefix. Core libraries belong in `/lib` and CUDA modules +in `/lib/backend`. Lake validates and copies those libraries into +`.lake/build/lib` beside `ix`. Provision ICICLE before building Ix; Lake does +not compile it. Nix builds the pinned installation as `.#icicle-bn254`. Those libraries may contain other curves; Ix exposes only BN254. KZG uses authenticated Ignition parameters or explicit known-trapdoor development parameters. Terminal PLONK requires authenticated Ignition. -The BN254 KZG release catalog is empty: local verification requires an -independently pinned verifying key and expected statement. PLONK verification +This build ships no bundled recursive KZG verifying keys. `ix verify` reports +that verification of these proofs is unsupported; use the library's +`verify_with_vk` or `verify_with_ignition_admission` with an independently +pinned verifying key and expected statement. PLONK verification requires an independently authorized `IX_PLONK_VERIFIER_CATALOG`. Groth16 requires its own circuit-specific setup and `IX_GROTH16_VERIFIER_CATALOG`; see the [terminal backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-terminal). @@ -123,7 +134,11 @@ copy is at `/opt/dlami/nvme/ix-bn254/parameters/ignition/`: independent source, cache and admission pins and the complete setup identity. Proving needs the prepared cache and admission; the source transcripts are -needed only to reproduce the import. Restore them onto fast local storage: +needed only to reproduce the import. `ix compress fetch-params` downloads the +pinned public cache and admission into `IX_PARAMS_DIR` (default +`~/.ix/compression/params`) and authenticates them; compression uses them +automatically when no `IX_KZG_IGNITION_*` variable is set. To restore them +from S3 onto fast local storage instead: ```sh IGNITION_DATA=/path/to/local-nvme/parameters/ignition @@ -174,17 +189,14 @@ Produce the descriptor from already trusted source and cache pins: ``` Retain the reported `admission_blake3` independently. To select these -parameters for proving, configure all four settings: +parameters for proving, configure all four settings before running +`ix compress` or `ix prove --compress`: ```sh export IX_KZG_IGNITION_CACHE=/path/to/ignition-prefix.bin export IX_KZG_IGNITION_CACHE_BLAKE3="$IGNITION_CACHE_MANIFEST_BLAKE3" export IX_KZG_IGNITION_ADMISSION=/path/to/new-admission.json export IX_KZG_IGNITION_ADMISSION_BLAKE3="$IGNITION_ADMISSION_BLAKE3" - -"$IX_COMPRESSION_WORKER" pipeline run-fri \ - /path/to/fri /path/to/claim.bin /path/to/root/root-claims.bin \ - /path/to/new-ignition-run 24 ``` On the validation host, the retained configuration supplies all four settings: @@ -198,108 +210,61 @@ the receipts above. It does not select or authorize a verifying key. Partial configuration, incorrect pins, changed anchors/source receipts and insufficient prefixes fail closed. `--dev-srs` explicitly selects development -parameters instead. The worker checks Ignition capability before starting -stages and binds both KZG stages to the same admitted setup; the recursive -verifying key must also retain its admitted deferred pairing keys. - -For local verification, supply the independently trusted verifying key digest -as well as the original claim, expected root statement and admitted setup: - -```sh -"$IX_COMPRESSION_WORKER" pipeline verify-ignition \ - /path/to/run/proof.envelope.bin /path/to/claim.bin \ - /path/to/root/root-claims.bin /path/to/run/final-kzg/vk.bin \ - "$BN254_VK_DIGEST" /path/to/new-admission.json \ - "$IGNITION_ADMISSION_BLAKE3" -``` - -This command reads no proving cache or setup environment and rejects -`--dev-srs`. The library equivalent is `verify_with_ignition_admission`, -with an `IgnitionAdmission` authenticated using `read` or `from_bytes`. -Ordinary application verification continues to use its release-bundled -verifying key catalog. A future BN254 production entry must bundle both -the verifying key digest and the admitted setup pin; generating either -artifact does not add that entry. The BN254 catalog remains empty and admits -no development entries. +parameters instead. Compression checks the setup before proving and binds +both KZG stages to the same admitted setup; the recursive verifying key must +also retain its admitted deferred pairing keys. + +For local verification, `verify_with_ignition_admission` takes the +independently trusted verifying key digest as well as the original claim, +expected root statement and an `IgnitionAdmission` authenticated using `read` +or `from_bytes`. It reads no proving cache or setup environment. +Ordinary application verification supports terminal PLONK and Groth16 through +their independently configured catalogs. Recursive KZG verification requires +one of the explicit pinned-key entry points. Generating a verifying key or +setup artifact does not authorize it for another verifier. ## Proving and verification -The worker has two entry points for a complete KZG run. `pipeline run` reads -`root-vk.bin`, `root-proof.bin` and `root-claims.bin`, performs FRI compression, -then runs first and recursive KZG stages. `pipeline run-fri` starts from an -existing `outer-vk.bin`, `outer-proof.bin` and `outer-claims.bin`; it verifies -that saved FRI proof and compares all eighteen statement words with the -separately supplied root claims before starting the KZG workers. - -Build and execute these commands only when machine time is available. Real -inputs require tens of millions of rows and substantial RAM; use the -measurements above when choosing a host and place outputs and caches on fast -local storage. - -```sh -cargo build --release --locked -p ix-proof-compression --bin ix-compression-worker \ - --no-default-features --features parallel --target-dir target/bn254 -export IX_COMPRESSION_WORKER="$PWD/target/bn254/release/ix-compression-worker" -"$IX_COMPRESSION_WORKER" fri capabilities - -"$IX_COMPRESSION_WORKER" pipeline run \ - /path/to/root /path/to/claim.bin /path/to/bn254-run 24 --dev-srs -``` - -Alternatively, reuse an already saved FRI proof in a separate empty output -directory: - -```sh -"$IX_COMPRESSION_WORKER" pipeline run-fri \ - /path/to/fri /path/to/claim.bin /path/to/root/root-claims.bin \ - /path/to/bn254-from-fri 24 --dev-srs -``` - -To verify the saved FRI input and count the translated frontend before -proving, use: - -```sh -"$IX_COMPRESSION_WORKER" kzg size \ - /path/to/fri /path/to/kzg-size.json --dev-srs -``` - -This retains the source Goldilocks circuit and translation wire map, without -allocating the translated circuit, proving traces or SRS. Its exact gate, -lookup and value counts do not estimate total prover memory or padded trace -sizes. The saved Init and mathlib inputs each used about 8.13 GiB peak RSS -and 20 seconds wall time for this preflight on the measurement host. - -`claim.bin` contains the canonical serialized claim whose digest appears in -the root statement. The top-level driver admits first-stage log-heights 24 -and 25, matching the saved recursive layout policy. Workers run in separate -processes and are checked for the selected curve before proving. Each run -writes `proof.envelope.bin`, `final-kzg/vk.bin`, -`expected-root-claims.bin` and `pipeline-report.json`; failed stages retain -their checkpoints. Shared cache environment variables override the default -per-run `cache/` directory. +`ix compress ROOT` and `ix prove ... --compress kzg|plonk|groth16` automatically +normalize the aggregate root using the versioned +[`ixvm-2026-10-29` layout](root-layouts/ixvm-2026-10-29.json). +`--fixed-root` is an optional layout override. The default layout is the one +shared by the saved Nat.add_comm, Init and Mathlib normalization runs; it has +101 circuit heights, 18 claim elements and no additional headroom beyond those +measured bounds. Its root verifier BLAKE3 is +`f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc`. +The default rejects another backend key, and normalization rejects executions +that exceed the fixed bounds before allocating traces. A larger supported +layout is a new release profile; compression does not resize it per proof. +The profile supplies normalization bounds, not a bundled compression verifier. + +`ix compress` and `ix prove ... --compress` run every stage in the `ix` +process through `pipeline::compress(CompressInput, &CompressConfig)`: FRI +compression of the normalized root, first KZG, recursive KZG and, when +selected, the terminal proof. Real inputs require tens of millions of rows +and substantial RAM; use the measurements above when choosing a host and +place outputs and caches on fast local storage. + +Stages are kept under the output directory, `~/.ix/compression//` +by default or `--out`: `root/`, `fri/`, `first-kzg/`, `final-kzg/` (including +`vk.bin`), then `plonk/` or `groth16/`, and the returned `compressed.bin`. +Each stage writes a `complete` marker last, after its output is checked. +Rerunning the same command resumes: marked stages are checked and reused, and +the first unmarked stage and every later one are rerun. A non-empty directory +whose `root/` does not match the proof, layout and statement is refused +unchanged. `--log-height` selects first-stage log-height 24 (default) or 25, +matching the saved recursive layout policy. Fixed-preprocessing and +development SRS caches are shared under `~/.ix/compression/cache`; +`IX_KZG_FIXED_CACHE` and `IX_KZG_SRS_CACHE` override them. BN254 has no bundled verifying key catalog entry. Its packet and verifying -key formats identify BN254; generating a key does not authorize -it. For local verification, set `BN254_VK_DIGEST` to the independently -reviewed BLAKE3 digest of `final-kzg/vk.bin`, then supply that pin, -the original claim and independently expected root claims: - -```sh -"$IX_COMPRESSION_WORKER" pipeline verify \ - /path/to/bn254-run/proof.envelope.bin /path/to/claim.bin \ - /path/to/root/root-claims.bin /path/to/bn254-run/final-kzg/vk.bin \ - "$BN254_VK_DIGEST" --dev-srs -``` - -Verification needs no proving SRS or cache. It checks the verifying key +key formats identify BN254; generating a key does not authorize it. For +local verification, `verify_with_vk` takes the independently reviewed BLAKE3 +digest of `final-kzg/vk.bin`, the original claim and independently expected +root claims. It needs no proving SRS or cache, and checks the verifying key digest, development opt-in, claim digest, all expected statement words, outer -KZG proof and deferred pairing. The generated report records the verifying -key digest for inspection; the verifier never obtains its trusted pin from -the proof. -The library equivalents are `compress`, `compress_fri` and -`verify_with_vk`. `ix verify` uses the catalog for its compiled curve; -BN254 has no authorized entries yet, so use the worker command above with an -explicit verifying key for development verification. +KZG proof and deferred pairing; the verifier never obtains its trusted pin +from the proof. ### Validation and sizing entry points @@ -324,7 +289,7 @@ includes the proof envelope, native and Solidity proof encodings, generated Solidity verifier, verifying key, admission, and candidate verifier catalog. The candidate must be reviewed independently before configuring it for `ix verify`. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) -for worker commands, Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the terminal PLONK verifier +for Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the terminal PLONK verifier and [claim adapter](../../contracts/plonk/README.md). ## CUDA and profiling @@ -343,26 +308,27 @@ cargo test --release -p ix-proof-compression --features cuda ``` `IX_CUDA=1` enables both multi-stark's Goldilocks backend for the Aiur -prover and the KZG backend here; there is no separate switch. KZG-specific -kernels are compiled into `ix-compression-worker`, the stage executable -built beside `ix`. Both executables use multi-stark's shared CUDA backend. +prover and the KZG backend here; there is no separate switch. The KZG and +PLONK kernels build into one `libix_bn254_cuda.so` (one sppark, a private C++ +runtime, C exports only), which Lake copies into `.lake/build/lib` beside +multi-stark's `libmulti_stark_cuda.so`. `IX_NO_PAR=1` builds without the `parallel` feature, and `IX_CUDA_TRACE_CODEGEN=1` adds the generated CUDA trace kernels described in [the GPU proving guide](../../docs/aiur-gpu-proving.md), implying `cuda`. See [the GPU proving guide](../../docs/aiur-gpu-proving.md) for shared CUDA -backend packaging and host-toolchain requirements. When deploying the worker, -copy `.lake/build/lib/libmulti_stark_cuda.so` into `../lib` relative to its -executable, as for the CUDA-enabled Lean binaries. +backend packaging and host-toolchain requirements. When deploying `ix`, +copy both libraries from `.lake/build/lib` into `../lib` relative to the +executable. With the feature compiled, every proving operation above the size thresholds in the prover runs on the devices and a rejected memory admission is an error; tune the residency limit or chunk size below rather than expecting a host fallback. A CUDA build keeps every committed column in memory; a CPU build serves the fixed columns from their cache files and -produces identical proof bytes. The worker installs its settings from the -environment before the first proving operation: +produces identical proof bytes. `ix` reads these settings from the +environment and installs them once, before the first KZG stage: | Variable | Values | Default | | --- | --- | --- | @@ -397,10 +363,10 @@ needs a GPU host; device unit tests are marked ignored and run with ### Profiling The global `IX_TEXRAY=1` switch enables TeXRay for proving, aggregation, -compression, and the Typecheck benchmark. On `ix compress` or a direct -worker invocation it reports coarse phase timings and RSS summaries on -stderr. Each worker installs TeXRay before loading inputs and examines a `compression/fri`, -`compression/kzg`, or `compression/wrap` root. The tree covers circuit +compression, and the Typecheck benchmark. On `ix compress` it reports +coarse phase timings and RSS summaries on stderr, with one root per stage: +`compression/fri`, `compression/kzg`, `compression/wrap`, and +`compression/plonk` or `compression/groth16`. The tree covers circuit construction, witness generation, lowering, setup, preprocessing caches, commitments, proving, verification, and output. The inherited `stark/*` lookup, quotient, and opening phases are included; `stark/fri_open` also @@ -420,13 +386,13 @@ proof witness. `Prepared::prove_fri`, `prepare_kzg`, `Prepared::prove_kzg` and `prepare_recursive` compose the stages with caller-selected bounds and SRS. These APIs return verifier-only metadata and keep the expected claim separate from the proof. They do not register a verifier in the application's bundled -catalog. The CLI uses the worker and trusted verifying keys described above. +catalog. The CLI uses `pipeline::compress` and the trusted verifying keys +described above. `fri` constrains FRI verification, `foreign` translates Goldilocks arithmetic into scalar-field constraints, `kzg` implements the commitment backend, and `recursive` constrains KZG verification with public pairing operands. -`pipeline` runs the stages through the Plonkish API, exposed by -`ix-compression-worker`. +`pipeline` runs the stages through the Plonkish API. Focused CPU checks use the small BN254 fixtures: diff --git a/docs/aiur-gpu-proving.md b/docs/aiur-gpu-proving.md index 23596827b..33ff783f1 100644 --- a/docs/aiur-gpu-proving.md +++ b/docs/aiur-gpu-proving.md @@ -38,12 +38,13 @@ Lean's bundled toolchain does not provide, and the final link would fail on `libix_ffi_net.a`. No `CFLAGS` setting is needed. If `nvcc` is not on `PATH`, set `NVCC` or `CUDA_HOME`. -CUDA-enabled Lean executables and the compression worker load `libmulti_stark_cuda.so` from `../lib` relative -to the executable. Multi-stark builds this library +CUDA-enabled Lean executables load `libmulti_stark_cuda.so` from `../lib` relative +to the executable; `ix` also loads `libix_bn254_cuda.so`, the KZG and PLONK +kernels compression uses. Both libraries are built with a private static GNU C++ runtime and unwinder; only the C interface is exported. Lean keeps its bundled libc++. When copying an executable out of the build -tree, also copy `.lake/build/lib/libmulti_stark_cuda.so` into the matching `lib/` +tree, also copy the shared libraries in `.lake/build/lib` into the matching `lib/` directory. A binary linked by the Nix Rust toolchain runs on Nix glibc, whose loader @@ -79,8 +80,8 @@ export AIUR_TRACE_ONLY_LOOKUPS=1 AIUR_MAX_PIECE_LOG_HEIGHT=24 export AIUR_TRACE_SHARD_MAX_CELLS=1500000000 export AIUR_GPU_TRACE=generated AIUR_LANES_CACHE_DIR=$PWD/cache systemd-run --user --scope -q -p MemoryMax=920G -- \ - ix prove --ixe mathlib.ixe --ixes mathlib.ixes --trace-shards \ - --lanes 4 --exec-jobs 3 --max-ram 230 --out-ixes mathlib-proven.ixes \ + ix prove --ixe mathlib.ixe --ixes mathlib.ixes \ + --gpus 4 --exec-jobs 3 --max-ram 230 --out-ixes mathlib-proven.ixes \ > lanes.out 2> lanes.err # 3. Verify the root against the partition that was actually proven. @@ -95,14 +96,15 @@ changes the partition, and `ix verify --ixes` must see the one the proofs belong to. The same two stages can still be driven by hand, one device per process, -with `ix prove --trace-shards --shards ` for a lane's claims and +with `ix prove --trace-shards --shards ` for a GPU's claims and `ix aggregate --trace-shards --subtree ` for its subtree; the lanes scheduler is the one-process equivalent and is what the measurements in §6 used. ## 3. Lanes: one resident prover per device -`ix prove --lanes N` builds an `AiurSystem` pair per device from the two +`ix prove --ixe E --ixes M` (on `--gpus N`, default every visible GPU) +builds an `AiurSystem` pair per device from the two compiled systems (nothing is copied across the FFI; the verifying keys do not depend on the device, and every cache key and claim binding derives from them) and runs one scheduler over them: @@ -195,7 +197,7 @@ The ones a run needs, then the ones a measurement might. | `--retention retain\|regenerate\|auto` | What a batch keeps between its two rounds. Every real GPU plan runs `regenerate`: round two rebuilds each shard from its seeds, which the resident seed cache makes cheap. | | `MULTI_STARK_CUDA_MIN_FREE_BYTES` | Device headroom the backend keeps by spilling LDEs to the host; default a quarter of the card. | | `MULTI_STARK_CUDA_MEMORY_LOG=1` | Logs stage-1 placement and per-lookup-job budget and free bytes to stderr. | -| `--texray` or `IX_TEXRAY=1` | Per-phase wall and RSS lines on stderr, as on the CPU. The environment switch also enables compression-worker TeXRay output. | +| `--texray` or `IX_TEXRAY=1` | Per-phase wall and RSS lines on stderr, as on the CPU. The environment switch also enables TeXRay output for compression. | ## 6. Benchmarking a multi-GPU run @@ -205,10 +207,9 @@ measurements below does only this: write the provenance, sample the devices, run the one command under the cap, and keep the exit status. Set `RUNS_DIR` to fast local storage for this script's outputs and lane cache. -For subsequent KZG compression, use the separate -[NVMe and cache placement instructions](../crates/proof-compression/README.md#disk-placement) -and [KZG benchmark recipe](../crates/proof-compression/README.md#kzg-benchmarking). -`RUNS_DIR` does not configure KZG cache paths. +For subsequent KZG compression, see +[the compression guide](../crates/proof-compression/README.md#proving-and-verification) +for output and cache placement. `RUNS_DIR` does not configure KZG cache paths. ```sh #!/bin/bash @@ -228,15 +229,15 @@ unset RUST_LOG MULTI_STARK_CUDA_MEMORY_LOG echo "ix $(sha256sum "$BIN" | cut -d' ' -f1)"; sha256sum "$IXE" "$IXES" echo "driver $(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1)" echo "thp $(cat /sys/kernel/mm/transparent_hugepage/enabled) / $(cat /sys/kernel/mm/transparent_hugepage/defrag)" - echo "flags --lanes 4 --exec-jobs $EXEC_JOBS --max-ram $MAX_RAM $* cap $MEM_MAX" + echo "flags --gpus 4 --exec-jobs $EXEC_JOBS --max-ram $MAX_RAM $* cap $MEM_MAX" env | grep -E '^(AIUR|MULTI_STARK)_' | sort } > "$OUT/meta.txt" nvidia-smi --query-gpu=timestamp,index,memory.used,utilization.gpu \ --format=csv,noheader,nounits --loop-ms=1000 > "$OUT/gpu.csv" 2>/dev/null & SMI=$! systemd-run --user --scope -q -p MemoryMax=$MEM_MAX -- \ - /usr/bin/time -v "$BIN" prove --ixe "$IXE" --ixes "$IXES" --trace-shards \ - --lanes 4 --exec-jobs "$EXEC_JOBS" --max-ram "$MAX_RAM" "$@" \ + /usr/bin/time -v "$BIN" prove --ixe "$IXE" --ixes "$IXES" \ + --gpus 4 --exec-jobs "$EXEC_JOBS" --max-ram "$MAX_RAM" "$@" \ > "$OUT/lanes.out" 2> "$OUT/lanes.err" echo "exit $?" >> "$OUT/meta.txt"; kill $SMI 2>/dev/null ``` diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md index b0d0f9cc0..9518b3c69 100644 --- a/docs/compression-stage-collapse.md +++ b/docs/compression-stage-collapse.md @@ -16,9 +16,10 @@ otherwise (October 2026, one RTX PRO 6000 Blackwell, 48 logical CPUs). rows that gnark PLONK on BN254 can reach (`4N` must fit the field's `2^28` two-adicity). This is an extrapolation; the compile-and-size gate the architecture doc asks for remains the way to close it. -- **Yes as one process, with a bounded win.** A persistent GPU worker holding - both stages is estimated to remove about 30 to 35 s of a 212 s pipeline; - the retained-worker path has not been measured. +- **Yes as one process, with a bounded win.** Every stage now runs inside + `ix` ([in-process compression](in-process-compression.md)); additionally + retaining both stages' provers warm across requests is estimated to remove + about 30 to 35 s of a 212 s pipeline, and has not been measured. - **The real collapse is upstream.** Shrinking the first KZG stage until its proof can be wrapped directly removes the recursive stage. Sizing from the checked-in Init key shows the first stage's cost is almost entirely per @@ -54,7 +55,7 @@ separate process. | Stage | Prover | Output | Wall time | Share | | --- | --- | --- | ---: | ---: | -| FRI compression | Goldilocks CUDA worker, root key's own parameters | 1.4 MB FRI proof | ~60 s | — | +| FRI compression | Goldilocks CUDA, root key's own parameters | 1.4 MB FRI proof | ~60 s | — | | First KZG | ix Plonkish prover, BN254 CUDA, 19 partitions of `2^24` | 32,869 B proof, 583-term opening MSM | 136 s | 64% | | Recursive KZG | Same prover, one `2^26` trace, 61M frontend rows | 1,917 B envelope, two deferred pairing points | 45 s | 21% | | gnark PLONK | Go helper, CUDA MSM, FFT and fused quotient | 584 B proof, 320,091 gas on Anvil | 32 s process, 11.4 s core | 15% | @@ -122,7 +123,7 @@ what result to expect from it. implementation work, not parameter selection: the in-circuit verifier rejects folding arity above one and nonzero Merkle caps (`fri/plan.rs`, `validate_parameters`; `fri/pcs.rs`, - `FixedPcsShape`), the first-stage and recursive workers enforce at least + `FixedPcsShape`), the first-stage and recursive stages enforce at least eleven partitions (`pipeline/first.rs`, `MIN_PARTITIONS`; `pipeline/wrap/saved.rs`), and the PLONK exporter and Go circuit hardcode the two-circuit recursive packet with its deferred pairing operands @@ -130,19 +131,19 @@ what result to expect from it. Every change re-derives the verifying keys and fixed preprocessing. Every candidate must preserve the current proven security bound under the same assumptions and accounting as the baseline. -2. **Fuse the recursive and PLONK stages into one warm worker** (estimated +2. **Keep the recursive and PLONK stages warm across requests** (estimated 30 s; unmeasured). Both fit one GPU at the same time (59 GiB and 19 GiB of VRAM). A persistent helper holding the proving key, compiled circuit and CUDA context would take the PLONK stage from 32 s toward its 11.4 s core time. On the KZG side, authenticating the `2^26` Ignition cache (5.5 s, from the Ignition import record) and restoring fixed - preprocessing (5.3 s, from the warm development rerun) are per-process + preprocessing (5.3 s, from the warm development rerun) are per-run costs that would disappear; the two figures come from different runs. The recursive verifier already separates compilation from request binding (`native_verifier/plan.rs`, `Plan::compile` and `Bindings::request_inputs`); `saved.rs` calls `Plan::build`, which does both per request, so the remaining work is retaining the compiled - circuit across requests in the worker. The wrapper plan already lists + circuit across requests. The wrapper plan already lists the warm service as gate G6. 3. **Benchmark a faster proving-key load** (candidate, unmeasured). The helper writes the 1 GB key with gnark's `WriteTo`, which compresses @@ -174,7 +175,7 @@ so it can be re-sized without a proof. The `fri_sizing` example under trace heights (`FRI_SIZING_MAX_LOG`), the circuit set (`FRI_SIZING_DROP`) or individual circuit shapes (`FRI_SIZING_SET=index:log_height:main_width`), builds the verifier, and counts the translated BN254 frontend with the same -counting pass the worker's `kzg size` preflight uses. With +counting pass the former `kzg size` preflight used. With `FRI_SIZING_LOWER=1` it also materializes the translated circuit and runs the partitioning step of the production lowering (`multi_stark_layout_with_max_height` at the chosen height), which gives the @@ -439,9 +440,8 @@ transcript scale the same way. The first of those rows is now a measurement. The PLONK exporter and the Go circuit were generalized to any number of circuits, a claim schema and an -optional deferred pairing (`ix-compression-worker plonk export-first - [--dev-srs]`, then the -helper's `compile`). Exporting the patched 15-circuit first proof gives a +optional deferred pairing (exported at the time with the since-removed +worker's `plonk export-first` command, then compiled by the helper). Exporting the patched 15-circuit first proof gives a verifying key with 295 points, 560 scalars, 179 fixed commitments, 9 opening groups and a 10,160-node scalar program; gnark compiles it to **130,850,185 constraints, domain `2^27`**, in 121 s and 44 GiB. That is @@ -616,20 +616,20 @@ Relevant to this pipeline: ## Review notes -- The PLONK CUDA library is a second, independently built sppark - instantiation with renamed symbols so it can coexist with the KZG kernels. - The split-bucket MSM fix for structured witnesses lives only there. The KZG +- The KZG and PLONK CUDA kernels now build against one sppark in + `libix_bn254_cuda.so`. When this review was written, the PLONK kernels were + a second, renamed sppark instantiation carrying a split-bucket MSM fix for + structured witnesses. The KZG prover's main-trace columns are also byte-structured, so its MSMs may show the same bucket concentration. -- The driver's saved-FRI path never passes the terminal PLONK flag, so the - benchmarked PLONK runs were produced by invoking the helper by hand. The CLI - help text still describes the flag as BLS12-381 and Filecoin. -- The PLONK cache identity hashes both the Rust worker and the Go helper - executables, so any rebuild of either triggers the six-minute key - preparation. The benchmark doc already flags this. +- The benchmarked PLONK runs were produced by invoking the helper by hand, + not through the driver's saved-FRI path. +- The PLONK cache identity hashed the executable at the time, so any rebuild + triggered the six-minute key preparation. It is now keyed by the cache + format, verifier description, setup and gnark's `go.sum`. - Recursive-stage phase timings for the GPU run are not recorded in the repo, only the CPU run's. The split of the 45 s between witness generation, - commitment and quotient decides how much the fused worker is worth. + commitment and quotient decides how much a warm recursive prover is worth. Sources: `crates/proof-compression/README.md`, `docs/proof-benchmark-comparison.md`, the pipeline, recursive verifier and diff --git a/docs/ffi.md b/docs/ffi.md index 81cdc26e2..24c83059f 100644 --- a/docs/ffi.md +++ b/docs/ffi.md @@ -39,16 +39,20 @@ performance-critical applications. ## Rust to gnark -`ix-proof-compression::pipeline::gnark` wraps a Go C archive inside the -compression worker. The worker enables `plonk` and/or `groth16`; both -are excluded from the Lean-facing `ix-ffi` build. Rust owns setup admission, +`ix-proof-compression::pipeline::gnark` wraps a Go C archive linked into `ix`. +`IX_PLONK` and `IX_GROTH16` enable the `plonk` and `groth16` features for the +`ix` executable only; both are off by default, so other executables, tests and +the default build do not link Go. Rust owns setup admission, artifact policy and publication; Go owns gnark objects and the verifier circuit. The private C interface exchanges byte views, owned output buffers and integer `runtime/cgo.Handle` values. `Prover` is neither `Send` nor `Sync`; its mutable proof method excludes concurrent use, and drop releases the handle. Returned C allocations are copied and freed through the adapter. Go panics on the exported -call's goroutine become errors; fatal runtime and device failures remain isolated -by the worker process. There is no ABI version or compatibility dispatcher. +call's goroutine become errors; fatal Go runtime and device failures end the +`ix` process, and a rerun resumes compression from its last complete stage. +There is no ABI version or compatibility dispatcher. The [backend guide](../tools/plonk-wrapper/README.md) describes builds and tests. +The [backend feature proposal](gnark-backend-features.md) describes matching +Go compilation to the `plonk` and `groth16` Cargo features. diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index a1e7576cd..fdba876df 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -1,7 +1,7 @@ # BN254 terminal gnark backends This Go module implements the terminal Ix verifier circuit and calls stock -gnark. Cargo builds it as a C archive inside `ix-compression-worker`; +gnark. Cargo builds it as a C archive linked into `ix`; `pipeline/gnark.rs` owns the Rust bindings and loaded prover handles. There is no separate Go executable. @@ -11,13 +11,13 @@ pairing obligations. It uses classic GLV, separate pairing checks, and explicit ## Build and use -`lake build ix` builds the Lean CLI and native Rust compression worker. The -worker enables `plonk` and `groth16`; the Lean-facing `ix-ffi` library does not. CPU -builds need Go and a C compiler, but no CUDA toolkit. A direct worker build is: +Compression runs inside `ix`. `IX_PLONK=1` and `IX_GROTH16=1` select the +`plonk` and `groth16` features for the `ix` executable only; both are off by +default, and only builds with one of them link Go. CPU builds need Go and a C +compiler, but no CUDA toolkit: ```sh -cargo build --release -p ix-proof-compression --bin ix-compression-worker \ - --no-default-features --features parallel,plonk,groth16 +IX_PLONK=1 IX_GROTH16=1 lake build ix ``` Configure the four Ignition environment variables from the @@ -30,9 +30,8 @@ ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output Rust authenticates the setup and inner proof, selects the cache, calls gnark, and publishes the native proof, Solidity payload, key, verifier contract, admission, and candidate catalog. The proof envelope is written last, after -verification succeeds. `IX_COMPRESSION_WORKER` overrides the worker location. -Nix provides `.#compression-worker`, `.#compression-worker-cuda`, and -`.#ix`. +verification succeeds. Rerunning the command with the same output directory +resumes from the last complete stage. ## CUDA @@ -44,16 +43,15 @@ proof encodings. ```sh export CUDA_HOME=/usr/local/cuda -export IX_PLONK_CUDA_ARCHS=120 IX_CUDA=1 lake build ix ``` -Cargo obtains the pinned sppark and BLST sources from its `cuda` dependencies. `IX_PLONK_SPPARK_SOURCE` and -`IX_PLONK_BLST_SOURCE` optionally override those roots. Alternatively, -`IX_PLONK_CUDA_LIBRARY` names an already-built `libix_plonk_bn254_cuda.so`. -Lake installs that library beside the worker in `../lib`. Nix supplies its -pinned native library directly. Primitive source ownership and build details -are in `crates/proof-compression/cuda/plonk`. +The CUDA primitives in `crates/proof-compression/cuda/plonk` are built with +the KZG kernels into `libix_bn254_cuda.so`, against the sppark and BLST +sources of the crate's `cuda` dependencies. `IX_KZG_CUDA_ARCHS` (or +`MULTI_STARK_CUDA_ARCHS`) selects the SM architectures. cgo links this +library, which carries a private C++ runtime and exports only its C entry +points; Lake installs it in `../lib` beside the executable. Select execution separately with `IX_PLONK_BACKEND=cpu` or `cuda`. `IX_PLONK_CUDA_DEVICE` selects the visible GPU ordinal; the default is zero. @@ -73,8 +71,8 @@ records why the backend uses the upstream interface without fused extensions. ## Trust and artifacts -Rust owns artifact and Lagrange-cache metadata. Circuit caches bind the worker -build, verifier description and admitted setup. Lagrange caches bind the setup, +Rust owns artifact and Lagrange-cache metadata. Circuit caches bind the cache +format, verifier description, admitted setup and gnark's `go.sum`. Lagrange caches bind the setup, domain and monomial receipt. Go checks the sizes and digests of decoded native artifacts and rejects a witness whose claim differs from the independent digest supplied by Rust. Retaining a prover avoids loading its circuit and keys again. @@ -84,12 +82,10 @@ candidate catalog is not automatically trusted. Entries bind the verifying-key SHA-256, inner verifier identity, program prefix, circuit hash, and Ignition admission. Verification requires neither a proving SRS nor a GPU. -The worker retains the `plonk export`, `export-first`, `srs`, `wrap`, and -`verify` commands. For example: - -```sh -ix-compression-worker plonk verify proof.envelope.bin claim.bin -``` +Release tooling is library code with no CLI: `pipeline::plonk::export` writes +the verifier description and witness for a recursive KZG packet, and +`pipeline::plonk::export_parameters` writes the Ignition SRS prefix a circuit +needs. `ix verify` checks PLONK proofs against the configured catalog. Internal metadata has no helper-executable identity. Rebuild old artifact caches and regenerate candidate catalogs instead of editing or translating them. @@ -120,7 +116,7 @@ Solidity verification of the unchanged proof encoding. ## BN254 Groth16 terminal -The worker also implements `groth16` over the same KZG verifier circuit and +The `groth16` feature implements Groth16 over the same KZG verifier circuit and witness schema. It compiles R1CS instead of PLONK's sparse constraints. The two public inputs remain the little-endian 128-bit halves of the expected BLAKE3 claim digest. The circuit retains the outer and deferred KZG pairing checks. @@ -132,23 +128,45 @@ commitment and its proof are part of the verified encoding. Development verifying keys and a proof of this fixture are retained in `crates/proof-compression/tests/fixtures/groth16`; they are not production keys. +### TODO: Reduce Groth16 commitment verification cost + +Evaluate a commitment-free Groth16 circuit. The current gnark commitment +extension adds a separate two-pair pairing check costing **113,000 gas** and +128 bytes to the Solidity proof payload (384 bytes instead of the ordinary +256-byte A/B/C proof). The ordinary four-pair Groth16 equation costs another +181,000 gas. SHA-256's precompile cost in the measured proof is only 84 gas. + +Anvil validation of normalized Nat.add_comm, Init and Mathlib under one key per +backend measured 320,286 execution gas for the Groth16 adapter and 275,438 for +PLONK. Total transaction gas was 348,262–348,298 and 311,070–311,118, +respectively. These measurements use Solidity 0.8.33, via-IR, 200 optimizer runs +and Prague; Groth16 uses development keys and PLONK uses Ignition. The adapters +receive a claim digest, so these costs exclude hashing raw claim bytes. +Receipts, traces and proof vectors are retained in +`target/terminal-solidity-20261029-xso7clim/` at the repository root. + +SP1's Groth16 path uses direct bit constraints for range checks and an ordinary +A/B/C verifier. Ix also uses gnark's emulated BN254 arithmetic for KZG pairings, +whose deferred checks use commitments; replacing range checks alone may not +eliminate the extension. Any alternative must preserve both inner KZG pairing +obligations and claim binding. It needs new circuit-specific keys and Solidity +validation, with measurements of constraint count, proving cost and verification +gas. This optimization is deferred; the commitment checks remain required by +the current circuit and verifier. + ### Preparing and using development keys -Export an already verified recursive KZG packet with the existing shared -exporter, then compile the Groth16 circuit: +Keys are prepared with library functions; there is no CLI for them. Export an +already verified recursive KZG packet with the shared exporter +(`pipeline::plonk::export`), compile the Groth16 circuit from its `vk.json` +with `pipeline::groth16::compile`, then create development keys with +`pipeline::groth16::setup_development`. -```sh -ix-compression-worker groth16 export verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json input -ix-compression-worker groth16 compile input/vk.json groth16-keys -ix-compression-worker groth16 setup-development groth16-keys -ix-compression-worker groth16 prove groth16-keys input/witness.json EXPECTED_CLAIM_DIGEST proof -``` - -`TRUSTED_VERIFIER_BLAKE3` authenticates the serialized KZG verifier file. -`EXPECTED_CLAIM_DIGEST` is obtained independently of the witness. The exporter -requires admitted Ignition parameters for the inner KZG proof; local Groth16 -setup does not change the inner setup policy. Compilation writes a fresh -circuit into an empty artifact directory. +The exporter takes the BLAKE3 digest that authenticates the serialized KZG +verifier file and the independently expected statement, and requires admitted +Ignition parameters for the inner KZG proof; local Groth16 setup does not +change the inner setup policy. Compilation writes a fresh circuit into an empty +artifact directory. The CLI uses prepared keys and an independently approved catalog: @@ -183,12 +201,8 @@ ix verify COMPRESSED_PROOF --dev-srs `--dev-groth16` permits development outer keys and requires `--groth16`. `--dev-srs` on verification is the existing permission for development proofs. -A saved recursive packet can be wrapped directly, avoiding earlier stages: - -```sh -ix-compression-worker groth16 wrap verifier.bin packet.bin TRUSTED_VERIFIER_BLAKE3 expected-statement.json output --allow-development -ix-compression-worker groth16 verify output/proof.envelope.bin claim.bin --allow-development -``` +Rerunning `ix compress` into an output directory whose recursive KZG stage is +complete proves only the Groth16 stage. Groth16 uses envelope tag `IXBN\0\0\0\x03`, followed by the 32-byte approved verifying-key SHA-256 and canonical native proof. Verification is CPU-only and @@ -199,14 +213,14 @@ requires neither proving parameters nor a visible GPU. Production keys require a Groth16-compatible phase-one setup and a circuit-specific phase two. Ignition's KZG cache is not a Groth16 setup. -`groth16 setup-mpc ` verifies each +`pipeline::groth16::setup_mpc(artifacts, ceremony, trusted_sha256)` verifies each phase-one and phase-two contribution against its predecessor, binds phase two to the R1CS digest, applies the recorded beacons, and writes the resulting keys. The descriptor includes the curve, domain, circuit digest, two 32-byte beacons, and contribution paths with byte counts and SHA-256 receipts. Its exact schema is defined by `groth16Ceremony` in `groth16_setup.go`. -`groth16 import ` imports an already +`pipeline::groth16::import(artifacts, release, trusted_sha256)` imports an already reviewed key bundle. The release binds both keys, the circuit and verifier-schema hashes, the commitment hash, and the ceremony digest. Import relies on the independently trusted release digest; it does not re-run the contribution chain. @@ -224,8 +238,6 @@ G1/G2 MSMs and QAP polynomial work. CPU and CUDA use the same serialized keys, proof format, SHA-256 commitment hash, and CPU verifier. ```sh -nix build .#compression-worker-cuda -export IX_COMPRESSION_WORKER="$PWD/result/bin/ix-compression-worker" export IX_GROTH16_BACKEND=cuda export IX_GROTH16_DEVICE=0 export IX_GROTH16_PIN_KEY=1 @@ -234,20 +246,24 @@ export IX_GROTH16_PIN_KEY=1 `IX_GROTH16_PIN_KEY` defaults to `0`, streaming key vectors as needed. Set it to `1` only when persistent key vectors and working memory fit on the selected GPU. Each prover handle stays on one device; operations and cleanup are serialized -per device. The worker does not warm other GPUs or silently fall back to CPU. - -For a manual ICICLE installation, build only BN254 with MSM, NTT and G2 enabled, -using the pinned revision above. Set `IX_GROTH16_ICICLE_ROOT` to its installation -prefix, then build with `IX_CUDA=1 lake build ix` or Cargo features -`parallel,plonk,groth16,cuda`. The native build checks the libraries and packages -both the core libraries and CUDA backend modules beside the worker. Nix supplies -these dependencies in `.#compression-worker-cuda`; `.#icicle-bn254` builds ICICLE -alone. Verification remains CPU-only and does not initialize a device. +per device. `ix` does not warm other GPUs or silently fall back to CPU. + +Install ICICLE separately using the pinned revision above, with BN254, MSM, +NTT and G2 enabled. Lake and Cargo default to `/usr/local`, with core libraries +in `/usr/local/lib` and CUDA modules under `/usr/local/lib/backend`. +`IX_GROTH16_ICICLE_ROOT` optionally selects another installation prefix. +Then build with `IX_CUDA=1 IX_GROTH16=1 lake build ix` or Cargo features +`parallel,groth16,cuda`. Lake checks and tracks the library files, then copies +the libraries and backend plugins into `.lake/build/lib` beside `ix`; it does not +compile ICICLE. `ix` discovers the packaged backend first, with the +installation's `lib/backend` as a fallback; `ICICLE_BACKEND_INSTALL_DIR` +overrides that runtime selection. `.#icicle-bn254` builds the pinned ICICLE +with Nix. Verification remains CPU-only and does not initialize a device. Rust parses device selection and key-pinning options and passes them explicitly through FFI. The retained handle owns its native circuit, keys and device memory. -Artifact manifests are internal metadata; regenerate them with the worker's -compile/setup commands when changing the implementation. +Artifact manifests are internal metadata; regenerate them with the library's +compile and setup functions when changing the implementation. ### Solidity and tests From 6ee80fff5d2ef85fc311a1bca1d64d7365d7919f Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:25:03 +0000 Subject: [PATCH 24/31] feat(compression): bundle verifying keys 0.1 and derive them without a proof Bundle one key per proof system in verifying-keys/0.1/ with its layout, statement prefix and Ignition admission, pinned in pipeline/verifying_keys.rs. `ix verify` checks a proof's key identifier against the bundled key for its tag; the PLONK and Groth16 catalogs are removed, and the development Groth16 key requires --dev-srs. Add pipeline::derive_recursive_vk and plonk::derive_verifying_key, which derive the bundled keys from trusted inputs alone by sharing each stage's construction with the proving path; ignored tests reproduce both byte for byte. Remove the old non-matching pipeline::setup. Rename the first KZG stage's claim-dependent vk-id.bin to instance-id.bin. --- crates/proof-compression/README.md | 24 +- crates/proof-compression/src/kzg/compact.rs | 12 - .../src/pipeline/compress.rs | 5 +- .../proof-compression/src/pipeline/derive.rs | 98 ++++++ .../proof-compression/src/pipeline/first.rs | 85 +++-- .../proof-compression/src/pipeline/groth16.rs | 303 +++++------------- crates/proof-compression/src/pipeline/mod.rs | 23 +- .../proof-compression/src/pipeline/plonk.rs | 298 +++++------------ .../proof-compression/src/pipeline/prover.rs | 42 ++- crates/proof-compression/src/pipeline/root.rs | 27 +- .../src/pipeline/root_layout.rs | 20 -- .../proof-compression/src/pipeline/setup.rs | 9 +- .../src/pipeline/stages/construct.rs | 24 +- .../src/pipeline/stages/fixed.rs | 9 +- .../src/pipeline/stages/mod.rs | 3 +- .../src/pipeline/stages/setup.rs | 40 +-- .../src/pipeline/stages/wrap.rs | 35 +- .../src/pipeline/terminal.rs | 11 +- .../src/pipeline/verifying_keys.rs | 172 ++++++++++ .../src/pipeline/wrap/mod.rs | 3 + .../src/pipeline/wrap/outer.rs | 124 +++++-- .../src/pipeline/wrap/outer/plonk_export.rs | 211 +++++++----- .../src/pipeline/wrap/saved.rs | 146 +++++---- .../src/recursive/native_verifier/plan.rs | 8 + .../proof-compression/tests/pipeline_root.rs | 11 +- .../0.1/groth16-development-verifying-key.bin | Bin 0 -> 560 bytes .../0.1/ignition-admission.json | 15 + .../0.1/layout.json} | 0 .../0.1/plonk-verifying-key.bin | Bin 0 -> 34384 bytes .../verifying-keys/0.1/recursive-vk.bin | Bin 0 -> 1542 bytes flake.nix | 2 +- lakefile.lean | 6 +- tools/plonk-wrapper/README.md | 36 +-- tools/plonk-wrapper/groth16.go | 3 + 34 files changed, 973 insertions(+), 832 deletions(-) create mode 100644 crates/proof-compression/src/pipeline/derive.rs delete mode 100644 crates/proof-compression/src/pipeline/root_layout.rs create mode 100644 crates/proof-compression/src/pipeline/verifying_keys.rs create mode 100644 crates/proof-compression/verifying-keys/0.1/groth16-development-verifying-key.bin create mode 100644 crates/proof-compression/verifying-keys/0.1/ignition-admission.json rename crates/proof-compression/{root-layouts/ixvm-2026-10-29.json => verifying-keys/0.1/layout.json} (100%) create mode 100644 crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin create mode 100644 crates/proof-compression/verifying-keys/0.1/recursive-vk.bin diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 4d677e8ba..2bcd74c35 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -34,13 +34,16 @@ Those libraries may contain other curves; Ix exposes only BN254. KZG uses authenticated Ignition parameters or explicit known-trapdoor development parameters. Terminal PLONK requires authenticated Ignition. -This build ships no bundled recursive KZG verifying keys. `ix verify` reports -that verification of these proofs is unsupported; use the library's -`verify_with_vk` or `verify_with_ignition_admission` with an independently -pinned verifying key and expected statement. PLONK verification -requires an independently authorized `IX_PLONK_VERIFIER_CATALOG`. Groth16 -requires its own circuit-specific setup and `IX_GROTH16_VERIFIER_CATALOG`; -see the [terminal backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-terminal). +Each build pins one version of the verifying keys, +[`0.1`](verifying-keys/0.1): one key per proof system, with the root layout, +statement prefix and Ignition admission they were derived for. `ix verify` selects the key by the proof's tag and requires +the packet's key identifier to name it; there is no catalog of alternatives. +Recursive KZG and PLONK proofs verify under Ignition. The release Groth16 key +comes from a development setup, so Groth16 proofs verify only with +`--dev-srs`; see the +[terminal backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-terminal). +The library's `verify_with_vk` and `verify_with_ignition_admission` still +verify recursive KZG proofs under an independently pinned key and statement. BN254 supports radix-2 domains through `2^28`; traces and quotient domains must both fit. Existing BN254 proof, verifier, and SRS encodings are retained. @@ -227,7 +230,7 @@ setup artifact does not authorize it for another verifier. `ix compress ROOT` and `ix prove ... --compress kzg|plonk|groth16` automatically normalize the aggregate root using the versioned -[`ixvm-2026-10-29` layout](root-layouts/ixvm-2026-10-29.json). +[version 0.1 layout](verifying-keys/0.1/layout.json). `--fixed-root` is an optional layout override. The default layout is the one shared by the saved Nat.add_comm, Init and Mathlib normalization runs; it has 101 circuit heights, 18 claim elements and no additional headroom beyond those @@ -286,9 +289,8 @@ ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output Use the four Ignition environment variables described above. The result includes the proof envelope, native and Solidity proof encodings, generated -Solidity verifier, verifying key, admission, and candidate verifier catalog. -The candidate must be reviewed independently before configuring it for -`ix verify`. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) +Solidity verifier and verifying key, which must equal the bundled key for +`ix verify` to accept the proof. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) for Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the terminal PLONK verifier and [claim adapter](../../contracts/plonk/README.md). diff --git a/crates/proof-compression/src/kzg/compact.rs b/crates/proof-compression/src/kzg/compact.rs index b367ddcf5..8b7ee047b 100644 --- a/crates/proof-compression/src/kzg/compact.rs +++ b/crates/proof-compression/src/kzg/compact.rs @@ -38,18 +38,6 @@ pub struct FixedProofCodec { } impl FixedProofCodec { - /// Shape-only input for deterministic verifier construction. This is not - /// a valid proof and must never be used as a verification shortcut. - pub(crate) fn template(&self) -> Proof { - let mut proof = self.template.clone(); - let mut next = proof.log_degrees.clone(); - next.sort_unstable(); - next.dedup(); - next.retain(|&log| log != 0); - proof.opening_proof.0.resize(1 + next.len(), G1Affine::default()); - proof - } - pub fn new( system: &System, log_degrees: &[u8], diff --git a/crates/proof-compression/src/pipeline/compress.rs b/crates/proof-compression/src/pipeline/compress.rs index 93be310c8..71448cb5a 100644 --- a/crates/proof-compression/src/pipeline/compress.rs +++ b/crates/proof-compression/src/pipeline/compress.rs @@ -507,7 +507,8 @@ fn check_first(dir: &Path, words: &statement::Statement) -> Result<()> { } /// The recursive verifying key is admitted by the setup and verifies the -/// recursive packet for the expected statement. +/// recursive packet for the expected statement, and a release key compresses +/// the release's aggregation program. fn check_final( dir: &Path, words: &statement::Statement, @@ -520,7 +521,7 @@ fn check_final( vk.check_setup_class(setup.class())?; } vk.verify(&fs::read(dir.join("kzg/packet.bin"))?, words)?; - Ok(()) + super::verifying_keys::check_statement(&vk.id(), words) } /// The terminal stage left a proof envelope of its system. diff --git a/crates/proof-compression/src/pipeline/derive.rs b/crates/proof-compression/src/pipeline/derive.rs new file mode 100644 index 000000000..c9cd1d27c --- /dev/null +++ b/crates/proof-compression/src/pipeline/derive.rs @@ -0,0 +1,98 @@ +//! Verifying keys derived from trusted inputs alone. No proof, witness or +//! stage directory is read: each stage's verifier is built by the +//! construction its proving path uses, so a derived key is the key every +//! compression under the same inputs proves to. +use super::{FixedRoot, Result, SetupConfig, first, root, wrap}; + +/// Derive the recursive verifying key, as `compress` writes it to +/// `final-kzg/vk.bin`, for roots of the aggregation backend whose serialized +/// key is `root_vk`, normalized to the fixed layout `layout` (JSON), and +/// compressed under `setup` with a first KZG stage of `2^log_height` rows. +pub fn derive_recursive_vk( + root_vk: &[u8], + layout: &str, + setup: &SetupConfig, + log_height: u32, +) -> Result> { + if !matches!(log_height, 24 | 25) { + return Err("the first KZG stage's log-height must be 24 or 25".into()); + } + let fri = { + let key = aiur::vk_codec::AiurVerifyingKey::from_bytes(root_vk)?; + root::verifier(&key, &FixedRoot::from_json(layout)?)? + }; + let (manifest, parts) = + first::setup(&fri, usize::try_from(log_height)?, setup)?; + drop(fri); + let vk = wrap::verifying_key(&manifest, parts, setup)?; + match setup.ignition_admission() { + Some(admission) => vk.check_ignition_admission(admission)?, + None => vk.check_setup_class(setup.class())?, + } + vk.to_bytes() +} + +#[cfg(test)] +mod tests { + use super::super::verifying_keys; + use super::*; + + const RECURSIVE_VK: &[u8] = + include_bytes!("../../verifying-keys/0.1/recursive-vk.bin"); + + fn ignition() -> SetupConfig { + let setup = SetupConfig::from_env(false).unwrap(); + assert!(setup.ignition_admission().is_some()); + setup + } + + #[test] + #[ignore = "needs IX_ROOT_VK naming the release aggregation backend key, \ + the pinned Ignition release under IX_PARAMS_DIR and about \ + 90 GB of memory; a CUDA build commits on the GPU"] + fn release_recursive_key_derives_without_a_proof() { + let root_vk = + std::fs::read(std::env::var_os("IX_ROOT_VK").unwrap()).unwrap(); + let layout = verifying_keys::release_root_layout(&root_vk).unwrap(); + let derived = + derive_recursive_vk(&root_vk, layout, &ignition(), 24).unwrap(); + eprintln!("derived recursive key BLAKE3 {}", blake3::hash(&derived)); + assert!(derived == RECURSIVE_VK, "derived recursive key differs"); + } + + #[cfg(feature = "plonk")] + #[test] + fn release_plonk_description_matches_its_pin() { + use sha2::{Digest, Sha256}; + let vk = wrap::VerifyingKey::from_bytes(RECURSIVE_VK).unwrap(); + let description = + wrap::plonk_description(&vk, &verifying_keys::STATEMENT_PREFIX).unwrap(); + assert_eq!( + Sha256::digest(&description).as_slice(), + verifying_keys::decode(verifying_keys::TERMINAL_SCHEMA_SHA256) + ); + } + + #[cfg(feature = "plonk")] + #[test] + #[ignore = "needs the pinned Ignition release under IX_PARAMS_DIR and \ + compiles and sets up the PLONK circuit into a new directory \ + under the temporary directory (minutes on a GPU-backed gnark, \ + longer on CPU)"] + fn release_plonk_key_derives_without_a_proof() { + use sha2::{Digest, Sha256}; + let cache = super::super::storage::temporary_path( + &std::env::temp_dir().join("ix-plonk-derive"), + ); + let derived = super::super::plonk::derive_verifying_key( + RECURSIVE_VK, + &verifying_keys::STATEMENT_PREFIX, + &ignition(), + &cache, + ) + .unwrap(); + eprintln!("derived PLONK key SHA-256 {:x}", Sha256::digest(&derived)); + assert!(derived == verifying_keys::PLONK_VK, "derived PLONK key differs"); + std::fs::remove_dir_all(cache).unwrap(); + } +} diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index f3661a0b7..6baa012fe 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -13,16 +13,19 @@ use multi_stark::plonkish::{Assignment, MultiStarkCircuit}; #[cfg(test)] use multi_stark::traits::Field; use multi_stark::traits::TwoAdicField; +use multi_stark::types::Val; +use p3_field::PrimeCharacteristicRing; use std::{fs, path::Path, time::Instant}; +#[cfg(test)] +use super::init_claim; use super::stages::construct::{self, FriConstruction, Lowered, max_height}; use super::{ fixed_cache::FixedCache, - setup::{FIRST_SEED, SetupClass}, + prover::FixedSetup, + setup::{FIRST_SEED, SetupClass, SetupSource}, storage, }; -#[cfg(test)] -use super::{init_claim, setup::SetupSource}; use super::{init_fri, prover}; /// Partitions of the supported Init layout: ten carry the public words and @@ -57,8 +60,7 @@ pub(super) fn run( )?; assert_eq!(prepared.public_values(), fixture.public); let public = super::stages::scalar_statement(&fixture.public); - let lowered = - construct::prepare_first(prepared, trace_height, construct::TABLE_HEIGHT)?; + let lowered = construct::prepare_first(prepared, trace_height)?; assert_eq!(lowered.assignment.public_values(), public); eprintln!( "Scalar translation and lowering of {} circuits: {:?}; {:?}", @@ -84,6 +86,48 @@ pub(super) fn run( Ok(()) } +/// The KZG configuration the first stage commits and proves under. +fn proving_config( + setup: &SetupSource, + height: usize, +) -> storage::Result { + Ok( + setup + .config(height, 2, false)? + .with_streaming_lookups() + .with_streaming_quotient(), + ) +} + +/// The first stage's fixed setup for the FRI stage `fri` at trace height +/// `2^log_height`, derived without a proof: its manifest, with claims for a +/// zero statement, and each partition's circuit and fixed commitment, as +/// `run` stages and commits them. +pub(super) fn setup( + fri: &super::stages::FriVerifier, + log_height: usize, + config: &super::SetupConfig, +) -> storage::Result<(storage::Manifest, Vec)> { + let plan = VerifierPlan::validate( + &fri.key, + construct::fri_profile(&fri.key, &fri.statement.template)?, + VerifierLimits::default(), + )?; + let circuit = FriConstruction::build(&plan, fri.statement.schema())?.circuit; + drop(plan); + let (compiled, _inputs) = construct::first_circuit(circuit, 1 << log_height)?; + let height = max_height(&compiled)?; + let setup = config.for_stage(FIRST_SEED, height)?; + let public = super::stages::scalar_statement(&[Val::ZERO; 18]); + let manifest = storage::Manifest::for_compiled( + &compiled, + compiled.claims(&public)?, + None, + )?; + let parts = prover::fixed_setup(&proving_config(&setup, height)?, &compiled)?; + Ok((manifest, parts)) +} + /// Commit the fixed and witness traces of every partition, prove them /// together, verify the proof and write it with its packet. Fixed /// preprocessing comes from the cache when it holds this verifying key and is @@ -138,34 +182,33 @@ fn prove( fs::write(out.join("SECURITY.txt"), setup.security_description())?; let start = Instant::now(); let prefetch_bytes = config.prefetch_bytes; - let config = setup - .config(height, 2, false)? - .with_streaming_lookups() - .with_streaming_quotient(); + let config = proving_config(&setup, height)?; eprintln!("KZG parameters ready: {:?}", start.elapsed()); eprintln!("Partition prefetch budget: {} GiB", prefetch_bytes >> 30); let prover::Proved { system, bytes, verify_seconds, .. } = lowered .commit(config, Some(&out), prefetch_bytes)? .prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); - // The label is part of every published verifying key id and predates the - // Ignition setup; the setup class is bound through the transcript seed. - let mut vk = blake3::Hasher::new(); - vk.update(b"init-fri-kzg-bn254/v1/development-srs/quotient2"); - vk.update(system.config.transcript_seed()); - vk.update(&plan_id); - vk.update(&fs::read(dir.join("manifest.bin"))?); + // Identifies this proof instance, not its key: the manifest holds the claim + // rows. Recursion verifies the proof against the setup files, so the + // identity need not be claim-independent. The label predates the Ignition + // setup; the setup class is bound through the transcript seed. + let mut instance = blake3::Hasher::new(); + instance.update(b"init-fri-kzg-bn254/v1/development-srs/quotient2"); + instance.update(system.config.transcript_seed()); + instance.update(&plan_id); + instance.update(&fs::read(dir.join("manifest.bin"))?); for i in 0..count { - vk.update(&fs::read(out.join(format!("setup-{i}.bin")))?); + instance.update(&fs::read(out.join(format!("setup-{i}.bin")))?); } - let vk = *vk.finalize().as_bytes(); - let mut packet = vk.to_vec(); + let instance = *instance.finalize().as_bytes(); + let mut packet = instance.to_vec(); for claim in &manifest.claims[1..19] { packet.extend_from_slice(&claim[3].canonical_limbs_le()[0].to_le_bytes()); } packet.extend_from_slice(&bytes); storage::write_atomic(&out.join("proof.compact.bin"), &bytes)?; - storage::write_atomic(&out.join("vk-id.bin"), &vk)?; + storage::write_atomic(&out.join("instance-id.bin"), &instance)?; storage::write_atomic(&out.join("packet.bin"), &packet)?; let development = setup.class() == SetupClass::Development; let public_setup = system.config.pcs().public_setup(); @@ -213,7 +256,7 @@ mod tests { fn artifacts(directory: &Path) -> storage::Result>> { Ok( - ["proof.compact.bin", "packet.bin", "vk-id.bin"] + ["proof.compact.bin", "packet.bin", "instance-id.bin"] .into_iter() .map(|name| fs::read(directory.join("kzg").join(name))) .collect::>()?, diff --git a/crates/proof-compression/src/pipeline/groth16.rs b/crates/proof-compression/src/pipeline/groth16.rs index b00a138f4..4ca76c7c6 100644 --- a/crates/proof-compression/src/pipeline/groth16.rs +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -1,17 +1,18 @@ -//! Terminal BN254 Groth16 proofs under independently admitted circuit keys. +//! Terminal BN254 Groth16 proofs under the release key. #[cfg(feature = "groth16")] use super::gnark; -#[cfg(any(feature = "groth16", test))] +#[cfg(feature = "groth16")] use super::storage; -#[cfg(test)] +#[cfg(all(test, feature = "groth16"))] use super::terminal::Scratch; #[cfg(feature = "groth16")] -use super::terminal::sha256; +use super::terminal::{hex, sha256}; use super::{ Result, setup::SetupClass, statement, - terminal::{Artifact, digest, hex, load_json}, + terminal::{Artifact, digest, load_json}, + verifying_keys, }; #[cfg(feature = "groth16")] use super::{ @@ -21,108 +22,52 @@ use super::{ }; #[cfg(feature = "groth16")] use std::fs; -use std::path::{Path, PathBuf}; +#[cfg(feature = "groth16")] +use std::path::Path; +use std::path::PathBuf; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x03"; -const CATALOG_FORMAT: &str = "ix-bn254-groth16-verifiers-v1"; -const CATALOG_VAR: &str = "IX_GROTH16_VERIFIER_CATALOG"; const ARTIFACTS_VAR: &str = "IX_GROTH16_ARTIFACTS"; const MAX_PROOF_BYTES: usize = 196; -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct TrustedCatalog { - format: String, - keys: Vec, -} - -#[derive(Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct TrustedKey { - sha256: String, - path: PathBuf, - inner_vk_id: String, - inner_schema_sha256: String, - statement_prefix: [u64; 10], - circuit_sha256: String, - setup: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - ceremony_sha256: Option, -} - -impl TrustedKey { - fn setup_class(&self, allow_development: bool) -> Result { - match self.setup.as_str() { - "mpc" => { - digest(self.ceremony_sha256.as_deref().ok_or("missing Groth16 ceremony identity")?)?; - Ok(SetupClass::Groth16) - }, - "development" if allow_development && self.ceremony_sha256.is_none() => Ok(SetupClass::Development), - "development" => Err("development Groth16 keys require explicit --allow-development or --dev-srs verification".into()), - _ => Err("unsupported Groth16 setup class".into()), - } - } -} - -fn catalog_path() -> Result { - std::env::var_os(CATALOG_VAR).map(PathBuf::from).ok_or_else(|| { - format!( - "set {CATALOG_VAR} to an independently trusted Groth16 verifier catalog" - ) - .into() - }) -} - -fn select_key( - path: &Path, - id: &[u8; 32], - allow_development: bool, -) -> Result { - let catalog: TrustedCatalog = load_json(path)?; - if catalog.format != CATALOG_FORMAT { - return Err("unsupported Groth16 verifier catalog".into()); - } - let mut selected = None; - let mut seen = std::collections::BTreeSet::new(); - for mut key in catalog.keys { - let key_id = digest(&key.sha256)?; - key.sha256 = hex(&key_id); - key.inner_vk_id = hex(&digest(&key.inner_vk_id)?); - key.inner_schema_sha256 = hex(&digest(&key.inner_schema_sha256)?); - key.circuit_sha256 = hex(&digest(&key.circuit_sha256)?); - if let Some(value) = &mut key.ceremony_sha256 { - *value = hex(&digest(value)?); - } - if !seen.insert(key_id) { - return Err("duplicate Groth16 verifying key in catalog".into()); - } - let words = statement::with_claim(&key.statement_prefix, b""); - statement::from_claims_bytes(&statement::to_claims_bytes(&words))?; - key.setup_class(true)?; - if key_id == *id { - key.setup_class(allow_development)?; - if key.path.is_relative() { - key.path = - path.parent().unwrap_or_else(|| Path::new(".")).join(&key.path); - } - selected = Some(key); - } +fn check_length(packet: &[u8]) -> Result<()> { + if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { + return Err("invalid Groth16 packet length".into()); } - selected.ok_or( - "Groth16 proof uses a verifying key not authorized by the catalog".into(), - ) + Ok(()) } +/// Verify a Groth16 packet of `claim` under the release key. #[cfg(feature = "groth16")] pub(super) fn verify( packet: &[u8], claim: &[u8], allow_development: bool, ) -> Result { - if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { - return Err("invalid Groth16 packet length".into()); + check_length(packet)?; + if packet[..32] != verifying_keys::decode(verifying_keys::GROTH16_VK_SHA256) { + return Err( + "Groth16 proof uses a verifying key other than the release key".into(), + ); + } + if ::digest(verifying_keys::GROTH16_VK) + .as_slice() + != &packet[..32] + { + return Err("bundled Groth16 verifying key differs from its pin".into()); } - verify_with_catalog(packet, claim, &catalog_path()?, allow_development) + verify_under(verifying_keys::GROTH16_VK, packet, claim)?; + verifying_keys::groth16_setup(allow_development) +} + +#[cfg(feature = "groth16")] +fn verify_under(key: &[u8], packet: &[u8], claim: &[u8]) -> Result<()> { + gnark::verify_groth16( + key, + packet[..32].try_into()?, + &packet[32..], + blake3::hash(claim).as_bytes(), + ) } #[cfg(not(feature = "groth16"))] @@ -131,34 +76,10 @@ pub(super) fn verify( _claim: &[u8], _allow_development: bool, ) -> Result { - if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { - return Err("invalid Groth16 packet length".into()); - } + check_length(packet)?; Err("Groth16 proofs are verified by gnark, and this ix was built without Groth16; rebuild with IX_GROTH16=1".into()) } -#[cfg(feature = "groth16")] -fn verify_with_catalog( - packet: &[u8], - claim: &[u8], - catalog: &Path, - allow_development: bool, -) -> Result { - if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { - return Err("invalid Groth16 packet length".into()); - } - let id = packet[..32].try_into()?; - let key = select_key(catalog, id, allow_development)?; - let expected = *blake3::hash(claim).as_bytes(); - gnark::verify_groth16( - &read_bounded(&key.path, 1 << 20)?, - id, - &packet[32..], - &expected, - )?; - key.setup_class(allow_development) -} - #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(super) struct Metadata { @@ -231,7 +152,9 @@ impl Metadata { } } -fn prepared(allow_development: bool) -> Result<(PathBuf, TrustedKey)> { +/// The prepared artifacts `IX_GROTH16_ARTIFACTS` names, which must be the +/// release circuit and key. +fn prepared(allow_development: bool) -> Result { let dir = std::env::var_os(ARTIFACTS_VAR).map(PathBuf::from).ok_or_else(|| { format!("set {ARTIFACTS_VAR} to prepared Groth16 circuit artifacts") @@ -241,25 +164,23 @@ fn prepared(allow_development: bool) -> Result<(PathBuf, TrustedKey)> { if !m.keys_ready { return Err("Groth16 keys are not prepared".into()); } - let key = select_key( - &catalog_path()?, - &digest(&m.artifact("verifying-key.bin")?.sha256)?, - allow_development, - )?; - if m.inner_vk != key.inner_schema_sha256 - || m.artifact("vk.json")?.sha256 != key.inner_schema_sha256 - || m.artifact("circuit.bin")?.sha256 != key.circuit_sha256 - || m.setup != key.setup - || m.ceremony != key.ceremony_sha256 + if m.artifact("verifying-key.bin")?.sha256 + != verifying_keys::GROTH16_VK_SHA256 + || m.inner_vk != verifying_keys::TERMINAL_SCHEMA_SHA256 + || m.artifact("circuit.bin")?.sha256 + != verifying_keys::GROTH16_CIRCUIT_SHA256 + || m.setup != "development" + || m.ceremony.is_some() { return Err( - "Groth16 artifacts differ from the admitted circuit and setup".into(), + "Groth16 artifacts differ from the release circuit and key".into(), ); } for name in ["proving-key.bin", "Groth16Verifier.sol"] { m.artifact(name)?; } - Ok((dir, key)) + verifying_keys::groth16_setup(allow_development)?; + Ok(dir) } pub(super) fn preflight(allow_development: bool) -> Result<()> { @@ -275,10 +196,10 @@ pub(super) fn check_statement( expected: &statement::Statement, allow_development: bool, ) -> Result<()> { - let (_, key) = prepared(allow_development)?; - if expected[..10] != key.statement_prefix { + prepared(allow_development)?; + if expected[..10] != verifying_keys::STATEMENT_PREFIX { return Err( - "Groth16 catalog authorizes another aggregation program".into(), + "the release Groth16 key proves another aggregation program".into(), ); } Ok(()) @@ -298,9 +219,11 @@ pub(super) fn wrap_packet( setup: &SetupConfig, ) -> Result<()> { statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; - let (artifacts, key) = prepared(allow_development)?; - if expected[..10] != key.statement_prefix { - return Err("Groth16 key does not authorize this inner verifier and aggregation program".into()); + let artifacts = prepared(allow_development)?; + if expected[..10] != verifying_keys::STATEMENT_PREFIX { + return Err( + "the release Groth16 key proves another aggregation program".into(), + ); } fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { @@ -311,8 +234,10 @@ pub(super) fn wrap_packet( let exported: serde_json::Value = load_json(&input.join("vk.json"))?; let inner_id = exported["vkId"].as_str().ok_or("missing exported inner key identity")?; - if digest(inner_id)? != digest(&key.inner_vk_id)? - || sha256(&input.join("vk.json"))? != digest(&key.inner_schema_sha256)? + if digest(inner_id)? + != verifying_keys::decode(verifying_keys::RECURSIVE_VK_ID) + || sha256(&input.join("vk.json"))? + != verifying_keys::decode(verifying_keys::TERMINAL_SCHEMA_SHA256) { return Err( "Groth16 circuit was compiled for a different verifier description" @@ -321,9 +246,10 @@ pub(super) fn wrap_packet( } let claim = claim_digest(expected)?; prove(&artifacts, &input.join("witness.json"), &claim, out)?; + let key_id = verifying_keys::decode(verifying_keys::GROTH16_VK_SHA256); gnark::verify_groth16( - &read_bounded(&key.path, 1 << 20)?, - &digest(&key.sha256)?, + verifying_keys::GROTH16_VK, + &key_id, &read_bounded(&out.join("proof.bin"), MAX_PROOF_BYTES)?, &claim, )?; @@ -332,7 +258,7 @@ pub(super) fn wrap_packet( return Err("invalid native Groth16 proof length".into()); } let mut envelope = PROOF_TAG.to_vec(); - envelope.extend(digest(&key.sha256)?); + envelope.extend(key_id); envelope.extend(proof); for name in ["verifying-key.bin", "Groth16Verifier.sol"] { storage::write_atomic(&out.join(name), &fs::read(artifacts.join(name))?)?; @@ -518,100 +444,21 @@ mod tests { ); let mut packet = id.to_vec(); packet.extend(bytes); - let key = TrustedKey { - sha256: hex(&id), - path: vk.clone(), - inner_vk_id: provenance["inner_vk_id"].as_str().unwrap().into(), - inner_schema_sha256: provenance["inner_schema_sha256"] - .as_str() - .unwrap() - .into(), - statement_prefix: serde_json::from_value( - provenance["statement_prefix"].clone(), - )?, - circuit_sha256: provenance["circuit_sha256"].as_str().unwrap().into(), - setup: "development".into(), - ceremony_sha256: None, - }; - let scratch = Scratch::new()?; - let catalog = scratch.0.join("catalog.json"); - storage::write_atomic( - &catalog, - &serde_json::to_vec(&TrustedCatalog { - format: CATALOG_FORMAT.into(), - keys: vec![key], - })?, - )?; - assert!(verify_with_catalog(&packet, claim, &catalog, false).is_err()); - assert_eq!( - verify_with_catalog(&packet, claim, &catalog, true)?, - SetupClass::Development - ); - assert!( - verify_with_catalog(&packet, b"another claim", &catalog, true).is_err() - ); - let mut wrong_key = fs::read(&vk)?; + let key = fs::read(&vk)?; + verify_under(&key, &packet, claim)?; + assert!(verify_under(&key, &packet, b"another claim").is_err()); + assert!(verify(&packet, claim, true).is_err()); + let mut wrong_key = key.clone(); wrong_key[0] ^= 1; - assert!( - gnark::verify_groth16( - &wrong_key, - &id, - &packet[32..], - &digest(provenance["claim_digest"].as_str().unwrap())? - ) - .is_err() - ); + assert!(verify_under(&wrong_key, &packet, claim).is_err()); packet[32] = 0; - assert!(verify_with_catalog(&packet, claim, &catalog, true).is_err()); - Ok(()) - } - - #[test] - fn catalog_requires_exact_key_identity_and_explicit_development() -> Result<()> - { - let scratch = Scratch::new()?; - let path = scratch.0.join("catalog.json"); - let mut catalog = TrustedCatalog { - format: CATALOG_FORMAT.into(), - keys: vec![TrustedKey { - sha256: hex(&[1; 32]), - path: "key.bin".into(), - inner_vk_id: hex(&[2; 32]), - inner_schema_sha256: hex(&[3; 32]), - statement_prefix: [0; 10], - circuit_sha256: hex(&[4; 32]), - setup: "development".into(), - ceremony_sha256: None, - }], - }; - let save = |value: &TrustedCatalog| { - storage::write_atomic(&path, &serde_json::to_vec(value).unwrap()) - }; - save(&catalog)?; - assert!(select_key(&path, &[1; 32], false).is_err()); - assert_eq!( - select_key(&path, &[1; 32], true)?.path, - scratch.0.join("key.bin") - ); - assert!(select_key(&path, &[9; 32], true).is_err()); - catalog.keys[0].setup = "mpc".into(); - save(&catalog)?; - assert!(select_key(&path, &[1; 32], false).is_err()); - catalog.keys[0].ceremony_sha256 = Some(hex(&[5; 32])); - save(&catalog)?; - assert_eq!( - select_key(&path, &[1; 32], false)?.setup_class(false)?, - SetupClass::Groth16 - ); - catalog.keys.push(catalog.keys[0].clone()); - save(&catalog)?; - assert!(select_key(&path, &[1; 32], false).is_err()); - assert_eq!(PROOF_TAG, b"IXBN\0\0\0\x03"); + assert!(verify_under(&key, &packet, claim).is_err()); Ok(()) } #[test] fn malformed_envelopes_fail_before_key_access() { + assert_eq!(PROOF_TAG, b"IXBN\0\0\0\x03"); for length in [0, 32, 195, 197, 227, 229, 1 << 20] { assert!( verify(&vec![0; length], b"claim", false) diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 621a87ab2..0010b4553 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -3,10 +3,12 @@ mod stages; pub use stages::{ FixedRoot, FriVerifier, KzgVerifier, Prepared, RecursiveProof, RecursiveStage, RecursiveVerifier, prepare_kzg, prepare_recursive, - prepare_root, setup, + prepare_root, }; mod compress; +mod derive; +pub use derive::derive_recursive_vk; pub(crate) use compress::StageConfig; pub use compress::{CompressConfig, CompressInput, compress}; mod first; @@ -22,8 +24,8 @@ mod terminal; pub use terminal::{Artifact, TerminalOptions, TerminalSystem}; mod prover; mod root; -mod root_layout; -pub use root_layout::release_root_layout; +mod verifying_keys; +pub use verifying_keys::release_root_layout; mod setup; pub use setup::{SetupClass, SetupConfig}; mod statement; @@ -53,8 +55,7 @@ pub fn setup_description(development: bool) -> Result<&'static str> { Ok(SetupConfig::from_env(development)?.class().description()) } -/// Verify a terminal proof using an independently configured verifier catalog. -/// Recursive KZG requires an explicitly pinned key and expected statement. +/// Verify a compressed proof of `claim` under the release key its tag selects. /// Proving parameters are not required. pub fn verify( proof: &[u8], @@ -67,8 +68,8 @@ pub fn verify( if let Some(packet) = proof.strip_prefix(plonk::PROOF_TAG) { return plonk::verify(packet, claim); } - if proof.starts_with(PROOF_TAG) { - return Err("bundled recursive KZG verification is unsupported in this build; use an independently pinned verifying key and expected statement".into()); + if let Some(packet) = proof.strip_prefix(PROOF_TAG) { + return verifying_keys::verify_recursive(packet, claim); } Err("unsupported compressed proof version".into()) } @@ -184,16 +185,12 @@ mod tests { } #[test] - fn bundled_kzg_verification_is_explicitly_unsupported() { + fn recursive_kzg_proofs_must_use_the_release_key() { let mut proof = PROOF_TAG.to_vec(); proof.extend([0; 32]); for allow_development in [false, true] { let error = verify(&proof, b"claim", allow_development).unwrap_err(); - assert!( - error - .to_string() - .contains("bundled recursive KZG verification is unsupported") - ); + assert!(error.to_string().contains("other than the")); } } diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 961f7a777..079a526d8 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -1,21 +1,19 @@ -//! Terminal PLONK proofs and authenticated parameter interchange. -#[cfg(feature = "plonk")] -#[cfg(feature = "plonk")] -use super::gnark; -#[cfg(any(feature = "plonk", feature = "groth16", test))] +//! Terminal PLONK proofs under the release key, and authenticated parameter +//! interchange. +#[cfg(any(feature = "plonk", feature = "groth16"))] use super::statement; -#[cfg(any(feature = "plonk", test))] +#[cfg(feature = "plonk")] use super::storage; -#[cfg(test)] -use super::terminal::Scratch; #[cfg(feature = "plonk")] -use super::terminal::{claim_digest, read_bounded, sha256}; -#[cfg(any(feature = "plonk", test))] +use super::terminal::{claim_digest, sha256}; +#[cfg(feature = "plonk")] use super::terminal::{digest, hex, load_json}; use super::{Result, setup::SetupClass}; +#[cfg(feature = "plonk")] +use super::{gnark, verifying_keys}; #[cfg(any(feature = "plonk", feature = "groth16"))] use super::{setup::SetupConfig, wrap}; -#[cfg(any(feature = "plonk", test))] +#[cfg(feature = "plonk")] use ark_ff::{BigInteger, PrimeField}; #[cfg(feature = "plonk")] use sha2::{Digest, Sha256}; @@ -23,44 +21,14 @@ use sha2::{Digest, Sha256}; use std::fs; #[cfg(feature = "plonk")] use std::io::Write; -#[cfg(any(feature = "plonk", feature = "groth16", test))] +#[cfg(any(feature = "plonk", feature = "groth16"))] use std::path::Path; -#[cfg(any(feature = "plonk", test))] +#[cfg(feature = "plonk")] use std::path::PathBuf; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; -#[cfg(any(feature = "plonk", test))] -const CATALOG_FORMAT: &str = "ix-bn254-plonk-verifiers-v1"; -#[cfg(any(feature = "plonk", test))] -const SETUP_NAME: &str = "ignition"; -#[cfg(feature = "plonk")] -const CATALOG_VAR: &str = "IX_PLONK_VERIFIER_CATALOG"; const MAX_PROOF_BYTES: usize = 1 << 20; -#[cfg(any(feature = "plonk", test))] -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct TrustedCatalog { - format: String, - keys: Vec, -} - -#[cfg(any(feature = "plonk", test))] -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct TrustedKey { - sha256: String, - path: PathBuf, - inner_vk_id: String, - statement_prefix: [u64; 10], - circuit_sha256: String, - setup: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - admission: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - admission_blake3: Option, -} - #[cfg(feature = "plonk")] fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { match config { @@ -75,7 +43,7 @@ fn anchors(config: &SetupConfig) -> Result { Ok(config.ignition_admission().ok_or("missing Ignition admission")?.anchors()) } -#[cfg(any(feature = "plonk", test))] +#[cfg(feature = "plonk")] fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { let mut bytes = Vec::new(); for value in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { @@ -84,7 +52,7 @@ fn encode_g2(point: crate::kzg::curve::G2Affine) -> Vec { bytes } -#[cfg(any(feature = "plonk", test))] +#[cfg(feature = "plonk")] fn anchor_encoding(srs: &crate::kzg::Srs) -> Vec { let mut bytes = Vec::new(); for value in [srs.g1[0].x, srs.g1[0].y] { @@ -95,71 +63,6 @@ fn anchor_encoding(srs: &crate::kzg::Srs) -> Vec { bytes } -#[cfg(any(feature = "plonk", test))] -fn key_anchors(key: &TrustedKey) -> Result { - { - Ok( - crate::kzg::srs::ignition::IgnitionAdmission::read( - key.admission.as_ref().ok_or("missing trusted admission path")?, - digest( - key - .admission_blake3 - .as_ref() - .ok_or("missing trusted admission pin")?, - )?, - )? - .anchors(), - ) - } -} - -#[cfg(any(feature = "plonk", test))] -fn select_key(path: &Path, id: &[u8; 32]) -> Result { - let catalog: TrustedCatalog = load_json(path)?; - if catalog.format != CATALOG_FORMAT { - return Err("unsupported PLONK verifier catalog".into()); - } - let mut selected = None; - let mut seen = std::collections::BTreeSet::new(); - for key in catalog.keys { - let key_id = digest(&key.sha256)?; - digest(&key.inner_vk_id)?; - digest(&key.circuit_sha256)?; - if !seen.insert(key_id) { - return Err("duplicate PLONK verifier key in catalog".into()); - } - if key.setup != SETUP_NAME { - return Err("PLONK catalog setup class differs".into()); - } - { - digest( - key.admission_blake3.as_ref().ok_or("missing trusted admission pin")?, - )?; - if key.admission.is_none() { - return Err("missing trusted admission path".into()); - } - } - let words = statement::with_claim(&key.statement_prefix, b""); - statement::from_claims_bytes(&statement::to_claims_bytes(&words))?; - if key_id == *id { - selected = Some(key); - } - } - let mut key = selected.ok_or( - "PLONK proof uses a verification key not authorized by the catalog", - )?; - if key.path.is_relative() { - key.path = path.parent().unwrap_or_else(|| Path::new(".")).join(&key.path); - } - if let Some(admission) = &mut key.admission - && admission.is_relative() - { - *admission = - path.parent().unwrap_or_else(|| Path::new(".")).join(&*admission); - } - Ok(key) -} - #[cfg(not(feature = "plonk"))] pub(super) fn verify(packet: &[u8], _claim: &[u8]) -> Result { if packet.len() <= 32 || packet.len() > MAX_PROOF_BYTES + 32 { @@ -168,30 +71,28 @@ pub(super) fn verify(packet: &[u8], _claim: &[u8]) -> Result { Err("PLONK proofs are verified by gnark, and this ix was built without PLONK; rebuild with IX_PLONK=1".into()) } +/// Verify a PLONK packet of `claim` under the release's PLONK key. #[cfg(feature = "plonk")] pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { if packet.len() <= 32 || packet.len() > MAX_PROOF_BYTES + 32 { return Err("invalid PLONK packet length".into()); } - let id = packet[..32].try_into()?; - let catalog = - std::env::var_os(CATALOG_VAR).map(PathBuf::from).ok_or_else(|| { - format!( - "set {CATALOG_VAR} to an independently trusted PLONK verifier catalog" - ) - })?; - let key = select_key(&catalog, id)?; - if fs::metadata(&key.path)?.len() > 1 << 20 { - return Err("PLONK verification key is too large".into()); + let id: [u8; 32] = packet[..32].try_into()?; + if id != digest(verifying_keys::PLONK_VK_SHA256)? { + return Err( + "PLONK proof uses a verifying key other than the release key".into(), + ); + } + if Sha256::digest(verifying_keys::PLONK_VK).as_slice() != id { + return Err("bundled PLONK verifying key differs from its pin".into()); } - let expected = *blake3::hash(claim).as_bytes(); - let anchor_bytes = anchor_encoding(&key_anchors(&key)?); + let anchors = anchor_encoding(&verifying_keys::admission()?.anchors()); gnark::verify( - &read_bounded(&key.path, 1 << 20)?, - id, + verifying_keys::PLONK_VK, + &id, &packet[32..], - &expected, - &anchor_bytes, + blake3::hash(claim).as_bytes(), + &anchors, )?; Ok(SetupClass::Ignition) } @@ -312,17 +213,6 @@ fn cached_parameters( Ok((path, receipt.blake3)) } -#[cfg(feature = "plonk")] -fn metadata_string<'a>( - value: &'a serde_json::Value, - field: &str, -) -> Result<&'a str> { - value - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("missing PLONK metadata field {field}").into()) -} - #[cfg(feature = "plonk")] fn artifact(path: &Path) -> Result { Ok(gnark::Artifact { @@ -369,12 +259,11 @@ pub(super) fn proving_backend() -> Result { #[cfg(feature = "plonk")] fn prepare_artifacts( - vk: &Path, + schema: &[u8], setup: &SetupConfig, cache_root: &Path, ) -> Result<(PathBuf, gnark::Manifest)> { - let schema = fs::read(vk)?; - let schema_hash = hex(&Sha256::digest(&schema)); + let schema_hash = hex(&Sha256::digest(schema)); // `go.sum` pins the gnark that compiles the circuit and serializes its keys. let mut identity = blake3::Hasher::new(); identity.update(b"ix/plonk-circuit-cache/v2"); @@ -395,8 +284,8 @@ fn prepare_artifacts( } metadata } else { - storage::write_atomic(&dir.join("vk.json"), &schema)?; - let info = gnark::compile(&schema, &dir.join("circuit.bin"))?; + storage::write_atomic(&dir.join("vk.json"), schema)?; + let info = gnark::compile(schema, &dir.join("circuit.bin"))?; let metadata = gnark::Manifest { inner_vk_sha256: schema_hash, srs_blake3: String::new(), @@ -447,7 +336,7 @@ fn prepare_artifacts( String::new() }; gnark::prepare(&gnark::Preparation { - schema: &schema, + schema, circuit: &dir.join("circuit.bin"), circuit_hash: metadata .files @@ -484,9 +373,41 @@ fn prepare_artifacts( Ok((dir, metadata)) } +/// Derive the PLONK verifying key for the recursive verifying key +/// `recursive_vk` and statement prefix `prefix` without a proof: compile the +/// verifier description `wrap_packet` proves and set its keys up under +/// `setup`'s Ignition parameters, caching them under `cache_root` as +/// `wrap_packet` does. +#[cfg(feature = "plonk")] +pub fn derive_verifying_key( + recursive_vk: &[u8], + prefix: &[u64; 10], + setup: &SetupConfig, + cache_root: &Path, +) -> Result> { + let vk = wrap::VerifyingKey::from_bytes(recursive_vk)?; + vk.check_ignition_admission( + setup + .ignition_admission() + .ok_or("PLONK requires admitted Ignition parameters")?, + )?; + let schema = wrap::plonk_description(&vk, prefix)?; + let (dir, metadata) = prepare_artifacts(&schema, setup, cache_root)?; + let key = fs::read(dir.join("verifying-key.bin"))?; + let recorded = &metadata + .files + .get("verifying-key.bin") + .ok_or("missing verifying key hash")? + .sha256; + if hex(&Sha256::digest(&key)) != *recorded { + return Err("cached PLONK verifying key differs from its manifest".into()); + } + Ok(key) +} + /// Prove the recursive KZG packet in `packet` in PLONK and write its -/// envelope, native and Solidity proofs, verifying key and candidate catalog -/// entry to the empty directory `out`. Compiled circuits, keys and parameter +/// envelope, native and Solidity proofs and verifying key to the empty +/// directory `out`. Compiled circuits, keys and parameter /// exports are cached under `cache_root`. #[cfg(feature = "plonk")] pub(super) fn wrap_packet( @@ -516,9 +437,8 @@ pub(super) fn wrap_packet( &input, setup, )?; - let vk_path = input.join("vk.json"); - let exported: serde_json::Value = load_json(&vk_path)?; - let (cache, metadata) = prepare_artifacts(&vk_path, setup, cache_root)?; + let (cache, metadata) = + prepare_artifacts(&fs::read(input.join("vk.json"))?, setup, cache_root)?; let started = Instant::now(); let mut prover = gnark::Prover::open(&cache, &metadata, backend)?; let load_seconds = started.elapsed().as_secs_f64(); @@ -554,35 +474,6 @@ pub(super) fn wrap_packet( &out.join("PlonkVerifier.sol"), &fs::read(cache.join("PlonkVerifier.sol"))?, )?; - let admission_bytes = setup - .ignition_admission() - .ok_or("missing Ignition admission")? - .to_bytes()?; - storage::write_atomic(&out.join("admission.json"), &admission_bytes)?; - let candidate = TrustedCatalog { - format: CATALOG_FORMAT.into(), - keys: vec![TrustedKey { - sha256: key_hash.clone(), - path: "verifying-key.bin".into(), - inner_vk_id: metadata_string(&exported, "vkId")?.into(), - statement_prefix: expected[..10].try_into()?, - circuit_sha256: metadata - .files - .get("circuit.bin") - .ok_or("missing circuit hash")? - .sha256 - .clone(), - setup: SETUP_NAME.into(), - admission: Some("admission.json".into()), - admission_blake3: Some( - blake3::hash(&admission_bytes).to_hex().to_string(), - ), - }], - }; - storage::write_atomic( - &out.join("verifier-catalog.candidate.json"), - &serde_json::to_vec_pretty(&candidate)?, - )?; storage::write_atomic( &out.join("report.json"), &serde_json::to_vec_pretty( @@ -598,50 +489,15 @@ mod bn254_tests { use super::*; #[test] - fn catalog_requires_pinned_ignition_anchors_without_a_proving_cache() - -> Result<()> { - let scratch = Scratch::new()?; - let (admission, _) = crate::kzg::srs::ignition::test_admission(7, 8, 41); - let bytes = admission.to_bytes()?; - let pin = blake3::hash(&bytes).to_hex().to_string(); - storage::write_atomic(&scratch.0.join("admission.json"), &bytes)?; - let mut catalog = TrustedCatalog { - format: CATALOG_FORMAT.into(), - keys: vec![TrustedKey { - sha256: hex(&[1; 32]), - path: "key.bin".into(), - inner_vk_id: hex(&[2; 32]), - statement_prefix: [0; 10], - circuit_sha256: hex(&[3; 32]), - setup: "ignition".into(), - admission: Some("admission.json".into()), - admission_blake3: Some(pin), - }], - }; - let path = scratch.0.join("catalog.json"); - let write = |catalog: &TrustedCatalog| { - storage::write_atomic(&path, &serde_json::to_vec(catalog).unwrap()) - }; - write(&catalog)?; - let key = select_key(&path, &[1; 32])?; - assert_eq!( - anchor_encoding(&key_anchors(&key)?), - anchor_encoding(&admission.anchors()) - ); - assert!(select_key(&path, &[9; 32]).is_err()); - catalog.keys[0].admission_blake3 = Some(hex(&[0; 32])); - write(&catalog)?; - assert!(key_anchors(&select_key(&path, &[1; 32])?).is_err()); - catalog.keys[0].admission = None; - write(&catalog)?; - assert!(select_key(&path, &[1; 32]).is_err()); - catalog.keys[0].setup = "filecoin".into(); - write(&catalog)?; - assert!(select_key(&path, &[1; 32]).is_err()); - catalog.format = "ix-bls-plonk-verifiers-v1".into(); - write(&catalog)?; - assert!(select_key(&path, &[1; 32]).is_err()); + fn plonk_packets_are_tagged_and_bounded() { assert_eq!(PROOF_TAG, b"IXBN\0\0\0\x02"); - Ok(()) + assert!(verify(&[0; 32], b"claim").is_err()); + } + + #[test] + #[cfg(feature = "plonk")] + fn other_keys_are_rejected() { + let error = verify(&[1; 64], b"claim").unwrap_err(); + assert!(error.to_string().contains("other than the release key")); } } diff --git a/crates/proof-compression/src/pipeline/prover.rs b/crates/proof-compression/src/pipeline/prover.rs index 590edbc4a..e9a687b97 100644 --- a/crates/proof-compression/src/pipeline/prover.rs +++ b/crates/proof-compression/src/pipeline/prover.rs @@ -209,19 +209,37 @@ impl Committed { } } +/// A partition's circuit metadata and fixed commitment, as its +/// `setup-{i}.bin` checkpoint records them. +pub(super) type FixedSetup = (Circuit, KzgCommitment); + /// Commit each fixed partition without allocating a witness. -pub(super) fn setup( - config: KzgConfig, +pub(super) fn fixed_setup( + config: &KzgConfig, compiled: &MultiStarkCircuit, +) -> Result> { + (0..compiled.num_circuits()) + .map(|i| { + let (circuit, data) = fixed_partition(config, compiled, i, None)?; + Ok((circuit, KzgProverData::concatenate([data]).0)) + }) + .collect() +} + +/// The system of fixed partitions in canonical order under `config`. +pub(super) fn fixed_system( + config: KzgConfig, + parts: Vec, ) -> Result> { - let count = compiled.num_circuits(); + let count = parts.len(); let mut circuits = Vec::with_capacity(count); let mut fixed = KzgCommitment(vec![]); - for i in 0..count { - let (circuit, data) = fixed_partition(&config, compiled, i, None)?; - let (commitment, _data) = KzgProverData::concatenate([data]); - fixed.0.extend(commitment.0); + for (circuit, mut commitment) in parts { + if circuit.preprocessed.is_some() || commitment.0.len() != 1 { + return Err("inconsistent setup metadata".into()); + } circuits.push(circuit); + fixed.0.append(&mut commitment.0); } Ok(System { config, @@ -231,6 +249,16 @@ pub(super) fn setup( }) } +/// Commit each fixed partition without allocating a witness. +#[cfg(test)] +pub(super) fn setup( + config: KzgConfig, + compiled: &MultiStarkCircuit, +) -> Result> { + let parts = fixed_setup(&config, compiled)?; + fixed_system(config, parts) +} + fn fixed_partition( config: &KzgConfig, compiled: &MultiStarkCircuit, diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index a21bce7b0..f8f8ed55e 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -13,6 +13,26 @@ use std::{fs, path::Path, time::Instant}; /// Layout file a normalized root carries beside its proof. pub(super) const FIXED_ROOT_LAYOUT: &str = "fixed-root.json"; +/// Largest trace of the FRI stage's lowered verifier circuit. +const MAX_TRACE_HEIGHT: usize = 1 << 24; + +/// The FRI stage's verifier for roots of the aggregation backend `key` +/// normalized to `layout`, derived without a proof: `run` proves the same +/// circuit, lowered and committed under the same parameters. +pub(super) fn verifier( + key: &aiur::vk_codec::AiurVerifyingKey, + layout: &super::stages::FixedRoot, +) -> Result> { + super::stages::setup::fri( + layout.circuit(key, VerifierLimits::default())?, + config(key), + MAX_TRACE_HEIGHT, + ) +} + +fn config(key: &aiur::vk_codec::AiurVerifyingKey) -> GoldilocksBlake3Config { + GoldilocksBlake3Config::new(key.commitment_parameters(), key.fri_parameters()) +} /// Verify the root input in `dir`, build its FRI verifier circuit and check /// its constraints, writing the circuit's identity and cost to `out`. With @@ -91,9 +111,8 @@ pub(super) fn run( fs::write(out.join("statement-schema.txt"), plan.description())?; plan.prepare(&proof, &claims[0], options)? }; - let max_trace_height = 1usize << 24; let layout = - prepared.circuit().multi_stark_layout_with_max_height(max_trace_height)?; + prepared.circuit().multi_stark_layout_with_max_height(MAX_TRACE_HEIGHT)?; eprintln!( "Circuit preparation: {:?}; {:?}", start.elapsed(), @@ -144,8 +163,8 @@ pub(super) fn run( drop(verifier_key); let public = prepared.public_values().to_vec(); let start = Instant::now(); - let (verifier, outer_proof) = prepared - .prove_fri(GoldilocksBlake3Config::new(cp, fp), max_trace_height)?; + let (verifier, outer_proof) = + prepared.prove_fri(config(&native_key), MAX_TRACE_HEIGHT)?; eprintln!("FRI proving and verification: {:?}", start.elapsed()); let _output_span = tracing::info_span!("compression/write_output").entered(); let bytes = outer_proof.to_bytes()?; diff --git a/crates/proof-compression/src/pipeline/root_layout.rs b/crates/proof-compression/src/pipeline/root_layout.rs deleted file mode 100644 index d2c25a4c6..000000000 --- a/crates/proof-compression/src/pipeline/root_layout.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Fixed normalization bounds for the released aggregation backend. -use super::{FixedRoot, Result}; - -const RELEASE: &str = "ixvm-2026-10-29"; -const LAYOUT: &str = include_str!("../../root-layouts/ixvm-2026-10-29.json"); -const ROOT_VERIFIER_BLAKE3: &str = - "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc"; - -/// The release layout for an authenticated aggregation backend's serialized key. -/// Circuit order and fixed-table heights are meaningful only under that key. -pub fn release_root_layout(root_vk: &[u8]) -> Result<&'static str> { - if blake3::hash(root_vk).to_hex().as_str() != ROOT_VERIFIER_BLAKE3 { - return Err(format!( - "aggregation backend differs from the {RELEASE} root layout; a matching release layout is required" - ) - .into()); - } - FixedRoot::from_json(LAYOUT)?; - Ok(LAYOUT) -} diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index b78cb0e3d..fbbdc2231 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -53,11 +53,9 @@ struct IgnitionRelease { const IGNITION_RELEASE: IgnitionRelease = IgnitionRelease { name: "bn254-ignition-main-197", cache_file: "ix-prefix-26.bin", - cache_manifest_blake3: - "6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b", + cache_manifest_blake3: "6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b", admission_file: "admission.json", - admission_blake3: - "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", + admission_blake3: "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", }; impl IgnitionRelease { @@ -91,7 +89,8 @@ fn parameters_directory_from( mut lookup: impl FnMut(&str) -> Option, ) -> Option { lookup(PARAMETERS_DIR_VAR).map(PathBuf::from).or_else(|| { - lookup("HOME").map(|home| PathBuf::from(home).join(".ix/compression/params")) + lookup("HOME") + .map(|home| PathBuf::from(home).join(".ix/compression/params")) }) } diff --git a/crates/proof-compression/src/pipeline/stages/construct.rs b/crates/proof-compression/src/pipeline/stages/construct.rs index b77f35f08..892d6af9f 100644 --- a/crates/proof-compression/src/pipeline/stages/construct.rs +++ b/crates/proof-compression/src/pipeline/stages/construct.rs @@ -200,19 +200,25 @@ pub(in crate::pipeline) fn assign_foreign( Ok(witness.generate()?) } +/// The first KZG stage's lowered circuit for a FRI verifier circuit, with the +/// inputs that carry a source assignment into it. +pub(in crate::pipeline) fn first_circuit( + source: Circuit, + height: usize, +) -> Result<(MultiStarkCircuit, GoldilocksInputs)> { + let translated = translate(&source); + drop(source); + let compiled = lower_first(translated.circuit, height, TABLE_HEIGHT)?; + Ok((compiled, translated.inputs)) +} + pub(in crate::pipeline) fn prepare_first( prepared: Prepared, height: usize, - table_height: usize, ) -> Result { - let translated = translate(&prepared.circuit); - drop(prepared.circuit); - let compiled = lower_first(translated.circuit, height, table_height)?; - let assignment = assign_foreign( - &translated.inputs, - compiled.circuit(), - &prepared.assignment, - )?; + let (compiled, inputs) = first_circuit(prepared.circuit, height)?; + let assignment = + assign_foreign(&inputs, compiled.circuit(), &prepared.assignment)?; Ok(Lowered { compiled, assignment }) } diff --git a/crates/proof-compression/src/pipeline/stages/fixed.rs b/crates/proof-compression/src/pipeline/stages/fixed.rs index 4bdbd6d9e..ae6620fea 100644 --- a/crates/proof-compression/src/pipeline/stages/fixed.rs +++ b/crates/proof-compression/src/pipeline/stages/fixed.rs @@ -365,8 +365,13 @@ mod tests { assert!(FixedRoot::new(vec![4, 1, 8, 16], memories, 5).is_err()); } assert_eq!(system.memory_circuits(), fixed.memory_traces()); - assert_eq!(FixedRoot::from_json(&fixed.to_json())?.to_json(), fixed.to_json()); - assert!(FixedRoot::from_json(&fixed.to_json().replace("-v1", "-v0")).is_err()); + assert_eq!( + FixedRoot::from_json(&fixed.to_json())?.to_json(), + fixed.to_json() + ); + assert!( + FixedRoot::from_json(&fixed.to_json().replace("-v1", "-v0")).is_err() + ); let other = FixedRoot::new(vec![4, 2, 8, 16], vec![(1, 1)], 5)?; assert_ne!(fixed.identity(&key), other.identity(&key)); let config = || GoldilocksBlake3Config::new(cp, fp); diff --git a/crates/proof-compression/src/pipeline/stages/mod.rs b/crates/proof-compression/src/pipeline/stages/mod.rs index be8db6955..95a90ec2f 100644 --- a/crates/proof-compression/src/pipeline/stages/mod.rs +++ b/crates/proof-compression/src/pipeline/stages/mod.rs @@ -10,7 +10,7 @@ pub(in crate::pipeline) mod statement; use statement::Statement; mod fixed; pub(in crate::pipeline) mod root; -mod setup; +pub(in crate::pipeline) mod setup; #[cfg(test)] mod tests; pub(in crate::pipeline) mod verification; @@ -19,7 +19,6 @@ pub use verification::RecursiveVerifier; pub use fixed::FixedRoot; pub use root::prepare_root; -pub use setup::setup; pub use wrap::{RecursiveProof, RecursiveStage, prepare_recursive}; use crate::fri::{self, VerifierKey}; diff --git a/crates/proof-compression/src/pipeline/stages/setup.rs b/crates/proof-compression/src/pipeline/stages/setup.rs index ee529a790..ec7f46322 100644 --- a/crates/proof-compression/src/pipeline/stages/setup.rs +++ b/crates/proof-compression/src/pipeline/stages/setup.rs @@ -1,28 +1,11 @@ -//! Deterministic verifier construction. No proof or witness is an input. +//! Verifier construction without a proof or witness. use super::*; -use aiur::vk_codec::AiurVerifyingKey; +#[cfg(test)] use multi_stark::traits::TwoAdicField; -/// Derive a complete fixed compression verifier from locally trusted inputs. -/// This performs circuit construction and preprocessing commitments, but no -/// proof generation. The supplied SRS must have been authenticated/validated. -pub fn setup( - root_key: &AiurVerifyingKey, - root: &FixedRoot, - srs: Arc, - fri_config: GoldilocksBlake3Config, - fri_height: usize, - kzg_height: usize, - recursive_height: usize, -) -> Result { - let circuit = root.circuit(root_key, Default::default())?; - let fri = fri(circuit, fri_config, fri_height)?; - let circuit = foreign(&fri)?; - let kzg = kzg(circuit, srs.clone(), kzg_height)?; - wrap::setup_recursive(&kzg, srs, recursive_height) -} - -pub(super) fn fri( +/// The FRI stage's verifier for `circuit`, with the commitments +/// `Prepared::prove_fri` derives, built without a witness. +pub(in crate::pipeline) fn fri( circuit: Circuit, config: GoldilocksBlake3Config, height: usize, @@ -45,15 +28,7 @@ pub(super) fn fri( Ok(FriVerifier { key: VerifierKey::new(system), statement }) } -pub(super) fn foreign(verifier: &FriVerifier) -> Result> { - let vk = construct::fri_profile(&verifier.key, &verifier.statement.template)?; - let plan = - fri::VerifierPlan::validate(&verifier.key, vk, Default::default())?; - let built = - construct::FriConstruction::build(&plan, verifier.statement.schema())?; - Ok(construct::translate(&built.circuit).circuit) -} - +#[cfg(test)] pub(super) fn kzg( circuit: Circuit, srs: Arc, @@ -79,7 +54,8 @@ pub(super) fn kzg( kzg_lowered(compiled, config, public_count) } -pub(super) fn kzg_lowered( +#[cfg(test)] +fn kzg_lowered( compiled: multi_stark::plonkish::MultiStarkCircuit, config: KzgConfig, public_count: usize, diff --git a/crates/proof-compression/src/pipeline/stages/wrap.rs b/crates/proof-compression/src/pipeline/stages/wrap.rs index 562ef9053..08ac4e105 100644 --- a/crates/proof-compression/src/pipeline/stages/wrap.rs +++ b/crates/proof-compression/src/pipeline/stages/wrap.rs @@ -1,9 +1,8 @@ use super::verification::{Pairings, RecursiveVerifier}; use super::{KzgVerifier, Prepared, Result, scalar_statement}; use crate::kzg::{KzgConfig, Scalar, Srs}; -use crate::recursive::{native_verifier, pairing::PUBLIC_VALUES}; +use crate::recursive::native_verifier; use multi_stark::prover::Proof; -use multi_stark::traits::Algebra; use multi_stark::types::Val; use std::sync::Arc; @@ -102,38 +101,6 @@ impl RecursiveStage { } } -pub(super) fn setup_recursive( - verifier: &KzgVerifier, - srs: Arc, - height: usize, -) -> Result { - let claim_len = verifier.statement.locations.len(); - let claims = verifier.statement.claims(&vec![Scalar::ZERO; claim_len])?; - let template = verifier.codec.template(); - let built = build_recursive(verifier, &template, &claims)?; - let compiled = super::construct::lower_recursive(built.circuit, height)?; - let height = super::construct::max_height(&compiled)?; - if height > srs.max_len() { - return Err("recursive trace exceeds the SRS range".into()); - } - let config = KzgConfig::with_max_trace_len(srs, height, 2) - .with_transcript(crate::kzg::TranscriptKind::Blake3) - .with_streaming_lookups() - .with_streaming_quotient(); - let outer = - super::setup::kzg_lowered(compiled, config, claim_len + PUBLIC_VALUES)?; - Ok(RecursiveVerifier { - verifier: outer, - pairings: Pairings { - keys: built - .pairing_keys - .try_into() - .map_err(|_keys| "expected two pairing keys")?, - }, - claim_len, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/proof-compression/src/pipeline/terminal.rs b/crates/proof-compression/src/pipeline/terminal.rs index d4088a0d0..63b5184e1 100644 --- a/crates/proof-compression/src/pipeline/terminal.rs +++ b/crates/proof-compression/src/pipeline/terminal.rs @@ -2,14 +2,14 @@ use super::Result; #[cfg(any(feature = "plonk", feature = "groth16"))] use super::statement; -#[cfg(test)] +#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] use super::storage; use serde::{Deserialize, Serialize}; #[cfg(any(feature = "plonk", feature = "groth16"))] use sha2::{Digest, Sha256}; use std::{fs, io::Read, path::Path}; -#[cfg(test)] +#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] use std::path::PathBuf; // TODO: Decide between SHA-256 and BLAKE3 for artifact/cache metadata. @@ -67,9 +67,9 @@ impl TryFrom for TerminalOptions { } } -#[cfg(test)] +#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] pub(super) struct Scratch(pub(super) PathBuf); -#[cfg(test)] +#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] impl Scratch { pub(super) fn new() -> Result { let path = @@ -78,13 +78,14 @@ impl Scratch { Ok(Self(path)) } } -#[cfg(test)] +#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } +#[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; let mut output = String::with_capacity(bytes.len() * 2); diff --git a/crates/proof-compression/src/pipeline/verifying_keys.rs b/crates/proof-compression/src/pipeline/verifying_keys.rs new file mode 100644 index 000000000..322d47cd9 --- /dev/null +++ b/crates/proof-compression/src/pipeline/verifying_keys.rs @@ -0,0 +1,172 @@ +//! The verifying keys this build compresses to and verifies, one per proof +//! system, with the aggregation backend and fixed root layout they were derived +//! for and the Ignition admission that authenticates their setup. A proof +//! verifies only under these keys; there is no catalog of alternatives. +use super::{ + FixedRoot, Result, setup::SetupClass, statement, wrap::VerifyingKey, +}; +use crate::kzg::srs::ignition::IgnitionAdmission; + +/// Version of the bundled verifying keys and the layout they were derived for. +const VERSION: &str = "0.1"; + +/// Serialized aggregation backend key whose roots the layout normalizes. +const ROOT_VK_BLAKE3: &str = + "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc"; +const LAYOUT: &str = include_str!("../../verifying-keys/0.1/layout.json"); + +/// Backend and entrypoint words of the aggregation program, the statement +/// prefix every compressed claim of this release carries. +pub(super) const STATEMENT_PREFIX: [u64; 10] = [ + 0, 293, 3849420707, 4087085283, 470000540, 2154705852, 3686466056, + 4052325645, 2284798839, 2062413424, +]; + +const ADMISSION: &[u8] = + include_bytes!("../../verifying-keys/0.1/ignition-admission.json"); +const ADMISSION_BLAKE3: &str = + "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46"; + +const RECURSIVE_VK: &[u8] = + include_bytes!("../../verifying-keys/0.1/recursive-vk.bin"); +const RECURSIVE_VK_BLAKE3: &str = + "b4c58f798380d2fd91594763613874fdaf2d407631cee98fe361a287265d074a"; +/// The identifier a recursive KZG packet begins with. +pub(super) const RECURSIVE_VK_ID: &str = + "376163d7ec5afa644e393d0d1828b3fc46be2f80cc5e4d122c739e9b28129330"; + +#[cfg(any(feature = "plonk", test))] +pub(super) const PLONK_VK: &[u8] = + include_bytes!("../../verifying-keys/0.1/plonk-verifying-key.bin"); +/// SHA-256 of the gnark PLONK verifying key, which a PLONK packet begins with. +#[cfg(any(feature = "plonk", test))] +pub(super) const PLONK_VK_SHA256: &str = + "7123a80f9c56b25138ffe8ec47a53c6b0b22b64bf183380d63e5c80a7e3144a9"; + +/// SHA-256 of the exported recursive verifier description that both terminal +/// circuits prove. +pub(super) const TERMINAL_SCHEMA_SHA256: &str = + "d5d07c29ad8c3c795146fe728f73e30812a344c6b62a8f32888033b1a6aba4bf"; + +/// The release Groth16 key comes from a development setup: whoever ran it can +/// forge proofs, so it verifies only when development setups are accepted. +#[cfg(any(feature = "groth16", test))] +pub(super) const GROTH16_VK: &[u8] = include_bytes!( + "../../verifying-keys/0.1/groth16-development-verifying-key.bin" +); +/// SHA-256 of the gnark Groth16 verifying key, which a Groth16 packet begins with. +pub(super) const GROTH16_VK_SHA256: &str = + "954d1d7ad6418ca64fbf8c8ea9041abacf822f748912e08089833cd27f13ce6d"; +pub(super) const GROTH16_CIRCUIT_SHA256: &str = + "cf226140822f7579846ea188f74081a360da720e02525307d47b57006a774edb"; + +/// The setup class of the release Groth16 key, if `allow_development` admits it. +pub(super) fn groth16_setup(allow_development: bool) -> Result { + if !allow_development { + return Err("the release Groth16 key uses a development setup; pass --dev-srs to accept it (not production-secure)".into()); + } + Ok(SetupClass::Development) +} + +pub(super) fn decode(hex: &str) -> [u8; 32] { + let mut bytes = [0; 32]; + for (byte, pair) in bytes.iter_mut().zip(hex.as_bytes().chunks(2)) { + *byte = u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap(); + } + bytes +} + +/// The layout for an aggregation backend's serialized key. Circuit order and +/// fixed-table heights are meaningful only under the release's backend. +pub fn release_root_layout(root_vk: &[u8]) -> Result<&'static str> { + if blake3::hash(root_vk).to_hex().as_str() != ROOT_VK_BLAKE3 { + return Err(format!( + "aggregation backend differs from the one verifying keys {VERSION} were derived for" + ) + .into()); + } + FixedRoot::from_json(LAYOUT)?; + Ok(LAYOUT) +} + +/// The Ignition admission that authenticates the release keys' setup. +pub(super) fn admission() -> Result { + Ok(IgnitionAdmission::from_bytes(ADMISSION, decode(ADMISSION_BLAKE3))?) +} + +fn recursive_vk() -> Result { + if blake3::hash(RECURSIVE_VK).to_hex().as_str() != RECURSIVE_VK_BLAKE3 { + return Err("bundled recursive verifying key differs from its pin".into()); + } + let vk = VerifyingKey::from_bytes(RECURSIVE_VK)?; + if vk.id() != decode(RECURSIVE_VK_ID) { + return Err("bundled recursive verifying key identifier differs".into()); + } + vk.check_ignition_admission(&admission()?)?; + Ok(vk) +} + +/// Verify a recursive KZG packet of `claim` under the release key. +pub(super) fn verify_recursive( + packet: &[u8], + claim: &[u8], +) -> Result { + let id = packet.get(..32).ok_or("truncated compressed proof")?; + if id != decode(RECURSIVE_VK_ID) { + return Err(format!( + "compressed proof uses a verifying key other than the version {VERSION} key" + ) + .into()); + } + let statement = statement::with_claim(&STATEMENT_PREFIX, claim); + recursive_vk()?.verify(packet, &statement)?; + Ok(SetupClass::Ignition) +} + +/// Check a compression's statement belongs to the release when its recursive +/// key is the release key. +pub(super) fn check_statement( + id: &[u8], + words: &statement::Statement, +) -> Result<()> { + if id == decode(RECURSIVE_VK_ID) && words[..10] != STATEMENT_PREFIX { + return Err(format!( + "the version {VERSION} key compresses a different aggregation program than this build's backend" + ) + .into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + + #[test] + fn bundled_release_files_match_their_pins() { + assert_eq!(blake3::hash(ADMISSION).as_bytes(), &decode(ADMISSION_BLAKE3)); + recursive_vk().unwrap(); + assert_eq!(Sha256::digest(PLONK_VK).as_slice(), decode(PLONK_VK_SHA256)); + assert_eq!( + Sha256::digest(GROTH16_VK).as_slice(), + decode(GROTH16_VK_SHA256) + ); + assert!(groth16_setup(false).is_err()); + assert_eq!(groth16_setup(true).unwrap(), SetupClass::Development); + FixedRoot::from_json(LAYOUT).unwrap(); + statement::from_claims_bytes(&statement::to_claims_bytes( + &statement::with_claim(&STATEMENT_PREFIX, b""), + )) + .unwrap(); + } + + #[test] + fn other_keys_and_backends_are_rejected() { + let mut packet = vec![0; 64]; + assert!(verify_recursive(&packet, b"claim").is_err()); + packet[..32].copy_from_slice(&decode(RECURSIVE_VK_ID)); + assert!(verify_recursive(&packet, b"claim").is_err()); + assert!(release_root_layout(b"another backend").is_err()); + } +} diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index 07e574a92..ad1defa37 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -2,6 +2,8 @@ mod outer; mod saved; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) use outer::plonk_export; +#[cfg(feature = "plonk")] +pub(super) use outer::plonk_description; /// Recursively verify the first KZG stage in `first` and prove the recursive /// stage into `out`, with its circuit report beside it. @@ -15,3 +17,4 @@ pub(super) fn run( } pub(super) use outer::VerifyingKey; +pub(super) use saved::verifying_key; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index bb3f4fa88..93ec6478c 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -4,6 +4,8 @@ mod plonk_export; #[cfg(any(feature = "plonk", feature = "groth16"))] mod relation; +#[cfg(feature = "plonk")] +pub(in crate::pipeline) use plonk_export::describe as plonk_description; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(in crate::pipeline) use plonk_export::export as plonk_export; @@ -69,6 +71,57 @@ pub(crate) struct Frontend { } impl Frontend { + /// The setup this circuit is proved under. Ignition parameters must also + /// be the ones the inner pairing keys come from. + fn setup( + &self, + config: &super::super::SetupConfig, + ) -> storage::Result { + let setup = config + .for_stage(OUTER_SEED, self.compiled.main_height())? + .with_transcript(TranscriptKind::Blake3); + if let Some(admission) = setup.ignition_admission() + && decode_pairing_keys(&self.pairings)? != admission.pairing_keys() + { + return Err( + "recursive inner pairing anchors differ from Ignition admission".into(), + ); + } + Ok(setup) + } + + /// The verifying key proofs of this circuit verify under, derived from its + /// fixed setup alone; `prove` assembles the same key from the setup it + /// commits. + pub(crate) fn verifying_key( + &self, + config: &super::super::SetupConfig, + ) -> storage::Result { + let setup = self.setup(config)?; + let height = self.compiled.main_height(); + let parts = + prover::fixed_setup(&proving_config(&setup, height)?, &self.compiled)?; + let heights = parts + .iter() + .map(|(circuit, _)| circuit.preprocessed_height) + .collect::>(); + let setup_bytes = parts + .iter() + .map(|part| { + bincode::serde::encode_to_vec(part, bincode::config::standard()) + }) + .collect::, _>>()?; + VerifyingKey::new( + heights.try_into().map_err(|_heights| "expected two partitions")?, + setup_bytes.try_into().map_err(|_bytes| "expected two partitions")?, + bincode::serde::encode_to_vec( + &self.pairings, + bincode::config::standard(), + )?, + &setup, + ) + } + fn from_built(built: Built, vk: &[u8]) -> storage::Result { Self::new(built.circuit, &built.pairing_keys, vk, MAX_TRACE_LEN) } @@ -111,17 +164,7 @@ impl Frontend { } let start = std::time::Instant::now(); let height = self.compiled.main_height(); - let setup = config - .setup - .for_stage(OUTER_SEED, height)? - .with_transcript(TranscriptKind::Blake3); - if let Some(admission) = setup.ignition_admission() - && decode_pairing_keys(&self.pairings)? != admission.pairing_keys() - { - return Err( - "recursive inner pairing anchors differ from Ignition admission".into(), - ); - } + let setup = self.setup(&config.setup)?; let setup_id = setup.identity(height, 2)?; fs::create_dir_all(dir)?; setup.bind_stage(dir, height, 2)?; @@ -254,6 +297,19 @@ fn staged_publics( StageStatement::recursive(RECURSIVE_PUBLIC_VALUES).public_values(claims) } +/// The KZG configuration the recursive stage commits and proves under. +fn proving_config( + setup: &SetupSource, + height: usize, +) -> storage::Result { + Ok( + setup + .config(height, 2, false)? + .with_streaming_lookups() + .with_streaming_quotient(), + ) +} + /// Commit the fixed and witness traces from memory, prove, verify the /// packet and write it with the portable verifier. Fixed columns are /// checkpointed because every proof of the verifying key shares them through the @@ -307,27 +363,19 @@ fn prove( setup.check_binding(dir, height, 2)?; fs::write(out.join("SECURITY.txt"), setup.security_description())?; let setup_id = setup.identity(height, 2)?; - let verifier_params = setup.verifier_params(height)?; let config_started = Instant::now(); - let config = setup - .config(height, 2, false)? - .with_streaming_lookups() - .with_streaming_quotient(); + let config = proving_config(setup, height)?; let srs_config_load_seconds = config_started.elapsed().as_secs_f64(); eprintln!("Outer KZG parameters ready: {:?}", start.elapsed()); let prover::Proved { system, codec, bytes, .. } = lowered.commit(config, Some(&out), 0)?.prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); - let verifier = VerifyingKey { - version: OUTER_VK_VERSION, - heights: [manifest.heights[0], manifest.heights[1]], - setup: [ - fs::read(out.join("setup-0.bin"))?, - fs::read(out.join("setup-1.bin"))?, - ], - pairings: fs::read(dir.join("pairings.bin"))?, - parameters: verifier_params.to_bytes()?, - }; + let verifier = VerifyingKey::new( + [manifest.heights[0], manifest.heights[1]], + [fs::read(out.join("setup-0.bin"))?, fs::read(out.join("setup-1.bin"))?], + fs::read(dir.join("pairings.bin"))?, + setup, + )?; let vk = verifier.id(); let mut packet = vk.to_vec(); for word in statement { @@ -345,8 +393,7 @@ fn prove( verify_packet(&system, &codec, &packet, &vk, &keys, &statement) })?; let verify_seconds = verify_start.elapsed().as_secs_f64(); - let encoded = - bincode::serde::encode_to_vec(&verifier, bincode::config::standard())?; + let encoded = verifier.to_bytes()?; storage::write_atomic(&out.join("proof.compact.bin"), &bytes)?; storage::write_atomic(&dir.join("vk.bin"), &encoded)?; storage::write_atomic(&out.join("vk-id.bin"), &vk)?; @@ -410,6 +457,27 @@ fn decode(bytes: &[u8]) -> storage::Result { } impl VerifyingKey { + /// The key of a recursive circuit whose two partitions have `heights` and + /// encoded fixed setups `setup`, proved under `source`. + fn new( + heights: [usize; 2], + setup: [Vec; 2], + pairings: Vec, + source: &SetupSource, + ) -> storage::Result { + Ok(Self { + version: OUTER_VK_VERSION, + heights, + setup, + pairings, + parameters: source.verifier_params(heights[0])?.to_bytes()?, + }) + } + + pub(crate) fn to_bytes(&self) -> storage::Result> { + Ok(bincode::serde::encode_to_vec(self, bincode::config::standard())?) + } + pub(crate) fn from_bytes(bytes: &[u8]) -> PipelineResult { let vk: Self = decode(bytes)?; vk.prepare()?; diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs index 35845c4f9..8ae29cdbc 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs @@ -91,40 +91,27 @@ struct Export<'a> { diagnostic: Json, } -pub(super) fn write_export( - vk: &VerifyingKey, - packet: &[u8], - expected: &Statement, - directory: &Path, - name: &str, - valid_pairing: bool, -) -> Result<()> { +/// The recursive verifier system of `vk` and its partitions' log heights. +fn verifier_system(vk: &VerifyingKey) -> Result<(System, Vec)> { let system = vk.system()?; if system.config.transcript_kind() != TranscriptKind::Blake3 { return Err( "PLONK wrapping requires a BLAKE3 final KZG verifying key".into(), ); } - let (claims, offset) = packet_claims(packet, &vk.id(), 2, expected)?; let logs = vk .heights .iter() .map(|h| u8::try_from(h.ilog2())) .collect::, _>>()?; - let proof = - FixedProofCodec::new(&system, &logs)?.decode(&packet[offset..])?; - let diagnostic = diagnostics(&system, &proof, &claims)?; - let deferred_bytes = &packet[32 + 18 * 8..offset]; - if check_pairings(deferred_bytes, &vk.keys()?).is_ok() != valid_pairing { - return Err("unexpected deferred pairing result".into()); - } - // The zero-anchor row precedes the public prefix, claim digest and - // deferred pairing operands in the recursive statement. - let claim_count = RECURSIVE_PUBLIC_VALUES + 1; - if claims.len() != claim_count { - return Err("recursive packet claim count".into()); - } - let slots = (0..claim_count) + Ok((system, logs)) +} + +/// The claim slots of the recursive statement and the source of each public. +/// The zero-anchor row precedes the public prefix, claim digest and deferred +/// pairing operands. +fn recursive_statement() -> (Vec>, Vec) { + let slots = (0..=RECURSIVE_PUBLIC_VALUES) .map(|i| { vec![ ir::ClaimSlot::Constant(Scalar::from_u8(94)), @@ -139,6 +126,52 @@ pub(super) fn write_export( .chain((0..8).map(PublicSource::DigestWord)) .chain((0..PUBLIC_VALUES).map(PublicSource::DeferredHalf)) .collect(); + (slots, publics) +} + +/// The verifier description (`vk.json`) terminal circuits are compiled from: +/// it depends on the recursive key `vk` and the statement prefix `prefix` +/// its claims carry, never on a proof. +#[cfg(feature = "plonk")] +pub(in crate::pipeline) fn describe( + vk: &VerifyingKey, + prefix: &[u64], +) -> Result> { + let (system, logs) = verifier_system(vk)?; + let (slots, publics) = recursive_statement(); + let (description, _layout) = description( + &system, + &logs, + &slots, + &publics, + vk.id(), + &vk.keys()?, + prefix, + )?; + Ok(serde_json::to_vec_pretty(&description)?) +} + +pub(super) fn write_export( + vk: &VerifyingKey, + packet: &[u8], + expected: &Statement, + directory: &Path, + name: &str, + valid_pairing: bool, +) -> Result<()> { + let (system, logs) = verifier_system(vk)?; + let (claims, offset) = packet_claims(packet, &vk.id(), 2, expected)?; + let proof = + FixedProofCodec::new(&system, &logs)?.decode(&packet[offset..])?; + let diagnostic = diagnostics(&system, &proof, &claims)?; + let deferred_bytes = &packet[32 + 18 * 8..offset]; + if check_pairings(deferred_bytes, &vk.keys()?).is_ok() != valid_pairing { + return Err("unexpected deferred pairing result".into()); + } + if claims.len() != RECURSIVE_PUBLIC_VALUES + 1 { + return Err("recursive packet claim count".into()); + } + let (slots, publics) = recursive_statement(); write_vk( &Export { system: &system, @@ -181,61 +214,15 @@ fn write_vk( diagnostic["pointEncodings"] = json!(encodings); diagnostic }; - let layout = codegen::OpeningLayout::new_for_system(system, logs)?; - let program = ir::build(system, &layout, &export.slots)?; - let params = system.config.pcs().verifier_params(); - let fixed: Vec<_> = system - .preprocessed_commit - .as_ref() - .ok_or("missing fixed commitment")? - .0 - .iter() - .flatten() - .copied() - .map(point) - .collect(); - if fixed.len() != layout.fixed_count { - return Err("fixed commitment count differs from layout".into()); - } - let circuits: Vec<_> = system.circuits.iter().enumerate().map(|(i, c)| { - json!({ - "logDegree": logs[i], - "shape": [c.constraint_count(), c.max_constraint_degree(), 1usize << logs[i], - c.preprocessed_width, c.main_width, c.stage_2_width, c.lookup_group_size], - }) - }).collect(); - let matrices: Vec<_> = layout - .matrices - .iter() - .map(|m| { - json!({ - "width": m.width, "rows": m.rows, "pointOffset": m.point_offset, - "valueOffset": m.value_offset, - }) - }) - .collect(); - let exported = json!({ - "format": "ix-bn254-plonk-vk-v1", - "vkId": hex_json(&export.vk_id), - "setupId": hex_json(¶ms.public_setup.id), - "publicMaxDegree": params.public_setup.max_degree, - "prefix": &expected[..10], - "seed": hex_json(system.config.transcript_seed()), - "circuits": circuits, - "matrices": matrices, - "pointCount": layout.point_count, - "fieldCount": layout.field_count, - "witnessOffset": layout.commitment_count, - "openingCount": layout.factors.len(), - "accumulatorCount": layout.accumulator_count, - "claims": export.slots.iter().map(|row| row.iter().copied().map(slot_json).collect::>()).collect::>(), - "publics": export.publics.iter().copied().map(PublicSource::json).collect::>(), - "fixed": fixed, - "generator": point(params.g1[0]), - "outerKeys": [key(¶ms.g2), key(¶ms.tau_g2)], - "deferredKeys": export.deferred_keys.iter().map(key).collect::>(), - "program": program, - }); + let (exported, layout) = description( + system, + logs, + &export.slots, + &export.publics, + export.vk_id, + &export.deferred_keys, + &expected[..10], + )?; let mut values: Vec = proof.intermediate_accumulators[..layout.accumulator_count].to_vec(); for round in [ @@ -292,3 +279,71 @@ fn write_vk( )?; Ok(()) } + +/// The verifier description of `system` and the opening layout it follows. +fn description( + system: &System, + logs: &[u8], + slots: &[Vec], + publics: &[PublicSource], + vk_id: [u8; 32], + deferred_keys: &[G2Affine], + prefix: &[u64], +) -> Result<(Json, codegen::OpeningLayout)> { + let layout = codegen::OpeningLayout::new_for_system(system, logs)?; + let program = ir::build(system, &layout, slots)?; + let params = system.config.pcs().verifier_params(); + let fixed: Vec<_> = system + .preprocessed_commit + .as_ref() + .ok_or("missing fixed commitment")? + .0 + .iter() + .flatten() + .copied() + .map(point) + .collect(); + if fixed.len() != layout.fixed_count { + return Err("fixed commitment count differs from layout".into()); + } + let circuits: Vec<_> = system.circuits.iter().enumerate().map(|(i, c)| { + json!({ + "logDegree": logs[i], + "shape": [c.constraint_count(), c.max_constraint_degree(), 1usize << logs[i], + c.preprocessed_width, c.main_width, c.stage_2_width, c.lookup_group_size], + }) + }).collect(); + let matrices: Vec<_> = layout + .matrices + .iter() + .map(|m| { + json!({ + "width": m.width, "rows": m.rows, "pointOffset": m.point_offset, + "valueOffset": m.value_offset, + }) + }) + .collect(); + let exported = json!({ + "format": "ix-bn254-plonk-vk-v1", + "vkId": hex_json(&vk_id), + "setupId": hex_json(¶ms.public_setup.id), + "publicMaxDegree": params.public_setup.max_degree, + "prefix": prefix, + "seed": hex_json(system.config.transcript_seed()), + "circuits": circuits, + "matrices": matrices, + "pointCount": layout.point_count, + "fieldCount": layout.field_count, + "witnessOffset": layout.commitment_count, + "openingCount": layout.factors.len(), + "accumulatorCount": layout.accumulator_count, + "claims": slots.iter().map(|row| row.iter().copied().map(slot_json).collect::>()).collect::>(), + "publics": publics.iter().copied().map(PublicSource::json).collect::>(), + "fixed": fixed, + "generator": point(params.g1[0]), + "outerKeys": [key(¶ms.g2), key(¶ms.tau_g2)], + "deferredKeys": deferred_keys.iter().map(key).collect::>(), + "program": program, + }); + Ok((exported, layout)) +} diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index ffb550f7d..b9413a333 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -1,27 +1,71 @@ use super::super::{ - setup::{BINDING_FILE, FIRST_SEED, SetupClass}, + prover::FixedSetup, + setup::{BINDING_FILE, FIRST_SEED, SetupClass, SetupConfig, SetupSource}, statement, + storage::Manifest, }; use crate::{ - kzg::{KzgCommitment, KzgConfig, Scalar, compact::FixedProofCodec}, + kzg::{KzgConfig, Scalar, compact::FixedProofCodec}, recursive::native_verifier::{ClaimSchema, Plan}, }; #[cfg(test)] use multi_stark::traits::Field; -use multi_stark::{ - prover::Proof, - system::{Circuit, System}, -}; +use multi_stark::{prover::Proof, system::System}; use serde::de::DeserializeOwned; use std::{collections::BTreeMap, fs, path::Path, time::Instant}; type Result = std::result::Result>; -#[derive(serde::Deserialize)] -struct Manifest { - widths: Vec, - heights: Vec, - claims: Vec>, +/// Check a first stage's manifest has a supported shape and claim rows, and +/// return its largest partition height. +fn check_manifest(manifest: &Manifest) -> Result { + let count = manifest.heights.len(); + let height = + *manifest.heights.iter().max().ok_or("empty recursive verifying key")?; + // The partition count follows the outer FRI proof's layout; the trace + // height is the supported verifying key's. InnerVerifyingKey identity is bound separately + // through the verifying key id and the application's bundled verifier. + if ![1 << 24, 1 << 25].contains(&height) + || count < super::super::first::MIN_PARTITIONS + || manifest.widths.len() != count + || manifest.widths.contains(&0) + || manifest.heights.iter().any(|h| *h < 2 || !h.is_power_of_two()) + { + return Err("unsupported partition verifying key".into()); + } + let statement = statement::from_rows(&manifest.claims)?; + if manifest.claims != super::super::first::expected_claims(count, &statement) + { + return Err("claim rows differ from the statement and anchors".into()); + } + Ok(height) +} + +/// The recursive stage's verifying key for a first stage with `manifest`'s +/// shape and fixed setup `parts`, derived without a proof: `prove` builds the +/// same plan and circuit from a loaded first stage. +pub(in crate::pipeline) fn verifying_key( + manifest: &Manifest, + parts: Vec, + config: &SetupConfig, +) -> Result { + let height = check_manifest(manifest)?; + let setup = config.for_stage(FIRST_SEED, height)?; + let system = + inner_system(&setup, &manifest.widths, &manifest.heights, parts)?; + let plan = super::super::stages::construct::recursive_plan( + &system, + &logs(&manifest.heights), + claim_schema(&manifest.claims)?, + )?; + let (circuit, keys) = plan.circuit()?; + super::outer::Frontend::new( + circuit, + &keys, + &plan.identity(), + super::outer::MAX_TRACE_LEN, + )? + .verifying_key(config) } fn read_recorded( @@ -60,6 +104,33 @@ pub(super) fn claim_schema(claims: &[Vec]) -> Result { Ok(ClaimSchema::from_claims(claims, &public)?) } +/// The first stage's verifier system as the recursive stage verifies it: +/// verifier-only parameters at the largest partition height, and each +/// partition's fixed setup, checked against the staged dimensions. +pub(super) fn inner_system( + setup: &SetupSource, + widths: &[usize], + heights: &[usize], + parts: Vec, +) -> Result> { + let height = *heights.iter().max().ok_or("empty recursive verifying key")?; + if parts.len() != heights.len() + || widths.len() != heights.len() + || parts.iter().zip(widths.iter().zip(heights)).any( + |((circuit, _), (&width, &height))| { + circuit.preprocessed_height != height || circuit.main_width != width + }, + ) + { + return Err("inconsistent setup metadata".into()); + } + super::super::prover::fixed_system(setup.config(height, 2, true)?, parts) +} + +pub(super) fn logs(heights: &[usize]) -> Vec { + heights.iter().map(|h| u8::try_from(h.ilog2()).unwrap()).collect() +} + pub(super) struct Input { pub(super) system: System, pub(super) proof: Proof, @@ -94,25 +165,7 @@ impl Input { let mut input_blake3 = BTreeMap::new(); let manifest: Manifest = load(dir, "manifest.bin", &mut input_blake3)?; let count = manifest.heights.len(); - let height = - *manifest.heights.iter().max().ok_or("empty recursive verifying key")?; - // The partition count follows the outer FRI proof's layout; the trace - // height is the supported verifying key's. InnerVerifyingKey identity is bound separately - // through the verifying key id and the application's bundled verifier. - if ![1 << 24, 1 << 25].contains(&height) - || count < super::super::first::MIN_PARTITIONS - || manifest.widths.len() != count - || manifest.widths.contains(&0) - || manifest.heights.iter().any(|h| *h < 2 || !h.is_power_of_two()) - { - return Err("unsupported partition verifying key".into()); - } - let statement = statement::from_rows(&manifest.claims)?; - if manifest.claims - != super::super::first::expected_claims(count, &statement) - { - return Err("claim rows differ from the statement and anchors".into()); - } + let height = check_manifest(&manifest)?; eprintln!("Loading authenticated verifier parameters and saved proof"); let proof_dir = if dir.join("kzg").is_dir() { dir.join("kzg") @@ -125,33 +178,12 @@ impl Input { if read_recorded(dir, BINDING_FILE, &mut input_blake3)? != setup_id { return Err("setup binding changed while loading saved metadata".into()); } - let config = setup.config(height, 2, true)?; - let mut circuits = vec![]; - let mut commitment = KzgCommitment(vec![]); - for i in 0..count { - let (circuit, mut part): (Circuit, KzgCommitment) = - load(&proof_dir, &format!("setup-{i}.bin"), &mut input_blake3)?; - if circuit.preprocessed_height != manifest.heights[i] - || circuit.main_width != manifest.widths[i] - || circuit.preprocessed.is_some() - || part.0.len() != 1 - { - return Err("inconsistent setup metadata".into()); - } - circuits.push(circuit); - commitment.0.append(&mut part.0); - } - let system = System { - config, - circuits, - preprocessed_commit: Some(commitment), - preprocessed_indices: (0..count).map(Some).collect(), - }; - let logs: Vec<_> = manifest - .heights - .iter() - .map(|h| u8::try_from(h.ilog2()).unwrap()) - .collect(); + let parts = (0..count) + .map(|i| load(&proof_dir, &format!("setup-{i}.bin"), &mut input_blake3)) + .collect::>>()?; + let system = + inner_system(&setup, &manifest.widths, &manifest.heights, parts)?; + let logs = logs(&manifest.heights); let plan = super::super::stages::construct::recursive_plan( &system, &logs, diff --git a/crates/proof-compression/src/recursive/native_verifier/plan.rs b/crates/proof-compression/src/recursive/native_verifier/plan.rs index d0a6ab519..ef578bff4 100644 --- a/crates/proof-compression/src/recursive/native_verifier/plan.rs +++ b/crates/proof-compression/src/recursive/native_verifier/plan.rs @@ -259,6 +259,14 @@ impl<'a> Plan<'a> { }) } + /// The verifier circuit and its deferred pairing keys, without a request. + pub(crate) fn circuit( + &self, + ) -> Result<(Circuit, Vec), String> { + let Compiled { circuit, bindings } = self.compile()?; + Ok((circuit, bindings.pairing_keys)) + } + pub(crate) fn compile(&self) -> Result { let construction = construct(&self.vk, &self.shape, &self.claims, Builder::new())?; diff --git a/crates/proof-compression/tests/pipeline_root.rs b/crates/proof-compression/tests/pipeline_root.rs index c8a7641ef..0db6f36df 100644 --- a/crates/proof-compression/tests/pipeline_root.rs +++ b/crates/proof-compression/tests/pipeline_root.rs @@ -3,7 +3,7 @@ use aiur::vk_codec::{AiurVerifyingKey, to_bytes}; use ix_proof_compression::kzg::Srs; use ix_proof_compression::pipeline::{ FixedRoot, FriVerifier, KzgVerifier, RecursiveVerifier, prepare_kzg, - prepare_recursive, prepare_root, setup, + prepare_recursive, prepare_root, }; use multi_stark::batch::ShardInput; use multi_stark::expr::Expr; @@ -314,12 +314,6 @@ fn complete_compression_chain() -> Result<(), Box> { FriParameters { num_queries: 1, ..fp }, ) }; - eprintln!("deriving the local verifier without a proof"); - let local = - setup(&key, &fixed, srs.clone(), config(), 1 << 20, height, height)?; - let local_bytes = local.to_bytes()?; - drop(local); - eprintln!("local verifier ready; generating the root proof"); let expected: Vec<_> = (0..18).map(|i| Val::from_u32(71 + i)).collect(); let mut row = expected.clone(); row.push(Val::ONE); @@ -347,8 +341,7 @@ fn complete_compression_chain() -> Result<(), Box> { let recursive = prepare_recursive(&kzg, &proof, &expected)?; eprintln!("recursive verifier: {:?}", recursive.circuit().stats()); let (verifier, proof) = recursive.prove(srs, height)?; - assert_eq!(verifier.to_bytes()?, local_bytes); - let verifier = RecursiveVerifier::from_bytes(&local_bytes)?; + let verifier = RecursiveVerifier::from_bytes(&verifier.to_bytes()?)?; verifier.verify(&proof, &expected)?; let bytes = verifier.encode_proof(&proof)?; verifier.verify_bytes(&bytes, &expected)?; diff --git a/crates/proof-compression/verifying-keys/0.1/groth16-development-verifying-key.bin b/crates/proof-compression/verifying-keys/0.1/groth16-development-verifying-key.bin new file mode 100644 index 0000000000000000000000000000000000000000..8936898da4fbd6c14f2993d1be6ebc22069c5720 GIT binary patch literal 560 zcmV-00?++{jeusS9Z8Vg=AIF&k3^D^K>!yjm{6lWG7T_x4)~VC_=AmwQM%q2qgc!e zWD+G=_NZG8I>0$N+y+uC7$r@~AtK3R$WJKX1+IU{i<1iMh zUzr{vKR6vP%%0naXJ&FZB%XTUjvIe|%3-mQHG-y#dVQw=000E<^IIt{p3$}C97hP^ zceft&h7AYKY(Es-XfyM{2Bu?~<4fvY7007Z)G^GKx>`Mccb7(juF2rPsxkU3%9lnKND literal 0 HcmV?d00001 diff --git a/crates/proof-compression/verifying-keys/0.1/ignition-admission.json b/crates/proof-compression/verifying-keys/0.1/ignition-admission.json new file mode 100644 index 000000000..96e05ff34 --- /dev/null +++ b/crates/proof-compression/verifying-keys/0.1/ignition-admission.json @@ -0,0 +1,15 @@ +{ + "format": "ix-bn254-ignition-admission-v1", + "curve": "bn254", + "basis": "shifted-tau", + "source_manifest_json": "{\n \"format\": \"ix-bn254-ignition-source-v1\",\n \"ceremony\": \"MAIN IGNITION\",\n \"basis\": \"shifted-tau\",\n \"num_g1_points\": 100800000,\n \"points_per_transcript\": 5040000,\n \"contribution\": {\n \"position\": 197,\n \"address\": \"0xa57ec000b7fb9ea9b0119efdee0b8f15f895c2f3\",\n \"transcripts\": [\n {\n \"index\": 0,\n \"blake2b512\": \"f6d1c6684b15a00561a29188d1fbdc402658500380ce701b88e2b579f779f122fbafc3ec36127419aec04fc38d7d7d2d113efe1c31b6b6b50eef9ed685039220\"\n },\n {\n \"index\": 1,\n \"blake2b512\": \"8f60a31a3ad5547833496146cc00586a1bab93e090a03a21316e0e2fcc624002a35c4cc24ef4ab0bd6a8a1b0ddfc7296cb8f573bc9dd9959e3f13474dc44998d\"\n },\n {\n \"index\": 2,\n \"blake2b512\": \"5b7cf4a2578fa5fc8f9ba56b3c83aab597a4ed499201918f6cff61f07cc656af884c57b6a7ac59749cdb11093cba07bb52c495fc4fc5f7b3ff5073f8782ede03\"\n },\n {\n \"index\": 3,\n \"blake2b512\": \"9f08f66ae4aefe2449eaeef90ec774af11802c8104a6ee8e460ddaee248c362d4b903bb309265cf4f7a5169b69f991e3a791a84ede41511af5b3bbb9f18f7bd4\"\n },\n {\n \"index\": 4,\n \"blake2b512\": \"ac84b799256ac212396d774b30d1278e96c45a35d3ac73c95f4085784fe7d95067523fae4de59a134aa094b84e628ca6fea9a3f22a6cbdd9849ab05c7c4e02c1\"\n },\n {\n \"index\": 5,\n \"blake2b512\": \"7e75e6f69b9f4cc4a24906967c881135d283ac648cb909978c83b03b9045821cb95c1d3018138ce77800df56e9be0d9c08e7ba4e0abb9c8b0ed043b08869812f\"\n },\n {\n \"index\": 6,\n \"blake2b512\": \"7fae09c4a5e34a7d44c061140553bbe22611c46e504faa0ec679679360d84cf246d8b676d5841bde2e6fdddb8b48ae8daee1f21949f9ae4a7be5d049d9308b03\"\n },\n {\n \"index\": 7,\n \"blake2b512\": \"6be79b1c950c8c991cf56fd89cbdf47d74f7aedc2399f1868feed479fba49481505154a488ddb52acf8781de0281de5aaf1b578d10d411dd2c46a17c98553ec4\"\n },\n {\n \"index\": 8,\n \"blake2b512\": \"0d6dcc77d791e42c979101fd66af3057c422d58ef4368ac4951e5c8c0cb771b868cc126a0720c0fe78b190435e41fa266f310876de9a1736c80d1a402bf480fa\"\n },\n {\n \"index\": 9,\n \"blake2b512\": \"9c065eb494bb6a404abea40e5e1a1cf5abaace3a7f8007aacede7f50bfb0fbc87ae815c2e08eb830f8ccc31ac3f34cde3865787ef9c62fbf797585f5929cd465\"\n },\n {\n \"index\": 10,\n \"blake2b512\": \"8bf321ebfb0b3a83d2e969dca772f3bcdd9c46997c47f8d2272d149358b56c8d5492204ced6da726cb129de260f254627db8d2747f0dd57c61edf4752135fee6\"\n },\n {\n \"index\": 11,\n \"blake2b512\": \"a59696e2a820bf3c91df9681a1bc40a6362470b57121392b861f04ff17dc1d598b0b18df05ddba2b086e7bef45b826c47c93086a8fe0db2d23391545b62a9fde\"\n },\n {\n \"index\": 12,\n \"blake2b512\": \"287fc8164806b875fa727f71fc64cabaffebefc85bae5e7062541ef4f6e22594c63e3c7170d8743db686c4f6a589e136ed82dff9b56a424562fc140bc03dece1\"\n },\n {\n \"index\": 13,\n \"blake2b512\": \"c00b9c2307322018315468c743fb4d03d89405668ff9bb849e9ef2c3831969c5fee77df6985465363b026016144a7def4c1b81d036704a1908eaa5ead4f8b8ef\"\n },\n {\n \"index\": 14,\n \"blake2b512\": \"92fcdc9fb37978fb610075e53cef302ad06dd435c8e1145b06ac040202783c8a8e2d301c9bf436ec44244025109d332aeaed2dfe18b448b830ada9275f595a8f\"\n },\n {\n \"index\": 15,\n \"blake2b512\": \"f78e9baeaccea601a78ced5bf92bbc641e49db1ae8a2b05e9cd8bc9bfe1db9cae98d9045ec7eda06554bac26ad649fd03365ac9b6ca13c155299f920cb3c2dff\"\n },\n {\n \"index\": 16,\n \"blake2b512\": \"9f606fa240ff8016b4c94414fcc85114f55537eaa030d6d81758e687cb1b1cf2ecc72cfafd712f7c2349167df7257547699595c9a7ecb95f32f4271f3453bc8f\"\n },\n {\n \"index\": 17,\n \"blake2b512\": \"0a9c105133e5a519a2aa5946796079881578c9b976abc820f7d72b0de984209c1735c48615580fe7d1cf7717da585f54dcf4642daec96f70614e23094cdb5c3b\"\n },\n {\n \"index\": 18,\n \"blake2b512\": \"b929ce9111a26606e8bb178d8d3652c3d7ae359f39aa77b14659bbd4b0321d284de13e41a549a905e6aa7756320bb101626154cc61e97bd15c3419b93e5f0f7f\"\n },\n {\n \"index\": 19,\n \"blake2b512\": \"15b41e0e6bd6720c7ae1d6e2f9cd0c40aa92e861ba66847cb53e0a0c4d46c90444a5fa21d28c16883f11dd4f0bc064957b84c940a95ad9074fc49e0dba164e27\"\n }\n ]\n },\n \"predecessor\": {\n \"position\": 196,\n \"address\": \"0x9c3f372a2aacc0d7272f56a4664311b7647b0031\",\n \"transcripts\": [\n {\n \"index\": 0,\n \"blake2b512\": \"d04b32d468c7e0a6cab0427158e3029ec24b9a9282f0ab625af0ba858be70014f3e531a4ce70a17196bd2b542ea9c3614282e19daa39015430d631b908240837\"\n },\n {\n \"index\": 1,\n \"blake2b512\": \"7c1620547a1c6ca4615e3b7136195f39e00dbc005c71de719bac5687330e6cee052a5ade81984f9e66415ff2c56ce320f06580732715b953f9495f737bc79032\"\n },\n {\n \"index\": 2,\n \"blake2b512\": \"1d7eea228a6932522a764b0d5a3ef0f1ae0ae5eb77303f29530f97d768fb738cd7aea55ee3344779230a7788aabfd89b9e733dd9a07922d6552d69d1a4c09e47\"\n },\n {\n \"index\": 3,\n \"blake2b512\": \"0d4df161fb6d2b44ef7ab8b653b2c1be05fac1c8b7e782e19b1ae7b00bcde65aa1d50247d6b45baa03a516adf2395d0dac21233ddaee65f156ea2e77b21424e3\"\n },\n {\n \"index\": 4,\n \"blake2b512\": \"c5d597835e04830d2b99bc35f83159075b56f5c4484b5e6628f2de80687e5f7d1dbc8732f1cf0b1089aafd24d67e1a0e29ecd029a3d0fcb7bfe1799281303626\"\n },\n {\n \"index\": 5,\n \"blake2b512\": \"f2d808de7a73983807b3c29a049731d2089e705af38120ec50a6dd084ced4481e0f9b4390f8ecf95f68ba05efd17c7ba34d2c76f2589b98c13b7de6f330c6f08\"\n },\n {\n \"index\": 6,\n \"blake2b512\": \"549591ad23c3a76836a087210ae59d41910a66019931f7abad0da5e414dfbf7e9e7aac7d5dc326a40a79349c67a40df24ed0f5e6cc169c39b08ae15890d31fcd\"\n },\n {\n \"index\": 7,\n \"blake2b512\": \"64ba6db639fa230e3633b9757342ce7708b5d9a4dd0b2ec01f00d8c3c0ef31875ce72f58e1f029791bba6bb22bffacca6507d55f291a7c866f0e63d23acc694c\"\n },\n {\n \"index\": 8,\n \"blake2b512\": \"c16ca43cb959ece5633efb272b7935354616aae04c395d1e4dd31afa918efd09d6c9d2d4eaae21d5d854d4295690d16703f3285f471673aca03e77c0cfc38264\"\n },\n {\n \"index\": 9,\n \"blake2b512\": \"c7afc7a3c00c7cd6b5fbc840d03071d7a0bc7bb023165249d680ca7b5404da02a288725c5958b425355c734d5c0912ec627f78c372d11a821ffba3eb75f248d9\"\n },\n {\n \"index\": 10,\n \"blake2b512\": \"8a79bf43e5b74b64b3915fb4803975d307b9c43509c68e049b4152ac1d1a6cf66105c2a733e045fbe7523e6c9cedeb909483d94943f0ce2f64478bac5d836c50\"\n },\n {\n \"index\": 11,\n \"blake2b512\": \"f2620927bc74a730c789223797a146fb74715218cb3a5789a53452ea77445a8e57efb121d5a9186d38d3c3a0ea6df028b4b5544e2359f24de541c409ae004437\"\n },\n {\n \"index\": 12,\n \"blake2b512\": \"162be8140b183c4173f3975bca2ace61fac3deec35387aea375ad88c3b4bb4f50278c7d67830a4fe59c69bcb020d17ea843063648a557595b699b5bb90e78ac4\"\n },\n {\n \"index\": 13,\n \"blake2b512\": \"7bfe0c7bfe09b6be6f69009f6ade7f0681fe930e12f366d0815c8da72afd80648bdcdec205d8db07ffe6201a630f6617a05fda76a4012a53b27393e946b7912a\"\n },\n {\n \"index\": 14,\n \"blake2b512\": \"379d31bcd33066c7882c779ce681507a0557a2c2af2dcabcf7690f35b118c0cd3c78e3719ead78097a8e33eeedd288cc2fd7d34178a26d993a399e8c4fec672c\"\n },\n {\n \"index\": 15,\n \"blake2b512\": \"6cd6c0f637b2eab63b8561df5e2b788f718dee6e72988fa58b85dfdab258313b10786015a626cfdc2022d1ea655b9ce97b807a1244c8d7f48625a9175d8d1f71\"\n },\n {\n \"index\": 16,\n \"blake2b512\": \"d53af0d3a4a72c7684fc765a63f294bf5a702b09b98a946865074ef1fe515122cb84b9274a4123f79c4f1febfed0fb37e56449b76323546ff82074a76f9b4c24\"\n },\n {\n \"index\": 17,\n \"blake2b512\": \"ca28272c56eef7ca0207cfee6d7480d3116e329434eb9763781d47fe3ee98b25046b1d8048178c3a9f899c0fb559ac920a20554bc22a68d90aace23b8b13ad1b\"\n },\n {\n \"index\": 18,\n \"blake2b512\": \"06046030fac8f719d9faa027baf2871df744015b742098787e51f716cbe866187eabf9edb60cee76897f7132e4029aaf7b7849dec13fbebda2a8e99a318bad27\"\n },\n {\n \"index\": 19,\n \"blake2b512\": \"26a264a78adc010f54f0b8a6321e389472922c91dd7e62938ece68576d18a8f26870aa1d8552a261034748dfe1c9c6b053723a5dc0f50d754edf611fa29226de\"\n }\n ]\n }\n}\n", + "source_manifest_blake3": "7f89c8c8c9dd94178599071752162d9e90ca0445dbd437c931e989a5cc43a17b", + "setup_id": "07f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b", + "public_max_degree": 100799999, + "g1": [ + "3bb0c91a284ca49f0e31d84e308b6399ab4f55639b05d574e6f7e7b593bea41f21b789c3e63737fb8ff4a5791e24f36011d31fe7a5b53c9de32862ac7fae011a", + "098493b799904a0b1919123a0c9434d2c133c78e655f1588a6a8c2faf1eb681f8c97139678ee78bebd56d1dbfe61a0b459fc2e3aa0f06f55ca74f0604a87fe20" + ], + "g2": "edf692d95cbdde46ddda5ef7d422436779445c5e66006a42761e1f12efde0018c212f3aeb785e49712e7a9353349aaf1255dfb31b7bf60723a480d9293938e19aa7dfa6601cce64c7bd3430c69e7d1e38f40cb8d8071ab4aeb6d8cdba55ec8125b9722d1dcdaac55f38eb37033314bbc95330c69ad999eec75f05f58d0890609", + "tau_g2": "cbe108231cc23c1041b9f6f3c615a5ae9e425217ae4231d70eedc201ad4f342a484a95db8d70c3dc4f502412ebf3f9cde76558f30a43c2608750c403bbacf122b328d48c25f84c030d3f29c02d53d1baacc2877520c2cb3c0d8d6f7bac30fd0ef8c34548964e460bd8733419e542714c0d0181f96832aa49849cba42b8159f15" +} diff --git a/crates/proof-compression/root-layouts/ixvm-2026-10-29.json b/crates/proof-compression/verifying-keys/0.1/layout.json similarity index 100% rename from crates/proof-compression/root-layouts/ixvm-2026-10-29.json rename to crates/proof-compression/verifying-keys/0.1/layout.json diff --git a/crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin b/crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin new file mode 100644 index 0000000000000000000000000000000000000000..6e7546747d6ad840a37683b2a2c05af771d2605f GIT binary patch literal 34384 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 literal 0 HcmV?d00001 diff --git a/crates/proof-compression/verifying-keys/0.1/recursive-vk.bin b/crates/proof-compression/verifying-keys/0.1/recursive-vk.bin new file mode 100644 index 0000000000000000000000000000000000000000..5d075d40a0726ac7683c314ed4917644bd3d2c5d GIT binary patch literal 1542 zcmZSo!@$760wfd|erGT%F)#wTctIRqSt2BXHn1{6t@$wd>qF;>1v@J}UKne{Ml?2V za}C&`xxnyYkza}dPz?tVvjG)@)N=wcJCMNyq`82Yo0Atv@&GX}rvL*J0h@u!_<)#! zgGGdcLlnqj0!cO&5SyI?#O4Hxb8&;%JluRNj12rNf^3XJ!YmxzBFxMT{NP~cU}0fm zW&ZsEzr`}7`&IY$y+KVUG=f@$7#TP>u1xihocZXC zQ0Tex*Gd^rZBK65TBQ9oKJbxOYI^Ss4V!nKEt!jgKJlvPJ+gkehGV|bgE?u9;c6!g zR(?Iu>>B0aTI%@WgJ;c#o$qFtw_ge* zv$_~+vBTczZ0kecV76?gJ=#sK`~L{fY?JiB>V%Pr5EO9HOg2|~WtV+E<989RtV~|vd*Y(!B3ciHxO896=an;7 zdvp9)1nXJW{8dkW#MwNWr+nq3<8-TAL3_m#8`mxuDELv@x$tVO#5cy-`YzVHl){?@ z)?7b(q``o*Sa@&hl6jS9y^o?6md*iBPaqQ>kwm%b<3OBdO&wcf@SoU~Elj!b@lbe)}_jQHNXFT7} zapOU0Yh7o+sSlPS9TT-$IX=1_{1h3+(b)m>z@O zSx#kg@yzn1&sUlnd#?JZ8vEOD`~HL?D-YgDlPC8{o_@%oEOW?4 zz;Wld&&NcUuAAo+B)-nc@H*eyLyT+vO|(3`rrz!?IDE%HKt<^F=bvYvr$&6{az2#M z9&m(t_nMDNKNuPOoRCB3zbF$Mh|m6mm-kTL@wT9uFYaugx?lXO?UTsMqK^5OkH)`s zkGaWhz2T&khR>4ue1ZGM}?$^7fe%ytpMLg{*j{ bm|Qw&e7r9;UbJJ`ibKCXzRs9G&>jZ>J^%X? literal 0 HcmV?d00001 diff --git a/flake.nix b/flake.nix index e2b174d16..256c96a75 100644 --- a/flake.nix +++ b/flake.nix @@ -156,7 +156,7 @@ # binary fixtures that Cargo's source filter does not retain. compressionData = pkgs.lib.fileset.unions [ ./crates/proof-compression/tests/fixtures - ./crates/proof-compression/root-layouts + ./crates/proof-compression/verifying-keys ]; # The Rust tests `include_bytes!` the `.ixe` files under # `Tests/Fixtures`: environments serialized by Lean, read back to check diff --git a/lakefile.lean b/lakefile.lean index 69aa8e24e..fe16cd377 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -196,10 +196,10 @@ def rustSources (pkg : Package) : SpawnM (Job Unit) := do ← inputTextFile (pkg.dir / "Cargo.lock"), ← inputTextFile (pkg.dir / ".cargo/config.toml") ] - let layouts ← inputDir (pkg.dir / "crates/proof-compression/root-layouts") true fun path => - path.extension == some "json" + let verifyingKeys ← inputDir (pkg.dir / "crates/proof-compression/verifying-keys") false fun path => + path.extension == some "json" || path.extension == some "bin" let sources := sources.zipWith (fun _ _ => ()) fixtures - let sources := sources.zipWith (fun _ _ => ()) layouts + let sources := sources.zipWith (fun _ _ => ()) verifyingKeys return sources.zipWith (fun _ _ => ()) manifests /-- Cargo's archive or executable and its native library are one build output. diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index fdba876df..e0fafe2a0 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -77,10 +77,10 @@ domain and monomial receipt. Go checks the sizes and digests of decoded native artifacts and rejects a witness whose claim differs from the independent digest supplied by Rust. Retaining a prover avoids loading its circuit and keys again. -`IX_PLONK_VERIFIER_CATALOG` selects an independently authorized catalog. A proof's -candidate catalog is not automatically trusted. Entries bind the verifying-key -SHA-256, inner verifier identity, program prefix, circuit hash, and Ignition -admission. Verification requires neither a proving SRS nor a GPU. +Verification uses the bundled PLONK verifying key and Ignition admission +(`crates/proof-compression/verifying-keys/`); a proof whose key identifier +names any other key is rejected. Verification requires neither a proving SRS +nor a GPU. Release tooling is library code with no CLI: `pipeline::plonk::export` writes the verifier description and witness for a recursive KZG packet, and @@ -168,33 +168,13 @@ Ignition parameters for the inner KZG proof; local Groth16 setup does not change the inner setup policy. Compilation writes a fresh circuit into an empty artifact directory. -The CLI uses prepared keys and an independently approved catalog: - -```json -{ - "format": "ix-bn254-groth16-verifiers-v1", - "keys": [{ - "sha256": "SHA256_OF_GROTH16_VERIFYING_KEY", - "path": "groth16-keys/verifying-key.bin", - "inner_vk_id": "VK_ID_FROM_EXPORTED_vkId", - "inner_schema_sha256": "SHA256_OF_EXPORTED_vk.json", - "statement_prefix": [0, 293, 0, 0, 0, 0, 0, 0, 0, 0], - "circuit_sha256": "SHA256_OF_circuit.bin", - "setup": "development" - }] -} -``` - -The hashes above are placeholders. Copy the complete ten-word `prefix` from -the exported verifier description; the example prefix is not an approved -program. `inner_vk_id` is the description's `vkId`, which differs from the -BLAKE3 digest of the serialized verifier file. Relative key paths resolve against -the catalog directory. A proof or generated manifest never authorizes its own -verifying key. +The prepared artifacts must be the bundled circuit and key: `IX_GROTH16_ARTIFACTS` +names a directory whose verifying key, circuit and verifier description match +the bundled pins. The bundled key comes from a development setup, so proving +needs `--dev-groth16` and verifying needs `--dev-srs`: ```sh export IX_GROTH16_ARTIFACTS=/path/to/groth16-keys -export IX_GROTH16_VERIFIER_CATALOG=/path/to/approved-catalog.json ix compress ROOT --groth16 --dev-groth16 ix verify COMPRESSED_PROOF --dev-srs ``` diff --git a/tools/plonk-wrapper/groth16.go b/tools/plonk-wrapper/groth16.go index cdb3316ad..fcb8b9d94 100644 --- a/tools/plonk-wrapper/groth16.go +++ b/tools/plonk-wrapper/groth16.go @@ -134,6 +134,9 @@ func groth16Load(dir string, m *groth16Manifest) (*verifyingKey, constraint.Cons return key, ccs, nil } +// TODO: Evaluate commitment-free arithmetic and range-check gadgets for Groth16. +// The commitment extension adds a pairing check and 128 Solidity proof bytes; +// a replacement must preserve the soundness of the deferred arithmetic checks. func compileGroth16(schema []byte, path string) (circuitInfo, error) { var info circuitInfo key, err := decodeVerifyingKey(schema) From c6c6cb95f2774df1e6119f57e8dae17da03665ec Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:58:47 +0000 Subject: [PATCH 25/31] refactor(compression): reuse stages by their proofs and clean up - Reuse a checkpointed stage only when its proof verifies; drop the completion markers and --log-height. - Rename the terminal stage to final. - Key the PLONK circuit cache on the gnark source digest. - Share the CUDA build helper between aiur and proof-compression. - Move the analysis scripts to tools/compression and drop unused examples and fixtures. --- Ix/Aggr/FFI.lean | 6 +- Ix/Cli/CompressCmd.lean | 65 +- Ix/Cli/Compression.lean | 37 +- Ix/Cli/CompressionCmd.lean | 14 +- Ix/Cli/ProveCmd.lean | 38 +- Ix/Cli/VerifyCmd.lean | 33 +- Tests/AggrActivation.lean | 6 +- Tests/CompressionCli.lean | 56 +- .../groth16/test/IxGroth16Verifier.t.sol | 23 +- .../groth16/testdata/committed/Verifier.sol | 712 ------------------ .../groth16/testdata/committed/proof.json | 6 - crates/aiur/Cargo.toml | 2 + crates/aiur/build.rs | 113 +-- crates/aiur/src/synthesis.rs | 2 +- crates/build_support/cuda.rs | 111 +++ crates/ffi/Cargo.toml | 2 +- crates/ffi/src/aiur/aggregate.rs | 4 +- crates/ffi/src/aiur/aggregate/ffi.rs | 2 + crates/ffi/src/aiur/aggregate/lanes.rs | 62 +- crates/ffi/src/aiur/aggregate/normalize.rs | 3 +- crates/ffi/src/aiur/aggregate/prove.rs | 7 +- crates/ffi/src/aiur/aggregate/scheduler.rs | 9 +- crates/ffi/src/compression.rs | 64 +- crates/proof-compression/Cargo.toml | 1 + crates/proof-compression/README.md | 162 ++-- crates/proof-compression/build.rs | 162 +--- crates/proof-compression/build/gnark.rs | 49 +- .../proof-compression/examples/fri_sizing.rs | 9 + .../proof-compression/examples/kzg_dev_srs.rs | 40 - .../examples/kzg_stage_resources.rs | 133 ---- .../examples/recursive_size.rs | 173 ----- crates/proof-compression/src/fri/key_codec.rs | 18 + .../src/pipeline/compress.rs | 409 +++++----- .../proof-compression/src/pipeline/derive.rs | 19 +- .../src/pipeline/development.rs | 339 +++++++++ .../pipeline/{terminal.rs => final_proof.rs} | 114 ++- .../proof-compression/src/pipeline/gnark.rs | 43 +- .../proof-compression/src/pipeline/groth16.rs | 223 ++++-- crates/proof-compression/src/pipeline/mod.rs | 38 +- .../proof-compression/src/pipeline/plonk.rs | 333 +++++--- .../proof-compression/src/pipeline/setup.rs | 4 +- .../proof-compression/src/pipeline/storage.rs | 63 ++ .../src/pipeline/verifying_keys.rs | 76 +- .../src/pipeline/wrap/mod.rs | 22 +- .../src/pipeline/wrap/outer.rs | 23 +- .../{plonk_export.rs => final_export.rs} | 14 +- .../{plonk_export => final_export}/ir.rs | 0 .../tests/fixtures/{gnark => plonk}/proof.bin | Bin .../fixtures/{gnark => plonk}/provenance.json | 0 .../{gnark => plonk}/verifying-key.bin | Bin docs/aiur-trace-sharding.md | 8 +- docs/compression-stage-collapse.md | 664 +--------------- docs/ffi.md | 4 +- docs/proof-benchmark-comparison.md | 131 +--- lakefile.lean | 10 +- tests/check_compression_build.py | 56 +- tests/compression_build_config.lean | 7 +- .../compression}/fri_security_estimate.py | 63 +- {tests => tools/compression}/run_gpu_stage.py | 0 tools/plonk-wrapper/README.md | 169 ++--- tools/plonk-wrapper/artifacts.go | 39 +- tools/plonk-wrapper/backend.go | 47 +- tools/plonk-wrapper/backend_bn254_test.go | 79 +- tools/plonk-wrapper/backend_cpu.go | 2 +- tools/plonk-wrapper/backend_cuda_bn254.go | 2 +- .../plonk-wrapper/backend_cuda_bn254_test.go | 8 +- tools/plonk-wrapper/backend_no_cuda_test.go | 2 +- tools/plonk-wrapper/backend_selection_test.go | 2 + tools/plonk-wrapper/cuda_init_bn254_test.go | 405 ---------- tools/plonk-wrapper/curve_bn254.go | 11 - tools/plonk-wrapper/ffi.go | 110 +-- tools/plonk-wrapper/ffi.h | 2 +- tools/plonk-wrapper/ffi_groth16.go | 28 +- tools/plonk-wrapper/ffi_plonk.go | 75 ++ tools/plonk-wrapper/final_compare_test.go | 176 +++++ tools/plonk-wrapper/groth16.go | 19 +- tools/plonk-wrapper/groth16_cuda.go | 5 +- tools/plonk-wrapper/groth16_cuda_test.go | 33 +- tools/plonk-wrapper/groth16_no_cuda.go | 2 +- tools/plonk-wrapper/groth16_setup.go | 5 +- tools/plonk-wrapper/groth16_setup_test.go | 2 + tools/plonk-wrapper/groth16_test.go | 22 + tools/plonk-wrapper/parameters.go | 2 + tools/plonk-wrapper/parameters_bn254_test.go | 8 +- tools/plonk-wrapper/profile.go | 16 + tools/plonk-wrapper/prover.go | 43 +- tools/plonk-wrapper/terminal_compare_test.go | 337 --------- tools/plonk-wrapper/test_helpers_test.go | 57 +- ...-comparison.json => final-comparison.json} | 4 +- 89 files changed, 2503 insertions(+), 3996 deletions(-) delete mode 100644 contracts/groth16/testdata/committed/Verifier.sol delete mode 100644 contracts/groth16/testdata/committed/proof.json create mode 100644 crates/build_support/cuda.rs delete mode 100644 crates/proof-compression/examples/kzg_dev_srs.rs delete mode 100644 crates/proof-compression/examples/kzg_stage_resources.rs delete mode 100644 crates/proof-compression/examples/recursive_size.rs create mode 100644 crates/proof-compression/src/pipeline/development.rs rename crates/proof-compression/src/pipeline/{terminal.rs => final_proof.rs} (55%) rename crates/proof-compression/src/pipeline/wrap/outer/{plonk_export.rs => final_export.rs} (96%) rename crates/proof-compression/src/pipeline/wrap/outer/{plonk_export => final_export}/ir.rs (100%) rename crates/proof-compression/tests/fixtures/{gnark => plonk}/proof.bin (100%) rename crates/proof-compression/tests/fixtures/{gnark => plonk}/provenance.json (100%) rename crates/proof-compression/tests/fixtures/{gnark => plonk}/verifying-key.bin (100%) rename {tests => tools/compression}/fri_security_estimate.py (78%) rename {tests => tools/compression}/run_gpu_stage.py (100%) delete mode 100644 tools/plonk-wrapper/cuda_init_bn254_test.go create mode 100644 tools/plonk-wrapper/ffi_plonk.go create mode 100644 tools/plonk-wrapper/final_compare_test.go delete mode 100644 tools/plonk-wrapper/terminal_compare_test.go rename tools/plonk-wrapper/testdata/{terminal-comparison.json => final-comparison.json} (94%) diff --git a/Ix/Aggr/FFI.lean b/Ix/Aggr/FFI.lean index 60d50bdd5..95c28f923 100644 --- a/Ix/Aggr/FFI.lean +++ b/Ix/Aggr/FFI.lean @@ -84,7 +84,7 @@ opaque aggregateStage2 (ixvmSystem aggrSystem : @& AiurSystem) (directJoins planOnly : Bool) (cacheFriBytes : @& ByteArray) (useCache writeOutputs traceShards : Bool) (rangeWidth : @& Nat) (wrapRoot : Bool) (execAhead : @& Nat) (verifyOnly : Bool) - (subtreeCode : @& Nat) : + (subtreeCode : @& Nat) (fresh : Bool := false) : Except String String /-- Reconstruct and audit the manifest-relative aggregate root entirely in @@ -118,12 +118,14 @@ budget (0 detects); `execJobs` the claim executions ahead of each prover; `lanes` the GPU count (0 uses every visible GPU). Oversized environment claims are bisected and checkpointed; completed proofs are reused. A nonempty `outManifest` receives the final partition. +`fresh` bypasses earlier proof indexes and refinement checkpoints while +publishing newly verified results to the usual cache locations. Returns the verified root address; progress is printed on stderr. -/ @[extern "rs_aiur_prove_lanes"] opaque proveLanes (ixvmSystem aggrSystem : @& AiurSystem) (envHandle : @& EnvHandle) (manifestPath : @& String) (verifyIdx aggrIdx lanes maxRamBytes execJobs structuralAbove : @& Nat) - (cacheFriBytes : @& ByteArray) (outManifest : @& String) : Except String String + (cacheFriBytes : @& ByteArray) (outManifest : @& String) (fresh : Bool := false) : Except String String end AiurSystem end Aiur diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index e368c395f..a4c34f099 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -10,36 +10,33 @@ namespace Ix.Cli.CompressCmd /-- Selected compression output: the proof system after recursive KZG and the setup policy. -/ structure Options where - terminal : UInt8 := 0 - devSrs : Bool := false - logHeight : Nat := 24 + finalProof : UInt8 := 0 + devKzgSrs : Bool := false + devFinalSrs : Bool := false out : Option String := none rootVk : Option String := none fixedRoot : Option String := none + fresh : Bool := false -/-- `kzg`, `plonk` or `groth16` (`dev-groth16` allows development Groth16 keys). -/ -def terminalOf : String → Except String UInt8 +/-- Select the proof system independently of its setup policy. -/ +def finalOf : String → Except String UInt8 | "kzg" => .ok 0 | "plonk" => .ok 1 | "groth16" => .ok 2 - | "dev-groth16" => .ok 3 | other => .error s!"unknown compression output {other}: expected kzg, plonk or groth16" /-- Check what can be checked before proving: the setup is configured and authenticates, and the options are consistent. Returns the setup description. -/ def preflight (opts : Options) : IO String := do - unless opts.logHeight == 24 || opts.logHeight == 25 do - throw <| IO.userError "--log-height must be 24 or 25" if let some path := opts.fixedRoot then unless ← System.FilePath.pathExists path do throw <| IO.userError s!"fixed root layout {path} does not exist" - Compression.describeSetup opts.devSrs + Compression.describeSetup opts.devKzgSrs opts.devFinalSrs opts.finalProof /-- Compress the aggregate root `addr`, persist the compressed proof under the same claim and return its address. -/ -def compressRoot (addr : Address) (opts : Options) : IO Address := do - let setup ← preflight opts - let wrapper ← Compression.readProof addr +private def compressProof (addr : Address) (wrapper : Ixon.Proof) + (opts : Options) (setup : String) : IO Address := do if Compression.isCompressed wrapper.proof then throw <| IO.userError "proof is already compressed" let .checkEnv _ _ := wrapper.claim | throw <| IO.userError "compression requires an aggregate CheckEnv proof" @@ -52,7 +49,8 @@ def compressRoot (addr : Address) (opts : Options) : IO Address := do | some path => pure path | none => let dir := (← StoreIO.toIO Store.getHomeDir) / ".ix/compression" - pure (dir / toString addr / toString opts.logHeight).toString + pure (dir / toString addr).toString + if opts.fresh then IO.eprintln s!"[compress] fresh: replacing stage artifacts in {out}" let vk ← match opts.rootVk with | some path => IO.FS.readBinFile path | none => pure backend.system.vkBytes @@ -65,28 +63,33 @@ def compressRoot (addr : Address) (opts : Options) : IO Address := do IO.eprintln s!"Compressing aggregate root {addr} with {setup}." let proof ← Compression.compress vk root (Ix.Claim.ser wrapper.claim) statement layout out (← Compression.cacheDir).toString - opts.devSrs opts.logHeight.toUInt32 opts.terminal + opts.devKzgSrs opts.devFinalSrs opts.finalProof opts.fresh let compressed : Ixon.Proof := { claim := wrapper.claim, proof } StoreIO.toIO (Store.write (Ixon.Proof.ser compressed)) +/-- Compress a stored aggregate proof and return the compressed proof address. -/ +def compressRoot (addr : Address) (opts : Options) : IO Address := do + let setup ← preflight opts + compressProof addr (← Compression.readProof addr) opts setup + def runCompressCmd (p : Cli.Parsed) : IO UInt32 := do try if p.hasFlag "plonk" && p.hasFlag "groth16" then throw <| IO.userError "choose at most one of --plonk and --groth16" - if p.hasFlag "dev-groth16" && !p.hasFlag "groth16" then - throw <| IO.userError "--dev-groth16 requires --groth16" - let terminal : UInt8 := if p.hasFlag "groth16" then - (if p.hasFlag "dev-groth16" then 3 else 2) + let finalProof : UInt8 := if p.hasFlag "groth16" then 2 else if p.hasFlag "plonk" then 1 else 0 - let hex := p.positionalArg! "proof" |>.as! String - let some addr := Address.fromString hex | throw <| IO.userError "expected a 64-character proof address" - let result ← compressRoot addr { - terminal - devSrs := p.hasFlag "dev-srs" - logHeight := ((p.flag? "log-height").map (·.as! Nat)).getD 24 + let opts : Options := { + finalProof + fresh := p.hasFlag "fresh" + devKzgSrs := p.hasFlag "dev-kzg-srs" + devFinalSrs := p.hasFlag "dev-final-srs" out := (p.flag? "out").map (·.as! String) rootVk := (p.flag? "root-vk").map (·.as! String) fixedRoot := (p.flag? "fixed-root").map (·.as! String) } + let setup ← preflight opts + let input := p.positionalArg! "proof" |>.as! String + let (addr, wrapper) ← Compression.readProofInput input + let result ← compressProof addr wrapper opts setup IO.println (toString result) return 0 catch e => @@ -100,16 +103,16 @@ def compressCmd : Cli.Cmd := `[Cli| compress VIA runCompressCmd; "Compress an aggregate CheckEnv proof; preserve its claim and print the new proof address." FLAGS: - "out" : String; "Output directory for the stage proofs (default ~/.ix/compression//). Rerunning with the same directory resumes from the last completed stage." - "log-height" : Nat; "First KZG partition height: 24 (default) or 25." + "fresh"; "Replace prior compression stages in --out (or the default output) with newly generated proofs. Reuse parameters, proving keys and fixed preprocessing." + "out" : String; "Output directory for the stage proofs (default ~/.ix/compression/). Rerunning with the same directory reuses every stage whose proof still verifies." "root-vk" : String; "Trusted root verifier key; omit to use the current aggregate backend." "fixed-root" : String; "Override the bundled release normalization layout with a layout from `ix compress layout`." - "dev-srs"; "Use known-trapdoor development parameters; results are not production-secure." - "plonk"; "Add a terminal PLONK proof using the compiled curve and selected setup." - "groth16"; "Add a terminal BN254 Groth16 proof using prepared, independently admitted circuit keys." - "dev-groth16"; "Allow development Groth16 keys; inner KZG still requires admitted Ignition parameters." + "dev-kzg-srs"; "Generate local development parameters for inner KZG. With --groth16 or --plonk, also requires --dev-final-srs." + "dev-final-srs"; "Generate and cache local development Groth16 or PLONK keys for the selected inner verifier. With --dev-kzg-srs, no Ignition parameters are required." + "plonk"; "Add a final PLONK proof using the compiled curve and selected setup." + "groth16"; "Add a final BN254 Groth16 proof using prepared, independently admitted circuit keys." ARGS: - proof : String; "Address of an aggregate Ixon.Proof in the ix store." + proof : String; "Aggregate Ixon.Proof file (including its claim), or its 64-character content address in the ix store. A hexadecimal filename needs a ./ prefix." SUBCOMMANDS: Ix.Cli.CompressionCmd.layoutCmd; diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 4976b162d..3b00094fe 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -13,17 +13,17 @@ opaque checkClaim (claim statement : @& ByteArray) : IO Unit @[extern "rs_compression_curve"] opaque curve : IO String -/-- Select development parameters when `devSrs` is set; otherwise require -authenticated production parameters for the compiled curve. -/ +/-- Describe the inner KZG setup and validate the independent final-setup +selection. Development inner KZG needs development keys for a final proof. -/ @[extern "rs_compression_setup"] -opaque describeSetup (devSrs : Bool) : IO String +opaque describeSetup (devKzgSrs devFinalSrs : Bool) (finalProof : UInt8) : IO String -/-- Run every stage into the empty directory `dir`, reusing the fixed -preprocessing and development SRS caches under `cacheRoot`. A nonempty -`fixedRoot` is the JSON layout `proof` was normalized to. -/ +/-- Run the stages in `dir`, resuming checked proofs unless `fresh` replaces +them. Fixed preprocessing, parameters and proving keys under `cacheRoot` +remain reusable. A nonempty `fixedRoot` is the JSON layout of `proof`. -/ @[extern "rs_compression_run"] opaque compress (vk proof claim statement : @& ByteArray) (fixedRoot dir cacheRoot : @& String) - (devSrs : Bool) (logHeight : UInt32) (terminal : UInt8) : IO ByteArray + (devKzgSrs devFinalSrs : Bool) (finalProof : UInt8) (fresh : Bool := false) : IO ByteArray /-- The versioned normalization layout for the aggregation backend's key. -/ @[extern "rs_compression_release_root_layout"] @@ -56,13 +56,17 @@ def normalize (backend : Aggr.VerificationBackend) (wrapper : Ixon.Proof) normalizeRoot backend.system backend.aggrIdx backend.allowed backend.ixvmVk (Ix.Claim.ser wrapper.claim) wrapper.proof layout -/-- Verify a terminal proof with an independently configured verifier catalog and -return its setup class. Development proofs need `devSrs`. Recursive KZG proofs -require explicit pinned-key verification and are unsupported by this entry point. -Needs neither the aggregation backend nor a GPU. -/ +/-- Verify a compressed proof under the bundled release keys. Development +proofs require `devSrs`; verification needs no proving setup or GPU. -/ @[extern "rs_compression_verify"] opaque verify (proof claim : @& ByteArray) (devSrs : Bool) : IO String +/-- Verify a final development proof with the caller's independent bundle pin. +The proof itself cannot authorize its key or a different circuit. -/ +@[extern "rs_compression_verify_development"] +opaque verifyDevelopment (proof claim bundle : @& ByteArray) + (trustedDigest : @& String) (devSrs : Bool) : IO String + /-- Return the GPU memory this process's Aiur prover retains between proofs to the driver. The compression worker is another process and cannot reuse it. -/ @[extern "rs_compression_release_gpu_memory"] @@ -81,6 +85,17 @@ def readProof (addr : Address) : IO Ixon.Proof := do let bytes ← StoreIO.toIO (Store.read addr) IO.ofExcept (Aggr.decodeAggregateWrapperAt addr bytes) +/-- A 64-character content address loads from the store; other inputs are +serialized proof-wrapper files. Prefix a hexadecimal filename with `./` to +select it as a path. File inputs do not require a store entry. -/ +def readProofInput (input : String) : IO (Address × Ixon.Proof) := do + if let some addr := Address.fromString input then + return (addr, ← readProof addr) + let bytes ← IO.FS.readBinFile input + let addr := Address.blake3 bytes + let wrapper ← IO.ofExcept (Aggr.decodeAggregateWrapperAt addr bytes) + return (addr, wrapper) + /-- The root statement a compressed proof of `claim` must expose: the public claim of the backend's `ix_aggr` entrypoint, serialized as one batch claim. -/ def statementBytes (backend : Aggr.VerificationBackend) (claim : Ix.Claim) : ByteArray := diff --git a/Ix/Cli/CompressionCmd.lean b/Ix/Cli/CompressionCmd.lean index e5cfd779e..758b14123 100644 --- a/Ix/Cli/CompressionCmd.lean +++ b/Ix/Cli/CompressionCmd.lean @@ -14,10 +14,8 @@ public import Ix.Aggr.Backend public section namespace Ix.Cli.CompressionCmd -private def loadRoot (hex : String) : IO (Address × Ixon.Proof) := do - let some addr := Address.fromString hex - | throw <| IO.userError s!"expected a 64-character proof address: {hex}" - let wrapper ← Compression.readProof addr +private def loadRoot (input : String) : IO (Address × Ixon.Proof) := do + let (addr, wrapper) ← Compression.readProofInput input if Compression.isCompressed wrapper.proof then throw <| IO.userError s!"proof {addr} is already compressed" let .checkEnv _ _ := wrapper.claim @@ -32,7 +30,7 @@ def runLayout (p : Cli.Parsed) : IO UInt32 := do let out := p.positionalArg! "out" |>.as! String let proofs := (p.variableArgsAs! String).toList if proofs.isEmpty then - throw <| IO.userError "expected at least one aggregate proof address" + throw <| IO.userError "expected at least one aggregate proof file or address" let headroom := ((p.flag? "headroom").map (·.as! Nat)).getD 0 let backend ← backend let mut rows := #[] @@ -79,7 +77,7 @@ def layoutCmd : Cli.Cmd := `[Cli| ARGS: out : String; "Output layout JSON file." - ...proofs : String; "Addresses of aggregate Ixon.Proof roots in the ix store." + ...proofs : String; "Aggregate Ixon.Proof files or content addresses in the ix store." ] def normalizeCmd : Cli.Cmd := `[Cli| @@ -87,7 +85,7 @@ def normalizeCmd : Cli.Cmd := `[Cli| "Prove a root's wrap in a fixed layout and write the FRI worker's root input directory." ARGS: - proof : String; "Address of an aggregate Ixon.Proof in the ix store." + proof : String; "Aggregate Ixon.Proof file or content address in the ix store." layout : String; "Layout JSON from `ix compress layout`." out : String; "Empty output directory for root-vk.bin, root-proof.bin, root-claims.bin and fixed-root.json." ] @@ -132,7 +130,7 @@ def runFetchParams (_ : Cli.Parsed) : IO UInt32 := do if (← IO.getEnv "IX_KZG_IGNITION_CACHE").isSome then IO.eprintln "IX_KZG_IGNITION_* is set and takes precedence over the downloaded release." IO.eprintln s!"Authenticating the configured parameters." - IO.eprintln s!"Using {← Compression.describeSetup false}." + IO.eprintln s!"Using {← Compression.describeSetup false false 0}." IO.println target return 0 catch e => diff --git a/Ix/Cli/ProveCmd.lean b/Ix/Cli/ProveCmd.lean index f9c2126b4..ac2bcd7f5 100644 --- a/Ix/Cli/ProveCmd.lean +++ b/Ix/Cli/ProveCmd.lean @@ -256,7 +256,8 @@ into `ix_aggr` until it is a single trace shard. -/ def proveOneShardRoot (ixe manifest : String) (ixvmSystem : Aiur.AiurSystem) (ixvmCompiled : Aiur.CompiledToplevel) (aggrSystem : Aiur.AiurSystem) (envHandle : Aiur.EnvHandle) (verifyIdx aggrIdx maxRamBytes : Nat) - (retention : Aiur.AiurSystem.ShardRetention) (cacheFriBytes : ByteArray) : + (retention : Aiur.AiurSystem.ShardRetention) (cacheFriBytes : ByteArray) + (fresh : Bool) : IO (Except String String) := do let .ok (ixonEnv, shards) ← Ix.Shard.loadEnvAndShards manifest ixe | return .error s!"{manifest}: cannot load the shard" @@ -264,7 +265,7 @@ def proveOneShardRoot (ixe manifest : String) (ixvmSystem : Aiur.AiurSystem) let owned := (Ix.Shard.ownedConstsPer ixonEnv shards)[k]! let index ← Ix.Cli.ShardProofIndex.indexDir match ← proveBlocksWithinBudget envHandle ixonEnv ixvmSystem verifyIdx maxRamBytes - false true retention ixvmCompiled (some index) true s!"shard {k}" shards[k]! owned with + false true retention ixvmCompiled (some index) (!fresh) s!"shard {k}" shards[k]! owned with | .error e => return .error e | .ok _ => pure () let .ok (claim, _) := IxVM.ClaimHarness.shardCheckEnvClaimTrees ixonEnv owned @@ -275,7 +276,7 @@ def proveOneShardRoot (ixe manifest : String) (ixvmSystem : Aiur.AiurSystem) IO.lazyPure fun _ => Aiur.AiurSystem.aggregateStage2 ixvmSystem aggrSystem envHandle manifest (toString proof) verifyIdx aggrIdx 0 ramBudget Aggr.defaultStructuralAbove 0 - false false cacheFriBytes true true true 0 true 1 false 0 + false false cacheFriBytes true true true 0 true 1 false 0 fresh def runProveCmd (p : Cli.Parsed) : IO UInt32 := do -- Streamed `[texray] : ── RAM Δ/peak` lines on stderr as @@ -304,6 +305,11 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do -- The shard-proof index: written for every proof this run persists, -- consulted before executing a leaf under `--skip-proven`. let skipProven := p.hasFlag "skip-proven" + let fresh := p.hasFlag "fresh" + if fresh && skipProven then + IO.eprintln "--fresh cannot be combined with --skip-proven" + return 1 + if fresh then IO.eprintln "[prove] fresh: bypassing earlier proof checkpoints" let indexDir? ← if p.hasFlag "no-index" then pure none else some <$> Ix.Cli.ShardProofIndex.indexDir -- Both `--ixes` branches end the same way: the partition the run @@ -332,18 +338,21 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do -- checked before any proving starts. let compress? ← match (p.flag? "compress").map (·.as! String) with | none => - if p.hasFlag "fixed-root" || p.hasFlag "dev-srs" then - IO.eprintln "--fixed-root and --dev-srs require --compress"; return 1 + if p.hasFlag "fixed-root" || p.hasFlag "dev-kzg-srs" || p.hasFlag "dev-final-srs" || p.hasFlag "compression-out" then + IO.eprintln "--fixed-root, --dev-kzg-srs, --dev-final-srs and --compression-out require --compress"; return 1 pure none | some output => if !aggregate then IO.eprintln "--compress requires aggregating a whole environment (--ixe and --ixes)"; return 1 - match Ix.Cli.CompressCmd.terminalOf output with + match Ix.Cli.CompressCmd.finalOf output with | .error e => IO.eprintln s!"--compress: {e}"; return 1 - | .ok terminal => + | .ok finalProof => let opts : Ix.Cli.CompressCmd.Options := { - terminal - devSrs := p.hasFlag "dev-srs" + finalProof + fresh + out := (p.flag? "compression-out").map (·.as! String) + devKzgSrs := p.hasFlag "dev-kzg-srs" + devFinalSrs := p.hasFlag "dev-final-srs" fixedRoot := (p.flag? "fixed-root").map (·.as! String) } try IO.eprintln s!"Compression will use {← Ix.Cli.CompressCmd.preflight opts}." @@ -384,12 +393,12 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do -- One shard has no join for the GPU scheduler to run: prove it, then -- wrap it into the aggregate root as `ix aggregate --wrap-root` does. proveOneShardRoot ixe manifest ixvmSystem ixvmCompiled aggrSystem envHandle - verifyIdx aggrIdx maxRamBytes retention rp.cacheFriBytes + verifyIdx aggrIdx maxRamBytes retention rp.cacheFriBytes fresh else IO.lazyPure fun _ => (Aiur.AiurSystem.proveLanes ixvmSystem aggrSystem envHandle manifest verifyIdx aggrIdx gpus maxRamBytes (((p.flag? "exec-jobs").map (·.as! Nat)).getD 0) 0 rp.cacheFriBytes - (outIxes.getD "")) + (outIxes.getD "") fresh) match result with | .error e => IO.eprintln s!"[prove] {e}"; return 1 | .ok root => @@ -594,6 +603,7 @@ def proveCmd : Cli.Cmd := `[Cli| "Generate a STARK proof for an `Ix.Claim` (mirrors `ix check`'s CLI shape)" FLAGS: + "fresh"; "Bypass prior leaf, aggregation and compression proofs, replacing their cache entries with newly verified results. Reuse parameters, proving keys and fixed preprocessing. Incompatible with --skip-proven." "keep-going"; "Continue past failures and report them at the end instead of halting on the first." "texray"; "Stream per-phase `[texray]` timing/RSS lines (execute, witness, STARK stages) to stderr as each span closes." "ixe" : String; "Path to a serialized `.ixe` env. When set, the binary reads the env from disk instead of using the compiled-in Lean env." @@ -613,9 +623,11 @@ def proveCmd : Cli.Cmd := `[Cli| "plan-only"; "With --distributed: report the static caller graph, the commit order and the largest group of mutually calling workers (how many records the first round holds at once) for this manifest, and stop before executing anything. The way to compare layouts without a run." "skip-proven"; "With --ixes: before executing a leaf, look its claim up in the shard-proof index (`~/.ix/cache/shard-proofs/`); a recorded proof that decodes, bundles exactly that claim and verifies natively is reused — its address printed, nothing executed — instead of proving again. How a partially proved partition resumes after a refinement." "no-index"; "Neither read nor write the shard-proof index (every persisted proof is normally recorded there under its claim digest)." - "compress" : String; "With --ixe and --ixes: compress the aggregate root to `kzg`, `plonk` or `groth16` (`dev-groth16` allows development Groth16 keys) and print the compressed proof's address after the root's. The setup is checked before proving." + "compress" : String; "With --ixe and --ixes: compress the aggregate root to `kzg`, `plonk` or `groth16` and print the compressed proof's address after the root's. The setup is checked before proving." + "compression-out" : String; "With --compress: directory for compression stage files; --fresh replaces prior stages there." "fixed-root" : String; "With --compress: override the bundled release normalization layout with a layout from `ix compress layout`." - "dev-srs"; "With --compress: use known-trapdoor development parameters; results are not production-secure." + "dev-kzg-srs"; "With --compress: generate local development inner KZG parameters. Final Groth16/PLONK proofs also require --dev-final-srs." + "dev-final-srs"; "With --compress groth16 or plonk: generate and cache local development final keys. With --dev-kzg-srs, no Ignition parameters are required." "max-ram" : Nat; "Per-shard prover-RAM budget, GiB — normally the same value the partition was sized with (`ix shard --max-ram`). Each shard is executed, its projected prover peak measured on the resulting record, and the proof attempted only if it fits; an over-budget shard is cut into the part count the peak model projects will fit, and each part re-gated, instead of being taken into the FFT phases that would exhaust the box. Omit to detect: 85% of the machine's available RAM." ARGS: diff --git a/Ix/Cli/VerifyCmd.lean b/Ix/Cli/VerifyCmd.lean index 5940d4fa6..451d639b1 100644 --- a/Ix/Cli/VerifyCmd.lean +++ b/Ix/Cli/VerifyCmd.lean @@ -219,10 +219,39 @@ def verifyShardComposition (ixePath manifestPath : String) (shardK? : Option Nat recordProof d proofAddr return 0 +private def verifyDevelopmentInputs (p : Cli.Parsed) : IO UInt32 := do + try + unless p.hasFlag "verifier-bundle" && p.hasFlag "verifier-bundle-blake3" do + throw <| IO.userError "--verifier-bundle and --verifier-bundle-blake3 must be supplied together" + unless p.hasFlag "dev-srs" do + throw <| IO.userError "development verifier requires --dev-srs" + for flag in #["ixe", "ixes", "shard", "aggregate", "record", "structural-above"] do + if p.hasFlag flag then + throw <| IO.userError s!"--verifier-bundle cannot be combined with --{flag}" + let inputs := p.variableArgsAs! String + if inputs.isEmpty then + throw <| IO.userError "expected a final proof file or store address" + let path := p.flag! "verifier-bundle" |>.as! String + let pin := p.flag! "verifier-bundle-blake3" |>.as! String + let bundle ← IO.FS.readBinFile path + for input in inputs do + let (addr, wrapper) ← Compression.readProofInput input + let claim := Ix.Claim.ser wrapper.claim + let setup ← Compression.verifyDevelopment wrapper.proof claim bundle pin true + unless setup == "development" do + throw <| IO.userError s!"unexpected development verification setup: {setup}" + IO.println s!"ok: development proof {addr} verifies claim {Address.blake3 claim} under the pinned verifier (not production-secure)" + return 0 + catch error => + IO.eprintln s!"verify: {error}" + return 1 + /-- Verify with an explicit aggregate-recursion configuration. Ordinary IxVM proof verification remains pinned to its independent canonical parameters. -/ def runVerifyCmdWith (recursionParameters : Aggr.RecursionParameters) (p : Cli.Parsed) : IO UInt32 := do + if p.hasFlag "verifier-bundle" || p.hasFlag "verifier-bundle-blake3" then + return ← verifyDevelopmentInputs p let proofs := (p.variableArgsAs! String).toList if p.hasFlag "aggregate" then if proofs.isEmpty then @@ -330,6 +359,8 @@ def verifyCmd : Cli.Cmd := `[Cli| FLAGS: "dev-srs"; "Allow development compressed proofs; these are not production-secure." + "verifier-bundle" : String; "Explicitly trusted development final-verifier JSON. Requires --verifier-bundle-blake3 and --dev-srs; accepts proof-wrapper files or store addresses." + "verifier-bundle-blake3" : String; "Independent BLAKE3 pin for --verifier-bundle; do not obtain it from an untrusted proof." "ixe" : String; "Path to a serialized `.ixe` env (with --ixes). With no proof args and no --shard: verify the partition off-circuit (every constant owned by exactly one shard)." "ixes" : String; "Path to a `.ixes` shard manifest (with --ixe). For aggregate roots, reproduces the manifest-relative hybrid structural root." "shard" : Nat; "0-based shard index K (with --ixe + --ixes). No proof: print shard K's reconstructed CheckEnv claim digest. With proof(s): bind each to shard K and verify." @@ -338,7 +369,7 @@ def verifyCmd : Cli.Cmd := `[Cli| "record"; "With --ixe + --ixes and proof(s): after a proof binds to its shard and verifies, record it in the shard-proof index (`~/.ix/cache/shard-proofs/` → address) so `ix prove --skip-proven` and `ix shard refine` reuse it. How already-proved leaves are imported before a refinement." ARGS: - ...proofs : String; "32-byte hex address(es) of persisted `Ixon.Proof` wrappers in `~/.ix/store/`. Omit when using --ixe + --ixes." + ...proofs : String; "32-byte hex addresses of persisted Ixon.Proof wrappers. With --verifier-bundle, proof-wrapper file paths are also accepted. Omit for --ixe + --ixes partition auditing." ] end diff --git a/Tests/AggrActivation.lean b/Tests/AggrActivation.lean index f64433379..5aafa2448 100644 --- a/Tests/AggrActivation.lean +++ b/Tests/AggrActivation.lean @@ -19,8 +19,8 @@ arms. The deterministic matrix crosses: That produces 4 wrap executions plus 128 pair executions. The runner executes the complete 132-case matrix twice through the generated native path, compares every per-circuit query count, and emits a Markdown report. It records -activation only; dummy-call policy remains deferred until a static terminal is -selected. +activation only; dummy-call policy remains deferred until a static final circuit +is selected. -/ public section @@ -455,7 +455,7 @@ private def report (compiled : Aiur.CompiledToplevel) represented in the execute FFI's query-count array." |>.push s!"- Catalog summary: {catalog.size} circuits; {alwaysActive} active \ in every case; {variableCount} input-dependent; {neverObserved} never observed." - |>.push "- Dummy calls remain deferred until a static terminal circuit is selected." + |>.push "- Dummy calls remain deferred until a static final circuit is selected." |>.push "" |>.push "## Trace-height control" |>.push "" diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index a1a2d7587..03457853c 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -18,7 +18,9 @@ private def invoke (home : System.FilePath) (args : Array String) let ix ← IO.FS.realPath ".lake/build/bin/ix" IO.Process.output { cmd := ix.toString, args, env := #[("HOME", some home.toString), ("IX_KZG_IGNITION_CACHE", none), ("IX_KZG_IGNITION_CACHE_BLAKE3", none), - ("IX_KZG_IGNITION_ADMISSION", none), ("IX_KZG_IGNITION_ADMISSION_BLAKE3", none)] ++ setupEnv } + ("IX_KZG_IGNITION_ADMISSION", none), ("IX_KZG_IGNITION_ADMISSION_BLAKE3", none), + ("IX_PARAMS_DIR", some (home / "params").toString), + ("IX_GROTH16_ARTIFACTS", none)] ++ setupEnv } private def rejects (home : System.FilePath) (args : Array String) (message : String) (setupEnv : Array (String × Option String) := #[]) : IO Unit := do @@ -37,6 +39,8 @@ public def run : IO Unit := do let help ← invoke home #["compress", "--help"] unless help.exitCode == 0 && (help.stdout.splitOn "--root-vk").length > 1 do throw <| IO.userError s!"compress help failed: {help.stdout} {help.stderr}" + unless (help.stdout.splitOn "--dev-groth16").length == 1 do + throw <| IO.userError "compression help exposes a development proof-system selector" let claim := Ix.Claim.checkEnv (Address.blake3 "test".toUTF8) none let native : Ixon.Proof := { claim, proof := "native".toUTF8 } let compressed : Ixon.Proof := { claim, proof := "IXCMP".toUTF8 } @@ -48,22 +52,58 @@ public def run : IO Unit := do rejects home #["compress", toString old, "--groth16", "--plonk"] "choose at most one" rejects home #["compress", toString old, "--dev-groth16"] - "--dev-groth16 requires --groth16" + "Unknown flag" + rejects home #["compress", toString old, "--dev-srs"] "Unknown flag" + rejects home #["prove", "--ixe", "missing.ixe", "--ixes", "missing.ixes", + "--compress", "dev-groth16"] "unknown compression output" + rejects home #["compress", toString old, "--dev-final-srs"] + "requires a final proof system" + for finalFlag in #["--plonk", "--groth16"] do + rejects home #["compress", toString old, finalFlag, "--dev-kzg-srs"] + "requires --dev-final-srs" unless old != addr && native.claim == compressed.claim do throw <| IO.userError "compression must preserve claim identity and change proof identity" - rejects home #["compress", toString old] "--dev-srs" - rejects home #["compress", toString otherAddr, "--dev-srs", "--out", (home / "run").toString] + rejects home #["compress", toString old] "--dev-kzg-srs" + rejects home #["compress", toString otherAddr, "--dev-kzg-srs", "--out", (home / "run").toString] "aggregate CheckEnv proof" unless !(← (home / "run").pathExists) do throw <| IO.userError "unsupported input created output" - rejects home #["compress", toString addr, "--dev-srs"] "already compressed" + let fileProof : Ixon.Proof := { other with proof := "file-native".toUTF8 } + let fileBytes := Ixon.Proof.ser fileProof + let filePath := home / "aggregate-proof.bin" + IO.FS.writeBinFile filePath fileBytes + let (fileAddr, loaded) ← Ix.Cli.Compression.readProofInput filePath.toString + unless fileAddr == Address.blake3 fileBytes && Ixon.Proof.ser loaded == fileBytes do + throw <| IO.userError "file input changed the proof or its content address" + rejects home #["compress", filePath.toString, "--dev-kzg-srs", "--out", (home / "file-run").toString] + "aggregate CheckEnv proof" + let fileHex := toString fileAddr + let storedFile := home / ".ix/store" / (fileHex.take 2).toString / + ((fileHex.drop 2).take 2).toString / ((fileHex.drop 4).take 2).toString / + (fileHex.drop 6).toString + unless !(← storedFile.pathExists) && !(← (home / "file-run").pathExists) do + throw <| IO.userError "file input was imported into the store or created output before validation" + rejects home #["compress", toString addr, "--dev-kzg-srs"] "already compressed" let cacheVar := "IX_KZG_IGNITION_CACHE" let setupError := "incomplete BN254 Ignition configuration" let missingCache := #[(cacheVar, some (home / "missing-setup-cache").toString)] rejects home #["compress", toString addr] setupError missingCache - rejects home #["compress", toString addr, "--dev-srs"] "already compressed" missingCache + rejects home #["compress", toString addr, "--dev-kzg-srs"] "already compressed" missingCache + rejects home #["compress", toString addr, "--groth16", "--dev-final-srs"] setupError missingCache + for finalFlag in #["--plonk", "--groth16"] do + rejects home #["compress", toString addr, finalFlag, "--dev-kzg-srs", "--dev-final-srs"] + "already compressed" missingCache + rejects home #["compress", filePath.toString, finalFlag, "--dev-kzg-srs", "--dev-final-srs"] + "aggregate CheckEnv proof" missingCache + rejects home #["prove", "--ixe", "missing.ixe", "--ixes", "missing.ixes", + "--compress", "groth16", "--dev-final-srs"] setupError missingCache rejects home #["verify", toString addr] "unsupported compressed proof version" rejects home #["verify", toString addr, "--dev-srs"] "unsupported compressed proof version" + rejects home #["verify", toString addr, "--verifier-bundle", "/missing", "--dev-srs"] + "must be supplied together" + rejects home #["verify", toString addr, "--verifier-bundle", "/missing", + "--verifier-bundle-blake3", String.ofList (List.replicate 64 '0')] + "requires --dev-srs" let removed ← invoke home #["verifier", "import", "/missing"] unless removed.exitCode != 0 do throw <| IO.userError "external verifier import remains available" @@ -71,8 +111,8 @@ public def run : IO Unit := do let unknownKey := ByteArray.mk #[73, 88, 66, 78, 0, 0, 0, 1] ++ ByteArray.mk (Array.replicate 32 0) let unknownAddr ← putProof home (Ixon.Proof.ser { claim, proof := unknownKey }) - rejects home #["verify", toString unknownAddr] "bundled recursive KZG verification is unsupported" - rejects home #["verify", toString unknownAddr, "--dev-srs"] "bundled recursive KZG verification is unsupported" + rejects home #["verify", toString unknownAddr] "other than the" + rejects home #["verify", toString unknownAddr, "--dev-srs"] "other than the" IO.println "compression CLI: BN254 setup and proof rejection checks passed" finally IO.FS.removeDirAll home diff --git a/contracts/groth16/test/IxGroth16Verifier.t.sol b/contracts/groth16/test/IxGroth16Verifier.t.sol index 5107f8b85..c259d1204 100644 --- a/contracts/groth16/test/IxGroth16Verifier.t.sol +++ b/contracts/groth16/test/IxGroth16Verifier.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.28; import {IxGroth16Verifier, IGroth16Verifier} from "../src/IxGroth16Verifier.sol"; import {Verifier as PlainVerifier} from "../testdata/plain/Verifier.sol"; -import {Verifier as CommittedVerifier} from "../testdata/committed/Verifier.sol"; import {Verifier as WrapperVerifier} from "../testdata/wrapper/Verifier.sol"; interface Vm { @@ -37,22 +36,9 @@ contract IxGroth16VerifierTest { require(!verifier.verify(proof, claim), "invalid point accepted"); } - function testCommittedProofAndCommitmentBinding() public { - IxGroth16Verifier verifier = facade(address(new CommittedVerifier())); - (bytes memory proof, bytes32 claim) = vector("committed"); - require(proof.length == 384, "unexpected committed encoding"); - require(verifier.verify(proof, claim), "valid committed proof rejected"); - require(!verifier.verify(proof, bytes32(uint256(claim) ^ (1 << 128))), "wrong claim half accepted"); - proof[256] ^= bytes1(0x01); - require(!verifier.verify(proof, claim), "changed commitment accepted"); - proof[256] ^= bytes1(0x01); - proof[383] ^= bytes1(0x01); - require(!verifier.verify(proof, claim), "changed commitment proof accepted"); - } - function testDifferentVerifierRejectsProof() public { IxGroth16Verifier verifier = facade(address(new PlainVerifier())); - (bytes memory proof, bytes32 claim) = vector("committed"); + (bytes memory proof, bytes32 claim) = vector("wrapper"); require(!verifier.verify(proof, claim), "proof for another key accepted"); } @@ -65,6 +51,13 @@ contract IxGroth16VerifierTest { emit log_named_uint("KZG wrapper verification gas", used); require(used < verifier.VERIFICATION_GAS(), "verification exceeds its gas budget"); require(!verifier.verify(proof, bytes32(uint256(claim) ^ 1)), "wrong KZG claim accepted"); + require(proof.length == 384, "unexpected committed encoding"); + require(!verifier.verify(proof, bytes32(uint256(claim) ^ (1 << 128))), "wrong claim half accepted"); + proof[256] ^= bytes1(0x01); + require(!verifier.verify(proof, claim), "changed commitment accepted"); + proof[256] ^= bytes1(0x01); + proof[383] ^= bytes1(0x01); + require(!verifier.verify(proof, claim), "changed commitment proof accepted"); } function testMissingOrDifferentVerifierCodeRejected() public { diff --git a/contracts/groth16/testdata/committed/Verifier.sol b/contracts/groth16/testdata/committed/Verifier.sol deleted file mode 100644 index 009dff023..000000000 --- a/contracts/groth16/testdata/committed/Verifier.sol +++ /dev/null @@ -1,712 +0,0 @@ - -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.28; - -/// @title Groth16 verifier template. -/// @author Remco Bloemen -/// @notice Supports verifying Groth16 proofs. Proofs can be in uncompressed -/// (256 bytes + optional commitments) and compressed (128 bytes) format. -/// Uncompressed proofs are passed as bytes calldata matching the output of -/// MarshalSolidity(). A view function is provided to compress proofs. -/// @notice See for further explanation. -contract Verifier { - - /// Some of the provided public input values are larger than the field modulus. - /// @dev Public input elements are not automatically reduced, as this is can be - /// a dangerous source of bugs. - error PublicInputNotInField(); - - /// The proof is invalid. - /// @dev This can mean that provided Groth16 proof points are not on their - /// curves, that pairing equation fails, or that the proof is not for the - /// provided public input. - error ProofInvalid(); - /// The commitment is invalid - /// @dev This can mean that provided commitment points and/or proof of knowledge are not on their - /// curves, that pairing equation fails, or that the commitment and/or proof of knowledge is not for the - /// commitment key. - error CommitmentInvalid(); - - // Addresses of precompiles - uint256 constant PRECOMPILE_MODEXP = 0x05; - uint256 constant PRECOMPILE_ADD = 0x06; - uint256 constant PRECOMPILE_MUL = 0x07; - uint256 constant PRECOMPILE_VERIFY = 0x08; - - // Base field Fp order P and scalar field Fr order R. - // For BN254 these are computed as follows: - // t = 4965661367192848881 - // P = 36⋅t⁴ + 36⋅t³ + 24⋅t² + 6⋅t + 1 - // R = 36⋅t⁴ + 36⋅t³ + 18⋅t² + 6⋅t + 1 - uint256 constant P = 0x30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd47; - uint256 constant R = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001; - - // Extension field Fp2 = Fp[i] / (i² + 1) - // Note: This is the complex extension field of Fp with i² = -1. - // Values in Fp2 are represented as a pair of Fp elements (a₀, a₁) as a₀ + a₁⋅i. - // Note: The order of Fp2 elements is *opposite* that of the pairing contract, which - // expects Fp2 elements in order (a₁, a₀). This is also the order in which - // Fp2 elements are encoded in the public interface as this became convention. - - // Constants in Fp - uint256 constant FRACTION_1_2_FP = 0x183227397098d014dc2822db40c0ac2ecbc0b548b438e5469e10460b6c3e7ea4; - uint256 constant FRACTION_27_82_FP = 0x2b149d40ceb8aaae81be18991be06ac3b5b4c5e559dbefa33267e6dc24a138e5; - uint256 constant FRACTION_3_82_FP = 0x2fcd3ac2a640a154eb23960892a85a68f031ca0c8344b23a577dcf1052b9e775; - - // Exponents for inversions and square roots mod P - uint256 constant EXP_INVERSE_FP = 0x30644E72E131A029B85045B68181585D97816A916871CA8D3C208C16D87CFD45; // P - 2 - uint256 constant EXP_SQRT_FP = 0xC19139CB84C680A6E14116DA060561765E05AA45A1C72A34F082305B61F3F52; // (P + 1) / 4; - - // Groth16 alpha point in G1 - uint256 constant ALPHA_X = 7828453201079832787741943236878950802432263443788436202034617928270201247641; - uint256 constant ALPHA_Y = 14255666985536596615592574588014779615656954681710491014089598027800790096749; - - // Groth16 beta point in G2 in powers of i - uint256 constant BETA_NEG_X_0 = 13207796800844159736991558829741024144350398059195558993930564542425648197183; - uint256 constant BETA_NEG_X_1 = 5562769691646014155903006538144500853811770131821955943491515825150107172017; - uint256 constant BETA_NEG_Y_0 = 8329093844409018926055428997212602963062219101789747890078174631438711991819; - uint256 constant BETA_NEG_Y_1 = 6975001982279568060670735408419413625776518258044989994379077548899369489894; - - // Groth16 gamma point in G2 in powers of i - uint256 constant GAMMA_NEG_X_0 = 5319119594649083035954077304699542490548608855808494871892290037370115570306; - uint256 constant GAMMA_NEG_X_1 = 8869150356442081652601624009554283055648262713669957942968344052186591156649; - uint256 constant GAMMA_NEG_Y_0 = 612568215963750390215976459655070115112209733412555183226850610639561770995; - uint256 constant GAMMA_NEG_Y_1 = 289509783331058742687045946268491500449099944169905865684122452693270933927; - - // Groth16 delta point in G2 in powers of i - uint256 constant DELTA_NEG_X_0 = 16910630396140037516481764176990593541585820638037470922337237333629952377462; - uint256 constant DELTA_NEG_X_1 = 15983544030216061968578018550514356993980238379809194129082890701170910052539; - uint256 constant DELTA_NEG_Y_0 = 4070693252652498844401126380683854598463896965020476222491793733691487946057; - uint256 constant DELTA_NEG_Y_1 = 7027148964577472698453990033418833455979329434633480551636010673630473865563; - // Pedersen G point in G2 in powers of i - uint256 constant PEDERSEN_G_X_0 = 20203264194858960482942528482684256535008310789754843830374999989727899690955; - uint256 constant PEDERSEN_G_X_1 = 8382908087252191233922903745298331907848741644388140952904734700438170107285; - uint256 constant PEDERSEN_G_Y_0 = 5546536256985687781255535208468721562114136907696702742981375928720727067233; - uint256 constant PEDERSEN_G_Y_1 = 15707789199779850463104407371475661377516643807672443755496733136705086998504; - - // Pedersen GSigmaNeg point in G2 in powers of i - uint256 constant PEDERSEN_GSIGMANEG_X_0 = 10192936832924620000566749992451340342179632899786430363478971532562789881097; - uint256 constant PEDERSEN_GSIGMANEG_X_1 = 19834842068758686654749624770883035837004476290916342446400967143640803279483; - uint256 constant PEDERSEN_GSIGMANEG_Y_0 = 19900910017079582115964424143640650853092219437495849195340189948080722260674; - uint256 constant PEDERSEN_GSIGMANEG_Y_1 = 5682933495873789872178741437241151981569204376908677251638317451499025736718; - - // Constant and public input points - uint256 constant CONSTANT_X = 15461909684414393037893004347544623071090233761159761059407566106390910469051; - uint256 constant CONSTANT_Y = 6701120498237355317221615283877069232680687209327960779643002943517161679837; - uint256 constant PUB_0_X = 6956891633129828870727903991584170543533574457764776891080307104547113842030; - uint256 constant PUB_0_Y = 8545373461165545755743195387772946139227724845719286818333583185639464631015; - uint256 constant PUB_1_X = 21097821209774993074543405356203237046294791601223309733595628196667638179787; - uint256 constant PUB_1_Y = 2435275676198758235523058150176492747615897942991796716991910709931421959209; - uint256 constant PUB_2_X = 14169900240262919740982593268047469149044687675736554278503791450553299993545; - uint256 constant PUB_2_Y = 8424757811983826375615643382947233504590941374102225679100532005257177651021; - - /// Negation in Fp. - /// @notice Returns a number x such that a + x = 0 in Fp. - /// @notice The input does not need to be reduced. - /// @param a the base - /// @return x the result - function negate(uint256 a) internal pure returns (uint256 x) { - unchecked { - x = (P - (a % P)) % P; // Modulo is cheaper than branching - } - } - - /// Exponentiation in Fp. - /// @notice Returns a number x such that a ^ e = x in Fp. - /// @notice The input does not need to be reduced. - /// @param a the base - /// @param e the exponent - /// @return x the result - function exp(uint256 a, uint256 e) internal view returns (uint256 x) { - bool success; - assembly ("memory-safe") { - let f := mload(0x40) - mstore(f, 0x20) - mstore(add(f, 0x20), 0x20) - mstore(add(f, 0x40), 0x20) - mstore(add(f, 0x60), a) - mstore(add(f, 0x80), e) - mstore(add(f, 0xa0), P) - success := staticcall(gas(), PRECOMPILE_MODEXP, f, 0xc0, f, 0x20) - x := mload(f) - } - if (!success) { - // Exponentiation failed. - // Should not happen. - revert ProofInvalid(); - } - } - - /// Invertsion in Fp. - /// @notice Returns a number x such that a * x = 1 in Fp. - /// @notice The input does not need to be reduced. - /// @notice Reverts with ProofInvalid() if the inverse does not exist - /// @param a the input - /// @return x the solution - function invert_Fp(uint256 a) internal view returns (uint256 x) { - x = exp(a, EXP_INVERSE_FP); - if (mulmod(a, x, P) != 1) { - // Inverse does not exist. - // Can only happen during G2 point decompression. - revert ProofInvalid(); - } - } - - /// Square root in Fp. - /// @notice Returns a number x such that x * x = a in Fp. - /// @notice Will revert with InvalidProof() if the input is not a square - /// or not reduced. - /// @param a the square - /// @return x the solution - function sqrt_Fp(uint256 a) internal view returns (uint256 x) { - x = exp(a, EXP_SQRT_FP); - if (mulmod(x, x, P) != a) { - // Square root does not exist or a is not reduced. - // Happens when G1 point is not on curve. - revert ProofInvalid(); - } - } - - /// Square test in Fp. - /// @notice Returns whether a number x exists such that x * x = a in Fp. - /// @notice Will revert with InvalidProof() if the input is not a square - /// or not reduced. - /// @param a the square - /// @return x the solution - function isSquare_Fp(uint256 a) internal view returns (bool) { - uint256 x = exp(a, EXP_SQRT_FP); - return mulmod(x, x, P) == a; - } - - /// Square root in Fp2. - /// @notice Fp2 is the complex extension Fp[i]/(i^2 + 1). The input is - /// a0 + a1 ⋅ i and the result is x0 + x1 ⋅ i. - /// @notice Will revert with InvalidProof() if - /// * the input is not a square, - /// * the hint is incorrect, or - /// * the input coefficients are not reduced. - /// @param a0 The real part of the input. - /// @param a1 The imaginary part of the input. - /// @param hint A hint which of two possible signs to pick in the equation. - /// @return x0 The real part of the square root. - /// @return x1 The imaginary part of the square root. - function sqrt_Fp2(uint256 a0, uint256 a1, bool hint) internal view returns (uint256 x0, uint256 x1) { - // If this square root reverts there is no solution in Fp2. - uint256 d = sqrt_Fp(addmod(mulmod(a0, a0, P), mulmod(a1, a1, P), P)); - if (hint) { - d = negate(d); - } - // If this square root reverts there is no solution in Fp2. - x0 = sqrt_Fp(mulmod(addmod(a0, d, P), FRACTION_1_2_FP, P)); - x1 = mulmod(a1, invert_Fp(mulmod(x0, 2, P)), P); - - // Check result to make sure we found a root. - // Note: this also fails if a0 or a1 is not reduced. - if (a0 != addmod(mulmod(x0, x0, P), negate(mulmod(x1, x1, P)), P) - || a1 != mulmod(2, mulmod(x0, x1, P), P)) { - revert ProofInvalid(); - } - } - - /// Compress a G1 point. - /// @notice Reverts with InvalidProof if the coordinates are not reduced - /// or if the point is not on the curve. - /// @notice The point at infinity is encoded as (0,0) and compressed to 0. - /// @param x The X coordinate in Fp. - /// @param y The Y coordinate in Fp. - /// @return c The compresed point (x with one signal bit). - function compress_g1(uint256 x, uint256 y) internal view returns (uint256 c) { - if (x >= P || y >= P) { - // G1 point not in field. - revert ProofInvalid(); - } - if (x == 0 && y == 0) { - // Point at infinity - return 0; - } - - // Note: sqrt_Fp reverts if there is no solution, i.e. the x coordinate is invalid. - uint256 y_pos = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); - if (y == y_pos) { - return (x << 1) | 0; - } else if (y == negate(y_pos)) { - return (x << 1) | 1; - } else { - // G1 point not on curve. - revert ProofInvalid(); - } - } - - /// Decompress a G1 point. - /// @notice Reverts with InvalidProof if the input does not represent a valid point. - /// @notice The point at infinity is encoded as (0,0) and compressed to 0. - /// @param c The compresed point (x with one signal bit). - /// @return x The X coordinate in Fp. - /// @return y The Y coordinate in Fp. - function decompress_g1(uint256 c) internal view returns (uint256 x, uint256 y) { - // Note that X = 0 is not on the curve since 0³ + 3 = 3 is not a square. - // so we can use it to represent the point at infinity. - if (c == 0) { - // Point at infinity as encoded in EIP196 and EIP197. - return (0, 0); - } - bool negate_point = c & 1 == 1; - x = c >> 1; - if (x >= P) { - // G1 x coordinate not in field. - revert ProofInvalid(); - } - - // Note: (x³ + 3) is irreducible in Fp, so it can not be zero and therefore - // y can not be zero. - // Note: sqrt_Fp reverts if there is no solution, i.e. the point is not on the curve. - y = sqrt_Fp(addmod(mulmod(mulmod(x, x, P), x, P), 3, P)); - if (negate_point) { - y = negate(y); - } - } - - /// Compress a G2 point. - /// @notice Reverts with InvalidProof if the coefficients are not reduced - /// or if the point is not on the curve. - /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) - /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). - /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). - /// @param x0 The real part of the X coordinate. - /// @param x1 The imaginary poart of the X coordinate. - /// @param y0 The real part of the Y coordinate. - /// @param y1 The imaginary part of the Y coordinate. - /// @return c0 The first half of the compresed point (x0 with two signal bits). - /// @return c1 The second half of the compressed point (x1 unmodified). - function compress_g2(uint256 x0, uint256 x1, uint256 y0, uint256 y1) - internal view returns (uint256 c0, uint256 c1) { - if (x0 >= P || x1 >= P || y0 >= P || y1 >= P) { - // G2 point not in field. - revert ProofInvalid(); - } - if ((x0 | x1 | y0 | y1) == 0) { - // Point at infinity - return (0, 0); - } - - // Compute y^2 - // Note: shadowing variables and scoping to avoid stack-to-deep. - uint256 y0_pos; - uint256 y1_pos; - { - uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); - uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); - uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); - y0_pos = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); - y1_pos = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); - } - - // Determine hint bit - // If this sqrt fails the x coordinate is not on the curve. - bool hint; - { - uint256 d = sqrt_Fp(addmod(mulmod(y0_pos, y0_pos, P), mulmod(y1_pos, y1_pos, P), P)); - hint = !isSquare_Fp(mulmod(addmod(y0_pos, d, P), FRACTION_1_2_FP, P)); - } - - // Recover y - (y0_pos, y1_pos) = sqrt_Fp2(y0_pos, y1_pos, hint); - if (y0 == y0_pos && y1 == y1_pos) { - c0 = (x0 << 2) | (hint ? 2 : 0) | 0; - c1 = x1; - } else if (y0 == negate(y0_pos) && y1 == negate(y1_pos)) { - c0 = (x0 << 2) | (hint ? 2 : 0) | 1; - c1 = x1; - } else { - // G1 point not on curve. - revert ProofInvalid(); - } - } - - /// Decompress a G2 point. - /// @notice Reverts with InvalidProof if the input does not represent a valid point. - /// @notice The G2 curve is defined over the complex extension Fp[i]/(i^2 + 1) - /// with coordinates (x0 + x1 ⋅ i, y0 + y1 ⋅ i). - /// @notice The point at infinity is encoded as (0,0,0,0) and compressed to (0,0). - /// @param c0 The first half of the compresed point (x0 with two signal bits). - /// @param c1 The second half of the compressed point (x1 unmodified). - /// @return x0 The real part of the X coordinate. - /// @return x1 The imaginary poart of the X coordinate. - /// @return y0 The real part of the Y coordinate. - /// @return y1 The imaginary part of the Y coordinate. - function decompress_g2(uint256 c0, uint256 c1) - internal view returns (uint256 x0, uint256 x1, uint256 y0, uint256 y1) { - // Note that X = (0, 0) is not on the curve since 0³ + 3/(9 + i) is not a square. - // so we can use it to represent the point at infinity. - if (c0 == 0 && c1 == 0) { - // Point at infinity as encoded in EIP197. - return (0, 0, 0, 0); - } - bool negate_point = c0 & 1 == 1; - bool hint = c0 & 2 == 2; - x0 = c0 >> 2; - x1 = c1; - if (x0 >= P || x1 >= P) { - // G2 x0 or x1 coefficient not in field. - revert ProofInvalid(); - } - - uint256 n3ab = mulmod(mulmod(x0, x1, P), P-3, P); - uint256 a_3 = mulmod(mulmod(x0, x0, P), x0, P); - uint256 b_3 = mulmod(mulmod(x1, x1, P), x1, P); - - y0 = addmod(FRACTION_27_82_FP, addmod(a_3, mulmod(n3ab, x1, P), P), P); - y1 = negate(addmod(FRACTION_3_82_FP, addmod(b_3, mulmod(n3ab, x0, P), P), P)); - - // Note: sqrt_Fp2 reverts if there is no solution, i.e. the point is not on the curve. - // Note: (X³ + 3/(9 + i)) is irreducible in Fp2, so y can not be zero. - // But y0 or y1 may still independently be zero. - (y0, y1) = sqrt_Fp2(y0, y1, hint); - if (negate_point) { - y0 = negate(y0); - y1 = negate(y1); - } - } - - /// Compute the public input linear combination. - /// @notice Reverts with PublicInputNotInField if the input is not in the field. - /// @notice Computes the multi-scalar-multiplication of the public input - /// elements and the verification key including the constant term. - /// @param input The public inputs. These are elements of the scalar field Fr. - /// @param publicCommitments public inputs generated from pedersen commitments. - /// @param commitments The Pedersen commitments from the proof. - /// @return x The X coordinate of the resulting G1 point. - /// @return y The Y coordinate of the resulting G1 point. - function publicInputMSM( - uint256[2] calldata input, - uint256[1] memory publicCommitments, - uint256[2] memory commitments - ) - internal view returns (uint256 x, uint256 y) { - // Note: The ECMUL precompile does not reject unreduced values, so we check this. - // Note: Unrolling this loop does not cost much extra in code-size, the bulk of the - // code-size is in the PUB_ constants. - // ECMUL has input (x, y, scalar) and output (x', y'). - // ECADD has input (x1, y1, x2, y2) and output (x', y'). - // We reduce commitments(if any) with constants as the first point argument to ECADD. - // We call them such that ecmul output is already in the second point - // argument to ECADD so we can have a tight loop. - bool success = true; - assembly ("memory-safe") { - let f := mload(0x40) - let g := add(f, 0x40) - let s - mstore(f, CONSTANT_X) - mstore(add(f, 0x20), CONSTANT_Y) - mstore(g, mload(commitments)) - mstore(add(g, 0x20), mload(add(commitments, 0x20))) - success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) - mstore(g, PUB_0_X) - mstore(add(g, 0x20), PUB_0_Y) - s := calldataload(input) - mstore(add(g, 0x40), s) - success := and(success, lt(s, R)) - success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) - success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) - mstore(g, PUB_1_X) - mstore(add(g, 0x20), PUB_1_Y) - s := calldataload(add(input, 32)) - mstore(add(g, 0x40), s) - success := and(success, lt(s, R)) - success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) - success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) - mstore(g, PUB_2_X) - mstore(add(g, 0x20), PUB_2_Y) - s := mload(publicCommitments) - mstore(add(g, 0x40), s) - success := and(success, lt(s, R)) - success := and(success, staticcall(gas(), PRECOMPILE_MUL, g, 0x60, g, 0x40)) - success := and(success, staticcall(gas(), PRECOMPILE_ADD, f, 0x80, f, 0x40)) - - x := mload(f) - y := mload(add(f, 0x20)) - } - if (!success) { - // Either Public input not in field, or verification key invalid. - // We assume the contract is correctly generated, so the verification key is valid. - revert PublicInputNotInField(); - } - } - - /// Compress a proof. - /// @notice Will revert with InvalidProof if the curve points are invalid, - /// but does not verify the proof itself. - /// @param proof The uncompressed Groth16 proof. Points (A, B, C) encoded as in EIP-197 - /// (256 bytes total). - /// Followed by Pedersen commitments (1 × 64 bytes) and proof of knowledge - /// (64 bytes) = 384 bytes total. - /// @return compressed The compressed proof. Elements are in the same order as for - /// verifyCompressedProof. I.e. points (A, B, C) in compressed format. - /// @return compressedCommitments compressed Pedersen commitments from the proof. - /// @return compressedCommitmentPok compressed proof of knowledge for the Pedersen commitments. - function compressProof(bytes calldata proof) - public view returns ( - uint256[4] memory compressed, - uint256[1] memory compressedCommitments, - uint256 compressedCommitmentPok - ) { - require(proof.length == 384, "invalid proof length"); - uint256 a0; - uint256 a1; - assembly ("memory-safe") { - a0 := calldataload(proof.offset) - a1 := calldataload(add(proof.offset, 0x20)) - } - compressed[0] = compress_g1(a0, a1); - assembly ("memory-safe") { - a0 := calldataload(add(proof.offset, 0x60)) - a1 := calldataload(add(proof.offset, 0x40)) - } - uint256 b0; - uint256 b1; - assembly ("memory-safe") { - b0 := calldataload(add(proof.offset, 0xa0)) - b1 := calldataload(add(proof.offset, 0x80)) - } - (compressed[2], compressed[1]) = compress_g2(a0, a1, b0, b1); - assembly ("memory-safe") { - a0 := calldataload(add(proof.offset, 0xc0)) - a1 := calldataload(add(proof.offset, 0xe0)) - } - compressed[3] = compress_g1(a0, a1); - assembly ("memory-safe") { - a0 := calldataload(add(proof.offset, 0x100)) - a1 := calldataload(add(proof.offset, 0x120)) - } - compressedCommitments[0] = compress_g1(a0, a1); - assembly ("memory-safe") { - a0 := calldataload(add(proof.offset, 0x140)) - a1 := calldataload(add(proof.offset, 0x160)) - } - compressedCommitmentPok = compress_g1(a0, a1); - } - - /// Verify a Groth16 proof with compressed points. - /// @notice Reverts with InvalidProof if the proof is invalid or - /// with PublicInputNotInField the public input is not reduced. - /// @notice There is no return value. If the function does not revert, the - /// proof was successfully verified. - /// @param compressedProof the points (A, B, C) in compressed format - /// matching the output of compressProof. - /// @param compressedCommitments compressed Pedersen commitments from the proof. - /// @param compressedCommitmentPok compressed proof of knowledge for the Pedersen commitments. - /// @param input the public input field elements in the scalar field Fr. - /// Elements must be reduced. - function verifyCompressedProof( - uint256[4] calldata compressedProof, - uint256[1] calldata compressedCommitments, - uint256 compressedCommitmentPok, - uint256[2] calldata input - ) public view { - uint256[1] memory publicCommitments; - uint256[2] memory commitments; - uint256[24] memory pairings; - { - (commitments[0], commitments[1]) = decompress_g1(compressedCommitments[0]); - (uint256 Px, uint256 Py) = decompress_g1(compressedCommitmentPok); - - uint256[] memory publicAndCommitmentCommitted; - - publicCommitments[0] = uint256( - sha256( - abi.encodePacked( - commitments[0], - commitments[1], - publicAndCommitmentCommitted - ) - ) - ) % R; - // Commitments - pairings[ 0] = commitments[0]; - pairings[ 1] = commitments[1]; - pairings[ 2] = PEDERSEN_GSIGMANEG_X_1; - pairings[ 3] = PEDERSEN_GSIGMANEG_X_0; - pairings[ 4] = PEDERSEN_GSIGMANEG_Y_1; - pairings[ 5] = PEDERSEN_GSIGMANEG_Y_0; - pairings[ 6] = Px; - pairings[ 7] = Py; - pairings[ 8] = PEDERSEN_G_X_1; - pairings[ 9] = PEDERSEN_G_X_0; - pairings[10] = PEDERSEN_G_Y_1; - pairings[11] = PEDERSEN_G_Y_0; - - // Verify pedersen commitments - bool success; - assembly ("memory-safe") { - let f := mload(0x40) - - success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x180, f, 0x20) - success := and(success, mload(f)) - } - if (!success) { - revert CommitmentInvalid(); - } - } - - { - (uint256 Ax, uint256 Ay) = decompress_g1(compressedProof[0]); - (uint256 Bx0, uint256 Bx1, uint256 By0, uint256 By1) = decompress_g2(compressedProof[2], compressedProof[1]); - (uint256 Cx, uint256 Cy) = decompress_g1(compressedProof[3]); - (uint256 Lx, uint256 Ly) = publicInputMSM( - input, - publicCommitments, - commitments - ); - - // Verify the pairing - // Note: The precompile expects the F2 coefficients in big-endian order. - // Note: The pairing precompile rejects unreduced values, so we won't check that here. - // e(A, B) - pairings[ 0] = Ax; - pairings[ 1] = Ay; - pairings[ 2] = Bx1; - pairings[ 3] = Bx0; - pairings[ 4] = By1; - pairings[ 5] = By0; - // e(C, -δ) - pairings[ 6] = Cx; - pairings[ 7] = Cy; - pairings[ 8] = DELTA_NEG_X_1; - pairings[ 9] = DELTA_NEG_X_0; - pairings[10] = DELTA_NEG_Y_1; - pairings[11] = DELTA_NEG_Y_0; - // e(α, -β) - pairings[12] = ALPHA_X; - pairings[13] = ALPHA_Y; - pairings[14] = BETA_NEG_X_1; - pairings[15] = BETA_NEG_X_0; - pairings[16] = BETA_NEG_Y_1; - pairings[17] = BETA_NEG_Y_0; - // e(L_pub, -γ) - pairings[18] = Lx; - pairings[19] = Ly; - pairings[20] = GAMMA_NEG_X_1; - pairings[21] = GAMMA_NEG_X_0; - pairings[22] = GAMMA_NEG_Y_1; - pairings[23] = GAMMA_NEG_Y_0; - - // Check pairing equation. - bool success; - uint256[1] memory output; - assembly ("memory-safe") { - success := staticcall(gas(), PRECOMPILE_VERIFY, pairings, 0x300, output, 0x20) - } - if (!success || output[0] != 1) { - // Either proof or verification key invalid. - // We assume the contract is correctly generated, so the verification key is valid. - revert ProofInvalid(); - } - } - } - - /// Verify an uncompressed Groth16 proof. - /// @notice Reverts with InvalidProof if the proof is invalid or - /// with PublicInputNotInField the public input is not reduced. - /// @notice There is no return value. If the function does not revert, the - /// proof was successfully verified. - /// @param proof the serialized proof, containing the points (A, B, C) in EIP-197 format - /// (256 bytes total). - /// Followed by Pedersen commitments (1 × 64 bytes) and proof of knowledge - /// (64 bytes) = 384 bytes total. - /// @param input the public input field elements in the scalar field Fr. - /// Elements must be reduced. - function verifyProof( - bytes calldata proof, - uint256[2] calldata input - ) public view { - require(proof.length == 384, "invalid proof length"); - // Copy commitment points from proof bytes into memory for publicInputMSM - uint256[2] memory commitments; - assembly ("memory-safe") { - calldatacopy(commitments, add(proof.offset, 0x100), 64) - } - - // HashToField - uint256[1] memory publicCommitments; - uint256[] memory publicAndCommitmentCommitted; - - { - bytes memory hashInput = abi.encodePacked( - proof[0x100:0x140], - publicAndCommitmentCommitted - ); - publicCommitments[0] = uint256(sha256(hashInput)) % R; - } - - // Verify pedersen commitments - bool success; - assembly ("memory-safe") { - let f := mload(0x40) - - calldatacopy(f, add(proof.offset, 0x100), 0x40) // Copy first commitment - mstore(add(f, 0x40), PEDERSEN_GSIGMANEG_X_1) - mstore(add(f, 0x60), PEDERSEN_GSIGMANEG_X_0) - mstore(add(f, 0x80), PEDERSEN_GSIGMANEG_Y_1) - mstore(add(f, 0xa0), PEDERSEN_GSIGMANEG_Y_0) - calldatacopy(add(f, 0xc0), add(proof.offset, 0x140), 0x40) // Copy PoK - mstore(add(f, 0x100), PEDERSEN_G_X_1) - mstore(add(f, 0x120), PEDERSEN_G_X_0) - mstore(add(f, 0x140), PEDERSEN_G_Y_1) - mstore(add(f, 0x160), PEDERSEN_G_Y_0) - - success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x180, f, 0x20) - success := and(success, mload(f)) - } - if (!success) { - revert CommitmentInvalid(); - } - - (uint256 x, uint256 y) = publicInputMSM( - input, - publicCommitments, - commitments - ); - - // Note: The precompile expects the F2 coefficients in big-endian order. - // Note: The pairing precompile rejects unreduced values, so we won't check that here. - assembly ("memory-safe") { - let f := mload(0x40) // Free memory pointer. - - // Copy points (A, B, C) to memory. They are already in correct encoding. - // This is pairing e(A, B) and G1 of e(C, -δ). - calldatacopy(f, proof.offset, 0x100) - - // Complete e(C, -δ) and write e(α, -β), e(L_pub, -γ) to memory. - // OPT: This could be better done using a single codecopy, but - // Solidity (unlike standalone Yul) doesn't provide a way to - // to do this. - mstore(add(f, 0x100), DELTA_NEG_X_1) - mstore(add(f, 0x120), DELTA_NEG_X_0) - mstore(add(f, 0x140), DELTA_NEG_Y_1) - mstore(add(f, 0x160), DELTA_NEG_Y_0) - mstore(add(f, 0x180), ALPHA_X) - mstore(add(f, 0x1a0), ALPHA_Y) - mstore(add(f, 0x1c0), BETA_NEG_X_1) - mstore(add(f, 0x1e0), BETA_NEG_X_0) - mstore(add(f, 0x200), BETA_NEG_Y_1) - mstore(add(f, 0x220), BETA_NEG_Y_0) - mstore(add(f, 0x240), x) - mstore(add(f, 0x260), y) - mstore(add(f, 0x280), GAMMA_NEG_X_1) - mstore(add(f, 0x2a0), GAMMA_NEG_X_0) - mstore(add(f, 0x2c0), GAMMA_NEG_Y_1) - mstore(add(f, 0x2e0), GAMMA_NEG_Y_0) - - // Check pairing equation. - success := staticcall(gas(), PRECOMPILE_VERIFY, f, 0x300, f, 0x20) - // Also check returned value (both are either 1 or 0). - success := and(success, mload(f)) - } - if (!success) { - // Either proof or verification key invalid. - // We assume the contract is correctly generated, so the verification key is valid. - revert ProofInvalid(); - } - } -} diff --git a/contracts/groth16/testdata/committed/proof.json b/contracts/groth16/testdata/committed/proof.json deleted file mode 100644 index 983b22a3e..000000000 --- a/contracts/groth16/testdata/committed/proof.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "commitments": 1, - "digest": "0x3100000000000000000000000000000001000000000000000000000000000000", - "proof": "0x21815841963b9d341e4a1eba060d56b5acbeced95f2888631d2f6eb3cb993ab82e58e1b0b9a3dec787438d1099a7c223e02c6259c8692d3a49dd6f7625fe08e80dd1b8ecccaa5abce4aedefa7bf014df53b26fa980b232c32041c322a56ed45117956c42510d35350949000f673d724a571833f6356465ff0226edc4a3e9421d2a64cd3315bb12e9f36c7b374eae721dade9385097c996f0d548f078de0ad8fc304abafe9d3b79be4cc76788bf0a06b9ba1d1a937ab989eb992fc2f99869996a13e5bda34f7d9a6706cc0bbc5a73660e53660bd67c0811dcd02a949e4244ac7e11f1c3c83b753d1b661118ba2bfca305e02f7521be50f002a5db36c884ddcccc1b9ef2757ddf8d98f5252087ad87ed19fdc9d0e0ee50b7bfd21733feea90084c1ab45c5973c780d928475f8a0a008a7b72f169309b70d9612ee704aa4a0c41c722123b6b9da458bc71182079bb6dbda343494de95cf06c2049ef1ab474c9cca5110d2a2c2f7f2c65e520970fe81d4ed54948c58cf53600accf8bd2523c1932da", - "scope": "development-backend-fixture-not-an-ix-proof" -} diff --git a/crates/aiur/Cargo.toml b/crates/aiur/Cargo.toml index 4343adc1e..04080753f 100644 --- a/crates/aiur/Cargo.toml +++ b/crates/aiur/Cargo.toml @@ -3,6 +3,8 @@ name = "aiur" version.workspace = true edition.workspace = true license.workspace = true +# The build script includes ../build_support, outside the package. +publish = false [dependencies] blake3 = { workspace = true } diff --git a/crates/aiur/build.rs b/crates/aiur/build.rs index b5dbd1eae..50d6b3e30 100644 --- a/crates/aiur/build.rs +++ b/crates/aiur/build.rs @@ -5,11 +5,15 @@ //! headers/libraries. use std::env; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +#[path = "../build_support/cuda.rs"] +mod cuda; +use cuda::{cuda_library_directories, nvcc_path}; use std::process::Command; fn main() { println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../build_support/cuda.rs"); println!("cargo:rerun-if-env-changed=NVCC"); println!("cargo:rerun-if-env-changed=NVCC_CCBIN"); println!("cargo:rerun-if-env-changed=NVCC_PREPEND_FLAGS"); @@ -37,7 +41,7 @@ fn main() { let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo did not set OUT_DIR")); let library = out_dir.join("libaiur_trace_cuda.a"); - let architectures = cuda_architectures(&nvcc); + let architectures = cuda::architectures(&nvcc, &["MULTI_STARK_CUDA_ARCHS"]); // Every aiur CUDA source belongs to the generated trace writers; the bare // `cuda` feature only enables multi-stark's device backend. @@ -180,108 +184,3 @@ fn main() { } } } - -fn nvcc_path() -> std::ffi::OsString { - if let Some(nvcc) = env::var_os("NVCC") { - return nvcc; - } - if let Some(root) = - env::var_os("CUDA_HOME").or_else(|| env::var_os("CUDA_PATH")) - { - let candidate = PathBuf::from(root).join("bin/nvcc"); - if candidate.is_file() { - return candidate.into_os_string(); - } - } - "nvcc".into() -} - -fn cuda_architectures(nvcc: &std::ffi::OsStr) -> Vec { - let supported = supported_architectures(nvcc); - let configured = env::var("MULTI_STARK_CUDA_ARCHS") - .ok() - .or_else(detect_nvidia_architectures) - .expect( - "no NVIDIA GPU detected; set MULTI_STARK_CUDA_ARCHS for the target GPU", - ); - let architectures: Vec<_> = configured - .split(',') - .map(str::trim) - .filter(|architecture| !architecture.is_empty()) - .map(|architecture| { - assert!( - (2..=3).contains(&architecture.len()) - && architecture.chars().all(|character| character.is_ascii_digit()), - "invalid CUDA architecture {architecture:?}; expected comma-separated numbers such as 80,90" - ); - assert!( - supported.is_empty() - || supported.iter().any(|item| item == architecture), - "nvcc does not support CUDA architecture sm_{architecture}" - ); - architecture.to_owned() - }) - .collect(); - assert!( - !architectures.is_empty(), - "MULTI_STARK_CUDA_ARCHS must contain at least one architecture" - ); - architectures -} - -fn supported_architectures(nvcc: &std::ffi::OsStr) -> Vec { - let Ok(output) = Command::new(nvcc).arg("--list-gpu-code").output() else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - String::from_utf8_lossy(&output.stdout) - .split_whitespace() - .filter_map(|code| code.strip_prefix("sm_")) - .filter(|code| code.chars().all(|character| character.is_ascii_digit())) - .map(str::to_owned) - .collect() -} - -/// Detect the architecture of the installed GPU when building on the target -/// machine. Cross builds and hosts without a GPU must set -/// `MULTI_STARK_CUDA_ARCHS` explicitly. -fn detect_nvidia_architectures() -> Option { - let output = Command::new("nvidia-smi") - .args(["--query-gpu=compute_cap", "--format=csv,noheader"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let capabilities = String::from_utf8(output.stdout).ok()?; - let mut architectures = capabilities - .lines() - .map(|capability| { - capability - .chars() - .filter(|character| character.is_ascii_digit()) - .collect::() - }) - .filter(|architecture| !architecture.is_empty()) - .collect::>(); - architectures.sort_unstable(); - architectures.dedup(); - (!architectures.is_empty()).then(|| architectures.join(",")) -} - -fn cuda_library_directories(nvcc: &std::ffi::OsStr) -> Vec { - let root = env::var_os("CUDA_HOME") - .or_else(|| env::var_os("CUDA_PATH")) - .map(PathBuf::from) - .or_else(|| { - let nvcc = Path::new(nvcc); - nvcc - .is_absolute() - .then(|| nvcc.parent()?.parent().map(Path::to_path_buf)) - .flatten() - }) - .unwrap_or_else(|| PathBuf::from("/usr/local/cuda")); - vec![root.join("lib64"), root.join("targets/x86_64-linux/lib")] -} diff --git a/crates/aiur/src/synthesis.rs b/crates/aiur/src/synthesis.rs index c0e66bb74..a162e6287 100644 --- a/crates/aiur/src/synthesis.rs +++ b/crates/aiur/src/synthesis.rs @@ -1746,7 +1746,7 @@ mod tests { ); system.verify(&claim, &proof).expect("xor split outputs must verify"); - // The terminal zkVM receives only the serialized verifier key, not the + // The compression verifier receives only the serialized verifier key, not the // prover-side `AiurSystem`. Exercise that exact path against a real proof // so codec round trips alone cannot mask a transcript/config mismatch. let vk_bytes = crate::vk_codec::aiur_system_to_bytes(&system) diff --git a/crates/build_support/cuda.rs b/crates/build_support/cuda.rs new file mode 100644 index 000000000..da421254d --- /dev/null +++ b/crates/build_support/cuda.rs @@ -0,0 +1,111 @@ +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +pub(crate) fn nvcc_path() -> std::ffi::OsString { + if let Some(nvcc) = env::var_os("NVCC") { + return nvcc; + } + if let Some(root) = + env::var_os("CUDA_HOME").or_else(|| env::var_os("CUDA_PATH")) + { + let candidate = PathBuf::from(root).join("bin/nvcc"); + if candidate.is_file() { + return candidate.into_os_string(); + } + } + "nvcc".into() +} + +pub(crate) fn architectures( + nvcc: &std::ffi::OsStr, + variables: &[&str], +) -> Vec { + let supported = supported_architectures(nvcc); + let configured = variables + .iter() + .find_map(|name| env::var(name).ok()) + .or_else(detect_nvidia_architectures) + .unwrap_or_else(|| { + panic!("no NVIDIA GPU detected; set {}", variables.join(" or ")) + }); + let architectures: Vec<_> = configured + .split(',') + .map(str::trim) + .filter(|architecture| !architecture.is_empty()) + .map(|architecture| { + assert!( + (2..=3).contains(&architecture.len()) + && architecture.chars().all(|character| character.is_ascii_digit()), + "invalid CUDA architecture {architecture:?}; expected comma-separated numbers such as 80,90" + ); + assert!( + supported.is_empty() + || supported.iter().any(|item| item == architecture), + "nvcc does not support CUDA architecture sm_{architecture}" + ); + architecture.to_owned() + }) + .collect(); + assert!( + !architectures.is_empty(), + "CUDA architecture selection must contain at least one architecture" + ); + architectures +} + +fn supported_architectures(nvcc: &std::ffi::OsStr) -> Vec { + let Ok(output) = Command::new(nvcc).arg("--list-gpu-code").output() else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .filter_map(|code| code.strip_prefix("sm_")) + .filter(|code| code.chars().all(|character| character.is_ascii_digit())) + .map(str::to_owned) + .collect() +} + +fn detect_nvidia_architectures() -> Option { + let output = Command::new("nvidia-smi") + .args(["--query-gpu=compute_cap", "--format=csv,noheader"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let capabilities = String::from_utf8(output.stdout).ok()?; + let mut architectures: Vec = capabilities + .lines() + .map(|capability| { + capability + .chars() + .filter(|character| character.is_ascii_digit()) + .collect() + }) + .filter(|architecture: &String| !architecture.is_empty()) + .collect(); + architectures.sort_unstable(); + architectures.dedup(); + (!architectures.is_empty()).then(|| architectures.join(",")) +} + +pub(crate) fn cuda_library_directories(nvcc: &std::ffi::OsStr) -> Vec { + let root = env::var_os("CUDA_HOME") + .or_else(|| env::var_os("CUDA_PATH")) + .map(PathBuf::from) + .or_else(|| { + let nvcc = Path::new(nvcc); + nvcc + .is_absolute() + .then(|| nvcc.parent()?.parent().map(Path::to_path_buf)) + .flatten() + }) + .unwrap_or_else(|| PathBuf::from("/usr/local/cuda")); + vec![root.join("lib64"), root.join("targets/x86_64-linux/lib")] +} diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 961722717..4b12a9b13 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -51,7 +51,7 @@ default = [] parallel = ["aiur/parallel", "ix-proof-compression/parallel"] cuda = ["aiur/cuda", "ix-proof-compression/cuda"] cuda-trace-codegen = ["cuda", "aiur/cuda-trace-codegen"] -# Terminal proof systems, which link the gnark Go archive. +# Final proof systems, which link the gnark Go archive. plonk = ["ix-proof-compression/plonk"] groth16 = ["ix-proof-compression/groth16"] test-ffi = [] diff --git a/crates/ffi/src/aiur/aggregate.rs b/crates/ffi/src/aiur/aggregate.rs index a489e362b..64a435899 100644 --- a/crates/ffi/src/aiur/aggregate.rs +++ b/crates/ffi/src/aiur/aggregate.rs @@ -75,6 +75,7 @@ struct RunConfig<'a> { plan_only: bool, cache_fri_bytes: &'a [u8], use_cache: bool, + fresh: bool, write_outputs: bool, trace_shards: bool, range_width: usize, @@ -166,7 +167,7 @@ fn run(config: RunConfig<'_>) -> Result { if config.plan_only && config.reprove_slot.is_some() { return Err("--plan-only cannot be combined with --reprove-slot".into()); } - if config.reprove_slot.is_some() && !config.use_cache { + if config.reprove_slot.is_some() && (!config.use_cache || config.fresh) { return Err("--reprove-slot requires aggregate cache reads".into()); } let started = Instant::now(); @@ -386,6 +387,7 @@ fn run(config: RunConfig<'_>) -> Result { aggr_idx: config.aggr_idx, store_dir: &store_dir, cache_dir, + cache_read_dir: if config.fresh { None } else { cache_dir }, reprove_slot: config.reprove_slot, write_outputs: config.write_outputs, // Slots share the run's budget evenly across the concurrent jobs; with diff --git a/crates/ffi/src/aiur/aggregate/ffi.rs b/crates/ffi/src/aiur/aggregate/ffi.rs index c30617da1..6f22af7c9 100644 --- a/crates/ffi/src/aiur/aggregate/ffi.rs +++ b/crates/ffi/src/aiur/aggregate/ffi.rs @@ -167,6 +167,7 @@ extern "C" fn rs_aiur_stage2_aggregate( exec_ahead: LeanNat>, verify_only: bool, subtree_code: LeanNat>, + fresh: bool, ) -> LeanExcept { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let reprove_slot = @@ -188,6 +189,7 @@ extern "C" fn rs_aiur_stage2_aggregate( plan_only, cache_fri_bytes: cache_fri_bytes.as_bytes(), use_cache, + fresh, write_outputs, trace_shards, range_width: lean_unbox_nat_as_usize(range_width.inner()), diff --git a/crates/ffi/src/aiur/aggregate/lanes.rs b/crates/ffi/src/aiur/aggregate/lanes.rs index 1ab0d6224..0203e348f 100644 --- a/crates/ffi/src/aiur/aggregate/lanes.rs +++ b/crates/ffi/src/aiur/aggregate/lanes.rs @@ -52,6 +52,7 @@ pub struct Config { pub structural_above: usize, pub cache_fri_bytes: Vec, pub out_manifest: Option, + pub fresh: bool, } /// The default ceiling on one execution record's counted bytes: large @@ -413,22 +414,26 @@ pub fn run( let checkpoint_path = cache_root .join("refined-manifests") .join(format!("{}.ixes", Address::hash(&manifest_bytes).hex())); - let mut manifest = match fs::read(&checkpoint_path) { - Ok(bytes) => { - let refined = ShardManifest::from_bytes(&bytes).map_err(|error| { - format!("refined manifest {}: {error}", checkpoint_path.display()) - })?; - say(&format!( - "resuming refined partition with {} claims from {}", - refined.num_shards, - checkpoint_path.display() - )); - refined - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => source, - Err(error) => { - return Err(format!("read {}: {error}", checkpoint_path.display())); - }, + let mut manifest = if cfg.fresh { + source + } else { + match fs::read(&checkpoint_path) { + Ok(bytes) => { + let refined = ShardManifest::from_bytes(&bytes).map_err(|error| { + format!("refined manifest {}: {error}", checkpoint_path.display()) + })?; + say(&format!( + "resuming refined partition with {} claims from {}", + refined.num_shards, + checkpoint_path.display() + )); + refined + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => source, + Err(error) => { + return Err(format!("read {}: {error}", checkpoint_path.display())); + }, + } }; let record_max_bytes = match std::env::var("AIUR_RECORD_MAX_BYTES") { Ok(value) => value @@ -519,6 +524,7 @@ struct Pass { store_dir: PathBuf, cache_dir: PathBuf, index_dir: PathBuf, + fresh: bool, /// CPU execution threads shared by every lane. executions: usize, /// The process-wide host limit, the prover budget of every slot. @@ -551,6 +557,7 @@ impl Pass { aggr_idx: self.aggr_idx, store_dir: &self.store_dir, cache_dir: Some(&self.cache_dir), + cache_read_dir: (!self.fresh).then_some(self.cache_dir.as_path()), reprove_slot: None, write_outputs: true, wrap_budget: self.wrap_budget, @@ -717,6 +724,7 @@ fn plan_pass( store_dir, cache_dir, index_dir, + fresh: cfg.fresh, executions, wrap_budget: Some(host.limit), pool, @@ -735,6 +743,16 @@ struct Resumed { fn resume_pass(pass: &Pass, ctx: ProveContext<'_>) -> Resumed { let count = pass.specs.len(); let shards = pass.shards(); + if pass.fresh { + say(&format!( + "{shards} claims: fresh proving, no indexed proofs or cached joins reused" + )); + return Resumed { + slots: vec![None; count], + retired: vec![false; count], + claim_done: vec![false; shards], + }; + } // Cached joins from the root down retire their subtrees. let mut slots: Vec>> = vec![None; count]; let mut retired = vec![false; count]; @@ -743,9 +761,13 @@ fn resume_pass(pass: &Pass, ctx: ProveContext<'_>) -> Resumed { if retired[index] || spec.kind != ChildKind::Aggr { continue; } - let Some((proof, address)) = - load_cached(ctx.aggr_system, ctx.store_dir, ctx.cache_dir, index, spec) - else { + let Some((proof, address)) = load_cached( + ctx.aggr_system, + ctx.store_dir, + ctx.cache_read_dir, + index, + spec, + ) else { continue; }; slots[index] = Some(Arc::new(Slot { @@ -1244,6 +1266,7 @@ extern "C" fn rs_aiur_prove_lanes( structural_above: LeanNat>, cache_fri_bytes: LeanByteArray>, out_manifest: LeanString>, + fresh: bool, ) -> LeanExcept { let lanes = match lean_unbox_nat_as_usize(lanes.inner()) { 0 => visible_gpus(), @@ -1257,6 +1280,7 @@ extern "C" fn rs_aiur_prove_lanes( cache_fri_bytes: cache_fri_bytes.as_bytes().to_vec(), out_manifest: (!out_manifest.as_str().is_empty()) .then(|| PathBuf::from(out_manifest.as_str())), + fresh, }; if cfg.lanes == 0 { return LeanExcept::error_string("no visible GPU to prove on"); diff --git a/crates/ffi/src/aiur/aggregate/normalize.rs b/crates/ffi/src/aiur/aggregate/normalize.rs index 549938086..86200eb70 100644 --- a/crates/ffi/src/aiur/aggregate/normalize.rs +++ b/crates/ffi/src/aiur/aggregate/normalize.rs @@ -35,7 +35,8 @@ impl RootWrap<'_> { /// Execute a wrap of `proof`: shape 1 verifies one `ix_aggr` proof and /// passes its claim through. The root proof is verified natively first. fn execute(&self, proof: &AiurProof) -> Result { - let expected = aggregate_outer_claim(self.aggr_idx, self.allowed, self.claim); + let expected = + aggregate_outer_claim(self.aggr_idx, self.allowed, self.claim); let advice = self .system .proof_to_advice_bytes(&expected, proof) diff --git a/crates/ffi/src/aiur/aggregate/prove.rs b/crates/ffi/src/aiur/aggregate/prove.rs index 96c9b5da3..92e53b757 100644 --- a/crates/ffi/src/aiur/aggregate/prove.rs +++ b/crates/ffi/src/aiur/aggregate/prove.rs @@ -59,6 +59,7 @@ pub(super) struct ProveContext<'a> { pub(super) aggr_idx: usize, pub(super) store_dir: &'a Path, pub(super) cache_dir: Option<&'a Path>, + pub(super) cache_read_dir: Option<&'a Path>, pub(super) reprove_slot: Option, pub(super) write_outputs: bool, /// The prover budget of one slot when its execution is proven as trace @@ -208,7 +209,7 @@ fn prepare_aggregate( if let Some((proof, address)) = load_cached( ctx.aggr_system, ctx.store_dir, - ctx.cache_dir, + ctx.cache_read_dir, slot_index, spec, ) { @@ -604,7 +605,7 @@ fn prove_range_tree( && let Some((proof, address)) = load_cached( ctx.aggr_system, ctx.store_dir, - ctx.cache_dir, + ctx.cache_read_dir, slot_index, spec, ) @@ -1028,7 +1029,7 @@ pub(super) fn load_replay_child( let (proof, proof_address) = load_cached( ctx.aggr_system, ctx.store_dir, - ctx.cache_dir, + ctx.cache_read_dir, child_index, spec, ) diff --git a/crates/ffi/src/aiur/aggregate/scheduler.rs b/crates/ffi/src/aiur/aggregate/scheduler.rs index 57a7cc0ae..1acebe64d 100644 --- a/crates/ffi/src/aiur/aggregate/scheduler.rs +++ b/crates/ffi/src/aiur/aggregate/scheduler.rs @@ -86,8 +86,13 @@ impl SlotWorker for ProveWorker<'_> { if spec.kind != ChildKind::Aggr { return None; } - let (proof, address) = - load_cached(ctx.aggr_system, ctx.store_dir, ctx.cache_dir, index, spec)?; + let (proof, address) = load_cached( + ctx.aggr_system, + ctx.store_dir, + ctx.cache_read_dir, + index, + spec, + )?; Some(Arc::new(Slot { kind: ChildKind::Aggr, statement: spec.statement.clone(), diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index 88d9de681..0da5d9474 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -22,24 +22,27 @@ extern "C" fn rs_compression_run( fixed_root: LeanString>, dir: LeanString>, cache_root: LeanString>, - dev_srs: bool, - log_height: u32, - terminal: u8, + dev_kzg_srs: bool, + dev_final_srs: bool, + final_proof: u8, + fresh: bool, ) -> LeanIOResult { let dir = dir.to_string(); let cache_root = cache_root.to_string(); - let terminal = match pipeline::TerminalOptions::try_from(terminal) { - Ok(system) => system, + let final_proof = match pipeline::FinalSystem::try_from(final_proof) { + Ok(system) => { + pipeline::FinalOptions { system, allow_development: dev_final_srs } + }, Err(error) => return LeanIOResult::error_string(error), }; let fixed_root = optional(&fixed_root); let result = pipeline::CompressConfig::from_env( std::path::Path::new(&cache_root), - dev_srs, - log_height, - terminal, + dev_kzg_srs, + final_proof, ) - .and_then(|config| { + .and_then(|mut config| { + config.fresh = fresh; pipeline::compress( pipeline::CompressInput { vk: vk.as_bytes(), @@ -70,14 +73,53 @@ extern "C" fn rs_compression_verify( } } +#[unsafe(no_mangle)] +extern "C" fn rs_compression_verify_development( + proof: LeanByteArray>, + claim: LeanByteArray>, + bundle: LeanByteArray>, + trusted_digest: LeanString>, + allow_development: bool, +) -> LeanIOResult { + let pin_text = trusted_digest.to_string(); + let pin = match blake3::Hash::from_hex(pin_text.as_bytes()) { + Ok(pin) => pin, + Err(error) => { + return LeanIOResult::error_string(&format!( + "invalid verifier bundle BLAKE3 pin: {error}" + )); + }, + }; + match pipeline::verify_with_development_verifier( + proof.as_bytes(), + claim.as_bytes(), + bundle.as_bytes(), + pin.as_bytes(), + allow_development, + ) { + Ok(class) => LeanIOResult::ok(LeanString::new(class.label())), + Err(error) => LeanIOResult::error_string(&error.to_string()), + } +} + #[unsafe(no_mangle)] extern "C" fn rs_compression_curve() -> LeanIOResult { LeanIOResult::ok(LeanString::new(ix_proof_compression::kzg::curve::NAME)) } #[unsafe(no_mangle)] -extern "C" fn rs_compression_setup(dev_srs: bool) -> LeanIOResult { - match pipeline::setup_description(dev_srs) { +extern "C" fn rs_compression_setup( + dev_kzg_srs: bool, + dev_final_srs: bool, + final_proof: u8, +) -> LeanIOResult { + let final_proof = match pipeline::FinalSystem::try_from(final_proof) { + Ok(system) => { + pipeline::FinalOptions { system, allow_development: dev_final_srs } + }, + Err(error) => return LeanIOResult::error_string(error), + }; + match pipeline::setup_description(dev_kzg_srs, final_proof) { Ok(description) => LeanIOResult::ok(LeanString::new(description)), Err(e) => LeanIOResult::error_string(&e.to_string()), } diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index b807076a5..8fb9d1352 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -38,6 +38,7 @@ p3-util = { git = "https://github.com/Plonky3/Plonky3", rev = "3152b14a89067c837 [build-dependencies] bindgen = { version = "=0.72.1", optional = true } +blake3 = { workspace = true } [dev-dependencies] rayon = { workspace = true } diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 2bcd74c35..203d46bcc 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -1,15 +1,15 @@ # Ix proof compression The pipeline is Goldilocks/Blake3 FRI → BN254 KZG → recursive BN254 KZG, -with an optional terminal BN254 PLONK or Groth16 proof. FRI uses Goldilocks and its +with an optional final BN254 PLONK or Groth16 proof. FRI uses Goldilocks and its quadratic extension with Blake3 Merkle commitments. Curve arithmetic starts when the FRI verifier is translated into BN254 scalar-field constraints. BN254 is unconditional, including builds with `--no-default-features`. The main Cargo features are `parallel`, `plonk`, `groth16`, and `cuda`. Either -terminal feature builds one shared gnark C archive. Combining a terminal +final feature builds one shared gnark C archive. Combining a final feature with `cuda` enables its GPU backend. -Compression runs inside `ix`. Lake enables the terminal features only for the +Compression runs inside `ix`. Lake enables the final features only for the `ix` executable, so other executables and tests never compile Go. ```sh @@ -20,9 +20,9 @@ IX_CUDA=1 IX_PLONK=1 IX_GROTH16=1 lake build ix Lake's switches select these Cargo features: `IX_PLONK` and `IX_GROTH16` (both off by default, so compression ends at the ~2.1 KB -recursive KZG proof and the default build needs no Go) select the terminal +recursive KZG proof and the default build needs no Go) select the final backends, and `IX_CUDA` -enables CUDA for Aiur, FRI, KZG, and each selected terminal backend. Native +enables CUDA for Aiur, FRI, KZG, and each selected final backend. Native BN254 arithmetic uses shared sppark/BLST support, and Rust calls stock gnark through its C archive. Groth16 CUDA links a separately installed, pinned ICICLE. Lake and Cargo default to `/usr/local`; `IX_GROTH16_ICICLE_ROOT` overrides the @@ -33,17 +33,25 @@ not compile it. Nix builds the pinned installation as `.#icicle-bn254`. Those libraries may contain other curves; Ix exposes only BN254. KZG uses authenticated Ignition parameters or explicit known-trapdoor -development parameters. Terminal PLONK requires authenticated Ignition. -Each build pins one version of the verifying keys, +development parameters. Final setup is independent: PLONK defaults to Ignition, +while `--dev-final-srs` prepares local keys for either PLONK or Groth16. With +`--dev-kzg-srs` as well, neither stage needs Ignition. +Default verification pins one version of the verifying keys, [`0.1`](verifying-keys/0.1): one key per proof system, with the root layout, statement prefix and Ignition admission they were derived for. `ix verify` selects the key by the proof's tag and requires the packet's key identifier to name it; there is no catalog of alternatives. -Recursive KZG and PLONK proofs verify under Ignition. The release Groth16 key -comes from a development setup, so Groth16 proofs verify only with -`--dev-srs`; see the -[terminal backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-terminal). +Bundled recursive KZG and PLONK proofs verify under Ignition. The release Groth16 key +comes from a development setup, so Groth16 proving requires `--dev-final-srs` +and verification requires `--dev-srs`. Use +`ix compress PROOF --groth16 --dev-final-srs`, where `PROOF` is a +store address or a serialized `Ixon.Proof` file containing its claim. File +inputs are read without importing them into the store. See the +[final backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-final). The library's `verify_with_vk` and `verify_with_ignition_admission` still verify recursive KZG proofs under an independently pinned key and statement. +Local final keys use `verify_with_development_verifier`. The CLI requires +`--verifier-bundle`, its independent `--verifier-bundle-blake3` pin, and +`--dev-srs`; see [local development compression](../../docs/development-compression.md). BN254 supports radix-2 domains through `2^28`; traces and quotient domains must both fit. Existing BN254 proof, verifier, and SRS encodings are retained. @@ -53,8 +61,7 @@ must both fit. Existing BN254 proof, verifier, and SRS encodings are retained. ### Ignition parameter import The BN254-only `kzg_ignition_import` example provides local transcript -inspection, import and authenticated prefix loading. The real `2^26` import -and authenticated cache loads passed on 2026-10-16. To build the tool: +inspection, import and authenticated prefix loading. To build the tool: ```sh cargo build --release --locked -p ix-proof-compression \ @@ -109,66 +116,10 @@ No ceremony pin is built in. The import follows SP1's PLONK setup path, Aztec Ignition's `MAIN IGNITION` transcripts through participants 196 and 197; this selects a candidate, not an authorized production setup. -The validated source snapshot contains all forty transcript files from -participants 196 and 197, matching the SP1-selected participant snapshot. -All forty per-file signatures verified using EIP-191 over the SHA256 of the -complete transcript. Both participants' first G1 and contribution G2 points -also matched the historical records in -[Aztec's verification repository at `c333ec47`](https://github.com/AztecProtocol/ignition-verification/tree/c333ec4775045139f86732abfbbd65728404ab7f). -The source inventory's complete-file BLAKE2b-512 hashes were then matched -against that signature evidence before import. This authenticates the files -to the documented participant keys; it does not replay the entire ceremony -or establish erasure of their secret randomness. The separate `sealed` -snapshot has different anchors and is not part of this import. - -The prepared cache, all forty source transcripts, admission and authentication -evidence are stored in the S3 bucket: -`s3://argument-kzg-params-063002298335-us-east-1-an/bn254/ignition/main-197/`. -All 116 objects (17,197,946,185 bytes) passed size and SHA-256 verification -after upload, including multipart checksum verification. The local validation -copy is at `/opt/dlami/nvme/ix-bn254/parameters/ignition/`: - -- `source-authentication.json` binds `source-manifest.json` to the signature - evidence and its pinned upstream revision. -- `authentication/verified-signatures.json` records every digest, signature - and recovered address; `authentication/verify_signatures.py` reproduces - those checks against the downloaded transcripts. -- `import-receipt.json` and `admission-receipt.json` record the - independent source, cache and admission pins and the complete setup identity. - -Proving needs the prepared cache and admission; the source transcripts are -needed only to reproduce the import. `ix compress fetch-params` downloads the -pinned public cache and admission into `IX_PARAMS_DIR` (default -`~/.ix/compression/params`) and authenticates them; compression uses them -automatically when no `IX_KZG_IGNITION_*` variable is set. To restore them -from S3 onto fast local storage instead: - -```sh -IGNITION_DATA=/path/to/local-nvme/parameters/ignition -IGNITION_S3=s3://argument-kzg-params-063002298335-us-east-1-an/bn254/ignition/main-197 -mkdir -p "$IGNITION_DATA" -export IX_KZG_IGNITION_CACHE="$IGNITION_DATA/ix-prefix-26.bin" -export IX_KZG_IGNITION_ADMISSION="$IGNITION_DATA/admission.json" -aws s3 cp "$IGNITION_S3/ix-prefix-26.bin" "$IX_KZG_IGNITION_CACHE" --region us-east-1 -aws s3 cp "$IGNITION_S3/admission.json" "$IX_KZG_IGNITION_ADMISSION" --region us-east-1 -export IX_KZG_IGNITION_CACHE_BLAKE3=6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b -export IX_KZG_IGNITION_ADMISSION_BLAKE3=4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46 -``` - -The host-specific `setup.env` and `setup.json` were excluded from S3; the -commands above configure replacement local paths with the verified pins. - -| Validated operation | Time | Peak process RSS | -| --- | ---: | ---: | -| Import `2^26` powers, checking contributions 196 and 197 | 211.88 s | 0.134 GiB | -| Authenticate and load the complete `2^26` cache | 5.54 s | 4.532 GiB | -| Load two verifier anchors from the authenticated cache | 4.31 ms | — | - -Times are the tool's operation timings; resource logs are under -`/opt/dlami/nvme/ix-bn254/logs/ignition-{import,verify}-26/`. CPU crate tests -passed for the imported Ignition parameters; see the historical measurement records for the exact revisions and test counts. -The complete cold and warm Init runs under Ignition also passed; see -[the real-input validation](#real-init-with-ignition). +The public proving cache and admission can be installed with `ix compress fetch-params`. +They are authenticated and stored under `IX_PARAMS_DIR` (default +`~/.ix/compression/params`), and selected when no `IX_KZG_IGNITION_*` override +is configured. The source transcripts are needed only to reproduce the import. ### Admitted Ignition pipeline @@ -200,31 +151,26 @@ export IX_KZG_IGNITION_CACHE=/path/to/ignition-prefix.bin export IX_KZG_IGNITION_CACHE_BLAKE3="$IGNITION_CACHE_MANIFEST_BLAKE3" export IX_KZG_IGNITION_ADMISSION=/path/to/new-admission.json export IX_KZG_IGNITION_ADMISSION_BLAKE3="$IGNITION_ADMISSION_BLAKE3" -``` - -On the validation host, the retained configuration supplies all four settings: -```sh -source /opt/dlami/nvme/ix-bn254/parameters/ignition/setup.env +ix compress ROOT_PROOF_ADDRESS --out /path/to/new-ignition-run ``` -It selects `ix-prefix-26.bin` and `admission.json`, using the pins recorded in -the receipts above. It does not select or authorize a verifying key. - Partial configuration, incorrect pins, changed anchors/source receipts and -insufficient prefixes fail closed. `--dev-srs` explicitly selects development -parameters instead. Compression checks the setup before proving and binds -both KZG stages to the same admitted setup; the recursive verifying key must +insufficient prefixes fail closed. `--dev-kzg-srs` explicitly selects development +inner KZG parameters. A final proof then also needs `--dev-final-srs`, which +prepares independent development PLONK parameters/keys or Groth16 keys. +Both flags bypass Ignition discovery and downloads. Compression checks setup before +proving and binds both KZG stages to the same admitted setup; the recursive verifying key must also retain its admitted deferred pairing keys. For local verification, `verify_with_ignition_admission` takes the independently trusted verifying key digest as well as the original claim, expected root statement and an `IgnitionAdmission` authenticated using `read` or `from_bytes`. It reads no proving cache or setup environment. -Ordinary application verification supports terminal PLONK and Groth16 through -their independently configured catalogs. Recursive KZG verification requires -one of the explicit pinned-key entry points. Generating a verifying key or -setup artifact does not authorize it for another verifier. +`ix verify` checks recursive KZG, PLONK and Groth16 proofs under the bundled +keys; these entry points serve keys pinned independently of the bundle. +Generating a verifying key or setup artifact does not authorize it for any +verifier. ## Proving and verification @@ -244,25 +190,25 @@ The profile supplies normalization bounds, not a bundled compression verifier. `ix compress` and `ix prove ... --compress` run every stage in the `ix` process through `pipeline::compress(CompressInput, &CompressConfig)`: FRI compression of the normalized root, first KZG, recursive KZG and, when -selected, the terminal proof. Real inputs require tens of millions of rows +selected, the final proof. Real inputs require tens of millions of rows and substantial RAM; use the measurements above when choosing a host and place outputs and caches on fast local storage. -Stages are kept under the output directory, `~/.ix/compression//` -by default or `--out`: `root/`, `fri/`, `first-kzg/`, `final-kzg/` (including +Stages are kept under the output directory, `~/.ix/compression/` by +default or `--out`: `root/`, `fri/`, `first-kzg/`, `final-kzg/` (including `vk.bin`), then `plonk/` or `groth16/`, and the returned `compressed.bin`. -Each stage writes a `complete` marker last, after its output is checked. -Rerunning the same command resumes: marked stages are checked and reused, and -the first unmarked stage and every later one are rerun. A non-empty directory -whose `root/` does not match the proof, layout and statement is refused -unchanged. `--log-height` selects first-stage log-height 24 (default) or 25, -matching the saved recursive layout policy. Fixed-preprocessing and +Rerunning the same command resumes: a stage is reused when the proof in its +directory verifies for the expected statement and setup (and, for this +version's backend under Ignition, the bundled keys), the same check that +follows proving; the first stage that fails, and every later one, is proved +again. A non-empty directory whose `root/` does not match the proof, layout +and statement is refused unchanged. The first KZG stage's partition height is +fixed at 2^24, the height the bundled keys were derived for. Fixed-preprocessing and development SRS caches are shared under `~/.ix/compression/cache`; `IX_KZG_FIXED_CACHE` and `IX_KZG_SRS_CACHE` override them. -BN254 has no bundled verifying key catalog entry. Its packet and verifying -key formats identify BN254; generating a key does not authorize it. For -local verification, `verify_with_vk` takes the independently reviewed BLAKE3 +`ix verify` checks recursive KZG proofs under the bundled key. To verify +under an independently pinned key instead, `verify_with_vk` takes the independently reviewed BLAKE3 digest of `final-kzg/vk.bin`, the original claim and independently expected root claims. It needs no proving SRS or cache, and checks the verifying key digest, development opt-in, claim digest, all expected statement words, outer @@ -271,6 +217,12 @@ from the proof. ### Validation and sizing entry points +`fri_sizing --statistics ` exports key statistics through the +Rust decoder without building a circuit. Pipe its JSON to +`tools/compression/fri_security_estimate.py -` for parameter estimates. +`fri_sizing` also retains opt-in frontend sizing; the GPU stage supervisor is +`tools/compression/run_gpu_stage.py`. + Focused entry points include the field primitives and complete verifier witness, with additional curve, transcript, MSM and public-pairing modules: @@ -281,7 +233,7 @@ cargo test -p ix-proof-compression --no-default-features --features parallel \ --lib recursive::native_verifier::reuse_tests::recursive_witness_rejects_tampering_without_native_preverification -- --exact ``` -## Terminal PLONK +## Final PLONK ```sh ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output @@ -291,7 +243,7 @@ Use the four Ignition environment variables described above. The result includes the proof envelope, native and Solidity proof encodings, generated Solidity verifier and verifying key, which must equal the bundled key for `ix verify` to accept the proof. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) -for Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the terminal PLONK verifier +for Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the final PLONK verifier and [claim adapter](../../contracts/plonk/README.md). ## CUDA and profiling @@ -387,9 +339,8 @@ multiple Aiur trace shards, `FixedRoot` for a bounded normalized root, and proof witness. `Prepared::prove_fri`, `prepare_kzg`, `Prepared::prove_kzg` and `prepare_recursive` compose the stages with caller-selected bounds and SRS. These APIs return verifier-only metadata and keep the expected claim separate -from the proof. They do not register a verifier in the application's bundled -catalog. The CLI uses `pipeline::compress` and the trusted verifying keys -described above. +from the proof; they do not change the bundled keys. The CLI uses +`pipeline::compress` and the bundled verifying keys described above. `fri` constrains FRI verification, `foreign` translates Goldilocks arithmetic into scalar-field constraints, `kzg` implements the commitment backend, and @@ -414,4 +365,3 @@ versions, and rejection of untrusted verifying keys. Sizing and benchmark context is retained in the [benchmark comparison](../../docs/proof-benchmark-comparison.md) and [stage construction notes](../../docs/compression-stage-collapse.md). -The retired BLS implementation is preserved on `sb/bn254-bls-preserved`. diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs index 877ef12ae..6a7ae0fa0 100644 --- a/crates/proof-compression/build.rs +++ b/crates/proof-compression/build.rs @@ -14,6 +14,9 @@ use std::env; use std::path::{Path, PathBuf}; +#[path = "../build_support/cuda.rs"] +mod cuda; +use cuda::{cuda_library_directories, nvcc_path}; use std::process::Command; const SOURCES: &[&str] = &[ @@ -50,6 +53,7 @@ mod gnark; fn main() { println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../build_support/cuda.rs"); for source in SOURCES { println!("cargo:rerun-if-changed={source}"); } @@ -126,7 +130,10 @@ fn build_bn254_cuda() -> PathBuf { "the BN254 CUDA backend supports x86_64 targets only" ); let nvcc = nvcc_path(); - let architectures = cuda_architectures(&nvcc); + let architectures = cuda::architectures( + &nvcc, + &["IX_KZG_CUDA_ARCHS", "MULTI_STARK_CUDA_ARCHS"], + ); let out = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR")); let architecture_header = out.join("kzg_architectures.h"); std::fs::write( @@ -158,7 +165,8 @@ fn build_bn254_cuda() -> PathBuf { } let mut objects = Vec::new(); for source in sources { - let object = out.join(Path::new(source).with_extension("o").file_name().unwrap()); + let object = + out.join(Path::new(source).with_extension("o").file_name().unwrap()); let mut command = cuda_compile_command(&nvcc, &architectures); command.args([ "-c", @@ -186,32 +194,36 @@ fn build_bn254_cuda() -> PathBuf { (blst_root.join("build/assembly.S"), "blst_assembly.o"), ] { let object = out.join(name); - run(Command::new(&compiler) - .args(["-O3", "-fPIC", "-fvisibility=hidden", "-D__BLST_PORTABLE__"]) - .arg(format!("-I{}", blst.display())) - .arg("-c") - .arg(source) - .arg("-o") - .arg(&object)); + run( + Command::new(&compiler) + .args(["-O3", "-fPIC", "-fvisibility=hidden", "-D__BLST_PORTABLE__"]) + .arg(format!("-I{}", blst.display())) + .arg("-c") + .arg(source) + .arg("-o") + .arg(&object), + ); objects.push(object); } let exports = out.join("ix_bn254_cuda.map"); std::fs::write(&exports, EXPORTS).expect("write BN254 CUDA export map"); let library = out.join(LIBRARY); - run(Command::new(&nvcc) - .args([ - "--shared", - "--cudart=static", - "--compiler-options=-static-libstdc++,-static-libgcc", - ]) - .arg(format!( - "-Xlinker=-z,defs,-soname,{LIBRARY},--version-script,{}", - exports.display() - )) - .arg("-o") - .arg(&library) - .args(&objects) - .args(["-ldl", "-lrt", "-lpthread"])); + run( + Command::new(&nvcc) + .args([ + "--shared", + "--cudart=static", + "--compiler-options=-static-libstdc++,-static-libgcc", + ]) + .arg(format!( + "-Xlinker=-z,defs,-soname,{LIBRARY},--version-script,{}", + exports.display() + )) + .arg("-o") + .arg(&library) + .args(&objects) + .args(["-ldl", "-lrt", "-lpthread"]), + ); println!("cargo:rustc-link-search=native={}", out.display()); println!("cargo:rustc-link-lib=dylib=ix_bn254_cuda"); println!("cargo:rustc-link-arg=-Wl,-rpath,{}", out.display()); @@ -253,107 +265,3 @@ fn cuda_compile_command( } command } - -fn nvcc_path() -> std::ffi::OsString { - if let Some(nvcc) = env::var_os("NVCC") { - return nvcc; - } - if let Some(root) = - env::var_os("CUDA_HOME").or_else(|| env::var_os("CUDA_PATH")) - { - let candidate = PathBuf::from(root).join("bin/nvcc"); - if candidate.is_file() { - return candidate.into_os_string(); - } - } - "nvcc".into() -} - -/// `IX_KZG_CUDA_ARCHS` selects the SM architectures; `MULTI_STARK_CUDA_ARCHS` -/// is honored as a fallback so one variable configures both CUDA backends of a -/// Lake build. Without either, the installed GPU architectures are detected. -fn cuda_architectures(nvcc: &std::ffi::OsStr) -> Vec { - let supported = supported_architectures(nvcc); - let configured = env::var("IX_KZG_CUDA_ARCHS") - .or_else(|_| env::var("MULTI_STARK_CUDA_ARCHS")) - .ok() - .or_else(detect_nvidia_architectures) - .expect("no NVIDIA GPU detected; set IX_KZG_CUDA_ARCHS or MULTI_STARK_CUDA_ARCHS for the target GPU"); - let architectures: Vec<_> = configured - .split(',') - .map(str::trim) - .filter(|architecture| !architecture.is_empty()) - .map(|architecture| { - assert!( - (2..=3).contains(&architecture.len()) - && architecture.chars().all(|character| character.is_ascii_digit()), - "invalid CUDA architecture {architecture:?}; expected comma-separated numbers such as 80,90" - ); - assert!( - supported.is_empty() - || supported.iter().any(|item| item == architecture), - "nvcc does not support CUDA architecture sm_{architecture}" - ); - architecture.to_owned() - }) - .collect(); - assert!( - !architectures.is_empty(), - "IX_KZG_CUDA_ARCHS must contain at least one architecture" - ); - architectures -} - -fn supported_architectures(nvcc: &std::ffi::OsStr) -> Vec { - let Ok(output) = Command::new(nvcc).arg("--list-gpu-code").output() else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - String::from_utf8_lossy(&output.stdout) - .split_whitespace() - .filter_map(|code| code.strip_prefix("sm_")) - .filter(|code| code.chars().all(|character| character.is_ascii_digit())) - .map(str::to_owned) - .collect() -} - -fn detect_nvidia_architectures() -> Option { - let output = Command::new("nvidia-smi") - .args(["--query-gpu=compute_cap", "--format=csv,noheader"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let capabilities = String::from_utf8(output.stdout).ok()?; - let mut architectures: Vec = capabilities - .lines() - .map(|capability| { - capability - .chars() - .filter(|character| character.is_ascii_digit()) - .collect() - }) - .filter(|architecture: &String| !architecture.is_empty()) - .collect(); - architectures.sort_unstable(); - architectures.dedup(); - (!architectures.is_empty()).then(|| architectures.join(",")) -} - -fn cuda_library_directories(nvcc: &std::ffi::OsStr) -> Vec { - let root = env::var_os("CUDA_HOME") - .or_else(|| env::var_os("CUDA_PATH")) - .map(PathBuf::from) - .or_else(|| { - let nvcc = Path::new(nvcc); - nvcc - .is_absolute() - .then(|| nvcc.parent()?.parent().map(Path::to_path_buf)) - .flatten() - }) - .unwrap_or_else(|| PathBuf::from("/usr/local/cuda")); - vec![root.join("lib64"), root.join("targets/x86_64-linux/lib")] -} diff --git a/crates/proof-compression/build/gnark.rs b/crates/proof-compression/build/gnark.rs index 060747ba6..78909a9a1 100644 --- a/crates/proof-compression/build/gnark.rs +++ b/crates/proof-compression/build/gnark.rs @@ -7,6 +7,39 @@ use std::{ process::Command, }; +/// BLAKE3 over the module definition and every non-test Go source, in path +/// order: what compiles the final circuits and serializes their keys. +fn source_digest(source: &std::path::Path) -> String { + let mut files = vec![source.join("go.mod"), source.join("go.sum")]; + let mut directories = vec![source.to_path_buf()]; + let mut sources = Vec::new(); + while let Some(directory) = directories.pop() { + for entry in fs::read_dir(&directory).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + if path.is_dir() { + if name != "vendor" && name != "testdata" { + directories.push(path); + } + } else if name.ends_with(".go") && !name.ends_with("_test.go") { + sources.push(path); + } + } + } + sources.sort(); + files.extend(sources); + let mut hasher = blake3::Hasher::new(); + for file in files { + let relative = file.strip_prefix(source).unwrap().to_string_lossy(); + hasher.update(relative.as_bytes()); + hasher.update(&[0]); + let bytes = fs::read(&file).unwrap(); + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + } + hasher.finalize().to_hex().to_string() +} + pub(super) fn build() -> Vec { let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let source = @@ -37,6 +70,10 @@ pub(super) fn build() -> Vec { .unwrap_or_else(|| panic!("cannot find {go:?} in PATH")) }; println!("cargo:rerun-if-changed={}", executable.display()); + println!( + "cargo:rustc-env=IX_GNARK_SOURCE_BLAKE3={}", + source_digest(&source) + ); let toolchain = Command::new(&go) .current_dir(&source) .env("GOTOOLCHAIN", "local") @@ -99,6 +136,12 @@ pub(super) fn build() -> Vec { }); let cuda = env::var_os("CARGO_FEATURE_CUDA").is_some(); let mut tags = Vec::new(); + if env::var_os("CARGO_FEATURE_PLONK").is_some() { + tags.push("plonk"); + } + if env::var_os("CARGO_FEATURE_GROTH16").is_some() { + tags.push("groth16"); + } let mut linker_flags = env::var("CGO_LDFLAGS").unwrap_or_default(); let mut artifacts = Vec::new(); if cuda && env::var_os("CARGO_FEATURE_PLONK").is_some() { @@ -148,10 +191,8 @@ pub(super) fn build() -> Vec { file.display() ); println!("cargo:rerun-if-changed={}", file.display()); - artifacts.push(super::RuntimeArtifact { - source: file, - path: name.to_owned(), - }); + artifacts + .push(super::RuntimeArtifact { source: file, path: name.to_owned() }); } for name in ["icicle_device", "icicle_field_bn254", "icicle_curve_bn254"] { println!("cargo:rustc-link-lib=dylib={name}"); diff --git a/crates/proof-compression/examples/fri_sizing.rs b/crates/proof-compression/examples/fri_sizing.rs index 20be2d098..27b15f42c 100644 --- a/crates/proof-compression/examples/fri_sizing.rs +++ b/crates/proof-compression/examples/fri_sizing.rs @@ -5,6 +5,7 @@ //! in-circuit verifier and cannot be sized this way. //! //! Usage: fri_sizing [queries,...] [log-height] +//! fri_sizing --statistics use ix_proof_compression::foreign::GoldilocksCircuit; use ix_proof_compression::fri::{ @@ -116,6 +117,14 @@ fn with_queries(key: &VerifierKey, queries: usize) -> VerifierKey { fn main() -> Result<()> { let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--statistics") { + if args.len() != 3 { + return Err("usage: fri_sizing --statistics ".into()); + } + let key = VerifierKey::from_bytes(&fs::read(&args[2])?)?; + println!("{}", key.statistics()); + return Ok(()); + } let dir = Path::new( args .get(1) diff --git a/crates/proof-compression/examples/kzg_dev_srs.rs b/crates/proof-compression/examples/kzg_dev_srs.rs deleted file mode 100644 index 0c80663ff..000000000 --- a/crates/proof-compression/examples/kzg_dev_srs.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Prepare deterministic development parameters separately from proof timings. - -use ix_proof_compression::kzg::{Scalar, Srs, curve}; -use multi_stark::traits::TwoAdicField; -use std::{path::Path, time::Instant}; - -fn main() -> Result<(), Box> { - let args: Vec<_> = std::env::args().skip(1).collect(); - let [directory, log, seed] = args.as_slice() else { - return Err( - "usage: kzg_dev_srs ".into(), - ); - }; - let log: usize = log.parse()?; - if !(1..=Scalar::TWO_ADICITY).contains(&log) { - return Err("unsupported SRS size".into()); - } - let count = - 1usize.checked_shl(u32::try_from(log)?).ok_or("SRS size overflow")?; - let started = Instant::now(); - let srs = Srs::unsafe_dev_setup_prefix_in_cache_directory( - count, - count - 1, - seed.as_bytes(), - Path::new(directory), - )?; - println!( - "{}", - serde_json::json!({ - "curve": curve::NAME, - "known_trapdoor": true, - "powers": count, - "seed": seed, - "public_max_degree": srs.public_setup.max_degree, - "public_setup_id": blake3::Hash::from_bytes(srs.public_setup.id).to_hex().as_str(), - "elapsed_seconds": started.elapsed().as_secs_f64(), - }) - ); - Ok(()) -} diff --git a/crates/proof-compression/examples/kzg_stage_resources.rs b/crates/proof-compression/examples/kzg_stage_resources.rs deleted file mode 100644 index 40d5e10d2..000000000 --- a/crates/proof-compression/examples/kzg_stage_resources.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Inspect saved stage metadata without loading coefficient matrices or an SRS. - -use ix_proof_compression::kzg::{KzgCommitment, Scalar, curve}; -use multi_stark::{ - expr::Source, - graph::{ConstraintGraph, Node, NodeId}, - system::Circuit, -}; -use serde::de::DeserializeOwned; -use serde_json::json; -use std::{collections::BTreeSet, fs, path::Path}; - -type Result = std::result::Result>; - -fn decode(path: &Path) -> Result { - let bytes = fs::read(path)?; - let (value, used) = bincode::serde::decode_from_slice( - &bytes, - bincode::config::standard().with_limit::<16777216>(), - )?; - if used != bytes.len() { - return Err("trailing metadata bytes".into()); - } - Ok(value) -} - -fn columns( - graph: &ConstraintGraph, - roots: impl IntoIterator, -) -> BTreeSet<(u8, usize)> { - let mut result = BTreeSet::new(); - let mut visited = BTreeSet::new(); - let mut pending: Vec<_> = roots.into_iter().collect(); - while let Some(id) = pending.pop() { - if !visited.insert(id.index()) { - continue; - } - match graph.nodes[id.index()] { - Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { - pending.extend([a, b]) - }, - Node::Neg(a) => pending.push(a), - Node::Var(column) => { - let source = match column.source { - Source::Preprocessed => 0, - Source::Main => 1, - Source::Stage2 => 2, - }; - result.insert((source, column.index as usize)); - }, - _ => {}, - } - } - result -} - -fn main() -> Result<()> { - let args: Vec<_> = std::env::args().skip(1).collect(); - let [stage] = args.as_slice() else { - return Err("usage: kzg_stage_resources ".into()); - }; - let stage = Path::new(stage); - let (widths, heights, _claims): (Vec, Vec, Vec>) = - decode(&stage.join("manifest.bin"))?; - let mut circuits = Vec::new(); - let mut scalar_bytes = 0usize; - for (i, (&width, &height)) in widths.iter().zip(&heights).enumerate() { - let (circuit, _): (Circuit, KzgCommitment) = - decode(&stage.join(format!("kzg/setup-{i}.bin")))?; - if width != circuit.main_width || height != circuit.preprocessed_height { - return Err("manifest/setup mismatch".into()); - } - let group_size = circuit.lookup_group_size.max(1); - let groups = circuit.stage_2_width; - let ratio = circuit.quotient_degree(); - let graph = &circuit.graph; - let mut lookup_columns = 0; - let mut quotient_columns = graph - .zeros - .iter() - .map(|&root| columns(graph, [root]).len()) - .max() - .unwrap_or(0); - for (group, lookups) in graph.lookups.chunks(group_size).enumerate() { - let mut used = columns( - graph, - lookups.iter().flat_map(|lookup| { - std::iter::once(lookup.multiplicity) - .chain(lookup.args.iter().copied()) - }), - ); - lookup_columns = lookup_columns.max(used.len()); - used.insert((2, group)); - used.insert((2, if group + 1 == groups { 0 } else { group + 1 })); - quotient_columns = quotient_columns.max(used.len()); - } - let all = circuit.preprocessed_width + circuit.main_width + groups; - let bytes_per_column = height * size_of::(); - scalar_bytes += bytes_per_column * (all + ratio); - // Treat every source column as varying and every graph node as a live - // scratch slot. Runtime constant folding and liveness can reduce this bound. - let overhead = (1usize << 30) - + 256 * 128 * graph.nodes.len().max(1) * size_of::() - + (graph.nodes.len() - + graph.lookups.len() - + all - + circuit.num_publics - + 32) - * 1024; - circuits.push(json!({ - "index": i, "rows": height, "main_columns": width, - "fixed_columns": circuit.preprocessed_width, "lookup_columns": groups, - "quotient_factor": ratio, "graph_nodes": graph.nodes.len(), - "lookup_count": graph.lookups.len(), "lookup_group_size": group_size, - "max_lookup_pass_columns": lookup_columns, "max_quotient_pass_columns": quotient_columns, - "dense_lookup_bytes_upper": bytes_per_column * (all - groups + 4 * groups) + overhead, - "pass_lookup_bytes_upper": bytes_per_column * (lookup_columns + groups + 3) + overhead, - "dense_quotient_bytes_upper": bytes_per_column * (all + ratio + 1) + overhead, - "pass_quotient_bytes_upper": bytes_per_column * (quotient_columns + ratio + 1) + overhead, - })); - } - let max_height = heights.iter().copied().max().ok_or("empty stage")?; - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "curve": curve::NAME, "stage": stage, "circuits": circuits, - "scalar_column_bytes": scalar_bytes, - "srs_host_bytes": max_height * size_of::(), - "scope": "Conservative column and GPU-pass accounting from metadata. Excludes frontend/witness memory, host transient copies, CUDA retained coefficients, SRS caches and MSM workspace. No proving arrays, SRS or CUDA context are allocated.", - }))? - ); - Ok(()) -} diff --git a/crates/proof-compression/examples/recursive_size.rs b/crates/proof-compression/examples/recursive_size.rs deleted file mode 100644 index 2207ad47f..000000000 --- a/crates/proof-compression/examples/recursive_size.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Count the recursive frontend for a four-row development KZG proof. - -use ix_proof_compression::{ - kzg::{KzgConfig, Scalar, Srs, compact::FixedProofCodec}, - recursive::native_verifier::{self, InnerVerifyingKey}, -}; -use multi_stark::{ - config::ProofConfig, - expr::Expr, - lookup::Lookup, - plonkish::CircuitStats, - system::{CircuitInputs, System, SystemWitness}, - traits::{Algebra, Field}, -}; -use p3_matrix::dense::RowMajorMatrix; -use serde_json::{Value, json}; -use std::{io::Write, sync::Arc}; - -fn counters(stats: CircuitStats) -> [(&'static str, usize); 10] { - [ - ("hash_calls", stats.hash_calls), - ("hash_compressions", stats.hash_compressions), - ("values", stats.values), - ("inputs", stats.inputs), - ("gates", stats.gates), - ("lookups", stats.lookups), - ("tables", stats.tables), - ("publics", stats.publics), - ("hint_calls", stats.hint_calls), - ("hint_outputs", stats.hint_outputs), - ] -} - -fn stats_json(stats: CircuitStats, previous: CircuitStats) -> Value { - Value::Object( - counters(stats) - .into_iter() - .zip(counters(previous)) - .map(|((name, value), (_, before))| { - ( - name.to_owned(), - json!(value.checked_sub(before).expect("cumulative frontend count")), - ) - }) - .collect(), - ) -} - -fn opening_values(matrices: &[Vec>]) -> usize { - matrices.iter().flatten().map(Vec::len).sum() -} - -fn main() -> Result<(), Box> { - let srs = - Arc::new(Srs::unsafe_dev_setup(4, b"recursive-size-development-only")); - let trace = RowMajorMatrix::new( - (2..=5u64) - .flat_map(|x| [Scalar::from_u64(x), Scalar::from_u64(x * x)]) - .collect(), - 2, - ); - let last_row = Expr::preprocessed(0); - let x = Expr::main(0); - let square = Expr::main(1); - let one = Expr::constant(Scalar::ONE); - let (system, key) = System::new( - KzgConfig::new(srs.clone(), 2), - [CircuitInputs { - main_width: 2, - preprocessed: Some(RowMajorMatrix::new_col(vec![ - Scalar::ZERO, - Scalar::ZERO, - Scalar::ZERO, - Scalar::ONE, - ])), - constraints: vec![ - square.clone() - x.clone() * x.clone(), - (one.clone() - last_row.clone()) - * (Expr::main_next(0) - x.clone() - one), - ], - lookups: vec![Lookup::pull( - last_row, - vec![Expr::constant(Scalar::from_u8(93)), x, square], - )], - ..Default::default() - }], - ); - let claims = - vec![vec![Scalar::from_u8(93), Scalar::from_u8(5), Scalar::from_u8(25)]]; - let claim_refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); - let proof = system.prove_multiple_claims( - &key, - &claim_refs, - SystemWitness::from_stage_1(vec![trace], &system), - ); - system - .verify_multiple_claims(&claim_refs, &proof) - .map_err(|error| format!("inner KZG verification failed: {error:?}"))?; - let fixed = - system.preprocessed_commit.as_ref().ok_or("missing fixed commitment")?; - let vk = InnerVerifyingKey { - circuits: &system.circuits, - fixed, - transcript_seed: system.config.transcript_seed(), - transcript_kind: system.config.transcript_kind(), - generator: srs.g1[0], - g2: srs.g2, - tau_g2: srs.tau_g2, - max_log_degree: system.config.max_log_degree(), - }; - let counts = native_verifier::count(&vk, &proof, &claims, &[(0, 1), (0, 2)])?; - let compact = - FixedProofCodec::new(&system, &proof.log_degrees)?.encode(&proof)?; - let mut previous = CircuitStats::default(); - let phases: Vec<_> = counts - .phases - .iter() - .map(|phase| { - let result = json!({ - "name": phase.name, - "incremental": stats_json(phase.stats, previous), - "cumulative": stats_json(phase.stats, CircuitStats::default()), - }); - previous = phase.stats; - result - }) - .collect(); - let tables: Vec<_> = counts - .table_dimensions - .iter() - .map(|&(rows, width)| json!({ "rows": rows, "width": width })) - .collect(); - let report = json!({ - "curve": "bn254", - "scope": "Four-row development fixture; frontend gates and lookups, not gnark PLONK constraints or production performance.", - "recursive_proof_generated": false, - "frontend": { - "total": stats_json(counts.stats, CircuitStats::default()), - "phases": phases, - "tables": tables, - "msm_terms": counts.msm_terms, - }, - "inner": { - "rows": 4, - "main_columns": 2, - "fixed_columns": 1, - "claims": [[93, 5, 25]], - "public_locations": [[0, 1], [0, 2]], - "max_quotient_degree": 2, - "development_srs_powers": srs.g1.len(), - "log_degrees": proof.log_degrees, - "commitments": { - "fixed": fixed.0.iter().map(Vec::len).sum::(), - "main": proof.commitments.stage_1_trace.0.iter().map(Vec::len).sum::(), - "stage2": proof.commitments.stage_2_trace.0.iter().map(Vec::len).sum::(), - "quotient": proof.commitments.quotient_chunks.0.iter().map(Vec::len).sum::(), - }, - "opening_values": { - "fixed": opening_values(proof.preprocessed_opened_values.as_deref().unwrap_or_default()), - "main": opening_values(&proof.stage_1_opened_values), - "stage2": opening_values(&proof.stage_2_opened_values), - "quotient": opening_values(&proof.quotient_opened_values), - }, - "opening_proof_points": proof.opening_proof.0.len(), - "compact_bytes": compact.len(), - "native_verification_passed": true, - }, - }); - let mut output = std::io::stdout().lock(); - serde_json::to_writer_pretty(&mut output, &report)?; - writeln!(output)?; - Ok(()) -} diff --git a/crates/proof-compression/src/fri/key_codec.rs b/crates/proof-compression/src/fri/key_codec.rs index 1152140ad..4e24f6ea0 100644 --- a/crates/proof-compression/src/fri/key_codec.rs +++ b/crates/proof-compression/src/fri/key_codec.rs @@ -31,6 +31,24 @@ struct KeyData { } impl VerifierKey { + /// Instance statistics for parameter sizing, decoded through the native key codec. + pub fn statistics(&self) -> serde_json::Value { + use multi_stark::config::StarkGenericConfig; + let system = self.system(); + let fri = system.config.fri_parameters(); + serde_json::json!({ + "log_blowup": system.config.log_blowup(), "cap": system.config.cap_height(), + "num_queries": fri.num_queries, "commit_pow": fri.commit_proof_of_work_bits, + "query_pow": fri.query_proof_of_work_bits, "max_log_arity": fri.max_log_arity, + "circuits": system.circuits.iter().map(|c| serde_json::json!({ + "main": c.main_width, "pre": c.preprocessed_width, "height": c.preprocessed_height, + "lookups": c.num_lookups, "stage2": c.stage_2_width, "group": c.lookup_group_size, + "max_degree": c.max_constraint_degree, "zeros": c.graph.zeros.len(), + "max_lookup_width": c.graph.lookups.iter().map(|lookup| lookup.args.len()).max().unwrap_or(0), + })).collect::>() + }) + } + /// Canonical versioned encoding for trusted key transport and fingerprints. pub fn to_bytes(&self) -> Result, VerifierError> { let s = self.system(); diff --git a/crates/proof-compression/src/pipeline/compress.rs b/crates/proof-compression/src/pipeline/compress.rs index 71448cb5a..9e2d32fc2 100644 --- a/crates/proof-compression/src/pipeline/compress.rs +++ b/crates/proof-compression/src/pipeline/compress.rs @@ -1,16 +1,15 @@ //! Compression of a normalized aggregate root through FRI, first KZG, -//! recursive KZG and an optional terminal proof, in this process. +//! recursive KZG and an optional final proof, in this process. //! //! Each stage reads its predecessor's directory under the output directory -//! and writes its own, ending with a `complete` marker that names the -//! configuration it was proved under. A rerun into the same directory loads -//! and checks every marked stage up to the first unmarked one, then deletes -//! and reruns that stage and all after it. +//! and writes its own. A stage's directory is reused when the proof in it +//! verifies for the expected statement, setup and keys, the same check that +//! follows proving; otherwise that stage and every later one is proved again. use super::{ - FixedRoot, Result, TerminalOptions, TerminalSystem, first, fixed_cache, - groth16, init_fri, plonk, root, + FinalOptions, FinalSystem, FixedRoot, Result, first, fixed_cache, groth16, + init_fri, plonk, root, setup::{SetupClass, SetupConfig}, - stages, statement, storage, wrap, + stages, statement, storage, verifying_keys, wrap, }; use std::{ fs, io, @@ -21,8 +20,6 @@ use std::{ /// The proof `compress` returns, also left in its output directory. const COMPRESSED_PROOF: &str = "compressed.bin"; -/// Written last into a stage directory, once its output has been checked. -const COMPLETE: &str = "complete"; /// The root input directory under the output directory. const ROOT: &str = "root"; @@ -47,14 +44,16 @@ pub struct CompressInput<'a> { /// Everything the stages read besides their input directories. pub struct CompressConfig { pub setup: SetupConfig, - /// Fixed preprocessing shared between runs, keyed by executable and - /// stage verifying key. + /// Fixed preprocessing shared between runs, keyed by format and stage + /// verifying key. pub fixed_cache: PathBuf, /// Compiled PLONK circuits, keys and parameter exports. pub plonk_cache: PathBuf, - /// Log2 of the first KZG stage's trace height: 24 or 25. - pub log_height: u32, - pub terminal: TerminalOptions, + /// Locally prepared Groth16 circuits and development keys. + pub groth16_cache: PathBuf, + pub final_proof: FinalOptions, + /// Ignore prior stage proofs and replace their artifacts in the output directory. + pub fresh: bool, /// Host memory the first KZG stage may spend prefetching partitions. pub prefetch_bytes: usize, /// KZG CUDA backend settings, installed once before the first KZG stage. @@ -63,18 +62,21 @@ pub struct CompressConfig { } impl CompressConfig { - /// The configuration `ix compress` runs with. `development` selects the - /// known-trapdoor setup. Cross-run caches live under `cache_root` unless + /// The configuration `ix compress` runs with. `development_kzg` selects local + /// inner KZG parameters independently of `final_proof.allow_development`. + /// Development inner KZG requires development final keys; the two setups + /// are generated and cached independently. + /// Cross-run caches live under `cache_root` unless /// `IX_KZG_FIXED_CACHE` or `IX_KZG_SRS_CACHE` name other directories; the /// setup, prefetch budget (`IX_KZG_PREFETCH_GIB`) and CUDA settings /// (`IX_KZG_CUDA_*`, `IX_KZG_MSM_CHUNK_POINTS`) come from the environment. pub fn from_env( cache_root: &Path, - development: bool, - log_height: u32, - terminal: TerminalOptions, + development_kzg: bool, + final_proof: FinalOptions, ) -> Result { - let mut setup = SetupConfig::from_env(development)?; + final_proof.validate_setup(development_kzg)?; + let mut setup = SetupConfig::from_env(development_kzg)?; if let SetupConfig::Development { cache_directory: directory @ None } = &mut setup { @@ -90,8 +92,9 @@ impl CompressConfig { fixed_cache: std::env::var_os("IX_KZG_FIXED_CACHE") .map_or_else(|| cache_root.join("fixed-preprocessing"), PathBuf::from), plonk_cache: cache_root.join("plonk"), - log_height, - terminal, + groth16_cache: cache_root.join("groth16"), + final_proof, + fresh: false, prefetch_bytes: prefetch_gib .checked_mul(1 << 30) .ok_or("prefetch budget overflow")?, @@ -102,51 +105,25 @@ impl CompressConfig { /// Reject unsupported combinations before any checkpoint is written. fn validate(&self) -> Result<()> { - let terminal = self.terminal; - if terminal.allow_development && terminal.system != TerminalSystem::Groth16 - { - return Err( - "development terminal keys are supported only for Groth16".into(), - ); - } - if terminal.system != TerminalSystem::None - && self.setup.class() == SetupClass::Development - { - return Err( - "terminal proofs require admitted inner KZG parameters; omit --dev-srs" - .into(), - ); - } - match terminal.system { - TerminalSystem::Plonk if !cfg!(feature = "plonk") => { + let final_proof = self.final_proof; + final_proof + .validate_setup(self.setup.class() == SetupClass::Development)?; + match final_proof.system { + FinalSystem::Plonk if !cfg!(feature = "plonk") => { return Err( "this ix was built without PLONK; rebuild with IX_PLONK=1".into(), ); }, - TerminalSystem::Plonk - if std::env::var("IX_PLONK_BACKEND").as_deref() == Ok("cuda") - && !cfg!(feature = "cuda") => - { - return Err( - "IX_PLONK_BACKEND=cuda requires a build with IX_CUDA=1".into(), - ); - }, - TerminalSystem::Groth16 if !cfg!(feature = "groth16") => { + FinalSystem::Groth16 if !cfg!(feature = "groth16") => { return Err( "this ix was built without Groth16; rebuild with IX_GROTH16=1".into(), ); }, - TerminalSystem::Groth16 => { - groth16::preflight(terminal.allow_development)? + FinalSystem::Groth16 => { + groth16::preflight(final_proof.allow_development)? }, _ => {}, } - if !matches!(self.log_height, 24 | 25) { - return Err( - "--log-height must be 24 or 25 for the supported recursive verifying keys" - .into(), - ); - } if std::env::var_os("IX_ROOT_GENERIC_HASHES").is_some() { return Err( "unset IX_ROOT_GENERIC_HASHES for the supported compression verifying key" @@ -155,17 +132,6 @@ impl CompressConfig { } Ok(()) } - - /// The setup a KZG stage's marker records: a stage proved under other - /// parameters is rerun. - fn setup_key(&self) -> String { - match &self.setup { - SetupConfig::Ignition { manifest, .. } => { - format!("ignition {}", blake3::Hash::from_bytes(*manifest).to_hex()) - }, - SetupConfig::Development { .. } => "development".into(), - } - } } /// Read the KZG CUDA backend settings from the environment. The pipeline @@ -286,9 +252,14 @@ pub(super) fn root_files( /// Prepare `out` to hold the stages of the root in `files`. An empty or /// missing directory starts a new compression; any other must hold a `root/` -/// whose files match these, or it is refused unchanged. Root files an -/// interrupted run did not write are written. -fn open_output(out: &Path, files: &[(&'static str, Vec)]) -> Result<()> { +/// whose files match these, or it is refused unchanged. A fresh run replaces +/// recognized root/stage artifacts; unrelated output files and shared key +/// caches are left alone. Root files an interrupted run did not write are written. +fn open_output( + out: &Path, + files: &[(&'static str, Vec)], + fresh: bool, +) -> Result<()> { fs::create_dir_all(out)?; let root = out.join(ROOT); if fs::read_dir(out)?.next().is_some() { @@ -310,6 +281,9 @@ fn open_output(out: &Path, files: &[(&'static str, Vec)]) -> Result<()> { root::FIXED_ROOT_LAYOUT, ] { let expected = files.iter().find(|(file, _)| *file == name); + if fresh { + continue; + } match fs::read(root.join(name)) { Ok(bytes) => match expected { Some((_, expected)) @@ -321,6 +295,20 @@ fn open_output(out: &Path, files: &[(&'static str, Vec)]) -> Result<()> { } } } + if fresh { + for name in [ROOT, "fri", "first-kzg", "final-kzg", "plonk", "groth16"] { + match fs::remove_dir_all(out.join(name)) { + Ok(()) => {}, + Err(e) if e.kind() == io::ErrorKind::NotFound => {}, + Err(e) => return Err(e.into()), + } + } + match fs::remove_file(out.join(COMPRESSED_PROOF)) { + Ok(()) => {}, + Err(e) if e.kind() == io::ErrorKind::NotFound => {}, + Err(e) => return Err(e.into()), + } + } fs::create_dir_all(&root)?; for (name, bytes) in files { let path = root.join(name); @@ -332,7 +320,7 @@ fn open_output(out: &Path, files: &[(&'static str, Vec)]) -> Result<()> { } /// Compress the normalized root in `input` to a recursive KZG proof, or with -/// a terminal system to that system's proof envelope, and return it. Stages +/// a final system to that system's proof envelope, and return it. Stages /// run in this process and keep their directories under `input.out`, so an /// interrupted run resumes from its last complete stage when rerun with the /// same input and output directory. @@ -342,14 +330,16 @@ pub fn compress( ) -> Result> { let words = super::check_claim(input.claim, input.statement)?; config.validate()?; - if config.terminal.system == TerminalSystem::Groth16 { - groth16::check_statement(&words, config.terminal.allow_development)?; + if config.final_proof.system == FinalSystem::Groth16 + && !config.final_proof.allow_development + { + groth16::check_statement(&words)?; } let files = root_files(input.vk, input.proof, &words, input.fixed_root)?; - open_output(input.out, &files)?; + open_output(input.out, &files, config.fresh)?; drop(files); let _monitor = MemoryMonitor::start(); - let mut checkpoints = Checkpoints { out: input.out, rerun: false }; + let mut checkpoints = Checkpoints { out: input.out, rerun: config.fresh }; let proof = run_stages(&mut checkpoints, config, &words)?; storage::write_atomic(&input.out.join(COMPRESSED_PROOF), &proof)?; Ok(proof) @@ -362,57 +352,68 @@ fn run_stages( words: &statement::Statement, ) -> Result> { let root = checkpoints.out.join(ROOT); + let root_vk = fs::read(root.join("root-vk.bin"))?; + let bundled = verifying_keys::bundled_for(&root_vk, &config.setup); let fri = checkpoints.stage( "fri", - "fri/v1", &tracing::info_span!("compression/fri"), |dir| root::run(&root, dir, true), - |dir| check_fri(dir, words), + |dir| check_fri(dir, words, &root), )?; #[cfg(feature = "cuda")] crate::kzg::cuda::configure(config.cuda.clone()); let stage = StageConfig::from_config(config); - let setup = config.setup_key(); - let log_height = usize::try_from(config.log_height)?; let first = checkpoints.stage( "first-kzg", - &format!("first-kzg/v1 log-height={log_height} setup={setup}"), &tracing::info_span!("compression/kzg"), - |dir| first::run(&fri, dir, log_height, &stage), - |dir| check_first(dir, words), + |dir| first::run(&fri, dir, verifying_keys::FIRST_LOG_HEIGHT, &stage), + |dir| wrap::check_first(dir, words, &stage), )?; let final_dir = checkpoints.stage( "final-kzg", - &format!("final-kzg/v1 setup={setup}"), &tracing::info_span!("compression/wrap"), |dir| wrap::run(&first, dir, &stage), - |dir| check_final(dir, words, &config.setup), + |dir| check_final(dir, words, &config.setup, &root_vk), )?; drop(stage); - let terminal = config.terminal; - let key = format!( - "{:?}/v1 allow-development={} setup={setup}", - terminal.system, terminal.allow_development - ); - let dir = match terminal.system { - TerminalSystem::None => { + let final_proof = config.final_proof; + let dir = match final_proof.system { + FinalSystem::None => { let mut proof = super::PROOF_TAG.to_vec(); proof.extend(fs::read(final_dir.join("kzg/packet.bin"))?); return Ok(proof); }, - TerminalSystem::Plonk => checkpoints.stage( + FinalSystem::Plonk => checkpoints.stage( "plonk", - &key, &tracing::info_span!("compression/plonk"), |dir| prove_plonk(&final_dir, dir, words, config), - |dir| check_envelope(dir, plonk::PROOF_TAG), + |dir| { + plonk::check_stage( + dir, + words, + &config.setup, + bundled, + &final_dir.join("vk.bin"), + final_proof.allow_development, + &config.plonk_cache, + ) + }, )?, - TerminalSystem::Groth16 => checkpoints.stage( + FinalSystem::Groth16 => checkpoints.stage( "groth16", - &key, &tracing::info_span!("compression/groth16"), |dir| prove_groth16(&final_dir, dir, words, config), - |dir| check_envelope(dir, groth16::PROOF_TAG), + |dir| { + groth16::check_stage( + dir, + words, + final_proof.allow_development, + bundled, + &final_dir.join("vk.bin"), + &config.setup, + &config.groth16_cache, + ) + }, )?, }; Ok(fs::read(dir.join("proof.envelope.bin"))?) @@ -426,37 +427,27 @@ struct Checkpoints<'a> { } impl Checkpoints<'_> { - /// The directory of stage `name`. When it is marked complete under `key` - /// and no earlier stage ran, it is checked and reused; otherwise it is - /// deleted, proved again, checked and marked. Pooled GPU memory is - /// returned to the driver after the stage proves, whether or not it - /// succeeded. + /// The directory of stage `name`. Unless an earlier stage ran, it is + /// reused when `check` accepts its contents; otherwise it is deleted, proved + /// again and checked. Pooled GPU memory is returned to the driver after the + /// stage proves, whether or not it succeeded. fn stage( &mut self, name: &str, - key: &str, span: &tracing::Span, prove: impl FnOnce(&Path) -> Result<()>, - check: impl FnOnce(&Path) -> Result<()>, + check: impl Fn(&Path) -> Result<()>, ) -> Result { let dir = self.out.join(name); - let marker = dir.join(COMPLETE); - if !self.rerun { - match fs::read(&marker) { - Ok(recorded) if recorded == key.as_bytes() => { - check(&dir).map_err(|error| { - format!( - "completed compression {name} stage in {} fails its check: \ - {error}; delete it to prove it again", - dir.display() - ) - })?; - tracing::info!("compression {name}: reusing the completed stage"); + if !self.rerun && dir.is_dir() { + match check(&dir) { + Ok(()) => { + eprintln!("compression {name}: reusing the verified stage"); return Ok(dir); }, - Ok(_) => {}, - Err(error) if error.kind() == io::ErrorKind::NotFound => {}, - Err(error) => return Err(error.into()), + Err(error) => { + eprintln!("compression {name}: proving again: {error}"); + }, } } self.rerun = true; @@ -470,67 +461,59 @@ impl Checkpoints<'_> { multi_stark::cuda::release_device_memory(); proved.and_then(|()| check(&dir)).map_err(|error| { format!( - "compression {name} failed: {error}; completed stages are kept for a \ + "compression {name} failed: {error}; earlier stages are kept for a \ rerun" ) })?; - storage::write_atomic(&marker, key.as_bytes())?; Ok(dir) } } -/// The saved FRI proof exposes the expected root statement. -fn check_fri(dir: &Path, words: &statement::Statement) -> Result<()> { +/// The saved FRI proof verifies, exposes the expected root statement, and +/// was proved for the root's layout and backend. +fn check_fri( + dir: &Path, + words: &statement::Statement, + root: &Path, +) -> Result<()> { let fixture = init_fri::load(dir)?; let actual = statement::from_scalars(&stages::scalar_statement(&fixture.public))?; if actual != *words { - return Err( - "saved FRI statement differs from the expected root statement".into(), - ); + return Err("FRI proof proves another statement".into()); } - Ok(()) -} - -/// The first KZG packet carries the expected statement after its verifying -/// key id. The recursive stage verifies its proof. -fn check_first(dir: &Path, words: &statement::Statement) -> Result<()> { - let packet = fs::read(dir.join("kzg/packet.bin"))?; - let encoded: Vec = - words.iter().flat_map(|word| word.to_le_bytes()).collect(); - if packet.get(32..32 + encoded.len()) != Some(encoded.as_slice()) { - return Err( - "first KZG packet differs from the expected root statement".into(), - ); + let layout = root.join(root::FIXED_ROOT_LAYOUT); + if layout.is_file() { + let key = aiur::vk_codec::AiurVerifyingKey::from_bytes(&fs::read( + root.join("root-vk.bin"), + )?)?; + let fixed = FixedRoot::from_json(&fs::read_to_string(layout)?)?; + if fs::read(dir.join("plan-id.bin"))? != fixed.identity(&key) { + return Err("FRI proof was made for another layout or backend".into()); + } } Ok(()) } -/// The recursive verifying key is admitted by the setup and verifies the -/// recursive packet for the expected statement, and a release key compresses -/// the release's aggregation program. +/// The recursive verifying key is admitted by the setup, is the bundled key +/// for this version's backend under Ignition, and verifies the recursive +/// packet for the expected statement. fn check_final( dir: &Path, words: &statement::Statement, setup: &SetupConfig, + root_vk: &[u8], ) -> Result<()> { - let vk = wrap::VerifyingKey::from_bytes(&fs::read(dir.join("vk.bin"))?)?; + let bytes = fs::read(dir.join("vk.bin"))?; + verifying_keys::check_recursive_key(root_vk, setup, &bytes)?; + let vk = wrap::VerifyingKey::from_bytes(&bytes)?; if let Some(admission) = setup.ignition_admission() { vk.check_ignition_admission(admission)?; } else { vk.check_setup_class(setup.class())?; } vk.verify(&fs::read(dir.join("kzg/packet.bin"))?, words)?; - super::verifying_keys::check_statement(&vk.id(), words) -} - -/// The terminal stage left a proof envelope of its system. -fn check_envelope(dir: &Path, tag: &[u8]) -> Result<()> { - let envelope = fs::read(dir.join("proof.envelope.bin"))?; - if envelope.len() <= tag.len() + 32 || !envelope.starts_with(tag) { - return Err("terminal proof envelope is malformed".into()); - } - Ok(()) + verifying_keys::check_statement(&vk.id(), words) } #[cfg(feature = "plonk")] @@ -550,6 +533,7 @@ fn prove_plonk( &config.plonk_cache, out, &config.setup, + config.final_proof.allow_development, ) } @@ -578,8 +562,9 @@ fn prove_groth16( trusted.as_str(), words, out, - config.terminal.allow_development, + config.final_proof.allow_development, &config.setup, + &config.groth16_cache, ) } @@ -640,92 +625,110 @@ mod tests { #[test] fn output_resumes_only_the_same_root() -> Result<()> { let out = scratch("open-output"); - open_output(&out, &files(b"proof"))?; + open_output(&out, &files(b"proof"), false)?; assert_eq!(fs::read(out.join("root/root-proof.bin"))?, b"proof"); // An interrupted root write is completed. fs::remove_file(out.join("root/root-claims.bin"))?; - open_output(&out, &files(b"proof"))?; + open_output(&out, &files(b"proof"), false)?; assert!(out.join("root/root-claims.bin").is_file()); // Another proof, or a layout the earlier run did not have, is refused // without changing the directory. fs::create_dir(out.join("fri"))?; - assert!(open_output(&out, &files(b"other")).is_err()); + assert!(open_output(&out, &files(b"other"), false).is_err()); fs::write(out.join("root").join(root::FIXED_ROOT_LAYOUT), b"{}")?; - assert!(open_output(&out, &files(b"proof")).is_err()); + assert!(open_output(&out, &files(b"proof"), false).is_err()); fs::remove_file(out.join("root").join(root::FIXED_ROOT_LAYOUT))?; assert_eq!(fs::read(out.join("root/root-proof.bin"))?, b"proof"); assert!(out.join("fri").is_dir()); // A non-empty directory without a root is refused. let other = scratch("open-output-foreign"); fs::create_dir_all(other.join("data"))?; - assert!(open_output(&other, &files(b"proof")).is_err()); + assert!(open_output(&other, &files(b"proof"), false).is_err()); + assert!(open_output(&other, &files(b"proof"), true).is_err()); assert!(!other.join(ROOT).exists()); fs::remove_dir_all(other)?; fs::remove_dir_all(out)?; Ok(()) } + #[test] + fn fresh_output_replaces_stages_without_touching_unrelated_files() + -> Result<()> { + let out = scratch("fresh-output"); + open_output(&out, &files(b"old proof"), false)?; + fs::write(out.join("notes"), b"keep")?; + fs::write(out.join(COMPRESSED_PROOF), b"old final proof")?; + fs::write(out.join(ROOT).join(root::FIXED_ROOT_LAYOUT), b"old layout")?; + for name in ["fri", "first-kzg", "final-kzg", "plonk", "groth16"] { + fs::create_dir(out.join(name))?; + fs::write(out.join(name).join("proof"), b"old stage")?; + } + open_output(&out, &files(b"new proof"), true)?; + assert_eq!(fs::read(out.join(ROOT).join("root-proof.bin"))?, b"new proof"); + assert_eq!(fs::read(out.join("notes"))?, b"keep"); + assert!(!out.join(COMPRESSED_PROOF).exists()); + assert!(!out.join(ROOT).join(root::FIXED_ROOT_LAYOUT).exists()); + for name in ["fri", "first-kzg", "final-kzg", "plonk", "groth16"] { + assert!(!out.join(name).exists()); + } + fs::remove_dir_all(out)?; + Ok(()) + } + fn checkpoints(out: &Path) -> Checkpoints<'_> { Checkpoints { out, rerun: false } } #[test] - fn completed_stages_are_reused_until_one_reruns() -> Result<()> { + fn stages_are_reused_while_their_output_checks() -> Result<()> { let out = scratch("checkpoints"); let proved = std::cell::RefCell::new(Vec::new()); - let run = - |checkpoints: &mut Checkpoints<'_>, keys: [&str; 3]| -> Result<()> { - for (name, key) in ["a", "b", "c"].into_iter().zip(keys) { - checkpoints.stage( - name, - key, - &tracing::Span::none(), - |dir| { - proved.borrow_mut().push(name); - fs::create_dir_all(dir)?; - Ok(fs::write(dir.join("output"), name)?) - }, - |dir| { - if fs::read(dir.join("output"))? == name.as_bytes() { - Ok(()) - } else { - Err("damaged".into()) - } - }, - )?; - } - Ok(()) - }; - run(&mut checkpoints(&out), ["1", "1", "1"])?; + let run = |checkpoints: &mut Checkpoints<'_>| -> Result<()> { + for name in ["a", "b", "c"] { + checkpoints.stage( + name, + &tracing::Span::none(), + |dir| { + proved.borrow_mut().push(name); + fs::create_dir_all(dir)?; + Ok(fs::write(dir.join("output"), name)?) + }, + |dir| { + if fs::read(dir.join("output"))? == name.as_bytes() { + Ok(()) + } else { + Err("damaged".into()) + } + }, + )?; + } + Ok(()) + }; + run(&mut checkpoints(&out))?; assert_eq!(proved.take(), ["a", "b", "c"]); - run(&mut checkpoints(&out), ["1", "1", "1"])?; + run(&mut checkpoints(&out))?; assert!(proved.take().is_empty()); - // A stage without its marker reruns, and so does every later stage. - fs::remove_file(out.join("b").join(COMPLETE))?; + run(&mut Checkpoints { out: &out, rerun: true })?; + assert_eq!(proved.take(), ["a", "b", "c"]); + // A stage whose output fails its check is proved again, and so is every + // later stage. + fs::write(out.join("b/output"), "x")?; fs::write(out.join("b/stale"), "")?; - run(&mut checkpoints(&out), ["1", "1", "1"])?; + run(&mut checkpoints(&out))?; assert_eq!(proved.take(), ["b", "c"]); assert!(!out.join("b/stale").exists()); - // A stage marked under another configuration reruns. - run(&mut checkpoints(&out), ["1", "1", "2"])?; + // So is a missing stage. + fs::remove_dir_all(out.join("c"))?; + run(&mut checkpoints(&out))?; assert_eq!(proved.take(), ["c"]); - // A marked stage that fails its check is reported, not overwritten. - fs::write(out.join("a/output"), "x")?; - assert!(run(&mut checkpoints(&out), ["1", "1", "2"]).is_err()); - assert!(proved.take().is_empty()); - // A failed stage is left unmarked. + // A stage whose fresh output fails its check is an error. let failing = checkpoints(&out).stage( "d", - "1", &tracing::Span::none(), - |dir| { - fs::create_dir_all(dir)?; - Err("failed".into()) - }, - |_dir| Ok(()), + |dir| Ok(fs::create_dir_all(dir)?), + |_dir| Err("unverifiable".into()), ); assert!(failing.is_err()); - assert!(!out.join("d").join(COMPLETE).exists()); fs::remove_dir_all(out)?; Ok(()) } diff --git a/crates/proof-compression/src/pipeline/derive.rs b/crates/proof-compression/src/pipeline/derive.rs index c9cd1d27c..dcdb38f8a 100644 --- a/crates/proof-compression/src/pipeline/derive.rs +++ b/crates/proof-compression/src/pipeline/derive.rs @@ -2,27 +2,25 @@ //! stage directory is read: each stage's verifier is built by the //! construction its proving path uses, so a derived key is the key every //! compression under the same inputs proves to. -use super::{FixedRoot, Result, SetupConfig, first, root, wrap}; +use super::{ + FixedRoot, Result, SetupConfig, first, root, verifying_keys, wrap, +}; /// Derive the recursive verifying key, as `compress` writes it to /// `final-kzg/vk.bin`, for roots of the aggregation backend whose serialized /// key is `root_vk`, normalized to the fixed layout `layout` (JSON), and -/// compressed under `setup` with a first KZG stage of `2^log_height` rows. +/// compressed under `setup`. pub fn derive_recursive_vk( root_vk: &[u8], layout: &str, setup: &SetupConfig, - log_height: u32, ) -> Result> { - if !matches!(log_height, 24 | 25) { - return Err("the first KZG stage's log-height must be 24 or 25".into()); - } let fri = { let key = aiur::vk_codec::AiurVerifyingKey::from_bytes(root_vk)?; root::verifier(&key, &FixedRoot::from_json(layout)?)? }; let (manifest, parts) = - first::setup(&fri, usize::try_from(log_height)?, setup)?; + first::setup(&fri, verifying_keys::FIRST_LOG_HEIGHT, setup)?; drop(fri); let vk = wrap::verifying_key(&manifest, parts, setup)?; match setup.ignition_admission() { @@ -54,8 +52,7 @@ mod tests { let root_vk = std::fs::read(std::env::var_os("IX_ROOT_VK").unwrap()).unwrap(); let layout = verifying_keys::release_root_layout(&root_vk).unwrap(); - let derived = - derive_recursive_vk(&root_vk, layout, &ignition(), 24).unwrap(); + let derived = derive_recursive_vk(&root_vk, layout, &ignition()).unwrap(); eprintln!("derived recursive key BLAKE3 {}", blake3::hash(&derived)); assert!(derived == RECURSIVE_VK, "derived recursive key differs"); } @@ -66,10 +63,10 @@ mod tests { use sha2::{Digest, Sha256}; let vk = wrap::VerifyingKey::from_bytes(RECURSIVE_VK).unwrap(); let description = - wrap::plonk_description(&vk, &verifying_keys::STATEMENT_PREFIX).unwrap(); + wrap::final_description(&vk, &verifying_keys::STATEMENT_PREFIX).unwrap(); assert_eq!( Sha256::digest(&description).as_slice(), - verifying_keys::decode(verifying_keys::TERMINAL_SCHEMA_SHA256) + verifying_keys::decode(verifying_keys::FINAL_SCHEMA_SHA256).unwrap() ); } diff --git a/crates/proof-compression/src/pipeline/development.rs b/crates/proof-compression/src/pipeline/development.rs new file mode 100644 index 000000000..99640a443 --- /dev/null +++ b/crates/proof-compression/src/pipeline/development.rs @@ -0,0 +1,339 @@ +//! Explicitly pinned, portable verifiers for locally generated final keys. +use super::final_proof::{decode_hex, digest}; +use super::{FinalSystem, Result, SetupClass}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[cfg(any(feature = "plonk", feature = "groth16"))] +use super::{SetupConfig, final_proof::hex, statement, storage}; +#[cfg(any(feature = "plonk", feature = "groth16"))] +use std::{fs, path::Path}; + +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub(super) const FILE: &str = "development-verifier.json"; +const FORMAT: &str = "ix-development-verifier-v1"; +const MAX_BYTES: usize = 4 << 20; + +/// The caller authorizes this entire object by its BLAKE3 digest. A proof's +/// embedded key identifier is never sufficient to authorize another circuit. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct Verifier { + format: String, + system: FinalSystem, + inner_setup: String, + inner_vk_blake3: String, + schema_sha256: String, + statement_prefix: [u64; 10], + verifying_key_sha256: String, + verifying_key: String, + plonk_srs_anchors: Option, +} + +impl Verifier { + #[cfg(any(feature = "plonk", feature = "groth16"))] + pub(super) fn new( + system: FinalSystem, + inner_vk: &Path, + schema: &[u8], + expected: &statement::Statement, + inner_setup: &SetupConfig, + key: &[u8], + anchors: Option<&[u8]>, + ) -> Result { + let value = Self { + format: FORMAT.into(), + system, + inner_setup: inner_setup.class().label().into(), + inner_vk_blake3: blake3::hash(&fs::read(inner_vk)?).to_hex().to_string(), + schema_sha256: hex(&Sha256::digest(schema)), + statement_prefix: expected[..10].try_into()?, + verifying_key_sha256: hex(&Sha256::digest(key)), + verifying_key: hex(key), + plonk_srs_anchors: anchors.map(hex), + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<()> { + if self.format != FORMAT + || !matches!(self.inner_setup.as_str(), "ignition" | "development") + || self.system == FinalSystem::None + || self.verifying_key.len() > 2 << 20 + { + return Err("invalid development verifier bundle".into()); + } + digest(&self.inner_vk_blake3)?; + digest(&self.schema_sha256)?; + let key = decode_hex(&self.verifying_key)?; + if key.is_empty() + || Sha256::digest(&key).as_slice() != digest(&self.verifying_key_sha256)? + { + return Err("development verifying key differs from its pin".into()); + } + match (&self.system, &self.plonk_srs_anchors) { + (FinalSystem::Plonk, Some(anchors)) + if decode_hex(anchors)?.len() == 320 => {}, + (FinalSystem::Groth16, None) => {}, + _ => return Err("development verifier setup anchors differ".into()), + } + Ok(()) + } + + #[cfg(any(feature = "plonk", feature = "groth16"))] + pub(super) fn write(&self, dir: &Path) -> Result<()> { + self.validate()?; + let bytes = serde_json::to_vec_pretty(self)?; + let pin = blake3::hash(&bytes).to_hex().to_string(); + storage::write_atomic(&dir.join(FILE), &bytes)?; + storage::write_atomic( + &dir.join("development-verifier.blake3"), + format!("{pin}\n").as_bytes(), + )?; + eprintln!( + "development verifier: {} (BLAKE3 {pin})", + dir.join(FILE).display() + ); + Ok(()) + } + + #[cfg(any(feature = "plonk", feature = "groth16"))] + pub(super) fn check_saved(&self, dir: &Path) -> Result<()> { + let bytes = super::final_proof::read_bounded(&dir.join(FILE), MAX_BYTES)?; + let saved: Self = serde_json::from_slice(&bytes)?; + saved.validate()?; + if saved != *self { + return Err("saved development verifier differs from the prepared circuit and setup".into()); + } + let pin = fs::read_to_string(dir.join("development-verifier.blake3"))?; + if digest(pin.trim())? != *blake3::hash(&bytes).as_bytes() { + return Err("saved development verifier pin differs".into()); + } + Ok(()) + } +} + +/// Verify a final proof under an independently pinned development verifier. +/// The pin must be supplied by the caller, not read from the proof or bundle. +pub fn verify_with_development_verifier( + proof: &[u8], + claim: &[u8], + bundle: &[u8], + trusted_digest: &[u8; 32], + allow_development: bool, +) -> Result { + if !allow_development { + return Err("development verifier requires --dev-srs".into()); + } + if bundle.len() > MAX_BYTES + || blake3::hash(bundle).as_bytes() != trusted_digest + { + return Err( + "development verifier bundle differs from its independent pin".into(), + ); + } + let verifier: Verifier = serde_json::from_slice(bundle)?; + verifier.validate()?; + if proof.len() > (1 << 20) + 40 { + return Err("development proof exceeds its size limit".into()); + } + let tag = match verifier.system { + FinalSystem::Plonk => super::plonk::PROOF_TAG, + FinalSystem::Groth16 => super::groth16::PROOF_TAG, + FinalSystem::None => { + return Err("development verifier has no final proof system".into()); + }, + }; + let packet = proof + .strip_prefix(tag.as_slice()) + .ok_or("proof system differs from development verifier")?; + let key_hash = digest(&verifier.verifying_key_sha256)?; + if packet.get(..32) != Some(key_hash.as_slice()) { + return Err("proof key differs from development verifier".into()); + } + #[cfg(any(feature = "plonk", feature = "groth16"))] + let key = decode_hex(&verifier.verifying_key)?; + #[cfg(any(feature = "plonk", feature = "groth16"))] + let claim_hash = blake3::hash(claim); + #[cfg(not(any(feature = "plonk", feature = "groth16")))] + let _ = claim; + let checked: Result<()> = match verifier.system { + #[cfg(feature = "plonk")] + FinalSystem::Plonk => super::gnark::verify( + &key, + &key_hash, + &packet[32..], + claim_hash.as_bytes(), + &decode_hex( + verifier + .plonk_srs_anchors + .as_deref() + .ok_or("missing PLONK setup anchors")?, + )?, + ), + #[cfg(not(feature = "plonk"))] + FinalSystem::Plonk => { + Err("this ix was built without PLONK; rebuild with IX_PLONK=1".into()) + }, + #[cfg(feature = "groth16")] + FinalSystem::Groth16 => super::gnark::verify_groth16( + &key, + &key_hash, + &packet[32..], + claim_hash.as_bytes(), + ), + #[cfg(not(feature = "groth16"))] + FinalSystem::Groth16 => { + Err("this ix was built without Groth16; rebuild with IX_GROTH16=1".into()) + }, + FinalSystem::None => { + Err("development verifier has no final proof system".into()) + }, + }; + checked?; + Ok(SetupClass::Development) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn development_permission_and_pin_precede_decoding() { + let bundle = b"not json"; + let pin = *blake3::hash(bundle).as_bytes(); + let error = verify_with_development_verifier(b"", b"", bundle, &pin, false) + .unwrap_err(); + assert!(error.to_string().contains("--dev-srs")); + let error = + verify_with_development_verifier(b"", b"", bundle, &[0; 32], true) + .unwrap_err(); + assert!(error.to_string().contains("independent pin")); + } + + #[cfg(any(feature = "plonk", feature = "groth16"))] + fn fixture_bundle( + system: FinalSystem, + key: &[u8], + anchors: Option, + ) -> Verifier { + let schema: serde_json::Value = serde_json::from_slice(include_bytes!( + "../../../../tools/plonk-wrapper/testdata/bn254/vk.json" + )) + .unwrap(); + Verifier { + format: FORMAT.into(), + system, + inner_setup: "ignition".into(), + inner_vk_blake3: "00".repeat(32), + schema_sha256: hex(&Sha256::digest(include_bytes!( + "../../../../tools/plonk-wrapper/testdata/bn254/vk.json" + ))), + statement_prefix: serde_json::from_value(schema["prefix"].clone()) + .unwrap(), + verifying_key_sha256: hex(&Sha256::digest(key)), + verifying_key: hex(key), + plonk_srs_anchors: anchors, + } + } + + #[cfg(any(feature = "plonk", feature = "groth16"))] + fn check_fixture(verifier: Verifier, tag: &[u8], proof: &[u8]) -> Result<()> { + let mut envelope = tag.to_vec(); + envelope.extend(digest(&verifier.verifying_key_sha256)?); + envelope.extend(proof); + let bytes = serde_json::to_vec(&verifier)?; + let pin = *blake3::hash(&bytes).as_bytes(); + let claim = include_bytes!("../../tests/fixtures/init-fri/claim.bin"); + assert_eq!( + verify_with_development_verifier(&envelope, claim, &bytes, &pin, true)?, + SetupClass::Development + ); + assert!( + verify_with_development_verifier(&envelope, claim, &bytes, &pin, false) + .is_err() + ); + assert!( + verify_with_development_verifier( + &envelope, + b"another claim", + &bytes, + &pin, + true + ) + .is_err() + ); + let mut wrong_key = envelope.clone(); + wrong_key[8] ^= 1; + assert!( + verify_with_development_verifier(&wrong_key, claim, &bytes, &pin, true) + .is_err() + ); + let mut wrong_system = envelope.clone(); + wrong_system[7] ^= 1; + assert!( + verify_with_development_verifier( + &wrong_system, + claim, + &bytes, + &pin, + true + ) + .is_err() + ); + let mut corrupted = envelope.clone(); + *corrupted.last_mut().unwrap() ^= 1; + assert!( + verify_with_development_verifier(&corrupted, claim, &bytes, &pin, true) + .is_err() + ); + assert!(super::super::verify(&envelope, claim, true).is_err()); + let mut tampered = verifier; + tampered.statement_prefix[0] ^= 1; + assert!( + verify_with_development_verifier( + &envelope, + claim, + &serde_json::to_vec(&tampered)?, + &pin, + true + ) + .is_err() + ); + Ok(()) + } + + #[test] + #[cfg(feature = "groth16")] + fn pinned_groth16_fixture_checks_claim_and_rejects_default_trust() + -> Result<()> { + check_fixture( + fixture_bundle( + FinalSystem::Groth16, + include_bytes!("../../tests/fixtures/groth16/verifying-key.bin"), + None, + ), + super::super::groth16::PROOF_TAG, + include_bytes!("../../tests/fixtures/groth16/proof.bin"), + ) + } + + #[test] + #[cfg(feature = "plonk")] + fn pinned_plonk_fixture_checks_claim_and_rejects_default_trust() -> Result<()> + { + let provenance: serde_json::Value = serde_json::from_slice( + include_bytes!("../../tests/fixtures/plonk/provenance.json"), + )?; + check_fixture( + fixture_bundle( + FinalSystem::Plonk, + include_bytes!("../../tests/fixtures/plonk/verifying-key.bin"), + Some(provenance["anchors"].as_str().unwrap().to_string()), + ), + super::super::plonk::PROOF_TAG, + include_bytes!("../../tests/fixtures/plonk/proof.bin"), + ) + } +} diff --git a/crates/proof-compression/src/pipeline/terminal.rs b/crates/proof-compression/src/pipeline/final_proof.rs similarity index 55% rename from crates/proof-compression/src/pipeline/terminal.rs rename to crates/proof-compression/src/pipeline/final_proof.rs index 63b5184e1..b32899776 100644 --- a/crates/proof-compression/src/pipeline/terminal.rs +++ b/crates/proof-compression/src/pipeline/final_proof.rs @@ -1,4 +1,4 @@ -//! Shared transport and artifact checks for terminal proof backends. +//! Shared transport and artifact checks for final proof backends. use super::Result; #[cfg(any(feature = "plonk", feature = "groth16"))] use super::statement; @@ -23,57 +23,91 @@ pub struct Artifact { pub bytes: u64, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, +)] +#[serde(rename_all = "lowercase")] #[repr(u8)] -pub enum TerminalSystem { +pub enum FinalSystem { #[default] None = 0, Plonk = 1, Groth16 = 2, } -impl TryFrom for TerminalSystem { +impl TryFrom for FinalSystem { type Error = &'static str; fn try_from(value: u8) -> std::result::Result { match value { 0 => Ok(Self::None), 1 => Ok(Self::Plonk), 2 => Ok(Self::Groth16), - _ => Err("unknown terminal proof system"), + _ => Err("unknown final proof system"), } } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct TerminalOptions { - pub system: TerminalSystem, +pub struct FinalOptions { + pub system: FinalSystem, pub allow_development: bool, } -impl From for TerminalOptions { - fn from(system: TerminalSystem) -> Self { +impl From for FinalOptions { + fn from(system: FinalSystem) -> Self { Self { system, allow_development: false } } } -impl TryFrom for TerminalOptions { - type Error = &'static str; - fn try_from(value: u8) -> std::result::Result { - if value == 3 { - Ok(Self { system: TerminalSystem::Groth16, allow_development: true }) - } else { - Ok(TerminalSystem::try_from(value)?.into()) +impl FinalOptions { + /// A development inner verifier needs separately prepared final keys; the + /// production final keys remain bound to the admitted inner verifier. + pub(super) fn validate_setup( + self, + development_kzg: bool, + ) -> std::result::Result<(), &'static str> { + if development_kzg + && self.system != FinalSystem::None + && !self.allow_development + { + return Err( + "development inner KZG requires --dev-final-srs with a final proof", + ); } + if self.allow_development { + match self.system { + FinalSystem::None => { + return Err("--dev-final-srs requires a final proof system"); + }, + FinalSystem::Plonk | FinalSystem::Groth16 => {}, + } + } + Ok(()) } } +/// Reconstruct the final circuit from the authenticated inner key and expected +/// program prefix, independently of any saved final proof or witness. +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub(super) fn description( + verifier: &Path, + expected: &statement::Statement, + setup: &super::SetupConfig, +) -> Result> { + let vk = super::wrap::VerifyingKey::from_bytes(&fs::read(verifier)?)?; + match setup.ignition_admission() { + Some(admission) => vk.check_ignition_admission(admission)?, + None => vk.check_setup_class(setup.class())?, + } + super::wrap::final_description(&vk, &expected[..10]) +} + #[cfg(all(test, any(feature = "plonk", feature = "groth16")))] pub(super) struct Scratch(pub(super) PathBuf); #[cfg(all(test, any(feature = "plonk", feature = "groth16")))] impl Scratch { pub(super) fn new() -> Result { - let path = - storage::temporary_path(&std::env::temp_dir().join("ix-terminal")); + let path = storage::temporary_path(&std::env::temp_dir().join("ix-final")); fs::create_dir(&path)?; Ok(Self(path)) } @@ -131,7 +165,7 @@ pub(super) fn read_bounded(path: &Path, limit: usize) -> Result> { let mut bytes = Vec::new(); fs::File::open(path)?.take((limit + 1) as u64).read_to_end(&mut bytes)?; if bytes.len() > limit { - return Err("terminal input exceeds its size limit".into()); + return Err("final input exceeds its size limit".into()); } Ok(bytes) } @@ -160,21 +194,31 @@ mod tests { use super::*; #[test] - fn terminal_selection_keeps_development_explicit() { - assert_eq!( - TerminalOptions::try_from(0).unwrap().system, - TerminalSystem::None - ); - assert_eq!( - TerminalOptions::try_from(1).unwrap().system, - TerminalSystem::Plonk - ); - let production = TerminalOptions::try_from(2).unwrap(); - let development = TerminalOptions::try_from(3).unwrap(); - assert_eq!(production.system, TerminalSystem::Groth16); - assert!(!production.allow_development); - assert!(development.allow_development); - assert_eq!(development.system, TerminalSystem::Groth16); - assert!(TerminalOptions::try_from(4).is_err()); + fn final_selection_keeps_development_explicit() { + for (tag, system) in [ + (0, FinalSystem::None), + (1, FinalSystem::Plonk), + (2, FinalSystem::Groth16), + ] { + assert_eq!(FinalSystem::try_from(tag).unwrap(), system); + assert!(!FinalOptions::from(system).allow_development); + } + assert!(FinalSystem::try_from(3).is_err()); + assert!(FinalSystem::try_from(4).is_err()); + for system in [FinalSystem::None, FinalSystem::Plonk, FinalSystem::Groth16] + { + for development_kzg in [false, true] { + for allow_development in [false, true] { + let options = FinalOptions { system, allow_development }; + let allowed = match system { + FinalSystem::None => !allow_development, + FinalSystem::Plonk | FinalSystem::Groth16 => { + !development_kzg || allow_development + }, + }; + assert_eq!(options.validate_setup(development_kzg).is_ok(), allowed); + } + } + } } } diff --git a/crates/proof-compression/src/pipeline/gnark.rs b/crates/proof-compression/src/pipeline/gnark.rs index 5b6d8001e..4b33ed08d 100644 --- a/crates/proof-compression/src/pipeline/gnark.rs +++ b/crates/proof-compression/src/pipeline/gnark.rs @@ -1,9 +1,11 @@ //! Owned gnark objects behind the gnark archive's C ABI. use super::Result; -use serde::{Deserialize, Serialize}; -use std::{ - collections::BTreeMap, ffi::c_void, marker::PhantomData, path::Path, rc::Rc, -}; +use serde::Deserialize; +#[cfg(feature = "plonk")] +use serde::Serialize; +#[cfg(feature = "plonk")] +use std::collections::BTreeMap; +use std::{ffi::c_void, marker::PhantomData, path::Path, rc::Rc}; #[allow( non_camel_case_types, @@ -16,8 +18,9 @@ mod raw { include!(concat!(env!("OUT_DIR"), "/gnark_bindings.rs")); } -pub use super::terminal::Artifact; +pub use super::final_proof::Artifact; +#[cfg(feature = "plonk")] #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase", deny_unknown_fields)] pub struct Manifest { @@ -42,6 +45,7 @@ pub struct CircuitInfo { pub commitments: usize, } +#[cfg(feature = "plonk")] #[derive(Clone, Copy, Debug, Default)] pub enum Backend { #[default] @@ -96,6 +100,7 @@ impl Response { } } +#[cfg(feature = "plonk")] pub fn compile(schema: &[u8], circuit_path: &Path) -> Result { // Inputs remain borrowed until the synchronous C call returns. let result = @@ -103,6 +108,7 @@ pub fn compile(schema: &[u8], circuit_path: &Path) -> Result { Ok(serde_json::from_slice(Response::checked(result)?.bytes())?) } +#[cfg(feature = "plonk")] pub struct Preparation<'a> { pub schema: &'a [u8], pub circuit: &'a Path, @@ -115,6 +121,7 @@ pub struct Preparation<'a> { pub output: &'a Path, } +#[cfg(feature = "plonk")] pub fn prepare(request: &Preparation<'_>) -> Result<()> { // The adapter retains no Rust pointers after this call. let result = unsafe { @@ -142,6 +149,7 @@ pub struct Prover { _thread: PhantomData>, } impl Prover { + #[cfg(feature = "plonk")] pub fn open( directory: &Path, manifest: &Manifest, @@ -205,6 +213,7 @@ impl Drop for Prover { } } +#[cfg(feature = "plonk")] pub fn verify( key: &[u8], trusted_digest: &[u8; 32], @@ -228,13 +237,13 @@ pub fn verify( #[cfg(all(test, feature = "plonk"))] mod tests { use super::*; - use crate::pipeline::terminal::{decode_hex, digest}; + use crate::pipeline::final_proof::{decode_hex, digest}; const KEY: &[u8] = - include_bytes!("../../tests/fixtures/gnark/verifying-key.bin"); - const PROOF: &[u8] = include_bytes!("../../tests/fixtures/gnark/proof.bin"); + include_bytes!("../../tests/fixtures/plonk/verifying-key.bin"); + const PROOF: &[u8] = include_bytes!("../../tests/fixtures/plonk/proof.bin"); fn fixture() -> ([u8; 32], [u8; 32], Vec) { let p: serde_json::Value = serde_json::from_str(include_str!( - "../../tests/fixtures/gnark/provenance.json" + "../../tests/fixtures/plonk/provenance.json" )) .unwrap(); ( @@ -286,7 +295,7 @@ mod tests { #[ignore = "compiles the full Init verifier circuit"] fn compiles_init_without_changing_constraints() -> Result<()> { use sha2::Digest; - let directory = crate::pipeline::terminal::Scratch::new()?; + let directory = crate::pipeline::final_proof::Scratch::new()?; let schema = include_bytes!("../../../../tools/plonk-wrapper/testdata/bn254/vk.json"); let path = directory.0.join("circuit.bin"); @@ -296,7 +305,7 @@ mod tests { assert_eq!(info.public_inputs, 2); let digest = sha2::Sha256::digest(std::fs::read(&path)?); assert_eq!( - crate::pipeline::terminal::hex(&digest), + crate::pipeline::final_proof::hex(&digest), "a684964f61296fcda0df427980275764df242590373ce5680f70b0ac5dcfbded" ); Ok(()) @@ -311,7 +320,7 @@ mod tests { let expected = std::env::var("IX_GNARK_INIT_MANIFEST_SHA256")?; use sha2::Digest; assert_eq!( - crate::pipeline::terminal::hex(&sha2::Sha256::digest(&data)), + crate::pipeline::final_proof::hex(&sha2::Sha256::digest(&data)), expected ); let metadata: Manifest = serde_json::from_slice(&data)?; @@ -386,7 +395,6 @@ pub(super) enum Groth16Backend { Cpu, Cuda { device: i32, - pin_keys: bool, library: String, }, } @@ -398,16 +406,15 @@ impl Prover { metadata: &super::groth16::Metadata, backend: &Groth16Backend, ) -> Result { - let (cuda, device, pin_keys, library) = match backend { - Groth16Backend::Cpu => (0, 0, 0, ""), - Groth16Backend::Cuda { device, pin_keys, library } => { - (1, *device, u32::from(*pin_keys), library.as_str()) + let (cuda, device, library) = match backend { + Groth16Backend::Cpu => (0, 0, ""), + Groth16Backend::Cuda { device, library } => { + (1, *device, library.as_str()) }, }; let options = raw::ix_gnark_groth16_backend { cuda, device, - pin_keys, library: view(library.as_bytes()), }; let encoded = serde_json::to_vec(metadata)?; diff --git a/crates/proof-compression/src/pipeline/groth16.rs b/crates/proof-compression/src/pipeline/groth16.rs index 4ca76c7c6..4b176fd89 100644 --- a/crates/proof-compression/src/pipeline/groth16.rs +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -1,23 +1,22 @@ -//! Terminal BN254 Groth16 proofs under the release key. +//! Final BN254 Groth16 proofs under the release key. +#[cfg(all(test, feature = "groth16"))] +use super::final_proof::Scratch; +#[cfg(feature = "groth16")] +use super::final_proof::{hex, sha256}; #[cfg(feature = "groth16")] use super::gnark; #[cfg(feature = "groth16")] use super::storage; -#[cfg(all(test, feature = "groth16"))] -use super::terminal::Scratch; -#[cfg(feature = "groth16")] -use super::terminal::{hex, sha256}; use super::{ Result, + final_proof::{Artifact, digest, load_json}, setup::SetupClass, - statement, - terminal::{Artifact, digest, load_json}, - verifying_keys, + statement, verifying_keys, }; #[cfg(feature = "groth16")] use super::{ + final_proof::{claim_digest, read_bounded}, setup::SetupConfig, - terminal::{claim_digest, read_bounded}, wrap, }; #[cfg(feature = "groth16")] @@ -45,7 +44,8 @@ pub(super) fn verify( allow_development: bool, ) -> Result { check_length(packet)?; - if packet[..32] != verifying_keys::decode(verifying_keys::GROTH16_VK_SHA256) { + if packet[..32] != verifying_keys::decode(verifying_keys::GROTH16_VK_SHA256)? + { return Err( "Groth16 proof uses a verifying key other than the release key".into(), ); @@ -56,8 +56,76 @@ pub(super) fn verify( { return Err("bundled Groth16 verifying key differs from its pin".into()); } + let setup = verifying_keys::groth16_setup(allow_development)?; verify_under(verifying_keys::GROTH16_VK, packet, claim)?; - verifying_keys::groth16_setup(allow_development) + Ok(setup) +} + +/// Verify the stage against the key prepared for the independently reconstructed +/// inner verifier. Development stages additionally bind their exported bundle. +#[cfg(feature = "groth16")] +pub(super) fn check_stage( + dir: &Path, + expected: &statement::Statement, + allow_development: bool, + bundled: bool, + verifier: &Path, + setup: &SetupConfig, + cache_root: &Path, +) -> Result<()> { + let schema = super::final_proof::description(verifier, expected, setup)?; + let artifacts = prepared_for_schema(&schema, allow_development, cache_root)?; + let metadata: Metadata = load_json(&artifacts.join("manifest.json"))?; + let envelope = fs::read(dir.join("proof.envelope.bin"))?; + let packet = envelope + .strip_prefix(PROOF_TAG.as_slice()) + .ok_or("not a Groth16 proof envelope")?; + check_length(packet)?; + let key = fs::read(dir.join("verifying-key.bin"))?; + if ::digest(&key).as_slice() != &packet[..32] { + return Err("Groth16 proof names another verifying key".into()); + } + if packet[..32] != digest(&metadata.artifact("verifying-key.bin")?.sha256)? { + return Err("Groth16 stage uses another prepared circuit key".into()); + } + if bundled + && !allow_development + && packet[..32] + != verifying_keys::decode(verifying_keys::GROTH16_VK_SHA256)? + { + return Err("Groth16 verifying key differs from the bundled key".into()); + } + if allow_development { + super::development::Verifier::new( + super::FinalSystem::Groth16, + verifier, + &schema, + expected, + setup, + &key, + None, + )? + .check_saved(dir)?; + } + gnark::verify_groth16( + &key, + packet[..32].try_into()?, + &packet[32..], + &claim_digest(expected)?, + ) +} + +#[cfg(not(feature = "groth16"))] +pub(super) fn check_stage( + _dir: &std::path::Path, + _expected: &statement::Statement, + _allow_development: bool, + _bundled: bool, + _verifier: &std::path::Path, + _setup: &super::SetupConfig, + _cache_root: &std::path::Path, +) -> Result<()> { + Err("this ix was built without Groth16; rebuild with IX_GROTH16=1".into()) } #[cfg(feature = "groth16")] @@ -166,7 +234,7 @@ fn prepared(allow_development: bool) -> Result { } if m.artifact("verifying-key.bin")?.sha256 != verifying_keys::GROTH16_VK_SHA256 - || m.inner_vk != verifying_keys::TERMINAL_SCHEMA_SHA256 + || m.inner_vk != verifying_keys::FINAL_SCHEMA_SHA256 || m.artifact("circuit.bin")?.sha256 != verifying_keys::GROTH16_CIRCUIT_SHA256 || m.setup != "development" @@ -176,9 +244,6 @@ fn prepared(allow_development: bool) -> Result { "Groth16 artifacts differ from the release circuit and key".into(), ); } - for name in ["proving-key.bin", "Groth16Verifier.sol"] { - m.artifact(name)?; - } verifying_keys::groth16_setup(allow_development)?; Ok(dir) } @@ -188,15 +253,15 @@ pub(super) fn preflight(allow_development: bool) -> Result<()> { Err(std::env::VarError::NotPresent) | Ok("" | "cpu" | "cuda") => {}, _ => return Err("IX_GROTH16_BACKEND must be cpu or cuda".into()), } - prepared(allow_development)?; + if !allow_development { + prepared(false)?; + } Ok(()) } -pub(super) fn check_statement( - expected: &statement::Statement, - allow_development: bool, -) -> Result<()> { - prepared(allow_development)?; +/// The release Groth16 key proves `expected`'s aggregation program. The +/// artifacts themselves are checked by `preflight`. +pub(super) fn check_statement(expected: &statement::Statement) -> Result<()> { if expected[..10] != verifying_keys::STATEMENT_PREFIX { return Err( "the release Groth16 key proves another aggregation program".into(), @@ -205,9 +270,8 @@ pub(super) fn check_statement( Ok(()) } -/// Prove the recursive KZG packet in `packet` in Groth16 under the prepared -/// artifacts `IX_GROTH16_ARTIFACTS` names, and write its envelope, verifying -/// key and Solidity verifier to the empty directory `out`. +/// Prove the recursive KZG packet with release artifacts or a cached local +/// development key, and publish its envelope, key and Solidity verifier. #[cfg(feature = "groth16")] pub(super) fn wrap_packet( verifier: &Path, @@ -217,52 +281,53 @@ pub(super) fn wrap_packet( out: &Path, allow_development: bool, setup: &SetupConfig, + cache_root: &Path, ) -> Result<()> { statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; - let artifacts = prepared(allow_development)?; - if expected[..10] != verifying_keys::STATEMENT_PREFIX { - return Err( - "the release Groth16 key proves another aggregation program".into(), - ); + if !allow_development { + check_statement(expected)?; } fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { return Err("Groth16 output directory must be empty".into()); } let input = out.join("input"); - wrap::plonk_export(verifier, packet, trusted, expected, &input, setup)?; - let exported: serde_json::Value = load_json(&input.join("vk.json"))?; - let inner_id = - exported["vkId"].as_str().ok_or("missing exported inner key identity")?; - if digest(inner_id)? - != verifying_keys::decode(verifying_keys::RECURSIVE_VK_ID) - || sha256(&input.join("vk.json"))? - != verifying_keys::decode(verifying_keys::TERMINAL_SCHEMA_SHA256) - { + wrap::final_export(verifier, packet, trusted, expected, &input, setup)?; + let schema = super::final_proof::description(verifier, expected, setup)?; + if fs::read(input.join("vk.json"))? != schema { return Err( - "Groth16 circuit was compiled for a different verifier description" - .into(), + "Groth16 witness export differs from the expected verifier".into(), ); } + let artifacts = prepared_for_schema(&schema, allow_development, cache_root)?; let claim = claim_digest(expected)?; prove(&artifacts, &input.join("witness.json"), &claim, out)?; - let key_id = verifying_keys::decode(verifying_keys::GROTH16_VK_SHA256); - gnark::verify_groth16( - verifying_keys::GROTH16_VK, - &key_id, - &read_bounded(&out.join("proof.bin"), MAX_PROOF_BYTES)?, - &claim, - )?; - let proof = fs::read(out.join("proof.bin"))?; + let metadata: Metadata = load_json(&artifacts.join("manifest.json"))?; + let key_id = digest(&metadata.artifact("verifying-key.bin")?.sha256)?; + let key = read_bounded(&artifacts.join("verifying-key.bin"), 1 << 20)?; + let proof = read_bounded(&out.join("proof.bin"), MAX_PROOF_BYTES)?; if !matches!(proof.len(), 164 | MAX_PROOF_BYTES) { return Err("invalid native Groth16 proof length".into()); } + gnark::verify_groth16(&key, &key_id, &proof, &claim)?; let mut envelope = PROOF_TAG.to_vec(); envelope.extend(key_id); envelope.extend(proof); for name in ["verifying-key.bin", "Groth16Verifier.sol"] { storage::write_atomic(&out.join(name), &fs::read(artifacts.join(name))?)?; } + if allow_development { + super::development::Verifier::new( + super::FinalSystem::Groth16, + verifier, + &schema, + expected, + setup, + &key, + None, + )? + .write(out)?; + } storage::write_atomic(&out.join("proof.envelope.bin"), &envelope)?; Ok(()) } @@ -276,10 +341,15 @@ fn artifact(path: &Path) -> Result { /// description `schema` into the empty directory `out`, without keys. #[cfg(feature = "groth16")] pub fn compile(schema: &Path, out: &Path) -> Result<()> { - super::prepare_output(out)?; let schema = read_bounded(schema, 16 << 20)?; - let info = gnark::compile_groth16(&schema, &out.join("circuit.bin"))?; - storage::write_atomic(&out.join("vk.json"), &schema)?; + compile_bytes(&schema, out) +} + +#[cfg(feature = "groth16")] +fn compile_bytes(schema: &[u8], out: &Path) -> Result<()> { + super::prepare_output(out)?; + let info = gnark::compile_groth16(schema, &out.join("circuit.bin"))?; + storage::write_atomic(&out.join("vk.json"), schema)?; let mut files = std::collections::BTreeMap::new(); for name in ["vk.json", "circuit.bin"] { files.insert(name.into(), artifact(&out.join(name))?); @@ -303,7 +373,49 @@ pub fn compile(schema: &Path, out: &Path) -> Result<()> { Ok(()) } -/// Prepare known-trapdoor development keys for the circuit compiled in +#[cfg(feature = "groth16")] +fn prepared_for_schema( + schema: &[u8], + allow_development: bool, + cache_root: &Path, +) -> Result { + use sha2::{Digest, Sha256}; + let schema_hash = hex(&Sha256::digest(schema)); + let dir = if !allow_development + || (schema_hash == verifying_keys::FINAL_SCHEMA_SHA256 + && std::env::var_os(ARTIFACTS_VAR).is_some()) + { + prepared(allow_development)? + } else { + let mut identity = blake3::Hasher::new(); + identity.update(b"ix/groth16-development-circuit/v1"); + identity.update(env!("IX_GNARK_SOURCE_BLAKE3").as_bytes()); + identity.update(schema_hash.as_bytes()); + let dir = cache_root + .join("development") + .join(identity.finalize().to_hex().as_str()); + storage::prepare_cached_directory(&dir, |temporary| { + compile_bytes(schema, temporary)?; + setup_development(temporary) + })?; + dir + }; + let metadata: Metadata = load_json(&dir.join("manifest.json"))?; + metadata.validate()?; + if !metadata.keys_ready + || metadata.inner_vk != schema_hash + || fs::read(dir.join("vk.json"))? != schema + || (allow_development + && (metadata.setup != "development" || metadata.ceremony.is_some())) + { + return Err( + "Groth16 cache differs from the selected circuit and setup".into(), + ); + } + Ok(dir) +} + +/// Prepare local development keys for the circuit compiled in /// `dir`. Proofs under them are accepted only with explicit development /// verification. #[cfg(feature = "groth16")] @@ -362,11 +474,6 @@ fn proving_backend() -> Result { if device < 0 { return Err("invalid Groth16 device".into()); } - let pin_keys = match std::env::var("IX_GROTH16_PIN_KEY").as_deref() { - Err(std::env::VarError::NotPresent) | Ok("" | "0") => false, - Ok("1") => true, - _ => return Err("IX_GROTH16_PIN_KEY must be 0 or 1".into()), - }; let library = std::env::var("ICICLE_BACKEND_INSTALL_DIR") .ok() .filter(|s| !s.is_empty()) @@ -380,7 +487,7 @@ fn proving_backend() -> Result { }) .or_else(|| option_env!("IX_GROTH16_ICICLE_BACKEND").map(str::to_owned)) .unwrap_or_default(); - Ok(gnark::Groth16Backend::Cuda { device, pin_keys, library }) + Ok(gnark::Groth16Backend::Cuda { device, library }) }, _ => Err("IX_GROTH16_BACKEND must be cpu or cuda".into()), } diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 0010b4553..1311bbc25 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -8,9 +8,12 @@ pub use stages::{ mod compress; mod derive; -pub use derive::derive_recursive_vk; +mod development; pub(crate) use compress::StageConfig; pub use compress::{CompressConfig, CompressInput, compress}; +pub use derive::derive_recursive_vk; +pub use development::verify_with_development_verifier; +mod final_proof; mod first; mod fixed_cache; #[cfg(any(feature = "plonk", feature = "groth16"))] @@ -20,8 +23,7 @@ pub mod groth16; mod init_claim; mod init_fri; pub mod plonk; -mod terminal; -pub use terminal::{Artifact, TerminalOptions, TerminalSystem}; +pub use final_proof::{Artifact, FinalOptions, FinalSystem}; mod prover; mod root; mod verifying_keys; @@ -50,9 +52,13 @@ pub fn check_claim( Ok(words) } -/// Describe the setup a new run would prove under, or why none is allowed. -pub fn setup_description(development: bool) -> Result<&'static str> { - Ok(SetupConfig::from_env(development)?.class().description()) +/// Describe the inner KZG setup selected for a compression run. +pub fn setup_description( + development_kzg: bool, + final_proof: FinalOptions, +) -> Result<&'static str> { + final_proof.validate_setup(development_kzg)?; + Ok(SetupConfig::from_env(development_kzg)?.class().description()) } /// Verify a compressed proof of `claim` under the release key its tag selects. @@ -198,12 +204,13 @@ mod tests { fn invalid_requests_are_rejected_before_io() { let statement = statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); - let config = |log_height, terminal: TerminalSystem| CompressConfig { + let config = |final_proof: FinalSystem| CompressConfig { setup: SetupConfig::Development { cache_directory: None }, fixed_cache: "/does-not-exist".into(), plonk_cache: "/does-not-exist".into(), - log_height, - terminal: terminal.into(), + groth16_cache: "/does-not-exist".into(), + final_proof: final_proof.into(), + fresh: false, prefetch_bytes: 0, #[cfg(feature = "cuda")] cuda: crate::kzg::cuda::Settings::default(), @@ -216,20 +223,17 @@ mod tests { fixed_root: None, out: Path::new("/does-not-exist"), }; - let error = |claim, log_height, terminal| { - compress(input(claim), &config(log_height, terminal)) - .unwrap_err() - .to_string() + let error = |claim, final_proof| { + compress(input(claim), &config(final_proof)).unwrap_err().to_string() }; - assert!(error(b"other", 24, TerminalSystem::None).contains("claim")); - assert!(error(b"claim", 23, TerminalSystem::None).contains("log-height")); - assert!(error(b"claim", 24, TerminalSystem::Plonk).contains("--dev-srs")); + assert!(error(b"other", FinalSystem::None).contains("claim")); + assert!(error(b"claim", FinalSystem::Plonk).contains("--dev-final-srs")); assert!(!Path::new("/does-not-exist").exists()); assert!(verify(PROOF_TAG, b"wrong", true).is_err()); } #[test] - fn terminal_proofs_without_their_feature_name_the_build_switch() { + fn final_proofs_without_their_feature_name_the_build_switch() { let mut plonk = plonk::PROOF_TAG.to_vec(); plonk.extend([0; 64]); let mut groth16 = groth16::PROOF_TAG.to_vec(); diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 079a526d8..54d39c67f 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -1,18 +1,18 @@ -//! Terminal PLONK proofs under the release key, and authenticated parameter +//! Final PLONK proofs under the release key, and authenticated parameter //! interchange. -#[cfg(any(feature = "plonk", feature = "groth16"))] -use super::statement; #[cfg(feature = "plonk")] -use super::storage; +use super::final_proof::{claim_digest, sha256}; #[cfg(feature = "plonk")] -use super::terminal::{claim_digest, sha256}; +use super::final_proof::{digest, hex, load_json}; +use super::setup::SetupConfig; +use super::statement; #[cfg(feature = "plonk")] -use super::terminal::{digest, hex, load_json}; +use super::storage; +#[cfg(any(feature = "plonk", feature = "groth16"))] +use super::wrap; use super::{Result, setup::SetupClass}; #[cfg(feature = "plonk")] use super::{gnark, verifying_keys}; -#[cfg(any(feature = "plonk", feature = "groth16"))] -use super::{setup::SetupConfig, wrap}; #[cfg(feature = "plonk")] use ark_ff::{BigInteger, PrimeField}; #[cfg(feature = "plonk")] @@ -20,27 +20,56 @@ use sha2::{Digest, Sha256}; #[cfg(feature = "plonk")] use std::fs; #[cfg(feature = "plonk")] -use std::io::Write; -#[cfg(any(feature = "plonk", feature = "groth16"))] +use std::io::{Read, Write}; use std::path::Path; #[cfg(feature = "plonk")] use std::path::PathBuf; pub(super) const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x02"; const MAX_PROOF_BYTES: usize = 1 << 20; +#[cfg(feature = "plonk")] +const FINAL_SRS_SEED: &[u8] = b"ix/bn254-plonk/srs/v1"; #[cfg(feature = "plonk")] -fn setup_manifest(config: &SetupConfig) -> Result<[u8; 32]> { +fn setup_identity(config: &SetupConfig) -> [u8; 32] { match config { - SetupConfig::Ignition { manifest, .. } => Ok(*manifest), - _ => Err("PLONK requires authenticated ceremony parameters".into()), + SetupConfig::Ignition { manifest, .. } => *manifest, + SetupConfig::Development { .. } => *blake3::hash(FINAL_SRS_SEED).as_bytes(), + } +} + +#[cfg(feature = "plonk")] +fn anchors(config: &SetupConfig, domain: usize) -> Result { + if let Some(admission) = config.ignition_admission() { + return Ok(admission.anchors()); + } + config.for_stage(FINAL_SRS_SEED, parameter_prefix(domain)?)?.srs(2, false) +} + +#[cfg(feature = "plonk")] +fn parameter_prefix(domain: usize) -> Result { + if domain < 2 || !domain.is_power_of_two() { + return Err("PLONK domain must be a power of two >= 2".into()); } + domain + .checked_add(3) + .and_then(usize::checked_next_power_of_two) + .ok_or_else(|| "PLONK SRS length overflow".into()) } #[cfg(feature = "plonk")] -fn anchors(config: &SetupConfig) -> Result { - setup_manifest(config)?; - Ok(config.ignition_admission().ok_or("missing Ignition admission")?.anchors()) +fn final_setup( + inner: &SetupConfig, + development: bool, + cache_root: &Path, +) -> SetupConfig { + if development { + SetupConfig::Development { + cache_directory: Some(cache_root.join("development-srs")), + } + } else { + inner.clone() + } } #[cfg(feature = "plonk")] @@ -71,6 +100,85 @@ pub(super) fn verify(packet: &[u8], _claim: &[u8]) -> Result { Err("PLONK proofs are verified by gnark, and this ix was built without PLONK; rebuild with IX_PLONK=1".into()) } +/// Verify the PLONK stage in `dir`: its envelope must name the stage's +/// verifying key, independently pinned by the release or the prepared circuit, +/// and verify for `expected` under the separately selected final setup. +#[cfg(feature = "plonk")] +pub(super) fn check_stage( + dir: &Path, + expected: &statement::Statement, + setup: &SetupConfig, + bundled: bool, + verifier: &Path, + development: bool, + cache_root: &Path, +) -> Result<()> { + let envelope = fs::read(dir.join("proof.envelope.bin"))?; + let packet = envelope + .strip_prefix(PROOF_TAG.as_slice()) + .ok_or("not a PLONK proof envelope")?; + if packet.len() <= 32 || packet.len() > MAX_PROOF_BYTES + 32 { + return Err("invalid PLONK packet length".into()); + } + let key = fs::read(dir.join("verifying-key.bin"))?; + let id: [u8; 32] = packet[..32].try_into()?; + if Sha256::digest(&key).as_slice() != id { + return Err("PLONK proof names another verifying key".into()); + } + if bundled && !development { + if id != digest(verifying_keys::PLONK_VK_SHA256)? { + return Err("PLONK verifying key differs from the bundled key".into()); + } + return gnark::verify( + &key, + &id, + &packet[32..], + &claim_digest(expected)?, + &anchor_encoding(&anchors(setup, 2)?), + ); + } + let schema = super::final_proof::description(verifier, expected, setup)?; + let outer_setup = final_setup(setup, development, cache_root); + let (_cache, metadata) = + prepare_artifacts(&schema, &outer_setup, cache_root)?; + let expected_key = &metadata + .files + .get("verifying-key.bin") + .ok_or("missing prepared PLONK verifying key")? + .sha256; + if id != digest(expected_key)? { + return Err("PLONK stage uses another prepared circuit key".into()); + } + let anchors = + anchor_encoding(&anchors(&outer_setup, usize::try_from(metadata.domain)?)?); + if development { + super::development::Verifier::new( + super::FinalSystem::Plonk, + verifier, + &schema, + expected, + setup, + &key, + Some(&anchors), + )? + .check_saved(dir)?; + } + gnark::verify(&key, &id, &packet[32..], &claim_digest(expected)?, &anchors) +} + +#[cfg(not(feature = "plonk"))] +pub(super) fn check_stage( + _dir: &Path, + _expected: &statement::Statement, + _setup: &SetupConfig, + _bundled: bool, + _verifier: &Path, + _development: bool, + _cache_root: &Path, +) -> Result<()> { + Err("this ix was built without PLONK; rebuild with IX_PLONK=1".into()) +} + /// Verify a PLONK packet of `claim` under the release's PLONK key. #[cfg(feature = "plonk")] pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { @@ -100,7 +208,7 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { /// Write the gnark verifier description (`vk.json`) and witness /// (`witness.json`) for the recursive KZG packet in `packet` to `out`. The /// verifying key in `verifier` must have BLAKE3 digest `trusted_digest` and -/// be admitted by `setup`'s Ignition parameters. +/// match `setup`'s admission or explicitly selected development setup. #[cfg(any(feature = "plonk", feature = "groth16"))] pub fn export( verifier: &Path, @@ -110,11 +218,11 @@ pub fn export( out: &Path, setup: &SetupConfig, ) -> Result<()> { - wrap::plonk_export(verifier, packet, trusted_digest, expected, out, setup) + wrap::final_export(verifier, packet, trusted_digest, expected, out, setup) } -/// Write the first `domain + 3` Ignition powers a PLONK circuit of `domain` -/// rows needs to `path`, with a JSON receipt beside it. +/// Write the first `domain + 3` powers of the selected final PLONK setup, +/// independently of the inner verifier's parameters, with a pinned receipt. #[cfg(feature = "plonk")] pub fn export_parameters( domain: usize, @@ -125,10 +233,9 @@ pub fn export_parameters( return Err("PLONK domain must be a power of two >= 2".into()); } let count = domain.checked_add(3).ok_or("PLONK SRS length overflow")?; - let prefix = - count.checked_next_power_of_two().ok_or("PLONK SRS length overflow")?; - let manifest = setup_manifest(config)?; - let source = config.for_stage(b"ix/bn254-plonk/srs/v1", prefix)?; + let prefix = parameter_prefix(domain)?; + let manifest = setup_identity(config); + let source = config.for_stage(FINAL_SRS_SEED, prefix)?; let srs = source.srs(prefix, false)?; let temporary = storage::temporary_path(path); if let Some(parent) = path.parent() { @@ -152,10 +259,11 @@ pub fn export_parameters( let mut hash = blake3::Hasher::new(); hash.update_mmap_rayon(path)?; let receipt = serde_json::json!({ - "format": "ix-bn254-plonk-srs-export-v1", "blake3": hash.finalize().to_hex().as_str(), + "format": "ix-bn254-plonk-srs-export-v2", "blake3": hash.finalize().to_hex().as_str(), "setupId": blake3::Hash::from_bytes(srs.public_setup.id).to_hex().as_str(), "publicMaxDegree": srs.public_setup.max_degree, "powers": count, - ("ignitionManifest"): blake3::Hash::from_bytes(manifest).to_hex().as_str(), + "setupClass": config.class().label(), + "setupIdentity": blake3::Hash::from_bytes(manifest).to_hex().as_str(), }); storage::write_atomic( &path.with_extension("json"), @@ -173,8 +281,8 @@ struct ParameterReceipt { setup_id: String, public_max_degree: usize, powers: usize, - #[serde(rename = "ignitionManifest")] - setup_manifest: String, + setup_class: String, + setup_identity: String, } #[cfg(feature = "plonk")] @@ -183,10 +291,11 @@ fn cached_parameters( root: &Path, config: &SetupConfig, ) -> Result<(PathBuf, String)> { - let manifest = setup_manifest(config)?; - let anchors = anchors(config)?; + let manifest = setup_identity(config); + let anchors = anchors(config, domain)?; let mut identity = blake3::Hasher::new(); - identity.update(b"ix/plonk-parameter-export/v1"); + identity.update(b"ix/plonk-parameter-export/v2"); + identity.update(config.class().label().as_bytes()); identity.update(&anchors.public_setup.id); identity.update(&manifest); identity.update(&u64::try_from(domain)?.to_le_bytes()); @@ -196,15 +305,28 @@ fn cached_parameters( export_parameters(domain, &path, config)?; } let receipt: ParameterReceipt = load_json(&receipt_path)?; - if receipt.format != "ix-bn254-plonk-srs-export-v1" + if receipt.format != "ix-bn254-plonk-srs-export-v2" + || receipt.setup_class != config.class().label() || digest(&receipt.setup_id)? != anchors.public_setup.id - || digest(&receipt.setup_manifest)? != manifest + || digest(&receipt.setup_identity)? != manifest || receipt.public_max_degree != anchors.public_setup.max_degree || receipt.powers != domain.checked_add(3).ok_or("PLONK SRS length overflow")? { return Err("cached PLONK parameter identity differs".into()); } + let mut header = [0u8; 88]; + fs::File::open(&path)?.read_exact(&mut header)?; + if &header[..8] != b"IXBPSR01" + || header[8..40] != anchors.public_setup.id + || u64::from_le_bytes(header[40..48].try_into()?) + != u64::try_from(receipt.public_max_degree)? + || header[48..80] != manifest + || u64::from_le_bytes(header[80..88].try_into()?) + != u64::try_from(receipt.powers)? + { + return Err("PLONK parameter header differs from its pinned setup".into()); + } let mut hash = blake3::Hasher::new(); hash.update_mmap_rayon(&path)?; if *hash.finalize().as_bytes() != digest(&receipt.blake3)? { @@ -221,40 +343,41 @@ fn artifact(path: &Path) -> Result { }) } -#[cfg(feature = "plonk")] +#[cfg(all(feature = "plonk", not(feature = "cuda")))] +#[allow( + clippy::unnecessary_wraps, + reason = "shares the signature of the CUDA build's fallible selection" +)] pub(super) fn proving_backend() -> Result { - match std::env::var("IX_PLONK_BACKEND").as_deref().unwrap_or("cpu") { - "" | "cpu" => Ok(gnark::Backend::Cpu), - "cuda" => { - let device: i32 = std::env::var("IX_PLONK_CUDA_DEVICE") - .unwrap_or_else(|_| "0".into()) - .parse()?; - if device < 0 { - return Err("IX_PLONK_CUDA_DEVICE must be nonnegative".into()); - } - let gib: u64 = std::env::var("IX_PLONK_CUDA_MEMORY_GIB") - .unwrap_or_else(|_| "64".into()) - .parse()?; - let memory_bytes = gib - .checked_mul(1 << 30) - .filter(|n| *n > 0) - .ok_or("invalid PLONK CUDA memory budget")?; - let mask_msm = match std::env::var("IX_PLONK_CUDA_MSM_STRATEGY") - .as_deref() - .unwrap_or("split") - { - "" | "split" => false, - "mask" => true, - _ => { - return Err( - "IX_PLONK_CUDA_MSM_STRATEGY must be split or mask".into(), - ); - }, - }; - Ok(gnark::Backend::Cuda { device, memory_bytes, mask_msm }) - }, - _ => Err("IX_PLONK_BACKEND must be cpu or cuda".into()), + Ok(gnark::Backend::Cpu) +} + +#[cfg(all(feature = "plonk", feature = "cuda"))] +pub(super) fn proving_backend() -> Result { + let device: i32 = std::env::var("IX_PLONK_CUDA_DEVICE") + .unwrap_or_else(|_| "0".into()) + .parse()?; + if device < 0 { + return Err("IX_PLONK_CUDA_DEVICE must be nonnegative".into()); } + let gib: u64 = std::env::var("IX_PLONK_CUDA_MEMORY_GIB") + .unwrap_or_else(|_| "64".into()) + .parse()?; + let memory_bytes = gib + .checked_mul(1 << 30) + .filter(|n| *n > 0) + .ok_or("invalid PLONK CUDA memory budget")?; + let mask_msm = match std::env::var("IX_PLONK_CUDA_MSM_STRATEGY") + .as_deref() + .unwrap_or("split") + { + "" | "split" => false, + "mask" => true, + _ => { + return Err("IX_PLONK_CUDA_MSM_STRATEGY must be split or mask".into()); + }, + }; + Ok(gnark::Backend::Cuda { device, memory_bytes, mask_msm }) } #[cfg(feature = "plonk")] @@ -264,12 +387,13 @@ fn prepare_artifacts( cache_root: &Path, ) -> Result<(PathBuf, gnark::Manifest)> { let schema_hash = hex(&Sha256::digest(schema)); - // `go.sum` pins the gnark that compiles the circuit and serializes its keys. + // The Go sources and module pins compile the circuit and serialize its keys. let mut identity = blake3::Hasher::new(); - identity.update(b"ix/plonk-circuit-cache/v2"); - identity.update(include_bytes!("../../../../tools/plonk-wrapper/go.sum")); + identity.update(b"ix/plonk-circuit-cache/v3"); + identity.update(env!("IX_GNARK_SOURCE_BLAKE3").as_bytes()); + identity.update(setup.class().label().as_bytes()); identity.update(schema_hash.as_bytes()); - identity.update(&setup_manifest(setup)?); + identity.update(&setup_identity(setup)); let dir = cache_root.join("circuits").join(identity.finalize().to_hex().as_str()); fs::create_dir_all(&dir)?; @@ -316,7 +440,7 @@ fn prepare_artifacts( let (srs, receipt) = cached_parameters(domain, &cache_root.join("parameters"), setup)?; let mut identity = Sha256::new(); - identity.update(setup_manifest(setup)?); + identity.update(setup_identity(setup)); identity.update(domain.to_le_bytes()); identity.update(receipt.as_bytes()); let lagrange_root = @@ -376,7 +500,7 @@ fn prepare_artifacts( /// Derive the PLONK verifying key for the recursive verifying key /// `recursive_vk` and statement prefix `prefix` without a proof: compile the /// verifier description `wrap_packet` proves and set its keys up under -/// `setup`'s Ignition parameters, caching them under `cache_root` as +/// the selected setup's parameters, caching them under `cache_root` as /// `wrap_packet` does. #[cfg(feature = "plonk")] pub fn derive_verifying_key( @@ -386,12 +510,11 @@ pub fn derive_verifying_key( cache_root: &Path, ) -> Result> { let vk = wrap::VerifyingKey::from_bytes(recursive_vk)?; - vk.check_ignition_admission( - setup - .ignition_admission() - .ok_or("PLONK requires admitted Ignition parameters")?, - )?; - let schema = wrap::plonk_description(&vk, prefix)?; + match setup.ignition_admission() { + Some(admission) => vk.check_ignition_admission(admission)?, + None => vk.check_setup_class(setup.class())?, + } + let schema = wrap::final_description(&vk, prefix)?; let (dir, metadata) = prepare_artifacts(&schema, setup, cache_root)?; let key = fs::read(dir.join("verifying-key.bin"))?; let recorded = &metadata @@ -418,18 +541,19 @@ pub(super) fn wrap_packet( cache_root: &Path, out: &Path, setup: &SetupConfig, + development: bool, ) -> Result<()> { use std::time::Instant; statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; let expected_digest = claim_digest(expected)?; - setup_manifest(setup)?; + let outer_setup = final_setup(setup, development, cache_root); let backend = proving_backend()?; fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { return Err("PLONK output directory must be empty".into()); } let input = out.join("input"); - wrap::plonk_export( + wrap::final_export( verifier, packet, trusted_digest, @@ -437,8 +561,13 @@ pub(super) fn wrap_packet( &input, setup, )?; - let (cache, metadata) = - prepare_artifacts(&fs::read(input.join("vk.json"))?, setup, cache_root)?; + let schema = super::final_proof::description(verifier, expected, setup)?; + if fs::read(input.join("vk.json"))? != schema { + return Err( + "PLONK witness export differs from the expected verifier".into(), + ); + } + let (cache, metadata) = prepare_artifacts(&schema, &outer_setup, cache_root)?; let started = Instant::now(); let mut prover = gnark::Prover::open(&cache, &metadata, backend)?; let load_seconds = started.elapsed().as_secs_f64(); @@ -454,12 +583,15 @@ pub(super) fn wrap_packet( .ok_or("missing verifying key hash")? .sha256; let key_id = digest(key_hash)?; + let key_bytes = fs::read(&key)?; + let anchors = + anchor_encoding(&anchors(&outer_setup, usize::try_from(metadata.domain)?)?); gnark::verify( - &fs::read(&key)?, + &key_bytes, &key_id, &proof.native, &expected_digest, - &anchor_encoding(&anchors(setup)?), + &anchors, )?; if proof.native.is_empty() || proof.native.len() > MAX_PROOF_BYTES { return Err("invalid native PLONK proof length".into()); @@ -477,9 +609,21 @@ pub(super) fn wrap_packet( storage::write_atomic( &out.join("report.json"), &serde_json::to_vec_pretty( - &serde_json::json!({"backend":match backend { gnark::Backend::Cpu => "cpu", gnark::Backend::Cuda { .. } => "cuda" },"loadSeconds":load_seconds,"proveSeconds":prove_seconds,"verifyingKeySHA256":key_hash,"solidityBytes":proof.solidity.len()}), + &serde_json::json!({"backend":match backend { gnark::Backend::Cpu => "cpu", gnark::Backend::Cuda { .. } => "cuda" },"setup":outer_setup.class().label(),"innerSetup":setup.class().label(),"loadSeconds":load_seconds,"proveSeconds":prove_seconds,"verifyingKeySHA256":key_hash,"solidityBytes":proof.solidity.len()}), )?, )?; + if development { + super::development::Verifier::new( + super::FinalSystem::Plonk, + verifier, + &schema, + expected, + setup, + &key_bytes, + Some(&anchors), + )? + .write(out)?; + } storage::write_atomic(&out.join("proof.envelope.bin"), &envelope)?; Ok(()) } @@ -488,6 +632,27 @@ pub(super) fn wrap_packet( mod bn254_tests { use super::*; + #[test] + #[cfg(feature = "plonk")] + fn development_parameters_need_no_ignition_and_bind_the_cache() -> Result<()> + { + let temporary = super::super::final_proof::Scratch::new()?; + let setup = SetupConfig::Development { cache_directory: None }; + let root = temporary.0.join("parameters"); + let (path, checksum) = cached_parameters(8, &root, &setup)?; + let (again, same_checksum) = cached_parameters(8, &root, &setup)?; + assert_eq!((path.clone(), checksum), (again, same_checksum)); + let receipt: ParameterReceipt = load_json(&path.with_extension("json"))?; + assert_eq!(receipt.setup_class, "development"); + assert_eq!(receipt.powers, 11); + assert_eq!(digest(&receipt.setup_id)?, anchors(&setup, 8)?.public_setup.id); + let mut corrupted = fs::read(&path)?; + corrupted[8] ^= 1; + fs::write(&path, corrupted)?; + assert!(cached_parameters(8, &root, &setup).is_err()); + Ok(()) + } + #[test] fn plonk_packets_are_tagged_and_bounded() { assert_eq!(PROOF_TAG, b"IXBN\0\0\0\x02"); diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index fbbdc2231..2ac3c63b1 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -146,7 +146,7 @@ fn development_refused() -> storage::Result { {IGNITION_CACHE_VAR}, {IGNITION_CACHE_MANIFEST_VAR}, \ {IGNITION_ADMISSION_VAR} and {IGNITION_ADMISSION_DIGEST_VAR}, run \ `ix compress fetch-params` to install the pinned release, or pass \ - --dev-srs to use the known-trapdoor development setup \ + --dev-kzg-srs to use the known-trapdoor development setup \ (not production-secure)" ); Err(message.into()) @@ -602,7 +602,7 @@ mod tests { if IGNITION_ENV_VARS.iter().all(|name| std::env::var_os(name).is_none()) { let error = SetupConfig::from_env(false).err().unwrap().to_string(); assert!(error.contains("BN254")); - assert!(error.contains("--dev-srs")); + assert!(error.contains("--dev-kzg-srs")); } let source = SetupConfig::from_env(true)?.for_stage(FIRST_SEED, 8)?; assert_eq!(source.class(), SetupClass::Development); diff --git a/crates/proof-compression/src/pipeline/storage.rs b/crates/proof-compression/src/pipeline/storage.rs index e5e5ed893..b3647064d 100644 --- a/crates/proof-compression/src/pipeline/storage.rs +++ b/crates/proof-compression/src/pipeline/storage.rs @@ -95,6 +95,42 @@ pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { persist(&temp, path) } +/// Publish a complete cache directory without exposing partially prepared +/// keys. Concurrent writers retain the first complete directory, including its +/// randomly generated Groth16 key, instead of replacing it with another key. +#[cfg(feature = "groth16")] +pub(crate) fn prepare_cached_directory( + path: &Path, + prepare: impl FnOnce(&Path) -> Result<()>, +) -> Result<()> { + if path.try_exists()? { + return Ok(()); + } + let parent = path.parent().ok_or("cache directory has no parent")?; + fs::create_dir_all(parent)?; + let temporary = temporary_path(path); + fs::create_dir(&temporary)?; + let result = prepare(&temporary).and_then(|()| { + sync_directory(&temporary)?; + match fs::rename(&temporary, path) { + Ok(()) => sync_directory(parent).map_err(Into::into), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AlreadyExists | io::ErrorKind::DirectoryNotEmpty + ) && path.is_dir() => + { + Ok(()) + }, + Err(error) => Err(error.into()), + } + }); + if temporary.exists() { + let _ = fs::remove_dir_all(&temporary); + } + result +} + pub(crate) fn save(path: &Path, value: &T) -> Result<()> { let temp = temporary_path(path); let mut f = BufWriter::new(File::create(&temp)?); @@ -119,3 +155,30 @@ pub(crate) fn load(path: &Path) -> Result { } Ok(value) } + +#[cfg(all(test, feature = "groth16"))] +mod tests { + use super::*; + + #[test] + fn key_cache_publishes_only_complete_keys_and_preserves_the_first() + -> Result<()> { + let temporary = crate::pipeline::final_proof::Scratch::new()?; + let directory = temporary.0.join("keys"); + let failed = prepare_cached_directory(&directory, |staging| { + write_atomic(&staging.join("key"), b"incomplete")?; + Err("setup failed".into()) + }); + assert!(failed.is_err()); + assert!(!directory.exists()); + prepare_cached_directory(&directory, |staging| { + write_atomic(&staging.join("key"), b"first complete key")?; + Ok(()) + })?; + prepare_cached_directory(&directory, |_| { + panic!("must reuse the completed key") + })?; + assert_eq!(fs::read(directory.join("key"))?, b"first complete key"); + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/verifying_keys.rs b/crates/proof-compression/src/pipeline/verifying_keys.rs index 322d47cd9..e98aa1b1b 100644 --- a/crates/proof-compression/src/pipeline/verifying_keys.rs +++ b/crates/proof-compression/src/pipeline/verifying_keys.rs @@ -3,13 +3,19 @@ //! for and the Ignition admission that authenticates their setup. A proof //! verifies only under these keys; there is no catalog of alternatives. use super::{ - FixedRoot, Result, setup::SetupClass, statement, wrap::VerifyingKey, + FixedRoot, Result, + setup::SetupClass, + statement, + wrap::{PreparedVerifyingKey, VerifyingKey}, }; use crate::kzg::srs::ignition::IgnitionAdmission; /// Version of the bundled verifying keys and the layout they were derived for. const VERSION: &str = "0.1"; +/// Log2 of the first KZG stage's partition height the keys were derived for. +pub(super) const FIRST_LOG_HEIGHT: usize = 24; + /// Serialized aggregation backend key whose roots the layout normalizes. const ROOT_VK_BLAKE3: &str = "f0c635f096bb2b9ff8fbeaf947c406978c11ce3bc447df94e43d6e9fe3cc6fbc"; @@ -43,9 +49,9 @@ pub(super) const PLONK_VK: &[u8] = pub(super) const PLONK_VK_SHA256: &str = "7123a80f9c56b25138ffe8ec47a53c6b0b22b64bf183380d63e5c80a7e3144a9"; -/// SHA-256 of the exported recursive verifier description that both terminal +/// SHA-256 of the exported recursive verifier description that both final /// circuits prove. -pub(super) const TERMINAL_SCHEMA_SHA256: &str = +pub(super) const FINAL_SCHEMA_SHA256: &str = "d5d07c29ad8c3c795146fe728f73e30812a344c6b62a8f32888033b1a6aba4bf"; /// The release Groth16 key comes from a development setup: whoever ran it can @@ -63,17 +69,14 @@ pub(super) const GROTH16_CIRCUIT_SHA256: &str = /// The setup class of the release Groth16 key, if `allow_development` admits it. pub(super) fn groth16_setup(allow_development: bool) -> Result { if !allow_development { - return Err("the release Groth16 key uses a development setup; pass --dev-srs to accept it (not production-secure)".into()); + return Err("the release Groth16 key uses a development setup; use --dev-final-srs for compression or --dev-srs for verification (not production-secure)".into()); } Ok(SetupClass::Development) } -pub(super) fn decode(hex: &str) -> [u8; 32] { - let mut bytes = [0; 32]; - for (byte, pair) in bytes.iter_mut().zip(hex.as_bytes().chunks(2)) { - *byte = u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap(); - } - bytes +/// A pinned digest, decoded strictly. +pub(super) fn decode(hex: &str) -> Result<[u8; 32]> { + super::final_proof::digest(hex) } /// The layout for an aggregation backend's serialized key. Circuit order and @@ -91,19 +94,21 @@ pub fn release_root_layout(root_vk: &[u8]) -> Result<&'static str> { /// The Ignition admission that authenticates the release keys' setup. pub(super) fn admission() -> Result { - Ok(IgnitionAdmission::from_bytes(ADMISSION, decode(ADMISSION_BLAKE3))?) + Ok(IgnitionAdmission::from_bytes(ADMISSION, decode(ADMISSION_BLAKE3)?)?) } -fn recursive_vk() -> Result { +/// The bundled recursive key's prepared verifier, after checking the key +/// against its pins and the admission. +fn recursive_vk() -> Result { if blake3::hash(RECURSIVE_VK).to_hex().as_str() != RECURSIVE_VK_BLAKE3 { return Err("bundled recursive verifying key differs from its pin".into()); } - let vk = VerifyingKey::from_bytes(RECURSIVE_VK)?; - if vk.id() != decode(RECURSIVE_VK_ID) { + let (vk, prepared) = VerifyingKey::prepared_from_bytes(RECURSIVE_VK)?; + if vk.id() != decode(RECURSIVE_VK_ID)? { return Err("bundled recursive verifying key identifier differs".into()); } vk.check_ignition_admission(&admission()?)?; - Ok(vk) + Ok(prepared) } /// Verify a recursive KZG packet of `claim` under the release key. @@ -112,7 +117,7 @@ pub(super) fn verify_recursive( claim: &[u8], ) -> Result { let id = packet.get(..32).ok_or("truncated compressed proof")?; - if id != decode(RECURSIVE_VK_ID) { + if id != decode(RECURSIVE_VK_ID)? { return Err(format!( "compressed proof uses a verifying key other than the version {VERSION} key" ) @@ -123,13 +128,36 @@ pub(super) fn verify_recursive( Ok(SetupClass::Ignition) } +/// Whether a compression of a `root_vk` root under `setup` must produce the +/// bundled keys: the root is this version's backend and the setup is Ignition. +pub(super) fn bundled_for(root_vk: &[u8], setup: &super::SetupConfig) -> bool { + blake3::hash(root_vk).to_hex().as_str() == ROOT_VK_BLAKE3 + && setup.class() == SetupClass::Ignition +} + +/// A recursive key proved for this version's backend under Ignition must be +/// the bundled key. +pub(super) fn check_recursive_key( + root_vk: &[u8], + setup: &super::SetupConfig, + vk: &[u8], +) -> Result<()> { + if bundled_for(root_vk, setup) && vk != RECURSIVE_VK { + return Err( + format!("recursive verifying key differs from version {VERSION}'s") + .into(), + ); + } + Ok(()) +} + /// Check a compression's statement belongs to the release when its recursive /// key is the release key. pub(super) fn check_statement( id: &[u8], words: &statement::Statement, ) -> Result<()> { - if id == decode(RECURSIVE_VK_ID) && words[..10] != STATEMENT_PREFIX { + if id == decode(RECURSIVE_VK_ID)? && words[..10] != STATEMENT_PREFIX { return Err(format!( "the version {VERSION} key compresses a different aggregation program than this build's backend" ) @@ -145,12 +173,18 @@ mod tests { #[test] fn bundled_release_files_match_their_pins() { - assert_eq!(blake3::hash(ADMISSION).as_bytes(), &decode(ADMISSION_BLAKE3)); + assert_eq!( + blake3::hash(ADMISSION).as_bytes(), + &decode(ADMISSION_BLAKE3).unwrap() + ); recursive_vk().unwrap(); - assert_eq!(Sha256::digest(PLONK_VK).as_slice(), decode(PLONK_VK_SHA256)); + assert_eq!( + Sha256::digest(PLONK_VK).as_slice(), + decode(PLONK_VK_SHA256).unwrap() + ); assert_eq!( Sha256::digest(GROTH16_VK).as_slice(), - decode(GROTH16_VK_SHA256) + decode(GROTH16_VK_SHA256).unwrap() ); assert!(groth16_setup(false).is_err()); assert_eq!(groth16_setup(true).unwrap(), SetupClass::Development); @@ -165,7 +199,7 @@ mod tests { fn other_keys_and_backends_are_rejected() { let mut packet = vec![0; 64]; assert!(verify_recursive(&packet, b"claim").is_err()); - packet[..32].copy_from_slice(&decode(RECURSIVE_VK_ID)); + packet[..32].copy_from_slice(&decode(RECURSIVE_VK_ID).unwrap()); assert!(verify_recursive(&packet, b"claim").is_err()); assert!(release_root_layout(b"another backend").is_err()); } diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index ad1defa37..9e4dc067b 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -1,9 +1,9 @@ mod outer; mod saved; #[cfg(any(feature = "plonk", feature = "groth16"))] -pub(super) use outer::plonk_export; -#[cfg(feature = "plonk")] -pub(super) use outer::plonk_description; +pub(super) use outer::final_description; +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub(super) use outer::final_export; /// Recursively verify the first KZG stage in `first` and prove the recursive /// stage into `out`, with its circuit report beside it. @@ -16,5 +16,19 @@ pub(super) fn run( saved::prove(first, out, config) } -pub(super) use outer::VerifyingKey; +/// Load the first KZG stage in `first`, check its setup binding and verify +/// its proof natively; it must prove `expected`. +pub(super) fn check_first( + first: &std::path::Path, + expected: &super::statement::Statement, + config: &super::StageConfig, +) -> Result<(), Box> { + let input = saved::Input::load(first, config)?; + if super::statement::from_rows(&input.claims)? != *expected { + return Err("first KZG proof proves another statement".into()); + } + Ok(()) +} + +pub(super) use outer::{PreparedVerifyingKey, VerifyingKey}; pub(super) use saved::verifying_key; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 93ec6478c..575026961 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -1,13 +1,13 @@ //! Recursive KZG wrapper: staging and proving in one process, and the //! portable verifier its packets are checked with. #[cfg(any(feature = "plonk", feature = "groth16"))] -mod plonk_export; +mod final_export; #[cfg(any(feature = "plonk", feature = "groth16"))] mod relation; -#[cfg(feature = "plonk")] -pub(in crate::pipeline) use plonk_export::describe as plonk_description; #[cfg(any(feature = "plonk", feature = "groth16"))] -pub(in crate::pipeline) use plonk_export::export as plonk_export; +pub(in crate::pipeline) use final_export::describe as final_description; +#[cfg(any(feature = "plonk", feature = "groth16"))] +pub(in crate::pipeline) use final_export::export as final_export; use super::super::stages::{ construct::{self, Lowered}, @@ -479,9 +479,16 @@ impl VerifyingKey { } pub(crate) fn from_bytes(bytes: &[u8]) -> PipelineResult { + Ok(Self::prepared_from_bytes(bytes)?.0) + } + + /// Decode a key and its prepared verifier, preparing it once. + pub(crate) fn prepared_from_bytes( + bytes: &[u8], + ) -> PipelineResult<(Self, PreparedVerifyingKey)> { let vk: Self = decode(bytes)?; - vk.prepare()?; - Ok(vk) + let prepared = vk.prepare()?; + Ok((vk, prepared)) } pub(crate) fn id(&self) -> [u8; 32] { @@ -742,7 +749,7 @@ mod tests { let directory = std::env::temp_dir() .join(format!("plonk-recursive-claims-{}", std::process::id())); fs::create_dir(&directory)?; - plonk_export::write_export( + final_export::write_export( &vk, &packet, &expected, &directory, "witness", true, )?; let exported: serde_json::Value = @@ -760,7 +767,7 @@ mod tests { let mut wrong = expected; wrong[10] ^= 1; assert!( - plonk_export::write_export( + final_export::write_export( &vk, &packet, &wrong, &directory, "wrong", true, ) .is_err() diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs similarity index 96% rename from crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs rename to crates/proof-compression/src/pipeline/wrap/outer/final_export.rs index 8ae29cdbc..93057e069 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs @@ -1,4 +1,4 @@ -//! Canonical verifier programs and witnesses for terminal circuits. +//! Canonical verifier programs and witnesses for final circuits. use super::relation::{codegen, *}; use super::*; use crate::kzg::curve::G1Affine; @@ -44,10 +44,11 @@ pub(in crate::pipeline) fn export( return Err("trusted verifier digest differs".into()); } let vk = VerifyingKey::from_bytes(&bytes)?; - let admission = setup - .ignition_admission() - .ok_or("terminal export requires admitted Ignition parameters")?; - vk.check_ignition_admission(admission)?; + match setup.ignition_admission() { + Some(admission) => vk.check_ignition_admission(admission)?, + None => vk.check_setup_class(setup.class())?, + } + vk.verify(&fs::read(packet)?, expected)?; write_export(&vk, &fs::read(packet)?, expected, directory, "witness", true) } @@ -129,10 +130,9 @@ fn recursive_statement() -> (Vec>, Vec) { (slots, publics) } -/// The verifier description (`vk.json`) terminal circuits are compiled from: +/// The verifier description (`vk.json`) final circuits are compiled from: /// it depends on the recursive key `vk` and the statement prefix `prefix` /// its claims carry, never on a proof. -#[cfg(feature = "plonk")] pub(in crate::pipeline) fn describe( vk: &VerifyingKey, prefix: &[u64], diff --git a/crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs b/crates/proof-compression/src/pipeline/wrap/outer/final_export/ir.rs similarity index 100% rename from crates/proof-compression/src/pipeline/wrap/outer/plonk_export/ir.rs rename to crates/proof-compression/src/pipeline/wrap/outer/final_export/ir.rs diff --git a/crates/proof-compression/tests/fixtures/gnark/proof.bin b/crates/proof-compression/tests/fixtures/plonk/proof.bin similarity index 100% rename from crates/proof-compression/tests/fixtures/gnark/proof.bin rename to crates/proof-compression/tests/fixtures/plonk/proof.bin diff --git a/crates/proof-compression/tests/fixtures/gnark/provenance.json b/crates/proof-compression/tests/fixtures/plonk/provenance.json similarity index 100% rename from crates/proof-compression/tests/fixtures/gnark/provenance.json rename to crates/proof-compression/tests/fixtures/plonk/provenance.json diff --git a/crates/proof-compression/tests/fixtures/gnark/verifying-key.bin b/crates/proof-compression/tests/fixtures/plonk/verifying-key.bin similarity index 100% rename from crates/proof-compression/tests/fixtures/gnark/verifying-key.bin rename to crates/proof-compression/tests/fixtures/plonk/verifying-key.bin diff --git a/docs/aiur-trace-sharding.md b/docs/aiur-trace-sharding.md index 02e7fb1d2..1b3ed418e 100644 --- a/docs/aiur-trace-sharding.md +++ b/docs/aiur-trace-sharding.md @@ -3,7 +3,7 @@ Design for splitting one Aiur execution into K independently provable trace shards whose proofs form a batch of STARKs, so that each shard's STARK phases fit in GPU memory, then aggregating that batch with the -`ix_aggr` recursion. Terminal compression of the final root is a +`ix_aggr` recursion. Final compression of the aggregate root is a downstream consumer of this design's verifier contract, not part of it. The design is implemented: the batch protocol in multi-stark, the @@ -56,8 +56,8 @@ on one or several GPUs is [aiur-gpu-proving.md](aiur-gpu-proving.md). is the `K = 1` vector and `verify_vector` is the verification routine for every Aiur proof. Anything that embeds an Aiur verifier — native Rust, the Lean in-circuit verifier, `ix verify`, and any downstream - consumer of the root such as a terminal compressor — adopts it. - Terminal compression itself is out of scope: it consumes a valid root + consumer of the root such as a final compressor — adopts it. + Final compression itself is out of scope: it consumes a valid root proof and is unchanged by how that root was aggregated. - **Byte tables** are present in every shard; one shard's copy carries the record's multiplicities and the others' carry zeros (deactivating @@ -659,7 +659,7 @@ final accumulator rejects every honest proof. (A second, `MEMSEG`-free key arrangement for root proofs would put two verifying keys into the allowed blob and is not worth the identity complexity.) -What happens to the root afterwards — native verification, or a terminal +What happens to the root afterwards — native verification, or a final compressor that re-verifies it inside another proof system to shrink it — is outside this design and independent of how the root was aggregated. The only requirement this design places on such a consumer diff --git a/docs/compression-stage-collapse.md b/docs/compression-stage-collapse.md index 9518b3c69..280d13d05 100644 --- a/docs/compression-stage-collapse.md +++ b/docs/compression-stage-collapse.md @@ -1,637 +1,51 @@ -# Collapsing the recursive KZG stage and the PLONK wrapper +# Compression stage boundaries -Review of the BN254 KZG → PLONK pipeline on `sb/bn254`, with sizing of the -FRI-retune alternative and a survey of upstream Plonky3 since the pinned -revision. Measurements are the branch's own benchmark records unless stated -otherwise (October 2026, one RTX PRO 6000 Blackwell, 48 logical CPUs). +The supported pipeline is an aggregate root proof, a Goldilocks/Blake3 FRI +proof, a BN254 KZG proof, recursive BN254 KZG, and an optional PLONK or Groth16 +final proof. The final systems share the same inner KZG verifier relation. -## Summary +The recursive KZG stage reduces the first stage's many commitments and openings +before translating its verifier into gnark constraints. Keep that boundary until +an actual first-stage packet has been exported, compiled directly for the chosen +final backend, and measured within the required security and memory limits. +Measurements of the recursive Init wrapper do not establish the cost of wrapping +the first stage directly. -- **Security requirement.** Preserve the current proven security bound. - Performance improvements and stage removal must not lower it; preserving - only a conjectured bound is insufficient. -- **Not as one proof, on the available evidence.** Scaling the wrapper's - measured component costs to the first KZG proof gives 250M to 385M - constraints, and even its variable-base MSM terms alone exceed the 67M - rows that gnark PLONK on BN254 can reach (`4N` must fit the field's `2^28` - two-adicity). This is an extrapolation; the compile-and-size gate the - architecture doc asks for remains the way to close it. -- **Yes as one process, with a bounded win.** Every stage now runs inside - `ix` ([in-process compression](in-process-compression.md)); additionally - retaining both stages' provers warm across requests is estimated to remove - about 30 to 35 s of a 212 s pipeline, and has not been measured. -- **The real collapse is upstream.** Shrinking the first KZG stage until its - proof can be wrapped directly removes the recursive stage. Sizing from the - checked-in Init key shows the first stage's cost is almost entirely per - query, and that 65% of it was an opening-reduction implementation that - rebuilt shared products per query. Restructuring that reduction (patch in - the working tree, verifier tests passing) halves the translated circuit: - at 100 queries the lowered layout drops from nine `2^24` computation - partitions to five, or three at `2^25`; at 40 queries it fits one `2^25` - trace. Measured on the GPU, the warm first stage goes from 135 s to 93 s - and from 175 GiB to 114 GiB under both development and Ignition - parameters; the warm recursive stage stays at about 41 s and verifies the - patched proof. The warm Ignition pipeline from saved FRI proof to - recursive packet is 134 s against the README's 181 s, and 121 s with the - lazy-nibble multi-stark change measured below. The 40-query - configuration lowers the proven bound and is therefore excluded; its - sizing results are diagnostic only. Fewer rows do not by themselves make - the first proof wrappable: KZG commits per column, and the - four BLAKE3 custom traces leave a one-partition first proof with about 360 - commitments, estimated above the `2^26` wrapper ceiling. Merging those - traces into one brings the estimate to about 60M constraints, just under - the ceiling, so removing the recursive stage rests on exporting such a - packet and compiling the adapted wrapper; column packing in the first - stage's PCS is the fallback if that compile does not fit. +## Execution and trust boundaries -## The pipeline as it stands +`pipeline::compress` runs the stages in-process with explicit inputs and +configuration. Stage directories retain checked checkpoints for resumption. +Final features link one gnark C archive into `ix`; a loaded prover retains +its circuit, keys and optional device allocations across calls. -The terminal helper now uses stock gnark with GPU numerator evaluation; see the -[stock gnark comparison](proof-benchmark-comparison.md#stock-gnark-comparison). -The timings below precede that change and include the fused quotient extension. +KZG and PLONK share a BN254 CUDA library built against sppark. Groth16 uses +installed ICICLE libraries. Cargo reports each runtime artifact to Lake for +packaging alongside the executable. -Historical BN254 Init figures with admitted Ignition parameters. Each stage is a -separate process. +PLONK uses admitted Ignition parameters. Groth16 needs its own circuit-specific +setup. Both final proof types require independently authorized verifier keys; +a generated candidate catalog does not grant that authorization. Explicit KZG +verification requires a pinned key and expected statement. -| Stage | Prover | Output | Wall time | Share | -| --- | --- | --- | ---: | ---: | -| FRI compression | Goldilocks CUDA, root key's own parameters | 1.4 MB FRI proof | ~60 s | — | -| First KZG | ix Plonkish prover, BN254 CUDA, 19 partitions of `2^24` | 32,869 B proof, 583-term opening MSM | 136 s | 64% | -| Recursive KZG | Same prover, one `2^26` trace, 61M frontend rows | 1,917 B envelope, two deferred pairing points | 45 s | 21% | -| gnark PLONK | Go helper, CUDA MSM, FFT and fused quotient | 584 B proof, 320,091 gas on Anvil | 32 s process, 11.4 s core | 15% | +## Parameter research -Shares are of the 212 s from saved FRI proof to PLONK proof. Of the PLONK -stage's 32 s, about 20 s is decoding the 1 GB proving key in a fresh process. - -The recursive stage verifies the first proof inside a custom Plonkish circuit -with byte-table lookups, then exposes its two pairing operands as public -values. The PLONK wrapper verifies that small packet with gnark's emulated -BN254 arithmetic and performs both pairing checks in-circuit. - -## Why one gnark proof over the first KZG proof is out of reach - -The wrapper today verifies 15 variable points, 19 fixed commitments and 2 -deferred points. The branch's component sizing (baseline GLV variant, 17.99M -total) attributes the cost as follows; the production classic-GLV variant fits -`2^24` with 2.09M rows of headroom. - -| Wrapper component (recursive packet) | Constraints | -| --- | ---: | -| Point validation and public binding | 168,592 | -| BLAKE3 transcript replay, 1.9 KB packet | 4,914,403 | -| Opening MSMs, 35 terms | 9,735,484 | -| Outer pairing check | 1,588,862 | -| Deferred pairing check | 1,588,278 | -| Classic-GLV total, separate pairings | 14,689,440 | - -Verifying the first proof directly means 583 MSM terms and a 33 KB -transcript. Scaling only those two components: - -| Component of a direct relation | Estimate | -| --- | ---: | -| Opening MSMs, 592 terms at 0.28M to 0.5M each | 165M – 300M | -| BLAKE3 transcript, about 17× the bytes | ~85M | -| One pairing check instead of two | ~1.6M | -| **Estimated direct relation** | **250M – 385M** | -| gnark PLONK ceiling on BN254 (`4N ≤ 2^28`) | `N = 2^26` = 67M | -| Ignition G1 points available | 100.8M | - -The branch's PLONK adapter already rejects domains above `2^26` for this -reason. These are scaled component costs, not a compiled lower bound: the -wrapper folds equal, opposite and small-multiple fixed bases before its MSM -(`circuit.go`, `foldFixedBases`), handles constant scalars separately, and a -packet's byte length is not its transcript's compression count. The margin -is nevertheless large. The measured first proof has 326 variable and 247 -fixed commitments plus ten opening witnesses. Counting only the 336 -variable-base points at the current wrapper's blended 0.28M constraints per -term (a baseline-GLV component figure that already includes its cheap -fixed-base terms) gives about 94M constraints before any transcript or -pairing work, or about 77M after scaling by the production classic-GLV -ratio (14.69M / 18.00M), still over the 67M ceiling. The recursive circuit exists -because it is about six times denser per MSM term than gnark's emulated -arithmetic: 77k lookup-backed rows per term against roughly 500k gnark -constraints. The same arithmetic rules out the reverse direction, proving the -61M-row recursive relation with standard PLONK gates. The architecture doc's -"compile and size both relations" gate should stay; the figures above say -what result to expect from it. - -## What does make sense, ranked by payoff - -1. **Shrink the first KZG stage** (removes a stage). Start with the - opening-reduction change measured below, which halves the circuit with no - parameter or protocol change. The retune steps after it are - implementation work, not parameter selection: the - in-circuit verifier rejects folding arity above one and nonzero Merkle - caps (`fri/plan.rs`, `validate_parameters`; `fri/pcs.rs`, - `FixedPcsShape`), the first-stage and recursive stages enforce at least - eleven partitions (`pipeline/first.rs`, `MIN_PARTITIONS`; - `pipeline/wrap/saved.rs`), and the PLONK exporter and Go circuit hardcode - the two-circuit recursive packet with its deferred pairing operands - (`wrap/outer/relation/codegen.rs`, `tools/plonk-wrapper/circuit.go`). - Every change re-derives the verifying keys and fixed preprocessing. - Every candidate must preserve the current proven security bound under - the same assumptions and accounting as the baseline. -2. **Keep the recursive and PLONK stages warm across requests** (estimated - 30 s; unmeasured). Both fit one GPU at the same time (59 GiB and 19 GiB - of VRAM). A persistent helper holding the proving key, compiled circuit - and CUDA context would take the PLONK stage from 32 s toward its 11.4 s - core time. On the KZG side, authenticating the `2^26` Ignition cache - (5.5 s, from the Ignition import record) and restoring fixed - preprocessing (5.3 s, from the warm development rerun) are per-run - costs that would disappear; the two figures come from different runs. - The recursive verifier already separates compilation from request - binding (`native_verifier/plan.rs`, `Plan::compile` and - `Bindings::request_inputs`); `saved.rs` calls `Plan::build`, which does - both per request, so the remaining work is retaining the compiled - circuit across requests. The wrapper plan already lists - the warm service as gate G6. -3. **Benchmark a faster proving-key load** (candidate, unmeasured). The - helper writes the 1 GB key with gnark's `WriteTo`, which compresses - points, and loads it with `ReadFrom` after verifying the file's SHA-256 - against the manifest. On BN254 G1 the subgroup check is only an on-curve - check, so the unchecked reader alone would save little; the cost to - measure is point decompression, one square root per point over about - `2^24` points. The candidate is raw serialization (`WriteRawTo`, about - twice the bytes) with the unchecked reader, keeping the hash check as the - integrity gate. How much of the 20 s this recovers is not established. -4. **Drop PLONK where latency matters more than gas** (trade-off, with - prerequisites). The recursive packet is 1,917 B and verifies natively in - 4 ms. The only like-for-like on-chain numbers are complete transactions: - 825,229 gas for the historical BLS Keccak v5 packet through the direct - verifier against 320,091 gas for BN254 PLONK (the record's 757,950 figure - is a conformance fixture's external-call cost without intrinsic or - calldata gas). At the comparison's $2.859 per million gas the difference - is about $1.44 per verification for 32 s of proving. A direct BN254 path - does not exist yet: the Solidity exporter is BLS-only and requires the - Keccak transcript, while BN254 packets use BLAKE3, so it would need a - Keccak transcript variant for the recursive stage plus a BN254 exporter. - -## FRI retune: what the sizing shows - -The first-stage circuit depends only on the saved FRI key and its parameters, -so it can be re-sized without a proof. The `fri_sizing` example under -`crates/proof-compression/examples` loads the checked-in Init key -(`tests/fixtures/init-fri`), overrides the query count and optionally the -trace heights (`FRI_SIZING_MAX_LOG`), the circuit set (`FRI_SIZING_DROP`) or -individual circuit shapes (`FRI_SIZING_SET=index:log_height:main_width`), -builds the verifier, and counts the translated BN254 frontend with the same -counting pass the former `kzg size` preflight used. With -`FRI_SIZING_LOWER=1` it also materializes the translated circuit and runs -the partitioning step of the production lowering -(`multi_stark_layout_with_max_height` at the chosen height), which gives the -computation-partition count including hash-bridge rows plus the custom -trace and table heights before production's table merging; that costs 47 GiB -and 54 s at 100 queries. The final circuit inventory is the computation -partitions plus ten auxiliary circuits (four BLAKE3 add-rotate traces, five -smaller custom traces, one merged table), which the stage metadata of the -GPU runs below confirms: 9 + 10 = 19 and 5 + 10 = 15. - -With the committed reduction change (`ffd25b69`) the command below reports -57,233,358 gates at 100 queries. The unpatched baseline figures in this -section, 113,913,480 gates at 100 queries matching the README, come from the -parent commit `5761ef42`; check that out, or revert `ffd25b69`, to reproduce -them. - -```sh -cargo build --release --locked -p ix-proof-compression \ - --no-default-features --features bn254,parallel --example fri_sizing -FRI_SIZING_LOWER=1 target/release/examples/fri_sizing \ - crates/proof-compression/tests/fixtures/init-fri 40,100 24 -``` - -### Per-query cost and its attribution - -Each query of the current circuit costs 1,125,583 translated gates, 312,920 -lookups and 578 BLAKE3 compressions; the query-independent remainder is 1.35M -gates. Varying the key's shape splits the per-query cost: - -| Experiment on the key | Gates per query | Compressions | What it prices | -| --- | ---: | ---: | --- | -| Baseline, 19 circuits, largest LDE `2^26` | 1,125,583 | 578 | — | -| Heights capped at `2^20` | 1,035,911 | 450 | one hash level ≈ 700 gates, 150 lookups | -| Heights capped at `2^16` | 961,673 | 345 | same, 703 gates per level | -| Without the four wide BLAKE3 circuits | 778,415 | 541 | one opened value ≈ 920 gates, 290 lookups | -| Only the seven FRI computation traces | 630,153 | 490 | same, ≈ 890 gates per value | - -About 800 base-field values are opened per query across the 19 stage-3 -circuits (seven FRI computation partitions at `2^24` with 44 each, four -BLAKE3 custom traces at `2^23` with about 70 each, and eight small tables), -so the opening reduction was about 65% of the per-query cost and hashing -about 36%. - -### The opening reduction was an implementation cost - -The reduction loop in `fri/pcs.rs` spent three extension multiplications -per opened value per query: advancing the alpha power, weighting the -difference by it, and multiplying by the opening denominator `1/(ζ_p − x)`. -The alpha powers and the alpha-weighted sums of the out-of-domain -evaluations are query-independent; the denominator depends on the query -point but is shared by every column of one (height, opening point). The -change folds the two query-independent quantities once per height, so each -query pays one extension-by-base product per opened value and one -denominator product per (matrix, point). -This is not a protocol change: the reduced value, the alpha index of every -column, the transcript and the proof bytes are identical, and the circuit -still constrains every input. It is the in-circuit port of the same -restructuring Plonky3 applied to its native FRI verifier in PR #2015 -(commit `a724a1aec0acab47384b34efc2a4d163e0351251`, "hoist the -query-invariant alpha work out of open_inputs"), which is why its -description matches the figures here: one extension-by-base dot product -per opening point plus one multiply by `1/(z − x)`, instead of three -extension multiplies per column. The circuit changes, so the first-stage -verifying key regenerates. Measured with the same example: - -| Circuit | Gates per query | Lookups per query | Gates at 100 queries | Lookups at 100 queries | -| --- | ---: | ---: | ---: | ---: | -| Current | 1,125,583 | 312,920 | 113,913,480 | 31,689,479 | -| Shared alpha powers, factored denominators | 554,220 | 141,070 | 57,233,358 | 14,643,377 | - -The verifier integration tests (`plonkish_pcs`, `plonkish_plan`, -`plonkish_batch`, `plonkish_verifier_algebra`, `plonkish_verifier_security`, -`pipeline_root`, `root_profile`) pass against the patched circuit. After the -change the per-query cost is about 73% hashing, so folding arity and Merkle -caps become the next lever. - -### Measured on the GPU - -Both workers were built from the same tree with `bn254,parallel,cuda` for -SM 120, differing only in `fri/pcs.rs`, and run on the saved Init FRI fixture -with development parameters prepared beforehand (2^24 and 2^26, 46 s). Host: -32 logical CPUs, 249 GiB, one RTX PRO 6000 Blackwell (97 GB), local NVMe. -Settings follow the README's matched comparison: one device, 8 GiB resident, -2^24 MSM chunks, constraint passes on, SRS cache off, TeXRay on, default CPU -parallelism. Each stage ran under `tests/run_gpu_stage.py`; records are under -`/opt/dlami/nvme/ix-retune/runs/`. One run per cell, OS page cache -uncontrolled. For scale, the baseline cold first stage took 207 s on the -48-CPU reference host and 235 s here. - -| First KZG stage | Baseline | Patched reduction | Change | -| --- | ---: | ---: | ---: | -| Cold fixed cache, wall time | 235.4 s | 148.9 s | −37% | -| Warm fixed cache, wall time | 135.2 s | 92.2 s | −32% | -| Peak host RSS | 175.3 GiB | 114.4 GiB | −35% | -| Sampled peak VRAM | 28.4 GB | 28.9 GB | — | -| Circuits | 9 × 2^24 + 10 small = 19 | 5 × 2^24 + 10 small = 15 | | -| Compact proof | 32,869 B | 27,365 B | −17% | -| Opening MSM terms (variable + fixed) | 326 + 247 | 286 + 179 | −19% | - -Warm phase spans, baseline → patched: translate 36.2 → 23.2 s, prepare -28.6 → 20.0 s, prove 42.4 → 27.2 s (lookup construction 16.1 → 10.5 s, -quotient 14.3 → 8.8 s, openings 11.7 → 7.6 s), lowering 13.8 → 11.5 s. - -| Recursive KZG stage | Over baseline first proof | Over patched first proof | -| --- | ---: | ---: | -| Wall time, cold fixed cache | 77.4 s | 76.2 s | -| Peak host RSS | 81.2 GiB | 78.6 GiB | -| Frontend rows / opening MSM terms | 61,151,005 / 583 and 9 | 50,627,679 / 475 and 9 | -| Padded computation trace | 2^26 | 2^26 | -| Native verification of the first proof, deferred pairings | pass | pass | -| Final packet | 1,909 B | 1,909 B | - -The recursive stage natively verified the patched first proof and its -external pairings, which is the full-scale correctness check for the patched -circuit. Its own cost did not move because the frontend still pads to 2^26; -it would need to fall below 2^25 rows to halve. Those recursion figures are -cold-fixed-cache runs; the warm figure is below. - -Under admitted Ignition parameters, patched worker, warm fixed caches, same -settings, one run per stage (`/opt/dlami/nvme/ix-retune/runs/ignition-opt/`): - -| Stage, warm, Ignition | README reference host (48 CPUs) | Patched, 32-CPU host | Change | -| --- | ---: | ---: | ---: | -| First KZG | 135.9 s | 93.4 s | −31% | -| Recursive KZG | 44.6 s | 40.6 s | −9% | -| Total | 180.5 s | 134.0 s | −26% | - -Peak RSS 114.4 GiB and 60.6 GiB; sampled VRAM 28.9 GB and 58.1 GB. Both -proofs verified natively with the deferred pairings; the first proof is -27,365 B and the recursive packet 1,909 B. The recursion's cold pass on the -same host took 75.6 s, so its 44.6 → 40.6 s warm change is within the -noise of a single run, while the first stage's reduction is the circuit -change. - -Per circuit, the patched first proof's commitments are: five computation -partitions at 3 + 5 + 2 variable and 17 fixed each; four BLAKE3 add-rotate -traces at 32 to 40 main + 9 to 11 lookup + 2 quotient variable and 14 fixed -each; and six small traces. The four BLAKE3 traces alone hold 192 of the 286 -variable commitments. A one-partition first proof would therefore still carry -about 246 variable and 111 fixed commitments. - -### Exact lowered layouts - -Lowering the translated circuit at the first stage's height cap gives the -real partition layout. The computation partitions below are followed in each -case by the same ten auxiliary circuits: four BLAKE3 add-rotate traces -(widths 32 to 40, `2^21` to `2^22` rows), five smaller custom traces and one -merged table. - -| Circuit | Queries | Height cap | Computation partitions | Used rows | -| --- | ---: | ---: | --- | ---: | -| Current | 100 | `2^24` | 9 × `2^24` | 150,796,384 | -| Current | 80 | `2^24` | 7 × `2^24` + `2^22` | 120,993,524 | -| Current | 60 | `2^24` | 5 × `2^24` + `2^23` | 91,190,662 | -| Current | 40 | `2^24` | 4 × `2^24` | 61,387,802 | -| Optimized reduction | 100 | `2^24` | 5 × `2^24` | 77,070,160 | -| Optimized reduction | 100 | `2^25` | 2 × `2^25` + `2^24` | 77,070,160 | -| Optimized reduction | 40 | `2^24` | 2 × `2^24` | 32,254,358 | -| Optimized reduction | 40 | `2^25` | **1 × `2^25`** | 32,254,358 | -| Optimized, one `2^27` FRI trace and one `2^25` BLAKE3 trace in stage 3 | 100 | `2^25` | 2 × `2^25` + `2^19` | 67,620,479 | -| Optimized, same merged shape | 40 | `2^24` | 2 × `2^24` | 27,613,737 | - -The nine-partition baseline matches the real run's 19 circuits (nine -computation plus ten auxiliary). The merged stage-3 shape rows are hypothetical -keys (the stage-3 prover has not produced them) and are included to show that -merging buys less than the reduction change did. - -## Soundness accounting - -`tests/fri_security_estimate.py` decodes a saved `outer-vk.bin`, derives the -instance facts from its circuit shapes, and prints every error term per -regime. Its unique-decoding, conjectured, ALI and DEEP formulas follow -`p3-security` at the pinned Plonky3 `3152b14a`; the Johnson column is -printed twice, in that revision's BCHKS25 form and in the DKT26 line-count -form of Plonky3 `main` at `4615dcd`, and the table shows the DKT26 value with -the pinned value in the limiting-terms column where they differ. The lookup -term is multi-stark's documented `(W + 2)·N / |F|`. For the checked-in key: -largest trace `2^24`, degree-2 Goldilocks extension (128 bits), BLAKE3 at 128 -bits of collision resistance, 657 batched columns, 195 folded constraints, -maximum constraint degree 4, two opening points, `N = Σ height × lookups = -1,785,791,520`, `W = 8`. Hypothetical shapes recompute these: the single -`2^27` FRI trace raises `N` to 2,037,449,760 (lookup term 93.75) and drops -the batched columns to 441; adding the merged BLAKE3 trace gives `N = -1,970,340,896` (93.80) and 231 columns. +Inspect keys through the Rust decoder before calculating candidate security +bounds: ```sh -K=crates/proof-compression/tests/fixtures/init-fri/outer-vk.bin -python3 tests/fri_security_estimate.py $K -python3 tests/fri_security_estimate.py $K --queries 40 -python3 tests/fri_security_estimate.py $K --drop 1,2,3,4,5,6 --set 0:27:3 --log-arity 3 -python3 tests/fri_security_estimate.py $K --drop 1,2,3,4,5,6,11,12,13 --set 0:27:3,10:25:40 --log-arity 3 +cargo run --release -p ix-proof-compression --no-default-features \ + --features parallel --example fri_sizing -- --statistics outer-vk.bin | \ + python3 tools/compression/fri_security_estimate.py - ``` -| Compression-stage shape | Proven, unique decoding | Proven, Johnson (DKT26; pinned BCHKS25) | Conjectured | Limiting terms | -| --- | ---: | ---: | ---: | --- | -| Current: `2^24`, blowup 4, 100 queries, 20 grinding bits, binary folding | 87.8 | 83.8 (81.2) | 93.9 | query / batching / lookup | -| `2^24`, folding arity 8 | 87.8 | 83.8 (81.2) | 93.9 | commit term falls 102.0 → 99.2 but does not bind | -| `2^24`, 40 queries | 47.1 | 59.9 (59.6) | 93.9 | query / query / lookup | -| One `2^27` FRI trace, arity 8, 100 queries | 87.8 | 81.4 (78.8) | 93.8 | query / batching / lookup; commit 96.2 | -| Same, 8 commit-grinding bits | 87.8 | 81.4 (78.8) | 93.8 | commit restored to 104.2 | -| One `2^27` FRI trace and one `2^25` BLAKE3 trace, arity 8, 100 queries | 87.8 | 82.3 (79.7) | 93.8 | query / batching / lookup | -| Same, 40 queries | 47.1 | 59.9 (59.5) | 93.8 | query / query / lookup | -| `2^24`, blowup 8, arity 8, 60 queries | 69.8 | 81.8 (78.7) | 93.9 | query / batching / lookup | - -Bits of security; each cell is the minimum over ALI, DEEP, lookup, batching -(proven regimes), FRI commit, FRI query and collision terms; the script -prints each term. Three corrections to the earlier draft of this document: -the conjectured figure is bounded by the lookup term near 93.9 bits, not by -the FRI commit term at 101.7; folding arity -is not free, it costs `log2(2^a − 1)` bits in the commit term (2.8 bits at -arity 8), which binds nowhere here but should be re-checked per shape; and -the taller `2^27` trace costs 3 bits in the commit term and 3 bits in the -Johnson batching term. Merkle caps cost nothing. - -**Required security floor:** the current proven security bound must not be -reduced further. Compare the baseline and each complete candidate shape -under the same proven assumptions and accounting, including all applicable -error terms. An unchanged conjectured bound does not satisfy this -requirement if the proven bound decreases. - -The 40-query configurations above are therefore excluded, despite their -unchanged conjectured bound. Their sizing results remain useful diagnostics, -not admissible parameter choices. Keep 100 queries for the current -configuration; any alternative query count, blowup, folding schedule or -trace layout must first demonstrate that it preserves the proven bound. -Increasing blowup at stage 3 also faces the 192 GiB LDE-array guard in -`pipeline/root.rs`. - -## Path to removing the recursive stage - -Measured layouts, in the order the changes should land: - -| Step | First stage at 100 queries | At 40 queries (sizing only) | Status | -| --- | --- | --- | --- | -| Today | 9 × `2^24` + 9 small circuits | 4 × `2^24` + 9 | measured | -| Opening reduction with shared alpha powers and factored denominators | 5 × `2^24`, or 2 × `2^25` + `2^24`, + 9 | 2 × `2^24`, or **1 × `2^25`**, + 9 | measured; patch uncommitted, tests pass, keys change | -| Plus folding arity 8 and Merkle cap 6 | ≈ 578 → 295 compressions per query, about −35% rows: projected 3 to 4 × `2^24` | projected 1 × `2^24` or `2^25` | blocked: in-circuit verifier asserts binary FRI and cap 0; multi-stark CUDA asserts the same; pinned p3-fri accepts a prover-chosen schedule (#2033) and has cap bugs (#2372, #2378) | -| Plus merged stage-3 shape (one `2^27` FRI trace, one BLAKE3 trace) | 2 × `2^25` + `2^19` measured on a hypothetical key, before arity | 2 × `2^24` | unmeasured stage-3 prover cost; smaller gain than the two rows above | - -To preserve the proven security bound, the current configuration keeps 100 -queries. The realistic landing point after the first two steps is about -three `2^25` computation partitions plus the ten auxiliary circuits, or two -if the merged shape pays off and preserves that bound. The measured -one-partition result at 40 queries is excluded from the implementation path -because it lowers the proven bound. - -Fewer rows do not by themselves make the first proof wrappable. KZG commits -to every column separately, and the first stage's columns are dominated by -the BLAKE3 custom traces (32 to 40 main columns each) rather than by the -computation partitions (3 main columns each). Counting from the measured -stage metadata, a one-partition first proof still carries about 246 variable -commitments and 111 fixed ones (the four BLAKE3 traces alone hold 192 -variable), against 15 and 19 in the recursive packet; its opened values and -transcript scale the same way. - -| Wrapped packet | MSM terms | MSM estimate, baseline GLV at 0.28M/term | Scaled to classic GLV (×0.82) | With transcript (today's 4.9M scaled by packet bytes) | -| --- | ---: | ---: | ---: | ---: | -| Recursive packet (measured wrapper) | 35 | 9.7M | — | 14.7M total, compiled | -| Patched five-partition first proof, four BLAKE3 traces (measured compile) | 475 | — | — | **130.85M, domain `2^27`** | -| One-partition first proof, four BLAKE3 traces | ≈ 360 | ≈ 101M | ≈ 82M | over the 67M ceiling | -| One-partition first proof, one merged BLAKE3 trace | ≈ 179 | ≈ 50M | ≈ 41M | ≈ 60M | - -The first of those rows is now a measurement. The PLONK exporter and the Go -circuit were generalized to any number of circuits, a claim schema and an -optional deferred pairing (exported at the time with the since-removed -worker's `plonk export-first` command, then compiled by the helper). Exporting the patched 15-circuit first proof gives a -verifying key with 295 points, 560 scalars, 179 fixed commitments, 9 opening -groups and a 10,160-node scalar program; gnark compiles it to -**130,850,185 constraints, domain `2^27`**, in 121 s and 44 GiB. That is -twice the `2^26` ceiling, so wrapping the first proof as it stands is out. -The merged-trace row remains an estimate below the ceiling with no headroom -to speak of and none of the production constraints (CUDA path capped at -`2^24`, `2^25` CPU proving at 104 s) accounted for; sizing it needs a -first-stage key with that shape, which the stage-3 prover has not produced. - -If that compile fails to fit `2^25` or `2^26`, the remaining route is for the -first stage to commit to fewer polynomials, for example by interleaving the -columns of a trace into one taller polynomial (the "fflonk-style packing" -the architecture doc already lists as a later PCS change). Until one of -these lands, the recursive stage stays as the commitment-packing step and -the measured win is the first stage's own time, 135 s to 92 s warm on the -GPU, with the recursive stage unchanged. - -STIR does not help here. Its savings come from shrinking later rounds' query -counts, while the round-0 opening reduction and input Merkle paths that any -STIR or FRI verifier performs identically are most of this circuit. - -## Where the rows go: gadget-level attribution - -Two counting reports now live beside the gadgets and run with -`cargo test ... -- --ignored --nocapture`: `recursive::native_msm::cost_breakdown::report` -for the opening MSM and `fri::pcs::cost_breakdown::report` for the first-stage -verifier's per-query pieces. Rows are gates plus lookups in the builder's -counting mode. - -### Recursive opening MSM - -| Component | Rows | Notes | -| --- | ---: | --- | -| Fq multiplication | 164 | 108 gates, 56 lookups; a relation is about 130 rows of which 56 are 16-bit range lookups | -| Affine addition | 752 | one inverse hint plus four relations | -| Affine doubling | 536 | | -| 16-way point select, variable base | 360 | 15 selects of 8 limbs, 3 gates per limb | -| 16-way point select, fixed base | 232 | constant tables | -| Variable-base 16-entry table | 10,077 | 15 curve operations | -| Fixed-base 16-entry table | 637 | | -| Scalar to bits | 961 | per term | -| Per variable term, 64 windows | 82,445 | 64 × (360 + 752) + table + bits | -| Per fixed term, 64 windows | 64,781 | 64 × (232 + 752) + table + bits | -| Shared per MSM | 556,850 | 256 doublings and two 254-bit constant multiplications for the offset correction | - -For the real first proof (296 variable and 179 fixed terms) this reproduces -the 37.75M-row MSM phase within 3%. The affine addition is 58% of a variable -term and 74% of a fixed term; the window selects are 28% and 23%. This is why -GLV cannot help: with doublings already shared, splitting each scalar into two -halves leaves the number of additions unchanged. - -Widening the range-check limbs from 16 to 20 bits (`RANGE_BITS` in -`recursive/native_field.rs`, a `2^20`-row table instead of `2^16`) cuts an Fq -multiplication to 144 rows and an addition to 672, about 8% off every MSM -term. On the real first proof the recursive frontend goes from 50,628,004 to -47,662,557 rows with the witness and external pairings still passing; it -remains padded to `2^26`. The change is committed; the merged table trace -grows from `2^17` to `2^20` rows. - -### First-stage verifier, per query, Goldilocks side - -| Component | Rows | Compressions | -| --- | ---: | ---: | -| One Merkle level (order the pair, hash) | 736 | 1 | -| Same with an injected row hash | 864 | 2 | -| Hash 8 opened values | 608 | 1 | -| Hash 187 opened values | 11,348 | 25 | -| Encode one opened value to bytes | 60 | | -| Extension multiply | 10 | | -| FRI round 0 with a 25-level path | 21,284 | 26 | -| Sample 26 query bits | 192 | 1 | -| Sample an extension challenge | 4,084 | 4 | - -A Merkle level was 736 rows of which 608 gates were the 64 byte selects that -place the sibling and the current digest in hash order: 9.5 gates per byte, -because `ByteGadgets::select` selected the value and both nibbles. The -compact BLAKE3 lowering only consumes the value, so two thirds of that work -was dead. With about 455 levels per query these selects were roughly 300k of -the 349k Goldilocks gates per query, the single largest first-stage cost -after the opening reduction fix. - -### Lazy byte nibbles in multi-stark - -multi-stark `d8c950be` (branch `sb/kzg-core`, on top of `416be66`) -makes `ByteValue`'s nibble decomposition optional: `select` -produces a value-only byte, and the generic paths that read nibbles (XOR, -word packing, comparisons) materialize them on demand with the same -nibble-hint-and-lookup constraints they always had. Measured with the sizing -example against the same change on the previous `sb/kzg-core` base: - -| First stage, 100 queries | Before | Lazy nibbles | -| --- | ---: | ---: | -| Translated gates per query | 554,220 | 389,868 | -| Translated gates | 57,233,358 | 40,750,830 | -| Lookups | 14,643,377 | 14,646,161 | -| Used rows | 77,070,160 | 60,590,416 | -| Computation partitions at `2^24` | 5 | **4** | - -This repository pins that revision. A CUDA worker built against the -change, with the 20-bit range limbs, ran the same warm Ignition sequence: - -| Stage, warm, Ignition | README reference | Reduction patch | Plus lazy nibbles | -| --- | ---: | ---: | ---: | -| First KZG | 135.9 s | 93.4 s | **80.6 s** | -| Recursive KZG | 44.6 s | 40.6 s | 40.3 s | -| Total | 180.5 s | 134.0 s | **120.9 s** | -| First-stage peak RSS | 175.3 GiB | 114.4 GiB | 99.3 GiB | -| First-stage circuits | 9 + 10 | 5 + 10 | 4 + 10 | -| First proof | 32,869 B | 27,365 B | 25,989 B | -| Recursive frontend rows | 61,151,005 | 50,628,004 | 45,179,819 | -| Recursive MSM terms | 583 and 9 | 475 and 9 | 448 and 9 | - -Both proofs verified natively with the deferred pairings under the admitted -setup; the recursive packet is unchanged at 1,669 B. Warm first-stage spans: -translate 16.6 s, lower 10.7 s, prepare 20.3 s, prove 24.1 s. The -recursion still pads to `2^26`, so its time did not move; its merged table -trace is now `2^21` rows because of the wider range table. All FRI, -first-stage and recursive gadget tests passed against the change. - - -## Refreshed terminal PLONK proof and Solidity verification - -The optimized Ignition recursive packet now has a regenerated PLONK key and -GPU proof. The wrapper remains at 14,689,440 constraints and domain `2^24`, -with both pairing checks retained. One fresh prover process took 33.872 s, -including 11.557 s of core proving, on the same 32-CPU GPU host. One-time -key preparation took 495.843 s and is excluded from proving time. - -An independent CPU helper verified the proof and rejected a changed claim -and corrupted proof. The refreshed Solidity verifier and claim-digest adapter -passed all 47 Anvil checks; the adapter transaction used 320,091 gas. Proof -sizes remain 584 bytes native, 624 bytes enveloped and 864 bytes in Solidity -encoding. The PLONK verifying-key SHA-256 is -`32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac`. - -Combining the separately measured 120.9 s KZG stages -with this 33.9 s wrapper gives about 155 s from saved FRI proof to PLONK; -this is a sum of stage measurements, not a new end-to-end run. - -## Upstream Plonky3 since the pin - -The pin is 31 August 2026; `main` is 18 October 2026, 362 commits later. -Relevant to this pipeline: - -- **Soundness fixes that matter for arity.** #2033 derives the FRI folding - schedule from configuration instead of trusting the prover; the pinned - verifier only checks that arities sum correctly, so two schedules can pass. - Harmless at binary folding, which is all ix uses, but mandatory before - enabling arity. #2372 and #2378 fix Merkle cap handling. -- **Security crate.** Legacy conjectured regime, the DKT26 Johnson bound, - grinding-difficulty checks against the model, and composition of - multi-STARK soundness with PCS budgets. A per-stage security report for the - ix pipeline could be built on it directly. -- **Breaking transcript changes.** A typed Fiat-Shamir layer now drives FRI, - STIR and WHIR, and the proof-of-work search squeezes the transcript first - (#2199, #2200). Upgrading the pin changes proof bytes, so the in-circuit - transcript and the recursive verifier would need re-deriving. This is the - main cost of moving the pin. -- **Prover and verifier performance.** Concurrent commit LDEs (#2397), - packed multi-round fold and cheaper opening quotient (#2204), closed-form - fold rows and the hoisted verifier alpha ladder (#2015; the latter is what - the first-stage circuit change above ports), - Goldilocks scalar and packed arithmetic (#2098, #2116), Merkle trees built - in subtree blocks (#2335). These speed the CPU paths; multi-stark's CUDA - backend has its own kernels. No GPU work landed upstream. -- **New protocols.** STIR is wired into the full pipeline with its own - schedule derivation, WHIR gained hiding and binary-domain variants, and a - multilinear SuperSpartan-style `p3-multi-stark` crate with buses, LogUp* - and binary-field backends has grown to 65k lines. These are - architecture-level alternatives, not drop-in improvements for the current - KZG path. - -## Review notes - -- The KZG and PLONK CUDA kernels now build against one sppark in - `libix_bn254_cuda.so`. When this review was written, the PLONK kernels were - a second, renamed sppark instantiation carrying a split-bucket MSM fix for - structured witnesses. The KZG - prover's main-trace columns are also byte-structured, so its MSMs may show - the same bucket concentration. -- The benchmarked PLONK runs were produced by invoking the helper by hand, - not through the driver's saved-FRI path. -- The PLONK cache identity hashed the executable at the time, so any rebuild - triggered the six-minute key preparation. It is now keyed by the cache - format, verifier description, setup and gnark's `go.sum`. -- Recursive-stage phase timings for the GPU run are not recorded in the repo, - only the CPU run's. The split of the 45 s between witness generation, - commitment and quotient decides how much a warm recursive prover is worth. - -Sources: `crates/proof-compression/README.md`, -`docs/proof-benchmark-comparison.md`, the pipeline, recursive verifier and -wrapper sources, multi-stark `416be66` and `d8c950be`, and Plonky3 -`3152b14a` and `main` at `4615dcd`. +The `fri_sizing` example estimates candidate frontend layouts. Materializing the +full lowering with `FRI_SIZING_LOWER=1` can require tens of GiB. Candidate query +counts and trace layouts are research inputs; performance comparisons must retain +the same security requirement. The calculator reports individual terms and the +assumptions of each regime. + +[Operational setup and commands](../crates/proof-compression/README.md), +[final backends](../tools/plonk-wrapper/README.md), and +[revision-labelled measurements](proof-benchmark-comparison.md) are maintained +separately. Detailed stage-collapse experiments are preserved in Git history at +`7b9a7f21`. diff --git a/docs/ffi.md b/docs/ffi.md index 24c83059f..464920db3 100644 --- a/docs/ffi.md +++ b/docs/ffi.md @@ -54,5 +54,5 @@ call's goroutine become errors; fatal Go runtime and device failures end the There is no ABI version or compatibility dispatcher. The [backend guide](../tools/plonk-wrapper/README.md) describes builds and tests. -The [backend feature proposal](gnark-backend-features.md) describes matching -Go compilation to the `plonk` and `groth16` Cargo features. +Cargo forwards `plonk` and `groth16` to Go build tags so unselected native +backends are excluded. Direct Go builds without tags include both CPU backends. diff --git a/docs/proof-benchmark-comparison.md b/docs/proof-benchmark-comparison.md index d7b1d493e..11eaa2958 100644 --- a/docs/proof-benchmark-comparison.md +++ b/docs/proof-benchmark-comparison.md @@ -1,108 +1,7 @@ -# Init proof benchmark comparison +# Final proof measurements -Measured on October 16–17, 2026, using 48 logical CPUs of an Intel Xeon -Platinum 8559C, 499 GiB of host RAM, local NVMe storage, and one NVIDIA RTX -PRO 6000 Blackwell Server GPU for GPU runs. - -Proving timings assume reusable preprocessing and proving keys are already -available. One-time key preparation is shown separately. Full prover processes -still load and authenticate those artifacts; core proving starts after the -circuit and keys are loaded. Times are rounded to the nearest second and -written as minutes and seconds. - -Proof size uses the largest available proof encoding: the ix envelope for -KZG and the Solidity payload for PLONK. Transaction calldata includes -additional arguments and is accounted for in the gas measurements. - -The GPU KZG pipeline starts with the saved Init FRI proof and produces the -final recursive KZG proof. It uses authenticated Filecoin parameters for -BLS12-381 and Ignition parameters for BN254. Initial aggregation and FRI -compression are outside these timings. - -| GPU KZG measurement | BLS12-381 | BN254 | -| ----------------------- | --------------: | --------------: | -| First KZG stage | 2m27s | 2m16s | -| Recursive KZG stage | 1m32s | 0m45s | -| **Total proving time** | **3m59s** | **3m0s** | -| Peak host RAM | 175.79 GiB | 175.29 GiB | -| Sampled peak GPU memory | 64.70 GiB | 57.27 GiB | -| **Proof size** | **2,189 bytes** | **1,917 bytes** | - -Total proving time is the sum of the two worker processes. BN254's complete -driver, including orchestration overhead, took 3m1s. Both stages reuse fixed -preprocessing and start fresh worker processes; OS page-cache state is -uncontrolled. These are one run per curve; both proofs passed independent -CPU verification. BN254 took approximately **25% less proving time**. - -The PLONK wrapper runs after KZG compression. Its timings are additional to -the KZG pipeline above. - -| PLONK measurement | BLS12-381 CPU | BN254 CPU | BN254 GPU | -| ------------------------ | --------------: | ------------: | ------------: | -| Constraints | 21,184,618 | 14,689,440 | 14,689,440 | -| Domain | 2²⁵ | 2²⁴ | 2²⁴ | -| One-time key preparation | 20m17s | 5m58s | 5m57s | -| **Core proving time** | **2m7s** | **0m54s** | **0m11s** | -| **Full prover process** | **4m54s** | **1m14s** | **0m32s** | -| Peak host RAM | 53.63 GiB | 25.19 GiB | 20.64 GiB | -| Sampled peak GPU memory | — | — | 19.26 GiB | -| **Proof size** | **1,184 bytes** | **864 bytes** | **864 bytes** | - -The matched BN254 core timings are medians of three runs with the same loaded -circuit, keys and witness. GPU acceleration gives a **4.75× speedup** over -CPU. BLS core proving and each complete process timing come from -one run. Full process time includes startup, artifact authentication/loading, -witness assembly, proving, verification and output. - -Key preparation covers SRS import, Lagrange conversion and generation of the -circuit's proving and verifying keys. Parameter downloads and circuit -compilation are excluded. This work is reusable across proofs and is not -included in either proving-time row. CPU and GPU use the same Lagrange basis -and keys. The current cache binds artifacts to a helper executable, so the -GPU helper required a separate preparation run despite that equivalence. - -**TODO:** Allow validated Lagrange-basis reuse across compatible CPU/GPU -helper builds without requiring the same executable hash. Preserve SRS and -domain binding, content hashes, format compatibility and basis validation; -keep circuit and proving-key compatibility checks separate. - -GPU process time and host RAM come from a separate production CLI run, using -the same circuit and byte-identical proving and verifying keys as the CPU -baseline. Its proof passed independent CPU verification, including rejection -of an altered claim and proof. Key preparation is excluded. GPU device -memory uses the earlier repeated benchmark; polling in the standalone run -missed the short peak. Its native allocation pool recorded an 18.59 GiB -reservation peak, excluding driver overhead. Device samples may miss peaks. - -Verification costs use **1.1718 gwei**, the arithmetic mean of Etherscan's -daily averages for **September 15–October 14, 2026**, the latest 30 days -available through [YCharts](https://ycharts.com/indicators/ethereum_average_gas_price) -when this comparison was prepared. The USD conversion uses -**$2,439.96 per ETH**, the [October 16 price](https://ycharts.com/indicators/ethereum_price). -That is approximately **$2.859 per million gas**. - -| Proof | Verification transaction gas | Estimated cost | -| ---------------------------------- | ---------------------------: | -------------: | -| BLS12-381 KZG, Keccak v5 reference | **825,229** | **$2.36** | -| BLS12-381 CPU PLONK | **404,637** | **$1.16** | -| BN254 CPU PLONK | **320,103** | **$0.92** | -| BN254 GPU PLONK | **320,091** | **$0.92** | - -These are complete verification transactions, including calldata and the -PLONK claim-digest adapter, but excluding contract deployment. Measurements -use local Anvil with Osaka execution rules and solc 0.8.37, optimizer 200, -via-IR and Prague bytecode. The 12-gas difference between BN254 CPU and GPU -proofs comes from calldata bytes; the verifier execution costs are identical. - -The KZG row verifies the aggregate BLS12-381 Keccak v5 proof. The PLONK rows -verify wrappers around the current BLAKE3 KZG proofs; the KZG reference uses -a different transcript. - -The dollar estimate is `transaction gas × average gwei × 10⁻⁹ × ETH/USD`. -It uses the historical gas average rather than a live gas quote. - -Implementation context is in the -[KZG compression README](../crates/proof-compression/README.md). +These records describe the measured revisions and workload. The earlier BLS +comparison is retained in Git history at `ca8ed5b5`. ## Stock gnark comparison @@ -115,3 +14,27 @@ transfers but only 0.96% versus stock gnark; stock gnark is now the supported integration. The implementation was `cfc35206e`, matching `sb/bn254` at `0a713fbf`; detailed reports and archive pins are in `target/quotient-benchmark/` in the measurement worktree. + +## Matched Init final measurements + +Recorded in `a5ddac43`, using stock gnark `262cf453c107`. Measured on 2026-10-29 with Go 1.26.8, 32 logical CPUs and an RTX PRO 6000 +Blackwell Server Edition. Both systems use exactly the same checked-in Init +verifier description and witness. Values are medians of three warm proofs in +separate processes at default parallelism; no other GPU compute process was +present when either CUDA run started. + +| Backend | CPU proving | CUDA proving | Native proof | Ix envelope | Solidity payload | +| --- | ---: | ---: | ---: | ---: | ---: | +| Groth16 | 7.35 s | 2.61 s | 196 B | 236 B | 384 B | +| PLONK | 66.07 s | 11.59 s | 584 B | 624 B | 864 B | + +Groth16 CUDA uses ICICLE with pinned key vectors; PLONK CUDA uses the existing +sppark adapter and retained bases. Proving includes gnark witness solving. +Opening the key/circuit takes about 27 seconds for Groth16 and 22 seconds for +PLONK, separately from the table. Earlier compression stages and witness export +are excluded. Groth16 uses development keys; PLONK reuses its pinned Ignition +cache. These are one-workload measurements, not predictions for every circuit. +Raw samples, file identities, opening times and peak process RSS are recorded +in [the raw receipt](../tools/plonk-wrapper/testdata/final-comparison.json). The actual Groth16 Init proof also passes +the Solidity adapter test, at 318,490 execution gas excluding transaction +intrinsic gas and calldata. diff --git a/lakefile.lean b/lakefile.lean index fe16cd377..7090d5707 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -37,7 +37,7 @@ The Rust static libraries use `target` + `moreLinkObjs` instead of `extern_lib` - Other application targets inherit `ix_rs` (`parallel`, plus opt-in `cuda`) from the `Ix` `lean_lib`. -Compression runs inside `ix`. Its terminal backends (`plonk`, `groth16`) are +Compression runs inside `ix`. Its final backends (`plonk`, `groth16`) are compiled into `ix_rs_net` only, so other executables never build Go. With `cuda`, the native libraries (multi-stark's, the BN254 KZG/PLONK library and, for Groth16, ICICLE) are copied to `.lake/build/lib` and found through @@ -60,7 +60,7 @@ def envFlag (name : String) (default : Bool) : IO Bool := do | some "0" | some "false" | some "no" => pure false | some _ => throw <| IO.userError s!"{name} must be 0/1, false/true or no/yes" -/-- Terminal backends of `ix`'s compression, mirroring the Cargo features of the +/-- Final backends of `ix`'s compression, mirroring the Cargo features of the same names: `IX_PLONK` and `IX_GROTH16`, both off by default, so compression ends at the recursive KZG proof. With `IX_CUDA`, each also enables its GPU backend; Groth16 links an installed ICICLE library. -/ @@ -306,10 +306,10 @@ target ix_rs_net pkg : FilePath := do buildRustStatic pkg (← cargoArgs (net := true)) "net" (some (← ix_rs.fetch)) /-- ICICLE, which only `ix` links, when it compresses to Groth16 on CUDA. -/ -target ix_terminal_runtime pkg : FilePath := do +target ix_final_runtime pkg : FilePath := do let all := runtimeLibraries (← cargoFeatures (net := true)) let shared := runtimeLibraries (← cargoFeatures) - linkRuntime pkg "ix_terminal_runtime" (all.filter (!shared.contains ·)) (← ix_rs_net.fetch) + linkRuntime pkg "ix_final_runtime" (all.filter (!shared.contains ·)) (← ix_rs_net.fetch) end FFI @@ -336,7 +336,7 @@ lean_lib Ix where lean_exe ix where root := `Main supportInterpreter := true - moreLinkObjs := #[ix_rs_net, ix_terminal_runtime] + moreLinkObjs := #[ix_rs_net, ix_final_runtime] section Tests diff --git a/tests/check_compression_build.py b/tests/check_compression_build.py index 8ef7cb4fd..04a74809d 100644 --- a/tests/check_compression_build.py +++ b/tests/check_compression_build.py @@ -37,19 +37,20 @@ def main(): (out / 'typecheck.log').write_text(result.stdout) if result.returncode: raise RuntimeError(f'Lake configuration typecheck failed: {out / "typecheck.log"}') - cases = [('default', {}, 'bn254', False, True)] - for curve in ['bn254']: - for cuda in [False, True]: - cases.append((f'{curve}-{"cuda" if cuda else "cpu"}', - {'IX_KZG_CURVE': curve, 'IX_CUDA': str(int(cuda))}, curve, cuda, True)) + cases = [('default', {}, False, True, set())] + for cuda in [False, True]: + for selected in [set(), {'plonk'}, {'groth16'}, {'plonk', 'groth16'}]: + name = ('cuda' if cuda else 'cpu') + '-' + ('-'.join(sorted(selected)) or 'kzg') + selection = {'IX_CUDA': str(int(cuda)), 'IX_PLONK': str(int('plonk' in selected)), 'IX_GROTH16': str(int('groth16' in selected))} + cases.append((name, selection, cuda, True, selected)) cases += [ - ('trace-codegen', {'IX_KZG_CURVE': 'bn254', 'IX_CUDA_TRACE_CODEGEN': '1'}, 'bn254', True, True), - ('no-parallel', {'IX_KZG_CURVE': 'bn254', 'IX_NO_PAR': '1'}, 'bn254', False, False), + ('trace-codegen', {'IX_CUDA_TRACE_CODEGEN': '1'}, True, True, set()), + ('no-parallel', {'IX_NO_PAR': '1'}, False, False, set()), ] report = {'lean': version, 'typecheck_passed': True, 'cases': []} - for name, selection, curve, cuda, parallel in cases: + for name, selection, cuda, parallel, final in cases: environment = dict(os.environ, LEAN_PATH=str(out)) - for variable in ['IX_KZG_CURVE', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR']: + for variable in ['IX_PLONK', 'IX_GROTH16', 'IX_CUDA', 'IX_CUDA_TRACE_CODEGEN', 'IX_NO_PAR']: environment.pop(variable, None) environment.update(selection) result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], @@ -58,49 +59,36 @@ def main(): if result.returncode: raise RuntimeError(f'configuration evaluation failed: {name}') actual = json.loads(result.stdout.splitlines()[-1]) - required = {'plonk', 'groth16'} - required |= {'parallel'} if parallel else set() - worker = required | ({'cuda'} if cuda else set()) - ffi = (required - {'plonk', 'groth16'}) | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) + ffi = ({'parallel'} if parallel else set()) | ({'cuda-trace-codegen'} if name == 'trace-codegen' else ({'cuda'} if cuda else set())) for target, expected in [ - ('worker', worker), ('ffi', ffi), - ('net', ffi | ({'net'} if sys.platform != 'darwin' else set())), + ('ffi', ffi), + ('net', ffi | final | ({'net'} if sys.platform != 'darwin' else set())), ('test', ffi | {'test-ffi'}), ]: arguments = actual[target] - package = 'ix-proof-compression' if target == 'worker' else 'ix-ffi' - if arguments[arguments.index('-p') + 1] != package: + if arguments[arguments.index('-p') + 1] != 'ix-ffi': raise RuntimeError(f'{name}: wrong package for {target}') if '--no-default-features' not in arguments or features(arguments) != expected: raise RuntimeError(f'{name}: unexpected {target} features: {arguments}') - if actual['worker'][actual['worker'].index('--bin') + 1] != 'ix-compression-worker': - raise RuntimeError(f'{name}: wrong worker executable') - if actual['terminal_plonk'] is not True or actual['terminal_groth16'] is not True: - raise RuntimeError(f'{name}: wrong terminal backend selection') - if actual['plonk_cuda'] != cuda: + if actual['final_plonk'] != ('plonk' in final) or actual['final_groth16'] != ('groth16' in final): + raise RuntimeError(f'{name}: wrong final backend selection') + if actual['plonk_cuda'] != (cuda and 'plonk' in final): raise RuntimeError(f'{name}: PLONK CUDA requires both plonk and cuda') report['cases'].append({'name': name, 'environment': selection, 'arguments': actual, 'passed': True}) - environment.update(IX_KZG_CURVE='invalid') + environment.update(IX_PLONK='invalid') result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) (out / 'invalid.log').write_text(result.stdout) - if result.returncode != 1 or 'IX_KZG_CURVE must be bn254' not in result.stdout: - raise RuntimeError('invalid curve was not rejected as expected') - report['invalid_curve_rejected'] = True - environment.update(IX_KZG_CURVE='bls12-381') - result = subprocess.run(prefix + ['--run', 'tests/compression_build_config.lean'], - env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - (out / 'retired-curve.log').write_text(result.stdout) - if result.returncode != 1 or 'IX_KZG_CURVE must be bn254' not in result.stdout: - raise RuntimeError('retired curve configuration was not rejected') - report['retired_curve_rejected'] = True + if result.returncode != 1 or 'IX_PLONK must be' not in result.stdout: + raise RuntimeError('invalid final feature flag was not rejected') + report['invalid_feature_flag_rejected'] = True report['source_sha256'] = {p: hashlib.sha256(Path(p).read_bytes()).hexdigest() for p in [ 'lakefile.lean', 'tests/compression_build_config.lean', 'tests/check_compression_build.py', 'crates/ffi/Cargo.toml', 'crates/proof-compression/Cargo.toml', 'lean-toolchain', ]} (out / 'report.json').write_text(json.dumps(report, indent=2) + '\n') print(json.dumps({'report': str(out / 'report.json'), 'typecheck': 'passed', - 'configurations_passed': len(cases), 'invalid_curve_rejected': True})) + 'configurations_passed': len(cases), 'invalid_feature_flag_rejected': True})) if __name__ == '__main__': diff --git a/tests/compression_build_config.lean b/tests/compression_build_config.lean index 78c6600ed..88b910e39 100644 --- a/tests/compression_build_config.lean +++ b/tests/compression_build_config.lean @@ -1,7 +1,7 @@ import lakefile def main : IO Unit := do - let features ← cargoFeatures + let features ← cargoFeatures (net := true) let ffi ← cargoArgs let net ← cargoArgs (net := true) let test ← cargoArgs (testFfi := true) @@ -9,8 +9,7 @@ def main : IO Unit := do ("ffi", Lean.toJson ffi), ("net", Lean.toJson net), ("test", Lean.toJson test), - ("worker", Lean.toJson (compressionWorkerArgs features)), - ("terminal_groth16", Lean.toJson ((← compressionFeatures).contains "groth16")), - ("terminal_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), + ("final_groth16", Lean.toJson ((← compressionFeatures).contains "groth16")), + ("final_plonk", Lean.toJson ((← compressionFeatures).contains "plonk")), ("plonk_cuda", Lean.toJson (features.contains "plonk" && usesCuda features)) ]).compress diff --git a/tests/fri_security_estimate.py b/tools/compression/fri_security_estimate.py similarity index 78% rename from tests/fri_security_estimate.py rename to tools/compression/fri_security_estimate.py index 7fae6062c..1eb1eda8b 100644 --- a/tests/fri_security_estimate.py +++ b/tools/compression/fri_security_estimate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Soundness estimate for a saved FRI compression key under candidate parameters. -Decodes the circuit shapes from an `outer-vk.bin`, derives the instance facts +Reads JSON from `fri_sizing --statistics`, derives the instance facts the bounds need (largest trace, batched columns, folded constraints, maximum constraint degree, lookup rows and width) and prints every error term for the unique-decoding, Johnson and conjectured regimes. Hypothetical shapes use the @@ -16,6 +16,8 @@ """ import argparse import math +import json +import sys from pathlib import Path log2 = math.log2 @@ -23,61 +25,6 @@ COLLISION_BITS = 128 # BLAKE3 -def decode_key(path): - data = Path(path).read_bytes() - pos = 0 - - def varint(): - nonlocal pos - b = data[pos] - pos += 1 - if b < 251: - return b - width = {251: 2, 252: 4, 253: 8}[b] - v = int.from_bytes(data[pos:pos + width], 'little') - pos += width - return v - - version = varint() - assert version == 1, version - log_blowup, cap = varint(), varint() - fri = [varint() for _ in range(5)] - circuits = [] - for _ in range(varint()): - for _ in range(varint()): - v = varint() - if v == 0: - pos += 8 - elif v == 1: - varint(), varint(), varint() - elif v == 2: - varint() - elif v in (6, 7, 8): - varint(), varint() - elif v == 9: - varint() - for _ in range(varint()): - varint() - zeros = varint() - for _ in range(zeros): - varint() - widths = [] - for _ in range(varint()): - varint() - args = varint() - for _ in range(args): - varint() - widths.append(args) - varint() - varint() - meta = [varint() for _ in range(8)] - circuits.append(dict( - main=meta[0], pre=meta[1], height=meta[2], lookups=meta[3], stage2=meta[4], - group=meta[6], max_degree=meta[7], zeros=zeros, max_lookup_width=max(widths, default=0))) - return dict(log_blowup=log_blowup, cap=cap, num_queries=fri[2], commit_pow=fri[3], - query_pow=fri[4], max_log_arity=fri[1], circuits=circuits) - - def apply_overrides(circuits, drop, sets): out = [] for i, c in enumerate(circuits): @@ -154,7 +101,7 @@ def terms(inst, log_blowup, queries, query_pow, log_arity, commit_pow): def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('key', help='outer-vk.bin of a saved FRI compression stage') + parser.add_argument('statistics', help='Rust-exported key statistics JSON, or - for stdin') parser.add_argument('--queries', type=int) parser.add_argument('--query-pow', type=int) parser.add_argument('--commit-pow', type=int) @@ -163,7 +110,7 @@ def main(): parser.add_argument('--drop', default='', help='comma-separated circuit indices to omit') parser.add_argument('--set', default='', help='comma-separated index:log_height:main_width overrides') args = parser.parse_args() - key = decode_key(args.key) + key = json.load(sys.stdin) if args.statistics == '-' else json.loads(Path(args.statistics).read_text()) drop = {int(i) for i in args.drop.split(',') if i} sets = {} for item in args.set.split(','): diff --git a/tests/run_gpu_stage.py b/tools/compression/run_gpu_stage.py similarity index 100% rename from tests/run_gpu_stage.py rename to tools/compression/run_gpu_stage.py diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index e0fafe2a0..a23f6165b 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -1,13 +1,16 @@ -# BN254 terminal gnark backends +# BN254 final gnark backends -This Go module implements the terminal Ix verifier circuit and calls stock +This Go module implements the final Ix verifier circuit and calls stock gnark. Cargo builds it as a C archive linked into `ix`; `pipeline/gnark.rs` owns the Rust bindings and loaded prover handles. There is no separate Go executable. +Go builds without backend tags include both CPU backends. Selecting just +`plonk` or `groth16` excludes the other implementation and C exports; Cargo +supplies these tags from its feature selection. -The circuit binds the expected claim digest and checks outer and deferred -pairing obligations. It uses classic GLV, separate pairing checks, and explicit -`accumulatorCount`, `claims`, and `publics` in the verifier description. +The circuit binds the expected claim digest, outer pairing and deferred pairing +obligations. Its schema explicitly describes accumulator counts, claim rows and +public-input mappings. ## Build and use @@ -28,10 +31,10 @@ ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output ``` Rust authenticates the setup and inner proof, selects the cache, calls gnark, -and publishes the native proof, Solidity payload, key, verifier contract, -admission, and candidate catalog. The proof envelope is written last, after -verification succeeds. Rerunning the command with the same output directory -resumes from the last complete stage. +and publishes the native proof, Solidity payload, key and verifier contract. +The proof envelope is written last, after verification succeeds. Rerunning the +command with the same output directory reuses every stage whose proof still +verifies. ## CUDA @@ -43,21 +46,23 @@ proof encodings. ```sh export CUDA_HOME=/usr/local/cuda -IX_CUDA=1 lake build ix +export IX_KZG_CUDA_ARCHS=120 +IX_CUDA=1 IX_PLONK=1 lake build ix ``` -The CUDA primitives in `crates/proof-compression/cuda/plonk` are built with -the KZG kernels into `libix_bn254_cuda.so`, against the sppark and BLST -sources of the crate's `cuda` dependencies. `IX_KZG_CUDA_ARCHS` (or -`MULTI_STARK_CUDA_ARCHS`) selects the SM architectures. cgo links this -library, which carries a private C++ runtime and exports only its C entry -points; Lake installs it in `../lib` beside the executable. +The KZG and PLONK kernels share `libix_bn254_cuda.so`, built against the configured +sppark dependency. Rust and cgo link that library; no source patching, private +sppark copy, or separate PLONK builder is involved. Cargo reports the runtime +libraries to Lake for installation beside `ix`. -Select execution separately with `IX_PLONK_BACKEND=cpu` or `cuda`. +`IX_KZG_CUDA_ARCHS` overrides `MULTI_STARK_CUDA_ARCHS`; otherwise the shared Rust +build helper detects GPU architectures and checks nvcc support. + +PLONK proving uses CUDA when built with `IX_CUDA=1`; CPU-only builds use CPU. `IX_PLONK_CUDA_DEVICE` selects the visible GPU ordinal; the default is zero. `IX_PLONK_CUDA_MEMORY_GIB` sets the budget, defaulting to 64 GiB with 8 GiB additional headroom. `IX_PLONK_CUDA_MSM_STRATEGY` accepts `split` (default) or -`mask`. `IX_CUDA` enables CUDA for Aiur, FRI, KZG, and terminal PLONK. +`mask`. `IX_CUDA` enables CUDA for Aiur, FRI, KZG, and final PLONK. A loaded prover retains its keys and optional accelerator across calls. Rust requires exclusive access for each proof and releases the Go handle on drop. @@ -77,24 +82,25 @@ domain and monomial receipt. Go checks the sizes and digests of decoded native artifacts and rejects a witness whose claim differs from the independent digest supplied by Rust. Retaining a prover avoids loading its circuit and keys again. -Verification uses the bundled PLONK verifying key and Ignition admission +Default verification uses the bundled PLONK verifying key and Ignition admission (`crates/proof-compression/verifying-keys/`); a proof whose key identifier names any other key is rejected. Verification requires neither a proving SRS nor a GPU. Release tooling is library code with no CLI: `pipeline::plonk::export` writes the verifier description and witness for a recursive KZG packet, and -`pipeline::plonk::export_parameters` writes the Ignition SRS prefix a circuit -needs. `ix verify` checks PLONK proofs against the configured catalog. +`pipeline::plonk::export_parameters` writes the selected final setup's SRS prefix. +Default `ix verify` uses the release keys; local final keys require an explicitly +pinned development verifier bundle. -Internal metadata has no helper-executable identity. Rebuild old artifact caches -and regenerate candidate catalogs instead of editing or translating them. +Regenerate internal artifact metadata through the Rust preparation APIs when +changing implementations. ## Tests ```sh cd tools/plonk-wrapper -go test ./... +go test -tags plonk,groth16 ./... ``` Go tests cover circuit tampering, canonical encodings, exceptional MSM inputs, @@ -110,11 +116,10 @@ cargo test --release -p ix-proof-compression --no-default-features \ The full compile and retained-prover tests are ignored by default. The latter requires `IX_GNARK_INIT_ARTIFACTS`, `IX_GNARK_INIT_MANIFEST_SHA256`, and `IX_GNARK_INIT_WITNESS`; run it explicitly only after budgeting memory and time. -The Go `TestBN254CUDAInit` remains an opt-in arithmetic integration/profiling -entry point. The [contract guide](../../contracts/plonk/README.md) covers +The [contract guide](../../contracts/plonk/README.md) covers Solidity verification of the unchanged proof encoding. -## BN254 Groth16 terminal +## BN254 Groth16 final The `groth16` feature implements Groth16 over the same KZG verifier circuit and witness schema. It compiles R1CS instead of PLONK's sparse constraints. The two @@ -143,7 +148,7 @@ respectively. These measurements use Solidity 0.8.33, via-IR, 200 optimizer runs and Prague; Groth16 uses development keys and PLONK uses Ignition. The adapters receive a claim digest, so these costs exclude hashing raw claim bytes. Receipts, traces and proof vectors are retained in -`target/terminal-solidity-20261029-xso7clim/` at the repository root. +`target/final-solidity-20261029-xso7clim/` at the repository root. SP1's Groth16 path uses direct bit constraints for range checks and an ordinary A/B/C verifier. Ix also uses gnark's emulated BN254 arithmetic for KZG pairings, @@ -156,31 +161,40 @@ the current circuit and verifier. ### Preparing and using development keys -Keys are prepared with library functions; there is no CLI for them. Export an +With `--dev-final-srs`, compression prepares and caches local keys automatically. +The underlying library functions can also prepare keys explicitly. Export an already verified recursive KZG packet with the shared exporter (`pipeline::plonk::export`), compile the Groth16 circuit from its `vk.json` with `pipeline::groth16::compile`, then create development keys with `pipeline::groth16::setup_development`. The exporter takes the BLAKE3 digest that authenticates the serialized KZG -verifier file and the independently expected statement, and requires admitted -Ignition parameters for the inner KZG proof; local Groth16 setup does not -change the inner setup policy. Compilation writes a fresh circuit into an empty +verifier file and the independently expected statement. It checks the selected +setup's Ignition admission or development class and verifies the inner proof. +Compilation writes a fresh circuit into an empty artifact directory. -The prepared artifacts must be the bundled circuit and key: `IX_GROTH16_ARTIFACTS` -names a directory whose verifying key, circuit and verifier description match -the bundled pins. The bundled key comes from a development setup, so proving -needs `--dev-groth16` and verifying needs `--dev-srs`: +`IX_GROTH16_ARTIFACTS` optionally reuses the release circuit's prepared artifacts, +which must match its pins. For a different inner verifier, or without those +artifacts, development keys are generated and cached automatically: ```sh -export IX_GROTH16_ARTIFACTS=/path/to/groth16-keys -ix compress ROOT --groth16 --dev-groth16 -ix verify COMPRESSED_PROOF --dev-srs +ix compress /path/to/aggregate-proof.bin --groth16 \ + --dev-kzg-srs --dev-final-srs --out ./development-groth16 +ix verify COMPRESSED_PROOF --dev-srs \ + --verifier-bundle ./development-groth16/groth16/development-verifier.json \ + --verifier-bundle-blake3 TRUSTED_BLAKE3_DIGEST ``` -`--dev-groth16` permits development outer keys and requires `--groth16`. -`--dev-srs` on verification is the existing permission for development proofs. +`--groth16` selects the proof system; `--dev-final-srs` permits its development +outer keys. `--dev-kzg-srs` is a separate inner-setup choice. Together the flags +need no Ignition parameters; the combination also works with `--plonk`. +PLONK's final powers and Lagrange parameters are independent of the inner KZG +verifier's parameters. A proof-file +input contains a serialized `Ixon.Proof`, including its claim, and is read without +importing it into the store. Verification's `--dev-srs` permits development proofs, +but an unknown final key also requires a separately pinned development bundle. +See [local development compression](../../docs/development-compression.md). Rerunning `ix compress` into an output directory whose recursive KZG stage is complete proves only the Groth16 stage. @@ -205,10 +219,11 @@ reviewed key bundle. The release binds both keys, the circuit and verifier-schem hashes, the commitment hash, and the ceremony digest. Import relies on the independently trusted release digest; it does not re-run the contribution chain. -Production catalog entries use `"setup": "mpc"` and a `ceremony_sha256` field. -The circuit, setup transcript provenance, and resulting key must be independently -admitted before adding that entry. Contributions generated by one local test -process are useful tests of the setup machinery, not a production ceremony. +A production key from such a ceremony replaces the bundled development key in +`crates/proof-compression/verifying-keys/`. The circuit, setup transcript +provenance, and resulting key must be independently reviewed first. +Contributions generated by one local test process are useful tests of the +setup machinery, not a production ceremony. ### CUDA @@ -220,11 +235,12 @@ proof format, SHA-256 commitment hash, and CPU verifier. ```sh export IX_GROTH16_BACKEND=cuda export IX_GROTH16_DEVICE=0 -export IX_GROTH16_PIN_KEY=1 ``` -`IX_GROTH16_PIN_KEY` defaults to `0`, streaming key vectors as needed. Set it to -`1` only when persistent key vectors and working memory fit on the selected GPU. +ICICLE loads proving-key vectors on demand and frees their device memory after +use. Compression produces one proof per prover handle, so retaining the entire +key in VRAM would not save transfers across proofs and would increase its +persistent memory footprint. Each prover handle stays on one device; operations and cleanup are serialized per device. `ix` does not warm other GPUs or silently fall back to CPU. @@ -251,13 +267,13 @@ compile and setup functions when changing the implementation. `contracts/groth16/src/IxGroth16Verifier.sol` fixes its address and code hash, converts the independently expected digest, and returns false for malformed proofs. Verification calls have a bounded gas budget. The contract fixtures -cover plain Groth16, commitment-augmented Groth16, and the actual Init wrapper. +cover plain Groth16 and the commitment-augmented Init wrapper. The commitment hash must remain SHA-256 in proving, native verification and Solidity export. ```sh -go test -run '^TestGroth16(BackendInterop|BackendSelection|MPCAndAdmission)$' . -IX_GROTH16_CUDA_TEST=1 go test -tags icicle -run '^TestGroth16CUDAInterop$' . +go test -tags groth16 -run '^TestGroth16(BackendInterop|BackendSelection|MPCAndAdmission)$' . +IX_GROTH16_CUDA_TEST=1 go test -tags groth16,icicle -run '^TestGroth16CUDAInterop$' . cd contracts/groth16 && forge test ``` @@ -266,38 +282,19 @@ Rust tests verify the checked-in Init proof through FFI by default. The ignored checks the full circuit identity. `retained_init_prover_reuses_keys` uses prepared keys in `IX_GROTH16_ARTIFACTS`, proves twice, checks independent verification, and tests rejection and cleanup; it requires several GiB of memory. -The matched performance harness is `TestBN254TerminalComparison`. It accepts -`IX_TERMINAL_COMPARE=1`, `IX_TERMINAL_COMPARE_OUT`, -`IX_TERMINAL_GROTH16_ARTIFACTS`, and optionally -`IX_TERMINAL_PLONK_ARTIFACTS` plus `IX_TERMINAL_PLONK_MANIFEST_SHA256` to reuse -an authenticated PLONK cache for the identical verifier schema. Select systems -and devices with `IX_TERMINAL_COMPARE_SYSTEMS` and -`IX_TERMINAL_COMPARE_DEVICES`; `IX_TERMINAL_COMPARE_REPEATS` defaults to three. -It separates opening the retained key/circuit, the first proof, warm proofs, -and verification. Proving includes gnark's witness solving; earlier Ix/KZG -stages and witness export are excluded. Compare CPU and GPU proofs by acceptance -and statement, not by bytes, because proofs are randomized. - -### Matched Init terminal measurements - -Measured on 2026-10-29 with Go 1.26.8, 32 logical CPUs and an RTX PRO 6000 -Blackwell Server Edition. Both systems use exactly the same checked-in Init -verifier description and witness. Values are medians of three warm proofs in -separate processes at default parallelism; no other GPU compute process was -present when either CUDA run started. - -| Backend | CPU proving | CUDA proving | Native proof | Ix envelope | Solidity payload | -| --- | ---: | ---: | ---: | ---: | ---: | -| Groth16 | 7.35 s | 2.61 s | 196 B | 236 B | 384 B | -| PLONK | 66.07 s | 11.59 s | 584 B | 624 B | 864 B | - -Groth16 CUDA uses ICICLE with pinned key vectors; PLONK CUDA uses the existing -sppark adapter and retained bases. Proving includes gnark witness solving. -Opening the key/circuit takes about 27 seconds for Groth16 and 22 seconds for -PLONK, separately from the table. Earlier compression stages and witness export -are excluded. Groth16 uses development keys; PLONK reuses its pinned Ignition -cache. These are one-workload measurements, not predictions for every circuit. -Raw samples, file identities, opening times and peak process RSS are recorded -in `testdata/terminal-comparison.json`. The actual Groth16 Init proof also passes -the Solidity adapter test, at 318,490 execution gas excluding transaction -intrinsic gas and calldata. +The opt-in `TestBN254FinalComparison` benchmark uses the retained production +provers for both backends. It requires prepared artifacts and pins in +`IX_FINAL_PLONK_ARTIFACTS`, `IX_FINAL_PLONK_MANIFEST_SHA256`, +`IX_FINAL_GROTH16_ARTIFACTS`, and `IX_FINAL_GROTH16_MANIFEST_SHA256`. +Set `IX_FINAL_COMPARE=1` and `IX_FINAL_COMPARE_OUT` to run it. Optional +`IX_FINAL_COMPARE_SYSTEMS` and `IX_FINAL_COMPARE_DEVICES` select comma-separated +backends and `cpu,cuda`; `IX_FINAL_COMPARE_REPEATS` defaults to three. + +Each result separates opening the circuit/key from warm proving, which is +timed together with verification and serialization. One unmeasured proof warms the prover before measurements. +`IX_FINAL_COMPARE_CPU_PROFILE=1` records a profile and marks the measurements +as profiled. Earlier compression stages, witness export, compilation and setup +are excluded. Proofs are randomized; compare their statements and acceptance. + +The [performance record](../../docs/proof-benchmark-comparison.md) contains +the matched Init measurements and the stock-gnark decision. diff --git a/tools/plonk-wrapper/artifacts.go b/tools/plonk-wrapper/artifacts.go index 285073059..ba4000672 100644 --- a/tools/plonk-wrapper/artifacts.go +++ b/tools/plonk-wrapper/artifacts.go @@ -16,14 +16,6 @@ type artifact struct { SHA256 string Bytes int64 } -type manifest struct { - InnerVKSHA256 string - SRSBLAKE3 string - KeysReady bool - Domain uint64 - Constraints, PublicInputs, Commitments int - Files map[string]artifact -} func fileHash(path string) (artifact, error) { f, err := os.Open(path) @@ -66,21 +58,6 @@ func atomicFile(path string, write func(io.Writer) error) error { return d.Sync() } -func (m *manifest) validate() error { - if m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil { - return fmt.Errorf("invalid wrapper artifact metadata") - } - if m.KeysReady { - if _, err := unhex(m.SRSBLAKE3, 32); err != nil { - return err - } - } - if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { - return fmt.Errorf("verifying key identity differs") - } - return nil -} - func readVerified(path string, expected artifact, value io.ReaderFrom) error { if expected.Bytes < 1 { return fmt.Errorf("missing artifact %s", path) @@ -104,3 +81,19 @@ func readVerified(path string, expected artifact, value io.ReaderFrom) error { } return nil } + +func readDescription(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, (16<<20)+1)) + if err != nil { + return nil, err + } + if len(data) > 16<<20 { + return nil, fmt.Errorf("verifier description is too large") + } + return data, nil +} diff --git a/tools/plonk-wrapper/backend.go b/tools/plonk-wrapper/backend.go index 72add9827..6e05b8fda 100644 --- a/tools/plonk-wrapper/backend.go +++ b/tools/plonk-wrapper/backend.go @@ -1,3 +1,5 @@ +//go:build plonk || !groth16 + package main import ( @@ -9,8 +11,10 @@ import ( "io" "os" + "github.com/consensys/gnark-crypto/ecc/bn254/fp" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/plonk" + plonkbn254 "github.com/consensys/gnark/backend/plonk/bn254" "github.com/consensys/gnark/backend/solidity" "github.com/consensys/gnark/frontend" "github.com/zeebo/blake3" @@ -18,7 +22,8 @@ import ( // readSRS consumes ix's authenticated export. The expected digest comes from // the Rust parameter loader, independently of this file's contents. -func readSRS(path, expectedDigest string, domain uint64, p *verifyingKey) (*nativeSRS, error) { +// These parameters belong to PLONK, independently of the KZG verifier circuit. +func readSRS(path, expectedDigest string, domain uint64) (*nativeSRS, error) { expected, err := unhex(expectedDigest, 32) if err != nil { return nil, err @@ -43,12 +48,9 @@ func readSRS(path, expectedDigest string, domain uint64, p *verifyingKey) (*nati if _, err := io.ReadFull(r, header[:]); err != nil { return nil, err } - setup, err := unhex(p.SetupID, 32) - if err != nil { - return nil, err - } count := binary.LittleEndian.Uint64(header[80:]) - if string(header[:8]) != srsMagic || !bytes.Equal(header[8:40], setup) || binary.LittleEndian.Uint64(header[40:48]) != p.PublicMaxDegree || count != domain+3 || domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || count-1 > p.PublicMaxDegree { + maxDegree := binary.LittleEndian.Uint64(header[40:48]) + if string(header[:8]) != srsMagic || count != domain+3 || domain < 2 || domain > 1<<26 || domain&(domain-1) != 0 || count-1 > maxDegree { return nil, fmt.Errorf("SRS identity, degree or required power count differs") } stat, err := f.Stat() @@ -69,24 +71,13 @@ func readSRS(path, expectedDigest string, domain uint64, p *verifyingKey) (*nati } srs.Vk.Lines[i] = precomputeLines(srs.Vk.G2[i]) } - anchors, err := g2Keys(p.OuterKeys) - if err != nil { - return nil, err - } - if !srs.Vk.G2[0].Equal(&anchors[0]) || !srs.Vk.G2[1].Equal(&anchors[1]) { - return nil, fmt.Errorf("SRS G2 anchors differ") - } srs.Pk.G1, err = readAffinePoints(r, count) if err != nil { return nil, err } srs.Vk.G1 = srs.Pk.G1[0] - g, err := p.Generator.native() - if err != nil { - return nil, err - } - if !srs.Vk.G1.Equal(&g) { - return nil, fmt.Errorf("SRS G1 anchor differs") + if srs.Vk.G1.IsInfinity() || !srs.Vk.G1.IsOnCurve() || !srs.Vk.G1.IsInSubGroup() { + return nil, fmt.Errorf("invalid SRS G1 anchor") } return srs, nil } @@ -127,12 +118,6 @@ func decodeProof(data []byte, commitments int) (*nativeProof, error) { return proof, nil } -type publicClaim struct { - Claim [2]frontend.Variable `gnark:",public"` -} - -func (*publicClaim) Define(frontend.API) error { return nil } - func verifyProof(key, trustedDigest, data, digest, expectedAnchors []byte) error { if len(key) > 1<<20 || len(data) > 1<<20 || len(trustedDigest) != 32 || len(digest) != 32 || len(expectedAnchors) != anchorBytesLen { return fmt.Errorf("invalid verification input length") @@ -162,3 +147,15 @@ func verifyProof(key, trustedDigest, data, digest, expectedAnchors []byte) error } return plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)) } + +type nativeVerifyingKey = plonkbn254.VerifyingKey +type nativeProof = plonkbn254.Proof + +func encodeAnchors(vk *nativeVerifyingKey) []byte { + out := make([]byte, 0, 320) + for _, v := range []*fp.Element{&vk.Kzg.G1.X, &vk.Kzg.G1.Y, &vk.Kzg.G2[0].X.A0, &vk.Kzg.G2[0].X.A1, &vk.Kzg.G2[0].Y.A0, &vk.Kzg.G2[0].Y.A1, &vk.Kzg.G2[1].X.A0, &vk.Kzg.G2[1].X.A1, &vk.Kzg.G2[1].Y.A0, &vk.Kzg.G2[1].Y.A1} { + b := v.Bytes() + out = append(out, b[:]...) + } + return out +} diff --git a/tools/plonk-wrapper/backend_bn254_test.go b/tools/plonk-wrapper/backend_bn254_test.go index 708c3b5a8..17048362e 100644 --- a/tools/plonk-wrapper/backend_bn254_test.go +++ b/tools/plonk-wrapper/backend_bn254_test.go @@ -1,3 +1,5 @@ +//go:build plonk || !groth16 + package main import ( @@ -6,11 +8,13 @@ import ( "encoding/hex" "encoding/json" "io" + "math/big" "os" "path/filepath" "testing" "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark-crypto/ecc/bn254/fp" "github.com/consensys/gnark-crypto/ecc/bn254/kzg" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/plonk" @@ -21,30 +25,7 @@ import ( "github.com/zeebo/blake3" ) -type backendCircuit struct { - Claim [2]frontend.Variable `gnark:",public"` - X frontend.Variable - Commit bool `gnark:"-"` -} - -func (c *backendCircuit) Define(api frontend.API) error { - api.AssertIsEqual(api.Mul(c.X, c.X), c.Claim[0]) - api.AssertIsEqual(c.Claim[1], 1) - if c.Commit { - challenge, err := api.(frontend.Committer).Commit(c.X) - if err != nil { - return err - } - api.AssertIsDifferent(challenge, 0) - } - return nil -} - func TestIgnitionBackendAndProofCodec(t *testing.T) { - p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) - if err != nil { - t.Fatal(err) - } var receipt struct { Format, Blake3, SetupID, IgnitionManifest string PublicMaxDegree, Powers uint64 @@ -53,14 +34,14 @@ func TestIgnitionBackendAndProofCodec(t *testing.T) { t.Fatal(err) } srsPath := filepath.Join("testdata", "bn254", "srs.bin") - srs, err := readSRS(srsPath, receipt.Blake3, 128, p) + srs, err := readSRS(srsPath, receipt.Blake3, 128) if err != nil { t.Fatal(err) } - if _, err := readSRS(srsPath, receipt.Blake3, 64, p); err == nil { + if _, err := readSRS(srsPath, receipt.Blake3, 64); err == nil { t.Fatal("accepted wrong power count") } - if _, err := readSRS(srsPath, string(bytes.Repeat([]byte{'0'}, 64)), 128, p); err == nil { + if _, err := readSRS(srsPath, string(bytes.Repeat([]byte{'0'}, 64)), 128); err == nil { t.Fatal("accepted wrong export receipt") } for _, commit := range []bool{false, true} { @@ -173,7 +154,7 @@ func verifyFixture(keyPath, trusted, proofPath string, claim []byte) error { return verifyProof(key, hash, proof, claim, encodeAnchors(vk)) } -func TestPrepareCircuitMatchesGnarkSetup(t *testing.T) { +func TestPrepareCircuitWithIndependentFinalSRS(t *testing.T) { schema, err := os.ReadFile(filepath.Join("testdata", "bn254", "vk.json")) if err != nil { t.Fatal(err) @@ -192,27 +173,49 @@ func TestPrepareCircuitMatchesGnarkSetup(t *testing.T) { if err != nil { t.Fatal(err) } - source, err := os.ReadFile(filepath.Join("testdata", "bn254", "srs.bin")) + development, err := kzg.NewSRS(domain+3, big.NewInt(42)) + if err != nil { + t.Fatal(err) + } + inner, err := decodeVerifyingKey(schema) + if err != nil { + t.Fatal(err) + } + innerAnchors, err := g2Keys(inner.OuterKeys) if err != nil { t.Fatal(err) } - powers := domain + 3 - source = source[:88+2*g2ExportBytes+2*baseBytes*powers] - binary.LittleEndian.PutUint64(source[80:88], powers) - receipt := blake3.Sum256(source) + if development.Vk.G2[1].Equal(&innerAnchors[1]) { + t.Fatal("development final SRS unexpectedly matches the inner verifier") + } + var source bytes.Buffer + var header [88]byte + copy(header[:8], srsMagic) + identity := blake3.Sum256([]byte("independent-final-test-parameters")) + copy(header[8:40], identity[:]) + binary.LittleEndian.PutUint64(header[40:48], domain+2) + copy(header[48:80], identity[:]) + binary.LittleEndian.PutUint64(header[80:88], domain+3) + source.Write(header[:]) + for _, point := range development.Vk.G2 { + for _, coordinate := range []*fp.Element{&point.X.A0, &point.X.A1, &point.Y.A0, &point.Y.A1} { + encoded := coordinate.Bytes() + source.Write(encoded[:]) + } + } + if err := writeAffinePoints(&source, development.Pk.G1); err != nil { + t.Fatal(err) + } + receipt := blake3.Sum256(source.Bytes()) srsPath := filepath.Join(dir, "srs.bin") - if err := os.WriteFile(srsPath, source, 0600); err != nil { + if err := os.WriteFile(srsPath, source.Bytes(), 0600); err != nil { t.Fatal(err) } lagrangePath := filepath.Join(dir, "lagrange.bin") if err := prepareCircuit(schema, circuit, circuitHash, domain, srsPath, hex.EncodeToString(receipt[:]), lagrangePath, "", dir); err != nil { t.Fatal(err) } - key, err := decodeVerifyingKey(schema) - if err != nil { - t.Fatal(err) - } - srs, err := readSRS(srsPath, hex.EncodeToString(receipt[:]), domain, key) + srs, err := readSRS(srsPath, hex.EncodeToString(receipt[:]), domain) if err != nil { t.Fatal(err) } diff --git a/tools/plonk-wrapper/backend_cpu.go b/tools/plonk-wrapper/backend_cpu.go index de4fffbd3..26cd4a392 100644 --- a/tools/plonk-wrapper/backend_cpu.go +++ b/tools/plonk-wrapper/backend_cpu.go @@ -1,4 +1,4 @@ -//go:build !cuda +//go:build (plonk || !groth16) && !cuda package main diff --git a/tools/plonk-wrapper/backend_cuda_bn254.go b/tools/plonk-wrapper/backend_cuda_bn254.go index 09ffc0596..46c4dfeca 100644 --- a/tools/plonk-wrapper/backend_cuda_bn254.go +++ b/tools/plonk-wrapper/backend_cuda_bn254.go @@ -1,4 +1,4 @@ -//go:build cuda +//go:build (plonk || !groth16) && cuda package main diff --git a/tools/plonk-wrapper/backend_cuda_bn254_test.go b/tools/plonk-wrapper/backend_cuda_bn254_test.go index 3cb87627b..6d1780b5c 100644 --- a/tools/plonk-wrapper/backend_cuda_bn254_test.go +++ b/tools/plonk-wrapper/backend_cuda_bn254_test.go @@ -1,4 +1,4 @@ -//go:build cuda +//go:build (plonk || !groth16) && cuda package main @@ -22,10 +22,6 @@ func TestCUDAProvingBackendFactory(t *testing.T) { if os.Getenv("IX_PLONK_CUDA_TEST") != "1" { t.Skip("set IX_PLONK_CUDA_TEST=1 for the native CUDA backend factory proof test") } - p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) - if err != nil { - t.Fatal(err) - } var receipt struct { Format, Blake3, SetupID, IgnitionManifest string PublicMaxDegree, Powers uint64 @@ -33,7 +29,7 @@ func TestCUDAProvingBackendFactory(t *testing.T) { if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { t.Fatal(err) } - srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128, p) + srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128) if err != nil { t.Fatal(err) } diff --git a/tools/plonk-wrapper/backend_no_cuda_test.go b/tools/plonk-wrapper/backend_no_cuda_test.go index d2f966474..9c8860121 100644 --- a/tools/plonk-wrapper/backend_no_cuda_test.go +++ b/tools/plonk-wrapper/backend_no_cuda_test.go @@ -1,4 +1,4 @@ -//go:build !cuda +//go:build (plonk || !groth16) && !cuda package main diff --git a/tools/plonk-wrapper/backend_selection_test.go b/tools/plonk-wrapper/backend_selection_test.go index 6d625a4ee..ec1d422b5 100644 --- a/tools/plonk-wrapper/backend_selection_test.go +++ b/tools/plonk-wrapper/backend_selection_test.go @@ -1,3 +1,5 @@ +//go:build plonk || !groth16 + package main import "testing" diff --git a/tools/plonk-wrapper/cuda_init_bn254_test.go b/tools/plonk-wrapper/cuda_init_bn254_test.go deleted file mode 100644 index 0a2d92349..000000000 --- a/tools/plonk-wrapper/cuda_init_bn254_test.go +++ /dev/null @@ -1,405 +0,0 @@ -//go:build cuda - -package main - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "runtime/pprof" - "strconv" - "strings" - "testing" - "time" - - "github.com/argumentcomputer/ix/tools/plonk-wrapper/internal/cuda" - "github.com/consensys/gnark/backend" - "github.com/consensys/gnark/backend/plonk" - native "github.com/consensys/gnark/backend/plonk/bn254" - "github.com/consensys/gnark/backend/solidity" - "github.com/consensys/gnark/frontend" -) - -func TestBN254CUDAInit(t *testing.T) { - if os.Getenv("IX_PLONK_CUDA_INIT") != "1" { - t.Skip("set IX_PLONK_CUDA_INIT=1 with explicit artifact and claim pins for full Init proofs") - } - repeats := 1 - if value := os.Getenv("IX_PLONK_CUDA_INIT_REPEATS"); value != "" { - parsed, err := strconv.Atoi(value) - if err != nil || parsed < 1 || parsed > 10 { - t.Fatal("IX_PLONK_CUDA_INIT_REPEATS must be an integer in 1..10") - } - repeats = parsed - } - modes := []string{"cpu", "cuda"} - if value := os.Getenv("IX_PLONK_CUDA_INIT_MODES"); value != "" { - selected := make(map[string]bool) - for _, mode := range strings.Split(value, ",") { - mode = strings.TrimSpace(mode) - if (mode != "cpu" && mode != "cuda") || selected[mode] { - t.Fatal("IX_PLONK_CUDA_INIT_MODES must be a unique comma-separated subset of cpu,cuda") - } - selected[mode] = true - } - filtered := make([]string, 0, len(selected)) - for _, mode := range modes { - if selected[mode] { - filtered = append(filtered, mode) - } - } - modes = filtered - } - flag := func(name string) bool { - switch os.Getenv(name) { - case "", "0": - return false - case "1": - return true - default: - t.Fatalf("%s must be 0 or 1", name) - return false - } - } - cpuProfile := flag("IX_PLONK_CUDA_INIT_CPU_PROFILE") - required := func(name string) string { - value := os.Getenv("IX_PLONK_CUDA_INIT_" + name) - if value == "" { - t.Fatalf("IX_PLONK_CUDA_INIT_%s must be explicit", name) - } - return value - } - dir, witnessPath, output := required("ARTIFACTS"), required("WITNESS"), required("OUTPUT") - manifestPin, err := initDigest(required("MANIFEST_SHA256")) - if err != nil { - t.Fatal(err) - } - claim, err := initDigest(required("EXPECTED_CLAIM")) - if err != nil { - t.Fatal(err) - } - if err := os.Mkdir(output, 0700); err != nil { - t.Fatal(err) - } - started := time.Now() - m, err := initManifest(dir, manifestPin) - if err != nil { - t.Fatal(err) - } - ccs, pk, vk := plonk.NewCS(curveID), plonk.NewProvingKey(curveID), plonk.NewVerifyingKey(curveID) - if err := initReadBinary(filepath.Join(dir, "circuit.bin"), m.Files["circuit.bin"], ccs); err != nil { - t.Fatal(err) - } - if err := initReadBinary(filepath.Join(dir, "proving-key.bin"), m.Files["proving-key.bin"], pk); err != nil { - t.Fatal(err) - } - if err := initReadBinary(filepath.Join(dir, "verifying-key.bin"), m.Files["verifying-key.bin"], vk); err != nil { - t.Fatal(err) - } - p, err := loadVerifyingKey(filepath.Join(dir, "vk.json")) - if err != nil { - t.Fatal(err) - } - if err := m.checkFile(dir, "vk.json"); err != nil { - t.Fatal(err) - } - assignment, err := witnessAssignment(p, witnessPath) - if err != nil { - t.Fatal(err) - } - witness, err := frontend.NewWitness(assignment, curveID.ScalarField()) - if err != nil { - t.Fatal(err) - } - digest, _ := hex.DecodeString(claim) - public, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(digest)}, curveID.ScalarField(), frontend.PublicOnly()) - if err != nil { - t.Fatal(err) - } - wrongDigest := append([]byte(nil), digest...) - wrongDigest[0] ^= 1 - wrongPublic, err := frontend.NewWitness(&publicClaim{Claim: digestLimbs(wrongDigest)}, curveID.ScalarField(), frontend.PublicOnly()) - if err != nil { - t.Fatal(err) - } - actualPublic, err := witness.Public() - if err != nil { - t.Fatal(err) - } - actualBytes, err := actualPublic.MarshalBinary() - if err != nil { - t.Fatal(err) - } - expectedBytes, err := public.MarshalBinary() - if err != nil || !bytes.Equal(actualBytes, expectedBytes) { - t.Fatal("witness differs from independently expected claim") - } - nativePK := pk.(*native.ProvingKey) - if ccs.GetNbConstraints() != m.Constraints || ccs.GetNbPublicVariables() != m.PublicInputs || len(ccs.GetCommitments().CommitmentIndexes()) != m.Commitments || nativePK.Vk.Size != m.Domain { - t.Fatal("decoded circuit or key dimensions differ from the manifest") - } - var embedded bytes.Buffer - if _, err := nativePK.Vk.WriteTo(&embedded); err != nil { - t.Fatal(err) - } - if fmt.Sprintf("%x", sha256.Sum256(embedded.Bytes())) != m.Files["verifying-key.bin"].SHA256 { - t.Fatal("proving key embeds another verifying key") - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - identity, err := fileHash(executable) - if err != nil { - t.Fatal(err) - } - report := map[string]any{ - "complete": false, - "scope": "Externally pinned existing Init artifacts; same loaded circuit, keys and witness for CPU and CUDA, fresh gnark blinding. No compilation or key preparation.", - "manifestSHA256": manifestPin, "manifest": m, "expectedClaim": claim, - "testBinary": identity, "artifactAndWitnessSeconds": time.Since(started).Seconds(), - "repeats": repeats, "modes": modes, "executionOrder": "iteration-major: " + strings.Join(modes, ", "), - "numCPU": runtime.NumCPU(), "gomaxprocs": runtime.GOMAXPROCS(0), - "freshCUDAContextPerProof": true, "forcedGC": false, - "cpuProfile": cpuProfile, - "cpuProfileScope": "Only the first selected run: CPU samples immediately around plonk.Prove, excluding artifact/witness loading, backend setup, serialization and verification. Profiler startup/shutdown are timed separately; subsequent runs are unprofiled.", - } - var runs []map[string]any - for iteration := 1; iteration <= repeats; iteration++ { - for _, mode := range modes { - stem := mode - if iteration > 1 { - stem = fmt.Sprintf("%s-%02d", mode, iteration) - } - t.Logf("stage=%s iteration=%d repeats=%d", mode, iteration, repeats) - setupStarted := time.Now() - var accelerator *cuda.Accelerator - var cudaConfig *cuda.Config - if mode != "cpu" { - config := cuda.Config{MemoryBytes: 64 << 30, HeadroomBytes: 8 << 30} - cudaConfig = &config - accelerator, err = cuda.Attach(nativePK, config) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = accelerator.Close() }) - } - setupSeconds := time.Since(setupStarted).Seconds() - var setupStats cuda.Stats - if accelerator != nil { - setupStats, err = accelerator.Stats() - if err != nil { - t.Fatal(err) - } - } - profileThisRun := cpuProfile && len(runs) == 0 - profilePath := "" - var profileFile *os.File - var profileStartSeconds, profileStopSeconds float64 - if profileThisRun { - profileStarted := time.Now() - profilePath = filepath.Join(output, stem+".cpu.pprof") - profileFile, err = os.OpenFile(profilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - t.Fatal(err) - } - if err := pprof.StartCPUProfile(profileFile); err != nil { - profileFile.Close() - t.Fatal(err) - } - profileStartSeconds = time.Since(profileStarted).Seconds() - } - proveStarted := time.Now() - var proof plonk.Proof - var proveSeconds float64 - var profileErr error - func() { - if profileFile != nil { - defer func() { - profileStopped := time.Now() - pprof.StopCPUProfile() - profileErr = profileFile.Close() - profileStopSeconds = time.Since(profileStopped).Seconds() - }() - } - proof, err = plonk.Prove(ccs, pk, witness, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) - proveSeconds = time.Since(proveStarted).Seconds() - }() - if err != nil { - t.Fatal(err) - } - if profileErr != nil { - t.Fatal(profileErr) - } - var stats cuda.Stats - if accelerator != nil { - stats, err = accelerator.Stats() - if err != nil { - t.Fatal(err) - } - if err := accelerator.Close(); err != nil { - t.Fatal(err) - } - if stats.MSMCalls == 0 || stats.FFTCalls == 0 { - t.Fatal("CUDA arithmetic was not dispatched") - } - if stats.NumeratorCalls != 1 { - t.Fatal("CUDA numerator was not dispatched exactly once") - } - } - var encoded bytes.Buffer - if _, err := proof.WriteTo(&encoded); err != nil { - t.Fatal(err) - } - decoded, err := decodeProof(encoded.Bytes(), m.Commitments) - if err != nil { - t.Fatal(err) - } - verifyStarted := time.Now() - if err := plonk.Verify(decoded, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err != nil { - t.Fatal(err) - } - verifySeconds := time.Since(verifyStarted).Seconds() - if err := plonk.Verify(decoded, vk, wrongPublic, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)); err == nil { - t.Fatal("accepted an altered claim") - } - proofPath := filepath.Join(output, stem+".proof.bin") - if err := os.WriteFile(proofPath, encoded.Bytes(), 0600); err != nil { - t.Fatal(err) - } - solidityPath := filepath.Join(output, stem+".solidity.bin") - if err := os.WriteFile(solidityPath, decoded.MarshalSolidity(), 0600); err != nil { - t.Fatal(err) - } - run := map[string]any{"mode": mode, "iteration": iteration, "cudaConfig": cudaConfig, "startedUTC": setupStarted.UTC().Format(time.RFC3339Nano), - "proveStartedUTC": proveStarted.UTC().Format(time.RFC3339Nano), "finishedUTC": time.Now().UTC().Format(time.RFC3339Nano), - "backendSetupSeconds": setupSeconds, "proveSeconds": proveSeconds, - "verifySeconds": verifySeconds, "proofPath": proofPath, "solidityPath": solidityPath, "proofBytes": encoded.Len(), - "proofSHA256": fmt.Sprintf("%x", sha256.Sum256(encoded.Bytes())), "cudaStats": stats, - "cudaSetupStats": setupStats} - run["cpuProfiled"], run["cpuProfilePath"] = profileThisRun, profilePath - run["profileStartSeconds"], run["profileStopSeconds"] = profileStartSeconds, profileStopSeconds - if profileThisRun { - profileIdentity, err := fileHash(profilePath) - if err != nil { - t.Fatal(err) - } - run["cpuProfileArtifact"] = profileIdentity - report["profiledRun"] = map[string]any{"mode": mode, "iteration": iteration, "path": profilePath, "artifact": profileIdentity} - } - if cudaConfig != nil { - run["msmStrategy"] = cudaConfig.MSMStrategy.String() - } - runs = append(runs, run) - report["runs"] = runs - if err := writeJSON(filepath.Join(output, "report.json"), report); err != nil { - t.Fatal(err) - } - t.Logf("stage=%s iteration=%d prove_seconds=%.6f setup_seconds=%.6f verify_seconds=%.6f stats=%+v", mode, iteration, proveSeconds, setupSeconds, verifySeconds, stats) - } - } - report["complete"], report["wallSeconds"] = true, time.Since(started).Seconds() - if err := writeJSON(filepath.Join(output, "report.json"), report); err != nil { - t.Fatal(err) - } -} - -func initDigest(text string) (string, error) { - value, err := unhex(text, sha256.Size) - if err != nil { - return "", err - } - return hex.EncodeToString(value), nil -} - -// Test executables have their own identity. This benchmark entry point trusts -// an externally pinned manifest; production loading retains its executable pin. -func initManifest(dir, expectedSHA256 string) (*manifest, error) { - f, err := os.Open(filepath.Join(dir, "manifest.json")) - if err != nil { - return nil, err - } - defer f.Close() - stat, err := f.Stat() - if err != nil { - return nil, err - } - if !stat.Mode().IsRegular() || stat.Size() > 1<<20 { - return nil, fmt.Errorf("Init manifest must be a regular file of at most 1 MiB") - } - data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) - if err != nil { - return nil, err - } - if len(data) > 1<<20 || fmt.Sprintf("%x", sha256.Sum256(data)) != expectedSHA256 { - return nil, fmt.Errorf("Init manifest differs from the explicitly trusted SHA-256") - } - var m manifest - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(&m); err != nil { - return nil, err - } - if err := d.Decode(new(any)); err != io.EOF { - return nil, fmt.Errorf("trailing Init manifest data") - } - if !m.KeysReady || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.PublicInputs != 2 || m.Commitments < 0 || m.Commitments > 1024 || len(m.Files) != 5 { - return nil, fmt.Errorf("Init requires prepared BN254 wrapper artifacts") - } - for _, digest := range []string{m.InnerVKSHA256, m.SRSBLAKE3} { - if _, err := initDigest(digest); err != nil { - return nil, fmt.Errorf("invalid Init manifest identity: %w", err) - } - } - for _, name := range []string{"circuit.bin", "proving-key.bin", "verifying-key.bin", "vk.json", "PlonkVerifier.sol"} { - want, ok := m.Files[name] - if !ok || want.Bytes <= 0 { - return nil, fmt.Errorf("missing Init artifact %s", name) - } - if _, err := initDigest(want.SHA256); err != nil { - return nil, fmt.Errorf("invalid Init artifact digest for %s", name) - } - stat, err := os.Stat(filepath.Join(dir, name)) - if err != nil { - return nil, err - } - if !stat.Mode().IsRegular() || stat.Size() != want.Bytes { - return nil, fmt.Errorf("Init artifact %s has an invalid type or size", name) - } - if err := m.checkFile(dir, name); err != nil { - return nil, err - } - } - if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { - return nil, fmt.Errorf("verifying key identity differs from its artifact digest") - } - return &m, nil -} - -// Hash the bytes actually decoded as well as the prior authentication pass. -func initReadBinary(path string, want artifact, value io.ReaderFrom) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - h := sha256.New() - n, err := value.ReadFrom(io.TeeReader(io.LimitReader(f, want.Bytes+1), h)) - if err != nil { - return err - } - if n != want.Bytes || hex.EncodeToString(h.Sum(nil)) != want.SHA256 { - return fmt.Errorf("decoded artifact %s differs from its pinned bytes", path) - } - var extra [1]byte - if n, err := f.Read(extra[:]); n != 0 || err != io.EOF { - return fmt.Errorf("trailing artifact data in %s", path) - } - return nil -} diff --git a/tools/plonk-wrapper/curve_bn254.go b/tools/plonk-wrapper/curve_bn254.go index 5677c64f3..2ac956d2b 100644 --- a/tools/plonk-wrapper/curve_bn254.go +++ b/tools/plonk-wrapper/curve_bn254.go @@ -7,7 +7,6 @@ import ( "github.com/consensys/gnark-crypto/ecc/bn254/fp" "github.com/consensys/gnark-crypto/ecc/bn254/fr" "github.com/consensys/gnark-crypto/ecc/bn254/kzg" - backend "github.com/consensys/gnark/backend/plonk/bn254" "github.com/consensys/gnark/frontend" sw "github.com/consensys/gnark/std/algebra/emulated/sw_bn254" "github.com/consensys/gnark/std/algebra/emulated/sw_emulated" @@ -21,8 +20,6 @@ type nativeG1 = native.G1Affine type nativeG2 = native.G2Affine type nativeSRS = kzg.SRS type nativeKzgProvingKey = kzg.ProvingKey -type nativeVerifyingKey = backend.VerifyingKey -type nativeProof = backend.Proof type circuitG1 = sw.G1Affine type circuitG2 = sw.G2Affine type circuitScalar = sw.Scalar @@ -81,14 +78,6 @@ func decodeG2(b []byte) (nativeG2, error) { } return p, nil } -func encodeAnchors(vk *nativeVerifyingKey) []byte { - out := make([]byte, 0, 320) - for _, v := range []*fp.Element{&vk.Kzg.G1.X, &vk.Kzg.G1.Y, &vk.Kzg.G2[0].X.A0, &vk.Kzg.G2[0].X.A1, &vk.Kzg.G2[0].Y.A0, &vk.Kzg.G2[0].Y.A1, &vk.Kzg.G2[1].X.A0, &vk.Kzg.G2[1].X.A1, &vk.Kzg.G2[1].Y.A0, &vk.Kzg.G2[1].Y.A1} { - b := v.Bytes() - out = append(out, b[:]...) - } - return out -} func pointBytes(api frontend.API, u *uints.BinaryField[uints.U32], f *emulated.Field[baseField], p *circuitG1) []uints.U8 { f.AssertIsInRange(&p.X) f.AssertIsInRange(&p.Y) diff --git a/tools/plonk-wrapper/ffi.go b/tools/plonk-wrapper/ffi.go index 4f8369cf8..c51621406 100644 --- a/tools/plonk-wrapper/ffi.go +++ b/tools/plonk-wrapper/ffi.go @@ -18,11 +18,6 @@ import ( "github.com/consensys/gnark/logger" ) -type retainedProver interface { - prove([]byte, []byte) ([]byte, []byte, error) - close() error -} - func init() { logger.SetOutput(os.Stderr) } func borrowed(view C.ix_gnark_view, limit uint64) ([]byte, error) { @@ -63,74 +58,6 @@ func guarded(call func() (C.ix_gnark_result, error)) (result C.ix_gnark_result) return result } -//export ix_gnark_compile -func ix_gnark_compile(schema C.ix_gnark_view, path C.ix_gnark_view) C.ix_gnark_result { - return guarded(func() (result C.ix_gnark_result, err error) { - data, err := borrowed(schema, 16<<20) - if err != nil { - return result, err - } - output, err := text(path) - if err != nil { - return result, err - } - info, err := compileCircuit(data, output) - if err != nil { - return result, err - } - encoded, err := json.Marshal(info) - if err != nil { - return result, err - } - result.data, result.len = buffer(encoded) - return result, nil - }) -} - -//export ix_gnark_prepare -func ix_gnark_prepare(schema, circuit, circuitHash C.ix_gnark_view, circuitBytes, domain C.uint64_t, srs, receipt, lagrange, lagrangeHash, output C.ix_gnark_view) C.ix_gnark_result { - return guarded(func() (result C.ix_gnark_result, err error) { - data, err := borrowed(schema, 16<<20) - if err != nil { - return result, err - } - strings := make([]string, 7) - for i, view := range []C.ix_gnark_view{circuit, circuitHash, srs, receipt, lagrange, lagrangeHash, output} { - strings[i], err = text(view) - if err != nil { - return result, err - } - } - if uint64(circuitBytes) > 1<<40 { - return result, fmt.Errorf("invalid circuit size") - } - return result, prepareCircuit(data, strings[0], artifact{SHA256: strings[1], Bytes: int64(circuitBytes)}, uint64(domain), strings[2], strings[3], strings[4], strings[5], strings[6]) - }) -} - -//export ix_gnark_open -func ix_gnark_open(directory, metadata C.ix_gnark_view, options C.ix_gnark_backend) C.ix_gnark_result { - return guarded(func() (result C.ix_gnark_result, err error) { - dir, err := text(directory) - if err != nil { - return result, err - } - data, err := borrowed(metadata, 1<<20) - if err != nil { - return result, err - } - if options.cuda > 1 || options.mask_msm > 1 || options.device < 0 { - return result, fmt.Errorf("invalid backend configuration") - } - p, err := openProver(dir, data, backendConfig{CUDA: options.cuda == 1, Device: int(options.device), MemoryBytes: uint64(options.memory_bytes), MaskMSM: options.mask_msm == 1}) - if err != nil { - return result, err - } - result.handle = C.uintptr_t(cgo.NewHandle(p)) - return result, nil - }) -} - //export ix_gnark_prove func ix_gnark_prove(handle C.uintptr_t, witness, claim C.ix_gnark_view) C.ix_gnark_result { return guarded(func() (result C.ix_gnark_result, err error) { @@ -153,20 +80,6 @@ func ix_gnark_prove(handle C.uintptr_t, witness, claim C.ix_gnark_view) C.ix_gna }) } -//export ix_gnark_verify -func ix_gnark_verify(key, keyHash, proof, claim, anchors C.ix_gnark_view) C.ix_gnark_result { - return guarded(func() (result C.ix_gnark_result, err error) { - values := make([][]byte, 5) - for i, view := range []C.ix_gnark_view{key, keyHash, proof, claim, anchors} { - values[i], err = borrowed(view, 1<<20) - if err != nil { - return result, err - } - } - return result, verifyProof(values[0], values[1], values[2], values[3], values[4]) - }) -} - //export ix_gnark_close func ix_gnark_close(handle C.uintptr_t) C.ix_gnark_result { return guarded(func() (result C.ix_gnark_result, err error) { @@ -179,3 +92,26 @@ func ix_gnark_close(handle C.uintptr_t) C.ix_gnark_result { //export ix_gnark_free func ix_gnark_free(pointer unsafe.Pointer) { C.free(pointer) } + +func compileFFI(schema C.ix_gnark_view, path C.ix_gnark_view, compile func([]byte, string) (circuitInfo, error)) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + data, err := borrowed(schema, 16<<20) + if err != nil { + return result, err + } + output, err := text(path) + if err != nil { + return result, err + } + info, err := compile(data, output) + if err != nil { + return result, err + } + encoded, err := json.Marshal(info) + if err != nil { + return result, err + } + result.data, result.len = buffer(encoded) + return result, nil + }) +} diff --git a/tools/plonk-wrapper/ffi.h b/tools/plonk-wrapper/ffi.h index 647a1d56f..2ddd3958a 100644 --- a/tools/plonk-wrapper/ffi.h +++ b/tools/plonk-wrapper/ffi.h @@ -10,5 +10,5 @@ typedef struct { uintptr_t handle; } ix_gnark_result; typedef struct { uint32_t cuda; int32_t device; uint64_t memory_bytes; uint32_t mask_msm; } ix_gnark_backend; -typedef struct { uint32_t cuda; int32_t device; uint32_t pin_keys; ix_gnark_view library; } ix_gnark_groth16_backend; +typedef struct { uint32_t cuda; int32_t device; ix_gnark_view library; } ix_gnark_groth16_backend; #endif diff --git a/tools/plonk-wrapper/ffi_groth16.go b/tools/plonk-wrapper/ffi_groth16.go index 2b49868ce..1cc91a9a8 100644 --- a/tools/plonk-wrapper/ffi_groth16.go +++ b/tools/plonk-wrapper/ffi_groth16.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build cgo && (groth16 || !plonk) package main @@ -8,33 +8,13 @@ package main import "C" import ( - "encoding/json" "fmt" "runtime/cgo" ) //export ix_gnark_groth16_compile func ix_gnark_groth16_compile(schema, path C.ix_gnark_view) C.ix_gnark_result { - return guarded(func() (result C.ix_gnark_result, err error) { - data, err := borrowed(schema, 16<<20) - if err != nil { - return result, err - } - output, err := text(path) - if err != nil { - return result, err - } - info, err := compileGroth16(data, output) - if err != nil { - return result, err - } - encoded, err := json.Marshal(info) - if err != nil { - return result, err - } - result.data, result.len = buffer(encoded) - return result, nil - }) + return compileFFI(schema, path, compileGroth16) } //export ix_gnark_groth16_setup @@ -80,10 +60,10 @@ func ix_gnark_groth16_open(directory, metadata C.ix_gnark_view, options C.ix_gna if err != nil { return result, err } - if options.cuda > 1 || options.pin_keys > 1 || options.device < 0 { + if options.cuda > 1 || options.device < 0 { return result, fmt.Errorf("invalid Groth16 backend configuration") } - p, err := openGroth16(dir, data, groth16Backend{CUDA: options.cuda == 1, Device: int(options.device), PinKeys: options.pin_keys == 1, Library: library}) + p, err := openGroth16(dir, data, groth16Backend{CUDA: options.cuda == 1, Device: int(options.device), Library: library}) if err != nil { return result, err } diff --git a/tools/plonk-wrapper/ffi_plonk.go b/tools/plonk-wrapper/ffi_plonk.go new file mode 100644 index 000000000..6c2ce6fab --- /dev/null +++ b/tools/plonk-wrapper/ffi_plonk.go @@ -0,0 +1,75 @@ +//go:build cgo && (plonk || !groth16) + +package main + +/* +#include "ffi.h" +*/ +import "C" +import ( + "fmt" + "runtime/cgo" +) + +//export ix_gnark_compile +func ix_gnark_compile(schema C.ix_gnark_view, path C.ix_gnark_view) C.ix_gnark_result { + return compileFFI(schema, path, compileCircuit) +} + +//export ix_gnark_prepare +func ix_gnark_prepare(schema, circuit, circuitHash C.ix_gnark_view, circuitBytes, domain C.uint64_t, srs, receipt, lagrange, lagrangeHash, output C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + data, err := borrowed(schema, 16<<20) + if err != nil { + return result, err + } + strings := make([]string, 7) + for i, view := range []C.ix_gnark_view{circuit, circuitHash, srs, receipt, lagrange, lagrangeHash, output} { + strings[i], err = text(view) + if err != nil { + return result, err + } + } + if uint64(circuitBytes) > 1<<40 { + return result, fmt.Errorf("invalid circuit size") + } + return result, prepareCircuit(data, strings[0], artifact{SHA256: strings[1], Bytes: int64(circuitBytes)}, uint64(domain), strings[2], strings[3], strings[4], strings[5], strings[6]) + }) +} + +//export ix_gnark_open +func ix_gnark_open(directory, metadata C.ix_gnark_view, options C.ix_gnark_backend) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + dir, err := text(directory) + if err != nil { + return result, err + } + data, err := borrowed(metadata, 1<<20) + if err != nil { + return result, err + } + if options.cuda > 1 || options.mask_msm > 1 || options.device < 0 { + return result, fmt.Errorf("invalid backend configuration") + } + p, err := openProver(dir, data, backendConfig{CUDA: options.cuda == 1, Device: int(options.device), MemoryBytes: uint64(options.memory_bytes), MaskMSM: options.mask_msm == 1}) + if err != nil { + return result, err + } + result.handle = C.uintptr_t(cgo.NewHandle(p)) + return result, nil + }) +} + +//export ix_gnark_verify +func ix_gnark_verify(key, keyHash, proof, claim, anchors C.ix_gnark_view) C.ix_gnark_result { + return guarded(func() (result C.ix_gnark_result, err error) { + values := make([][]byte, 5) + for i, view := range []C.ix_gnark_view{key, keyHash, proof, claim, anchors} { + values[i], err = borrowed(view, 1<<20) + if err != nil { + return result, err + } + } + return result, verifyProof(values[0], values[1], values[2], values[3], values[4]) + }) +} diff --git a/tools/plonk-wrapper/final_compare_test.go b/tools/plonk-wrapper/final_compare_test.go new file mode 100644 index 000000000..b8bee236f --- /dev/null +++ b/tools/plonk-wrapper/final_compare_test.go @@ -0,0 +1,176 @@ +//go:build (plonk && groth16) || (!plonk && !groth16) + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "strconv" + "strings" + "testing" + "time" +) + +func TestBN254FinalComparison(t *testing.T) { + if os.Getenv("IX_FINAL_COMPARE") != "1" { + t.Skip("set IX_FINAL_COMPARE=1 with prepared artifact and manifest pins") + } + out := os.Getenv("IX_FINAL_COMPARE_OUT") + if out == "" { + t.Fatal("IX_FINAL_COMPARE_OUT is required") + } + if err := os.MkdirAll(out, 0755); err != nil { + t.Fatal(err) + } + input := os.Getenv("IX_FINAL_COMPARE_INPUT") + if input == "" { + input = filepath.Join("testdata", "bn254") + } + schema, err := readDescription(filepath.Join(input, "vk.json")) + if err != nil { + t.Fatal(err) + } + witness, err := readDescription(filepath.Join(input, "witness.json")) + if err != nil { + t.Fatal(err) + } + var assignment witnessJSON + if err := json.Unmarshal(witness, &assignment); err != nil { + t.Fatal(err) + } + claim, err := unhex(assignment.ClaimDigest, 32) + if err != nil { + t.Fatal(err) + } + schemaHash := sha256.Sum256(schema) + witnessHash := sha256.Sum256(witness) + count := 3 + if value := os.Getenv("IX_FINAL_COMPARE_REPEATS"); value != "" { + count, err = strconv.Atoi(value) + if err != nil || count < 1 || count > 10 { + t.Fatal("repeats must be in 1..10") + } + } + selectValues := func(name, fallback string) []string { + value := os.Getenv(name) + if value == "" { + value = fallback + } + return strings.Split(value, ",") + } + boolean := func(name string) bool { + switch os.Getenv(name) { + case "", "0": + return false + case "1": + return true + default: + t.Fatalf("%s must be 0 or 1", name) + return false + } + } + report := map[string]any{"scope": "final proving, verification, and serialization through retained production provers", "go": runtime.Version(), "cpus": runtime.NumCPU(), "gomaxprocs": runtime.GOMAXPROCS(0), "schemaSHA256": hex.EncodeToString(schemaHash[:]), "witnessSHA256": hex.EncodeToString(witnessHash[:])} + var runs []map[string]any + for _, device := range selectValues("IX_FINAL_COMPARE_DEVICES", "cpu") { + if device != "cpu" && device != "cuda" { + t.Fatal("devices must be cpu or cuda") + } + for _, system := range selectValues("IX_FINAL_COMPARE_SYSTEMS", "groth16,plonk") { + if system != "plonk" && system != "groth16" { + t.Fatal("systems must be plonk or groth16") + } + prefix := "IX_FINAL_" + strings.ToUpper(system) + dir := os.Getenv(prefix + "_ARTIFACTS") + if dir == "" { + t.Fatalf("%s_ARTIFACTS is required", prefix) + } + metadata, err := readDescription(filepath.Join(dir, "manifest.json")) + if err != nil { + t.Fatal(err) + } + hash := sha256.Sum256(metadata) + if hex.EncodeToString(hash[:]) != os.Getenv(prefix+"_MANIFEST_SHA256") { + t.Fatalf("%s manifest differs from its pin", system) + } + started := time.Now() + var open func() (retainedProver, error) + var inner string + var constraints int + var domain uint64 + if system == "plonk" { + var m manifest + if err := decodeReader(bytes.NewReader(metadata), &m); err != nil { + t.Fatal(err) + } + inner, constraints, domain = m.InnerVKSHA256, m.Constraints, m.Domain + open = func() (retainedProver, error) { + return openProver(dir, metadata, backendConfig{CUDA: device == "cuda", MemoryBytes: 64 << 30}) + } + } else { + var m groth16Manifest + if err := decodeReader(bytes.NewReader(metadata), &m); err != nil { + t.Fatal(err) + } + inner, constraints, domain = m.InnerVK, m.Constraints, m.Domain + open = func() (retainedProver, error) { + return openGroth16(dir, metadata, groth16Backend{CUDA: device == "cuda", Library: os.Getenv("ICICLE_BACKEND_INSTALL_DIR")}) + } + } + if inner != hex.EncodeToString(schemaHash[:]) { + t.Fatal("artifacts describe another verifier schema") + } + prover, err := open() + if err != nil { + t.Fatal(err) + } + run := map[string]any{"system": system, "device": device, "pinKeys": false, "constraints": constraints, "domain": domain, "manifestSHA256": hex.EncodeToString(hash[:]), "openSeconds": time.Since(started).Seconds()} + func() { + defer func() { + if err := prover.close(); err != nil { + t.Error(err) + } + }() + if _, _, err := prover.prove(witness, claim); err != nil { + t.Fatal(err) + } + if boolean("IX_FINAL_COMPARE_CPU_PROFILE") { + path := filepath.Join(out, system+"-"+device+".pprof") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := pprof.StartCPUProfile(file); err != nil { + t.Fatal(err) + } + defer pprof.StopCPUProfile() + run["cpuProfile"] = path + } + times := make([]float64, 0, count) + for i := 0; i < count; i++ { + started := time.Now() + native, solidity, err := prover.prove(witness, claim) + if err != nil { + t.Fatal(err) + } + times = append(times, time.Since(started).Seconds()) + run["nativeBytes"], run["solidityBytes"] = len(native), len(solidity) + } + run["proveVerifySeconds"] = times + }() + runs = append(runs, run) + report["runs"] = runs + if err := writeJSON(filepath.Join(out, "report.json"), report); err != nil { + t.Fatal(err) + } + t.Log(fmt.Sprint(run)) + } + } +} diff --git a/tools/plonk-wrapper/groth16.go b/tools/plonk-wrapper/groth16.go index fcb8b9d94..fce67019c 100644 --- a/tools/plonk-wrapper/groth16.go +++ b/tools/plonk-wrapper/groth16.go @@ -1,3 +1,5 @@ +//go:build groth16 || !plonk + package main import ( @@ -24,18 +26,6 @@ import ( const groth16MaxProofBytes = 196 -func decodeGroth16JSON(data []byte, out any) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(out); err != nil { - return err - } - if err := decoder.Decode(new(any)); err != io.EOF { - return fmt.Errorf("trailing Groth16 JSON data") - } - return nil -} - type groth16Manifest struct { InnerVK string `json:"innerVKSHA256"` Constraints int `json:"constraints"` @@ -239,7 +229,6 @@ type groth16Engine struct { type groth16Backend struct { CUDA bool Device int - PinKeys bool Library string } @@ -268,7 +257,7 @@ type groth16Session struct { func openGroth16(dir string, metadata []byte, config groth16Backend) (*groth16Session, error) { var m groth16Manifest - if err := decodeGroth16JSON(metadata, &m); err != nil { + if err := decodeReader(bytes.NewReader(metadata), &m); err != nil { return nil, err } p, ccs, err := groth16Load(dir, &m) @@ -466,7 +455,7 @@ func (p *groth16Session) prove(data, claim []byte) ([]byte, []byte, error) { func setupGroth16(dir string, metadata []byte, kind uint32, descriptor, pin string) ([]byte, error) { var m groth16Manifest - if err := decodeGroth16JSON(metadata, &m); err != nil { + if err := decodeReader(bytes.NewReader(metadata), &m); err != nil { return nil, err } var err error diff --git a/tools/plonk-wrapper/groth16_cuda.go b/tools/plonk-wrapper/groth16_cuda.go index 9c90e15a0..b55bca4f2 100644 --- a/tools/plonk-wrapper/groth16_cuda.go +++ b/tools/plonk-wrapper/groth16_cuda.go @@ -1,4 +1,4 @@ -//go:build icicle +//go:build (groth16 || !plonk) && icicle package main @@ -39,7 +39,6 @@ func onGroth16Device(device *cr.Device, call func() error) error { func newGroth16CUDAEngine(config groth16Backend) (*groth16Engine, error) { deviceID := config.Device - pin := config.PinKeys groth16CUDAInit.Do(func() { var status cr.EIcicleError if config.Library != "" { @@ -71,7 +70,7 @@ func newGroth16CUDAEngine(config groth16Backend) (*groth16Engine, error) { engine.prove = func(ccs constraint.ConstraintSystem, w witness.Witness) (proof groth16.Proof, err error) { lock.Lock() defer lock.Unlock() - config, err := icicle.NewConfig(icicle.WithDeviceID(deviceID), icicle.WithPinKeysToGPU(pin), + config, err := icicle.NewConfig(icicle.WithDeviceID(deviceID), icicle.WithPinKeysToGPU(false), icicle.WithProverOptions(backend.WithProverHashToFieldFunction(sha256.New()))) if err != nil { return nil, err diff --git a/tools/plonk-wrapper/groth16_cuda_test.go b/tools/plonk-wrapper/groth16_cuda_test.go index c545bc1a6..8ab23dad3 100644 --- a/tools/plonk-wrapper/groth16_cuda_test.go +++ b/tools/plonk-wrapper/groth16_cuda_test.go @@ -1,4 +1,4 @@ -//go:build icicle +//go:build (groth16 || !plonk) && icicle package main @@ -40,27 +40,24 @@ func TestGroth16CUDAInterop(t *testing.T) { if err != nil { t.Fatal(err) } - for _, pin := range []string{"0", "1"} { - t.Setenv("IX_GROTH16_PIN_KEY", pin) - engine, err := newGroth16Engine(groth16Backend{CUDA: true, PinKeys: pin == "1", Library: os.Getenv("ICICLE_BACKEND_INSTALL_DIR")}) - if err != nil { + engine, err := newGroth16Engine(groth16Backend{CUDA: true, Library: os.Getenv("ICICLE_BACKEND_INSTALL_DIR")}) + if err != nil { + t.Fatal(err) + } + func() { + defer engine.close() + if _, err := engine.key.ReadFrom(bytes.NewReader(encoded.Bytes())); err != nil { t.Fatal(err) } - func() { - defer engine.close() - if _, err := engine.key.ReadFrom(bytes.NewReader(encoded.Bytes())); err != nil { + for range 2 { + proof, err := engine.prove(ccs, w) + if err != nil { t.Fatal(err) } - for range 2 { - proof, err := engine.prove(ccs, w) - if err != nil { - t.Fatal(err) - } - if err := groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())); err != nil { - t.Fatal(err) - } + if err := groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())); err != nil { + t.Fatal(err) } - }() - } + } + }() } } diff --git a/tools/plonk-wrapper/groth16_no_cuda.go b/tools/plonk-wrapper/groth16_no_cuda.go index f07535345..c58f41670 100644 --- a/tools/plonk-wrapper/groth16_no_cuda.go +++ b/tools/plonk-wrapper/groth16_no_cuda.go @@ -1,4 +1,4 @@ -//go:build !icicle +//go:build (groth16 || !plonk) && !icicle package main diff --git a/tools/plonk-wrapper/groth16_setup.go b/tools/plonk-wrapper/groth16_setup.go index 84f9ef6fc..1716c13a5 100644 --- a/tools/plonk-wrapper/groth16_setup.go +++ b/tools/plonk-wrapper/groth16_setup.go @@ -1,6 +1,9 @@ +//go:build groth16 || !plonk + package main import ( + "bytes" "crypto/sha256" "encoding/hex" "fmt" @@ -81,7 +84,7 @@ func groth16PinnedJSON(path, pin string, out any) error { if hex.EncodeToString(h[:]) != pin { return fmt.Errorf("Groth16 setup metadata differs from the independently trusted digest") } - return decodeGroth16JSON(data, out) + return decodeReader(bytes.NewReader(data), out) } func groth16MPCSetup(dir string, m *groth16Manifest, path, trusted string) error { diff --git a/tools/plonk-wrapper/groth16_setup_test.go b/tools/plonk-wrapper/groth16_setup_test.go index bfd3eb661..4cbd89fdf 100644 --- a/tools/plonk-wrapper/groth16_setup_test.go +++ b/tools/plonk-wrapper/groth16_setup_test.go @@ -1,3 +1,5 @@ +//go:build groth16 || !plonk + package main import ( diff --git a/tools/plonk-wrapper/groth16_test.go b/tools/plonk-wrapper/groth16_test.go index a2efb3a35..5682a270e 100644 --- a/tools/plonk-wrapper/groth16_test.go +++ b/tools/plonk-wrapper/groth16_test.go @@ -1,3 +1,5 @@ +//go:build groth16 || !plonk + package main import ( @@ -130,3 +132,23 @@ func TestGroth16BackendSelection(t *testing.T) { } } } + +func verifyGroth16Files(keyPath, pin, proofPath, claim string) error { + key, err := os.ReadFile(keyPath) + if err != nil { + return err + } + proof, err := os.ReadFile(proofPath) + if err != nil { + return err + } + trusted, err := unhex(pin, 32) + if err != nil { + return err + } + expected, err := unhex(claim, 32) + if err != nil { + return err + } + return verifyGroth16(key, trusted, proof, expected) +} diff --git a/tools/plonk-wrapper/parameters.go b/tools/plonk-wrapper/parameters.go index a365aef67..118366e30 100644 --- a/tools/plonk-wrapper/parameters.go +++ b/tools/plonk-wrapper/parameters.go @@ -1,3 +1,5 @@ +//go:build plonk || !groth16 + package main import ( diff --git a/tools/plonk-wrapper/parameters_bn254_test.go b/tools/plonk-wrapper/parameters_bn254_test.go index 87f46f061..2e94a8b5d 100644 --- a/tools/plonk-wrapper/parameters_bn254_test.go +++ b/tools/plonk-wrapper/parameters_bn254_test.go @@ -1,3 +1,5 @@ +//go:build plonk || !groth16 + package main import ( @@ -11,10 +13,6 @@ import ( ) func TestLagrangeCacheAndBasisConversion(t *testing.T) { - p, err := loadVerifyingKey(filepath.Join("testdata", "bn254", "vk.json")) - if err != nil { - t.Fatal(err) - } var receipt struct { Format, Blake3, SetupID, IgnitionManifest string PublicMaxDegree, Powers uint64 @@ -22,7 +20,7 @@ func TestLagrangeCacheAndBasisConversion(t *testing.T) { if err := decodeJSON(filepath.Join("testdata", "bn254", "srs.json"), &receipt); err != nil { t.Fatal(err) } - srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128, p) + srs, err := readSRS(filepath.Join("testdata", "bn254", "srs.bin"), receipt.Blake3, 128) if err != nil { t.Fatal(err) } diff --git a/tools/plonk-wrapper/profile.go b/tools/plonk-wrapper/profile.go index eeacef5b3..f31dec287 100644 --- a/tools/plonk-wrapper/profile.go +++ b/tools/plonk-wrapper/profile.go @@ -323,3 +323,19 @@ func digestLimbs(digest []byte) [2]frontend.Variable { } return result } + +type circuitInfo struct { + Domain uint64 + Constraints, PublicInputs, Commitments int +} + +type publicClaim struct { + Claim [2]frontend.Variable `gnark:",public"` +} + +func (*publicClaim) Define(frontend.API) error { return nil } + +type retainedProver interface { + prove([]byte, []byte) ([]byte, []byte, error) + close() error +} diff --git a/tools/plonk-wrapper/prover.go b/tools/plonk-wrapper/prover.go index a399041ad..821cfc3e5 100644 --- a/tools/plonk-wrapper/prover.go +++ b/tools/plonk-wrapper/prover.go @@ -1,3 +1,5 @@ +//go:build plonk || !groth16 + package main import ( @@ -6,7 +8,6 @@ import ( "encoding/hex" "fmt" "io" - "os" "path/filepath" "runtime/debug" "sync" @@ -20,11 +21,6 @@ import ( "github.com/consensys/gnark/frontend/cs/scs" ) -type circuitInfo struct { - Domain uint64 - Constraints, PublicInputs, Commitments int -} - type backendConfig struct { CUDA bool Device int @@ -50,11 +46,10 @@ func compileCircuit(schema []byte, path string) (circuitInfo, error) { } func prepareCircuit(schema []byte, circuitPath string, circuitHash artifact, domain uint64, srsPath, receipt, lagrangePath, lagrangeDigest, output string) error { - key, err := decodeVerifyingKey(schema) - if err != nil { + if _, err := decodeVerifyingKey(schema); err != nil { return err } - srs, err := readSRS(srsPath, receipt, domain, key) + srs, err := readSRS(srsPath, receipt, domain) if err != nil { return err } @@ -210,18 +205,26 @@ func (p *prover) close() error { return err } -func readDescription(path string) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, err +type manifest struct { + InnerVKSHA256 string + SRSBLAKE3 string + KeysReady bool + Domain uint64 + Constraints, PublicInputs, Commitments int + Files map[string]artifact +} + +func (m *manifest) validate() error { + if m.PublicInputs != 2 || m.Constraints < 1 || m.Constraints > 1<<26 || m.Domain < uint64(m.Constraints+2) || m.Domain > 1<<26 || m.Domain&(m.Domain-1) != 0 || m.Commitments < 0 || m.Commitments > 1024 || m.Files == nil { + return fmt.Errorf("invalid wrapper artifact metadata") } - defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, (16<<20)+1)) - if err != nil { - return nil, err + if m.KeysReady { + if _, err := unhex(m.SRSBLAKE3, 32); err != nil { + return err + } } - if len(data) > 16<<20 { - return nil, fmt.Errorf("verifier description is too large") + if m.Files["vk.json"].SHA256 != m.InnerVKSHA256 { + return fmt.Errorf("verifying key identity differs") } - return data, nil + return nil } diff --git a/tools/plonk-wrapper/terminal_compare_test.go b/tools/plonk-wrapper/terminal_compare_test.go deleted file mode 100644 index 42a78c484..000000000 --- a/tools/plonk-wrapper/terminal_compare_test.go +++ /dev/null @@ -1,337 +0,0 @@ -package main - -import ( - "crypto/sha256" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "sort" - "strconv" - "strings" - "testing" - "time" - - "github.com/consensys/gnark-crypto/ecc" - "github.com/consensys/gnark/backend" - "github.com/consensys/gnark/backend/groth16" - g16 "github.com/consensys/gnark/backend/groth16/bn254" - "github.com/consensys/gnark/backend/plonk" - "github.com/consensys/gnark/backend/solidity" - "github.com/consensys/gnark/frontend" - "github.com/consensys/gnark/frontend/cs/scs" - "github.com/consensys/gnark/test/unsafekzg" -) - -type terminalSample struct { - ProveSeconds float64 `json:"proveSeconds"` - VerifySeconds float64 `json:"verifySeconds"` - NativeBytes int64 `json:"nativeBytes"` - SolidityBytes int `json:"solidityBytes"` - EnvelopeBytes int64 `json:"envelopeBytes"` -} - -type terminalMeasurement struct { - System string `json:"system"` - Device string `json:"device"` - PinKeys bool `json:"pinKeys"` - Constraints int `json:"constraints"` - Domain uint64 `json:"domain"` - Commitments int `json:"commitments"` - LoadSeconds float64 `json:"openSeconds"` - First terminalSample `json:"first"` - Warm []terminalSample `json:"warm"` - MedianProveSeconds float64 `json:"medianProveSeconds"` -} - -type terminalComparison struct { - Scope string `json:"scope"` - Setup string `json:"setup"` - Schema artifact `json:"schema"` - Witness artifact `json:"witness"` - GoVersion string `json:"goVersion"` - CPUs int `json:"cpus"` - GOMAXPROCS int `json:"gomaxprocs"` - PlonkCompileSeconds float64 `json:"plonkCompileSeconds"` - PlonkSRSSeconds float64 `json:"plonkSRSSeconds"` - PlonkSetupSeconds float64 `json:"plonkSetupSeconds"` - Groth16KeysReused bool `json:"groth16KeysReused"` - PlonkKeysReused bool `json:"plonkKeysReused"` - Results []terminalMeasurement `json:"results"` -} - -func TestBN254TerminalComparison(t *testing.T) { - if os.Getenv("IX_TERMINAL_COMPARE") != "1" { - t.Skip("set IX_TERMINAL_COMPARE=1 for the matched terminal benchmark") - } - out := os.Getenv("IX_TERMINAL_COMPARE_OUT") - grothDir := os.Getenv("IX_TERMINAL_GROTH16_ARTIFACTS") - if out == "" || grothDir == "" { - t.Fatal("set IX_TERMINAL_COMPARE_OUT and IX_TERMINAL_GROTH16_ARTIFACTS") - } - input := os.Getenv("IX_TERMINAL_COMPARE_INPUT") - if input == "" { - input = filepath.Join("testdata", "bn254") - } - repeats := 3 - if value := os.Getenv("IX_TERMINAL_COMPARE_REPEATS"); value != "" { - var err error - repeats, err = strconv.Atoi(value) - if err != nil || repeats < 1 || repeats > 10 { - t.Fatal("repeats must be in 1..10") - } - } - devices := strings.Split(os.Getenv("IX_TERMINAL_COMPARE_DEVICES"), ",") - if devices[0] == "" { - devices = []string{"cpu"} - } - for _, device := range devices { - if device != "cpu" && device != "cuda" { - t.Fatal("devices must be cpu or cuda") - } - } - keyPath, witnessPath := filepath.Join(input, "vk.json"), filepath.Join(input, "witness.json") - p, err := loadVerifyingKey(keyPath) - if err != nil { - t.Fatal(err) - } - assignment, err := witnessAssignment(p, witnessPath) - if err != nil { - t.Fatal(err) - } - w, err := frontend.NewWitness(assignment, curveID.ScalarField()) - if err != nil { - t.Fatal(err) - } - public, err := w.Public() - if err != nil { - t.Fatal(err) - } - report := terminalComparison{Scope: "actual Init recursive KZG verifier and witness; terminal proving only", Setup: "Groth16 development keys; PLONK setup is recorded by the selected cache", - GoVersion: runtime.Version(), CPUs: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), Groth16KeysReused: true} - report.Schema, err = fileHash(keyPath) - if err != nil { - t.Fatal(err) - } - report.Witness, err = fileHash(witnessPath) - if err != nil { - t.Fatal(err) - } - gm := new(groth16Manifest) - if err := decodeJSON(filepath.Join(grothDir, "manifest.json"), gm); err != nil { - t.Fatal(err) - } - if err := gm.validate(); err != nil { - t.Fatal(err) - } - if gm.InnerVK != report.Schema.SHA256 || gm.Setup != "development" { - t.Fatal("Groth16 cache does not match the comparison fixture") - } - plonkDir := filepath.Join(out, "plonk") - externalPlonk := os.Getenv("IX_TERMINAL_PLONK_ARTIFACTS") - if externalPlonk != "" { - plonkDir = externalPlonk - metadata, err := fileHash(filepath.Join(plonkDir, "manifest.json")) - if err != nil { - t.Fatal(err) - } - if metadata.SHA256 != os.Getenv("IX_TERMINAL_PLONK_MANIFEST_SHA256") { - t.Fatal("PLONK manifest differs from the comparison pin") - } - var m manifest - if err := decodeJSON(filepath.Join(plonkDir, "manifest.json"), &m); err != nil { - t.Fatal(err) - } - if m.InnerVKSHA256 != report.Schema.SHA256 || !m.KeysReady || m.PublicInputs != 2 { - t.Fatal("PLONK artifacts describe another circuit") - } - for _, name := range []string{"vk.json", "circuit.bin", "proving-key.bin", "verifying-key.bin"} { - if err := m.checkFile(plonkDir, name); err != nil { - t.Fatal(err) - } - } - report.PlonkKeysReused = true - } else if _, err := os.Stat(filepath.Join(plonkDir, "ready.json")); os.IsNotExist(err) { - start := time.Now() - ccs, err := frontend.Compile(curveID.ScalarField(), scs.NewBuilder, newCircuit(p)) - if err != nil { - t.Fatal(err) - } - report.PlonkCompileSeconds = time.Since(start).Seconds() - if err := atomicFile(filepath.Join(plonkDir, "circuit.bin"), func(w io.Writer) error { _, err := ccs.WriteTo(w); return err }); err != nil { - t.Fatal(err) - } - t.Logf("PLONK constraints=%d domain=%d; Groth16 constraints=%d domain=%d", ccs.GetNbConstraints(), ecc.NextPowerOfTwo(uint64(ccs.GetNbConstraints()+ccs.GetNbPublicVariables())), gm.Constraints, gm.Domain) - if os.Getenv("IX_TERMINAL_COMPARE_COMPILE_ONLY") == "1" { - return - } - start = time.Now() - srs, lagrange, err := unsafekzg.NewSRS(ccs) - if err != nil { - t.Fatal(err) - } - report.PlonkSRSSeconds = time.Since(start).Seconds() - start = time.Now() - pk, vk, err := plonk.Setup(ccs, srs, lagrange) - if err != nil { - t.Fatal(err) - } - report.PlonkSetupSeconds = time.Since(start).Seconds() - for _, item := range []struct { - name string - value io.WriterTo - }{{"proving-key.bin", pk}, {"verifying-key.bin", vk}} { - if err := atomicFile(filepath.Join(plonkDir, item.name), func(w io.Writer) error { _, err := item.value.WriteTo(w); return err }); err != nil { - t.Fatal(err) - } - } - if err := writeJSON(filepath.Join(plonkDir, "ready.json"), report); err != nil { - t.Fatal(err) - } - } else if err != nil { - t.Fatal(err) - } - if externalPlonk == "" { - var cached terminalComparison - if err := decodeJSON(filepath.Join(plonkDir, "ready.json"), &cached); err != nil { - t.Fatal(err) - } - if cached.Schema != report.Schema || cached.Witness != report.Witness { - t.Fatal("PLONK cache belongs to another fixture") - } - report.PlonkCompileSeconds, report.PlonkSRSSeconds, report.PlonkSetupSeconds = cached.PlonkCompileSeconds, cached.PlonkSRSSeconds, cached.PlonkSetupSeconds - } - if os.Getenv("IX_TERMINAL_COMPARE_PREPARE_ONLY") == "1" { - return - } - systems := strings.Split(os.Getenv("IX_TERMINAL_COMPARE_SYSTEMS"), ",") - if systems[0] == "" { - systems = []string{"groth16", "plonk"} - } - for _, system := range systems { - if system != "groth16" && system != "plonk" { - t.Fatal("systems must be groth16 or plonk") - } - } - debug.FreeOSMemory() - for _, device := range devices { - for _, system := range systems { - measurement := terminalMeasurement{System: system, Device: device, PinKeys: system == "groth16" && device == "cuda" && os.Getenv("IX_GROTH16_PIN_KEY") == "1"} - start := time.Now() - var prove func() (terminalSample, error) - var close func() - if system == "groth16" { - t.Setenv("IX_GROTH16_BACKEND", device) - engine, err := newGroth16Engine(groth16Backend{CUDA: device == "cuda", PinKeys: measurement.PinKeys, Library: os.Getenv("ICICLE_BACKEND_INSTALL_DIR")}) - if err != nil { - t.Fatal(err) - } - ccs, vk := groth16.NewCS(curveID), new(g16.VerifyingKey) - for _, item := range []struct { - name string - value io.ReaderFrom - }{{"circuit.bin", ccs}, {"proving-key.bin", engine.key}, {"verifying-key.bin", vk}} { - if err := readBinary(filepath.Join(grothDir, item.name), item.value); err != nil { - engine.close() - t.Fatal(err) - } - } - close = engine.close - measurement.Constraints, measurement.Domain, measurement.Commitments = gm.Constraints, gm.Domain, gm.Commitments - prove = func() (terminalSample, error) { - start := time.Now() - proof, err := engine.prove(ccs, w) - if err != nil { - return terminalSample{}, err - } - result := terminalSample{ProveSeconds: time.Since(start).Seconds()} - start = time.Now() - err = groth16.Verify(proof, vk, public, backend.WithVerifierHashToFieldFunction(sha256.New())) - result.VerifySeconds = time.Since(start).Seconds() - if err != nil { - return result, err - } - result.NativeBytes, err = proof.WriteTo(io.Discard) - result.SolidityBytes = len(proof.(*g16.Proof).MarshalSolidity()) - result.EnvelopeBytes = 40 + result.NativeBytes - return result, err - } - } else { - t.Setenv("IX_PLONK_BACKEND", device) - ccs := plonk.NewCS(curveID) - pk, vk := plonk.NewProvingKey(curveID), plonk.NewVerifyingKey(curveID) - for _, item := range []struct { - name string - value io.ReaderFrom - }{{"circuit.bin", ccs}, {"proving-key.bin", pk}, {"verifying-key.bin", vk}} { - if err := readBinary(filepath.Join(plonkDir, item.name), item.value); err != nil { - t.Fatal(err) - } - } - cleanup, err := configureBackend(pk, backendConfig{CUDA: device == "cuda", MemoryBytes: 64 << 30}) - if err != nil { - t.Fatal(err) - } - close = func() { - if err := cleanup(); err != nil { - t.Error(err) - } - } - measurement.Constraints, measurement.Domain, measurement.Commitments = ccs.GetNbConstraints(), vk.(*nativeVerifyingKey).Size, len(vk.(*nativeVerifyingKey).Qcp) - prove = func() (terminalSample, error) { - start := time.Now() - proof, err := plonk.Prove(ccs, pk, w, solidity.WithProverTargetSolidityVerifier(backend.PLONK)) - if err != nil { - return terminalSample{}, err - } - result := terminalSample{ProveSeconds: time.Since(start).Seconds()} - start = time.Now() - err = plonk.Verify(proof, vk, public, solidity.WithVerifierTargetSolidityVerifier(backend.PLONK)) - result.VerifySeconds = time.Since(start).Seconds() - if err != nil { - return result, err - } - result.NativeBytes, err = proof.WriteTo(io.Discard) - result.SolidityBytes = len(proof.(*nativeProof).MarshalSolidity()) - result.EnvelopeBytes = 40 + result.NativeBytes - return result, err - } - } - measurement.LoadSeconds = time.Since(start).Seconds() - func() { - defer close() - var err error - measurement.First, err = prove() - if err != nil { - t.Fatal(err) - } - for range repeats { - sample, err := prove() - if err != nil { - t.Fatal(err) - } - measurement.Warm = append(measurement.Warm, sample) - } - }() - times := make([]float64, len(measurement.Warm)) - for i, sample := range measurement.Warm { - times[i] = sample.ProveSeconds - } - sort.Float64s(times) - measurement.MedianProveSeconds = times[len(times)/2] - if len(times)%2 == 0 { - measurement.MedianProveSeconds = (times[len(times)/2-1] + times[len(times)/2]) / 2 - } - report.Results = append(report.Results, measurement) - if err := writeJSON(filepath.Join(out, "comparison.json"), report); err != nil { - t.Fatal(err) - } - t.Logf("%s %s: median=%.3fs native=%d envelope=%d Solidity=%d", system, device, measurement.MedianProveSeconds, measurement.First.NativeBytes, measurement.First.EnvelopeBytes, measurement.First.SolidityBytes) - prove, close = nil, nil - debug.FreeOSMemory() - } - } - fmt.Println("Matched comparison:", filepath.Join(out, "comparison.json")) -} diff --git a/tools/plonk-wrapper/test_helpers_test.go b/tools/plonk-wrapper/test_helpers_test.go index bb41b71da..05642d91e 100644 --- a/tools/plonk-wrapper/test_helpers_test.go +++ b/tools/plonk-wrapper/test_helpers_test.go @@ -2,31 +2,15 @@ package main import ( "encoding/json" - "fmt" + "github.com/consensys/gnark/frontend" "io" "os" - "path/filepath" ) func writeJSON(path string, value any) error { return atomicFile(path, func(w io.Writer) error { enc := json.NewEncoder(w); enc.SetIndent("", " "); return enc.Encode(value) }) } -func (m *manifest) checkFile(dir, name string) error { - want, ok := m.Files[name] - if !ok { - return fmt.Errorf("artifact %s missing from manifest", name) - } - got, err := fileHash(filepath.Join(dir, name)) - if err != nil { - return err - } - if got != want { - return fmt.Errorf("artifact %s checksum or size differs", name) - } - return nil -} - func decodeJSON(path string, out any) error { f, err := os.Open(path) if err != nil { @@ -55,32 +39,21 @@ func witnessAssignment(p *verifyingKey, path string) (*wrapperCircuit, error) { return assignWitness(p, &w) } -func readBinary(path string, value io.ReaderFrom) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - _, err = value.ReadFrom(f) - return err +type backendCircuit struct { + Claim [2]frontend.Variable `gnark:",public"` + X frontend.Variable + Commit bool `gnark:"-"` } -func verifyGroth16Files(keyPath, pin, proofPath, claim string) error { - key, err := os.ReadFile(keyPath) - if err != nil { - return err - } - proof, err := os.ReadFile(proofPath) - if err != nil { - return err - } - trusted, err := unhex(pin, 32) - if err != nil { - return err - } - expected, err := unhex(claim, 32) - if err != nil { - return err +func (c *backendCircuit) Define(api frontend.API) error { + api.AssertIsEqual(api.Mul(c.X, c.X), c.Claim[0]) + api.AssertIsEqual(c.Claim[1], 1) + if c.Commit { + challenge, err := api.(frontend.Committer).Commit(c.X) + if err != nil { + return err + } + api.AssertIsDifferent(challenge, 0) } - return verifyGroth16(key, trusted, proof, expected) + return nil } diff --git a/tools/plonk-wrapper/testdata/terminal-comparison.json b/tools/plonk-wrapper/testdata/final-comparison.json similarity index 94% rename from tools/plonk-wrapper/testdata/terminal-comparison.json rename to tools/plonk-wrapper/testdata/final-comparison.json index b7f4c06b0..5a1a32fed 100644 --- a/tools/plonk-wrapper/testdata/terminal-comparison.json +++ b/tools/plonk-wrapper/testdata/final-comparison.json @@ -1,5 +1,5 @@ { - "scope": "actual Init recursive KZG verifier and witness; terminal proving only", + "scope": "actual Init recursive KZG verifier and witness; final proving only", "setup": "Groth16 development setup; PLONK cached Ignition parameters", "schema": { "SHA256": "b86e203bcc2dcd87a67c1e325e24328e12534676ada7ea9602ac319568139c29", @@ -194,5 +194,5 @@ }, "date": "2026-10-29", "samplePolicy": "one first proof and three warm proofs per case, separate processes, default parallelism", - "limits": "Terminal proving only. Prove includes gnark witness solving. Key opening, previous Ix/KZG stages and witness export are excluded from warm proving. Groth16 CUDA pins key vectors; PLONK CUDA uses its existing sppark adapter." + "limits": "Final proving only. Prove includes gnark witness solving. Key opening, previous Ix/KZG stages and witness export are excluded from warm proving. Groth16 CUDA pins key vectors; PLONK CUDA uses its existing sppark adapter." } From d893a81fb17c7e64a84fd568c9843f3780c930e6 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:20:52 +0000 Subject: [PATCH 26/31] refactor(compression): use the gnark Ignition SRS and the Ix mirror Delegate final-contribution verification to gnark-ignition-verifier and load the standard gnark SRS (srs.bin), removing the custom transcript verification, cache receipts and admission metadata. The release keys and parameters are unchanged. The generator reads the Ix mirror of the final two contributions by default. Default the crate to parallel only, naming the gnark features in CI. --- .github/workflows/ci.yml | 6 +- Cargo.lock | 11 - Ix/Cli/CompressionCmd.lean | 16 +- crates/proof-compression/Cargo.toml | 3 +- crates/proof-compression/README.md | 139 +--- crates/proof-compression/build/gnark.rs | 7 +- .../examples/kzg_ignition_import.rs | 188 ------ .../src/kzg/cuda/tests/plonk_scale.rs | 22 +- crates/proof-compression/src/kzg/srs.rs | 2 +- crates/proof-compression/src/kzg/srs/gnark.rs | 159 +++++ .../proof-compression/src/kzg/srs/ignition.rs | 260 -------- .../src/kzg/srs/ignition/admission.rs | 607 ----------------- .../src/kzg/srs/ignition/cache.rs | 421 ------------ .../src/kzg/srs/ignition/points.rs | 106 --- .../src/kzg/srs/ignition/source.rs | 461 ------------- .../src/kzg/srs/ignition/tests.rs | 608 ------------------ .../src/pipeline/compress.rs | 6 +- .../proof-compression/src/pipeline/derive.rs | 7 +- .../src/pipeline/final_proof.rs | 5 +- crates/proof-compression/src/pipeline/mod.rs | 25 - .../proof-compression/src/pipeline/plonk.rs | 21 +- .../proof-compression/src/pipeline/setup.rs | 293 ++------- .../src/pipeline/setup/ignition_tests.rs | 335 ++-------- .../src/pipeline/verifier.rs | 22 - .../src/pipeline/verifying_keys.rs | 44 +- .../src/pipeline/wrap/outer.rs | 163 ++--- .../src/pipeline/wrap/outer/final_export.rs | 5 +- .../0.1/ignition-admission.json | 15 - flake.nix | 2 +- tools/plonk-wrapper/README.md | 12 +- tools/plonk-wrapper/cmd/ignition/main.go | 109 ++++ tools/plonk-wrapper/cmd/ignition/main_test.go | 41 ++ tools/plonk-wrapper/go.mod | 1 + tools/plonk-wrapper/go.sum | 2 + 34 files changed, 569 insertions(+), 3555 deletions(-) delete mode 100644 crates/proof-compression/examples/kzg_ignition_import.rs create mode 100644 crates/proof-compression/src/kzg/srs/gnark.rs delete mode 100644 crates/proof-compression/src/kzg/srs/ignition.rs delete mode 100644 crates/proof-compression/src/kzg/srs/ignition/admission.rs delete mode 100644 crates/proof-compression/src/kzg/srs/ignition/cache.rs delete mode 100644 crates/proof-compression/src/kzg/srs/ignition/points.rs delete mode 100644 crates/proof-compression/src/kzg/srs/ignition/source.rs delete mode 100644 crates/proof-compression/src/kzg/srs/ignition/tests.rs delete mode 100644 crates/proof-compression/verifying-keys/0.1/ignition-admission.json create mode 100644 tools/plonk-wrapper/cmd/ignition/main.go create mode 100644 tools/plonk-wrapper/cmd/ignition/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79e2f5a60..e67e3fdb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,11 +144,11 @@ jobs: if: github.event_name != 'push' uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1.1.2 - name: Check clippy warnings - run: cargo clippy --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile -- -D warnings + run: cargo clippy --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile,ix-proof-compression/plonk,ix-proof-compression/groth16 -- -D warnings - name: Check *everything* compiles - run: cargo check --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile + run: cargo check --release --workspace --all-targets --features ix-ffi/parallel,ix-ffi/net,ix-ffi/test-ffi,ixon/sharing-profile,ix-proof-compression/plonk,ix-proof-compression/groth16 - name: Tests - run: cargo nextest run --release --profile ci --workspace ${{ github.event_name == 'push' && '--no-run' || '' }} + run: cargo nextest run --release --profile ci --workspace --features ix-proof-compression/plonk,ix-proof-compression/groth16 ${{ github.event_name == 'push' && '--no-run' || '' }} - name: Get Rust version if: github.event_name != 'push' run: | diff --git a/Cargo.lock b/Cargo.lock index 3536bab18..76186ccd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -388,16 +388,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "blake2b_simd" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" -dependencies = [ - "arrayvec", - "constant_time_eq", -] - [[package]] name = "blake3" version = "1.8.5" @@ -2091,7 +2081,6 @@ dependencies = [ "ark-serialize", "bincode", "bindgen", - "blake2b_simd", "blake3", "blst", "multi-stark", diff --git a/Ix/Cli/CompressionCmd.lean b/Ix/Cli/CompressionCmd.lean index 758b14123..11e36c526 100644 --- a/Ix/Cli/CompressionCmd.lean +++ b/Ix/Cli/CompressionCmd.lean @@ -3,7 +3,7 @@ of each given aggregate proof and sizes one fixed layout that holds all of them; `normalize` proves a root's wrap in that layout and writes the FRI worker's root input. Every root normalized to one layout compresses under - the same FRI verifier. `fetch-params` installs the pinned Ignition proving + the same FRI verifier. `fetch-params` installs the released Ignition proving parameters. -/ module @@ -103,13 +103,11 @@ private def download (url : String) (path : System.FilePath) : IO Unit := do throw <| IO.userError s!"curl exited with {status} downloading {url}" IO.FS.rename part path -/-- The published Ignition release: its directory name under the parameters -directory, base URL and files. The pins that authenticate the files live with -the setup policy in `ix-proof-compression`; this mirrors only their names. -/ +/-- The published gnark SRS selected during release preparation. -/ private def ignitionRelease := "bn254-ignition-main-197" private def ignitionUrl := "https://argument-kzg-params-063002298335-us-east-1-an.s3.us-east-1.amazonaws.com/bn254/ignition/main-197" -private def ignitionFiles := #["admission.json", "ix-prefix-26.bin"] +private def ignitionFiles := #["srs.bin"] /-- `IX_PARAMS_DIR`, else `~/.ix/compression/params`. -/ private def parametersDir : IO System.FilePath := do @@ -127,9 +125,9 @@ def runFetchParams (_ : Cli.Parsed) : IO UInt32 := do else IO.eprintln s!"Downloading {ignitionUrl}/{file}." download s!"{ignitionUrl}/{file}" path - if (← IO.getEnv "IX_KZG_IGNITION_CACHE").isSome then - IO.eprintln "IX_KZG_IGNITION_* is set and takes precedence over the downloaded release." - IO.eprintln s!"Authenticating the configured parameters." + if (← IO.getEnv "IX_KZG_SRS").isSome then + IO.eprintln "IX_KZG_SRS is set and takes precedence over the downloaded release." + IO.eprintln s!"Checking the configured parameters against the release key." IO.eprintln s!"Using {← Compression.describeSetup false false 0}." IO.println target return 0 @@ -139,7 +137,7 @@ def runFetchParams (_ : Cli.Parsed) : IO UInt32 := do def fetchParamsCmd : Cli.Cmd := `[Cli| "fetch-params" VIA runFetchParams; - "Download the pinned BN254 Ignition proving parameters (4.3 GB, public) into IX_PARAMS_DIR (default ~/.ix/compression/params) and authenticate them." + "Download the released BN254 Ignition SRS (4.3 GB, public) into IX_PARAMS_DIR (default ~/.ix/compression/params) and check its anchors." ] end Ix.Cli.CompressionCmd diff --git a/crates/proof-compression/Cargo.toml b/crates/proof-compression/Cargo.toml index 8fb9d1352..09188255d 100644 --- a/crates/proof-compression/Cargo.toml +++ b/crates/proof-compression/Cargo.toml @@ -16,7 +16,6 @@ ark-serialize = { version = "0.5", features = ["std"] } bincode = { version = "2", features = ["serde"] } blake3 = { workspace = true, features = ["mmap", "rayon"] } tiny-keccak = { workspace = true } -blake2b_simd = "1" num-bigint = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = { workspace = true } @@ -56,7 +55,7 @@ features = ["cuda"] [features] plonk = ["dep:bindgen"] groth16 = ["dep:bindgen"] -default = ["parallel", "plonk", "groth16"] +default = ["parallel"] # GPU proving: multi-stark's Goldilocks backend for FRI compression and the # KZG kernels here, plus acceleration for the enabled gnark backends. Needs # the CUDA toolkit at build time and a visible GPU at runtime; CPU-only diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 203d46bcc..49b299057 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -38,7 +38,7 @@ while `--dev-final-srs` prepares local keys for either PLONK or Groth16. With `--dev-kzg-srs` as well, neither stage needs Ignition. Default verification pins one version of the verifying keys, [`0.1`](verifying-keys/0.1): one key per proof system, with the root layout, -statement prefix and Ignition admission they were derived for. `ix verify` selects the key by the proof's tag and requires +statement prefix and Ignition parameters they were derived for. `ix verify` selects the key by the proof's tag and requires the packet's key identifier to name it; there is no catalog of alternatives. Bundled recursive KZG and PLONK proofs verify under Ignition. The release Groth16 key comes from a development setup, so Groth16 proving requires `--dev-final-srs` @@ -47,130 +47,59 @@ and verification requires `--dev-srs`. Use store address or a serialized `Ixon.Proof` file containing its claim. File inputs are read without importing them into the store. See the [final backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-final). -The library's `verify_with_vk` and `verify_with_ignition_admission` still +The library's `verify_with_vk` can verify recursive KZG proofs under an independently pinned key and statement. Local final keys use `verify_with_development_verifier`. The CLI requires `--verifier-bundle`, its independent `--verifier-bundle-blake3` pin, and `--dev-srs`; see [local development compression](../../docs/development-compression.md). BN254 supports radix-2 domains through `2^28`; traces and quotient domains -must both fit. Existing BN254 proof, verifier, and SRS encodings are retained. +must both fit. Existing BN254 proof and verifier encodings are retained. ## Ignition setup -### Ignition parameter import - -The BN254-only `kzg_ignition_import` example provides local transcript -inspection, import and authenticated prefix loading. To build the tool: +The released recursive KZG and PLONK verifying keys fix the approved Ignition +parameters. Verification needs no SRS file, source manifest or admission JSON. +Proving loads a prefix of the release's standard gnark `SRS.WriteRawTo` file: ```sh -cargo build --release --locked -p ix-proof-compression \ - --no-default-features --features parallel --example kzg_ignition_import -IGNITION_IMPORTER="$PWD/target/release/examples/kzg_ignition_import" -"$IGNITION_IMPORTER" info -"$IGNITION_IMPORTER" inspect \ - /path/to/ignition-downloads /path/to/source-manifest.candidate.json +ix compress fetch-params +ix compress ROOT_PROOF_ADDRESS --out /path/to/new-run ``` -`inspect` reads the complete local contributions 196 and 197, in the -`NNN_0xaddress/transcript00.dat` through `transcript19.dat` directories. It -checks framing and embedded checksums and produces an **untrusted candidate** -inventory with complete-file BLAKE2b-512 hashes. This importer-specific source -manifest is different from Aztec's downloaded `manifest.json`. Inspection -does not authenticate participants or replay the ceremony. +The file is `bn254-ignition-main-197/srs.bin` under `IX_PARAMS_DIR` (default +`~/.ix/compression/params`). `IX_KZG_SRS` can select another local copy of the +same release SRS. Its G1/G2 anchors must match the released key. Provenance +comes from release preparation and distribution; these runtime checks do not +replay the ceremony or verify participant signatures. -Independently authenticate the selected ceremony bytes before pinning the -exact source-manifest JSON's BLAKE3 digest in trusted configuration. Then: +`--dev-kzg-srs` selects known-trapdoor inner parameters and bypasses Ignition +loading. Use `--dev-final-srs` as well for development final PLONK/Groth16 keys. +`verify_with_vk` verifies an independently pinned recursive key and expected +statement, requiring `allow_development` when its parameters differ from the +release. The default verifier accepts only the bundled keys. -```sh -"$IGNITION_IMPORTER" import \ - /path/to/ignition-downloads /path/to/source-manifest.json \ - "$IGNITION_SOURCE_MANIFEST_BLAKE3" /path/to/new-ignition-prefix.bin 26 -"$IGNITION_IMPORTER" verify \ - /path/to/new-ignition-prefix.bin "$IGNITION_CACHE_MANIFEST_BLAKE3" 26 -``` +### Preparing a parameter release -Import reports `cache_manifest_blake3`; retain it separately from the cache -for subsequent loads. It authenticates all source files, including unused -suffixes, by hash. It checks canonical coordinates and curve/subgroup -membership for retained G1 points and G2 anchors, the shifted first power, -every retained adjacency and the selected predecessor link. The link check -is not full participant-signature or ceremony verification. Prefixes must be -powers of two from 2 through `2^26`. -The default inspection inventory includes both contributions; library -callers can supply a separately pinned manifest without predecessor evidence. - -The importer preserves the sequence beginning at `tau * G1`; it never -inserts the canonical generator. A complete 100,800,000-entry source declares -public polynomial degree 100,799,999 even when fewer points are retained. -Import memory is bounded by chunks, and cache publication is atomic and -refuses existing destinations. Normalized G1 payloads occupy 64 bytes per -point (about 4 GiB for `2^26`), plus metadata. Cache loads authenticate whole -overlapping chunks; a two-point verifier load reads just the authenticated -manifest and anchors. - -Library entry points are `kzg::srs::ignition::{inspect_ignition_source, -read_source_manifest, import_ignition_transcripts, load_ignition_cache}`. -`load_ignition_cache_with_identity` also returns the source receipt metadata. -No ceremony pin is built in. The import follows SP1's PLONK setup path, -Aztec Ignition's `MAIN IGNITION` transcripts through participants 196 and -197; this selects a candidate, not an authorized production setup. - -The public proving cache and admission can be installed with `ix compress fetch-params`. -They are authenticated and stored under `IX_PARAMS_DIR` (default -`~/.ix/compression/params`), and selected when no `IX_KZG_IGNITION_*` override -is configured. The source transcripts are needed only to reproduce the import. - -### Admitted Ignition pipeline - -An `IgnitionAdmission` carries the exact source-manifest JSON, its pin, -source-derived setup identity and degree, and four verifier anchors. Its -independent BLAKE3 pin authorizes those parameters. It contains no proving -cache path or cache-prefix receipt, so verification can use it without a -proving cache and different authenticated prefixes can share an admission. -The source-derived identity prevents relabeling an existing development -setup as Ignition. Source provenance still depends on the caller's trust in -the descriptor; creating this file does not independently authenticate a -ceremony or authorize a verifying key. - -Produce the descriptor from already trusted source and cache pins: +The offline tool uses the same `gnark-ignition-verifier` revision and final two +contributions as SP1: MAIN IGNITION manifest indices 174 and 175, positions 196 +and 197. It checks both complete contributions and their link, performs a KZG +opening sanity check, and writes the retained prefix using gnark serialization. +It does not verify contributor signatures. Authenticate the ceremony source as +part of release review, and distribute the approved SRS alongside the release. ```sh -"$IGNITION_IMPORTER" admission \ - /path/to/ignition-prefix.bin "$IGNITION_CACHE_MANIFEST_BLAKE3" \ - /path/to/source-manifest.json "$IGNITION_SOURCE_MANIFEST_BLAKE3" \ - /path/to/new-admission.json +cd tools/plonk-wrapper +go run ./cmd/ignition -cache /path/to/transcripts -out /path/to/srs.bin ``` -Retain the reported `admission_blake3` independently. To select these -parameters for proving, configure all four settings before running -`ix compress` or `ix prove --compress`: - -```sh -export IX_KZG_IGNITION_CACHE=/path/to/ignition-prefix.bin -export IX_KZG_IGNITION_CACHE_BLAKE3="$IGNITION_CACHE_MANIFEST_BLAKE3" -export IX_KZG_IGNITION_ADMISSION=/path/to/new-admission.json -export IX_KZG_IGNITION_ADMISSION_BLAKE3="$IGNITION_ADMISSION_BLAKE3" - -ix compress ROOT_PROOF_ADDRESS --out /path/to/new-ignition-run -``` +This is a release operation: it downloads both complete contributions if absent +and holds their point arrays in memory. It is not run during builds or proving. +The output retains the shifted basis beginning at `tau * G1`; the release key +carries the full ceremony's public degree independently of the retained prefix. -Partial configuration, incorrect pins, changed anchors/source receipts and -insufficient prefixes fail closed. `--dev-kzg-srs` explicitly selects development -inner KZG parameters. A final proof then also needs `--dev-final-srs`, which -prepares independent development PLONK parameters/keys or Groth16 keys. -Both flags bypass Ignition discovery and downloads. Compression checks setup before -proving and binds both KZG stages to the same admitted setup; the recursive verifying key must -also retain its admitted deferred pairing keys. - -For local verification, `verify_with_ignition_admission` takes the -independently trusted verifying key digest as well as the original claim, -expected root statement and an `IgnitionAdmission` authenticated using `read` -or `from_bytes`. It reads no proving cache or setup environment. -`ix verify` checks recursive KZG, PLONK and Groth16 proofs under the bundled -keys; these entry points serve keys pinned independently of the bundle. -Generating a verifying key or setup artifact does not authorize it for any -verifier. +The parameter publisher must upload `srs.bin` to the existing +`bn254/ignition/main-197/` release location before `fetch-params` can install it. ## Proving and verification @@ -239,7 +168,7 @@ cargo test -p ix-proof-compression --no-default-features --features parallel \ ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output ``` -Use the four Ignition environment variables described above. The result +Install the released SRS or select a local copy with `IX_KZG_SRS`. The result includes the proof envelope, native and Solidity proof encodings, generated Solidity verifier and verifying key, which must equal the bundled key for `ix verify` to accept the proof. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) diff --git a/crates/proof-compression/build/gnark.rs b/crates/proof-compression/build/gnark.rs index 78909a9a1..44b35d472 100644 --- a/crates/proof-compression/build/gnark.rs +++ b/crates/proof-compression/build/gnark.rs @@ -18,7 +18,7 @@ fn source_digest(source: &std::path::Path) -> String { let path = entry.unwrap().path(); let name = path.file_name().unwrap().to_string_lossy().into_owned(); if path.is_dir() { - if name != "vendor" && name != "testdata" { + if name != "vendor" && name != "testdata" && name != "cmd" { directories.push(path); } } else if name.ends_with(".go") && !name.ends_with("_test.go") { @@ -70,10 +70,7 @@ pub(super) fn build() -> Vec { .unwrap_or_else(|| panic!("cannot find {go:?} in PATH")) }; println!("cargo:rerun-if-changed={}", executable.display()); - println!( - "cargo:rustc-env=IX_GNARK_SOURCE_BLAKE3={}", - source_digest(&source) - ); + println!("cargo:rustc-env=IX_GNARK_SOURCE_BLAKE3={}", source_digest(&source)); let toolchain = Command::new(&go) .current_dir(&source) .env("GOTOOLCHAIN", "local") diff --git a/crates/proof-compression/examples/kzg_ignition_import.rs b/crates/proof-compression/examples/kzg_ignition_import.rs deleted file mode 100644 index 101373567..000000000 --- a/crates/proof-compression/examples/kzg_ignition_import.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Inspect, import and authenticate a local Ignition SRS prefix. - -use ix_proof_compression::kzg::srs::ignition::{ - FINAL_ADDRESS, FINAL_POSITION, IGNITION_MANIFEST_URL, IgnitionAdmission, - MAX_G1_POINTS, MAX_PREFIX_LEN, PREDECESSOR_ADDRESS, PREDECESSOR_POSITION, - SHIFTED_TAU_BASIS, import_ignition_transcripts, inspect_ignition_source, - load_ignition_cache, -}; -use std::{ - fs::{self, OpenOptions}, - io::Write, - path::Path, - time::Instant, -}; - -type Result = std::result::Result>; - -const USAGE: &str = "usage: kzg_ignition_import info\n\ - kzg_ignition_import inspect \n\ - kzg_ignition_import import \n\ - kzg_ignition_import admission \n\ - kzg_ignition_import verify "; - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn digest(text: &str) -> Result<[u8; 32]> { - if text.len() != 64 || !text.is_ascii() { - return Err( - "manifest digest must contain 64 hexadecimal characters".into(), - ); - } - let mut digest = [0; 32]; - for (i, byte) in digest.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)?; - } - Ok(digest) -} - -fn prefix(text: &str) -> Result { - let log: u32 = text.parse()?; - if !(1..=MAX_PREFIX_LEN.ilog2()).contains(&log) { - return Err("prefix log must be in 1..=26".into()); - } - Ok(1usize << log) -} - -fn print(report: &serde_json::Value) -> Result<()> { - println!("{}", serde_json::to_string_pretty(report)?); - Ok(()) -} - -fn main() -> Result<()> { - let args: Vec<_> = std::env::args().skip(1).collect(); - let start = Instant::now(); - match args.iter().map(String::as_str).collect::>().as_slice() { - ["info"] => print(&serde_json::json!({ - "curve": "bn254", - "ceremony_manifest_url": IGNITION_MANIFEST_URL, - "basis": SHIFTED_TAU_BASIS, - "available_g1_entries": MAX_G1_POINTS, - "public_max_degree": MAX_G1_POINTS - 1, - "max_prefix_len": MAX_PREFIX_LEN, - "contribution": {"position": FINAL_POSITION, "address": FINAL_ADDRESS}, - "predecessor": {"position": PREDECESSOR_POSITION, "address": PREDECESSOR_ADDRESS}, - "requires_independently_trusted_source_manifest": true, - })), - ["inspect", source, destination] => { - let destination = Path::new(destination); - if destination.try_exists()? { - return Err("candidate manifest destination already exists".into()); - } - eprintln!( - "Hashing complete local contributions 196 and 197; output is an untrusted candidate, not ceremony authentication." - ); - let manifest = inspect_ignition_source( - Path::new(source), - FINAL_POSITION, - FINAL_ADDRESS, - Some((PREDECESSOR_POSITION, PREDECESSOR_ADDRESS)), - )?; - let mut bytes = serde_json::to_vec_pretty(&manifest)?; - bytes.push(b'\n'); - if let Some(parent) = - destination.parent().filter(|p| !p.as_os_str().is_empty()) - { - fs::create_dir_all(parent)?; - } - let mut file = - OpenOptions::new().write(true).create_new(true).open(destination)?; - file.write_all(&bytes)?; - file.sync_all()?; - print(&serde_json::json!({ - "candidate_manifest": destination, - "candidate_manifest_blake3": blake3::hash(&bytes).to_hex().as_str(), - "source_provenance_authenticated": false, - "scope": "Observed file hashes and internal framing/checksums only. Independently authenticate the ceremony source before trusting this manifest digest.", - "elapsed_seconds": start.elapsed().as_secs_f64(), - })) - }, - ["import", source, manifest, trusted, destination, log] => { - let prefix_len = prefix(log)?; - let trusted = digest(trusted)?; - eprintln!( - "Authenticating complete source files and validating {prefix_len} retained powers per selected contribution; the destination must be new." - ); - let receipt = import_ignition_transcripts( - Path::new(source), - Path::new(manifest), - trusted, - Path::new(destination), - prefix_len, - )?; - print(&serde_json::json!({ - "cache": destination, - "prefix_len": receipt.prefix_len, - "cache_manifest_blake3": hex(&receipt.digest), - "source_manifest_blake3": hex(&receipt.source_manifest_digest), - "setup_id": hex(&receipt.public_setup.id), - "public_max_degree": receipt.public_setup.max_degree, - "basis": SHIFTED_TAU_BASIS, - "scope": "Source bytes authenticated against the caller's pinned manifest and retained powers validated; no participant-signature or full ceremony replay.", - "elapsed_seconds": start.elapsed().as_secs_f64(), - }))?; - eprintln!( - "Retain cache_manifest_blake3 in trusted configuration separately from the cache." - ); - Ok(()) - }, - ["verify", cache, trusted, log] => { - let srs = - load_ignition_cache(Path::new(cache), digest(trusted)?, prefix(log)?)?; - print(&serde_json::json!({ - "cache_authenticated": true, - "loaded_prefix_len": srs.max_len(), - "public_max_degree": srs.public_setup.max_degree, - "setup_id": hex(&srs.public_setup.id), - "basis": SHIFTED_TAU_BASIS, - "elapsed_seconds": start.elapsed().as_secs_f64(), - })) - }, - [ - "admission", - cache, - trusted_cache, - source, - trusted_source, - destination, - ] => { - let destination = Path::new(destination); - if destination.try_exists()? { - return Err("admission destination already exists".into()); - } - let admission = IgnitionAdmission::from_cache( - Path::new(cache), - digest(trusted_cache)?, - Path::new(source), - digest(trusted_source)?, - )?; - let bytes = admission.to_bytes()?; - if let Some(parent) = - destination.parent().filter(|p| !p.as_os_str().is_empty()) - { - fs::create_dir_all(parent)?; - } - let mut file = - OpenOptions::new().write(true).create_new(true).open(destination)?; - file.write_all(&bytes)?; - file.sync_all()?; - print(&serde_json::json!({ - "admission": destination, - "admission_blake3": hex(blake3::hash(&bytes).as_bytes()), - "source_manifest_blake3": hex(&admission.source_manifest_digest()), - "setup_id": hex(&admission.public_setup().id), - "public_max_degree": admission.public_setup().max_degree, - "basis": SHIFTED_TAU_BASIS, - "scope": "Portable authorization from the caller's trusted source and cache pins. The admission pin must be configured separately; no participant-signature or full ceremony replay.", - "elapsed_seconds": start.elapsed().as_secs_f64(), - }))?; - eprintln!( - "Retain admission_blake3 in trusted verifier configuration separately from the admission and proof." - ); - Ok(()) - }, - _ => Err(USAGE.into()), - } -} diff --git a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs index 8192ec5e3..c9880409b 100644 --- a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs +++ b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs @@ -1,7 +1,7 @@ //! Opt-in dense primitive measurements for the 2^24 PLONK domain. use super::*; -use crate::kzg::srs::ignition::load_ignition_cache_with_identity; +use crate::pipeline::SetupConfig; use ark_serialize::CanonicalSerialize; use serde_json::{Value, json}; use std::{ @@ -12,10 +12,6 @@ use std::{ }; const N: usize = 1 << 24; -const CACHE: &str = - "/opt/dlami/nvme/ix-bn254/parameters/ignition/ix-prefix-26.bin"; -const CACHE_PIN: &str = - "6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b"; const SEED: &[u8] = b"ix/bn254/plonk-scale/dense-primitives/v1"; unsafe extern "C" { @@ -375,21 +371,19 @@ fn uncached_msm( } fn msm_benchmark(report: &mut Report, cached: bool, repeats: usize) { - let pin: [u8; 32] = std::array::from_fn(|i| { - u8::from_str_radix(&CACHE_PIN[2 * i..2 * i + 2], 16).unwrap() - }); - // The power-of-two loader must read 2N to authenticate the N+3 range. - let ((srs, identity), load_seconds) = timed(|| { - load_ignition_cache_with_identity(std::path::Path::new(CACHE), pin, 2 * N) + let (srs, load_seconds) = timed(|| { + SetupConfig::from_env(false) + .unwrap() + .for_stage(SEED, 2 * N) + .unwrap() + .srs(2 * N, false) .unwrap() }); report.record(json!({ - "phase": "authenticated_srs_load", "seconds": load_seconds, - "cache": CACHE, "cache_manifest_blake3": CACHE_PIN, + "phase": "srs_load", "seconds": load_seconds, "loaded_prefix_points": srs.g1.len(), "largest_used_point_end": N+10, "public_max_degree": srs.public_setup.max_degree, "setup_id": blake3::Hash::from(srs.public_setup.id).to_hex().to_string(), - "source_manifest_blake3": blake3::Hash::from(identity.source_manifest_digest).to_hex().to_string(), })); let (values, generate_seconds) = timed(|| dense(N + 3)); let (input_hash, hash_seconds) = timed(|| field_hash(&values)); diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs index f7ed0ed4f..717746388 100644 --- a/crates/proof-compression/src/kzg/srs.rs +++ b/crates/proof-compression/src/kzg/srs.rs @@ -15,7 +15,7 @@ use ark_serialize::CanonicalSerialize; use p3_maybe_rayon::prelude::*; use std::io::{self, Write}; -pub mod ignition; +pub mod gnark; const PUBLIC_SETUP_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/known-trapdoor-public-degree/v1"; diff --git a/crates/proof-compression/src/kzg/srs/gnark.rs b/crates/proof-compression/src/kzg/srs/gnark.rs new file mode 100644 index 000000000..4fa51db15 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs/gnark.rs @@ -0,0 +1,159 @@ +//! Prefix loading for BN254 `kzg.SRS.WriteRawTo` files from gnark-crypto. +//! Ceremony verification and selection happen during release preparation. +use super::{PublicSetup, Srs}; +use crate::kzg::curve::{Fq, G1Affine, G2Affine}; +use ark_bn254::Fq2; +use ark_ec::AffineRepr; +use ark_serialize::CanonicalDeserialize; +use p3_maybe_rayon::prelude::*; +use std::{ + fs::File, + io::{self, Read, Seek, SeekFrom}, + path::Path, +}; + +const MAX_POINTS: usize = 100_800_000; +const BLOCK_POINTS: usize = 1 << 16; +// Two G2 points, one G1 point, and 2×2×66 pairs of Fq2 line coefficients. +const VK_BYTES: u64 = 2 * 128 + 64 + 2 * 2 * 66 * 2 * 64; + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn field(bytes: &[u8]) -> io::Result { + let mut encoded: [u8; 32] = bytes.try_into().expect("coordinate length"); + encoded.reverse(); + Fq::deserialize_uncompressed(encoded.as_slice()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn g1(bytes: &[u8]) -> io::Result { + let point = + G1Affine::new_unchecked(field(&bytes[..32])?, field(&bytes[32..])?); + // BN254 G1 has cofactor one. + if point.is_zero() || !point.is_on_curve() { + return Err(invalid("invalid BN254 G1 point")); + } + Ok(point) +} + +fn g2(bytes: &[u8]) -> io::Result { + // gnark writes the imaginary component before the real component. + let point = G2Affine::new_unchecked( + Fq2::new(field(&bytes[32..64])?, field(&bytes[..32])?), + Fq2::new(field(&bytes[96..128])?, field(&bytes[64..96])?), + ); + if point.is_zero() + || !point.is_on_curve() + || !point.is_in_correct_subgroup_assuming_on_curve() + { + return Err(invalid("invalid BN254 G2 point")); + } + Ok(point) +} + +/// Load a prefix of an approved, uncompressed gnark SRS. The public setup +/// identity comes from the verifier release, not metadata in this file. +pub fn load( + path: &Path, + prefix_len: usize, + public_setup: PublicSetup, +) -> io::Result { + if prefix_len < 2 + || !prefix_len.is_power_of_two() + || prefix_len > MAX_POINTS + || prefix_len - 1 > public_setup.max_degree + { + return Err(invalid("invalid SRS prefix length")); + } + let mut file = File::open(path)?; + let mut count = [0; 4]; + file.read_exact(&mut count)?; + let count = u32::from_be_bytes(count) as usize; + if count < prefix_len + || count > MAX_POINTS + || file.metadata()?.len() != 4 + count as u64 * 64 + VK_BYTES + { + return Err(invalid( + "invalid gnark SRS length; expected WriteRawTo encoding", + )); + } + let mut powers = Vec::with_capacity(prefix_len); + let mut buffer = vec![0; prefix_len.min(BLOCK_POINTS) * 64]; + while powers.len() < prefix_len { + let count = (prefix_len - powers.len()).min(BLOCK_POINTS); + let bytes = &mut buffer[..count * 64]; + file.read_exact(bytes)?; + let chunk = + bytes.par_chunks_exact(64).map(g1).collect::>>()?; + powers.extend(chunk); + } + file.seek(SeekFrom::Start(4 + count as u64 * 64))?; + let mut anchors = [0; 320]; + file.read_exact(&mut anchors)?; + if g1(&anchors[256..])? != powers[0] { + return Err(invalid("gnark SRS G1 anchor differs from its powers")); + } + Ok(Srs { + g1: powers, + g2: g2(&anchors[..128])?, + tau_g2: g2(&anchors[128..256])?, + public_setup, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ff::{BigInteger, PrimeField}; + use std::io::Write; + + // Only the G1/G2 portion is consumed by Rust; pairing lines are a gnark optimization. + fn fixture(srs: &Srs) -> Vec { + let mut bytes = (srs.g1.len() as u32).to_be_bytes().to_vec(); + let encode_g1 = |p: G1Affine| { + [p.x, p.y] + .into_iter() + .flat_map(|v| v.into_bigint().to_bytes_be()) + .collect::>() + }; + for p in &srs.g1 { + bytes.extend(encode_g1(*p)); + } + for p in [srs.g2, srs.tau_g2] { + for v in [p.x.c1, p.x.c0, p.y.c1, p.y.c0] { + bytes.extend(v.into_bigint().to_bytes_be()); + } + } + bytes.extend(encode_g1(srs.g1[0])); + bytes.resize(4 + srs.g1.len() * 64 + VK_BYTES as usize, 0); + bytes + } + + #[test] + fn prefix_loading_checks_framing_coordinates_and_anchors() -> io::Result<()> { + let srs = Srs::unsafe_dev_setup(8, b"gnark-reader"); + let path = + std::env::temp_dir().join(format!("ix-gnark-srs-{}", std::process::id())); + let mut file = + std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?; + let bytes = fixture(&srs); + file.write_all(&bytes)?; + drop(file); + let prefix = load(&path, 4, srs.public_setup)?; + assert_eq!(prefix.g1, srs.g1[..4]); + assert_eq!(prefix.g2, srs.g2); + assert_eq!(prefix.tau_g2, srs.tau_g2); + assert!(load(&path, 16, srs.public_setup).is_err()); + for offset in [0, 4, 4 + 8 * 64, 4 + 8 * 64 + 256] { + let mut damaged = bytes.clone(); + damaged[offset] ^= 0xff; + std::fs::write(&path, damaged)?; + assert!(load(&path, 4, srs.public_setup).is_err()); + } + std::fs::write(&path, &bytes[..bytes.len() - 1])?; + assert!(load(&path, 4, srs.public_setup).is_err()); + std::fs::remove_file(path) + } +} diff --git a/crates/proof-compression/src/kzg/srs/ignition.rs b/crates/proof-compression/src/kzg/srs/ignition.rs deleted file mode 100644 index aba507e71..000000000 --- a/crates/proof-compression/src/kzg/srs/ignition.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! Import Ignition transcripts using an independently trusted source manifest. -//! -//! A transcript's embedded checksum establishes integrity, not ceremony -//! provenance. The caller pins a manifest of complete source-file digests; -//! inspecting local downloads only produces a candidate for that trust decision. -//! Import checks all pinned file bytes and the retained powers, then publishes -//! a prefix cache atomically. Cache loads require the import receipt separately. -//! Ignition's shifted basis starts at `tau * G1`, with public degree `M - 1` -//! for `M` published entries, independent of the retained prefix. - -mod admission; -mod cache; -mod points; -mod source; - -pub use admission::IgnitionAdmission; -#[cfg(test)] -pub(crate) use admission::{test_admission, test_admission_cache}; -pub use cache::{ - IgnitionCacheIdentity, load_ignition_cache, load_ignition_cache_with_identity, -}; -pub use source::{ - Contribution, SHIFTED_TAU_BASIS, SOURCE_FORMAT, SourceManifest, Transcript, - inspect_ignition_source, read_source_manifest, -}; - -use super::super::curve::{Fr, G1Affine, G1Projective, G2Affine, PairingCurve}; -use ark_ec::{AffineRepr, CurveGroup, VariableBaseMSM, pairing::Pairing}; -use ark_ff::{Field, PrimeField, Zero}; -use std::{io, path::Path}; - -pub const MAX_G1_POINTS: usize = 100_800_000; -pub const MAX_PREFIX_LEN: usize = 1 << 26; -pub const IGNITION_MANIFEST_URL: &str = - "https://aztec-ignition.s3.amazonaws.com/MAIN%20IGNITION/manifest.json"; -pub const FINAL_POSITION: u32 = 197; -pub const FINAL_ADDRESS: &str = "0xa57ec000b7fb9ea9b0119efdee0b8f15f895c2f3"; -pub const PREDECESSOR_POSITION: u32 = 196; -pub const PREDECESSOR_ADDRESS: &str = - "0x9c3f372a2aacc0d7272f56a4664311b7647b0031"; - -#[derive(Clone, Copy, Debug)] -struct Anchors { - g1: [G1Affine; 2], - tau_g2: G2Affine, - contribution_g2: G2Affine, -} - -fn invalid( - message: impl Into>, -) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) -} - -fn validate_prefix(prefix_len: usize, total: usize) -> io::Result<()> { - if prefix_len < 2 - || !prefix_len.is_power_of_two() - || prefix_len > MAX_PREFIX_LEN - || prefix_len > total - { - return Err(invalid("invalid Ignition prefix length")); - } - Ok(()) -} - -fn validate_anchors(anchors: &Anchors) -> io::Result<()> { - if anchors.g1.iter().any(|point| { - point.is_zero() - || !point.is_on_curve() - || !point.is_in_correct_subgroup_assuming_on_curve() - }) || [anchors.tau_g2, anchors.contribution_g2].iter().any(|point| { - point.is_zero() - || !point.is_on_curve() - || !point.is_in_correct_subgroup_assuming_on_curve() - }) { - return Err(invalid("invalid Ignition anchor")); - } - if !PairingCurve::multi_pairing( - [anchors.g1[0], -G1Affine::generator()], - [G2Affine::generator(), anchors.tau_g2], - ) - .is_zero() - { - return Err(invalid( - "Ignition first power does not match its shifted basis", - )); - } - if !PairingCurve::multi_pairing( - [anchors.g1[1], -anchors.g1[0]], - [G2Affine::generator(), anchors.tau_g2], - ) - .is_zero() - { - return Err(invalid("Ignition anchors are not successive powers")); - } - Ok(()) -} - -/// Authenticate complete local source files and create a reusable prefix cache. -/// -/// `trusted_source_manifest` is the BLAKE3 of independently trusted manifest -/// bytes, not a digest read from the same untrusted download. All selected -/// transcript files are hashed, including bytes beyond the retained prefix. -/// An optional predecessor is checked against its own pinned files and powers, -/// followed by a contribution-link pairing. This is not a replay of the full -/// ceremony or verification of participant signatures. Memory is bounded by -/// chunks; the destination must not exist. -pub fn import_ignition_transcripts( - source_root: &Path, - source_manifest: &Path, - trusted_source_manifest: [u8; 32], - cache: &Path, - prefix_len: usize, -) -> io::Result { - let manifest = - read_source_manifest(source_manifest, trusted_source_manifest)?; - validate_prefix(prefix_len, manifest.num_g1_points as usize)?; - let mut output = cache::CacheWriter::create( - cache, - prefix_len, - manifest.public_setup()?, - trusted_source_manifest, - )?; - let previous = if let Some(previous) = &manifest.predecessor { - let mut progression = ProgressionCheck::new( - trusted_source_manifest, - prefix_len, - previous.position, - ); - let anchors = source::stream_contribution( - source_root, - &manifest, - previous, - prefix_len, - |points| progression.absorb(points), - )?; - validate_anchors(&anchors)?; - progression.verify(&anchors)?; - Some(anchors) - } else { - None - }; - let mut progression = ProgressionCheck::new( - trusted_source_manifest, - prefix_len, - manifest.contribution.position, - ); - let anchors = source::stream_contribution( - source_root, - &manifest, - &manifest.contribution, - prefix_len, - |points| { - progression.absorb(points)?; - output.push(points) - }, - )?; - validate_anchors(&anchors)?; - progression.verify(&anchors)?; - if let Some(previous) = previous - && !PairingCurve::multi_pairing( - [anchors.g1[0], -previous.g1[0]], - [G2Affine::generator(), anchors.contribution_g2], - ) - .is_zero() - { - return Err(invalid( - "Ignition contribution does not follow its predecessor", - )); - } - output.finish(&anchors) -} - -struct ProgressionCheck { - r: Fr, - next_power: Fr, - previous: Option, - low: G1Projective, - high: G1Projective, - expected: usize, - seen: usize, -} - -impl ProgressionCheck { - fn new(source_manifest: [u8; 32], prefix_len: usize, position: u32) -> Self { - let mut hash = blake3::Hasher::new(); - hash.update(b"ix/bn254/ignition-prefix-progression/v1"); - hash.update(&source_manifest); - hash.update(&(prefix_len as u64).to_le_bytes()); - hash.update(&position.to_le_bytes()); - let mut reader = hash.finalize_xof(); - let r = loop { - let mut wide = [0; 64]; - reader.fill(&mut wide); - let candidate = Fr::from_le_bytes_mod_order(&wide); - if !candidate.is_zero() { - break candidate; - } - }; - Self { - r, - next_power: Fr::ONE, - previous: None, - low: G1Projective::zero(), - high: G1Projective::zero(), - expected: prefix_len, - seen: 0, - } - } - - fn absorb(&mut self, points: &[G1Affine]) -> io::Result<()> { - if points.is_empty() { - return Ok(()); - } - self.seen = self - .seen - .checked_add(points.len()) - .filter(|seen| *seen <= self.expected) - .ok_or_else(|| invalid("too many Ignition prefix points"))?; - if let Some(previous) = self.previous { - // Adjacency across transcript and chunk boundaries is part of the relation. - self.low += previous * self.next_power; - self.high += points[0] * self.next_power; - self.next_power *= self.r; - } - if points.len() > 1 { - let scalars: Vec<_> = (1..points.len()) - .map(|_| { - let scalar = self.next_power; - self.next_power *= self.r; - scalar - }) - .collect(); - self.low += G1Projective::msm(&points[..points.len() - 1], &scalars) - .expect("equal lengths"); - self.high += - G1Projective::msm(&points[1..], &scalars).expect("equal lengths"); - } - self.previous = points.last().copied(); - Ok(()) - } - - fn verify(self, anchors: &Anchors) -> io::Result<()> { - if self.seen != self.expected { - return Err(invalid("incomplete Ignition prefix")); - } - if !PairingCurve::multi_pairing( - [self.high.into_affine(), (-self.low).into_affine()], - [G2Affine::generator(), anchors.tau_g2], - ) - .is_zero() - { - return Err(invalid("Ignition prefix is not one tau progression")); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/proof-compression/src/kzg/srs/ignition/admission.rs b/crates/proof-compression/src/kzg/srs/ignition/admission.rs deleted file mode 100644 index a3c2aa047..000000000 --- a/crates/proof-compression/src/kzg/srs/ignition/admission.rs +++ /dev/null @@ -1,607 +0,0 @@ -//! Portable, explicitly pinned authorization for one Ignition setup. - -use std::{ - fs::{self, File}, - io::{self, Read}, - path::Path, -}; - -use serde::{Deserialize, Serialize}; - -use super::{ - IgnitionCacheIdentity, SHIFTED_TAU_BASIS, cache, invalid, - load_ignition_cache_with_identity, - points::{decode_g1, decode_g2, encode_g1, encode_g2}, - source::{parse_source_manifest, read_source_manifest_bytes}, -}; -use crate::kzg::{ - PublicSetup, Srs, VerifierParams, - curve::{self, G1Affine, G2Affine}, -}; - -const FORMAT: &str = "ix-bn254-ignition-admission-v1"; -const MAX_ADMISSION_BYTES: u64 = 4 << 20; - -/// The pin is an explicit trust decision over setup identity and anchors. -/// Its embedded source manifest binds the identity to a specific ceremony -/// descriptor; neither the admission nor its parser authenticates participants. -#[derive(Clone, Debug)] -pub struct IgnitionAdmission { - source_manifest_json: String, - source_manifest_digest: [u8; 32], - public_setup: PublicSetup, - g1: [G1Affine; 2], - g2: G2Affine, - tau_g2: G2Affine, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AdmissionEncoding { - format: String, - curve: String, - basis: String, - source_manifest_json: String, - source_manifest_blake3: String, - setup_id: String, - public_max_degree: u64, - g1: [String; 2], - g2: String, - tau_g2: String, -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn unhex(text: &str) -> io::Result<[u8; N]> { - if text.len() != 2 * N || !text.as_bytes().iter().all(u8::is_ascii_hexdigit) { - return Err(invalid("invalid Ignition admission hexadecimal field")); - } - let mut bytes = [0; N]; - for (index, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&text[2 * index..2 * index + 2], 16) - .map_err(invalid)?; - } - Ok(bytes) -} - -impl IgnitionAdmission { - /// Authorize anchors from an authenticated import against an independently - /// pinned source descriptor. Only the cache's small manifest is loaded. - pub fn from_cache( - cache: &Path, - trusted_cache: [u8; 32], - source_manifest: &Path, - trusted_source: [u8; 32], - ) -> io::Result { - let (source_bytes, manifest) = - read_source_manifest_bytes(source_manifest, trusted_source)?; - let (srs, identity) = - load_ignition_cache_with_identity(cache, trusted_cache, 2)?; - if identity.source_manifest_digest != trusted_source - || identity.public_setup != manifest.public_setup()? - { - return Err(invalid( - "Ignition admission cache does not match its pinned source manifest", - )); - } - let admission = Self { - source_manifest_json: String::from_utf8(source_bytes).map_err(invalid)?, - source_manifest_digest: trusted_source, - public_setup: srs.public_setup, - g1: [srs.g1[0], srs.g1[1]], - g2: srs.g2, - tau_g2: srs.tau_g2, - }; - admission.check_cache(&srs, &identity)?; - Ok(admission) - } - - /// Canonical JSON for a new admission pin. The original source JSON bytes - /// remain unchanged inside its string field. - pub fn to_bytes(&self) -> io::Result> { - let encoding = AdmissionEncoding { - format: FORMAT.to_owned(), - curve: curve::NAME.to_owned(), - basis: SHIFTED_TAU_BASIS.to_owned(), - source_manifest_json: self.source_manifest_json.clone(), - source_manifest_blake3: hex(&self.source_manifest_digest), - setup_id: hex(&self.public_setup.id), - public_max_degree: self.public_setup.max_degree as u64, - g1: self.g1.map(|point| hex(&encode_g1(&point))), - g2: hex(&encode_g2(&self.g2)), - tau_g2: hex(&encode_g2(&self.tau_g2)), - }; - let mut bytes = serde_json::to_vec_pretty(&encoding).map_err(invalid)?; - bytes.push(b'\n'); - Ok(bytes) - } - - /// Authenticate the exact admission bytes before parsing. The trusted pin - /// must be configured separately from the proof, verifying key and admission file. - pub fn from_bytes( - bytes: &[u8], - trusted_admission: [u8; 32], - ) -> io::Result { - if bytes.len() as u64 > MAX_ADMISSION_BYTES { - return Err(invalid("Ignition admission exceeds 4 MiB")); - } - if blake3::hash(bytes).as_bytes() != &trusted_admission { - return Err(invalid("Ignition admission digest mismatch")); - } - let encoding: AdmissionEncoding = - serde_json::from_slice(bytes).map_err(invalid)?; - if encoding.format != FORMAT - || encoding.curve != curve::NAME - || encoding.basis != SHIFTED_TAU_BASIS - { - return Err(invalid( - "Ignition admission format, curve or basis mismatch", - )); - } - let source_manifest_digest = unhex(&encoding.source_manifest_blake3)?; - let manifest = parse_source_manifest( - encoding.source_manifest_json.as_bytes(), - source_manifest_digest, - )?; - let public_setup = PublicSetup { - max_degree: usize::try_from(encoding.public_max_degree) - .map_err(invalid)?, - id: unhex(&encoding.setup_id)?, - }; - if public_setup != manifest.public_setup()? { - return Err(invalid( - "Ignition admission setup identity or degree disagrees with its source manifest", - )); - } - let g1 = [ - decode_g1(&unhex::<64>(&encoding.g1[0])?)?, - decode_g1(&unhex::<64>(&encoding.g1[1])?)?, - ]; - let g2 = decode_g2(&unhex::<128>(&encoding.g2)?)?; - let tau_g2 = decode_g2(&unhex::<128>(&encoding.tau_g2)?)?; - cache::validate_anchors(&g1, g2, tau_g2)?; - Ok(Self { - source_manifest_json: encoding.source_manifest_json, - source_manifest_digest, - public_setup, - g1, - g2, - tau_g2, - }) - } - - pub fn read(path: &Path, pin: [u8; 32]) -> io::Result { - if !fs::metadata(path)?.is_file() { - return Err(invalid("Ignition admission must be a regular file")); - } - let file = File::open(path)?; - if !file.metadata()?.is_file() { - return Err(invalid("Ignition admission must be a regular file")); - } - let mut bytes = Vec::new(); - file.take(MAX_ADMISSION_BYTES + 1).read_to_end(&mut bytes)?; - Self::from_bytes(&bytes, pin) - } - - pub fn public_setup(&self) -> PublicSetup { - self.public_setup - } - - pub fn source_manifest_digest(&self) -> [u8; 32] { - self.source_manifest_digest - } - - pub fn anchors(&self) -> Srs { - Srs { - g1: self.g1.to_vec(), - g2: self.g2, - tau_g2: self.tau_g2, - public_setup: self.public_setup, - } - } - - pub fn pairing_keys(&self) -> [G2Affine; 2] { - [self.g2, self.tau_g2] - } - - pub fn check_verifier(&self, params: &VerifierParams) -> io::Result<()> { - if params.public_setup != self.public_setup - || params.g1 != self.g1 - || params.g2 != self.g2 - || params.tau_g2 != self.tau_g2 - { - return Err(invalid( - "verifier parameters do not match Ignition admission", - )); - } - super::validate_prefix( - params.max_trace_len(), - self.public_setup.max_degree + 1, - ) - } - - /// Compare an authenticated cache load with the admitted source and anchors. - /// This does not replace loading the cache against its separately trusted pin. - pub fn check_cache( - &self, - srs: &Srs, - identity: &IgnitionCacheIdentity, - ) -> io::Result<()> { - if srs.public_setup != self.public_setup - || identity.public_setup != self.public_setup - || identity.source_manifest_digest != self.source_manifest_digest - || srs.g1.len() < 2 - || srs.g1[..2] != self.g1 - || srs.g2 != self.g2 - || srs.tau_g2 != self.tau_g2 - || srs.max_len() > identity.prefix_len - { - return Err(invalid("cache does not match Ignition admission")); - } - super::validate_prefix( - identity.prefix_len, - self.public_setup.max_degree + 1, - )?; - super::validate_prefix(srs.max_len(), self.public_setup.max_degree + 1) - } -} - -#[cfg(test)] -pub(crate) fn test_admission( - tau: u64, - count: usize, - marker: u8, -) -> (IgnitionAdmission, Srs) { - use super::{ - Contribution, FINAL_ADDRESS, SOURCE_FORMAT, SourceManifest, Transcript, - }; - use crate::kzg::curve::{Fr, G1Projective}; - use ark_ec::{AffineRepr, CurveGroup, PrimeGroup}; - use ark_ff::{Field, PrimeField}; - - assert!(tau != 0 && count >= 2 && count.is_power_of_two()); - let tau = Fr::from(tau); - let g1: Vec<_> = (1..=count) - .map(|exponent| { - (G1Projective::generator() * tau.pow([exponent as u64])).into_affine() - }) - .collect(); - let tau_g2 = (G2Affine::generator() * tau).into_affine(); - let total = u32::try_from(count).unwrap(); - let mut transcript: Vec<_> = [0, 1, total, 1, total, 2, 0] - .into_iter() - .flat_map(u32::to_be_bytes) - .collect(); - for point in &g1 { - for coordinate in [point.x, point.y] { - transcript.extend( - coordinate.into_bigint().0.into_iter().flat_map(u64::to_be_bytes), - ); - } - } - for point in [tau_g2; 2] { - for coordinate in [point.x.c0, point.x.c1, point.y.c0, point.y.c1] { - transcript.extend( - coordinate.into_bigint().0.into_iter().flat_map(u64::to_be_bytes), - ); - } - } - transcript.extend_from_slice(blake2b_simd::blake2b(&transcript).as_bytes()); - let manifest = SourceManifest { - format: SOURCE_FORMAT.to_owned(), - ceremony: format!("synthetic ignition {marker}"), - basis: SHIFTED_TAU_BASIS.to_owned(), - num_g1_points: total, - points_per_transcript: total, - contribution: Contribution { - position: 197, - address: FINAL_ADDRESS.to_owned(), - transcripts: vec![Transcript { - index: 0, - blake2b512: blake2b_simd::blake2b(&transcript).to_hex().to_string(), - }], - }, - predecessor: None, - }; - let source_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); - let srs = Srs { - g1, - g2: G2Affine::generator(), - tau_g2, - public_setup: manifest.public_setup().unwrap(), - }; - let admission = IgnitionAdmission { - source_manifest_json: String::from_utf8(source_bytes.clone()).unwrap(), - source_manifest_digest: *blake3::hash(&source_bytes).as_bytes(), - public_setup: srs.public_setup, - g1: [srs.g1[0], srs.g1[1]], - g2: srs.g2, - tau_g2: srs.tau_g2, - }; - let bytes = admission.to_bytes().unwrap(); - ( - IgnitionAdmission::from_bytes(&bytes, *blake3::hash(&bytes).as_bytes()) - .unwrap(), - srs, - ) -} - -#[cfg(test)] -pub(crate) fn test_admission_cache( - path: &Path, - admission: &IgnitionAdmission, - srs: &Srs, -) -> IgnitionCacheIdentity { - srs.validate().unwrap(); - admission.check_verifier(&VerifierParams::from_srs(srs)).unwrap(); - let mut output = cache::CacheWriter::create( - path, - srs.max_len(), - admission.public_setup, - admission.source_manifest_digest, - ) - .unwrap(); - output.push(&srs.g1).unwrap(); - output - .finish(&super::Anchors { - g1: admission.g1, - tau_g2: admission.tau_g2, - contribution_g2: admission.tau_g2, - }) - .unwrap() -} - -#[cfg(test)] -mod tests { - use super::*; - use ark_ec::AffineRepr; - use std::{ - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, - }; - - static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); - - struct Directory(PathBuf); - - impl Directory { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "ix-ignition-admission-{}-{}", - std::process::id(), - NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed), - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - } - - impl Drop for Directory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - fn repin(value: &serde_json::Value) -> io::Result { - let bytes = serde_json::to_vec(value).unwrap(); - IgnitionAdmission::from_bytes(&bytes, *blake3::hash(&bytes).as_bytes()) - } - - #[test] - fn admission_roundtrip_authorizes_verifier_without_cache() { - let (admission, srs) = test_admission(7, 16, 1); - let bytes = admission.to_bytes().unwrap(); - let decoded = - IgnitionAdmission::from_bytes(&bytes, *blake3::hash(&bytes).as_bytes()) - .unwrap(); - assert_eq!(decoded.to_bytes().unwrap(), bytes); - assert_eq!(decoded.source_manifest_json, admission.source_manifest_json); - assert_eq!(decoded.anchors().g1, srs.g1[..2]); - assert_eq!(decoded.pairing_keys(), [srs.g2, srs.tau_g2]); - let params = VerifierParams::with_max_trace_len(&decoded.anchors(), 16); - let portable = - VerifierParams::from_bytes(¶ms.to_bytes().unwrap()).unwrap(); - decoded.check_verifier(&portable).unwrap(); - } - - #[test] - fn cache_admission_preserves_source_bytes_and_ignores_cached_prefix_size() { - let directory = Directory::new(); - let (admission, srs) = test_admission(7, 16, 1); - let source = directory.0.join("source.json"); - fs::write(&source, &admission.source_manifest_json).unwrap(); - for prefix_len in [4, 16] { - let cache = directory.0.join(format!("cache-{prefix_len}.bin")); - let prefix = Srs { - g1: srs.g1[..prefix_len].to_vec(), - g2: srs.g2, - tau_g2: srs.tau_g2, - public_setup: srs.public_setup, - }; - let receipt = test_admission_cache(&cache, &admission, &prefix); - let (anchors, identity) = - load_ignition_cache_with_identity(&cache, receipt.digest, 2).unwrap(); - assert_eq!(identity, receipt); - assert_eq!(identity.prefix_len, prefix_len); - admission.check_cache(&anchors, &identity).unwrap(); - let derived = IgnitionAdmission::from_cache( - &cache, - receipt.digest, - &source, - admission.source_manifest_digest, - ) - .unwrap(); - assert_eq!(derived.to_bytes().unwrap(), admission.to_bytes().unwrap()); - assert!( - IgnitionAdmission::from_cache( - &cache, - [0; 32], - &source, - admission.source_manifest_digest, - ) - .is_err() - ); - fs::remove_file(cache).unwrap(); - } - fs::remove_file(source).unwrap(); - let path = directory.0.join("admission.json"); - let bytes = admission.to_bytes().unwrap(); - fs::write(&path, &bytes).unwrap(); - let loaded = - IgnitionAdmission::read(&path, *blake3::hash(&bytes).as_bytes()).unwrap(); - loaded.check_verifier(&VerifierParams::from_srs(&srs)).unwrap(); - } - - #[test] - fn cache_admission_rejects_relabelled_identity_and_different_source_pin() { - let directory = Directory::new(); - let (admission, srs) = test_admission(7, 16, 1); - let source = directory.0.join("source.json"); - fs::write(&source, &admission.source_manifest_json).unwrap(); - let cache = directory.0.join("cache.bin"); - let dev_setup = PublicSetup::known_trapdoor(15, b"admission-relabel"); - let mut output = cache::CacheWriter::create( - &cache, - 16, - dev_setup, - admission.source_manifest_digest, - ) - .unwrap(); - output.push(&srs.g1).unwrap(); - let receipt = output - .finish(&super::super::Anchors { - g1: admission.g1, - tau_g2: admission.tau_g2, - contribution_g2: admission.tau_g2, - }) - .unwrap(); - assert!( - IgnitionAdmission::from_cache( - &cache, - receipt.digest, - &source, - admission.source_manifest_digest, - ) - .is_err() - ); - - let real_cache = directory.0.join("valid-cache.bin"); - let receipt = test_admission_cache(&real_cache, &admission, &srs); - let mut changed_source = admission.source_manifest_json.clone(); - changed_source.push(' '); - fs::write(&source, &changed_source).unwrap(); - assert!( - IgnitionAdmission::from_cache( - &real_cache, - receipt.digest, - &source, - *blake3::hash(changed_source.as_bytes()).as_bytes(), - ) - .is_err() - ); - } - - #[test] - fn admission_requires_exact_external_pin_and_bounded_schema() { - let (admission, _) = test_admission(7, 16, 1); - let bytes = admission.to_bytes().unwrap(); - assert!(IgnitionAdmission::from_bytes(&bytes, [0; 32]).is_err()); - let mut changed = bytes.clone(); - changed.push(b' '); - assert!( - IgnitionAdmission::from_bytes(&changed, *blake3::hash(&bytes).as_bytes()) - .is_err() - ); - let oversized = - vec![b' '; usize::try_from(MAX_ADMISSION_BYTES).unwrap() + 1]; - assert!( - IgnitionAdmission::from_bytes( - &oversized, - *blake3::hash(&oversized).as_bytes() - ) - .is_err() - ); - let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - value["extra"] = true.into(); - assert!(repin(&value).is_err()); - } - - #[test] - fn admission_rejects_inconsistent_source_identity_and_anchors() { - let (admission, _) = test_admission(7, 16, 1); - let original: serde_json::Value = - serde_json::from_slice(&admission.to_bytes().unwrap()).unwrap(); - let zero_digest = "00".repeat(32); - for (field, replacement) in [ - ("curve", "bls12-381"), - ("basis", "unshifted"), - ("source_manifest_blake3", zero_digest.as_str()), - ("setup_id", zero_digest.as_str()), - ] { - let mut value = original.clone(); - value[field] = replacement.into(); - assert!(repin(&value).is_err()); - } - let mut wrong_degree = original.clone(); - wrong_degree["public_max_degree"] = 31.into(); - assert!(repin(&wrong_degree).is_err()); - let mut wrong_g1 = original.clone(); - wrong_g1["g1"][0] = hex(&encode_g1(&G1Affine::generator())).into(); - assert!(repin(&wrong_g1).is_err()); - let mut noncanonical = original.clone(); - noncanonical["g2"] = "ff".repeat(128).into(); - assert!(repin(&noncanonical).is_err()); - let mut wrong_source = original; - let (other, _) = test_admission(7, 16, 2); - wrong_source["source_manifest_json"] = other.source_manifest_json.into(); - wrong_source["source_manifest_blake3"] = - hex(&other.source_manifest_digest).into(); - assert!(repin(&wrong_source).is_err()); - } - - #[test] - fn admission_rejects_known_development_identity_and_relabelled_verifier() { - let (admission, _) = test_admission(7, 16, 1); - let mut development = Srs::unsafe_dev_setup(16, b"ignition-admission-dev"); - let mut value: serde_json::Value = - serde_json::from_slice(&admission.to_bytes().unwrap()).unwrap(); - value["setup_id"] = hex(&development.public_setup.id).into(); - assert!(repin(&value).is_err()); - assert!( - admission - .check_verifier(&VerifierParams::from_srs(&development)) - .is_err() - ); - development.public_setup = admission.public_setup(); - assert!( - admission - .check_verifier(&VerifierParams::from_srs(&development)) - .is_err() - ); - } - - #[test] - fn admission_checks_cache_source_receipt_and_all_anchors() { - let (admission, mut srs) = test_admission(7, 16, 1); - let identity = IgnitionCacheIdentity { - prefix_len: 16, - digest: [9; 32], - public_setup: admission.public_setup(), - source_manifest_digest: admission.source_manifest_digest(), - }; - admission.check_cache(&srs, &identity).unwrap(); - let mut bad = identity; - bad.source_manifest_digest[0] ^= 1; - assert!(admission.check_cache(&srs, &bad).is_err()); - let mut bad = identity; - bad.public_setup.max_degree += 1; - assert!(admission.check_cache(&srs, &bad).is_err()); - let mut bad = identity; - bad.prefix_len = 8; - assert!(admission.check_cache(&srs, &bad).is_err()); - srs.g1[0] = -srs.g1[0]; - assert!(admission.check_cache(&srs, &identity).is_err()); - } -} diff --git a/crates/proof-compression/src/kzg/srs/ignition/cache.rs b/crates/proof-compression/src/kzg/srs/ignition/cache.rs deleted file mode 100644 index 85ab54709..000000000 --- a/crates/proof-compression/src/kzg/srs/ignition/cache.rs +++ /dev/null @@ -1,421 +0,0 @@ -//! Normalized Ignition prefixes authenticated by an externally pinned receipt. - -use std::{ - fs::{self, File, OpenOptions}, - io::{self, Read, Seek, SeekFrom, Write}, - path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, -}; - -use ark_bn254::{Bn254, G1Affine, G2Affine}; -use ark_ec::{AffineRepr, pairing::Pairing}; -use ark_ff::Zero; -use p3_maybe_rayon::prelude::*; - -use super::super::{PublicSetup, Srs}; -use super::{ - Anchors, MAX_G1_POINTS, MAX_PREFIX_LEN, - points::{decode_g1, decode_g2, encode_g1, encode_g2}, -}; - -const MAGIC: &[u8] = b"multi-stark/bn254/ignition-srs/v1\0"; -const G1_BYTES: usize = 64; -const G2_BYTES: usize = 128; -const BLOCK_POINTS: usize = 1 << 16; -const HEADER_BYTES: usize = - MAGIC.len() + 64 + 3 * 8 + 2 * G1_BYTES + 2 * G2_BYTES; -static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); - -/// Pin `digest` through trusted configuration after a successful import. -/// It authenticates the source identity, complete public degree, shifted -/// generators, retained prefix length and every retained G1 chunk digest. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct IgnitionCacheIdentity { - pub prefix_len: usize, - pub digest: [u8; 32], - pub public_setup: PublicSetup, - pub source_manifest_digest: [u8; 32], -} - -fn invalid( - message: impl Into>, -) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) -} - -fn validate_prefix(prefix_len: usize) -> io::Result<()> { - if prefix_len < 2 - || !prefix_len.is_power_of_two() - || prefix_len > MAX_PREFIX_LEN - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("Ignition prefix must be a power of two in 2..={MAX_PREFIX_LEN}"), - )); - } - Ok(()) -} - -fn validate_degree( - public_setup: PublicSetup, - prefix_len: usize, -) -> io::Result<()> { - if public_setup.max_degree >= MAX_G1_POINTS - || public_setup.max_degree < prefix_len - 1 - { - return Err(invalid("Ignition public degree does not cover the prefix")); - } - Ok(()) -} - -pub(super) fn validate_anchors( - g1: &[G1Affine; 2], - g2: G2Affine, - tau_g2: G2Affine, -) -> io::Result<()> { - if g1.iter().any(|point| point.is_zero() || !point.is_on_curve()) - || [g2, tau_g2].iter().any(|point| { - point.is_zero() - || !point.is_on_curve() - || !point.is_in_correct_subgroup_assuming_on_curve() - }) - || g2 != G2Affine::generator() - { - return Err(invalid("invalid Ignition cache anchors")); - } - if !Bn254::multi_pairing([g1[0], -G1Affine::generator()], [g2, tau_g2]) - .is_zero() - || !Bn254::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]).is_zero() - { - return Err(invalid("Ignition cache anchors are not a shifted tau basis")); - } - Ok(()) -} - -fn encode_header( - prefix_len: usize, - public_setup: PublicSetup, - source_manifest_digest: [u8; 32], - anchors: &Anchors, -) -> Vec { - let mut header = Vec::with_capacity(HEADER_BYTES); - header.extend_from_slice(MAGIC); - header.extend_from_slice(&public_setup.id); - header.extend_from_slice(&source_manifest_digest); - header.extend_from_slice(&(public_setup.max_degree as u64).to_le_bytes()); - header.extend_from_slice(&(prefix_len as u64).to_le_bytes()); - header.extend_from_slice(&(BLOCK_POINTS as u64).to_le_bytes()); - for point in &anchors.g1 { - header.extend_from_slice(&encode_g1(point)); - } - header.extend_from_slice(&encode_g2(&G2Affine::generator())); - header.extend_from_slice(&encode_g2(&anchors.tau_g2)); - header -} - -fn block_digest(index: usize, bytes: &[u8]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"multi-stark/kzg/bn254/ignition-cache-block/v1"); - hasher.update(&(index as u64).to_le_bytes()); - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(bytes); - *hasher.finalize().as_bytes() -} - -fn manifest_digest(header: &[u8], index: &[u8]) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"multi-stark/kzg/bn254/ignition-cache-manifest/v1"); - hasher.update(header); - hasher.update(index); - *hasher.finalize().as_bytes() -} - -struct Temporary(PathBuf); - -impl Temporary { - fn create(destination: &Path) -> io::Result<(Self, File)> { - if destination.try_exists()? { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "Ignition cache already exists", - )); - } - loop { - let path = destination.with_extension(format!( - "{}.{}.partial", - std::process::id(), - NEXT_TEMP.fetch_add(1, Ordering::Relaxed) - )); - match OpenOptions::new().write(true).create_new(true).open(&path) { - Ok(file) => return Ok((Self(path), file)), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, - Err(error) => return Err(error), - } - } - } - - fn publish(&self, destination: &Path) -> io::Result<()> { - // Hard-link publication is atomic and never replaces an existing cache. - fs::hard_link(&self.0, destination) - } -} - -impl Drop for Temporary { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } -} - -pub(super) struct CacheWriter { - output: File, - temporary: Temporary, - destination: PathBuf, - prefix_len: usize, - public_setup: PublicSetup, - source_manifest_digest: [u8; 32], - points_written: usize, - first_g1: [Option; 2], - pending: Vec, - index: Vec, - failed: bool, -} - -impl CacheWriter { - pub(super) fn create( - cache: &Path, - prefix_len: usize, - public_setup: PublicSetup, - source_manifest_digest: [u8; 32], - ) -> io::Result { - validate_prefix(prefix_len)?; - validate_degree(public_setup, prefix_len)?; - let (temporary, mut output) = Temporary::create(cache)?; - output.write_all(&[0; HEADER_BYTES])?; - Ok(Self { - output, - temporary, - destination: cache.to_path_buf(), - prefix_len, - public_setup, - source_manifest_digest, - points_written: 0, - first_g1: [None; 2], - pending: Vec::with_capacity(prefix_len.min(BLOCK_POINTS) * G1_BYTES), - index: Vec::with_capacity(prefix_len.div_ceil(BLOCK_POINTS) * 32), - failed: false, - }) - } - - pub(super) fn push(&mut self, points: &[G1Affine]) -> io::Result<()> { - if self.failed { - return Err(invalid("Ignition cache writer has already failed")); - } - let result = self.push_inner(points); - if result.is_err() { - self.failed = true; - } - result - } - - fn push_inner(&mut self, points: &[G1Affine]) -> io::Result<()> { - if points.len() > self.prefix_len - self.points_written { - return Err(invalid("too many points for the Ignition cache prefix")); - } - for point in points { - if self.points_written < 2 { - self.first_g1[self.points_written] = Some(*point); - } - self.pending.extend_from_slice(&encode_g1(point)); - self.points_written += 1; - if self.pending.len() == BLOCK_POINTS * G1_BYTES { - self.flush_block()?; - } - } - Ok(()) - } - - fn flush_block(&mut self) -> io::Result<()> { - if !self.pending.is_empty() { - self.output.write_all(&self.pending)?; - self - .index - .extend_from_slice(&block_digest(self.index.len() / 32, &self.pending)); - self.pending.clear(); - } - Ok(()) - } - - /// Call only after source authentication and complete prefix progression - /// checks. Until this point the temporary file has no valid cache header. - pub(super) fn finish( - mut self, - anchors: &Anchors, - ) -> io::Result { - if self.failed || self.points_written != self.prefix_len { - return Err(invalid("incomplete Ignition cache prefix")); - } - if self.first_g1 != anchors.g1.map(Some) { - return Err(invalid("Ignition cache prefix disagrees with its anchors")); - } - validate_anchors(&anchors.g1, G2Affine::generator(), anchors.tau_g2)?; - self.flush_block()?; - let header = encode_header( - self.prefix_len, - self.public_setup, - self.source_manifest_digest, - anchors, - ); - let digest = manifest_digest(&header, &self.index); - self.output.write_all(&self.index)?; - self.output.write_all(&digest)?; - self.output.seek(SeekFrom::Start(0))?; - self.output.write_all(&header)?; - self.output.sync_all()?; - drop(self.output); - self.temporary.publish(&self.destination)?; - Ok(IgnitionCacheIdentity { - prefix_len: self.prefix_len, - digest, - public_setup: self.public_setup, - source_manifest_digest: self.source_manifest_digest, - }) - } -} - -/// Authenticate the cache with a receipt supplied by trusted configuration. -/// A two-point load reads only the manifest and validates its anchors. Larger -/// loads authenticate complete overlapping chunks before decoding requested -/// points; unrequested chunks are not read. Prefix loads preserve the complete -/// ceremony's public degree and setup identity. -pub fn load_ignition_cache( - cache: &Path, - expected_manifest_digest: [u8; 32], - prefix_len: usize, -) -> io::Result { - load_ignition_cache_with_identity(cache, expected_manifest_digest, prefix_len) - .map(|(srs, _)| srs) -} - -/// Load a prefix and the authenticated identity of its complete cache. -/// The identity's prefix length describes the stored cache, which may be -/// larger than the requested prefix. -pub fn load_ignition_cache_with_identity( - cache: &Path, - expected_manifest_digest: [u8; 32], - prefix_len: usize, -) -> io::Result<(Srs, IgnitionCacheIdentity)> { - validate_prefix(prefix_len)?; - if !fs::metadata(cache)?.is_file() { - return Err(invalid("Ignition cache must be a regular file")); - } - let mut file = File::open(cache)?; - if !file.metadata()?.is_file() { - return Err(invalid("Ignition cache must be a regular file")); - } - let mut header = [0; HEADER_BYTES]; - file.read_exact(&mut header)?; - if !header.starts_with(MAGIC) { - return Err(invalid("Ignition cache format or curve mismatch")); - } - let numbers = MAGIC.len() + 64; - let number = |offset: usize| { - u64::from_le_bytes( - header[numbers + offset..numbers + offset + 8] - .try_into() - .expect("fixed header field"), - ) - }; - let stored_len = usize::try_from(number(8)).map_err(invalid)?; - validate_prefix(stored_len).map_err(invalid)?; - let public_setup = PublicSetup { - max_degree: usize::try_from(number(0)).map_err(invalid)?, - id: header[MAGIC.len()..MAGIC.len() + 32] - .try_into() - .expect("fixed header field"), - }; - validate_degree(public_setup, stored_len)?; - if number(16) != BLOCK_POINTS as u64 { - return Err(invalid("Ignition cache block size mismatch")); - } - if prefix_len > stored_len { - return Err(invalid("Ignition cache prefix is smaller than requested")); - } - let index_bytes = stored_len - .div_ceil(BLOCK_POINTS) - .checked_mul(32) - .ok_or_else(|| invalid("Ignition cache index length overflow"))?; - let index_offset = u64::try_from(stored_len) - .map_err(invalid)? - .checked_mul(G1_BYTES as u64) - .and_then(|bytes| bytes.checked_add(HEADER_BYTES as u64)) - .ok_or_else(|| invalid("Ignition cache data length overflow"))?; - let expected_bytes = index_offset - .checked_add(u64::try_from(index_bytes).map_err(invalid)?) - .and_then(|bytes| bytes.checked_add(32)) - .ok_or_else(|| invalid("Ignition cache total length overflow"))?; - if file.metadata()?.len() != expected_bytes { - return Err(invalid("Ignition cache has the wrong length")); - } - file.seek(SeekFrom::Start(index_offset))?; - let mut index = vec![0; index_bytes]; - file.read_exact(&mut index)?; - let mut declared_digest = [0; 32]; - file.read_exact(&mut declared_digest)?; - if declared_digest != expected_manifest_digest - || manifest_digest(&header, &index) != expected_manifest_digest - { - return Err(invalid( - "Ignition cache does not match the pinned manifest digest", - )); - } - let g1_offset = numbers + 24; - let g2_offset = g1_offset + 2 * G1_BYTES; - let g1_anchors = [ - decode_g1(&header[g1_offset..g1_offset + G1_BYTES])?, - decode_g1(&header[g1_offset + G1_BYTES..g2_offset])?, - ]; - let g2 = decode_g2(&header[g2_offset..g2_offset + G2_BYTES])?; - let tau_g2 = decode_g2(&header[g2_offset + G2_BYTES..])?; - validate_anchors(&g1_anchors, g2, tau_g2)?; - let mut g1 = Vec::new(); - g1.try_reserve_exact(prefix_len).map_err(io::Error::other)?; - if prefix_len == 2 { - g1.extend_from_slice(&g1_anchors); - } else { - file.seek(SeekFrom::Start(HEADER_BYTES as u64))?; - let mut buffer = vec![0; stored_len.min(BLOCK_POINTS) * G1_BYTES]; - for block_index in 0..prefix_len.div_ceil(BLOCK_POINTS) { - let start = block_index * BLOCK_POINTS; - let count = (stored_len - start).min(BLOCK_POINTS); - let bytes = &mut buffer[..count * G1_BYTES]; - file.read_exact(bytes)?; - if block_digest(block_index, bytes) - != index[block_index * 32..(block_index + 1) * 32] - { - return Err(invalid(format!( - "Ignition cache chunk {block_index} digest mismatch" - ))); - } - let needed = (prefix_len - start).min(count); - let points = bytes[..needed * G1_BYTES] - .par_chunks_exact(G1_BYTES) - .map(decode_g1) - .collect::>>()?; - g1.extend(points); - } - if g1[..2] != g1_anchors { - return Err(invalid( - "Ignition cache G1 anchors disagree with its prefix", - )); - } - } - let identity = IgnitionCacheIdentity { - prefix_len: stored_len, - digest: expected_manifest_digest, - public_setup, - source_manifest_digest: header[MAGIC.len() + 32..MAGIC.len() + 64] - .try_into() - .expect("fixed header field"), - }; - Ok((Srs { g1, g2, tau_g2, public_setup }, identity)) -} diff --git a/crates/proof-compression/src/kzg/srs/ignition/points.rs b/crates/proof-compression/src/kzg/srs/ignition/points.rs deleted file mode 100644 index 3dd920e6d..000000000 --- a/crates/proof-compression/src/kzg/srs/ignition/points.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::io; - -use ark_bn254::Fq2; -use ark_ec::AffineRepr; -use ark_ff::{BigInt, PrimeField}; - -use crate::kzg::curve::{Fq, G1Affine, G2Affine}; - -fn invalid(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) -} - -fn decode_field(bytes: &[u8], source_encoding: bool) -> io::Result { - if bytes.len() != 32 { - return Err(invalid("invalid BN254 coordinate length")); - } - let mut limbs = [0; 4]; - for (limb, bytes) in limbs.iter_mut().zip(bytes.as_chunks::<8>().0) { - let word = *bytes; - *limb = if source_encoding { - u64::from_be_bytes(word) - } else { - u64::from_le_bytes(word) - }; - } - Fq::from_bigint(BigInt(limbs)) - .ok_or_else(|| invalid("noncanonical BN254 coordinate")) -} - -fn read_g1(bytes: &[u8], source_encoding: bool) -> io::Result { - if bytes.len() != 64 { - return Err(invalid("invalid BN254 G1 length")); - } - let point = G1Affine::new_unchecked( - decode_field(&bytes[..32], source_encoding)?, - decode_field(&bytes[32..], source_encoding)?, - ); - // BN254 G1 has cofactor one, so curve membership implies subgroup membership. - if point.is_zero() || !point.is_on_curve() { - return Err(invalid("invalid or identity BN254 G1 point")); - } - Ok(point) -} - -fn read_g2(bytes: &[u8], source_encoding: bool) -> io::Result { - if bytes.len() != 128 { - return Err(invalid("invalid BN254 G2 length")); - } - let point = G2Affine::new_unchecked( - Fq2::new( - decode_field(&bytes[..32], source_encoding)?, - decode_field(&bytes[32..64], source_encoding)?, - ), - Fq2::new( - decode_field(&bytes[64..96], source_encoding)?, - decode_field(&bytes[96..], source_encoding)?, - ), - ); - if point.is_zero() - || !point.is_on_curve() - || !point.is_in_correct_subgroup_assuming_on_curve() - { - return Err(invalid("invalid or identity BN254 G2 point")); - } - Ok(point) -} - -pub(super) fn decode_source_g1(bytes: &[u8]) -> io::Result { - read_g1(bytes, true) -} - -pub(super) fn decode_source_g2(bytes: &[u8]) -> io::Result { - read_g2(bytes, true) -} - -fn encode_field(value: &Fq, bytes: &mut [u8]) { - for (limb, bytes) in - value.into_bigint().0.iter().zip(bytes.as_chunks_mut::<8>().0) - { - bytes.copy_from_slice(&limb.to_le_bytes()); - } -} - -pub(super) fn encode_g1(point: &G1Affine) -> [u8; 64] { - let mut bytes = [0; 64]; - encode_field(&point.x, &mut bytes[..32]); - encode_field(&point.y, &mut bytes[32..]); - bytes -} - -pub(super) fn decode_g1(bytes: &[u8]) -> io::Result { - read_g1(bytes, false) -} - -pub(super) fn encode_g2(point: &G2Affine) -> [u8; 128] { - let mut bytes = [0; 128]; - encode_field(&point.x.c0, &mut bytes[..32]); - encode_field(&point.x.c1, &mut bytes[32..64]); - encode_field(&point.y.c0, &mut bytes[64..96]); - encode_field(&point.y.c1, &mut bytes[96..]); - bytes -} - -pub(super) fn decode_g2(bytes: &[u8]) -> io::Result { - read_g2(bytes, false) -} diff --git a/crates/proof-compression/src/kzg/srs/ignition/source.rs b/crates/proof-compression/src/kzg/srs/ignition/source.rs deleted file mode 100644 index abfe62394..000000000 --- a/crates/proof-compression/src/kzg/srs/ignition/source.rs +++ /dev/null @@ -1,461 +0,0 @@ -use std::{ - fs::{self, File}, - io::{self, Read}, - path::{Path, PathBuf}, -}; - -use p3_maybe_rayon::prelude::*; -use serde::{Deserialize, Serialize}; - -use super::{ - Anchors, - points::{decode_source_g1, decode_source_g2}, -}; -use crate::kzg::{ - curve::{self, G1Affine, G2Affine}, - srs::PublicSetup, -}; - -pub const SOURCE_FORMAT: &str = "ix-bn254-ignition-source-v1"; -pub const SHIFTED_TAU_BASIS: &str = "shifted-tau"; -const MAX_MANIFEST_BYTES: u64 = 1 << 20; -const MAX_G1_POINTS: u32 = 100_800_000; -const MAX_TRANSCRIPTS: u32 = 32; -const BLOCK_POINTS: usize = 1 << 16; - -/// A separately authenticated description of the complete ceremony snapshot. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SourceManifest { - pub format: String, - pub ceremony: String, - pub basis: String, - pub num_g1_points: u32, - pub points_per_transcript: u32, - pub contribution: Contribution, - pub predecessor: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Contribution { - pub position: u32, - pub address: String, - pub transcripts: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Transcript { - pub index: u32, - /// BLAKE2b-512 of the complete file, including its embedded checksum. - pub blake2b512: String, -} - -fn invalid( - message: impl Into>, -) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) -} - -fn open_regular_file(path: &Path) -> io::Result { - if !fs::metadata(path)?.is_file() { - return Err(invalid("Ignition input must be a regular file")); - } - let file = File::open(path)?; - if !file.metadata()?.is_file() { - return Err(invalid("Ignition input must be a regular file")); - } - Ok(file) -} - -fn validate_participant(position: u32, address: &str) -> io::Result<()> { - if position > 9999 - || address.len() != 42 - || !address.starts_with("0x") - || !address.as_bytes()[2..] - .iter() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) - { - return Err(invalid("invalid Ignition contribution position or address")); - } - Ok(()) -} - -fn decode_digest(text: &str) -> io::Result<[u8; 64]> { - if text.len() != 128 || !text.as_bytes().iter().all(u8::is_ascii_hexdigit) { - return Err(invalid("invalid Ignition BLAKE2b-512 digest")); - } - let mut bytes = [0; 64]; - for (byte, hex) in bytes.iter_mut().zip(text.as_bytes().as_chunks::<2>().0) { - let digit = |value: u8| { - if value.is_ascii_digit() { - value - b'0' - } else { - value.to_ascii_lowercase() - b'a' + 10 - } - }; - *byte = digit(hex[0]) * 16 + digit(hex[1]); - } - Ok(bytes) -} - -fn encode_digest(bytes: &[u8; 64]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut text = String::with_capacity(128); - for byte in bytes { - text.push(char::from(HEX[usize::from(byte >> 4)])); - text.push(char::from(HEX[usize::from(byte & 15)])); - } - text -} - -impl SourceManifest { - pub fn validate(&self) -> io::Result<()> { - if self.format != SOURCE_FORMAT - || self.basis != SHIFTED_TAU_BASIS - || self.ceremony.trim().is_empty() - || self.ceremony.len() > 256 - || self.ceremony.chars().any(char::is_control) - || !(2..=MAX_G1_POINTS).contains(&self.num_g1_points) - || self.points_per_transcript == 0 - || self.points_per_transcript > self.num_g1_points - { - return Err(invalid("invalid Ignition source manifest metadata")); - } - let count = self.num_g1_points.div_ceil(self.points_per_transcript); - if count > MAX_TRANSCRIPTS { - return Err(invalid("too many Ignition transcripts")); - } - for contribution in - std::iter::once(&self.contribution).chain(self.predecessor.iter()) - { - validate_participant(contribution.position, &contribution.address)?; - if contribution.transcripts.len() != count as usize { - return Err(invalid("wrong number of Ignition transcript digests")); - } - for (expected, transcript) in (0..count).zip(&contribution.transcripts) { - if transcript.index != expected { - return Err(invalid( - "Ignition transcript indices are not contiguous", - )); - } - decode_digest(&transcript.blake2b512)?; - } - } - if self - .predecessor - .as_ref() - .is_some_and(|previous| previous.position >= self.contribution.position) - { - return Err(invalid("Ignition predecessor must precede contribution")); - } - Ok(()) - } - - /// The shifted basis starts at `τG`, so N points allow degree N−1. - /// Optional predecessor evidence does not change the selected setup's identity. - pub fn public_setup(&self) -> io::Result { - self.validate()?; - let mut hash = blake3::Hasher::new(); - hash.update(b"multi-stark/kzg/bn254/ignition-public-setup/v1"); - for text in [ - curve::NAME, - &self.format, - &self.basis, - &self.ceremony, - &self.contribution.address, - ] { - hash.update(&(text.len() as u64).to_le_bytes()); - hash.update(text.as_bytes()); - } - hash.update(&self.num_g1_points.to_le_bytes()); - hash.update(&self.points_per_transcript.to_le_bytes()); - hash.update(&self.contribution.position.to_le_bytes()); - for transcript in &self.contribution.transcripts { - hash.update(&transcript.index.to_le_bytes()); - hash.update(&decode_digest(&transcript.blake2b512)?); - } - Ok(PublicSetup { - max_degree: self.num_g1_points as usize - 1, - id: *hash.finalize().as_bytes(), - }) - } -} - -/// The expected digest must come from trusted configuration, independently of -/// the source directory and its manifest. It covers the exact JSON bytes. -pub fn read_source_manifest( - path: &Path, - expected: [u8; 32], -) -> io::Result { - read_source_manifest_bytes(path, expected).map(|(_, manifest)| manifest) -} - -pub(super) fn read_source_manifest_bytes( - path: &Path, - expected: [u8; 32], -) -> io::Result<(Vec, SourceManifest)> { - let mut bytes = Vec::new(); - open_regular_file(path)? - .take(MAX_MANIFEST_BYTES + 1) - .read_to_end(&mut bytes)?; - let manifest = parse_source_manifest(&bytes, expected)?; - Ok((bytes, manifest)) -} - -pub(super) fn parse_source_manifest( - bytes: &[u8], - expected: [u8; 32], -) -> io::Result { - if bytes.len() as u64 > MAX_MANIFEST_BYTES { - return Err(invalid("Ignition source manifest exceeds 1 MiB")); - } - if blake3::hash(bytes).as_bytes() != &expected { - return Err(invalid("Ignition source manifest digest mismatch")); - } - let manifest: SourceManifest = - serde_json::from_slice(bytes).map_err(invalid)?; - manifest.validate()?; - Ok(manifest) -} - -fn transcript_path( - root: &Path, - contribution: &Contribution, - index: u32, -) -> PathBuf { - root - .join(format!("{:03}_{}", contribution.position, contribution.address)) - .join(format!("transcript{index:02}.dat")) -} - -fn parse_header(bytes: &[u8; 28]) -> [u32; 7] { - let mut header = [0; 7]; - for (word, bytes) in header.iter_mut().zip(bytes.as_chunks::<4>().0) { - *word = u32::from_be_bytes(*bytes); - } - header -} - -fn expected_header(manifest: &SourceManifest, index: u32) -> [u32; 7] { - let start = index * manifest.points_per_transcript; - [ - index, - manifest.num_g1_points.div_ceil(manifest.points_per_transcript), - manifest.num_g1_points, - 1, - manifest.points_per_transcript.min(manifest.num_g1_points - start), - if index == 0 { 2 } else { 0 }, - start, - ] -} - -struct FileResult { - digest: [u8; 64], - first_g1: Vec, - g2: Option<[G2Affine; 2]>, -} - -fn stream_file( - root: &Path, - manifest: &SourceManifest, - contribution: &Contribution, - index: u32, - prefix_len: usize, - visit: &mut impl FnMut(&[G1Affine]) -> io::Result<()>, -) -> io::Result { - let path = transcript_path(root, contribution, index); - let mut file = open_regular_file(&path)?; - let mut bytes = [0; 28]; - file.read_exact(&mut bytes)?; - let header = parse_header(&bytes); - if header != expected_header(manifest, index) { - return Err(invalid(format!( - "Ignition header mismatch in {}", - path.display() - ))); - } - let expected_bytes = - 28 + u64::from(header[4]) * 64 + u64::from(header[5]) * 128 + 64; - if file.metadata()?.len() != expected_bytes { - return Err(invalid(format!( - "Ignition file length mismatch in {}", - path.display() - ))); - } - let mut checksum = blake2b_simd::Params::new().hash_length(64).to_state(); - checksum.update(&bytes); - let mut first_g1 = Vec::with_capacity(2); - let mut buffer = vec![0; BLOCK_POINTS * 64]; - let mut consumed = 0; - let count = header[4] as usize; - let retained = prefix_len.saturating_sub(header[6] as usize).min(count); - while consumed < count { - let chunk_len = (count - consumed).min(BLOCK_POINTS); - let chunk = &mut buffer[..chunk_len * 64]; - file.read_exact(chunk)?; - checksum.update(chunk); - let retained_chunk = retained.saturating_sub(consumed).min(chunk_len); - if retained_chunk != 0 { - let points: Vec<_> = chunk[..retained_chunk * 64] - .par_chunks_exact(64) - .map(decode_source_g1) - .collect::>()?; - first_g1.extend(points.iter().take(2 - first_g1.len()).copied()); - visit(&points)?; - } - consumed += chunk_len; - } - let g2 = if index == 0 { - let mut bytes = [0; 256]; - file.read_exact(&mut bytes)?; - checksum.update(&bytes); - Some([decode_source_g2(&bytes[..128])?, decode_source_g2(&bytes[128..])?]) - } else { - None - }; - let mut stored_checksum = [0; 64]; - file.read_exact(&mut stored_checksum)?; - let mut trailing = [0; 1]; - if file.read(&mut trailing)? != 0 { - return Err(invalid("trailing bytes in Ignition transcript")); - } - if checksum.finalize().as_bytes() != stored_checksum { - return Err(invalid(format!( - "Ignition internal checksum mismatch in {}", - path.display() - ))); - } - checksum.update(&stored_checksum); - let digest = checksum.finalize().as_bytes().try_into().expect("BLAKE2b-512"); - Ok(FileResult { digest, first_g1, g2 }) -} - -/// Read and authenticate complete files, decoding only the retained prefix. -/// The visitor must keep its output private until this function succeeds. -pub(super) fn stream_contribution( - root: &Path, - manifest: &SourceManifest, - contribution: &Contribution, - prefix_len: usize, - mut visit: impl FnMut(&[G1Affine]) -> io::Result<()>, -) -> io::Result { - manifest.validate()?; - if contribution != &manifest.contribution - && manifest.predecessor.as_ref() != Some(contribution) - { - return Err(invalid( - "Ignition contribution is absent from source manifest", - )); - } - if prefix_len < 2 || prefix_len > manifest.num_g1_points as usize { - return Err(invalid("Ignition retained prefix is outside source bounds")); - } - let mut first_g1 = Vec::with_capacity(2); - let mut anchors_g2 = None; - for transcript in &contribution.transcripts { - let result = stream_file( - root, - manifest, - contribution, - transcript.index, - prefix_len, - &mut visit, - )?; - if result.digest != decode_digest(&transcript.blake2b512)? { - return Err(invalid(format!( - "Ignition source digest mismatch at transcript {}", - transcript.index, - ))); - } - first_g1.extend(result.first_g1.into_iter().take(2 - first_g1.len())); - if result.g2.is_some() { - anchors_g2 = result.g2; - } - } - let [tau_g2, contribution_g2] = - anchors_g2.ok_or_else(|| invalid("missing Ignition G2 anchors"))?; - Ok(Anchors { - g1: first_g1 - .try_into() - .map_err(|_points| invalid("missing Ignition G1 anchors"))?, - tau_g2, - contribution_g2, - }) -} - -/// Produce an untrusted inventory, not evidence of ceremony provenance. -/// Checksums can detect damage but an attacker can replace both data and hashes. -pub fn inspect_ignition_source( - root: &Path, - position: u32, - address: &str, - predecessor: Option<(u32, &str)>, -) -> io::Result { - validate_participant(position, address)?; - if let Some((position, address)) = predecessor { - validate_participant(position, address)?; - } - let contribution = Contribution { - position, - address: address.to_owned(), - transcripts: Vec::new(), - }; - let mut bytes = [0; 28]; - open_regular_file(&transcript_path(root, &contribution, 0))? - .read_exact(&mut bytes)?; - let header = parse_header(&bytes); - let mut manifest = SourceManifest { - format: SOURCE_FORMAT.to_owned(), - ceremony: "MAIN IGNITION".to_owned(), - basis: SHIFTED_TAU_BASIS.to_owned(), - num_g1_points: header[2], - points_per_transcript: header[4], - contribution, - predecessor: predecessor.map(|(position, address)| Contribution { - position, - address: address.to_owned(), - transcripts: Vec::new(), - }), - }; - if header[1] == 0 || header[1] > MAX_TRANSCRIPTS { - return Err(invalid("invalid Ignition transcript count")); - } - let placeholders: Vec<_> = (0..header[1]) - .map(|index| Transcript { index, blake2b512: "0".repeat(128) }) - .collect(); - manifest.contribution.transcripts = placeholders.clone(); - if let Some(previous) = &mut manifest.predecessor { - previous.transcripts = placeholders; - } - manifest.validate()?; - let inspect = |contribution: &Contribution| -> io::Result> { - contribution - .transcripts - .iter() - .map(|transcript| { - let result = stream_file( - root, - &manifest, - contribution, - transcript.index, - 0, - &mut |_| Ok(()), - )?; - Ok(Transcript { - index: transcript.index, - blake2b512: encode_digest(&result.digest), - }) - }) - .collect() - }; - let current = inspect(&manifest.contribution)?; - let previous = manifest.predecessor.as_ref().map(inspect).transpose()?; - manifest.contribution.transcripts = current; - if let Some(previous) = previous { - manifest.predecessor.as_mut().expect("requested predecessor").transcripts = - previous; - } - Ok(manifest) -} diff --git a/crates/proof-compression/src/kzg/srs/ignition/tests.rs b/crates/proof-compression/src/kzg/srs/ignition/tests.rs deleted file mode 100644 index 845f2076a..000000000 --- a/crates/proof-compression/src/kzg/srs/ignition/tests.rs +++ /dev/null @@ -1,608 +0,0 @@ -use super::*; -use ark_bn254::{Fq, Fq2, G2Projective}; -use ark_ec::PrimeGroup; -use std::{ - fs, - path::PathBuf, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; - -use crate::kzg::{KzgPcs, KzgTranscript, Scalar, VerifierParams}; -use multi_stark::traits::{EvaluationDomain, Pcs}; -use p3_matrix::dense::RowMajorMatrix; - -static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); -const TOTAL_POINTS: u32 = 12; -const POINTS_PER_TRANSCRIPT: u32 = 5; - -fn source_field(value: Fq) -> Vec { - value.into_bigint().0.into_iter().flat_map(u64::to_be_bytes).collect() -} - -fn source_g1(point: G1Affine) -> Vec { - [source_field(point.x), source_field(point.y)].concat() -} - -fn source_g2(point: G2Affine) -> Vec { - [point.x.c0, point.x.c1, point.y.c0, point.y.c1] - .into_iter() - .flat_map(source_field) - .collect() -} - -fn digest_text(bytes: &[u8]) -> String { - blake2b_simd::blake2b(bytes).to_hex().to_string() -} - -fn transcript_path( - root: &Path, - contribution: &Contribution, - index: u32, -) -> PathBuf { - root - .join(format!("{:03}_{}", contribution.position, contribution.address)) - .join(format!("transcript{index:02}.dat")) -} - -fn shifted_points(tau: Fr, count: usize) -> Vec { - (1..=count) - .map(|exponent| { - (G1Projective::generator() * tau.pow([exponent as u64])).into_affine() - }) - .collect() -} - -fn write_contribution( - root: &Path, - position: u32, - address: &str, - tau: Fr, - update: Fr, -) -> Contribution { - let mut contribution = Contribution { - position, - address: address.to_owned(), - transcripts: Vec::new(), - }; - let points = shifted_points(tau, TOTAL_POINTS as usize); - let count = TOTAL_POINTS.div_ceil(POINTS_PER_TRANSCRIPT); - for index in 0..count { - let start = index * POINTS_PER_TRANSCRIPT; - let length = POINTS_PER_TRANSCRIPT.min(TOTAL_POINTS - start); - let mut bytes: Vec = [ - index, - count, - TOTAL_POINTS, - 1, - length, - if index == 0 { 2 } else { 0 }, - start, - ] - .into_iter() - .flat_map(u32::to_be_bytes) - .collect(); - for point in &points[start as usize..(start + length) as usize] { - bytes.extend(source_g1(*point)); - } - if index == 0 { - for scalar in [tau, update] { - bytes.extend(source_g2( - (G2Projective::generator() * scalar).into_affine(), - )); - } - } - bytes.extend_from_slice(blake2b_simd::blake2b(&bytes).as_bytes()); - let path = transcript_path(root, &contribution, index); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, &bytes).unwrap(); - contribution - .transcripts - .push(Transcript { index, blake2b512: digest_text(&bytes) }); - } - contribution -} - -struct Fixture { - directory: PathBuf, - root: PathBuf, - manifest_path: PathBuf, - cache: PathBuf, - manifest: SourceManifest, - pin: [u8; 32], - points: Vec, -} - -impl Fixture { - fn new(with_predecessor: bool) -> Self { - let directory = std::env::temp_dir().join(format!( - "ix-ignition-import-{}-{}", - std::process::id(), - NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), - )); - fs::create_dir(&directory).unwrap(); - let root = directory.join("source"); - let previous_tau = Fr::from(7u64); - let update = Fr::from(11u64); - let tau = previous_tau * update; - let contribution = - write_contribution(&root, FINAL_POSITION, FINAL_ADDRESS, tau, update); - let predecessor = with_predecessor.then(|| { - write_contribution( - &root, - PREDECESSOR_POSITION, - PREDECESSOR_ADDRESS, - previous_tau, - previous_tau, - ) - }); - let manifest = SourceManifest { - format: SOURCE_FORMAT.to_owned(), - ceremony: "MAIN IGNITION".to_owned(), - basis: SHIFTED_TAU_BASIS.to_owned(), - num_g1_points: TOTAL_POINTS, - points_per_transcript: POINTS_PER_TRANSCRIPT, - contribution, - predecessor, - }; - let mut fixture = Self { - manifest_path: directory.join("source.json"), - cache: directory.join("cache.bin"), - directory, - root, - manifest, - pin: [0; 32], - points: shifted_points(tau, TOTAL_POINTS as usize), - }; - fixture.save_manifest(); - fixture - } - - fn save_manifest(&mut self) { - let bytes = serde_json::to_vec_pretty(&self.manifest).unwrap(); - self.pin = *blake3::hash(&bytes).as_bytes(); - fs::write(&self.manifest_path, bytes).unwrap(); - } - - fn mutate_source( - &mut self, - predecessor: bool, - index: u32, - repair_checksum: bool, - mutate: impl FnOnce(&mut Vec), - ) { - let contribution = if predecessor { - self.manifest.predecessor.as_mut().unwrap() - } else { - &mut self.manifest.contribution - }; - let path = transcript_path(&self.root, contribution, index); - let mut bytes = fs::read(&path).unwrap(); - mutate(&mut bytes); - if repair_checksum { - let end = bytes.len() - 64; - let checksum = blake2b_simd::blake2b(&bytes[..end]); - bytes[end..].copy_from_slice(checksum.as_bytes()); - } - fs::write(path, &bytes).unwrap(); - contribution.transcripts[index as usize].blake2b512 = digest_text(&bytes); - self.save_manifest(); - } - - fn import(&self, prefix_len: usize) -> io::Result { - import_ignition_transcripts( - &self.root, - &self.manifest_path, - self.pin, - &self.cache, - prefix_len, - ) - } - - fn assert_rejected(&self, prefix_len: usize, reason: &str) { - let error = self.import(prefix_len).unwrap_err(); - assert!(error.to_string().contains(reason), "unexpected error: {error}"); - assert!(!self.cache.exists()); - assert!(fs::read_dir(&self.directory).unwrap().all(|entry| { - !entry.unwrap().file_name().to_string_lossy().ends_with(".partial") - })); - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.directory); - } -} - -#[test] -fn authenticated_shifted_prefixes_preserve_public_identity_and_degree() { - let fixture = Fixture::new(true); - let receipt = fixture.import(8).unwrap(); - assert_eq!(receipt.source_manifest_digest, fixture.pin); - assert_eq!(receipt.public_setup, fixture.manifest.public_setup().unwrap()); - assert_eq!(receipt.public_setup.max_degree, TOTAL_POINTS as usize - 1); - for prefix_len in [2, 4, 8] { - let srs = - load_ignition_cache(&fixture.cache, receipt.digest, prefix_len).unwrap(); - assert_eq!(srs.g1, fixture.points[..prefix_len]); - assert_ne!(srs.g1[0], G1Affine::generator()); - assert_eq!(srs.g2, G2Affine::generator()); - assert_eq!(srs.public_setup, receipt.public_setup); - srs.validate().unwrap(); - } - let smaller_cache = fixture.directory.join("smaller.bin"); - let smaller = import_ignition_transcripts( - &fixture.root, - &fixture.manifest_path, - fixture.pin, - &smaller_cache, - 4, - ) - .unwrap(); - assert_eq!(smaller.public_setup, receipt.public_setup); - assert_ne!(smaller.digest, receipt.digest); -} - -#[test] -fn shifted_import_supports_native_kzg_openings_with_anchor_only_verifier() { - let fixture = Fixture::new(false); - let receipt = fixture.import(8).unwrap(); - let srs = load_ignition_cache(&fixture.cache, receipt.digest, 8).unwrap(); - let anchors = load_ignition_cache(&fixture.cache, receipt.digest, 2).unwrap(); - let verifier = - KzgPcs::verifier_only(VerifierParams::with_max_trace_len(&anchors, 8), 2); - let pcs = KzgPcs::new(Arc::new(srs), 2); - let domain = pcs.natural_domain_for_degree(8); - let matrix = RowMajorMatrix::new( - (0..domain.size()) - .flat_map(|i| { - [Scalar(Fr::from(7u64)), Scalar(Fr::from((i * i + 3) as u64))] - }) - .collect(), - 2, - ); - let (commitment, data) = pcs.commit(vec![(domain, matrix)]); - let point = Scalar(Fr::from(19u64)); - let (values, proof) = - pcs.open(vec![(&data, vec![vec![point]])], &mut KzgTranscript::default()); - let mut rounds = - vec![(commitment, vec![(domain, vec![(point, values[0][0][0].clone())])])]; - verifier - .verify(rounds.clone(), &proof, &mut KzgTranscript::default()) - .unwrap(); - rounds[0].1[0].1[0].1[0] += Scalar(Fr::ONE); - assert!( - verifier.verify(rounds, &proof, &mut KzgTranscript::default()).is_err() - ); -} - -#[test] -fn inventory_hashes_complete_files_and_does_not_change_setup_identity() { - let fixture = Fixture::new(true); - let inventory = inspect_ignition_source( - &fixture.root, - FINAL_POSITION, - FINAL_ADDRESS, - Some((PREDECESSOR_POSITION, PREDECESSOR_ADDRESS)), - ) - .unwrap(); - assert_eq!(inventory, fixture.manifest); - let mut without_previous = inventory.clone(); - without_previous.predecessor = None; - assert_eq!( - inventory.public_setup().unwrap(), - without_previous.public_setup().unwrap() - ); - let first = - fs::read(transcript_path(&fixture.root, &inventory.contribution, 0)) - .unwrap(); - assert_ne!( - inventory.contribution.transcripts[0].blake2b512, - digest_text(&first[..first.len() - 64]), - ); -} - -#[test] -fn source_manifest_requires_an_external_exact_byte_pin() { - let fixture = Fixture::new(false); - assert!(read_source_manifest(&fixture.manifest_path, [0; 32]).is_err()); - let mut bytes = fs::read(&fixture.manifest_path).unwrap(); - bytes.push(b'\n'); - fs::write(&fixture.manifest_path, bytes).unwrap(); - fixture.assert_rejected(8, "manifest digest mismatch"); -} - -#[test] -fn source_manifest_rejects_oversize_and_unknown_fields() { - let fixture = Fixture::new(false); - let oversized = vec![b' '; (1 << 20) + 1]; - fs::write(&fixture.manifest_path, &oversized).unwrap(); - assert!( - read_source_manifest( - &fixture.manifest_path, - *blake3::hash(&oversized).as_bytes() - ) - .is_err() - ); - let mut value = serde_json::to_value(&fixture.manifest).unwrap(); - value["unrecognized"] = serde_json::Value::Bool(true); - let bytes = serde_json::to_vec(&value).unwrap(); - fs::write(&fixture.manifest_path, &bytes).unwrap(); - assert!( - read_source_manifest( - &fixture.manifest_path, - *blake3::hash(&bytes).as_bytes() - ) - .is_err() - ); -} - -#[test] -fn descriptor_rejects_bad_geometry_paths_and_transcript_order() { - let fixture = Fixture::new(true); - let original = fixture.manifest.clone(); - let mutations: [fn(&mut SourceManifest); 8] = [ - |m| m.basis = "unshifted".to_owned(), - |m| m.num_g1_points = u32::MAX, - |m| m.points_per_transcript = 0, - |m| m.contribution.address = "../outside".to_owned(), - |m| m.contribution.position = u32::MAX, - |m| m.contribution.transcripts.swap(0, 1), - |m| m.contribution.transcripts[0].blake2b512 = "ff".to_owned(), - |m| m.predecessor.as_mut().unwrap().position = m.contribution.position, - ]; - for mutate in mutations { - let mut manifest = original.clone(); - mutate(&mut manifest); - assert!(manifest.validate().is_err()); - } -} - -#[test] -fn wrong_source_pin_in_unretained_transcript_never_publishes_cache() { - let mut fixture = Fixture::new(false); - fixture.manifest.contribution.transcripts[2].blake2b512 = "00".repeat(64); - fixture.save_manifest(); - fixture.assert_rejected(8, "source digest mismatch"); -} - -#[test] -fn embedded_checksum_is_checked_even_with_a_matching_external_file_pin() { - let mut fixture = Fixture::new(false); - fixture - .mutate_source(false, 2, false, |bytes| *bytes.last_mut().unwrap() ^= 1); - fixture.assert_rejected(8, "internal checksum mismatch"); -} - -#[test] -fn malformed_header_and_file_lengths_never_publish_cache() { - let mut fixture = Fixture::new(false); - fixture.mutate_source(false, 1, true, |bytes| bytes[27] ^= 1); - fixture.assert_rejected(8, "header mismatch"); - for extra_byte in [false, true] { - let mut fixture = Fixture::new(false); - fixture.mutate_source(false, 2, false, |bytes| { - if extra_byte { - bytes.push(0); - } else { - bytes.pop(); - } - }); - fixture.assert_rejected(8, "file length mismatch"); - } -} - -#[test] -fn wrong_successive_power_with_repaired_hashes_is_rejected() { - let mut fixture = Fixture::new(false); - let wrong = source_g1(fixture.points[6]); - fixture.mutate_source(false, 1, true, |bytes| { - bytes[28..92].copy_from_slice(&wrong) - }); - fixture.assert_rejected(8, "not one tau progression"); -} - -#[test] -fn rescaled_valid_progression_is_not_the_ignition_shifted_basis() { - let mut fixture = Fixture::new(false); - let rescaled: Vec<_> = fixture - .points - .iter() - .map(|p| (*p * Fr::from(13u64)).into_affine()) - .collect(); - for index in 0..TOTAL_POINTS.div_ceil(POINTS_PER_TRANSCRIPT) { - fixture.mutate_source(false, index, true, |bytes| { - let start = (index * POINTS_PER_TRANSCRIPT) as usize; - let count = - (TOTAL_POINTS as usize - start).min(POINTS_PER_TRANSCRIPT as usize); - for (i, point) in rescaled[start..start + count].iter().enumerate() { - bytes[28 + i * 64..28 + (i + 1) * 64] - .copy_from_slice(&source_g1(*point)); - } - }); - } - fixture.assert_rejected(8, "does not match its shifted basis"); -} - -#[test] -fn incorrect_contribution_link_is_rejected() { - let mut fixture = Fixture::new(true); - fixture.mutate_source(false, 0, true, |bytes| { - let start = 28 + POINTS_PER_TRANSCRIPT as usize * 64 + 128; - let wrong = - source_g2((G2Projective::generator() * Fr::from(13u64)).into_affine()); - bytes[start..start + 128].copy_from_slice(&wrong); - }); - fixture.assert_rejected(8, "does not follow its predecessor"); -} - -#[test] -fn predecessor_prefix_and_unused_files_are_authenticated() { - let mut fixture = Fixture::new(true); - let wrong = source_g1(G1Affine::generator()); - fixture.mutate_source(true, 1, true, |bytes| { - bytes[28..92].copy_from_slice(&wrong) - }); - fixture.assert_rejected(8, "not one tau progression"); - let mut fixture = Fixture::new(true); - fixture.manifest.predecessor.as_mut().unwrap().transcripts[2].blake2b512 = - "00".repeat(64); - fixture.save_manifest(); - fixture.assert_rejected(8, "source digest mismatch"); -} - -#[test] -fn invalid_prefixes_are_rejected_before_publication() { - let fixture = Fixture::new(false); - for prefix_len in [0, 1, 3, 16, usize::MAX] { - fixture.assert_rejected(prefix_len, "prefix length"); - } -} - -#[test] -fn destination_is_never_overwritten() { - let fixture = Fixture::new(false); - fs::write(&fixture.cache, b"existing cache").unwrap(); - assert_eq!( - fixture.import(8).unwrap_err().kind(), - io::ErrorKind::AlreadyExists - ); - assert_eq!(fs::read(&fixture.cache).unwrap(), b"existing cache"); -} - -#[test] -fn cache_pin_header_and_requested_data_are_authenticated() { - let fixture = Fixture::new(false); - let receipt = fixture.import(8).unwrap(); - assert!(load_ignition_cache(&fixture.cache, [0; 32], 8).is_err()); - assert!(load_ignition_cache(&fixture.cache, receipt.digest, 16).is_err()); - let original = fs::read(&fixture.cache).unwrap(); - let payload_offset = original.len() - 8 * 64 - 32 - 32; - let mut corrupt = original.clone(); - corrupt[payload_offset + 6 * 64] ^= 1; - fs::write(&fixture.cache, &corrupt).unwrap(); - assert!(load_ignition_cache(&fixture.cache, receipt.digest, 8).is_err()); - assert!(load_ignition_cache(&fixture.cache, receipt.digest, 4).is_err()); - let anchors = load_ignition_cache(&fixture.cache, receipt.digest, 2).unwrap(); - assert_eq!(anchors.g1, fixture.points[..2]); - for offset in [0, payload_offset - 1, original.len() - 33, original.len() - 1] - { - let mut corrupt = original.clone(); - corrupt[offset] ^= 1; - fs::write(&fixture.cache, &corrupt).unwrap(); - assert!(load_ignition_cache(&fixture.cache, receipt.digest, 2).is_err()); - } - fs::write(&fixture.cache, &original[..original.len() - 1]).unwrap(); - assert!(load_ignition_cache(&fixture.cache, receipt.digest, 2).is_err()); -} - -#[test] -fn cache_prefix_loads_skip_unrequested_chunks() { - let fixture = Fixture::new(false); - let block_points = 1 << 16; - let prefix_len = 2 * block_points; - let setup = - crate::kzg::PublicSetup { max_degree: prefix_len - 1, id: [29; 32] }; - let anchors = Anchors { - g1: [G1Affine::generator(); 2], - tau_g2: G2Affine::generator(), - contribution_g2: G2Affine::generator(), - }; - let mut writer = - cache::CacheWriter::create(&fixture.cache, prefix_len, setup, [31; 32]) - .unwrap(); - let block = vec![G1Affine::generator(); block_points]; - writer.push(&block).unwrap(); - writer.push(&block).unwrap(); - let receipt = writer.finish(&anchors).unwrap(); - let mut bytes = fs::read(&fixture.cache).unwrap(); - let payload_offset = bytes.len() - prefix_len * 64 - 2 * 32 - 32; - bytes[payload_offset + block_points * 64] ^= 1; - fs::write(&fixture.cache, bytes).unwrap(); - let loaded = load_ignition_cache(&fixture.cache, receipt.digest, 4).unwrap(); - assert_eq!(loaded.g1, vec![G1Affine::generator(); 4]); - assert_eq!(loaded.public_setup, setup); - assert!( - load_ignition_cache(&fixture.cache, receipt.digest, prefix_len).is_err() - ); -} - -#[test] -fn coordinate_decoders_match_canonical_generators_and_reject_aliases() { - let g1 = G1Affine::generator(); - let g2 = G2Affine::generator(); - let mut source_generator = [0; 64]; - source_generator[7] = 1; - source_generator[39] = 2; - assert_eq!(points::decode_source_g1(&source_generator).unwrap(), g1); - let mut cache_generator = [0; 64]; - cache_generator[0] = 1; - cache_generator[32] = 2; - assert_eq!(points::encode_g1(&g1), cache_generator); - assert_eq!(points::decode_source_g1(&source_g1(g1)).unwrap(), g1); - assert_eq!(points::decode_source_g2(&source_g2(g2)).unwrap(), g2); - assert_eq!(points::decode_g1(&points::encode_g1(&g1)).unwrap(), g1); - assert_eq!(points::decode_g2(&points::encode_g2(&g2)).unwrap(), g2); - let mut noncanonical = source_g1(g1); - let modulus: Vec<_> = - Fq::MODULUS.0.into_iter().flat_map(u64::to_be_bytes).collect(); - noncanonical[..32].copy_from_slice(&modulus); - assert!(points::decode_source_g1(&noncanonical).is_err()); - assert!(points::decode_source_g1(&[0; 64]).is_err()); - assert!(points::decode_source_g2(&[0; 128]).is_err()); - assert!(points::decode_source_g1(&[0; 63]).is_err()); - assert!(points::decode_source_g2(&[0; 129]).is_err()); - let mut swapped = source_g2(g2); - for coordinate in swapped.as_chunks_mut::<64>().0 { - coordinate.rotate_left(32); - } - assert!(points::decode_source_g2(&swapped).is_err()); -} - -#[test] -fn g2_decoder_rejects_on_curve_points_outside_the_prime_order_subgroup() { - let point = (0..100u64).find_map(|value| { - let x = Fq2::new(Fq::from(value), Fq::ONE); - let rhs = x.square() * x + ::COEFF_B; - let point = G2Affine::new_unchecked(x, rhs.sqrt()?); - (!point.is_in_correct_subgroup_assuming_on_curve()).then_some(point) - }).expect("small deterministic G2 point outside the subgroup"); - assert!(point.is_on_curve()); - assert!(points::decode_source_g2(&source_g2(point)).is_err()); -} - -#[test] -fn progression_checks_include_chunk_boundaries() { - let tau = Fr::from(7u64); - let points = shifted_points(tau, 8); - let anchors = Anchors { - g1: [points[0], points[1]], - tau_g2: (G2Projective::generator() * tau).into_affine(), - contribution_g2: G2Affine::generator(), - }; - for chunk_size in [1, 2, 3, 8] { - let mut check = ProgressionCheck::new([17; 32], points.len(), 197); - check.absorb(&[]).unwrap(); - for chunk in points.chunks(chunk_size) { - check.absorb(chunk).unwrap(); - } - check.verify(&anchors).unwrap(); - } - let mut corrupt = points; - for point in &mut corrupt[4..] { - *point = (*point * Fr::from(13u64)).into_affine(); - } - let mut check = ProgressionCheck::new([17; 32], corrupt.len(), 197); - for chunk in corrupt.chunks(4) { - check.absorb(chunk).unwrap(); - } - assert!(check.verify(&anchors).is_err()); - let mut check = ProgressionCheck::new([17; 32], 8, 197); - check.absorb(&corrupt[..2]).unwrap(); - assert!(check.verify(&anchors).is_err()); - let mut check = ProgressionCheck::new([17; 32], 2, 197); - assert!(check.absorb(&corrupt).is_err()); -} diff --git a/crates/proof-compression/src/pipeline/compress.rs b/crates/proof-compression/src/pipeline/compress.rs index 9e2d32fc2..016208586 100644 --- a/crates/proof-compression/src/pipeline/compress.rs +++ b/crates/proof-compression/src/pipeline/compress.rs @@ -507,11 +507,7 @@ fn check_final( let bytes = fs::read(dir.join("vk.bin"))?; verifying_keys::check_recursive_key(root_vk, setup, &bytes)?; let vk = wrap::VerifyingKey::from_bytes(&bytes)?; - if let Some(admission) = setup.ignition_admission() { - vk.check_ignition_admission(admission)?; - } else { - vk.check_setup_class(setup.class())?; - } + vk.check_setup_class(setup.class())?; vk.verify(&fs::read(dir.join("kzg/packet.bin"))?, words)?; verifying_keys::check_statement(&vk.id(), words) } diff --git a/crates/proof-compression/src/pipeline/derive.rs b/crates/proof-compression/src/pipeline/derive.rs index dcdb38f8a..dc332442e 100644 --- a/crates/proof-compression/src/pipeline/derive.rs +++ b/crates/proof-compression/src/pipeline/derive.rs @@ -23,10 +23,7 @@ pub fn derive_recursive_vk( first::setup(&fri, verifying_keys::FIRST_LOG_HEIGHT, setup)?; drop(fri); let vk = wrap::verifying_key(&manifest, parts, setup)?; - match setup.ignition_admission() { - Some(admission) => vk.check_ignition_admission(admission)?, - None => vk.check_setup_class(setup.class())?, - } + vk.check_setup_class(setup.class())?; vk.to_bytes() } @@ -40,7 +37,7 @@ mod tests { fn ignition() -> SetupConfig { let setup = SetupConfig::from_env(false).unwrap(); - assert!(setup.ignition_admission().is_some()); + assert_eq!(setup.class(), super::super::SetupClass::Ignition); setup } diff --git a/crates/proof-compression/src/pipeline/final_proof.rs b/crates/proof-compression/src/pipeline/final_proof.rs index b32899776..35b38a863 100644 --- a/crates/proof-compression/src/pipeline/final_proof.rs +++ b/crates/proof-compression/src/pipeline/final_proof.rs @@ -95,10 +95,7 @@ pub(super) fn description( setup: &super::SetupConfig, ) -> Result> { let vk = super::wrap::VerifyingKey::from_bytes(&fs::read(verifier)?)?; - match setup.ignition_admission() { - Some(admission) => vk.check_ignition_admission(admission)?, - None => vk.check_setup_class(setup.class())?, - } + vk.check_setup_class(setup.class())?; super::wrap::final_description(&vk, &expected[..10]) } diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 1311bbc25..a5f1e26a8 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -104,31 +104,6 @@ pub fn verify_with_vk( ) } -/// Verify with an independently authorized verifying key and Ignition admission. -/// Both outer and deferred pairing keys must match the admitted parameters. -/// No proving cache or setup environment variables are read. -pub fn verify_with_ignition_admission( - proof: &[u8], - claim: &[u8], - expected_claims: &[u8], - vk: &[u8], - trusted_vk_digest: &[u8; 32], - admission: &crate::kzg::srs::ignition::IgnitionAdmission, -) -> Result { - let words = check_claim(claim, expected_claims)?; - let packet = proof - .strip_prefix(PROOF_TAG) - .ok_or("unsupported compressed proof curve or version")?; - verifier::verify_with_ignition_admission( - packet, - claim, - &words, - vk, - trusted_vk_digest, - admission, - ) -} - fn prepare_output(out: &Path) -> Result<()> { fs::create_dir_all(out)?; if fs::read_dir(out)?.next().is_some() { diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 54d39c67f..1457a975f 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -33,17 +33,19 @@ const FINAL_SRS_SEED: &[u8] = b"ix/bn254-plonk/srs/v1"; #[cfg(feature = "plonk")] fn setup_identity(config: &SetupConfig) -> [u8; 32] { match config { - SetupConfig::Ignition { manifest, .. } => *manifest, + SetupConfig::Ignition { .. } => { + verifying_keys::ignition_parameters() + .expect("bundled Ignition parameters") + .public_setup + .id + }, SetupConfig::Development { .. } => *blake3::hash(FINAL_SRS_SEED).as_bytes(), } } #[cfg(feature = "plonk")] fn anchors(config: &SetupConfig, domain: usize) -> Result { - if let Some(admission) = config.ignition_admission() { - return Ok(admission.anchors()); - } - config.for_stage(FINAL_SRS_SEED, parameter_prefix(domain)?)?.srs(2, false) + config.for_stage(FINAL_SRS_SEED, parameter_prefix(domain)?)?.srs(2, true) } #[cfg(feature = "plonk")] @@ -194,7 +196,7 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { if Sha256::digest(verifying_keys::PLONK_VK).as_slice() != id { return Err("bundled PLONK verifying key differs from its pin".into()); } - let anchors = anchor_encoding(&verifying_keys::admission()?.anchors()); + let anchors = anchor_encoding(&verifying_keys::ignition_anchors()?); gnark::verify( verifying_keys::PLONK_VK, &id, @@ -208,7 +210,7 @@ pub(super) fn verify(packet: &[u8], claim: &[u8]) -> Result { /// Write the gnark verifier description (`vk.json`) and witness /// (`witness.json`) for the recursive KZG packet in `packet` to `out`. The /// verifying key in `verifier` must have BLAKE3 digest `trusted_digest` and -/// match `setup`'s admission or explicitly selected development setup. +/// match the released parameters or explicitly selected development setup. #[cfg(any(feature = "plonk", feature = "groth16"))] pub fn export( verifier: &Path, @@ -510,10 +512,7 @@ pub fn derive_verifying_key( cache_root: &Path, ) -> Result> { let vk = wrap::VerifyingKey::from_bytes(recursive_vk)?; - match setup.ignition_admission() { - Some(admission) => vk.check_ignition_admission(admission)?, - None => vk.check_setup_class(setup.class())?, - } + vk.check_setup_class(setup.class())?; let schema = wrap::final_description(&vk, prefix)?; let (dir, metadata) = prepare_artifacts(&schema, setup, cache_root)?; let key = fs::read(dir.join("verifying-key.bin"))?; diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 2ac3c63b1..2c034fc70 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -1,22 +1,11 @@ -//! Setup selection and binding for compression checkpoints. -//! -//! Every setup declares a public identity and full public degree. The -//! Ignition policy requires independently pinned admission -//! metadata and an authenticated prefix cache. The development policy derives a -//! known trapdoor from the stage seed, with parameters from a per-stage cache -//! file or from the shared directory named by `IX_KZG_SRS_CACHE`. -//! -//! Without explicit Ignition settings, the pinned Ignition release is used -//! when its files are present in the parameters directory (`IX_PARAMS_DIR`, -//! else `~/.ix/compression/params`), as `ix compress fetch-params` leaves -//! them. -use super::storage; -use crate::kzg::srs::ignition::{ - IgnitionAdmission, load_ignition_cache_with_identity, -}; +//! Compression uses the release's Ignition parameters or an explicitly selected +//! known-trapdoor development setup. The released verifying keys authorize the +//! production parameters; ceremony verification belongs to release preparation. +use super::{storage, verifying_keys}; +use crate::kzg::srs::gnark; use crate::kzg::{KzgConfig, PublicSetup, Srs, TranscriptKind, VerifierParams}; use multi_stark::traits::TwoAdicField; -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::{ fs, path::{Path, PathBuf}, @@ -25,64 +14,10 @@ use std::{ pub(crate) const FIRST_SEED: &[u8] = b"init-fri-kzg-ordinary-v1"; pub(crate) const BINDING_FILE: &str = "kzg-setup-id.bin"; -pub(crate) const IGNITION_CACHE_VAR: &str = "IX_KZG_IGNITION_CACHE"; -pub(crate) const IGNITION_CACHE_MANIFEST_VAR: &str = - "IX_KZG_IGNITION_CACHE_BLAKE3"; -pub(crate) const IGNITION_ADMISSION_VAR: &str = "IX_KZG_IGNITION_ADMISSION"; -pub(crate) const IGNITION_ADMISSION_DIGEST_VAR: &str = - "IX_KZG_IGNITION_ADMISSION_BLAKE3"; -const IGNITION_ENV_VARS: [&str; 4] = [ - IGNITION_CACHE_VAR, - IGNITION_CACHE_MANIFEST_VAR, - IGNITION_ADMISSION_VAR, - IGNITION_ADMISSION_DIGEST_VAR, -]; +pub(crate) const IGNITION_SRS_VAR: &str = "IX_KZG_SRS"; pub(crate) const PARAMETERS_DIR_VAR: &str = "IX_PARAMS_DIR"; - -/// Published BN254 Ignition proving parameters and their pins. The objects are -/// publicly readable from the URL `ix compress fetch-params` downloads; the -/// pins, not the location, authenticate them. -struct IgnitionRelease { - name: &'static str, - cache_file: &'static str, - cache_manifest_blake3: &'static str, - admission_file: &'static str, - admission_blake3: &'static str, -} - -const IGNITION_RELEASE: IgnitionRelease = IgnitionRelease { - name: "bn254-ignition-main-197", - cache_file: "ix-prefix-26.bin", - cache_manifest_blake3: "6b4a939d863d026ab045d3da2c8175abb9224889a58ccbf26bacb92f74248e6b", - admission_file: "admission.json", - admission_blake3: "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", -}; - -impl IgnitionRelease { - fn directory(&self, parameters: &Path) -> PathBuf { - parameters.join(self.name) - } - - fn config(&self, directory: &Path) -> storage::Result { - SetupConfig::ignition( - directory.join(self.cache_file), - parse_ignition_digest( - IGNITION_CACHE_MANIFEST_VAR, - OsStr::new(self.cache_manifest_blake3), - )?, - &directory.join(self.admission_file), - parse_ignition_digest( - IGNITION_ADMISSION_DIGEST_VAR, - OsStr::new(self.admission_blake3), - )?, - ) - } - - fn present(&self, directory: &Path) -> bool { - directory.join(self.cache_file).is_file() - && directory.join(self.admission_file).is_file() - } -} +const IGNITION_DIRECTORY: &str = "bn254-ignition-main-197"; +const IGNITION_FILE: &str = "srs.bin"; /// `IX_PARAMS_DIR`, else `$HOME/.ix/compression/params`. fn parameters_directory_from( @@ -97,8 +32,7 @@ fn parameters_directory_from( /// The trust class of verifier parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SetupClass { - /// Ignition parameters whose identity, degree and anchors match an - /// independently pinned admission of the ceremony source. + /// Ignition parameters matching the released verifying key. Ignition, /// A trapdoor derived from a seed; anyone can forge proofs. Development, @@ -107,18 +41,15 @@ pub enum SetupClass { } impl SetupClass { - /// Production classification requires independently admitted Ignition - /// metadata; verifier parameters alone do not establish provenance. - pub fn of(_params: &VerifierParams) -> Self { - Self::Development - } - - pub fn of_with_ignition( - params: &VerifierParams, - admission: &IgnitionAdmission, - ) -> storage::Result { - admission.check_verifier(params)?; - Ok(Self::Ignition) + /// Match the complete setup identity and anchors authorized by the release. + pub fn of(params: &VerifierParams) -> Self { + let matches = verifying_keys::ignition_parameters().is_ok_and(|release| { + params.public_setup == release.public_setup + && params.g1 == release.g1 + && params.g2 == release.g2 + && params.tau_g2 == release.tau_g2 + }); + if matches { Self::Ignition } else { Self::Development } } pub fn label(self) -> &'static str { @@ -131,7 +62,7 @@ impl SetupClass { pub fn description(self) -> &'static str { match self { - Self::Ignition => "the admitted Aztec Ignition BN254 powers of tau", + Self::Ignition => "the released Aztec Ignition BN254 powers of tau", Self::Groth16 => "an admitted BN254 Groth16 circuit-specific ceremony", Self::Development => { "a known-trapdoor development SRS (not production-secure)" @@ -141,36 +72,26 @@ impl SetupClass { } fn development_refused() -> storage::Result { - let message = format!( - "no admitted BN254 Ignition setup is configured; set \ - {IGNITION_CACHE_VAR}, {IGNITION_CACHE_MANIFEST_VAR}, \ - {IGNITION_ADMISSION_VAR} and {IGNITION_ADMISSION_DIGEST_VAR}, run \ - `ix compress fetch-params` to install the pinned release, or pass \ - --dev-kzg-srs to use the known-trapdoor development setup \ - (not production-secure)" - ); - Err(message.into()) + Err( + format!( + "no BN254 Ignition SRS is installed; run `ix compress fetch-params`, \ + set {IGNITION_SRS_VAR} to the released gnark SRS, or pass --dev-kzg-srs \ + to use the known-trapdoor development setup (not production-secure)" + ) + .into(), + ) } -/// The KZG parameters a compression proves under: admitted Ignition -/// parameters, or with `development` the known-trapdoor development setup. #[derive(Clone)] pub enum SetupConfig { #[non_exhaustive] - Ignition { - cache: PathBuf, - manifest: [u8; 32], - admission: Arc, - }, + Ignition { srs: PathBuf }, #[non_exhaustive] Development { cache_directory: Option }, } impl SetupConfig { - /// Select the setup from the `IX_KZG_IGNITION_*` variables or the - /// parameters directory, admitting Ignition parameters before returning. - /// With `development`, the development setup caches its SRS files in - /// `IX_KZG_SRS_CACHE` when set. + /// Select the release SRS or the explicitly requested development setup. pub fn from_env(development: bool) -> storage::Result { Self::from_ignition_env(development, |name| std::env::var_os(name)) } @@ -184,55 +105,16 @@ impl SetupConfig { cache_directory: lookup("IX_KZG_SRS_CACHE").map(PathBuf::from), }); } - let values = IGNITION_ENV_VARS.map(&mut lookup); - if values.iter().all(Option::is_none) { - let release = parameters_directory_from(&mut lookup) - .map(|parameters| IGNITION_RELEASE.directory(¶meters)) - .filter(|directory| IGNITION_RELEASE.present(directory)); - return match release { - Some(directory) => IGNITION_RELEASE.config(&directory), - None => development_refused(), - }; - } - let [cache, manifest, admission, admission_digest] = values; - let required = |name, value: Option| { - value.ok_or_else(|| { - format!("incomplete BN254 Ignition configuration: missing {name}") - }) + let path = lookup(IGNITION_SRS_VAR).map(PathBuf::from).or_else(|| { + parameters_directory_from(&mut lookup) + .map(|directory| directory.join(IGNITION_DIRECTORY).join(IGNITION_FILE)) + }); + let Some(srs) = path.filter(|path| path.is_file()) else { + return development_refused(); }; - let cache = PathBuf::from(required(IGNITION_CACHE_VAR, cache)?); - let manifest = parse_ignition_digest( - IGNITION_CACHE_MANIFEST_VAR, - &required(IGNITION_CACHE_MANIFEST_VAR, manifest)?, - )?; - let admission_path = - PathBuf::from(required(IGNITION_ADMISSION_VAR, admission)?); - let admission_digest = parse_ignition_digest( - IGNITION_ADMISSION_DIGEST_VAR, - &required(IGNITION_ADMISSION_DIGEST_VAR, admission_digest)?, - )?; - Self::ignition(cache, manifest, &admission_path, admission_digest) - } - - fn ignition( - cache: PathBuf, - manifest: [u8; 32], - admission_path: &Path, - admission_digest: [u8; 32], - ) -> storage::Result { - let admission = - Arc::new(IgnitionAdmission::read(admission_path, admission_digest)?); - let (anchors, identity) = - load_ignition_cache_with_identity(&cache, manifest, 2)?; - admission.check_cache(&anchors, &identity)?; - Ok(Self::Ignition { cache, manifest, admission }) - } - - pub(crate) fn ignition_admission(&self) -> Option<&IgnitionAdmission> { - match self { - Self::Ignition { admission, .. } => Some(admission), - Self::Development { .. } => None, - } + let config = Self::Ignition { srs }; + config.for_stage(FIRST_SEED, 2)?.srs(2, false)?; + Ok(config) } pub(crate) fn class(&self) -> SetupClass { @@ -254,9 +136,8 @@ impl SetupConfig { return Err("invalid trace height".into()); } match self { - Self::Ignition { cache, manifest, admission } => { - let source = - SetupSource::ignition(cache.clone(), *manifest, admission.clone()); + Self::Ignition { srs } => { + let source = SetupSource::ignition(srs.clone())?; source.check_height(height)?; Ok(source) }, @@ -273,15 +154,8 @@ impl SetupConfig { #[derive(Clone)] enum Policy { - Development { - seed: Vec, - cache_directory: Option, - }, - Ignition { - cache: PathBuf, - manifest: [u8; 32], - admission: Arc, - }, + Development { seed: Vec, cache_directory: Option }, + Ignition { srs: PathBuf }, } #[derive(Clone)] @@ -291,28 +165,6 @@ pub(crate) struct SetupSource { transcript_kind: TranscriptKind, } -fn parse_ignition_digest( - variable: &str, - value: &OsStr, -) -> storage::Result<[u8; 32]> { - let text = value - .to_str() - .ok_or_else(|| format!("{variable} must contain an ASCII BLAKE3 digest"))? - .trim(); - if text.len() != 64 || !text.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err( - format!("{variable} must be a 64-character BLAKE3 digest").into(), - ); - } - let mut digest = [0; 32]; - for (byte, pair) in digest.iter_mut().zip(text.as_bytes().as_chunks::<2>().0) - { - let pair = std::str::from_utf8(pair).expect("validated ASCII hex"); - *byte = u8::from_str_radix(pair, 16)?; - } - Ok(digest) -} - impl SetupSource { pub(crate) fn development( seed: &[u8], @@ -331,25 +183,12 @@ impl SetupSource { }) } - /// Verification uses admitted anchors directly. Proving additionally - /// authenticates the requested cache prefix and matches its provenance. - pub(crate) fn ignition( - cache: PathBuf, - manifest: [u8; 32], - admission: Arc, - ) -> Self { - Self { - public_setup: admission.public_setup(), - policy: Policy::Ignition { cache, manifest, admission }, + pub(crate) fn ignition(srs: PathBuf) -> storage::Result { + Ok(Self { + public_setup: verifying_keys::ignition_parameters()?.public_setup, + policy: Policy::Ignition { srs }, transcript_kind: TranscriptKind::Blake3, - } - } - - pub(crate) fn ignition_admission(&self) -> Option<&IgnitionAdmission> { - match &self.policy { - Policy::Ignition { admission, .. } => Some(admission), - Policy::Development { .. } => None, - } + }) } /// Bind checkpoint identity to the selected Fiat-Shamir protocol. @@ -380,7 +219,7 @@ impl SetupSource { pub(crate) fn security_description(&self) -> &'static str { match self.policy { Policy::Ignition { .. } => { - "Aztec Ignition BN254 powers of tau, with setup identity, full public degree and shifted-basis anchors authorized by an independently pinned admission. Proving prefixes are authenticated against a separate cache receipt. Ceremony provenance depends on the admission's trusted source pins.\n" + "Aztec Ignition BN254 powers of tau selected during release preparation. Verification uses the fixed release keys.\n" }, Policy::Development { .. } => { "Known-trapdoor development public-degree KZG. Not ceremony parameters. For development and testing only.\n" @@ -408,13 +247,18 @@ impl SetupSource { self.check_height(height)?; let length = if verify_only { 2 } else { height }; match &self.policy { - Policy::Ignition { admission, .. } if verify_only => { - Ok(admission.anchors()) + Policy::Ignition { .. } if verify_only => { + verifying_keys::ignition_anchors() }, - Policy::Ignition { cache, manifest, admission } => { - let (srs, identity) = - load_ignition_cache_with_identity(cache, *manifest, length)?; - admission.check_cache(&srs, &identity)?; + Policy::Ignition { srs } => { + let srs = gnark::load(srs, length, self.public_setup)?; + if SetupClass::of(&VerifierParams::from_srs(&srs)) + != SetupClass::Ignition + { + return Err( + "SRS anchors differ from the released Ignition parameters".into(), + ); + } Ok(srs) }, Policy::Development { seed, cache_directory } => { @@ -596,23 +440,6 @@ mod tests { Ok(()) } - #[test] - fn bn254_requires_an_admission_or_explicit_development_setup() - -> storage::Result<()> { - if IGNITION_ENV_VARS.iter().all(|name| std::env::var_os(name).is_none()) { - let error = SetupConfig::from_env(false).err().unwrap().to_string(); - assert!(error.contains("BN254")); - assert!(error.contains("--dev-kzg-srs")); - } - let source = SetupConfig::from_env(true)?.for_stage(FIRST_SEED, 8)?; - assert_eq!(source.class(), SetupClass::Development); - assert_eq!( - SetupClass::of(&source.verifier_params(8)?), - SetupClass::Development - ); - Ok(()) - } - #[test] fn setup_rejects_unsupported_trace_and_quotient_domains() -> storage::Result<()> { diff --git a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs index fa65a1b00..364e998dc 100644 --- a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs +++ b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs @@ -1,323 +1,74 @@ use super::*; -use crate::kzg::srs::ignition::{ - IgnitionCacheIdentity, test_admission, test_admission_cache, -}; -use std::{ - collections::BTreeMap, - sync::atomic::{AtomicU64, Ordering}, -}; - -static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); - -struct Fixture { - directory: PathBuf, - cache: PathBuf, - admission_path: PathBuf, - admission: Arc, - admission_digest: [u8; 32], - receipt: IgnitionCacheIdentity, - srs: Srs, -} - -impl Fixture { - fn new(tau: u64, marker: u8) -> Self { - let directory = std::env::temp_dir().join(format!( - "ix-ignition-setup-{}-{}", - std::process::id(), - NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed), - )); - fs::create_dir(&directory).unwrap(); - let (admission, srs) = test_admission(tau, 16, marker); - let cache = directory.join("prefix.bin"); - let receipt = test_admission_cache(&cache, &admission, &srs); - let admission_path = directory.join("admission.json"); - let bytes = admission.to_bytes().unwrap(); - fs::write(&admission_path, &bytes).unwrap(); - let admission_digest = *blake3::hash(&bytes).as_bytes(); - Self { - directory, - cache, - admission_path, - admission: Arc::new(admission), - admission_digest, - receipt, - srs, - } - } - - fn configuration(&self) -> SetupConfig { - SetupConfig::Ignition { - cache: self.cache.clone(), - manifest: self.receipt.digest, - admission: self.admission.clone(), - } - } - - fn environment(&self) -> BTreeMap<&'static str, OsString> { - BTreeMap::from([ - (IGNITION_CACHE_VAR, self.cache.as_os_str().to_owned()), - (IGNITION_CACHE_MANIFEST_VAR, digest_text(self.receipt.digest).into()), - (IGNITION_ADMISSION_VAR, self.admission_path.as_os_str().to_owned()), - ( - IGNITION_ADMISSION_DIGEST_VAR, - digest_text(self.admission_digest).into(), - ), - ]) - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.directory); - } -} - -fn digest_text(digest: [u8; 32]) -> String { - blake3::Hash::from(digest).to_hex().to_string() -} - -fn parse_environment( - values: &BTreeMap<&'static str, OsString>, -) -> storage::Result { - SetupConfig::from_ignition_env(false, |name| values.get(name).cloned()) -} #[test] -fn ignition_class_requires_admitted_identity_degree_and_every_anchor() { - let (admission, srs) = test_admission(7, 16, 1); - let params = VerifierParams::from_srs(&srs); - assert_eq!(SetupClass::of(¶ms), SetupClass::Development); - assert_eq!( - SetupClass::of_with_ignition(¶ms, &admission).unwrap(), - SetupClass::Ignition, - ); - let mut changed = Vec::new(); - let mut params_changed = params.clone(); - params_changed.public_setup.id[0] ^= 1; - changed.push(params_changed); - let mut params_changed = params.clone(); - params_changed.public_setup.max_degree += 1; - changed.push(params_changed); - for i in 0..2 { - let mut params_changed = params.clone(); - params_changed.g1[i] = -params_changed.g1[i]; - changed.push(params_changed); - } - let mut params_changed = params.clone(); - params_changed.g2 = -params_changed.g2; - changed.push(params_changed); - let mut params_changed = params.clone(); - params_changed.tau_g2 = -params_changed.tau_g2; - changed.push(params_changed); - for params in changed { - assert!(SetupClass::of_with_ignition(¶ms, &admission).is_err()); - assert_eq!(SetupClass::of(¶ms), SetupClass::Development); +fn release_parameters_fix_identity_degree_and_anchors() -> storage::Result<()> { + let release = verifying_keys::ignition_parameters()?; + assert_eq!(SetupClass::of(&release), SetupClass::Ignition); + for index in 0..6 { + let mut changed = release.clone(); + match index { + 0 => changed.public_setup.id[0] ^= 1, + 1 => changed.public_setup.max_degree += 1, + 2 => changed.g1[0] = -changed.g1[0], + 3 => changed.g1[1] = -changed.g1[1], + 4 => changed.g2 = -changed.g2, + _ => changed.tau_g2 = -changed.tau_g2, + } + assert_eq!(SetupClass::of(&changed), SetupClass::Development); } - let development = Srs::unsafe_dev_setup(16, b"admission-substitution"); - assert!( - SetupClass::of_with_ignition( - &VerifierParams::from_srs(&development), - &admission, - ) - .is_err() - ); -} - -#[test] -fn ignition_prefixes_preserve_identity_and_verification_needs_no_cache() --> storage::Result<()> { - let fixture = Fixture::new(7, 2); - let config = fixture.configuration(); - assert_eq!(config.class(), SetupClass::Ignition); - assert_eq!( - config.ignition_admission().unwrap().public_setup(), - fixture.srs.public_setup, - ); - let source = config.for_stage(FIRST_SEED, 8)?; + let source = SetupSource::ignition(PathBuf::from("absent-srs.bin"))?; assert_eq!(source.class(), SetupClass::Ignition); - assert_eq!( - source.ignition_admission().unwrap().public_setup(), - fixture.srs.public_setup, - ); - let prefix = source.srs(8, false)?; - assert_eq!(prefix.g1, fixture.srs.g1[..8]); - assert_eq!(prefix.public_setup, fixture.srs.public_setup); - assert_eq!(prefix.public_setup.max_degree, 15); - assert_eq!( - source.config(8, 2, false)?.transcript_seed(), - source.config(8, 2, true)?.transcript_seed(), - ); - assert!(config.for_stage(FIRST_SEED, 32).is_err()); - assert!(source.srs(32, true).is_err()); - assert!(source.srs(3, true).is_err()); - fs::remove_file(&fixture.cache)?; - let verifier = source.srs(16, true)?; - assert_eq!(verifier.g1, fixture.srs.g1[..2]); - assert_eq!(verifier.public_setup, fixture.srs.public_setup); - assert!(source.srs(16, false).is_err()); + assert_eq!(source.srs(8, true)?.g1, release.g1); + assert!(source.srs(8, false).is_err()); Ok(()) } #[test] -fn ignition_stage_seeds_and_cache_locations_do_not_change_public_identity() --> storage::Result<()> { - let fixture = Fixture::new(7, 3); - let config = fixture.configuration(); - let first = config.for_stage(FIRST_SEED, 8)?; - let recursive = config.for_stage(b"recursive-stage", 8)?; - assert_eq!(first.identity(8, 2)?, recursive.identity(8, 2)?); - let missing = SetupSource::ignition( - fixture.directory.join("missing"), - [0; 32], - fixture.admission.clone(), - ) - .with_cache_directory(fixture.directory.join("development-cache")); - assert_eq!(first.identity(8, 2)?, missing.identity(8, 2)?); - assert!(missing.srs(8, false).is_err()); - assert!(!fixture.directory.join("development-cache").exists()); - assert_ne!(first.identity(8, 2)?, first.identity(4, 2)?); - assert_ne!(first.identity(8, 2)?, first.identity(8, 4)?); +fn release_identity_does_not_depend_on_path_or_stage() -> storage::Result<()> { + let a = SetupConfig::Ignition { srs: PathBuf::from("a.bin") } + .for_stage(FIRST_SEED, 8)?; + let b = SetupConfig::Ignition { srs: PathBuf::from("b.bin") } + .for_stage(b"other-stage", 8)?; + assert_eq!(a.identity(8, 2)?, b.identity(8, 2)?); + assert_ne!(a.identity(8, 2)?, a.identity(4, 2)?); + assert_ne!(a.identity(8, 2)?, a.identity(8, 4)?); assert_ne!( - first.identity(8, 2)?, - first.clone().with_transcript(TranscriptKind::Keccak256).identity(8, 2)?, - ); - let (other_admission, _) = test_admission(7, 16, 4); - let other = SetupSource::ignition( - fixture.cache.clone(), - fixture.receipt.digest, - Arc::new(other_admission), + a.identity(8, 2)?, + a.clone().with_transcript(TranscriptKind::Keccak256).identity(8, 2)? ); - assert_ne!(first.identity(8, 2)?, other.identity(8, 2)?); - assert!(other.srs(8, false).is_err()); Ok(()) } #[test] -fn ignition_cache_receipt_must_match_admitted_provenance() { - let fixture = Fixture::new(7, 5); - fixture.admission.check_cache(&fixture.srs, &fixture.receipt).unwrap(); - let mut changed = fixture.receipt; - changed.source_manifest_digest[0] ^= 1; - assert!(fixture.admission.check_cache(&fixture.srs, &changed).is_err()); - let mut changed = fixture.receipt; - changed.public_setup.id[0] ^= 1; - assert!(fixture.admission.check_cache(&fixture.srs, &changed).is_err()); - let mut changed = fixture.receipt; - changed.public_setup.max_degree += 1; - assert!(fixture.admission.check_cache(&fixture.srs, &changed).is_err()); - let other = Fixture::new(11, 6); - let wrong_cache = SetupSource::ignition( - other.cache.clone(), - other.receipt.digest, - fixture.admission.clone(), - ); - assert!(wrong_cache.srs(8, false).is_err()); -} - -#[test] -fn ignition_environment_requires_every_value_and_authenticates_before_stages() --> storage::Result<()> { - let fixture = Fixture::new(7, 7); - let environment = fixture.environment(); - let config = parse_environment(&environment)?; - assert_eq!(config.class(), SetupClass::Ignition); - for mask in 0usize..15 { - let partial: BTreeMap<_, _> = IGNITION_ENV_VARS - .iter() - .enumerate() - .filter(|(i, _)| mask & (1 << *i) != 0) - .map(|(_, name)| (*name, environment[*name].clone())) - .collect(); - assert!(parse_environment(&partial).is_err()); - } - for variable in [IGNITION_CACHE_MANIFEST_VAR, IGNITION_ADMISSION_DIGEST_VAR] { - for bad in ["".to_owned(), "g".repeat(64), "0".repeat(63)] { - let mut changed = environment.clone(); - changed.insert(variable, bad.into()); - assert!(parse_environment(&changed).is_err()); - } - let mut changed = environment.clone(); - changed.insert(variable, digest_text([0; 32]).into()); - assert!(parse_environment(&changed).is_err()); - } - let other = Fixture::new(11, 8); - let mut changed = environment.clone(); - changed.insert( - IGNITION_ADMISSION_VAR, - other.admission_path.as_os_str().to_owned(), - ); - changed.insert( - IGNITION_ADMISSION_DIGEST_VAR, - digest_text(other.admission_digest).into(), - ); - assert!(parse_environment(&changed).is_err()); - fs::remove_file(&fixture.cache)?; - assert!(parse_environment(&environment).is_err()); - assert!(config.for_stage(FIRST_SEED, 8)?.srs(8, true).is_ok()); - assert!(config.for_stage(FIRST_SEED, 8)?.srs(8, false).is_err()); - Ok(()) -} - -#[test] -fn explicit_development_ignores_production_configuration() -> storage::Result<()> -{ +fn missing_release_requires_explicit_development() -> storage::Result<()> { + let error = SetupConfig::from_ignition_env(false, |_| None).err().unwrap(); + assert!(error.to_string().contains("fetch-params")); let config = SetupConfig::from_ignition_env(true, |name| { assert_eq!(name, "IX_KZG_SRS_CACHE"); Some("development-cache".into()) })?; assert_eq!(config.class(), SetupClass::Development); - assert!(config.ignition_admission().is_none()); - assert!(config.for_stage(FIRST_SEED, 8)?.ignition_admission().is_none()); assert_eq!( config.for_stage(FIRST_SEED, 8)?.identity(8, 2)?, - SetupSource::development(FIRST_SEED, 7)?.identity(8, 2)?, + SetupSource::development(FIRST_SEED, 7)?.identity(8, 2)? ); Ok(()) } -#[cfg(unix)] #[test] -fn ignition_digest_parser_rejects_non_unicode_values() { - use std::os::unix::ffi::OsStringExt; - let value = OsString::from_vec(vec![0xff; 64]); - assert!(parse_ignition_digest(IGNITION_CACHE_MANIFEST_VAR, &value).is_err()); +#[ignore = "requires the released native gnark SRS under IX_KZG_SRS or IX_PARAMS_DIR"] +fn installed_gnark_srs_matches_release() -> storage::Result<()> { + let config = SetupConfig::from_env(false)?; + let source = config.for_stage(FIRST_SEED, 1024)?; + let srs = source.srs(1024, false)?; + srs.validate()?; assert_eq!( - parse_ignition_digest( - IGNITION_CACHE_MANIFEST_VAR, - OsStr::new(&"ab".repeat(32)) - ) - .unwrap(), - [0xab; 32], + SetupClass::of(&VerifierParams::from_srs(&srs)), + SetupClass::Ignition + ); + assert_eq!( + source.config(1024, 2, true)?.transcript_seed(), + source.config(1024, 2, false)?.transcript_seed() ); -} - -#[test] -fn installed_release_is_used_only_without_explicit_configuration() --> storage::Result<()> { - let fixture = Fixture::new(13, 9); - let parameters = fixture.directory.join("parameters"); - let lookup = |name: &str| { - (name == PARAMETERS_DIR_VAR).then(|| parameters.as_os_str().to_owned()) - }; - let missing = SetupConfig::from_ignition_env(false, lookup).err().unwrap(); - assert!(missing.to_string().contains("fetch-params")); - - // Files that do not match the release pins fail closed rather than falling - // back to the development refusal. - let release = IGNITION_RELEASE.directory(¶meters); - fs::create_dir_all(&release)?; - fs::copy(&fixture.cache, release.join(IGNITION_RELEASE.cache_file))?; - fs::copy( - &fixture.admission_path, - release.join(IGNITION_RELEASE.admission_file), - )?; - let mismatched = SetupConfig::from_ignition_env(false, lookup).err().unwrap(); - assert!(!mismatched.to_string().contains("fetch-params")); - - let explicit = fixture.environment(); - let config = SetupConfig::from_ignition_env(false, |name| { - explicit.get(name).cloned().or_else(|| lookup(name)) - })?; - assert_eq!(config.class(), SetupClass::Ignition); Ok(()) } diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs index c0f2c8d81..9b03ce54c 100644 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ b/crates/proof-compression/src/pipeline/verifier.rs @@ -25,25 +25,3 @@ pub(super) fn verify_with_vk( vk.verify(packet, expected)?; Ok(setup) } - -/// Verify with an independently authenticated verifying key and ceremony admission. -/// The admission contains the verifier anchors; no proving cache is required. -pub(super) fn verify_with_ignition_admission( - packet: &[u8], - claim: &[u8], - expected: &statement::Statement, - vk: &[u8], - trusted_digest: &[u8; 32], - admission: &crate::kzg::srs::ignition::IgnitionAdmission, -) -> Result { - if blake3::hash(vk).as_bytes() != trusted_digest { - return Err( - "local verifying key digest differs from the trusted digest".into(), - ); - } - statement::bind_claim(claim, expected)?; - let vk = VerifyingKey::from_bytes(vk)?; - vk.check_ignition_admission(admission)?; - vk.verify(packet, expected)?; - Ok(SetupClass::Ignition) -} diff --git a/crates/proof-compression/src/pipeline/verifying_keys.rs b/crates/proof-compression/src/pipeline/verifying_keys.rs index e98aa1b1b..ef02a8e24 100644 --- a/crates/proof-compression/src/pipeline/verifying_keys.rs +++ b/crates/proof-compression/src/pipeline/verifying_keys.rs @@ -1,14 +1,13 @@ //! The verifying keys this build compresses to and verifies, one per proof //! system, with the aggregation backend and fixed root layout they were derived -//! for and the Ignition admission that authenticates their setup. A proof -//! verifies only under these keys; there is no catalog of alternatives. +//! for. A proof verifies only under these keys; there is no catalog of alternatives. use super::{ FixedRoot, Result, setup::SetupClass, statement, wrap::{PreparedVerifyingKey, VerifyingKey}, }; -use crate::kzg::srs::ignition::IgnitionAdmission; +use crate::kzg::{Srs, VerifierParams}; /// Version of the bundled verifying keys and the layout they were derived for. const VERSION: &str = "0.1"; @@ -28,11 +27,6 @@ pub(super) const STATEMENT_PREFIX: [u64; 10] = [ 4052325645, 2284798839, 2062413424, ]; -const ADMISSION: &[u8] = - include_bytes!("../../verifying-keys/0.1/ignition-admission.json"); -const ADMISSION_BLAKE3: &str = - "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46"; - const RECURSIVE_VK: &[u8] = include_bytes!("../../verifying-keys/0.1/recursive-vk.bin"); const RECURSIVE_VK_BLAKE3: &str = @@ -92,13 +86,31 @@ pub fn release_root_layout(root_vk: &[u8]) -> Result<&'static str> { Ok(LAYOUT) } -/// The Ignition admission that authenticates the release keys' setup. -pub(super) fn admission() -> Result { - Ok(IgnitionAdmission::from_bytes(ADMISSION, decode(ADMISSION_BLAKE3)?)?) +/// The release key fixes the Ignition identity, public degree and anchors. +pub(crate) fn ignition_parameters() -> Result { + // Only the parameter section is needed here, not a prepared circuit verifier. + let (vk, consumed): (VerifyingKey, usize) = + bincode::serde::decode_from_slice( + RECURSIVE_VK, + bincode::config::standard(), + )?; + if consumed != RECURSIVE_VK.len() { + return Err("trailing bundled recursive key data".into()); + } + vk.verifier_params() +} + +pub(super) fn ignition_anchors() -> Result { + let params = ignition_parameters()?; + Ok(Srs { + g1: params.g1.to_vec(), + g2: params.g2, + tau_g2: params.tau_g2, + public_setup: params.public_setup, + }) } -/// The bundled recursive key's prepared verifier, after checking the key -/// against its pins and the admission. +/// The bundled recursive key's prepared verifier. fn recursive_vk() -> Result { if blake3::hash(RECURSIVE_VK).to_hex().as_str() != RECURSIVE_VK_BLAKE3 { return Err("bundled recursive verifying key differs from its pin".into()); @@ -107,7 +119,7 @@ fn recursive_vk() -> Result { if vk.id() != decode(RECURSIVE_VK_ID)? { return Err("bundled recursive verifying key identifier differs".into()); } - vk.check_ignition_admission(&admission()?)?; + vk.check_setup_class(SetupClass::Ignition)?; Ok(prepared) } @@ -173,10 +185,6 @@ mod tests { #[test] fn bundled_release_files_match_their_pins() { - assert_eq!( - blake3::hash(ADMISSION).as_bytes(), - &decode(ADMISSION_BLAKE3).unwrap() - ); recursive_vk().unwrap(); assert_eq!( Sha256::digest(PLONK_VK).as_slice(), diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index 575026961..f9982baba 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -80,11 +80,14 @@ impl Frontend { let setup = config .for_stage(OUTER_SEED, self.compiled.main_height())? .with_transcript(TranscriptKind::Blake3); - if let Some(admission) = setup.ignition_admission() - && decode_pairing_keys(&self.pairings)? != admission.pairing_keys() + if setup.class() == SetupClass::Ignition + && decode_pairing_keys(&self.pairings)? != { + let parameters = setup.verifier_params(2)?; + [parameters.g2, parameters.tau_g2] + } { return Err( - "recursive inner pairing anchors differ from Ignition admission".into(), + "recursive inner pairing anchors differ from the released Ignition parameters".into(), ); } Ok(setup) @@ -524,25 +527,13 @@ impl VerifyingKey { &self, expected: SetupClass, ) -> PipelineResult<()> { - if expected == SetupClass::Ignition { - return Err( - "Ignition verification requires an explicit trusted admission".into(), - ); - } let parameters = self.verifier_params()?; if SetupClass::of(¶meters) != expected { - return Err("bundled verifier setup class differs".into()); + return Err("verifier setup class differs".into()); } - Ok(()) - } - - /// Admit both opening equations against independently pinned ceremony data. - pub(crate) fn check_ignition_admission( - &self, - admission: &crate::kzg::srs::ignition::IgnitionAdmission, - ) -> PipelineResult<()> { - SetupClass::of_with_ignition(&self.verifier_params()?, admission)?; - if self.keys()? != admission.pairing_keys() { + if expected == SetupClass::Ignition + && self.keys()? != [parameters.g2, parameters.tau_g2] + { return Err( "Ignition verifier has different inner pairing anchors".into(), ); @@ -673,13 +664,10 @@ mod tests { }; use std::sync::Arc; - fn ignition_vk_fixture() -> storage::Result<( - crate::kzg::srs::ignition::IgnitionAdmission, - VerifyingKey, - Vec, - )> { - use crate::kzg::{VerifierParams, srs::ignition::test_admission}; - let (admission, srs) = test_admission(7, 256, 41); + fn recursive_vk_fixture() -> storage::Result<(VerifyingKey, Vec)> { + use crate::kzg::VerifierParams; + let srs = Srs::unsafe_dev_setup(256, b"recursive-verifier-fixture"); + let pairing_keys = [srs.g2, srs.tau_g2]; let claims = init_packet_claims(&srs); let publics = staged_publics(&claims, 2)?; let mut builder = CircuitBuilder::new(); @@ -717,7 +705,7 @@ mod tests { )?) }; let mut keys = Vec::new(); - admission.pairing_keys().to_vec().serialize_compressed(&mut keys)?; + pairing_keys.to_vec().serialize_compressed(&mut keys)?; let vk = VerifyingKey { version: OUTER_VK_VERSION, heights: [ @@ -737,14 +725,14 @@ mod tests { } packet.extend(pairing_bytes(&publics[18..])?); packet.extend(&proved.bytes); - Ok((admission, vk, packet)) + Ok((vk, packet)) } #[test] #[cfg(any(feature = "plonk", feature = "groth16"))] fn plonk_recursive_export_includes_anchor_and_deferred_publics() -> storage::Result<()> { - let (_, vk, packet) = ignition_vk_fixture()?; + let (vk, packet) = recursive_vk_fixture()?; let expected = init_claim::INIT_PUBLIC_WORDS; let directory = std::env::temp_dir() .join(format!("plonk-recursive-claims-{}", std::process::id())); @@ -779,11 +767,7 @@ mod tests { #[test] fn ignition_staging_rejects_other_inner_keys_before_creating_files() -> storage::Result<()> { - use crate::{ - kzg::srs::ignition::test_admission, - pipeline::{StageConfig, setup::SetupConfig}, - }; - let (admission, _) = test_admission(7, 256, 44); + use crate::pipeline::{StageConfig, setup::SetupConfig}; let inner = Srs::unsafe_dev_setup(2, b"ignition-stage-development-inner"); let claims = init_packet_claims(&inner); let mut builder = CircuitBuilder::new(); @@ -800,18 +784,14 @@ mod tests { let frontend = Frontend::new( circuit, &[inner.g2, inner.tau_g2], - b"ignition-stage-admission", + b"ignition-stage-anchors", 128, )?; let root = std::env::temp_dir() - .join(format!("ignition-stage-admission-{}", std::process::id(),)); + .join(format!("ignition-stage-anchors-{}", std::process::id(),)); assert!(!root.exists()); let config = StageConfig { - setup: SetupConfig::Ignition { - cache: root.join("absent-cache.bin"), - manifest: [0; 32], - admission: Arc::new(admission), - }, + setup: SetupConfig::Ignition { srs: root.join("absent-srs.bin") }, fixed_cache_root: None, prefetch_bytes: 0, }; @@ -824,92 +804,49 @@ mod tests { } #[test] - fn ignition_admission_authenticates_both_openings_without_a_cache() + fn pinned_recursive_key_binds_claim_and_rejects_tampering() -> storage::Result<()> { - use super::super::super::verifier::verify_with_ignition_admission; - use crate::kzg::{ - VerifierParams, - srs::ignition::{IgnitionAdmission, test_admission}, - }; - let (admission, vk, packet) = ignition_vk_fixture()?; + use super::super::super::verifier::verify_with_vk; + let (vk, packet) = recursive_vk_fixture()?; let claim = include_bytes!("../../../tests/fixtures/init-fri/claim.bin"); let expected = init_claim::INIT_PUBLIC_WORDS; - let portable = - bincode::serde::encode_to_vec(&vk, bincode::config::standard())?; + let portable = vk.to_bytes()?; let trusted = *blake3::hash(&portable).as_bytes(); - let verify = |packet: &[u8], - claim: &[u8], - expected: &_, - trusted: &_, - admission: &IgnitionAdmission| { - verify_with_ignition_admission( - packet, claim, expected, &portable, trusted, admission, - ) - }; + let verify = + |packet: &[u8], claim: &[u8], expected: &_, pin: &_, development| { + verify_with_vk(packet, claim, expected, &portable, pin, development) + }; assert_eq!( - verify(&packet, claim, &expected, &trusted, &admission)?, - SetupClass::Ignition - ); - assert!(vk.check_setup_class(SetupClass::Ignition).is_err()); - assert_eq!(SetupClass::of(&vk.verifier_params()?), SetupClass::Development); - assert!(verify(&packet, claim, &expected, &[0; 32], &admission).is_err()); - assert!( - verify(&packet, b"wrong", &expected, &trusted, &admission).is_err() + verify(&packet, claim, &expected, &trusted, true)?, + SetupClass::Development ); + assert!(verify(&packet, claim, &expected, &trusted, false).is_err()); + assert!(verify(&packet, claim, &expected, &[0; 32], true).is_err()); + assert!(verify(&packet, b"wrong", &expected, &trusted, true).is_err()); let mut wrong_statement = expected; wrong_statement[0] ^= 1; - assert!( - verify(&packet, claim, &wrong_statement, &trusted, &admission).is_err() - ); - let mut bad_packet = packet.clone(); + assert!(verify(&packet, claim, &wrong_statement, &trusted, true).is_err()); + let mut bad_packet = packet; *bad_packet.last_mut().unwrap() ^= 1; - assert!( - verify(&bad_packet, claim, &expected, &trusted, &admission).is_err() - ); - for (tau, count, marker) in [(11, 256, 41), (7, 512, 41), (7, 256, 42)] { - let (other, _) = test_admission(tau, count, marker); - assert!(verify(&packet, claim, &expected, &trusted, &other).is_err()); - } - for change in [0, 1, 2] { - let mut changed: VerifyingKey = decode(&portable)?; - let mut parameters = changed.verifier_params()?; - match change { - 0 => parameters.public_setup.id[0] ^= 1, - 1 => parameters.public_setup.max_degree += 1, - 2 => { - let (_, other) = test_admission(11, 256, 41); - parameters = VerifierParams::with_max_trace_len( - &other, - parameters.max_trace_len(), - ); - }, - _ => unreachable!(), - } - changed.parameters = parameters.to_bytes()?; - assert!(changed.check_ignition_admission(&admission).is_err()); - } - let mut mixed: VerifyingKey = decode(&portable)?; - let development = Srs::unsafe_dev_setup(2, b"ignition-mixed-inner"); + assert!(verify(&bad_packet, claim, &expected, &trusted, true).is_err()); + Ok(()) + } + + #[test] + fn ignition_verifier_rejects_substituted_inner_pairing_keys() + -> storage::Result<()> { + let mut vk = VerifyingKey::from_bytes(include_bytes!( + "../../../verifying-keys/0.1/recursive-vk.bin" + ))?; + vk.check_setup_class(SetupClass::Ignition)?; + let development = Srs::unsafe_dev_setup(2, b"substituted-inner-keys"); let mut keys = Vec::new(); vec![development.g2, development.tau_g2].serialize_compressed(&mut keys)?; - mixed.pairings = bincode::serde::encode_to_vec( + vk.pairings = bincode::serde::encode_to_vec( Pairings { keys }, bincode::config::standard(), )?; - assert!( - mixed - .check_ignition_admission(&admission) - .unwrap_err() - .to_string() - .contains("inner pairing anchors") - ); - let mut development_vk: VerifyingKey = decode(&portable)?; - let development = - Srs::unsafe_dev_setup_prefix(2, 255, b"ignition-existing-development-vk"); - development_vk.parameters = - VerifierParams::with_max_trace_len(&development, vk.heights[0]) - .to_bytes()?; - assert!(development_vk.check_ignition_admission(&admission).is_err()); + assert!(vk.check_setup_class(SetupClass::Ignition).is_err()); Ok(()) } diff --git a/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs b/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs index 93057e069..4207c9ce1 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs @@ -44,10 +44,7 @@ pub(in crate::pipeline) fn export( return Err("trusted verifier digest differs".into()); } let vk = VerifyingKey::from_bytes(&bytes)?; - match setup.ignition_admission() { - Some(admission) => vk.check_ignition_admission(admission)?, - None => vk.check_setup_class(setup.class())?, - } + vk.check_setup_class(setup.class())?; vk.verify(&fs::read(packet)?, expected)?; write_export(&vk, &fs::read(packet)?, expected, directory, "witness", true) } diff --git a/crates/proof-compression/verifying-keys/0.1/ignition-admission.json b/crates/proof-compression/verifying-keys/0.1/ignition-admission.json deleted file mode 100644 index 96e05ff34..000000000 --- a/crates/proof-compression/verifying-keys/0.1/ignition-admission.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "format": "ix-bn254-ignition-admission-v1", - "curve": "bn254", - "basis": "shifted-tau", - "source_manifest_json": "{\n \"format\": \"ix-bn254-ignition-source-v1\",\n \"ceremony\": \"MAIN IGNITION\",\n \"basis\": \"shifted-tau\",\n \"num_g1_points\": 100800000,\n \"points_per_transcript\": 5040000,\n \"contribution\": {\n \"position\": 197,\n \"address\": \"0xa57ec000b7fb9ea9b0119efdee0b8f15f895c2f3\",\n \"transcripts\": [\n {\n \"index\": 0,\n \"blake2b512\": \"f6d1c6684b15a00561a29188d1fbdc402658500380ce701b88e2b579f779f122fbafc3ec36127419aec04fc38d7d7d2d113efe1c31b6b6b50eef9ed685039220\"\n },\n {\n \"index\": 1,\n \"blake2b512\": \"8f60a31a3ad5547833496146cc00586a1bab93e090a03a21316e0e2fcc624002a35c4cc24ef4ab0bd6a8a1b0ddfc7296cb8f573bc9dd9959e3f13474dc44998d\"\n },\n {\n \"index\": 2,\n \"blake2b512\": \"5b7cf4a2578fa5fc8f9ba56b3c83aab597a4ed499201918f6cff61f07cc656af884c57b6a7ac59749cdb11093cba07bb52c495fc4fc5f7b3ff5073f8782ede03\"\n },\n {\n \"index\": 3,\n \"blake2b512\": \"9f08f66ae4aefe2449eaeef90ec774af11802c8104a6ee8e460ddaee248c362d4b903bb309265cf4f7a5169b69f991e3a791a84ede41511af5b3bbb9f18f7bd4\"\n },\n {\n \"index\": 4,\n \"blake2b512\": \"ac84b799256ac212396d774b30d1278e96c45a35d3ac73c95f4085784fe7d95067523fae4de59a134aa094b84e628ca6fea9a3f22a6cbdd9849ab05c7c4e02c1\"\n },\n {\n \"index\": 5,\n \"blake2b512\": \"7e75e6f69b9f4cc4a24906967c881135d283ac648cb909978c83b03b9045821cb95c1d3018138ce77800df56e9be0d9c08e7ba4e0abb9c8b0ed043b08869812f\"\n },\n {\n \"index\": 6,\n \"blake2b512\": \"7fae09c4a5e34a7d44c061140553bbe22611c46e504faa0ec679679360d84cf246d8b676d5841bde2e6fdddb8b48ae8daee1f21949f9ae4a7be5d049d9308b03\"\n },\n {\n \"index\": 7,\n \"blake2b512\": \"6be79b1c950c8c991cf56fd89cbdf47d74f7aedc2399f1868feed479fba49481505154a488ddb52acf8781de0281de5aaf1b578d10d411dd2c46a17c98553ec4\"\n },\n {\n \"index\": 8,\n \"blake2b512\": \"0d6dcc77d791e42c979101fd66af3057c422d58ef4368ac4951e5c8c0cb771b868cc126a0720c0fe78b190435e41fa266f310876de9a1736c80d1a402bf480fa\"\n },\n {\n \"index\": 9,\n \"blake2b512\": \"9c065eb494bb6a404abea40e5e1a1cf5abaace3a7f8007aacede7f50bfb0fbc87ae815c2e08eb830f8ccc31ac3f34cde3865787ef9c62fbf797585f5929cd465\"\n },\n {\n \"index\": 10,\n \"blake2b512\": \"8bf321ebfb0b3a83d2e969dca772f3bcdd9c46997c47f8d2272d149358b56c8d5492204ced6da726cb129de260f254627db8d2747f0dd57c61edf4752135fee6\"\n },\n {\n \"index\": 11,\n \"blake2b512\": \"a59696e2a820bf3c91df9681a1bc40a6362470b57121392b861f04ff17dc1d598b0b18df05ddba2b086e7bef45b826c47c93086a8fe0db2d23391545b62a9fde\"\n },\n {\n \"index\": 12,\n \"blake2b512\": \"287fc8164806b875fa727f71fc64cabaffebefc85bae5e7062541ef4f6e22594c63e3c7170d8743db686c4f6a589e136ed82dff9b56a424562fc140bc03dece1\"\n },\n {\n \"index\": 13,\n \"blake2b512\": \"c00b9c2307322018315468c743fb4d03d89405668ff9bb849e9ef2c3831969c5fee77df6985465363b026016144a7def4c1b81d036704a1908eaa5ead4f8b8ef\"\n },\n {\n \"index\": 14,\n \"blake2b512\": \"92fcdc9fb37978fb610075e53cef302ad06dd435c8e1145b06ac040202783c8a8e2d301c9bf436ec44244025109d332aeaed2dfe18b448b830ada9275f595a8f\"\n },\n {\n \"index\": 15,\n \"blake2b512\": \"f78e9baeaccea601a78ced5bf92bbc641e49db1ae8a2b05e9cd8bc9bfe1db9cae98d9045ec7eda06554bac26ad649fd03365ac9b6ca13c155299f920cb3c2dff\"\n },\n {\n \"index\": 16,\n \"blake2b512\": \"9f606fa240ff8016b4c94414fcc85114f55537eaa030d6d81758e687cb1b1cf2ecc72cfafd712f7c2349167df7257547699595c9a7ecb95f32f4271f3453bc8f\"\n },\n {\n \"index\": 17,\n \"blake2b512\": \"0a9c105133e5a519a2aa5946796079881578c9b976abc820f7d72b0de984209c1735c48615580fe7d1cf7717da585f54dcf4642daec96f70614e23094cdb5c3b\"\n },\n {\n \"index\": 18,\n \"blake2b512\": \"b929ce9111a26606e8bb178d8d3652c3d7ae359f39aa77b14659bbd4b0321d284de13e41a549a905e6aa7756320bb101626154cc61e97bd15c3419b93e5f0f7f\"\n },\n {\n \"index\": 19,\n \"blake2b512\": \"15b41e0e6bd6720c7ae1d6e2f9cd0c40aa92e861ba66847cb53e0a0c4d46c90444a5fa21d28c16883f11dd4f0bc064957b84c940a95ad9074fc49e0dba164e27\"\n }\n ]\n },\n \"predecessor\": {\n \"position\": 196,\n \"address\": \"0x9c3f372a2aacc0d7272f56a4664311b7647b0031\",\n \"transcripts\": [\n {\n \"index\": 0,\n \"blake2b512\": \"d04b32d468c7e0a6cab0427158e3029ec24b9a9282f0ab625af0ba858be70014f3e531a4ce70a17196bd2b542ea9c3614282e19daa39015430d631b908240837\"\n },\n {\n \"index\": 1,\n \"blake2b512\": \"7c1620547a1c6ca4615e3b7136195f39e00dbc005c71de719bac5687330e6cee052a5ade81984f9e66415ff2c56ce320f06580732715b953f9495f737bc79032\"\n },\n {\n \"index\": 2,\n \"blake2b512\": \"1d7eea228a6932522a764b0d5a3ef0f1ae0ae5eb77303f29530f97d768fb738cd7aea55ee3344779230a7788aabfd89b9e733dd9a07922d6552d69d1a4c09e47\"\n },\n {\n \"index\": 3,\n \"blake2b512\": \"0d4df161fb6d2b44ef7ab8b653b2c1be05fac1c8b7e782e19b1ae7b00bcde65aa1d50247d6b45baa03a516adf2395d0dac21233ddaee65f156ea2e77b21424e3\"\n },\n {\n \"index\": 4,\n \"blake2b512\": \"c5d597835e04830d2b99bc35f83159075b56f5c4484b5e6628f2de80687e5f7d1dbc8732f1cf0b1089aafd24d67e1a0e29ecd029a3d0fcb7bfe1799281303626\"\n },\n {\n \"index\": 5,\n \"blake2b512\": \"f2d808de7a73983807b3c29a049731d2089e705af38120ec50a6dd084ced4481e0f9b4390f8ecf95f68ba05efd17c7ba34d2c76f2589b98c13b7de6f330c6f08\"\n },\n {\n \"index\": 6,\n \"blake2b512\": \"549591ad23c3a76836a087210ae59d41910a66019931f7abad0da5e414dfbf7e9e7aac7d5dc326a40a79349c67a40df24ed0f5e6cc169c39b08ae15890d31fcd\"\n },\n {\n \"index\": 7,\n \"blake2b512\": \"64ba6db639fa230e3633b9757342ce7708b5d9a4dd0b2ec01f00d8c3c0ef31875ce72f58e1f029791bba6bb22bffacca6507d55f291a7c866f0e63d23acc694c\"\n },\n {\n \"index\": 8,\n \"blake2b512\": \"c16ca43cb959ece5633efb272b7935354616aae04c395d1e4dd31afa918efd09d6c9d2d4eaae21d5d854d4295690d16703f3285f471673aca03e77c0cfc38264\"\n },\n {\n \"index\": 9,\n \"blake2b512\": \"c7afc7a3c00c7cd6b5fbc840d03071d7a0bc7bb023165249d680ca7b5404da02a288725c5958b425355c734d5c0912ec627f78c372d11a821ffba3eb75f248d9\"\n },\n {\n \"index\": 10,\n \"blake2b512\": \"8a79bf43e5b74b64b3915fb4803975d307b9c43509c68e049b4152ac1d1a6cf66105c2a733e045fbe7523e6c9cedeb909483d94943f0ce2f64478bac5d836c50\"\n },\n {\n \"index\": 11,\n \"blake2b512\": \"f2620927bc74a730c789223797a146fb74715218cb3a5789a53452ea77445a8e57efb121d5a9186d38d3c3a0ea6df028b4b5544e2359f24de541c409ae004437\"\n },\n {\n \"index\": 12,\n \"blake2b512\": \"162be8140b183c4173f3975bca2ace61fac3deec35387aea375ad88c3b4bb4f50278c7d67830a4fe59c69bcb020d17ea843063648a557595b699b5bb90e78ac4\"\n },\n {\n \"index\": 13,\n \"blake2b512\": \"7bfe0c7bfe09b6be6f69009f6ade7f0681fe930e12f366d0815c8da72afd80648bdcdec205d8db07ffe6201a630f6617a05fda76a4012a53b27393e946b7912a\"\n },\n {\n \"index\": 14,\n \"blake2b512\": \"379d31bcd33066c7882c779ce681507a0557a2c2af2dcabcf7690f35b118c0cd3c78e3719ead78097a8e33eeedd288cc2fd7d34178a26d993a399e8c4fec672c\"\n },\n {\n \"index\": 15,\n \"blake2b512\": \"6cd6c0f637b2eab63b8561df5e2b788f718dee6e72988fa58b85dfdab258313b10786015a626cfdc2022d1ea655b9ce97b807a1244c8d7f48625a9175d8d1f71\"\n },\n {\n \"index\": 16,\n \"blake2b512\": \"d53af0d3a4a72c7684fc765a63f294bf5a702b09b98a946865074ef1fe515122cb84b9274a4123f79c4f1febfed0fb37e56449b76323546ff82074a76f9b4c24\"\n },\n {\n \"index\": 17,\n \"blake2b512\": \"ca28272c56eef7ca0207cfee6d7480d3116e329434eb9763781d47fe3ee98b25046b1d8048178c3a9f899c0fb559ac920a20554bc22a68d90aace23b8b13ad1b\"\n },\n {\n \"index\": 18,\n \"blake2b512\": \"06046030fac8f719d9faa027baf2871df744015b742098787e51f716cbe866187eabf9edb60cee76897f7132e4029aaf7b7849dec13fbebda2a8e99a318bad27\"\n },\n {\n \"index\": 19,\n \"blake2b512\": \"26a264a78adc010f54f0b8a6321e389472922c91dd7e62938ece68576d18a8f26870aa1d8552a261034748dfe1c9c6b053723a5dc0f50d754edf611fa29226de\"\n }\n ]\n }\n}\n", - "source_manifest_blake3": "7f89c8c8c9dd94178599071752162d9e90ca0445dbd437c931e989a5cc43a17b", - "setup_id": "07f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b", - "public_max_degree": 100799999, - "g1": [ - "3bb0c91a284ca49f0e31d84e308b6399ab4f55639b05d574e6f7e7b593bea41f21b789c3e63737fb8ff4a5791e24f36011d31fe7a5b53c9de32862ac7fae011a", - "098493b799904a0b1919123a0c9434d2c133c78e655f1588a6a8c2faf1eb681f8c97139678ee78bebd56d1dbfe61a0b459fc2e3aa0f06f55ca74f0604a87fe20" - ], - "g2": "edf692d95cbdde46ddda5ef7d422436779445c5e66006a42761e1f12efde0018c212f3aeb785e49712e7a9353349aaf1255dfb31b7bf60723a480d9293938e19aa7dfa6601cce64c7bd3430c69e7d1e38f40cb8d8071ab4aeb6d8cdba55ec8125b9722d1dcdaac55f38eb37033314bbc95330c69ad999eec75f05f58d0890609", - "tau_g2": "cbe108231cc23c1041b9f6f3c615a5ae9e425217ae4231d70eedc201ad4f342a484a95db8d70c3dc4f502412ebf3f9cde76558f30a43c2608750c403bbacf122b328d48c25f84c030d3f29c02d53d1baacc2877520c2cb3c0d8d6f7bac30fd0ef8c34548964e460bd8733419e542714c0d0181f96832aa49849cba42b8159f15" -} diff --git a/flake.nix b/flake.nix index 256c96a75..1fbb84797 100644 --- a/flake.nix +++ b/flake.nix @@ -137,7 +137,7 @@ root = ./tools/plonk-wrapper; fileset = plonkSources; }; - vendorHash = "sha256-RJNclxTYY6UQBG6f7Kwt0XspL7DZruLdsYKubHlUOvg="; + vendorHash = "sha256-JZUh5bcjyiNmCiqc0jer2uiyhvBkx0G9A+p+caoEjZ4="; subPackages = [ "." ]; buildPhase = "runHook preBuild; runHook postBuild"; installPhase = '' diff --git a/tools/plonk-wrapper/README.md b/tools/plonk-wrapper/README.md index a23f6165b..71e133494 100644 --- a/tools/plonk-wrapper/README.md +++ b/tools/plonk-wrapper/README.md @@ -23,7 +23,7 @@ compiler, but no CUDA toolkit: IX_PLONK=1 IX_GROTH16=1 lake build ix ``` -Configure the four Ignition environment variables from the +Install the released Ignition SRS as described in the [compression setup guide](../../crates/proof-compression/README.md), then run: ```sh @@ -77,17 +77,17 @@ records why the backend uses the upstream interface without fused extensions. ## Trust and artifacts Rust owns artifact and Lagrange-cache metadata. Circuit caches bind the cache -format, verifier description, admitted setup and gnark's `go.sum`. Lagrange caches bind the setup, +format, verifier description, released setup and gnark's `go.sum`. Lagrange caches bind the setup, domain and monomial receipt. Go checks the sizes and digests of decoded native artifacts and rejects a witness whose claim differs from the independent digest supplied by Rust. Retaining a prover avoids loading its circuit and keys again. -Default verification uses the bundled PLONK verifying key and Ignition admission +Default verification uses the bundled PLONK verifying key and its fixed Ignition anchors (`crates/proof-compression/verifying-keys/`); a proof whose key identifier names any other key is rejected. Verification requires neither a proving SRS nor a GPU. -Release tooling is library code with no CLI: `pipeline::plonk::export` writes +Circuit release tooling is library code: `pipeline::plonk::export` writes the verifier description and witness for a recursive KZG packet, and `pipeline::plonk::export_parameters` writes the selected final setup's SRS prefix. Default `ix verify` uses the release keys; local final keys require an explicitly @@ -170,7 +170,7 @@ with `pipeline::groth16::compile`, then create development keys with The exporter takes the BLAKE3 digest that authenticates the serialized KZG verifier file and the independently expected statement. It checks the selected -setup's Ignition admission or development class and verifies the inner proof. +setup's released Ignition anchors or development class and verifies the inner proof. Compilation writes a fresh circuit into an empty artifact directory. @@ -205,7 +205,7 @@ requires neither proving parameters nor a visible GPU. ### Ceremony verification and key import Production keys require a Groth16-compatible phase-one setup and a -circuit-specific phase two. Ignition's KZG cache is not a Groth16 setup. +circuit-specific phase two. Ignition's KZG SRS is not a Groth16 setup. `pipeline::groth16::setup_mpc(artifacts, ceremony, trusted_sha256)` verifies each phase-one and phase-two contribution against its predecessor, binds phase two diff --git a/tools/plonk-wrapper/cmd/ignition/main.go b/tools/plonk-wrapper/cmd/ignition/main.go new file mode 100644 index 000000000..6b30db82e --- /dev/null +++ b/tools/plonk-wrapper/cmd/ignition/main.go @@ -0,0 +1,109 @@ +// Command ignition verifies the final two Aztec Ignition contributions and +// writes a gnark BN254 SRS for release preparation. +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + "github.com/consensys/gnark-ignition-verifier/ignition" +) + +// The Ix mirror of the MAIN IGNITION manifest and its final two +// contributions, byte-identical to Aztec's originals. +const ( + mirrorURL = "https://argument-kzg-params-063002298335-us-east-1-an.s3.us-east-1.amazonaws.com/bn254/ignition/" + mirrorCeremony = "main-197" +) + +func main() { + baseURL := flag.String("base-url", mirrorURL, "ceremony host; https://aztec-ignition.s3.amazonaws.com/ with -ceremony \"MAIN IGNITION\" reads Aztec's originals") + ceremony := flag.String("ceremony", mirrorCeremony, "ceremony directory under the base URL") + cache := flag.String("cache", "data", "directory for downloaded ceremony transcripts") + output := flag.String("out", "srs.bin", "new gnark SRS file") + powers := flag.Int("powers", 1<<26, "number of G1 powers to retain") + flag.Parse() + if err := run(*baseURL, *ceremony, *cache, *output, *powers); err != nil { + log.Fatal(err) + } +} + +func run(baseURL, ceremony, cache, output string, powers int) error { + if powers < 2 || powers > 1<<26 || powers&(powers-1) != 0 { + return fmt.Errorf("powers must be a power of two in [2, 2^26]") + } + if _, err := os.Stat(output); err == nil { + return fmt.Errorf("output already exists: %s", output) + } else if !os.IsNotExist(err) { + return err + } + config := ignition.Config{BaseURL: baseURL, Ceremony: ceremony, CacheDir: cache} + if err := os.MkdirAll(cache, 0755); err != nil { + return err + } + manifest, err := ignition.NewManifest(config) + if err != nil { + return err + } + // These are manifest indices; the corresponding ceremony positions are 196 and 197. + if len(manifest.Participants) != 176 || manifest.Participants[174].Position != 196 || manifest.Participants[175].Position != 197 || powers > manifest.NumG1Points { + return fmt.Errorf("unexpected MAIN IGNITION manifest") + } + previous := ignition.NewContribution(manifest.NumG1Points) + final := ignition.NewContribution(manifest.NumG1Points) + if err := previous.Get(manifest.Participants[174], config); err != nil { + return err + } + if err := final.Get(manifest.Participants[175], config); err != nil { + return err + } + if !final.Follows(&previous) { + return fmt.Errorf("final contribution does not follow its predecessor") + } + _, _, _, g2 := bn254.Generators() + srs := &kzg.SRS{Pk: kzg.ProvingKey{G1: final.G1[:powers]}, Vk: kzg.VerifyingKey{G1: final.G1[0], G2: [2]bn254.G2Affine{g2, final.G2[0]}}} + return writeSRS(output, srs) +} + +func writeSRS(output string, srs *kzg.SRS) error { + for i := range srs.Vk.G2 { + srs.Vk.Lines[i] = bn254.PrecomputeLines(srs.Vk.G2[i]) + } + polynomial := []fr.Element{fr.NewElement(3), fr.NewElement(5)} + commitment, err := kzg.Commit(polynomial, srs.Pk) + if err != nil { + return err + } + point := fr.NewElement(7) + proof, err := kzg.Open(polynomial, point, srs.Pk) + if err != nil { + return err + } + if err := kzg.Verify(&commitment, &proof, point, srs.Vk); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(output), ".ignition-*.bin") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + writer := bufio.NewWriterSize(file, 1<<20) + if _, err := srs.WriteRawTo(writer); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Link(file.Name(), output) +} diff --git a/tools/plonk-wrapper/cmd/ignition/main_test.go b/tools/plonk-wrapper/cmd/ignition/main_test.go new file mode 100644 index 000000000..7ccf8fed3 --- /dev/null +++ b/tools/plonk-wrapper/cmd/ignition/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/consensys/gnark-crypto/ecc/bn254/kzg" +) + +func TestNativeSRSRoundTrip(t *testing.T) { + srs, err := kzg.NewSRS(8, big.NewInt(7)) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "srs.bin") + if err := writeSRS(path, srs); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var restored kzg.SRS + if _, err := restored.ReadFrom(file); err != nil { + t.Fatal(err) + } + if len(restored.Pk.G1) != len(srs.Pk.G1) || !restored.Vk.G2[1].Equal(&srs.Vk.G2[1]) { + t.Fatal("SRS round trip differs") + } + for i := range srs.Pk.G1 { + if !restored.Pk.G1[i].Equal(&srs.Pk.G1[i]) { + t.Fatalf("power %d differs", i) + } + } + if err := writeSRS(path, srs); err == nil { + t.Fatal("replaced an existing release") + } +} diff --git a/tools/plonk-wrapper/go.mod b/tools/plonk-wrapper/go.mod index d13999f5c..0fc33e7ac 100644 --- a/tools/plonk-wrapper/go.mod +++ b/tools/plonk-wrapper/go.mod @@ -5,6 +5,7 @@ go 1.26.8 require ( github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107 github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b + github.com/consensys/gnark-ignition-verifier v0.0.0-20230527014722-10693546ab33 github.com/ingonyama-zk/icicle-gnark/v3 v3.2.2 github.com/zeebo/blake3 v0.2.4 ) diff --git a/tools/plonk-wrapper/go.sum b/tools/plonk-wrapper/go.sum index 6ab36002c..d68ab4e3e 100644 --- a/tools/plonk-wrapper/go.sum +++ b/tools/plonk-wrapper/go.sum @@ -6,6 +6,8 @@ github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107 h1:3j1bREIJfIVu github.com/consensys/gnark v0.16.5-0.20261015185324-262cf453c107/go.mod h1:Y4orPf3hT0CjoUOyjo22jJN+0TD0gD1tS4BFWL1mkVw= github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b h1:IDT/Waq2euwKsSOVZGufUN1tjYTN94Jf7FdSqt0XrH0= github.com/consensys/gnark-crypto v0.22.1-0.20261009142911-8911ee45d22b/go.mod h1:HL8XflRG4cvau1PEG3CWBTD2kkzUzdlqZF6BBgy57F8= +github.com/consensys/gnark-ignition-verifier v0.0.0-20230527014722-10693546ab33 h1:z42ewLaLxoTYeQ17arcF4WExZc/eSaN3YVlF7eEaPt4= +github.com/consensys/gnark-ignition-verifier v0.0.0-20230527014722-10693546ab33/go.mod h1:JdKor28c/KR4BbznP88bz8AAvnCgovzrB3KWsiR7lwk= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= From 8c6ac3be898f21e2b9e4c901c885c9ecff366686 Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:02:19 +0000 Subject: [PATCH 27/31] feat(compression): add ix compress setup over an in-place fixed cache Stages read fixed preprocessing from the cache entry in place and publish moves it there; restoring used to copy ~69 GB into every compression output when the cache was on another filesystem. `ix compress setup` downloads srs.bin if missing, checks its pinned SHA-256, and derives and publishes the preprocessing of every bundled stage not already cached. It replaces `ix compress fetch-params`. --- Ix/Cli/CompressCmd.lean | 2 +- Ix/Cli/Compression.lean | 7 + Ix/Cli/CompressionCmd.lean | 54 +-- crates/ffi/src/compression.rs | 29 ++ crates/proof-compression/README.md | 17 +- .../proof-compression/src/pipeline/derive.rs | 4 +- .../proof-compression/src/pipeline/first.rs | 78 ++++- .../src/pipeline/fixed_cache.rs | 152 ++++++++- crates/proof-compression/src/pipeline/mod.rs | 2 + .../proof-compression/src/pipeline/prepare.rs | 309 ++++++++++++++++++ .../proof-compression/src/pipeline/prover.rs | 15 + .../proof-compression/src/pipeline/setup.rs | 2 +- .../src/pipeline/setup/ignition_tests.rs | 2 +- .../src/pipeline/verifying_keys.rs | 11 +- .../src/pipeline/wrap/mod.rs | 4 +- .../src/pipeline/wrap/outer.rs | 82 ++++- .../src/pipeline/wrap/saved.rs | 13 +- 17 files changed, 711 insertions(+), 72 deletions(-) create mode 100644 crates/proof-compression/src/pipeline/prepare.rs diff --git a/Ix/Cli/CompressCmd.lean b/Ix/Cli/CompressCmd.lean index a4c34f099..3efcfa0ce 100644 --- a/Ix/Cli/CompressCmd.lean +++ b/Ix/Cli/CompressCmd.lean @@ -117,6 +117,6 @@ def compressCmd : Cli.Cmd := `[Cli| SUBCOMMANDS: Ix.Cli.CompressionCmd.layoutCmd; Ix.Cli.CompressionCmd.normalizeCmd; - Ix.Cli.CompressionCmd.fetchParamsCmd + Ix.Cli.CompressionCmd.setupCmd ] end diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean index 3b00094fe..11c0ea829 100644 --- a/Ix/Cli/Compression.lean +++ b/Ix/Cli/Compression.lean @@ -67,6 +67,13 @@ The proof itself cannot authorize its key or a different circuit. -/ opaque verifyDevelopment (proof claim bundle : @& ByteArray) (trustedDigest : @& String) (devSrs : Bool) : IO String +/-- Check the Ignition SRS against its pin and publish the fixed +preprocessing of every stage of the bundled version for the aggregation +backend `vk` to the fixed cache under `cacheRoot`; with `plonk`, also build +the PLONK cache. Present entries are only checked. -/ +@[extern "rs_compression_prepare"] +opaque prepare (vk : @& ByteArray) (cacheRoot : @& String) (devKzgSrs plonk : Bool) : IO Unit + /-- Return the GPU memory this process's Aiur prover retains between proofs to the driver. The compression worker is another process and cannot reuse it. -/ @[extern "rs_compression_release_gpu_memory"] diff --git a/Ix/Cli/CompressionCmd.lean b/Ix/Cli/CompressionCmd.lean index 11e36c526..09ed38746 100644 --- a/Ix/Cli/CompressionCmd.lean +++ b/Ix/Cli/CompressionCmd.lean @@ -1,10 +1,10 @@ /- - `ix compress layout|normalize|fetch-params`. `layout` executes a root wrap + `ix compress layout|normalize|setup`. `layout` executes a root wrap of each given aggregate proof and sizes one fixed layout that holds all of them; `normalize` proves a root's wrap in that layout and writes the FRI worker's root input. Every root normalized to one layout compresses under - the same FRI verifier. `fetch-params` installs the released Ignition proving - parameters. + the same FRI verifier. `setup` installs the released Ignition proving + parameters and the fixed preprocessing every stage reads. -/ module public import Cli @@ -114,30 +114,40 @@ private def parametersDir : IO System.FilePath := do if let some dir ← IO.getEnv "IX_PARAMS_DIR" then return dir return (← StoreIO.toIO Store.getHomeDir) / ".ix/compression/params" -def runFetchParams (_ : Cli.Parsed) : IO UInt32 := do +private def backendVk : IO ByteArray := do + return (← backend).system.vkBytes + +def runSetup (p : Cli.Parsed) : IO UInt32 := do try - let target := (← parametersDir) / ignitionRelease - IO.FS.createDirAll target - for file in ignitionFiles do - let path := target / file - if ← path.pathExists then - IO.eprintln s!"{path} exists; skipping download." - else - IO.eprintln s!"Downloading {ignitionUrl}/{file}." - download s!"{ignitionUrl}/{file}" path - if (← IO.getEnv "IX_KZG_SRS").isSome then - IO.eprintln "IX_KZG_SRS is set and takes precedence over the downloaded release." - IO.eprintln s!"Checking the configured parameters against the release key." - IO.eprintln s!"Using {← Compression.describeSetup false false 0}." - IO.println target + let devKzgSrs := p.hasFlag "dev-kzg-srs" + if devKzgSrs then + IO.eprintln "parameters: development setup, nothing to download" + else if (← IO.getEnv "IX_KZG_SRS").isSome then + IO.eprintln "parameters: IX_KZG_SRS is set and replaces the release SRS" + else + let target := (← parametersDir) / ignitionRelease + IO.FS.createDirAll target + for file in ignitionFiles do + let path := target / file + if ← path.pathExists then + IO.eprintln s!"parameters: {path} present" + else + IO.eprintln s!"parameters: downloading {ignitionUrl}/{file}" + download s!"{ignitionUrl}/{file}" path + Compression.prepare (← backendVk) (← Compression.cacheDir).toString + devKzgSrs (p.hasFlag "plonk") return 0 catch e => - IO.eprintln s!"compress fetch-params: {e}" + IO.eprintln s!"compress setup: {e}" return 1 -def fetchParamsCmd : Cli.Cmd := `[Cli| - "fetch-params" VIA runFetchParams; - "Download the released BN254 Ignition SRS (4.3 GB, public) into IX_PARAMS_DIR (default ~/.ix/compression/params) and check its anchors." +def setupCmd : Cli.Cmd := `[Cli| + "setup" VIA runSetup; + "Prepare this machine for compression of the bundled version: download the released BN254 Ignition SRS (4.3 GB, public) into IX_PARAMS_DIR (default ~/.ix/compression/params) if missing and check it against its pin, then publish the fixed preprocessing of the first KZG stage and the recursive stage to the fixed cache, checking each derived key against its bundled pin. Present cache entries are only checked." + + FLAGS: + "dev-kzg-srs"; "Prepare the known-trapdoor development setup instead of Ignition (not production-secure)." + "plonk"; "Also build the PLONK circuit, keys and parameter exports into the PLONK cache." ] end Ix.Cli.CompressionCmd diff --git a/crates/ffi/src/compression.rs b/crates/ffi/src/compression.rs index 0da5d9474..2e46312a9 100644 --- a/crates/ffi/src/compression.rs +++ b/crates/ffi/src/compression.rs @@ -266,3 +266,32 @@ extern "C" fn rs_compression_release_gpu_memory() -> LeanIOResult { multi_stark::cuda::release_device_memory(); LeanIOResult::ok(LeanOwned::box_usize(0)) } + +#[unsafe(no_mangle)] +extern "C" fn rs_compression_prepare( + root_vk: LeanByteArray>, + cache_root: LeanString>, + dev_kzg_srs: bool, + plonk: bool, +) -> LeanIOResult { + let cache_root = cache_root.to_string(); + let result = pipeline::CompressConfig::from_env( + std::path::Path::new(&cache_root), + dev_kzg_srs, + pipeline::FinalOptions { + system: pipeline::FinalSystem::None, + allow_development: dev_kzg_srs, + }, + ) + .and_then(|config| { + pipeline::prepare(root_vk.as_bytes(), &config)?; + if plonk { + pipeline::prepare_plonk(&config)?; + } + Ok(()) + }); + match result { + Ok(()) => LeanIOResult::ok(LeanOwned::box_usize(0)), + Err(error) => LeanIOResult::error_string(&error.to_string()), + } +} diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index 49b299057..bc37c7f3c 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -63,15 +63,19 @@ parameters. Verification needs no SRS file, source manifest or admission JSON. Proving loads a prefix of the release's standard gnark `SRS.WriteRawTo` file: ```sh -ix compress fetch-params +ix compress setup ix compress ROOT_PROOF_ADDRESS --out /path/to/new-run ``` -The file is `bn254-ignition-main-197/srs.bin` under `IX_PARAMS_DIR` (default +`ix compress setup` downloads the file if it is missing and checks it against +its pinned SHA-256, then publishes every stage's fixed preprocessing to the +fixed cache; entries already present are only checked. The file is +`bn254-ignition-main-197/srs.bin` under `IX_PARAMS_DIR` (default `~/.ix/compression/params`). `IX_KZG_SRS` can select another local copy of the -same release SRS. Its G1/G2 anchors must match the released key. Provenance -comes from release preparation and distribution; these runtime checks do not -replay the ceremony or verify participant signatures. +same release SRS; it is not pin-checked. Its G1/G2 anchors must match the +released key. Provenance comes from release preparation and distribution; +these runtime checks do not replay the ceremony or verify participant +signatures. `--dev-kzg-srs` selects known-trapdoor inner parameters and bypasses Ignition loading. Use `--dev-final-srs` as well for development final PLONK/Groth16 keys. @@ -99,7 +103,8 @@ The output retains the shifted basis beginning at `tau * G1`; the release key carries the full ceremony's public degree independently of the retained prefix. The parameter publisher must upload `srs.bin` to the existing -`bn254/ignition/main-197/` release location before `fetch-params` can install it. +`bn254/ignition/main-197/` release location before `ix compress setup` can +install it. ## Proving and verification diff --git a/crates/proof-compression/src/pipeline/derive.rs b/crates/proof-compression/src/pipeline/derive.rs index dc332442e..c33ff5e9c 100644 --- a/crates/proof-compression/src/pipeline/derive.rs +++ b/crates/proof-compression/src/pipeline/derive.rs @@ -19,8 +19,8 @@ pub fn derive_recursive_vk( let key = aiur::vk_codec::AiurVerifyingKey::from_bytes(root_vk)?; root::verifier(&key, &FixedRoot::from_json(layout)?)? }; - let (manifest, parts) = - first::setup(&fri, verifying_keys::FIRST_LOG_HEIGHT, setup)?; + let (manifest, parts, _) = + first::setup(&fri, verifying_keys::FIRST_LOG_HEIGHT, setup, None)?; drop(fri); let vk = wrap::verifying_key(&manifest, parts, setup)?; vk.check_setup_class(setup.class())?; diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index 6baa012fe..f19162fd2 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -71,8 +71,8 @@ pub(super) fn run( let height = max_height(&lowered.compiled)?; let setup = config.setup.for_stage(FIRST_SEED, height)?; setup.bind_stage(out, height, 2)?; - let setup_id = setup.identity(height, 2)?; - let cache = config.fixed_cache(out, &[plan_id, setup_id].concat()); + let cache = + config.fixed_cache(out, &cache_identity(&plan_id, &setup, height)?); let cached = cache.restore(out)?; let manifest = storage::Manifest::for_compiled( &lowered.compiled, @@ -99,21 +99,53 @@ fn proving_config( ) } +/// The fixed-cache identity of a first stage proved for the FRI verifier +/// plan `plan_id` under `setup` at trace height `height`. +pub(super) fn cache_identity( + plan_id: &[u8; 32], + setup: &SetupSource, + height: usize, +) -> storage::Result> { + Ok([plan_id.as_slice(), &setup.identity(height, 2)?].concat()) +} + +/// The first stage's fixed cache under `config`, at trace height +/// `2^log_height`, for the FRI verifier plan `plan_id`. +pub(super) fn fixed_cache( + plan_id: &[u8; 32], + log_height: usize, + config: &super::SetupConfig, + root: &Path, +) -> storage::Result { + let height = 1 << log_height; + let setup = config.for_stage(FIRST_SEED, height)?; + Ok(FixedCache::from_identity(root, &cache_identity(plan_id, &setup, height)?)) +} + /// The first stage's fixed setup for the FRI stage `fri` at trace height /// `2^log_height`, derived without a proof: its manifest, with claims for a /// zero statement, and each partition's circuit and fixed commitment, as -/// `run` stages and commits them. +/// `run` stages and commits them. With `cache`, a fixed cache root, the +/// fixed preprocessing comes from or is published to its entry, and the +/// result says which, with the plan identity the entry is keyed by. pub(super) fn setup( fri: &super::stages::FriVerifier, log_height: usize, config: &super::SetupConfig, -) -> storage::Result<(storage::Manifest, Vec)> { + cache: Option<&Path>, +) -> storage::Result<( + storage::Manifest, + Vec, + Option<([u8; 32], super::fixed_cache::Prepared)>, +)> { let plan = VerifierPlan::validate( &fri.key, construct::fri_profile(&fri.key, &fri.statement.template)?, VerifierLimits::default(), )?; - let circuit = FriConstruction::build(&plan, fri.statement.schema())?.circuit; + let schema = fri.statement.schema(); + let plan_id = plan.identity(&schema)?; + let circuit = FriConstruction::build(&plan, schema)?.circuit; drop(plan); let (compiled, _inputs) = construct::first_circuit(circuit, 1 << log_height)?; let height = max_height(&compiled)?; @@ -124,8 +156,19 @@ pub(super) fn setup( compiled.claims(&public)?, None, )?; - let parts = prover::fixed_setup(&proving_config(&setup, height)?, &compiled)?; - Ok((manifest, parts)) + let config = proving_config(&setup, height)?; + let (parts, prepared) = match cache { + Some(root) => { + let cache = FixedCache::from_identity( + root, + &cache_identity(&plan_id, &setup, height)?, + ); + let (parts, prepared) = cache.prepare(&config, &compiled)?; + (parts, Some((plan_id, prepared))) + }, + None => (prover::fixed_setup(&config, &compiled)?, None), + }; + Ok((manifest, parts, prepared)) } /// Commit the fixed and witness traces of every partition, prove them @@ -185,8 +228,9 @@ fn prove( let config = proving_config(&setup, height)?; eprintln!("KZG parameters ready: {:?}", start.elapsed()); eprintln!("Partition prefetch budget: {} GiB", prefetch_bytes >> 30); + let fixed = cache.fixed_directory(dir)?; let prover::Proved { system, bytes, verify_seconds, .. } = lowered - .commit(config, Some(&out), prefetch_bytes)? + .commit(config, Some(&fixed), prefetch_bytes)? .prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); // Identifies this proof instance, not its key: the manifest holds the claim @@ -369,8 +413,15 @@ mod tests { prove(&dir, &cache, 9)?; let first = artifacts(&dir)?; let manifest: storage::Manifest = storage::load(&dir.join("manifest.bin"))?; + let entries = fs::read_dir(&cache_root)? + .map(|entry| Ok(entry?.path())) + .collect::>>()?; + let [entry] = entries.as_slice() else { + panic!("expected one published entry, found {entries:?}"); + }; for i in 0..manifest.heights.len() { - assert!(dir.join(format!("kzg/fixed-{i}.bin")).exists()); + assert!(!dir.join(format!("kzg/fixed-{i}.bin")).exists()); + assert!(entry.join(format!("kzg/fixed-{i}.bin")).exists()); assert!(dir.join(format!("kzg/setup-{i}.bin")).exists()); assert!(!dir.join(format!("kzg/main-{i}.bin")).exists()); } @@ -384,7 +435,8 @@ mod tests { let warm = root.join("warm"); let cache = stage(&warm, compiled, public, &cache_root)?; for i in 0..manifest.heights.len() { - assert!(warm.join(format!("kzg/fixed-{i}.bin")).exists()); + assert!(warm.join(format!("kzg/setup-{i}.bin")).exists()); + assert!(!warm.join(format!("kzg/fixed-{i}.bin")).exists()); } prove(&warm, &cache, 9)?; assert_eq!(first, artifacts(&warm)?); @@ -450,10 +502,8 @@ mod tests { let error = prove(&malformed, &cache, 9).unwrap_err(); assert!(error.to_string().contains("staged claims differ")); - // A damaged cache entry is rejected before anything is linked into a - // stage, whether it is metadata or a fixed checkpoint whose interior - // bytes still decode. Files are replaced rather than edited so the - // hard links into the proved stages keep their contents. + // A damaged cache entry is rejected before anything is staged, whether + // it is metadata or a fixed checkpoint whose interior bytes still decode. let entries = fs::read_dir(&cache_root)? .map(|entry| Ok(entry?.path())) .collect::>>()?; diff --git a/crates/proof-compression/src/pipeline/fixed_cache.rs b/crates/proof-compression/src/pipeline/fixed_cache.rs index 0b7bf7bc0..f40a97b97 100644 --- a/crates/proof-compression/src/pipeline/fixed_cache.rs +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -3,7 +3,15 @@ //! key, which commits to the fixed polynomials, so rebuilding the executable //! keeps them valid. //! The root is `CompressConfig::fixed_cache`, or `/kzg/fixed-cache`. -use super::storage::{self, Manifest, Result}; +//! Fixed columns are tens of gigabytes per stage, so stages read them from +//! the cache in place and never keep their own copy; only the small setup +//! metadata, which later stages read from the stage directory, is staged. +use super::{ + prover::{self, FixedSetup}, + storage::{self, Manifest, Result}, +}; +use crate::kzg::{KzgConfig, Scalar}; +use multi_stark::plonkish::MultiStarkCircuit; use serde::{Deserialize, Serialize}; use std::{ fs::{self, File}, @@ -34,6 +42,20 @@ struct Entry { files: Vec, } +impl Entry { + fn size(&self) -> u64 { + self.files.iter().map(|file| file.size).sum() + } +} + +/// Whether a stage's fixed preprocessing was already published or has just +/// been, and its size in bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Prepared { + Present { bytes: u64 }, + Published { bytes: u64 }, +} + pub(crate) struct FixedCache { identity: [u8; 32], directory: PathBuf, @@ -71,9 +93,31 @@ impl FixedCache { name = "compression/cache_restore" )] pub(crate) fn restore(&self, staged: &Path) -> Result> { + let Some(entry) = self.checked_entry()? else { + eprintln!("Fixed preprocessing cache miss: {}", self.directory.display()); + return Ok(None); + }; + fs::create_dir_all(staged.join("kzg"))?; + for CachedFile { name, .. } in + entry.files.iter().filter(|f| is_setup(&f.name)) + { + let destination = staged.join(name); + if destination.try_exists()? { + return Err( + format!("refusing to replace {}", destination.display()).into(), + ); + } + fs::copy(self.directory.join(name), destination)?; + } + eprintln!("Reused fixed preprocessing: {}", self.directory.display()); + Ok(Some(entry.shape)) + } + + /// The published entry, after checking its identity, shape and every + /// file's size and digest, or `None` when nothing is published. + fn checked_entry(&self) -> Result> { let ready = self.directory.join("ready.bin"); if !ready.try_exists()? { - eprintln!("Fixed preprocessing cache miss: {}", self.directory.display()); return Ok(None); } let entry: Entry = storage::load(&ready)?; @@ -94,12 +138,73 @@ impl FixedCache { return Err("fixed cache entry is incomplete or damaged".into()); } } - fs::create_dir_all(staged.join("kzg"))?; - for CachedFile { name, .. } in &entry.files { - link_or_copy(&self.directory.join(name), &staged.join(name))?; + Ok(Some(entry)) + } + + /// The published entry's total size, after checking it as a restore does, + /// or `None` when nothing is published. + pub(crate) fn check(&self) -> Result> { + Ok(self.checked_entry()?.map(|entry| entry.size())) + } + + /// The circuit metadata and fixed commitment of every partition of a + /// checked entry, as its `setup-{i}.bin` files record them. + pub(crate) fn setup_parts(&self) -> Result> { + let entry = self.checked_entry()?.ok_or("fixed cache entry is missing")?; + entry + .files + .iter() + .filter(|file| is_setup(&file.name)) + .map(|file| storage::load(&self.directory.join(&file.name))) + .collect() + } + + /// The fixed setup of `compiled` under `config`, from the published entry + /// when it checks, otherwise committed into a staging directory beside the + /// entry and published, as a stage's proof would leave it. + pub(crate) fn prepare( + &self, + config: &KzgConfig, + compiled: &MultiStarkCircuit, + ) -> Result<(Vec, Prepared)> { + if let Some(bytes) = self.check()? { + return Ok((self.setup_parts()?, Prepared::Present { bytes })); } - eprintln!("Reused fixed preprocessing: {}", self.directory.display()); - Ok(Some(entry.shape)) + let staged = storage::temporary_path(&self.directory); + let result = (|| -> Result> { + fs::create_dir_all(staged.join("kzg"))?; + let manifest = Manifest::for_compiled(compiled, vec![], None)?; + storage::save(&staged.join("manifest.bin"), &manifest)?; + let parts = + prover::fixed_setup_in(config, compiled, &staged.join("kzg"))?; + self.publish(&staged)?; + Ok(parts) + })(); + match fs::remove_dir_all(&staged) { + Ok(()) => {}, + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(error.into()), + } + let parts = result?; + let bytes = + self.check()?.ok_or("published fixed cache entry is missing")?; + Ok((parts, Prepared::Published { bytes })) + } + + #[cfg(test)] + pub(crate) fn directory(&self) -> &Path { + &self.directory + } + + /// The directory the prover reads and writes fixed checkpoints in: the + /// published entry when there is one, so its fixed columns are read in + /// place, otherwise `staged`, from which `publish` moves them. + pub(crate) fn fixed_directory(&self, staged: &Path) -> Result { + Ok(if self.directory.join("ready.bin").try_exists()? { + self.directory.join("kzg") + } else { + staged.join("kzg") + }) } /// Publish only after the proof using these parameters has verified. @@ -112,7 +217,7 @@ impl FixedCache { )] pub(crate) fn publish(&self, staged: &Path) -> Result<()> { if self.directory.join("ready.bin").try_exists()? { - return Ok(()); + return remove_staged_fixed(staged); } let manifest: Manifest = storage::load(&staged.join("manifest.bin"))?; let temporary = storage::temporary_path(&self.directory); @@ -123,7 +228,11 @@ impl FixedCache { let size = fs::metadata(&source)?.len(); let digest = file_digest(&source)?; let published = temporary.join(&name); - link_or_copy(&source, &published)?; + if is_setup(&name) { + fs::copy(&source, &published)?; + } else { + move_file(&source, &published)?; + } File::open(published)?.sync_all()?; files.push(CachedFile { name, size, digest }); } @@ -153,16 +262,37 @@ fn file_names(count: usize) -> impl Iterator { .flat_map(|i| [format!("kzg/setup-{i}.bin"), format!("kzg/fixed-{i}.bin")]) } -fn link_or_copy(source: &Path, destination: &Path) -> Result<()> { +fn is_setup(name: &str) -> bool { + name.starts_with("kzg/setup-") +} + +/// Drop the fixed columns a stage wrote while another run published the +/// same entry first. +fn remove_staged_fixed(staged: &Path) -> Result<()> { + let Ok(entries) = fs::read_dir(staged.join("kzg")) else { + return Ok(()); + }; + for entry in entries { + let path = entry?.path(); + let name = path.file_name().and_then(|name| name.to_str()); + if name.is_some_and(|name| name.starts_with("fixed-")) { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn move_file(source: &Path, destination: &Path) -> Result<()> { if destination.try_exists()? { return Err( format!("refusing to replace {}", destination.display()).into(), ); } - match fs::hard_link(source, destination) { + match fs::rename(source, destination) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::CrossesDevices => { fs::copy(source, destination)?; + fs::remove_file(source)?; Ok(()) }, Err(error) => Err(error.into()), diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index a5f1e26a8..716fd2a6e 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -23,7 +23,9 @@ pub mod groth16; mod init_claim; mod init_fri; pub mod plonk; +mod prepare; pub use final_proof::{Artifact, FinalOptions, FinalSystem}; +pub use prepare::{prepare, prepare_plonk}; mod prover; mod root; mod verifying_keys; diff --git a/crates/proof-compression/src/pipeline/prepare.rs b/crates/proof-compression/src/pipeline/prepare.rs new file mode 100644 index 000000000..c1b6bf265 --- /dev/null +++ b/crates/proof-compression/src/pipeline/prepare.rs @@ -0,0 +1,309 @@ +//! `ix compress setup`: everything a fresh machine computes once before its +//! first compression of the bundled version. The Ignition SRS is checked +//! against its pin, and the fixed preprocessing of the first KZG stage and +//! the recursive stage is published to the fixed cache under the identity +//! those stages look it up by. +//! +//! Under Ignition each stage's cache identity follows from bundled pins and +//! the setup alone, so a stage whose entry is present and checks costs only +//! that check. A missing stage is derived without a proof, its key checked +//! against its pin, and its fixed columns committed and published. +//! Development setups have no pins: the first stage is still found by its +//! pinned plan, but the recursive stage is rebuilt to find its identity, +//! and only missing entries are committed. +use super::{ + CompressConfig, FixedRoot, Result, SetupClass, SetupConfig, first, + fixed_cache::{FixedCache, Prepared}, + prover::FixedSetup, + root, + setup::IGNITION_SRS_VAR, + stages::statement::first_claims, + storage::Manifest, + verifying_keys, wrap, +}; +use sha2::{Digest, Sha256}; +use std::{fs, io::Read, path::Path}; + +/// SHA-256 of the released gnark-format Ignition SRS file. +pub(super) const IGNITION_SRS_SHA256: &str = + "06db33d8aa83291237aec6b8fa74752d3d4a516db0bb1221f4a554e8f8510a93"; + +fn sha256_file(path: &Path) -> Result<[u8; 32]> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 1 << 24]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().into()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn gigabytes(bytes: u64) -> String { + format!( + "{}.{} GB", + bytes / 1_000_000_000, + bytes % 1_000_000_000 / 100_000_000 + ) +} + +fn report(stage: &str, prepared: Prepared) { + match prepared { + Prepared::Present { bytes } => { + eprintln!("{stage}: present ({}), skipped", gigabytes(bytes)); + }, + Prepared::Published { bytes } => { + eprintln!("{stage}: published ({})", gigabytes(bytes)); + }, + } +} + +/// Check the Ignition SRS file `config` loads against its pin. A file named +/// by `IX_KZG_SRS` replaces the release file and is only checked against +/// the released anchors when it is loaded. +fn check_srs(config: &SetupConfig) -> Result<()> { + let SetupConfig::Ignition { srs, .. } = config else { + eprintln!("parameters: development setup, no Ignition SRS needed"); + return Ok(()); + }; + if std::env::var_os(IGNITION_SRS_VAR).is_some() { + eprintln!( + "parameters: {} from {IGNITION_SRS_VAR}, not pin-checked", + srs.display() + ); + return Ok(()); + } + if sha256_file(srs)? != verifying_keys::decode(IGNITION_SRS_SHA256)? { + return Err( + format!( + "{} differs from the released Ignition SRS; delete it and rerun", + srs.display() + ) + .into(), + ); + } + eprintln!("parameters: {} verified", srs.display()); + Ok(()) +} + +/// The first stage's manifest for its fixed setup `parts`, with the claims +/// of a zero statement, as `first::setup` derives it. +fn first_manifest(parts: &[FixedSetup]) -> Manifest { + Manifest { + widths: parts.iter().map(|(circuit, _)| circuit.main_width).collect(), + heights: parts + .iter() + .map(|(circuit, _)| circuit.preprocessed_height) + .collect(), + claims: first_claims(parts.len(), &[0; 18]), + } +} + +/// The first stage's manifest and fixed setup, from its cache entry or +/// derived and published, and the entry's status. +fn prepare_first( + root_vk: &[u8], + config: &SetupConfig, + cache_root: &Path, +) -> Result<(Option<(Manifest, Vec)>, Prepared, FixedCache)> { + let plan_id = verifying_keys::decode(verifying_keys::FIRST_PLAN_ID)?; + let cache = first::fixed_cache( + &plan_id, + verifying_keys::FIRST_LOG_HEIGHT, + config, + cache_root, + )?; + if let Some(bytes) = cache.check()? { + return Ok((None, Prepared::Present { bytes }, cache)); + } + let fri = { + let key = aiur::vk_codec::AiurVerifyingKey::from_bytes(root_vk)?; + let layout = verifying_keys::release_root_layout(root_vk)?; + root::verifier(&key, &FixedRoot::from_json(layout)?)? + }; + let (manifest, parts, prepared) = first::setup( + &fri, + verifying_keys::FIRST_LOG_HEIGHT, + config, + Some(cache_root), + )?; + let (derived, prepared) = prepared.ok_or("first stage was not cached")?; + if derived != plan_id { + return Err( + format!( + "derived first-stage plan {} differs from its pin", + hex(&derived) + ) + .into(), + ); + } + Ok((Some((manifest, parts)), prepared, cache)) +} + +/// Publish the fixed preprocessing of the bundled version's stages for the +/// aggregation backend `root_vk` under `config`, and check the Ignition SRS. +pub fn prepare(root_vk: &[u8], config: &CompressConfig) -> Result<()> { + let setup = &config.setup; + let cache_root = config.fixed_cache.as_path(); + check_srs(setup)?; + verifying_keys::release_root_layout(root_vk)?; + fs::create_dir_all(cache_root)?; + eprintln!("fixed cache: {}", cache_root.display()); + let (mut first_setup, prepared, first_cache) = + prepare_first(root_vk, setup, cache_root)?; + report("first KZG", prepared); + let mut first_setup = || -> Result<(Manifest, Vec)> { + if let Some(derived) = first_setup.take() { + return Ok(derived); + } + let parts = first_cache.setup_parts()?; + Ok((first_manifest(&parts), parts)) + }; + + if setup.class() == SetupClass::Ignition { + let recursive = + wrap::VerifyingKey::from_bytes(verifying_keys::RECURSIVE_VK)?; + let height = recursive.system()?.circuits[0].preprocessed_height; + let identity = + verifying_keys::decode(verifying_keys::RECURSIVE_FRONTEND_ID)?; + let cache = wrap::fixed_cache(&identity, height, setup, cache_root)?; + let prepared = match cache.check()? { + Some(bytes) => Prepared::Present { bytes }, + None => { + let (manifest, parts) = first_setup()?; + let frontend = wrap::frontend(&manifest, parts, setup)?; + if frontend.identity() != identity { + return Err( + format!( + "derived recursive frontend {} differs from its pin", + hex(frontend.identity()) + ) + .into(), + ); + } + let (vk, prepared) = frontend.prepared_key(setup, cache_root)?; + if vk.to_bytes()? != verifying_keys::RECURSIVE_VK { + return Err("derived recursive key differs from its pin".into()); + } + prepared + }, + }; + report("recursive KZG", prepared); + } else { + let (manifest, parts) = first_setup()?; + let (_, prepared) = wrap::frontend(&manifest, parts, setup)? + .prepared_key(setup, cache_root)?; + report("recursive KZG", prepared); + } + Ok(()) +} + +/// Build the PLONK circuit, keys and parameter exports for the bundled +/// recursive key into the PLONK cache, and check the key against its pin. +#[cfg(feature = "plonk")] +pub fn prepare_plonk(config: &CompressConfig) -> Result<()> { + let key = super::plonk::derive_verifying_key( + verifying_keys::RECURSIVE_VK, + &verifying_keys::STATEMENT_PREFIX, + &config.setup, + &config.plonk_cache, + )?; + if Sha256::digest(&key).as_slice() + != verifying_keys::decode(verifying_keys::PLONK_VK_SHA256)? + { + return Err( + "the PLONK key derived for the bundled recursive key differs from the \ + bundled PLONK key" + .into(), + ); + } + eprintln!("PLONK: {} verified", config.plonk_cache.display()); + Ok(()) +} + +#[cfg(not(feature = "plonk"))] +pub fn prepare_plonk(_config: &CompressConfig) -> Result<()> { + Err("this ix was built without PLONK; rebuild with IX_PLONK=1".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kzg::Scalar; + use multi_stark::{ + plonkish::CircuitBuilder, + traits::{Algebra, Field}, + }; + + fn compiled() -> multi_stark::plonkish::MultiStarkCircuit { + let mut b = CircuitBuilder::new(); + let table = + b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let x = b.input("x"); + b.lookup(table, &[x]); + let y = b.mul(x, x); + b.expose_public(y); + b.finish() + .lower_to_multi_stark_sharded(Scalar::from_u8(94), 16) + .unwrap() + .merge_table_traces(16) + .unwrap() + } + + #[test] + fn prepared_preprocessing_is_published_once_in_the_prover_format() + -> Result<()> { + let root = std::env::temp_dir() + .join(format!("ix-setup-publish-{}", std::process::id())); + let compiled = compiled(); + let setup = crate::pipeline::setup::SetupSource::development(b"setup", 63)?; + let config = setup.config(16, 2, false)?; + let cache = FixedCache::from_identity(&root, b"setup-fixture"); + assert_eq!(cache.check()?, None); + let (parts, first) = cache.prepare(&config, &compiled)?; + let Prepared::Published { bytes } = first else { + panic!("expected a new entry, found {first:?}"); + }; + assert_eq!(cache.check()?, Some(bytes)); + let (again, second) = cache.prepare(&config, &compiled)?; + assert_eq!(second, Prepared::Present { bytes }); + let encode = |parts: &[FixedSetup]| { + bincode::serde::encode_to_vec(parts, bincode::config::standard()).unwrap() + }; + assert_eq!(encode(&parts), encode(&again)); + assert_eq!( + encode(&parts), + encode(&super::super::prover::fixed_setup(&config, &compiled,)?) + ); + // A stage restores the entry and reads its fixed columns in place. + let staged = root.join("stage"); + let shape = cache.restore(&staged)?.ok_or("entry was not restored")?; + assert_eq!(shape.heights.len(), compiled.num_circuits()); + let fixed = cache.fixed_directory(&staged)?; + assert!(fixed.starts_with(cache.directory())); + let read = + super::super::prover::fixed_setup_in(&config, &compiled, &fixed)?; + assert_eq!(encode(&parts), encode(&read)); + // Only the entry is left beside the staged stage: no partial directories. + let names: Vec<_> = fs::read_dir(&root)? + .map(|entry| Ok(entry?.file_name())) + .collect::>()?; + assert_eq!(names.len(), 2); + // A damaged entry fails the check instead of being skipped. + let checkpoint = cache.directory().join("kzg/fixed-0.bin"); + let mut bytes = fs::read(&checkpoint)?; + *bytes.last_mut().unwrap() ^= 1; + fs::remove_file(&checkpoint)?; + fs::write(&checkpoint, bytes)?; + assert!(cache.check().is_err()); + fs::remove_dir_all(root)?; + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/prover.rs b/crates/proof-compression/src/pipeline/prover.rs index e9a687b97..f4b116416 100644 --- a/crates/proof-compression/src/pipeline/prover.rs +++ b/crates/proof-compression/src/pipeline/prover.rs @@ -226,6 +226,21 @@ pub(super) fn fixed_setup( .collect() } +/// Commit each fixed partition without allocating a witness, writing its +/// `setup-{i}.bin` and `fixed-{i}.bin` checkpoints into `dir`. +pub(super) fn fixed_setup_in( + config: &KzgConfig, + compiled: &MultiStarkCircuit, + dir: &Path, +) -> Result> { + (0..compiled.num_circuits()) + .map(|i| { + let (circuit, data) = fixed_partition(config, compiled, i, Some(dir))?; + Ok((circuit, KzgProverData::concatenate([data]).0)) + }) + .collect() +} + /// The system of fixed partitions in canonical order under `config`. pub(super) fn fixed_system( config: KzgConfig, diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 2c034fc70..076c5ffae 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -74,7 +74,7 @@ impl SetupClass { fn development_refused() -> storage::Result { Err( format!( - "no BN254 Ignition SRS is installed; run `ix compress fetch-params`, \ + "no BN254 Ignition SRS is installed; run `ix compress setup`, \ set {IGNITION_SRS_VAR} to the released gnark SRS, or pass --dev-kzg-srs \ to use the known-trapdoor development setup (not production-secure)" ) diff --git a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs index 364e998dc..4793d49a9 100644 --- a/crates/proof-compression/src/pipeline/setup/ignition_tests.rs +++ b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs @@ -42,7 +42,7 @@ fn release_identity_does_not_depend_on_path_or_stage() -> storage::Result<()> { #[test] fn missing_release_requires_explicit_development() -> storage::Result<()> { let error = SetupConfig::from_ignition_env(false, |_| None).err().unwrap(); - assert!(error.to_string().contains("fetch-params")); + assert!(error.to_string().contains("compress setup")); let config = SetupConfig::from_ignition_env(true, |name| { assert_eq!(name, "IX_KZG_SRS_CACHE"); Some("development-cache".into()) diff --git a/crates/proof-compression/src/pipeline/verifying_keys.rs b/crates/proof-compression/src/pipeline/verifying_keys.rs index ef02a8e24..92ddf5516 100644 --- a/crates/proof-compression/src/pipeline/verifying_keys.rs +++ b/crates/proof-compression/src/pipeline/verifying_keys.rs @@ -27,7 +27,7 @@ pub(super) const STATEMENT_PREFIX: [u64; 10] = [ 4052325645, 2284798839, 2062413424, ]; -const RECURSIVE_VK: &[u8] = +pub(super) const RECURSIVE_VK: &[u8] = include_bytes!("../../verifying-keys/0.1/recursive-vk.bin"); const RECURSIVE_VK_BLAKE3: &str = "b4c58f798380d2fd91594763613874fdaf2d407631cee98fe361a287265d074a"; @@ -35,6 +35,15 @@ const RECURSIVE_VK_BLAKE3: &str = pub(super) const RECURSIVE_VK_ID: &str = "376163d7ec5afa644e393d0d1828b3fc46be2f80cc5e4d122c739e9b28129330"; +/// Identity of the first stage's FRI verifier plan for the bundled backend +/// and layout, half of its fixed-cache identity. Independent of the KZG setup. +pub(super) const FIRST_PLAN_ID: &str = + "99048e124eb0d8bb752c77db7e06ebf7a8254174c05c9140e31c039c4d03a198"; +/// Identity of the recursive stage's verifier frontend over the first stage +/// under Ignition, half of its fixed-cache identity. +pub(super) const RECURSIVE_FRONTEND_ID: &str = + "2f201f43533d930326e535fb61ee3e8ee8716ebfdaf8ffce7e1083b71ef160f6"; + #[cfg(any(feature = "plonk", test))] pub(super) const PLONK_VK: &[u8] = include_bytes!("../../verifying-keys/0.1/plonk-verifying-key.bin"); diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index 9e4dc067b..eb1379712 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -30,5 +30,5 @@ pub(super) fn check_first( Ok(()) } -pub(super) use outer::{PreparedVerifyingKey, VerifyingKey}; -pub(super) use saved::verifying_key; +pub(super) use outer::{PreparedVerifyingKey, VerifyingKey, fixed_cache}; +pub(super) use saved::{frontend, verifying_key}; diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index f9982baba..f1661479b 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -104,6 +104,37 @@ impl Frontend { let height = self.compiled.main_height(); let parts = prover::fixed_setup(&proving_config(&setup, height)?, &self.compiled)?; + self.key_from_parts(&parts, &setup) + } + + /// The verifying key, with the fixed preprocessing taken from or published + /// to the fixed cache under `root` that `stage` uses for this circuit. + pub(crate) fn prepared_key( + &self, + config: &super::super::SetupConfig, + root: &Path, + ) -> storage::Result<(VerifyingKey, fixed_cache::Prepared)> { + let setup = self.setup(config)?; + let height = self.compiled.main_height(); + let cache = fixed_cache::FixedCache::from_identity( + root, + &cache_identity(&self.vk, &setup, height)?, + ); + let (parts, prepared) = + cache.prepare(&proving_config(&setup, height)?, &self.compiled)?; + Ok((self.key_from_parts(&parts, &setup)?, prepared)) + } + + /// The frontend identity this circuit's fixed cache is keyed by. + pub(crate) fn identity(&self) -> &[u8] { + &self.vk + } + + fn key_from_parts( + &self, + parts: &[prover::FixedSetup], + setup: &SetupSource, + ) -> storage::Result { let heights = parts .iter() .map(|(circuit, _)| circuit.preprocessed_height) @@ -121,7 +152,7 @@ impl Frontend { &self.pairings, bincode::config::standard(), )?, - &setup, + setup, ) } @@ -168,12 +199,11 @@ impl Frontend { let start = std::time::Instant::now(); let height = self.compiled.main_height(); let setup = self.setup(&config.setup)?; - let setup_id = setup.identity(height, 2)?; fs::create_dir_all(dir)?; setup.bind_stage(dir, height, 2)?; storage::save(&dir.join("pairings.bin"), &self.pairings)?; - let cache_identity = [self.vk.as_slice(), &setup_id].concat(); - let cache = config.fixed_cache(dir, &cache_identity); + let cache = + config.fixed_cache(dir, &cache_identity(&self.vk, &setup, height)?); let cached = cache.restore(dir)?; let manifest = storage::Manifest::for_compiled( &self.compiled, @@ -194,6 +224,34 @@ impl Frontend { } } +/// The fixed-cache identity of a recursive or merge stage whose verifier +/// frontend is `vk`, under `setup` at computation height `height`. +fn cache_identity( + vk: &[u8], + setup: &SetupSource, + height: usize, +) -> storage::Result> { + Ok([vk, &setup.identity(height, 2)?].concat()) +} + +/// The fixed cache under `root` of a recursive or merge stage whose verifier +/// frontend is `vk` and computation height `height`, as `Frontend::stage` +/// keys it. +pub(crate) fn fixed_cache( + vk: &[u8], + height: usize, + config: &super::super::SetupConfig, + root: &Path, +) -> storage::Result { + let setup = config + .for_stage(OUTER_SEED, height)? + .with_transcript(TranscriptKind::Blake3); + Ok(fixed_cache::FixedCache::from_identity( + root, + &cache_identity(vk, &setup, height)?, + )) +} + fn pairing_keys(dir: &Path) -> storage::Result> { let vk: Pairings = storage::load(&dir.join("pairings.bin"))?; decode_pairing_keys(&vk) @@ -370,8 +428,9 @@ fn prove( let config = proving_config(setup, height)?; let srs_config_load_seconds = config_started.elapsed().as_secs_f64(); eprintln!("Outer KZG parameters ready: {:?}", start.elapsed()); + let fixed = cache.fixed_directory(dir)?; let prover::Proved { system, codec, bytes, .. } = - lowered.commit(config, Some(&out), 0)?.prove(&manifest.claims)?; + lowered.commit(config, Some(&fixed), 0)?.prove(&manifest.claims)?; let output_span = tracing::info_span!("compression/write_output").entered(); let verifier = VerifyingKey::new( [manifest.heights[0], manifest.heights[1]], @@ -541,7 +600,9 @@ impl VerifyingKey { Ok(()) } - fn system(&self) -> storage::Result> { + pub(crate) fn system( + &self, + ) -> storage::Result> { use crate::kzg::VerifierParams; use multi_stark::system::{Circuit, System}; let supported = self.version == OUTER_VK_VERSION; @@ -1051,7 +1112,8 @@ mod tests { let setup = SetupSource::development(OUTER_SEED, height - 1)? .with_transcript(TranscriptKind::Blake3); prove(&first, Lowered { compiled, assignment }, &cache, &setup)?; - assert!(first.join("kzg/fixed-0.bin").exists()); + assert!(first.join("kzg/setup-0.bin").exists()); + assert!(!first.join("kzg/fixed-0.bin").exists()); assert!(!first.join("kzg/main-0.bin").exists()); let packet = fs::read(first.join("kzg/packet.bin"))?; verify_saved(&first, &packet, None)?; @@ -1070,7 +1132,8 @@ mod tests { let Lowered { compiled, assignment } = make()?; let cache = stage_fixture(&second, &cache_root, &compiled, &assignment, &keys)?; - assert!(second.join("kzg/fixed-0.bin").exists()); + assert!(second.join("kzg/setup-0.bin").exists()); + assert!(!second.join("kzg/fixed-0.bin").exists()); prove(&second, Lowered { compiled, assignment }, &cache, &setup)?; assert_eq!(packet, fs::read(second.join("kzg/packet.bin"))?); for (row, col) in [(0, 3), (1, 0), (1, 1), (1, 2)] { @@ -1223,7 +1286,8 @@ mod tests { storage::load(&dir.join("manifest.bin"))?; for i in 0..manifest.heights.len() { assert!(!dir.join(format!("kzg/main-{i}.bin")).exists()); - assert!(dir.join(format!("kzg/fixed-{i}.bin")).exists()); + assert!(dir.join(format!("kzg/setup-{i}.bin")).exists()); + assert!(!dir.join(format!("kzg/fixed-{i}.bin")).exists()); } let packet = fs::read(dir.join("kzg/packet.bin"))?; verify_saved(&dir, &packet, None)?; diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index b9413a333..38bf2ca10 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -49,6 +49,16 @@ pub(in crate::pipeline) fn verifying_key( parts: Vec, config: &SetupConfig, ) -> Result { + frontend(manifest, parts, config)?.verifying_key(config) +} + +/// The recursive stage's circuit for a first stage with `manifest`'s shape +/// and fixed setup `parts`, as `prove` builds it from a loaded first stage. +pub(in crate::pipeline) fn frontend( + manifest: &Manifest, + parts: Vec, + config: &SetupConfig, +) -> Result { let height = check_manifest(manifest)?; let setup = config.for_stage(FIRST_SEED, height)?; let system = @@ -64,8 +74,7 @@ pub(in crate::pipeline) fn verifying_key( &keys, &plan.identity(), super::outer::MAX_TRACE_LEN, - )? - .verifying_key(config) + ) } fn read_recorded( From 84d2997467fce2176019d964b5e9edf02a4601fa Mon Sep 17 00:00:00 2001 From: samuelburnham <45365069+samuelburnham@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:34:11 +0000 Subject: [PATCH 28/31] refactor(compression): consolidate modules, tests and tooling Fold pipeline/stages into the pipeline modules that use it (stage, lowering, claims, root, wrap/recursive) and fold single-function files into their callers. Rename the development SRS file cache and the GPU SRS residency module apart, and tools/plonk-wrapper to tools/gnark, which also proves Groth16 and converts the Ignition SRS. Drop the vendored SP1 Solidity verifier. The PLONK verifier is now gnark's own export of the bundled release key, written by tools/gnark/cmd/solidity; it is byte-identical to the verifier key preparation writes. Read nvcc, the CUDA architectures and the CUDA library directories from multi-stark's build metadata instead of rediscovering them in aiur and proof-compression; this removes crates/build_support. Merge the integration tests into one binary and remove benchmarks and cost reports posing as tests, tests that re-prove only to compare bytes with an older code path, tests made redundant by stronger ones, BLS12-381-era cases and unwired scripts. Test temporary directories share one RAII helper. Rename the aggregation "lanes" to multi-GPU (proveMultiGpu, --gpus, AIUR_PROOF_CACHE_DIR). CI: fix the compression CLI test for the current setup errors, expect the KZG backend in the CUDA link smoke test, fix clippy casts in the gnark SRS test, and stop installing Go in the bench workflows, whose builds no longer need it. --- .github/workflows/bench-main.yml | 4 - .github/workflows/bench-pr.yml | 9 - .github/workflows/ci.yml | 24 +- Cargo.lock | 2 +- Cargo.toml | 2 +- Ix/Aggr/FFI.lean | 8 +- Ix/Cli/ProveCmd.lean | 2 +- Tests/CompressionCli.lean | 36 +- contracts/plonk/README.md | 126 +- contracts/plonk/bn254/.gitignore | 1 - contracts/plonk/bn254/bench.py | 281 ---- contracts/plonk/bn254/provenance.json | 151 -- .../plonk/bn254/upstream/ISP1Verifier.sol | 24 - .../bn254/upstream/v6.1.0/PlonkVerifier.sol | 1341 ----------------- .../upstream/v6.1.0/SP1VerifierPlonk.sol | 86 -- contracts/plonk/foundry.toml | 2 +- .../plonk/{bn254 => }/src/PlonkVerifier.sol | 14 +- contracts/plonk/test/IxPlonkVerifier.t.sol | 12 +- .../plonk/{bn254 => }/testdata/init.json | 0 crates/aiur/Cargo.toml | 2 - crates/aiur/build.rs | 34 +- crates/build_support/cuda.rs | 111 -- crates/ffi/src/aiur/aggregate.rs | 6 +- .../aiur/aggregate/{lanes.rs => multi_gpu.rs} | 49 +- .../{lanes => multi_gpu}/checkpoint.rs | 0 .../aggregate/{lanes => multi_gpu}/limits.rs | 19 +- .../aggregate/{lanes => multi_gpu}/queue.rs | 0 crates/ffi/src/aiur/aggregate/scheduler.rs | 12 +- crates/ffi/src/aiur/aggregate/store.rs | 4 +- .../ffi/src/aiur/aggregate/tests/scheduler.rs | 2 +- crates/ffi/src/aiur/protocol.rs | 11 +- crates/proof-compression/README.md | 15 +- crates/proof-compression/build.rs | 50 +- crates/proof-compression/build/gnark.rs | 3 +- crates/proof-compression/src/foreign.rs | 10 - .../src/foreign/tests/independent_witness.rs | 40 - .../src/foreign/tests/indexed_names.rs | 229 --- .../src/foreign/tests/quotient_hints.rs | 34 +- .../src/foreign/tests/range_hints.rs | 38 - .../foreign/tests/translation_intervals.rs | 155 -- crates/proof-compression/src/fri/pcs.rs | 137 -- crates/proof-compression/src/kzg/buffer.rs | 22 - crates/proof-compression/src/kzg/config.rs | 70 +- crates/proof-compression/src/kzg/cuda.rs | 10 +- .../cuda/{srs_cache.rs => srs_residency.rs} | 0 .../{srs_cache => srs_residency}/tests.rs | 0 .../proof-compression/src/kzg/cuda/tests.rs | 29 +- .../src/kzg/cuda/tests/plonk_scale.rs | 583 ------- crates/proof-compression/src/kzg/curve.rs | 49 +- .../kzg/{srs_cache.rs => dev_srs_cache.rs} | 75 +- crates/proof-compression/src/kzg/field.rs | 10 - crates/proof-compression/src/kzg/mod.rs | 3 +- crates/proof-compression/src/kzg/pcs.rs | 107 +- crates/proof-compression/src/kzg/srs.rs | 38 +- crates/proof-compression/src/kzg/srs/gnark.rs | 10 +- .../proof-compression/src/kzg/transcript.rs | 53 - .../src/kzg/verifier_params.rs | 17 - crates/proof-compression/src/lib.rs | 2 + .../{stages/statement.rs => claims.rs} | 22 +- .../src/pipeline/compress.rs | 27 +- .../proof-compression/src/pipeline/derive.rs | 6 +- .../src/pipeline/development.rs | 4 +- .../src/pipeline/final_proof.rs | 22 - .../proof-compression/src/pipeline/first.rs | 28 +- .../proof-compression/src/pipeline/gnark.rs | 6 +- .../proof-compression/src/pipeline/groth16.rs | 13 +- .../src/pipeline/init_claim.rs | 6 - .../src/pipeline/init_fri.rs | 24 +- .../{stages/construct.rs => lowering.rs} | 6 +- crates/proof-compression/src/pipeline/mod.rs | 27 +- .../proof-compression/src/pipeline/plonk.rs | 6 +- .../proof-compression/src/pipeline/prepare.rs | 10 +- .../proof-compression/src/pipeline/prover.rs | 5 +- crates/proof-compression/src/pipeline/root.rs | 270 +++- .../src/pipeline/{stages => root}/fixed.rs | 16 +- .../proof-compression/src/pipeline/setup.rs | 22 +- .../src/pipeline/{stages/mod.rs => stage.rs} | 230 ++- .../src/pipeline/{stages => stage}/tests.rs | 105 +- .../src/pipeline/stages/codec.rs | 130 -- .../src/pipeline/stages/root.rs | 225 --- .../src/pipeline/stages/setup.rs | 72 - .../src/pipeline/stages/wrap.rs | 202 --- .../src/pipeline/statement.rs | 2 +- .../proof-compression/src/pipeline/storage.rs | 5 +- .../src/pipeline/verifier.rs | 27 - .../src/pipeline/verifying_keys.rs | 29 + .../src/pipeline/wrap/mod.rs | 4 + .../src/pipeline/wrap/outer.rs | 135 +- .../verification.rs => wrap/recursive.rs} | 209 ++- .../src/pipeline/wrap/saved.rs | 20 +- .../src/recursive/native_field.rs | 20 +- .../src/recursive/native_field/advice.rs | 3 - .../recursive/native_field/advice/tests.rs | 170 +-- .../src/recursive/native_msm.rs | 98 -- .../native_verifier/parallel_prefix_tests.rs | 124 +- crates/proof-compression/src/test_support.rs | 45 + crates/proof-compression/tests/bn254_kzg.rs | 221 --- .../tests/integration/kzg.rs | 279 ++++ .../tests/{ => integration}/kzg_sharding.rs | 0 .../tests/{ => integration}/kzg_streaming.rs | 0 .../tests/integration/main.rs | 16 + .../tests/{ => integration}/parity_fixture.rs | 3 +- .../tests/{ => integration}/pipeline_root.rs | 0 .../tests/{ => integration}/plonkish_batch.rs | 3 +- .../tests/{ => integration}/plonkish_hash.rs | 0 .../tests/{ => integration}/plonkish_pcs.rs | 3 +- .../tests/{ => integration}/plonkish_plan.rs | 4 +- .../plonkish_verifier_algebra.rs | 6 +- .../plonkish_verifier_security.rs | 3 +- .../tests/{ => integration}/root_profile.rs | 3 +- .../tests/integration/support/mod.rs | 5 + .../tests/{ => integration}/support/parity.rs | 0 .../support/parity_algebra.rs | 0 .../{ => integration}/support/root_profile.rs | 0 .../support/verifier_audit.rs | 0 .../tests/kzg_transcripts.rs | 181 --- .../tests/plonkish_transcript.rs | 95 -- docs/aiur-gpu-proving.md | 38 +- docs/compression-stage-collapse.md | 2 +- docs/ffi.md | 2 +- docs/proof-benchmark-comparison.md | 3 +- flake.nix | 6 +- lakefile.lean | 2 +- tests/check_compression_build.py | 95 -- tests/compression_build_config.lean | 15 - tests/cuda_link_smoke.c | 11 +- tests/kzg_backend_parity.py | 86 +- tools/compression/run_gpu_stage.py | 143 -- tools/{plonk-wrapper => gnark}/README.md | 26 +- tools/{plonk-wrapper => gnark}/artifacts.go | 0 tools/{plonk-wrapper => gnark}/backend.go | 0 .../backend_bn254_test.go | 11 +- tools/{plonk-wrapper => gnark}/backend_cpu.go | 4 +- .../backend_cuda_bn254.go | 4 +- .../backend_cuda_bn254_test.go | 11 +- .../backend_selection_test.go | 9 + tools/{plonk-wrapper => gnark}/blake3.go | 0 tools/{plonk-wrapper => gnark}/blake3_test.go | 0 tools/{plonk-wrapper => gnark}/circuit.go | 0 .../circuit_bn254_test.go | 0 .../cmd/ignition/main.go | 0 .../cmd/ignition/main_test.go | 0 tools/gnark/cmd/solidity/main.go | 101 ++ tools/{plonk-wrapper => gnark}/curve_bn254.go | 0 tools/{plonk-wrapper => gnark}/ffi.go | 0 tools/{plonk-wrapper => gnark}/ffi.h | 0 tools/{plonk-wrapper => gnark}/ffi_groth16.go | 0 tools/{plonk-wrapper => gnark}/ffi_plonk.go | 0 tools/{plonk-wrapper => gnark}/ffi_test.go | 0 tools/{plonk-wrapper => gnark}/go.mod | 2 +- tools/{plonk-wrapper => gnark}/go.sum | 0 tools/{plonk-wrapper => gnark}/groth16.go | 0 .../{plonk-wrapper => gnark}/groth16_cuda.go | 0 .../groth16_cuda_test.go | 0 .../groth16_no_cuda.go | 2 +- .../{plonk-wrapper => gnark}/groth16_setup.go | 0 .../groth16_setup_test.go | 0 .../{plonk-wrapper => gnark}/groth16_test.go | 2 - .../internal/cuda/abi.go | 0 .../internal/cuda/accelerator.go | 0 .../internal/cuda/accelerator_test.go | 0 .../internal/cuda/masking_test.go | 0 .../internal/cuda/split_boundaries_test.go | 0 .../internal/cuda/structured_test.go | 0 .../internal/cuda/trace.go | 0 tools/{plonk-wrapper => gnark}/main.go | 0 tools/{plonk-wrapper => gnark}/parameters.go | 0 .../parameters_bn254_test.go | 9 +- tools/{plonk-wrapper => gnark}/profile.go | 0 tools/{plonk-wrapper => gnark}/prover.go | 0 .../test_helpers_test.go | 3 + .../testdata/bn254/provenance.json | 3 +- .../testdata/bn254/srs.bin | Bin .../testdata/bn254/vk.json | 0 .../testdata/bn254/witness-diagnostic.json | 19 + .../testdata/bn254/witness.json | 0 .../verifier_bn254_test.go | 5 +- tools/plonk-wrapper/backend_no_cuda_test.go | 11 - tools/plonk-wrapper/final_compare_test.go | 176 --- tools/plonk-wrapper/testdata/bn254/srs.json | 8 - .../testdata/bn254/witness-diagnostic.json | 38 - .../testdata/final-comparison.json | 198 --- 182 files changed, 1777 insertions(+), 6629 deletions(-) delete mode 100644 contracts/plonk/bn254/.gitignore delete mode 100644 contracts/plonk/bn254/bench.py delete mode 100644 contracts/plonk/bn254/provenance.json delete mode 100644 contracts/plonk/bn254/upstream/ISP1Verifier.sol delete mode 100644 contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol delete mode 100644 contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol rename contracts/plonk/{bn254 => }/src/PlonkVerifier.sol (98%) rename contracts/plonk/{bn254 => }/testdata/init.json (100%) delete mode 100644 crates/build_support/cuda.rs rename crates/ffi/src/aiur/aggregate/{lanes.rs => multi_gpu.rs} (97%) rename crates/ffi/src/aiur/aggregate/{lanes => multi_gpu}/checkpoint.rs (100%) rename crates/ffi/src/aiur/aggregate/{lanes => multi_gpu}/limits.rs (91%) rename crates/ffi/src/aiur/aggregate/{lanes => multi_gpu}/queue.rs (100%) delete mode 100644 crates/proof-compression/src/kzg/buffer.rs rename crates/proof-compression/src/kzg/cuda/{srs_cache.rs => srs_residency.rs} (100%) rename crates/proof-compression/src/kzg/cuda/{srs_cache => srs_residency}/tests.rs (100%) delete mode 100644 crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs rename crates/proof-compression/src/kzg/{srs_cache.rs => dev_srs_cache.rs} (83%) rename crates/proof-compression/src/pipeline/{stages/statement.rs => claims.rs} (90%) delete mode 100644 crates/proof-compression/src/pipeline/init_claim.rs rename crates/proof-compression/src/pipeline/{stages/construct.rs => lowering.rs} (98%) rename crates/proof-compression/src/pipeline/{stages => root}/fixed.rs (98%) rename crates/proof-compression/src/pipeline/{stages/mod.rs => stage.rs} (54%) rename crates/proof-compression/src/pipeline/{stages => stage}/tests.rs (77%) delete mode 100644 crates/proof-compression/src/pipeline/stages/codec.rs delete mode 100644 crates/proof-compression/src/pipeline/stages/root.rs delete mode 100644 crates/proof-compression/src/pipeline/stages/setup.rs delete mode 100644 crates/proof-compression/src/pipeline/stages/wrap.rs delete mode 100644 crates/proof-compression/src/pipeline/verifier.rs rename crates/proof-compression/src/pipeline/{stages/verification.rs => wrap/recursive.rs} (54%) create mode 100644 crates/proof-compression/src/test_support.rs delete mode 100644 crates/proof-compression/tests/bn254_kzg.rs create mode 100644 crates/proof-compression/tests/integration/kzg.rs rename crates/proof-compression/tests/{ => integration}/kzg_sharding.rs (100%) rename crates/proof-compression/tests/{ => integration}/kzg_streaming.rs (100%) create mode 100644 crates/proof-compression/tests/integration/main.rs rename crates/proof-compression/tests/{ => integration}/parity_fixture.rs (99%) rename crates/proof-compression/tests/{ => integration}/pipeline_root.rs (100%) rename crates/proof-compression/tests/{ => integration}/plonkish_batch.rs (99%) rename crates/proof-compression/tests/{ => integration}/plonkish_hash.rs (100%) rename crates/proof-compression/tests/{ => integration}/plonkish_pcs.rs (99%) rename crates/proof-compression/tests/{ => integration}/plonkish_plan.rs (99%) rename crates/proof-compression/tests/{ => integration}/plonkish_verifier_algebra.rs (99%) rename crates/proof-compression/tests/{ => integration}/plonkish_verifier_security.rs (99%) rename crates/proof-compression/tests/{ => integration}/root_profile.rs (99%) create mode 100644 crates/proof-compression/tests/integration/support/mod.rs rename crates/proof-compression/tests/{ => integration}/support/parity.rs (100%) rename crates/proof-compression/tests/{ => integration}/support/parity_algebra.rs (100%) rename crates/proof-compression/tests/{ => integration}/support/root_profile.rs (100%) rename crates/proof-compression/tests/{ => integration}/support/verifier_audit.rs (100%) delete mode 100644 crates/proof-compression/tests/kzg_transcripts.rs delete mode 100644 crates/proof-compression/tests/plonkish_transcript.rs delete mode 100644 tests/check_compression_build.py delete mode 100644 tests/compression_build_config.lean delete mode 100644 tools/compression/run_gpu_stage.py rename tools/{plonk-wrapper => gnark}/README.md (92%) rename tools/{plonk-wrapper => gnark}/artifacts.go (100%) rename tools/{plonk-wrapper => gnark}/backend.go (100%) rename tools/{plonk-wrapper => gnark}/backend_bn254_test.go (95%) rename tools/{plonk-wrapper => gnark}/backend_cpu.go (63%) rename tools/{plonk-wrapper => gnark}/backend_cuda_bn254.go (92%) rename tools/{plonk-wrapper => gnark}/backend_cuda_bn254_test.go (88%) rename tools/{plonk-wrapper => gnark}/backend_selection_test.go (54%) rename tools/{plonk-wrapper => gnark}/blake3.go (100%) rename tools/{plonk-wrapper => gnark}/blake3_test.go (100%) rename tools/{plonk-wrapper => gnark}/circuit.go (100%) rename tools/{plonk-wrapper => gnark}/circuit_bn254_test.go (100%) rename tools/{plonk-wrapper => gnark}/cmd/ignition/main.go (100%) rename tools/{plonk-wrapper => gnark}/cmd/ignition/main_test.go (100%) create mode 100644 tools/gnark/cmd/solidity/main.go rename tools/{plonk-wrapper => gnark}/curve_bn254.go (100%) rename tools/{plonk-wrapper => gnark}/ffi.go (100%) rename tools/{plonk-wrapper => gnark}/ffi.h (100%) rename tools/{plonk-wrapper => gnark}/ffi_groth16.go (100%) rename tools/{plonk-wrapper => gnark}/ffi_plonk.go (100%) rename tools/{plonk-wrapper => gnark}/ffi_test.go (100%) rename tools/{plonk-wrapper => gnark}/go.mod (94%) rename tools/{plonk-wrapper => gnark}/go.sum (100%) rename tools/{plonk-wrapper => gnark}/groth16.go (100%) rename tools/{plonk-wrapper => gnark}/groth16_cuda.go (100%) rename tools/{plonk-wrapper => gnark}/groth16_cuda_test.go (100%) rename tools/{plonk-wrapper => gnark}/groth16_no_cuda.go (58%) rename tools/{plonk-wrapper => gnark}/groth16_setup.go (100%) rename tools/{plonk-wrapper => gnark}/groth16_setup_test.go (100%) rename tools/{plonk-wrapper => gnark}/groth16_test.go (99%) rename tools/{plonk-wrapper => gnark}/internal/cuda/abi.go (100%) rename tools/{plonk-wrapper => gnark}/internal/cuda/accelerator.go (100%) rename tools/{plonk-wrapper => gnark}/internal/cuda/accelerator_test.go (100%) rename tools/{plonk-wrapper => gnark}/internal/cuda/masking_test.go (100%) rename tools/{plonk-wrapper => gnark}/internal/cuda/split_boundaries_test.go (100%) rename tools/{plonk-wrapper => gnark}/internal/cuda/structured_test.go (100%) rename tools/{plonk-wrapper => gnark}/internal/cuda/trace.go (100%) rename tools/{plonk-wrapper => gnark}/main.go (100%) rename tools/{plonk-wrapper => gnark}/parameters.go (100%) rename tools/{plonk-wrapper => gnark}/parameters_bn254_test.go (87%) rename tools/{plonk-wrapper => gnark}/profile.go (100%) rename tools/{plonk-wrapper => gnark}/prover.go (100%) rename tools/{plonk-wrapper => gnark}/test_helpers_test.go (90%) rename tools/{plonk-wrapper => gnark}/testdata/bn254/provenance.json (84%) rename tools/{plonk-wrapper => gnark}/testdata/bn254/srs.bin (100%) rename tools/{plonk-wrapper => gnark}/testdata/bn254/vk.json (100%) create mode 100644 tools/gnark/testdata/bn254/witness-diagnostic.json rename tools/{plonk-wrapper => gnark}/testdata/bn254/witness.json (100%) rename tools/{plonk-wrapper => gnark}/verifier_bn254_test.go (95%) delete mode 100644 tools/plonk-wrapper/backend_no_cuda_test.go delete mode 100644 tools/plonk-wrapper/final_compare_test.go delete mode 100644 tools/plonk-wrapper/testdata/bn254/srs.json delete mode 100644 tools/plonk-wrapper/testdata/bn254/witness-diagnostic.json delete mode 100644 tools/plonk-wrapper/testdata/final-comparison.json diff --git a/.github/workflows/bench-main.yml b/.github/workflows/bench-main.yml index fc6f5df90..a545749d2 100644 --- a/.github/workflows/bench-main.yml +++ b/.github/workflows/bench-main.yml @@ -71,10 +71,6 @@ jobs: - uses: $/.github/actions/setup-rust-toolchain with: use-github-cache: "false" - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: "1.26.8" - cache: false - uses: leanprover/lean-action@50fcf42d2e460296f1a34b402e990d1b24f8b596 # v1.6.0 with: auto-config: false diff --git a/.github/workflows/bench-pr.yml b/.github/workflows/bench-pr.yml index 3eb9aa23b..a2ed3296c 100644 --- a/.github/workflows/bench-pr.yml +++ b/.github/workflows/bench-pr.yml @@ -218,10 +218,6 @@ jobs: with: use-github-cache: "false" # A persistent hit still needs the matching toolchain for libleanshared. - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: "1.26.8" - cache: false - uses: leanprover/lean-action@50fcf42d2e460296f1a34b402e990d1b24f8b596 # v1.6.0 with: auto-config: false @@ -782,11 +778,6 @@ jobs: uses: $/.github/actions/log-cpu with: label: Base benchmark binary build CPU - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - if: steps.decide.outputs.run-base == 'true' && steps.base-src.outputs.cached != 'true' - with: - go-version: "1.26.8" - cache: false - name: Build base (ix, bench-typecheck) if: steps.decide.outputs.run-base == 'true' && steps.base-src.outputs.cached != 'true' uses: leanprover/lean-action@50fcf42d2e460296f1a34b402e990d1b24f8b596 # v1.6.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e67e3fdb3..638a62f49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,8 +44,8 @@ jobs: use-github-cache: "false" - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/plonk-wrapper/go.mod - cache-dependency-path: tools/plonk-wrapper/go.sum + go-version-file: tools/gnark/go.mod + cache-dependency-path: tools/gnark/go.sum # lean-action runs `lake build`, then `lake test`, then `lake lint`. # The build-all lint driver compiles every lib/exe target with --wfail, # so a warning in any target (exes, benchmarks, Apps) — not just the @@ -95,13 +95,13 @@ jobs: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/plonk-wrapper/go.mod - cache-dependency-path: tools/plonk-wrapper/go.sum + go-version-file: tools/gnark/go.mod + cache-dependency-path: tools/gnark/go.sum - name: Test the BN254 CPU wrapper - working-directory: tools/plonk-wrapper + working-directory: tools/gnark run: go test ./... - name: Check Go formatting - working-directory: tools/plonk-wrapper + working-directory: tools/gnark run: test -z "$(gofmt -l .)" rust-test: @@ -135,8 +135,8 @@ jobs: use-github-cache: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/plonk-wrapper/go.mod - cache-dependency-path: tools/plonk-wrapper/go.sum + go-version-file: tools/gnark/go.mod + cache-dependency-path: tools/gnark/go.sum # A push to main already passed the lints and tests in the merge # queue; that run only builds, so the sticky disk holds the check, # clippy and test-binary artifacts the next PR run will fingerprint. @@ -197,8 +197,8 @@ jobs: cache-key: cuda-13.3.1-ubuntu26 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version-file: tools/plonk-wrapper/go.mod - cache-dependency-path: tools/plonk-wrapper/go.sum + go-version-file: tools/gnark/go.mod + cache-dependency-path: tools/gnark/go.sum # S3 caching keeps the container independent of host bind mounts. - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -211,8 +211,8 @@ jobs: use-github-cache: false - name: Build pinned BN254 ICICLE libraries run: | - go -C tools/plonk-wrapper mod download github.com/ingonyama-zk/icicle-gnark/v3 - icicleSource=$(go -C tools/plonk-wrapper list -m -f '{{.Dir}}' github.com/ingonyama-zk/icicle-gnark/v3) + go -C tools/gnark mod download github.com/ingonyama-zk/icicle-gnark/v3 + icicleSource=$(go -C tools/gnark list -m -f '{{.Dir}}' github.com/ingonyama-zk/icicle-gnark/v3) cmake -S "$icicleSource/icicle" -B "$RUNNER_TEMP/icicle-build" -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/icicle" \ -DCURVE=bn254 -DMSM=ON -DNTT=ON -DG2=ON -DCPU_BACKEND=OFF \ diff --git a/Cargo.lock b/Cargo.lock index 76186ccd2..cf4b8594d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "multi-stark" version = "0.1.0" -source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=1b5b65d501bf87bfc684f682d88b996e803142ea#1b5b65d501bf87bfc684f682d88b996e803142ea" +source = "git+https://github.com/argumentcomputer/multi-stark.git?rev=c270c9ddb51b35845c80c848558e239cc70155f6#c270c9ddb51b35845c80c848558e239cc70155f6" dependencies = [ "bincode", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index de566ebe4..3c00403f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ log = "0.4" memmap2 = "0.9" # Bundle v3.5.1 to fix large-arena failures (microsoft/mimalloc#1309). mimalloc = { git = "https://github.com/argumentcomputer/mimalloc_rust.git", rev = "616cc1f130d9ea2a1111a46b4e98c140e078f882", default-features = false } -multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "1b5b65d501bf87bfc684f682d88b996e803142ea" } +multi-stark = { git = "https://github.com/argumentcomputer/multi-stark.git", rev = "c270c9ddb51b35845c80c848558e239cc70155f6" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" diff --git a/Ix/Aggr/FFI.lean b/Ix/Aggr/FFI.lean index 95c28f923..6625fa21a 100644 --- a/Ix/Aggr/FFI.lean +++ b/Ix/Aggr/FFI.lean @@ -115,16 +115,16 @@ in Rust; nothing is copied across the FFI), every claim and join scheduled over them as their inputs become ready, the root wrapped, and every claim proof verified natively at the end. `maxRamBytes` is one worker's host budget (0 detects); `execJobs` the claim executions ahead of each prover; -`lanes` the GPU count (0 uses every visible GPU). +`gpus` the GPU count (0 uses every visible GPU). Oversized environment claims are bisected and checkpointed; completed proofs are reused. A nonempty `outManifest` receives the final partition. `fresh` bypasses earlier proof indexes and refinement checkpoints while publishing newly verified results to the usual cache locations. Returns the verified root address; progress is printed on stderr. -/ -@[extern "rs_aiur_prove_lanes"] -opaque proveLanes (ixvmSystem aggrSystem : @& AiurSystem) +@[extern "rs_aiur_prove_multi_gpu"] +opaque proveMultiGpu (ixvmSystem aggrSystem : @& AiurSystem) (envHandle : @& EnvHandle) (manifestPath : @& String) - (verifyIdx aggrIdx lanes maxRamBytes execJobs structuralAbove : @& Nat) + (verifyIdx aggrIdx gpus maxRamBytes execJobs structuralAbove : @& Nat) (cacheFriBytes : @& ByteArray) (outManifest : @& String) (fresh : Bool := false) : Except String String end AiurSystem diff --git a/Ix/Cli/ProveCmd.lean b/Ix/Cli/ProveCmd.lean index ac2bcd7f5..7daf3399b 100644 --- a/Ix/Cli/ProveCmd.lean +++ b/Ix/Cli/ProveCmd.lean @@ -395,7 +395,7 @@ def runProveCmd (p : Cli.Parsed) : IO UInt32 := do proveOneShardRoot ixe manifest ixvmSystem ixvmCompiled aggrSystem envHandle verifyIdx aggrIdx maxRamBytes retention rp.cacheFriBytes fresh else IO.lazyPure fun _ => - (Aiur.AiurSystem.proveLanes ixvmSystem aggrSystem envHandle manifest + (Aiur.AiurSystem.proveMultiGpu ixvmSystem aggrSystem envHandle manifest verifyIdx aggrIdx gpus maxRamBytes (((p.flag? "exec-jobs").map (·.as! Nat)).getD 0) 0 rp.cacheFriBytes (outIxes.getD "") fresh) diff --git a/Tests/CompressionCli.lean b/Tests/CompressionCli.lean index 03457853c..cec714e32 100644 --- a/Tests/CompressionCli.lean +++ b/Tests/CompressionCli.lean @@ -17,8 +17,7 @@ private def invoke (home : System.FilePath) (args : Array String) (setupEnv : Array (String × Option String) := #[]) : IO IO.Process.Output := do let ix ← IO.FS.realPath ".lake/build/bin/ix" IO.Process.output { cmd := ix.toString, args, env := #[("HOME", some home.toString), - ("IX_KZG_IGNITION_CACHE", none), ("IX_KZG_IGNITION_CACHE_BLAKE3", none), - ("IX_KZG_IGNITION_ADMISSION", none), ("IX_KZG_IGNITION_ADMISSION_BLAKE3", none), + ("IX_KZG_SRS", none), ("IX_KZG_SRS_CACHE", none), ("IX_PARAMS_DIR", some (home / "params").toString), ("IX_GROTH16_ARTIFACTS", none)] ++ setupEnv } @@ -35,12 +34,11 @@ public def run : IO Unit := do unless Ix.Cli.Compression.isCompressed tag.toUTF8 do throw <| IO.userError s!"unrecognized compressed proof family: {tag}" let home ← IO.FS.createTempDir + let setupError := "no BN254 Ignition SRS is installed; run `ix compress setup`" try let help ← invoke home #["compress", "--help"] unless help.exitCode == 0 && (help.stdout.splitOn "--root-vk").length > 1 do throw <| IO.userError s!"compress help failed: {help.stdout} {help.stderr}" - unless (help.stdout.splitOn "--dev-groth16").length == 1 do - throw <| IO.userError "compression help exposes a development proof-system selector" let claim := Ix.Claim.checkEnv (Address.blake3 "test".toUTF8) none let native : Ixon.Proof := { claim, proof := "native".toUTF8 } let compressed : Ixon.Proof := { claim, proof := "IXCMP".toUTF8 } @@ -51,19 +49,16 @@ public def run : IO Unit := do let otherAddr ← putProof home (Ixon.Proof.ser other) rejects home #["compress", toString old, "--groth16", "--plonk"] "choose at most one" - rejects home #["compress", toString old, "--dev-groth16"] - "Unknown flag" - rejects home #["compress", toString old, "--dev-srs"] "Unknown flag" rejects home #["prove", "--ixe", "missing.ixe", "--ixes", "missing.ixes", - "--compress", "dev-groth16"] "unknown compression output" + "--compress", "fri"] "unknown compression output" rejects home #["compress", toString old, "--dev-final-srs"] - "requires a final proof system" + "--dev-final-srs requires a final proof system" for finalFlag in #["--plonk", "--groth16"] do rejects home #["compress", toString old, finalFlag, "--dev-kzg-srs"] - "requires --dev-final-srs" + "development inner KZG requires --dev-final-srs with a final proof" unless old != addr && native.claim == compressed.claim do throw <| IO.userError "compression must preserve claim identity and change proof identity" - rejects home #["compress", toString old] "--dev-kzg-srs" + rejects home #["compress", toString old] setupError rejects home #["compress", toString otherAddr, "--dev-kzg-srs", "--out", (home / "run").toString] "aggregate CheckEnv proof" unless !(← (home / "run").pathExists) do @@ -84,19 +79,17 @@ public def run : IO Unit := do unless !(← storedFile.pathExists) && !(← (home / "file-run").pathExists) do throw <| IO.userError "file input was imported into the store or created output before validation" rejects home #["compress", toString addr, "--dev-kzg-srs"] "already compressed" - let cacheVar := "IX_KZG_IGNITION_CACHE" - let setupError := "incomplete BN254 Ignition configuration" - let missingCache := #[(cacheVar, some (home / "missing-setup-cache").toString)] - rejects home #["compress", toString addr] setupError missingCache - rejects home #["compress", toString addr, "--dev-kzg-srs"] "already compressed" missingCache - rejects home #["compress", toString addr, "--groth16", "--dev-final-srs"] setupError missingCache + let missingSrs := #[("IX_KZG_SRS", some (home / "missing-srs.bin").toString)] + rejects home #["compress", toString addr] setupError missingSrs + rejects home #["compress", toString addr, "--dev-kzg-srs"] "already compressed" missingSrs + rejects home #["compress", toString addr, "--groth16", "--dev-final-srs"] setupError missingSrs for finalFlag in #["--plonk", "--groth16"] do rejects home #["compress", toString addr, finalFlag, "--dev-kzg-srs", "--dev-final-srs"] - "already compressed" missingCache + "already compressed" missingSrs rejects home #["compress", filePath.toString, finalFlag, "--dev-kzg-srs", "--dev-final-srs"] - "aggregate CheckEnv proof" missingCache + "aggregate CheckEnv proof" missingSrs rejects home #["prove", "--ixe", "missing.ixe", "--ixes", "missing.ixes", - "--compress", "groth16", "--dev-final-srs"] setupError missingCache + "--compress", "groth16", "--dev-final-srs"] setupError missingSrs rejects home #["verify", toString addr] "unsupported compressed proof version" rejects home #["verify", toString addr, "--dev-srs"] "unsupported compressed proof version" rejects home #["verify", toString addr, "--verifier-bundle", "/missing", "--dev-srs"] @@ -104,9 +97,6 @@ public def run : IO Unit := do rejects home #["verify", toString addr, "--verifier-bundle", "/missing", "--verifier-bundle-blake3", String.ofList (List.replicate 64 '0')] "requires --dev-srs" - let removed ← invoke home #["verifier", "import", "/missing"] - unless removed.exitCode != 0 do - throw <| IO.userError "external verifier import remains available" let unknownKey := ByteArray.mk #[73, 88, 66, 78, 0, 0, 0, 1] ++ ByteArray.mk (Array.replicate 32 0) diff --git a/contracts/plonk/README.md b/contracts/plonk/README.md index 69be59024..38640e801 100644 --- a/contracts/plonk/README.md +++ b/contracts/plonk/README.md @@ -5,120 +5,30 @@ little-endian 128-bit public inputs and calls a fixed gnark verifier address. The gnark key fixes the complete wrapper circuit, including both inner pairing obligations. BN254 uses Ethereum's BN254 precompiles. -Use `PlonkVerifier.sol` and `proof.solidity.bin` emitted by the helper for the -reviewed wrapper key. Deploy the generated verifier and pass its address to -the adapter constructor. Applications must select the intended adapter and -derive the expected digest independently of the proof. A successful call must -return `true`. - -The tests use the checked-in BN254 Init verifier and proof, with acceptance, -changed-digest, malformed-proof, and deployment-size checks: +`src/PlonkVerifier.sol` is gnark's own Solidity export of the bundled release +key, `crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin`. +Regenerate it from the repository root whenever that key changes: ```sh -forge test --root contracts/plonk -vv +go -C tools/gnark run ./cmd/solidity -system plonk \ + -key ../../crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin \ + -out ../../contracts/plonk/src/PlonkVerifier.sol ``` -The pinned test configuration uses solc 0.8.37, via-IR, 200 optimizer runs, -Prague bytecode and Osaka execution rules. Keep deployment, external-call gas -and total transaction gas separate in benchmark results. The target for a full -wrapper transaction is at most 500k gas and at least 25% below the validated -optimized direct verifier. - -## Actual BN254 Init proof on Anvil - -The actual `2^24` BN254 Init proof passes the adapted SP1 v6.1.0 PLONK verifier -and the ix claim-digest adapter on local Anvil. The fixture was refreshed on -2026-10-19 for the recursive KZG packet after the opening-reduction, lazy-byte- -nibble and 20-bit range-limb optimizations, using admitted Ignition parameters. -It uses the 14,689,440-constraint classic-GLV circuit with both inner pairing -checks and the original independently expected Init claim digest. - -The matching PLONK verifying-key SHA-256 is -`32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac`. -The older October 17 key does not identify this optimized circuit. - -The untouched SP1 verifier rejects the proof. Its fixed circuit key uses five -public inputs and domain `2^25`; ix uses two inputs and domain `2^24`. The test -also supplies five inputs to the stock low-level verifier and correctly framed -SP1 selector/exit-code/vk-root/nonce bytes to its high-level facade. Those paths -reject cryptographically; the facade returns `InvalidProof()`. - -Only **23 `VK_*` constants** differ between the adapted contract and SP1's -low-level contract: public-input count, domain/inverse/root, circuit commitments -and the commitment-API index. The verification algorithm, SRS anchors, proof -layout and transcript logic are unchanged. With the pragma normalized, the -adapted source equals the verifier emitted by our pinned gnark helper byte for -byte. The existing ix adapter supplies the two little-endian 128-bit limbs of -the independently expected claim digest, instead of SP1's five-field public -statement mapping. The proof itself is unchanged. - -Source: [SP1 v6.1.0 verifier](https://github.com/succinctlabs/sp1-contracts/blob/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/v6.1.0/PlonkVerifier.sol). -The original sources, licenses and hashes are retained under `bn254/upstream`; -`bn254/provenance.json` records every changed constant and the authenticated ix -key/proof identities. `bn254/src/PlonkVerifier.sol` is specific to this -benchmark circuit, not an interchangeable SP1 verification key. - -### Receipt measurements +Deploy the generated verifier and pass its address to the adapter +constructor. Applications must select the intended adapter and derive the +expected digest independently of the proof. Proofs use gnark's +`MarshalSolidity` encoding; the native and envelope encodings are rejected. A +successful call must return `true`. -Measured on 2026-10-19 with Foundry/Anvil 1.8.5, solc 0.8.37, via-IR, optimizer -200 runs, Prague bytecode and Osaka execution rules. Deployment-size limits -were enabled. These are mined transactions on a fresh local chain: - -| Path | Execution gas | Intrinsic/calldata gas | Total receipt gas | ABI calldata | -| --- | ---: | ---: | ---: | ---: | -| Adapted SP1 `Verify(bytes,uint256[])` | 279,915 | 36,076 | **315,991** | 1,060 bytes | -| ix `verify(bytes,bytes32)` adapter | 284,435 | 35,656 | **320,091** | 964 bytes | - -The Solidity proof is **864 bytes**. The native 584-byte encoding and 624-byte -ix envelope are storage/interchange formats and are rejected by this Solidity -interface. Both successful calls return `true`; transaction traces confirm the -return value as well as the receipt status. The totals exceed the EIP-7623 -calldata floor, so the execution/intrinsic split above applies. - -The adapted verifier has **7,533 bytes** of runtime code and **7,559 bytes** of -initcode; deployment used **1,681,703 gas**. The adapter has **913 runtime bytes** -and **1,159 initcode bytes including its constructor argument**; deployment used -**255,356 gas**. All fit ordinary EIP-170/EIP-3860 limits. These deployment costs -are excluded from verification gas. - -The valid trace calls SHA-256 nine times, MODEXP six times, BN254 scalar -multiplication 21 times, BN254 addition 21 times and the two-pair BN254 pairing -precompile once. Their combined precompile gas is 257,366. Both inner pairing -obligations are already constrained by the PLONK circuit; no extra application -pairing calls are required. - -All **47 acceptance/rejection checks passed**: stock-key incompatibility, -positive raw/adapter verification, both digest halves, swapped/missing/extra or -noncanonical public inputs, empty/truncated/trailing proofs, wrong proof formats, -mutations of all 27 proof words, and noncanonical point/scalar encodings. Every -call uses the same 2M gas bound, including the positive controls. The test uses -only an isolated local Anvil chain and shuts down its own node after completion. - -The GPU proof took **11.557 s** inside `plonk.Prove` and **33.872 s** for the -complete fresh prover process, with **20.64 GiB** peak host RSS. This is one -run on 32 logical Xeon Platinum 8559C CPUs and one RTX PRO 6000 Blackwell, -using prepared keys and default parallelism. One-time key preparation took -**495.843 s**, separately from proving. An independent CPU helper accepted -the proof with pinned Ignition anchors and rejected an altered claim and -corrupted proof. - -### Reproduce - -Use the hash-pinned Foundry and Solidity binaries documented in -[`../kzg/README.md`](../kzg/README.md#run-local-checks). No prover, proving key, -SRS download or new proof generation is needed: +The tests use the proof in `testdata/init.json`, whose `verifyingKeySHA256` +must match the release key. They cover acceptance, changed digests, malformed +proofs and deployment-size limits: ```sh -python3 contracts/plonk/bn254/bench.py \ - --tools target/toolchains/solidity \ - --out target/plonk-evm/replay +forge test --root contracts/plonk -vv ``` -The output directory must be new. Compilation input/output, deployment and -verification receipts, call traces, negative-call results and the Anvil log are -saved there. The refreshed run is -`target/plonk-refresh-20261019/anvil/report.json`. Matching keys, proof, -exported witness and frozen CPU/CUDA helpers are retained at -`/opt/dlami/nvme/ix-bn254/plonk/optimized-init-20261019/`. -This validates the exported circuit key and fixture; production applications -still select their intended key and obtain the expected digest independently. +The pinned test configuration uses solc 0.8.37, via-IR, 200 optimizer runs, +Prague bytecode and Osaka execution rules. Keep deployment, external-call gas +and total transaction gas separate in benchmark results. diff --git a/contracts/plonk/bn254/.gitignore b/contracts/plonk/bn254/.gitignore deleted file mode 100644 index c18dd8d83..000000000 --- a/contracts/plonk/bn254/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__/ diff --git a/contracts/plonk/bn254/bench.py b/contracts/plonk/bn254/bench.py deleted file mode 100644 index d0c0ee5b7..000000000 --- a/contracts/plonk/bn254/bench.py +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env python3 -"""Verify the pinned Init proof with stock and adapted SP1 contracts on local Anvil.""" - -import argparse -import hashlib -import json -import re -import socket -import subprocess -import time -import urllib.error -import urllib.request -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -R = 21888242871839275222246405745257275088548364400416034343698204186575808495617 -P = 21888242871839275222246405745257275088696311157297823662689037894645226208583 -CALL_GAS = 2_000_000 - - -def read(path): - return json.loads(path.read_text()) - - -def write(path, value): - path.write_text(json.dumps(value, indent=2) + '\n') - - -def sha(data): - return hashlib.sha256(data).hexdigest() - - -def word(value): - return value.to_bytes(32, 'big') - - -def blob(value): - return word(len(value)) + value + bytes((-len(value)) % 32) - - -class RpcError(Exception): - def __init__(self, error): - super().__init__(json.dumps(error)) - self.error = error - - -class Rpc: - def __init__(self, url): - self.url = url - self.sequence = 0 - - def __call__(self, method, params=None): - self.sequence += 1 - body = json.dumps({'jsonrpc': '2.0', 'id': self.sequence, - 'method': method, 'params': params or []}).encode() - request = urllib.request.Request(self.url, body, {'Content-Type': 'application/json'}) - with urllib.request.urlopen(request, timeout=30) as response: - result = json.load(response) - if 'error' in result: - raise RpcError(result['error']) - return result['result'] - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--tools', type=Path, required=True, help='directory containing solc, anvil and cast') - parser.add_argument('--out', type=Path, required=True, help='new directory for compiler output and receipts') - args = parser.parse_args() - args.tools = args.tools.resolve() - out = args.out.resolve() - out.mkdir(parents=True, exist_ok=False) - versions = {name: subprocess.check_output([str(args.tools / name), '--version'], text=True).strip() - for name in ['solc', 'anvil', 'cast']} - assert '0.8.37+' in versions['solc'] and '1.8.5' in versions['anvil'], versions - fixture_path = ROOT / 'testdata/init.json' - fixture, provenance = read(fixture_path), read(ROOT / 'provenance.json') - assert sha(fixture_path.read_bytes()) == provenance['adaptation']['fixture_sha256'] - proof = bytes.fromhex(fixture['proof'][2:]) - digest = bytes.fromhex(fixture['claimDigest'][2:]) - claim = bytes.fromhex(fixture['claim'][2:]) - assert len(proof) == 864 and len(digest) == 32 - assert sha(proof) == provenance['adaptation']['proof_solidity_sha256'] - assert fixture['verifyingKeySHA256'] == provenance['adaptation']['verifying_key_sha256'] - for name, record in provenance['files'].items(): - directory = 'upstream' if name == 'ISP1Verifier.sol' else 'upstream/v6.1.0' - assert sha((ROOT / directory / name).read_bytes()) == record['sha256'], name - source = (ROOT / 'src/PlonkVerifier.sol').read_text() - assert sha(source.encode()) == provenance['adaptation']['adapted_source_sha256'] - stock_source = (ROOT / 'upstream/v6.1.0/PlonkVerifier.sol').read_text() - substitutions = provenance['adaptation']['changed_constants'] - pattern = r'(uint256 private constant )(VK_\w+)( = )([^;]+)(;)' - adapted = re.sub(pattern, lambda m: m[1] + m[2] + m[3] + substitutions.get(m[2], {'ix': m[4]})['ix'] + m[5], stock_source) - assert adapted == source, 'adaptation changed more than the pinned key constants' - assert sha(source.replace('pragma solidity ^0.8.20;', 'pragma solidity ^0.8.0;').encode()) == provenance['adaptation']['generated_gnark_source_sha256'] - sources = {str(p.relative_to(ROOT)): {'content': p.read_text()} - for p in [ROOT / 'src/PlonkVerifier.sol', ROOT / 'upstream/ISP1Verifier.sol', - ROOT / 'upstream/v6.1.0/PlonkVerifier.sol', ROOT / 'upstream/v6.1.0/SP1VerifierPlonk.sol']} - sources['IxPlonkVerifier.sol'] = {'content': (ROOT.parent / 'src/IxPlonkVerifier.sol').read_text()} - compiler = {'language': 'Solidity', 'sources': sources, 'settings': { - 'optimizer': {'enabled': True, 'runs': 200}, 'viaIR': True, 'evmVersion': 'prague', - 'outputSelection': {'*': {'*': ['abi', 'evm.bytecode.object', 'evm.deployedBytecode.object']}}}} - write(out / 'compiler-input.json', compiler) - print('Compiling stock SP1, adapted SP1 and ix adapter contracts.', flush=True) - compiled = subprocess.run([str(args.tools / 'solc'), '--standard-json'], - input=json.dumps(compiler), capture_output=True, text=True, check=True) - (out / 'compiler-output.json').write_text(compiled.stdout) - output = json.loads(compiled.stdout) - errors = [e for e in output.get('errors', []) if e['severity'] == 'error'] - assert not errors, errors - - def selector(signature): - return bytes.fromhex(subprocess.check_output([str(args.tools / 'cast'), 'sig', signature], text=True).strip()[2:]) - - verify_selector = selector('Verify(bytes,uint256[])') - adapter_selector = selector('verify(bytes,bytes32)') - facade_selector = selector('verifyProof(bytes32,bytes,bytes)') - inputs = [int.from_bytes(digest[:16], 'little'), int.from_bytes(digest[16:], 'little')] - - def raw_call(payload=proof, public=inputs): - encoded = blob(payload) - return verify_selector + word(64) + word(64 + len(encoded)) + encoded + word(len(public)) + b''.join(word(x) for x in public) - - def adapter_call(payload=proof, expected=digest): - return adapter_selector + word(64) + expected + blob(payload) - - report = {'format': 'ix-bn254-plonk-anvil-v1', 'toolchain': versions, - 'compiler_settings': compiler['settings'], 'execution_hardfork': 'osaka', - 'fixture_sha256': sha(fixture_path.read_bytes()), 'verifying_key_sha256': fixture['verifyingKeySHA256'], - 'claim_digest': fixture['claimDigest'], 'sp1_commit': provenance['commit'], - 'changed_vk_constants': list(substitutions), 'verifier_algorithm_unchanged': True, - 'proof_solidity_bytes': len(proof), 'native_proof_bytes': len(bytes.fromhex(fixture['nativeProof'][2:])), - 'envelope_bytes': len(bytes.fromhex(fixture['envelope'][2:])), - 'deployments': {}, 'checks': {}, 'transactions': {}, 'call_gas_limit': CALL_GAS} - with socket.socket() as listener: - listener.bind(('127.0.0.1', 0)) - port = listener.getsockname()[1] - rpc = Rpc(f'http://127.0.0.1:{port}') - anvil_log = (out / 'anvil.log').open('w') - process = subprocess.Popen([str(args.tools / 'anvil'), '--host', '127.0.0.1', '--port', str(port), - '--hardfork', 'osaka', '--chain-id', '31337', '--gas-limit', '30000000', - '--steps-tracing'], stdout=anvil_log, stderr=subprocess.STDOUT) - try: - for _ in range(100): - if process.poll() is not None: - raise RuntimeError('Anvil exited before becoming ready') - try: - if rpc('eth_chainId') == hex(31337): - break - except (OSError, urllib.error.URLError): - time.sleep(0.1) - else: - raise RuntimeError('Anvil did not become ready') - report['client_version'] = rpc('web3_clientVersion') - account = rpc('eth_accounts')[0] - - def transaction(name, data, address=None, gas=CALL_GAS): - tx = {'from': account, 'data': '0x' + data.hex(), 'gas': hex(gas)} - if address is not None: - tx['to'] = address - txid = rpc('eth_sendTransaction', [tx]) - for _ in range(100): - receipt = rpc('eth_getTransactionReceipt', [txid]) - if receipt is not None: - break - time.sleep(0.05) - else: - raise RuntimeError('missing transaction receipt') - assert int(receipt['status'], 16) == 1, (name, receipt) - write(out / f'{name}-receipt.json', receipt) - result = {'transaction_hash': txid, 'gas_used': int(receipt['gasUsed'], 16), 'calldata_bytes': len(data)} - if address is not None: - zeros = data.count(0) - intrinsic = 21000 + 4 * zeros + 16 * (len(data) - zeros) - floor = 21000 + 10 * (zeros + 4 * (len(data) - zeros)) - assert result['gas_used'] > floor - result.update(intrinsic_calldata_gas=intrinsic, eip7623_floor_gas=floor, - execution_gas=result['gas_used'] - intrinsic) - trace = rpc('debug_traceTransaction', [txid, {'tracer': 'callTracer'}]) - assert trace.get('output') == '0x' + word(1).hex(), (name, trace) - write(out / f'{name}-trace.json', trace) - precompiles = [] - def collect(call): - address_value = int(call.get('to', '0x0'), 16) - if 1 <= address_value <= 17: - precompiles.append({'address': address_value, 'gas_used': int(call['gasUsed'], 16), - 'input_bytes': len(bytes.fromhex(call.get('input', '0x')[2:]))}) - for child in call.get('calls', []): - collect(child) - collect(trace) - result['precompile_calls'] = precompiles - report['transactions'][name] = result - return receipt, result - - def deploy(name, file, contract, arguments=b''): - artifact = output['contracts'][file][contract]['evm'] - init = bytes.fromhex(artifact['bytecode']['object']) + arguments - runtime = bytes.fromhex(artifact['deployedBytecode']['object']) - assert len(init) <= 49152 and len(runtime) <= 24576, (name, len(init), len(runtime)) - receipt, result = transaction('deploy-' + name, init, gas=10_000_000) - address = receipt['contractAddress'] - deployed = bytes.fromhex(rpc('eth_getCode', [address, 'latest'])[2:]) - assert len(deployed) == len(runtime) - result.update(address=address, initcode_bytes=len(init), runtime_bytes=len(deployed), runtime_sha256=sha(deployed)) - report['deployments'][name] = result - return address - - stock = deploy('stock-sp1', 'upstream/v6.1.0/PlonkVerifier.sol', 'PlonkVerifier') - facade = deploy('stock-sp1-facade', 'upstream/v6.1.0/SP1VerifierPlonk.sol', 'SP1Verifier') - adapted_address = deploy('adapted-sp1', 'src/PlonkVerifier.sol', 'PlonkVerifier') - adapter = deploy('ix-adapter', 'IxPlonkVerifier.sol', 'IxPlonkVerifier', word(int(adapted_address, 16))) - - def evaluate(name, address, data, expected, returns_bool=True): - try: - result = rpc('eth_call', [{'to': address, 'from': account, 'data': '0x' + data.hex(), 'gas': hex(CALL_GAS)}, 'latest']) - if returns_bool: - assert result in ['0x' + word(0).hex(), '0x' + word(1).hex()], (name, result) - accepted = result == '0x' + word(1).hex() - else: - assert result == '0x', (name, result) - accepted = True - record = {'accepted': accepted, 'result': result} - except RpcError as error: - message = json.dumps(error.error).lower() - assert 'revert' in message, (name, error.error) - accepted = False - record = {'accepted': False, 'reverted': True, 'error': error.error} - assert accepted == expected, (name, record) - report['checks'][name] = record - - evaluate('stock-rejects-two-ix-inputs', stock, raw_call(), False) - evaluate('stock-rejects-proof-with-five-inputs', stock, raw_call(public=inputs + [0, 0, 0]), False) - verifier_hash = bytes.fromhex(rpc('eth_call', [{'to': facade, 'data': '0x' + selector('VERIFIER_HASH()').hex()}, 'latest'])[2:]) - vk_root = bytes.fromhex(rpc('eth_call', [{'to': facade, 'data': '0x' + selector('VK_ROOT()').hex()}, 'latest'])[2:]) - framed = verifier_hash[:4] + word(0) + vk_root + word(0) + proof - public_values = blob(claim) - framed_call = facade_selector + word(1) + word(96) + word(96 + len(public_values)) + public_values + blob(framed) - evaluate('stock-facade-rejects-correctly-framed-ix-proof', facade, framed_call, False, returns_bool=False) - assert report['checks']['stock-facade-rejects-correctly-framed-ix-proof']['error']['data'] == '0x' + selector('InvalidProof()').hex() - evaluate('adapted-sp1-accepts-init', adapted_address, raw_call(), True) - evaluate('ix-adapter-accepts-init', adapter, adapter_call(), True) - transaction('adapted-sp1-verify', raw_call(), adapted_address) - transaction('ix-adapter-verify', adapter_call(), adapter) - print('Actual Init proof accepted by adapted SP1 verifier and ix adapter.', flush=True) - - for index in [0, 15, 16, 31]: - wrong = bytearray(digest) - wrong[index] ^= 1 - evaluate(f'changed-claim-byte-{index}', adapter, adapter_call(expected=bytes(wrong)), False) - for name, public in [('swapped-inputs', inputs[::-1]), ('missing-input', inputs[:1]), - ('extra-input', inputs + [0]), ('noncanonical-input', [R, inputs[1]])]: - evaluate(name, adapted_address, raw_call(public=public), False) - for name, payload in [('empty-proof', b''), ('truncated-proof', proof[:-1]), ('trailing-proof', proof + b'\x00'), - ('native-format', bytes.fromhex(fixture['nativeProof'][2:])), - ('ix-envelope-format', bytes.fromhex(fixture['envelope'][2:]))]: - evaluate(name, adapter, adapter_call(payload=payload), False) - for index in range(len(proof) // 32): - wrong = bytearray(proof) - wrong[32 * index + 31] ^= 1 - evaluate(f'changed-proof-word-{index}', adapter, adapter_call(payload=bytes(wrong)), False) - for name, offset, modulus in [('noncanonical-point', 0, P), ('noncanonical-scalar', 0x180, R)]: - wrong = bytearray(proof) - wrong[offset:offset+32] = word(modulus) - evaluate(name, adapter, adapter_call(payload=bytes(wrong)), False) - report['all_checks_passed'] = True - report['acceptance_scope'] = 'Actual Init proof, independently fixed expected claim digest and pinned circuit verification key; isolated local Anvil only.' - write(out / 'report.json', report) - print(json.dumps({'transactions': {k: {field: value for field, value in v.items() if field != 'precompile_calls'} for k, v in report['transactions'].items()}, - 'deployments': report['deployments'], 'checks_passed': len(report['checks'])}, indent=2), flush=True) - finally: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - assert process.poll() is not None - anvil_log.close() - - -if __name__ == '__main__': - main() diff --git a/contracts/plonk/bn254/provenance.json b/contracts/plonk/bn254/provenance.json deleted file mode 100644 index f0142e9c3..000000000 --- a/contracts/plonk/bn254/provenance.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "repository": "https://github.com/succinctlabs/sp1-contracts", - "default_branch": "main", - "commit": "d3629729c3216eb51bd4859d027a8eb729399fa4", - "files": { - "PlonkVerifier.sol": { - "url": "https://raw.githubusercontent.com/succinctlabs/sp1-contracts/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/v6.1.0/PlonkVerifier.sol", - "sha256": "0263d7fd2b47d41798ac6093188cc39a7cf1c9bfbd4cde6294f3999c41bbd6f9" - }, - "SP1VerifierPlonk.sol": { - "url": "https://raw.githubusercontent.com/succinctlabs/sp1-contracts/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/v6.1.0/SP1VerifierPlonk.sol", - "sha256": "23484564ed34dd6b083f591d52c311a5567951f32b2322e7036e804cc782dd04" - }, - "ISP1Verifier.sol": { - "url": "https://raw.githubusercontent.com/succinctlabs/sp1-contracts/d3629729c3216eb51bd4859d027a8eb729399fa4/contracts/src/ISP1Verifier.sol", - "sha256": "9e918032a5aa799c1319b14b013154d6a40ca6e5f2267c9f540560abd7fd7689" - } - }, - "adaptation": { - "changed_constants": { - "VK_NB_PUBLIC_INPUTS": { - "sp1": "5", - "ix": "2" - }, - "VK_DOMAIN_SIZE": { - "sp1": "33554432", - "ix": "16777216" - }, - "VK_INV_DOMAIN_SIZE": { - "sp1": "21888242219518804655518433051623070663413851959604507555939307129453691614729", - "ix": "21888241567198334088790460357988866238279339518792980768180410072331574733841" - }, - "VK_OMEGA": { - "sp1": "19200870435978225707111062059747084165650991997241425080699860725083300967194", - "ix": "5709868443893258075976348696661355716898495876243883251619397131511003808859" - }, - "VK_QL_COM_X": { - "sp1": "9496857109770518741292064488682135861248962674494967861604446614769298140801", - "ix": "2247446810280598997078512583076010463705556275328790190283284160986145432419" - }, - "VK_QL_COM_Y": { - "sp1": "13306697158707148893187289359474517489309092662132847111865615083807847780686", - "ix": "17899293397787521469498438166384916767131308403173136158957583810720457160067" - }, - "VK_QR_COM_X": { - "sp1": "15168106396031721196861946917417531048941395918951726120971774887904231224033", - "ix": "17261453585169625555680195078041304151068282529357212908112765392770894534278" - }, - "VK_QR_COM_Y": { - "sp1": "17067422783799132011484353829344785446718705273302151466551760455276544179973", - "ix": "5032802837544509306758328961405050308554210384548103718736125522125179924100" - }, - "VK_QM_COM_X": { - "sp1": "2062503189072358517228813627364112801206468481541880929627061212524046867500", - "ix": "3584607594627111430618022243188257181559026512530565511559511908334574961735" - }, - "VK_QM_COM_Y": { - "sp1": "16383264006058748048241814683452691377147195760917894158624764699848260181996", - "ix": "11088732376897191683720631537870533628246508861445120171445562729184158960707" - }, - "VK_QO_COM_X": { - "sp1": "11276993554321744761007348475396375004546042576992247443165667159541648019615", - "ix": "2970551836734429030743170272625326962021756571268061179291098770692736928725" - }, - "VK_QO_COM_Y": { - "sp1": "1336009123797329867458955655460754684518485281155722686318249389277951918969", - "ix": "14253370184471031778663230296712970012512019999228359328162740821710798273297" - }, - "VK_QK_COM_X": { - "sp1": "3509076513963323471275614870100244642947645425705684850560588726111445186717", - "ix": "8638199322136519747148286291472634149689894578550046327199061687921842786694" - }, - "VK_QK_COM_Y": { - "sp1": "15082251669986564403690950092410766520944967428195940447691297074393449013243", - "ix": "10330219132359379321952244687961679343297956293983744809309690374608570754593" - }, - "VK_S1_COM_X": { - "sp1": "20702610951114261233096944021168607816381642857429919420395478944329365232425", - "ix": "8374572668059626572024474755514158947558130423615005095635236580751301971603" - }, - "VK_S1_COM_Y": { - "sp1": "6287924284852772991087729385363871923831267251255140888671281832443192104403", - "ix": "20107543125503712563986728692881849476263382184844757010316687939070575623448" - }, - "VK_S2_COM_X": { - "sp1": "645470009383750670501092774958765334436477314405650592677994973080307449345", - "ix": "7806919962685161745616169473075389877111993406540456889644868872676349445654" - }, - "VK_S2_COM_Y": { - "sp1": "4256397297118091181646823265799511434474377494065149067487129126410465669924", - "ix": "19683449971971340047606283183514389334699079190813975475424665498017446086377" - }, - "VK_S3_COM_X": { - "sp1": "7492538199234778964992190797161279888333633666731339557560747528992222778140", - "ix": "11850267738493914463571045673465636487389201930732259686382873306579288151723" - }, - "VK_S3_COM_Y": { - "sp1": "19883641962643068517995924785061749749272042967551599977561118882192215161378", - "ix": "12366451455213994943132905838640349406977921177620674929449191152457830777150" - }, - "VK_QCP_0_X": { - "sp1": "21276573233863489069809736193483015861331560639958294945226697354328765942402", - "ix": "8403212342070192176300521556178609155662477728821447177638870487361910854373" - }, - "VK_QCP_0_Y": { - "sp1": "21667749868347388386642256911557299806363086510301513644953688310359382808300", - "ix": "3273252539024198469180603526521578769816238206567705742708524768856957917807" - }, - "VK_INDEX_COMMIT_API_0": { - "sp1": "25304569", - "ix": "5919900" - } - }, - "algorithm_unchanged": true, - "generated_gnark_source_sha256": "62634b3b8a0944462778fc745d28168008b34ab36cc7716739dbd0ef07bf7a13", - "adapted_source_sha256": "cd4096aa71fcf21aea567b9e700ed12903625ba9df9a2733daa4f652ced458f8", - "verifying_key_sha256": "32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac", - "proof_solidity_sha256": "0944e5b3bb3cb51b4fb8b1bca7e0d16cb48f2d729e9fcf05a64fe2539c5790b7", - "native_proof_sha256": "7dc47b6f1bd26d31c64cf5ca7abc7a469d90c1ac0294f2c90d050a29546f7751", - "claim_blake3": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", - "fixture_sha256": "13f469ab83f3eaf285e87dc201dd5c8d2b538137f6646dae2fa8400afc33ab39", - "gnark": "262cf453c107d147a228b6b7418c5cf791c3b79b", - "benchmark_report": "target/plonk-refresh-20261019/proof-report.json", - "anvil_report": "target/plonk-refresh-20261019/anvil/report.json", - "refresh": { - "date": "2026-10-19", - "inner_verifying_key_blake3": "1c613f8cfb94399f75687735cbb07e4e91bd4d23e7a58c35388fdfafc67f50e6", - "inner_verifying_key_id": "0x40553b0a35b10e8efcca761b87d7db712728cfd92e0d342893def16282692626", - "inner_packet_sha256": "276362cda2b3de03f2bdeb5494135c3537725550076938f4d09c17c46dcb6544", - "recursive_trace_heights": [ - 67108864, - 2097152 - ], - "ignition_admission_blake3": "4e16f069766667b6f5b9710b23bbf4fce067053967666db78f34ff058b48ff46", - "setup_id": "07f80d0dc28ec7865299e8dcb795bf17f53de459d315416fd3c55fed475cd90b", - "helper_source_revision": "dd2511fc29966aa13df8351ff62e9f840d39e0e0", - "helper_sha256": "f6bc8b1bac015df09444473544be42bd634d576991055913b0196b3ec2dbf0b6", - "cpu_verifier_sha256": "b19338ff6c09d8810cce79eba1225e2eda565deae186da5e2ff14f94059f6ab1", - "worker_sha256": "df6e9ebeffd1ee3e89ed13dc24aa37d9152cf91db0a90a3a305166feb8007aa3", - "worker_source_revision": "7d69f01787ac6aae7fabf45212ede36503a18ce2", - "exporter_patch_sha256": "8361804f676a152cb99291efe4fe92c9e40d90ed0eabe11954209890988331f6", - "compiled_circuit_sha256": "82c8473b89526488c06cac26606bb8f3e6544beca916ffa48163b1b7ab604e36", - "proving_backend": "cuda", - "native_cpu_verification_passed": true, - "native_negative_checks": [ - "wrong-claim", - "corrupt-proof" - ] - } - } -} diff --git a/contracts/plonk/bn254/upstream/ISP1Verifier.sol b/contracts/plonk/bn254/upstream/ISP1Verifier.sol deleted file mode 100644 index 21eb13d62..000000000 --- a/contracts/plonk/bn254/upstream/ISP1Verifier.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title SP1 Verifier Interface -/// @author Succinct Labs -/// @notice This contract is the interface for the SP1 Verifier. -interface ISP1Verifier { - /// @notice Verifies a proof with given public values and vkey. - /// @dev It is expected that the first 4 bytes of proofBytes must match the first 4 bytes of - /// target verifier's VERIFIER_HASH. - /// @param programVKey The verification key for the RISC-V program. - /// @param publicValues The public values encoded as bytes. - /// @param proofBytes The proof of the program execution the SP1 zkVM encoded as bytes. - function verifyProof( - bytes32 programVKey, - bytes calldata publicValues, - bytes calldata proofBytes - ) external view; -} - -interface ISP1VerifierWithHash is ISP1Verifier { - /// @notice Returns the hash of the verifier. - function VERIFIER_HASH() external pure returns (bytes32); -} diff --git a/contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol b/contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol deleted file mode 100644 index 9ecc18126..000000000 --- a/contracts/plonk/bn254/upstream/v6.1.0/PlonkVerifier.sol +++ /dev/null @@ -1,1341 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Copyright 2023 Consensys Software Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by gnark DO NOT EDIT - -pragma solidity ^0.8.20; - -contract PlonkVerifier { - - uint256 private constant R_MOD = 21888242871839275222246405745257275088548364400416034343698204186575808495617; - uint256 private constant R_MOD_MINUS_ONE = 21888242871839275222246405745257275088548364400416034343698204186575808495616; - uint256 private constant P_MOD = 21888242871839275222246405745257275088696311157297823662689037894645226208583; - - uint256 private constant G2_SRS_0_X_0 = 11559732032986387107991004021392285783925812861821192530917403151452391805634; - uint256 private constant G2_SRS_0_X_1 = 10857046999023057135944570762232829481370756359578518086990519993285655852781; - uint256 private constant G2_SRS_0_Y_0 = 4082367875863433681332203403145435568316851327593401208105741076214120093531; - uint256 private constant G2_SRS_0_Y_1 = 8495653923123431417604973247489272438418190587263600148770280649306958101930; - - uint256 private constant G2_SRS_1_X_0 = 15805639136721018565402881920352193254830339253282065586954346329754995870280; - uint256 private constant G2_SRS_1_X_1 = 19089565590083334368588890253123139704298730990782503769911324779715431555531; - uint256 private constant G2_SRS_1_Y_0 = 9779648407879205346559610309258181044130619080926897934572699915909528404984; - uint256 private constant G2_SRS_1_Y_1 = 6779728121489434657638426458390319301070371227460768374343986326751507916979; - - uint256 private constant G1_SRS_X = 14312776538779914388377568895031746459131577658076416373430523308756343304251; - uint256 private constant G1_SRS_Y = 11763105256161367503191792604679297387056316997144156930871823008787082098465; - - // ----------------------- vk --------------------- - uint256 private constant VK_NB_PUBLIC_INPUTS = 5; - uint256 private constant VK_DOMAIN_SIZE = 33554432; - uint256 private constant VK_INV_DOMAIN_SIZE = 21888242219518804655518433051623070663413851959604507555939307129453691614729; - uint256 private constant VK_OMEGA = 19200870435978225707111062059747084165650991997241425080699860725083300967194; - uint256 private constant VK_QL_COM_X = 9496857109770518741292064488682135861248962674494967861604446614769298140801; - uint256 private constant VK_QL_COM_Y = 13306697158707148893187289359474517489309092662132847111865615083807847780686; - uint256 private constant VK_QR_COM_X = 15168106396031721196861946917417531048941395918951726120971774887904231224033; - uint256 private constant VK_QR_COM_Y = 17067422783799132011484353829344785446718705273302151466551760455276544179973; - uint256 private constant VK_QM_COM_X = 2062503189072358517228813627364112801206468481541880929627061212524046867500; - uint256 private constant VK_QM_COM_Y = 16383264006058748048241814683452691377147195760917894158624764699848260181996; - uint256 private constant VK_QO_COM_X = 11276993554321744761007348475396375004546042576992247443165667159541648019615; - uint256 private constant VK_QO_COM_Y = 1336009123797329867458955655460754684518485281155722686318249389277951918969; - uint256 private constant VK_QK_COM_X = 3509076513963323471275614870100244642947645425705684850560588726111445186717; - uint256 private constant VK_QK_COM_Y = 15082251669986564403690950092410766520944967428195940447691297074393449013243; - - uint256 private constant VK_S1_COM_X = 20702610951114261233096944021168607816381642857429919420395478944329365232425; - uint256 private constant VK_S1_COM_Y = 6287924284852772991087729385363871923831267251255140888671281832443192104403; - - uint256 private constant VK_S2_COM_X = 645470009383750670501092774958765334436477314405650592677994973080307449345; - uint256 private constant VK_S2_COM_Y = 4256397297118091181646823265799511434474377494065149067487129126410465669924; - - uint256 private constant VK_S3_COM_X = 7492538199234778964992190797161279888333633666731339557560747528992222778140; - uint256 private constant VK_S3_COM_Y = 19883641962643068517995924785061749749272042967551599977561118882192215161378; - - uint256 private constant VK_COSET_SHIFT = 5; - - - uint256 private constant VK_QCP_0_X = 21276573233863489069809736193483015861331560639958294945226697354328765942402; - uint256 private constant VK_QCP_0_Y = 21667749868347388386642256911557299806363086510301513644953688310359382808300; - - - uint256 private constant VK_INDEX_COMMIT_API_0 = 25304569; - uint256 private constant VK_NB_CUSTOM_GATES = 1; - - // ------------------------------------------------ - - // size of the proof without call custom gate - uint256 private constant FIXED_PROOF_SIZE = 0x300; - - // offset proof - - uint256 private constant PROOF_L_COM_X = 0x0; - uint256 private constant PROOF_L_COM_Y = 0x20; - uint256 private constant PROOF_R_COM_X = 0x40; - uint256 private constant PROOF_R_COM_Y = 0x60; - uint256 private constant PROOF_O_COM_X = 0x80; - uint256 private constant PROOF_O_COM_Y = 0xa0; - - // h = h_0 + x^{n+2}h_1 + x^{2(n+2)}h_2 - uint256 private constant PROOF_H_0_COM_X = 0xc0; - uint256 private constant PROOF_H_0_COM_Y = 0xe0; - uint256 private constant PROOF_H_1_COM_X = 0x100; - uint256 private constant PROOF_H_1_COM_Y = 0x120; - uint256 private constant PROOF_H_2_COM_X = 0x140; - uint256 private constant PROOF_H_2_COM_Y = 0x160; - - // "evaluations of wire polynomials at zeta - uint256 private constant PROOF_L_AT_ZETA = 0x180; - uint256 private constant PROOF_R_AT_ZETA = 0x1a0; - uint256 private constant PROOF_O_AT_ZETA = 0x1c0; - - // S1(zeta),S2(zeta) - uint256 private constant PROOF_S1_AT_ZETA = 0x1e0; // Sσ1(zeta) - uint256 private constant PROOF_S2_AT_ZETA = 0x200; // Sσ2(zeta) - - // [Z] - uint256 private constant PROOF_GRAND_PRODUCT_COMMITMENT_X = 0x220; - uint256 private constant PROOF_GRAND_PRODUCT_COMMITMENT_Y = 0x240; - - uint256 private constant PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA = 0x260; // z(w*zeta) - - // Folded proof for the opening of linearised poly, l, r, o, s_1, s_2, qcp - uint256 private constant PROOF_BATCH_OPENING_AT_ZETA_X = 0x280; - uint256 private constant PROOF_BATCH_OPENING_AT_ZETA_Y = 0x2a0; - - uint256 private constant PROOF_OPENING_AT_ZETA_OMEGA_X = 0x2c0; - uint256 private constant PROOF_OPENING_AT_ZETA_OMEGA_Y = 0x2e0; - - uint256 private constant PROOF_OPENING_QCP_AT_ZETA = 0x300; - uint256 private constant PROOF_BSB_COMMITMENTS = 0x320; - - // -------- offset state - - // challenges to check the claimed quotient - - uint256 private constant STATE_ALPHA = 0x0; - uint256 private constant STATE_BETA = 0x20; - uint256 private constant STATE_GAMMA = 0x40; - uint256 private constant STATE_ZETA = 0x60; - uint256 private constant STATE_ALPHA_SQUARE_LAGRANGE_0 = 0x80; - uint256 private constant STATE_FOLDED_H_X = 0xa0; - uint256 private constant STATE_FOLDED_H_Y = 0xc0; - uint256 private constant STATE_LINEARISED_POLYNOMIAL_X = 0xe0; - uint256 private constant STATE_LINEARISED_POLYNOMIAL_Y = 0x100; - uint256 private constant STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA = 0x120; - uint256 private constant STATE_FOLDED_CLAIMED_VALUES = 0x140; // Folded proof for the opening of H, linearised poly, l, r, o, s_1, s_2, qcp - uint256 private constant STATE_FOLDED_DIGESTS_X = 0x160; // linearised poly, l, r, o, s_1, s_2, qcp - uint256 private constant STATE_FOLDED_DIGESTS_Y = 0x180; - uint256 private constant STATE_PI = 0x1a0; - uint256 private constant STATE_ZETA_POWER_N_MINUS_ONE = 0x1c0; - uint256 private constant STATE_GAMMA_KZG = 0x1e0; - uint256 private constant STATE_SUCCESS = 0x200; - uint256 private constant STATE_CHECK_VAR = 0x220; // /!\ this slot is used for debugging only - uint256 private constant STATE_LAST_MEM = 0x240; - - // -------- utils (for Fiat Shamir) - uint256 private constant FS_ALPHA = 0x616C706861; // "alpha" - uint256 private constant FS_BETA = 0x62657461; // "beta" - uint256 private constant FS_GAMMA = 0x67616d6d61; // "gamma" - uint256 private constant FS_ZETA = 0x7a657461; // "zeta" - uint256 private constant FS_GAMMA_KZG = 0x67616d6d61; // "gamma" - - // -------- errors - uint256 private constant ERROR_STRING_ID = 0x08c379a000000000000000000000000000000000000000000000000000000000; // selector for function Error(string) - - - // -------- utils (for hash_fr) - uint256 private constant HASH_FR_BB = 340282366920938463463374607431768211456; // 2**128 - uint256 private constant HASH_FR_ZERO_UINT256 = 0; - uint8 private constant HASH_FR_LEN_IN_BYTES = 48; - uint8 private constant HASH_FR_SIZE_DOMAIN = 11; - uint8 private constant HASH_FR_ONE = 1; - uint8 private constant HASH_FR_TWO = 2; - - - // -------- precompiles - uint8 private constant SHA2 = 0x2; - uint8 private constant MOD_EXP = 0x5; - uint8 private constant EC_ADD = 0x6; - uint8 private constant EC_MUL = 0x7; - uint8 private constant EC_PAIR = 0x8; - - /// Verify a Plonk proof. - /// Reverts if the proof or the public inputs are malformed. - /// @param proof serialised plonk proof (using gnark's MarshalSolidity) - /// @param public_inputs (must be reduced) - /// @return success true if the proof passes false otherwise - function Verify(bytes calldata proof, uint256[] calldata public_inputs) - public view returns(bool success) { - - assembly { - - let mem := mload(0x40) - let freeMem := add(mem, STATE_LAST_MEM) - - // sanity checks - check_number_of_public_inputs(public_inputs.length) - check_inputs_size(public_inputs.length, public_inputs.offset) - check_proof_size(proof.length) - check_proof_openings_size(proof.offset) - - // compute the challenges - let prev_challenge_non_reduced - prev_challenge_non_reduced := derive_gamma(proof.offset, public_inputs.length, public_inputs.offset) - prev_challenge_non_reduced := derive_beta(prev_challenge_non_reduced) - prev_challenge_non_reduced := derive_alpha(proof.offset, prev_challenge_non_reduced) - derive_zeta(proof.offset, prev_challenge_non_reduced) - - // evaluation of Z=Xⁿ-1 at ζ, we save this value - let zeta := mload(add(mem, STATE_ZETA)) - let zeta_power_n_minus_one := addmod(pow(zeta, VK_DOMAIN_SIZE, freeMem), sub(R_MOD, 1), R_MOD) - mstore(add(mem, STATE_ZETA_POWER_N_MINUS_ONE), zeta_power_n_minus_one) - - // public inputs contribution - let l_pi := sum_pi_wo_api_commit(public_inputs.offset, public_inputs.length, freeMem) - let l_pi_commit := sum_pi_commit(proof.offset, public_inputs.length, freeMem) - l_pi := addmod(l_pi_commit, l_pi, R_MOD) - mstore(add(mem, STATE_PI), l_pi) - - compute_alpha_square_lagrange_0() - compute_opening_linearised_polynomial(proof.offset) - fold_h(proof.offset) - compute_commitment_linearised_polynomial(proof.offset) - compute_gamma_kzg(proof.offset) - fold_state(proof.offset) - batch_verify_multi_points(proof.offset) - - success := mload(add(mem, STATE_SUCCESS)) - - // Beginning errors ------------------------------------------------- - - function error_nb_public_inputs() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0x1d) - mstore(add(ptError, 0x44), "wrong number of public inputs") - revert(ptError, 0x64) - } - - /// Called when an exponentiation mod r fails - function error_mod_exp() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0xc) - mstore(add(ptError, 0x44), "error mod exp") - revert(ptError, 0x64) - } - - /// Called when an operation on Bn254 fails - /// @dev for instance when calling EcMul on a point not on Bn254. - function error_ec_op() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0x12) - mstore(add(ptError, 0x44), "error ec operation") - revert(ptError, 0x64) - } - - /// Called when one of the public inputs is not reduced. - function error_inputs_size() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0x18) - mstore(add(ptError, 0x44), "inputs are bigger than r") - revert(ptError, 0x64) - } - - /// Called when the size proof is not as expected - /// @dev to avoid overflow attack for instance - function error_proof_size() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0x10) - mstore(add(ptError, 0x44), "wrong proof size") - revert(ptError, 0x64) - } - - /// Called when one the openings is bigger than r - /// The openings are the claimed evalutions of a polynomial - /// in a Kzg proof. - function error_proof_openings_size() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0x16) - mstore(add(ptError, 0x44), "openings bigger than r") - revert(ptError, 0x64) - } - - function error_pairing() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0xd) - mstore(add(ptError, 0x44), "error pairing") - revert(ptError, 0x64) - } - - function error_verify() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0xc) - mstore(add(ptError, 0x44), "error verify") - revert(ptError, 0x64) - } - - function error_random_generation() { - let ptError := mload(0x40) - mstore(ptError, ERROR_STRING_ID) // selector for function Error(string) - mstore(add(ptError, 0x4), 0x20) - mstore(add(ptError, 0x24), 0x14) - mstore(add(ptError, 0x44), "error random gen kzg") - revert(ptError, 0x64) - } - // end errors ------------------------------------------------- - - // Beginning checks ------------------------------------------------- - - /// @param s actual number of public inputs - function check_number_of_public_inputs(s) { - if iszero(eq(s, VK_NB_PUBLIC_INPUTS)) { - error_nb_public_inputs() - } - } - - /// Checks that the public inputs are < R_MOD. - /// @param s number of public inputs - /// @param p pointer to the public inputs array - function check_inputs_size(s, p) { - for {let i} lt(i, s) {i:=add(i,1)} - { - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_inputs_size() - } - p := add(p, 0x20) - } - } - - /// Checks if the proof is of the correct size - /// @param actual_proof_size size of the proof (not the expected size) - function check_proof_size(actual_proof_size) { - let expected_proof_size := add(FIXED_PROOF_SIZE, mul(VK_NB_CUSTOM_GATES,0x60)) - if iszero(eq(actual_proof_size, expected_proof_size)) { - error_proof_size() - } - } - - /// Checks if the multiple openings of the polynomials are < R_MOD. - /// @param aproof pointer to the beginning of the proof - /// @dev the 'a' prepending proof is to have a local name - function check_proof_openings_size(aproof) { - - // PROOF_L_AT_ZETA - let p := add(aproof, PROOF_L_AT_ZETA) - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - - // PROOF_R_AT_ZETA - p := add(aproof, PROOF_R_AT_ZETA) - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - - // PROOF_O_AT_ZETA - p := add(aproof, PROOF_O_AT_ZETA) - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - - // PROOF_S1_AT_ZETA - p := add(aproof, PROOF_S1_AT_ZETA) - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - - // PROOF_S2_AT_ZETA - p := add(aproof, PROOF_S2_AT_ZETA) - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - - // PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA - p := add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA) - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - - // PROOF_OPENING_QCP_AT_ZETA - - p := add(aproof, PROOF_OPENING_QCP_AT_ZETA) - for {let i:=0} lt(i, VK_NB_CUSTOM_GATES) {i:=add(i,1)} - { - if gt(calldataload(p), R_MOD_MINUS_ONE) { - error_proof_openings_size() - } - p := add(p, 0x20) - } - - } - // end checks ------------------------------------------------- - - // Beginning challenges ------------------------------------------------- - - /// Derive gamma as Sha256() - /// @param aproof pointer to the proof - /// @param nb_pi number of public inputs - /// @param pi pointer to the array of public inputs - /// @return the challenge gamma, not reduced - /// @notice The transcript is the concatenation (in this order) of: - /// * the word "gamma" in ascii, equal to [0x67,0x61,0x6d, 0x6d, 0x61] and encoded as a uint256. - /// * the commitments to the permutation polynomials S1, S2, S3, where we concatenate the coordinates of those points - /// * the commitments of Ql, Qr, Qm, Qo, Qk - /// * the public inputs - /// * the commitments of the wires related to the custom gates (commitments_wires_commit_api) - /// * commitments to L, R, O (proof__com_) - /// The data described above is written starting at mPtr. "gamma" lies on 5 bytes, - /// and is encoded as a uint256 number n. In basis b = 256, the number looks like this - /// [0 0 0 .. 0x67 0x61 0x6d, 0x6d, 0x61]. The first non zero entry is at position 27=0x1b - /// Gamma reduced (the actual challenge) is stored at add(state, state_gamma) - function derive_gamma(aproof, nb_pi, pi)->gamma_not_reduced { - - let state := mload(0x40) - let mPtr := add(state, STATE_LAST_MEM) - - mstore(mPtr, FS_GAMMA) // "gamma" - - - mstore(add(mPtr, 0x20), VK_S1_COM_X) - mstore(add(mPtr, 0x40), VK_S1_COM_Y) - mstore(add(mPtr, 0x60), VK_S2_COM_X) - mstore(add(mPtr, 0x80), VK_S2_COM_Y) - mstore(add(mPtr, 0xa0), VK_S3_COM_X) - mstore(add(mPtr, 0xc0), VK_S3_COM_Y) - mstore(add(mPtr, 0xe0), VK_QL_COM_X) - mstore(add(mPtr, 0x100), VK_QL_COM_Y) - mstore(add(mPtr, 0x120), VK_QR_COM_X) - mstore(add(mPtr, 0x140), VK_QR_COM_Y) - mstore(add(mPtr, 0x160), VK_QM_COM_X) - mstore(add(mPtr, 0x180), VK_QM_COM_Y) - mstore(add(mPtr, 0x1a0), VK_QO_COM_X) - mstore(add(mPtr, 0x1c0), VK_QO_COM_Y) - mstore(add(mPtr, 0x1e0), VK_QK_COM_X) - mstore(add(mPtr, 0x200), VK_QK_COM_Y) - - mstore(add(mPtr, 0x220), VK_QCP_0_X) - mstore(add(mPtr, 0x240), VK_QCP_0_Y) - - // public inputs - let _mPtr := add(mPtr, 0x260) - let size_pi_in_bytes := mul(nb_pi, 0x20) - calldatacopy(_mPtr, pi, size_pi_in_bytes) - _mPtr := add(_mPtr, size_pi_in_bytes) - - // commitments to l, r, o - let size_commitments_lro_in_bytes := 0xc0 - calldatacopy(_mPtr, aproof, size_commitments_lro_in_bytes) - _mPtr := add(_mPtr, size_commitments_lro_in_bytes) - - // total size is : - // sizegamma(=0x5) + 11*64(=0x2c0) - // + nb_public_inputs*0x20 - // + nb_custom gates*0x40 - let size := add(0x2c5, size_pi_in_bytes) - - size := add(size, mul(VK_NB_CUSTOM_GATES, 0x40)) - let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1b), size, mPtr, 0x20) //0x1b -> 000.."gamma" - if iszero(l_success) { - error_verify() - } - gamma_not_reduced := mload(mPtr) - mstore(add(state, STATE_GAMMA), mod(gamma_not_reduced, R_MOD)) - } - - /// derive beta as Sha256 - /// @param gamma_not_reduced the previous challenge (gamma) not reduced - /// @return beta_not_reduced the next challenge, beta, not reduced - /// @notice the transcript consists of the previous challenge only. - /// The reduced version of beta is stored at add(state, state_beta) - function derive_beta(gamma_not_reduced)->beta_not_reduced{ - - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - - // beta - mstore(mPtr, FS_BETA) // "beta" - mstore(add(mPtr, 0x20), gamma_not_reduced) - let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1c), 0x24, mPtr, 0x20) //0x1b -> 000.."gamma" - if iszero(l_success) { - error_verify() - } - beta_not_reduced := mload(mPtr) - mstore(add(state, STATE_BETA), mod(beta_not_reduced, R_MOD)) - } - - /// derive alpha as sha256 - /// @param aproof pointer to the proof object - /// @param beta_not_reduced the previous challenge (beta) not reduced - /// @return alpha_not_reduced the next challenge, alpha, not reduced - /// @notice the transcript consists of the previous challenge (beta) - /// not reduced, the commitments to the wires associated to the QCP_i, - /// and the commitment to the grand product polynomial - function derive_alpha(aproof, beta_not_reduced)->alpha_not_reduced { - - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - let full_size := 0x65 // size("alpha") + 0x20 (previous challenge) - - // alpha - mstore(mPtr, FS_ALPHA) // "alpha" - let _mPtr := add(mPtr, 0x20) - mstore(_mPtr, beta_not_reduced) - _mPtr := add(_mPtr, 0x20) - - // Bsb22Commitments - let proof_bsb_commitments := add(aproof, PROOF_BSB_COMMITMENTS) - let size_bsb_commitments := mul(0x40, VK_NB_CUSTOM_GATES) - calldatacopy(_mPtr, proof_bsb_commitments, size_bsb_commitments) - _mPtr := add(_mPtr, size_bsb_commitments) - full_size := add(full_size, size_bsb_commitments) - - // [Z], the commitment to the grand product polynomial - calldatacopy(_mPtr, add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X), 0x40) - let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1b), full_size, mPtr, 0x20) - if iszero(l_success) { - error_verify() - } - - alpha_not_reduced := mload(mPtr) - mstore(add(state, STATE_ALPHA), mod(alpha_not_reduced, R_MOD)) - } - - /// derive zeta as sha256 - /// @param aproof pointer to the proof object - /// @param alpha_not_reduced the previous challenge (alpha) not reduced - /// The transcript consists of the previous challenge and the commitment to - /// the quotient polynomial h. - function derive_zeta(aproof, alpha_not_reduced) { - - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - - // zeta - mstore(mPtr, FS_ZETA) // "zeta" - mstore(add(mPtr, 0x20), alpha_not_reduced) - calldatacopy(add(mPtr, 0x40), add(aproof, PROOF_H_0_COM_X), 0xc0) - let l_success := staticcall(gas(), SHA2, add(mPtr, 0x1c), 0xe4, mPtr, 0x20) - if iszero(l_success) { - error_verify() - } - let zeta_not_reduced := mload(mPtr) - mstore(add(state, STATE_ZETA), mod(zeta_not_reduced, R_MOD)) - } - // END challenges ------------------------------------------------- - - // BEGINNING compute_pi ------------------------------------------------- - - /// sum_pi_wo_api_commit computes the public inputs contributions, - /// except for the public inputs coming from the custom gate - /// @param ins pointer to the public inputs - /// @param n number of public inputs - /// @param mPtr free memory - /// @return pi_wo_commit public inputs contribution (except the public inputs coming from the custom gate) - function sum_pi_wo_api_commit(ins, n, mPtr)->pi_wo_commit { - - let state := mload(0x40) - let z := mload(add(state, STATE_ZETA)) - let zpnmo := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) - - let li := mPtr - batch_compute_lagranges_at_z(z, zpnmo, n, li) - - let tmp := 0 - for {let i:=0} lt(i,n) {i:=add(i,1)} - { - tmp := mulmod(mload(li), calldataload(ins), R_MOD) - pi_wo_commit := addmod(pi_wo_commit, tmp, R_MOD) - li := add(li, 0x20) - ins := add(ins, 0x20) - } - - } - - /// batch_compute_lagranges_at_z computes [L_0(z), .., L_{n-1}(z)] - /// @param z point at which the Lagranges are evaluated - /// @param zpnmo ζⁿ-1 - /// @param n_pub number of public inputs (number of Lagranges to compute) - /// @param mPtr pointer to which the results are stored - function batch_compute_lagranges_at_z(z, zpnmo, n_pub, mPtr) { - - let zn := mulmod(zpnmo, VK_INV_DOMAIN_SIZE, R_MOD) // 1/n * (ζⁿ - 1) - - let _w := 1 - let _mPtr := mPtr - for {let i:=0} lt(i,n_pub) {i:=add(i,1)} - { - mstore(_mPtr, addmod(z,sub(R_MOD, _w), R_MOD)) - _w := mulmod(_w, VK_OMEGA, R_MOD) - _mPtr := add(_mPtr, 0x20) - } - batch_invert(mPtr, n_pub, _mPtr) - _mPtr := mPtr - _w := 1 - for {let i:=0} lt(i,n_pub) {i:=add(i,1)} - { - mstore(_mPtr, mulmod(mulmod(mload(_mPtr), zn , R_MOD), _w, R_MOD)) - _mPtr := add(_mPtr, 0x20) - _w := mulmod(_w, VK_OMEGA, R_MOD) - } - } - - /// @notice Montgomery trick for batch inversion mod R_MOD - /// @param ins pointer to the data to batch invert - /// @param number of elements to batch invert - /// @param mPtr free memory - function batch_invert(ins, nb_ins, mPtr) { - mstore(mPtr, 1) - let offset := 0 - for {let i:=0} lt(i, nb_ins) {i:=add(i,1)} - { - let prev := mload(add(mPtr, offset)) - let cur := mload(add(ins, offset)) - cur := mulmod(prev, cur, R_MOD) - offset := add(offset, 0x20) - mstore(add(mPtr, offset), cur) - } - ins := add(ins, sub(offset, 0x20)) - mPtr := add(mPtr, offset) - let inv := pow(mload(mPtr), sub(R_MOD,2), add(mPtr, 0x20)) - for {let i:=0} lt(i, nb_ins) {i:=add(i,1)} - { - mPtr := sub(mPtr, 0x20) - let tmp := mload(ins) - let cur := mulmod(inv, mload(mPtr), R_MOD) - mstore(ins, cur) - inv := mulmod(inv, tmp, R_MOD) - ins := sub(ins, 0x20) - } - } - - - /// Public inputs (the ones coming from the custom gate) contribution - /// @param aproof pointer to the proof - /// @param nb_public_inputs number of public inputs - /// @param mPtr pointer to free memory - /// @return pi_commit custom gate public inputs contribution - function sum_pi_commit(aproof, nb_public_inputs, mPtr)->pi_commit { - - let state := mload(0x40) - let z := mload(add(state, STATE_ZETA)) - let zpnmo := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) - - let p := add(aproof, PROOF_BSB_COMMITMENTS) - - let h_fr, ith_lagrange - - - h_fr := hash_fr(calldataload(p), calldataload(add(p, 0x20)), mPtr) - ith_lagrange := compute_ith_lagrange_at_z(z, zpnmo, add(nb_public_inputs, VK_INDEX_COMMIT_API_0), mPtr) - pi_commit := addmod(pi_commit, mulmod(h_fr, ith_lagrange, R_MOD), R_MOD) - - - - } - - /// Computes L_i(zeta) = ωⁱ/n * (ζⁿ-1)/(ζ-ωⁱ) where: - /// @param z zeta - /// @param zpmno ζⁿ-1 - /// @param i i-th lagrange - /// @param mPtr free memory - /// @return res = ωⁱ/n * (ζⁿ-1)/(ζ-ωⁱ) - function compute_ith_lagrange_at_z(z, zpnmo, i, mPtr)->res { - - let w := pow(VK_OMEGA, i, mPtr) // w**i - i := addmod(z, sub(R_MOD, w), R_MOD) // z-w**i - w := mulmod(w, VK_INV_DOMAIN_SIZE, R_MOD) // w**i/n - i := pow(i, sub(R_MOD,2), mPtr) // (z-w**i)**-1 - w := mulmod(w, i, R_MOD) // w**i/n*(z-w)**-1 - res := mulmod(w, zpnmo, R_MOD) - - } - - /// @dev https://tools.ietf.org/html/draft-irtf-cfrg-hash-to-curve-06#section-5.2 - /// @param x x coordinate of a point on Bn254(𝔽_p) - /// @param y y coordinate of a point on Bn254(𝔽_p) - /// @param mPtr free memory - /// @return res an element mod R_MOD - function hash_fr(x, y, mPtr)->res { - - // [0x00, .. , 0x00 || x, y, || 0, 48, 0, dst, HASH_FR_SIZE_DOMAIN] - // <- 64 bytes -> <-64b -> <- 1 bytes each -> - - // [0x00, .., 0x00] 64 bytes of zero - mstore(mPtr, HASH_FR_ZERO_UINT256) - mstore(add(mPtr, 0x20), HASH_FR_ZERO_UINT256) - - // msg = x || y , both on 32 bytes - mstore(add(mPtr, 0x40), x) - mstore(add(mPtr, 0x60), y) - - // 0 || 48 || 0 all on 1 byte - mstore8(add(mPtr, 0x80), 0) - mstore8(add(mPtr, 0x81), HASH_FR_LEN_IN_BYTES) - mstore8(add(mPtr, 0x82), 0) - - // "BSB22-Plonk" = [42, 53, 42, 32, 32, 2d, 50, 6c, 6f, 6e, 6b,] - mstore8(add(mPtr, 0x83), 0x42) - mstore8(add(mPtr, 0x84), 0x53) - mstore8(add(mPtr, 0x85), 0x42) - mstore8(add(mPtr, 0x86), 0x32) - mstore8(add(mPtr, 0x87), 0x32) - mstore8(add(mPtr, 0x88), 0x2d) - mstore8(add(mPtr, 0x89), 0x50) - mstore8(add(mPtr, 0x8a), 0x6c) - mstore8(add(mPtr, 0x8b), 0x6f) - mstore8(add(mPtr, 0x8c), 0x6e) - mstore8(add(mPtr, 0x8d), 0x6b) - - // size domain - mstore8(add(mPtr, 0x8e), HASH_FR_SIZE_DOMAIN) - - let l_success := staticcall(gas(), SHA2, mPtr, 0x8f, mPtr, 0x20) - if iszero(l_success) { - error_verify() - } - - let b0 := mload(mPtr) - - // [b0 || one || dst || HASH_FR_SIZE_DOMAIN] - // <-64bytes -> <- 1 byte each -> - mstore8(add(mPtr, 0x20), HASH_FR_ONE) // 1 - - mstore8(add(mPtr, 0x21), 0x42) // dst - mstore8(add(mPtr, 0x22), 0x53) - mstore8(add(mPtr, 0x23), 0x42) - mstore8(add(mPtr, 0x24), 0x32) - mstore8(add(mPtr, 0x25), 0x32) - mstore8(add(mPtr, 0x26), 0x2d) - mstore8(add(mPtr, 0x27), 0x50) - mstore8(add(mPtr, 0x28), 0x6c) - mstore8(add(mPtr, 0x29), 0x6f) - mstore8(add(mPtr, 0x2a), 0x6e) - mstore8(add(mPtr, 0x2b), 0x6b) - - mstore8(add(mPtr, 0x2c), HASH_FR_SIZE_DOMAIN) // size domain - l_success := staticcall(gas(), SHA2, mPtr, 0x2d, mPtr, 0x20) - if iszero(l_success) { - error_verify() - } - - // b1 is located at mPtr. We store b2 at add(mPtr, 0x20) - - // [b0^b1 || two || dst || HASH_FR_SIZE_DOMAIN] - // <-64bytes -> <- 1 byte each -> - mstore(add(mPtr, 0x20), xor(mload(mPtr), b0)) - mstore8(add(mPtr, 0x40), HASH_FR_TWO) - - mstore8(add(mPtr, 0x41), 0x42) // dst - mstore8(add(mPtr, 0x42), 0x53) - mstore8(add(mPtr, 0x43), 0x42) - mstore8(add(mPtr, 0x44), 0x32) - mstore8(add(mPtr, 0x45), 0x32) - mstore8(add(mPtr, 0x46), 0x2d) - mstore8(add(mPtr, 0x47), 0x50) - mstore8(add(mPtr, 0x48), 0x6c) - mstore8(add(mPtr, 0x49), 0x6f) - mstore8(add(mPtr, 0x4a), 0x6e) - mstore8(add(mPtr, 0x4b), 0x6b) - - mstore8(add(mPtr, 0x4c), HASH_FR_SIZE_DOMAIN) // size domain - - let offset := add(mPtr, 0x20) - l_success := staticcall(gas(), SHA2, offset, 0x2d, offset, 0x20) - if iszero(l_success) { - error_verify() - } - - // at this point we have mPtr = [ b1 || b2] where b1 is on 32byes and b2 in 16bytes. - // we interpret it as a big integer mod r in big endian (similar to regular decimal notation) - // the result is then 2**(8*16)*mPtr[:32] + mPtr[32:48] - res := mulmod(mload(mPtr), HASH_FR_BB, R_MOD) // <- res = 2**128 * mPtr[:32] - let b1 := shr(128, mload(add(mPtr, 0x20))) // b1 <- [0, 0, .., 0 || b2[:16] ] - res := addmod(res, b1, R_MOD) - - } - - // END compute_pi ------------------------------------------------- - - /// @notice compute α² * 1/n * (ζ{n}-1)/(ζ - 1) where - /// * α = challenge derived in derive_gamma_beta_alpha_zeta - /// * n = vk_domain_size - /// * ω = vk_omega (generator of the multiplicative cyclic group of order n in (ℤ/rℤ)*) - /// * ζ = zeta (challenge derived with Fiat Shamir) - function compute_alpha_square_lagrange_0() { - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - - let res := mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)) - let den := addmod(mload(add(state, STATE_ZETA)), sub(R_MOD, 1), R_MOD) - den := pow(den, sub(R_MOD, 2), mPtr) - den := mulmod(den, VK_INV_DOMAIN_SIZE, R_MOD) - res := mulmod(den, res, R_MOD) - - let l_alpha := mload(add(state, STATE_ALPHA)) - res := mulmod(res, l_alpha, R_MOD) - res := mulmod(res, l_alpha, R_MOD) - mstore(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0), res) - } - - /// @notice follows alg. p.13 of https://eprint.iacr.org/2019/953.pdf - /// with t₁ = t₂ = 1, and the proofs are ([digest] + [quotient] +purported evaluation): - /// * [state_folded_state_digests], [proof_batch_opening_at_zeta_x], state_folded_evals - /// * [proof_grand_product_commitment], [proof_opening_at_zeta_omega_x], [proof_grand_product_at_zeta_omega] - /// @param aproof pointer to the proof - function batch_verify_multi_points(aproof) { - let state := mload(0x40) - let mPtr := add(state, STATE_LAST_MEM) - - // derive a random number. As there is no random generator, we - // do an FS like challenge derivation, depending on both digests and - // ζ to ensure that the prover cannot control the random number. - // Note: adding the other point ζω is not needed, as ω is known beforehand. - mstore(mPtr, mload(add(state, STATE_FOLDED_DIGESTS_X))) - mstore(add(mPtr, 0x20), mload(add(state, STATE_FOLDED_DIGESTS_Y))) - mstore(add(mPtr, 0x40), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X))) - mstore(add(mPtr, 0x60), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_Y))) - mstore(add(mPtr, 0x80), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X))) - mstore(add(mPtr, 0xa0), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_Y))) - mstore(add(mPtr, 0xc0), calldataload(add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X))) - mstore(add(mPtr, 0xe0), calldataload(add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_Y))) - mstore(add(mPtr, 0x100), mload(add(state, STATE_ZETA))) - mstore(add(mPtr, 0x120), mload(add(state, STATE_GAMMA_KZG))) - let random := staticcall(gas(), SHA2, mPtr, 0x140, mPtr, 0x20) - if iszero(random){ - error_random_generation() - } - random := mod(mload(mPtr), R_MOD) // use the same variable as we are one variable away from getting stack-too-deep error... - - let folded_quotients := mPtr - mPtr := add(folded_quotients, 0x40) - mstore(folded_quotients, calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X))) - mstore(add(folded_quotients, 0x20), calldataload(add(aproof, PROOF_BATCH_OPENING_AT_ZETA_Y))) - point_acc_mul_calldata(folded_quotients, add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X), random, mPtr) - - let folded_digests := add(state, STATE_FOLDED_DIGESTS_X) - point_acc_mul_calldata(folded_digests, add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X), random, mPtr) - - let folded_evals := add(state, STATE_FOLDED_CLAIMED_VALUES) - fr_acc_mul_calldata(folded_evals, add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA), random) - - let folded_evals_commit := mPtr - mPtr := add(folded_evals_commit, 0x40) - mstore(folded_evals_commit, G1_SRS_X) - mstore(add(folded_evals_commit, 0x20), G1_SRS_Y) - mstore(add(folded_evals_commit, 0x40), mload(folded_evals)) - let check_staticcall := staticcall(gas(), 7, folded_evals_commit, 0x60, folded_evals_commit, 0x40) - if iszero(check_staticcall) { - error_verify() - } - - let folded_evals_commit_y := add(folded_evals_commit, 0x20) - mstore(folded_evals_commit_y, sub(P_MOD, mload(folded_evals_commit_y))) - point_add(folded_digests, folded_digests, folded_evals_commit, mPtr) - - let folded_points_quotients := mPtr - mPtr := add(mPtr, 0x40) - point_mul_calldata( - folded_points_quotients, - add(aproof, PROOF_BATCH_OPENING_AT_ZETA_X), - mload(add(state, STATE_ZETA)), - mPtr - ) - let zeta_omega := mulmod(mload(add(state, STATE_ZETA)), VK_OMEGA, R_MOD) - random := mulmod(random, zeta_omega, R_MOD) - point_acc_mul_calldata(folded_points_quotients, add(aproof, PROOF_OPENING_AT_ZETA_OMEGA_X), random, mPtr) - - point_add(folded_digests, folded_digests, folded_points_quotients, mPtr) - - let folded_quotients_y := add(folded_quotients, 0x20) - mstore(folded_quotients_y, sub(P_MOD, mload(folded_quotients_y))) - - mstore(mPtr, mload(folded_digests)) - - mstore(add(mPtr, 0x20), mload(add(folded_digests, 0x20))) - mstore(add(mPtr, 0x40), G2_SRS_0_X_0) // the 4 lines are the canonical G2 point on BN254 - mstore(add(mPtr, 0x60), G2_SRS_0_X_1) - mstore(add(mPtr, 0x80), G2_SRS_0_Y_0) - mstore(add(mPtr, 0xa0), G2_SRS_0_Y_1) - mstore(add(mPtr, 0xc0), mload(folded_quotients)) - mstore(add(mPtr, 0xe0), mload(add(folded_quotients, 0x20))) - mstore(add(mPtr, 0x100), G2_SRS_1_X_0) - mstore(add(mPtr, 0x120), G2_SRS_1_X_1) - mstore(add(mPtr, 0x140), G2_SRS_1_Y_0) - mstore(add(mPtr, 0x160), G2_SRS_1_Y_1) - check_pairing_kzg(mPtr) - } - - /// @notice check_pairing_kzg checks the result of the final pairing product of the batched - /// kzg verification. The purpose of this function is to avoid exhausting the stack - /// in the function batch_verify_multi_points. - /// @param mPtr pointer storing the tuple of pairs - function check_pairing_kzg(mPtr) { - let state := mload(0x40) - - let l_success := staticcall(gas(), 8, mPtr, 0x180, 0x00, 0x20) - if iszero(l_success) { - error_pairing() - } - let res_pairing := mload(0x00) - mstore(add(state, STATE_SUCCESS), res_pairing) - } - - /// @notice Fold the opening proofs at ζ: - /// * at state+state_folded_digest we store: [Linearised_polynomial]+γ[L] + γ²[R] + γ³[O] + γ⁴[S₁] +γ⁵[S₂] + ∑ᵢγ⁵⁺ⁱ[Pi_{i}] - /// * at state+state_folded_claimed_values we store: Linearised_polynomial(ζ)+γL(ζ) + γ²R(ζ)+ γ³O(ζ) + γ⁴S₁(ζ) +γ⁵S₂(ζ) + ∑ᵢγ⁵⁺ⁱPi_{i}(ζ) - /// @param aproof pointer to the proof - /// acc_gamma stores the γⁱ - function fold_state(aproof) { - - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - let mPtr20 := add(mPtr, 0x20) - let mPtr40 := add(mPtr, 0x40) - - let l_gamma_kzg := mload(add(state, STATE_GAMMA_KZG)) - let acc_gamma := l_gamma_kzg - let state_folded_digests := add(state, STATE_FOLDED_DIGESTS_X) - - mstore(state_folded_digests, mload(add(state, STATE_LINEARISED_POLYNOMIAL_X))) - mstore(add(state, STATE_FOLDED_DIGESTS_Y), mload(add(state, STATE_LINEARISED_POLYNOMIAL_Y))) - mstore(add(state, STATE_FOLDED_CLAIMED_VALUES), mload(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA))) - - point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_L_COM_X), acc_gamma, mPtr) - fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_L_AT_ZETA), acc_gamma) - - acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) - point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_R_COM_X), acc_gamma, mPtr) - fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_R_AT_ZETA), acc_gamma) - - acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) - point_acc_mul_calldata(state_folded_digests, add(aproof, PROOF_O_COM_X), acc_gamma, mPtr) - fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_O_AT_ZETA), acc_gamma) - - acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) - mstore(mPtr, VK_S1_COM_X) - mstore(mPtr20, VK_S1_COM_Y) - point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) - fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_S1_AT_ZETA), acc_gamma) - - acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) - mstore(mPtr, VK_S2_COM_X) - mstore(mPtr20, VK_S2_COM_Y) - point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) - fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), add(aproof, PROOF_S2_AT_ZETA), acc_gamma) - let poqaz := add(aproof, PROOF_OPENING_QCP_AT_ZETA) - - acc_gamma := mulmod(acc_gamma, l_gamma_kzg, R_MOD) - mstore(mPtr, VK_QCP_0_X) - mstore(mPtr20, VK_QCP_0_Y) - point_acc_mul(state_folded_digests, mPtr, acc_gamma, mPtr40) - fr_acc_mul_calldata(add(state, STATE_FOLDED_CLAIMED_VALUES), poqaz, acc_gamma) - poqaz := add(poqaz, 0x20) - - } - - /// @notice generate the challenge (using Fiat Shamir) to fold the opening proofs - /// at ζ. - /// The process for deriving γ is the same as in derive_gamma but this time the inputs are - /// in this order (the [] means it's a commitment): - /// * ζ - /// * [Linearised polynomial] - /// * [L], [R], [O] - /// * [S₁] [S₂] - /// * [Pi_{i}] (wires associated to custom gates) - /// Then there are the purported evaluations of the previous committed polynomials: - /// * Linearised_polynomial(ζ) - /// * L(ζ), R(ζ), O(ζ), S₁(ζ), S₂(ζ) - /// * Pi_{i}(ζ) - /// * Z(ζω) - /// @param aproof pointer to the proof - function compute_gamma_kzg(aproof) { - - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - mstore(mPtr, FS_GAMMA_KZG) // "gamma" - mstore(add(mPtr, 0x20), mload(add(state, STATE_ZETA))) - mstore(add(mPtr,0x40), mload(add(state, STATE_LINEARISED_POLYNOMIAL_X))) - mstore(add(mPtr,0x60), mload(add(state, STATE_LINEARISED_POLYNOMIAL_Y))) - calldatacopy(add(mPtr, 0x80), add(aproof, PROOF_L_COM_X), 0xc0) - mstore(add(mPtr,0x140), VK_S1_COM_X) - mstore(add(mPtr,0x160), VK_S1_COM_Y) - mstore(add(mPtr,0x180), VK_S2_COM_X) - mstore(add(mPtr,0x1a0), VK_S2_COM_Y) - - let offset := 0x1c0 - - mstore(add(mPtr,offset), VK_QCP_0_X) - mstore(add(mPtr,add(offset, 0x20)), VK_QCP_0_Y) - offset := add(offset, 0x40) - mstore(add(mPtr, offset), mload(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA))) - mstore(add(mPtr, add(offset, 0x20)), calldataload(add(aproof, PROOF_L_AT_ZETA))) - mstore(add(mPtr, add(offset, 0x40)), calldataload(add(aproof, PROOF_R_AT_ZETA))) - mstore(add(mPtr, add(offset, 0x60)), calldataload(add(aproof, PROOF_O_AT_ZETA))) - mstore(add(mPtr, add(offset, 0x80)), calldataload(add(aproof, PROOF_S1_AT_ZETA))) - mstore(add(mPtr, add(offset, 0xa0)), calldataload(add(aproof, PROOF_S2_AT_ZETA))) - - let _mPtr := add(mPtr, add(offset, 0xc0)) - - - let _poqaz := add(aproof, PROOF_OPENING_QCP_AT_ZETA) - calldatacopy(_mPtr, _poqaz, mul(VK_NB_CUSTOM_GATES, 0x20)) - _mPtr := add(_mPtr, mul(VK_NB_CUSTOM_GATES, 0x20)) - - - mstore(_mPtr, calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA))) - - let start_input := 0x1b // 00.."gamma" - let size_input := add(0x14, mul(VK_NB_CUSTOM_GATES,3)) // number of 32bytes elmts = 0x14 (zeta+3*6 for the digests+openings) + 3*VK_NB_CUSTOM_GATES (for the commitments of the selectors) + 1 (opening of Z at ζω) - size_input := add(0x5, mul(size_input, 0x20)) // size in bytes: 15*32 bytes + 5 bytes for gamma - let check_staticcall := staticcall(gas(), SHA2, add(mPtr,start_input), size_input, add(state, STATE_GAMMA_KZG), 0x20) - if iszero(check_staticcall) { - error_verify() - } - mstore(add(state, STATE_GAMMA_KZG), mod(mload(add(state, STATE_GAMMA_KZG)), R_MOD)) - } - - function compute_commitment_linearised_polynomial_ec(aproof, s1, s2) { - - let state := mload(0x40) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - - mstore(mPtr, VK_QL_COM_X) - mstore(add(mPtr, 0x20), VK_QL_COM_Y) - point_mul( - add(state, STATE_LINEARISED_POLYNOMIAL_X), - mPtr, - calldataload(add(aproof, PROOF_L_AT_ZETA)), - add(mPtr, 0x40) - ) - - mstore(mPtr, VK_QR_COM_X) - mstore(add(mPtr, 0x20), VK_QR_COM_Y) - point_acc_mul( - add(state, STATE_LINEARISED_POLYNOMIAL_X), - mPtr, - calldataload(add(aproof, PROOF_R_AT_ZETA)), - add(mPtr, 0x40) - ) - - let rl := mulmod(calldataload(add(aproof, PROOF_L_AT_ZETA)), calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) - mstore(mPtr, VK_QM_COM_X) - mstore(add(mPtr, 0x20), VK_QM_COM_Y) - point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, rl, add(mPtr, 0x40)) - - mstore(mPtr, VK_QO_COM_X) - mstore(add(mPtr, 0x20), VK_QO_COM_Y) - point_acc_mul( - add(state, STATE_LINEARISED_POLYNOMIAL_X), - mPtr, - calldataload(add(aproof, PROOF_O_AT_ZETA)), - add(mPtr, 0x40) - ) - - mstore(mPtr, VK_QK_COM_X) - mstore(add(mPtr, 0x20), VK_QK_COM_Y) - point_add( - add(state, STATE_LINEARISED_POLYNOMIAL_X), - add(state, STATE_LINEARISED_POLYNOMIAL_X), - mPtr, - add(mPtr, 0x40) - ) - - - let qcp_opening_at_zeta := add(aproof, PROOF_OPENING_QCP_AT_ZETA) - let bsb_commitments := add(aproof, PROOF_BSB_COMMITMENTS) - for { - let i := 0 - } lt(i, VK_NB_CUSTOM_GATES) { - i := add(i, 1) - } { - mstore(mPtr, calldataload(bsb_commitments)) - mstore(add(mPtr, 0x20), calldataload(add(bsb_commitments, 0x20))) - point_acc_mul( - add(state, STATE_LINEARISED_POLYNOMIAL_X), - mPtr, - calldataload(qcp_opening_at_zeta), - add(mPtr, 0x40) - ) - qcp_opening_at_zeta := add(qcp_opening_at_zeta, 0x20) - bsb_commitments := add(bsb_commitments, 0x40) - } - - - mstore(mPtr, VK_S3_COM_X) - mstore(add(mPtr, 0x20), VK_S3_COM_Y) - point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, s1, add(mPtr, 0x40)) - - mstore(mPtr, calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_X))) - mstore(add(mPtr, 0x20), calldataload(add(aproof, PROOF_GRAND_PRODUCT_COMMITMENT_Y))) - point_acc_mul(add(state, STATE_LINEARISED_POLYNOMIAL_X), mPtr, s2, add(mPtr, 0x40)) - - point_add( - add(state, STATE_LINEARISED_POLYNOMIAL_X), - add(state, STATE_LINEARISED_POLYNOMIAL_X), - add(state, STATE_FOLDED_H_X), - mPtr) - } - - /// @notice Compute the commitment to the linearized polynomial equal to - /// L(ζ)[Qₗ]+r(ζ)[Qᵣ]+R(ζ)L(ζ)[Qₘ]+O(ζ)[Qₒ]+[Qₖ]+Σᵢqc'ᵢ(ζ)[BsbCommitmentᵢ] + - /// α*( Z(μζ)(L(ζ)+β*S₁(ζ)+γ)*(R(ζ)+β*S₂(ζ)+γ)[S₃]-[Z](L(ζ)+β*id_{1}(ζ)+γ)*(R(ζ)+β*id_{2}(ζ)+γ)*(O(ζ)+β*id_{3}(ζ)+γ) ) + - /// α²*L₁(ζ)[Z] - Z_{H}(ζ)*(([H₀] + ζᵐ⁺²*[H₁] + ζ²⁽ᵐ⁺²⁾*[H₂]) - /// where - /// * id_1 = id, id_2 = vk_coset_shift*id, id_3 = vk_coset_shift^{2}*id - /// * the [] means that it's a commitment (i.e. a point on Bn254(F_p)) - /// * Z_{H}(ζ) = ζ^n-1 - /// @param aproof pointer to the proof - function compute_commitment_linearised_polynomial(aproof) { - let state := mload(0x40) - let l_beta := mload(add(state, STATE_BETA)) - let l_gamma := mload(add(state, STATE_GAMMA)) - let l_zeta := mload(add(state, STATE_ZETA)) - let l_alpha := mload(add(state, STATE_ALPHA)) - - let u := mulmod(calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA)), l_beta, R_MOD) - let v := mulmod(l_beta, calldataload(add(aproof, PROOF_S1_AT_ZETA)), R_MOD) - v := addmod(v, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) - v := addmod(v, l_gamma, R_MOD) - - let w := mulmod(l_beta, calldataload(add(aproof, PROOF_S2_AT_ZETA)), R_MOD) - w := addmod(w, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) - w := addmod(w, l_gamma, R_MOD) - - let s1 := mulmod(u, v, R_MOD) - s1 := mulmod(s1, w, R_MOD) - s1 := mulmod(s1, l_alpha, R_MOD) - - let coset_square := mulmod(VK_COSET_SHIFT, VK_COSET_SHIFT, R_MOD) - let betazeta := mulmod(l_beta, l_zeta, R_MOD) - u := addmod(betazeta, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) - u := addmod(u, l_gamma, R_MOD) - - v := mulmod(betazeta, VK_COSET_SHIFT, R_MOD) - v := addmod(v, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) - v := addmod(v, l_gamma, R_MOD) - - w := mulmod(betazeta, coset_square, R_MOD) - w := addmod(w, calldataload(add(aproof, PROOF_O_AT_ZETA)), R_MOD) - w := addmod(w, l_gamma, R_MOD) - - let s2 := mulmod(u, v, R_MOD) - s2 := mulmod(s2, w, R_MOD) - s2 := sub(R_MOD, s2) - s2 := mulmod(s2, l_alpha, R_MOD) - s2 := addmod(s2, mload(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0)), R_MOD) - - // at this stage: - // * s₁ = α*Z(μζ)(l(ζ)+β*s₁(ζ)+γ)*(r(ζ)+β*s₂(ζ)+γ)*β - // * s₂ = -α*(l(ζ)+β*ζ+γ)*(r(ζ)+β*u*ζ+γ)*(o(ζ)+β*u²*ζ+γ) + α²*L₁(ζ) - - compute_commitment_linearised_polynomial_ec(aproof, s1, s2) - } - - /// @notice compute -z_h(ζ)*([H₁] + ζⁿ⁺²[H₂] + ζ²⁽ⁿ⁺²⁾[H₃]) and store the result at - /// state + state_folded_h - /// @param aproof pointer to the proof - function fold_h(aproof) { - let state := mload(0x40) - let n_plus_two := add(VK_DOMAIN_SIZE, 2) - let mPtr := add(mload(0x40), STATE_LAST_MEM) - let zeta_power_n_plus_two := pow(mload(add(state, STATE_ZETA)), n_plus_two, mPtr) - point_mul_calldata(add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_2_COM_X), zeta_power_n_plus_two, mPtr) - point_add_calldata(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_1_COM_X), mPtr) - point_mul(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), zeta_power_n_plus_two, mPtr) - point_add_calldata(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), add(aproof, PROOF_H_0_COM_X), mPtr) - point_mul(add(state, STATE_FOLDED_H_X), add(state, STATE_FOLDED_H_X), mload(add(state, STATE_ZETA_POWER_N_MINUS_ONE)), mPtr) - let folded_h_y := mload(add(state, STATE_FOLDED_H_Y)) - folded_h_y := sub(P_MOD, folded_h_y) - mstore(add(state, STATE_FOLDED_H_Y), folded_h_y) - } - - /// @notice check that the opening of the linearised polynomial at zeta is equal to - /// - [ PI(ζ) - α²*L₁(ζ) + α(l(ζ)+β*s1(ζ)+γ)(r(ζ)+β*s2(ζ)+γ)(o(ζ)+γ)*z(ωζ) ] - /// @param aproof pointer to the proof - function compute_opening_linearised_polynomial(aproof) { - - let state := mload(0x40) - - // (l(ζ)+β*s1(ζ)+γ) - let s1 - s1 := mulmod(calldataload(add(aproof, PROOF_S1_AT_ZETA)), mload(add(state, STATE_BETA)), R_MOD) - s1 := addmod(s1, mload(add(state, STATE_GAMMA)), R_MOD) - s1 := addmod(s1, calldataload(add(aproof, PROOF_L_AT_ZETA)), R_MOD) - - // (r(ζ)+β*s2(ζ)+γ) - let s2 - s2 := mulmod(calldataload(add(aproof, PROOF_S2_AT_ZETA)), mload(add(state, STATE_BETA)), R_MOD) - s2 := addmod(s2, mload(add(state, STATE_GAMMA)), R_MOD) - s2 := addmod(s2, calldataload(add(aproof, PROOF_R_AT_ZETA)), R_MOD) - - // (o(ζ)+γ) - let o - o := addmod(calldataload(add(aproof, PROOF_O_AT_ZETA)), mload(add(state, STATE_GAMMA)), R_MOD) - - // α*Z(μζ)*(l(ζ)+β*s1(ζ)+γ)*(r(ζ)+β*s2(ζ)+γ)*(o(ζ)+γ) - s1 := mulmod(s1, s2, R_MOD) - s1 := mulmod(s1, o, R_MOD) - s1 := mulmod(s1, mload(add(state, STATE_ALPHA)), R_MOD) - s1 := mulmod(s1, calldataload(add(aproof, PROOF_GRAND_PRODUCT_AT_ZETA_OMEGA)), R_MOD) - - // PI(ζ) - α²*L₁(ζ) + α(l(ζ)+β*s1(ζ)+γ)(r(ζ)+β*s2(ζ)+γ)(o(ζ)+γ)*z(ωζ) - s1 := addmod(s1, mload(add(state, STATE_PI)), R_MOD) - s2 := mload(add(state, STATE_ALPHA_SQUARE_LAGRANGE_0)) - s2 := sub(R_MOD, s2) - s1 := addmod(s1, s2, R_MOD) - s1 := sub(R_MOD, s1) - - mstore(add(state, STATE_OPENING_LINEARISED_POLYNOMIAL_ZETA), s1) - } - - // BEGINNING utils math functions ------------------------------------------------- - - /// @param dst pointer storing the result - /// @param p pointer to the first point - /// @param q pointer to the second point - /// @param mPtr pointer to free memory - function point_add(dst, p, q, mPtr) { - mstore(mPtr, mload(p)) - mstore(add(mPtr, 0x20), mload(add(p, 0x20))) - mstore(add(mPtr, 0x40), mload(q)) - mstore(add(mPtr, 0x60), mload(add(q, 0x20))) - let l_success := staticcall(gas(),EC_ADD,mPtr,0x80,dst,0x40) - if iszero(l_success) { - error_ec_op() - } - } - - /// @param dst pointer storing the result - /// @param p pointer to the first point (calldata) - /// @param q pointer to the second point (calladata) - /// @param mPtr pointer to free memory - function point_add_calldata(dst, p, q, mPtr) { - mstore(mPtr, mload(p)) - mstore(add(mPtr, 0x20), mload(add(p, 0x20))) - mstore(add(mPtr, 0x40), calldataload(q)) - mstore(add(mPtr, 0x60), calldataload(add(q, 0x20))) - let l_success := staticcall(gas(), EC_ADD, mPtr, 0x80, dst, 0x40) - if iszero(l_success) { - error_ec_op() - } - } - - /// @parma dst pointer storing the result - /// @param src pointer to a point on Bn254(𝔽_p) - /// @param s scalar - /// @param mPtr free memory - function point_mul(dst,src,s, mPtr) { - mstore(mPtr,mload(src)) - mstore(add(mPtr,0x20),mload(add(src,0x20))) - mstore(add(mPtr,0x40),s) - let l_success := staticcall(gas(),EC_MUL,mPtr,0x60,dst,0x40) - if iszero(l_success) { - error_ec_op() - } - } - - /// @parma dst pointer storing the result - /// @param src pointer to a point on Bn254(𝔽_p) on calldata - /// @param s scalar - /// @param mPtr free memory - function point_mul_calldata(dst, src, s, mPtr) { - mstore(mPtr, calldataload(src)) - mstore(add(mPtr, 0x20), calldataload(add(src, 0x20))) - mstore(add(mPtr, 0x40), s) - let l_success := staticcall(gas(), EC_MUL, mPtr, 0x60, dst, 0x40) - if iszero(l_success) { - error_ec_op() - } - } - - /// @notice dst <- dst + [s]src (Elliptic curve) - /// @param dst pointer accumulator point storing the result - /// @param src pointer to the point to multiply and add - /// @param s scalar - /// @param mPtr free memory - function point_acc_mul(dst,src,s, mPtr) { - mstore(mPtr,mload(src)) - mstore(add(mPtr,0x20),mload(add(src,0x20))) - mstore(add(mPtr,0x40),s) - let l_success := staticcall(gas(),7,mPtr,0x60,mPtr,0x40) - mstore(add(mPtr,0x40),mload(dst)) - mstore(add(mPtr,0x60),mload(add(dst,0x20))) - l_success := and(l_success, staticcall(gas(),EC_ADD,mPtr,0x80,dst, 0x40)) - if iszero(l_success) { - error_ec_op() - } - } - - /// @notice dst <- dst + [s]src (Elliptic curve) - /// @param dst pointer accumulator point storing the result - /// @param src pointer to the point to multiply and add (on calldata) - /// @param s scalar - /// @mPtr free memory - function point_acc_mul_calldata(dst, src, s, mPtr) { - mstore(mPtr, calldataload(src)) - mstore(add(mPtr, 0x20), calldataload(add(src, 0x20))) - mstore(add(mPtr, 0x40), s) - let l_success := staticcall(gas(), 7, mPtr, 0x60, mPtr, 0x40) - mstore(add(mPtr, 0x40), mload(dst)) - mstore(add(mPtr, 0x60), mload(add(dst, 0x20))) - l_success := and(l_success, staticcall(gas(), EC_ADD, mPtr, 0x80, dst, 0x40)) - if iszero(l_success) { - error_ec_op() - } - } - - /// @notice dst <- dst + src*s (Fr) dst,src are addresses, s is a value - /// @param dst pointer storing the result - /// @param src pointer to the scalar to multiply and add (on calldata) - /// @param s scalar - function fr_acc_mul_calldata(dst, src, s) { - let tmp := mulmod(calldataload(src), s, R_MOD) - mstore(dst, addmod(mload(dst), tmp, R_MOD)) - } - - /// @param x element to exponentiate - /// @param e exponent - /// @param mPtr free memory - /// @return res x ** e mod r - function pow(x, e, mPtr)->res { - mstore(mPtr, 0x20) - mstore(add(mPtr, 0x20), 0x20) - mstore(add(mPtr, 0x40), 0x20) - mstore(add(mPtr, 0x60), x) - mstore(add(mPtr, 0x80), e) - mstore(add(mPtr, 0xa0), R_MOD) - let check_staticcall := staticcall(gas(),MOD_EXP,mPtr,0xc0,mPtr,0x20) - if eq(check_staticcall, 0) { - error_mod_exp() - } - res := mload(mPtr) - } - } - } -} diff --git a/contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol b/contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol deleted file mode 100644 index 2bd03a30c..000000000 --- a/contracts/plonk/bn254/upstream/v6.1.0/SP1VerifierPlonk.sol +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ISP1Verifier, ISP1VerifierWithHash} from "../ISP1Verifier.sol"; -import {PlonkVerifier} from "./PlonkVerifier.sol"; - -/// @title SP1 Verifier -/// @author Succinct Labs -/// @notice This contracts implements a solidity verifier for SP1. -contract SP1Verifier is PlonkVerifier, ISP1VerifierWithHash { - /// @notice Thrown when the verifier selector from this proof does not match the one in this - /// verifier. This indicates that this proof was sent to the wrong verifier. - /// @param received The verifier selector from the first 4 bytes of the proof. - /// @param expected The verifier selector from the first 4 bytes of the VERIFIER_HASH(). - error WrongVerifierSelector(bytes4 received, bytes4 expected); - - /// @notice Thrown when the exit code is invalid. - error InvalidExitCode(); - - /// @notice Thrown when the proof is invalid. - error InvalidProof(); - - /// @notice Thrown when the vkRoot is invalid. - error InvalidVkRoot(); - - /// @notice The version of the circuit. - function VERSION() external pure returns (string memory) { - return "v6.1.0"; - } - - /// @inheritdoc ISP1VerifierWithHash - function VERIFIER_HASH() public pure returns (bytes32) { - return 0x5a093a2fcb46394f5cadfe55c44d4d572fad9cec7aeb38026b0278322ef07fac; - } - - /// @notice The recursion vk root. - function VK_ROOT() public pure returns (bytes32) { - return 0x002f850ee998974d6cc00e50cd0814b098c05bfade466d28573240d057f25352; - } - - /// @notice Hashes the public values to a field elements inside Bn254. - /// @param publicValues The public values. - function hashPublicValues(bytes calldata publicValues) public pure returns (bytes32) { - return sha256(publicValues) & bytes32(uint256((1 << 253) - 1)); - } - - /// @notice Verifies a proof with given public values and vkey. - /// @param programVKey The verification key for the RISC-V program. - /// @param publicValues The public values encoded as bytes. - /// @param proofBytes The proof of the program execution the SP1 zkVM encoded as bytes. - function verifyProof( - bytes32 programVKey, - bytes calldata publicValues, - bytes calldata proofBytes - ) external view { - bytes4 receivedSelector = bytes4(proofBytes[:4]); - bytes4 expectedSelector = bytes4(VERIFIER_HASH()); - if (receivedSelector != expectedSelector) { - revert WrongVerifierSelector(receivedSelector, expectedSelector); - } - - uint256 expectedVkRoot = uint256(VK_ROOT()); - - bytes32 publicValuesDigest = hashPublicValues(publicValues); - uint256 exitCode = uint256(bytes32(proofBytes[4:36])); - uint256 vkRoot = uint256(bytes32(proofBytes[36:68])); - uint256 nonce = uint256(bytes32(proofBytes[68:100])); - - uint256[] memory inputs = new uint256[](5); - inputs[0] = uint256(programVKey); - inputs[1] = uint256(publicValuesDigest); - inputs[2] = exitCode; - inputs[3] = vkRoot; - inputs[4] = nonce; - if (exitCode != 0) { - revert InvalidExitCode(); - } - if (vkRoot != expectedVkRoot) { - revert InvalidVkRoot(); - } - bool success = this.Verify(proofBytes[100:], inputs); - if (!success) { - revert InvalidProof(); - } - } -} diff --git a/contracts/plonk/foundry.toml b/contracts/plonk/foundry.toml index be22e90dc..3e275ae32 100644 --- a/contracts/plonk/foundry.toml +++ b/contracts/plonk/foundry.toml @@ -10,4 +10,4 @@ hardfork = "osaka" optimizer = true optimizer_runs = 200 via_ir = true -fs_permissions = [{ access = "read", path = "./bn254/testdata" }] +fs_permissions = [{ access = "read", path = "./testdata" }] diff --git a/contracts/plonk/bn254/src/PlonkVerifier.sol b/contracts/plonk/src/PlonkVerifier.sol similarity index 98% rename from contracts/plonk/bn254/src/PlonkVerifier.sol rename to contracts/plonk/src/PlonkVerifier.sol index b5a423ec5..ba43c5f61 100644 --- a/contracts/plonk/bn254/src/PlonkVerifier.sol +++ b/contracts/plonk/src/PlonkVerifier.sol @@ -16,7 +16,7 @@ // Code generated by gnark DO NOT EDIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.0; contract PlonkVerifier { @@ -42,16 +42,16 @@ contract PlonkVerifier { uint256 private constant VK_DOMAIN_SIZE = 16777216; uint256 private constant VK_INV_DOMAIN_SIZE = 21888241567198334088790460357988866238279339518792980768180410072331574733841; uint256 private constant VK_OMEGA = 5709868443893258075976348696661355716898495876243883251619397131511003808859; - uint256 private constant VK_QL_COM_X = 2247446810280598997078512583076010463705556275328790190283284160986145432419; - uint256 private constant VK_QL_COM_Y = 17899293397787521469498438166384916767131308403173136158957583810720457160067; - uint256 private constant VK_QR_COM_X = 17261453585169625555680195078041304151068282529357212908112765392770894534278; - uint256 private constant VK_QR_COM_Y = 5032802837544509306758328961405050308554210384548103718736125522125179924100; + uint256 private constant VK_QL_COM_X = 16701059307537836626555997159330120966944409192043807154522028097132612737111; + uint256 private constant VK_QL_COM_Y = 15641916856234058077564717889790381915101939504854130293314587558164024999098; + uint256 private constant VK_QR_COM_X = 17866256018130120097181161205000858122512183064937595135401250601603969832513; + uint256 private constant VK_QR_COM_Y = 8229603566429884583029561969830822653856844067770597252008576016098049491038; uint256 private constant VK_QM_COM_X = 3584607594627111430618022243188257181559026512530565511559511908334574961735; uint256 private constant VK_QM_COM_Y = 11088732376897191683720631537870533628246508861445120171445562729184158960707; uint256 private constant VK_QO_COM_X = 2970551836734429030743170272625326962021756571268061179291098770692736928725; uint256 private constant VK_QO_COM_Y = 14253370184471031778663230296712970012512019999228359328162740821710798273297; - uint256 private constant VK_QK_COM_X = 8638199322136519747148286291472634149689894578550046327199061687921842786694; - uint256 private constant VK_QK_COM_Y = 10330219132359379321952244687961679343297956293983744809309690374608570754593; + uint256 private constant VK_QK_COM_X = 1153534261751935493632379155913683854708987621901585919817771317330210075003; + uint256 private constant VK_QK_COM_Y = 13766938121707058222410574775336129861937943919036346455874120517360017751210; uint256 private constant VK_S1_COM_X = 8374572668059626572024474755514158947558130423615005095635236580751301971603; uint256 private constant VK_S1_COM_Y = 20107543125503712563986728692881849476263382184844757010316687939070575623448; diff --git a/contracts/plonk/test/IxPlonkVerifier.t.sol b/contracts/plonk/test/IxPlonkVerifier.t.sol index 38f1a0c3a..12a40a6d2 100644 --- a/contracts/plonk/test/IxPlonkVerifier.t.sol +++ b/contracts/plonk/test/IxPlonkVerifier.t.sol @@ -2,17 +2,21 @@ pragma solidity ^0.8.28; import {IxPlonkVerifier, IPlonkVerifier} from "../src/IxPlonkVerifier.sol"; -import {PlonkVerifier} from "../bn254/src/PlonkVerifier.sol"; +import {PlonkVerifier} from "../src/PlonkVerifier.sol"; interface Vm { function projectRoot() external view returns (string memory); function readFile(string calldata path) external view returns (string memory); function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory); function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32); + function parseJsonString(string calldata json, string calldata key) external pure returns (string memory); } contract IxPlonkVerifierTest { Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + /// SHA-256 of crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin, + /// the key src/PlonkVerifier.sol is generated from. + string private constant RELEASE_KEY_SHA256 = "7123a80f9c56b25138ffe8ec47a53c6b0b22b64bf183380d63e5c80a7e3144a9"; IxPlonkVerifier private adapter; PlonkVerifier private plonk; bytes private proof; @@ -20,7 +24,11 @@ contract IxPlonkVerifierTest { event log_named_uint(string key, uint256 value); function setUp() public { - string memory fixture = vm.readFile(string.concat(vm.projectRoot(), "/bn254/testdata/init.json")); + string memory fixture = vm.readFile(string.concat(vm.projectRoot(), "/testdata/init.json")); + require( + keccak256(bytes(vm.parseJsonString(fixture, ".verifyingKeySHA256"))) == keccak256(bytes(RELEASE_KEY_SHA256)), + "fixture proof is not under the release PLONK key" + ); proof = vm.parseJsonBytes(fixture, ".proof"); digest = vm.parseJsonBytes32(fixture, ".claimDigest"); plonk = new PlonkVerifier(); diff --git a/contracts/plonk/bn254/testdata/init.json b/contracts/plonk/testdata/init.json similarity index 100% rename from contracts/plonk/bn254/testdata/init.json rename to contracts/plonk/testdata/init.json diff --git a/crates/aiur/Cargo.toml b/crates/aiur/Cargo.toml index 04080753f..4343adc1e 100644 --- a/crates/aiur/Cargo.toml +++ b/crates/aiur/Cargo.toml @@ -3,8 +3,6 @@ name = "aiur" version.workspace = true edition.workspace = true license.workspace = true -# The build script includes ../build_support, outside the package. -publish = false [dependencies] blake3 = { workspace = true } diff --git a/crates/aiur/build.rs b/crates/aiur/build.rs index 50d6b3e30..abdf2fde0 100644 --- a/crates/aiur/build.rs +++ b/crates/aiur/build.rs @@ -5,22 +5,22 @@ //! headers/libraries. use std::env; +use std::ffi::OsString; use std::path::PathBuf; -#[path = "../build_support/cuda.rs"] -mod cuda; -use cuda::{cuda_library_directories, nvcc_path}; use std::process::Command; +/// Toolchain metadata from multi-stark's CUDA build, so the trace writers +/// target the same compiler and GPUs as the backend they link against. +fn multi_stark_cuda(key: &str) -> OsString { + env::var_os(format!("DEP_MULTI_STARK_CUDA_{key}")) + .unwrap_or_else(|| panic!("multi-stark's CUDA build did not report {key}")) +} + fn main() { println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=../build_support/cuda.rs"); - println!("cargo:rerun-if-env-changed=NVCC"); println!("cargo:rerun-if-env-changed=NVCC_CCBIN"); println!("cargo:rerun-if-env-changed=NVCC_PREPEND_FLAGS"); println!("cargo:rerun-if-env-changed=NVCC_APPEND_FLAGS"); - println!("cargo:rerun-if-env-changed=CUDA_HOME"); - println!("cargo:rerun-if-env-changed=CUDA_PATH"); - println!("cargo:rerun-if-env-changed=MULTI_STARK_CUDA_ARCHS"); if env::var_os("CARGO_FEATURE_CUDA").is_none() { return; @@ -37,17 +37,21 @@ fn main() { "the first-party CUDA backend currently supports x86_64 targets only" ); - let nvcc = nvcc_path(); - let out_dir = - PathBuf::from(env::var_os("OUT_DIR").expect("Cargo did not set OUT_DIR")); - let library = out_dir.join("libaiur_trace_cuda.a"); - let architectures = cuda::architectures(&nvcc, &["MULTI_STARK_CUDA_ARCHS"]); - // Every aiur CUDA source belongs to the generated trace writers; the bare // `cuda` feature only enables multi-stark's device backend. if env::var_os("CARGO_FEATURE_CUDA_TRACE_CODEGEN").is_none() { return; } + let nvcc = multi_stark_cuda("NVCC"); + let architectures: Vec = multi_stark_cuda("ARCHS") + .into_string() + .expect("CUDA architectures are ASCII") + .split(',') + .map(str::to_owned) + .collect(); + let out_dir = + PathBuf::from(env::var_os("OUT_DIR").expect("Cargo did not set OUT_DIR")); + let library = out_dir.join("libaiur_trace_cuda.a"); let include = env::var_os("DEP_MULTI_STARK_CUDA_INCLUDE") .expect("cuda-trace-codegen requires multi-stark's shared CUDA field header metadata"); let includes = vec![PathBuf::from("cuda"), PathBuf::from(&include)]; @@ -178,7 +182,7 @@ fn main() { println!("cargo:rustc-link-lib=dylib=pthread"); println!("cargo:rustc-link-lib=dylib=stdc++"); - for directory in cuda_library_directories(&nvcc) { + for directory in env::split_paths(&multi_stark_cuda("LIB_DIRS")) { if directory.is_dir() { println!("cargo:rustc-link-search=native={}", directory.display()); } diff --git a/crates/build_support/cuda.rs b/crates/build_support/cuda.rs deleted file mode 100644 index da421254d..000000000 --- a/crates/build_support/cuda.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::{ - env, - path::{Path, PathBuf}, - process::Command, -}; - -pub(crate) fn nvcc_path() -> std::ffi::OsString { - if let Some(nvcc) = env::var_os("NVCC") { - return nvcc; - } - if let Some(root) = - env::var_os("CUDA_HOME").or_else(|| env::var_os("CUDA_PATH")) - { - let candidate = PathBuf::from(root).join("bin/nvcc"); - if candidate.is_file() { - return candidate.into_os_string(); - } - } - "nvcc".into() -} - -pub(crate) fn architectures( - nvcc: &std::ffi::OsStr, - variables: &[&str], -) -> Vec { - let supported = supported_architectures(nvcc); - let configured = variables - .iter() - .find_map(|name| env::var(name).ok()) - .or_else(detect_nvidia_architectures) - .unwrap_or_else(|| { - panic!("no NVIDIA GPU detected; set {}", variables.join(" or ")) - }); - let architectures: Vec<_> = configured - .split(',') - .map(str::trim) - .filter(|architecture| !architecture.is_empty()) - .map(|architecture| { - assert!( - (2..=3).contains(&architecture.len()) - && architecture.chars().all(|character| character.is_ascii_digit()), - "invalid CUDA architecture {architecture:?}; expected comma-separated numbers such as 80,90" - ); - assert!( - supported.is_empty() - || supported.iter().any(|item| item == architecture), - "nvcc does not support CUDA architecture sm_{architecture}" - ); - architecture.to_owned() - }) - .collect(); - assert!( - !architectures.is_empty(), - "CUDA architecture selection must contain at least one architecture" - ); - architectures -} - -fn supported_architectures(nvcc: &std::ffi::OsStr) -> Vec { - let Ok(output) = Command::new(nvcc).arg("--list-gpu-code").output() else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - String::from_utf8_lossy(&output.stdout) - .split_whitespace() - .filter_map(|code| code.strip_prefix("sm_")) - .filter(|code| code.chars().all(|character| character.is_ascii_digit())) - .map(str::to_owned) - .collect() -} - -fn detect_nvidia_architectures() -> Option { - let output = Command::new("nvidia-smi") - .args(["--query-gpu=compute_cap", "--format=csv,noheader"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let capabilities = String::from_utf8(output.stdout).ok()?; - let mut architectures: Vec = capabilities - .lines() - .map(|capability| { - capability - .chars() - .filter(|character| character.is_ascii_digit()) - .collect() - }) - .filter(|architecture: &String| !architecture.is_empty()) - .collect(); - architectures.sort_unstable(); - architectures.dedup(); - (!architectures.is_empty()).then(|| architectures.join(",")) -} - -pub(crate) fn cuda_library_directories(nvcc: &std::ffi::OsStr) -> Vec { - let root = env::var_os("CUDA_HOME") - .or_else(|| env::var_os("CUDA_PATH")) - .map(PathBuf::from) - .or_else(|| { - let nvcc = Path::new(nvcc); - nvcc - .is_absolute() - .then(|| nvcc.parent()?.parent().map(Path::to_path_buf)) - .flatten() - }) - .unwrap_or_else(|| PathBuf::from("/usr/local/cuda")); - vec![root.join("lib64"), root.join("targets/x86_64-linux/lib")] -} diff --git a/crates/ffi/src/aiur/aggregate.rs b/crates/ffi/src/aiur/aggregate.rs index 64a435899..639d12f21 100644 --- a/crates/ffi/src/aiur/aggregate.rs +++ b/crates/ffi/src/aiur/aggregate.rs @@ -35,7 +35,7 @@ pub(crate) use store::write_store; use store::{load_input_proofs, persist_wrapper, wrapper_address}; mod ffi; -pub mod lanes; +pub mod multi_gpu; pub(crate) mod normalize; mod plan; mod prepare; @@ -263,7 +263,7 @@ fn run(config: RunConfig<'_>) -> Result { print_plan(&specs, &prepared.shards, config.structural_above); } if let (true, Some(target)) = (config.plan_only, config.subtree) { - // The lane's `ix prove --shards` argument, on stdout as one line. + // The subtree run's `ix prove --shards` argument, on stdout as one line. let mut ids: Vec = required .iter() .enumerate() @@ -422,7 +422,7 @@ fn run(config: RunConfig<'_>) -> Result { &active, )?; if let Some(target) = config.subtree { - // A lane's success means its subtree root is proven, verified and + // A subtree run's success means its subtree root is proven, verified and // published where the final run will look for it. let slot = slots .get_mut(target) diff --git a/crates/ffi/src/aiur/aggregate/lanes.rs b/crates/ffi/src/aiur/aggregate/multi_gpu.rs similarity index 97% rename from crates/ffi/src/aiur/aggregate/lanes.rs rename to crates/ffi/src/aiur/aggregate/multi_gpu.rs index 0203e348f..0f48f3072 100644 --- a/crates/ffi/src/aiur/aggregate/lanes.rs +++ b/crates/ffi/src/aiur/aggregate/multi_gpu.rs @@ -43,8 +43,8 @@ use rustc_hash::FxHashSet; use std::thread; pub struct Config { - pub lanes: usize, - /// Per-GPU host budget in bytes, multiplied by `lanes`; 0 detects one + pub gpus: usize, + /// Per-GPU host budget in bytes, multiplied by `gpus`; 0 detects one /// process-wide allowance. pub max_ram_bytes: usize, /// Preparation threads per GPU, combined into one process-wide pool. @@ -58,7 +58,7 @@ pub struct Config { /// The default ceiling on one execution record's counted bytes: large /// enough that any single constant of the environments proven so far /// executes as one claim, small enough that one record cannot take most -/// of a four-lane pool. A claim over it is bisected rather than executed. +/// of a four-GPU pool. A claim over it is bisected rather than executed. /// `AIUR_RECORD_MAX_BYTES` overrides it. const DEFAULT_RECORD_MAX_BYTES: usize = 128 << 30; @@ -409,7 +409,7 @@ pub fn run( let source = ShardManifest::from_bytes(&manifest_bytes) .map_err(|error| format!("manifest parse failed: {error}"))?; let home = std::env::var_os("HOME").ok_or("no HOME environment variable")?; - let cache_root = std::env::var_os("AIUR_LANES_CACHE_DIR") + let cache_root = std::env::var_os("AIUR_PROOF_CACHE_DIR") .map_or_else(|| PathBuf::from(home).join(".ix/cache"), PathBuf::from); let checkpoint_path = cache_root .join("refined-manifests") @@ -525,7 +525,7 @@ struct Pass { cache_dir: PathBuf, index_dir: PathBuf, fresh: bool, - /// CPU execution threads shared by every lane. + /// CPU execution threads shared by every GPU. executions: usize, /// The process-wide host limit, the prover budget of every slot. wrap_budget: Option, @@ -569,7 +569,7 @@ impl Pass { } fn say(message: &str) { - eprintln!("[lanes] {message}"); + eprintln!("[multi-gpu] {message}"); } fn plan_pass( @@ -629,18 +629,17 @@ fn plan_pass( fs::create_dir_all(&cache_dir) .map_err(|error| format!("create {}: {error}", cache_dir.display()))?; - if cfg.lanes == 0 { - return Err("at least one lane is required".into()); + if cfg.gpus == 0 { + return Err("at least one GPU is required".into()); } let exec_jobs = if cfg.exec_jobs == 0 { let cores = thread::available_parallelism().map_or(1, usize::from); - (cores / cfg.lanes).max(1) + (cores / cfg.gpus).max(1) } else { cfg.exec_jobs }; - let executions = exec_jobs - .checked_mul(cfg.lanes) - .ok_or("execution thread count overflow")?; + let executions = + exec_jobs.checked_mul(cfg.gpus).ok_or("execution thread count overflow")?; let cells = match std::env::var("AIUR_TRACE_SHARD_MAX_CELLS") { Ok(value) => value .parse::() @@ -652,7 +651,7 @@ fn plan_pass( }; let cgroup_limit = cgroup_memory_limit(); let host = HostBudget::new( - cfg.lanes, + cfg.gpus, executions, cfg.max_ram_bytes, super::super::protocol::detected_ram_budget(), @@ -676,7 +675,7 @@ fn plan_pass( format_gib(host.records), format_gib(host.workspace), format_gib(host.headroom), - cfg.lanes, + cfg.gpus, format_gib(host.initial.min(pool.record_limit())), )); say(&format!( @@ -687,11 +686,11 @@ fn plan_pass( // A prover pair per device, sharing the bytecode of the systems the // CLI built. The verifying keys must not depend on the device: every // cache key and claim binding is derived from them. - say(&format!("building {} resident workers", cfg.lanes)); - let systems: Vec<(AiurSystem, AiurSystem)> = (0..cfg.lanes) + say(&format!("building {} resident workers", cfg.gpus)); + let systems: Vec<(AiurSystem, AiurSystem)> = (0..cfg.gpus) .map(|g| { let device = - i32::try_from(g).map_err(|error| format!("lane {g}: {error}"))?; + i32::try_from(g).map_err(|error| format!("GPU {g}: {error}"))?; Ok((ixvm.on_device(device), aggr.on_device(device))) }) .collect::>()?; @@ -1117,7 +1116,7 @@ fn run_manifest( let (events_tx, events_rx) = mpsc::channel::(); let prep_queue = WorkQueue::::new(pass.executions); - let item_queue = WorkQueue::::new(cfg.lanes); + let item_queue = WorkQueue::::new(cfg.gpus); let outcome = thread::scope(|scope| -> Result { let _shutdown = ShutdownRun { pool: &pass.pool, prep: &prep_queue, items: &item_queue }; @@ -1250,17 +1249,17 @@ fn visible_gpus() -> usize { fs::read_dir("/proc/driver/nvidia/gpus").map_or(0, |gpus| gpus.count()) } -/// `AiurSystem.proveLanes`: the whole multi-device run; 0 lanes uses every +/// `AiurSystem.proveMultiGpu`: the whole multi-device run; 0 GPUs uses every /// visible GPU. #[unsafe(no_mangle)] -extern "C" fn rs_aiur_prove_lanes( +extern "C" fn rs_aiur_prove_multi_gpu( ixvm_system: LeanExternal>, aggr_system: LeanExternal>, env_handle: LeanExternal>, manifest_path: LeanString>, verify_idx: LeanNat>, aggr_idx: LeanNat>, - lanes: LeanNat>, + gpus: LeanNat>, max_ram_bytes: LeanNat>, exec_jobs: LeanNat>, structural_above: LeanNat>, @@ -1268,12 +1267,12 @@ extern "C" fn rs_aiur_prove_lanes( out_manifest: LeanString>, fresh: bool, ) -> LeanExcept { - let lanes = match lean_unbox_nat_as_usize(lanes.inner()) { + let gpus = match lean_unbox_nat_as_usize(gpus.inner()) { 0 => visible_gpus(), n => n, }; let cfg = Config { - lanes, + gpus, max_ram_bytes: lean_unbox_nat_as_usize(max_ram_bytes.inner()), exec_jobs: lean_unbox_nat_as_usize(exec_jobs.inner()), structural_above: lean_unbox_nat_as_usize(structural_above.inner()), @@ -1282,7 +1281,7 @@ extern "C" fn rs_aiur_prove_lanes( .then(|| PathBuf::from(out_manifest.as_str())), fresh, }; - if cfg.lanes == 0 { + if cfg.gpus == 0 { return LeanExcept::error_string("no visible GPU to prove on"); } let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -1300,7 +1299,7 @@ extern "C" fn rs_aiur_prove_lanes( Ok(Ok(root)) => LeanExcept::ok(LeanString::new(&root)), Ok(Err(error)) => LeanExcept::error_string(&error), Err(payload) => LeanExcept::error_string(&format!( - "lane scheduler panicked: {}", + "multi-GPU scheduler panicked: {}", panic_text(&payload) )), } diff --git a/crates/ffi/src/aiur/aggregate/lanes/checkpoint.rs b/crates/ffi/src/aiur/aggregate/multi_gpu/checkpoint.rs similarity index 100% rename from crates/ffi/src/aiur/aggregate/lanes/checkpoint.rs rename to crates/ffi/src/aiur/aggregate/multi_gpu/checkpoint.rs diff --git a/crates/ffi/src/aiur/aggregate/lanes/limits.rs b/crates/ffi/src/aiur/aggregate/multi_gpu/limits.rs similarity index 91% rename from crates/ffi/src/aiur/aggregate/lanes/limits.rs rename to crates/ffi/src/aiur/aggregate/multi_gpu/limits.rs index 0003e505c..6718266eb 100644 --- a/crates/ffi/src/aiur/aggregate/lanes/limits.rs +++ b/crates/ffi/src/aiur/aggregate/multi_gpu/limits.rs @@ -13,23 +13,22 @@ pub(super) struct HostBudget { impl HostBudget { pub(super) fn new( - lanes: usize, + gpus: usize, executions: usize, - per_lane: usize, + per_gpu: usize, detected: Option, ceiling: Option, cells: usize, ) -> Result { - if lanes == 0 || executions == 0 || cells == 0 { + if gpus == 0 || executions == 0 || cells == 0 { return Err( - "lanes, execution threads and trace-cell budget must be positive" - .into(), + "GPUs, execution threads and trace-cell budget must be positive".into(), ); } - let requested = if per_lane == 0 { + let requested = if per_gpu == 0 { detected.ok_or("cannot detect host memory; specify --max-ram per GPU")? } else { - per_lane.checked_mul(lanes).ok_or("process host budget overflow")? + per_gpu.checked_mul(gpus).ok_or("process host budget overflow")? }; let limit = ceiling.map_or(requested, |ceiling| requested.min(ceiling)); let workspace = cells @@ -40,12 +39,12 @@ impl HostBudget { .ok_or("prover workspace budget overflow")?; let headroom = limit / 10; let reserved = workspace - .checked_mul(lanes) + .checked_mul(gpus) .and_then(|n| n.checked_add(headroom)) .ok_or("process workspace budget overflow")?; let records = limit.checked_sub(reserved) .ok_or("host budget cannot hold the GPU workers' workspace; raise --max-ram or lower AIUR_TRACE_SHARD_MAX_CELLS")?; - let slots = lanes + let slots = gpus .checked_mul(2) .and_then(|n| n.checked_add(executions)) .ok_or("record admission count overflow")?; @@ -98,7 +97,7 @@ mod tests { use super::*; #[test] - fn per_lane_values_expand_once_and_auto_detection_is_global() { + fn per_gpu_values_expand_once_and_auto_detection_is_global() { let gib = 1usize << 30; let explicit = HostBudget::new(4, 12, 230 * gib, None, Some(920 * gib), 1_500_000_000) diff --git a/crates/ffi/src/aiur/aggregate/lanes/queue.rs b/crates/ffi/src/aiur/aggregate/multi_gpu/queue.rs similarity index 100% rename from crates/ffi/src/aiur/aggregate/lanes/queue.rs rename to crates/ffi/src/aiur/aggregate/multi_gpu/queue.rs diff --git a/crates/ffi/src/aiur/aggregate/scheduler.rs b/crates/ffi/src/aiur/aggregate/scheduler.rs index 1acebe64d..c969a033b 100644 --- a/crates/ffi/src/aiur/aggregate/scheduler.rs +++ b/crates/ffi/src/aiur/aggregate/scheduler.rs @@ -141,10 +141,10 @@ enum SchedulerEvent { Finished(usize, Result, String>), } -/// Runs the slot plan with two lanes: `ahead` threads prepare ready slots +/// Runs the slot plan on two thread pools: `ahead` threads prepare ready slots /// (children complete) — the CPU half: advice, execution, planning — /// while `jobs` threads prove prepared slots — the GPU half — so a join -/// executes while the previous one proves. `ahead == 0` fuses the lanes: +/// executes while the previous one proves. `ahead == 0` fuses the pools: /// each admitted slot prepares and proves on one thread, `jobs` at a /// time. Slots are admitted bottom level first, then by index, so parents /// become ready as early as possible; the RAM weights gate admission, a @@ -171,7 +171,7 @@ pub(super) fn run_scheduler_with( let fused = ahead == 0; let max_ahead = if fused { max_jobs } else { ahead }; // Raw leaves only verify: they hold no record and take no prover, so - // they run beside both lanes, a core each. + // they run beside both pools, a core each. let max_verify = thread::available_parallelism().map_or(1, usize::from); let mut level = vec![0usize; count]; for (index, spec) in specs.iter().enumerate() { @@ -188,7 +188,7 @@ pub(super) fn run_scheduler_with( let mut retired = vec![false; count]; let mut completed_count = 0usize; // Cache check from the root down: a cached proof retires its whole - // subtree, so a resumed run, or the final run over lanes' subtree + // subtree, so a resumed run, or the final run over the subtree runs' // roots, loads and verifies only the highest cached proofs and never // visits what is under them. Specs are in post-order, so walking them // backwards meets every parent before its children. @@ -225,7 +225,7 @@ pub(super) fn run_scheduler_with( while completed_count < target { if failures.is_empty() { - // Prover lane first: prepared slots, in the order prepared. + // Provers first: prepared slots, in the order prepared. while proving < max_jobs { let Some((index, staged)) = prepared.pop_front() else { break; @@ -246,7 +246,7 @@ pub(super) fn run_scheduler_with( let _ = sender.send(SchedulerEvent::Finished(index, result)); }); } - // Prepare lane: ready slots, bottom level first; a prepared record + // Then preparation: ready slots, bottom level first; a prepared record // waiting for the prover counts against the lookahead, and raw // leaves have their own allowance. let mut ready: Vec = (0..count) diff --git a/crates/ffi/src/aiur/aggregate/store.rs b/crates/ffi/src/aiur/aggregate/store.rs index 9d65df2bb..9e50eca2c 100644 --- a/crates/ffi/src/aiur/aggregate/store.rs +++ b/crates/ffi/src/aiur/aggregate/store.rs @@ -113,7 +113,7 @@ pub(super) fn load_input_proofs( format!("proof {} matches no manifest shard", address.hex()) })?; if !required[shard] { - // A lane proving several subtrees passes all its claim proofs to + // A run proving several subtrees passes all its claim proofs to // each; one outside this selection is ignored, not rejected. continue; } @@ -236,7 +236,7 @@ pub(super) fn persist_wrapper( /// Persists a slot's proof and publishes its cache entry. `Ok(None)` when /// the run writes nothing or has no cache; a failure is an error, since a /// run's success promises that its proofs are where a later run (a resume, -/// or the final run after subtree lanes) will look for them. +/// or the final run after the subtree runs) will look for them. pub(super) fn persist_cached( store_dir: &Path, cache_dir: Option<&Path>, diff --git a/crates/ffi/src/aiur/aggregate/tests/scheduler.rs b/crates/ffi/src/aiur/aggregate/tests/scheduler.rs index 2780e3d81..8008d7b9c 100644 --- a/crates/ffi/src/aiur/aggregate/tests/scheduler.rs +++ b/crates/ffi/src/aiur/aggregate/tests/scheduler.rs @@ -51,7 +51,7 @@ fn check_children(spec: &SlotSpec, children: &[Arc]) { } /// A worker that completes every slot while preparing it, so the fused -/// lanes run each slot on one thread as the production worker would. +/// pools run each slot on one thread as the production worker would. struct TestWorker<'a, F> { specs: &'a [SlotSpec], run: F, diff --git a/crates/ffi/src/aiur/protocol.rs b/crates/ffi/src/aiur/protocol.rs index 7894a7605..fc94687c4 100644 --- a/crates/ffi/src/aiur/protocol.rs +++ b/crates/ffi/src/aiur/protocol.rs @@ -1403,9 +1403,9 @@ fn prove_shards_ahead( let mut next = 0usize; let mut in_flight = 0usize; let mut done = 0usize; - // Shards that outgrew their share, to rerun alone once the lane is - // empty; while one is pending, nothing new starts ahead, so the lane - // drains for it. + // Shards that outgrew their share, to rerun alone once nothing else + // is executing; while one is pending, nothing new starts ahead, so the + // running executions drain for it. let mut alone: std::collections::VecDeque = std::collections::VecDeque::new(); let mut alone_running = false; @@ -1419,8 +1419,9 @@ fn prove_shards_ahead( let _ = done_tx.send((k, is_alone, result)); }); }; - // Admission: an alone rerun waits for an empty lane and runs by - // itself; otherwise up to `jobs` executions run ahead at their share. + // Admission: an alone rerun waits until nothing else is executing and + // runs by itself; otherwise up to `jobs` executions run ahead at their + // share. // Run before each proof as well as at the start, so a slot freed by a // record the prover takes is refilled while that record proves. macro_rules! admit { diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md index bc37c7f3c..3e02b3f10 100644 --- a/crates/proof-compression/README.md +++ b/crates/proof-compression/README.md @@ -46,7 +46,7 @@ and verification requires `--dev-srs`. Use `ix compress PROOF --groth16 --dev-final-srs`, where `PROOF` is a store address or a serialized `Ixon.Proof` file containing its claim. File inputs are read without importing them into the store. See the -[final backend guide](../../tools/plonk-wrapper/README.md#bn254-groth16-final). +[final backend guide](../../tools/gnark/README.md#bn254-groth16-final). The library's `verify_with_vk` can verify recursive KZG proofs under an independently pinned key and statement. Local final keys use `verify_with_development_verifier`. The CLI requires @@ -93,7 +93,7 @@ It does not verify contributor signatures. Authenticate the ceremony source as part of release review, and distribute the approved SRS alongside the release. ```sh -cd tools/plonk-wrapper +cd tools/gnark go run ./cmd/ignition -cache /path/to/transcripts -out /path/to/srs.bin ``` @@ -154,8 +154,7 @@ from the proof. `fri_sizing --statistics ` exports key statistics through the Rust decoder without building a circuit. Pipe its JSON to `tools/compression/fri_security_estimate.py -` for parameter estimates. -`fri_sizing` also retains opt-in frontend sizing; the GPU stage supervisor is -`tools/compression/run_gpu_stage.py`. +`fri_sizing` also retains opt-in frontend sizing. Focused entry points include the field primitives and complete verifier witness, with additional curve, transcript, MSM and public-pairing modules: @@ -176,7 +175,7 @@ ix compress ROOT_PROOF_ADDRESS --plonk --out /path/to/empty-output Install the released SRS or select a local copy with `IX_KZG_SRS`. The result includes the proof envelope, native and Solidity proof encodings, generated Solidity verifier and verifying key, which must equal the bundled key for -`ix verify` to accept the proof. See the [gnark backend guide](../../tools/plonk-wrapper/README.md) +`ix verify` to accept the proof. See the [gnark backend guide](../../tools/gnark/README.md) for Rust FFI tests, CUDA configuration, and artifact cache rules. On-chain verification uses the final PLONK verifier and [claim adapter](../../contracts/plonk/README.md). @@ -284,10 +283,8 @@ into scalar-field constraints, `kzg` implements the commitment backend, and Focused CPU checks use the small BN254 fixtures: ```sh -cargo test --release --locked -p ix-proof-compression --test bn254_kzg -cargo test --release --locked -p ix-proof-compression --test kzg_transcripts -python3 tests/check_compression_build.py \ - --toolchain /path/to/lean-4.34.1-linux --out /tmp/ix-build-config +cargo test --release --locked -p ix-proof-compression --test integration \ + -- kzg:: ``` `tests/kzg_backend_parity.py` compares independent CPU/CUDA builds on small diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs index 6a7ae0fa0..d21e238d0 100644 --- a/crates/proof-compression/build.rs +++ b/crates/proof-compression/build.rs @@ -13,10 +13,8 @@ //! CUDA backend plugins are loaded at runtime and appear only here. use std::env; +use std::ffi::OsString; use std::path::{Path, PathBuf}; -#[path = "../build_support/cuda.rs"] -mod cuda; -use cuda::{cuda_library_directories, nvcc_path}; use std::process::Command; const SOURCES: &[&str] = &[ @@ -53,20 +51,15 @@ mod gnark; fn main() { println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=../build_support/cuda.rs"); for source in SOURCES { println!("cargo:rerun-if-changed={source}"); } for variable in [ - "NVCC", - "CUDA_HOME", - "CUDA_PATH", "NVCC_CCBIN", "NVCC_PREPEND_FLAGS", "NVCC_APPEND_FLAGS", "CC", "IX_KZG_CUDA_ARCHS", - "MULTI_STARK_CUDA_ARCHS", ] { println!("cargo:rerun-if-env-changed={variable}"); } @@ -117,6 +110,38 @@ fn json_string(value: &str) -> String { quoted } +/// Toolchain metadata from multi-stark's CUDA build, which this crate's +/// Goldilocks backend links against. +fn multi_stark_cuda(key: &str) -> OsString { + env::var_os(format!("DEP_MULTI_STARK_CUDA_{key}")) + .unwrap_or_else(|| panic!("multi-stark's CUDA build did not report {key}")) +} + +/// `IX_KZG_CUDA_ARCHS`, or else the architectures multi-stark compiled its +/// backend for. +fn kzg_architectures() -> Vec { + let selected = env::var("IX_KZG_CUDA_ARCHS").unwrap_or_else(|_| { + multi_stark_cuda("ARCHS") + .into_string() + .expect("CUDA architectures are ASCII") + }); + let architectures: Vec = selected + .split(',') + .map(str::trim) + .filter(|architecture| !architecture.is_empty()) + .map(str::to_owned) + .collect(); + assert!( + !architectures.is_empty() + && architectures.iter().all(|architecture| { + (2..=3).contains(&architecture.len()) + && architecture.chars().all(|c| c.is_ascii_digit()) + }), + "invalid CUDA architectures {selected:?}; expected numbers such as 80,90" + ); + architectures +} + /// Builds `libix_bn254_cuda.so` in `OUT_DIR` and links it into this crate. fn build_bn254_cuda() -> PathBuf { assert_eq!( @@ -129,11 +154,8 @@ fn build_bn254_cuda() -> PathBuf { Ok("x86_64"), "the BN254 CUDA backend supports x86_64 targets only" ); - let nvcc = nvcc_path(); - let architectures = cuda::architectures( - &nvcc, - &["IX_KZG_CUDA_ARCHS", "MULTI_STARK_CUDA_ARCHS"], - ); + let nvcc = multi_stark_cuda("NVCC"); + let architectures = kzg_architectures(); let out = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR")); let architecture_header = out.join("kzg_architectures.h"); std::fs::write( @@ -228,7 +250,7 @@ fn build_bn254_cuda() -> PathBuf { println!("cargo:rustc-link-lib=dylib=ix_bn254_cuda"); println!("cargo:rustc-link-arg=-Wl,-rpath,{}", out.display()); // Tests query the CUDA runtime directly. - for directory in cuda_library_directories(&nvcc) { + for directory in env::split_paths(&multi_stark_cuda("LIB_DIRS")) { if directory.is_dir() { println!("cargo:rustc-link-search=native={}", directory.display()); } diff --git a/crates/proof-compression/build/gnark.rs b/crates/proof-compression/build/gnark.rs index 44b35d472..090f9de26 100644 --- a/crates/proof-compression/build/gnark.rs +++ b/crates/proof-compression/build/gnark.rs @@ -42,8 +42,7 @@ fn source_digest(source: &std::path::Path) -> String { pub(super) fn build() -> Vec { let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); - let source = - manifest.join("../../tools/plonk-wrapper").canonicalize().unwrap(); + let source = manifest.join("../../tools/gnark").canonicalize().unwrap(); let out = PathBuf::from(env::var_os("OUT_DIR").unwrap()); println!("cargo:rerun-if-changed={}", source.display()); for variable in [ diff --git a/crates/proof-compression/src/foreign.rs b/crates/proof-compression/src/foreign.rs index ccde5bb63..0d9f387a1 100644 --- a/crates/proof-compression/src/foreign.rs +++ b/crates/proof-compression/src/foreign.rs @@ -481,16 +481,6 @@ impl GoldilocksCircuit { }) } - #[cfg(test)] - fn translate_with_inputs( - source: &Circuit, - b: &mut CircuitBuilder, - expand_hashes: bool, - input: impl FnMut(&mut CircuitBuilder, usize) -> Value, - ) -> GoldilocksInputs { - Self::translate_inner(source, b, expand_hashes, 0, input) - } - fn translate_inner( source: &Circuit, b: &mut CircuitBuilder, diff --git a/crates/proof-compression/src/foreign/tests/independent_witness.rs b/crates/proof-compression/src/foreign/tests/independent_witness.rs index 999beac03..5d7d869de 100644 --- a/crates/proof-compression/src/foreign/tests/independent_witness.rs +++ b/crates/proof-compression/src/foreign/tests/independent_witness.rs @@ -199,43 +199,3 @@ fn public_translation_activates_parallel_schedule() { actual.values(translated.circuit.id()).unwrap() ); } - -#[test] -fn scheduled_translation_preserves_verified_kzg_bytes() { - use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; - use multi_stark::system::{System, SystemWitness}; - use std::sync::Arc; - let fixture = Fixture::new(2); - let config = KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(1 << 16, b"independent-witness-advice")), - 4, - ); - let mut proofs = Vec::new(); - for chunk_size in [0, 16] { - let translated = fixture.translate(chunk_size); - assert_eq!( - translated.inputs.scheduled(), - chunk_size > 0 && cfg!(feature = "parallel") - ); - let assignment = fixture.witness(&translated).generate().unwrap(); - let compiled = - translated.circuit.lower_to_multi_stark(Scalar::from_u8(89)).unwrap(); - let claims = compiled.claims(assignment.public_values()).unwrap(); - let refs = claims.iter().map(Vec::as_slice).collect::>(); - let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); - let traces = compiled.traces(&assignment).unwrap(); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - proofs.push( - FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .unwrap(), - ); - } - assert_eq!(proofs[0], proofs[1]); -} diff --git a/crates/proof-compression/src/foreign/tests/indexed_names.rs b/crates/proof-compression/src/foreign/tests/indexed_names.rs index 8ee8a8bee..60f8f543e 100644 --- a/crates/proof-compression/src/foreign/tests/indexed_names.rs +++ b/crates/proof-compression/src/foreign/tests/indexed_names.rs @@ -1,193 +1,5 @@ use super::*; -struct Fixture { - circuit: Circuit, - inputs: Vec<(Value, Val)>, -} - -impl Fixture { - fn new(rounds: usize) -> Self { - let mut builder = CircuitBuilder::::new(); - let bytes = builder.fixed_table( - "bytes", - (0..=255).map(|i| vec![Val::from_u16(i)]).collect(), - ); - let mut inputs = Vec::new(); - for i in 0..rounds { - let constant = builder.constant(Val::from_usize(17 + i % 31)); - let bit = builder.input("choice"); - inputs.push((bit, Val::from_usize(i % 2))); - let bit = builder.assert_bool(bit); - let byte = builder.input("byte"); - inputs.push((byte, Val::from_usize(7 + i % 249))); - builder.lookup(bytes, &[byte]); - let wide = builder.input("wide"); - inputs - .push((wide, Val::from_u64(P - 1 - u64::try_from(i % 257).unwrap()))); - let product = builder.mul(wide, byte); - let shifted = builder.add(product, constant); - let selected = builder.select(bit, shifted, byte); - let copied = builder.hint("copy", &[selected], |values| Ok(values[0])); - builder.assert_equal(copied, selected); - builder.constant(Val::from_usize(333 + i % 37)); - if i + 1 == rounds { - builder.expose_public(copied); - } - } - Self { circuit: builder.finish(), inputs } - } - - fn assignment(&self) -> Assignment { - let mut witness = self.circuit.witness(); - for &(wire, value) in &self.inputs { - witness.set(wire, value).unwrap(); - } - witness.generate().unwrap() - } -} - -fn translate( - source: &Circuit, - builder: &mut CircuitBuilder, -) -> GoldilocksInputs { - if INDEXED { - GoldilocksCircuit::translate(source, builder, false) - } else { - GoldilocksCircuit::translate_with_inputs( - source, - builder, - false, - |builder, i| builder.input(format!("goldilocks[{i}]")), - ) - } -} - -fn preallocated( - source: &Circuit, -) -> GoldilocksCircuit { - let stats = { - let mut builder = CircuitBuilder::counting(); - translate::(source, &mut builder); - builder.stats() - }; - let mut builder = CircuitBuilder::new(); - builder.reserve(stats); - let inputs = translate::(source, &mut builder); - assert_eq!(builder.stats(), stats); - GoldilocksCircuit { circuit: builder.finish(), inputs } -} - -fn assert_layout(expected: &GoldilocksCircuit, actual: &GoldilocksCircuit) { - assert_eq!(expected.circuit.stats(), actual.circuit.stats()); - assert_eq!(expected.inputs.is_input, actual.inputs.is_input); - assert_eq!( - expected.inputs.wires.iter().map(|v| v.index()).collect::>(), - actual.inputs.wires.iter().map(|v| v.index()).collect::>() - ); - for (left, right) in - expected.circuit.value_sources().zip(actual.circuit.value_sources()) - { - match (left, right) { - (Recipe::Input, Recipe::Input) | (Recipe::Hint, Recipe::Hint) => {}, - (Recipe::Arithmetic(a), Recipe::Arithmetic(b)) => assert_eq!(a, b), - (Recipe::Constant(a), Recipe::Constant(b)) => assert_eq!(a, b), - _ => panic!("different recipe kind"), - } - } - for (left, right) in - expected.circuit.gates().iter().zip(actual.circuit.gates()) - { - assert_eq!(left.wires.map(|v| v.index()), right.wires.map(|v| v.index())); - assert_eq!(left.coefficients, right.coefficients); - } - for (left, right) in - expected.circuit.tables().iter().zip(actual.circuit.tables()) - { - assert_eq!(left.name(), right.name()); - assert_eq!(left.rows(), right.rows()); - } - for (left, right) in - expected.circuit.lookups().iter().zip(actual.circuit.lookups()) - { - assert_eq!(left.table.index(), right.table.index()); - assert_eq!( - left.values.iter().map(|v| v.index()).collect::>(), - right.values.iter().map(|v| v.index()).collect::>() - ); - } - assert_eq!( - expected - .circuit - .public_values() - .iter() - .map(|v| v.index()) - .collect::>(), - actual - .circuit - .public_values() - .iter() - .map(|v| v.index()) - .collect::>() - ); -} - -fn assign( - translated: &GoldilocksCircuit, - source: &Assignment, -) -> Assignment { - let mut witness = translated.circuit.witness(); - translated.assign(source, &mut witness).unwrap(); - witness.generate().unwrap() -} - -#[test] -fn indexed_names_preserve_translation_layout_values_and_missing_inputs() { - let fixture = Fixture::new(5); - let source = fixture.assignment(); - let expected = preallocated::(&fixture.circuit); - let actual = GoldilocksCircuit::new_preallocated(&fixture.circuit); - assert_layout(&expected, &actual); - let old_assignment = assign(&expected, &source); - let new_assignment = assign(&actual, &source); - assert_eq!( - old_assignment.values(expected.circuit.id()).unwrap(), - new_assignment.values(actual.circuit.id()).unwrap() - ); - assert_eq!(old_assignment.public_values(), new_assignment.public_values()); - let indices: Vec<_> = actual - .inputs - .is_input - .iter() - .enumerate() - .filter_map(|(index, &input)| input.then_some(index)) - .collect(); - assert!(indices.windows(2).any(|pair| pair[1] != pair[0] + 1)); - let values = source.values(fixture.circuit.id()).unwrap(); - for missing in - [indices[0], indices[indices.len() / 2], *indices.last().unwrap()] - { - for translated in [&expected, &actual] { - let mut witness = translated.circuit.witness(); - for &index in &indices { - if index != missing { - witness - .set( - translated.inputs.wires[index], - Scalar::from_u64(values[index].as_canonical_u64()), - ) - .unwrap(); - } - } - assert_eq!( - witness.generate().err(), - Some(WitnessError::MissingInput { - name: format!("goldilocks[{missing}]") - }) - ); - } - } -} - #[test] fn indexed_names_preserve_owned_names_and_lazy_census() { struct UnusedName; @@ -219,44 +31,3 @@ fn indexed_names_preserve_owned_names_and_lazy_census() { }) ); } - -#[test] -fn indexed_names_preserve_verified_kzg_bytes() { - use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; - use multi_stark::system::{System, SystemWitness}; - use std::sync::Arc; - let fixture = Fixture::new(2); - let source = fixture.assignment(); - let config = KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(1 << 16, b"indexed-input-names")), - 4, - ); - let mut proofs = Vec::new(); - for indexed in [false, true] { - let translated = if indexed { - GoldilocksCircuit::new_preallocated(&fixture.circuit) - } else { - preallocated::(&fixture.circuit) - }; - let assignment = assign(&translated, &source); - let compiled = - translated.circuit.lower_to_multi_stark(Scalar::from_u8(88)).unwrap(); - let claims = compiled.claims(assignment.public_values()).unwrap(); - let refs = claims.iter().map(Vec::as_slice).collect::>(); - let traces = compiled.traces(&assignment).unwrap(); - let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - proofs.push( - FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .unwrap(), - ); - } - assert_eq!(proofs[0], proofs[1]); -} diff --git a/crates/proof-compression/src/foreign/tests/quotient_hints.rs b/crates/proof-compression/src/foreign/tests/quotient_hints.rs index 1af5f68c2..0305e8981 100644 --- a/crates/proof-compression/src/foreign/tests/quotient_hints.rs +++ b/crates/proof-compression/src/foreign/tests/quotient_hints.rs @@ -124,7 +124,7 @@ fn bounded_quotient_hints_preserve_negative_and_large_input_behavior() { } } -fn proof_fixture(bounded: bool) -> (Circuit, Assignment) { +fn fixture(bounded: bool) -> (Circuit, Assignment) { let mut b = CircuitBuilder::::new(); let table = b.fixed_table("limbs", (0..16).map(|n| vec![Scalar::from_u8(n)]).collect()); @@ -160,11 +160,8 @@ fn proof_fixture(bounded: bool) -> (Circuit, Assignment) { } #[test] -fn bounded_quotient_hints_preserve_assignment_constraints_and_kzg_bytes() { - use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; - use multi_stark::system::{System, SystemWitness}; - use std::sync::Arc; - let fixtures = [proof_fixture(false), proof_fixture(true)]; +fn bounded_quotient_hints_preserve_assignment_and_constraints() { + let fixtures = [fixture(false), fixture(true)]; assert_eq!(fixtures[0].0.stats(), fixtures[1].0.stats()); assert_eq!( fixtures[0].1.values(fixtures[0].0.id()).unwrap(), @@ -179,29 +176,4 @@ fn bounded_quotient_hints_preserve_assignment_constraints_and_kzg_bytes() { ); assert_eq!(reference.coefficients, bounded.coefficients); } - let config = KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(128, b"quotient-hint-parity")), - 4, - ); - let mut bytes = Vec::new(); - for (circuit, assignment) in fixtures { - let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(78)).unwrap(); - let traces = compiled.traces(&assignment).unwrap(); - let claims = compiled.claims(assignment.public_values()).unwrap(); - let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); - let refs = claims.iter().map(Vec::as_slice).collect::>(); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - bytes.push( - FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .unwrap(), - ); - } - assert_eq!(bytes[0], bytes[1]); } diff --git a/crates/proof-compression/src/foreign/tests/range_hints.rs b/crates/proof-compression/src/foreign/tests/range_hints.rs index 690936787..edd81002a 100644 --- a/crates/proof-compression/src/foreign/tests/range_hints.rs +++ b/crates/proof-compression/src/foreign/tests/range_hints.rs @@ -131,41 +131,3 @@ fn grouped_range_hints_still_reject_non_limbs_with_valid_packing() { Err(WitnessError::LookupMissing { table: "limbs".into() }) ); } - -#[test] -fn grouped_range_hints_preserve_kzg_proof_bytes() { - use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; - use multi_stark::system::{System, SystemWitness}; - use std::sync::Arc; - let config = KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(64, b"range-hint-parity")), - 4, - ); - let mut proofs = Vec::new(); - for grouped in [false, true] { - let (circuit, inputs) = fixture(&[17, 32], grouped, 16); - let assignment = assign( - &circuit, - &inputs, - &[Scalar::from_u32((1 << 16) + 9), Scalar::from_u32((7 << 16) + 3)], - ); - let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(77)).unwrap(); - let traces = compiled.traces(&assignment).unwrap(); - let claims = compiled.claims(assignment.public_values()).unwrap(); - let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); - let refs = claims.iter().map(Vec::as_slice).collect::>(); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - proofs.push( - FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .unwrap(), - ); - } - assert_eq!(proofs[0], proofs[1]); -} diff --git a/crates/proof-compression/src/foreign/tests/translation_intervals.rs b/crates/proof-compression/src/foreign/tests/translation_intervals.rs index 0473315fa..9511d805f 100644 --- a/crates/proof-compression/src/foreign/tests/translation_intervals.rs +++ b/crates/proof-compression/src/foreign/tests/translation_intervals.rs @@ -85,158 +85,3 @@ fn fixed_intervals_match_signed_boundaries_and_quotient_bits() { } } } - -fn fixture(reference: bool, count: usize) -> (Circuit, [Value; 3]) { - let mut source = CircuitBuilder::::new(); - let source_wires = [source.input("a"), source.input("b"), source.input("c")]; - let mut builder = CircuitBuilder::::new(); - let table = builder - .fixed_table("limbs", (0..16).map(|i| vec![Scalar::from_u8(i)]).collect()); - let zero = builder.constant(Scalar::ZERO); - let values = [builder.input("a"), builder.input("b"), builder.input("c")]; - let wires = [zero, values[0], values[1], values[2]]; - for i in 0..count { - let coefficients = match i % 6 { - 0 => [1, 0, 0, -1, 0], - 1 => [-1, 1, 0, -1, 0], - 2 => [0, 1, -1, -1, 0], - 3 => [0, 1, 1, -1, 0], - 4 => [i128::from(P / 2), -7, 13, -1, -1], - _ => [-i128::from(P / 2), 19, -31, -1, 7], - }; - let coefficients = coefficients.map(|v| { - let magnitude = Val::from_u64(u64::try_from(v.unsigned_abs()).unwrap()); - if v < 0 { -magnitude } else { magnitude } - }); - let gate = Gate { wires: source_wires, coefficients }; - if reference { - constrain_reference( - &mut builder, - table, - &gate, - &wires, - &[P - 1; 4], - false, - ); - } else { - constrain_gate(&mut builder, table, &gate, &wires, &[P - 1; 4], false); - } - } - builder.expose_public(values[2]); - (builder.finish(), values) -} - -#[test] -fn fixed_intervals_preserve_constraint_layout() { - let (expected, _) = fixture(true, 24); - let (actual, _) = fixture(false, 24); - assert_eq!(expected.stats(), actual.stats()); - for (a, b) in expected.gates().iter().zip(actual.gates()) { - assert_eq!(a.wires.map(|v| v.index()), b.wires.map(|v| v.index())); - assert_eq!(a.coefficients, b.coefficients); - } - for (a, b) in expected.lookups().iter().zip(actual.lookups()) { - assert_eq!(a.table.index(), b.table.index()); - assert_eq!( - a.values.iter().map(|v| v.index()).collect::>(), - b.values.iter().map(|v| v.index()).collect::>() - ); - } - assert!(expected.value_sources().zip(actual.value_sources()).all( - |(a, b)| { std::mem::discriminant(&a) == std::mem::discriminant(&b) } - )); -} - -#[test] -fn fixed_intervals_preserve_assignment_and_kzg_bytes() { - use crate::kzg::{KzgConfig, Srs, compact::FixedProofCodec}; - use multi_stark::system::{System, SystemWitness}; - use std::sync::Arc; - let mut assignments = Vec::new(); - let mut proofs = Vec::new(); - let config = KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(128, b"interval-plan-parity")), - 4, - ); - for reference in [true, false] { - let (circuit, inputs) = fixture(reference, 1); - let mut witness = circuit.witness(); - for (wire, value) in inputs.into_iter().zip([P - 1, 2, P - 2]) { - witness.set(wire, Scalar::from_u64(value)).unwrap(); - } - let assignment = witness.generate().unwrap(); - assignments.push(assignment.values(circuit.id()).unwrap().to_vec()); - let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(87)).unwrap(); - let traces = compiled.traces(&assignment).unwrap(); - let claims = compiled.claims(assignment.public_values()).unwrap(); - let refs = claims.iter().map(Vec::as_slice).collect::>(); - let (system, key) = System::new(config.clone(), compiled.circuit_inputs()); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - proofs.push( - FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .unwrap(), - ); - } - assert_eq!(assignments[0], assignments[1]); - assert_eq!(proofs[0], proofs[1]); -} - -/// The modular gate construction before fixed-width interval planning, -/// kept as the reference for the plan's offsets and quotient bounds. -fn constrain_reference( - b: &mut CircuitBuilder, - table: Table, - gate: &Gate, - wires: &[Value], - bounds: &[u64], - integer_relation: bool, -) { - let coefficients = gate.coefficients.map(signed); - let values = gate.wires.map(|v| wires[v.index()]); - if integer_relation - || boolean_gate(gate) - || small_interval(coefficients, gate.wires.map(|v| bounds[v.index()])) - .is_some_and(|(low, high)| low > -i128::from(P) && high < i128::from(P)) - { - b.constrain_gate(values, coefficients.map(signed_scalar)); - return; - } - let (low, high) = - interval(coefficients, gate.wires.map(|v| bounds[v.index()])); - let p = BigInt::from(P); - if low > -&p && high < p { - b.constrain_gate(values, coefficients.map(|c| scalar(&BigInt::from(c)))); - return; - } - let shift = - if low < BigInt::from(0) { (-low + &p - 1) / &p } else { BigInt::from(0) }; - let offset = &shift * &p; - let max_q = (high + &offset) / &p; - let bits = usize::try_from(max_q.bits()).expect("quotient fits usize"); - assert!(bits <= 130, "Goldilocks gate quotient bound"); - let hint = quotient::QuotientHint::new(coefficients, &offset); - let quotient = - b.hint("Goldilocks quotient", &values, move |v| hint.evaluate(v)); - range(b, table, quotient, bits); - let [qm, qa, qb, qc, k] = coefficients.map(|c| scalar(&BigInt::from(c))); - let product = b.mul(values[0], values[1]); - let residual = b.linear_combination( - &[ - (qm, product), - (qa, values[0]), - (qb, values[1]), - (qc, values[2]), - (-Scalar::from_u64(P), quotient), - ], - k + scalar(&offset), - ); - let zero = b.constant(Scalar::ZERO); - b.assert_equal(residual, zero); -} diff --git a/crates/proof-compression/src/fri/pcs.rs b/crates/proof-compression/src/fri/pcs.rs index 8c98aac04..63108937a 100644 --- a/crates/proof-compression/src/fri/pcs.rs +++ b/crates/proof-compression/src/fri/pcs.rs @@ -559,140 +559,3 @@ pub(super) fn constrain_pcs( } queries } - -#[cfg(test)] -mod cost_breakdown { - use super::*; - use multi_stark::plonkish::CircuitStats; - - fn record( - out: &mut serde_json::Map, - name: &str, - b: &CircuitBuilder, - before: CircuitStats, - ) { - let s = b.stats(); - out.insert( - name.into(), - serde_json::json!({ - "gates": s.gates - before.gates, - "lookups": s.lookups - before.lookups, - "compressions": s.hash_compressions - before.hash_compressions, - "rows": (s.gates + s.lookups) - (before.gates + before.lookups), - }), - ); - } - - fn digest(b: &mut CircuitBuilder, bytes: &ByteGadgets) -> Digest { - std::array::from_fn(|_| bytes.input(b, "digest byte")) - } - - /// Goldilocks-side row attribution of the first-stage verifier's - /// per-query pieces, printed as JSON. Run with `--ignored --nocapture`. - #[test] - #[ignore = "cost report; run explicitly"] - fn report() { - let mut b = CircuitBuilder::::counting(); - b.enable_compact_blake3(); - let bytes = ByteGadgets::new(&mut b); - let mut out = serde_json::Map::new(); - let root = digest(&mut b, &bytes); - let leaf = digest(&mut b, &bytes); - let sibling = digest(&mut b, &bytes); - let bit = { - let v = b.input("bit"); - b.assert_bool(v) - }; - let before = b.stats(); - authenticate( - &mut b, - &bytes, - &root, - leaf, - &[bit], - &[sibling], - &BTreeMap::new(), - ); - record(&mut out, "merkle_level", &b, before); - let injected = digest(&mut b, &bytes); - let before = b.stats(); - authenticate( - &mut b, - &bytes, - &root, - leaf, - &[bit], - &[sibling], - &BTreeMap::from([(0usize, injected)]), - ); - record(&mut out, "merkle_level_with_injection", &b, before); - for width in [1usize, 8, 44, 187] { - let values: Vec<_> = (0..width).map(|_| b.input("opened")).collect(); - let before = b.stats(); - hash_fields(&mut b, &bytes, values); - record(&mut out, &format!("hash_fields_{width}_values"), &b, before); - } - let value = b.input("value"); - let before = b.stats(); - bytes.encode_field64(&mut b, value); - record(&mut out, "encode_field64", &b, before); - let before = b.stats(); - bytes.constrain_byte(&mut b, value); - record(&mut out, "constrain_byte", &b, before); - let a = Q::input(&mut b, "a"); - let c = Q::input(&mut b, "c"); - let before = b.stats(); - a.mul(&mut b, c); - record(&mut out, "ext_mul", &b, before); - let before = b.stats(); - a.mul_base(&mut b, value); - record(&mut out, "ext_mul_base", &b, before); - let before = b.stats(); - a.inverse(&mut b); - record(&mut out, "ext_inverse", &b, before); - let bits: Vec = (0..26) - .map(|_| { - let v = b.input("qbit"); - b.assert_bool(v) - }) - .collect(); - let before = b.stats(); - reversed_power(&mut b, Val::two_adic_generator(26), &bits); - record(&mut out, "reversed_power_26", &b, before); - let before = b.stats(); - let lo = Q::select(&mut b, bits[0], a, c); - let hi = Q::select(&mut b, bits[0], c, a); - let leaf = hash_fields(&mut b, &bytes, lo.0.into_iter().chain(hi.0)); - let path: Vec = (0..25).map(|_| digest(&mut b, &bytes)).collect(); - authenticate( - &mut b, - &bytes, - &root, - leaf, - &bits[1..], - &path, - &BTreeMap::new(), - ); - let x = reversed_power(&mut b, Val::two_adic_generator(26), &bits[1..]); - let inv_x = b.inverse(x); - let sum = lo.add(&mut b, hi).scale(&mut b, Val::ONE.halve()); - let difference = lo - .sub(&mut b, hi) - .mul_base(&mut b, inv_x) - .scale(&mut b, Val::ONE.halve()); - let odd = difference.mul(&mut b, a); - let _folded = sum.add(&mut b, odd); - record(&mut out, "fri_round_0_total_with_25_level_path", &b, before); - let mut challenger = Blake3Challenger::new(&mut b, &bytes, b"cost"); - let before = b.stats(); - challenger.sample_bits(&mut b, &bytes, 26); - record(&mut out, "sample_bits_26", &b, before); - let before = b.stats(); - challenger.sample_extension(&mut b, &bytes); - record(&mut out, "sample_extension", &b, before); - let before = b.stats(); - challenger.observe_extension(&mut b, &bytes, a); - record(&mut out, "observe_extension", &b, before); - println!("{}", serde_json::to_string_pretty(&out).unwrap()); - } -} diff --git a/crates/proof-compression/src/kzg/buffer.rs b/crates/proof-compression/src/kzg/buffer.rs deleted file mode 100644 index 2cd03da4b..000000000 --- a/crates/proof-compression/src/kzg/buffer.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Parallel first-touch initialization of large field buffers. - -use p3_maybe_rayon::prelude::*; - -pub(super) fn generate( - len: usize, - value: impl Fn(usize) -> T + Sync, -) -> Vec { - let mut output: Vec = Vec::with_capacity(len); - output.spare_capacity_mut()[..len] - .par_chunks_mut(1 << 14) - .enumerate() - .for_each(|(tile, chunk)| { - for (offset, slot) in chunk.iter_mut().enumerate() { - slot.write(value((tile << 14) + offset)); - } - }); - // Every slot is initialized after the parallel iterator joins. T is Copy, - // so a panic during initialization cannot leak partially constructed values. - unsafe { output.set_len(len) }; - output -} diff --git a/crates/proof-compression/src/kzg/config.rs b/crates/proof-compression/src/kzg/config.rs index 533310b7d..98d647b20 100644 --- a/crates/proof-compression/src/kzg/config.rs +++ b/crates/proof-compression/src/kzg/config.rs @@ -457,8 +457,7 @@ mod tests { use p3_matrix::dense::RowMajorMatrix; /// `a·b = c` per row, with a self-canceling push/pull lookup pair to - /// exercise the stage-2 machinery — the KZG twin of the BabyBear - /// config's smoke test. + /// exercise the stage-2 machinery. fn mul_circuit() -> CircuitInputs { let m = Expr::main; CircuitInputs { @@ -488,16 +487,6 @@ mod tests { SystemWitness::from_stage_1(vec![trace], system) } - #[test] - fn kzg_prove_verify() { - let (system, key) = kzg_system(); - let no_claims: &[&[Scalar]] = &[]; - let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); - system - .verify_multiple_claims(no_claims, &proof) - .expect("KZG proof failed to verify"); - } - #[test] fn proof_binds_public_identity_degree_and_trace_cap() { let srs = Arc::new(Srs::unsafe_dev_setup(16, b"public-parameters")); @@ -673,61 +662,4 @@ mod tests { >::ONE; assert!(system.verify_multiple_claims(no_claims, &tampered).is_err()); } - - #[test] - fn kzg_wrong_claim_rejected() { - let (system, key) = kzg_system(); - let no_claims: &[&[Scalar]] = &[]; - let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); - let claim = [Scalar::from_u32(42)]; - assert!(system.verify(&claim, &proof).is_err()); - } - - #[test] - fn kzg_compact_round_trip() { - let (system, key) = kzg_system(); - let no_claims: &[&[Scalar]] = &[]; - let proof = system.prove_multiple_claims(&key, no_claims, witness(&system)); - let codec = - super::super::compact::FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap(); - let bytes = codec.encode(&proof).expect("serialize"); - let proof2 = codec.decode(&bytes).expect("deserialize"); - system.verify_multiple_claims(no_claims, &proof2).unwrap(); - } - - /// Two circuits at different trace heights: ζ·g differs per height, - /// so the opening carries three distinct points and the per-point - /// witness batching (and cross-point pairing batch) is exercised. - #[test] - fn kzg_two_circuits_two_heights() { - let srs = Arc::new(Srs::unsafe_dev_setup(1 << 8, b"test")); - let config = KzgConfig::new(srs, 8); - let (system, key) = System::new(config, [mul_circuit(), mul_circuit()]); - let f = Scalar::from_u32; - let small = RowMajorMatrix::new( - [2, 3, 6, 4, 5, 20, 7, 8, 56, 1, 1, 1].map(f).to_vec(), - 3, - ); - let mut long = small.values.clone(); - for _ in 0..2 { - long.extend(long.clone()); - } - let witness = SystemWitness::from_stage_1( - vec![small, RowMajorMatrix::new(long, 3)], - &system, - ); - let no_claims: &[&[Scalar]] = &[]; - let proof = system.prove_multiple_claims(&key, no_claims, witness); - let bytes = - super::super::compact::FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .expect("serialize"); - println!( - "KZG proof: {} bytes (two circuits, heights 4 and 16)", - bytes.len() - ); - system.verify_multiple_claims(no_claims, &proof).unwrap(); - } } diff --git a/crates/proof-compression/src/kzg/cuda.rs b/crates/proof-compression/src/kzg/cuda.rs index 4c7e618aa..2dc8e12e2 100644 --- a/crates/proof-compression/src/kzg/cuda.rs +++ b/crates/proof-compression/src/kzg/cuda.rs @@ -22,7 +22,7 @@ mod abi; pub(super) mod lookup; mod passes; pub(super) mod quotient; -pub(super) mod srs_cache; +pub(super) mod srs_residency; #[cfg(test)] mod tests; @@ -236,7 +236,9 @@ impl Devices { unsafe { ix_kzg_memory(self.ids[index], &mut free, &mut total) }, "memory query", ); - free.saturating_add(srs_cache::reclaimable(index)).saturating_sub(total / 4) + free + .saturating_add(srs_residency::reclaimable(index)) + .saturating_sub(total / 4) } fn get() -> &'static Self { @@ -373,7 +375,7 @@ impl<'a> Device<'a> { } fn prepare_budget(&self, required: usize) -> bool { - srs_cache::prepare_budget(self, required) + srs_residency::prepare_budget(self, required) } fn id(&self) -> i32 { @@ -1048,7 +1050,7 @@ pub(super) fn divide(coefficients: &[Fr], z: Fr) -> Vec { } // Parallel first-touch keeps page faults out of the pinned-ring download. let mut values = - super::buffer::generate(coefficients.len() - 1, |_| Fr::ZERO); + super::pcs::generate_buffer(coefficients.len() - 1, |_| Fr::ZERO); let device = Devices::get().acquire(); assert!( device diff --git a/crates/proof-compression/src/kzg/cuda/srs_cache.rs b/crates/proof-compression/src/kzg/cuda/srs_residency.rs similarity index 100% rename from crates/proof-compression/src/kzg/cuda/srs_cache.rs rename to crates/proof-compression/src/kzg/cuda/srs_residency.rs diff --git a/crates/proof-compression/src/kzg/cuda/srs_cache/tests.rs b/crates/proof-compression/src/kzg/cuda/srs_residency/tests.rs similarity index 100% rename from crates/proof-compression/src/kzg/cuda/srs_cache/tests.rs rename to crates/proof-compression/src/kzg/cuda/srs_residency/tests.rs diff --git a/crates/proof-compression/src/kzg/cuda/tests.rs b/crates/proof-compression/src/kzg/cuda/tests.rs index 14719f658..452fb3439 100644 --- a/crates/proof-compression/src/kzg/cuda/tests.rs +++ b/crates/proof-compression/src/kzg/cuda/tests.rs @@ -4,7 +4,6 @@ use ark_ff::PrimeField; use ark_poly::{EvaluationDomain, Radix2EvaluationDomain}; mod constraints; -mod plonk_scale; unsafe extern "C" { fn ix_kzg_ntt_parameters(lg: u32, output: *mut [[u64; 4]; 3]) -> i32; @@ -170,16 +169,6 @@ fn projective_limb_transport_preserves_coordinates_and_infinity() { assert!(projective([[0; FQ_LIMBS]; 3]).is_zero()); } -#[test] -fn settings_install_once() { - // The first installation wins; later ones report that they were ignored. - let first = configure(Settings::default()); - assert!(!configure(Settings { srs_cache: true, ..Settings::default() })); - if first { - assert_eq!(*settings(), Settings::default()); - } -} - fn lease_devices() -> Devices { Devices { ids: vec![0, 1], @@ -471,7 +460,7 @@ fn cuda_fft_queues_reuse_resident_inputs_and_preserve_order() { .get_coset(Fr::from(7)) .unwrap() .fft(&input); - // More jobs than lanes forces buffer reuse; mixed inputs exercise both + // More jobs than queue slots forces buffer reuse; mixed inputs exercise both // device affinity and host-only queues, with repeated immutable handles. let jobs: Vec<_> = (0..11) .map(|i| { @@ -538,16 +527,11 @@ fn cuda_interpolation_retains_outputs_for_msm_and_cosets() { #[test] #[ignore = "requires a CUDA GPU"] -fn cuda_polynomial_evaluation_and_division_match_horner() { +fn cuda_polynomial_division_matches_horner() { for n in [0, 1, 2, 31, 32, 33, 1023, 1024, 1025, 65537] { let coefficients = scalars(n); for z in [Fr::ZERO, Fr::ONE, -Fr::ONE, Fr::from(918273)] { let expected = coefficients.iter().rev().fold(Fr::ZERO, |v, c| v * z + c); - assert_eq!( - evaluate_many(&coefficients, &[z]), - [expected], - "evaluation n={n}" - ); let quotient = divide(&coefficients, z); assert_eq!(quotient.len(), n.saturating_sub(1)); if n >= 2 { @@ -568,14 +552,15 @@ fn cuda_polynomial_evaluation_and_division_match_horner() { #[ignore = "requires a CUDA GPU"] fn cuda_polynomial_evaluates_all_points_in_order() { let mut points = scalars(41); - points[..5].copy_from_slice(&[ + points[..6].copy_from_slice(&[ Fr::ZERO, Fr::ONE, -Fr::ONE, Fr::ONE, Fr::ZERO, + Fr::from(918273), ]); - for n in [0, 1, 2, 31, 1025, 65_537] { + for n in [0, 1, 2, 31, 32, 33, 1023, 1024, 1025, 65_537] { let coefficients = scalars(n); for count in [0, 1, 2, 3, 4, 7, 8, 41] { let points = &points[..count]; @@ -612,7 +597,7 @@ fn opening_points(count: usize) -> Vec { }) .collect(); let basis = G1Projective::normalize_batch(&basis); - crate::kzg::buffer::generate(count, |i| basis[i % basis.len()]) + crate::kzg::pcs::generate_buffer(count, |i| basis[i % basis.len()]) } #[test] @@ -706,7 +691,7 @@ fn concurrent_uncached_msm_completes() { fn concurrent_uncached_msm_body() { use std::sync::Barrier; assert_eq!(Devices::get().ids.len(), 1, "run with one device selected"); - assert!(!srs_cache::enabled()); + assert!(!srs_residency::enabled()); let n = 1 << 20; let basis: Vec<_> = (1u64..=17) .map(|i| (G1Affine::generator() * Fr::from(i)).into_affine()) diff --git a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs b/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs deleted file mode 100644 index c9880409b..000000000 --- a/crates/proof-compression/src/kzg/cuda/tests/plonk_scale.rs +++ /dev/null @@ -1,583 +0,0 @@ -//! Opt-in dense primitive measurements for the 2^24 PLONK domain. - -use super::*; -use crate::pipeline::SetupConfig; -use ark_serialize::CanonicalSerialize; -use serde_json::{Value, json}; -use std::{ - fs::{File, OpenOptions}, - io::{Seek, Write}, - path::PathBuf, - time::{Instant, SystemTime, UNIX_EPOCH}, -}; - -const N: usize = 1 << 24; -const SEED: &[u8] = b"ix/bn254/plonk-scale/dense-primitives/v1"; - -unsafe extern "C" { - fn ix_kzg_srs_plan( - device: i32, - count: usize, - window: *mut u32, - persistent: *mut usize, - temporary: *mut usize, - ) -> i32; - fn ix_kzg_srs_upload( - device: i32, - input: *const c_void, - count: usize, - output: *mut *mut c_void, - ) -> i32; - fn ix_kzg_srs_destroy(device: i32, points: *mut c_void) -> i32; - fn ix_kzg_srs_workspace_create( - device: i32, - count: usize, - output: *mut *mut c_void, - ) -> i32; - fn ix_kzg_srs_workspace_destroy(device: i32, workspace: *mut c_void) -> i32; - fn ix_kzg_srs_msm( - device: i32, - workspace: *mut c_void, - points: *const c_void, - point_offset: usize, - output: *mut ProjectiveLimbs, - host: *const [u64; 4], - polynomial: *const c_void, - scalar_offset: usize, - count: usize, - normalization: *const [u64; 4], - ) -> i32; -} - -struct Report { - file: File, - value: Value, -} - -impl Report { - fn new(operation: &str, repeats: usize, ordinal: i32) -> Self { - let path = PathBuf::from( - std::env::var_os("IX_PLONK_PRIMITIVE_REPORT") - .expect("set IX_PLONK_PRIMITIVE_REPORT to a new report path"), - ); - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .expect("create a new primitive benchmark report"); - let mut report = Self { - file, - value: json!({ - "format": "ix-bn254-plonk-scale-primitives/v1", - "operation": operation, - "completed": false, - "all_comparisons_passed": false, - "started_unix_seconds": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), - "device_ordinal": ordinal, - "cuda_visible_devices": std::env::var("CUDA_VISIBLE_DEVICES").ok(), - "compiled_cuda_architectures": env!("IX_KZG_CUDA_COMPILED_ARCHS"), - "rayon_threads": rayon::current_num_threads(), - "warm_repeats": repeats, - "seed": String::from_utf8_lossy(SEED), - "field_vector_hash_encoding": "BLAKE3 of little-endian Montgomery Fr limbs; native ABI checked", - "scope": "Existing ix BN254 CUDA primitives, not a gnark CUDA prover. Native calls include host transfers. Only the first case is process-cold; OS page-cache state is uncontrolled.", - "warm_definition": "Repeated identical operation within one case; uncached MSM still uploads its bases on every call", - "records": [], - }), - }; - report.save(); - report - } - - fn save(&mut self) { - self.file.set_len(0).unwrap(); - self.file.rewind().unwrap(); - serde_json::to_writer_pretty(&mut self.file, &self.value).unwrap(); - self.file.write_all(b"\n").unwrap(); - self.file.flush().unwrap(); - } - - fn record(&mut self, record: Value) { - eprintln!("plonk-scale {}", serde_json::to_string(&record).unwrap()); - self.value["records"].as_array_mut().unwrap().push(record); - self.save(); - } -} - -fn timed(operation: impl FnOnce() -> T) -> (T, f64) { - let start = Instant::now(); - let result = operation(); - (result, start.elapsed().as_secs_f64()) -} - -fn dense(count: usize) -> Vec { - (0..count) - .into_par_iter() - .map(|index| { - let mut wide = [0; 64]; - blake3::Hasher::new() - .update(SEED) - .update(&(index as u64).to_le_bytes()) - .finalize_xof() - .fill(&mut wide); - let value = Fr::from_le_bytes_mod_order(&wide); - if value.is_zero() { Fr::ONE } else { value } - }) - .collect() -} - -fn field_hash(values: &[Fr]) -> String { - // The ABI asserts a padding-free four-limb Fr representation. - let bytes = unsafe { - std::slice::from_raw_parts( - values.as_ptr().cast::(), - size_of_val(values), - ) - }; - let mut hash = blake3::Hasher::new(); - hash.update_rayon(bytes); - hash.finalize().to_hex().to_string() -} - -fn point_hex(point: G1Projective) -> String { - let mut bytes = Vec::new(); - point.into_affine().serialize_compressed(&mut bytes).unwrap(); - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn memory(device: i32) -> Value { - // libix_bn254_cuda.so keeps its CUDA runtime private; these queries read - // driver-wide state through a separate static runtime. - #[link(name = "cudart_static", kind = "static")] - unsafe extern "C" { - fn cudaMemGetInfo(free: *mut usize, total: *mut usize) -> i32; - fn cudaDeviceGetDefaultMemPool(pool: *mut *mut c_void, device: i32) -> i32; - fn cudaDeviceGetMemPool(pool: *mut *mut c_void, device: i32) -> i32; - fn cudaMemPoolGetAttribute( - pool: *mut c_void, - attribute: i32, - value: *mut c_void, - ) -> i32; - } - - let (mut free, mut total) = (0, 0); - check( - unsafe { ix_kzg_memory(device, &mut free, &mut total) }, - "benchmark memory query", - ); - // ix_kzg_memory selects this device before querying its allocation pool. - let (mut driver_free, mut driver_total) = (0, 0); - check( - unsafe { cudaMemGetInfo(&mut driver_free, &mut driver_total) }, - "benchmark driver memory query", - ); - let mut default_pool = std::ptr::null_mut(); - check( - unsafe { cudaDeviceGetDefaultMemPool(&mut default_pool, device) }, - "benchmark default allocation pool", - ); - let mut active_pool = std::ptr::null_mut(); - check( - unsafe { cudaDeviceGetMemPool(&mut active_pool, device) }, - "benchmark active allocation pool", - ); - let statistics = |pool| { - // cudaMemPoolAttr 5..=8 each returns a uint64_t byte count. - let mut values = [0u64; 4]; - for (value, attribute) in values.iter_mut().zip(5..=8) { - check( - unsafe { - cudaMemPoolGetAttribute( - pool, - attribute, - std::ptr::from_mut(value).cast(), - ) - }, - "benchmark allocation pool statistics", - ); - } - json!({ - "reserved_current_bytes": values[0], - "reserved_high_bytes": values[1], - "used_current_bytes": values[2], - "used_high_bytes": values[3], - }) - }; - json!({ - "total_bytes": total, - "available_including_reusable_pool_bytes": free, - "driver_total_bytes": driver_total, - "driver_free_bytes": driver_free, - "driver_used_bytes": driver_total.saturating_sub(driver_free), - "default_pool": statistics(default_pool), - "active_pool_is_default": active_pool == default_pool, - "different_active_pool": if active_pool == default_pool { - None - } else { - Some(statistics(active_pool)) - }, - "pool_high_water_scope": "Cumulative since pool creation or last external reset; this benchmark does not reset it", - "pool_peak_is_total_vram_peak": false, - "pool_peak_exclusions": "Driver/context/module overhead and allocations outside this pool; driver_used_bytes is only an instantaneous snapshot", - }) -} - -struct CachedMsm<'a, 'b> { - device: &'a Device<'b>, - points: *mut c_void, - workspace: *mut c_void, -} - -impl<'a, 'b> CachedMsm<'a, 'b> { - fn new(device: &'a Device<'b>, points: &[G1Affine]) -> (Self, Value) { - let mut handle = Self { - device, - points: std::ptr::null_mut(), - workspace: std::ptr::null_mut(), - }; - let (mut window, mut persistent, mut temporary) = (0, 0, 0); - check( - unsafe { - ix_kzg_srs_plan( - device.id(), - points.len(), - &mut window, - &mut persistent, - &mut temporary, - ) - }, - "benchmark cached MSM plan", - ); - let (_, workspace_seconds) = timed(|| { - check( - unsafe { - ix_kzg_srs_workspace_create( - device.id(), - points.len(), - &mut handle.workspace, - ) - }, - "benchmark cached MSM workspace", - ) - }); - let (_, upload_seconds) = timed(|| { - check( - unsafe { - ix_kzg_srs_upload( - device.id(), - points.as_ptr().cast(), - points.len(), - &mut handle.points, - ) - }, - "benchmark cached MSM points", - ) - }); - ( - handle, - json!({ - "native_count": points.len(), "window": window, - "planned_persistent_workspace_bytes": persistent, - "planned_temporary_workspace_bytes": temporary, - "device_point_bytes": points.len() * 64, - "workspace_seconds": workspace_seconds, "point_upload_seconds": upload_seconds, - "initialization_seconds": workspace_seconds + upload_seconds, - }), - ) - } - - fn invoke(&self, scalars: &[Fr]) -> G1Projective { - let mut output = [[0; FQ_LIMBS]; 3]; - check( - unsafe { - ix_kzg_srs_msm( - self.device.id(), - self.workspace, - self.points, - 0, - &mut output, - scalars.as_ptr().cast(), - std::ptr::null(), - 0, - scalars.len(), - std::ptr::null(), - ) - }, - "benchmark cached MSM", - ); - projective(output) - } - - fn close(mut self) { - let points = std::mem::replace(&mut self.points, std::ptr::null_mut()); - check( - unsafe { ix_kzg_srs_destroy(self.device.id(), points) }, - "benchmark cached points destruction", - ); - let workspace = - std::mem::replace(&mut self.workspace, std::ptr::null_mut()); - check( - unsafe { ix_kzg_srs_workspace_destroy(self.device.id(), workspace) }, - "benchmark cached workspace destruction", - ); - } -} - -impl Drop for CachedMsm<'_, '_> { - fn drop(&mut self) { - if !self.points.is_null() { - unsafe { ix_kzg_srs_destroy(self.device.id(), self.points) }; - } - if !self.workspace.is_null() { - unsafe { ix_kzg_srs_workspace_destroy(self.device.id(), self.workspace) }; - } - } -} - -fn uncached_msm( - device: &Device<'_>, - points: &[G1Affine], - scalars: &[Fr], -) -> (G1Projective, Value) { - let (resident, upload_seconds) = timed(|| upload_msm_points(device, points)); - let mut output = [[0; FQ_LIMBS]; 3]; - let (_, invoke_seconds) = timed(|| { - check( - unsafe { - ix_kzg_msm_invoke( - device.id(), - resident.context, - &mut output, - scalars.as_ptr().cast(), - scalars.len(), - std::ptr::null(), - ) - }, - "benchmark uncached MSM", - ) - }); - let (_, destroy_seconds) = timed(|| { - let status = unsafe { ix_kzg_msm_destroy(device.id(), resident.context) }; - std::mem::forget(resident); - check(status, "benchmark uncached MSM destruction"); - }); - ( - projective(output), - json!({ - "point_upload_and_workspace_seconds": upload_seconds, - "invoke_seconds": invoke_seconds, "destroy_seconds": destroy_seconds, - }), - ) -} - -fn msm_benchmark(report: &mut Report, cached: bool, repeats: usize) { - let (srs, load_seconds) = timed(|| { - SetupConfig::from_env(false) - .unwrap() - .for_stage(SEED, 2 * N) - .unwrap() - .srs(2 * N, false) - .unwrap() - }); - report.record(json!({ - "phase": "srs_load", "seconds": load_seconds, - "loaded_prefix_points": srs.g1.len(), "largest_used_point_end": N+10, - "public_max_degree": srs.public_setup.max_degree, - "setup_id": blake3::Hash::from(srs.public_setup.id).to_hex().to_string(), - })); - let (values, generate_seconds) = timed(|| dense(N + 3)); - let (input_hash, hash_seconds) = timed(|| field_hash(&values)); - report.record(json!({ - "phase": "dense_scalar_generation", "elements": values.len(), - "seconds": generate_seconds, "hash_seconds": hash_seconds, - "input_montgomery_blake3": input_hash, "zero_scalars": 0, - })); - for (offset, count) in [(0, N), (7, N + 3)] { - let points = &srs.g1[offset..offset + count]; - let scalars = &values[..count]; - eprintln!("plonk-scale phase=cpu_msm offset={offset} count={count}"); - let (expected, cpu_seconds) = - timed(|| G1Projective::msm(points, scalars).unwrap()); - assert!( - !expected.is_zero(), - "dense reference unexpectedly equals infinity" - ); - report.record(json!({ - "phase": "cpu_msm_reference", "offset": offset, "count": count, - "seconds": cpu_seconds, "compressed_result": point_hex(expected), - })); - let device = Devices::get().acquire_at(0); - assert!( - device.prepare_budget(N * MSM_BYTES_PER_POINT + (1 << 30)), - "full N-point MSM cannot be admitted" - ); - let (resident, initialization) = if cached { - let (resident, initialization) = CachedMsm::new(&device, &points[..N]); - (Some(resident), initialization) - } else { - (None, json!({"initialization_seconds": 0.0})) - }; - report.record(json!({ - "phase": "msm_initialization", "offset": offset, "count": count, - "cached": cached, "details": initialization, - "memory": memory(device.id()), - })); - for trial in 0..=repeats { - let (point, gpu_seconds, details) = if let Some(resident) = &resident { - let (point, seconds) = timed(|| resident.invoke(&scalars[..N])); - (point, seconds, json!({})) - } else { - let ((point, details), seconds) = - timed(|| uncached_msm(&device, &points[..N], &scalars[..N])); - (point, seconds, details) - }; - let (actual, tail_seconds) = timed(|| { - if count == N { - point - } else { - point + G1Projective::msm(&points[N..], &scalars[N..]).unwrap() - } - }); - let matches = actual == expected; - report.record(json!({ - "phase": "gpu_msm", "trial": trial, - "warm": trial != 0, "cached": cached, "offset": offset, - "process_first_msm": offset == 0 && trial == 0, - "basis_first_use": trial == 0, - "resident_basis_reused": cached && trial != 0, - "host_point_end": offset+count, "count": count, - "native_gpu_count": N, "native_gpu_calls": 1, "native_point_offset": 0, - "cpu_tail_points": count-N, - "gpu_call_seconds": gpu_seconds, "cpu_tail_and_add_seconds": tail_seconds, - "operation_seconds": gpu_seconds+tail_seconds, - "first_use_with_initialization_seconds": if trial == 0 { - Some(gpu_seconds+tail_seconds+initialization["initialization_seconds"].as_f64().unwrap()) - } else { None }, - "compressed_result": point_hex(actual), "matches_full_cpu_reference": matches, - "native_details": details, "memory": memory(device.id()), - })); - assert!(matches, "large MSM differs from full CPU reference"); - } - if let Some(resident) = resident { - let (_, seconds) = timed(|| resident.close()); - report.record(json!({"phase": "cached_msm_cleanup", "seconds": seconds})); - } - } -} - -fn fft_benchmark(report: &mut Report, log_size: usize, repeats: usize) { - let inverse = match std::env::var("IX_PLONK_PRIMITIVE_DIRECTION") - .as_deref() - .unwrap_or("forward") - { - "forward" => false, - "inverse" => true, - _ => panic!("IX_PLONK_PRIMITIVE_DIRECTION must be forward or inverse"), - }; - let shift: u64 = std::env::var("IX_PLONK_PRIMITIVE_SHIFT") - .unwrap_or_else(|_| "5".into()) - .parse() - .unwrap(); - assert!(shift != 0, "FFT coset shift must be nonzero"); - let shift = Fr::from(shift); - let count = 1usize << log_size; - let (input, generate_seconds) = timed(|| dense(count)); - let (input_hash, input_hash_seconds) = timed(|| field_hash(&input)); - report.record(json!({ - "phase": "dense_fft_input", "log_size": log_size, "elements": count, - "bytes_per_vector": count*32, "inverse": inverse, - "shift": shift.into_bigint().to_string(), "generation_seconds": generate_seconds, - "hash_seconds": input_hash_seconds, "input_montgomery_blake3": input_hash, - })); - let (domain, domain_seconds) = timed(|| { - Radix2EvaluationDomain::::new(count).unwrap().get_coset(shift).unwrap() - }); - let (mut expected, cpu_copy_seconds) = timed(|| input.clone()); - eprintln!("plonk-scale phase=cpu_fft log_size={log_size} inverse={inverse}"); - let (_, cpu_seconds) = timed(|| { - if inverse { - domain.ifft_in_place(&mut expected) - } else { - domain.fft_in_place(&mut expected) - } - }); - let (expected_hash, hash_seconds) = timed(|| field_hash(&expected)); - report.record(json!({ - "phase": "cpu_fft_reference", "domain_seconds": domain_seconds, - "input_copy_seconds": cpu_copy_seconds, "seconds": cpu_seconds, - "hash_seconds": hash_seconds, "output_montgomery_blake3": expected_hash, - })); - for trial in 0..=repeats { - let (mut actual, copy_seconds) = timed(|| input.clone()); - let (_, gpu_seconds) = timed(|| fft(&mut actual, inverse, shift)); - let (mismatch, compare_seconds) = timed(|| { - actual - .par_iter() - .zip(&expected) - .enumerate() - .find_any(|(_, (a, b))| a != b) - .map(|(index, _)| index) - }); - let (actual_hash, hash_seconds) = timed(|| field_hash(&actual)); - report.record(json!({ - "phase": "gpu_fft", "trial": trial, "warm": trial != 0, - "native_gpu_count": count, "native_gpu_log_size": log_size, - "native_gpu_calls": 1, "inverse": inverse, - "input_copy_seconds": copy_seconds, "gpu_call_seconds": gpu_seconds, - "compare_seconds": compare_seconds, "hash_seconds": hash_seconds, - "output_montgomery_blake3": actual_hash, "mismatch_index": mismatch, - "matches_full_cpu_reference": mismatch.is_none(), - "memory": memory(Devices::get().ids[0]), - })); - assert!(mismatch.is_none(), "large FFT differs from full CPU reference"); - } -} - -// The CPU references are only representative with the `parallel` feature. -#[cfg(feature = "parallel")] -#[test] -#[ignore = "full-scale CUDA benchmark; set IX_PLONK_PRIMITIVE_OP and IX_PLONK_PRIMITIVE_REPORT and run this test alone"] -fn bn254_plonk_scale_primitives() { - let operation = std::env::var("IX_PLONK_PRIMITIVE_OP") - .expect("explicit primitive operation required"); - assert!(matches!( - operation.as_str(), - "msm-cached" | "msm-uncached" | "fft-n" | "fft-m" - )); - let repeats: usize = std::env::var("IX_PLONK_PRIMITIVE_REPEATS") - .unwrap_or_else(|_| "3".into()) - .parse() - .unwrap(); - assert!((1..=10).contains(&repeats)); - let device: i32 = std::env::var("IX_PLONK_PRIMITIVE_DEVICE") - .unwrap_or_else(|_| "0".into()) - .parse() - .unwrap(); - assert!( - configure(Settings { - devices: Some(vec![device]), - msm_chunk_points: N, - srs_cache: true, - ..Settings::default() - }), - "run the benchmark alone in a fresh process" - ); - let mut report = Report::new(&operation, repeats, device); - let start = Instant::now(); - let (_, initialization_seconds) = timed(Devices::get); - report.record(json!({ - "phase": "device_initialization", "seconds": initialization_seconds, - "memory": memory(device), - })); - match operation.as_str() { - "msm-cached" => msm_benchmark(&mut report, true, repeats), - "msm-uncached" => msm_benchmark(&mut report, false, repeats), - "fft-n" => fft_benchmark(&mut report, 24, repeats), - "fft-m" => fft_benchmark(&mut report, 26, repeats), - _ => unreachable!(), - } - report.value["total_seconds"] = json!(start.elapsed().as_secs_f64()); - report.value["completed"] = json!(true); - report.value["all_comparisons_passed"] = json!(true); - report.save(); -} diff --git a/crates/proof-compression/src/kzg/curve.rs b/crates/proof-compression/src/kzg/curve.rs index f48079cbd..7f75bb814 100644 --- a/crates/proof-compression/src/kzg/curve.rs +++ b/crates/proof-compression/src/kzg/curve.rs @@ -40,9 +40,10 @@ pub(crate) fn read_g1_compressed( mod tests { use super::*; use ark_ec::AffineRepr; + use ark_ff::{BigInteger, PrimeField}; #[test] - fn canonical_point_widths_match_arkworks() { + fn point_encodings_match_widths_and_vectors() { for point in [G1Affine::identity(), G1Affine::generator()] { let mut bytes = Vec::new(); point.serialize_compressed(&mut bytes).unwrap(); @@ -63,36 +64,7 @@ mod tests { assert_eq!(bytes.len(), G2_UNCOMPRESSED_BYTES); assert_eq!(G2Affine::deserialize_uncompressed(&*bytes).unwrap(), point); } - } - - #[test] - fn canonical_point_reader_preserves_sign_and_consumes_one_point() { - for point in - [G1Affine::identity(), G1Affine::generator(), -G1Affine::generator()] - { - let mut bytes = Vec::new(); - point.serialize_compressed(&mut bytes).unwrap(); - bytes.extend([0x12, 0x34]); - let mut input = bytes.as_slice(); - assert_eq!(read_g1_compressed(&mut input).unwrap(), point); - assert_eq!(input, &[0x12, 0x34]); - } - } - #[test] - fn canonical_point_reader_rejects_invalid_and_truncated_encodings() { - let mut bytes = Vec::new(); - G1Affine::generator().serialize_compressed(&mut bytes).unwrap(); - assert!( - read_g1_compressed(&mut &bytes[..G1_COMPRESSED_BYTES - 1]).is_err() - ); - assert!( - read_g1_compressed(&mut [0xff; G1_COMPRESSED_BYTES].as_slice()).is_err() - ); - } - - #[test] - fn bn254_compressed_point_vectors() { let mut positive = [0; 32]; positive[0] = 1; let mut negative = positive; @@ -107,13 +79,24 @@ mod tests { let mut bytes = Vec::new(); point.serialize_compressed(&mut bytes).unwrap(); assert_eq!(bytes.as_slice(), expected.as_slice()); - assert_eq!(read_g1_compressed(&mut expected.as_slice()).unwrap(), point); + // The reader consumes exactly one point and leaves what follows. + bytes.extend([0x12, 0x34]); + let mut input = bytes.as_slice(); + assert_eq!(read_g1_compressed(&mut input).unwrap(), point); + assert_eq!(input, &[0x12, 0x34]); } } #[test] - fn bn254_point_reader_rejects_infinity_alias_and_noncanonical_x() { - use ark_ff::{BigInteger, PrimeField}; + fn point_reader_rejects_truncated_invalid_aliased_and_noncanonical_points() { + let mut bytes = Vec::new(); + G1Affine::generator().serialize_compressed(&mut bytes).unwrap(); + assert!( + read_g1_compressed(&mut &bytes[..G1_COMPRESSED_BYTES - 1]).is_err() + ); + assert!( + read_g1_compressed(&mut [0xff; G1_COMPRESSED_BYTES].as_slice()).is_err() + ); let mut alias = [0; 32]; alias[0] = 1; diff --git a/crates/proof-compression/src/kzg/srs_cache.rs b/crates/proof-compression/src/kzg/dev_srs_cache.rs similarity index 83% rename from crates/proof-compression/src/kzg/srs_cache.rs rename to crates/proof-compression/src/kzg/dev_srs_cache.rs index f98b44327..ce42e42fd 100644 --- a/crates/proof-compression/src/kzg/srs_cache.rs +++ b/crates/proof-compression/src/kzg/dev_srs_cache.rs @@ -228,60 +228,31 @@ fn write_cache(path: &Path, header: &[u8], srs: &Srs) -> io::Result<()> { #[cfg(test)] mod directory_tests { use super::*; + use crate::test_support::TempDir; - struct TestDirectory(PathBuf); - - impl TestDirectory { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "ix-dev-srs-{}-{}", - std::process::id(), - NEXT_TEMP.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - - fn path(&self, max_len: usize, seed: &[u8]) -> PathBuf { - self.0.join(format!( - "{}.dev-srs", - blake3::hash(&header(max_len, seed)).to_hex() - )) - } - } - - impl Drop for TestDirectory { - fn drop(&mut self) { - fs::remove_dir_all(&self.0).unwrap(); - } - } - - #[test] - fn cache_identity_is_curve_specific() { - let seed = b"curve-specific-cache"; - let mut legacy_header = b"ix/bls12-381/dev-srs/v1\0".to_vec(); - legacy_header.extend_from_slice(&16u64.to_le_bytes()); - legacy_header.extend_from_slice(blake3::hash(seed).as_bytes()); - assert_ne!(header(16, seed), legacy_header); - assert_ne!(blake3::hash(&header(16, seed)), blake3::hash(&legacy_header)); + fn cache_path(directory: &Path, max_len: usize, seed: &[u8]) -> PathBuf { + directory.join(format!( + "{}.dev-srs", + blake3::hash(&header(max_len, seed)).to_hex() + )) } #[test] fn cache_round_trip_and_key_separation() { - let directory = TestDirectory::new(); + let directory = TempDir::new("dev-srs"); for (size, seed) in [ (2, b"first".as_slice()), (16, b"first"), (32, b"first"), (16, b"second"), ] { - let fresh = cached_in_directory(size, 63, seed, &directory.0).unwrap(); - let cached = cached_in_directory(size, 63, seed, &directory.0).unwrap(); + let fresh = cached_in_directory(size, 63, seed, &directory).unwrap(); + let cached = cached_in_directory(size, 63, seed, &directory).unwrap(); let expected = Srs::unsafe_dev_setup_prefix(size, 63, seed); assert_eq!(expected.g1[0].uncompressed_size(), G1_BYTES); assert_eq!(expected.g2.uncompressed_size(), G2_BYTES); assert_eq!( - fs::metadata(directory.path(size, seed)).unwrap().len(), + fs::metadata(cache_path(&directory, size, seed)).unwrap().len(), (header(size, seed).len() + size * G1_BYTES + 2 * G2_BYTES + 32) as u64 ); for actual in [fresh, cached] { @@ -292,16 +263,16 @@ mod directory_tests { actual.validate().unwrap(); } } - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 4); - assert!(cached_in_directory(16, 14, b"first", &directory.0).is_err()); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 4); + assert!(cached_in_directory(16, 14, b"first", &directory).is_err()); } #[test] fn damaged_cache_fails_without_regeneration() { - let directory = TestDirectory::new(); + let directory = TempDir::new("dev-srs"); let seed = b"damage"; - cached_in_directory(16, 15, seed, &directory.0).unwrap(); - let path = directory.path(16, seed); + cached_in_directory(16, 15, seed, &directory).unwrap(); + let path = cache_path(&directory, 16, seed); let valid = fs::read(&path).unwrap(); let mut variants = vec![ valid[..valid.len() - 1].to_vec(), @@ -320,41 +291,41 @@ mod directory_tests { variants.push(damaged); for damaged in variants { fs::write(&path, &damaged).unwrap(); - assert!(cached_in_directory(16, 15, seed, &directory.0).is_err()); + assert!(cached_in_directory(16, 15, seed, &directory).is_err()); assert_eq!(fs::read(&path).unwrap(), damaged); } } #[test] fn cache_rejects_a_different_seed_even_with_valid_checksum() { - let directory = TestDirectory::new(); + let directory = TempDir::new("dev-srs"); let expected_header = header(16, b"expected"); - let path = directory.path(16, b"expected"); + let path = cache_path(&directory, 16, b"expected"); write_cache( &path, &expected_header, &Srs::unsafe_dev_setup_prefix(16, 15, b"different"), ) .unwrap(); - assert!(cached_in_directory(16, 15, b"expected", &directory.0).is_err()); + assert!(cached_in_directory(16, 15, b"expected", &directory).is_err()); } #[test] fn concurrent_cache_writers_publish_complete_files() { - let directory = TestDirectory::new(); + let directory = TempDir::new("dev-srs"); std::thread::scope(|scope| { for _ in 0..4 { scope.spawn(|| { - cached_in_directory(32, 31, b"concurrent", &directory.0).unwrap(); + cached_in_directory(32, 31, b"concurrent", &directory).unwrap(); }); } }); let actual = - cached_in_directory(32, 31, b"concurrent", &directory.0).unwrap(); + cached_in_directory(32, 31, b"concurrent", &directory).unwrap(); assert_eq!( actual.g1, Srs::unsafe_dev_setup_prefix(32, 31, b"concurrent").g1 ); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 1); } } diff --git a/crates/proof-compression/src/kzg/field.rs b/crates/proof-compression/src/kzg/field.rs index 62bb07f3a..767fe544b 100644 --- a/crates/proof-compression/src/kzg/field.rs +++ b/crates/proof-compression/src/kzg/field.rs @@ -318,14 +318,4 @@ mod tests { assert!(decoded.is_err()); } } - - #[test] - fn inverse_and_powers() { - let x = Scalar::from_u32(12345); - assert_eq!(x * x.inverse(), >::ONE); - let cube: Vec = x.powers().take(4).collect(); - assert_eq!(cube[3], x * x * x); - assert_eq!(x.exp_u64(3), cube[3]); - assert_eq!(x.exp_power_of_2(3), x.exp_u64(8)); - } } diff --git a/crates/proof-compression/src/kzg/mod.rs b/crates/proof-compression/src/kzg/mod.rs index 97e6da4c8..7aa85f58d 100644 --- a/crates/proof-compression/src/kzg/mod.rs +++ b/crates/proof-compression/src/kzg/mod.rs @@ -1,13 +1,13 @@ //! KZG backend with BLAKE3 and Keccak-256 transcripts. //! Commitments contain one G1 point per column; openings are batched by point. -mod buffer; mod coefficients; pub mod compact; pub mod config; #[cfg(feature = "cuda")] pub mod cuda; pub mod curve; +mod dev_srs_cache; pub mod domain; pub mod encoding; mod fft_batch; @@ -16,7 +16,6 @@ pub mod pcs; mod quotient; mod quotient_plan; pub mod srs; -mod srs_cache; pub mod transcript; pub use config::KzgConfig; diff --git a/crates/proof-compression/src/kzg/pcs.rs b/crates/proof-compression/src/kzg/pcs.rs index 9f222e342..b5eeffba5 100644 --- a/crates/proof-compression/src/kzg/pcs.rs +++ b/crates/proof-compression/src/kzg/pcs.rs @@ -417,7 +417,7 @@ pub enum KzgError { pub struct KzgPcs { srs: Option>, #[cfg(feature = "cuda")] - srs_owner: Option>, + srs_owner: Option>, verifier: super::VerifierParams, max_quotient_degree: usize, } @@ -532,7 +532,7 @@ impl KzgPcs { let constants = matrix.constant_columns(); let width = selected.len(); let height = domain.size(); - let mut values = super::buffer::generate(width * height, |index| { + let mut values = generate_buffer(width * height, |index| { let column = selected[index % width]; if constants[column] { Scalar(views[column].0[0]) @@ -590,7 +590,7 @@ impl KzgPcs { Self::validate_quotient_domain(&verifier, max_quotient_degree); Self { #[cfg(feature = "cuda")] - srs_owner: Some(cuda::srs_cache::Owner::new(Arc::clone(&srs))), + srs_owner: Some(cuda::srs_residency::Owner::new(Arc::clone(&srs))), srs: Some(srs), verifier, max_quotient_degree, @@ -628,7 +628,7 @@ impl KzgPcs { } #[cfg(feature = "cuda")] - fn srs_owner(&self) -> &Arc { + fn srs_owner(&self) -> &Arc { self.srs_owner.as_ref().expect("proving requires the full SRS") } @@ -711,8 +711,8 @@ impl KzgPcs { .map(|&i| (views[i].0, residents.and_then(|r| r[i].as_deref()))) .collect(); let started = std::time::Instant::now(); - let points = if cuda::srs_cache::enabled() { - cuda::srs_cache::msm_columns(self.srs_owner(), 0, &inputs) + let points = if cuda::srs_residency::enabled() { + cuda::srs_residency::msm_columns(self.srs_owner(), 0, &inputs) } else { cuda::msm_columns_resident(&self.prover_srs().g1[..count], &inputs) }; @@ -774,8 +774,8 @@ impl KzgPcs { } #[cfg(feature = "cuda")] if coeffs.len() >= 1 << 12 { - if cuda::srs_cache::enabled() { - return cuda::srs_cache::msm_columns( + if cuda::srs_residency::enabled() { + return cuda::srs_residency::msm_columns( self.srs_owner(), 0, &[(coeffs, None)], @@ -826,7 +826,7 @@ impl KzgPcs { (0..width) .into_par_iter() .map(|column| { - super::buffer::generate(matrix.height(), |row| { + generate_buffer(matrix.height(), |row| { matrix.values[row * width + column].0 }) }) @@ -1289,8 +1289,12 @@ impl KzgPcs { fn witness(&self, combined: Vec, z: Fr) -> G1Projective { #[cfg(feature = "cuda")] if combined.len() >= 1 << 16 { - let witness = if cuda::srs_cache::enabled() { - cuda::srs_cache::single_device_opening(self.srs_owner(), &combined, z) + let witness = if cuda::srs_residency::enabled() { + cuda::srs_residency::single_device_opening( + self.srs_owner(), + &combined, + z, + ) } else { cuda::single_device_opening( &self.prover_srs().g1[..combined.len() - 1], @@ -1379,6 +1383,26 @@ fn divide_by_linear(mut p: Vec, z: Fr) -> Vec { p } +/// Parallel first-touch initialization of a large field buffer. +pub(super) fn generate_buffer( + len: usize, + value: impl Fn(usize) -> T + Sync, +) -> Vec { + let mut output: Vec = Vec::with_capacity(len); + output.spare_capacity_mut()[..len] + .par_chunks_mut(1 << 14) + .enumerate() + .for_each(|(tile, chunk)| { + for (offset, slot) in chunk.iter_mut().enumerate() { + slot.write(value((tile << 14) + offset)); + } + }); + // Every slot is initialized after the parallel iterator joins. T is Copy, + // so a panic during initialization cannot leak partially constructed values. + unsafe { output.set_len(len) }; + output +} + #[cfg(test)] mod tests { use super::*; @@ -1417,9 +1441,9 @@ mod tests { } #[test] - fn checkpoints_reject_other_curves_and_oversized_domains() { + fn checkpoints_reject_wrong_magic_and_oversized_domains() { let pcs = - KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(4, b"checkpoint-curve")), 2); + KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(4, b"checkpoint-magic")), 2); let domain = pcs.natural_domain_for_degree(4); let (commitment, data) = pcs.commit(vec![(domain, RowMajorMatrix::new_col(vec![Scalar::ONE; 4]))]); @@ -1428,10 +1452,9 @@ mod tests { let decoded = KzgProverData::read_checkpoint(bytes.as_slice()).unwrap(); assert_eq!(decoded.commitment, commitment); - let mut wrong_curve = bytes.clone(); - let other_magic = b"IXKZGCP4"; - wrong_curve[..8].copy_from_slice(other_magic); - assert!(KzgProverData::read_checkpoint(wrong_curve.as_slice()).is_err()); + let mut wrong_magic = bytes.clone(); + wrong_magic[7] ^= 1; + assert!(KzgProverData::read_checkpoint(wrong_magic.as_slice()).is_err()); let log_offset = 8 + commitment.0.compressed_size() + 8; bytes[log_offset..log_offset + 8] @@ -1505,7 +1528,7 @@ mod tests { } #[test] - fn constant_coefficients_match_dense_commitments_evaluations_and_openings() { + fn constant_columns_match_dense_data_and_survive_checkpoints() { let pcs = KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(32, b"constant-columns")), 2); let domain = pcs.natural_domain_for_degree(16); @@ -1551,19 +1574,29 @@ mod tests { pcs.open(vec![(&compact, points.clone())], &mut KzgTranscript::default()), pcs.open(vec![(&dense, points)], &mut KzgTranscript::default()) ); + + // Columns are classified individually, and a checkpoint keeps the split. + let (_, mixed) = pcs.commit(vec![( + domain, + RowMajorMatrix::new( + (0..16) + .flat_map(|row| [Scalar::from_u8(7), Scalar::from_usize(row)]) + .collect(), + 2, + ), + )]); + assert_eq!(mixed.matrices[0].constant_columns(), [true, false]); + let mut bytes = Vec::new(); + mixed.write_checkpoint(&mut bytes).unwrap(); + let loaded = KzgProverData::read_checkpoint(bytes.as_slice()).unwrap(); + assert_eq!(loaded.matrices[0].constant_columns(), [true, false]); } #[test] fn disk_coefficients_match_resident_and_detect_changes() { use std::io::{Seek, SeekFrom, Write}; - let path = std::env::temp_dir().join(format!( - "kzg-columns-{}-{}.bin", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + let directory = crate::test_support::TempDir::new("kzg-columns"); + let path = directory.join("columns.bin"); let mut file = std::fs::File::create_new(&path).unwrap(); let pcs = KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(1 << 17, b"disk-columns")), 2); @@ -1619,9 +1652,6 @@ mod tests { file.set_len(0).unwrap(); assert!(disk.matrices[0].columns[0].load().is_err()); assert!(KzgProverData::read_checkpoint_file(&path).is_err()); - drop(file); - drop(disk); - std::fs::remove_file(path).unwrap(); } #[test] @@ -1670,25 +1700,6 @@ mod tests { assert_eq!(pcs.msm(&coefficients), expected); } - #[test] - fn constant_columns_are_detected_on_commit_and_checkpoint_load() { - let pcs = - KzgPcs::new(Arc::new(Srs::unsafe_dev_setup(16, b"constant-columns")), 4); - let domain = pcs.natural_domain_for_degree(16); - let matrix = RowMajorMatrix::new( - (0..16) - .flat_map(|row| [Scalar::from_u8(7), Scalar::from_usize(row)]) - .collect(), - 2, - ); - let (_, data) = pcs.commit(vec![(domain, matrix)]); - assert_eq!(data.matrices[0].constant_columns(), [true, false]); - let mut bytes = Vec::new(); - data.write_checkpoint(&mut bytes).unwrap(); - let loaded = KzgProverData::read_checkpoint(bytes.as_slice()).unwrap(); - assert_eq!(loaded.matrices[0].constant_columns(), [true, false]); - } - #[test] fn mixed_height_openings_verify_without_proving_powers() { let pcs = KzgPcs::new( diff --git a/crates/proof-compression/src/kzg/srs.rs b/crates/proof-compression/src/kzg/srs.rs index 717746388..3589ba186 100644 --- a/crates/proof-compression/src/kzg/srs.rs +++ b/crates/proof-compression/src/kzg/srs.rs @@ -69,7 +69,7 @@ impl Srs { seed: &[u8], directory: impl AsRef, ) -> io::Result { - super::srs_cache::cached_in_directory( + super::dev_srs_cache::cached_in_directory( max_len, public_max_degree, seed, @@ -125,7 +125,7 @@ impl Srs { } // Preserve the validation challenge's exact encoding without retaining a - // second, serialized copy of all powers (12 GiB for a 2^28 BLS12-381 SRS). + // second, serialized copy of all powers (gigabytes for a 2^28 SRS). struct HashWriter(blake3::Hasher); impl Write for HashWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { @@ -257,35 +257,6 @@ impl Srs { mod tests { use super::*; - #[test] - fn development_identity_and_anchors_are_curve_specific() { - let seed = b"curve-specific-setup"; - let srs = Srs::unsafe_dev_setup(4, seed); - let mut legacy_id = blake3::Hasher::new(); - legacy_id.update(b"multi-stark/kzg/known-trapdoor-public-degree/v1"); - legacy_id.update(&3u64.to_le_bytes()); - legacy_id.update(blake3::hash(seed).as_bytes()); - assert_ne!(srs.public_setup.id, *legacy_id.finalize().as_bytes()); - - let mut wide = [0; 64]; - blake3::Hasher::new() - .update(b"multi-stark/kzg/dev-srs") - .update(seed) - .finalize_xof() - .fill(&mut wide); - let legacy_tau = Fr::from_le_bytes_mod_order(&wide); - assert_ne!(dev_tau(seed), legacy_tau); - assert_ne!( - srs.g1[1], - (G1Projective::generator() * legacy_tau).into_affine() - ); - assert_ne!( - srs.tau_g2, - (G2Projective::generator() * legacy_tau).into_affine() - ); - srs.validate().unwrap(); - } - #[test] fn development_powers_match_naive_at_chunk_boundaries() { let seed = b"chunk-powers"; @@ -305,11 +276,6 @@ mod tests { } } - #[test] - fn dev_setup_validates() { - Srs::unsafe_dev_setup(1 << 5, b"validate-test").validate().unwrap(); - } - #[test] fn validation_covers_chunk_boundaries() { let mut srs = Srs::unsafe_dev_setup(1 << 17, b"validation-chunks"); diff --git a/crates/proof-compression/src/kzg/srs/gnark.rs b/crates/proof-compression/src/kzg/srs/gnark.rs index 4fa51db15..d102f5ea7 100644 --- a/crates/proof-compression/src/kzg/srs/gnark.rs +++ b/crates/proof-compression/src/kzg/srs/gnark.rs @@ -111,7 +111,7 @@ mod tests { // Only the G1/G2 portion is consumed by Rust; pairing lines are a gnark optimization. fn fixture(srs: &Srs) -> Vec { - let mut bytes = (srs.g1.len() as u32).to_be_bytes().to_vec(); + let mut bytes = u32::try_from(srs.g1.len()).unwrap().to_be_bytes().to_vec(); let encode_g1 = |p: G1Affine| { [p.x, p.y] .into_iter() @@ -127,15 +127,15 @@ mod tests { } } bytes.extend(encode_g1(srs.g1[0])); - bytes.resize(4 + srs.g1.len() * 64 + VK_BYTES as usize, 0); + bytes.resize(4 + srs.g1.len() * 64 + usize::try_from(VK_BYTES).unwrap(), 0); bytes } #[test] fn prefix_loading_checks_framing_coordinates_and_anchors() -> io::Result<()> { let srs = Srs::unsafe_dev_setup(8, b"gnark-reader"); - let path = - std::env::temp_dir().join(format!("ix-gnark-srs-{}", std::process::id())); + let directory = crate::test_support::TempDir::new("gnark-srs"); + let path = directory.join("srs.bin"); let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?; let bytes = fixture(&srs); @@ -154,6 +154,6 @@ mod tests { } std::fs::write(&path, &bytes[..bytes.len() - 1])?; assert!(load(&path, 4, srs.public_setup).is_err()); - std::fs::remove_file(path) + Ok(()) } } diff --git a/crates/proof-compression/src/kzg/transcript.rs b/crates/proof-compression/src/kzg/transcript.rs index ca7b680c2..f1039fd26 100644 --- a/crates/proof-compression/src/kzg/transcript.rs +++ b/crates/proof-compression/src/kzg/transcript.rs @@ -262,33 +262,10 @@ impl Transcript for Keccak256Transcript { #[cfg(test)] mod tests { use super::*; - use crate::kzg::curve::G1Affine; - use ark_ec::AffineRepr; use ark_ff::BigInteger; use multi_stark::traits::Field; use num_bigint::BigUint; - #[test] - fn deterministic_and_sensitive() { - let run = |values: &[u64]| { - let mut t = Blake3Transcript::new(); - for &v in values { - t.observe_field(Scalar::from_u64(v)); - } - t.sample_challenge() - }; - assert_eq!(run(&[1, 2, 3]), run(&[1, 2, 3])); - assert_ne!(run(&[1, 2, 3]), run(&[1, 2, 4])); - assert_ne!(run(&[1, 2, 3]), run(&[1, 2])); - } - - #[test] - fn consecutive_samples_differ() { - let mut t = Blake3Transcript::new(); - t.observe_field(Scalar::from_u64(7)); - assert_ne!(t.sample_challenge(), t.sample_challenge()); - } - #[test] fn blake3_challenges_match_integer_reduction() { let mut t = Blake3Transcript::new(); @@ -373,36 +350,6 @@ mod tests { assert_ne!(t.digest(0), keccak256(&[&[0], &t.state])); } - #[test] - fn blake3_enum_preserves_legacy_transcript() { - let mut legacy = Blake3Transcript::new(); - let mut selected = KzgTranscript::default(); - assert_eq!(selected.kind(), TranscriptKind::Blake3); - assert_eq!(legacy.sample_challenge(), selected.sample_challenge()); - - let xs = [Scalar::from_u64(5), Scalar::from_u64(6)]; - let commitment = KzgCommitment(vec![ - vec![G1Affine::generator(), G1Affine::default()], - vec![], - ]); - legacy.observe_bytes(b"parameter seed"); - selected.observe_bytes(b"parameter seed"); - legacy.observe_canonical(&Fr::from(123u64)); - selected.observe_canonical(&Fr::from(123u64)); - legacy.observe_field_slice(&xs); - selected.observe_field_slice(&xs); - legacy.observe_commitment(commitment.clone()); - selected.observe_commitment(commitment); - for _ in 0..3 { - let challenge = legacy.sample_challenge(); - assert_eq!(challenge, selected.sample_challenge()); - legacy.observe_challenge(challenge); - selected.observe_challenge(challenge); - legacy.observe_field(xs[0]); - selected.observe_field(xs[0]); - } - } - #[test] fn keccak_incremental_observations_are_equivalent() { let bytes: Vec<_> = (0u8..200).collect(); diff --git a/crates/proof-compression/src/kzg/verifier_params.rs b/crates/proof-compression/src/kzg/verifier_params.rs index 5c5a40ebc..598bb75db 100644 --- a/crates/proof-compression/src/kzg/verifier_params.rs +++ b/crates/proof-compression/src/kzg/verifier_params.rs @@ -132,23 +132,6 @@ mod tests { assert!(VerifierParams::from_bytes(&invalid.to_bytes().unwrap()).is_err()); } - #[test] - fn parameter_encoding_identifies_curve() { - use super::super::curve::{G1_COMPRESSED_BYTES, G2_COMPRESSED_BYTES}; - - let params = - VerifierParams::from_srs(&Srs::unsafe_dev_setup(2, b"curve-version")); - let mut encoded = params.to_bytes().unwrap(); - assert_eq!( - encoded.len(), - 3 * 8 + 32 + 2 * G1_COMPRESSED_BYTES + 2 * G2_COMPRESSED_BYTES - ); - assert_eq!(&encoded[..8], &VERSION.to_le_bytes()); - let other_curve_version: u64 = 4; - encoded[..8].copy_from_slice(&other_curve_version.to_le_bytes()); - assert!(VerifierParams::from_bytes(&encoded).is_err()); - } - #[test] fn public_identity_and_degree_are_independent_of_loaded_prefix() { let prover = Srs::unsafe_dev_setup(64, b"verifier-params"); diff --git a/crates/proof-compression/src/lib.rs b/crates/proof-compression/src/lib.rs index ab9cbe72f..2414725db 100644 --- a/crates/proof-compression/src/lib.rs +++ b/crates/proof-compression/src/lib.rs @@ -6,3 +6,5 @@ pub mod fri; pub mod kzg; pub mod pipeline; pub mod recursive; +#[cfg(test)] +mod test_support; diff --git a/crates/proof-compression/src/pipeline/stages/statement.rs b/crates/proof-compression/src/pipeline/claims.rs similarity index 90% rename from crates/proof-compression/src/pipeline/stages/statement.rs rename to crates/proof-compression/src/pipeline/claims.rs index 2284fa9de..1c8732451 100644 --- a/crates/proof-compression/src/pipeline/stages/statement.rs +++ b/crates/proof-compression/src/pipeline/claims.rs @@ -1,4 +1,6 @@ -//! Public cells and fixed activation anchors of a lowered statement. +//! Claim rows of a lowered circuit: the template of its public cells and +//! fixed activation anchors, the rows' transport, and the rows a first-stage +//! proof of a root statement exposes. use super::Result; use crate::kzg::Scalar; use multi_stark::{ @@ -11,12 +13,12 @@ use p3_field::PrimeField64; /// The lowering's complete claim template, including all activation anchors. /// Only public-value cells may be replaced during verification. #[derive(serde::Serialize, serde::Deserialize)] -pub(in crate::pipeline) struct Statement { +pub(in crate::pipeline) struct ClaimTemplate { pub(in crate::pipeline) template: Vec>, pub(in crate::pipeline) locations: Vec<(usize, usize)>, } -impl Statement { +impl ClaimTemplate { pub(in crate::pipeline) fn from_lowered( circuit: &MultiStarkCircuit, count: usize, @@ -39,7 +41,7 @@ impl Statement { } } -impl Statement { +impl ClaimTemplate { pub(super) fn validate(&self) -> Result<()> { let normalized = Self::from_claims(&self.template, self.locations.len())?; if self.locations != normalized.locations @@ -51,7 +53,7 @@ impl Statement { } } -impl Statement { +impl ClaimTemplate { pub(in crate::pipeline) fn from_claims( claims: &[Vec], count: usize, @@ -99,7 +101,7 @@ impl Statement { } } -impl Statement { +impl ClaimTemplate { pub(in crate::pipeline) fn schema( &self, ) -> crate::fri::Statement { @@ -119,13 +121,13 @@ impl Statement { } } -impl Statement { +impl ClaimTemplate { pub(in crate::pipeline) fn recursive(count: usize) -> Self { Self { template: (0..=count) .map(|i| { vec![ - Scalar::from_u8(super::construct::RECURSIVE_NAMESPACE), + Scalar::from_u8(super::lowering::RECURSIVE_NAMESPACE), Scalar::ONE, Scalar::from_usize(i), Scalar::ZERO, @@ -181,9 +183,9 @@ pub(in crate::pipeline) fn decode_claims( /// partition and the six fixed namespace anchors. pub(in crate::pipeline) fn first_claims( count: usize, - statement: &super::super::statement::Statement, + statement: &super::statement::Statement, ) -> Vec> { - let namespace = Scalar::from_u8(super::construct::FIRST_NAMESPACE); + let namespace = Scalar::from_u8(super::lowering::FIRST_NAMESPACE); let mut expected: Vec<_> = std::iter::once(0) .chain(statement.iter().copied()) .enumerate() diff --git a/crates/proof-compression/src/pipeline/compress.rs b/crates/proof-compression/src/pipeline/compress.rs index 016208586..b6dba0ca9 100644 --- a/crates/proof-compression/src/pipeline/compress.rs +++ b/crates/proof-compression/src/pipeline/compress.rs @@ -9,7 +9,7 @@ use super::{ FinalOptions, FinalSystem, FixedRoot, Result, first, fixed_cache, groth16, init_fri, plonk, root, setup::{SetupClass, SetupConfig}, - stages, statement, storage, verifying_keys, wrap, + stage, statement, storage, verifying_keys, wrap, }; use std::{ fs, io, @@ -478,7 +478,7 @@ fn check_fri( ) -> Result<()> { let fixture = init_fri::load(dir)?; let actual = - statement::from_scalars(&stages::scalar_statement(&fixture.public))?; + statement::from_scalars(&stage::scalar_statement(&fixture.public))?; if actual != *words { return Err("FRI proof proves another statement".into()); } @@ -606,13 +606,7 @@ impl MemoryMonitor { #[cfg(test)] mod tests { use super::*; - - fn scratch(name: &str) -> PathBuf { - let path = std::env::temp_dir() - .join(format!("ix-compress-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&path); - path - } + use crate::test_support::TempDir; fn files(proof: &[u8]) -> Vec<(&'static str, Vec)> { root_files(b"vk", proof, &[7; statement::WORDS], None).unwrap() @@ -620,7 +614,8 @@ mod tests { #[test] fn output_resumes_only_the_same_root() -> Result<()> { - let out = scratch("open-output"); + let scratch = TempDir::new("compress-open-output"); + let out = scratch.join("out"); open_output(&out, &files(b"proof"), false)?; assert_eq!(fs::read(out.join("root/root-proof.bin"))?, b"proof"); // An interrupted root write is completed. @@ -637,20 +632,19 @@ mod tests { assert_eq!(fs::read(out.join("root/root-proof.bin"))?, b"proof"); assert!(out.join("fri").is_dir()); // A non-empty directory without a root is refused. - let other = scratch("open-output-foreign"); + let other = scratch.join("foreign"); fs::create_dir_all(other.join("data"))?; assert!(open_output(&other, &files(b"proof"), false).is_err()); assert!(open_output(&other, &files(b"proof"), true).is_err()); assert!(!other.join(ROOT).exists()); - fs::remove_dir_all(other)?; - fs::remove_dir_all(out)?; Ok(()) } #[test] fn fresh_output_replaces_stages_without_touching_unrelated_files() -> Result<()> { - let out = scratch("fresh-output"); + let scratch = TempDir::new("compress-fresh-output"); + let out = scratch.join("out"); open_output(&out, &files(b"old proof"), false)?; fs::write(out.join("notes"), b"keep")?; fs::write(out.join(COMPRESSED_PROOF), b"old final proof")?; @@ -667,7 +661,6 @@ mod tests { for name in ["fri", "first-kzg", "final-kzg", "plonk", "groth16"] { assert!(!out.join(name).exists()); } - fs::remove_dir_all(out)?; Ok(()) } @@ -677,7 +670,8 @@ mod tests { #[test] fn stages_are_reused_while_their_output_checks() -> Result<()> { - let out = scratch("checkpoints"); + let scratch = TempDir::new("compress-checkpoints"); + let out = scratch.join("out"); let proved = std::cell::RefCell::new(Vec::new()); let run = |checkpoints: &mut Checkpoints<'_>| -> Result<()> { for name in ["a", "b", "c"] { @@ -725,7 +719,6 @@ mod tests { |_dir| Err("unverifiable".into()), ); assert!(failing.is_err()); - fs::remove_dir_all(out)?; Ok(()) } } diff --git a/crates/proof-compression/src/pipeline/derive.rs b/crates/proof-compression/src/pipeline/derive.rs index c33ff5e9c..8a1a08365 100644 --- a/crates/proof-compression/src/pipeline/derive.rs +++ b/crates/proof-compression/src/pipeline/derive.rs @@ -75,9 +75,8 @@ mod tests { longer on CPU)"] fn release_plonk_key_derives_without_a_proof() { use sha2::{Digest, Sha256}; - let cache = super::super::storage::temporary_path( - &std::env::temp_dir().join("ix-plonk-derive"), - ); + let scratch = crate::test_support::TempDir::new("plonk-derive"); + let cache = scratch.join("cache"); let derived = super::super::plonk::derive_verifying_key( RECURSIVE_VK, &verifying_keys::STATEMENT_PREFIX, @@ -87,6 +86,5 @@ mod tests { .unwrap(); eprintln!("derived PLONK key SHA-256 {:x}", Sha256::digest(&derived)); assert!(derived == verifying_keys::PLONK_VK, "derived PLONK key differs"); - std::fs::remove_dir_all(cache).unwrap(); } } diff --git a/crates/proof-compression/src/pipeline/development.rs b/crates/proof-compression/src/pipeline/development.rs index 99640a443..5c4de94db 100644 --- a/crates/proof-compression/src/pipeline/development.rs +++ b/crates/proof-compression/src/pipeline/development.rs @@ -219,7 +219,7 @@ mod tests { anchors: Option, ) -> Verifier { let schema: serde_json::Value = serde_json::from_slice(include_bytes!( - "../../../../tools/plonk-wrapper/testdata/bn254/vk.json" + "../../../../tools/gnark/testdata/bn254/vk.json" )) .unwrap(); Verifier { @@ -228,7 +228,7 @@ mod tests { inner_setup: "ignition".into(), inner_vk_blake3: "00".repeat(32), schema_sha256: hex(&Sha256::digest(include_bytes!( - "../../../../tools/plonk-wrapper/testdata/bn254/vk.json" + "../../../../tools/gnark/testdata/bn254/vk.json" ))), statement_prefix: serde_json::from_value(schema["prefix"].clone()) .unwrap(), diff --git a/crates/proof-compression/src/pipeline/final_proof.rs b/crates/proof-compression/src/pipeline/final_proof.rs index 35b38a863..7edfdd9d2 100644 --- a/crates/proof-compression/src/pipeline/final_proof.rs +++ b/crates/proof-compression/src/pipeline/final_proof.rs @@ -2,16 +2,11 @@ use super::Result; #[cfg(any(feature = "plonk", feature = "groth16"))] use super::statement; -#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] -use super::storage; use serde::{Deserialize, Serialize}; #[cfg(any(feature = "plonk", feature = "groth16"))] use sha2::{Digest, Sha256}; use std::{fs, io::Read, path::Path}; -#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] -use std::path::PathBuf; - // TODO: Decide between SHA-256 and BLAKE3 for artifact/cache metadata. // Coordinate Rust/Go encodings and any catalog or envelope key-ID changes; // proof-protocol hashes have separate compatibility requirements. @@ -99,23 +94,6 @@ pub(super) fn description( super::wrap::final_description(&vk, &expected[..10]) } -#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] -pub(super) struct Scratch(pub(super) PathBuf); -#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] -impl Scratch { - pub(super) fn new() -> Result { - let path = storage::temporary_path(&std::env::temp_dir().join("ix-final")); - fs::create_dir(&path)?; - Ok(Self(path)) - } -} -#[cfg(all(test, any(feature = "plonk", feature = "groth16")))] -impl Drop for Scratch { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} - #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; diff --git a/crates/proof-compression/src/pipeline/first.rs b/crates/proof-compression/src/pipeline/first.rs index f19162fd2..2f790620a 100644 --- a/crates/proof-compression/src/pipeline/first.rs +++ b/crates/proof-compression/src/pipeline/first.rs @@ -18,8 +18,8 @@ use p3_field::PrimeCharacteristicRing; use std::{fs, path::Path, time::Instant}; #[cfg(test)] -use super::init_claim; -use super::stages::construct::{self, FriConstruction, Lowered, max_height}; +use super::init_fri::INIT_PUBLIC_WORDS; +use super::lowering::{self, FriConstruction, Lowered, max_height}; use super::{ fixed_cache::FixedCache, prover::FixedSetup, @@ -31,7 +31,7 @@ use super::{init_fri, prover}; /// Partitions of the supported Init layout: ten carry the public words and /// the rest are tables, which only exist above eleven partitions. pub(super) const MIN_PARTITIONS: usize = 11; -pub(super) use super::stages::statement::first_claims as expected_claims; +pub(super) use super::claims::first_claims as expected_claims; /// Translate, lower and prove the saved FRI proof in `fri`, writing the /// first KZG stage to `out`. @@ -59,8 +59,8 @@ pub(super) fn run( fixture.claims, )?; assert_eq!(prepared.public_values(), fixture.public); - let public = super::stages::scalar_statement(&fixture.public); - let lowered = construct::prepare_first(prepared, trace_height)?; + let public = super::stage::scalar_statement(&fixture.public); + let lowered = lowering::prepare_first(prepared, trace_height)?; assert_eq!(lowered.assignment.public_values(), public); eprintln!( "Scalar translation and lowering of {} circuits: {:?}; {:?}", @@ -129,7 +129,7 @@ pub(super) fn fixed_cache( /// fixed preprocessing comes from or is published to its entry, and the /// result says which, with the plan identity the entry is keyed by. pub(super) fn setup( - fri: &super::stages::FriVerifier, + fri: &super::FriVerifier, log_height: usize, config: &super::SetupConfig, cache: Option<&Path>, @@ -140,17 +140,17 @@ pub(super) fn setup( )> { let plan = VerifierPlan::validate( &fri.key, - construct::fri_profile(&fri.key, &fri.statement.template)?, + lowering::fri_profile(&fri.key, &fri.statement.template)?, VerifierLimits::default(), )?; let schema = fri.statement.schema(); let plan_id = plan.identity(&schema)?; let circuit = FriConstruction::build(&plan, schema)?.circuit; drop(plan); - let (compiled, _inputs) = construct::first_circuit(circuit, 1 << log_height)?; + let (compiled, _inputs) = lowering::first_circuit(circuit, 1 << log_height)?; let height = max_height(&compiled)?; let setup = config.for_stage(FIRST_SEED, height)?; - let public = super::stages::scalar_statement(&[Val::ZERO; 18]); + let public = super::stage::scalar_statement(&[Val::ZERO; 18]); let manifest = storage::Manifest::for_compiled( &compiled, compiled.claims(&public)?, @@ -286,6 +286,7 @@ fn prove( #[cfg(test)] mod tests { use super::*; + use crate::test_support::TempDir; use multi_stark::plonkish::gadgets::ByteGadgets; use multi_stark::plonkish::gadgets::blake3; use multi_stark::plonkish::{CircuitBuilder, Value}; @@ -336,7 +337,7 @@ mod tests { builder.assert_equal(input.value(), seven); } let public: Vec<_> = - init_claim::INIT_PUBLIC_WORDS.into_iter().map(Scalar::from_u64).collect(); + INIT_PUBLIC_WORDS.into_iter().map(Scalar::from_u64).collect(); for &word in &public { let value = builder.constant(word); builder.expose_public(value); @@ -386,11 +387,7 @@ mod tests { } fn prove_fixture(log_height: usize) -> storage::Result { - let root = std::env::temp_dir().join(format!( - "init-fri-kzg-fixture-{}-{log_height}", - std::process::id() - )); - fs::create_dir(&root)?; + let root = TempDir::new(&format!("init-fri-kzg-fixture-{log_height}")); let fixture = fixture(log_height)?; let (compiled, public) = (&fixture.compiled, &fixture.public); assert_eq!(compiled.num_circuits(), if log_height == 16 { 12 } else { 11 }); @@ -531,7 +528,6 @@ mod tests { } fs::create_dir(&damaged)?; assert!(cache.restore(&damaged)?.is_some()); - fs::remove_dir_all(root)?; Ok(first[0].len()) } } diff --git a/crates/proof-compression/src/pipeline/gnark.rs b/crates/proof-compression/src/pipeline/gnark.rs index 4b33ed08d..e0f57eea7 100644 --- a/crates/proof-compression/src/pipeline/gnark.rs +++ b/crates/proof-compression/src/pipeline/gnark.rs @@ -295,10 +295,10 @@ mod tests { #[ignore = "compiles the full Init verifier circuit"] fn compiles_init_without_changing_constraints() -> Result<()> { use sha2::Digest; - let directory = crate::pipeline::final_proof::Scratch::new()?; + let directory = crate::test_support::TempDir::new("gnark-compile"); let schema = - include_bytes!("../../../../tools/plonk-wrapper/testdata/bn254/vk.json"); - let path = directory.0.join("circuit.bin"); + include_bytes!("../../../../tools/gnark/testdata/bn254/vk.json"); + let path = directory.join("circuit.bin"); let info = compile(schema, &path)?; assert_eq!(info.constraints, 14689440); assert_eq!(info.domain, 1 << 24); diff --git a/crates/proof-compression/src/pipeline/groth16.rs b/crates/proof-compression/src/pipeline/groth16.rs index 4b176fd89..57960f241 100644 --- a/crates/proof-compression/src/pipeline/groth16.rs +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -1,6 +1,4 @@ //! Final BN254 Groth16 proofs under the release key. -#[cfg(all(test, feature = "groth16"))] -use super::final_proof::Scratch; #[cfg(feature = "groth16")] use super::final_proof::{hex, sha256}; #[cfg(feature = "groth16")] @@ -584,10 +582,10 @@ mod native_tests { #[test] #[ignore = "compiles the full Init verifier circuit"] fn compiles_init_without_changing_constraints() -> Result<()> { - let directory = Scratch::new()?; + let directory = crate::test_support::TempDir::new("groth16-compile"); let schema = - include_bytes!("../../../../tools/plonk-wrapper/testdata/bn254/vk.json"); - let path = directory.0.join("circuit.bin"); + include_bytes!("../../../../tools/gnark/testdata/bn254/vk.json"); + let path = directory.join("circuit.bin"); let info = gnark::compile_groth16(schema, &path)?; let expected: serde_json::Value = serde_json::from_str(include_str!( "../../tests/fixtures/groth16/provenance.json" @@ -622,9 +620,8 @@ mod native_tests { let key = include_bytes!("../../tests/fixtures/groth16/verifying-key.bin"); let expected_key = digest(&metadata.artifact("verifying-key.bin")?.sha256)?; let claim = digest(provenance["claim_digest"].as_str().unwrap())?; - let witness = include_bytes!( - "../../../../tools/plonk-wrapper/testdata/bn254/witness.json" - ); + let witness = + include_bytes!("../../../../tools/gnark/testdata/bn254/witness.json"); let mut prover = gnark::Prover::open_groth16(&dir, &metadata, &proving_backend()?)?; let first = prover.prove(witness, &claim)?; diff --git a/crates/proof-compression/src/pipeline/init_claim.rs b/crates/proof-compression/src/pipeline/init_claim.rs deleted file mode 100644 index 8a30d3f60..000000000 --- a/crates/proof-compression/src/pipeline/init_claim.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Independently expected public values of the Init proof fixture. -pub(crate) const INIT_PUBLIC_WORDS: [u64; 18] = [ - 0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, - 1176003176, 683533119, 3085659920, 1422858688, 3165160492, 1899567894, - 784981652, 376864732, 1915641123, 2034217541, 2163067803, -]; diff --git a/crates/proof-compression/src/pipeline/init_fri.rs b/crates/proof-compression/src/pipeline/init_fri.rs index 6b31c9dcc..71c48a18b 100644 --- a/crates/proof-compression/src/pipeline/init_fri.rs +++ b/crates/proof-compression/src/pipeline/init_fri.rs @@ -1,5 +1,5 @@ //! The saved intermediate FRI proof and the statement it exposes. -use super::stages::statement::decode_claims; +use super::claims::{ClaimTemplate, decode_claims}; use crate::fri::*; use multi_stark::prover::Proof; use multi_stark::types::GoldilocksBlake3Config; @@ -7,6 +7,14 @@ use multi_stark::types::Val; use p3_field::PrimeCharacteristicRing; use std::{fs, path::Path}; +/// Independently expected public values of the Init proof fixture. +#[cfg(test)] +pub(crate) const INIT_PUBLIC_WORDS: [u64; 18] = [ + 0, 293, 3008464260, 3936129970, 1088962797, 1141056675, 2347219926, + 1176003176, 683533119, 3085659920, 1422858688, 3165160492, 1899567894, + 784981652, 376864732, 1915641123, 2034217541, 2163067803, +]; + pub(crate) struct Fixture { pub key: VerifierKey, pub proof: Proof, @@ -29,15 +37,12 @@ pub(crate) fn load(dir: &Path) -> Result> { return Err("noncanonical proof".into()); } let claims = decode_claims(&fs::read(dir.join("outer-claims.bin"))?)?; - let statement = - super::stages::statement::Statement::from_claims(&claims, 18)?; + let statement = ClaimTemplate::from_claims(&claims, 18)?; let public: [Val; 18] = statement .public_values(&claims)? .try_into() .map_err(|_words| "compressed proof does not expose 18 statement words")?; - if statement.template[0][0] - != Val::from_u8(super::stages::construct::FRI_NAMESPACE) - { + if statement.template[0][0] != Val::from_u8(super::lowering::FRI_NAMESPACE) { return Err("compressed proof does not expose the statement rows".into()); } let refs: Vec<_> = claims.iter().map(Vec::as_slice).collect(); @@ -46,7 +51,7 @@ pub(crate) fn load(dir: &Path) -> Result> { .verify_multiple_claims(&refs, &proof) .map_err(|e| format!("saved recursive proof: {e:?}"))?; eprintln!("Saved {}-byte recursive FRI proof verified", bytes.len()); - let profile = super::stages::construct::fri_profile(&key, &claims)?; + let profile = super::lowering::fri_profile(&key, &claims)?; if proof.active != profile.active || proof.log_degrees != profile.log_degrees { return Err("compressed proof profile differs from the key".into()); @@ -65,10 +70,7 @@ mod tests { &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/init-fri"), ) .unwrap(); - assert_eq!( - fixture.public, - super::super::init_claim::INIT_PUBLIC_WORDS.map(Val::from_u64) - ); + assert_eq!(fixture.public, INIT_PUBLIC_WORDS.map(Val::from_u64)); for i in 0..fixture.public.len() { let mut wrong = fixture.claims.clone(); wrong[i + 1][3] += Val::ONE; diff --git a/crates/proof-compression/src/pipeline/stages/construct.rs b/crates/proof-compression/src/pipeline/lowering.rs similarity index 98% rename from crates/proof-compression/src/pipeline/stages/construct.rs rename to crates/proof-compression/src/pipeline/lowering.rs index 892d6af9f..7229758d6 100644 --- a/crates/proof-compression/src/pipeline/stages/construct.rs +++ b/crates/proof-compression/src/pipeline/lowering.rs @@ -1,5 +1,5 @@ //! Production circuit layouts and ownership of lowered witnesses. -use super::{Prepared, Result}; +use super::{Result, stage::Prepared}; use crate::{ foreign::{GoldilocksCircuit, GoldilocksInputs}, fri::{self, VerifierInputs, VerifierKey, VerifierPlan}, @@ -95,8 +95,8 @@ impl Lowered { config: KzgConfig, preprocessing: Option<&Path>, prefetch_bytes: usize, - ) -> Result { - let committed = super::super::prover::Committed::prepare( + ) -> Result { + let committed = super::prover::Committed::prepare( config, &self.compiled.trace_shards(&self.assignment)?, preprocessing, diff --git a/crates/proof-compression/src/pipeline/mod.rs b/crates/proof-compression/src/pipeline/mod.rs index 716fd2a6e..b036b87da 100644 --- a/crates/proof-compression/src/pipeline/mod.rs +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -1,9 +1,11 @@ //! Compression and verification of aggregate root proofs. -mod stages; -pub use stages::{ - FixedRoot, FriVerifier, KzgVerifier, Prepared, RecursiveProof, - RecursiveStage, RecursiveVerifier, prepare_kzg, prepare_recursive, - prepare_root, +mod claims; +mod lowering; +mod stage; +pub use root::{FixedRoot, prepare_root}; +pub use stage::{FriVerifier, KzgVerifier, Prepared, prepare_kzg}; +pub use wrap::{ + RecursiveProof, RecursiveStage, RecursiveVerifier, prepare_recursive, }; mod compress; @@ -19,8 +21,6 @@ mod fixed_cache; #[cfg(any(feature = "plonk", feature = "groth16"))] pub mod gnark; pub mod groth16; -#[cfg(test)] -mod init_claim; mod init_fri; pub mod plonk; mod prepare; @@ -34,7 +34,6 @@ mod setup; pub use setup::{SetupClass, SetupConfig}; mod statement; mod storage; -mod verifier; mod wrap; use std::{fs, path::Path}; @@ -96,7 +95,7 @@ pub fn verify_with_vk( let packet = proof .strip_prefix(PROOF_TAG) .ok_or("unsupported compressed proof curve or version")?; - verifier::verify_with_vk( + verifying_keys::verify_with_vk( packet, claim, &words, @@ -158,12 +157,14 @@ mod tests { use super::*; #[test] - fn explicit_vk_verification_rejects_other_curve_envelopes() { + fn explicit_vk_verification_rejects_other_proof_tags() { let expected = statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); - let other = b"IXCMP\0\0\x04"; - let error = verify_with_vk(other, b"claim", &expected, b"", &[0; 32], true) - .unwrap_err(); + let mut other = *PROOF_TAG; + other[7] ^= 0x80; + let error = + verify_with_vk(&other, b"claim", &expected, b"", &[0; 32], true) + .unwrap_err(); assert!(error.to_string().contains("curve or version")); } diff --git a/crates/proof-compression/src/pipeline/plonk.rs b/crates/proof-compression/src/pipeline/plonk.rs index 1457a975f..bc438ac8b 100644 --- a/crates/proof-compression/src/pipeline/plonk.rs +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -628,16 +628,16 @@ pub(super) fn wrap_packet( } #[cfg(test)] -mod bn254_tests { +mod tests { use super::*; #[test] #[cfg(feature = "plonk")] fn development_parameters_need_no_ignition_and_bind_the_cache() -> Result<()> { - let temporary = super::super::final_proof::Scratch::new()?; + let temporary = crate::test_support::TempDir::new("plonk-parameters"); let setup = SetupConfig::Development { cache_directory: None }; - let root = temporary.0.join("parameters"); + let root = temporary.join("parameters"); let (path, checksum) = cached_parameters(8, &root, &setup)?; let (again, same_checksum) = cached_parameters(8, &root, &setup)?; assert_eq!((path.clone(), checksum), (again, same_checksum)); diff --git a/crates/proof-compression/src/pipeline/prepare.rs b/crates/proof-compression/src/pipeline/prepare.rs index c1b6bf265..b98128191 100644 --- a/crates/proof-compression/src/pipeline/prepare.rs +++ b/crates/proof-compression/src/pipeline/prepare.rs @@ -12,12 +12,13 @@ //! pinned plan, but the recursive stage is rebuilt to find its identity, //! and only missing entries are committed. use super::{ - CompressConfig, FixedRoot, Result, SetupClass, SetupConfig, first, + CompressConfig, FixedRoot, Result, SetupClass, SetupConfig, + claims::first_claims, + first, fixed_cache::{FixedCache, Prepared}, prover::FixedSetup, root, setup::IGNITION_SRS_VAR, - stages::statement::first_claims, storage::Manifest, verifying_keys, wrap, }; @@ -260,8 +261,8 @@ mod tests { #[test] fn prepared_preprocessing_is_published_once_in_the_prover_format() -> Result<()> { - let root = std::env::temp_dir() - .join(format!("ix-setup-publish-{}", std::process::id())); + let scratch = crate::test_support::TempDir::new("setup-publish"); + let root = scratch.join("cache"); let compiled = compiled(); let setup = crate::pipeline::setup::SetupSource::development(b"setup", 63)?; let config = setup.config(16, 2, false)?; @@ -303,7 +304,6 @@ mod tests { fs::remove_file(&checkpoint)?; fs::write(&checkpoint, bytes)?; assert!(cache.check().is_err()); - fs::remove_dir_all(root)?; Ok(()) } } diff --git a/crates/proof-compression/src/pipeline/prover.rs b/crates/proof-compression/src/pipeline/prover.rs index f4b116416..bed2d6ffa 100644 --- a/crates/proof-compression/src/pipeline/prover.rs +++ b/crates/proof-compression/src/pipeline/prover.rs @@ -484,8 +484,8 @@ mod tests { ); let expected = FixedProofCodec::new(&reference, &proof.log_degrees)?.encode(&proof)?; - let directory = - storage::temporary_path(&std::env::temp_dir().join("ix-kzg-prover")); + let scratch = crate::test_support::TempDir::new("kzg-prover"); + let directory = scratch.join("preprocessing"); let traces = compiled.trace_shards(&assignment)?; for (preprocessing, prefetch) in [ (None, 0), @@ -507,7 +507,6 @@ mod tests { .verify_multiple_claims(&refs, &result.proof) .map_err(|error| format!("reference verification: {error:?}"))?; } - fs::remove_dir_all(directory)?; } Ok(compiled.num_circuits()) } diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs index f8f8ed55e..82058730f 100644 --- a/crates/proof-compression/src/pipeline/root.rs +++ b/crates/proof-compression/src/pipeline/root.rs @@ -1,13 +1,22 @@ -//! FRI compression of an exported aggregate root proof. -use super::stages::statement::decode_claims; -use crate::fri::ImplementationOptions; -use crate::fri::VerifierKey; -use crate::fri::VerifierLimits; +//! The aggregate root and its FRI compression: the circuit verifying an +//! exported root proof, generic over the proof's geometry or fixed by the +//! layout of a normalized root, and the FRI stage that proves it. +mod fixed; +pub use fixed::FixedRoot; + +use super::{ + Result, + claims::{decode_claims, encode_claims}, + stage::{FriVerifier, Prepared}, +}; +use crate::fri::*; +use aiur::{synthesis::AiurProof, vk_codec::AiurVerifyingKey}; use multi_stark::batch::BatchProof; +use multi_stark::plonkish::CircuitBuilder; use multi_stark::types::GoldilocksBlake3Config; use multi_stark::types::Val; use p3_blake3::Blake3; -use p3_field::TwoAdicField; +use p3_field::{PrimeField64, TwoAdicField}; use p3_symmetric::CryptographicHasher; use std::{fs, path::Path, time::Instant}; @@ -20,32 +29,28 @@ const MAX_TRACE_HEIGHT: usize = 1 << 24; /// normalized to `layout`, derived without a proof: `run` proves the same /// circuit, lowered and committed under the same parameters. pub(super) fn verifier( - key: &aiur::vk_codec::AiurVerifyingKey, - layout: &super::stages::FixedRoot, -) -> Result> { - super::stages::setup::fri( + key: &AiurVerifyingKey, + layout: &FixedRoot, +) -> Result { + FriVerifier::derive( layout.circuit(key, VerifierLimits::default())?, config(key), MAX_TRACE_HEIGHT, ) } -fn config(key: &aiur::vk_codec::AiurVerifyingKey) -> GoldilocksBlake3Config { +fn config(key: &AiurVerifyingKey) -> GoldilocksBlake3Config { GoldilocksBlake3Config::new(key.commitment_parameters(), key.fri_parameters()) } /// Verify the root input in `dir`, build its FRI verifier circuit and check /// its constraints, writing the circuit's identity and cost to `out`. With /// `prove`, also prove the circuit and write the outer FRI proof. -pub(super) fn run( - dir: &Path, - out: &Path, - prove: bool, -) -> Result<(), Box> { +pub(super) fn run(dir: &Path, out: &Path, prove: bool) -> Result<()> { fs::create_dir_all(out)?; let input_span = tracing::info_span!("compression/load_input").entered(); let vk_bytes = fs::read(dir.join("root-vk.bin"))?; - let native_key = aiur::vk_codec::AiurVerifyingKey::from_bytes(&vk_bytes)?; + let native_key = AiurVerifyingKey::from_bytes(&vk_bytes)?; let system = native_key.system(); let cp = native_key.commitment_parameters(); let fp = native_key.fri_parameters(); @@ -93,17 +98,12 @@ pub(super) fn run( let layout_path = dir.join(FIXED_ROOT_LAYOUT); let start = Instant::now(); let prepared = if layout_path.exists() { - let fixed = - super::stages::FixedRoot::from_json(&fs::read_to_string(&layout_path)?)?; + let fixed = FixedRoot::from_json(&fs::read_to_string(&layout_path)?)?; fs::write(out.join(FIXED_ROOT_LAYOUT), fixed.to_json())?; fs::write(out.join("plan-id.bin"), fixed.identity(&native_key))?; fixed.prepare(&native_key, &proof, &claims[0], VerifierLimits::default())? } else { - let plan = super::stages::root::RootPlan::new( - &verifier_key, - &proof, - VerifierLimits::default(), - )?; + let plan = RootPlan::new(&verifier_key, &proof, VerifierLimits::default())?; let compact = std::env::var_os("IX_ROOT_GENERIC_HASHES").is_none(); let options = ImplementationOptions { compact_blake3: compact }; fs::write(out.join("plan-id.bin"), plan.identity()?)?; @@ -173,9 +173,7 @@ pub(super) fn run( fs::write(out.join("outer-vk.bin"), verifier.key.to_bytes()?)?; fs::write( out.join("outer-claims.bin"), - super::stages::statement::encode_claims( - &verifier.statement.claims(&public)?, - ), + encode_claims(&verifier.statement.claims(&public)?), )?; fs::write( out.join("statement-values.bin"), @@ -183,3 +181,221 @@ pub(super) fn run( )?; Ok(()) } + +/// Prepare every trace shard under the trusted Aiur policy. Claim values +/// remain public; activation, heights and memory-closure messages fix the +/// circuit's shape. +pub fn prepare_root( + key: &AiurVerifyingKey, + proof: &AiurProof, + expected: &[Val], + limits: VerifierLimits, +) -> Result> { + key.verify(expected, proof).map_err(|e| format!("aggregate root: {e:?}"))?; + let verifier = VerifierKey::from_system(key.system()); + RootPlan::new(&verifier, proof, limits)?.prepare( + proof, + expected, + Default::default(), + ) +} + +pub(in crate::pipeline) struct RootPlan<'a> { + key: &'a VerifierKey, + plans: Vec>, + schemas: Vec>, +} + +impl<'a> RootPlan<'a> { + pub(in crate::pipeline) fn new( + key: &'a VerifierKey, + proof: &AiurProof, + limits: VerifierLimits, + ) -> Result { + if proof.preamble.headers.is_empty() { + return Err("empty aggregate batch".into()); + } + let mut plans = Vec::new(); + let mut schemas = Vec::new(); + let mut proof_values = 0usize; + for header in &proof.preamble.headers { + let plan = VerifierPlan::validate( + key, + ProofProfile { + envelope: Envelope::SingleBatch, + active: header.active.clone(), + log_degrees: header.log_degrees.clone(), + claim_lengths: header.claims.iter().map(Vec::len).collect(), + message_lengths: proof + .preamble + .messages + .iter() + .map(|m| m.args.len()) + .collect(), + max_field_retries: DEFAULT_FIELD_RETRIES, + }, + limits, + )?; + proof_values = proof_values + .checked_add(plan.resources().proof_values) + .ok_or("batch proof size overflow")?; + if proof_values > limits.max_proof_values { + return Err("batch exceeds proof value limit".into()); + } + schemas.push(Statement { + claims: header + .claims + .iter() + .map(|c| vec![StatementSlot::Public; c.len()]) + .collect(), + messages: proof + .preamble + .messages + .iter() + .map(|m| Message { + args: m.args.iter().copied().map(StatementSlot::Constant).collect(), + multiplicity: StatementSlot::Constant(m.multiplicity), + }) + .collect(), + }); + plans.push(plan); + } + Ok(Self { key, plans, schemas }) + } + + pub(in crate::pipeline) fn identity(&self) -> Result<[u8; 32]> { + let ids = self + .plans + .iter() + .zip(&self.schemas) + .map(|(p, s)| p.identity(s)) + .collect::, _>>()?; + if let [id] = ids.as_slice() { + return Ok(*id); + } + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/aggregate-batch-plan/v1"); + for id in ids { + hash.update(&id); + } + Ok(*hash.finalize().as_bytes()) + } + + pub(in crate::pipeline) fn build_identity( + &self, + options: ImplementationOptions, + ) -> Result<[u8; 32]> { + let ids = self + .plans + .iter() + .zip(&self.schemas) + .map(|(p, s)| { + p.build_identity( + s, + options, + concat!("ix-proof-compression/", env!("CARGO_PKG_VERSION")), + "plonkish-multi-stark/v1", + "max-height=16777216;lookup-group=3", + ) + }) + .collect::, _>>()?; + if let [id] = ids.as_slice() { + return Ok(*id); + } + let mut hash = blake3::Hasher::new(); + hash.update(b"ix/aggregate-batch-build/v1"); + for id in ids { + hash.update(&id); + } + Ok(*hash.finalize().as_bytes()) + } + + pub(in crate::pipeline) fn description(&self) -> String { + format!( + "Profiles: {:?}\nSchemas: {:?}\n", + self.plans.iter().map(VerifierPlan::profile).collect::>(), + self.schemas + ) + } + + pub(in crate::pipeline) fn prepare( + &self, + proof: &AiurProof, + expected: &[Val], + options: ImplementationOptions, + ) -> Result> { + let shapes: Vec<_> = self.plans.iter().map(|p| p.shape().clone()).collect(); + let expanded = tracing::info_span!("compression/expand_witness") + .in_scope(|| expand_batch_witness(self.key.system(), &shapes, proof))?; + let build_span = tracing::info_span!("compression/build_circuit").entered(); + let mut builder = CircuitBuilder::new(); + if options.compact_blake3 { + builder.enable_compact_blake3(); + } + let public: Vec<_> = (0..expected.len()) + .map(|i| builder.public_input(format!("claim[0][{i}]"))) + .collect(); + let claims = self + .schemas + .iter() + .map(|s| { + s.claims + .iter() + .map(|claim| { + if claim.len() != public.len() { + return Err("root claim dimensions differ"); + } + Ok(public.clone()) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + // Constant binding precedes the byte gadgets in the production layout. + let messages = proof + .preamble + .messages + .iter() + .map(|m| Message { + args: m.args.iter().map(|&v| builder.constant(v)).collect(), + multiplicity: builder.constant(m.multiplicity), + }) + .collect::>(); + let bytes = ByteGadgets::new(&mut builder); + let inputs = constrain_batch_verifier( + &mut builder, + &bytes, + self.key.system(), + &shapes, + claims, + &messages, + ); + let circuit = builder.finish(); + drop(build_span); + let _witness_span = tracing::info_span!("compression/witness").entered(); + let mut witness = circuit.witness(); + for (&wire, &value) in public.iter().zip(expected) { + witness.set(wire, value)?; + } + for (expanded, inputs) in expanded.iter().zip(&inputs) { + expanded.assign_proof(&mut witness, inputs)?; + } + let assignment = witness.generate()?; + for (expanded, inputs) in expanded.iter().zip(&inputs) { + for (bits, &expected) in + inputs.query_bits.iter().zip(&expanded.query_indices) + { + let actual: u64 = bits + .iter() + .enumerate() + .map(|(i, b)| { + assignment.value(b.value()).unwrap().as_canonical_u64() << i + }) + .sum(); + if actual != expected as u64 { + return Err("query index differs from native verification".into()); + } + } + } + Ok(Prepared { circuit, assignment }) + } +} diff --git a/crates/proof-compression/src/pipeline/stages/fixed.rs b/crates/proof-compression/src/pipeline/root/fixed.rs similarity index 98% rename from crates/proof-compression/src/pipeline/stages/fixed.rs rename to crates/proof-compression/src/pipeline/root/fixed.rs index ae6620fea..1aa565a9e 100644 --- a/crates/proof-compression/src/pipeline/stages/fixed.rs +++ b/crates/proof-compression/src/pipeline/root/fixed.rs @@ -311,8 +311,8 @@ impl FixedRoot { #[cfg(test)] mod tests { - use super::super::setup; use super::*; + use crate::pipeline::FriVerifier; use multi_stark::{ batch::ShardInput, expr::Expr, @@ -375,8 +375,11 @@ mod tests { let other = FixedRoot::new(vec![4, 2, 8, 16], vec![(1, 1)], 5)?; assert_ne!(fixed.identity(&key), other.identity(&key)); let config = || GoldilocksBlake3Config::new(cp, fp); - let local = - setup::fri(fixed.circuit(&key, Default::default())?, config(), 1 << 16)?; + let local = FriVerifier::derive( + fixed.circuit(&key, Default::default())?, + config(), + 1 << 16, + )?; for value in [3, 7] { let input = vec![Val::from_u32(value), Val::TWO]; let mut io = @@ -451,8 +454,11 @@ mod tests { ) }; // Generated before, and independently of, every certificate and witness. - let local = - setup::fri(fixed.circuit(&key, Default::default())?, config(), 1 << 16)?; + let local = FriVerifier::derive( + fixed.circuit(&key, Default::default())?, + config(), + 1 << 16, + )?; for (small_end, large_end) in [(0, 0), (2, 8), (3, 1), (1, 9)] { let expected = vec![Val::from_u32(17 + small_end), Val::from_u32(31 + large_end)]; diff --git a/crates/proof-compression/src/pipeline/setup.rs b/crates/proof-compression/src/pipeline/setup.rs index 076c5ffae..cf6c997df 100644 --- a/crates/proof-compression/src/pipeline/setup.rs +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -360,13 +360,12 @@ impl SetupSource { #[cfg(test)] mod tests { use super::*; + use crate::test_support::TempDir; #[test] fn setup_binding_preserves_public_identity_and_rejects_mismatch() -> storage::Result<()> { - let dir = std::env::temp_dir() - .join(format!("ix-setup-binding-{}", std::process::id())); - fs::create_dir(&dir)?; + let dir = TempDir::new("setup-binding"); let setup = SetupSource::development(b"setup-binding", 63)?; let prover = setup.srs(64, false)?; let verifier = setup.srs(64, true)?; @@ -402,15 +401,12 @@ mod tests { .is_err() ); assert!(setup.config(128, 2, true).is_err()); - fs::remove_dir_all(dir)?; Ok(()) } #[test] fn cache_directory_yields_the_generated_parameters() -> storage::Result<()> { - let dir = std::env::temp_dir() - .join(format!("ix-setup-cache-dir-{}", std::process::id())); - fs::create_dir(&dir)?; + let dir = TempDir::new("setup-cache-dir"); let generated = SetupSource::development(b"setup-cache", 63)?; let directory = SetupSource::development(b"setup-cache", 63)? .with_cache_directory(dir.join("shared")); @@ -425,18 +421,6 @@ mod tests { generated.config(64, 2, false)?.transcript_seed(), directory.config(64, 2, false)?.transcript_seed() ); - fs::remove_dir_all(dir)?; - Ok(()) - } - - #[test] - fn development_flag_selects_development_parameters() -> storage::Result<()> { - let config = SetupConfig::from_env(true)?; - assert_eq!(config.class(), SetupClass::Development); - assert_eq!( - config.for_stage(FIRST_SEED, 64)?.identity(64, 2)?, - SetupSource::development(FIRST_SEED, 63)?.identity(64, 2)? - ); Ok(()) } diff --git a/crates/proof-compression/src/pipeline/stages/mod.rs b/crates/proof-compression/src/pipeline/stage.rs similarity index 54% rename from crates/proof-compression/src/pipeline/stages/mod.rs rename to crates/proof-compression/src/pipeline/stage.rs index 95a90ec2f..f30756811 100644 --- a/crates/proof-compression/src/pipeline/stages/mod.rs +++ b/crates/proof-compression/src/pipeline/stage.rs @@ -1,43 +1,42 @@ -//! Compression stages for aggregated Ix certificates. +//! The FRI and KZG compression stages of aggregated Ix certificates: a +//! prepared circuit is proved into a stage proof and the trusted verifier it +//! checks under, or that verifier is derived from the circuit alone, without +//! a proof or witness. //! //! The caller supplies the trusted aggregate key, expected outer claim and //! proving parameters. No certificate, filesystem layout or development SRS //! is built into these stages. Verifiers returned by local construction must //! be authenticated before use elsewhere; a prover-supplied key is not trusted. -mod codec; -pub(in crate::pipeline) mod construct; -pub(in crate::pipeline) mod statement; -use statement::Statement; -mod fixed; -pub(in crate::pipeline) mod root; -pub(in crate::pipeline) mod setup; +//! Verifier transport carries no prover data, and its bytes must likewise be +//! authenticated by the caller: decoding a key supplied alongside a proof does +//! not establish trust. #[cfg(test)] mod tests; -pub(in crate::pipeline) mod verification; -mod wrap; -pub use verification::RecursiveVerifier; - -pub use fixed::FixedRoot; -pub use root::prepare_root; -pub use wrap::{RecursiveProof, RecursiveStage, prepare_recursive}; use crate::fri::{self, VerifierKey}; -use crate::kzg::{KzgConfig, Scalar, Srs, VerifierParams}; +use crate::kzg::{ + KzgCommitment, KzgConfig, Scalar, Srs, TranscriptKind, VerifierParams, +}; use multi_stark::plonkish::{Assignment, Circuit}; use multi_stark::prover::Proof; use multi_stark::system::{System, SystemWitness}; #[cfg(test)] use multi_stark::traits::Algebra; -use multi_stark::traits::Field; +use multi_stark::traits::{Field, TwoAdicField}; use multi_stark::types::{GoldilocksBlake3Config, Val}; use p3_field::PrimeField64; +use serde::{Serialize, de::DeserializeOwned}; use std::sync::Arc; use super::{ Result, + claims::ClaimTemplate, + lowering, prover::{self, validate_config}, }; +const KZG_VERIFIER_MAGIC: &[u8; 8] = b"IXBNKV02"; + /// A checked circuit and its witness, ready for lowering and proving. /// Keeping preparation separate lets callers inspect costs and choose an SRS /// and partition height before allocating prover data. @@ -59,7 +58,7 @@ impl Prepared { /// Trusted verifier for the first, FRI-compressed stage. pub struct FriVerifier { pub(in crate::pipeline) key: VerifierKey, - pub(in crate::pipeline) statement: Statement, + pub(in crate::pipeline) statement: ClaimTemplate, } impl FriVerifier { @@ -96,14 +95,14 @@ impl Prepared { }, config.fri_parameters(), )?; - let compiled = construct::lower_fri(self.circuit, max_height)?; - let statement = Statement::from_lowered( + let compiled = lowering::lower_fri(self.circuit, max_height)?; + let statement = ClaimTemplate::from_lowered( &compiled, self.assignment.public_values().len(), )?; let claims = statement.claims(self.assignment.public_values())?; let trace_span = tracing::info_span!("compression/trace").entered(); - let definitions = construct::fri_definitions(&compiled); + let definitions = lowering::fri_definitions(&compiled); validate_config(&config, &definitions)?; let traces = compiled.traces(&self.assignment)?; drop(trace_span); @@ -138,19 +137,19 @@ pub fn prepare_kzg( ) -> Result> { verifier.verify(proof, expected)?; let claims = verifier.statement.claims(expected)?; - let profile = construct::fri_profile(&verifier.key, &claims)?; + let profile = lowering::fri_profile(&verifier.key, &claims)?; if proof.active != profile.active || proof.log_degrees != profile.log_degrees { return Err("compressed proof profile differs from the key".into()); } let plan = fri::VerifierPlan::validate(&verifier.key, profile, limits)?; let source = - construct::FriConstruction::build(&plan, verifier.statement.schema())? + lowering::FriConstruction::build(&plan, verifier.statement.schema())? .assign(&plan, proof, claims)?; - let foreign = construct::translate(&source.circuit); + let foreign = lowering::translate(&source.circuit); drop(source.circuit); drop(plan); - let assignment = construct::assign_foreign( + let assignment = lowering::assign_foreign( &foreign.inputs, &foreign.circuit, &source.assignment, @@ -163,16 +162,16 @@ pub fn prepare_kzg( /// Trusted verifier for a scalar-field compression stage. It retains only /// verification parameters, never the SRS proving powers or fixed matrices. pub struct KzgVerifier { - system: System, - params: VerifierParams, - statement: Statement, - codec: crate::kzg::compact::FixedProofCodec, + pub(in crate::pipeline) system: System, + pub(in crate::pipeline) params: VerifierParams, + pub(in crate::pipeline) statement: ClaimTemplate, + pub(in crate::pipeline) codec: crate::kzg::compact::FixedProofCodec, } impl KzgVerifier { pub(in crate::pipeline) fn new( system: System, - statement: Statement, + statement: ClaimTemplate, ) -> Result { use multi_stark::config::ProofConfig; let params = system.config.pcs().verifier_params().clone(); @@ -259,14 +258,11 @@ impl Prepared { "partition height must be a power of two within the SRS range".into(), ); } - if max_height.ilog2() as usize + 1 - > ::TWO_ADICITY - { + if max_height.ilog2() as usize + 1 > Scalar::TWO_ADICITY { return Err("quotient domain exceeds scalar field two-adicity".into()); } - let compiled = - construct::lower_first(self.circuit, max_height, max_height)?; - let height = construct::max_height(&compiled)?; + let compiled = lowering::lower_first(self.circuit, max_height, max_height)?; + let height = lowering::max_height(&compiled)?; if height > srs.max_len() { return Err("compression trace exceeds the SRS range".into()); } @@ -274,7 +270,7 @@ impl Prepared { .with_streaming_lookups() .with_streaming_quotient(); prove_lowered( - construct::Lowered { compiled, assignment: self.assignment }, + lowering::Lowered { compiled, assignment: self.assignment }, config, ) } @@ -284,12 +280,12 @@ pub(in crate::pipeline) fn scalar_statement(expected: &[Val]) -> Vec { expected.iter().map(|v| Scalar::from_u64(v.as_canonical_u64())).collect() } -fn prove_lowered( - lowered: construct::Lowered, +pub(in crate::pipeline) fn prove_lowered( + lowered: lowering::Lowered, config: KzgConfig, ) -> Result<(KzgVerifier, Proof)> { use multi_stark::config::ProofConfig; - let statement = Statement::from_lowered( + let statement = ClaimTemplate::from_lowered( &lowered.compiled, lowered.assignment.public_values().len(), )?; @@ -302,3 +298,157 @@ fn prove_lowered( KzgConfig::verifier_only(params, 2).with_transcript(transcript); Ok((KzgVerifier::new(system, statement)?, proof)) } + +pub(in crate::pipeline) fn encode_key( + tag: &[u8; 8], + value: impl Serialize, +) -> Result> { + let mut bytes = tag.to_vec(); + bytes + .extend(bincode::serde::encode_to_vec(value, bincode::config::standard())?); + Ok(bytes) +} + +pub(in crate::pipeline) fn decode_key( + tag: &[u8; 8], + bytes: &[u8], +) -> Result { + if !bytes.starts_with(tag) || bytes.len() > 64 * 1024 * 1024 { + return Err("invalid compression key tag or size".into()); + } + let (value, used): (T, usize) = bincode::serde::decode_from_slice( + &bytes[8..], + bincode::config::standard().with_limit::<67108864>(), + )?; + if used != bytes.len() - 8 || encode_key(tag, &value)? != bytes { + return Err("noncanonical compression key".into()); + } + Ok(value) +} + +impl FriVerifier { + /// The verifier for `circuit`, with the commitments `Prepared::prove_fri` + /// derives, built without a witness. + pub(in crate::pipeline) fn derive( + circuit: Circuit, + config: GoldilocksBlake3Config, + height: usize, + ) -> Result { + use multi_stark::config::StarkGenericConfig; + fri::validate_parameters( + multi_stark::types::CommitmentParameters { + log_blowup: config.log_blowup(), + cap_height: config.cap_height(), + }, + config.fri_parameters(), + )?; + let public_count = circuit.public_values().len(); + let compiled = lowering::lower_fri(circuit, height)?; + let statement = ClaimTemplate::from_lowered(&compiled, public_count)?; + let definitions = lowering::fri_definitions(&compiled); + drop(compiled); + validate_config(&config, &definitions)?; + let (system, _) = System::new_without_preprocessed(config, definitions); + Ok(Self { key: VerifierKey::new(system), statement }) + } + + /// Encode the verifier, including its public statement schema. No prover + /// matrices are included. Authenticate the complete encoding on transport. + pub fn to_bytes(&self) -> Result> { + encode_key(b"IXFRIV01", (self.key.to_bytes()?, &self.statement)) + } + + /// Decode an independently trusted verifier with a 64 MiB decoding budget. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (key, statement): (Vec, ClaimTemplate) = + decode_key(b"IXFRIV01", bytes)?; + statement.validate()?; + Ok(Self { key: VerifierKey::from_bytes(&key)?, statement }) + } +} + +impl KzgVerifier { + /// The verifier `Prepared::prove_kzg` returns for `circuit`, built without + /// a witness. + #[cfg(test)] + pub(in crate::pipeline) fn derive( + circuit: Circuit, + srs: Arc, + height: usize, + ) -> Result { + use multi_stark::config::ProofConfig; + if height < 2 || !height.is_power_of_two() || height > srs.max_len() { + return Err( + "partition height must be a power of two within the SRS range".into(), + ); + } + if height.ilog2() as usize + 1 > Scalar::TWO_ADICITY { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } + let public_count = circuit.public_values().len(); + let compiled = lowering::lower_first(circuit, height, height)?; + let height = lowering::max_height(&compiled)?; + if height > srs.max_len() { + return Err("compression trace exceeds the SRS range".into()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_streaming_lookups() + .with_streaming_quotient(); + let statement = ClaimTemplate::from_lowered(&compiled, public_count)?; + let params = config.pcs().verifier_params().clone(); + let transcript = config.transcript_kind(); + let mut system = prover::setup(config, &compiled)?; + drop(compiled); + system.config = + KzgConfig::verifier_only(params, 2).with_transcript(transcript); + Self::new(system, statement) + } + + /// Encode verifier parameters, compiled constraints, fixed commitments and + /// the public statement schema. Proving powers and matrices are excluded. + pub fn to_bytes(&self) -> Result> { + encode_key( + KZG_VERIFIER_MAGIC, + ( + self.params.to_bytes()?, + match self.system.config.transcript_kind() { + TranscriptKind::Blake3 => 0u8, + TranscriptKind::Keccak256 => 1u8, + }, + &self.system.circuits, + &self.system.preprocessed_commit, + &self.statement, + ), + ) + } + + /// Decode an independently trusted verifier with a 64 MiB decoding budget. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (params, transcript, circuits, preprocessing, statement): ( + Vec, + u8, + Vec>, + Option, + ClaimTemplate, + ) = decode_key(KZG_VERIFIER_MAGIC, bytes)?; + let params = VerifierParams::from_bytes(¶ms)?; + if params.max_trace_len().ilog2() as usize + 1 > Scalar::TWO_ADICITY { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } + let transcript = match transcript { + 0 => TranscriptKind::Blake3, + 1 => TranscriptKind::Keccak256, + _ => return Err("unsupported KZG transcript".into()), + }; + let indices = (0..circuits.len()).map(Some).collect(); + Self::new( + System { + config: KzgConfig::verifier_only(params, 2).with_transcript(transcript), + circuits, + preprocessed_commit: preprocessing, + preprocessed_indices: indices, + }, + statement, + ) + } +} diff --git a/crates/proof-compression/src/pipeline/stages/tests.rs b/crates/proof-compression/src/pipeline/stage/tests.rs similarity index 77% rename from crates/proof-compression/src/pipeline/stages/tests.rs rename to crates/proof-compression/src/pipeline/stage/tests.rs index 2c5fccc12..21c6f8554 100644 --- a/crates/proof-compression/src/pipeline/stages/tests.rs +++ b/crates/proof-compression/src/pipeline/stage/tests.rs @@ -1,24 +1,32 @@ use super::*; +use crate::pipeline::prepare_recursive; use multi_stark::plonkish::CircuitBuilder; use multi_stark::types::{CommitmentParameters, FriParameters}; +fn fri_config( + cap_height: usize, + max_log_arity: usize, + log_final_poly_len: usize, + num_queries: usize, +) -> GoldilocksBlake3Config { + GoldilocksBlake3Config::new( + CommitmentParameters { log_blowup: 2, cap_height }, + FriParameters { + log_final_poly_len, + max_log_arity, + num_queries, + commit_proof_of_work_bits: 0, + query_proof_of_work_bits: 0, + }, + ) +} + #[test] fn unsupported_fri_profiles_fail_before_proving() { for (cap, arity, final_log, queries) in [(1, 1, 0, 2), (0, 2, 0, 2), (0, 1, 1, 2), (0, 1, 0, 0)] { - let config = || { - GoldilocksBlake3Config::new( - CommitmentParameters { log_blowup: 2, cap_height: cap }, - FriParameters { - log_final_poly_len: final_log, - max_log_arity: arity, - num_queries: queries, - commit_proof_of_work_bits: 0, - query_proof_of_work_bits: 0, - }, - ) - }; + let config = || fri_config(cap, arity, final_log, queries); if cfg!(feature = "cuda") && (cap != 0 || arity > 1) { assert!(std::panic::catch_unwind(config).is_err()); } else { @@ -48,20 +56,12 @@ fn fri_stage_preserves_publics_through_foreign_translation() -> Result<()> { for value in [Val::ONE, Val::TWO] { let expected = [value, value * value]; let prepared = arithmetic(value, 2); - let config = || { - GoldilocksBlake3Config::new( - CommitmentParameters { log_blowup: 2, cap_height: 0 }, - FriParameters { - log_final_poly_len: 0, - max_log_arity: 1, - num_queries: 2, - commit_proof_of_work_bits: 0, - query_proof_of_work_bits: 0, - }, - ) - }; - let local = setup::fri(arithmetic(value, 2).circuit, config(), 32)?; - let (verifier, proof) = prepared.prove_fri(config(), 32)?; + let local = FriVerifier::derive( + arithmetic(value, 2).circuit, + fri_config(0, 1, 0, 2), + 32, + )?; + let (verifier, proof) = prepared.prove_fri(fri_config(0, 1, 0, 2), 32)?; assert_eq!(local.to_bytes()?, verifier.to_bytes()?); let key_bytes = verifier.to_bytes()?; let verifier = FriVerifier::from_bytes(&key_bytes)?; @@ -99,28 +99,16 @@ fn fri_stage_preserves_publics_through_foreign_translation() -> Result<()> { #[test] fn fri_memory_reduction_preserves_the_dense_proof() -> Result<()> { - let config = || { - GoldilocksBlake3Config::new( - CommitmentParameters { log_blowup: 2, cap_height: 0 }, - FriParameters { - log_final_poly_len: 0, - max_log_arity: 1, - num_queries: 2, - commit_proof_of_work_bits: 0, - query_proof_of_work_bits: 0, - }, - ) - }; // Include multiple partitions and a lookup table. The reference uses dense // lowering and keeps the fixed evaluations through proving. let prepared = arithmetic(Val::TWO, 50); let compiled = prepared.circuit.lower_to_multi_stark_with_max_height( - Val::from_u8(construct::FRI_NAMESPACE), + Val::from_u8(lowering::FRI_NAMESPACE), 32, )?; let claims = compiled.claims(prepared.assignment.public_values())?; let (system, key) = - System::new(config(), construct::fri_definitions(&compiled)); + System::new(fri_config(0, 1, 0, 2), lowering::fri_definitions(&compiled)); let reference = system.prove_multiple_claims( &key, &claims.iter().map(Vec::as_slice).collect::>(), @@ -129,7 +117,8 @@ fn fri_memory_reduction_preserves_the_dense_proof() -> Result<()> { &system, ), ); - let (verifier, proof) = arithmetic(Val::TWO, 50).prove_fri(config(), 32)?; + let (verifier, proof) = + arithmetic(Val::TWO, 50).prove_fri(fri_config(0, 1, 0, 2), 32)?; assert_eq!(proof.to_bytes()?, reference.to_bytes()?); assert!( verifier.key.system().circuits.iter().all(|c| c.preprocessed.is_none()) @@ -146,16 +135,17 @@ fn kzg_stages_derive_claims_for_different_layouts_and_parameters() -> Result<()> let expected = [value, value * value]; let srs = Arc::new(Srs::unsafe_dev_setup(height, &height.to_le_bytes())); srs.validate()?; - let local = setup::kzg(arithmetic(value, 50).circuit, srs.clone(), height)?; + let local = + KzgVerifier::derive(arithmetic(value, 50).circuit, srs.clone(), height)?; let (verifier, proof) = stage.prove_kzg(srs.clone(), height)?; assert_eq!(local.to_bytes()?, verifier.to_bytes()?); drop(local); let key_bytes = verifier.to_bytes()?; let verifier = KzgVerifier::from_bytes(&key_bytes)?; assert_eq!(verifier.to_bytes()?, key_bytes); - let mut other_curve = key_bytes.clone(); - other_curve[..8].copy_from_slice(b"IXKZGV02"); - assert!(KzgVerifier::from_bytes(&other_curve).is_err()); + let mut wrong_magic = key_bytes.clone(); + wrong_magic[7] ^= 1; + assert!(KzgVerifier::from_bytes(&wrong_magic).is_err()); assert!( KzgVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]).is_err() ); @@ -221,8 +211,8 @@ fn kzg_table_merging_respects_the_callers_height_bound() -> Result<()> { let srs = Arc::new(Srs::unsafe_dev_setup(256, b"bounded-table-merge")); assert!(tables().prove_kzg(srs.clone(), 64).is_err()); - assert!(setup::kzg(tables().circuit, srs.clone(), 64).is_err()); - let local = setup::kzg(tables().circuit, srs.clone(), 128)?; + assert!(KzgVerifier::derive(tables().circuit, srs.clone(), 64).is_err()); + let local = KzgVerifier::derive(tables().circuit, srs.clone(), 128)?; let (verifier, proof) = tables().prove_kzg(srs, 128)?; assert_eq!(local.to_bytes()?, verifier.to_bytes()?); assert!( @@ -231,3 +221,24 @@ fn kzg_table_merging_respects_the_callers_height_bound() -> Result<()> { verifier.verify(&proof, &[Scalar::from_u8(7)])?; Ok(()) } + +#[test] +fn verifier_decoder_rejects_trace_cap_without_quotient_headroom() -> Result<()> +{ + let cap = 1usize << Scalar::TWO_ADICITY; + let anchors = Srs::unsafe_dev_setup_prefix(2, cap - 1, b"domain-cap"); + let params = VerifierParams::with_max_trace_len(&anchors, cap); + let encoded = encode_key( + KZG_VERIFIER_MAGIC, + ( + params.to_bytes()?, + 0u8, + Vec::>::new(), + Option::::None, + ClaimTemplate::recursive(0), + ), + )?; + let error = KzgVerifier::from_bytes(&encoded).err().unwrap(); + assert!(error.to_string().contains("quotient domain")); + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/stages/codec.rs b/crates/proof-compression/src/pipeline/stages/codec.rs deleted file mode 100644 index aa8695b63..000000000 --- a/crates/proof-compression/src/pipeline/stages/codec.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Verifier-only transport. These bytes must be authenticated by the caller; -//! decoding a key supplied alongside a proof does not establish trust. -use super::{FriVerifier, KzgVerifier, Result, Statement}; -use crate::fri::VerifierKey; -use crate::kzg::{ - KzgCommitment, KzgConfig, Scalar, TranscriptKind, VerifierParams, -}; -use multi_stark::system::{Circuit, System}; -use multi_stark::traits::TwoAdicField; -use multi_stark::types::Val; -use serde::{Serialize, de::DeserializeOwned}; - -const KZG_VERIFIER_MAGIC: &[u8; 8] = b"IXBNKV02"; - -pub(super) fn encode(tag: &[u8; 8], value: impl Serialize) -> Result> { - let mut bytes = tag.to_vec(); - bytes - .extend(bincode::serde::encode_to_vec(value, bincode::config::standard())?); - Ok(bytes) -} - -pub(super) fn decode( - tag: &[u8; 8], - bytes: &[u8], -) -> Result { - if !bytes.starts_with(tag) || bytes.len() > 64 * 1024 * 1024 { - return Err("invalid compression key tag or size".into()); - } - let (value, used): (T, usize) = bincode::serde::decode_from_slice( - &bytes[8..], - bincode::config::standard().with_limit::<67108864>(), - )?; - if used != bytes.len() - 8 || encode(tag, &value)? != bytes { - return Err("noncanonical compression key".into()); - } - Ok(value) -} - -impl FriVerifier { - /// Encode the verifier, including its public statement schema. No prover - /// matrices are included. Authenticate the complete encoding on transport. - pub fn to_bytes(&self) -> Result> { - encode(b"IXFRIV01", (self.key.to_bytes()?, &self.statement)) - } - - /// Decode an independently trusted verifier with a 64 MiB decoding budget. - pub fn from_bytes(bytes: &[u8]) -> Result { - let (key, statement): (Vec, Statement) = - decode(b"IXFRIV01", bytes)?; - statement.validate()?; - Ok(Self { key: VerifierKey::from_bytes(&key)?, statement }) - } -} - -impl KzgVerifier { - /// Encode verifier parameters, compiled constraints, fixed commitments and - /// the public statement schema. Proving powers and matrices are excluded. - pub fn to_bytes(&self) -> Result> { - encode( - KZG_VERIFIER_MAGIC, - ( - self.params.to_bytes()?, - match self.system.config.transcript_kind() { - TranscriptKind::Blake3 => 0u8, - TranscriptKind::Keccak256 => 1u8, - }, - &self.system.circuits, - &self.system.preprocessed_commit, - &self.statement, - ), - ) - } - - /// Decode an independently trusted verifier with a 64 MiB decoding budget. - pub fn from_bytes(bytes: &[u8]) -> Result { - let (params, transcript, circuits, preprocessing, statement): ( - Vec, - u8, - Vec>, - Option, - Statement, - ) = decode(KZG_VERIFIER_MAGIC, bytes)?; - let params = VerifierParams::from_bytes(¶ms)?; - if params.max_trace_len().ilog2() as usize + 1 > Scalar::TWO_ADICITY { - return Err("quotient domain exceeds scalar field two-adicity".into()); - } - let transcript = match transcript { - 0 => TranscriptKind::Blake3, - 1 => TranscriptKind::Keccak256, - _ => return Err("unsupported KZG transcript".into()), - }; - let indices = (0..circuits.len()).map(Some).collect(); - Self::new( - System { - config: KzgConfig::verifier_only(params, 2).with_transcript(transcript), - circuits, - preprocessed_commit: preprocessing, - preprocessed_indices: indices, - }, - statement, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::kzg::Srs; - - #[test] - fn verifier_decoder_rejects_trace_cap_without_quotient_headroom() -> Result<()> - { - let cap = 1usize << Scalar::TWO_ADICITY; - let anchors = Srs::unsafe_dev_setup_prefix(2, cap - 1, b"domain-cap"); - let params = VerifierParams::with_max_trace_len(&anchors, cap); - let encoded = encode( - KZG_VERIFIER_MAGIC, - ( - params.to_bytes()?, - 0u8, - Vec::>::new(), - Option::::None, - Statement::recursive(0), - ), - )?; - let error = KzgVerifier::from_bytes(&encoded).err().unwrap(); - assert!(error.to_string().contains("quotient domain")); - Ok(()) - } -} diff --git a/crates/proof-compression/src/pipeline/stages/root.rs b/crates/proof-compression/src/pipeline/stages/root.rs deleted file mode 100644 index 7b10446a4..000000000 --- a/crates/proof-compression/src/pipeline/stages/root.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! Aggregate-root construction shared by file and in-memory callers. -use super::{Prepared, Result}; -use crate::fri::*; -use aiur::{synthesis::AiurProof, vk_codec::AiurVerifyingKey}; -use multi_stark::plonkish::CircuitBuilder; -use multi_stark::types::Val; -use p3_field::PrimeField64; - -/// Prepare every trace shard under the trusted Aiur policy. Claim values -/// remain public; activation, heights and memory-closure messages fix the -/// circuit's shape. -pub fn prepare_root( - key: &AiurVerifyingKey, - proof: &AiurProof, - expected: &[Val], - limits: VerifierLimits, -) -> Result> { - key.verify(expected, proof).map_err(|e| format!("aggregate root: {e:?}"))?; - let verifier = VerifierKey::from_system(key.system()); - RootPlan::new(&verifier, proof, limits)?.prepare( - proof, - expected, - Default::default(), - ) -} - -pub(in crate::pipeline) struct RootPlan<'a> { - key: &'a VerifierKey, - plans: Vec>, - schemas: Vec>, -} - -impl<'a> RootPlan<'a> { - pub(in crate::pipeline) fn new( - key: &'a VerifierKey, - proof: &AiurProof, - limits: VerifierLimits, - ) -> Result { - if proof.preamble.headers.is_empty() { - return Err("empty aggregate batch".into()); - } - let mut plans = Vec::new(); - let mut schemas = Vec::new(); - let mut proof_values = 0usize; - for header in &proof.preamble.headers { - let plan = VerifierPlan::validate( - key, - ProofProfile { - envelope: Envelope::SingleBatch, - active: header.active.clone(), - log_degrees: header.log_degrees.clone(), - claim_lengths: header.claims.iter().map(Vec::len).collect(), - message_lengths: proof - .preamble - .messages - .iter() - .map(|m| m.args.len()) - .collect(), - max_field_retries: DEFAULT_FIELD_RETRIES, - }, - limits, - )?; - proof_values = proof_values - .checked_add(plan.resources().proof_values) - .ok_or("batch proof size overflow")?; - if proof_values > limits.max_proof_values { - return Err("batch exceeds proof value limit".into()); - } - schemas.push(Statement { - claims: header - .claims - .iter() - .map(|c| vec![StatementSlot::Public; c.len()]) - .collect(), - messages: proof - .preamble - .messages - .iter() - .map(|m| Message { - args: m.args.iter().copied().map(StatementSlot::Constant).collect(), - multiplicity: StatementSlot::Constant(m.multiplicity), - }) - .collect(), - }); - plans.push(plan); - } - Ok(Self { key, plans, schemas }) - } - - pub(in crate::pipeline) fn identity(&self) -> Result<[u8; 32]> { - let ids = self - .plans - .iter() - .zip(&self.schemas) - .map(|(p, s)| p.identity(s)) - .collect::, _>>()?; - if let [id] = ids.as_slice() { - return Ok(*id); - } - let mut hash = blake3::Hasher::new(); - hash.update(b"ix/aggregate-batch-plan/v1"); - for id in ids { - hash.update(&id); - } - Ok(*hash.finalize().as_bytes()) - } - - pub(in crate::pipeline) fn build_identity( - &self, - options: ImplementationOptions, - ) -> Result<[u8; 32]> { - let ids = self - .plans - .iter() - .zip(&self.schemas) - .map(|(p, s)| { - p.build_identity( - s, - options, - concat!("ix-proof-compression/", env!("CARGO_PKG_VERSION")), - "plonkish-multi-stark/v1", - "max-height=16777216;lookup-group=3", - ) - }) - .collect::, _>>()?; - if let [id] = ids.as_slice() { - return Ok(*id); - } - let mut hash = blake3::Hasher::new(); - hash.update(b"ix/aggregate-batch-build/v1"); - for id in ids { - hash.update(&id); - } - Ok(*hash.finalize().as_bytes()) - } - - pub(in crate::pipeline) fn description(&self) -> String { - format!( - "Profiles: {:?}\nSchemas: {:?}\n", - self.plans.iter().map(VerifierPlan::profile).collect::>(), - self.schemas - ) - } - - pub(in crate::pipeline) fn prepare( - &self, - proof: &AiurProof, - expected: &[Val], - options: ImplementationOptions, - ) -> Result> { - let shapes: Vec<_> = self.plans.iter().map(|p| p.shape().clone()).collect(); - let expanded = tracing::info_span!("compression/expand_witness") - .in_scope(|| expand_batch_witness(self.key.system(), &shapes, proof))?; - let build_span = tracing::info_span!("compression/build_circuit").entered(); - let mut builder = CircuitBuilder::new(); - if options.compact_blake3 { - builder.enable_compact_blake3(); - } - let public: Vec<_> = (0..expected.len()) - .map(|i| builder.public_input(format!("claim[0][{i}]"))) - .collect(); - let claims = self - .schemas - .iter() - .map(|s| { - s.claims - .iter() - .map(|claim| { - if claim.len() != public.len() { - return Err("root claim dimensions differ"); - } - Ok(public.clone()) - }) - .collect::, _>>() - }) - .collect::, _>>()?; - // Constant binding precedes the byte gadgets in the production layout. - let messages = proof - .preamble - .messages - .iter() - .map(|m| Message { - args: m.args.iter().map(|&v| builder.constant(v)).collect(), - multiplicity: builder.constant(m.multiplicity), - }) - .collect::>(); - let bytes = ByteGadgets::new(&mut builder); - let inputs = constrain_batch_verifier( - &mut builder, - &bytes, - self.key.system(), - &shapes, - claims, - &messages, - ); - let circuit = builder.finish(); - drop(build_span); - let _witness_span = tracing::info_span!("compression/witness").entered(); - let mut witness = circuit.witness(); - for (&wire, &value) in public.iter().zip(expected) { - witness.set(wire, value)?; - } - for (expanded, inputs) in expanded.iter().zip(&inputs) { - expanded.assign_proof(&mut witness, inputs)?; - } - let assignment = witness.generate()?; - for (expanded, inputs) in expanded.iter().zip(&inputs) { - for (bits, &expected) in - inputs.query_bits.iter().zip(&expanded.query_indices) - { - let actual: u64 = bits - .iter() - .enumerate() - .map(|(i, b)| { - assignment.value(b.value()).unwrap().as_canonical_u64() << i - }) - .sum(); - if actual != expected as u64 { - return Err("query index differs from native verification".into()); - } - } - } - Ok(Prepared { circuit, assignment }) - } -} diff --git a/crates/proof-compression/src/pipeline/stages/setup.rs b/crates/proof-compression/src/pipeline/stages/setup.rs deleted file mode 100644 index ec7f46322..000000000 --- a/crates/proof-compression/src/pipeline/stages/setup.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Verifier construction without a proof or witness. -use super::*; -#[cfg(test)] -use multi_stark::traits::TwoAdicField; - -/// The FRI stage's verifier for `circuit`, with the commitments -/// `Prepared::prove_fri` derives, built without a witness. -pub(in crate::pipeline) fn fri( - circuit: Circuit, - config: GoldilocksBlake3Config, - height: usize, -) -> Result { - use multi_stark::config::StarkGenericConfig; - fri::validate_parameters( - multi_stark::types::CommitmentParameters { - log_blowup: config.log_blowup(), - cap_height: config.cap_height(), - }, - config.fri_parameters(), - )?; - let public_count = circuit.public_values().len(); - let compiled = construct::lower_fri(circuit, height)?; - let statement = Statement::from_lowered(&compiled, public_count)?; - let definitions = construct::fri_definitions(&compiled); - drop(compiled); - validate_config(&config, &definitions)?; - let (system, _) = System::new_without_preprocessed(config, definitions); - Ok(FriVerifier { key: VerifierKey::new(system), statement }) -} - -#[cfg(test)] -pub(super) fn kzg( - circuit: Circuit, - srs: Arc, - height: usize, -) -> Result { - if height < 2 || !height.is_power_of_two() || height > srs.max_len() { - return Err( - "partition height must be a power of two within the SRS range".into(), - ); - } - if height.ilog2() as usize + 1 > Scalar::TWO_ADICITY { - return Err("quotient domain exceeds scalar field two-adicity".into()); - } - let public_count = circuit.public_values().len(); - let compiled = construct::lower_first(circuit, height, height)?; - let height = construct::max_height(&compiled)?; - if height > srs.max_len() { - return Err("compression trace exceeds the SRS range".into()); - } - let config = KzgConfig::with_max_trace_len(srs, height, 2) - .with_streaming_lookups() - .with_streaming_quotient(); - kzg_lowered(compiled, config, public_count) -} - -#[cfg(test)] -fn kzg_lowered( - compiled: multi_stark::plonkish::MultiStarkCircuit, - config: KzgConfig, - public_count: usize, -) -> Result { - use multi_stark::config::ProofConfig; - let statement = Statement::from_lowered(&compiled, public_count)?; - let params = config.pcs().verifier_params().clone(); - let transcript = config.transcript_kind(); - let mut system = prover::setup(config, &compiled)?; - drop(compiled); - system.config = - KzgConfig::verifier_only(params, 2).with_transcript(transcript); - KzgVerifier::new(system, statement) -} diff --git a/crates/proof-compression/src/pipeline/stages/wrap.rs b/crates/proof-compression/src/pipeline/stages/wrap.rs deleted file mode 100644 index 08ac4e105..000000000 --- a/crates/proof-compression/src/pipeline/stages/wrap.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::verification::{Pairings, RecursiveVerifier}; -use super::{KzgVerifier, Prepared, Result, scalar_statement}; -use crate::kzg::{KzgConfig, Scalar, Srs}; -use crate::recursive::native_verifier; -use multi_stark::prover::Proof; -use multi_stark::types::Val; -use std::sync::Arc; - -/// Recursive proof and its public pairing operands. Certificate claim values -/// are supplied independently to RecursiveVerifier::verify. -pub struct RecursiveProof { - pub proof: Proof, - pub pairing_values: Vec, -} - -fn build_recursive( - verifier: &KzgVerifier, - proof: &Proof, - claims: &[Vec], -) -> Result { - let plan = super::construct::recursive_plan( - &verifier.system, - &proof.log_degrees, - native_verifier::ClaimSchema::from_claims( - claims, - &verifier.statement.locations, - )?, - )?; - Ok(plan.build(proof, claims)?) -} - -pub struct RecursiveStage { - prepared: Prepared, - pairings: Pairings, - claim_len: usize, -} - -/// Build the recursive KZG verifier. The original public statement remains -/// public; the opening-check operands are additional publics. -pub fn prepare_recursive( - verifier: &KzgVerifier, - proof: &Proof, - expected: &[Val], -) -> Result { - let public = scalar_statement(expected); - verifier.verify(proof, &public)?; - let claims = verifier.statement.claims(&public)?; - let built = build_recursive(verifier, proof, &claims)?; - let assignment = built.assign()?; - let pairings = Pairings { - keys: built - .pairing_keys - .try_into() - .map_err(|_keys| "expected two pairing keys")?, - }; - pairings.verify(&assignment.public_values()[public.len()..])?; - Ok(RecursiveStage { - prepared: Prepared { circuit: built.circuit, assignment }, - pairings, - claim_len: public.len(), - }) -} - -impl RecursiveStage { - pub fn circuit(&self) -> &multi_stark::plonkish::Circuit { - self.prepared.circuit() - } - - pub fn prove( - self, - srs: Arc, - max_height: usize, - ) -> Result<(RecursiveVerifier, RecursiveProof)> { - let pairing_values = - self.prepared.public_values()[self.claim_len..].to_vec(); - let compiled = - super::construct::lower_recursive(self.prepared.circuit, max_height)?; - let height = super::construct::max_height(&compiled)?; - if height > srs.max_len() { - return Err("recursive trace exceeds the SRS range".into()); - } - let config = KzgConfig::with_max_trace_len(srs, height, 2) - .with_transcript(crate::kzg::TranscriptKind::Blake3) - .with_streaming_lookups() - .with_streaming_quotient(); - let (verifier, proof) = super::prove_lowered( - super::construct::Lowered { - compiled, - assignment: self.prepared.assignment, - }, - config, - )?; - Ok(( - RecursiveVerifier { - verifier, - pairings: self.pairings, - claim_len: self.claim_len, - }, - RecursiveProof { proof, pairing_values }, - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::kzg::curve::{Fr, G1_COMPRESSED_BYTES}; - use crate::recursive::pairing; - use ark_ec::CurveGroup; - use multi_stark::plonkish::CircuitBuilder; - use multi_stark::traits::Algebra; - use multi_stark::traits::Field; - - #[test] - fn final_verifier_requires_claim_outer_proof_and_both_pairings() -> Result<()> - { - let inner = Srs::unsafe_dev_setup(8, b"inner-test"); - let outer = Arc::new(Srs::unsafe_dev_setup(128, b"outer-test")); - // Exercise the final verification boundary with small generated outer - // circuits. Actual recursive constraints are tested by prepare_recursive - // and the native_verifier tests, without a large recursive proving run. - for claim_len in [2, 5] { - for invalid_pairing in [false, true] { - let mut points = [inner.g1[1], -inner.g1[0]]; - let keys = [inner.g2, inner.tau_g2]; - if invalid_pairing { - points[0] = (points[0] * Fr::from(2u8)).into_affine(); - } - let packed = pairing::encode(points); - let expected: Vec<_> = - (0..claim_len).map(|i| Val::from_usize(7 + i)).collect(); - let mut public = scalar_statement(&expected); - public.extend_from_slice(&packed); - let mut b = CircuitBuilder::new(); - let table = - b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); - let one = b.constant(Scalar::ONE); - b.lookup(table, &[one]); - let wires: Vec<_> = (0..public.len()) - .map(|i| b.public_input(format!("public{i}"))) - .collect(); - let circuit = b.finish(); - let mut witness = circuit.witness(); - for (&wire, &value) in wires.iter().zip(&public) { - witness.set(wire, value)?; - } - let assignment = witness.generate()?; - let stage = RecursiveStage { - prepared: Prepared { circuit, assignment }, - pairings: Pairings { keys }, - claim_len, - }; - let (verifier, mut proof) = stage.prove(outer.clone(), 128)?; - assert_eq!( - verifier.verify(&proof, &expected).is_err(), - invalid_pairing - ); - if !invalid_pairing { - let key_bytes = verifier.to_bytes()?; - let verifier = RecursiveVerifier::from_bytes(&key_bytes)?; - assert_eq!(verifier.to_bytes()?, key_bytes); - let mut old_key = key_bytes.clone(); - old_key[..8].copy_from_slice(b"IXRECV01"); - assert!(RecursiveVerifier::from_bytes(&old_key).is_err()); - let mut other_curve = key_bytes.clone(); - other_curve[..8].copy_from_slice(b"IXRECV02"); - assert!(RecursiveVerifier::from_bytes(&other_curve).is_err()); - assert!( - RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) - .is_err() - ); - let mut extra_key = key_bytes.clone(); - extra_key.push(0); - assert!(RecursiveVerifier::from_bytes(&extra_key).is_err()); - let bytes = verifier.encode_proof(&proof)?; - verifier.verify_bytes(&bytes, &expected)?; - assert!( - verifier - .verify_bytes(&bytes[..bytes.len() - 1], &expected) - .is_err() - ); - let mut extra = bytes.clone(); - extra.push(0); - assert!(verifier.verify_bytes(&extra, &expected).is_err()); - let mut malformed = bytes.clone(); - malformed[..G1_COMPRESSED_BYTES].fill(0xff); - assert!(verifier.verify_bytes(&malformed, &expected).is_err()); - let mut wrong = expected.clone(); - wrong[0] += Val::ONE; - assert!(verifier.verify(&proof, &wrong).is_err()); - assert!(verifier.verify(&proof, &expected[..claim_len - 1]).is_err()); - proof.pairing_values[0] += Scalar::ONE; - assert!(verifier.verify(&proof, &expected).is_err()); - proof.pairing_values[0] -= Scalar::ONE; - proof.proof.stage_1_opened_values[0][0][0] += Scalar::ONE; - assert!(verifier.verify(&proof, &expected).is_err()); - } - } - } - Ok(()) - } -} diff --git a/crates/proof-compression/src/pipeline/statement.rs b/crates/proof-compression/src/pipeline/statement.rs index 738e2e2f5..3135a3eda 100644 --- a/crates/proof-compression/src/pipeline/statement.rs +++ b/crates/proof-compression/src/pipeline/statement.rs @@ -93,7 +93,7 @@ pub(crate) fn from_scalars(values: &[Scalar]) -> Result { /// The statement carried by a stage's claim rows: rows `1..=18` hold one /// word each in their last column, behind the zero anchor of row `0`. pub(crate) fn from_rows(claims: &[Vec]) -> Result { - let schema = super::stages::statement::Statement::from_claims(claims, WORDS)?; + let schema = super::claims::ClaimTemplate::from_claims(claims, WORDS)?; from_scalars(&schema.public_values(claims)?) } diff --git a/crates/proof-compression/src/pipeline/storage.rs b/crates/proof-compression/src/pipeline/storage.rs index b3647064d..dfca14089 100644 --- a/crates/proof-compression/src/pipeline/storage.rs +++ b/crates/proof-compression/src/pipeline/storage.rs @@ -159,12 +159,13 @@ pub(crate) fn load(path: &Path) -> Result { #[cfg(all(test, feature = "groth16"))] mod tests { use super::*; + use crate::test_support::TempDir; #[test] fn key_cache_publishes_only_complete_keys_and_preserves_the_first() -> Result<()> { - let temporary = crate::pipeline::final_proof::Scratch::new()?; - let directory = temporary.0.join("keys"); + let temporary = TempDir::new("key-cache"); + let directory = temporary.join("keys"); let failed = prepare_cached_directory(&directory, |staging| { write_atomic(&staging.join("key"), b"incomplete")?; Err("setup failed".into()) diff --git a/crates/proof-compression/src/pipeline/verifier.rs b/crates/proof-compression/src/pipeline/verifier.rs deleted file mode 100644 index 9b03ce54c..000000000 --- a/crates/proof-compression/src/pipeline/verifier.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Recursive KZG verification with independently authenticated keys and statements. -use super::{Result, setup::SetupClass, statement, wrap::VerifyingKey}; - -/// Verify using an independently authenticated verifying key and expected statement. -pub(super) fn verify_with_vk( - packet: &[u8], - claim: &[u8], - expected: &statement::Statement, - vk: &[u8], - trusted_digest: &[u8; 32], - allow_development: bool, -) -> Result { - if blake3::hash(vk).as_bytes() != trusted_digest { - return Err( - "local verifying key digest differs from the trusted digest".into(), - ); - } - statement::bind_claim(claim, expected)?; - let vk = VerifyingKey::from_bytes(vk)?; - let setup = SetupClass::of(&vk.verifier_params()?); - vk.check_setup_class(setup)?; - if setup == SetupClass::Development && !allow_development { - return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); - } - vk.verify(packet, expected)?; - Ok(setup) -} diff --git a/crates/proof-compression/src/pipeline/verifying_keys.rs b/crates/proof-compression/src/pipeline/verifying_keys.rs index 92ddf5516..26e68a57d 100644 --- a/crates/proof-compression/src/pipeline/verifying_keys.rs +++ b/crates/proof-compression/src/pipeline/verifying_keys.rs @@ -1,6 +1,8 @@ //! The verifying keys this build compresses to and verifies, one per proof //! system, with the aggregation backend and fixed root layout they were derived //! for. A proof verifies only under these keys; there is no catalog of alternatives. +//! A caller may instead verify under its own key, authenticated by a digest it +//! trusts independently of the proof. use super::{ FixedRoot, Result, setup::SetupClass, @@ -149,6 +151,33 @@ pub(super) fn verify_recursive( Ok(SetupClass::Ignition) } +/// Verify a recursive KZG packet under a verifying key the caller supplies +/// with an independently trusted digest, against an independently expected +/// statement. +pub(super) fn verify_with_vk( + packet: &[u8], + claim: &[u8], + expected: &statement::Statement, + vk: &[u8], + trusted_digest: &[u8; 32], + allow_development: bool, +) -> Result { + if blake3::hash(vk).as_bytes() != trusted_digest { + return Err( + "local verifying key digest differs from the trusted digest".into(), + ); + } + statement::bind_claim(claim, expected)?; + let vk = VerifyingKey::from_bytes(vk)?; + let setup = SetupClass::of(&vk.verifier_params()?); + vk.check_setup_class(setup)?; + if setup == SetupClass::Development && !allow_development { + return Err("compressed proof uses the known-trapdoor development setup; pass --dev-srs to accept it (not production-secure)".into()); + } + vk.verify(packet, expected)?; + Ok(setup) +} + /// Whether a compression of a `root_vk` root under `setup` must produce the /// bundled keys: the root is this version's backend and the setup is Ignition. pub(super) fn bundled_for(root_vk: &[u8], setup: &super::SetupConfig) -> bool { diff --git a/crates/proof-compression/src/pipeline/wrap/mod.rs b/crates/proof-compression/src/pipeline/wrap/mod.rs index eb1379712..3aea1fa9b 100644 --- a/crates/proof-compression/src/pipeline/wrap/mod.rs +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -1,9 +1,13 @@ mod outer; +mod recursive; mod saved; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) use outer::final_description; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(super) use outer::final_export; +pub use recursive::{ + RecursiveProof, RecursiveStage, RecursiveVerifier, prepare_recursive, +}; /// Recursively verify the first KZG stage in `first` and prove the recursive /// stage into `out`, with its circuit report beside it. diff --git a/crates/proof-compression/src/pipeline/wrap/outer.rs b/crates/proof-compression/src/pipeline/wrap/outer.rs index f1661479b..66ff21885 100644 --- a/crates/proof-compression/src/pipeline/wrap/outer.rs +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -9,15 +9,14 @@ pub(in crate::pipeline) use final_export::describe as final_description; #[cfg(any(feature = "plonk", feature = "groth16"))] pub(in crate::pipeline) use final_export::export as final_export; -use super::super::stages::{ - construct::{self, Lowered}, - statement::Statement as StageStatement, - verification, -}; +use super::recursive::{self, RecursiveVerifier}; use super::{ super::Result as PipelineResult, super::{ - fixed_cache, prover, + claims::ClaimTemplate, + fixed_cache, + lowering::{self, Lowered}, + prover, setup::{SetupClass, SetupSource}, statement::{self, Statement}, storage, @@ -43,7 +42,7 @@ const PAIRING_BYTES: usize = 2 * G1_COMPRESSED_BYTES; const RECURSIVE_PUBLIC_VALUES: usize = statement::WORDS + PUBLIC_VALUES; /// Largest admitted recursive computation trace. The development public /// degree of a staged verifying key covers exactly its own height. -pub(super) const MAX_TRACE_LEN: usize = construct::RECURSIVE_HEIGHT; +pub(super) const MAX_TRACE_LEN: usize = lowering::RECURSIVE_HEIGHT; #[derive(serde::Serialize, serde::Deserialize)] struct Pairings { @@ -172,7 +171,7 @@ impl Frontend { } let mut encoded = vec![]; keys.serialize_compressed(&mut encoded)?; - let compiled = construct::lower_recursive(circuit, max_height)?; + let compiled = lowering::lower_recursive(circuit, max_height)?; Ok(Self { compiled, pairings: Pairings { keys: encoded }, vk: vk.to_vec() }) } @@ -282,10 +281,10 @@ fn decode_pairing_keys(vk: &Pairings) -> storage::Result> { } fn pairing_bytes(publics: &[Scalar]) -> storage::Result> { - verification::pack_pairings(publics) + recursive::pack_pairings(publics) } fn check_pairings(bytes: &[u8], keys: &[G2Affine]) -> storage::Result<()> { - verification::Pairings { + recursive::Pairings { keys: keys.try_into().map_err(|_keys| "pairing point count")?, } .verify_bytes(bytes) @@ -322,8 +321,8 @@ fn packet_claims( let body = packet_body(packet, vk, expected)?; let mut public: Vec<_> = expected.iter().copied().map(Scalar::from_u64).collect(); - public.extend(verification::unpack_pairings(&body[..PAIRING_BYTES])?); - let claims = StageStatement::recursive(public.len()).claims(&public)?; + public.extend(recursive::unpack_pairings(&body[..PAIRING_BYTES])?); + let claims = ClaimTemplate::recursive(public.len()).claims(&public)?; Ok((claims, 32 + statement::WORDS * 8 + PAIRING_BYTES)) } fn verify_packet( @@ -335,13 +334,13 @@ fn verify_packet( expected: &Statement, ) -> storage::Result<()> { let body = packet_body(packet, vk, expected)?; - let pairings = verification::Pairings { + let pairings = recursive::Pairings { keys: keys.try_into().map_err(|_keys| "pairing point count")?, }; - verification::verify_recursive_bytes( + recursive::verify_recursive_bytes( system, codec, - &StageStatement::recursive(RECURSIVE_PUBLIC_VALUES), + &ClaimTemplate::recursive(RECURSIVE_PUBLIC_VALUES), &pairings, body, &expected.iter().copied().map(Scalar::from_u64).collect::>(), @@ -355,7 +354,7 @@ fn staged_publics( if pairing_count != 2 { return Err("pairing point count".into()); } - StageStatement::recursive(RECURSIVE_PUBLIC_VALUES).public_values(claims) + ClaimTemplate::recursive(RECURSIVE_PUBLIC_VALUES).public_values(claims) } /// The KZG configuration the recursive stage commits and proves under. @@ -651,9 +650,9 @@ impl VerifyingKey { pub(crate) fn prepare(&self) -> PipelineResult { Ok(PreparedVerifyingKey { id: self.id(), - verifier: super::super::stages::RecursiveVerifier::from_parts( + verifier: RecursiveVerifier::from_parts( self.system()?, - StageStatement::recursive(RECURSIVE_PUBLIC_VALUES), + ClaimTemplate::recursive(RECURSIVE_PUBLIC_VALUES), &self.keys()?, 18, )?, @@ -671,7 +670,7 @@ impl VerifyingKey { pub(crate) struct PreparedVerifyingKey { id: [u8; 32], - verifier: super::super::stages::RecursiveVerifier, + verifier: RecursiveVerifier, } impl PreparedVerifyingKey { @@ -713,9 +712,9 @@ pub(crate) fn verify_saved( #[cfg(test)] mod tests { - use super::super::super::init_claim; + use super::super::super::init_fri::INIT_PUBLIC_WORDS; use super::*; - use crate::{foreign::KzgCircuitExt, kzg::Srs}; + use crate::{foreign::KzgCircuitExt, kzg::Srs, test_support::TempDir}; use ark_ec::CurveGroup; use ark_serialize::CanonicalSerialize; use multi_stark::{ @@ -781,7 +780,7 @@ mod tests { parameters: parameters.to_bytes()?, }; let mut packet = vk.id().to_vec(); - for word in init_claim::INIT_PUBLIC_WORDS { + for word in INIT_PUBLIC_WORDS { packet.extend(word.to_le_bytes()); } packet.extend(pairing_bytes(&publics[18..])?); @@ -794,10 +793,8 @@ mod tests { fn plonk_recursive_export_includes_anchor_and_deferred_publics() -> storage::Result<()> { let (vk, packet) = recursive_vk_fixture()?; - let expected = init_claim::INIT_PUBLIC_WORDS; - let directory = std::env::temp_dir() - .join(format!("plonk-recursive-claims-{}", std::process::id())); - fs::create_dir(&directory)?; + let expected = INIT_PUBLIC_WORDS; + let directory = TempDir::new("plonk-recursive-claims"); final_export::write_export( &vk, &packet, &expected, &directory, "witness", true, )?; @@ -821,7 +818,6 @@ mod tests { ) .is_err() ); - fs::remove_dir_all(directory)?; Ok(()) } @@ -848,8 +844,8 @@ mod tests { b"ignition-stage-anchors", 128, )?; - let root = std::env::temp_dir() - .join(format!("ignition-stage-anchors-{}", std::process::id(),)); + let scratch = TempDir::new("ignition-stage-anchors"); + let root = scratch.join("stage"); assert!(!root.exists()); let config = StageConfig { setup: SetupConfig::Ignition { srs: root.join("absent-srs.bin") }, @@ -857,7 +853,7 @@ mod tests { prefetch_bytes: 0, }; let error = frontend - .stage(assignment, &root, &init_claim::INIT_PUBLIC_WORDS, &config) + .stage(assignment, &root, &INIT_PUBLIC_WORDS, &config) .unwrap_err(); assert!(error.to_string().contains("inner pairing anchors differ")); assert!(!root.exists()); @@ -867,10 +863,10 @@ mod tests { #[test] fn pinned_recursive_key_binds_claim_and_rejects_tampering() -> storage::Result<()> { - use super::super::super::verifier::verify_with_vk; + use super::super::super::verifying_keys::verify_with_vk; let (vk, packet) = recursive_vk_fixture()?; let claim = include_bytes!("../../../tests/fixtures/init-fri/claim.bin"); - let expected = init_claim::INIT_PUBLIC_WORDS; + let expected = INIT_PUBLIC_WORDS; let portable = vk.to_bytes()?; let trusted = *blake3::hash(&portable).as_bytes(); let verify = @@ -918,7 +914,7 @@ mod tests { let vk = [17u8; 32]; let packet_prefix = |wrong: bool| { let mut packet = vk.to_vec(); - for v in init_claim::INIT_PUBLIC_WORDS { + for v in INIT_PUBLIC_WORDS { packet.extend(v.to_le_bytes()); } let lhs = if wrong { @@ -930,13 +926,8 @@ mod tests { (-inner.g1[0]).serialize_compressed(&mut packet).unwrap(); packet }; - let (claims, _) = packet_claims( - &packet_prefix(false), - &vk, - 2, - &init_claim::INIT_PUBLIC_WORDS, - ) - .unwrap(); + let (claims, _) = + packet_claims(&packet_prefix(false), &vk, 2, &INIT_PUBLIC_WORDS).unwrap(); let mut b = CircuitBuilder::new(); let inputs: Vec<_> = (1..claims.len()) .map(|i| { @@ -960,7 +951,7 @@ mod tests { for wrong in [false, true] { let mut packet = packet_prefix(wrong); let (claims, _) = - packet_claims(&packet, &vk, 2, &init_claim::INIT_PUBLIC_WORDS).unwrap(); + packet_claims(&packet, &vk, 2, &INIT_PUBLIC_WORDS).unwrap(); let mut w = lowered.witness(); for (&input, claim) in inputs.iter().zip(&claims[1..]) { w.set(input, claim[3]).unwrap(); @@ -973,15 +964,8 @@ mod tests { ); packet.extend(codec.encode(&proof).unwrap()); assert_eq!( - verify_packet( - &system, - &codec, - &packet, - &vk, - &keys, - &init_claim::INIT_PUBLIC_WORDS - ) - .is_ok(), + verify_packet(&system, &codec, &packet, &vk, &keys, &INIT_PUBLIC_WORDS) + .is_ok(), !wrong ); if !wrong { @@ -994,7 +978,7 @@ mod tests { &changed, &vk, &keys, - &init_claim::INIT_PUBLIC_WORDS + &INIT_PUBLIC_WORDS ) .is_err() ); @@ -1015,7 +999,7 @@ mod tests { &packet[..packet.len() - 1], &vk, &keys, - &init_claim::INIT_PUBLIC_WORDS + &INIT_PUBLIC_WORDS ) .is_err() ); @@ -1027,7 +1011,7 @@ mod tests { &packet, &vk, &keys, - &init_claim::INIT_PUBLIC_WORDS + &INIT_PUBLIC_WORDS ) .is_err() ); @@ -1037,14 +1021,12 @@ mod tests { fn init_packet_claims(inner: &Srs) -> Vec> { let mut prefix = [0u8; 32].to_vec(); - for word in init_claim::INIT_PUBLIC_WORDS { + for word in INIT_PUBLIC_WORDS { prefix.extend(word.to_le_bytes()); } inner.g1[1].serialize_compressed(&mut prefix).unwrap(); (-inner.g1[0]).serialize_compressed(&mut prefix).unwrap(); - packet_claims(&prefix, &[0u8; 32], 2, &init_claim::INIT_PUBLIC_WORDS) - .unwrap() - .0 + packet_claims(&prefix, &[0u8; 32], 2, &INIT_PUBLIC_WORDS).unwrap().0 } /// Stage a compiled fixture's metadata the way `Frontend::stage` does. @@ -1077,9 +1059,7 @@ mod tests { #[test] fn fused_vks_prove_and_portable_verifiers_round_trip() -> storage::Result<()> { - let dir = std::env::temp_dir() - .join(format!("outer-staged-{}-v{OUTER_VK_VERSION}", std::process::id())); - fs::create_dir(&dir)?; + let dir = TempDir::new("outer-staged"); let inner = Srs::unsafe_dev_setup(8, b"staged-inner"); let keys = vec![inner.g2, inner.tau_g2]; let claims = init_packet_claims(&inner); @@ -1149,29 +1129,24 @@ mod tests { let claim = include_bytes!("../../../tests/fixtures/init-fri/claim.bin"); let trusted = *blake3::hash(&portable).as_bytes(); let verify_local = |claim: &[u8], expected, trusted: &[u8; 32], allow| { - super::super::super::verifier::verify_with_vk( + super::super::super::verifying_keys::verify_with_vk( &packet, claim, expected, &portable, trusted, allow, ) }; assert_eq!( - verify_local(claim, &init_claim::INIT_PUBLIC_WORDS, &trusted, true)?, + verify_local(claim, &INIT_PUBLIC_WORDS, &trusted, true)?, SetupClass::Development ); - assert!( - verify_local(claim, &init_claim::INIT_PUBLIC_WORDS, &trusted, false) - .is_err() - ); + assert!(verify_local(claim, &INIT_PUBLIC_WORDS, &trusted, false).is_err()); let mut wrong_digest = trusted; wrong_digest[0] ^= 1; assert!( - verify_local(claim, &init_claim::INIT_PUBLIC_WORDS, &wrong_digest, true) - .is_err() + verify_local(claim, &INIT_PUBLIC_WORDS, &wrong_digest, true).is_err() ); assert!( - verify_local(b"wrong", &init_claim::INIT_PUBLIC_WORDS, &trusted, true) - .is_err() + verify_local(b"wrong", &INIT_PUBLIC_WORDS, &trusted, true).is_err() ); - let mut wrong_program = init_claim::INIT_PUBLIC_WORDS; + let mut wrong_program = INIT_PUBLIC_WORDS; wrong_program[0] ^= 1; assert!(verify_local(claim, &wrong_program, &trusted, true).is_err()); let vk = VerifyingKey::from_bytes(&portable)?; @@ -1191,7 +1166,7 @@ mod tests { // Verify using only the portable object after deleting the SRS and all checkpoints. fs::remove_dir_all(&dir)?; - vk.verify(&packet, &init_claim::INIT_PUBLIC_WORDS)?; + vk.verify(&packet, &INIT_PUBLIC_WORDS)?; let mut wrong_range: VerifyingKey = decode(&portable)?; wrong_range.heights[0] *= 2; assert!(wrong_range.system().is_err()); @@ -1259,9 +1234,7 @@ mod tests { assignment, )) }; - let root = std::env::temp_dir() - .join(format!("kzg-recursive-fused-{}", std::process::id())); - fs::create_dir(&root)?; + let root = TempDir::new("kzg-recursive-fused"); let mut outputs = vec![]; for (name, private) in [("first", 7), ("same", 7), ("other", 9)] { let dir = root.join(name); @@ -1272,7 +1245,7 @@ mod tests { assignment, &dir, b"recursive-fixture", - &init_claim::INIT_PUBLIC_WORDS, + &INIT_PUBLIC_WORDS, &crate::pipeline::StageConfig::development(), )?; let report: serde_json::Value = @@ -1299,7 +1272,6 @@ mod tests { assert_eq!(outputs[0], outputs[1]); assert_ne!(outputs[1][0], outputs[2][0]); assert_eq!(outputs[1][2], outputs[2][2]); - fs::remove_dir_all(root)?; Ok(()) } @@ -1310,7 +1282,7 @@ mod tests { use crate::recursive::native_verifier::Plan; use multi_stark::{expr::Expr, lookup::Lookup, system::CircuitInputs}; use p3_matrix::dense::RowMajorMatrix; - let words = init_claim::INIT_PUBLIC_WORDS; + let words = INIT_PUBLIC_WORDS; let srs = Arc::new(Srs::unsafe_dev_setup(8, b"constant-column-inner")); let (system, key) = System::new( KzgConfig::new(srs.clone(), 2), @@ -1384,15 +1356,13 @@ mod tests { )?; let built = plan.build(&proof, &claims)?; let assignment = built.check()?; - let dir = std::env::temp_dir() - .join(format!("kzg-recursive-constant-{}", std::process::id())); - fs::create_dir(&dir)?; + let dir = TempDir::new("kzg-recursive-constant"); stage( built, assignment, &dir, &plan.identity(), - &init_claim::INIT_PUBLIC_WORDS, + &INIT_PUBLIC_WORDS, &crate::pipeline::StageConfig::development(), )?; let packet = fs::read(dir.join("kzg/packet.bin"))?; @@ -1400,7 +1370,6 @@ mod tests { let mut bad = packet.clone(); bad[32 + 18 * 8 + 1] ^= 1; assert!(verify_saved(&dir, &bad, None).is_err()); - fs::remove_dir_all(dir)?; Ok(()) } } diff --git a/crates/proof-compression/src/pipeline/stages/verification.rs b/crates/proof-compression/src/pipeline/wrap/recursive.rs similarity index 54% rename from crates/proof-compression/src/pipeline/stages/verification.rs rename to crates/proof-compression/src/pipeline/wrap/recursive.rs index a8ca8c429..92ea89bdc 100644 --- a/crates/proof-compression/src/pipeline/stages/verification.rs +++ b/crates/proof-compression/src/pipeline/wrap/recursive.rs @@ -1,20 +1,118 @@ -//! Prepared recursive verification shared by local and bundled verifying keys. -use super::{ - KzgVerifier, Result, scalar_statement, statement::Statement, - wrap::RecursiveProof, -}; +//! The recursive KZG stage: a circuit verifying a first KZG stage proof with +//! its opening pairing deferred to public operands, the proof of it, and the +//! prepared verifier shared by local and bundled verifying keys. use crate::kzg::{ - KzgConfig, Scalar, + KzgConfig, Scalar, Srs, curve::{ G1_COMPRESSED_BYTES, G1Affine, G2_COMPRESSED_BYTES, G2Affine, PairingCurve, read_g1_compressed, }, }; +use crate::pipeline::{ + Result, + claims::ClaimTemplate, + lowering, + stage::{self, KzgVerifier, Prepared, scalar_statement}, +}; +use crate::recursive::native_verifier; use crate::recursive::pairing::{self, PUBLIC_VALUES}; use ark_ec::{AffineRepr, pairing::Pairing}; use ark_ff::Zero; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use multi_stark::{system::System, types::Val}; +use multi_stark::{prover::Proof, system::System, types::Val}; +use std::sync::Arc; + +/// Recursive proof and its public pairing operands. Certificate claim values +/// are supplied independently to RecursiveVerifier::verify. +pub struct RecursiveProof { + pub proof: Proof, + pub pairing_values: Vec, +} + +fn build_recursive( + verifier: &KzgVerifier, + proof: &Proof, + claims: &[Vec], +) -> Result { + let plan = lowering::recursive_plan( + &verifier.system, + &proof.log_degrees, + native_verifier::ClaimSchema::from_claims( + claims, + &verifier.statement.locations, + )?, + )?; + Ok(plan.build(proof, claims)?) +} + +pub struct RecursiveStage { + prepared: Prepared, + pairings: Pairings, + claim_len: usize, +} + +/// Build the recursive KZG verifier. The original public statement remains +/// public; the opening-check operands are additional publics. +pub fn prepare_recursive( + verifier: &KzgVerifier, + proof: &Proof, + expected: &[Val], +) -> Result { + let public = scalar_statement(expected); + verifier.verify(proof, &public)?; + let claims = verifier.statement.claims(&public)?; + let built = build_recursive(verifier, proof, &claims)?; + let assignment = built.assign()?; + let pairings = Pairings { + keys: built + .pairing_keys + .try_into() + .map_err(|_keys| "expected two pairing keys")?, + }; + pairings.verify(&assignment.public_values()[public.len()..])?; + Ok(RecursiveStage { + prepared: Prepared { circuit: built.circuit, assignment }, + pairings, + claim_len: public.len(), + }) +} + +impl RecursiveStage { + pub fn circuit(&self) -> &multi_stark::plonkish::Circuit { + self.prepared.circuit() + } + + pub fn prove( + self, + srs: Arc, + max_height: usize, + ) -> Result<(RecursiveVerifier, RecursiveProof)> { + let pairing_values = + self.prepared.public_values()[self.claim_len..].to_vec(); + let compiled = + lowering::lower_recursive(self.prepared.circuit, max_height)?; + let height = lowering::max_height(&compiled)?; + if height > srs.max_len() { + return Err("recursive trace exceeds the SRS range".into()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_transcript(crate::kzg::TranscriptKind::Blake3) + .with_streaming_lookups() + .with_streaming_quotient(); + let (verifier, proof) = stage::prove_lowered( + lowering::Lowered { compiled, assignment: self.prepared.assignment }, + config, + )?; + Ok(( + RecursiveVerifier { + verifier, + pairings: self.pairings, + claim_len: self.claim_len, + }, + RecursiveProof { proof, pairing_values }, + )) + } +} const RECURSIVE_VERIFIER_MAGIC: &[u8; 8] = b"IXBNRV02"; @@ -51,7 +149,7 @@ impl RecursiveVerifier { for key in &self.pairings.keys { key.serialize_compressed(&mut keys)?; } - super::codec::encode( + stage::encode_key( RECURSIVE_VERIFIER_MAGIC, (self.verifier.to_bytes()?, self.claim_len, keys), ) @@ -60,7 +158,7 @@ impl RecursiveVerifier { /// Decode an independently trusted verifier with a 64 MiB decoding budget. pub fn from_bytes(bytes: &[u8]) -> Result { let (verifier, claim_len, keys): (Vec, usize, Vec) = - super::codec::decode(RECURSIVE_VERIFIER_MAGIC, bytes)?; + stage::decode_key(RECURSIVE_VERIFIER_MAGIC, bytes)?; let verifier = KzgVerifier::from_bytes(&verifier)?; if keys.len() != 2 * G2_COMPRESSED_BYTES || claim_len.checked_add(PUBLIC_VALUES) @@ -113,7 +211,7 @@ impl RecursiveVerifier { impl RecursiveVerifier { pub(in crate::pipeline) fn from_parts( system: System, - statement: Statement, + statement: ClaimTemplate, keys: &[G2Affine], claim_len: usize, ) -> Result { @@ -192,7 +290,7 @@ pub(in crate::pipeline) fn pack_pairings(values: &[Scalar]) -> Result> { pub(in crate::pipeline) fn verify_recursive_bytes( system: &System, codec: &crate::kzg::compact::FixedProofCodec, - statement: &Statement, + statement: &ClaimTemplate, pairings: &Pairings, bytes: &[u8], expected: &[Scalar], @@ -220,7 +318,94 @@ mod tests { use crate::kzg::curve::Fr; use crate::recursive::pairing::POINT_LIMB_BYTES; use ark_ec::CurveGroup; - use multi_stark::traits::Algebra; + use multi_stark::plonkish::CircuitBuilder; + use multi_stark::traits::{Algebra, Field}; + + #[test] + fn final_verifier_requires_claim_outer_proof_and_both_pairings() -> Result<()> + { + let inner = Srs::unsafe_dev_setup(8, b"inner-test"); + let outer = Arc::new(Srs::unsafe_dev_setup(128, b"outer-test")); + // Exercise the final verification boundary with small generated outer + // circuits. Actual recursive constraints are tested by prepare_recursive + // and the native_verifier tests, without a large recursive proving run. + for claim_len in [2, 5] { + for invalid_pairing in [false, true] { + let mut points = [inner.g1[1], -inner.g1[0]]; + let keys = [inner.g2, inner.tau_g2]; + if invalid_pairing { + points[0] = (points[0] * Fr::from(2u8)).into_affine(); + } + let packed = pairing::encode(points); + let expected: Vec<_> = + (0..claim_len).map(|i| Val::from_usize(7 + i)).collect(); + let mut public = scalar_statement(&expected); + public.extend_from_slice(&packed); + let mut b = CircuitBuilder::new(); + let table = + b.fixed_table("small", vec![vec![Scalar::ONE], vec![Scalar::TWO]]); + let one = b.constant(Scalar::ONE); + b.lookup(table, &[one]); + let wires: Vec<_> = (0..public.len()) + .map(|i| b.public_input(format!("public{i}"))) + .collect(); + let circuit = b.finish(); + let mut witness = circuit.witness(); + for (&wire, &value) in wires.iter().zip(&public) { + witness.set(wire, value)?; + } + let assignment = witness.generate()?; + let stage = RecursiveStage { + prepared: Prepared { circuit, assignment }, + pairings: Pairings { keys }, + claim_len, + }; + let (verifier, mut proof) = stage.prove(outer.clone(), 128)?; + assert_eq!( + verifier.verify(&proof, &expected).is_err(), + invalid_pairing + ); + if !invalid_pairing { + let key_bytes = verifier.to_bytes()?; + let verifier = RecursiveVerifier::from_bytes(&key_bytes)?; + assert_eq!(verifier.to_bytes()?, key_bytes); + let mut wrong_magic = key_bytes.clone(); + wrong_magic[7] ^= 1; + assert!(RecursiveVerifier::from_bytes(&wrong_magic).is_err()); + assert!( + RecursiveVerifier::from_bytes(&key_bytes[..key_bytes.len() - 1]) + .is_err() + ); + let mut extra_key = key_bytes.clone(); + extra_key.push(0); + assert!(RecursiveVerifier::from_bytes(&extra_key).is_err()); + let bytes = verifier.encode_proof(&proof)?; + verifier.verify_bytes(&bytes, &expected)?; + assert!( + verifier + .verify_bytes(&bytes[..bytes.len() - 1], &expected) + .is_err() + ); + let mut extra = bytes.clone(); + extra.push(0); + assert!(verifier.verify_bytes(&extra, &expected).is_err()); + let mut malformed = bytes.clone(); + malformed[..G1_COMPRESSED_BYTES].fill(0xff); + assert!(verifier.verify_bytes(&malformed, &expected).is_err()); + let mut wrong = expected.clone(); + wrong[0] += Val::ONE; + assert!(verifier.verify(&proof, &wrong).is_err()); + assert!(verifier.verify(&proof, &expected[..claim_len - 1]).is_err()); + proof.pairing_values[0] += Scalar::ONE; + assert!(verifier.verify(&proof, &expected).is_err()); + proof.pairing_values[0] -= Scalar::ONE; + proof.proof.stage_1_opened_values[0][0][0] += Scalar::ONE; + assert!(verifier.verify(&proof, &expected).is_err()); + } + } + } + Ok(()) + } #[test] fn pairing_transport_preserves_points_and_rejects_noncanonical_values() diff --git a/crates/proof-compression/src/pipeline/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs index 38bf2ca10..ba44d0d1b 100644 --- a/crates/proof-compression/src/pipeline/wrap/saved.rs +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -63,7 +63,7 @@ pub(in crate::pipeline) fn frontend( let setup = config.for_stage(FIRST_SEED, height)?; let system = inner_system(&setup, &manifest.widths, &manifest.heights, parts)?; - let plan = super::super::stages::construct::recursive_plan( + let plan = super::super::lowering::recursive_plan( &system, &logs(&manifest.heights), claim_schema(&manifest.claims)?, @@ -103,13 +103,12 @@ fn load( #[cfg(test)] pub(super) fn layout() -> crate::recursive::native_verifier::LayoutPolicy { - super::super::stages::construct::recursive_layout() + super::super::lowering::recursive_layout() } pub(super) fn claim_schema(claims: &[Vec]) -> Result { let public = - super::super::stages::statement::Statement::from_claims(claims, 18)? - .locations; + super::super::claims::ClaimTemplate::from_claims(claims, 18)?.locations; Ok(ClaimSchema::from_claims(claims, &public)?) } @@ -154,7 +153,7 @@ pub(super) struct Input { impl Input { fn plan(&self) -> Result> { - super::super::stages::construct::recursive_plan( + super::super::lowering::recursive_plan( &self.system, &self.logs, claim_schema(&self.claims)?, @@ -193,7 +192,7 @@ impl Input { let system = inner_system(&setup, &manifest.widths, &manifest.heights, parts)?; let logs = logs(&manifest.heights); - let plan = super::super::stages::construct::recursive_plan( + let plan = super::super::lowering::recursive_plan( &system, &logs, claim_schema(&manifest.claims)?, @@ -288,7 +287,7 @@ pub(super) fn prove( mod tests { use super::*; use crate::kzg::Srs; - use crate::pipeline::init_claim; + use crate::pipeline::init_fri::INIT_PUBLIC_WORDS; use crate::pipeline::setup::SetupSource; use crate::recursive::native_verifier::LayoutPolicy; use multi_stark::traits::Algebra; @@ -416,12 +415,10 @@ mod tests { #[test] fn saved_loader_requires_binding_before_setup_metadata() -> Result<()> { - let root = std::env::temp_dir() - .join(format!("ix-saved-binding-{}", std::process::id())); - fs::create_dir(&root)?; + let root = crate::test_support::TempDir::new("saved-binding"); let ns = Scalar::from_u8(93); let mut claims: Vec<_> = std::iter::once(0) - .chain(init_claim::INIT_PUBLIC_WORDS) + .chain(INIT_PUBLIC_WORDS) .enumerate() .map(|(index, word)| { vec![ns, Scalar::ONE, Scalar::from_usize(index), Scalar::from_u64(word)] @@ -464,7 +461,6 @@ mod tests { .err() .unwrap(); assert!(malformed.downcast_ref::().is_some()); - fs::remove_dir_all(root)?; Ok(()) } } diff --git a/crates/proof-compression/src/recursive/native_field.rs b/crates/proof-compression/src/recursive/native_field.rs index e176efd71..a166d373a 100644 --- a/crates/proof-compression/src/recursive/native_field.rs +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -30,8 +30,6 @@ pub struct FqVar(pub [Value; L]); pub struct FqGadget { range: Table, - #[cfg(test)] - reference_advice: bool, } pub fn integer(v: Scalar) -> BigUint { @@ -77,8 +75,6 @@ impl FqGadget { "Fq range", (0..1u64 << RANGE_BITS).map(|v| vec![Scalar::from_u64(v)]).collect(), ), - #[cfg(test)] - reference_advice: false, } } pub fn range(&self, b: &mut Builder, value: Value, bits: usize) { @@ -99,13 +95,7 @@ impl FqGadget { bits: usize, ) { assert!(bits > 0 && bits <= N * RANGE_BITS); - #[cfg(test)] - let reference_advice = self.reference_advice; let limbs = b.hint_many_pure::("bounded integer", &[value], move |v| { - #[cfg(test)] - if reference_advice { - return Ok(advice::range_reference(v[0])); - } Ok(advice::range_limbs(v[0])) }); for &limb in &limbs { @@ -253,18 +243,10 @@ impl FqGadget { let pc: Vec<_> = products.iter().map(|(c, _, _)| *c).collect(); let lc: Vec<_> = linear.iter().map(|(c, _)| *c).collect(); let p = &advice::constants().modulus_digits; - #[cfg(test)] - let reference_advice = self.reference_advice; let advice = b.hint_many_pure::( "Fq quotient and carries", &deps, - move |values| { - #[cfg(test)] - if reference_advice { - return advice::relation_reference(values, &pc, &lc, constant, p); - } - advice::relation(values, &pc, &lc, constant) - }, + move |values| advice::relation(values, &pc, &lc, constant), ); for (i, &v) in advice[..L].iter().enumerate() { self.range(b, v, if i == L - 1 { QUOTIENT_TOP_BITS } else { W }); diff --git a/crates/proof-compression/src/recursive/native_field/advice.rs b/crates/proof-compression/src/recursive/native_field/advice.rs index efc1b80a1..371fc09af 100644 --- a/crates/proof-compression/src/recursive/native_field/advice.rs +++ b/crates/proof-compression/src/recursive/native_field/advice.rs @@ -128,6 +128,3 @@ fn divide_carry(carry: &mut BigInt) -> Result<(), String> { #[cfg(test)] mod tests; - -#[cfg(test)] -pub(super) use tests::{range_reference, relation_reference}; diff --git a/crates/proof-compression/src/recursive/native_field/advice/tests.rs b/crates/proof-compression/src/recursive/native_field/advice/tests.rs index 1e4f35b17..c326047b3 100644 --- a/crates/proof-compression/src/recursive/native_field/advice/tests.rs +++ b/crates/proof-compression/src/recursive/native_field/advice/tests.rs @@ -1,8 +1,5 @@ use super::*; -use crate::{ - kzg::curve::Fq, - recursive::native_field::{Builder, FqGadget}, -}; +use crate::{kzg::curve::Fq, recursive::native_field::FqGadget}; use ark_ff::PrimeField; fn integer_reference(value: Scalar) -> BigUint { @@ -15,16 +12,14 @@ fn integer_reference(value: Scalar) -> BigUint { ) } -pub(in crate::recursive::native_field) fn range_reference( - value: Scalar, -) -> [Scalar; N] { +fn range_reference(value: Scalar) -> [Scalar; N] { let n = integer_reference(value); let bits = crate::recursive::native_field::RANGE_BITS; let mask = (BigUint::from(1u8) << bits) - BigUint::from(1u8); std::array::from_fn(|i| scalar(&((&n >> (bits * i)) & &mask))) } -pub(in crate::recursive::native_field) fn relation_reference( +fn relation_reference( values: &[Scalar], pc: &[i8], lc: &[i8], @@ -196,7 +191,7 @@ fn carry_division_preserves_signed_exactness_at_radix_boundaries() { #[test] fn zero_and_quotient_limit_errors_match_reference() { - let modulus_digits = constants().modulus_digits.clone(); + let modulus_digits = digits(&modulus()); assert_eq!( relation(&[], &[], &[], 0), relation_reference(&[], &[], &[], 0, &modulus_digits) @@ -249,7 +244,7 @@ fn maximal_representatives_have_exact_signed_integer_quotients() { #[test] fn quotient_carries_and_rejections_match_reference() { - let modulus_digits = constants().modulus_digits.clone(); + let modulus_digits = digits(&modulus()); for mut case in cases(256) { let expected = relation_reference( &case.values, @@ -300,158 +295,3 @@ fn quotient_carries_and_rejections_match_reference() { ); } } - -fn fixture( - reference: bool, -) -> ( - multi_stark::plonkish::Circuit, - multi_stark::plonkish::Assignment, -) { - let mut builder = Builder::new(); - let mut field = FqGadget::new(&mut builder); - field.reference_advice = reference; - let x = field.input(&mut builder, "x"); - let y = field.input(&mut builder, "y"); - let product = field.mul(&mut builder, x, y); - let expected = field.constant(&mut builder, Fq::from(0u8)); - field.equal(&mut builder, product, expected); - for value in product.0 { - builder.expose_public(value); - } - let circuit = builder.finish(); - let mut witness = circuit.witness(); - for (value, assigned) in x - .0 - .into_iter() - .zip(FqGadget::witness_values(-Fq::from(1u8))) - .chain(y.0.into_iter().zip(FqGadget::witness_values(Fq::from(0u8)))) - { - witness.set(value, assigned).unwrap(); - } - let assignment = witness.generate().unwrap(); - (circuit, assignment) -} - -#[test] -fn advice_preserves_complete_frontend_assignment_and_constraints() { - let (reference, old) = fixture(true); - let (optimized, new) = fixture(false); - assert_eq!(reference.stats(), optimized.stats()); - assert_eq!(old.public_values(), new.public_values()); - for (left, right) in reference.gates().iter().zip(optimized.gates()) { - assert_eq!(left.wires.map(|v| v.index()), right.wires.map(|v| v.index())); - assert_eq!(left.coefficients, right.coefficients); - assert_eq!( - left.wires.map(|v| old.value(v).unwrap()), - right.wires.map(|v| new.value(v).unwrap()) - ); - } - for (left, right) in reference.lookups().iter().zip(optimized.lookups()) { - assert_eq!(left.table.index(), right.table.index()); - assert_eq!( - left.values.iter().map(|&v| old.value(v).unwrap()).collect::>(), - right.values.iter().map(|&v| new.value(v).unwrap()).collect::>() - ); - } -} - -#[test] -#[ignore = "two CPU KZG proofs of a native Fq primitive with its full range table"] -fn recursive_advice_kzg_byte_parity() { - use crate::{ - foreign::KzgCircuitExt, - kzg::{KzgConfig, Srs, compact::FixedProofCodec}, - }; - use multi_stark::system::{System, SystemWitness}; - use std::sync::Arc; - let config = KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(1 << 16, b"recursive-advice-parity")), - 4, - ); - let mut bytes = Vec::new(); - for reference in [true, false] { - let (circuit, assignment) = fixture(reference); - let compiled = circuit.lower_to_multi_stark(Scalar::from_u8(79)).unwrap(); - let traces = compiled.traces(&assignment).unwrap(); - let claims = compiled.claims(assignment.public_values()).unwrap(); - let (system, key) = System::new( - config.clone(), - compiled.kzg_circuit_inputs(1 << 16, 4).unwrap(), - ); - let refs = claims.iter().map(Vec::as_slice).collect::>(); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - bytes.push( - FixedProofCodec::new(&system, &proof.log_degrees) - .unwrap() - .encode(&proof) - .unwrap(), - ); - } - assert_eq!(bytes[0], bytes[1]); - println!( - "RECURSIVE_ADVICE_PROOF bytes={} blake3={}", - bytes[0].len(), - blake3::hash(&bytes[0]) - ); -} - -#[test] -#[ignore = "isolated recursive-field advice comparison"] -fn recursive_advice_benchmark() { - use std::{hint::black_box, time::Instant}; - let cases = cases(1 << 12); - let modulus_digits = constants().modulus_digits.clone(); - for case in &cases { - assert_eq!( - relation(&case.values, &case.products, &case.linear, case.constant), - relation_reference( - &case.values, - &case.products, - &case.linear, - case.constant, - &modulus_digits - ) - ); - } - for iteration in 0..5 { - let mut seconds = [0.0; 2]; - let mut outputs = [Vec::new(), Vec::new()]; - for optimized in - if iteration % 2 == 0 { [false, true] } else { [true, false] } - { - let started = Instant::now(); - outputs[usize::from(optimized)] = cases - .iter() - .map(|case| { - let case = black_box(case); - if optimized { - relation(&case.values, &case.products, &case.linear, case.constant) - .unwrap() - } else { - relation_reference( - &case.values, - &case.products, - &case.linear, - case.constant, - &modulus_digits, - ) - .unwrap() - } - }) - .collect::>(); - seconds[usize::from(optimized)] = started.elapsed().as_secs_f64(); - } - assert_eq!(outputs[0], outputs[1]); - println!( - "RECURSIVE_ADVICE_BENCH iteration={iteration} relations={} reference_seconds={:.9} optimized_seconds={:.9}", - cases.len(), - seconds[0], - seconds[1] - ); - } -} diff --git a/crates/proof-compression/src/recursive/native_msm.rs b/crates/proof-compression/src/recursive/native_msm.rs index 48abae164..034762003 100644 --- a/crates/proof-compression/src/recursive/native_msm.rs +++ b/crates/proof-compression/src/recursive/native_msm.rs @@ -448,101 +448,3 @@ mod tests { assert!(rejects_changed_gate(&c, &a, result.point.y.0[0], Scalar::ONE)); } } - -#[cfg(test)] -mod cost_breakdown { - use super::*; - use multi_stark::plonkish::CircuitStats; - - fn record( - out: &mut serde_json::Map, - name: &str, - b: &Builder, - before: CircuitStats, - ) { - let s = b.stats(); - out.insert( - name.into(), - serde_json::json!({ - "gates": s.gates - before.gates, - "lookups": s.lookups - before.lookups, - "rows": (s.gates + s.lookups) - (before.gates + before.lookups), - }), - ); - } - - /// Row attribution of the opening-MSM gadget, printed as JSON. - /// Run with `--ignored --nocapture`. - #[test] - #[ignore = "cost report; run explicitly"] - fn report() { - let mut b = Builder::counting(); - let f = FqGadget::new(&mut b); - let bytes = ByteGadgets::new(&mut b); - let p = PointInput::new(&mut b, &f, "p").nonzero(&mut b, &f); - let q = PointInput::new(&mut b, &f, "q").nonzero(&mut b, &f); - let mut out = serde_json::Map::new(); - let before = b.stats(); - f.mul(&mut b, p.x, q.x); - record(&mut out, "fq_mul", &b, before); - let before = b.stats(); - f.hint_pure(&mut b, "h", &[p.x], |v| Ok(v[0])); - record(&mut out, "fq_hint_pure", &b, before); - let before = b.stats(); - p.add(&mut b, &f, q); - record(&mut out, "affine_add", &b, before); - let before = b.stats(); - p.double(&mut b, &f); - record(&mut out, "affine_double", &b, before); - let before = b.stats(); - p.add_complete(&mut b, &f, q); - record(&mut out, "affine_add_complete", &b, before); - let before = b.stats(); - let mut base = Base::new(PointInput::new(&mut b, &f, "v"), None); - let table_var = base.table(&mut b, &f); - record(&mut out, "table_variable_16", &b, before); - let before = b.stats(); - let mut base = - Base::new(PointInput::new(&mut b, &f, "c"), Some(G1Affine::generator())); - let table_fixed = base.table(&mut b, &f); - record(&mut out, "table_fixed_16", &b, before); - let s = b.input("s"); - let before = b.stats(); - let enc = scalar_bytes(&mut b, &bytes, s); - let bits: Vec<_> = - enc.into_iter().flat_map(|v| bytes.bits(&mut b, v)).collect(); - record(&mut out, "scalar_bytes_and_bits", &b, before); - let before = b.stats(); - select(&mut b, &table_var, &bits[..4]); - record(&mut out, "select_16_variable", &b, before); - let before = b.stats(); - select(&mut b, &table_fixed, &bits[..4]); - record(&mut out, "select_16_fixed", &b, before); - let before = b.stats(); - constant_mul(&mut b, &f, p, correction_coefficients().0); - record(&mut out, "constant_mul_correction", &b, before); - for (nv, nf) in [(1usize, 0usize), (2, 0), (0, 1), (0, 2), (1, 1)] { - let mut b = Builder::counting(); - let f = FqGadget::new(&mut b); - let bytes = ByteGadgets::new(&mut b); - let h = PointInput::new(&mut b, &f, "h").nonzero(&mut b, &f); - let mut bases: Vec = Vec::new(); - for i in 0..nv { - bases - .push(Base::new(PointInput::new(&mut b, &f, &format!("v{i}")), None)); - } - for i in 0..nf { - bases.push(Base::new( - PointInput::new(&mut b, &f, &format!("c{i}")), - Some((G1Affine::generator() * Fr::from(i as u64 + 2)).into_affine()), - )); - } - let terms: Vec<_> = - (0..nv + nf).map(|i| (i, b.input(format!("s{i}")))).collect(); - let before = b.stats(); - msm_with_offset(&mut b, &f, &bytes, &mut bases, &terms, h); - record(&mut out, &format!("msm_var{nv}_fixed{nf}"), &b, before); - } - println!("{}", serde_json::to_string_pretty(&out).unwrap()); - } -} diff --git a/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs index 5fbf15875..92f919899 100644 --- a/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs +++ b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs @@ -4,14 +4,12 @@ use crate::recursive::native_field::L; use ark_ec::AffineRepr; use ark_ff::PrimeField; use multi_stark::plonkish::{Assignment, WitnessError}; -use std::time::Instant; struct PointFixture { circuit: Circuit, inputs: Vec<(Value, Scalar)>, expected_publics: Vec, prefix_ends: Vec, - certified: bool, } fn points(count: usize) -> Vec { @@ -89,13 +87,7 @@ impl PointFixture { .copied() .fold(Scalar::ZERO, |sum, value| sum + value), ); - Self { - circuit: c.b.finish(), - inputs, - expected_publics, - prefix_ends, - certified, - } + Self { circuit: c.b.finish(), inputs, expected_publics, prefix_ends } } fn generate(&self) -> Result, WitnessError> { @@ -157,19 +149,6 @@ fn same_relation(a: &PointFixture, b: &PointFixture) { assert_eq!(a.expected_publics, b.expected_publics); } -fn assignment_digest( - circuit: &Circuit, - assignment: &Assignment, -) -> String { - let mut hash = blake3::Hasher::new(); - for value in assignment.values(circuit.id()).unwrap() { - for limb in value.canonical_limbs_le() { - hash.update(&limb.to_le_bytes()); - } - } - hash.finalize().to_hex().to_string() -} - #[test] fn point_prefix_matches_serial_inputs_values_and_encodings() { let points = points(9); @@ -210,104 +189,3 @@ fn point_prefix_preserves_earliest_point_error() { assert_eq!(actual, expected, "missing={missing:?}, corrupt={corrupt:?}"); } } - -#[test] -#[ignore = "isolated point-prefix witness comparison; default fixture has 335 dynamic points"] -fn native_point_prefix_operation_benchmark() { - let count = std::env::var("IX_KZG_POINT_PREFIX_POINTS") - .map_or(335, |value| value.parse::().expect("valid point count")); - assert!(count >= 2); - let points = points(count); - let start = Instant::now(); - let serial = PointFixture::new(&points, false); - let parallel = PointFixture::new(&points, true); - let construction_seconds = start.elapsed().as_secs_f64(); - same_relation(&serial, ¶llel); - let stats = parallel.circuit.stats(); - println!( - "NATIVE_POINT_PREFIX_SETUP {}", - serde_json::json!({ - "dynamic_points": count, - "fixed_points": 4, - "prefix_blocks": parallel.prefix_ends.len(), - "prefix_values": parallel.prefix_ends.last().unwrap(), - "values": stats.values, - "inputs": stats.inputs, - "gates": stats.gates, - "lookups": stats.lookups, - "hint_calls": stats.hint_calls, - "hint_outputs": stats.hint_outputs, - "publics": stats.publics, - "certified": parallel.certified, - "construction_seconds": construction_seconds, - "scope": "Point-input subgroup checks and encodings, then a dependent serial suffix; no recursive proof or lowering", - }) - ); - let mut reference = None::<(Vec, Vec)>; - let mut digest = String::new(); - let mut parallel_digest_checked = false; - let mut serial_seconds = Vec::new(); - let mut parallel_seconds = Vec::new(); - for sample in 0..5 { - let modes = if sample % 2 == 0 { - [("serial", &serial), ("parallel", ¶llel)] - } else { - [("parallel", ¶llel), ("serial", &serial)] - }; - for (mode, fixture) in modes { - let start = Instant::now(); - let assignment = fixture.generate().unwrap(); - let seconds = start.elapsed().as_secs_f64(); - assert_eq!(assignment.public_values(), fixture.expected_publics); - if let Some((values, publics)) = &reference { - assert!(assignment.values(fixture.circuit.id()).unwrap() == values); - assert_eq!(assignment.public_values(), publics); - } else { - digest = assignment_digest(&fixture.circuit, &assignment); - } - if fixture.certified && !parallel_digest_checked { - assert_eq!(assignment_digest(&fixture.circuit, &assignment), digest); - parallel_digest_checked = true; - } - if fixture.certified { - parallel_seconds.push(seconds); - } else { - serial_seconds.push(seconds); - } - println!( - "NATIVE_POINT_PREFIX_SAMPLE {}", - serde_json::json!({ - "sample": sample, - "mode": mode, - "seconds": seconds, - "full_assignment_equal": true, - "input_mapping_equal": true, - "public_encodings_equal": true, - "full_relation_checked": true, - "assignment_blake3": digest, - }) - ); - if reference.is_none() { - reference = Some(( - assignment.values(fixture.circuit.id()).unwrap().to_vec(), - assignment.public_values().to_vec(), - )); - } - } - } - serial_seconds.sort_by(f64::total_cmp); - parallel_seconds.sort_by(f64::total_cmp); - println!( - "NATIVE_POINT_PREFIX_RESULT {}", - serde_json::json!({ - "dynamic_points": count, - "paired_samples": 5, - "serial_median_seconds": serial_seconds[2], - "parallel_median_seconds": parallel_seconds[2], - "speedup": serial_seconds[2] / parallel_seconds[2], - "full_assignment_equal": true, - "assignment_blake3": digest, - "timing_scope": "Input assignment, recipe evaluation and complete frontend relation checks; excludes construction, comparisons, checksums and assignment destruction", - }) - ); -} diff --git a/crates/proof-compression/src/test_support.rs b/crates/proof-compression/src/test_support.rs new file mode 100644 index 000000000..f4f7b8c3f --- /dev/null +++ b/crates/proof-compression/src/test_support.rs @@ -0,0 +1,45 @@ +//! Helpers shared by the unit tests. +use std::{ + fs, + ops::Deref, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +/// A fresh, empty directory under the system temporary directory. It is +/// removed with its contents on drop, so a failing test leaves nothing +/// behind; a test may also delete it early. +pub(crate) struct TempDir(PathBuf); + +impl TempDir { + pub(crate) fn new(name: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "ix-{name}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Deref for TempDir { + type Target = Path; + + fn deref(&self) -> &Path { + &self.0 + } +} + +impl AsRef for TempDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} diff --git a/crates/proof-compression/tests/bn254_kzg.rs b/crates/proof-compression/tests/bn254_kzg.rs deleted file mode 100644 index 2bbc7d373..000000000 --- a/crates/proof-compression/tests/bn254_kzg.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::sync::Arc; - -use ark_bn254::Fr; -use ark_ec::{AffineRepr, CurveGroup}; -use ark_ff::{BigInteger, PrimeField}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use ix_proof_compression::kzg::compact::FixedProofCodec; -use ix_proof_compression::kzg::curve::{G1Affine, G2Affine}; -use ix_proof_compression::kzg::encoding::{read_scalars, write_scalars}; -use ix_proof_compression::kzg::{ - KzgConfig, Scalar, Srs, TranscriptKind, VerifierParams, -}; -use multi_stark::config::ProofConfig; -use multi_stark::expr::Expr; -use multi_stark::lookup::Lookup; -use multi_stark::system::{CircuitInputs, System, SystemWitness}; -use multi_stark::traits::{Algebra, Field, TwoAdicField}; -use p3_matrix::dense::RowMajorMatrix; - -const HEIGHTS: [usize; 2] = [4, 8]; -const QUOTIENT_DEGREE: usize = 4; -const SEED: &[u8] = b"bn254-kzg-integration"; - -fn claims() -> [[Scalar; 3]; 2] { - HEIGHTS.map(|height| { - let last = Scalar::from_usize(height - 1); - [Scalar::from_usize(height), last, last * last] - }) -} - -#[test] -fn blake3_proof_roundtrips_and_rejects_tampering_without_proving_powers() { - let srs = Srs::unsafe_dev_setup(8, SEED); - srs.validate().unwrap(); - let config = KzgConfig::new(Arc::new(srs), QUOTIENT_DEGREE) - .with_transcript(TranscriptKind::Blake3); - let (mut system, key) = System::new( - config, - HEIGHTS.map(|height| { - let x = Expr::main(0); - let square = Expr::main(1); - CircuitInputs { - main_width: 2, - preprocessed: Some(RowMajorMatrix::new( - (0..height) - .flat_map(|row| { - [Scalar::from_usize(row), Scalar::from_bool(row + 1 == height)] - }) - .collect(), - 2, - )), - constraints: vec![ - x.clone() - Expr::preprocessed(0), - square.clone() - x.clone() * x.clone(), - Expr::IsTransition - * (Expr::main_next(0) - x.clone() - Expr::constant(Scalar::ONE)), - ], - lookups: vec![Lookup::pull( - Expr::preprocessed(1), - vec![Expr::constant(Scalar::from_usize(height)), x, square], - )], - ..Default::default() - } - }), - ); - let claims = claims(); - let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); - let traces = HEIGHTS - .map(|height| { - RowMajorMatrix::new( - (0..height) - .flat_map(|row| { - let x = Scalar::from_usize(row); - [x, x * x] - }) - .collect(), - 2, - ) - }) - .to_vec(); - let proof = system.prove_multiple_claims( - &key, - &refs, - SystemWitness::from_stage_1(traces, &system), - ); - system.verify_multiple_claims(&refs, &proof).unwrap(); - assert_eq!(proof.log_degrees, [2, 3]); - assert_eq!(proof.opening_proof.0.len(), 3); - - let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); - let encoded = codec.encode(&proof).unwrap(); - assert_eq!(&encoded[..4], b"KBN1"); - let mut wrong_curve = encoded.clone(); - wrong_curve[..4].copy_from_slice(b"KQP4"); - assert!(codec.decode(&wrong_curve).is_err()); - let decoded = codec.decode(&encoded).unwrap(); - assert_eq!(codec.encode(&decoded).unwrap(), encoded); - - let anchors = Srs::unsafe_dev_setup_prefix(2, 7, SEED); - let params = VerifierParams::with_max_trace_len(&anchors, 8); - let parameters = params.to_bytes().unwrap(); - assert_eq!( - parameters, - system.config.pcs().verifier_params().to_bytes().unwrap() - ); - let params = VerifierParams::from_bytes(¶meters).unwrap(); - assert_eq!(params.public_setup(), anchors.public_setup); - assert_eq!(params.max_trace_len(), 8); - let verifier = KzgConfig::verifier_only(params, QUOTIENT_DEGREE) - .with_transcript(TranscriptKind::Blake3); - assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); - system.config = verifier.clone(); - drop(key); - system.verify_multiple_claims(&refs, &proof).unwrap(); - system.verify_multiple_claims(&refs, &decoded).unwrap(); - - let mut changed_claims = claims; - changed_claims[0][2] += Scalar::ONE; - let changed_refs: Vec<_> = - changed_claims.iter().map(|claim| claim.as_slice()).collect(); - assert!(system.verify_multiple_claims(&changed_refs, &decoded).is_err()); - - for attack in ["evaluation", "commitment", "opening", "accumulator"] { - let mut bad = proof.clone(); - match attack { - "evaluation" => bad.stage_1_opened_values[0][0][0] += Scalar::ONE, - "commitment" => { - let point = &mut bad.commitments.stage_1_trace.0[0][0]; - *point = (*point + G1Affine::generator()).into_affine(); - }, - "opening" => { - let point = &mut bad.opening_proof.0[0]; - *point = (*point + G1Affine::generator()).into_affine(); - }, - "accumulator" => bad.intermediate_accumulators[0] += Scalar::ONE, - _ => unreachable!(), - } - let bad = codec.decode(&codec.encode(&bad).unwrap()).unwrap(); - assert!( - system.verify_multiple_claims(&refs, &bad).is_err(), - "accepted changed {attack}" - ); - } - - for end in [0, 4, 5, encoded.len() - 1] { - assert!(codec.decode(&encoded[..end]).is_err()); - } - let mut bad = encoded.clone(); - bad.push(0); - assert!(codec.decode(&bad).is_err()); - let mut bad = encoded.clone(); - bad[5..37].fill(0xff); - assert!(codec.decode(&bad).is_err()); - let mut with_identity = proof.clone(); - with_identity.commitments.stage_1_trace.0[0][0] = G1Affine::identity(); - let mut aliased_identity = codec.encode(&with_identity).unwrap(); - codec.decode(&aliased_identity).unwrap(); - aliased_identity[5] = 1; - assert!(codec.decode(&aliased_identity).is_err()); - let mut bad = encoded; - let scalar_start = bad.len() - 32; - bad[scalar_start..].copy_from_slice(&Fr::MODULUS.to_bytes_le()); - assert!(codec.decode(&bad).is_err()); - - system.config = verifier.with_transcript(TranscriptKind::Keccak256); - assert!(system.verify_multiple_claims(&refs, &proof).is_err()); -} - -#[test] -fn bn254_scalars_and_points_use_canonical_encodings() { - let _: Fr = Scalar::ONE.0; - assert_eq!(Scalar::TWO_ADICITY, 28); - let values = [Scalar::ZERO, Scalar::ONE, Scalar::NEG_ONE]; - let mut encoded = Vec::new(); - write_scalars(&mut encoded, &values).unwrap(); - assert_eq!(encoded.len(), 3 * 32); - assert_eq!(read_scalars(&mut encoded.as_slice(), 3).unwrap(), values); - let mut native = Vec::new(); - for value in values { - value.0.serialize_compressed(&mut native).unwrap(); - } - assert_eq!(encoded, native); - let modulus = Fr::MODULUS.to_bytes_le(); - assert!(read_scalars(&mut modulus.as_slice(), 1).is_err()); - assert!(read_scalars(&mut &encoded[..31], 1).is_err()); - - for point in [G1Affine::generator(), G1Affine::identity()] { - let mut bytes = Vec::new(); - point.serialize_compressed(&mut bytes).unwrap(); - assert_eq!(bytes.len(), 32); - assert_eq!( - G1Affine::deserialize_compressed(bytes.as_slice()).unwrap(), - point - ); - } - let point = G2Affine::generator(); - let mut bytes = Vec::new(); - point.serialize_compressed(&mut bytes).unwrap(); - assert_eq!(bytes.len(), 64); - assert_eq!( - G2Affine::deserialize_compressed(bytes.as_slice()).unwrap(), - point - ); - assert!(G1Affine::deserialize_compressed([0xff; 32].as_slice()).is_err()); -} - -#[test] -fn portable_parameters_reject_inconsistent_anchors_and_extra_bytes() { - let mut srs = Srs::unsafe_dev_setup(8, SEED); - let parameters = VerifierParams::from_srs(&srs).to_bytes().unwrap(); - let mut trailing = parameters.clone(); - trailing.push(0); - assert!(VerifierParams::from_bytes(&trailing).is_err()); - assert!( - VerifierParams::from_bytes(¶meters[..parameters.len() - 1]).is_err() - ); - - srs.g1[1] = (srs.g1[1] + G1Affine::generator()).into_affine(); - let inconsistent = VerifierParams::from_srs(&srs).to_bytes().unwrap(); - assert!(VerifierParams::from_bytes(&inconsistent).is_err()); -} diff --git a/crates/proof-compression/tests/integration/kzg.rs b/crates/proof-compression/tests/integration/kzg.rs new file mode 100644 index 000000000..9060ee1c3 --- /dev/null +++ b/crates/proof-compression/tests/integration/kzg.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; + +use ark_bn254::Fr; +use ark_ec::{AffineRepr, CurveGroup}; +use ark_ff::{BigInteger, PrimeField}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use ix_proof_compression::kzg::compact::FixedProofCodec; +use ix_proof_compression::kzg::curve::{G1Affine, G2Affine}; +use ix_proof_compression::kzg::encoding::{read_scalars, write_scalars}; +use ix_proof_compression::kzg::{ + KzgConfig, Scalar, Srs, TranscriptKind, VerifierParams, +}; +use multi_stark::config::ProofConfig; +use multi_stark::expr::Expr; +use multi_stark::lookup::Lookup; +use multi_stark::prover::Proof; +use multi_stark::system::{CircuitInputs, ProverKey, System, SystemWitness}; +use multi_stark::traits::{Algebra, Field, TwoAdicField}; +use p3_matrix::dense::RowMajorMatrix; + +const HEIGHTS: [usize; 2] = [4, 8]; +const QUOTIENT_DEGREE: usize = 4; +const SEED: &[u8] = b"bn254-kzg-integration"; + +fn config() -> KzgConfig { + let srs = Srs::unsafe_dev_setup(8, SEED); + srs.validate().unwrap(); + KzgConfig::new(Arc::new(srs), QUOTIENT_DEGREE) +} + +fn system(config: KzgConfig) -> (System, ProverKey) { + System::new( + config, + HEIGHTS.map(|height| { + let x = Expr::main(0); + let square = Expr::main(1); + CircuitInputs { + main_width: 2, + preprocessed: Some(RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + [Scalar::from_usize(row), Scalar::from_bool(row + 1 == height)] + }) + .collect(), + 2, + )), + constraints: vec![ + x.clone() - Expr::preprocessed(0), + square.clone() - x.clone() * x.clone(), + Expr::IsTransition + * (Expr::main_next(0) - x.clone() - Expr::constant(Scalar::ONE)), + ], + // A fixed Boolean column gives the claim multiplicity one; + // raw Lagrange boundary selectors are not normalized. + lookups: vec![Lookup::pull( + Expr::preprocessed(1), + vec![Expr::constant(Scalar::from_usize(height)), x, square], + )], + ..Default::default() + } + }), + ) +} + +fn claims() -> [[Scalar; 3]; 2] { + HEIGHTS.map(|height| { + let last = Scalar::from_usize(height - 1); + [Scalar::from_usize(height), last, last * last] + }) +} + +fn prove( + system: &System, + key: &ProverKey, +) -> Proof { + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let traces = HEIGHTS + .map(|height| { + RowMajorMatrix::new( + (0..height) + .flat_map(|row| { + let x = Scalar::from_usize(row); + [x, x * x] + }) + .collect(), + 2, + ) + }) + .to_vec(); + system.prove_multiple_claims( + key, + &refs, + SystemWitness::from_stage_1(traces, system), + ) +} + +#[test] +fn transcript_kinds_verify_compact_proofs_and_reject_tampering() { + let base = config(); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let mut encodings = Vec::new(); + for kind in [TranscriptKind::Blake3, TranscriptKind::Keccak256] { + let (mut system, key) = system(base.clone().with_transcript(kind)); + assert_eq!(system.config.transcript_kind(), kind); + let proof = prove(&system, &key); + drop(key); + system.verify_multiple_claims(&refs, &proof).unwrap(); + assert_eq!(proof.log_degrees, [2, 3]); + assert_eq!(proof.opening_proof.0.len(), 3); + + let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); + let encoded = codec.encode(&proof).unwrap(); + assert_eq!(&encoded[..4], b"KBN1"); + let mut wrong_tag = encoded.clone(); + wrong_tag[..4].copy_from_slice(b"KQP4"); + assert!(codec.decode(&wrong_tag).is_err()); + let decoded = codec.decode(&encoded).unwrap(); + assert_eq!(codec.encode(&decoded).unwrap(), encoded); + + // A verifier built from the portable anchors alone accepts both forms. + let anchors = Srs::unsafe_dev_setup_prefix(2, 7, SEED); + let params = VerifierParams::with_max_trace_len(&anchors, 8); + let parameters = params.to_bytes().unwrap(); + assert_eq!( + parameters, + system.config.pcs().verifier_params().to_bytes().unwrap() + ); + let params = VerifierParams::from_bytes(¶meters).unwrap(); + assert_eq!(params.public_setup(), anchors.public_setup); + assert_eq!(params.max_trace_len(), 8); + let verifier = + KzgConfig::verifier_only(params, QUOTIENT_DEGREE).with_transcript(kind); + assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); + system.config = verifier.clone(); + system.verify_multiple_claims(&refs, &proof).unwrap(); + system.verify_multiple_claims(&refs, &decoded).unwrap(); + + let mut changed_claims = claims; + changed_claims[0][2] += Scalar::ONE; + let changed_refs: Vec<_> = + changed_claims.iter().map(|claim| claim.as_slice()).collect(); + assert!( + system.verify_multiple_claims(&changed_refs, &decoded).is_err(), + "{kind:?} accepted a changed public claim" + ); + + for attack in ["evaluation", "commitment", "opening", "accumulator"] { + let mut bad = proof.clone(); + match attack { + "evaluation" => bad.stage_1_opened_values[0][0][0] += Scalar::ONE, + "commitment" => { + let point = &mut bad.commitments.stage_1_trace.0[0][0]; + *point = (*point + G1Affine::generator()).into_affine(); + }, + "opening" => { + let point = &mut bad.opening_proof.0[0]; + *point = (*point + G1Affine::generator()).into_affine(); + }, + "accumulator" => bad.intermediate_accumulators[0] += Scalar::ONE, + _ => unreachable!(), + } + let bad = codec.decode(&codec.encode(&bad).unwrap()).unwrap(); + assert!( + system.verify_multiple_claims(&refs, &bad).is_err(), + "{kind:?} accepted a changed {attack}" + ); + } + + for end in [0, 4, 5, encoded.len() - 1] { + assert!(codec.decode(&encoded[..end]).is_err()); + } + let mut bad = encoded.clone(); + bad.push(0); + assert!(codec.decode(&bad).is_err()); + let mut bad = encoded.clone(); + bad[5..37].fill(0xff); + assert!(codec.decode(&bad).is_err()); + let mut with_identity = proof.clone(); + with_identity.commitments.stage_1_trace.0[0][0] = G1Affine::identity(); + let mut aliased_identity = codec.encode(&with_identity).unwrap(); + codec.decode(&aliased_identity).unwrap(); + aliased_identity[5] = 1; + assert!(codec.decode(&aliased_identity).is_err()); + let mut bad = encoded.clone(); + let scalar_start = bad.len() - 32; + bad[scalar_start..].copy_from_slice(&Fr::MODULUS.to_bytes_le()); + assert!(codec.decode(&bad).is_err()); + encodings.push(encoded); + + let other = match kind { + TranscriptKind::Blake3 => TranscriptKind::Keccak256, + TranscriptKind::Keccak256 => TranscriptKind::Blake3, + }; + system.config = verifier.with_transcript(other); + assert!( + system.verify_multiple_claims(&refs, &proof).is_err(), + "{other:?} accepted a {kind:?} proof" + ); + assert!( + system.verify_multiple_claims(&refs, &decoded).is_err(), + "{other:?} accepted a compact {kind:?} proof" + ); + } + assert_ne!(encodings[0], encodings[1]); + assert_eq!(encodings[0].len(), encodings[1].len()); +} + +#[test] +fn default_transcript_preserves_explicit_blake3_proof_bytes() { + let config = config(); + assert_eq!(config.transcript_kind(), TranscriptKind::Blake3); + let (mut system, key) = system(config.clone()); + let claims = claims(); + let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); + let default = prove(&system, &key); + system.verify_multiple_claims(&refs, &default).unwrap(); + let codec = FixedProofCodec::new(&system, &default.log_degrees).unwrap(); + + system.config = config.with_transcript(TranscriptKind::Blake3); + let explicit = prove(&system, &key); + system.verify_multiple_claims(&refs, &explicit).unwrap(); + assert_eq!(codec.encode(&default).unwrap(), codec.encode(&explicit).unwrap()); +} + +#[test] +fn bn254_scalars_and_points_use_canonical_encodings() { + let _: Fr = Scalar::ONE.0; + assert_eq!(Scalar::TWO_ADICITY, 28); + let values = [Scalar::ZERO, Scalar::ONE, Scalar::NEG_ONE]; + let mut encoded = Vec::new(); + write_scalars(&mut encoded, &values).unwrap(); + assert_eq!(encoded.len(), 3 * 32); + assert_eq!(read_scalars(&mut encoded.as_slice(), 3).unwrap(), values); + let mut native = Vec::new(); + for value in values { + value.0.serialize_compressed(&mut native).unwrap(); + } + assert_eq!(encoded, native); + let modulus = Fr::MODULUS.to_bytes_le(); + assert!(read_scalars(&mut modulus.as_slice(), 1).is_err()); + assert!(read_scalars(&mut &encoded[..31], 1).is_err()); + + for point in [G1Affine::generator(), G1Affine::identity()] { + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), 32); + assert_eq!( + G1Affine::deserialize_compressed(bytes.as_slice()).unwrap(), + point + ); + } + let point = G2Affine::generator(); + let mut bytes = Vec::new(); + point.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), 64); + assert_eq!( + G2Affine::deserialize_compressed(bytes.as_slice()).unwrap(), + point + ); + assert!(G1Affine::deserialize_compressed([0xff; 32].as_slice()).is_err()); +} + +#[test] +fn portable_parameters_reject_inconsistent_anchors_and_extra_bytes() { + let mut srs = Srs::unsafe_dev_setup(8, SEED); + let parameters = VerifierParams::from_srs(&srs).to_bytes().unwrap(); + let mut trailing = parameters.clone(); + trailing.push(0); + assert!(VerifierParams::from_bytes(&trailing).is_err()); + assert!( + VerifierParams::from_bytes(¶meters[..parameters.len() - 1]).is_err() + ); + + srs.g1[1] = (srs.g1[1] + G1Affine::generator()).into_affine(); + let inconsistent = VerifierParams::from_srs(&srs).to_bytes().unwrap(); + assert!(VerifierParams::from_bytes(&inconsistent).is_err()); +} diff --git a/crates/proof-compression/tests/kzg_sharding.rs b/crates/proof-compression/tests/integration/kzg_sharding.rs similarity index 100% rename from crates/proof-compression/tests/kzg_sharding.rs rename to crates/proof-compression/tests/integration/kzg_sharding.rs diff --git a/crates/proof-compression/tests/kzg_streaming.rs b/crates/proof-compression/tests/integration/kzg_streaming.rs similarity index 100% rename from crates/proof-compression/tests/kzg_streaming.rs rename to crates/proof-compression/tests/integration/kzg_streaming.rs diff --git a/crates/proof-compression/tests/integration/main.rs b/crates/proof-compression/tests/integration/main.rs new file mode 100644 index 000000000..ef34a6b57 --- /dev/null +++ b/crates/proof-compression/tests/integration/main.rs @@ -0,0 +1,16 @@ +//! Integration tests of the FRI verifier circuits, the KZG backend and the +//! compression pipeline, as one test binary. +mod support; + +mod kzg; +mod kzg_sharding; +mod kzg_streaming; +mod parity_fixture; +mod pipeline_root; +mod plonkish_batch; +mod plonkish_hash; +mod plonkish_pcs; +mod plonkish_plan; +mod plonkish_verifier_algebra; +mod plonkish_verifier_security; +mod root_profile; diff --git a/crates/proof-compression/tests/parity_fixture.rs b/crates/proof-compression/tests/integration/parity_fixture.rs similarity index 99% rename from crates/proof-compression/tests/parity_fixture.rs rename to crates/proof-compression/tests/integration/parity_fixture.rs index 96375a630..fb6fb902b 100644 --- a/crates/proof-compression/tests/parity_fixture.rs +++ b/crates/proof-compression/tests/integration/parity_fixture.rs @@ -1,7 +1,6 @@ //! Pin the inner statement and fixed shape before building its circuit verifier. -#[path = "support/parity.rs"] -mod parity; +use crate::support::parity; use std::collections::HashMap; diff --git a/crates/proof-compression/tests/pipeline_root.rs b/crates/proof-compression/tests/integration/pipeline_root.rs similarity index 100% rename from crates/proof-compression/tests/pipeline_root.rs rename to crates/proof-compression/tests/integration/pipeline_root.rs diff --git a/crates/proof-compression/tests/plonkish_batch.rs b/crates/proof-compression/tests/integration/plonkish_batch.rs similarity index 99% rename from crates/proof-compression/tests/plonkish_batch.rs rename to crates/proof-compression/tests/integration/plonkish_batch.rs index 39065cc46..3232e99d8 100644 --- a/crates/proof-compression/tests/plonkish_batch.rs +++ b/crates/proof-compression/tests/integration/plonkish_batch.rs @@ -1,5 +1,4 @@ -#[path = "support/parity.rs"] -mod fixture; +use crate::support::parity as fixture; use ix_proof_compression::fri::ExpandedPcsWitness; use ix_proof_compression::fri::FixedPcsShape; use ix_proof_compression::fri::FixedVerifierInputs; diff --git a/crates/proof-compression/tests/plonkish_hash.rs b/crates/proof-compression/tests/integration/plonkish_hash.rs similarity index 100% rename from crates/proof-compression/tests/plonkish_hash.rs rename to crates/proof-compression/tests/integration/plonkish_hash.rs diff --git a/crates/proof-compression/tests/plonkish_pcs.rs b/crates/proof-compression/tests/integration/plonkish_pcs.rs similarity index 99% rename from crates/proof-compression/tests/plonkish_pcs.rs rename to crates/proof-compression/tests/integration/plonkish_pcs.rs index 48281cf25..f0bb1463c 100644 --- a/crates/proof-compression/tests/plonkish_pcs.rs +++ b/crates/proof-compression/tests/integration/plonkish_pcs.rs @@ -1,5 +1,4 @@ -#[path = "support/parity.rs"] -mod parity; +use crate::support::parity; use ix_proof_compression::fri::FixedPcsShape; use ix_proof_compression::fri::build_fixed_verifier; diff --git a/crates/proof-compression/tests/plonkish_plan.rs b/crates/proof-compression/tests/integration/plonkish_plan.rs similarity index 99% rename from crates/proof-compression/tests/plonkish_plan.rs rename to crates/proof-compression/tests/integration/plonkish_plan.rs index b7cd2f975..3a5f759f5 100644 --- a/crates/proof-compression/tests/plonkish_plan.rs +++ b/crates/proof-compression/tests/integration/plonkish_plan.rs @@ -1,4 +1,5 @@ //! Tests the generic generator using a small lookup system. +use crate::support::root_profile as root; use ix_proof_compression::fri::*; use multi_stark::batch::BatchMessage; use multi_stark::batch::ShardInput; @@ -329,9 +330,6 @@ fn key_transport_constants_and_implementation_equivalence() { )); } -#[allow(dead_code)] -#[path = "support/root_profile.rs"] -mod root; #[test] fn generic_plan_handles_sparse_mixed_heights_and_grinding() { let profile = root::Profile::Smoke; diff --git a/crates/proof-compression/tests/plonkish_verifier_algebra.rs b/crates/proof-compression/tests/integration/plonkish_verifier_algebra.rs similarity index 99% rename from crates/proof-compression/tests/plonkish_verifier_algebra.rs rename to crates/proof-compression/tests/integration/plonkish_verifier_algebra.rs index 5eb25bbac..dd81a9f26 100644 --- a/crates/proof-compression/tests/plonkish_verifier_algebra.rs +++ b/crates/proof-compression/tests/integration/plonkish_verifier_algebra.rs @@ -1,7 +1,5 @@ -#[path = "support/parity_algebra.rs"] -mod adapter; -#[path = "support/parity.rs"] -mod parity; +use crate::support::parity; +use crate::support::parity_algebra as adapter; use ix_proof_compression::fri::AlgebraicInputs; use ix_proof_compression::fri::AlgebraicOutputs; diff --git a/crates/proof-compression/tests/plonkish_verifier_security.rs b/crates/proof-compression/tests/integration/plonkish_verifier_security.rs similarity index 99% rename from crates/proof-compression/tests/plonkish_verifier_security.rs rename to crates/proof-compression/tests/integration/plonkish_verifier_security.rs index 060529fef..bd03824c7 100644 --- a/crates/proof-compression/tests/plonkish_verifier_security.rs +++ b/crates/proof-compression/tests/integration/plonkish_verifier_security.rs @@ -1,5 +1,4 @@ -#[path = "support/verifier_audit.rs"] -mod fixture; +use crate::support::verifier_audit as fixture; use ix_proof_compression::fri::*; use multi_stark::batch::ShardInput; diff --git a/crates/proof-compression/tests/root_profile.rs b/crates/proof-compression/tests/integration/root_profile.rs similarity index 99% rename from crates/proof-compression/tests/root_profile.rs rename to crates/proof-compression/tests/integration/root_profile.rs index 455d24c61..3030b807f 100644 --- a/crates/proof-compression/tests/root_profile.rs +++ b/crates/proof-compression/tests/integration/root_profile.rs @@ -1,5 +1,4 @@ -#[path = "support/root_profile.rs"] -mod fixture; +use crate::support::root_profile as fixture; use ix_proof_compression::fri::ExpandedPcsWitness; use ix_proof_compression::fri::expand_pcs_witness; diff --git a/crates/proof-compression/tests/integration/support/mod.rs b/crates/proof-compression/tests/integration/support/mod.rs new file mode 100644 index 000000000..c8b40ad41 --- /dev/null +++ b/crates/proof-compression/tests/integration/support/mod.rs @@ -0,0 +1,5 @@ +//! Fixtures shared by the integration tests. +pub(crate) mod parity; +pub(crate) mod parity_algebra; +pub(crate) mod root_profile; +pub(crate) mod verifier_audit; diff --git a/crates/proof-compression/tests/support/parity.rs b/crates/proof-compression/tests/integration/support/parity.rs similarity index 100% rename from crates/proof-compression/tests/support/parity.rs rename to crates/proof-compression/tests/integration/support/parity.rs diff --git a/crates/proof-compression/tests/support/parity_algebra.rs b/crates/proof-compression/tests/integration/support/parity_algebra.rs similarity index 100% rename from crates/proof-compression/tests/support/parity_algebra.rs rename to crates/proof-compression/tests/integration/support/parity_algebra.rs diff --git a/crates/proof-compression/tests/support/root_profile.rs b/crates/proof-compression/tests/integration/support/root_profile.rs similarity index 100% rename from crates/proof-compression/tests/support/root_profile.rs rename to crates/proof-compression/tests/integration/support/root_profile.rs diff --git a/crates/proof-compression/tests/support/verifier_audit.rs b/crates/proof-compression/tests/integration/support/verifier_audit.rs similarity index 100% rename from crates/proof-compression/tests/support/verifier_audit.rs rename to crates/proof-compression/tests/integration/support/verifier_audit.rs diff --git a/crates/proof-compression/tests/kzg_transcripts.rs b/crates/proof-compression/tests/kzg_transcripts.rs deleted file mode 100644 index 3f81490fc..000000000 --- a/crates/proof-compression/tests/kzg_transcripts.rs +++ /dev/null @@ -1,181 +0,0 @@ -use std::sync::Arc; - -use ix_proof_compression::kzg::compact::FixedProofCodec; -use ix_proof_compression::kzg::{KzgConfig, Scalar, Srs, TranscriptKind}; -use multi_stark::config::ProofConfig; -use multi_stark::expr::Expr; -use multi_stark::lookup::Lookup; -use multi_stark::prover::Proof; -use multi_stark::system::{CircuitInputs, ProverKey, System, SystemWitness}; -use multi_stark::traits::{Algebra, Field}; -use p3_matrix::dense::RowMajorMatrix; - -const HEIGHTS: [usize; 2] = [4, 8]; -const MAX_QUOTIENT_DEGREE: usize = 4; - -fn config() -> KzgConfig { - KzgConfig::new( - Arc::new(Srs::unsafe_dev_setup(8, b"kzg-transcript-integration")), - MAX_QUOTIENT_DEGREE, - ) -} - -fn system(config: KzgConfig) -> (System, ProverKey) { - System::new( - config, - HEIGHTS.map(|height| { - let x = Expr::main(0); - let square = Expr::main(1); - CircuitInputs { - main_width: 2, - preprocessed: Some(RowMajorMatrix::new( - (0..height) - .flat_map(|row| { - [ - Scalar::from_usize(row), - if row + 1 == height { Scalar::ONE } else { Scalar::ZERO }, - ] - }) - .collect(), - 2, - )), - constraints: vec![ - x.clone() - Expr::preprocessed(0), - square.clone() - x.clone() * x.clone(), - Expr::IsTransition - * (Expr::main_next(0) - x.clone() - Expr::constant(Scalar::ONE)), - ], - // A fixed Boolean column gives the claim multiplicity one; - // raw Lagrange boundary selectors are not normalized. - lookups: vec![Lookup::pull( - Expr::preprocessed(1), - vec![Expr::constant(Scalar::from_usize(height)), x, square], - )], - ..Default::default() - } - }), - ) -} - -fn claims() -> [[Scalar; 3]; 2] { - HEIGHTS.map(|height| { - let last = Scalar::from_usize(height - 1); - [Scalar::from_usize(height), last, last * last] - }) -} - -fn prove( - system: &System, - key: &ProverKey, -) -> Proof { - let claims = claims(); - let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); - let traces = HEIGHTS - .map(|height| { - RowMajorMatrix::new( - (0..height) - .flat_map(|row| { - let x = Scalar::from_usize(row); - [x, x * x] - }) - .collect(), - 2, - ) - }) - .to_vec(); - system.prove_multiple_claims( - key, - &refs, - SystemWitness::from_stage_1(traces, system), - ) -} - -#[test] -fn transcript_kinds_verify_compact_proofs_and_reject_tampering() { - let base = config(); - let claims = claims(); - let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); - let mut encodings = Vec::new(); - for kind in [TranscriptKind::Blake3, TranscriptKind::Keccak256] { - let (mut system, key) = system(base.clone().with_transcript(kind)); - assert_eq!(system.config.transcript_kind(), kind); - let proof = prove(&system, &key); - assert_eq!(proof.log_degrees, [2, 3]); - assert_eq!(proof.opening_proof.0.len(), 3); - system.verify_multiple_claims(&refs, &proof).unwrap(); - - let codec = FixedProofCodec::new(&system, &proof.log_degrees).unwrap(); - let compact = codec.encode(&proof).unwrap(); - let decoded = codec.decode(&compact).unwrap(); - assert_eq!(codec.encode(&decoded).unwrap(), compact); - system.verify_multiple_claims(&refs, &decoded).unwrap(); - encodings.push(compact); - - let verifier = KzgConfig::verifier_only( - system.config.pcs().verifier_params().clone(), - MAX_QUOTIENT_DEGREE, - ) - .with_transcript(kind); - assert_eq!(verifier.transcript_seed(), system.config.transcript_seed()); - system.config = verifier.clone(); - system.verify_multiple_claims(&refs, &proof).unwrap(); - system.verify_multiple_claims(&refs, &decoded).unwrap(); - - let mut wrong_claims = claims; - wrong_claims[0][2] += Scalar::ONE; - let wrong_refs: Vec<_> = - wrong_claims.iter().map(|claim| claim.as_slice()).collect(); - assert!( - system.verify_multiple_claims(&wrong_refs, &proof).is_err(), - "{kind:?} accepted a changed public claim" - ); - - let mut wrong_evaluation = proof.clone(); - wrong_evaluation.stage_1_opened_values[0][0][0] += Scalar::ONE; - assert!( - system.verify_multiple_claims(&refs, &wrong_evaluation).is_err(), - "{kind:?} accepted a changed opened value" - ); - - let mut wrong_opening = proof.clone(); - wrong_opening.opening_proof.0[0] = Default::default(); - assert_ne!(wrong_opening.opening_proof, proof.opening_proof); - assert!( - system.verify_multiple_claims(&refs, &wrong_opening).is_err(), - "{kind:?} accepted a changed KZG opening witness" - ); - - let other = match kind { - TranscriptKind::Blake3 => TranscriptKind::Keccak256, - TranscriptKind::Keccak256 => TranscriptKind::Blake3, - }; - system.config = verifier.with_transcript(other); - assert!( - system.verify_multiple_claims(&refs, &proof).is_err(), - "{other:?} accepted a {kind:?} proof" - ); - assert!( - system.verify_multiple_claims(&refs, &decoded).is_err(), - "{other:?} accepted a compact {kind:?} proof" - ); - } - assert_ne!(encodings[0], encodings[1]); - assert_eq!(encodings[0].len(), encodings[1].len()); -} - -#[test] -fn default_transcript_preserves_explicit_blake3_proof_bytes() { - let config = config(); - assert_eq!(config.transcript_kind(), TranscriptKind::Blake3); - let (mut system, key) = system(config.clone()); - let claims = claims(); - let refs: Vec<_> = claims.iter().map(|claim| claim.as_slice()).collect(); - let default = prove(&system, &key); - system.verify_multiple_claims(&refs, &default).unwrap(); - let codec = FixedProofCodec::new(&system, &default.log_degrees).unwrap(); - - system.config = config.with_transcript(TranscriptKind::Blake3); - let explicit = prove(&system, &key); - system.verify_multiple_claims(&refs, &explicit).unwrap(); - assert_eq!(codec.encode(&default).unwrap(), codec.encode(&explicit).unwrap()); -} diff --git a/crates/proof-compression/tests/plonkish_transcript.rs b/crates/proof-compression/tests/plonkish_transcript.rs deleted file mode 100644 index 8679f2cda..000000000 --- a/crates/proof-compression/tests/plonkish_transcript.rs +++ /dev/null @@ -1,95 +0,0 @@ -#[path = "support/parity_algebra.rs"] -mod adapter; -#[path = "support/parity.rs"] -mod parity; - -use ix_proof_compression::fri::AlgebraicInputs; -use ix_proof_compression::fri::ByteGadgets; -use ix_proof_compression::fri::TranscriptCommitments; -use ix_proof_compression::fri::constrain_algebraic_checks; -use ix_proof_compression::fri::constrain_transcript; -use multi_stark::p3_field::PrimeCharacteristicRing; -use multi_stark::plonkish::CircuitBuilder; -use multi_stark::system::System; -use multi_stark::system::SystemWitness; -use multi_stark::types::Val; - -#[test] -fn parity_transcript_and_algebra_match_native_and_reject_changed_roots() { - let (system, key) = System::new(parity::config(), parity::circuit_inputs()); - let mut builder = CircuitBuilder::new(); - let inputs = AlgebraicInputs::allocate(&mut builder, &system.circuits, &[3]); - let bytes = ByteGadgets::new(&mut builder); - let roots = TranscriptCommitments::allocate(&mut builder, &bytes); - constrain_transcript( - &mut builder, - &bytes, - &system, - &[parity::LOG_HEIGHT; 2], - &inputs, - &roots, - ); - constrain_algebraic_checks( - &mut builder, - &system.circuits, - &[parity::LOG_HEIGHT; 2], - &inputs, - ); - // This intermediate test deliberately omits PCS verification. - adapter::expose_boundary(&mut builder, &inputs); - for byte in roots.stage1.iter().chain(&roots.stage2).chain(&roots.quotient) { - builder.expose_public(byte.value()); - } - let circuit = builder.finish(); - println!( - "Transcript + algebra: {} gates, {} lookups, {} values", - circuit.gates().len(), - circuit.lookups().len(), - circuit.num_values() - ); - for (function, n) in [ - (parity::Function::Even, 0), - (parity::Function::Odd, 17), - (parity::Function::Even, 100), - (parity::Function::Odd, 127), - ] { - let claim = parity::claim(function, n, function.result(n)); - let proof = system.prove( - &key, - &claim, - SystemWitness::from_stage_1(parity::traces(function, n), &system), - ); - system.verify(&claim, &proof).unwrap(); - let challenges = adapter::challenges(&system, &proof, &[&claim]); - let digests = [ - proof.commitments.stage_1_trace.roots()[0], - proof.commitments.stage_2_trace.roots()[0], - proof.commitments.quotient_chunks.roots()[0], - ]; - for altered_root in [None, Some(0), Some(1), Some(2)] { - let mut witness = circuit.witness(); - adapter::assign(&mut witness, &inputs, &proof, &[&claim], challenges) - .unwrap(); - for (i, (wires, digest)) in - [&roots.stage1, &roots.stage2, &roots.quotient] - .into_iter() - .zip(digests) - .enumerate() - { - for (j, (wire, value)) in wires.iter().zip(digest).enumerate() { - let value = value ^ u8::from(altered_root == Some(i) && j == 0); - witness.set(wire.value(), Val::from_u8(value)).unwrap(); - } - } - let result = witness.generate(); - if altered_root.is_some() { - assert!(result.is_err(), "altered root {altered_root:?}"); - } else { - let assignment = result.unwrap(); - let mut expected = adapter::statement(&proof, &[&claim], challenges); - expected.extend(digests.into_iter().flatten().map(Val::from_u8)); - assert_eq!(assignment.public_values(), expected); - } - } - } -} diff --git a/docs/aiur-gpu-proving.md b/docs/aiur-gpu-proving.md index 33ff783f1..ac94fb046 100644 --- a/docs/aiur-gpu-proving.md +++ b/docs/aiur-gpu-proving.md @@ -1,4 +1,4 @@ -# Proving on GPUs: lanes, generated traces, and how to benchmark them +# Proving on GPUs: multi-GPU scheduling, generated traces, and how to benchmark them Everything in this document builds on prover-level trace sharding ([aiur-trace-sharding.md](aiur-trace-sharding.md)): one execution of a @@ -18,7 +18,7 @@ Three layers, each a Cargo feature over the previous one: | `cuda` | `IX_CUDA=1` | multi-stark's CUDA backend: device LDEs, Merkle trees, lookup and quotient kernels, with sppark for every NTT. Proofs are the same Goldilocks/BLAKE3 protocol the CPU verifies. | | `cuda-trace-codegen` | `IX_CUDA_TRACE_CODEGEN=1` | The CUDA row writers generated from the compiled bytecode (§4), their Rust seed packers, and the registry the trace runtime binds against. Implies `cuda`. | -The lanes scheduler (§3) needs only `cuda`; generated traces are an +The multi-GPU scheduler (§3) needs only `cuda`; generated traces are an optional provider inside it. On the host the ordinary `ix` binary runs everything else unchanged. @@ -78,15 +78,15 @@ ix shard mathlib.ixe --max-ram 230 --exec-jobs 3 --out mathlib.ixes # and its address printed on stdout. export AIUR_TRACE_ONLY_LOOKUPS=1 AIUR_MAX_PIECE_LOG_HEIGHT=24 export AIUR_TRACE_SHARD_MAX_CELLS=1500000000 -export AIUR_GPU_TRACE=generated AIUR_LANES_CACHE_DIR=$PWD/cache +export AIUR_GPU_TRACE=generated AIUR_PROOF_CACHE_DIR=$PWD/cache systemd-run --user --scope -q -p MemoryMax=920G -- \ ix prove --ixe mathlib.ixe --ixes mathlib.ixes \ --gpus 4 --exec-jobs 3 --max-ram 230 --out-ixes mathlib-proven.ixes \ - > lanes.out 2> lanes.err + > prove.out 2> prove.err # 3. Verify the root against the partition that was actually proven. ix verify --aggregate --structural-above 0 --ixe mathlib.ixe \ - --ixes mathlib-proven.ixes "$(tail -1 lanes.out)" + --ixes mathlib-proven.ixes "$(tail -1 prove.out)" ``` Rerunning the same command resumes: completed claim proofs and join @@ -97,11 +97,11 @@ belong to. The same two stages can still be driven by hand, one device per process, with `ix prove --trace-shards --shards ` for a GPU's claims and -`ix aggregate --trace-shards --subtree ` for its subtree; the lanes +`ix aggregate --trace-shards --subtree ` for its subtree; the multi-GPU scheduler is the one-process equivalent and is what the measurements in §6 used. -## 3. Lanes: one resident prover per device +## 3. Multi-GPU: one resident prover per device `ix prove --ixe E --ixes M` (on `--gpus N`, default every visible GPU) builds an `AiurSystem` pair per device from the two @@ -111,10 +111,10 @@ from them) and runs one scheduler over them: - **One execution pool.** Claims and ready joins execute on the host through one bounded queue, joins first, `--exec-jobs` executions at a - time per lane. A prepared record goes to whichever device is free, so no + time per GPU. A prepared record goes to whichever device is free, so no packing is decided ahead of time. - **Record reservations.** Each execution reserves its record's bytes from - a pool sized by `--max-ram` per lane; the reservation follows the record + a pool sized by `--max-ram` per GPU; the reservation follows the record through both proving rounds and is released when the proof lands. Under pressure, executions wait for capacity; if waiters block each other, the youngest is cancelled so the selected one can finish. @@ -130,10 +130,10 @@ from them) and runs one scheduler over them: the end. Memory is governed by three numbers and nothing else: `--max-ram` (the -record pool per lane), `--exec-jobs` (records in flight per lane, so the +record pool per GPU), `--exec-jobs` (records in flight per GPU, so the cold wave and the speculative execution concurrency), and the cgroup cap as the backstop. On a 4-GPU, 1 TiB host, `--max-ram 230 --exec-jobs 3` -keeps Mathlib's largest records (about 45 GiB) inside one lane's share and +keeps Mathlib's largest records (about 45 GiB) inside one GPU's share and the process peak under 750 GiB; `--exec-jobs 4` is 5% faster at the cost of the pool running at 99%. @@ -193,7 +193,7 @@ The ones a run needs, then the ones a measurement might. | `AIUR_MAX_PIECE_LOG_HEIGHT=24` | Caps a piece's trace height, so a tall circuit is cut shorter than the room alone requires, at one activation per extra piece. | | `AIUR_GPU_TRACE=cpu\|generated` | Main-trace provider (§4); `cpu` is the reference builder and the default. | | `AIUR_RECORD_MAX_BYTES` | The record ceiling (§3), default 128 GiB. Size it from the pool: 256 GiB at a 731 GiB pool held FLT's 174 GiB leaf without waits. | -| `AIUR_LANES_CACHE_DIR` | Where completed claim proofs, join subtrees and the partition checkpoint live between runs. | +| `AIUR_PROOF_CACHE_DIR` | Where completed claim proofs, join subtrees and the partition checkpoint live between runs. | | `--retention retain\|regenerate\|auto` | What a batch keeps between its two rounds. Every real GPU plan runs `regenerate`: round two rebuilds each shard from its seeds, which the resident seed cache makes cheap. | | `MULTI_STARK_CUDA_MIN_FREE_BYTES` | Device headroom the backend keeps by spilling LDEs to the host; default a quarter of the card. | | `MULTI_STARK_CUDA_MEMORY_LOG=1` | Logs stage-1 placement and per-lookup-job budget and free bytes to stderr. | @@ -206,14 +206,14 @@ command, recorded with its identities. The script that produced the measurements below does only this: write the provenance, sample the devices, run the one command under the cap, and keep the exit status. -Set `RUNS_DIR` to fast local storage for this script's outputs and lane cache. +Set `RUNS_DIR` to fast local storage for this script's outputs and proof cache. For subsequent KZG compression, see [the compression guide](../crates/proof-compression/README.md#proving-and-verification) for output and cache placement. `RUNS_DIR` does not configure KZG cache paths. ```sh #!/bin/bash -# run-lanes4.sh