diff --git a/.cargo/config.toml b/.cargo/config.toml index 5474bc4a9..76d3fea68 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,9 @@ [build] -rustflags = ["-Ctarget-cpu=native"] \ No newline at end of file +rustflags = ["-Ctarget-cpu=native"] + +# Lean's bundled toolchain links against glibc stubs older than 2.38, whose +# headers in C23 mode, the default of recent GCC, redirect `strtol` and its +# relatives to `__isoc23_*` symbols the stubs lack. C17 keeps symbols the link +# can resolve. +[env] +CFLAGS = "-std=gnu17" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6f29a5d8..465b7ab11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,9 +142,8 @@ jobs: image: nvidia/cuda:13.3.1-devel-ubuntu26.04 env: # This job validates feature plumbing and the Rust-staticlib-to-Lean - # link without paying for a GPU runner. Multi-stark CI compiles the - # complete supported cubin set; one representative architecture is - # sufficient for this downstream integration gate. + # link without paying for a GPU runner. One explicit architecture + # is sufficient for this downstream integration gate. MULTI_STARK_CUDA_ARCHS: "80" steps: # No sccache here yet. Once the GPU benchmark lands after #644, wire @@ -177,9 +176,10 @@ 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 - - name: Link a CUDA-enabled Lean executable - run: IX_CUDA=1 lake build bench-typecheck + run: | + cargo check --release --locked --workspace --all-targets --features ix-ffi/parallel,ix-ffi/cuda,ix-proof-compression/cuda + - name: Link CUDA-enabled Lean executables + run: IX_CUDA=1 lake build ix bench-typecheck lints: name: Lint workflows 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 b6820ad19..5b004cb2a 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,135 @@ dependencies = [ "object", ] +[[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" +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 +258,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -238,7 +379,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.117", ] [[package]] @@ -259,6 +400,8 @@ dependencies = [ "cfg-if", "constant_time_eq", "cpufeatures 0.3.0", + "memmap2", + "rayon-core", ] [[package]] @@ -288,6 +431,18 @@ dependencies = [ "objc2", ] +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -545,7 +700,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -569,7 +724,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -580,7 +735,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -642,7 +797,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -652,7 +807,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 +829,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -725,7 +880,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -775,6 +930,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 +969,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -813,7 +980,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]] @@ -849,7 +1036,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]] @@ -991,7 +1178,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1201,6 +1388,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -1693,7 +1886,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1835,6 +2028,7 @@ dependencies = [ "ix-common", "ix-compile", "ix-kernel", + "ix-proof-compression", "ixon", "ixvm-codegen", "lean-ffi", @@ -1875,6 +2069,42 @@ dependencies = [ "smallvec", ] +[[package]] +name = "ix-proof-compression" +version = "0.1.0" +dependencies = [ + "aiur", + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "bincode", + "bindgen", + "blake3", + "blst", + "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", + "rayon", + "serde", + "serde_json", + "sha2 0.10.9", + "sppark", + "tiny-keccak", + "tracing", + "tracing-subscriber", + "tracing-texray", +] + [[package]] name = "ixon" version = "0.1.0" @@ -2118,7 +2348,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=3515632f1dc1a5764b60fc0146ea2226bb6fc32d#3515632f1dc1a5764b60fc0146ea2226bb6fc32d" dependencies = [ "bincode", "itertools 0.14.0", @@ -2160,7 +2390,7 @@ checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2393,7 +2623,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]] @@ -2440,6 +2670,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -2459,7 +2699,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2885,7 +3125,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3009,7 +3249,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3043,7 +3283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -3102,7 +3342,7 @@ checksum = "a9a28b8493dd664c8b171dd944da82d933f7d456b829bfb236738e1fe06c5ba4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3181,13 +3421,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 +3452,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 +3472,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 +3650,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3454,7 +3720,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]] @@ -3512,7 +3778,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3650,7 +3916,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3689,8 +3955,8 @@ dependencies = [ [[package]] name = "sppark" -version = "0.1.15" -source = "git+https://github.com/argumentcomputer/sppark?rev=e10e107673aa22861f0f8b9758fc62169ab919ae#e10e107673aa22861f0f8b9758fc62169ab919ae" +version = "0.1.16" +source = "git+https://github.com/argumentcomputer/sppark?rev=9326ac014ec5415ebc4eab24c1246276714aee57#9326ac014ec5415ebc4eab24c1246276714aee57" dependencies = [ "cc", "which", @@ -3739,7 +4005,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3759,6 +4025,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 +4053,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3792,7 +4069,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]] @@ -3812,7 +4089,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3824,6 +4101,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.3.47" @@ -3916,7 +4202,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4072,7 +4358,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4349,7 +4635,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4531,7 +4817,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4542,7 +4828,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4735,7 +5021,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4751,7 +5037,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4867,7 +5153,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4894,7 +5180,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4914,7 +5200,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4935,7 +5221,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4968,7 +5254,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fb42bb09..7d028c82c 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 = "3515632f1dc1a5764b60fc0146ea2226bb6fc32d" } nom = "7.1.3" num-bigint = "0.4.6" quickcheck = "1.0.3" 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/Aggr/FFI.lean b/Ix/Aggr/FFI.lean index 984c02bf0..6625fa21a 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 @@ -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,20 +109,23 @@ 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; +`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) - (cacheFriBytes : @& ByteArray) (outManifest : @& String) : Except String String + (verifyIdx aggrIdx gpus maxRamBytes execJobs structuralAbove : @& Nat) + (cacheFriBytes : @& ByteArray) (outManifest : @& String) (fresh : Bool := false) : Except String String end AiurSystem end Aiur diff --git a/Ix/Cli/AggregateCmd.lean b/Ix/Cli/AggregateCmd.lean index ec7e0e235..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 @@ -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 new file mode 100644 index 000000000..3efcfa0ce --- /dev/null +++ b/Ix/Cli/CompressCmd.lean @@ -0,0 +1,122 @@ +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 + finalProof : UInt8 := 0 + devKzgSrs : Bool := false + devFinalSrs : Bool := false + out : Option String := none + rootVk : Option String := none + fixedRoot : Option String := none + fresh : Bool := false + +/-- Select the proof system independently of its setup policy. -/ +def finalOf : String → Except String UInt8 + | "kzg" => .ok 0 + | "plonk" => .ok 1 + | "groth16" => .ok 2 + | 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 + 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.devKzgSrs opts.devFinalSrs opts.finalProof + +/-- Compress the aggregate root `addr`, persist the compressed proof under the +same claim and return its address. -/ +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" + -- 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 + 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 + 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.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" + let finalProof : UInt8 := if p.hasFlag "groth16" then 2 + else if p.hasFlag "plonk" then 1 else 0 + 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 => + 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 CheckEnv proof; preserve its claim and print the new proof address." + FLAGS: + "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-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; "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; + Ix.Cli.CompressionCmd.normalizeCmd; + Ix.Cli.CompressionCmd.setupCmd +] +end diff --git a/Ix/Cli/Compression.lean b/Ix/Cli/Compression.lean new file mode 100644 index 000000000..11c0ea829 --- /dev/null +++ b/Ix/Cli/Compression.lean @@ -0,0 +1,127 @@ +module +public import Ix.Aggr.Verification +public import Ix.Aggr.Backend +public import Ix.Store + +public section +namespace Ix.Cli.Compression + +@[extern "rs_compression_check_claim"] +opaque checkClaim (claim statement : @& ByteArray) : IO Unit + +/-- The KZG curve compiled into this executable: `"bn254"`. -/ +@[extern "rs_compression_curve"] +opaque curve : IO String + +/-- 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 (devKzgSrs devFinalSrs : Bool) (finalProof : UInt8) : IO String + +/-- 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) + (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"] +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) + (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 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 + +/-- 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"] +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 + 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 || proof.extract 0 4 == "IXBN".toUTF8 + +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 := + 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 + match setup with + | "development" => + 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 + +end Ix.Cli.Compression +end diff --git a/Ix/Cli/CompressionCmd.lean b/Ix/Cli/CompressionCmd.lean new file mode 100644 index 000000000..09ed38746 --- /dev/null +++ b/Ix/Cli/CompressionCmd.lean @@ -0,0 +1,155 @@ +/- + `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. `setup` installs the released Ignition proving + parameters and the fixed preprocessing every stage reads. +-/ +module +public import Cli +public import Ix.Cli.Compression +public import Ix.Aggr.Backend + +public section +namespace Ix.Cli.CompressionCmd + +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 + | 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 file or 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!"compress 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!"compress 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; "Aggregate Ixon.Proof files or content addresses 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; "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." +] + +/-- 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 + +/-- 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 := #["srs.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" + +private def backendVk : IO ByteArray := do + return (← backend).system.vkBytes + +def runSetup (p : Cli.Parsed) : IO UInt32 := do + try + 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 setup: {e}" + return 1 + +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 + +end diff --git a/Ix/Cli/ProveCmd.lean b/Ix/Cli/ProveCmd.lean index 77f67edf4..7daf3399b 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,11 +251,38 @@ 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) + (fresh : Bool) : + 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) (!fresh) 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 fresh + 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 @@ -275,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 @@ -291,18 +326,44 @@ 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-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.finalOf output with + | .error e => IO.eprintln s!"--compress: {e}"; return 1 + | .ok finalProof => + let opts : Ix.Cli.CompressCmd.Options := { + 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}." + 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 +386,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 fresh + else IO.lazyPure fun _ => + (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) 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 @@ -526,24 +603,31 @@ 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." "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` 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-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/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/Ix/Cli/VerifyCmd.lean b/Ix/Cli/VerifyCmd.lean index 6390e3447..451d639b1 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 @@ -93,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 @@ -108,11 +110,16 @@ private def verifyAggregateProof (backend : Aggr.VerificationBackend) IO.eprintln s!"error: aggregate claim {wrapper.claim} does not match expected {expected.claim}" return 1 | none => pure () + if Compression.isCompressed wrapper.proof then + 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 proof {proofAddr} does not decode: {e}" return 1 + 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 @@ -212,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 @@ -267,16 +303,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 @@ -287,13 +329,22 @@ 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 backend? := 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 + if (← Compression.verifyProof proofAddr wrapper (p.hasFlag "dev-srs")) != 0 then + rc := 1 + else + let (aiurSystem, compiled) ← match backend? with + | some b => pure b + | none => + let b ← IO.ofExcept (← buildBackend) + backend? := some b + pure b + if (← verifyOneProof aiurSystem compiled proofAddr) != 0 then rc := 1 return rc def runVerifyCmd (p : Cli.Parsed) : IO UInt32 := @@ -304,9 +355,12 @@ 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 proof(s) against their bundled claims, or a `.ixes` shard partition" 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." @@ -315,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/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 6963f1ad3..22d7f26f0 100644 --- a/Main.lean +++ b/Main.lean @@ -18,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 @@ -52,6 +53,7 @@ def ixCmd : Cli.Cmd := `[Cli| treeCmd; profileCmd; proveCmd; + compressCmd; 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/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 new file mode 100644 index 000000000..cec714e32 --- /dev/null +++ b/Tests/CompressionCli.lean @@ -0,0 +1,111 @@ +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) + (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_SRS", none), ("IX_KZG_SRS_CACHE", 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 + 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 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}" + 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) + rejects home #["compress", toString old, "--groth16", "--plonk"] + "choose at most one" + rejects home #["prove", "--ixe", "missing.ixe", "--ixes", "missing.ixes", + "--compress", "fri"] "unknown compression output" + rejects home #["compress", toString old, "--dev-final-srs"] + "--dev-final-srs requires a final proof system" + for finalFlag in #["--plonk", "--groth16"] do + rejects home #["compress", toString old, finalFlag, "--dev-kzg-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] setupError + 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" + 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 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" missingSrs + rejects home #["compress", filePath.toString, finalFlag, "--dev-kzg-srs", "--dev-final-srs"] + "aggregate CheckEnv proof" missingSrs + rejects home #["prove", "--ixe", "missing.ixe", "--ixes", "missing.ixes", + "--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"] + "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 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] "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 + +end Tests.CompressionCli +end diff --git a/Tests/Main.lean b/Tests/Main.lean index c06428164..6a99deb18 100644 --- a/Tests/Main.lean +++ b/Tests/Main.lean @@ -73,6 +73,7 @@ import Tests.AggrProof import Tests.AggrSemantics import Tests.AggrActivation import Tests.Cli +import Tests.CompressionCli import Tests.Ix.Ixes import Tests.ShardMap import Tests.Ix.EnvBody @@ -381,6 +382,11 @@ def main (args : List String) : IO UInt32 := do if args.contains "cli" then return ← Tests.Cli.suite + -- Compression CLI setup and proof-rejection checks, run explicitly. + if args.contains "compression-cli" then + Tests.CompressionCli.run + return 0 + -- Section 14 M1-e diagnostic: keep the 132-case, twice-executed `ix_aggr` -- activation matrix out of the default primary suite while making its gate -- explicit and independent of the large environment used by ignored tests. 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..c259d1204 --- /dev/null +++ b/contracts/groth16/test/IxGroth16Verifier.t.sol @@ -0,0 +1,72 @@ +// 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 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 testDifferentVerifierRejectsProof() public { + IxGroth16Verifier verifier = facade(address(new PlainVerifier())); + (bytes memory proof, bytes32 claim) = vector("wrapper"); + 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"); + 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 { + 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/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/contracts/plonk/.gitignore b/contracts/plonk/.gitignore new file mode 100644 index 000000000..2039e605a --- /dev/null +++ b/contracts/plonk/.gitignore @@ -0,0 +1,3 @@ +/out/ +/cache/ +/generated/ diff --git a/contracts/plonk/README.md b/contracts/plonk/README.md new file mode 100644 index 000000000..38640e801 --- /dev/null +++ b/contracts/plonk/README.md @@ -0,0 +1,34 @@ +# ix PLONK Solidity adapter + +`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. BN254 uses Ethereum's BN254 precompiles. + +`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 +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 +``` + +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`. + +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 +forge test --root contracts/plonk -vv +``` + +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/foundry.toml b/contracts/plonk/foundry.toml new file mode 100644 index 000000000..3e275ae32 --- /dev/null +++ b/contracts/plonk/foundry.toml @@ -0,0 +1,13 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +cache_path = "cache" +libs = [] +solc = "0.8.37" +evm_version = "prague" +hardfork = "osaka" +optimizer = true +optimizer_runs = 200 +via_ir = true +fs_permissions = [{ access = "read", path = "./testdata" }] diff --git a/contracts/plonk/src/IxPlonkVerifier.sol b/contracts/plonk/src/IxPlonkVerifier.sol new file mode 100644 index 000000000..9e3d34276 --- /dev/null +++ b/contracts/plonk/src/IxPlonkVerifier.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.28; + +interface IPlonkVerifier { + function Verify(bytes calldata proof, uint256[] calldata publicInputs) external view returns (bool); +} + +/// The verifier address fixes the wrapper circuit and its aggregation-program prefix. +contract IxPlonkVerifier { + IPlonkVerifier public immutable verifier; + + constructor(IPlonkVerifier trustedVerifier) { + require(address(trustedVerifier).code.length != 0, "missing verifier code"); + verifier = trustedVerifier; + } + + /// The caller obtains expectedClaimDigest independently of the proof. + function verify(bytes calldata proof, bytes32 expectedClaimDigest) external view returns (bool) { + uint256[] memory publicInputs = new uint256[](2); + publicInputs[0] = _reverse128(uint128(uint256(expectedClaimDigest) >> 128)); + publicInputs[1] = _reverse128(uint128(uint256(expectedClaimDigest))); + return verifier.Verify(proof, publicInputs); + } + + 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/plonk/src/PlonkVerifier.sol b/contracts/plonk/src/PlonkVerifier.sol new file mode 100644 index 000000000..ba43c5f61 --- /dev/null +++ b/contracts/plonk/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.0; + +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 = 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 = 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; + + 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; + + 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/test/IxPlonkVerifier.t.sol b/contracts/plonk/test/IxPlonkVerifier.t.sol new file mode 100644 index 000000000..12a40a6d2 --- /dev/null +++ b/contracts/plonk/test/IxPlonkVerifier.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.28; + +import {IxPlonkVerifier, IPlonkVerifier} from "../src/IxPlonkVerifier.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; + bytes32 private digest; + event log_named_uint(string key, uint256 value); + + function setUp() public { + 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(); + adapter = new IxPlonkVerifier(IPlonkVerifier(address(plonk))); + } + + function testValidInitProof() public { + bytes memory bytes_ = proof; + uint256 start = gasleft(); + require(adapter.verify{gas: 2_000_000}(bytes_, digest), "valid proof rejected"); + emit log_named_uint("external call gas excluding transaction intrinsic", start - gasleft()); + emit log_named_uint("proof bytes", bytes_.length); + emit log_named_uint("ABI calldata bytes", abi.encodeCall(adapter.verify, (bytes_, digest)).length); + } + + function testBothDigestHalvesAreBound() public view { + _reject(proof, digest ^ bytes32(uint256(1))); + _reject(proof, digest ^ bytes32(uint256(1) << 128)); + } + + function testMalformedProofs() public view { + _reject(new bytes(proof.length - 1), digest); + _reject(bytes.concat(proof, hex"00"), digest); + bytes memory wrong = proof; + wrong[0] ^= bytes1(0x01); + _reject(wrong, digest); + for (uint256 i; i < 96; ++i) wrong[i] = 0xff; + _reject(wrong, digest); + } + + function testDeploymentLimits() public view { + require(address(plonk).code.length <= 24576, "PLONK verifier exceeds EIP-170"); + require(address(adapter).code.length <= 24576, "adapter exceeds EIP-170"); + require(type(PlonkVerifier).creationCode.length <= 49152, "PLONK initcode exceeds EIP-3860"); + } + + function _reject(bytes memory badProof, bytes32 badDigest) private view { + (bool success, bytes memory result) = address(adapter).staticcall{gas: 2_000_000}( + abi.encodeCall(adapter.verify, (badProof, badDigest)) + ); + require(!success || !abi.decode(result, (bool)), "invalid proof accepted"); + } +} diff --git a/contracts/plonk/testdata/init.json b/contracts/plonk/testdata/init.json new file mode 100644 index 000000000..dff0ff648 --- /dev/null +++ b/contracts/plonk/testdata/init.json @@ -0,0 +1,15 @@ +{ + "format": "ix-bn254-plonk-solidity-init-v1", + "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": "32b357a1090e6508aae17b2925af2162079a2d133c1702516618a24705da2fac", + "domain": 16777216, + "constraints": 14689440, + "publicInputs": 2, + "commitments": 1, + "innerProfileBLAKE3": "1c613f8cfb94399f75687735cbb07e4e91bd4d23e7a58c35388fdfafc67f50e6" +} diff --git a/crates/aiur/build.rs b/crates/aiur/build.rs index d14c6d633..abdf2fde0 100644 --- a/crates/aiur/build.rs +++ b/crates/aiur/build.rs @@ -5,15 +5,22 @@ //! headers/libraries. use std::env; -use std::path::{Path, PathBuf}; +use std::ffi::OsString; +use std::path::PathBuf; 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-env-changed=NVCC"); - 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"); + 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"); if env::var_os("CARGO_FEATURE_CUDA").is_none() { return; @@ -30,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); - // 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)]; @@ -171,130 +182,9 @@ 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()); } } } - -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().map_or_else( - || { - let preferred = ["80", "86", "89", "90", "100", "120"]; - let selected = preferred - .into_iter() - .filter(|architecture| { - supported.iter().any(|item| item == architecture) - }) - .collect::>(); - let defaults = if selected.is_empty() { - detect_nvidia_architectures().unwrap_or_else(|| "80".to_owned()) - } else { - selected.join(",") - }; - (defaults, false) - }, - |value| (value, true), - ); - let (configured, explicitly_configured) = configured; - 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!( - !explicitly_configured - || 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. This avoids requiring users to translate `nvidia-smi`'s `12.0` -/// compute capability into nvcc's `sm_120` spelling. Cross builds and hosts -/// without a visible GPU retain the portable sm_80 default and can still 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 a3f0a2ef1..a162e6287 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 @@ -1665,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/aiur/src/synthesis/normalization.rs b/crates/aiur/src/synthesis/normalization.rs new file mode 100644 index 000000000..c25f3b44e --- /dev/null +++ b/crates/aiur/src/synthesis/normalization.rs @@ -0,0 +1,163 @@ +//! 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 { + /// 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 + /// 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..4b12a9b13 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,9 +48,12 @@ serde = { version = "1.0.219", features = ["derive"], optional = true } [features] default = [] -parallel = ["aiur/parallel"] -cuda = ["aiur/cuda"] +parallel = ["aiur/parallel", "ix-proof-compression/parallel"] +cuda = ["aiur/cuda", "ix-proof-compression/cuda"] cuda-trace-codegen = ["cuda", "aiur/cuda-trace-codegen"] +# Final 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.rs b/crates/ffi/src/aiur/aggregate.rs index 5011fd5c1..639d12f21 100644 --- a/crates/ffi/src/aiur/aggregate.rs +++ b/crates/ffi/src/aiur/aggregate.rs @@ -35,10 +35,11 @@ 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; -mod protocol; +pub(crate) mod protocol; mod prove; mod scheduler; mod statement; @@ -74,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, @@ -165,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(); @@ -261,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() @@ -385,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 @@ -419,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/ffi.rs b/crates/ffi/src/aiur/aggregate/ffi.rs index 0459d7803..6f22af7c9 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 @@ -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/multi_gpu.rs similarity index 93% rename from crates/ffi/src/aiur/aggregate/lanes.rs rename to crates/ffi/src/aiur/aggregate/multi_gpu.rs index 936f2af18..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. @@ -52,12 +52,13 @@ 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 /// 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; @@ -408,27 +409,31 @@ 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") .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,7 +524,8 @@ struct Pass { store_dir: PathBuf, cache_dir: PathBuf, index_dir: PathBuf, - /// CPU execution threads shared by every lane. + fresh: bool, + /// CPU execution threads shared by every GPU. executions: usize, /// The process-wide host limit, the prover budget of every slot. wrap_budget: Option, @@ -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, @@ -562,7 +569,7 @@ impl Pass { } fn say(message: &str) { - eprintln!("[lanes] {message}"); + eprintln!("[multi-gpu] {message}"); } fn plan_pass( @@ -622,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::() @@ -645,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(), @@ -669,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!( @@ -680,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::>()?; @@ -717,6 +723,7 @@ fn plan_pass( store_dir, cache_dir, index_dir, + fresh: cfg.fresh, executions, wrap_budget: Some(host.limit), pool, @@ -735,6 +742,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 +760,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 { @@ -1095,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 }; @@ -1215,33 +1236,53 @@ 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.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>, cache_fri_bytes: LeanByteArray>, out_manifest: LeanString>, + fresh: bool, ) -> LeanExcept { + let gpus = match lean_unbox_nat_as_usize(gpus.inner()) { + 0 => visible_gpus(), + n => n, + }; let cfg = Config { - lanes: lean_unbox_nat_as_usize(lanes.inner()), + 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()), 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("at least one lane is required"); + if cfg.gpus == 0 { + return LeanExcept::error_string("no visible GPU to prove on"); } let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { run( @@ -1258,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/normalize.rs b/crates/ffi/src/aiur/aggregate/normalize.rs new file mode 100644 index 000000000..86200eb70 --- /dev/null +++ b/crates/ffi/src/aiur/aggregate/normalize.rs @@ -0,0 +1,127 @@ +//! 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/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/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..c969a033b 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(), @@ -136,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 @@ -166,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() { @@ -183,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. @@ -220,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; @@ -241,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/ffi/src/compression.rs b/crates/ffi/src/compression.rs new file mode 100644 index 000000000..2e46312a9 --- /dev/null +++ b/crates/ffi/src/compression.rs @@ -0,0 +1,297 @@ +//! Main CLI bridge; compression is linked into the ix executable. +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, 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_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 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_kzg_srs, + final_proof, + ) + .and_then(|mut config| { + config.fresh = fresh; + 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()), + } +} + +#[unsafe(no_mangle)] +extern "C" fn rs_compression_verify( + proof: LeanByteArray>, + claim: LeanByteArray>, + dev_srs: bool, +) -> LeanIOResult { + 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()), + } +} + +#[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_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()), + } +} + +#[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()), + } +} + +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(), + } +} + +/// 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( + 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 stage'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()), + } +} + +/// 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)) +} + +#[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/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/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..09188255d --- /dev/null +++ b/crates/proof-compression/Cargo.toml @@ -0,0 +1,67 @@ +[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-bn254 = { 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, features = ["mmap", "rayon"] } +tiny-keccak = { workspace = true } +num-bigint = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-texray = { workspace = true } +rayon = { workspace = true, optional = true } +blst = { version = "=0.3.16", features = ["portable"], optional = 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" } + +[build-dependencies] +bindgen = { version = "=0.72.1", optional = true } +blake3 = { workspace = true } + +[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 +# C++ linkage. The revision must match multi-stark's Goldilocks pin. +[dependencies.sppark] +git = "https://github.com/argumentcomputer/sppark" +rev = "9326ac014ec5415ebc4eab24c1246276714aee57" +optional = true +features = ["cuda"] + +[features] +plonk = ["dep:bindgen"] +groth16 = ["dep:bindgen"] +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 +# 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"] + +[lints] +workspace = true diff --git a/crates/proof-compression/README.md b/crates/proof-compression/README.md new file mode 100644 index 000000000..3e02b3f10 --- /dev/null +++ b/crates/proof-compression/README.md @@ -0,0 +1,298 @@ +# Ix proof compression + +The pipeline is Goldilocks/Blake3 FRI → BN254 KZG → recursive BN254 KZG, +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 +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 final 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 +``` + +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 final +backends, and `IX_CUDA` +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 +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. 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 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` +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/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 +`--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 and verifier encodings are retained. + +## Ignition setup + +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 +ix compress setup +ix compress ROOT_PROOF_ADDRESS --out /path/to/new-run +``` + +`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; 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. +`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. + +### Preparing a parameter release + +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 +cd tools/gnark +go run ./cmd/ignition -cache /path/to/transcripts -out /path/to/srs.bin +``` + +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. + +The parameter publisher must upload `srs.bin` to the existing +`bn254/ignition/main-197/` release location before `ix compress setup` can +install it. + +## Proving and verification + +`ix compress ROOT` and `ix prove ... --compress kzg|plonk|groth16` automatically +normalize the aggregate root using the versioned +[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 +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 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 +`vk.bin`), then `plonk/` or `groth16/`, and the returned `compressed.bin`. +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. + +`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 +KZG proof and deferred pairing; the verifier never obtains its trusted pin +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. + +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 parallel \ + --lib recursive::native_field:: +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 +``` + +## Final PLONK + +```sh +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/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). + +## CUDA and profiling + +### 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 +toolkit at build time and a visible GPU at runtime; a CPU-only build +neither compiles sppark nor links CUDA. + +```sh +IX_CUDA=1 MULTI_STARK_CUDA_ARCHS=120 lake build ix +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. 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 `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. `ix` reads these settings from the +environment and installs them once, before the first KZG stage: + +| Variable | Values | Default | +| --- | --- | --- | +| `IX_KZG_CUDA_DEVICES` | CUDA ordinals, comma separated, in preference order | `0`; the library itself defaults to every supported device | +| `IX_KZG_MSM_CHUNK_POINTS` | `1..=2^28` | `2^24` | +| `IX_KZG_CUDA_RESIDENT_GIB` | device bytes kept for resident coefficients, in GiB | first stage: up to half the device memory; recursive stage: `8` | +| `IX_KZG_CUDA_SRS_CACHE` | `0`, `1` | `0` | +| `IX_KZG_CUDA_PASSES` | `0`, `1`, `force` | `1`: constraint passes when the dense working set does not fit | +| `IX_KZG_CUDA_PROFILE` | set to record NVTX ranges and transfer timings | unset | + +With several devices selected, each runs one native operation at a time +and the work spreads by job: partitions prove concurrently, FFT queues and +MSM chunks split across the devices, and resident coefficients are retained +on whichever device has the most room. A lookup, quotient or constraint +pass runs whole on one device that admits its working set, preferring the +device holding most of its resident columns and uploading the others from +the host; the first free admitted device is taken rather than waiting for +the preferred one. Devices never exchange data directly, so there is no +peer access to configure. The pipeline selects device `0` by default; +set `IX_KZG_CUDA_DEVICES` to use additional devices. + +`IX_KZG_CUDA_ARCHS` selects the SM architectures at build time and falls +back to `MULTI_STARK_CUDA_ARCHS`, so one variable configures both CUDA +backends of a Lake build. Without either variable, the build detects the +installed GPUs; set explicit targets for headless CI or cross builds. +Device discovery admits only GPUs compatible with a compiled kernel image; +an architecture excluded by the toolkit or an explicit selection is rejected +before proving. The feature-compiled test suite +needs a GPU host; device unit tests are marked ignored and run with +`--ignored`. CPU/GPU proof-byte parity is checked by comparing the two builds' outputs. + +### Profiling + +The global `IX_TEXRAY=1` switch enables TeXRay for proving, aggregation, +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 +names KZG openings in the generic prover. Prefetched trace generation can +overlap commitment, so child durations should not be summed as wall time. + +`IX_KZG_CUDA_PROFILE=1` independently enables fine-grained NVTX ranges and +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. + +## 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 +`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 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 +`recursive` constrains KZG verification with public pairing operands. +`pipeline` runs the stages through the Plonkish API. + +Focused CPU checks use the small BN254 fixtures: + +```sh +cargo test --release --locked -p ix-proof-compression --test integration \ + -- kzg:: +``` + +`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). diff --git a/crates/proof-compression/build.rs b/crates/proof-compression/build.rs new file mode 100644 index 000000000..4a7a9eb7b --- /dev/null +++ b/crates/proof-compression/build.rs @@ -0,0 +1,304 @@ +//! Native proving backends. +//! +//! 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": , "link": }`. +//! A Rust static archive does not carry its shared-library dependencies, so a +//! final link of one must also link each `"link": true` library. The others, +//! ICICLE's CUDA backend plugins, are only loaded at runtime. + +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +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", + "cuda/kzg_passes.cuh", + "cuda/kzg_transfer.cuh", + "cuda/kzg_profile.cuh", + "cuda/kzg_runtime.cpp", + "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, + link: bool, +} + +#[cfg(any(feature = "plonk", feature = "groth16"))] +#[path = "build/gnark.rs"] +mod gnark; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + for source in SOURCES { + println!("cargo:rerun-if-changed={source}"); + } + for variable in [ + "NVCC_CCBIN", + "NVCC_PREPEND_FLAGS", + "NVCC_APPEND_FLAGS", + "CC", + "IX_KZG_CUDA_ARCHS", + ] { + println!("cargo:rerun-if-env-changed={variable}"); + } + let mut artifacts = Vec::new(); + if env::var_os("CARGO_FEATURE_CUDA").is_some() { + let multi_stark = PathBuf::from(multi_stark_cuda("LIBRARY")); + artifacts.push(RuntimeArtifact { + path: multi_stark + .file_name() + .and_then(|name| name.to_str()) + .expect("multi-stark's CUDA library has a UTF-8 file name") + .to_owned(), + source: multi_stark, + link: true, + }); + artifacts.push(RuntimeArtifact { + source: build_bn254_cuda(), + path: LIBRARY.to_owned(), + link: true, + }); + } + // 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 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":{},"link":{}}}"#, + json_string(source), + json_string(&artifact.path), + artifact.link + ) + }) + .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 +} + +/// 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!( + env::var("CARGO_CFG_TARGET_OS").as_deref(), + Ok("linux"), + "the BN254 CUDA backend supports Linux targets only" + ); + assert_eq!( + env::var("CARGO_CFG_TARGET_ARCH").as_deref(), + Ok("x86_64"), + "the BN254 CUDA backend supports x86_64 targets only" + ); + 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( + &architecture_header, + format!( + "#define IX_KZG_CUDA_ARCHITECTURES {{{}}}\n", + architectures.join(",") + ), + ) + .expect("write BN254 CUDA architecture list"); + println!( + "cargo:rustc-env=IX_KZG_CUDA_COMPILED_ARCHS={}", + architectures.join(",") + ); + let root = PathBuf::from( + env::var_os("DEP_SPPARK_ROOT") + .expect("sppark's build script exports DEP_SPPARK_ROOT"), + ); + let blst = PathBuf::from( + env::var_os("DEP_BLST_C_SRC") + .expect("blst's build script exports DEP_BLST_C_SRC"), + ); + 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 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", + ]); + 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); + // 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), + ); + 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"]), + ); + 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()); + // Tests query the CUDA runtime directly. + for directory in env::split_paths(&multi_stark_cuda("LIB_DIRS")) { + 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( + nvcc: &std::ffi::OsStr, + architectures: &[String], +) -> Command { + let mut command = Command::new(nvcc); + command.args([ + "--std=c++17", + "--cudart=static", + "--default-stream=per-thread", + "-O3", + "-lineinfo", + "--compiler-options=-fPIC", + ]); + // Native cubins avoid PTX versions newer than the installed driver's JIT, + // even when both the toolkit and driver support the GPU's native ISA. + for architecture in architectures { + command.arg(format!( + "-gencode=arch=compute_{architecture},code=sm_{architecture}" + )); + } + command +} diff --git a/crates/proof-compression/build/gnark.rs b/crates/proof-compression/build/gnark.rs new file mode 100644 index 000000000..783d53288 --- /dev/null +++ b/crates/proof-compression/build/gnark.rs @@ -0,0 +1,243 @@ +use std::{ + env, + ffi::OsString, + fs, + hash::{Hash, Hasher}, + path::PathBuf, + 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" && name != "cmd" { + 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 = 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 [ + "GO", + "PATH", + "GOROOT", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "CC", + "CXX", + "IX_GROTH16_ICICLE_ROOT", + ] { + 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()); + println!("cargo:rustc-env=IX_GNARK_SOURCE_BLAKE3={}", source_digest(&source)); + 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(); + 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() { + assert_eq!(target_os, "linux", "PLONK CUDA requires Linux"); + assert_eq!(target_arch, "x86_64", "PLONK CUDA requires x86_64"); + 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(); + 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); + // 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 = 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 [ + "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(), + link: !name.contains('/'), + }); + } + for name in ["icicle_device", "icicle_field_bn254", "icicle_curve_bn254"] { + println!("cargo:rustc-link-lib=dylib={name}"); + } + let backend = library.join("backend"); + 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"); + } + artifacts +} diff --git a/crates/proof-compression/cuda/kzg.cu b/crates/proof-compression/cuda/kzg.cu new file mode 100644 index 000000000..f7dea1a0d --- /dev/null +++ b/crates/proof-compression/cuda/kzg.cu @@ -0,0 +1,590 @@ +#include "kzg_curve.cuh" +#include +#include +#include +#include "kzg_profile.cuh" +#define SPPARK_DONT_INSTANTIATE_TEMPLATES +#define TAKE_RESPONSIBILITY_FOR_ERROR_MESSAGE +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "kzg_transfer.cuh" + +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(affine_t) == 2 * sizeof(fp_t)); +static_assert(sizeof(point_t) == 3 * sizeof(fp_t)); + +struct ArkAffine { + uint64_t x[KZG_FQ_LIMBS]; + uint64_t y[KZG_FQ_LIMBS]; + uint8_t infinity; +}; +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; + i < count; i += size_t(gridDim.x) * blockDim.x) { + affine_t point; + if (input[i].infinity) + memset(&point, 0, sizeof(point)); + else + memcpy(&point, &input[i], sizeof(point)); + output[i] = point; + } +} + +extern "C" void ix_kzg_range_push(const char* name) { + if (transfer_profiling()) nvtxRangePushA(name); +} + +extern "C" void ix_kzg_range_pop() { + if (transfer_profiling()) nvtxRangePop(); +} + +static int failure(int code, const char* message) { + // Complete queued host transfers before Rust releases its limb buffers. + (void)cudaDeviceSynchronize(); + std::fprintf(stderr, "KZG CUDA: %s\n", message); + return code; +} + +extern "C" int ix_kzg_devices(int* count) { + try { + *count = static_cast(ngpus()); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +extern "C" int ix_kzg_device_ordinal(int index, int* ordinal) { + try { + if (!ordinal || index < 0 || static_cast(index) >= ngpus()) + return cudaErrorInvalidDevice; + *ordinal = all_gpus()[index]->cid(); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +static const gpu_t& device_gpu(int ordinal) { + const gpu_t* found = nullptr; + for (const auto* gpu : all_gpus()) + if (gpu->cid() == ordinal) found = gpu; + if (!found) CUDA_OK(cudaErrorInvalidDevice); + return select_gpu(found->id()); +} + +extern "C" int ix_kzg_memory(int device, size_t* free, size_t* total) { + try { + if (!free || !total) return cudaErrorInvalidValue; + device_gpu(device); + CUDA_OK(cudaMemGetInfo(free, total)); + cudaMemPool_t pool = nullptr; + CUDA_OK(cudaDeviceGetMemPool(&pool, device)); + uint64_t reserved = 0, used = 0; + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrReservedMemCurrent, &reserved)); + CUDA_OK(cudaMemPoolGetAttribute(pool, cudaMemPoolAttrUsedMemCurrent, &used)); + // Every KZG allocation uses cudaMallocAsync. Unused pool pages remain + // reusable even when cudaMemGetInfo excludes them from driver-free bytes. + *free = std::min(*free, *total); + const uint64_t reusable = reserved > used ? reserved - used : 0; + *free += std::min(reusable, *total - *free); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +namespace { +struct MsmContext { + int device; + size_t count; + dev_ptr_t points; + msm_impl msm; + MsmContext(const gpu_t& gpu, size_t count) + : device(gpu.cid()), count(count), points(count, gpu), + msm(nullptr, count, sizeof(affine_t), gpu.id()) {} +}; + +int msm_result(RustError status) { + if (status.code) + failure(status.code, status.message ? status.message : cudaGetErrorString( + static_cast(status.code))); + std::free(status.message); + return status.code; +} +} // namespace + +// Arkworks coordinates and scalars use little-endian Montgomery limbs. +// Sppark affine infinity is (0, 0); projective infinity has Z = 0. +extern "C" int ix_kzg_msm_create(int device, void** out, + const ArkAffine* points, size_t count) { + ProfileRange range("kzg/msm-points"); + try { + if (!out || !points || count == 0 || count > (size_t(1) << 28)) + return cudaErrorInvalidValue; + // Resident chunk points avoid the streaming path's cross-stream use of + // an allocation before its cudaMallocAsync stream has completed. + const auto& gpu = device_gpu(device); + auto context = std::make_unique(gpu, count); + dev_ptr_t input(count, gpu); + TransferStats stats; + auto& transfer = transfer_lane(gpu); + transfer.upload.upload(gpu, gpu, input, points, count * sizeof(ArkAffine), stats); + TimedKernel kernel; + kernel.start(gpu); + unpack_affine<<>>(context->points, input, count); + CUDA_OK(cudaGetLastError()); + kernel.end(gpu); + gpu.sync(); + transfer.upload.finish_upload(stats); + kernel.collect(stats); + stats.report(device, "msm-points", count); + *out = context.release(); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +__global__ void scale_values(fr_t* output, const fr_t* input, size_t count, fr_t scale) { + for (size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + i < count; i += size_t(gridDim.x) * blockDim.x) + output[i] = input[i] * scale; +} + +extern "C" int ix_kzg_msm_invoke(int device, void* context, point_t* out, + const fr_t* scalars, size_t count, + const fr_t* normalization) { + ProfileRange range("kzg/msm-host-scalars"); + try { + if (!context || !out || !scalars || count == 0) + return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + auto& msm = *static_cast(context); + if (msm.device != device || count > msm.count) return cudaErrorInvalidValue; + dev_ptr_t data(count, gpu); + TransferStats stats; + auto& transfer = transfer_lane(gpu); + transfer.upload.upload(gpu, gpu, data, scalars, count * sizeof(fr_t), stats); + gpu.sync(); + transfer.upload.finish_upload(stats); + stats.report(device, "msm-scalars", count); + MsmKernelTimings kernels; + TransferStats compute; + const auto started = std::chrono::steady_clock::now(); + if (normalization) { + auto* timer = kzg_msm_profile_start(gpu); + scale_values<<>>(data, data, count, *normalization); + CUDA_OK(cudaGetLastError()); + kzg_msm_profile_end(timer, gpu); + gpu.sync(); + } + const int code = msm_result(msm.msm.invoke(*out, msm.points, count, data, true)); + compute.call_ms = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + if (code == 0) kernels.collect(compute); + compute.report(device, "msm-compute", count); + return code; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +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 msm; + 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()); } +} + +__global__ void scale_coset(fr_t* data, size_t count, fr_t shift) { + size_t i = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const size_t stride = static_cast(gridDim.x) * blockDim.x; + fr_t power = shift ^ static_cast(i); + const fr_t step = shift ^ static_cast(stride); + for (; i < count; i += stride, power *= step) + data[i] *= power; +} + +namespace { + +struct ResidentPolynomial { + int device; + size_t count; + dev_ptr_t data; + ResidentPolynomial(const gpu_t& gpu, size_t count, const stream_t& stream) + : device(gpu.cid()), count(count), data(count, stream) {} +}; + +class DivisionStream { + const stream_t& stream; + size_t count; + public: + DivisionStream(const stream_t& stream, size_t count) : stream(stream), count(count) {} + int sm_count() const { return stream.sm_count(); } + + template + void launch_coop(void(*kernel)(Types...), launch_params_t params, Types... args) const { + // sppark e10e107 reads xchg[laneid] even when a small grid's launcher + // reserves fewer than 32 field elements. Every lane needs valid storage. + params.shared = std::max(params.shared, WARP_SZ * sizeof(fr_t)); + // A partial final cooperative tile can read the preceding tile's carry + // before it is published. Exact tiles or one block avoid that race. + while (params.gridDim.x > 1 && count % (2 * params.blockDim.x * params.gridDim.x) != 0) + --params.gridDim.x; + stream.launch_coop(kernel, params, args...); + } +}; + +void divide_in_place(const gpu_t& gpu, fr_t* data, size_t count, const fr_t& z) { + // The upstream launcher caches its block size in a mutable function static. + // Serialize host launches while allowing kernels on different GPUs to overlap. + static std::mutex launch_mutex; + std::lock_guard lock(launch_mutex); + div_by_x_minus_z(data, count, z, DivisionStream(gpu, count)); +} + +struct FftJob { + const fr_t* input; + const ResidentPolynomial* resident_input; + fr_t* output; + 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) { + if (lg == 0) return; + const size_t count = size_t(1) << lg; + if (!shift.is_one() && !inverse) { + scale_coset<<>>(data, count, shift); + CUDA_OK(cudaGetLastError()); + } + NTT::Base_dev_ptr(stream, data, lg, NTT::InputOutputOrder::NN, + inverse ? NTT::Direction::inverse : NTT::Direction::forward, + NTT::Type::standard); + if (!shift.is_one() && inverse) { + scale_coset<<>>(data, count, shift); + CUDA_OK(cudaGetLastError()); + } +} + +} // namespace + +#include "kzg_quotient.cuh" +#include "kzg_lookup.cuh" +#include "kzg_passes.cuh" + +extern "C" int ix_kzg_polynomial_upload(int device, const fr_t* input, + size_t count, void** output) { + try { + if (!input || !output || !count) return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + auto resident = std::make_unique(gpu, count, gpu); + auto& transfer = transfer_lane(gpu); + TransferStats stats; + transfer.upload.upload(gpu, gpu, resident->data, input, count * sizeof(fr_t), stats); + gpu.sync(); + transfer.upload.finish_upload(stats); + stats.report(device, "retain", count); + *output = resident.release(); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +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); + gpu.sync(); + delete polynomial; + 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()); } +} + +extern "C" int ix_kzg_msm_invoke_resident(int device, void* context, + point_t* out, const void* polynomial, + size_t offset, size_t count, + const fr_t* normalization) { + ProfileRange range("kzg/msm-resident-scalars"); + try { + if (!context || !out || !polynomial || !count) return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + const auto& coefficients = *static_cast(polynomial); + auto& msm = *static_cast(context); + 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); + fr_t* input = const_cast(&coefficients.data[offset]); + MsmKernelTimings kernels; + TransferStats compute; + const auto started = std::chrono::steady_clock::now(); + if (normalization) { + auto* timer = kzg_msm_profile_start(gpu); + scale_values<<>>(normalized, input, count, *normalization); + CUDA_OK(cudaGetLastError()); + kzg_msm_profile_end(timer, gpu); + gpu.sync(); + input = normalized; + } + dev_ptr_t scalars(input, count); + const int code = msm_result(msm.msm.invoke(*out, msm.points, count, scalars, true)); + compute.call_ms = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + if (code == 0) kernels.collect(compute); + compute.report(device, "msm-compute", count); + return code; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +#include "kzg_srs.cuh" + +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"); + try { + 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) { + const auto& job = jobs[i]; + if (!job.output || job.input_count > count || + (job.input_count && !job.input && !job.resident_input) || + (job.resident_input && (job.resident_input->device != device || + job.resident_input->count != job.input_count))) + 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; + TimedKernel kernel; + TransferStats stats; + }; + { + std::array queue; + const auto drain = [&](size_t index) { + auto& slot = queue[index]; + if (!slot.job) return; + auto& transfers = transfer_lane(gpu, index); + transfers.download.download(gpu, gpu[index], slot.job->output, + slot.buffer->data, count * sizeof(fr_t), slot.stats); + transfers.upload.finish_upload(slot.stats); + slot.kernel.collect(slot.stats); + slot.stats.report(device, inverse ? "ifft" : "fft", count); + if (slot.job->resident_output) + residents[slot.job - jobs] = std::move(slot.buffer); + slot.job = nullptr; + }; + for (size_t i = 0; i < job_count; ++i) { + const size_t index = i % slots; + drain(index); + auto& slot = queue[index]; + auto& stream = gpu[index]; + auto& transfers = transfer_lane(gpu, index); + const auto& job = jobs[i]; + if (!slot.buffer) + slot.buffer = std::make_unique(gpu, count, stream); + slot.stats = TransferStats{}; + if (job.resident_input) { + CUDA_OK(cudaMemcpyAsync(slot.buffer->data, job.resident_input->data, + job.input_count * sizeof(fr_t), cudaMemcpyDeviceToDevice, stream)); + slot.stats.device_copy_bytes += job.input_count * sizeof(fr_t); + } else { + transfers.upload.upload(gpu, stream, slot.buffer->data, job.input, + job.input_count * sizeof(fr_t), slot.stats); + } + if (job.input_count < count) + CUDA_OK(cudaMemsetAsync(&slot.buffer->data[job.input_count], 0, + (count - job.input_count) * sizeof(fr_t), stream)); + slot.kernel.start(stream); + transform(gpu, stream, slot.buffer->data, lg, inverse, *shift); + slot.kernel.end(stream); + slot.job = &job; + } + for (size_t i = 0; i < slots; ++i) drain(i); + } + // 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()); } +} + +extern "C" int ix_kzg_fft_from(int device, const fr_t* input, + size_t input_count, fr_t* output, + uint32_t lg, bool inverse, const fr_t* shift) { + const FftJob job{input, nullptr, output, input_count, nullptr}; + return ix_kzg_fft_batch(device, &job, 1, lg, inverse, shift, 1); +} + +extern "C" int ix_kzg_fft(int device, fr_t* values, uint32_t lg, + bool inverse, const fr_t* shift) { + if (lg > KZG_MAX_FFT_LOG) return cudaErrorInvalidValue; + return ix_kzg_fft_from(device, values, size_t(1) << lg, + values, lg, inverse, shift); +} + +extern "C" int ix_kzg_evaluate_resident(int device, const fr_t* coefficients, + const void* resident_pointer, size_t count, + const fr_t* points, size_t point_count, fr_t* results) { + ProfileRange range("kzg/evaluate"); + try { + if ((!coefficients && !resident_pointer) || !points || !results || count < 2 || point_count == 0) + return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + const auto* resident = static_cast(resident_pointer); + if (resident && (resident->device != device || resident->count != count)) + return cudaErrorInvalidValue; + auto& transfer = transfer_lane(gpu); + TransferStats stats; + dev_ptr_t data(resident ? 0 : count, gpu); + const fr_t* input = resident ? resident->data.data() : data.data(); + if (!resident) + transfer.upload.upload(gpu, gpu, data, coefficients, count * sizeof(fr_t), stats); + dev_ptr_t inputs(point_count, gpu), outputs(point_count, gpu); + gpu.HtoD(&inputs[0], points, point_count); + TimedKernel kernel; + kernel.start(gpu); + // The pinned multi-point kernel reuses shared power storage without a + // block barrier. Single-point launches retain the coefficient upload + // across points and avoid that shared-memory race. + for (size_t i = 0; i < point_count; ++i) + evaluate(&outputs[i], &inputs[i], 1, input, count, gpu); + kernel.end(gpu); + gpu.DtoH(results, &outputs[0], point_count); + gpu.sync(); + transfer.upload.finish_upload(stats); + kernel.collect(stats); + stats.report(device, "evaluate", count); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +extern "C" int ix_kzg_evaluate(int device, const fr_t* coefficients, + size_t count, const fr_t* points, + size_t point_count, fr_t* results) { + return ix_kzg_evaluate_resident(device, coefficients, nullptr, count, + points, point_count, results); +} + +extern "C" int ix_kzg_divide(int device, const fr_t* input, fr_t* output, size_t count, + const fr_t* z) { + ProfileRange range("kzg/divide"); + try { + if (!input || !output || !z || count < 2) return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + dev_ptr_t data(count, gpu); + auto& transfer = transfer_lane(gpu); + TransferStats stats; + transfer.upload.upload(gpu, gpu, data, input, count * sizeof(fr_t), stats); + TimedKernel kernel; + kernel.start(gpu); + // Rotation leaves the quotient at the front and the remainder last. + divide_in_place(gpu, &data[0], count, *z); + kernel.end(gpu); + transfer.download.download(gpu, gpu, output, &data[0], (count - 1) * sizeof(fr_t), stats); + gpu.sync(); + transfer.upload.finish_upload(stats); + kernel.collect(stats); + stats.report(device, "divide", count); + return 0; + } catch (const cuda_error& e) { + return failure(e.code(), e.what()); + } catch (const std::exception& e) { + return failure(cudaErrorUnknown, e.what()); + } +} + +extern "C" int ix_kzg_divide_resident(int device, const fr_t* input, + size_t count, const fr_t* z, void** output) { + ProfileRange range("kzg/divide-resident"); + try { + if (!input || !z || !output || count < 2) return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + auto polynomial = std::make_unique(gpu, count, gpu); + auto& transfer = transfer_lane(gpu); + TransferStats stats; + transfer.upload.upload(gpu, gpu, polynomial->data, input, count * sizeof(fr_t), stats); + TimedKernel kernel; + kernel.start(gpu); + divide_in_place(gpu, &polynomial->data[0], count, *z); + kernel.end(gpu); + gpu.sync(); + transfer.upload.finish_upload(stats); + kernel.collect(stats); + stats.report(device, "divide-resident", count); + polynomial->count = count - 1; + *output = polynomial.release(); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +__global__ void sample_scalars(fr_t* samples, const fr_t* data, size_t count) { + const size_t i = threadIdx.x; + samples[i] = data[(i * size_t(0x9e3779b97f4a7c15)) % count]; +} + +extern "C" int ix_kzg_polynomial_sample(int device, const void* pointer, + size_t offset, size_t count, fr_t* samples) { + try { + if (!pointer || !samples || !count) return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + const auto& polynomial = *static_cast(pointer); + if (polynomial.device != device || offset > polynomial.count || + count > polynomial.count - offset) return cudaErrorInvalidValue; + dev_ptr_t output(128, gpu); + sample_scalars<<<1, 128, 0, gpu>>>(output, &polynomial.data[offset], count); + CUDA_OK(cudaGetLastError()); + gpu.DtoH(samples, &output[0], 128); + gpu.sync(); + TransferStats stats; + stats.download_bytes = 128 * sizeof(fr_t); + stats.report(device, "msm-sample", count); + return 0; + } 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..6d11abc3f --- /dev/null +++ b/crates/proof-compression/cuda/kzg_curve.cuh @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#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); + +// 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; + 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; + 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 new file mode 100644 index 000000000..12a5acc42 --- /dev/null +++ b/crates/proof-compression/cuda/kzg_lookup.cuh @@ -0,0 +1,233 @@ +#pragma once + +#include +#include + +namespace { + +struct alignas(32) LookupParameters { + fr_t beta, gamma; + 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, + const QuotientColumn* columns, const QuotientInstruction* nodes, size_t node_count, + const QuotientLookup* lookups, size_t lookup_count, const uint32_t* arguments, + LookupParameters parameters, size_t column_stride, size_t row, size_t next, + fr_t first, fr_t last, fr_t transition +) { + const size_t groups = (lookup_count + parameters.group_size - 1) / parameters.group_size; + fr_t zero; + zero.zero(); + const fr_t one = fr_t::one(); + evaluate_constraint_nodes(values, evaluations, columns, nodes, node_count, + nullptr, column_stride, row, next, first, last, transition); + for (size_t group = 0; group < groups; ++group) { + const size_t start = group * parameters.group_size; + const size_t remaining = lookup_count - start; + const size_t count = remaining < parameters.group_size ? remaining : parameters.group_size; + fr_t messages[8], prefix[9], suffix[9]; + bool active[8]; + prefix[0] = suffix[count] = one; + for (size_t i = 0; i < count; ++i) { + const auto& lookup = lookups[start + i]; + fr_t fingerprint = zero; + for (size_t j = lookup.arg_count; j > 0; --j) + fingerprint = fingerprint * parameters.gamma + + values[size_t(arguments[lookup.arg_start + j - 1]) * blockDim.x]; + const fr_t message = fingerprint + parameters.beta; + active[i] = !message.is_zero(); + messages[i] = fr_t::csel(message, one, active[i]); + prefix[i + 1] = prefix[i] * messages[i]; + } + for (size_t i = count; i > 0; --i) + suffix[i - 1] = messages[i - 1] * suffix[i]; + fr_t numerator = zero; + for (size_t i = 0; i < count; ++i) { + const fr_t contribution = prefix[i] * suffix[i + 1] + * values[size_t(lookups[start + i].multiplicity) * blockDim.x]; + numerator += fr_t::csel(contribution, zero, active[i]); + } + // Replacing zero messages by one and masking their numerator terms + // preserves the field convention inverse(0) = 0. + numerators[group] = numerator; + denominators[group] = prefix[count]; + } +} + +__global__ __launch_bounds__(128) void lookup_rationals( + fr_t* numerators, fr_t* denominators, const fr_t* evaluations, + const QuotientColumn* columns, const QuotientInstruction* nodes, size_t node_count, + const QuotientLookup* lookups, size_t lookup_count, const uint32_t* arguments, + fr_t* scratch, LookupParameters parameters +) { + const size_t n = size_t(1) << parameters.trace_log; + const size_t groups = (lookup_count + parameters.group_size - 1) / parameters.group_size; + fr_t zero; + zero.zero(); + const fr_t one = fr_t::one(); + fr_t* values = scratch + size_t(blockIdx.x) * parameters.slots * blockDim.x + threadIdx.x; + for (size_t row = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + row < n; row += size_t(gridDim.x) * blockDim.x) { + const size_t next = (row + 1) & (n - 1); + // Lookup witnesses use Boolean trace selectors, not coset selectors. + const fr_t first = fr_t::csel(one, zero, row == 0); + const fr_t last = fr_t::csel(one, zero, row + 1 == n); + const fr_t transition = fr_t::csel(zero, one, row + 1 == n); + lookup_rational_row(numerators + row * groups, denominators + row * groups, + values, evaluations, columns, nodes, node_count, lookups, lookup_count, + arguments, parameters, n, row, next, first, last, transition); + } +} + +struct ReverseOutput { + fr_t* values; + size_t count; + __device__ __forceinline__ fr_t& operator[](size_t i) const { return values[count - 1 - i]; } +}; + +struct ReverseInput { + const fr_t* values; + size_t count; + __device__ __forceinline__ fr_t operator[](size_t i) const { return values[count - 1 - i]; } +}; + +__global__ void lookup_inverse_product(fr_t* inverse, const fr_t* product) { + // Sppark's reciprocal cooperates with the adjacent lane via shfl_xor. + // Every lane participates, while only lane zero publishes the result. + fr_t local[1]; + batch_inversion(local, product); + if (threadIdx.x == 0) inverse[0] = local[0]; +} + +__global__ void lookup_contributions(fr_t* numerators, const fr_t* forward, + const fr_t* reverse, const fr_t* inverse, size_t count) { + for (size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + i < count; i += size_t(gridDim.x) * blockDim.x) { + const fr_t left = i == 0 ? fr_t::one() : forward[i - 1]; + const fr_t right = i + 1 == count ? fr_t::one() : reverse[i + 1]; + numerators[i] *= left * right * inverse[0]; + } +} + +__global__ void lookup_exclusive_columns(fr_t* columns, const fr_t* inclusive, + size_t n, size_t groups) { + for (size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + i < n * groups; i += size_t(gridDim.x) * blockDim.x) { + const size_t column = i / n, row = i % n; + const size_t index = row * groups + column; + fr_t value; + if (index) value = inclusive[index - 1]; else value.zero(); + columns[i] = value; + } +} + +} // namespace + +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, + const QuotientLookup* lookups, size_t lookup_count, + const uint32_t* args, size_t arg_count, fr_t* const* outputs, fr_t* total +) { + ProfileRange range("kzg/lookup-resident"); + try { + if (!parameters || !columns || !lookups || !lookup_count || !outputs || !total) + return cudaErrorInvalidValue; + const auto p = *parameters; + if (p.trace_log < 10 || p.trace_log > 27 || !p.slots || !p.blocks || p.blocks > 256 + || !p.group_size || p.group_size > 8) + return cudaErrorInvalidValue; + const size_t n = size_t(1) << p.trace_log; + const size_t groups = (lookup_count + p.group_size - 1) / p.group_size; + if (groups > SIZE_MAX / n) return cudaErrorInvalidValue; + const size_t count = n * groups; + for (size_t i = 0; i < groups; ++i) + if (!outputs[i]) return cudaErrorInvalidValue; + for (size_t i = 0; i < column_count; ++i) { + const auto& column = columns[i]; + if (!column.input || !column.count || column.count > n + || (column.slot != UINT32_MAX && column.slot >= evaluation_columns) + || (column.resident && (column.resident->device != device || column.resident->count != column.count))) + return cudaErrorInvalidValue; + } + const auto& gpu = device_gpu(device); + auto& stream = gpu[0]; + auto& transfer = transfer_lane(gpu); + TransferStats stats; + const auto started = std::chrono::steady_clock::now(); + { + dev_ptr_t evaluations(evaluation_columns * n, stream); + dev_ptr_t numerators(count, stream), denominators(count, stream); + dev_ptr_t forward(count, stream), reverse(count, stream), inverse(1, stream); + dev_ptr_t scratch(size_t(p.blocks) * 128 * p.slots, stream); + dev_ptr_t d_columns(column_count, stream); + dev_ptr_t d_nodes(node_count, stream); + dev_ptr_t d_lookups(lookup_count, stream); + dev_ptr_t d_args(arg_count, stream); + auto upload = [&](void* dst, const void* src, size_t bytes) { + if (bytes) transfer.upload.upload(gpu, stream, dst, src, bytes, stats); + }; + upload(d_columns, columns, column_count * sizeof(*columns)); + upload(d_nodes, nodes, node_count * sizeof(*nodes)); + upload(d_lookups, lookups, lookup_count * sizeof(*lookups)); + upload(d_args, args, arg_count * sizeof(*args)); + std::vector> kernels; + auto timed = [&](auto operation) { + auto timer = std::make_unique(); + timer->start(stream); + operation(); + timer->end(stream); + kernels.push_back(std::move(timer)); + }; + for (size_t i = 0; i < column_count; ++i) { + const auto& column = columns[i]; + if (column.slot == UINT32_MAX) continue; + fr_t* destination = &evaluations[size_t(column.slot) * n]; + const size_t bytes = column.count * sizeof(fr_t); + if (column.resident) { + CUDA_OK(cudaMemcpyAsync(destination, column.resident->data, bytes, cudaMemcpyDeviceToDevice, stream)); + stats.device_copy_bytes += bytes; + } else upload(destination, column.input, bytes); + if (column.count < n) + CUDA_OK(cudaMemsetAsync(destination + column.count, 0, (n - column.count) * sizeof(fr_t), stream)); + timed([&] { transform(gpu, stream, destination, p.trace_log, false, fr_t::one()); }); + } + timed([&] { + lookup_rationals<<>>(numerators, denominators, evaluations, + d_columns, d_nodes, node_count, d_lookups, lookup_count, d_args, scratch, p); + CUDA_OK(cudaGetLastError()); + prefix_op>(forward.data(), denominators.data(), count, stream); + prefix_op>(ReverseOutput{reverse, count}, ReverseInput{denominators, count}, count, stream); + lookup_inverse_product<<<1, 32, 0, stream>>>(inverse, &forward[count - 1]); + CUDA_OK(cudaGetLastError()); + lookup_contributions<<>>(numerators, forward, reverse, inverse, count); + CUDA_OK(cudaGetLastError()); + prefix_op>(numerators.data(), count, stream); + lookup_exclusive_columns<<>>(denominators, numerators, n, groups); + CUDA_OK(cudaGetLastError()); + for (size_t group = 0; group < groups; ++group) + transform(gpu, stream, &denominators[group * n], p.trace_log, true, fr_t::one()); + }); + transfer.download.download(gpu, stream, total, &numerators[count - 1], sizeof(fr_t), stats); + for (size_t group = 0; group < groups; ++group) + transfer.download.download(gpu, stream, outputs[group], &denominators[group * n], n * sizeof(fr_t), stats); + stream.sync(); + transfer.upload.finish_upload(stats); + for (auto& timer : kernels) timer->collect(stats); + } + stream.sync(); + stats.call_ms = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + stats.report(device, "lookup-resident", n); + 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_passes.cuh b/crates/proof-compression/cuda/kzg_passes.cuh new file mode 100644 index 000000000..64920b7b8 --- /dev/null +++ b/crates/proof-compression/cuda/kzg_passes.cuh @@ -0,0 +1,307 @@ +#pragma once + +namespace { + +struct alignas(32) ConstraintPass { + fr_t weight; + const QuotientColumn* columns; + size_t column_count, evaluated; + const QuotientInstruction* nodes; + size_t node_count; + const uint32_t* roots; + size_t root_count; + const QuotientLookup* lookups; + size_t lookup_count; + const uint32_t* args; + 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 { + size_t columns = 0, evaluated = 0, nodes = 0, roots = 0, lookups = 0, args = 0, slots = 1; + + PassShape(int device, const ConstraintPass* passes, size_t count, size_t rows) { + if (!passes || !count) CUDA_OK(cudaErrorInvalidValue); + for (size_t i = 0; i < count; ++i) { + const auto& p = passes[i]; + if (!p.columns || !p.column_count || !p.slots || p.evaluated > p.column_count + || (p.node_count && !p.nodes) || (p.root_count && !p.roots) + || (p.lookup_count && !p.lookups) || (p.arg_count && !p.args)) + CUDA_OK(cudaErrorInvalidValue); + for (size_t j = 0; j < p.column_count; ++j) { + const auto& c = p.columns[j]; + if (!c.input || !c.count || c.count > rows + || (c.slot != UINT32_MAX && c.slot >= p.evaluated) + || (c.resident && (c.resident->device != device || c.resident->count != c.count))) + CUDA_OK(cudaErrorInvalidValue); + } + columns = std::max(columns, p.column_count); + evaluated = std::max(evaluated, p.evaluated); + nodes = std::max(nodes, p.node_count); + roots = std::max(roots, p.root_count); + lookups = std::max(lookups, p.lookup_count); + args = std::max(args, p.arg_count); + slots = std::max(slots, p.slots); + } + if (evaluated > SIZE_MAX / rows / sizeof(fr_t)) CUDA_OK(cudaErrorInvalidValue); + } +}; + +struct PassBuffers { + dev_ptr_t evaluations, scratch; + dev_ptr_t columns; + dev_ptr_t nodes; + dev_ptr_t roots, args; + dev_ptr_t lookups; + std::vector> kernels; + + PassBuffers(const PassShape& shape, size_t n, size_t blocks, const stream_t& stream) + : evaluations(shape.evaluated * n, stream), scratch(blocks * 128 * shape.slots, stream), + columns(shape.columns, stream), nodes(shape.nodes, stream), roots(shape.roots, stream), + args(shape.args, stream), lookups(shape.lookups, stream) {} + + template void timed(const stream_t& stream, Operation operation) { + auto timer = std::make_unique(); + timer->start(stream); + operation(); + timer->end(stream); + kernels.push_back(std::move(timer)); + } + + void load(const gpu_t& gpu, stream_t& stream, const ConstraintPass& pass, + uint32_t log_n, fr_t shift, TransferStats& stats) { + const size_t n = size_t(1) << log_n; + auto& transfer = transfer_lane(gpu); + auto upload = [&](void* to, const void* from, size_t bytes) { + if (bytes) transfer.upload.upload(gpu, stream, to, from, bytes, stats); + }; + upload(columns, pass.columns, pass.column_count * sizeof(QuotientColumn)); + upload(nodes, pass.nodes, pass.node_count * sizeof(QuotientInstruction)); + upload(roots, pass.roots, pass.root_count * sizeof(uint32_t)); + upload(lookups, pass.lookups, pass.lookup_count * sizeof(QuotientLookup)); + upload(args, pass.args, pass.arg_count * sizeof(uint32_t)); + for (size_t i = 0; i < pass.column_count; ++i) { + const auto& column = pass.columns[i]; + if (column.slot == UINT32_MAX) continue; + fr_t* destination = evaluations.data() + size_t(column.slot) * n; + const size_t bytes = column.count * sizeof(fr_t); + if (column.resident) { + CUDA_OK(cudaMemcpyAsync(destination, column.resident->data, bytes, cudaMemcpyDeviceToDevice, stream)); + stats.device_copy_bytes += bytes; + } else { + upload(destination, column.input, bytes); + } + if (column.count < n) + CUDA_OK(cudaMemsetAsync(destination + column.count, 0, (n - column.count) * sizeof(fr_t), stream)); + timed(stream, [&] { transform(gpu, stream, destination, log_n, false, shift); }); + } + } + + void collect(TransferStats& stats) { + for (auto& timer : kernels) timer->collect(stats); + } +}; + +__global__ void lookup_pass_row_totals(fr_t* totals, const fr_t* contributions, size_t n, size_t groups) { + for (size_t row = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + row < n; row += size_t(gridDim.x) * blockDim.x) { + fr_t sum; + sum.zero(); + for (size_t group = 0; group < groups; ++group) sum += contributions[group * n + row]; + totals[row] = sum; + } +} + +__global__ void lookup_pass_accumulators(fr_t* contributions, const fr_t* inclusive_rows, + size_t n, size_t groups) { + for (size_t row = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + row < n; row += size_t(gridDim.x) * blockDim.x) { + fr_t prefix; + if (row) prefix = inclusive_rows[row - 1]; else prefix.zero(); + // Read a contribution before replacing it with its row/group prefix. + for (size_t group = 0; group < groups; ++group) { + const size_t offset = group * n + row; + const fr_t value = contributions[offset]; + contributions[offset] = prefix; + prefix += value; + } + } +} + +__global__ __launch_bounds__(128) void quotient_pass_sweep( + fr_t* output, const fr_t* evaluations, const fr_t* first_selector, + const QuotientColumn* columns, const QuotientInstruction* nodes, size_t node_count, + const uint32_t* roots, const QuotientLookup* lookups, size_t lookup_count, + const uint32_t* args, const fr_t* publics, fr_t* scratch, QuotientParameters parameters, + size_t groups, size_t group, fr_t weight, fr_t shift, fr_t inverse_vanishing, uint32_t coset +) { + const size_t n = size_t(1) << parameters.trace_log; + const size_t ratio = size_t(1) << (parameters.quotient_log - parameters.trace_log); + const size_t stride = size_t(gridDim.x) * blockDim.x; + size_t row = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + fr_t point = shift * (parameters.generator ^ uint32_t(row)); + const fr_t step = parameters.generator ^ uint32_t(stride); + fr_t* values = scratch + size_t(blockIdx.x) * parameters.slots * blockDim.x + threadIdx.x; + for (; row < n; row += stride, point *= step) { + const size_t next = (row + 1) & (n - 1); + const fr_t first = first_selector[row]; + const fr_t last = parameters.generator * first_selector[next]; + evaluate_constraint_nodes(values, evaluations, columns, nodes, node_count, publics, + n, row, next, first, last, point - parameters.generator_inverse); + const fr_t value = group == SIZE_MAX ? values[size_t(roots[0]) * blockDim.x] + : quotient_lookup_group(values, evaluations, columns, lookups, lookup_count, args, + publics, parameters, groups, group, n, row, next, last); + output[row * ratio + coset] += weight * value * inverse_vanishing; + } +} + +} // namespace + +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 +) { + ProfileRange range("kzg/lookup-passes"); + try { + if (!parameters || !outputs || !total || !passes || !pass_count) return cudaErrorInvalidValue; + const auto p = *parameters; + if (p.trace_log < 10 || p.trace_log > 27 || !p.blocks || p.blocks > 256 + || !p.group_size || p.group_size > 8) return cudaErrorInvalidValue; + const size_t n = size_t(1) << p.trace_log; + if (pass_count > SIZE_MAX / n / sizeof(fr_t)) return cudaErrorInvalidValue; + const PassShape shape(device, passes, pass_count, n); + for (size_t i = 0; i < pass_count; ++i) { + if (!outputs[i] || passes[i].group != i || !passes[i].lookup_count + || passes[i].lookup_count > p.group_size || passes[i].root_count) return cudaErrorInvalidValue; + } + const auto& gpu = device_gpu(device); + auto& stream = gpu[0]; + auto& transfer = transfer_lane(gpu); + TransferStats stats; + const auto started = std::chrono::steady_clock::now(); + { + PassBuffers buffers(shape, n, p.blocks, stream); + dev_ptr_t contributions(n * pass_count, stream), denominators(n, stream); + dev_ptr_t forward(n, stream), reverse(n, stream), inverse(1, stream); + for (size_t group = 0; group < pass_count; ++group) { + const auto& pass = passes[group]; + auto local = p; + local.slots = uint32_t(pass.slots); + buffers.load(gpu, stream, pass, p.trace_log, fr_t::one(), stats); + fr_t* values = contributions.data() + group * n; + buffers.timed(stream, [&] { + lookup_rationals<<>>(values, denominators, buffers.evaluations, + buffers.columns, buffers.nodes, pass.node_count, buffers.lookups, pass.lookup_count, + buffers.args, buffers.scratch, local); + CUDA_OK(cudaGetLastError()); + prefix_op>(forward.data(), denominators.data(), n, stream); + prefix_op>(ReverseOutput{reverse, n}, ReverseInput{denominators, n}, n, stream); + lookup_inverse_product<<<1, 32, 0, stream>>>(inverse, forward.data() + n - 1); + CUDA_OK(cudaGetLastError()); + lookup_contributions<<>>(values, forward, reverse, inverse, n); + CUDA_OK(cudaGetLastError()); + }); + } + buffers.timed(stream, [&] { + lookup_pass_row_totals<<>>(denominators, contributions, n, pass_count); + CUDA_OK(cudaGetLastError()); + prefix_op>(denominators.data(), n, stream); + lookup_pass_accumulators<<>>(contributions, denominators, n, pass_count); + CUDA_OK(cudaGetLastError()); + }); + transfer.download.download(gpu, stream, total, denominators.data() + n - 1, sizeof(fr_t), stats); + for (size_t group = 0; group < pass_count; ++group) { + fr_t* values = contributions.data() + group * n; + buffers.timed(stream, [&] { transform(gpu, stream, values, p.trace_log, true, fr_t::one()); }); + transfer.download.download(gpu, stream, outputs[group], values, n * sizeof(fr_t), stats); + } + stream.sync(); + transfer.upload.finish_upload(stats); + buffers.collect(stats); + } + stream.sync(); + stats.call_ms = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + stats.report(device, "lookup-passes", n); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +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 +) { + ProfileRange range("kzg/quotient-passes"); + try { + if (!parameters || !publics || public_count < 4 || !shifts || !inverse_vanishing || !outputs) + return cudaErrorInvalidValue; + const auto p = *parameters; + if (p.trace_log < 10 || p.trace_log > 27 || p.quotient_log < p.trace_log || p.quotient_log > 28 + || !p.blocks || p.blocks > 256 || !p.group_size || p.group_size > 8) + return cudaErrorInvalidValue; + const size_t n = size_t(1) << p.trace_log, ratio = size_t(1) << (p.quotient_log - p.trace_log); + const PassShape shape(device, passes, pass_count, n); + const size_t columns = passes[0].column_count; + if (p.stage2_start >= columns) return cudaErrorInvalidValue; + const size_t groups = columns - p.stage2_start; + for (size_t i = 0; i < ratio; ++i) if (!outputs[i]) return cudaErrorInvalidValue; + for (size_t i = 0; i < pass_count; ++i) { + const auto& pass = passes[i]; + if (pass.column_count != columns || pass.lookup_count > p.group_size + || (pass.group == SIZE_MAX ? (pass.root_count != 1 || pass.lookup_count != 0) + : (pass.group >= groups || pass.root_count != 0))) + return cudaErrorInvalidValue; + } + const auto& gpu = device_gpu(device); + auto& stream = gpu[0]; + auto& transfer = transfer_lane(gpu); + TransferStats stats; + const auto started = std::chrono::steady_clock::now(); + { + PassBuffers buffers(shape, n, p.blocks, stream); + dev_ptr_t first(n, stream), quotient(n * ratio, stream), d_publics(public_count, stream); + transfer.upload.upload(gpu, stream, d_publics.data(), publics, public_count * sizeof(fr_t), stats); + CUDA_OK(cudaMemsetAsync(quotient.data(), 0, n * ratio * sizeof(fr_t), stream)); + for (size_t coset = 0; coset < ratio; ++coset) { + buffers.timed(stream, [&] { + quotient_ones<<>>(first, n); + CUDA_OK(cudaGetLastError()); + transform(gpu, stream, first, p.trace_log, false, shifts[coset]); + }); + for (size_t i = 0; i < pass_count; ++i) { + const auto& pass = passes[i]; + auto local = p; + local.slots = uint32_t(pass.slots); + buffers.load(gpu, stream, pass, p.trace_log, shifts[coset], stats); + buffers.timed(stream, [&] { + quotient_pass_sweep<<>>(quotient, buffers.evaluations, first, + buffers.columns, buffers.nodes, pass.node_count, buffers.roots, buffers.lookups, + pass.lookup_count, buffers.args, d_publics, buffers.scratch, local, groups, + pass.group, pass.weight, shifts[coset], inverse_vanishing[coset], uint32_t(coset)); + CUDA_OK(cudaGetLastError()); + }); + } + } + buffers.timed(stream, [&] { transform(gpu, stream, quotient, p.quotient_log, true, p.quotient_shift_inverse); }); + for (size_t i = 0; i < ratio; ++i) + transfer.download.download(gpu, stream, outputs[i], quotient.data() + i * n, n * sizeof(fr_t), stats); + stream.sync(); + transfer.upload.finish_upload(stats); + buffers.collect(stats); + } + stream.sync(); + stats.call_ms = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + stats.report(device, "quotient-passes", n * ratio); + 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_profile.cuh b/crates/proof-compression/cuda/kzg_profile.cuh new file mode 100644 index 000000000..3b1b6092b --- /dev/null +++ b/crates/proof-compression/cuda/kzg_profile.cuh @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +#include "kzg_transfer.cuh" + +namespace { + +class ProfileRange { + public: + explicit ProfileRange(const char* name) { + if (transfer_profiling()) nvtxRangePushA(name); + } + ~ProfileRange() { + if (transfer_profiling()) nvtxRangePop(); + } + ProfileRange(const ProfileRange&) = delete; +}; + +class MsmKernelTimings; +thread_local MsmKernelTimings* active_msm_timings = nullptr; + +class MsmKernelTimings { + MsmKernelTimings* previous_; + std::vector> intervals_; + public: + MsmKernelTimings() : previous_(active_msm_timings) { + if (transfer_profiling()) active_msm_timings = this; + } + ~MsmKernelTimings() { active_msm_timings = previous_; } + MsmKernelTimings(const MsmKernelTimings&) = delete; + + TimedKernel* start(cudaStream_t stream) { + auto timer = std::make_unique(); + timer->start(stream); + intervals_.push_back(std::move(timer)); + return intervals_.back().get(); + } + void collect(TransferStats& stats) { + for (const auto& timer : intervals_) timer->collect(stats); + } +}; + +TimedKernel* kzg_msm_profile_start(cudaStream_t stream) { + return active_msm_timings ? active_msm_timings->start(stream) : nullptr; +} + +void kzg_msm_profile_end(TimedKernel* timer, cudaStream_t stream) { + if (timer) timer->end(stream); +} + +} // namespace + +// sppark's Pippenger header brackets its digit and bucket passes with these. +#define SPPARK_MSM_PROFILE_START(stream) kzg_msm_profile_start(stream) +#define SPPARK_MSM_PROFILE_END(timer, stream) kzg_msm_profile_end(timer, stream) diff --git a/crates/proof-compression/cuda/kzg_quotient.cuh b/crates/proof-compression/cuda/kzg_quotient.cuh new file mode 100644 index 000000000..098ef42f8 --- /dev/null +++ b/crates/proof-compression/cuda/kzg_quotient.cuh @@ -0,0 +1,271 @@ +#pragma once + +namespace { + +struct alignas(32) QuotientInstruction { + fr_t value; + uint32_t a, b, op, out; +}; + +struct QuotientLookup { + uint32_t multiplicity, arg_start, arg_count; +}; + +struct alignas(32) QuotientColumn { + const fr_t* input; + const ResidentPolynomial* resident; + size_t count; + uint64_t padding; + fr_t constant; + uint32_t slot, reserved; +}; + +struct alignas(32) QuotientParameters { + fr_t alpha, generator, generator_inverse, delta, quotient_shift_inverse; + uint32_t trace_log, quotient_log, slots, stage2_start, group_size, blocks; +}; + +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; + i < count; i += size_t(gridDim.x) * blockDim.x) + values[i] = fr_t::one(); +} + +__device__ __forceinline__ fr_t quotient_column( + const fr_t* evaluations, const QuotientColumn* columns, + size_t n, uint32_t column, size_t row +) { + const auto& descriptor = columns[column]; + return descriptor.slot == UINT32_MAX ? descriptor.constant + : evaluations[size_t(descriptor.slot) * n + row]; +} + +__device__ __forceinline__ void evaluate_constraint_nodes( + fr_t* values, const fr_t* evaluations, const QuotientColumn* columns, + const QuotientInstruction* nodes, size_t node_count, const fr_t* publics, + size_t n, size_t row, size_t next, const fr_t& first, const fr_t& last, + const fr_t& transition +) { + for (size_t i = 0; i < node_count; ++i) { + const auto& node = nodes[i]; + fr_t value; + switch (node.op) { + case 0: value = node.value; break; + case 1: value = quotient_column(evaluations, columns, n, node.a, node.b ? next : row); break; + case 2: value = publics[node.a]; break; + case 3: value = first; break; + case 4: value = last; break; + case 5: value = transition; break; + case 6: value = values[size_t(node.a) * blockDim.x] + values[size_t(node.b) * blockDim.x]; break; + case 7: value = values[size_t(node.a) * blockDim.x] - values[size_t(node.b) * blockDim.x]; break; + case 8: value = values[size_t(node.a) * blockDim.x] * values[size_t(node.b) * blockDim.x]; break; + case 9: value = -values[size_t(node.a) * blockDim.x]; break; + default: value.zero(); break; + } + values[size_t(node.out) * blockDim.x] = value; + } +} + +__device__ __forceinline__ fr_t quotient_lookup_group( + fr_t* values, const fr_t* evaluations, const QuotientColumn* columns, + const QuotientLookup* lookups, size_t lookup_count, const uint32_t* args, + const fr_t* publics, QuotientParameters parameters, size_t groups, size_t group, + size_t n, size_t row, size_t next, fr_t last +) { + const fr_t target = group + 1 == groups + ? quotient_column(evaluations, columns, n, parameters.stage2_start, next) + last * parameters.delta + : quotient_column(evaluations, columns, n, parameters.stage2_start + uint32_t(group + 1), row); + fr_t constraint = target - quotient_column(evaluations, columns, n, parameters.stage2_start + uint32_t(group), row); + fr_t messages[8], prefix[9], suffix[9]; + prefix[0] = suffix[lookup_count] = fr_t::one(); + for (size_t i = 0; i < lookup_count; ++i) { + const auto& lookup = lookups[i]; + fr_t fingerprint; + fingerprint.zero(); + for (size_t j = lookup.arg_count; j > 0; --j) + fingerprint = fingerprint * publics[1] + values[size_t(args[lookup.arg_start + j - 1]) * blockDim.x]; + messages[i] = fingerprint + publics[0]; + prefix[i + 1] = prefix[i] * messages[i]; + } + for (size_t i = lookup_count; i > 0; --i) suffix[i - 1] = messages[i - 1] * suffix[i]; + constraint *= prefix[lookup_count]; + for (size_t i = 0; i < lookup_count; ++i) + constraint -= prefix[i] * suffix[i + 1] * values[size_t(lookups[i].multiplicity) * blockDim.x]; + return constraint; +} + +__device__ __forceinline__ fr_t quotient_row( + fr_t* values, const fr_t* evaluations, const QuotientColumn* columns, + const QuotientInstruction* nodes, size_t node_count, const uint32_t* roots, + size_t root_count, const QuotientLookup* lookups, size_t lookup_count, + const uint32_t* arguments, const fr_t* publics, QuotientParameters parameters, + size_t n, size_t row, size_t next, const fr_t& first, const fr_t& last, + const fr_t& transition +) { + evaluate_constraint_nodes(values, evaluations, columns, nodes, node_count, + publics, n, row, next, first, last, transition); + fr_t accumulator; + accumulator.zero(); + for (size_t i = 0; i < root_count; ++i) + accumulator = accumulator * parameters.alpha + values[size_t(roots[i]) * blockDim.x]; + const size_t groups = lookup_count ? (lookup_count + parameters.group_size - 1) / parameters.group_size : 1; + for (size_t group = 0; group < groups; ++group) { + const size_t start = group * parameters.group_size; + const size_t remaining = lookup_count - start; + const size_t count = remaining < parameters.group_size ? remaining : parameters.group_size; + const fr_t constraint = quotient_lookup_group(values, evaluations, columns, + count ? lookups + start : nullptr, count, arguments, publics, parameters, + groups, group, n, row, next, last); + accumulator = accumulator * parameters.alpha + constraint; + } + return accumulator; +} + +__global__ __launch_bounds__(128) void quotient_sweep( + fr_t* output, const fr_t* evaluations, const fr_t* first_selector, + const QuotientColumn* columns, const QuotientInstruction* nodes, size_t node_count, + const uint32_t* roots, size_t root_count, const QuotientLookup* lookups, size_t lookup_count, + const uint32_t* arguments, const fr_t* publics, fr_t* scratch, + QuotientParameters parameters, fr_t shift, fr_t inverse_vanishing, uint32_t coset +) { + const size_t n = size_t(1) << parameters.trace_log; + const size_t ratio = size_t(1) << (parameters.quotient_log - parameters.trace_log); + const size_t stride = size_t(gridDim.x) * blockDim.x; + size_t row = size_t(blockIdx.x) * blockDim.x + threadIdx.x; + fr_t point = shift * (parameters.generator ^ uint32_t(row)); + const fr_t point_step = parameters.generator ^ uint32_t(stride); + fr_t* values = scratch + size_t(blockIdx.x) * parameters.slots * blockDim.x + threadIdx.x; + for (; row < n; row += stride, point *= point_step) { + const size_t next = (row + 1) & (n - 1); + const fr_t first = first_selector[row]; + // These selectors are unnormalized: L_last(X) = g * L_first(gX). + const fr_t last = parameters.generator * first_selector[next]; + const fr_t transition = point - parameters.generator_inverse; + output[row * ratio + coset] = quotient_row(values, evaluations, columns, + nodes, node_count, roots, root_count, lookups, lookup_count, arguments, + publics, parameters, n, row, next, first, last, transition) * inverse_vanishing; + } +} + +} // namespace + +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, + const uint32_t* roots, size_t root_count, const QuotientLookup* lookups, size_t lookup_count, + const uint32_t* args, size_t arg_count, const fr_t* publics, size_t public_count, + const fr_t* coset_shifts, const fr_t* inverse_vanishing, fr_t* const* outputs +) { + ProfileRange range("kzg/quotient-resident"); + try { + 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 > 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; + const size_t n = size_t(1) << p.trace_log; + const size_t ratio = size_t(1) << (p.quotient_log - p.trace_log); + for (size_t i = 0; i < ratio; ++i) + if (!outputs[i]) return cudaErrorInvalidValue; + for (size_t i = 0; i < column_count; ++i) { + const auto& column = columns[i]; + if (!column.input || !column.count || column.count > n + || (column.slot != UINT32_MAX && column.slot >= evaluation_columns) + || (column.resident && (column.resident->device != device || column.resident->count != column.count))) + return cudaErrorInvalidValue; + } + const auto& gpu = device_gpu(device); + auto& stream = gpu[0]; + auto& transfer = transfer_lane(gpu); + TransferStats stats; + const auto started = std::chrono::steady_clock::now(); + { + dev_ptr_t evaluations(evaluation_columns * n, stream); + dev_ptr_t first(n, stream), quotient(n * ratio, stream); + dev_ptr_t scratch(size_t(p.blocks) * 128 * p.slots, stream); + dev_ptr_t d_columns(column_count, stream); + dev_ptr_t d_nodes(node_count, stream); + dev_ptr_t d_roots(root_count, stream), d_args(arg_count, stream); + dev_ptr_t d_lookups(lookup_count, stream); + dev_ptr_t d_publics(public_count, stream); + auto upload = [&](void* dst, const void* src, size_t bytes) { + if (bytes) transfer.upload.upload(gpu, stream, dst, src, bytes, stats); + }; + upload(d_columns, columns, column_count * sizeof(*columns)); + upload(d_nodes, nodes, node_count * sizeof(*nodes)); + upload(d_roots, roots, root_count * sizeof(*roots)); + upload(d_lookups, lookups, lookup_count * sizeof(*lookups)); + upload(d_args, args, arg_count * sizeof(*args)); + upload(d_publics, publics, public_count * sizeof(*publics)); + std::vector> kernels; + auto timed = [&](auto operation) { + auto timer = std::make_unique(); + timer->start(stream); + operation(); + timer->end(stream); + kernels.push_back(std::move(timer)); + }; + for (size_t coset = 0; coset < ratio; ++coset) { + for (size_t i = 0; i < column_count; ++i) { + const auto& column = columns[i]; + if (column.slot == UINT32_MAX) continue; + fr_t* destination = &evaluations[size_t(column.slot) * n]; + const size_t bytes = column.count * sizeof(fr_t); + if (column.resident) { + CUDA_OK(cudaMemcpyAsync(destination, column.resident->data, bytes, cudaMemcpyDeviceToDevice, stream)); + stats.device_copy_bytes += bytes; + } else { + upload(destination, column.input, bytes); + } + if (column.count < n) + CUDA_OK(cudaMemsetAsync(destination + column.count, 0, (n - column.count) * sizeof(fr_t), stream)); + timed([&] { transform(gpu, stream, destination, p.trace_log, false, coset_shifts[coset]); }); + } + timed([&] { + quotient_ones<<>>(first, n); + CUDA_OK(cudaGetLastError()); + transform(gpu, stream, first, p.trace_log, false, coset_shifts[coset]); + quotient_sweep<<>>(quotient, evaluations, first, + d_columns, d_nodes, node_count, d_roots, root_count, d_lookups, lookup_count, + d_args, d_publics, scratch, p, coset_shifts[coset], inverse_vanishing[coset], uint32_t(coset)); + CUDA_OK(cudaGetLastError()); + }); + } + timed([&] { transform(gpu, stream, quotient, p.quotient_log, true, p.quotient_shift_inverse); }); + for (size_t k = 0; k < ratio; ++k) + transfer.download.download(gpu, stream, outputs[k], "ient[k * n], n * sizeof(fr_t), stats); + stream.sync(); + transfer.upload.finish_upload(stats); + for (auto& timer : kernels) timer->collect(stats); + } + // Complete cudaFreeAsync before a later lease performs admission. + stream.sync(); + stats.call_ms = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + stats.report(device, "quotient-resident", n * ratio); + 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_runtime.cpp b/crates/proof-compression/cuda/kzg_runtime.cpp new file mode 100644 index 000000000..ebbe3996d --- /dev/null +++ b/crates/proof-compression/cuda/kzg_runtime.cpp @@ -0,0 +1,71 @@ +#include + +namespace { +constexpr bool cubin_compatible(int architecture, int major, int minor) { + // Native cubins can run on newer minor revisions of the same major ISA. + return architecture / 10 == major && architecture % 10 <= minor; +} + +static_assert(cubin_compatible(70, 7, 5)); +static_assert(cubin_compatible(75, 7, 5)); +static_assert(!cubin_compatible(75, 7, 0)); +static_assert(!cubin_compatible(80, 7, 5)); +static_assert(cubin_compatible(120, 12, 1)); +static_assert(!cubin_compatible(120, 13, 0)); + +bool has_compiled_kernel(const cudaDeviceProp& properties) { + constexpr int architectures[] = IX_KZG_CUDA_ARCHITECTURES; + for (int architecture : architectures) + if (cubin_compatible(architecture, properties.major, properties.minor)) + return true; + return false; +} + +class Devices { +public: + std::vector values; + + Devices() { + int count = 0, saved = 0; + if (cudaGetDeviceCount(&count) != cudaSuccess || count == 0) return; + CUDA_OK(cudaGetDevice(&saved)); + for (int id = 0; id < count; ++id) { + cudaDeviceProp properties; + CUDA_OK(cudaGetDeviceProperties(&properties, id)); + if (properties.major < 7 || !properties.cooperativeLaunch || + !has_compiled_kernel(properties)) continue; + CUDA_OK(cudaSetDevice(id)); + values.push_back(new gpu_t(static_cast(values.size()), id, properties)); + } + CUDA_OK(cudaSetDevice(saved)); + } + + ~Devices() { + int saved = 0; + (void)cudaGetDevice(&saved); + for (const auto* gpu : values) { + // CUDA stream destruction requires the stream's owning context. + gpu->select(); + delete gpu; + } + (void)cudaSetDevice(saved); + } +}; +} + +const std::vector& all_gpus() { + static Devices devices; + return devices.values; +} + +size_t ngpus() { return all_gpus().size(); } + +const gpu_t& select_gpu(int id) { + if (id < 0 || static_cast(id) >= ngpus()) + CUDA_OK(cudaErrorInvalidDevice); + const auto* gpu = all_gpus()[id]; + gpu->select(); + return *gpu; +} + +const cudaDeviceProp& gpu_props(int id) { return all_gpus().at(id)->props(); } diff --git a/crates/proof-compression/cuda/kzg_srs.cuh b/crates/proof-compression/cuda/kzg_srs.cuh new file mode 100644 index 000000000..49aed2b07 --- /dev/null +++ b/crates/proof-compression/cuda/kzg_srs.cuh @@ -0,0 +1,186 @@ +#pragma once + +namespace { + +constexpr size_t SRS_UPLOAD_POINTS = size_t(1) << 18; + +uint32_t srs_msm_window(size_t count) { + count = (count + 31) & ~size_t(31); + uint32_t bits = 0; + for (size_t n = count + count / 2; n >>= 1;) ++bits; + return count <= 192 ? 10 : std::max(10u, std::min(bits - 8, 18u)); +} + +struct SrsPoints { + int device; + size_t count; + dev_ptr_t points; + SrsPoints(const gpu_t& gpu, size_t count) + : device(gpu.cid()), count(count), points(count, gpu) {} +}; + +struct SrsMsmWorkspace { + int device; + uint32_t window; + msm_impl msm; + SrsMsmWorkspace(const gpu_t& gpu, size_t count) + : device(gpu.cid()), window(srs_msm_window(count)), + msm(nullptr, count, sizeof(affine_t), gpu.id()) {} +}; + +} // namespace + +extern "C" int ix_kzg_srs_plan(int device, size_t count, uint32_t* window, + size_t* persistent, size_t* temporary) { + try { + if (!count || count > (size_t(1) << 24) || !window || !persistent || !temporary) + return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + *window = srs_msm_window(count); + const size_t windows = (fr_t::bit_length() - 1) / *window + 1; + const size_t row = size_t(1) << (*window - 1); + *persistent = (windows * row + gpu.sm_count() * BATCH_ADD_BLOCK_SIZE / WARP_SZ) + * sizeof(bucket_t::mem_t) + windows * row * sizeof(uint32_t); + uint32_t lg = 0; + for (size_t n = count + count / 2; n >>= 1;) ++lg; + const size_t batch = std::max(size_t(1), (size_t(1) << (std::max(lg, *window) - *window)) >> 6); + const size_t stride = ((count + batch - 1) / batch + 31) & ~size_t(31); + // Device scalar pointers avoid sppark's point/scalar upload buffers. + // One full scalar buffer covers host uploads or resident normalization. + *temporary = stride * (2 * sizeof(uint2) + windows * sizeof(uint32_t)) + + ((count + 31) & ~size_t(31)) * sizeof(fr_t); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +extern "C" int ix_kzg_srs_upload(int device, const ArkAffine* input, + size_t count, void** output) { + ProfileRange range("kzg/srs-cache-upload"); + try { + if (!input || !output || !count || count > (size_t(1) << 24)) + return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + auto result = std::make_unique(gpu, count); + { + dev_ptr_t staging(std::min(count, SRS_UPLOAD_POINTS), gpu); + auto& transfer = transfer_lane(gpu); + TransferStats stats; + std::vector conversions((count + SRS_UPLOAD_POINTS - 1) / SRS_UPLOAD_POINTS); + const auto started = std::chrono::steady_clock::now(); + for (size_t offset = 0; offset < count; offset += SRS_UPLOAD_POINTS) { + const size_t length = std::min(count - offset, SRS_UPLOAD_POINTS); + transfer.upload.upload(gpu, gpu, staging, input + offset, + length * sizeof(ArkAffine), stats); + auto& conversion = conversions[offset / SRS_UPLOAD_POINTS]; + conversion.start(gpu); + unpack_affine<<>>( + &result->points[offset], staging, length); + CUDA_OK(cudaGetLastError()); + conversion.end(gpu); + } + gpu.sync(); + transfer.upload.finish_upload(stats); + for (auto& conversion : conversions) conversion.collect(stats); + stats.call_ms = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + stats.report(device, "srs-cache-upload", count); + } + gpu.sync(); + *output = result.release(); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +extern "C" int ix_kzg_srs_destroy(int device, void* pointer) { + try { + const auto& gpu = device_gpu(device); + auto* points = static_cast(pointer); + if (!points || points->device != device) return cudaErrorInvalidValue; + gpu.sync(); + delete points; + 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()); } +} + +extern "C" int ix_kzg_srs_workspace_create(int device, size_t count, void** output) { + try { + if (!output || !count || count > (size_t(1) << 24)) return cudaErrorInvalidValue; + *output = new SrsMsmWorkspace(device_gpu(device), count); + return 0; + } catch (const cuda_error& e) { return failure(e.code(), e.what()); } + catch (const std::exception& e) { return failure(cudaErrorUnknown, e.what()); } +} + +extern "C" int ix_kzg_srs_workspace_destroy(int device, void* pointer) { + try { + const auto& gpu = device_gpu(device); + auto* workspace = static_cast(pointer); + if (!workspace || workspace->device != device) return cudaErrorInvalidValue; + delete workspace; + 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()); } +} + +extern "C" int ix_kzg_srs_msm(int device, void* workspace_pointer, + const void* points_pointer, size_t point_offset, point_t* output, + const fr_t* host_scalars, const void* polynomial, size_t scalar_offset, + size_t count, const fr_t* normalization) { + ProfileRange range("kzg/msm-cached-points"); + try { + if (!workspace_pointer || !points_pointer || !output || !count + || count > (size_t(1) << 24) || bool(host_scalars) == bool(polynomial)) + return cudaErrorInvalidValue; + const auto& gpu = device_gpu(device); + auto& workspace = *static_cast(workspace_pointer); + const auto& points = *static_cast(points_pointer); + if (workspace.device != device || workspace.window != srs_msm_window(count) + || points.device != device || point_offset > points.count || count > points.count - point_offset) + return cudaErrorInvalidValue; + const auto* coefficients = static_cast(polynomial); + if (coefficients && (coefficients->device != device || scalar_offset > coefficients->count + || count > coefficients->count - scalar_offset)) return cudaErrorInvalidValue; + { + dev_ptr_t temporary(host_scalars || normalization ? count : 0, gpu); + fr_t* input = temporary; + if (coefficients) input = const_cast(&coefficients->data[scalar_offset]); + TransferStats transfers; + if (host_scalars) { + auto& transfer = transfer_lane(gpu); + transfer.upload.upload(gpu, gpu, temporary, host_scalars, count * sizeof(fr_t), transfers); + gpu.sync(); + transfer.upload.finish_upload(transfers); + transfers.report(device, "msm-scalars", count); + } + MsmKernelTimings kernels; + TransferStats compute; + const auto started = std::chrono::steady_clock::now(); + if (normalization) { + auto* timer = kzg_msm_profile_start(gpu); + scale_values<<>>(temporary, input, count, *normalization); + CUDA_OK(cudaGetLastError()); + kzg_msm_profile_end(timer, gpu); + gpu.sync(); + input = temporary; + } + // sppark retains pointer values between invocations. Rebind both + // views while the device lease keeps their allocations alive. + dev_ptr_t point_view(const_cast(&points.points[point_offset]), count); + dev_ptr_t scalar_view(input, count); + const int code = msm_result(workspace.msm.invoke(*output, point_view, count, scalar_view, true)); + if (code) return code; + kernels.collect(compute); + compute.call_ms = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + compute.report(device, "msm-compute", count); + } + 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_transfer.cuh b/crates/proof-compression/cuda/kzg_transfer.cuh new file mode 100644 index 000000000..a33e09655 --- /dev/null +++ b/crates/proof-compression/cuda/kzg_transfer.cuh @@ -0,0 +1,197 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool transfer_profiling() { + static const bool enabled = [] { + const char* value = std::getenv("IX_KZG_CUDA_PROFILE"); + return value && std::strcmp(value, "1") == 0; + }(); + return enabled; +} + +struct TransferStats { + double upload_ms = 0, download_ms = 0, kernel_ms = 0, host_copy_ms = 0, call_ms = 0; + size_t upload_bytes = 0, download_bytes = 0, device_copy_bytes = 0; + + void report(int device, const char* operation, size_t count) const { + if (!transfer_profiling()) return; + std::fprintf(stderr, + "KZG CUDA profile device=%d operation=%s elements=%zu " + "upload_ms=%.3f kernel_ms=%.3f download_ms=%.3f host_copy_ms=%.3f " + "upload_bytes=%zu download_bytes=%zu device_copy_bytes=%zu call_ms=%.3f\n", + device, operation, count, upload_ms, kernel_ms, download_ms, + host_copy_ms, upload_bytes, download_bytes, device_copy_bytes, call_ms); + } +}; + +class TimedKernel { + cudaEvent_t start_ = nullptr, end_ = nullptr; + public: + TimedKernel() { + if (transfer_profiling()) { + CUDA_OK(cudaEventCreate(&start_)); + try { CUDA_OK(cudaEventCreate(&end_)); } + catch (...) { cudaEventDestroy(start_); throw; } + } + } + ~TimedKernel() { + if (start_) cudaEventDestroy(start_); + if (end_) cudaEventDestroy(end_); + } + void start(cudaStream_t stream) { + if (start_) CUDA_OK(cudaEventRecord(start_, stream)); + } + void end(cudaStream_t stream) { + if (end_) CUDA_OK(cudaEventRecord(end_, stream)); + } + void collect(TransferStats& stats) { + if (!end_) return; + CUDA_OK(cudaEventSynchronize(end_)); + float elapsed = 0; + CUDA_OK(cudaEventElapsedTime(&elapsed, start_, end_)); + stats.kernel_ms += elapsed; + } +}; + +// Each device lease owns two FFT lanes. A ring bounds pinned memory regardless +// of polynomial size and overlaps the next host copy with the preceding DMA. +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_{}; + + void wait(size_t slot, double& milliseconds) { + if (!pending_[slot]) return; + CUDA_OK(cudaEventSynchronize(ready_[slot])); + if (start_[slot]) { + float elapsed = 0; + CUDA_OK(cudaEventElapsedTime(&elapsed, start_[slot], ready_[slot])); + milliseconds += elapsed; + } + pending_[slot] = false; + } + + void copy_host(const gpu_t& gpu, void* dst, const void* src, size_t bytes, + TransferStats& stats) { + const auto started = std::chrono::steady_clock::now(); + constexpr size_t GRAIN = size_t(4) << 20; + const size_t parts = (bytes + GRAIN - 1) / GRAIN; + gpu.par_map(parts, 1, [&](size_t part) { + const size_t offset = part * GRAIN; + std::memcpy(static_cast(dst) + offset, + static_cast(src) + offset, + std::min(GRAIN, bytes - offset)); + }); + stats.host_copy_ms += std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + } + + void enqueue(size_t slot, void* dst, const void* src, size_t bytes, + cudaMemcpyKind kind, cudaStream_t stream) { + if (start_[slot]) CUDA_OK(cudaEventRecord(start_[slot], stream)); + CUDA_OK(cudaMemcpyAsync(dst, src, bytes, kind, stream)); + CUDA_OK(cudaEventRecord(ready_[slot], stream)); + pending_[slot] = true; + } + + 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() + ? cudaEventDefault : cudaEventDisableTiming)); + if (transfer_profiling()) CUDA_OK(cudaEventCreate(&start_[i])); + } + } catch (...) { release(); throw; } + } + TransferRing(const TransferRing&) = delete; + ~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]) { + if (pending_[i]) cudaEventSynchronize(ready_[i]); + cudaEventDestroy(ready_[i]); + } + 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, + const void* src, size_t bytes, TransferStats& stats) { + for (size_t offset = 0, i = 0; offset < bytes; offset += CHUNK, ++i) { + const size_t slot = i % RING; + wait(slot, stats.upload_ms); + auto* buffer = memory_ + slot * CHUNK; + const size_t count = std::min(CHUNK, bytes - offset); + copy_host(gpu, buffer, static_cast(src) + offset, + count, stats); + enqueue(slot, static_cast(dst) + offset, buffer, count, + cudaMemcpyHostToDevice, stream); + } + stats.upload_bytes += bytes; + } + + void finish_upload(TransferStats& stats) { + for (size_t i = 0; i < RING; ++i) wait(i, stats.upload_ms); + } + + void download(const gpu_t& gpu, cudaStream_t stream, void* dst, + const void* src, size_t bytes, TransferStats& stats) { + const size_t chunks = (bytes + CHUNK - 1) / CHUNK; + for (size_t i = 0; i < chunks + RING; ++i) { + const size_t slot = i % RING; + auto* buffer = memory_ + slot * CHUNK; + if (i >= RING && i - RING < chunks) { + wait(slot, stats.download_ms); + const size_t offset = (i - RING) * CHUNK; + copy_host(gpu, static_cast(dst) + offset, buffer, + std::min(CHUNK, bytes - offset), stats); + } + if (i < chunks) { + const size_t offset = i * CHUNK; + enqueue(slot, buffer, static_cast(src) + offset, + std::min(CHUNK, bytes - offset), cudaMemcpyDeviceToHost, stream); + } + } + stats.download_bytes += bytes; + } +}; + +struct TransferLane { + TransferRing upload, download; +}; + +TransferLane& transfer_lane(const gpu_t& gpu, size_t lane = 0) { + static std::mutex mutex; + static std::map, 2>> lanes; + std::lock_guard lock(mutex); + auto& entry = lanes[gpu.cid()][lane]; + if (!entry) entry = std::make_unique(); + return *entry; +} + +} // namespace 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..0727335cf --- /dev/null +++ b/crates/proof-compression/cuda/plonk/ix_plonk_bn254.h @@ -0,0 +1,142 @@ +#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_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 { + 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; + 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); +/* Yields the numerator in LagrangeCoset/BitReverse form. */ +int ix_plonk_bn254_quotient_finish(ix_plonk_bn254_context *context, + 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, + 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/plonk.cu b/crates/proof-compression/cuda/plonk/plonk.cu new file mode 100644 index 000000000..bbaa775af --- /dev/null +++ b/crates/proof-compression/cuda/plonk/plonk.cu @@ -0,0 +1,820 @@ +#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(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; + 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; +}; + +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); + 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->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()) + 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, 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 && 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.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"); + 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; + } + ++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/examples/fri_sizing.rs b/crates/proof-compression/examples/fri_sizing.rs new file mode 100644 index 000000000..27b15f42c --- /dev/null +++ b/crates/proof-compression/examples/fri_sizing.rs @@ -0,0 +1,245 @@ +//! Size the first KZG stage's FRI verifier for alternative query counts +//! without a proof: the verifier relation depends only on the key, the +//! 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. +//! +//! Usage: fri_sizing [queries,...] [log-height] +//! fri_sizing --statistics + +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(); + 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) + .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 vk = 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, vk, 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/crates/proof-compression/examples/kzg_backend_parity.rs b/crates/proof-compression/examples/kzg_backend_parity.rs new file mode 100644 index 000000000..903bea908 --- /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("vk.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/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..0d9f387a1 --- /dev/null +++ b/crates/proof-compression/src/foreign.rs @@ -0,0 +1,1044 @@ +//! 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}; +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; + +mod interval; +mod quotient; + +pub trait KzgCircuitExt { + fn kzg_circuit_inputs( + &self, + max_trace_len: usize, + quotient_budget: usize, + ) -> Result>, LoweringError>; + fn kzg_circuit_input( + &self, + index: 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 trace cap and quotient budget. + fn kzg_circuit_inputs( + &self, + max_trace_len: usize, + quotient_budget: usize, + ) -> Result>, LoweringError> { + let mut inputs = self.circuit_inputs(); + for input in &mut inputs { + tune_kzg_input(input, max_trace_len, quotient_budget)?; + } + Ok(inputs) + } + + /// Generate and tune one circuit's preprocessing, without materializing the others. + fn kzg_circuit_input( + &self, + index: 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, max_trace_len, quotient_budget)?; + Ok(Some(input)) + } +} + +fn tune_kzg_input( + input: &mut CircuitInputs, + max_trace_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() > max_trace_len { + return Err(LoweringError::InvalidTraceHeight); + } + let graph = input + .compile_graph(&ExtensionParams { + degree: 1, + w: Scalar::ZERO, + karatsuba: false, + }) + .expect("valid lowering"); + let commitment_bytes = crate::kzg::curve::G1_COMPRESSED_BYTES; + 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, + 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 { + 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)) +} + +#[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]; + 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; + } + 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 limb in limbs { + b.lookup(table, &[limb]); + terms.push((coefficient, limb)); + coefficient *= radix; + } + let packed = b.linear_combination(&terms, Scalar::ZERO); + 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_pure("Goldilocks high word", &[value], |v| { + Ok(Scalar::from_u64(v[0].canonical_low_u64() >> 32)) + }); + 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(), + )) + }); + 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 = is_zero(b, delta); + let invalid = b.mul(max_hi, 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 { + 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) + }) + } + + 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(), + ); + 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 = 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() + .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); + record_chunk(b, &mut chunks); + } + for value in source.public_values() { + b.expose_public(wires[value.index()]); + } + 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( + &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 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 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(signed_scalar); + 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::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::*; + + #[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/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..5d7d869de --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/independent_witness.rs @@ -0,0 +1,201 @@ +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() + ); +} 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..60f8f543e --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/indexed_names.rs @@ -0,0 +1,33 @@ +use super::*; + +#[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) + }) + ); +} 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..0305e8981 --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/quotient_hints.rs @@ -0,0 +1,179 @@ +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 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_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(), + 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); + } +} 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..edd81002a --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/range_hints.rs @@ -0,0 +1,133 @@ +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() }) + ); +} 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..9511d805f --- /dev/null +++ b/crates/proof-compression/src/foreign/tests/translation_intervals.rs @@ -0,0 +1,87 @@ +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)) + ); + } + } +} diff --git a/crates/proof-compression/src/fri/algebra.rs b/crates/proof-compression/src/fri/algebra.rs new file mode 100644 index 000000000..68d561123 --- /dev/null +++ b/crates/proof-compression/src/fri/algebra.rs @@ -0,0 +1,429 @@ +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 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, + 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; + // 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; + 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..4e24f6ea0 --- /dev/null +++ b/crates/proof-compression/src/fri/key_codec.rs @@ -0,0 +1,212 @@ +//! 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 { + /// 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(); + 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..63108937a --- /dev/null +++ b/crates/proof-compression/src/fri/pcs.rs @@ -0,0 +1,561 @@ +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]); + // 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]; + 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 powers = &alpha_powers[&height]; + let reduced = reductions.entry(height).or_insert(zero); + for (point, &(start, weighted)) in + ood_terms[batch][matrix].iter().enumerate() + { + 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()) { + constant.assert_equal(b, zero); + } + 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); + 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..2b2a4becc --- /dev/null +++ b/crates/proof-compression/src/kzg/coefficients.rs @@ -0,0 +1,206 @@ +//! Compact constant, resident or checksummed, trusted-local coefficient storage. +use std::{ + borrow::Cow, + fs::File, + io::{Read, Seek, SeekFrom}, + ops::Range, + sync::{Arc, Mutex}, +}; + +use super::curve::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 { + /// 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, + } + } + + /// Host coefficients without materializing a constant column: a constant + /// is the one-element slice of its constant term. Disk-backed columns + /// return `None`. + #[cfg(feature = "cuda")] + pub(super) fn host_coefficients(&self) -> Option<&[Fr]> { + match self { + Self::Constant { value, .. } => Some(std::slice::from_ref(value)), + _ => self.resident(), + } + } + + 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..8b7ee047b --- /dev/null +++ b/crates/proof-compression/src/kzg/compact.rs @@ -0,0 +1,376 @@ +//! Fixed-verifying key 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 super::curve::{Fr, G1_COMPRESSED_BYTES, G1Affine, read_g1_compressed}; +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; + +const MAGIC: &[u8; 4] = b"KBN1"; + +#[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 verifying key") + } +} +impl std::error::Error for CompactProofError {} + +/// 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, + max_openings: usize, + body_bytes: usize, +} + +impl FixedProofCodec { + 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![]), 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() { + 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]); + 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() * G1_COMPRESSED_BYTES + + 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 = MAGIC.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] != MAGIC { + return Err(CompactProofError); + } + let openings = usize::from(bytes[4]); + if openings == 0 + || openings > self.max_openings + || bytes.len() != 5 + self.body_bytes + G1_COMPRESSED_BYTES * 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().flatten() { + *point = + read_g1_compressed(&mut input).map_err(|_error| CompactProofError)?; + } + } + for point in &mut proof.opening_proof.0 { + *point = + read_g1_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 = 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().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, + ] { + shape.push(c.0.len()); + shape.extend(c.0.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!(codec.encode(&decoded).unwrap(), bytes); + system.verify_multiple_claims(&[], &decoded).unwrap(); + 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()); + } + let mut bad = bytes.clone(); + bad.push(0); + assert!(codec.decode(&bad).is_err()); + bad = bytes.clone(); + bad[5..5 + G1_COMPRESSED_BYTES].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..98d647b20 --- /dev/null +++ b/crates/proof-compression/src/kzg/config.rs @@ -0,0 +1,665 @@ +//! [`ProofConfig`] instantiation for the KZG backend: BN254's +//! scalar field (its own challenge field, `D = 1`), a selected 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; + +#[cfg(feature = "cuda")] +use super::cuda; +use super::field::Scalar; +use super::pcs::{KzgCommitment, KzgPcs, KzgProverData}; +use super::srs::Srs; +use super::transcript::{KzgTranscript, TranscriptKind}; + +#[derive(Clone)] +pub struct KzgConfig { + pcs: KzgPcs, + /// 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, +} + +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 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::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_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/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] + { + 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 { + 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 + } + + /// 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 { + 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; + } + #[cfg(feature = "cuda")] + let results: Vec<(KzgProverData, Scalar)> = { + use rayon::prelude::*; + let mut parts: Vec<_> = inputs + .par_iter() + .map(|input| { + cuda::lookup::coefficients(input, beta, gamma).map( + |(columns, local)| { + let domain = input.stage_1.0.matrices[input.stage_1.1].domain; + ( + self.pcs.commit_trace_coefficients(domain.log_size, columns), + local, + ) + }, + ) + }) + .collect(); + // 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)); + } + } + parts.into_iter().map(Option::unwrap).collect() + }; + #[cfg(not(feature = "cuda"))] + 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 (commitment, data) = KzgProverData::concatenate(parts); + Some((commitment, data, accumulators)) + } + + fn accelerated_quotient_commit( + &self, + inputs: &[multi_stark::config::QuotientCommitInput<'_, Self>], + alpha: Scalar, + ) -> Option<(KzgCommitment, KzgProverData)> { + if !self.stream_quotient { + return None; + } + #[cfg(feature = "cuda")] + { + use rayon::prelude::*; + let mut parts: Vec<_> = inputs + .par_iter() + .map(|input| { + cuda::quotient::coefficients(input, alpha).map(|columns| { + self.pcs.commit_quotient_coefficients( + input.trace_domain.log_size, + columns, + ) + }) + }) + .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)); + } + } + Some(KzgProverData::concatenate(parts.into_iter().map(Option::unwrap))) + } + #[cfg(not(feature = "cuda"))] + { + Some(KzgProverData::concatenate( + inputs + .iter() + .enumerate() + .map(|(i, input)| self.host_quotient_part(i, input, alpha)), + )) + } + } + + 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 = KzgTranscript; + + fn pcs(&self) -> &KzgPcs { + &self.pcs + } + + fn initialise_challenger(&self) -> KzgTranscript { + let mut challenger = KzgTranscript::new(self.transcript_kind); + 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::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. + 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 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; + 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()); + } +} diff --git a/crates/proof-compression/src/kzg/cuda.rs b/crates/proof-compression/src/kzg/cuda.rs new file mode 100644 index 000000000..2dc8e12e2 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda.rs @@ -0,0 +1,1208 @@ +//! 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 +//! disk-backed columns use the host implementation. Memory admission is +//! checked before each operation and a rejected admission is an error, as is +//! any failure after a native operation has started. Every native call holds +//! a device lease; leases are per thread and refuse reentrant acquisition so +//! Rayon work stealing cannot deadlock a worker on its own device. + +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::*; +use std::cell::Cell; +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; +pub(super) mod srs_residency; +#[cfg(test)] +mod tests; + +// Conservative bound for point storage, upload staging, scalars, signed +// 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. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PassMode { + Off, + /// Only when the dense working set does not fit the device. + Fallback, + Force, +} + +/// Runtime selection for the CUDA backend. Install it once with +/// [`configure`] before the first proving operation; afterwards the defaults +/// apply. Environment parsing belongs to the command line, not here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Settings { + /// CUDA ordinals to use, in preference order; `None` uses every supported + /// device. + pub devices: Option>, + /// Points per MSM chunk, in `1..=2^28`. + pub msm_chunk_points: usize, + /// Device bytes retained for resident coefficients per device; `None` + /// uses half of each device's memory. The default keeps 8 GiB so that the + /// lookup and quotient working sets of large partitions are always + /// admitted beside the retained columns on one device. + pub resident_limit_bytes: Option, + /// Keep uploaded SRS ranges resident across operations. + pub srs_cache: bool, + pub passes: PassMode, +} + +impl Default for Settings { + fn default() -> Self { + Self { + devices: None, + msm_chunk_points: 1 << 24, + resident_limit_bytes: Some(8 << 30), + srs_cache: false, + passes: PassMode::Fallback, + } + } +} + +static SETTINGS: OnceLock = OnceLock::new(); + +/// Install the backend settings. Returns `false` if settings were already +/// installed or implicitly fixed by an earlier operation. +pub fn configure(settings: Settings) -> bool { + assert!( + (1..=1 << 28).contains(&settings.msm_chunk_points), + "MSM chunk size must be in 1..=2^28" + ); + SETTINGS.set(settings).is_ok() +} + +pub fn settings() -> &'static Settings { + SETTINGS.get_or_init(Settings::default) +} + +#[repr(C)] +struct FftJob { + input: *const [u64; 4], + resident_input: *const c_void, + output: *mut [u64; 4], + input_count: usize, + resident_output: *mut *mut c_void, +} + +unsafe extern "C" { + fn ix_kzg_range_push(name: *const std::ffi::c_char); + fn ix_kzg_range_pop(); + fn ix_kzg_fft_batch( + device: i32, + jobs: *const FftJob, + job_count: usize, + lg: u32, + inverse: bool, + shift: *const [u64; 4], + slots: usize, + ) -> i32; + fn ix_kzg_polynomial_upload( + device: i32, + input: *const [u64; 4], + count: usize, + output: *mut *mut c_void, + ) -> i32; + fn ix_kzg_polynomial_destroy(device: i32, pointer: *mut c_void) -> i32; + + fn ix_kzg_devices(count: *mut i32) -> i32; + fn ix_kzg_device_ordinal(index: i32, ordinal: *mut i32) -> i32; + fn ix_kzg_memory(device: i32, free: *mut usize, total: *mut usize) -> i32; + fn ix_kzg_msm_create( + device: i32, + out: *mut *mut c_void, + points: *const c_void, + count: usize, + ) -> i32; + fn ix_kzg_msm_invoke( + device: i32, + context: *mut c_void, + out: *mut ProjectiveLimbs, + scalars: *const [u64; 4], + count: usize, + normalization: *const [u64; 4], + ) -> i32; + fn ix_kzg_msm_invoke_resident( + device: i32, + context: *mut c_void, + out: *mut ProjectiveLimbs, + polynomial: *const c_void, + offset: usize, + count: usize, + normalization: *const [u64; 4], + ) -> i32; + fn ix_kzg_msm_destroy(device: i32, context: *mut c_void) -> i32; + #[cfg(test)] + fn ix_kzg_fft( + device: i32, + values: *mut [u64; 4], + lg: u32, + inverse: bool, + shift: *const [u64; 4], + ) -> i32; + #[cfg(test)] + fn ix_kzg_fft_from( + device: i32, + input: *const [u64; 4], + input_count: usize, + output: *mut [u64; 4], + lg: u32, + inverse: bool, + shift: *const [u64; 4], + ) -> i32; + fn ix_kzg_evaluate_resident( + device: i32, + coefficients: *const [u64; 4], + resident: *const c_void, + count: usize, + points: *const [u64; 4], + point_count: usize, + results: *mut [u64; 4], + ) -> i32; + fn ix_kzg_divide( + device: i32, + input: *const [u64; 4], + output: *mut [u64; 4], + count: usize, + z: *const [u64; 4], + ) -> i32; + fn ix_kzg_divide_resident( + device: i32, + input: *const [u64; 4], + count: usize, + z: *const [u64; 4], + output: *mut *mut c_void, + ) -> i32; + fn ix_kzg_polynomial_sample( + device: i32, + polynomial: *const c_void, + offset: usize, + count: usize, + samples: *mut [u64; 4], + ) -> i32; +} + +/// An NVTX range on the calling host thread, closed when dropped. +pub(super) struct ProfileRange(std::marker::PhantomData>); + +impl ProfileRange { + pub(super) fn new(name: &std::ffi::CStr) -> Self { + unsafe { ix_kzg_range_push(name.as_ptr()) }; + Self(std::marker::PhantomData) + } +} + +impl Drop for ProfileRange { + fn drop(&mut self) { + unsafe { ix_kzg_range_pop() }; + } +} + +fn check(code: i32, operation: &str) { + assert_eq!(code, 0, "KZG CUDA {operation} failed (CUDA status {code})"); +} + +struct Devices { + ids: Vec, + busy: Mutex>, + available: Condvar, + chunk_points: usize, + resident_bytes: Mutex>, + resident_limits: Vec, +} + +static DEVICES: OnceLock = OnceLock::new(); + +thread_local! { + static DEVICE_LEASES: Cell = const { Cell::new(0) }; +} + +impl Devices { + fn potential_budget(&self, index: usize) -> usize { + let (mut free, mut total) = (0, 0); + check( + unsafe { ix_kzg_memory(self.ids[index], &mut free, &mut total) }, + "memory query", + ); + free + .saturating_add(srs_residency::reclaimable(index)) + .saturating_sub(total / 4) + } + + 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. + check(unsafe { ix_kzg_devices(&mut count) }, "device discovery"); + assert!( + count > 0, + "the KZG CUDA backend requires a visible compatible CUDA GPU (compiled SM architectures: {}); build without the cuda feature for host execution", + env!("IX_KZG_CUDA_COMPILED_ARCHS") + ); + let supported: Vec = (0..count) + .map(|index| { + let mut ordinal = -1; + // Logical sppark indices need not match CUDA ordinals on mixed hosts. + check( + unsafe { ix_kzg_device_ordinal(index, &mut ordinal) }, + "device ordinal", + ); + ordinal + }) + .collect(); + let ids: Vec = + settings.devices.clone().unwrap_or_else(|| supported.clone()); + assert!(!ids.is_empty(), "at least one KZG CUDA device is required"); + for (i, &id) in ids.iter().enumerate() { + assert!(supported.contains(&id), "unsupported KZG CUDA ordinal {id}; compiled SM architectures: {}", env!("IX_KZG_CUDA_COMPILED_ARCHS")); + assert!(!ids[..i].contains(&id), "duplicate KZG CUDA ordinal {id}"); + } + let chunk_points = settings.msm_chunk_points; + let resident_limits: Vec<_> = ids + .iter() + .map(|&id| { + let (mut free, mut total) = (0, 0); + check(unsafe { ix_kzg_memory(id, &mut free, &mut total) }, "memory query"); + settings + .resident_limit_bytes + .unwrap_or(total / 2) + .min(total / 2) + .min(free.saturating_sub(total / 4)) + }) + .collect(); + 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()]), + resident_limits, + ids, + available: Condvar::new(), + chunk_points, + } + }) + } + + fn acquire(&self) -> Device<'_> { + Device::assert_unleased(); + let mut busy = self.busy.lock().unwrap(); + loop { + if let Some(index) = busy.iter().position(|&v| !v) { + busy[index] = true; + return Device::new(self, index); + } + busy = self.available.wait(busy).unwrap(); + } + } + + fn acquire_at(&self, index: usize) -> Device<'_> { + Device::assert_unleased(); + let mut busy = self.busy.lock().unwrap(); + while busy[index] { + busy = self.available.wait(busy).unwrap(); + } + busy[index] = true; + Device::new(self, index) + } + + fn acquire_admitted( + &self, + preferred: &[usize], + mut admit: impl FnMut(&Device<'_>) -> bool, + ) -> Option> { + Device::assert_unleased(); + let mut pending = preferred.to_vec(); + while !pending.is_empty() { + let device = { + let mut busy = self.busy.lock().unwrap(); + loop { + if let Some(position) = pending.iter().position(|&i| !busy[i]) { + let index = pending.remove(position); + busy[index] = true; + break Device::new(self, index); + } + busy = self.available.wait(busy).unwrap(); + } + }; + // A busy operation's scratch is temporary. Only its released device + // can report the capacity available for the next operation. + if admit(&device) { + return Some(device); + } + } + None + } + + fn reserve(&self, bytes: usize) -> Option { + let mut retained = self.resident_bytes.lock().unwrap(); + let index = (0..self.ids.len()) + .filter(|&i| bytes <= self.resident_limits[i].saturating_sub(retained[i])) + .min_by_key(|&i| retained[i])?; + retained[index] += bytes; + Some(Reservation { index, bytes }) + } +} + +struct Device<'a> { + devices: &'a Devices, + index: usize, + // Thread-local accounting requires release on the acquiring thread. + _thread: std::marker::PhantomData>, +} + +impl<'a> Device<'a> { + fn new(devices: &'a Devices, index: usize) -> Self { + DEVICE_LEASES.set(DEVICE_LEASES.get() + 1); + Self { devices, index, _thread: std::marker::PhantomData } + } + + fn assert_unleased() { + // Nested Rayon work can steal a job that waits for its own thread's lease. + assert_eq!(DEVICE_LEASES.get(), 0, "reentrant KZG device lease"); + } + + fn prepare_budget(&self, required: usize) -> bool { + srs_residency::prepare_budget(self, required) + } + + fn id(&self) -> i32 { + self.devices.ids[self.index] + } + + fn budget(&self) -> usize { + let (mut free, mut total) = (0, 0); + // The lease excludes other KZG operations on these sppark streams. + check( + unsafe { ix_kzg_memory(self.id(), &mut free, &mut total) }, + "memory query", + ); + free.saturating_sub(total / 4) + } +} + +impl Drop for Device<'_> { + fn drop(&mut self) { + self.devices.busy.lock().unwrap()[self.index] = false; + DEVICE_LEASES.set(DEVICE_LEASES.get() - 1); + self.devices.available.notify_all(); + } +} + +struct Reservation { + index: usize, + bytes: usize, +} + +impl Drop for Reservation { + fn drop(&mut self) { + Devices::get().resident_bytes.lock().unwrap()[self.index] -= self.bytes; + } +} + +/// Immutable device coefficients; every native use holds this device's lease. +/// Host coefficients remain the recovery source when the residency budget is +/// full. +pub(super) struct ResidentPolynomial { + reservation: Reservation, + context: usize, +} + +impl Drop for ResidentPolynomial { + fn drop(&mut self) { + let device = Devices::get().acquire_at(self.reservation.index); + // Native destruction waits for all streams before the budget is returned. + let code = unsafe { + ix_kzg_polynomial_destroy(device.id(), self.context as *mut c_void) + }; + if code != 0 { + tracing::error!(code, "KZG resident polynomial destruction failed"); + } + } +} + +pub(super) fn retain(coefficients: &[Fr]) -> Option> { + if coefficients.len() < 1 << 16 { + return None; + } + let devices = Devices::get(); + let reservation = + devices.reserve(coefficients.len().checked_mul(32).unwrap())?; + let device = devices.acquire_at(reservation.index); + if !device.prepare_budget(reservation.bytes + (1 << 30)) { + return None; + } + let mut context = std::ptr::null_mut(); + // The native handle owns immutable storage and synchronizes its upload. + check( + unsafe { + ix_kzg_polynomial_upload( + device.id(), + coefficients.as_ptr().cast(), + coefficients.len(), + &mut context, + ) + }, + "retain polynomial", + ); + Some(Arc::new(ResidentPolynomial { reservation, context: context as usize })) +} + +/// Assign resident inputs to their owning device, balancing host inputs +/// across the remaining queues. Each queue has at most two full-size working +/// buffers. +pub(super) fn fft_columns( + inputs: &[(&[Fr], Option<&ResidentPolynomial>)], + lg: usize, + inverse: bool, + shift: Fr, +) -> Vec> { + 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() { + return vec![]; + } + let devices = Devices::get(); + let mut groups = vec![Vec::new(); devices.ids.len()]; + for (column, (_, resident)) in inputs.iter().enumerate() { + if let Some(resident) = resident { + groups[resident.reservation.index].push(column); + } + } + for (column, (_, resident)) in inputs.iter().enumerate() { + if resident.is_none() { + let index = (0..groups.len()).min_by_key(|&i| groups[i].len()).unwrap(); + groups[index].push(column); + } + } + let shift = if inverse { shift.inverse().unwrap() } else { shift }; + let groups: Vec<_> = groups + .into_par_iter() + .enumerate() + .filter(|(_, g)| !g.is_empty()) + .map(|(index, group)| { + let started = std::time::Instant::now(); + let device = devices.acquire_at(index); + let wait_seconds = started.elapsed().as_secs_f64(); + device.prepare_budget(count * 32 * group.len().min(2) + (1 << 30)); + let workspace = device.budget().saturating_sub(1 << 30); + let slots = (workspace / (count * 32)).min(2).min(group.len()); + assert!(slots > 0, "insufficient free VRAM for KZG FFT queue"); + let mut outputs: Vec<_> = + group.iter().map(|_| Vec::::with_capacity(count)).collect(); + let jobs: Vec<_> = group + .iter() + .zip(&mut outputs) + .map(|(&column, output)| { + let (input, resident) = inputs[column]; + FftJob { + input: input.as_ptr().cast(), + resident_input: resident + .map_or(std::ptr::null(), |r| r.context as *const c_void), + output: output.spare_capacity_mut().as_mut_ptr().cast(), + input_count: input.len(), + resident_output: std::ptr::null_mut(), + } + }) + .collect(); + let started = std::time::Instant::now(); + // Native queues drain both streams on success and failure. Only a + // successful call initializes every field in each output allocation. + check( + unsafe { + ix_kzg_fft_batch( + device.id(), + jobs.as_ptr(), + jobs.len(), + u32::try_from(lg).expect("domain log size"), + inverse, + &shift.0.0, + slots, + ) + }, + "FFT queue", + ); + for output in &mut outputs { + unsafe { output.set_len(count) }; + } + tracing::debug!( + device = device.id(), + columns = jobs.len(), + elements = count, + slots, + wait_seconds, + cuda_seconds = started.elapsed().as_secs_f64(), + "KZG FFT queue" + ); + group.into_iter().zip(outputs).collect::>() + }) + .collect(); + let mut outputs = vec![Vec::new(); inputs.len()]; + for (index, output) in groups.into_iter().flatten() { + outputs[index] = output; + } + outputs +} + +/// Interpolate owned columns in place on the host while retaining admitted +/// device outputs. A native queue owns unfinished outputs until it drains. +pub(super) fn interpolate_columns( + columns: Vec>, + lg: usize, + shift: Fr, +) -> (Vec>, Vec>>) { + 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> = + (0..devices.ids.len()).map(|_| Vec::new()).collect(); + let width = columns.len(); + for (column, values) in columns.into_iter().enumerate() { + assert_eq!(values.len(), count); + let reservation = + if count >= 1 << 16 { devices.reserve(count * 32) } else { None }; + let index = reservation.as_ref().map_or_else( + || (0..groups.len()).min_by_key(|&i| groups[i].len()).unwrap(), + |r| r.index, + ); + groups[index].push((column, values, reservation)); + } + let shift = shift.inverse().unwrap(); + let parts: Vec<_> = groups + .into_par_iter() + .enumerate() + .filter(|(_, group)| !group.is_empty()) + .map(|(index, mut group)| { + let device = devices.acquire_at(index); + let bytes = count * 32; + // All retained outputs survive the queue. Count them in addition to + // the two transient slots, even though their lifetimes overlap. + let retained = + group.iter().filter(|(_, _, r)| r.is_some()).count() * bytes; + device.prepare_budget(retained + bytes * group.len().min(2) + (1 << 30)); + let budget = device.budget().saturating_sub(1 << 30); + if retained + bytes > budget { + for (_, _, reservation) in &mut group { + *reservation = None; + } + } + let retained = + group.iter().filter(|(_, _, r)| r.is_some()).count() * bytes; + let slots = ((budget - retained) / bytes).min(2).min(group.len()); + assert!(slots > 0, "insufficient free VRAM for KZG interpolation queue"); + let mut handles = vec![std::ptr::null_mut(); group.len()]; + let jobs: Vec<_> = group + .iter_mut() + .zip(&mut handles) + .map(|((_, values, reservation), handle)| FftJob { + input: values.as_ptr().cast(), + resident_input: std::ptr::null(), + output: values.as_mut_ptr().cast(), + input_count: count, + resident_output: if reservation.is_some() { + handle + } else { + std::ptr::null_mut() + }, + }) + .collect(); + let started = std::time::Instant::now(); + let code = unsafe { + ix_kzg_fft_batch( + device.id(), + jobs.as_ptr(), + jobs.len(), + u32::try_from(lg).expect("domain log size"), + true, + &shift.0.0, + slots, + ) + }; + check(code, "interpolation queue"); + tracing::debug!( + device = device.id(), + columns = group.len(), + elements = count, + slots, + resident_bytes = retained, + cuda_seconds = started.elapsed().as_secs_f64(), + "KZG interpolation queue" + ); + drop(device); + group + .into_iter() + .zip(handles) + .map(|((column, values, reservation), context)| { + let resident = reservation.map(|reservation| { + assert!(!context.is_null()); + Arc::new(ResidentPolynomial { + reservation, + context: context as usize, + }) + }); + (column, values, resident) + }) + .collect::>() + }) + .collect(); + let mut outputs = vec![Vec::new(); width]; + let mut residents = vec![None; width]; + for (column, values, resident) in parts.into_iter().flatten() { + outputs[column] = values; + residents[column] = resident; + } + (outputs, residents) +} + +pub(super) fn msm(points: &[G1Affine], scalars: &[Fr]) -> G1Projective { + msm_chunked(points, scalars, Devices::get().chunk_points) +} + +fn msm_chunked( + points: &[G1Affine], + scalars: &[Fr], + chunk_limit: usize, +) -> G1Projective { + msm_columns_chunked(points, &[scalars], chunk_limit).remove(0) +} + +pub(super) fn msm_columns( + points: &[G1Affine], + columns: &[&[Fr]], +) -> Vec { + msm_columns_chunked(points, columns, Devices::get().chunk_points) +} + +pub(super) fn msm_columns_resident( + points: &[G1Affine], + columns: &[(&[Fr], Option<&ResidentPolynomial>)], +) -> Vec { + assert!(columns.iter().all(|(c, _)| c.len() == points.len())); + if points.is_empty() { + return vec![G1Projective::zero(); columns.len()]; + } + let devices = Devices::get(); + let mut groups = vec![Vec::new(); devices.ids.len()]; + let mut host = Vec::new(); + for (i, (_, resident)) in columns.iter().enumerate() { + if let Some(resident) = resident { + groups[resident.reservation.index].push(i); + } else { + host.push(i); + } + } + let parts: Vec<_> = groups + .into_par_iter() + .enumerate() + .filter(|(_, g)| !g.is_empty()) + .map(|(index, group)| { + let device = devices.acquire_at(index); + device.prepare_budget( + devices.chunk_points.min(points.len()) * MSM_BYTES_PER_POINT + + (1 << 30), + ); + 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() { + let resident_points = upload_msm_points(&device, points); + let start = part * chunk; + for (sum, &column) in sums.iter_mut().zip(&group) { + let (coefficients, resident) = columns[column]; + let input = &coefficients[start..start + points.len()]; + let normalization = msm_normalization(input); + 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( + unsafe { + ix_kzg_msm_invoke_resident( + device.id(), + resident_points.context, + &mut out, + resident.unwrap().context as *const c_void, + start, + points.len(), + normalization + .as_ref() + .map_or(std::ptr::null(), |(_, inverse)| &inverse.0.0), + ) + }, + "resident scalar MSM", + ); + let point = projective(out); + *sum += normalization.map_or(point, |(scale, _)| point * scale); + } + } + group.into_iter().zip(sums).collect::>() + }) + .collect(); + let mut sums = vec![G1Projective::zero(); columns.len()]; + for (i, point) in parts.into_iter().flatten() { + sums[i] = point; + } + if !host.is_empty() { + let inputs: Vec<_> = host.iter().map(|&i| columns[i].0).collect(); + for (i, point) in host.into_iter().zip(msm_columns(points, &inputs)) { + sums[i] = point; + } + } + sums +} + +fn projective(limbs: ProjectiveLimbs) -> G1Projective { + G1Projective::new_unchecked( + Fq::new_unchecked(BigInt(limbs[0])), + Fq::new_unchecked(BigInt(limbs[1])), + Fq::new_unchecked(BigInt(limbs[2])), + ) +} + +fn upload_msm_points<'lease, 'devices>( + device: &'lease Device<'devices>, + points: &[G1Affine], +) -> ResidentMsm<'lease, 'devices> { + let mut context = std::ptr::null_mut(); + // The layout-checked arkworks storage stays borrowed through the upload. + // Native conversion preserves infinity without a polynomial-sized host copy. + check( + unsafe { + ix_kzg_msm_create( + device.id(), + &mut context, + points.as_ptr().cast(), + points.len(), + ) + }, + "MSM point upload", + ); + assert!(!context.is_null()); + ResidentMsm { device, context } +} + +struct ResidentMsm<'lease, 'devices> { + device: &'lease Device<'devices>, + context: *mut c_void, +} + +impl Drop for ResidentMsm<'_, '_> { + fn drop(&mut self) { + // The borrowed lease outlives the native points and bucket workspace. + unsafe { ix_kzg_msm_destroy(self.device.id(), self.context) }; + } +} + +fn msm_normalization(scalars: &[Fr]) -> Option<(Fr, Fr)> { + if scalars.len() < 4096 { + return None; + } + sample_normalization( + (0usize..128) + .map(|i| scalars[i.wrapping_mul(0x9e37_79b9_7f4a_7c15) % scalars.len()]), + ) +} + +fn sample_normalization(samples: impl Iterator) -> Option<(Fr, Fr)> { + let mut counts = HashMap::new(); + for value in samples { + if !value.is_zero() { + *counts.entry(value.min(-value)).or_insert(0usize) += 1; + } + } + let (scale, count) = + counts.into_iter().max_by_key(|&(scale, count)| (count, scale))?; + if count < 16 || scale == Fr::ONE || scale == -Fr::ONE { + return None; + } + // Equal nonunit scalars overload one serial Pippenger bucket per window. + // Scaling them to +/-1 uses sppark's parallel addition path; multiplying + // the resulting point by `scale` restores the original MSM exactly. + Some((scale, scale.inverse().unwrap())) +} + +fn msm_columns_chunked( + points: &[G1Affine], + columns: &[&[Fr]], + chunk_limit: usize, +) -> Vec { + assert!(columns.iter().all(|column| column.len() == points.len())); + if points.is_empty() || columns.is_empty() { + return vec![G1Projective::zero(); columns.len()]; + } + assert!(chunk_limit > 0); + let devices = Devices::get(); + let chunk = points.len().div_ceil(devices.ids.len()).min(chunk_limit).max(1); + points + .par_chunks(chunk) + .enumerate() + .map(|(chunk_index, points)| { + let device = devices.acquire(); + 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) / 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() { + let start = chunk_index * chunk + part * admitted; + let count = points.len(); + let resident = upload_msm_points(&device, points); + for (sum, column) in sums.iter_mut().zip(columns) { + let input = &column[start..start + count]; + let normalization = msm_normalization(input); + let mut out = [[0; FQ_LIMBS]; 3]; + // The resident point chunk is immutable across all column calls. + check( + unsafe { + ix_kzg_msm_invoke( + device.id(), + resident.context, + &mut out, + input.as_ptr().cast(), + count, + normalization + .as_ref() + .map_or(std::ptr::null(), |(_, inverse)| &inverse.0.0), + ) + }, + "MSM", + ); + let point = projective(out); + *sum += match normalization { + Some((scale, _)) => point * scale, + None => point, + }; + } + } + sums + }) + .reduce( + || vec![G1Projective::zero(); columns.len()], + |mut sums, parts| { + for (sum, part) in sums.iter_mut().zip(parts) { + *sum += part; + } + sums + }, + ) +} + +#[cfg(test)] +pub(super) fn fft(values: &mut [Fr], inverse: bool, shift: Fr) { + 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; + } + let started = std::time::Instant::now(); + let device = Devices::get().acquire(); + let wait_seconds = started.elapsed().as_secs_f64(); + assert!( + device.prepare_budget(values.len().checked_mul(32).unwrap() + (1 << 30)), + "insufficient free VRAM for KZG FFT" + ); + let shift = if inverse { shift.inverse().unwrap() } else { shift }; + let started = std::time::Instant::now(); + // sppark normalizes inverse NTTs. Coset powers act on coefficients, before + // the forward transform or after the inverse transform in natural order. + check( + // The asserted layout covers every byte of each field. The exclusive + // slice remains borrowed until all native transfers have completed. + unsafe { + ix_kzg_fft( + device.id(), + values.as_mut_ptr().cast(), + values.len().ilog2(), + inverse, + &shift.0.0, + ) + }, + "FFT", + ); + let cuda_seconds = started.elapsed().as_secs_f64(); + tracing::debug!( + device = device.id(), + elements = values.len(), + wait_seconds, + cuda_seconds, + "KZG FFT transfer profile" + ); +} + +#[cfg(test)] +pub(super) fn fft_from(coefficients: &[Fr], lg: usize, shift: Fr) -> Vec { + 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(); + assert!( + device.prepare_budget(count * 32 + (1 << 30)), + "insufficient free VRAM for KZG FFT" + ); + let mut values = Vec::::with_capacity(count); + // Input limbs have the checked layout above. CUDA initializes the entire + // output before its length is set; failure leaves an empty, droppable Vec. + check( + unsafe { + ix_kzg_fft_from( + device.id(), + coefficients.as_ptr().cast(), + coefficients.len(), + values.spare_capacity_mut().as_mut_ptr().cast(), + u32::try_from(lg).expect("domain log size"), + false, + &shift.0.0, + ) + }, + "out-of-place FFT", + ); + unsafe { values.set_len(count) }; + values +} + +#[cfg(test)] +pub(super) fn evaluate_many(coefficients: &[Fr], points: &[Fr]) -> Vec { + evaluate_resident(coefficients, None, points) +} + +pub(super) fn evaluate_resident( + coefficients: &[Fr], + resident: Option<&ResidentPolynomial>, + points: &[Fr], +) -> Vec { + if points.is_empty() { + return vec![]; + } + if coefficients.len() < 2 { + return vec![ + coefficients.first().copied().unwrap_or(Fr::ZERO); + points.len() + ]; + } + let devices = Devices::get(); + let device = resident.map_or_else( + || devices.acquire(), + |r| devices.acquire_at(r.reservation.index), + ); + let bytes = points + .len() + .checked_mul(64) + .and_then(|bytes| { + bytes.checked_add(if resident.is_some() { + 0 + } else { + coefficients.len().checked_mul(32)? + }) + }) + .and_then(|bytes| bytes.checked_add(1 << 30)) + .expect("KZG evaluation size overflow"); + assert!( + device.prepare_budget(bytes), + "insufficient free VRAM for KZG evaluation" + ); + let mut results = Vec::::with_capacity(points.len()); + // Coefficients are immutable for the entire call. The resident handle is + // tied to its owning device and results keep point order, with repeats. + check( + unsafe { + ix_kzg_evaluate_resident( + device.id(), + coefficients.as_ptr().cast(), + resident.map_or(std::ptr::null(), |r| r.context as *const c_void), + coefficients.len(), + points.as_ptr().cast(), + points.len(), + results.spare_capacity_mut().as_mut_ptr().cast(), + ) + }, + "polynomial evaluation", + ); + unsafe { results.set_len(points.len()) }; + results +} + +pub(super) fn divide(coefficients: &[Fr], z: Fr) -> Vec { + if coefficients.len() < 2 { + return vec![]; + } + // Parallel first-touch keeps page faults out of the pinned-ring download. + let mut values = + super::pcs::generate_buffer(coefficients.len() - 1, |_| Fr::ZERO); + let device = Devices::get().acquire(); + assert!( + device + .prepare_budget(coefficients.len().checked_mul(32).unwrap() + (1 << 30)), + "insufficient free VRAM for KZG division" + ); + // The call synchronizes its copies and kernels before releasing buffers. + check( + unsafe { + ix_kzg_divide( + device.id(), + coefficients.as_ptr().cast(), + values.as_mut_ptr().cast(), + coefficients.len(), + &z.0.0, + ) + }, + "polynomial division", + ); + values +} + +pub(super) fn single_device_opening( + points: &[G1Affine], + coefficients: &[Fr], + z: Fr, +) -> Option { + // A quotient on one card loses the multi-device MSM partitioning benefit. + if Devices::get().ids.len() != 1 { + return None; + } + divide_and_msm(points, coefficients, z) +} + +/// Divide and commit without a polynomial-sized download or scalar re-upload. +/// The quotient and its MSM scratch must fit together under one device lease. +fn divide_and_msm( + points: &[G1Affine], + coefficients: &[Fr], + z: Fr, +) -> Option { + divide_and_msm_chunked(points, coefficients, z, Devices::get().chunk_points) +} + +fn divide_and_msm_chunked( + points: &[G1Affine], + coefficients: &[Fr], + z: Fr, + chunk_limit: usize, +) -> Option { + assert_eq!(points.len(), coefficients.len().saturating_sub(1)); + if points.is_empty() { + return Some(G1Projective::zero()); + } + 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(MSM_BYTES_PER_POINT)?, + )?, + ); + let chunk = opening_chunk(coefficients.len(), chunk_limit, device.budget())?; + let mut context = std::ptr::null_mut(); + check( + unsafe { + ix_kzg_divide_resident( + device.id(), + coefficients.as_ptr().cast(), + coefficients.len(), + &z.0.0, + &mut context, + ) + }, + "resident polynomial division", + ); + let quotient = LeasedPolynomial { device: &device, context }; + Some(commit_leased_quotient("ient, points, chunk)) +} + +fn opening_chunk( + count: usize, + chunk_limit: usize, + budget: usize, +) -> Option { + let fixed = count.checked_mul(32)?.checked_add(1 << 30)?; + let chunk = chunk_limit.min(budget.checked_sub(fixed)? / MSM_BYTES_PER_POINT); + (chunk > 0).then_some(chunk) +} + +fn commit_leased_quotient( + quotient: &LeasedPolynomial<'_, '_>, + points: &[G1Affine], + chunk: usize, +) -> G1Projective { + let device = quotient.device; + let mut result = G1Projective::zero(); + for (part, points) in points.chunks(chunk).enumerate() { + let offset = part * chunk; + let mut samples = [Fr::ZERO; 128]; + let normalization = if points.len() >= 4096 { + check( + unsafe { + ix_kzg_polynomial_sample( + device.id(), + quotient.context, + offset, + points.len(), + samples.as_mut_ptr().cast(), + ) + }, + "resident scalar sample", + ); + sample_normalization(samples.into_iter()) + } else { + None + }; + let resident_points = upload_msm_points(device, points); + let mut out = [[0; FQ_LIMBS]; 3]; + check( + unsafe { + ix_kzg_msm_invoke_resident( + device.id(), + resident_points.context, + &mut out, + quotient.context, + offset, + points.len(), + normalization + .as_ref() + .map_or(std::ptr::null(), |(_, inverse)| &inverse.0.0), + ) + }, + "opening witness MSM", + ); + let point = projective(out); + result += normalization.map_or(point, |(scale, _)| point * scale); + } + result +} + +struct LeasedPolynomial<'lease, 'devices> { + device: &'lease Device<'devices>, + context: *mut c_void, +} + +impl Drop for LeasedPolynomial<'_, '_> { + fn drop(&mut self) { + // The existing lease covers destruction; reacquiring it would deadlock. + let code = + unsafe { ix_kzg_polynomial_destroy(self.device.id(), self.context) }; + if code != 0 { + tracing::error!(code, "KZG temporary polynomial destruction failed"); + } + } +} 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..6b703ca06 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/abi.rs @@ -0,0 +1,88 @@ +//! 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}; + +pub(super) const FQ_LIMBS: usize = 4; + +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 new file mode 100644 index 000000000..d23957374 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/lookup.rs @@ -0,0 +1,187 @@ +//! Device lookup construction with grouped rational sums and stock prefix +//! scans. + +use super::quotient::{self, Column, Instruction, Lookup, Program, allocation}; +use super::*; +use crate::kzg::{config::KzgConfig, field::Scalar, pcs::CommittedMatrix}; +use multi_stark::config::LookupCommitInput; +use multi_stark::traits::Algebra; + +#[repr(C, align(32))] +pub(super) struct Parameters { + pub(super) beta: [u64; 4], + pub(super) gamma: [u64; 4], + pub(super) trace_log: u32, + pub(super) slots: u32, + pub(super) group_size: u32, + 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, + parameters: *const Parameters, + columns: *const Column, + column_count: usize, + evaluation_columns: usize, + nodes: *const Instruction, + node_count: usize, + lookups: *const Lookup, + lookup_count: usize, + args: *const u32, + arg_count: usize, + outputs: *const *mut [u64; 4], + total: *mut [u64; 4], + ) -> i32; +} + +fn memory_bound( + program: &Program, + n: usize, + groups: usize, + columns: usize, + evaluated: usize, +) -> Option { + let blocks = n.div_ceil(quotient::TILE).min(quotient::MAX_BLOCKS); + let elements = n + .checked_mul(evaluated)? + .checked_add(n.checked_mul(groups)?.checked_mul(4)?)? + .checked_add( + blocks.checked_mul(quotient::TILE)?.checked_mul(program.slots.max(1))?, + )?; + [ + allocation::(elements)?, + allocation::(program.nodes.len())?, + allocation::(program.lookups.len())?, + allocation::(program.args.len())?, + allocation::(columns)?, + allocation::(1)?, + quotient::HEADROOM, + ] + .into_iter() + .try_fold(0usize, usize::checked_add) +} + +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 { + Some(data) => Some(quotient::matrix(data)?), + None => None, + }; + let trace = main.domain(); + if trace.shift != Scalar::ONE || !(10..=27).contains(&trace.log_size) { + return None; + } + let n = 1usize.checked_shl(u32::try_from(trace.log_size).ok()?)?; + let circuit = input.circuit; + let group_size = circuit.lookup_group_size.max(1); + let groups = circuit.graph.lookups.len().div_ceil(group_size).max(1); + let widths = [fixed.map_or(0, CommittedMatrix::width), main.width(), 0]; + if group_size > multi_stark::lookup::MAX_LOOKUP_GROUP + || groups != circuit.stage_2_width + || widths[..2] != [circuit.preprocessed_width, circuit.main_width] + || fixed.is_some_and(|matrix| matrix.domain() != trace) + { + return None; + } + let program = quotient::encode_lookup(&circuit.graph, widths)?; + if program.lookups.is_empty() { + return Some((vec![vec![Fr::ZERO; n]], Scalar::ZERO)); + } + let all_columns = quotient::views([fixed, Some(main)].into_iter().flatten())?; + if all_columns + .iter() + .any(|(column, _, _)| column.is_empty() || column.len() > n) + { + return None; + } + let evaluated = + all_columns.iter().filter(|(_, _, constant)| !constant).count(); + let bytes = memory_bound(&program, n, groups, all_columns.len(), evaluated)?; + let parameters = Parameters { + beta: beta.0.0.0, + gamma: gamma.0.0.0, + trace_log: u32::try_from(trace.log_size).ok()?, + slots: u32::try_from(program.slots.max(1)).ok()?, + group_size: u32::try_from(group_size).ok()?, + blocks: u32::try_from(n.div_ceil(quotient::TILE).min(quotient::MAX_BLOCKS)) + .ok()?, + }; + let run_passes = + || passes::lookup_coefficients(input, ¶meters, &all_columns, widths); + 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() + && mode == PassMode::Fallback + && let Some(output) = run_passes() + { + return Some(output); + } + let device = device.unwrap_or_else(|| { + panic!( + "KZG lookup of {n} rows needs {bytes} bytes and no selected device admits it (budgets {:?}); lower IX_KZG_CUDA_RESIDENT_GIB or the chunk size", + quotient::device_budgets() + ) + }); + let mut outputs: Vec<_> = (0..groups).map(|_| vec![Fr::ZERO; n]).collect(); + let output_pointers: Vec<*mut [u64; 4]> = + outputs.iter_mut().map(|values| values.as_mut_ptr().cast()).collect(); + let mut total = [0; 4]; + let started = std::time::Instant::now(); + // The native job synchronizes every transfer before borrowed buffers expire. + check( + unsafe { + ix_kzg_lookup( + device.id(), + ¶meters, + columns.as_ptr(), + columns.len(), + evaluated, + program.nodes.as_ptr(), + program.nodes.len(), + program.lookups.as_ptr(), + program.lookups.len(), + program.args.as_ptr(), + program.args.len(), + output_pointers.as_ptr(), + &mut total, + ) + }, + "resident lookup", + ); + tracing::info!( + device = device.id(), + rows = n, + groups, + evaluated, + bytes, + seconds = started.elapsed().as_secs_f64(), + "KZG lookup constructed on device" + ); + Some((outputs, Scalar(Fr::new_unchecked(BigInt(total))))) +} diff --git a/crates/proof-compression/src/kzg/cuda/passes.rs b/crates/proof-compression/src/kzg/cuda/passes.rs new file mode 100644 index 000000000..5748258c0 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/passes.rs @@ -0,0 +1,386 @@ +//! Constraint-specific device working sets for traces that exceed dense +//! admission. + +use super::quotient::{ + Column, Instruction, Lookup, PolynomialView, Program, allocation, +}; +use super::*; +use crate::kzg::{Scalar, config::KzgConfig}; +use multi_stark::config::{LookupCommitInput, QuotientCommitInput}; +use multi_stark::traits::EvaluationDomain; + +#[repr(C, align(32))] +struct NativePass { + weight: [u64; 4], + columns: *const Column, + column_count: usize, + evaluated: usize, + nodes: *const Instruction, + node_count: usize, + roots: *const u32, + root_count: usize, + lookups: *const Lookup, + lookup_count: usize, + args: *const u32, + arg_count: usize, + slots: usize, + 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, + evaluated: usize, + group: usize, + weight: Fr, +} + +impl Pass { + pub(super) fn new( + program: Program, + all: &[PolynomialView<'_>], + extra: &[usize], + group: usize, + weight: Fr, + ) -> Option { + let (columns, evaluated) = quotient::pass_columns(all, &program, extra)?; + Some(Self { program, columns, evaluated, group, weight }) + } + + fn native(&self) -> NativePass { + NativePass { + weight: self.weight.0.0, + columns: self.columns.as_ptr(), + column_count: self.columns.len(), + evaluated: self.evaluated, + nodes: self.program.nodes.as_ptr(), + node_count: self.program.nodes.len(), + roots: self.program.roots.as_ptr(), + root_count: self.program.roots.len(), + lookups: self.program.lookups.as_ptr(), + lookup_count: self.program.lookups.len(), + args: self.program.args.as_ptr(), + arg_count: self.program.args.len(), + slots: self.program.slots.max(1), + group: self.group, + } + } +} + +unsafe extern "C" { + fn ix_kzg_lookup_passes( + device: i32, + parameters: *const lookup::Parameters, + passes: *const NativePass, + pass_count: usize, + outputs: *const *mut [u64; 4], + total: *mut [u64; 4], + ) -> i32; + fn ix_kzg_quotient_passes( + device: i32, + parameters: *const quotient::Parameters, + passes: *const NativePass, + pass_count: usize, + publics: *const [u64; 4], + public_count: usize, + shifts: *const [u64; 4], + inverse_vanishing: *const [u64; 4], + outputs: *const *mut [u64; 4], + ) -> i32; +} + +fn memory_bound( + passes: &[Pass], + rows: usize, + blocks: usize, + extra_columns: usize, + publics: usize, +) -> Option { + let max = |f: fn(&Pass) -> usize| passes.iter().map(f).max().unwrap_or(0); + let evaluated = max(|p| p.evaluated); + let slots = max(|p| p.program.slots.max(1)); + let elements = rows + .checked_mul(evaluated.checked_add(extra_columns)?)? + .checked_add(blocks.checked_mul(quotient::TILE)?.checked_mul(slots)?)?; + [ + allocation::(elements)?, + allocation::(max(|p| p.columns.len()))?, + allocation::(max(|p| p.program.nodes.len()))?, + allocation::(max(|p| p.program.roots.len()))?, + allocation::(max(|p| p.program.lookups.len()))?, + allocation::(max(|p| p.program.args.len()))?, + allocation::(publics.checked_add(1)?)?, + quotient::HEADROOM, + ] + .into_iter() + .try_fold(0usize, usize::checked_add) +} + +/// Lease an admitted device for `passes`, preferring the one holding most of +/// their resident columns, and bind that device's residents into every +/// pass. A pass reads the same columns as the dense working set, so the +/// device choice follows the same admission rule. +pub(super) fn admit( + passes: &mut [Pass], + all: &[PolynomialView<'_>], + bytes: usize, +) -> Option> { + let Some(device) = quotient::acquire_device(all, bytes) else { + tracing::info!( + bytes, + budgets = ?quotient::device_budgets(), + "KZG constraint passes not admitted" + ); + return None; + }; + for pass in passes { + quotient::bind_resident(&mut pass.columns, all, &device); + } + Some(device) +} + +pub(super) fn lookup_coefficients( + input: &LookupCommitInput<'_, KzgConfig>, + parameters: &lookup::Parameters, + all: &[PolynomialView<'_>], + widths: [usize; 3], +) -> Option<(Vec>, Scalar)> { + let rows = 1usize.checked_shl(parameters.trace_log)?; + let group_size = parameters.group_size as usize; + let graph = &input.circuit.graph; + let groups = graph.lookups.len().div_ceil(group_size); + if groups == 0 { + return None; + } + let mut passes: Vec<_> = (0..groups) + .map(|group| { + let start = group * group_size; + let program = quotient::encode_pass( + graph, + widths, + 0, + &[], + start..(start + group_size).min(graph.lookups.len()), + )?; + Pass::new(program, all, &[], group, Fr::ONE) + }) + .collect::>()?; + let bytes = memory_bound( + &passes, + rows, + parameters.blocks as usize, + groups.checked_add(3)?, + 0, + )?; + let device = admit(&mut passes, all, bytes)?; + let native: Vec<_> = passes.iter().map(Pass::native).collect(); + let mut outputs = vec![vec![Fr::ZERO; rows]; groups]; + let pointers: Vec<*mut [u64; 4]> = + outputs.iter_mut().map(|v| v.as_mut_ptr().cast()).collect(); + let mut total = [0; 4]; + let started = std::time::Instant::now(); + // Every descriptor and output remains borrowed until the native stream + // drains. + check( + unsafe { + ix_kzg_lookup_passes( + device.id(), + parameters, + native.as_ptr(), + native.len(), + pointers.as_ptr(), + &mut total, + ) + }, + "lookup constraint passes", + ); + tracing::info!( + device = device.id(), + rows, + groups, + passes = passes.len(), + evaluated = passes.iter().map(|p| p.evaluated).max().unwrap_or(0), + bytes, + seconds = started.elapsed().as_secs_f64(), + "KZG lookup constructed in GPU passes" + ); + Some((outputs, Scalar(Fr::new_unchecked(BigInt(total))))) +} + +pub(super) fn quotient_coefficients( + input: &QuotientCommitInput<'_, KzgConfig>, + parameters: "ient::Parameters, + all: &[PolynomialView<'_>], + widths: [usize; 3], + shifts: &[Fr], + inverse_vanishing: &[Fr], +) -> Option>> { + if !(10..=27).contains(&input.trace_domain.log_size) { + return None; + } + let rows = input.trace_domain.size(); + let ratio = input.quotient_domain.size() / rows; + let graph = &input.circuit.graph; + let groups = widths[2]; + let stage2 = widths[0].checked_add(widths[1])?; + let count = graph.zeros.len().checked_add(groups)?; + let mut weight = Fr::ONE; + let mut weights: Vec<_> = (0..count) + .map(|_| { + let value = weight; + weight *= Fr::new_unchecked(BigInt(parameters.alpha)); + value + }) + .collect(); + weights.reverse(); + let mut passes = Vec::with_capacity(count); + for (i, root) in graph.zeros.iter().enumerate() { + let program = quotient::encode_pass( + graph, + widths, + input.lookup_publics.len(), + core::slice::from_ref(root), + 0..0, + )?; + passes.push(Pass::new(program, all, &[], usize::MAX, weights[i])?); + } + for group in 0..groups { + let start = group * parameters.group_size as usize; + let end = (start + parameters.group_size as usize).min(graph.lookups.len()); + let program = quotient::encode_pass( + graph, + widths, + input.lookup_publics.len(), + &[], + start..end, + )?; + let next = if group + 1 == groups { 0 } else { group + 1 }; + passes.push(Pass::new( + program, + all, + &[stage2 + group, stage2 + next], + group, + weights[graph.zeros.len() + group], + )?); + } + let bytes = memory_bound( + &passes, + rows, + parameters.blocks as usize, + ratio.checked_add(1)?, + input.lookup_publics.len(), + )?; + let device = admit(&mut passes, all, bytes)?; + let native: Vec<_> = passes.iter().map(Pass::native).collect(); + let mut outputs = vec![vec![Fr::ZERO; rows]; ratio]; + let pointers: Vec<*mut [u64; 4]> = + outputs.iter_mut().map(|v| v.as_mut_ptr().cast()).collect(); + let started = std::time::Instant::now(); + // Native failures are errors; only preflight admission can select the host + // fallback. + check( + unsafe { + ix_kzg_quotient_passes( + device.id(), + parameters, + native.as_ptr(), + native.len(), + input.lookup_publics.as_ptr().cast(), + input.lookup_publics.len(), + shifts.as_ptr().cast(), + inverse_vanishing.as_ptr().cast(), + pointers.as_ptr(), + ) + }, + "quotient constraint passes", + ); + tracing::info!( + device = device.id(), + rows, + ratio, + passes = passes.len(), + evaluated = passes.iter().map(|p| p.evaluated).max().unwrap_or(0), + bytes, + seconds = started.elapsed().as_secs_f64(), + "KZG quotient evaluated in GPU passes" + ); + Some(outputs) +} + +#[cfg(test)] +mod tests { + use super::*; + use multi_stark::traits::Algebra; + + #[test] + fn pass_abi_and_memory_arithmetic() { + assert_eq!(size_of::(), 160); + assert_eq!(std::mem::offset_of!(NativePass, group), 128); + assert!(memory_bound(&[], usize::MAX, 256, 4, 0).is_none()); + } + + #[test] + fn pruned_pass_contains_only_transitive_dependencies() { + use multi_stark::expr::{ColRef, RowOffset, Source}; + use multi_stark::graph::{ConstraintGraph, Node, NodeId}; + let graph = ConstraintGraph { + nodes: vec![ + Node::Var(ColRef { + source: Source::Preprocessed, + offset: RowOffset::Current, + index: 0, + }), + Node::Var(ColRef { + source: Source::Preprocessed, + offset: RowOffset::Next, + index: 1, + }), + Node::Var(ColRef { + source: Source::Main, + offset: RowOffset::Current, + index: 0, + }), + Node::Mul(NodeId(0), NodeId(2)), + Node::Add(NodeId(3), NodeId(1)), + Node::Const(Scalar::ONE), + ], + degrees: vec![1; 6], + zeros: vec![NodeId(3), NodeId(4)], + lookups: vec![multi_stark::lookup::Lookup { + multiplicity: NodeId(5), + args: vec![NodeId(1)], + }], + lookup_prefix_len: 6, + max_constraint_degree: 2, + }; + let values = [Fr::ONE]; + let all: Vec> = + (0..4).map(|_| (&values[..], None, false)).collect(); + let root = + quotient::encode_pass(&graph, [2, 1, 1], 0, &[NodeId(3)], 0..0).unwrap(); + let root = Pass::new(root, &all, &[], usize::MAX, Fr::ONE).unwrap(); + assert_eq!(root.program.nodes.len(), 3); + assert_eq!(root.evaluated, 2); + let lookup = + quotient::encode_pass(&graph, [2, 1, 1], 0, &[], 0..1).unwrap(); + let lookup = Pass::new(lookup, &all, &[3], 0, Fr::ONE).unwrap(); + assert_eq!(lookup.program.nodes.len(), 2); + assert_eq!(lookup.evaluated, 2); + assert!( + quotient::encode_pass(&graph, [2, 1, 1], 0, &[NodeId(9)], 0..0).is_none() + ); + assert!(quotient::encode_pass(&graph, [2, 1, 1], 0, &[], 0..2).is_none()); + assert!(memory_bound(&[root], 1 << 27, 256, 3, 4).unwrap() < 22usize << 30); + } +} diff --git a/crates/proof-compression/src/kzg/cuda/quotient.rs b/crates/proof-compression/src/kzg/cuda/quotient.rs new file mode 100644 index 000000000..369cd03bf --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/quotient.rs @@ -0,0 +1,625 @@ +//! Quotient construction on trace-sized device cosets, with coefficient-only +//! output. + +use super::*; +use crate::kzg::{config::KzgConfig, field::Scalar, pcs::CommittedMatrix}; +use multi_stark::config::QuotientCommitInput; +use multi_stark::expr::{RowOffset, Source}; +use multi_stark::graph::{ConstraintGraph, Node, NodeId}; +use multi_stark::traits::Algebra; + +pub(super) const TILE: usize = 128; +pub(super) const MAX_BLOCKS: usize = 256; +pub(super) const HEADROOM: usize = 1 << 30; + +#[derive(Clone, Copy, Default)] +#[repr(C, align(32))] +pub(super) struct Instruction { + value: [u64; 4], + a: u32, + b: u32, + op: u32, + out: u32, +} + +#[repr(C)] +pub(super) struct Lookup { + multiplicity: u32, + arg_start: u32, + arg_count: u32, +} + +#[repr(C, align(32))] +pub(super) struct Column { + input: *const [u64; 4], + pub(super) resident: *const c_void, + count: usize, + padding: u64, + constant: [u64; 4], + slot: u32, + reserved: u32, +} + +#[repr(C, align(32))] +pub(super) struct Parameters { + pub(super) alpha: [u64; 4], + pub(super) generator: [u64; 4], + pub(super) generator_inverse: [u64; 4], + pub(super) delta: [u64; 4], + pub(super) quotient_shift_inverse: [u64; 4], + pub(super) trace_log: u32, + pub(super) quotient_log: u32, + pub(super) slots: u32, + pub(super) stage2_start: u32, + pub(super) group_size: u32, + 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, + parameters: *const Parameters, + columns: *const Column, + column_count: usize, + evaluation_columns: usize, + nodes: *const Instruction, + node_count: usize, + roots: *const u32, + root_count: usize, + lookups: *const Lookup, + lookup_count: usize, + args: *const u32, + arg_count: usize, + publics: *const [u64; 4], + public_count: usize, + coset_shifts: *const [u64; 4], + inverse_vanishing: *const [u64; 4], + outputs: *const *mut [u64; 4], + ) -> i32; +} + +pub(super) struct Program { + pub(super) nodes: Vec, + pub(super) roots: Vec, + pub(super) lookups: Vec, + pub(super) args: Vec, + pub(super) slots: usize, +} + +fn encode( + graph: &ConstraintGraph, + widths: [usize; 3], + publics: usize, +) -> Option { + encode_range(graph, widths, publics, graph.nodes.len(), &graph.zeros, None) +} + +pub(super) fn encode_lookup( + graph: &ConstraintGraph, + widths: [usize; 3], +) -> Option { + encode_range(graph, widths, 0, graph.lookup_prefix_len, &[], None) +} + +fn encode_range( + graph: &ConstraintGraph, + widths: [usize; 3], + publics: usize, + n: usize, + roots: &[NodeId], + selection: Option<&core::ops::Range>, +) -> Option { + let selected = + graph.lookups.get(selection.cloned().unwrap_or(0..graph.lookups.len()))?; + let mut live = vec![selection.is_none(); n]; + if selection.is_some() { + let mut pending: Vec<_> = roots + .iter() + .chain( + selected + .iter() + .flat_map(|l| core::iter::once(&l.multiplicity).chain(l.args.iter())), + ) + .copied() + .collect(); + while let Some(id) = pending.pop() { + let used = live.get_mut(id.index())?; + if *used { + continue; + } + *used = true; + match *graph.nodes.get(id.index())? { + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { + if a.index() >= id.index() || b.index() >= id.index() { + return None; + } + pending.extend([a, b]); + }, + Node::Neg(a) => { + if a.index() >= id.index() { + return None; + } + pending.push(a); + }, + _ => {}, + } + } + } + let node_range = graph.nodes.get(..n)?; + let mut last: Vec<_> = (0..n).collect(); + for (i, node) in node_range.iter().enumerate() { + if !live[i] { + continue; + } + let mut consume = |id: NodeId| { + if id.index() >= i { + return None; + } + last[id.index()] = i; + Some(()) + }; + match *node { + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { + consume(a)?; + consume(b)?; + }, + Node::Neg(a) => consume(a)?, + _ => {}, + } + } + for id in roots.iter().chain(selected.iter().flat_map(|lookup| { + core::iter::once(&lookup.multiplicity).chain(lookup.args.iter()) + })) { + *last.get_mut(id.index())? = n; + } + let mut mapping = vec![0u32; n]; + let mut free = Vec::new(); + let mut retire = vec![Vec::new(); n + 1]; + let mut slots = 0u32; + let mut nodes = Vec::with_capacity(n); + for (i, node) in node_range.iter().enumerate() { + free.append(&mut retire[i]); + if !live[i] { + continue; + } + let out = free.pop().unwrap_or_else(|| { + let slot = slots; + slots += 1; + slot + }); + mapping[i] = out; + if last[i] < n { + retire[last[i] + 1].push(out); + } + let mut encoded = Instruction { out, ..Default::default() }; + match *node { + Node::Const(value) => encoded.value = value.0.0.0, + Node::Var(column) => { + let source = match column.source { + Source::Preprocessed => 0, + Source::Main => 1, + Source::Stage2 => 2, + }; + if column.index as usize >= widths[source] { + return None; + } + encoded.op = 1; + encoded.a = u32::try_from(widths[..source].iter().sum::()) + .ok()? + .checked_add(column.index)?; + encoded.b = u32::from(column.offset == RowOffset::Next); + }, + Node::Public(index) => { + if index as usize >= publics { + return None; + } + encoded.op = 2; + encoded.a = index; + }, + Node::IsFirstRow => encoded.op = 3, + Node::IsLastRow => encoded.op = 4, + Node::IsTransition => encoded.op = 5, + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) => { + encoded.op = match node { + Node::Add(..) => 6, + Node::Sub(..) => 7, + _ => 8, + }; + encoded.a = mapping[a.index()]; + encoded.b = mapping[b.index()]; + }, + Node::Neg(a) => { + encoded.op = 9; + encoded.a = mapping[a.index()]; + }, + } + nodes.push(encoded); + } + let roots = roots.iter().map(|id| mapping[id.index()]).collect(); + let mut lookups = Vec::with_capacity(selected.len()); + let mut args = Vec::new(); + for lookup in selected { + lookups.push(Lookup { + multiplicity: mapping[lookup.multiplicity.index()], + arg_start: u32::try_from(args.len()).ok()?, + arg_count: u32::try_from(lookup.args.len()).ok()?, + }); + args.extend(lookup.args.iter().map(|id| mapping[id.index()])); + } + Some(Program { nodes, roots, lookups, args, slots: slots as usize }) +} + +pub(super) fn encode_pass( + graph: &ConstraintGraph, + widths: [usize; 3], + publics: usize, + roots: &[NodeId], + lookups: core::ops::Range, +) -> Option { + encode_range(graph, widths, publics, graph.nodes.len(), roots, Some(&lookups)) +} + +pub(super) fn pass_columns( + all: &[PolynomialView<'_>], + program: &Program, + extra: &[usize], +) -> Option<(Vec, usize)> { + let mut used = vec![false; all.len()]; + for node in &program.nodes { + if node.op == 1 { + *used.get_mut(node.a as usize)? = true; + } + } + for &column in extra { + *used.get_mut(column)? = true; + } + let mut columns = column_descriptors(all); + let mut count = 0; + for ((descriptor, (_, _, constant)), needed) in + columns.iter_mut().zip(all).zip(used) + { + descriptor.slot = if needed && !constant { + let slot = u32::try_from(count).ok()?; + count += 1; + slot + } else { + u32::MAX + }; + } + Some((columns, count)) +} + +pub(super) fn matrix( + data: (&crate::kzg::pcs::KzgProverData, usize), +) -> Option<&CommittedMatrix> { + data.0.matrices.get(data.1) +} + +pub(super) fn allocation(count: usize) -> Option { + count + .checked_add(31)? + .checked_div(32)? + .checked_mul(32)? + .checked_mul(size_of::()) +} + +fn memory_bound( + program: &Program, + n: usize, + ratio: usize, + columns: usize, + evaluated: usize, + publics: usize, +) -> Option { + let blocks = n.div_ceil(TILE).min(MAX_BLOCKS); + let elements = evaluated + .checked_mul(n)? + .checked_add(n)? + .checked_add(n.checked_mul(ratio)?)? + .checked_add( + blocks.checked_mul(TILE)?.checked_mul(program.slots.max(1))?, + )?; + // Base_dev_ptr is in-place. Both static twiddle tables fit in 21 MiB; the + // remaining headroom covers allocator granularity and runtime state. The + // memory query excludes live allocations and credits reusable pool pages. + [ + allocation::(elements)?, + allocation::(program.nodes.len())?, + allocation::(program.roots.len())?, + allocation::(program.lookups.len())?, + allocation::(program.args.len())?, + allocation::(columns)?, + allocation::(publics)?, + HEADROOM, + ] + .into_iter() + .try_fold(0usize, usize::checked_add) +} + +/// Every column of the input matrices as a host slice with its device +/// residency and constant flag. Disk-backed columns are not admitted. +pub(super) fn views<'a>( + matrices: impl IntoIterator, +) -> Option>> { + let mut all = Vec::new(); + for matrix in matrices { + all.extend(matrix.cuda_columns()?); + } + Some(all) +} + +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; + if trace.log_size < 10 + || quotient.log_size > 28 + || trace.log_size > quotient.log_size + || trace.shift != Scalar::ONE + || quotient.shift == Scalar::ZERO + { + return None; + } + let n = 1usize.checked_shl(u32::try_from(trace.log_size).ok()?)?; + let ratio = 1usize + .checked_shl(u32::try_from(quotient.log_size - trace.log_size).ok()?)?; + let circuit = input.circuit; + let group_size = circuit.lookup_group_size.max(1); + let groups = circuit.graph.lookups.len().div_ceil(group_size).max(1); + if input.lookup_publics.len() < 4 + || group_size > multi_stark::lookup::MAX_LOOKUP_GROUP + || groups != circuit.stage_2_width + || input.constraint_count + != circuit.graph.zeros.len().checked_add(groups)? + { + return None; + } + let fixed = match input.preprocessed { + Some(data) => Some(matrix(data)?), + None => None, + }; + let main = matrix(input.stage_1)?; + let stage2 = matrix(input.stage_2)?; + let widths = + [fixed.map_or(0, CommittedMatrix::width), main.width(), stage2.width()]; + if widths + != [circuit.preprocessed_width, circuit.main_width, circuit.stage_2_width] + || [fixed, Some(main), Some(stage2)] + .into_iter() + .flatten() + .any(|m| m.domain() != trace) + { + return None; + } + let program = encode(&circuit.graph, widths, input.lookup_publics.len())?; + let generator = trace.generator().0; + let generator_inverse = generator.inverse()?; + let mut shift = quotient.shift.0; + let mut shifts = Vec::with_capacity(ratio); + let mut inverse_vanishing = Vec::with_capacity(ratio); + for _ in 0..ratio { + shifts.push(shift); + inverse_vanishing.push((shift.pow([n as u64]) - Fr::ONE).inverse()?); + shift *= quotient.generator().0; + } + let all_columns = + views([fixed, Some(main), Some(stage2)].into_iter().flatten())?; + if all_columns + .iter() + .any(|(column, _, _)| column.is_empty() || column.len() > n) + { + return None; + } + let evaluated = + all_columns.iter().filter(|(_, _, constant)| !constant).count(); + let bytes = memory_bound( + &program, + n, + ratio, + all_columns.len(), + evaluated, + input.lookup_publics.len(), + )?; + let parameters = Parameters { + alpha: alpha.0.0.0, + generator: generator.0.0, + generator_inverse: generator_inverse.0.0, + delta: ((input.lookup_publics[3].0 - input.lookup_publics[2].0) + * (Fr::from(n as u64) * generator).inverse()?) + .0 + .0, + quotient_shift_inverse: quotient.shift.0.inverse()?.0.0, + trace_log: u32::try_from(trace.log_size).ok()?, + quotient_log: u32::try_from(quotient.log_size).ok()?, + slots: u32::try_from(program.slots.max(1)).ok()?, + stage2_start: u32::try_from(widths[0].checked_add(widths[1])?).ok()?, + group_size: u32::try_from(group_size).ok()?, + blocks: u32::try_from(n.div_ceil(TILE).min(MAX_BLOCKS)).ok()?, + }; + let run_passes = || { + passes::quotient_coefficients( + input, + ¶meters, + &all_columns, + widths, + &shifts, + &inverse_vanishing, + ) + }; + 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() + && mode == PassMode::Fallback + && let Some(output) = run_passes() + { + return Some(output); + } + let device = device.unwrap_or_else(|| { + panic!( + "KZG quotient of {n} rows needs {bytes} bytes and no selected device admits it (budgets {:?}); lower IX_KZG_CUDA_RESIDENT_GIB or the chunk size", + device_budgets() + ) + }); + let mut outputs: Vec<_> = (0..ratio).map(|_| vec![Fr::ZERO; n]).collect(); + let output_pointers: Vec<*mut [u64; 4]> = + outputs.iter_mut().map(|values| values.as_mut_ptr().cast()).collect(); + let started = std::time::Instant::now(); + // Every borrowed coefficient and output buffer outlives the synchronous call. + check( + unsafe { + ix_kzg_quotient( + device.id(), + ¶meters, + columns.as_ptr(), + columns.len(), + evaluated, + program.nodes.as_ptr(), + program.nodes.len(), + program.roots.as_ptr(), + program.roots.len(), + program.lookups.as_ptr(), + program.lookups.len(), + program.args.as_ptr(), + program.args.len(), + input.lookup_publics.as_ptr().cast(), + input.lookup_publics.len(), + shifts.as_ptr().cast(), + inverse_vanishing.as_ptr().cast(), + output_pointers.as_ptr(), + ) + }, + "resident quotient", + ); + tracing::info!( + device = device.id(), + rows = n, + ratio, + evaluated, + bytes, + seconds = started.elapsed().as_secs_f64(), + "KZG quotient evaluated on device" + ); + Some(outputs) +} + +/// Current admission budget of every selected device, for diagnostics. +pub(super) fn device_budgets() -> Vec { + let devices = Devices::get(); + (0..devices.ids.len()).map(|index| devices.potential_budget(index)).collect() +} + +pub(super) type PolynomialView<'a> = + (&'a [Fr], Option<&'a ResidentPolynomial>, bool); + +fn preferred_devices(all_columns: &[PolynomialView<'_>]) -> Vec { + let devices = Devices::get(); + let mut preferred: Vec<_> = (0..devices.ids.len()).collect(); + preferred.sort_by_cached_key(|&index| { + std::cmp::Reverse( + all_columns + .iter() + .filter(|(_, resident, _)| { + resident.is_some_and(|r| r.reservation.index == index) + }) + .count(), + ) + }); + preferred +} + +pub(super) fn column_descriptors( + all_columns: &[PolynomialView<'_>], +) -> Vec { + let mut next_slot = 0; + all_columns + .iter() + .map(|(column, _, constant)| { + let slot = if *constant { + u32::MAX + } else { + let slot = next_slot; + next_slot += 1; + slot + }; + Column { + input: column.as_ptr().cast(), + resident: std::ptr::null(), + count: column.len(), + padding: 0, + constant: column[0].0.0, + slot, + reserved: 0, + } + }) + .collect() +} + +/// Check admission under each device's lease, preferring resident columns. +/// Busy devices remain candidates until their current operation completes. +pub(super) fn acquire_device( + all_columns: &[PolynomialView<'_>], + bytes: usize, +) -> Option> { + Device::assert_unleased(); + let devices = Devices::get(); + devices.acquire_admitted(&preferred_devices(all_columns), |device| { + bytes <= devices.potential_budget(device.index) + && device.prepare_budget(bytes) + }) +} + +/// Point descriptors at the resident copies that live on `device`. The +/// kernels reject a resident pointer from another device and upload every +/// unbound column from its host slice instead. +pub(super) fn bind_resident( + columns: &mut [Column], + all_columns: &[PolynomialView<'_>], + device: &Device<'_>, +) { + for (column, (_, resident, _)) in columns.iter_mut().zip(all_columns) { + column.resident = resident + .filter(|r| r.reservation.index == device.index) + .map_or(std::ptr::null(), |r| r.context as *const c_void); + } +} + +pub(super) fn acquire_columns( + columns: &mut [Column], + all_columns: &[PolynomialView<'_>], + bytes: usize, +) -> Option> { + let device = acquire_device(all_columns, bytes)?; + bind_resident(columns, all_columns, &device); + Some(device) +} diff --git a/crates/proof-compression/src/kzg/cuda/srs_residency.rs b/crates/proof-compression/src/kzg/cuda/srs_residency.rs new file mode 100644 index 000000000..389671de5 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/srs_residency.rs @@ -0,0 +1,532 @@ +//! Evictable immutable SRS ranges, independent of mutable sppark MSM scratch. + +use super::*; +use crate::kzg::srs::Srs; +use std::sync::{ + Weak, + atomic::{AtomicU64, Ordering}, +}; + +const LIMIT: usize = 12 << 30; +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, +} + +impl Owner { + pub(in crate::kzg) fn new(srs: Arc) -> Arc { + static NEXT: AtomicU64 = AtomicU64::new(1); + let id = NEXT + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| { + id.checked_add(1) + }) + .expect("SRS cache identity exhausted"); + Arc::new(Self { id, srs }) + } +} + +pub(in crate::kzg) fn enabled() -> bool { + settings().srs_cache +} + +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 Entry { + owner: Weak, + id: u64, + start: usize, + count: usize, + pointer: usize, + bytes: usize, + used: u64, +} + +impl Entry { + fn contains(&self, owner: &Owner, start: usize, count: usize) -> bool { + self.id == owner.id + && start >= self.start + && start - self.start <= self.count + && count <= self.count - (start - self.start) + } + + fn overlaps(&self, owner: &Owner, start: usize, count: usize) -> bool { + self.id == owner.id + && self.start < start + count + && start < self.start + self.count + } +} + +struct Workspace { + pointer: usize, + window: u32, + bytes: usize, +} + +#[derive(Default)] +struct Cache { + entries: Vec, + workspace: Option, + clock: u64, + hits: u64, + uploads: u64, + upload_bytes: u64, + evictions: u64, +} + +impl Cache { + fn point_bytes(&self) -> usize { + self.entries.iter().map(|entry| entry.bytes).sum() + } + fn bytes(&self) -> usize { + self.point_bytes() + self.workspace.as_ref().map_or(0, |w| w.bytes) + } + + fn evict(&mut self, device: &Device<'_>, index: usize) { + let entry = self.entries.swap_remove(index); + check( + unsafe { ix_kzg_srs_destroy(device.id(), entry.pointer as *mut c_void) }, + "SRS eviction", + ); + self.evictions += 1; + tracing::debug!( + device = device.id(), + owner = entry.id, + start = entry.start, + bytes = entry.bytes, + "KZG SRS cache evicted" + ); + } + + fn drop_workspace(&mut self, device: &Device<'_>) { + if let Some(workspace) = self.workspace.take() { + check( + unsafe { + ix_kzg_srs_workspace_destroy( + device.id(), + workspace.pointer as *mut c_void, + ) + }, + "MSM workspace eviction", + ); + } + } + + fn purge(&mut self, device: &Device<'_>) { + while let Some(index) = + self.entries.iter().position(|entry| entry.owner.strong_count() == 0) + { + self.evict(device, index); + } + } + + fn oldest(&self) -> Option { + self + .entries + .iter() + .enumerate() + .min_by_key(|(_, entry)| entry.used) + .map(|(i, _)| i) + } +} + +fn caches() -> &'static [Mutex] { + static CACHES: OnceLock>> = OnceLock::new(); + CACHES.get_or_init(|| { + Devices::get().ids.iter().map(|_| Mutex::new(Cache::default())).collect() + }) +} + +pub(super) fn reclaimable(index: usize) -> usize { + caches()[index].lock().unwrap().bytes() +} + +/// The caller owns the only operation lease; no cached native handle can be +/// in use. +pub(super) fn prepare_budget(device: &Device<'_>, required: usize) -> bool { + let mut cache = caches()[device.index].lock().unwrap(); + cache.purge(device); + while device.budget() < required { + if let Some(index) = cache.oldest() { + cache.evict(device, index); + } else if cache.workspace.is_some() { + cache.drop_workspace(device); + } else { + return false; + } + } + true +} + +struct Handles { + points: usize, + point_offset: usize, + workspace: usize, +} + +fn prepare( + device: &Device<'_>, + owner: &Arc, + start: usize, + count: usize, +) -> Handles { + assert!(count > 0 && count <= MAX_CHUNK); + assert!(start <= owner.srs.g1.len() && count <= owner.srs.g1.len() - start); + let (mut window, mut persistent, mut temporary) = (0, 0, 0); + check( + unsafe { + ix_kzg_srs_plan( + device.id(), + count, + &mut window, + &mut persistent, + &mut temporary, + ) + }, + "MSM memory plan", + ); + 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 + .workspace + .as_ref() + .is_some_and(|workspace| workspace.window != window) + { + cache.drop_workspace(device); + } + loop { + let hit = cache + .entries + .iter() + .position(|entry| entry.contains(owner, start, count)); + if hit.is_none() + && let Some(index) = cache + .entries + .iter() + .position(|entry| entry.overlaps(owner, start, count)) + { + cache.evict(device, index); + continue; + } + if hit.is_none() && cache.point_bytes() + point_bytes > LIMIT { + let index = cache.oldest().expect("SRS chunk exceeds cache limit"); + cache.evict(device, index); + continue; + } + let needed = temporary + + HEADROOM + + if hit.is_none() { point_bytes + staging_bytes } else { 0 } + + if cache.workspace.is_none() { persistent } else { 0 }; + if device.budget() < needed { + if let Some(index) = cache.oldest() { + cache.evict(device, index); + continue; + } + panic!("insufficient free VRAM for admitted cached MSM"); + } + if cache.workspace.is_none() { + let mut pointer = std::ptr::null_mut(); + check( + unsafe { + ix_kzg_srs_workspace_create(device.id(), count, &mut pointer) + }, + "MSM workspace creation", + ); + cache.workspace = Some(Workspace { + pointer: pointer as usize, + window, + bytes: persistent, + }); + } + cache.clock = + cache.clock.checked_add(1).expect("SRS cache clock exhausted"); + let used = cache.clock; + let index = if let Some(index) = hit { + cache.hits += 1; + cache.entries[index].used = used; + index + } else { + let mut pointer = std::ptr::null_mut(); + check( + unsafe { + ix_kzg_srs_upload( + device.id(), + owner.srs.g1[start..].as_ptr().cast(), + count, + &mut pointer, + ) + }, + "SRS cache upload", + ); + cache.uploads += 1; + cache.upload_bytes += (count * size_of::()) as u64; + cache.entries.push(Entry { + owner: Arc::downgrade(owner), + id: owner.id, + start, + count, + pointer: pointer as usize, + bytes: point_bytes, + used, + }); + cache.entries.len() - 1 + }; + assert!( + device.budget() >= temporary + HEADROOM, + "cached MSM free-memory recheck failed" + ); + let entry = &cache.entries[index]; + tracing::debug!( + device = device.id(), + owner = owner.id, + start, + count, + hit = hit.is_some(), + point_bytes = cache.point_bytes(), + workspace_bytes = persistent, + hits = cache.hits, + uploads = cache.uploads, + upload_bytes = cache.upload_bytes, + evictions = cache.evictions, + "KZG SRS cache access" + ); + return Handles { + points: entry.pointer, + point_offset: start - entry.start, + workspace: cache.workspace.as_ref().unwrap().pointer, + }; + } +} + +#[derive(Clone, Copy)] +enum Scalars<'a> { + Host(&'a [Fr]), + Resident { pointer: usize, offset: usize, count: usize }, +} + +fn invoke( + device: &Device<'_>, + handles: &Handles, + scalars: Scalars<'_>, + normalization: Option<(Fr, Fr)>, +) -> G1Projective { + let (host, resident, scalar_offset, count) = match scalars { + Scalars::Host(values) => { + (values.as_ptr().cast(), std::ptr::null(), 0, values.len()) + }, + Scalars::Resident { pointer, offset, count } => { + (std::ptr::null(), pointer as *const c_void, offset, count) + }, + }; + let mut output = [[0; FQ_LIMBS]; 3]; + check( + unsafe { + ix_kzg_srs_msm( + device.id(), + handles.workspace as *mut c_void, + handles.points as *const c_void, + handles.point_offset, + &mut output, + host, + resident, + scalar_offset, + count, + normalization + .as_ref() + .map_or(std::ptr::null(), |(_, inverse)| &inverse.0.0), + ) + }, + "cached SRS MSM", + ); + let result = projective(output); + normalization.map_or(result, |(scale, _)| result * scale) +} + +pub(in crate::kzg) fn msm_columns( + owner: &Arc, + start: usize, + columns: &[(&[Fr], Option<&ResidentPolynomial>)], +) -> Vec { + let count = columns.first().map_or(0, |(column, _)| column.len()); + assert!(columns.iter().all(|(column, _)| column.len() == count)); + assert!(start <= owner.srs.g1.len() && count <= owner.srs.g1.len() - start); + if count == 0 { + return vec![G1Projective::zero(); columns.len()]; + } + let devices = Devices::get(); + let limit = devices.chunk_points.min(MAX_CHUNK); + let mut groups = vec![Vec::new(); devices.ids.len()]; + let mut host = Vec::new(); + for (column, (_, resident)) in columns.iter().enumerate() { + if let Some(resident) = resident { + groups[resident.reservation.index].push(column); + } else { + host.push(column); + } + } + let mut work = Vec::new(); + for (index, group) in groups.into_iter().enumerate() { + if !group.is_empty() { + work.push((index, 0, count, group)); + } + } + if !host.is_empty() { + let chunk = count.div_ceil(devices.ids.len()).min(limit).max(1); + for (index, offset) in (0..count).step_by(chunk).enumerate() { + work.push(( + index % devices.ids.len(), + offset, + (count - offset).min(chunk), + host.clone(), + )); + } + } + let parts: Vec<_> = work + .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) + / 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) { + let length = (offset + length - part).min(chunk); + let handles = prepare(&device, owner, start + part, length); + for (&column, sum) in group.iter().zip(&mut sums) { + let (coefficients, resident) = columns[column]; + let input = &coefficients[part..part + length]; + let scalars = resident.map_or(Scalars::Host(input), |resident| { + Scalars::Resident { + pointer: resident.context, + offset: part, + count: length, + } + }); + *sum += invoke(&device, &handles, scalars, msm_normalization(input)); + } + } + group.into_iter().zip(sums).collect::>() + }) + .collect(); + let mut result = vec![G1Projective::zero(); columns.len()]; + for (column, point) in parts.into_iter().flatten() { + result[column] += point; + } + result +} + +pub(in crate::kzg) fn single_device_opening( + owner: &Arc, + coefficients: &[Fr], + z: Fr, +) -> Option { + let devices = Devices::get(); + if devices.ids.len() != 1 || coefficients.len() < 2 { + return None; + } + let count = coefficients.len() - 1; + assert!(count <= owner.srs.g1.len()); + let device = devices.acquire_at(0); + let chunk = opening_chunk( + coefficients.len(), + devices.chunk_points.min(MAX_CHUNK), + devices.potential_budget(0), + )?; + let required = coefficients + .len() + .checked_mul(32)? + .checked_add(HEADROOM)? + .checked_add(chunk.checked_mul(MSM_BYTES_PER_POINT)?)?; + if !device.prepare_budget(required) { + return None; + } + let mut context = std::ptr::null_mut(); + check( + unsafe { + ix_kzg_divide_resident( + device.id(), + coefficients.as_ptr().cast(), + coefficients.len(), + &z.0.0, + &mut context, + ) + }, + "cached SRS opening division", + ); + let quotient = LeasedPolynomial { device: &device, context }; + let mut result = G1Projective::zero(); + for offset in (0..count).step_by(chunk) { + let count = (count - offset).min(chunk); + let mut samples = [Fr::ZERO; 128]; + let normalization = if count >= 4096 { + check( + unsafe { + ix_kzg_polynomial_sample( + device.id(), + quotient.context, + offset, + count, + samples.as_mut_ptr().cast(), + ) + }, + "cached SRS opening sample", + ); + sample_normalization(samples.into_iter()) + } else { + None + }; + let handles = prepare(&device, owner, offset, count); + result += invoke( + &device, + &handles, + Scalars::Resident { pointer: quotient.context as usize, offset, count }, + normalization, + ); + } + Some(result) +} diff --git a/crates/proof-compression/src/kzg/cuda/srs_residency/tests.rs b/crates/proof-compression/src/kzg/cuda/srs_residency/tests.rs new file mode 100644 index 000000000..84ff7a281 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/srs_residency/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 new file mode 100644 index 000000000..452fb3439 --- /dev/null +++ b/crates/proof-compression/src/kzg/cuda/tests.rs @@ -0,0 +1,751 @@ +use super::*; +use ark_ec::{AffineRepr, CurveGroup, VariableBaseMSM}; +use ark_ff::PrimeField; +use ark_poly::{EvaluationDomain, Radix2EvaluationDomain}; + +mod constraints; + +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(u32::try_from(lg).unwrap(), &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( + u32::try_from(MAX_FFT_LOG).unwrap() + 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| { + let mut bytes = [0; 64]; + blake3::Hasher::new() + .update(&(i as u64).to_le_bytes()) + .finalize_xof() + .fill(&mut bytes); + Fr::from_le_bytes_mod_order(&bytes) + }) + .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 [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 { + 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()); +} + +fn lease_devices() -> Devices { + Devices { + ids: vec![0, 1], + busy: Mutex::new(vec![false; 2]), + available: Condvar::new(), + chunk_points: 1, + resident_bytes: Mutex::new(vec![0; 2]), + resident_limits: vec![0; 2], + } +} + +#[test] +fn device_leases_reject_reentrancy_and_release_on_unwind() { + let devices = lease_devices(); + for entry in 0..4 { + let result = std::panic::catch_unwind(|| { + let _lease = devices.acquire_at(0); + match entry { + 0 => drop(devices.acquire()), + 1 => drop(devices.acquire_at(1)), + 2 => drop(quotient::acquire_columns(&mut [], &[], 0)), + _ => drop(devices.acquire_admitted(&[1], |_| true)), + } + }); + assert!(result.is_err()); + assert_eq!(DEVICE_LEASES.get(), 0); + assert!(devices.busy.lock().unwrap().iter().all(|busy| !busy)); + } + drop(devices.acquire()); + assert!(devices.busy.lock().unwrap().iter().all(|busy| !busy)); +} + +#[test] +fn device_admission_waits_for_busy_candidates_to_release_scratch() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::time::Duration; + + let devices = lease_devices(); + let occupied = devices.acquire_at(0); + let free = AtomicUsize::new(12); + let (rejected, rejection) = mpsc::channel(); + let (finished, completion) = mpsc::channel(); + std::thread::scope(|scope| { + scope.spawn(|| { + let mut attempted = Vec::new(); + let device = devices + .acquire_admitted(&[0, 1], |device| { + assert_eq!(DEVICE_LEASES.get(), 1); + attempted.push(device.index); + if device.index == 1 { + rejected.send(()).unwrap(); + false + } else { + free.load(Ordering::SeqCst) >= 17 + } + }) + .expect("released scratch admits the waiting operation"); + assert_eq!(device.index, 0); + assert_eq!(attempted, [1, 0]); + drop(device); + finished.send(()).unwrap(); + }); + rejection.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + free.store(28, Ordering::SeqCst); + drop(occupied); + completion.recv_timeout(Duration::from_secs(5)).unwrap(); + }); + assert!(devices.busy.lock().unwrap().iter().all(|busy| !busy)); +} + +#[test] +fn device_admission_uses_a_free_candidate_and_checks_every_rejection() { + use std::sync::mpsc; + use std::time::Duration; + + let devices = lease_devices(); + let occupied = devices.acquire_at(0); + let (finished, completion) = mpsc::channel(); + std::thread::scope(|scope| { + scope.spawn(|| { + let device = devices.acquire_admitted(&[0, 1], |_| true).unwrap(); + assert_eq!(device.index, 1); + drop(device); + finished.send(()).unwrap(); + }); + completion.recv_timeout(Duration::from_secs(5)).unwrap(); + drop(occupied); + }); + let mut attempted = Vec::new(); + assert!( + devices + .acquire_admitted(&[1, 0], |device| { + assert_eq!(DEVICE_LEASES.get(), 1); + attempted.push(device.index); + false + }) + .is_none() + ); + assert_eq!(attempted, [1, 0]); + assert_eq!(DEVICE_LEASES.get(), 0); + assert!(devices.busy.lock().unwrap().iter().all(|busy| !busy)); +} + +#[test] +fn opening_memory_admission_keeps_scratch_reserve() { + let fixed = (1 << 30) + 32 * 1024; + assert_eq!(opening_chunk(1024, 16, fixed - 1), None); + assert_eq!(opening_chunk(1024, 16, fixed + 383), None); + assert_eq!(opening_chunk(1024, 16, fixed + 384), Some(1)); + assert_eq!(opening_chunk(1024, 16, fixed + 384 * 32), Some(16)); + assert_eq!(opening_chunk(usize::MAX, 16, usize::MAX), None); + assert_eq!(opening_chunk(1024, 0, usize::MAX), None); +} + +#[test] +fn normalization_samples_structured_columns_only() { + let structured = vec![Fr::from(19).inverse().unwrap(); 8192]; + assert!(msm_normalization(&structured).is_some()); + assert!(msm_normalization(&scalars(8192)).is_none()); + assert!(msm_normalization(&vec![Fr::ONE; 8192]).is_none()); + assert!(msm_normalization(&structured[..4095]).is_none()); +} + +// 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() { + let mut scalars = scalars(4099); + let mut points: Vec<_> = scalars + .iter() + .map(|&s| (G1Affine::generator() * s).into_affine()) + .collect(); + points[0] = G1Affine::zero(); + points[1] = G1Affine::generator(); + points[2] = -points[1]; + points[4].infinity = true; + scalars[1] = Fr::ONE; + scalars[2] = Fr::ONE; + scalars[3] = Fr::ZERO; + scalars[4] = -Fr::ONE; + for n in [0, 1, 31, 32, 33, 257, 4099] { + assert_eq!( + msm_chunked(&points[..n], &scalars[..n], 257), + G1Projective::msm(&points[..n], &scalars[..n]).unwrap() + ); + } + assert!(msm_chunked(&points, &vec![Fr::ZERO; points.len()], 1024).is_zero()); + assert!(msm_chunked(&points[1..3], &scalars[1..3], 1).is_zero()); +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_resident_msm_columns_match_arkworks() { + let input = scalars(4099); + let mut points: Vec<_> = + input.iter().map(|&s| (G1Affine::generator() * s).into_affine()).collect(); + points[0] = G1Affine::zero(); + let negative: Vec<_> = input.iter().map(|&s| -s).collect(); + let zeros = vec![Fr::ZERO; input.len()]; + let ones = vec![Fr::ONE; input.len()]; + let columns = + [input.as_slice(), zeros.as_slice(), negative.as_slice(), ones.as_slice()]; + let actual = msm_columns_chunked(&points, &columns, 257); + for (actual, coefficients) in actual.iter().zip(columns) { + assert_eq!(*actual, G1Projective::msm(&points, coefficients).unwrap()); + } + assert!((actual[0] + actual[2]).is_zero()); + points[1] = -points[1]; + assert_eq!( + msm_chunked(&points, &input, 257), + G1Projective::msm(&points, &input).unwrap() + ); +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_msm_normalizes_structured_columns() { + let n = 65_537; + let seeds = scalars(17); + let mut basis: Vec<_> = + seeds.iter().map(|&s| (G1Affine::generator() * s).into_affine()).collect(); + basis[0] = G1Affine::zero(); + let points: Vec<_> = (0..n).map(|i| basis[i % basis.len()]).collect(); + let scale = Fr::from(19).inverse().unwrap(); + let structured: Vec<_> = (0..n) + .map(|i| { + if i % 11 == 0 { + Fr::ZERO + } else if i % 257 == 0 { + seeds[i % seeds.len()] + } else if i % 7 == 0 { + -scale + } else { + scale + } + }) + .collect(); + let scaled: Vec<_> = structured.iter().map(|&s| s * Fr::from(7)).collect(); + let random = scalars(n); + let columns = [structured.as_slice(), scaled.as_slice(), random.as_slice()]; + let actual = msm_columns_chunked(&points, &columns, 1 << 15); + for (actual, coefficients) in actual.into_iter().zip(columns) { + assert_eq!(actual, G1Projective::msm(&points, coefficients).unwrap()); + } +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_fft_matches_arkworks_on_subgroups_and_arbitrary_cosets() { + for lg in [0, 1, 5, 10, 11, 16] { + for shift in [Fr::ONE, Fr::from(7), Fr::from(12345)] { + let domain = Radix2EvaluationDomain::::new(1 << lg) + .unwrap() + .get_coset(shift) + .unwrap(); + let input = scalars(1 << lg); + let mut actual = input.clone(); + fft(&mut actual, false, shift); + assert_eq!(actual, domain.fft(&input), "forward lg={lg}"); + fft(&mut actual, true, shift); + assert_eq!(actual, input, "inverse lg={lg}"); + let mut actual = input.clone(); + fft(&mut actual, true, shift); + assert_eq!(actual, domain.ifft(&input), "inverse parity lg={lg}"); + } + } +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_fft_from_pads_without_mutating_coefficients() { + for lg in [0, 1, 10, 16] { + let size = 1 << lg; + let input = scalars(size); + let saved = input.clone(); + for count in [0, 1, size / 3, size] { + for shift in [Fr::ONE, Fr::from(7)] { + let domain = Radix2EvaluationDomain::::new(size) + .unwrap() + .get_coset(shift) + .unwrap(); + assert_eq!( + fft_from(&input[..count], lg, shift), + domain.fft(&input[..count]) + ); + assert_eq!(input, saved); + } + } + } +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_fft_queues_reuse_resident_inputs_and_preserve_order() { + let lg = 17; + let input = scalars(1 << lg); + let resident = retain(&input).expect("test requires resident VRAM budget"); + let expected = Radix2EvaluationDomain::::new(input.len()) + .unwrap() + .get_coset(Fr::from(7)) + .unwrap() + .fft(&input); + // 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| { + ( + input.as_slice(), + if i % 2 == 0 { Some(resident.as_ref()) } else { None }, + ) + }) + .collect(); + for _ in 0..2 { + let actual = fft_columns(&jobs, lg, false, Fr::from(7)); + assert_eq!(actual.len(), jobs.len()); + assert!(actual.iter().all(|column| *column == expected)); + } + let points = [Fr::ZERO, Fr::from(7), Fr::ONE, Fr::from(7)]; + let evaluations = evaluate_resident(&input, Some(&resident), &points); + for (z, actual) in points.into_iter().zip(evaluations) { + assert_eq!(actual, input.iter().rev().fold(Fr::ZERO, |acc, c| acc * z + c)); + } + drop(resident); + assert_eq!(fft_columns(&[], lg, false, Fr::ONE), Vec::>::new()); +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_interpolation_retains_outputs_for_msm_and_cosets() { + let count = 1 << 16; + let shift = Fr::from(7); + let domain = + Radix2EvaluationDomain::::new(count).unwrap().get_coset(shift).unwrap(); + let coefficients = + [scalars(count), vec![Fr::from(19).inverse().unwrap(); count]]; + let inputs = coefficients.iter().map(|c| domain.fft(c)).collect(); + let (actual, resident) = interpolate_columns(inputs, 16, shift); + assert_eq!(actual, coefficients); + assert!(resident.iter().all(Option::is_some)); + let points: Vec<_> = (0..count) + .map(|i| { + if i % 257 == 0 { + G1Affine::zero() + } else { + (G1Affine::generator() * Fr::from((i % 31 + 1) as u64)).into_affine() + } + }) + .collect(); + let inputs: Vec<_> = actual + .iter() + .zip(&resident) + .map(|(c, r)| (c.as_slice(), r.as_deref())) + .collect(); + let commitments = msm_columns_resident(&points, &inputs); + for (column, commitment) in actual.iter().zip(commitments) { + assert_eq!(commitment, G1Projective::msm(&points, column).unwrap()); + } + let output = fft_columns(&inputs, 16, false, Fr::from(123)); + let coset = Radix2EvaluationDomain::::new(count) + .unwrap() + .get_coset(Fr::from(123)) + .unwrap(); + for (column, output) in coefficients.iter().zip(output) { + assert_eq!(output, coset.fft(column)); + } +} + +#[test] +#[ignore = "requires a CUDA GPU"] +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); + let quotient = divide(&coefficients, z); + assert_eq!(quotient.len(), n.saturating_sub(1)); + if n >= 2 { + assert_eq!(coefficients[0] + z * quotient[0], expected); + for i in 1..n { + assert_eq!( + quotient[i - 1] - z * quotient.get(i).copied().unwrap_or(Fr::ZERO), + coefficients[i], + "division n={n}, i={i}" + ); + } + } + } + } +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_polynomial_evaluates_all_points_in_order() { + let mut points = scalars(41); + 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, 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]; + let expected: Vec<_> = points + .iter() + .map(|z| coefficients.iter().rev().fold(Fr::ZERO, |v, c| v * z + c)) + .collect(); + assert_eq!( + evaluate_many(&coefficients, points), + expected, + "n={n}, points={count}" + ); + } + } +} + +fn division_reference(coefficients: &[Fr], z: Fr) -> Vec { + let mut quotient = vec![Fr::ZERO; coefficients.len().saturating_sub(1)]; + let mut carry = Fr::ZERO; + for i in (1..coefficients.len()).rev() { + carry = coefficients[i] + z * carry; + quotient[i - 1] = carry; + } + quotient +} + +fn opening_points(count: usize) -> Vec { + let mut point = G1Projective::zero(); + let basis: Vec<_> = (0..256) + .map(|_| { + let output = point; + point += G1Affine::generator(); + output + }) + .collect(); + let basis = G1Projective::normalize_batch(&basis); + crate::kzg::pcs::generate_buffer(count, |i| basis[i % basis.len()]) +} + +#[test] +#[ignore = "requires a CUDA GPU"] +fn cuda_resident_division_msm_matches_arkworks() { + for n in [0usize, 1, 2, 33, 4097, 65537] { + let points = opening_points(n.saturating_sub(1)); + for (coefficients, z) in [ + (scalars(n), Fr::from(918273)), + (vec![Fr::from(19).inverse().unwrap(); n], Fr::ZERO), + (vec![Fr::ZERO; n], Fr::ONE), + ] { + let expected = + G1Projective::msm(&points, &division_reference(&coefficients, z)) + .unwrap(); + assert_eq!( + divide_and_msm_chunked(&points, &coefficients, z, 16385), + Some(expected), + "n={n}, z={z}" + ); + } + } + assert!( + divide_and_msm_chunked(&opening_points(1), &[Fr::ONE; 2], Fr::ZERO, 0) + .is_none() + ); + assert_eq!( + divide_and_msm(&opening_points(1), &[Fr::ONE; 2], Fr::ZERO), + Some(G1Projective::zero()) + ); +} + +/// Run `test` on its own thread and fail if it does not finish within the +/// deadline, so a device-lease deadlock cannot hang the test runner. +fn with_deadline(seconds: u64, test: impl FnOnce() + Send + 'static) { + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + test(); + let _ = done.send(()); + }); + finished + .recv_timeout(std::time::Duration::from_secs(seconds)) + .expect("test did not complete within the deadline, or panicked"); +} + +#[test] +#[ignore = "requires two CUDA GPUs; pass admission across resident columns on both"] +fn pass_admission_binds_only_the_admitted_devices_residents() { + let devices = Devices::get(); + assert!(devices.ids.len() >= 2, "run with two devices selected"); + let values = scalars(1 << 16); + // Reservations balance retained bytes across the devices. + let retained: Vec<_> = + (0..4).map(|_| retain(&values).expect("resident column")).collect(); + let a: &ResidentPolynomial = &retained[0]; + let b: &ResidentPolynomial = retained + .iter() + .map(|r| &**r) + .find(|r| r.reservation.index != a.reservation.index) + .expect("retentions spread over two devices"); + fn expect_device(all: &[quotient::PolynomialView<'_>], expected: usize) { + let empty = quotient::Program { + nodes: vec![], + roots: vec![], + lookups: vec![], + args: vec![], + slots: 0, + }; + let mut passes = + vec![passes::Pass::new(empty, all, &[], 0, Fr::ONE).expect("pass")]; + let device = passes::admit(&mut passes, all, 1 << 20).expect("admitted"); + assert_eq!(device.index, expected); + for (column, (_, resident, _)) in passes[0].columns.iter().zip(all) { + let local = resident.is_some_and(|r| r.reservation.index == expected); + assert_eq!(!column.resident.is_null(), local); + } + } + let host = (&values[..], None, false); + let on_a = (&values[..], Some(a), false); + let on_b = (&values[..], Some(b), false); + expect_device(&[on_a, on_a, on_b, host], a.reservation.index); + expect_device(&[on_b, host], b.reservation.index); +} + +#[test] +#[ignore = "requires one CUDA GPU; two Rayon threads race uncached MSMs and divisions"] +fn concurrent_uncached_msm_completes() { + with_deadline(120, concurrent_uncached_msm_body); +} + +fn concurrent_uncached_msm_body() { + use std::sync::Barrier; + assert_eq!(Devices::get().ids.len(), 1, "run with one device selected"); + assert!(!srs_residency::enabled()); + let n = 1 << 20; + let basis: Vec<_> = (1u64..=17) + .map(|i| (G1Affine::generator() * Fr::from(i)).into_affine()) + .collect(); + let points: Vec<_> = (0..n).map(|i| basis[i % basis.len()]).collect(); + let scalars = vec![Fr::from(37); n]; + assert!(msm_normalization(&scalars).is_some()); + let expected = G1Projective::msm(&points, &scalars).unwrap(); + let start = Barrier::new(2); + std::thread::scope(|scope| { + let jobs: Vec<_> = (0..2) + .map(|_| { + scope.spawn(|| { + start.wait(); + assert_eq!(msm(&points, &scalars), expected); + assert_eq!(divide(&scalars, Fr::ZERO), scalars[..n - 1]); + }) + }) + .collect(); + for job in jobs { + job.join().unwrap(); + } + }); +} + +#[test] +#[ignore = "requires one CUDA GPU; concurrent shared-key hydration"] +fn concurrent_shared_matrix_hydration_completes() { + with_deadline(120, concurrent_shared_matrix_hydration_body); +} + +fn concurrent_shared_matrix_hydration_body() { + use crate::kzg::{Scalar, domain::Radix2Coset, pcs::KzgProverData}; + let n = 1 << 16; + let mut constant = vec![Fr::ZERO; n]; + constant[0] = Fr::from(37); + let coefficients = vec![Fr::from(19); n]; + let data = KzgProverData::coefficient_fixture( + Radix2Coset { log_size: 16, shift: Scalar(Fr::ONE) }, + vec![constant, coefficients.clone(), coefficients.clone()], + ); + let matrix = &data.matrices[0]; + let columns: Vec<_> = + (0..16).into_par_iter().map(|_| matrix.resident_columns()).collect(); + assert!(columns.iter().all(|view| std::ptr::eq(*view, columns[0]))); + let resident = columns[0]; + assert!(resident[0].is_none()); + let z = Fr::from(7); + let expected = coefficients.iter().rev().fold(Fr::ZERO, |sum, c| sum * z + c); + for column in &resident[1..] { + assert!(column.is_some(), "small resident fixture must fit"); + assert_eq!( + evaluate_resident(&coefficients, column.as_deref(), &[z]), + [expected] + ); + } +} 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..41f6d2f68 --- /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( + std::slice::from_ref(&lookup_values), + &[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/curve.rs b/crates/proof-compression/src/kzg/curve.rs new file mode 100644 index 000000000..7f75bb814 --- /dev/null +++ b/crates/proof-compression/src/kzg/curve.rs @@ -0,0 +1,113 @@ +//! Curve types and canonical encoding widths for the selected KZG backend. +//! +//! BN254 encodings are arkworks canonical +//! encodings; these widths do not describe in-memory or EVM representations. + +use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, Read, SerializationError, +}; + +pub use ark_bn254::{ + Bn254 as PairingCurve, Fq, Fr, G1Affine, G1Projective, G2Affine, G2Projective, +}; + +pub const NAME: &str = "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; + use ark_ff::{BigInteger, PrimeField}; + + #[test] + 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(); + 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); + } + + 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()); + // 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 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; + 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/dev_srs_cache.rs b/crates/proof-compression/src/kzg/dev_srs_cache.rs new file mode 100644 index 000000000..ce42e42fd --- /dev/null +++ b/crates/proof-compression/src/kzg/dev_srs_cache.rs @@ -0,0 +1,331 @@ +//! 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::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_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/bn254/dev-srs/v1\0"; +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::*; + use crate::test_support::TempDir; + + 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 = 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).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(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] { + 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).unwrap().count(), 4); + assert!(cached_in_directory(16, 14, b"first", &directory).is_err()); + } + + #[test] + fn damaged_cache_fails_without_regeneration() { + let directory = TempDir::new("dev-srs"); + let seed = b"damage"; + 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(), + [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).is_err()); + assert_eq!(fs::read(&path).unwrap(), damaged); + } + } + + #[test] + fn cache_rejects_a_different_seed_even_with_valid_checksum() { + let directory = TempDir::new("dev-srs"); + let expected_header = header(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).is_err()); + } + + #[test] + fn concurrent_cache_writers_publish_complete_files() { + let directory = TempDir::new("dev-srs"); + std::thread::scope(|scope| { + for _ in 0..4 { + scope.spawn(|| { + cached_in_directory(32, 31, b"concurrent", &directory).unwrap(); + }); + } + }); + let actual = + 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).unwrap().count(), 1); + } +} diff --git a/crates/proof-compression/src/kzg/domain.rs b/crates/proof-compression/src/kzg/domain.rs new file mode 100644 index 000000000..91503203e --- /dev/null +++ b/crates/proof-compression/src/kzg/domain.rs @@ -0,0 +1,208 @@ +//! 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::{curve::Fr, field::Scalar}; + +const ONE: Scalar = >::ONE; + +/// 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, + 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 { + assert!( + self.log_size <= Scalar::TWO_ADICITY, + "domain exceeds scalar two-adicity" + ); + 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 { + 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, + 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]); + } + } + + #[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 new file mode 100644 index 000000000..eecffc89c --- /dev/null +++ b/crates/proof-compression/src/kzg/encoding.rs @@ -0,0 +1,131 @@ +//! Bounded parallel conversion of canonical scalar-field streams. + +use std::io::{self, Read, Write}; + +use super::curve::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::{BigInteger, 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 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 new file mode 100644 index 000000000..9ecd47cc3 --- /dev/null +++ b/crates/proof-compression/src/kzg/fft_batch.rs @@ -0,0 +1,87 @@ +use super::coefficients::Column; +use super::curve::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..767fe544b --- /dev/null +++ b/crates/proof-compression/src/kzg/field.rs @@ -0,0 +1,321 @@ +//! Crate field traits for the selected KZG curve's scalar field. +//! +//! [`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`, 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 super::curve::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 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); + +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 { + const TWO_ADICITY: usize = ::TWO_ADICITY as usize; + + #[inline] + fn two_adic_generator(bits: usize) -> Self { + assert!(bits <= Self::TWO_ADICITY, "domain exceeds scalar 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::*; + use ark_ff::{BigInteger, PrimeField}; + + #[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); + assert_eq!( + Scalar::two_adic_generator(Scalar::TWO_ADICITY) + .exp_power_of_2(Scalar::TWO_ADICITY - 1), + Scalar::NEG_ONE + ); + assert_eq!(Scalar::TWO_ADICITY, 28); + } + + #[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() { + 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()); + } + } +} diff --git a/crates/proof-compression/src/kzg/mod.rs b/crates/proof-compression/src/kzg/mod.rs new file mode 100644 index 000000000..7aa85f58d --- /dev/null +++ b/crates/proof-compression/src/kzg/mod.rs @@ -0,0 +1,29 @@ +//! KZG backend with BLAKE3 and Keccak-256 transcripts. +//! Commitments contain one G1 point per column; openings are batched by point. + +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; +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::{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 new file mode 100644 index 000000000..b5eeffba5 --- /dev/null +++ b/crates/proof-compression/src/kzg/pcs.rs @@ -0,0 +1,1754 @@ +//! 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 +//! 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. Requested +//! evaluation domains must be at least as large as the committed trace; +//! smaller-domain coefficient folding is unsupported. +//! +//! With the `cuda` feature, resident columns of sufficient size run their +//! transforms, commitments, evaluations and opening divisions on the +//! selected devices, retaining device copies within the residency budget. +//! Only shapes below the device thresholds and disk-backed columns use the +//! host implementation; a device that cannot admit an operation is an error. +//! The proof bytes do not depend on the path taken. + +use std::sync::Arc; + +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::{ + 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::TwoAdicField; +use multi_stark::traits::VerifyRounds; + +use super::coefficients::Column; +#[cfg(feature = "cuda")] +use super::cuda; +use super::domain::Radix2Coset; +use super::field::Scalar; +use super::srs::Srs; +use super::transcript::KzgTranscript; + +const CHECKPOINT_MAGIC: &[u8; 8] = b"IXBNCP01"; + +/// Per-matrix column commitments. +#[derive(Clone, Debug, PartialEq, Eq)] +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); + +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)?; + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for KzgCommitment { + fn deserialize>( + deserializer: D, + ) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + 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(count) + } + let rows = count(&mut input, 8)?; + let mut matrices = Vec::with_capacity(rows); + for _ in 0..rows { + let width = count(&mut input, G1_COMPRESSED_BYTES)?; + let mut points = Vec::with_capacity(width); + for _ in 0..width { + points.push(read_g1_compressed(&mut input)?); + } + matrices.push(points); + } + if !input.is_empty() { + return Err(SerializationError::InvalidData); + } + Ok(KzgCommitment(matrices)) +} + +// 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 KzgProof { + fn deserialize>( + _deserializer: D, + ) -> Result { + Err(serde::de::Error::custom("KZG proofs require FixedProofCodec")) + } +} + +/// 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, + /// 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, + #[cfg(feature = "cuda")] + resident: std::sync::OnceLock>>>, +} + +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, + #[cfg(feature = "cuda")] + resident: Default::default(), + } + } + + fn constant_columns(&self) -> &[bool] { + &self.constants + } + + /// Every column as a host slice with its current device residency and + /// constant flag, or `None` when a column is disk-backed. A compact + /// constant column is the one-element slice of its constant term; device + /// consumers read only that term or zero-pad it to the trace length. + /// Residency is reported, not created; see [`Self::resident_columns`]. + #[cfg(feature = "cuda")] + pub(super) fn cuda_columns( + &self, + ) -> Option, bool)>> { + let resident = self.resident.get(); + self + .columns + .iter() + .enumerate() + .zip(&self.constants) + .map(|((index, column), &constant)| { + Some(( + column.host_coefficients()?, + resident.and_then(|columns| columns[index].as_deref()), + constant, + )) + }) + .collect() + } + + /// Device copies of the non-constant resident columns, uploaded on first + /// use within the residency budget. Disk-backed columns stay host-only. + #[cfg(feature = "cuda")] + pub(super) fn resident_columns( + &self, + ) -> &[Option>] { + self.resident.get_or_init(|| { + // A OnceLock initializer must not run Rayon work that can reenter it. + self + .columns + .iter() + .zip(&self.constants) + .map(|(column, &constant)| { + if constant { None } else { column.resident().and_then(cuda::retain) } + }) + .collect() + }) + } +} + +/// Prover-side retained data for one commitment round. +pub struct KzgProverData { + commitment: KzgCommitment, + pub matrices: Vec, +} + +impl KzgProverData { + #[cfg(all(test, feature = "cuda"))] + pub(super) fn coefficient_fixture( + domain: Radix2Coset, + columns: Vec>, + ) -> Self { + Self { + commitment: KzgCommitment(vec![vec![]]), + matrices: vec![CommittedMatrix::new( + domain, + columns.into_iter().map(Column::from).collect(), + )], + } + } + + /// 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> { + 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 { + (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(()); + column.visit(|_, values| { + if result.is_err() { + return; + } + result = + super::encoding::write_fields(&mut out, values, |value| *value) + .map_err(ark_serialize::SerializationError::from); + })?; + result?; + } + } + Ok(()) + } + + /// Load a trusted, locally produced prover checkpoint. + pub fn read_checkpoint( + mut input: impl std::io::Read, + ) -> Result { + let mut magic = [0; 8]; + input.read_exact(&mut magic)?; + if &magic != CHECKPOINT_MAGIC { + 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() { + 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 > Scalar::TWO_ADICITY { + return Err(ark_serialize::SerializationError::InvalidData); + } + let shift = Scalar(Fr::deserialize_compressed(&mut input)?); + 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); + } + 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 { + 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 mut magic = [0; 8]; + reader.read_exact(&mut magic)?; + if &magic != CHECKPOINT_MAGIC { + 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() { + 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 > Scalar::TWO_ADICITY as u64 { + 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::new(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![]); + let mut matrices = Vec::new(); + for mut part in parts { + commitment.0.append(&mut part.commitment.0); + 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, + /// A commitment or witness point is off the curve or outside the subgroup. + InvalidPoint, +} + +/// See the module docs. +#[derive(Clone)] +pub struct KzgPcs { + srs: Option>, + #[cfg(feature = "cuda")] + srs_owner: Option>, + verifier: super::VerifierParams, + max_quotient_degree: usize, +} + +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, + idx: usize, + domain: Radix2Coset, + 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); \ + raise max_quotient_degree if this cost is intended", + domain.size(), + self.max_quotient_degree + ); + #[cfg(feature = "cuda")] + if domain.size() >= 1 << 10 + && let Some(views) = matrix.cuda_columns() + { + return Self::evaluate_columns_on_device( + matrix, + &views, + domain, + selected, + 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(); + 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) + } + + /// Coset-evaluate the selected columns through device FFT queues. The + /// destination holds the full matrix; simultaneous FFT outputs are bounded + /// by `scratch_bytes`, or one column when a column exceeds that budget. + #[cfg(feature = "cuda")] + fn evaluate_columns_on_device( + matrix: &CommittedMatrix, + views: &[(&[Fr], Option<&cuda::ResidentPolynomial>, bool)], + domain: Radix2Coset, + selected: &[usize], + scratch_bytes: usize, + ) -> RowMajorMatrix { + let _profile = cuda::ProfileRange::new(c"kzg/evaluation-reconstruction"); + let started = std::time::Instant::now(); + let constants = matrix.constant_columns(); + let width = selected.len(); + let height = domain.size(); + let mut values = generate_buffer(width * height, |index| { + let column = selected[index % width]; + if constants[column] { + Scalar(views[column].0[0]) + } else { + Scalar(Fr::ZERO) + } + }); + let active: Vec<_> = + (0..width).filter(|&position| !constants[selected[position]]).collect(); + let batch_columns = (scratch_bytes / (height * size_of::())).max(1); + let residents = matrix.resident_columns(); + for batch in active.chunks(batch_columns) { + let inputs: Vec<_> = batch + .iter() + .map(|&position| { + let column = selected[position]; + (views[column].0, residents[column].as_deref()) + }) + .collect(); + let transformed = + cuda::fft_columns(&inputs, domain.log_size, false, domain.shift.0); + values.par_chunks_mut(width * (1 << 12)).enumerate().for_each( + |(tile, rows)| { + for (offset, row) in rows.chunks_mut(width).enumerate() { + let index = tile * (1 << 12) + offset; + for (&position, evaluations) in batch.iter().zip(&transformed) { + row[position] = Scalar(evaluations[index]); + } + } + }, + ); + } + tracing::info!( + width, + height, + batch_columns, + seconds = started.elapsed().as_secs_f64(), + "KZG evaluation reconstruction" + ); + RowMajorMatrix::new(values, width) + } + + pub fn new(srs: Arc, max_quotient_degree: usize) -> Self { + 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::validate_quotient_domain(&verifier, max_quotient_degree); + Self { + #[cfg(feature = "cuda")] + srs_owner: Some(cuda::srs_residency::Owner::new(Arc::clone(&srs))), + srs: Some(srs), + verifier, + max_quotient_degree, + } + } + + pub fn verifier_only( + verifier: super::VerifierParams, + max_quotient_degree: usize, + ) -> Self { + Self::validate_quotient_domain(&verifier, max_quotient_degree); + Self { + srs: None, + #[cfg(feature = "cuda")] + srs_owner: None, + verifier, + max_quotient_degree, + } + } + + 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") + } + + #[cfg(feature = "cuda")] + fn srs_owner(&self) -> &Arc { + self.srs_owner.as_ref().expect("proving requires the full SRS") + } + + /// Commit device-generated coefficient columns of one trace-sized matrix. + #[cfg(feature = "cuda")] + pub(super) fn commit_trace_coefficients( + &self, + trace_log_size: usize, + columns: Vec>, + ) -> KzgProverData { + let n = 1usize + .checked_shl(u32::try_from(trace_log_size).expect("trace log size")) + .unwrap(); + assert!( + n <= self.verifier.max_trace_len() && n <= self.prover_srs().max_len() + ); + assert!(!columns.is_empty()); + assert!(columns.iter().all(|column| column.len() == n)); + let matrix = CommittedMatrix::new( + Radix2Coset { + log_size: trace_log_size, + shift: multi_stark::traits::Algebra::::ONE, + }, + columns.into_iter().map(Column::from).collect(), + ); + let commitments = self.commit_matrix_columns(&matrix); + KzgProverData { + commitment: KzgCommitment(vec![commitments]), + matrices: vec![matrix], + } + } + + #[cfg(feature = "cuda")] + pub(super) fn commit_quotient_coefficients( + &self, + trace_log_size: usize, + columns: Vec>, + ) -> KzgProverData { + assert!(!columns.is_empty() && columns.len() <= self.max_quotient_degree); + self.commit_trace_coefficients(trace_log_size, columns) + } + + /// 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_matrix_columns(&self, matrix: &CommittedMatrix) -> Vec { + #[cfg(feature = "cuda")] + if matrix.domain.size() >= 1 << 12 + && let Some(views) = matrix.cuda_columns() + { + let count = matrix.domain.size(); + assert!( + count <= self.verifier.max_trace_len() + && count <= self.prover_srs().max_len(), + "polynomial length {count} exceeds the trace cap or loaded SRS" + ); + let constants = matrix.constant_columns(); + let mut commits = vec![G1Projective::zero(); views.len()]; + let active: Vec<_> = + (0..views.len()).filter(|&i| !constants[i]).collect(); + for (i, (column, _, _)) in views.iter().enumerate() { + if constants[i] { + commits[i] = self.prover_srs().g1[0] * column[0]; + } + } + if !active.is_empty() { + // Residency is retained from 2^16 rows, where repeated device use + // amortizes the upload; smaller columns are streamed from the host. + let residents = (count >= 1 << 16).then(|| matrix.resident_columns()); + let inputs: Vec<_> = active + .iter() + .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_residency::enabled() { + cuda::srs_residency::msm_columns(self.srs_owner(), 0, &inputs) + } else { + cuda::msm_columns_resident(&self.prover_srs().g1[..count], &inputs) + }; + for (i, point) in active.into_iter().zip(points) { + commits[i] = point; + } + tracing::debug!( + count, + columns = inputs.len(), + seconds = started.elapsed().as_secs_f64(), + "KZG column MSMs" + ); + } + return G1Projective::normalize_batch(&commits); + } + let commits: Vec = + matrix.columns.iter().map(|c| self.commit_column(c)).collect(); + G1Projective::normalize_batch(&commits) + } + + /// 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, 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_trace_len(), + "polynomial length {} exceeds the trace cap ({})", + coeffs.len(), + self.verifier.max_trace_len() + ); + if coeffs.is_empty() { + return G1Projective::zero(); + } + assert!( + coeffs.len() <= self.prover_srs().max_len(), + "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); + } + #[cfg(feature = "cuda")] + if coeffs.len() >= 1 << 12 { + if cuda::srs_residency::enabled() { + return cuda::srs_residency::msm_columns( + self.srs_owner(), + 0, + &[(coeffs, None)], + )[0]; + } + 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; + 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 = 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, + ) -> CommittedMatrix { + assert_eq!(matrix.height(), domain.size(), "matrix height != domain size"); + let width = matrix.width(); + 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 { + (0..width) + .into_par_iter() + .map(|column| { + generate_buffer(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); + } + } + #[cfg(feature = "cuda")] + let resident = std::sync::OnceLock::new(); + #[cfg(feature = "cuda")] + let interpolated = if domain.size() >= 1 << 10 { + let (columns, residents) = + cuda::interpolate_columns(inputs, domain.log_size, domain.shift.0); + let mut all_residents = vec![None; width]; + for (&i, resident) in active.iter().zip(residents) { + all_residents[i] = resident; + } + 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"))] + 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, + #[cfg(feature = "cuda")] + resident, + } + } + + fn interpolate_on_host( + domain: Radix2Coset, + columns: Vec>, + ) -> Vec> { + let ark = Self::ark_domain(domain); + columns + .into_par_iter() + .map(|mut col| { + ark.ifft_in_place(&mut col); + col + }) + .collect() + } +} + +impl Pcs for KzgPcs { + type F = Scalar; + type Challenge = Scalar; + type Domain = Radix2Coset; + type Challenger = KzgTranscript; + 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)| Self::interpolate_columns(domain, matrix)) + .collect(); + let commitment = KzgCommitment( + matrices.iter().map(|m| self.commit_matrix_columns(m)).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 = + 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 + // 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::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_matrix_columns(m)).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 KzgTranscript, + ) -> (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)); + } + } + // 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()]; + if !points.is_empty() { + let zs: Vec<_> = points.iter().map(|z| z.0).collect(); + let constants = matrix.constant_columns(); + #[cfg(feature = "cuda")] + let views = matrix.cuda_columns(); + #[cfg(feature = "cuda")] + let residents = views.as_ref().map(|_| matrix.resident_columns()); + for (column_index, column) in matrix.columns.iter().enumerate() { + 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); + } + continue; + } + #[cfg(feature = "cuda")] + if let Some(views) = &views + && views[column_index].0.len() >= 1 << 12 + { + let values = cuda::evaluate_resident( + views[column_index].0, + residents.and_then(|r| r[column_index].as_deref()), + &zs, + ); + for (row, value) in matrix_values.iter_mut().zip(values) { + row[column_index] = Scalar(value); + } + 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| { + for (&z, row) in zs.iter().zip(&mut matrix_values) { + row[column_index].0 += + eval_poly(chunk, z) * z.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.witness(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 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)); + } + } + for (commitment, matrices) in &rounds { + if commitment.0.len() != matrices.len() + || matrices.iter().any(|(domain, _)| { + domain.log_size > self.verifier.max_trace_len().ilog2() as usize + }) + { + return Err(KzgError::ShapeMismatch); + } + } + // 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 = PairingCurve::multi_pairing( + [lhs.into_affine(), (-rhs).into_affine()], + [self.verifier.g2, self.verifier.tau_g2], + ); + if check.is_zero() { Ok(()) } else { Err(KzgError::PairingCheckFailed) } + } +} + +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 { + #[cfg(feature = "cuda")] + if combined.len() >= 1 << 16 { + 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], + &combined, + z, + ) + }; + if let Some(witness) = witness { + return witness; + } + } + self.msm(÷_by_linear(combined, z)) + } +} + +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(); + } + #[cfg(feature = "cuda")] + 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; + 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 +} + +/// 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::*; + + #[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_wrong_magic_and_oversized_domains() { + let pcs = + 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]))]); + 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_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] + .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()); + 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(); + 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_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); + 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 { .. })) + ); + assert_eq!(compact.matrices[0].constant_columns(), [true; 3]); + 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(); + let dense = KzgProverData { + commitment: commitment.clone(), + matrices: vec![CommittedMatrix::new(domain, columns)], + }; + 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) + ); + } + let points = vec![vec![Scalar::from_u8(13), Scalar::from_u8(19)]]; + assert_eq!( + 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 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); + 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 KzgTranscript::default()); + let actual = + pcs.open(vec![(&disk, points.clone())], &mut KzgTranscript::default()); + pcs + .verify( + vec![( + resident.commitment.clone(), + vec![( + domain, + points[0].iter().copied().zip(actual.0[0][0].clone()).collect(), + )], + )], + &actual.1, + &mut KzgTranscript::default(), + ) + .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()); + } + + #[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_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(); + let expected = G1Projective::msm( + &pcs.prover_srs().g1[..coefficients.len()], + &coefficients, + ) + .unwrap(); + assert_eq!(pcs.msm(&coefficients), expected); + } + + #[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( + domains + .into_iter() + .map(|d| { + (d, RowMajorMatrix::new_col(vec![Scalar::from_u8(7); d.size()])) + }) + .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 KzgTranscript::default(), + ); + let rounds = vec![( + commitment, + domains + .into_iter() + .zip(&values[0]) + .map(|(d, v)| (d, vec![(z, v[0].clone())])) + .collect(), + )]; + let verifier = KzgPcs::verifier_only(pcs.verifier.clone(), 4); + assert!(verifier.srs.is_none()); + verifier + .verify(rounds.clone(), &proof, &mut KzgTranscript::default()) + .unwrap(); + let mut corrupt = rounds.clone(); + corrupt[0].1[0].1[0].1[0] += Scalar::ONE; + assert!( + verifier.verify(corrupt, &proof, &mut KzgTranscript::default()).is_err() + ); + let mut oversized = rounds; + oversized[0].1[0].0.log_size = 5; + assert!(matches!( + 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/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..3589ba186 --- /dev/null +++ b/crates/proof-compression/src/kzg/srs.rs @@ -0,0 +1,306 @@ +//! 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 declared public degree is independent of the locally loaded prefix. +//! [`Srs::unsafe_dev_setup`] reveals its trapdoor and is only for tests. + +use super::curve::{ + Fr, G1Affine, G1Projective, G2Affine, G2Projective, PairingCurve, +}; +use ark_ec::{ + AffineRepr, CurveGroup, PrimeGroup, VariableBaseMSM, pairing::Pairing, +}; +use ark_ff::{Field, PrimeField, Zero}; +use ark_serialize::CanonicalSerialize; +use p3_maybe_rayon::prelude::*; +use std::io::{self, Write}; + +pub mod gnark; + +const PUBLIC_SETUP_DOMAIN: &[u8] = + b"multi-stark/kzg/bn254/known-trapdoor-public-degree/v1"; +const DEV_TAU_DOMAIN: &[u8] = b"multi-stark/kzg/bn254/dev-srs"; +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)] +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(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() } + } +} + +/// The development trapdoor of `seed`. +pub(super) fn dev_tau(seed: &[u8]) -> Fr { + let mut wide = [0u8; 64]; + blake3::Hasher::new() + .update(DEV_TAU_DOMAIN) + .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, + pub public_setup: PublicSetup, +} + +impl 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, + ) -> io::Result { + super::dev_srs_cache::cached_in_directory( + max_len, + public_max_degree, + seed, + directory.as_ref(), + ) + } + + /// 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"); + } + let srs = Self { g1, g2, tau_g2, public_setup }; + srs.validate()?; + Ok(srs) + } + + /// Number of locally loaded powers, independent of the full public degree. + #[inline] + pub fn max_len(&self) -> usize { + self.g1.len() + } + + /// 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 + /// 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"); + } + 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 + // 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 { + self.0.update(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = HashWriter(blake3::Hasher::new()); + 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"); + 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); + 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. + 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 = PairingCurve::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()); + 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. 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 scalars: Vec<_> = (0..dst.len()) + .map(|_| { + let value = acc; + acc *= tau; + value + }) + .collect(); + 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(), + public_setup, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(DEV_TAU_DOMAIN) + .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 validation_covers_chunk_boundaries() { + let mut srs = Srs::unsafe_dev_setup(1 << 17, b"validation-chunks"); + 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; + } + } + + #[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/srs/gnark.rs b/crates/proof-compression/src/kzg/srs/gnark.rs new file mode 100644 index 000000000..d102f5ea7 --- /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 = u32::try_from(srs.g1.len()).unwrap().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 + 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 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); + 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()); + Ok(()) + } +} diff --git a/crates/proof-compression/src/kzg/transcript.rs b/crates/proof-compression/src/kzg/transcript.rs new file mode 100644 index 000000000..f1039fd26 --- /dev/null +++ b/crates/proof-compression/src/kzg/transcript.rs @@ -0,0 +1,373 @@ +//! 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 super::curve::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 { + 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); + } + + fn sample_challenge(&mut self) -> Scalar { + Scalar(self.squeeze()) + } +} + +const KECCAK256_DOMAIN: &[u8] = b"ix/kzg/bn254/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 { + Scalar(self.squeeze()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ff::BigInteger; + use multi_stark::traits::Field; + use num_bigint::BigUint; + + #[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] + 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()); + } + + 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 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 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 new file mode 100644 index 000000000..598bb75db --- /dev/null +++ b/crates/proof-compression/src/kzg/verifier_params.rs @@ -0,0 +1,175 @@ +//! Portable KZG anchors and the complete public degree allowance. +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; + +const VERSION: u64 = 0x424e0001; + +#[derive(Clone)] +pub struct VerifierParams { + max_trace_len: usize, + pub(crate) public_setup: PublicSetup, + pub(crate) g1: [G1Affine; 2], + pub(crate) g2: G2Affine, + pub(crate) tau_g2: G2Affine, +} + +fn valid_cap(cap: usize, setup: PublicSetup) -> bool { + cap >= 2 + && cap.is_power_of_two() + && cap.ilog2() as usize <= Scalar::TWO_ADICITY + && 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!(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_trace_len, + public_setup: srs.public_setup, + g1: [srs.g1[0], srs.g1[1]], + g2: srs.g2, + tau_g2: srs.tau_g2, + } + } + + 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![]; + 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)?; + for point in self.g1 { + point.serialize_compressed(&mut out)?; + } + self.g2.serialize_compressed(&mut out)?; + self.tau_g2.serialize_compressed(&mut out)?; + Ok(out) + } + + /// Validated point decoding and shape checks; provenance is authenticated separately. + pub fn from_bytes(mut input: &[u8]) -> Result { + if u64::deserialize_compressed(&mut input)? != VERSION { + return Err(SerializationError::InvalidData); + } + 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)?; + 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 = [ + 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)?; + if !input.is_empty() + || g1.iter().any(AffineRepr::is_zero) + || g2.is_zero() + || tau_g2.is_zero() + || !PairingCurve::multi_pairing([g1[1], -g1[0]], [g2, tau_g2]).is_zero() + { + return Err(SerializationError::InvalidData); + } + Ok(Self { max_trace_len, public_setup, g1, g2, tau_g2 }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_cap_respects_scalar_two_adicity() { + assert_eq!(Scalar::TWO_ADICITY, 28); + 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 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 rejects_malformed_parameters() { + let params = + VerifierParams::from_srs(&Srs::unsafe_dev_setup(64, b"verifier-params")); + let encoded = params.to_bytes().unwrap(); + 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.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]; + 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..2414725db --- /dev/null +++ b/crates/proof-compression/src/lib.rs @@ -0,0 +1,10 @@ +//! 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; +#[cfg(test)] +mod test_support; diff --git a/crates/proof-compression/src/pipeline/claims.rs b/crates/proof-compression/src/pipeline/claims.rs new file mode 100644 index 000000000..1c8732451 --- /dev/null +++ b/crates/proof-compression/src/pipeline/claims.rs @@ -0,0 +1,216 @@ +//! 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::{ + 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 ClaimTemplate { + pub(in crate::pipeline) template: Vec>, + pub(in crate::pipeline) locations: Vec<(usize, usize)>, +} + +impl ClaimTemplate { + 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 ClaimTemplate { + 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 ClaimTemplate { + 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 ClaimTemplate { + 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 ClaimTemplate { + pub(in crate::pipeline) fn recursive(count: usize) -> Self { + Self { + template: (0..=count) + .map(|i| { + vec![ + Scalar::from_u8(super::lowering::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::statement::Statement, +) -> Vec> { + let namespace = Scalar::from_u8(super::lowering::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/compress.rs b/crates/proof-compression/src/pipeline/compress.rs new file mode 100644 index 000000000..b6dba0ca9 --- /dev/null +++ b/crates/proof-compression/src/pipeline/compress.rs @@ -0,0 +1,724 @@ +//! Compression of a normalized aggregate root through FRI, first KZG, +//! 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. 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::{ + FinalOptions, FinalSystem, FixedRoot, Result, first, fixed_cache, groth16, + init_fri, plonk, root, + setup::{SetupClass, SetupConfig}, + stage, statement, storage, verifying_keys, 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"; +/// 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 format and stage + /// verifying key. + pub fixed_cache: PathBuf, + /// Compiled PLONK circuits, keys and parameter exports. + pub plonk_cache: PathBuf, + /// 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. + #[cfg(feature = "cuda")] + pub cuda: crate::kzg::cuda::Settings, +} + +impl CompressConfig { + /// 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_kzg: bool, + final_proof: FinalOptions, + ) -> Result { + final_proof.validate_setup(development_kzg)?; + let mut setup = SetupConfig::from_env(development_kzg)?; + 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"), + groth16_cache: cache_root.join("groth16"), + final_proof, + fresh: false, + 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 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(), + ); + }, + FinalSystem::Groth16 if !cfg!(feature = "groth16") => { + return Err( + "this ix was built without Groth16; rebuild with IX_GROTH16=1".into(), + ); + }, + FinalSystem::Groth16 => { + groth16::preflight(final_proof.allow_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(), + ); + } + Ok(()) + } +} + +/// 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. 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() { + 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); + if fresh { + continue; + } + 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()), + } + } + } + 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); + if !path.try_exists()? { + storage::write_atomic(&path, bytes)?; + } + } + Ok(()) +} + +/// Compress the normalized root in `input` to a recursive KZG proof, or with +/// 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. +pub fn compress( + input: CompressInput<'_>, + config: &CompressConfig, +) -> Result> { + let words = super::check_claim(input.claim, input.statement)?; + config.validate()?; + 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, config.fresh)?; + drop(files); + let _monitor = MemoryMonitor::start(); + 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) +} + +/// 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 root_vk = fs::read(root.join("root-vk.bin"))?; + let bundled = verifying_keys::bundled_for(&root_vk, &config.setup); + let fri = checkpoints.stage( + "fri", + &tracing::info_span!("compression/fri"), + |dir| root::run(&root, dir, true), + |dir| check_fri(dir, words, &root), + )?; + #[cfg(feature = "cuda")] + crate::kzg::cuda::configure(config.cuda.clone()); + let stage = StageConfig::from_config(config); + let first = checkpoints.stage( + "first-kzg", + &tracing::info_span!("compression/kzg"), + |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", + &tracing::info_span!("compression/wrap"), + |dir| wrap::run(&first, dir, &stage), + |dir| check_final(dir, words, &config.setup, &root_vk), + )?; + drop(stage); + 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); + }, + FinalSystem::Plonk => checkpoints.stage( + "plonk", + &tracing::info_span!("compression/plonk"), + |dir| prove_plonk(&final_dir, dir, words, config), + |dir| { + plonk::check_stage( + dir, + words, + &config.setup, + bundled, + &final_dir.join("vk.bin"), + final_proof.allow_development, + &config.plonk_cache, + ) + }, + )?, + FinalSystem::Groth16 => checkpoints.stage( + "groth16", + &tracing::info_span!("compression/groth16"), + |dir| prove_groth16(&final_dir, dir, words, config), + |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"))?) +} + +/// 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`. 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, + span: &tracing::Span, + prove: impl FnOnce(&Path) -> Result<()>, + check: impl Fn(&Path) -> Result<()>, + ) -> Result { + let dir = self.out.join(name); + if !self.rerun && dir.is_dir() { + match check(&dir) { + Ok(()) => { + eprintln!("compression {name}: reusing the verified stage"); + return Ok(dir); + }, + Err(error) => { + eprintln!("compression {name}: proving again: {error}"); + }, + } + } + 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}; earlier stages are kept for a \ + rerun" + ) + })?; + Ok(dir) + } +} + +/// 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(&stage::scalar_statement(&fixture.public))?; + if actual != *words { + return Err("FRI proof proves another 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, 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 bytes = fs::read(dir.join("vk.bin"))?; + verifying_keys::check_recursive_key(root_vk, setup, &bytes)?; + let vk = wrap::VerifyingKey::from_bytes(&bytes)?; + vk.check_setup_class(setup.class())?; + vk.verify(&fs::read(dir.join("kzg/packet.bin"))?, words)?; + verifying_keys::check_statement(&vk.id(), words) +} + +#[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, + config.final_proof.allow_development, + ) +} + +#[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.final_proof.allow_development, + &config.setup, + &config.groth16_cache, + ) +} + +#[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::*; + use crate::test_support::TempDir; + + 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 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. + fs::remove_file(out.join("root/root-claims.bin"))?; + 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"), false).is_err()); + fs::write(out.join("root").join(root::FIXED_ROOT_LAYOUT), b"{}")?; + 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.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()); + Ok(()) + } + + #[test] + fn fresh_output_replaces_stages_without_touching_unrelated_files() + -> Result<()> { + 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")?; + 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()); + } + Ok(()) + } + + fn checkpoints(out: &Path) -> Checkpoints<'_> { + Checkpoints { out, rerun: false } + } + + #[test] + fn stages_are_reused_while_their_output_checks() -> Result<()> { + 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"] { + 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))?; + assert!(proved.take().is_empty()); + 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))?; + assert_eq!(proved.take(), ["b", "c"]); + assert!(!out.join("b/stale").exists()); + // So is a missing stage. + fs::remove_dir_all(out.join("c"))?; + run(&mut checkpoints(&out))?; + assert_eq!(proved.take(), ["c"]); + // A stage whose fresh output fails its check is an error. + let failing = checkpoints(&out).stage( + "d", + &tracing::Span::none(), + |dir| Ok(fs::create_dir_all(dir)?), + |_dir| Err("unverifiable".into()), + ); + assert!(failing.is_err()); + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/derive.rs b/crates/proof-compression/src/pipeline/derive.rs new file mode 100644 index 000000000..8a1a08365 --- /dev/null +++ b/crates/proof-compression/src/pipeline/derive.rs @@ -0,0 +1,90 @@ +//! 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, 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`. +pub fn derive_recursive_vk( + root_vk: &[u8], + layout: &str, + setup: &SetupConfig, +) -> Result> { + 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, verifying_keys::FIRST_LOG_HEIGHT, setup, None)?; + drop(fri); + let vk = wrap::verifying_key(&manifest, parts, setup)?; + 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_eq!(setup.class(), super::super::SetupClass::Ignition); + 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()).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::final_description(&vk, &verifying_keys::STATEMENT_PREFIX).unwrap(); + assert_eq!( + Sha256::digest(&description).as_slice(), + verifying_keys::decode(verifying_keys::FINAL_SCHEMA_SHA256).unwrap() + ); + } + + #[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 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, + &ignition(), + &cache, + ) + .unwrap(); + eprintln!("derived PLONK key SHA-256 {:x}", Sha256::digest(&derived)); + assert!(derived == verifying_keys::PLONK_VK, "derived PLONK key differs"); + } +} diff --git a/crates/proof-compression/src/pipeline/development.rs b/crates/proof-compression/src/pipeline/development.rs new file mode 100644 index 000000000..5c4de94db --- /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/gnark/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/gnark/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/final_proof.rs b/crates/proof-compression/src/pipeline/final_proof.rs new file mode 100644 index 000000000..7edfdd9d2 --- /dev/null +++ b/crates/proof-compression/src/pipeline/final_proof.rs @@ -0,0 +1,199 @@ +//! Shared transport and artifact checks for final proof backends. +use super::Result; +#[cfg(any(feature = "plonk", feature = "groth16"))] +use super::statement; +use serde::{Deserialize, Serialize}; +#[cfg(any(feature = "plonk", feature = "groth16"))] +use sha2::{Digest, Sha256}; +use std::{fs, io::Read, path::Path}; + +// 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 { + #[serde(rename = "SHA256")] + pub sha256: String, + pub bytes: u64, +} + +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, +)] +#[serde(rename_all = "lowercase")] +#[repr(u8)] +pub enum FinalSystem { + #[default] + None = 0, + Plonk = 1, + Groth16 = 2, +} + +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 final proof system"), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FinalOptions { + pub system: FinalSystem, + pub allow_development: bool, +} + +impl From for FinalOptions { + fn from(system: FinalSystem) -> Self { + Self { system, allow_development: false } + } +} + +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)?)?; + vk.check_setup_class(setup.class())?; + super::wrap::final_description(&vk, &expected[..10]) +} + +#[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); + for byte in bytes { + write!(output, "{byte:02x}").unwrap(); + } + output +} + +pub(super) fn decode_hex(text: &str) -> Result> { + let text = text.strip_prefix("0x").unwrap_or(text); + if !text.is_ascii() || !text.len().is_multiple_of(2) { + return Err("invalid hexadecimal bytes".into()); + } + (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"))] +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 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("final input exceeds its size limit".into()); + } + Ok(bytes) +} + +pub(super) fn load_json( + path: &Path, +) -> Result { + Ok(serde_json::from_slice(&read_bounded(path, 1 << 20)?)?) +} + +#[cfg(any(feature = "plonk", feature = "groth16"))] +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 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/first.rs b/crates/proof-compression/src/pipeline/first.rs new file mode 100644 index 000000000..2f790620a --- /dev/null +++ b/crates/proof-compression/src/pipeline/first.rs @@ -0,0 +1,533 @@ +//! 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}; +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 multi_stark::types::Val; +use p3_field::PrimeCharacteristicRing; +use std::{fs, path::Path, time::Instant}; + +#[cfg(test)] +use super::init_fri::INIT_PUBLIC_WORDS; +use super::lowering::{self, FriConstruction, Lowered, max_height}; +use super::{ + fixed_cache::FixedCache, + prover::FixedSetup, + setup::{FIRST_SEED, SetupClass, SetupSource}, + storage, +}; +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::claims::first_claims as expected_claims; + +/// Translate, lower and prove the saved FRI proof in `fri`, writing the +/// first KZG stage to `out`. +pub(super) fn run( + fri: &Path, + out: &Path, + log_height: usize, + config: &super::StageConfig, +) -> 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::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: {:?}; {:?}", + 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 cache = + config.fixed_cache(out, &cache_identity(&plan_id, &setup, height)?); + 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)?; + eprintln!("Generating fixed and witness traces directly into commitment"); + prove(out, lowered, &cache, config)?; + eprintln!("RUN COMPLETE: {:?}", start.elapsed()); + 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 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. 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::FriVerifier, + log_height: usize, + config: &super::SetupConfig, + cache: Option<&Path>, +) -> storage::Result<( + storage::Manifest, + Vec, + Option<([u8; 32], super::fixed_cache::Prepared)>, +)> { + let plan = VerifierPlan::validate( + &fri.key, + 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) = lowering::first_circuit(circuit, 1 << log_height)?; + let height = max_height(&compiled)?; + let setup = config.for_stage(FIRST_SEED, height)?; + let public = super::stage::scalar_statement(&[Val::ZERO; 18]); + let manifest = storage::Manifest::for_compiled( + &compiled, + compiled.claims(&public)?, + None, + )?; + 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 +/// together, verify the proof and write it with its packet. Fixed +/// preprocessing comes from the cache when it holds this verifying key and is +/// published to it after the proof verifies. +fn prove( + dir: &Path, + lowered: Lowered, + cache: &FixedCache, + config: &super::StageConfig, +) -> 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.min(Scalar::TWO_ADICITY - 1) + || 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 = 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(&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 + // 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 { + instance.update(&fs::read(out.join(format!("setup-{i}.bin")))?); + } + 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("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(); + let evidence = serde_json::json!({ + "curve": crate::kzg::curve::NAME, + "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)?; + eprintln!( + "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 crate::test_support::TempDir; + use multi_stark::plonkish::gadgets::ByteGadgets; + use multi_stark::plonkish::gadgets::blake3; + use multi_stark::plonkish::{CircuitBuilder, Value}; + + const OPTIONS: super::super::StageConfig = super::super::StageConfig { + 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", "instance-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_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 `run` 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_identity(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 = 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 }); + 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"))?; + 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!(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()); + } + 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/setup-{i}.bin")).exists()); + assert!(!warm.join(format!("kzg/fixed-{i}.bin")).exists()); + } + prove(&warm, &cache, 9)?; + assert_eq!(first, artifacts(&warm)?); + 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. + 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 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::>>()?; + let [entry] = entries.as_slice() else { + panic!("expected one published entry, found {entries:?}"); + }; + 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); + 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()); + 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..f40a97b97 --- /dev/null +++ b/crates/proof-compression/src/pipeline/fixed_cache.rs @@ -0,0 +1,300 @@ +//! Trusted local preprocessing, independent of each proof's witness and +//! 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`. +//! 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}, + io, + path::{Path, PathBuf}, +}; + +#[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, +} + +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, +} + +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 { + /// 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/v2"); + hasher.update(vk); + let identity = *hasher.finalize().as_bytes(); + 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 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()? { + 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()); + } + } + 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 })); + } + 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. + /// 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 remove_staged_fixed(staged); + } + 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); + if is_setup(&name) { + fs::copy(&source, &published)?; + } else { + move_file(&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 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::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/gnark.rs b/crates/proof-compression/src/pipeline/gnark.rs new file mode 100644 index 000000000..e0f57eea7 --- /dev/null +++ b/crates/proof-compression/src/pipeline/gnark.rs @@ -0,0 +1,453 @@ +//! Owned gnark objects behind the gnark archive's C ABI. +use super::Result; +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, + non_snake_case, + non_upper_case_globals, + dead_code, + unreachable_pub +)] +mod raw { + include!(concat!(env!("OUT_DIR"), "/gnark_bindings.rs")); +} + +pub use super::final_proof::Artifact; + +#[cfg(feature = "plonk")] +#[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, +} + +#[cfg(feature = "plonk")] +#[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) } + } +} + +#[cfg(feature = "plonk")] +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())?) +} + +#[cfg(feature = "plonk")] +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, +} + +#[cfg(feature = "plonk")] +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 { + #[cfg(feature = "plonk")] + 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(); + } +} + +#[cfg(feature = "plonk")] +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::final_proof::{decode_hex, digest}; + const KEY: &[u8] = + 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/plonk/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::test_support::TempDir::new("gnark-compile"); + let schema = + 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); + assert_eq!(info.public_inputs, 2); + let digest = sha2::Sha256::digest(std::fs::read(&path)?); + assert_eq!( + crate::pipeline::final_proof::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::final_proof::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, + library: String, + }, +} + +#[cfg(feature = "groth16")] +impl Prover { + pub(super) fn open_groth16( + directory: &Path, + metadata: &super::groth16::Metadata, + backend: &Groth16Backend, + ) -> Result { + 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, + 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 new file mode 100644 index 000000000..57960f241 --- /dev/null +++ b/crates/proof-compression/src/pipeline/groth16.rs @@ -0,0 +1,642 @@ +//! Final BN254 Groth16 proofs under the release key. +#[cfg(feature = "groth16")] +use super::final_proof::{hex, sha256}; +#[cfg(feature = "groth16")] +use super::gnark; +#[cfg(feature = "groth16")] +use super::storage; +use super::{ + Result, + final_proof::{Artifact, digest, load_json}, + setup::SetupClass, + statement, verifying_keys, +}; +#[cfg(feature = "groth16")] +use super::{ + final_proof::{claim_digest, read_bounded}, + setup::SetupConfig, + wrap, +}; +#[cfg(feature = "groth16")] +use std::fs; +#[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 ARTIFACTS_VAR: &str = "IX_GROTH16_ARTIFACTS"; +const MAX_PROOF_BYTES: usize = 196; + +fn check_length(packet: &[u8]) -> Result<()> { + if packet.len() != 164 + 32 && packet.len() != MAX_PROOF_BYTES + 32 { + return Err("invalid Groth16 packet length".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 { + 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()); + } + let setup = verifying_keys::groth16_setup(allow_development)?; + verify_under(verifying_keys::GROTH16_VK, packet, claim)?; + 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")] +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"))] +pub(super) fn verify( + packet: &[u8], + _claim: &[u8], + _allow_development: bool, +) -> Result { + check_length(packet)?; + Err("Groth16 proofs are verified by gnark, and this ix was built without Groth16; rebuild with IX_GROTH16=1".into()) +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(super) struct Metadata { + #[serde(rename = "innerVKSHA256")] + inner_vk: String, + constraints: usize, + domain: u64, + public_inputs: usize, + commitments: usize, + setup: String, + #[serde( + default, + rename = "ceremonySHA256", + skip_serializing_if = "Option::is_none" + )] + ceremony: Option, + keys_ready: bool, + files: std::collections::BTreeMap, +} + +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 + .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) + } +} + +/// 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") + })?; + let m: Metadata = load_json(&dir.join("manifest.json"))?; + m.validate()?; + if !m.keys_ready { + return Err("Groth16 keys are not prepared".into()); + } + if m.artifact("verifying-key.bin")?.sha256 + != verifying_keys::GROTH16_VK_SHA256 + || m.inner_vk != verifying_keys::FINAL_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 release circuit and key".into(), + ); + } + verifying_keys::groth16_setup(allow_development)?; + Ok(dir) +} + +pub(super) fn preflight(allow_development: bool) -> Result<()> { + match std::env::var("IX_GROTH16_BACKEND").as_deref() { + Err(std::env::VarError::NotPresent) | Ok("" | "cpu" | "cuda") => {}, + _ => return Err("IX_GROTH16_BACKEND must be cpu or cuda".into()), + } + if !allow_development { + prepared(false)?; + } + Ok(()) +} + +/// 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(), + ); + } + Ok(()) +} + +/// 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, + packet: &Path, + trusted: &str, + expected: &statement::Statement, + out: &Path, + allow_development: bool, + setup: &SetupConfig, + cache_root: &Path, +) -> Result<()> { + statement::from_claims_bytes(&statement::to_claims_bytes(expected))?; + 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::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 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 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(()) +} + +#[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")] +pub fn compile(schema: &Path, out: &Path) -> Result<()> { + let schema = read_bounded(schema, 16 << 20)?; + 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))?); + } + 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 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")] +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"))?; + 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 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, 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] + #[cfg(feature = "groth16")] + fn saved_init_groth16_proof_verifies_without_gpu() -> Result<()> { + 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)?; + 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(), + provenance["claim_digest"].as_str().unwrap() + ); + let mut packet = id.to_vec(); + packet.extend(bytes); + 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!(verify_under(&wrong_key, &packet, claim).is_err()); + packet[32] = 0; + 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) + .unwrap_err() + .to_string() + .contains("packet length") + ); + } + } +} + +#[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 = crate::test_support::TempDir::new("groth16-compile"); + let schema = + 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" + ))?; + 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/gnark/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/init_fri.rs b/crates/proof-compression/src/pipeline/init_fri.rs new file mode 100644 index 000000000..71c48a18b --- /dev/null +++ b/crates/proof-compression/src/pipeline/init_fri.rs @@ -0,0 +1,94 @@ +//! The saved intermediate FRI proof and the statement it exposes. +use super::claims::{ClaimTemplate, decode_claims}; +use crate::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}; + +/// 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, + 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 = 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::lowering::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:?}"))?; + eprintln!("Saved {}-byte recursive FRI proof verified", bytes.len()); + 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()); + } + 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, 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/lowering.rs b/crates/proof-compression/src/pipeline/lowering.rs new file mode 100644 index 000000000..7229758d6 --- /dev/null +++ b/crates/proof-compression/src/pipeline/lowering.rs @@ -0,0 +1,245 @@ +//! Production circuit layouts and ownership of lowered witnesses. +use super::{Result, stage::Prepared}; +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::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()?) +} + +/// 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, +) -> Result { + let (compiled, inputs) = first_circuit(prepared.circuit, height)?; + let assignment = + assign_foreign(&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/mod.rs b/crates/proof-compression/src/pipeline/mod.rs new file mode 100644 index 000000000..b036b87da --- /dev/null +++ b/crates/proof-compression/src/pipeline/mod.rs @@ -0,0 +1,228 @@ +//! Compression and verification of aggregate root proofs. +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; +mod derive; +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"))] +pub mod gnark; +pub mod groth16; +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; +pub use verifying_keys::release_root_layout; +mod setup; +pub use setup::{SetupClass, SetupConfig}; +mod statement; +mod storage; +mod wrap; + +use std::{fs, path::Path}; + +pub const PROOF_TAG: &[u8; 8] = b"IXBN\0\0\0\x01"; +pub type Result = std::result::Result>; + +/// 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) +} + +/// 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. +/// Proving parameters are not required. +pub fn verify( + proof: &[u8], + claim: &[u8], + allow_development: bool, +) -> 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); + } + if let Some(packet) = proof.strip_prefix(PROOF_TAG) { + return verifying_keys::verify_recursive(packet, claim); + } + Err("unsupported compressed proof version".into()) +} + +/// 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_vk( + proof: &[u8], + claim: &[u8], + expected_claims: &[u8], + 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")?; + verifying_keys::verify_with_vk( + packet, + claim, + &words, + vk, + trusted_vk_digest, + allow_development, + ) +} + +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(()) +} + +/// Write the root input directory 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) +} + +/// 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, + vk: &[u8], + proof: &[u8], + words: &statement::Statement, + fixed_root: Option<&str>, +) -> Result<()> { + fs::create_dir_all(dir)?; + for (name, bytes) in compress::root_files(vk, proof, words, fixed_root)? { + storage::write_atomic(&dir.join(name), &bytes)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_vk_verification_rejects_other_proof_tags() { + let expected = + statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); + 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")); + } + + #[test] + 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("other than the")); + } + } + + #[test] + fn invalid_requests_are_rejected_before_io() { + let statement = + statement::to_claims_bytes(&statement::with_claim(&[0; 10], b"claim")); + let config = |final_proof: FinalSystem| CompressConfig { + setup: SetupConfig::Development { cache_directory: None }, + fixed_cache: "/does-not-exist".into(), + plonk_cache: "/does-not-exist".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(), + }; + let input = |claim| CompressInput { + vk: b"", + proof: b"", + claim, + statement: &statement, + fixed_root: None, + out: Path::new("/does-not-exist"), + }; + let error = |claim, final_proof| { + compress(input(claim), &config(final_proof)).unwrap_err().to_string() + }; + 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 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(); + 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 new file mode 100644 index 000000000..bc438ac8b --- /dev/null +++ b/crates/proof-compression/src/pipeline/plonk.rs @@ -0,0 +1,667 @@ +//! Final PLONK proofs under the release key, and authenticated parameter +//! interchange. +#[cfg(feature = "plonk")] +use super::final_proof::{claim_digest, sha256}; +#[cfg(feature = "plonk")] +use super::final_proof::{digest, hex, load_json}; +use super::setup::SetupConfig; +use super::statement; +#[cfg(feature = "plonk")] +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(feature = "plonk")] +use ark_ff::{BigInteger, PrimeField}; +#[cfg(feature = "plonk")] +use sha2::{Digest, Sha256}; +#[cfg(feature = "plonk")] +use std::fs; +#[cfg(feature = "plonk")] +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_identity(config: &SetupConfig) -> [u8; 32] { + match config { + 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 { + config.for_stage(FINAL_SRS_SEED, parameter_prefix(domain)?)?.srs(2, true) +} + +#[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 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")] +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] { + bytes.extend(value.into_bigint().to_bytes_be()); + } + bytes +} + +#[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] { + bytes.extend(value.into_bigint().to_bytes_be()); + } + bytes.extend(encode_g2(srs.g2)); + bytes.extend(encode_g2(srs.tau_g2)); + bytes +} + +#[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()) +} + +/// 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 { + if packet.len() <= 32 || packet.len() > MAX_PROOF_BYTES + 32 { + return Err("invalid PLONK packet length".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 anchors = anchor_encoding(&verifying_keys::ignition_anchors()?); + gnark::verify( + verifying_keys::PLONK_VK, + &id, + &packet[32..], + blake3::hash(claim).as_bytes(), + &anchors, + )?; + Ok(SetupClass::Ignition) +} + +/// 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 the released parameters or explicitly selected development setup. +#[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::final_export(verifier, packet, trusted_digest, expected, out, setup) +} + +/// 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, + 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 = 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() { + fs::create_dir_all(parent)?; + } + let mut out = std::io::BufWriter::new(fs::File::create(&temporary)?); + 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)?; + out.write_all(&(count as u64).to_le_bytes())?; + 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())?; + } + 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-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, + "setupClass": config.class().label(), + "setupIdentity": blake3::Hash::from_bytes(manifest).to_hex().as_str(), + }); + storage::write_atomic( + &path.with_extension("json"), + &serde_json::to_vec_pretty(&receipt)?, + )?; + Ok(()) +} + +#[cfg(feature = "plonk")] +#[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, + setup_class: String, + setup_identity: String, +} + +#[cfg(feature = "plonk")] +fn cached_parameters( + domain: usize, + root: &Path, + config: &SetupConfig, +) -> Result<(PathBuf, String)> { + let manifest = setup_identity(config); + let anchors = anchors(config, domain)?; + let mut identity = blake3::Hasher::new(); + 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()); + 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-bn254-plonk-srs-export-v2" + || receipt.setup_class != config.class().label() + || digest(&receipt.setup_id)? != anchors.public_setup.id + || 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)? { + return Err("cached PLONK parameter export is damaged".into()); + } + Ok((path, receipt.blake3)) +} + +#[cfg(feature = "plonk")] +fn artifact(path: &Path) -> Result { + Ok(gnark::Artifact { + sha256: hex(&sha256(path)?), + bytes: fs::metadata(path)?.len(), + }) +} + +#[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 { + 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")] +fn prepare_artifacts( + schema: &[u8], + setup: &SetupConfig, + cache_root: &Path, +) -> Result<(PathBuf, gnark::Manifest)> { + let schema_hash = hex(&Sha256::digest(schema)); + // 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/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_identity(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_identity(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, + 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)) +} + +/// 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 +/// the selected setup's 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_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 + .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 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( + verifier: &Path, + packet: &Path, + trusted_digest: &str, + expected: &statement::Statement, + 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)?; + 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::final_export( + verifier, + packet, + trusted_digest, + expected, + &input, + setup, + )?; + 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(); + 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"); + let key_hash = &metadata + .files + .get("verifying-key.bin") + .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( + &key_bytes, + &key_id, + &proof.native, + &expected_digest, + &anchors, + )?; + 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.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"), + &fs::read(cache.join("PlonkVerifier.sol"))?, + )?; + 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" },"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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "plonk")] + fn development_parameters_need_no_ignition_and_bind_the_cache() -> Result<()> + { + let temporary = crate::test_support::TempDir::new("plonk-parameters"); + let setup = SetupConfig::Development { cache_directory: None }; + 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)); + 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"); + 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/prepare.rs b/crates/proof-compression/src/pipeline/prepare.rs new file mode 100644 index 000000000..b98128191 --- /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, + claims::first_claims, + first, + fixed_cache::{FixedCache, Prepared}, + prover::FixedSetup, + root, + setup::IGNITION_SRS_VAR, + 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 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)?; + 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()); + Ok(()) + } +} diff --git a/crates/proof-compression/src/pipeline/prover.rs b/crates/proof-compression/src/pipeline/prover.rs new file mode 100644 index 000000000..bed2d6ffa --- /dev/null +++ b/crates/proof-compression/src/pipeline/prover.rs @@ -0,0 +1,513 @@ +//! 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::{BufReader, 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(), + }) + } +} + +/// 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 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() +} + +/// 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, + parts: Vec, +) -> Result> { + let count = parts.len(); + let mut circuits = Vec::with_capacity(count); + let mut fixed = KzgCommitment(vec![]); + 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, + circuits, + preprocessed_commit: Some(fixed), + preprocessed_indices: (0..count).map(Some).collect(), + }) +} + +/// 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, + 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)?; + if !cfg!(feature = "cuda") { + drop(data); + data = read_checkpoint(&fixed)?; + } + storage::save( + &meta, + &(&circuit, local.preprocessed_commit.ok_or("missing fixed commitment")?), + )?; + } + Ok((circuit, data)) +} + +/// CUDA needs host coefficients; CPU proving can stream them from disk. +fn read_checkpoint(path: &Path) -> Result { + Ok(if cfg!(feature = "cuda") { + KzgProverData::read_checkpoint(BufReader::with_capacity( + 1 << 20, + File::open(path)?, + ))? + } else { + 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)?; + // 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) + .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"] + { + 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 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), + (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:?}"))?; + } + } + Ok(compiled.num_circuits()) + } +} diff --git a/crates/proof-compression/src/pipeline/root.rs b/crates/proof-compression/src/pipeline/root.rs new file mode 100644 index 000000000..82058730f --- /dev/null +++ b/crates/proof-compression/src/pipeline/root.rs @@ -0,0 +1,401 @@ +//! 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::{PrimeField64, TwoAdicField}; +use p3_symmetric::CryptographicHasher; +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: &AiurVerifyingKey, + layout: &FixedRoot, +) -> Result { + FriVerifier::derive( + layout.circuit(key, VerifierLimits::default())?, + config(key), + MAX_TRACE_HEIGHT, + ) +} + +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<()> { + 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 = 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:?}"))?; + eprintln!( + "Native Ix root batch verified in {:?}: {} bytes, {} circuits, {} shards", + start.elapsed(), + bytes.len(), + system.circuits.len(), + proof.proofs.len() + ); + eprintln!( + "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 + ); + 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() + ); + 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 + // 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 = if layout_path.exists() { + 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 = 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 layout = + prepared.circuit().multi_stark_layout_with_max_height(MAX_TRACE_HEIGHT)?; + eprintln!( + "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() + .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; + 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 !prove { + return Ok(()); + } + // 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")?; + eprintln!( + "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(), + ); + } + drop(proof); + drop(verifier_key); + let public = prepared.public_values().to_vec(); + let start = Instant::now(); + 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()?; + 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( + out.join("outer-claims.bin"), + encode_claims(&verifier.statement.claims(&public)?), + )?; + fs::write( + out.join("statement-values.bin"), + bincode::serde::encode_to_vec(&public, bincode::config::standard())?, + )?; + 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/root/fixed.rs b/crates/proof-compression/src/pipeline/root/fixed.rs new file mode 100644 index 000000000..1aa565a9e --- /dev/null +++ b/crates/proof-compression/src/pipeline/root/fixed.rs @@ -0,0 +1,537 @@ +//! 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}; + +const LAYOUT_FORMAT: &str = "ix-fixed-root-layout-v1"; + +#[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 }) + } + + /// 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, + 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::FriVerifier; + 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()); + } + 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 = 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 = + 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, + &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 = 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)]; + 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/setup.rs b/crates/proof-compression/src/pipeline/setup.rs new file mode 100644 index 000000000..cf6c997df --- /dev/null +++ b/crates/proof-compression/src/pipeline/setup.rs @@ -0,0 +1,448 @@ +//! 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::OsString; +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 IGNITION_SRS_VAR: &str = "IX_KZG_SRS"; +pub(crate) const PARAMETERS_DIR_VAR: &str = "IX_PARAMS_DIR"; +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( + 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)] +pub enum SetupClass { + /// Ignition parameters matching the released verifying key. + 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 { + /// 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 { + match self { + Self::Ignition => "ignition", + Self::Groth16 => "groth16", + Self::Development => "development", + } + } + + pub fn description(self) -> &'static str { + match self { + 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)" + }, + } + } +} + +fn development_refused() -> storage::Result { + Err( + format!( + "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)" + ) + .into(), + ) +} + +#[derive(Clone)] +pub enum SetupConfig { + #[non_exhaustive] + Ignition { srs: PathBuf }, + #[non_exhaustive] + Development { cache_directory: Option }, +} + +impl SetupConfig { + /// 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)) + } + + fn from_ignition_env( + development: bool, + mut lookup: impl FnMut(&str) -> Option, + ) -> storage::Result { + if development { + return Ok(Self::Development { + cache_directory: lookup("IX_KZG_SRS_CACHE").map(PathBuf::from), + }); + } + 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 config = Self::Ignition { srs }; + config.for_stage(FIRST_SEED, 2)?.srs(2, false)?; + Ok(config) + } + + pub(crate) fn class(&self) -> SetupClass { + match self { + Self::Ignition { .. } => SetupClass::Ignition, + Self::Development { .. } => SetupClass::Development, + } + } + + pub(crate) fn for_stage( + &self, + seed: &[u8], + height: usize, + ) -> storage::Result { + 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 { + Self::Ignition { srs } => { + let source = SetupSource::ignition(srs.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 { + source = source.with_cache_directory(directory.clone()); + } + Ok(source) + }, + } + } +} + +#[derive(Clone)] +enum Policy { + Development { seed: Vec, cache_directory: Option }, + Ignition { srs: PathBuf }, +} + +#[derive(Clone)] +pub(crate) struct SetupSource { + policy: Policy, + public_setup: PublicSetup, + transcript_kind: TranscriptKind, +} + +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, + }) + } + + 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, + }) + } + + /// 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 { + match &mut self.policy { + Policy::Development { cache_directory, .. } => { + *cache_directory = Some(directory); + }, + Policy::Ignition { .. } => {}, + } + self + } + + pub(crate) fn class(&self) -> SetupClass { + match self.policy { + Policy::Development { .. } => SetupClass::Development, + Policy::Ignition { .. } => SetupClass::Ignition, + } + } + + pub(crate) fn security_description(&self) -> &'static str { + match self.policy { + Policy::Ignition { .. } => { + "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" + }, + } + } + + fn check_height(&self, height: usize) -> storage::Result<()> { + if height < 2 + || !height.is_power_of_two() + || 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()); + } + 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::Ignition { .. } if verify_only => { + verifying_keys::ignition_anchors() + }, + 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 } => { + 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 { + 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) + } 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 verifying keys. + 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::test_support::TempDir; + + #[test] + fn setup_binding_preserves_public_identity_and_rejects_mismatch() + -> storage::Result<()> { + 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)?; + 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()); + Ok(()) + } + + #[test] + fn cache_directory_yields_the_generated_parameters() -> storage::Result<()> { + 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")); + 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() + ); + 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(test)] +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..4793d49a9 --- /dev/null +++ b/crates/proof-compression/src/pipeline/setup/ignition_tests.rs @@ -0,0 +1,74 @@ +use super::*; + +#[test] +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 source = SetupSource::ignition(PathBuf::from("absent-srs.bin"))?; + assert_eq!(source.class(), SetupClass::Ignition); + assert_eq!(source.srs(8, true)?.g1, release.g1); + assert!(source.srs(8, false).is_err()); + Ok(()) +} + +#[test] +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!( + a.identity(8, 2)?, + a.clone().with_transcript(TranscriptKind::Keccak256).identity(8, 2)? + ); + Ok(()) +} + +#[test] +fn missing_release_requires_explicit_development() -> storage::Result<()> { + let error = SetupConfig::from_ignition_env(false, |_| None).err().unwrap(); + 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()) + })?; + assert_eq!(config.class(), SetupClass::Development); + assert_eq!( + config.for_stage(FIRST_SEED, 8)?.identity(8, 2)?, + SetupSource::development(FIRST_SEED, 7)?.identity(8, 2)? + ); + Ok(()) +} + +#[test] +#[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!( + SetupClass::of(&VerifierParams::from_srs(&srs)), + SetupClass::Ignition + ); + assert_eq!( + source.config(1024, 2, true)?.transcript_seed(), + source.config(1024, 2, false)?.transcript_seed() + ); + Ok(()) +} diff --git a/crates/proof-compression/src/pipeline/stage.rs b/crates/proof-compression/src/pipeline/stage.rs new file mode 100644 index 000000000..f30756811 --- /dev/null +++ b/crates/proof-compression/src/pipeline/stage.rs @@ -0,0 +1,454 @@ +//! 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. +//! 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; + +use crate::fri::{self, VerifierKey}; +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, 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. +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: ClaimTemplate, +} + +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 = 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 = lowering::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 selected KZG 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 = 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 = + lowering::FriConstruction::build(&plan, verifier.statement.schema())? + .assign(&plan, proof, claims)?; + let foreign = lowering::translate(&source.circuit); + drop(source.circuit); + drop(plan); + let assignment = lowering::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 { + 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: ClaimTemplate, + ) -> 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(), + ); + } + if max_height.ilog2() as usize + 1 > Scalar::TWO_ADICITY { + return Err("quotient domain exceeds scalar field two-adicity".into()); + } + 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()); + } + let config = KzgConfig::with_max_trace_len(srs, height, 2) + .with_streaming_lookups() + .with_streaming_quotient(); + prove_lowered( + lowering::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() +} + +pub(in crate::pipeline) fn prove_lowered( + lowered: lowering::Lowered, + config: KzgConfig, +) -> Result<(KzgVerifier, Proof)> { + use multi_stark::config::ProofConfig; + let statement = ClaimTemplate::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)) +} + +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/stage/tests.rs b/crates/proof-compression/src/pipeline/stage/tests.rs new file mode 100644 index 000000000..21c6f8554 --- /dev/null +++ b/crates/proof-compression/src/pipeline/stage/tests.rs @@ -0,0 +1,244 @@ +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 = || fri_config(cap, arity, final_log, queries); + if cfg!(feature = "cuda") && (cap != 0 || arity > 1) { + assert!(std::panic::catch_unwind(config).is_err()); + } else { + 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 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)?; + 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<()> { + // 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(lowering::FRI_NAMESPACE), + 32, + )?; + let claims = compiled.claims(prepared.assignment.public_values())?; + let (system, key) = + 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::>(), + SystemWitness::from_stage_1( + compiled.traces(&prepared.assignment)?, + &system, + ), + ); + 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()) + ); + 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 = + 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 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() + ); + 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(()) +} + +#[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!(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!( + verifier.system.circuits.iter().all(|c| c.preprocessed_height <= 128) + ); + 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/statement.rs b/crates/proof-compression/src/pipeline/statement.rs new file mode 100644 index 000000000..3135a3eda --- /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::claims::ClaimTemplate::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..dfca14089 --- /dev/null +++ b/crates/proof-compression/src/pipeline/storage.rs @@ -0,0 +1,185 @@ +//! 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) +} + +/// 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)?); + 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) +} + +#[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 = 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()) + }); + 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 new file mode 100644 index 000000000..26e68a57d --- /dev/null +++ b/crates/proof-compression/src/pipeline/verifying_keys.rs @@ -0,0 +1,252 @@ +//! 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, + statement, + wrap::{PreparedVerifyingKey, VerifyingKey}, +}; +use crate::kzg::{Srs, VerifierParams}; + +/// 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"; +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, +]; + +pub(super) 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"; + +/// 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"); +/// 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 final +/// circuits prove. +pub(super) const FINAL_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; use --dev-final-srs for compression or --dev-srs for verification (not production-secure)".into()); + } + Ok(SetupClass::Development) +} + +/// 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 +/// 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 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. +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, 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_setup_class(SetupClass::Ignition)?; + Ok(prepared) +} + +/// 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) +} + +/// 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 { + 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 { + 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() { + recursive_vk().unwrap(); + 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).unwrap() + ); + 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).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 new file mode 100644 index 000000000..3aea1fa9b --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/mod.rs @@ -0,0 +1,38 @@ +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. +pub(super) fn run( + first: &std::path::Path, + out: &std::path::Path, + config: &super::StageConfig, +) -> Result<(), Box> { + std::fs::create_dir_all(out)?; + saved::prove(first, out, config) +} + +/// 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, 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 new file mode 100644 index 000000000..66ff21885 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer.rs @@ -0,0 +1,1375 @@ +//! 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 final_export; +#[cfg(any(feature = "plonk", feature = "groth16"))] +mod relation; +#[cfg(any(feature = "plonk", feature = "groth16"))] +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::recursive::{self, RecursiveVerifier}; +use super::{ + super::Result as PipelineResult, + super::{ + claims::ClaimTemplate, + fixed_cache, + lowering::{self, Lowered}, + prover, + setup::{SetupClass, SetupSource}, + statement::{self, Statement}, + storage, + }, +}; +use crate::{ + kzg::{ + KzgCommitment, KzgConfig, Scalar, TranscriptKind, + compact::FixedProofCodec, + curve::{G1_COMPRESSED_BYTES, G2_COMPRESSED_BYTES, G2Affine}, + }, + recursive::{native_verifier::Built, pairing::PUBLIC_VALUES}, +}; +use multi_stark::{ + plonkish::{Assignment, MultiStarkCircuit}, + traits::{Field, TwoAdicField}, +}; +use std::{fs, path::Path}; + +pub(super) const OUTER_SEED: &[u8] = b"init-kzg-recursive-bn254-v1"; +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 verifying key covers exactly its own height. +pub(super) const MAX_TRACE_LEN: usize = lowering::RECURSIVE_HEIGHT; + +#[derive(serde::Serialize, serde::Deserialize)] +struct Pairings { + keys: Vec, +} + +pub(crate) fn stage( + built: Built, + assignment: Assignment, + dir: &Path, + vk: &[u8], + statement: &Statement, + config: &super::super::StageConfig, +) -> storage::Result<()> { + 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 verifying key. +pub(crate) struct Frontend { + compiled: MultiStarkCircuit, + pairings: Pairings, + vk: Vec, +} + +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 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 the released Ignition parameters".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)?; + 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) + .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) + } + + pub(crate) fn new( + circuit: multi_stark::plonkish::Circuit, + keys: &[G2Affine], + vk: &[u8], + max_height: usize, + ) -> storage::Result { + use ark_serialize::CanonicalSerialize; + if keys.len() != 2 { + return Err("invalid recursive pairing keys".into()); + } + let mut encoded = vec![]; + keys.serialize_compressed(&mut encoded)?; + let compiled = lowering::lower_recursive(circuit, max_height)?; + 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 + /// verifying key and is published to it after the proof verifies. + pub(crate) fn stage( + self, + assignment: Assignment, + dir: &Path, + statement: &Statement, + config: &super::super::StageConfig, + ) -> storage::Result<()> { + if assignment.public_values().len() != RECURSIVE_PUBLIC_VALUES + || 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 = self.setup(&config.setup)?; + fs::create_dir_all(dir)?; + setup.bind_stage(dir, height, 2)?; + storage::save(&dir.join("pairings.bin"), &self.pairings)?; + 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, + 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(()) + } +} + +/// 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) +} + +fn decode_pairing_keys(vk: &Pairings) -> storage::Result> { + use ark_ec::AffineRepr; + use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; + if vk.keys.len() != 8 + 2 * G2_COMPRESSED_BYTES + || vk.keys.get(..8) != Some(&2u64.to_le_bytes()) + { + return Err("invalid pairing keys".into()); + } + 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 keys".into()); + } + let mut canonical = Vec::new(); + keys.serialize_compressed(&mut canonical)?; + if canonical != vk.keys { + return Err("noncanonical pairing keys".into()); + } + Ok(keys) +} + +fn pairing_bytes(publics: &[Scalar]) -> storage::Result> { + recursive::pack_pairings(publics) +} +fn check_pairings(bytes: &[u8], keys: &[G2Affine]) -> storage::Result<()> { + recursive::Pairings { + keys: keys.try_into().map_err(|_keys| "pairing point count")?, + } + .verify_bytes(bytes) +} +fn packet_body<'a>( + packet: &'a [u8], + vk: &[u8; 32], + expected: &Statement, +) -> storage::Result<&'a [u8]> { + let offset = 32 + statement::WORDS * 8; + 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) + { + if u64::from_le_bytes(*bytes) != word { + return Err("packet statement differs from the claim's".into()); + } + } + Ok(&packet[offset..]) +} + +#[cfg(any(test, feature = "plonk", feature = "groth16"))] +fn packet_claims( + packet: &[u8], + 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, vk, expected)?; + let mut public: Vec<_> = + expected.iter().copied().map(Scalar::from_u64).collect(); + 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( + system: &multi_stark::system::System, + codec: &FixedProofCodec, + packet: &[u8], + vk: &[u8; 32], + keys: &[G2Affine], + expected: &Statement, +) -> storage::Result<()> { + let body = packet_body(packet, vk, expected)?; + let pairings = recursive::Pairings { + keys: keys.try_into().map_err(|_keys| "pairing point count")?, + }; + recursive::verify_recursive_bytes( + system, + codec, + &ClaimTemplate::recursive(RECURSIVE_PUBLIC_VALUES), + &pairings, + body, + &expected.iter().copied().map(Scalar::from_u64).collect::>(), + ) +} + +fn staged_publics( + claims: &[Vec], + pairing_count: usize, +) -> storage::Result> { + if pairing_count != 2 { + return Err("pairing point count".into()); + } + ClaimTemplate::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 +/// 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 + || h.ilog2() as usize >= Scalar::TWO_ADICITY + }) + || 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 config_started = Instant::now(); + 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(&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]], + [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 { + 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, &vk, &keys, &statement) + })?; + let verify_seconds = verify_start.elapsed().as_secs_f64(); + 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)?; + 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": PAIRING_BYTES, + "vk_bytes": 32, + "vk_version": OUTER_VK_VERSION, + "transcript": "blake3", + "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(), + "vk_id": blake3::Hash::from_bytes(vk).to_hex().to_string(), + "trace_heights": manifest.heights, + }); + fs::write( + out.join("prove-report.json"), + serde_json::to_vec_pretty(&report)?, + )?; + eprintln!("{}", serde_json::to_string_pretty(&report)?); + drop(output_span); + cache.publish(dir)?; + Ok(()) +} + +/// Recursive verifier metadata authenticated by a bundle or an independent digest. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct VerifyingKey { + 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("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 verifying key bytes".into()); + } + Ok(value) +} + +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 { + 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)?; + let prepared = vk.prepare()?; + Ok((vk, prepared)) + } + + 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. + 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()); + 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 verifying key carries. + pub(crate) fn verifier_params( + &self, + ) -> storage::Result { + Ok(crate::kzg::VerifierParams::from_bytes(&self.parameters)?) + } + + /// 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<()> { + let parameters = self.verifier_params()?; + if SetupClass::of(¶meters) != expected { + return Err("verifier setup class differs".into()); + } + if expected == SetupClass::Ignition + && self.keys()? != [parameters.g2, parameters.tau_g2] + { + return Err( + "Ignition verifier has different inner pairing anchors".into(), + ); + } + Ok(()) + } + + pub(crate) fn system( + &self, + ) -> storage::Result> { + use crate::kzg::VerifierParams; + use multi_stark::system::{Circuit, System}; + let supported = self.version == OUTER_VK_VERSION; + if !supported { + return Err("unsupported recursive verifying key".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 verifying key".into()); + } + let params = VerifierParams::from_bytes(&self.parameters)?; + 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() { + 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::Blake3), + circuits, + preprocessed_commit: Some(fixed), + preprocessed_indices: vec![Some(0), Some(1)], + }) + } + + pub(crate) fn prepare(&self) -> PipelineResult { + Ok(PreparedVerifyingKey { + id: self.id(), + verifier: RecursiveVerifier::from_parts( + self.system()?, + ClaimTemplate::recursive(RECURSIVE_PUBLIC_VALUES), + &self.keys()?, + 18, + )?, + }) + } + + pub(crate) fn verify( + &self, + packet: &[u8], + expected: &Statement, + ) -> PipelineResult<()> { + self.prepare()?.verify(packet, expected) + } +} + +pub(crate) struct PreparedVerifyingKey { + id: [u8; 32], + verifier: RecursiveVerifier, +} + +impl PreparedVerifyingKey { + 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. +#[cfg(test)] +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 + }, + }; + VerifyingKey::from_bytes(&fs::read(dir.join("vk.bin"))?)? + .verify(packet, expected) +} + +#[cfg(test)] +mod tests { + use super::super::super::init_fri::INIT_PUBLIC_WORDS; + use super::*; + use crate::{foreign::KzgCircuitExt, kzg::Srs, test_support::TempDir}; + use ark_ec::CurveGroup; + use ark_serialize::CanonicalSerialize; + use multi_stark::{ + plonkish::CircuitBuilder, + system::{System, SystemWitness}, + traits::Algebra, + }; + use std::sync::Arc; + + 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(); + 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(); + pairing_keys.to_vec().serialize_compressed(&mut keys)?; + let vk = VerifyingKey { + version: OUTER_VK_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 = vk.id().to_vec(); + for word in INIT_PUBLIC_WORDS { + packet.extend(word.to_le_bytes()); + } + packet.extend(pairing_bytes(&publics[18..])?); + packet.extend(&proved.bytes); + Ok((vk, packet)) + } + + #[test] + #[cfg(any(feature = "plonk", feature = "groth16"))] + fn plonk_recursive_export_includes_anchor_and_deferred_publics() + -> storage::Result<()> { + let (vk, packet) = recursive_vk_fixture()?; + let expected = INIT_PUBLIC_WORDS; + let directory = TempDir::new("plonk-recursive-claims"); + final_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!( + final_export::write_export( + &vk, &packet, &wrong, &directory, "wrong", true, + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn ignition_staging_rejects_other_inner_keys_before_creating_files() + -> storage::Result<()> { + 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(); + 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-anchors", + 128, + )?; + 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") }, + fixed_cache_root: None, + prefetch_bytes: 0, + }; + let error = frontend + .stage(assignment, &root, &INIT_PUBLIC_WORDS, &config) + .unwrap_err(); + assert!(error.to_string().contains("inner pairing anchors differ")); + assert!(!root.exists()); + Ok(()) + } + + #[test] + fn pinned_recursive_key_binds_claim_and_rejects_tampering() + -> storage::Result<()> { + 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_PUBLIC_WORDS; + let portable = vk.to_bytes()?; + let trusted = *blake3::hash(&portable).as_bytes(); + 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, 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, true).is_err()); + let mut bad_packet = packet; + *bad_packet.last_mut().unwrap() ^= 1; + 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)?; + vk.pairings = bincode::serde::encode_to_vec( + Pairings { keys }, + bincode::config::standard(), + )?; + assert!(vk.check_setup_class(SetupClass::Ignition).is_err()); + Ok(()) + } + + #[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 vk = [17u8; 32]; + let packet_prefix = |wrong: bool| { + let mut packet = vk.to_vec(); + for v in INIT_PUBLIC_WORDS { + packet.extend(v.to_le_bytes()); + } + let lhs = if wrong { + (inner.g1[1] * crate::kzg::curve::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), &vk, 2, &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::Blake3); + 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, &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(); + } + 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, &vk, &keys, &INIT_PUBLIC_WORDS) + .is_ok(), + !wrong + ); + if !wrong { + let mut changed = packet.clone(); + changed[32 + 8] ^= 1; + assert!( + verify_packet( + &system, + &codec, + &changed, + &vk, + &keys, + &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], + &vk, + &keys, + &INIT_PUBLIC_WORDS + ) + .is_err() + ); + packet.push(0); + assert!( + verify_packet( + &system, + &codec, + &packet, + &vk, + &keys, + &INIT_PUBLIC_WORDS + ) + .is_err() + ); + } + } + } + + fn init_packet_claims(inner: &Srs) -> Vec> { + let mut prefix = [0u8; 32].to_vec(); + 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_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: &[G2Affine], + ) -> storage::Result { + fs::create_dir_all(dir)?; + let height = compiled.main_height(); + SetupSource::development(OUTER_SEED, height - 1)? + .with_transcript(TranscriptKind::Blake3) + .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_identity(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_vks_prove_and_portable_verifiers_round_trip() -> storage::Result<()> + { + 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); + 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::Blake3); + prove(&first, Lowered { compiled, assignment }, &cache, &setup)?; + 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)?; + 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 verifying key 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/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)] { + 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("vk.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::verifying_keys::verify_with_vk( + &packet, claim, expected, &portable, trusted, allow, + ) + }; + assert_eq!( + verify_local(claim, &INIT_PUBLIC_WORDS, &trusted, true)?, + SetupClass::Development + ); + 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_PUBLIC_WORDS, &wrong_digest, true).is_err() + ); + assert!( + verify_local(b"wrong", &INIT_PUBLIC_WORDS, &trusted, true).is_err() + ); + 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)?; + assert_eq!(vk.version, OUTER_VK_VERSION); + assert_eq!(vk.system()?.config.transcript_kind(), TranscriptKind::Blake3); + + let unsupported_versions = [0, 5, 6]; + for version in unsupported_versions { + let mut unsupported: VerifyingKey = decode(&portable)?; + unsupported.version = version; + let unsupported = bincode::serde::encode_to_vec( + &unsupported, + bincode::config::standard(), + )?; + 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)?; + vk.verify(&packet, &INIT_PUBLIC_WORDS)?; + let mut wrong_range: VerifyingKey = decode(&portable)?; + wrong_range.heights[0] *= 2; + assert!(wrong_range.system().is_err()); + { + 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: VerifyingKey = 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 = VerifyingKey::from_bytes(&bytes).err().unwrap(); + assert!(error.to_string().contains("quotient domains exceed")); + } + 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 = TempDir::new("kzg-recursive-fused"); + 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_PUBLIC_WORDS, + &crate::pipeline::StageConfig::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/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)?; + outputs.push( + ["proof.compact.bin", "packet.bin", "vk-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]); + 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_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 = TempDir::new("kzg-recursive-constant"); + stage( + built, + assignment, + &dir, + &plan.identity(), + &INIT_PUBLIC_WORDS, + &crate::pipeline::StageConfig::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()); + 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 new file mode 100644 index 000000000..4207c9ce1 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/final_export.rs @@ -0,0 +1,346 @@ +//! Canonical verifier programs and witnesses for final circuits. +use super::relation::{codegen, *}; +use super::*; +use crate::kzg::curve::G1Affine; +use ark_ec::AffineRepr; +use ark_ff::{BigInteger, PrimeField}; +use ark_serialize::CanonicalDeserialize; +use multi_stark::{ + config::ProofConfig, + prover::Proof, + system::System, + traits::{Algebra, Pcs}, +}; +use serde_json::{Value as Json, json}; +type Result = std::result::Result>; +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![]; + 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) +} + +pub(in crate::pipeline) fn export( + verifier: &Path, + packet: &Path, + 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)?; + vk.check_setup_class(setup.class())?; + vk.verify(&fs::read(packet)?, expected)?; + 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, +} + +/// 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 logs = vk + .heights + .iter() + .map(|h| u8::try_from(h.ilog2())) + .collect::, _>>()?; + 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)), + 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(); + (slots, publics) +} + +/// 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. +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, + proof: &proof, + logs: &logs, + slots, + publics, + vk_id: vk.id(), + deferred_keys: vk.keys()?, + deferred_bytes, + packet, + diagnostic, + }, + expected, + directory, + name, + ) +} + +fn write_vk( + export: &Export<'_>, + expected: &Statement, + directory: &Path, + name: &str, +) -> Result<()> { + let Export { system, proof, logs, .. } = export; + let diagnostic = { + let mut diagnostic = export.diagnostic.clone(); + 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::>(); + diagnostic["pointEncodings"] = json!(encodings); + diagnostic + }; + 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 [ + &proof.stage_1_opened_values, + &proof.stage_2_opened_values, + &proof.quotient_opened_values, + 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 = export + .deferred_bytes + .as_chunks::<{ G1_COMPRESSED_BYTES }>() + .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("vk.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")), export.packet)?; + storage::write_atomic( + &directory.join(format!("{name}-statement.json")), + &serde_json::to_vec_pretty(expected)?, + )?; + 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/outer/final_export/ir.rs b/crates/proof-compression/src/pipeline/wrap/outer/final_export/ir.rs new file mode 100644 index 000000000..7ae67bb07 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/final_export/ir.rs @@ -0,0 +1,289 @@ +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; + } +} + +/// 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..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 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. + 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 + 1 < circuits { values[i] } else { Term::ZERO }; + let cp = [beta, gamma, accumulator, next]; + let rows = |round: usize| { + 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] + } 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/outer/relation.rs b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs new file mode 100644 index 000000000..4ab26f9bb --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation.rs @@ -0,0 +1,402 @@ +//! Shared verifier layouts, graph validation and native differential vectors. +use super::*; +use crate::kzg::curve::{Fq, G1Affine, G1Projective}; +use ark_ec::{AffineRepr, CurveGroup}; +use ark_ff::{BigInteger, PrimeField, Zero}; +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 _; +type Result = std::result::Result>; +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(); + } + out +} + +pub(super) fn hex_json(bytes: &[u8]) -> String { + format!("0x{}", hex(bytes)) +} + +pub(super) fn scalar(value: Scalar) -> String { + hex_json(&value.0.into_bigint().to_bytes_be()) +} + +pub(super) fn fq(value: Fq) -> Vec { + value.into_bigint().to_bytes_be() +} + +pub(super) fn g1(point: G1Affine) -> Vec { + if point.is_zero() { + return vec![0; 64]; + } + [fq(point.x), fq(point.y)].concat() +} + +pub(super) 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)] +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)] +pub(super) struct GraphInputs { + pub(super) main: GraphMatrix, + pub(super) preprocessed: GraphMatrix, + pub(super) stage2: GraphMatrix, + pub(super) public_count: usize, + pub(super) matrix_count: usize, +} + +impl GraphInputs { + pub(super) fn for_circuit( + system: &System, + index: usize, + ) -> Result { + let count = system.circuits.len(); + 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: 3 * count + index, + width: c.preprocessed_width, + rows: 1 + + usize::from(system.opens_next_row(index, Source::Preprocessed)), + }, + stage2: GraphMatrix { + index: count + index, + width: c.stage_2_width, + rows: 2, + }, + public_count: c.num_publics, + matrix_count: 4 * count, + }) + } + + pub(super) 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)] +pub(super) 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), +} + +pub(super) struct GraphProgram { + pub(super) instructions: Vec, +} + +impl GraphProgram { + pub(super) 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 >= inputs.matrix_count || !(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) + } + + #[cfg(any(feature = "plonk", feature = "groth16"))] + pub(super) 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, +} + +pub(super) 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..a433b99c3 --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/outer/relation/codegen.rs @@ -0,0 +1,348 @@ +//! Fixed opening layouts and scalar arithmetic for authenticated verifying keys. + +use super::{KzgConfig, Result, Scalar, System}; +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(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(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, + /// 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 { + 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(in crate::pipeline::wrap::outer) fn new_for_system( + system: &System, + logs: &[u8], + ) -> Result { + if logs.len() != system.circuits.len() { + return Err("trace logs must cover every circuit".into()); + } + let mut shapes = Vec::with_capacity(system.circuits.len()); + for (ci, circuit) in system.circuits.iter().enumerate() { + let height = 1usize << logs[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 { + 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 + || 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(4 * circuits); + let mut factors = Vec::with_capacity(3); + let mut commitment_count = 0; + let mut fixed_count = 0; + let mut field_count = accumulator_count; + 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 factors.len() < 2 { + return Err("invalid evaluation-point count".into()); + } + let point_count = add(commitment_count, factors.len())?; + for matrix in &mut matrices[3 * circuits..] { + 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, + accumulator_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/recursive.rs b/crates/proof-compression/src/pipeline/wrap/recursive.rs new file mode 100644 index 000000000..92ea89bdc --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/recursive.rs @@ -0,0 +1,457 @@ +//! 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, 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::{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"; + +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], +} + +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_points(pairing::decode(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)?; + } + stage::encode_key( + RECURSIVE_VERIFIER_MAGIC, + (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) = + 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) + != Some(verifier.statement.locations.len()) + { + return Err("invalid recursive public statement dimensions".into()); + } + 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) + } + + /// 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: ClaimTemplate, + 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(PUBLIC_VALUES).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<()> { + 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", feature = "groth16"))] +pub(in crate::pipeline) fn unpack_pairings( + bytes: &[u8], +) -> Result> { + Ok(pairing::encode(pairing_points(bytes)?).to_vec()) +} + +pub(in crate::pipeline) fn pack_pairings(values: &[Scalar]) -> Result> { + let mut bytes = Vec::with_capacity(PAIRING_BYTES); + for point in pairing::decode(values)? { + point.serialize_compressed(&mut bytes)?; + } + Ok(bytes) +} + +pub(in crate::pipeline) fn verify_recursive_bytes( + system: &System, + codec: &crate::kzg::compact::FixedProofCodec, + statement: &ClaimTemplate, + pairings: &Pairings, + bytes: &[u8], + expected: &[Scalar], +) -> Result<()> { + 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(pairing::encode(points)); + let claims = statement.claims(&public)?; + 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_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::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() + -> 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(()) + } + + #[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/wrap/saved.rs b/crates/proof-compression/src/pipeline/wrap/saved.rs new file mode 100644 index 000000000..ba44d0d1b --- /dev/null +++ b/crates/proof-compression/src/pipeline/wrap/saved.rs @@ -0,0 +1,466 @@ +use super::super::{ + prover::FixedSetup, + setup::{BINDING_FILE, FIRST_SEED, SetupClass, SetupConfig, SetupSource}, + statement, + storage::Manifest, +}; +use crate::{ + kzg::{KzgConfig, Scalar, compact::FixedProofCodec}, + recursive::native_verifier::{ClaimSchema, Plan}, +}; +#[cfg(test)] +use multi_stark::traits::Field; +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>; + +/// 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 { + 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 = + inner_system(&setup, &manifest.widths, &manifest.heights, parts)?; + let plan = super::super::lowering::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, + ) +} + +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::lowering::recursive_layout() +} + +pub(super) fn claim_schema(claims: &[Vec]) -> Result { + let public = + super::super::claims::ClaimTemplate::from_claims(claims, 18)?.locations; + 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, + pub(super) claims: Vec>, + pub(super) logs: Vec, + pub(super) proof_bytes: Vec, + input_blake3: BTreeMap, + setup_identity: [u8; 32], + development_srs: bool, + load_seconds: f64, +} + +impl Input { + fn plan(&self) -> Result> { + super::super::lowering::recursive_plan( + &self.system, + &self.logs, + claim_schema(&self.claims)?, + ) + } + + #[tracing::instrument( + level = "info", + skip_all, + name = "compression/load_input" + )] + pub(super) fn load( + dir: &Path, + config: &super::super::StageConfig, + ) -> 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 = check_manifest(&manifest)?; + 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 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::lowering::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(super) fn prove( + dir: &Path, + stage: &Path, + config: &super::super::StageConfig, +) -> Result<()> { + let start = Instant::now(); + let input = Input::load(dir, config)?; + let build_start = Instant::now(); + let plan = input.plan()?; + 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(); + 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!({ + "curve": crate::kzg::curve::NAME, + "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(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(), + "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( + 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)] +mod tests { + use super::*; + use crate::kzg::Srs; + use crate::pipeline::init_fri::INIT_PUBLIC_WORDS; + use crate::pipeline::setup::SetupSource; + use crate::recursive::native_verifier::LayoutPolicy; + 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 = crate::test_support::TempDir::new("saved-binding"); + let ns = Scalar::from_u8(93); + let mut claims: Vec<_> = std::iter::once(0) + .chain(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::StageConfig::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::StageConfig::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::StageConfig::development()) + .err() + .unwrap(); + assert!(malformed.downcast_ref::().is_some()); + 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..37c415914 --- /dev/null +++ b/crates/proof-compression/src/recursive/mod.rs @@ -0,0 +1,7 @@ +//! 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; +pub mod pairing; 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..47efca072 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_curve.rs @@ -0,0 +1,358 @@ +//! Affine arithmetic with constrained denominators and a complete final addition. +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::{ + plonkish::{Bool, Value}, + traits::{Algebra, 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); + 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 { + let slope = f.hint_pure(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_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_pure(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_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_pure(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_pure(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_pure(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 } + } + + /// 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; + 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); + } + + /// BN254 G1 has cofactor one, so `on_curve` already proves membership. + 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[..L]) == fq_value(&v[L..]))) + }); + let flag = b.assert_bool(equal); + let zero = b.constant(Scalar::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))) + }); + 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 { + 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 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) }; + 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 crate::kzg::curve::Fr; + 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 * Fr::from(2u8)).into_affine()); + out.equal(&mut b, &f, expected); + }, + "native-add" => { + 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 * 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 crate::kzg::curve::Fr; + use ark_ec::CurveGroup; + #[test] + 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"); + 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] { + let point = (G1Affine::generator() * Fr::from(n)).into_affine(); + run(point).unwrap(); + run(-point).unwrap(); + } + 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"] { + 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 * 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 new file mode 100644 index 000000000..a166d373a --- /dev/null +++ b/crates/proof-compression/src/recursive/native_field.rs @@ -0,0 +1,496 @@ +//! Bounded Fq arithmetic in a native Fr Plonkish circuit. +use crate::kzg::{Scalar, curve::Fq}; +use ark_ff::{BigInteger, PrimeField}; +use multi_stark::{ + plonkish::{CircuitBuilder, Table, Value}, + traits::{Algebra, Field}, +}; +use num_bigint::{BigInt, BigUint, Sign}; +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); +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; + +#[derive(Clone, Copy, Debug)] +pub struct FqVar(pub [Value; L]); + +pub struct FqGadget { + range: Table, +} + +pub fn integer(v: Scalar) -> BigUint { + 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(); + 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 range", + (0..1u64 << RANGE_BITS).map(|v| vec![Scalar::from_u64(v)]).collect(), + ), + } + } + pub fn range(&self, b: &mut Builder, value: Value, bits: usize) { + 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), + 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 * RANGE_BITS); + let limbs = b.hint_many_pure::("bounded integer", &[value], move |v| { + Ok(advice::range_limbs(v[0])) + }); + for &limb in &limbs { + b.lookup(self.range, &[limb]); + } + 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) << (RANGE_BITS * 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)) + } + /// 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, + 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_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)?)) + })); + 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 { 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::<{ 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; 2 * L]; + 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 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 BN254's exact moduli. + 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 = &advice::constants().modulus_digits; + let advice = b.hint_many_pure::( + "Fq quotient and carries", + &deps, + 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 }); + } + for &v in &advice[L..] { + self.range(b, v, CARRY_BITS); + } + let mut terms: Vec> = vec![vec![]; 2 * L]; + 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) << 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 { + 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) << 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 = + 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 < L - 1 { + 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_pure(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, 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) << 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(); + } + 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 == L - 1 { TOP_LIMB_BITS } else { W }), + ) + } 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 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 crate::kzg::curve::Fr; + let one = BigUint::from(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 << 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()); + assert!(equation_bound < fr); + assert!( + ((&radix + 1u8) * coefficient_bound + &carry_bound) / (&radix * &radix) + < carry_bound + ); + } +} 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..371fc09af --- /dev/null +++ b/crates/proof-compression/src/recursive/native_field/advice.rs @@ -0,0 +1,130 @@ +use std::sync::OnceLock; + +use super::{ + 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}; + +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) << 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) << CARRY_BIAS_BITS, + } + }) +} + +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| { + 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) + }) +} + +pub(super) fn relation( + values: &[Scalar], + products: &[i8], + linear: &[i8], + constant: i8, +) -> 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; 2 * L] = 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() > QUOTIENT_BITS as u64 { + 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; RELATION_ADVICE_LEN]; + for i in 0..L { + out[i] = scalar("ient[i]); + } + let mut carry = BigInt::from(0u8); + for j in 0..L { + carry += &terms[2 * j] + (&terms[2 * j + 1] << W); + divide_carry(&mut carry)?; + 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 { + 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; 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..c326047b3 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_field/advice/tests.rs @@ -0,0 +1,297 @@ +use super::*; +use crate::{kzg::curve::Fq, recursive::native_field::FqGadget}; +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::>(), + ) +} + +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))) +} + +fn relation_reference( + values: &[Scalar], + pc: &[i8], + lc: &[i8], + constant: i8, + p_hint: &[BigUint; L], +) -> 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 { + 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) << QUOTIENT_BIAS_BITS); + let q = q.to_biguint().ok_or("negative biased quotient")?; + if q.bits() > QUOTIENT_BITS as u64 { + return Err("Fq quotient overflow".into()); + } + let q = digits(&q); + 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; 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..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 < 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()); + } + 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 = digits(&modulus()); + 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 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 = digits(&modulus()); + 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) + ); + } +} 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..034762003 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_msm.rs @@ -0,0 +1,450 @@ +use crate::kzg::{ + Scalar, + curve::{Fr, G1Affine, G1Projective}, +}; +use crate::recursive::{ + native_curve::{Affine, PointInput}, + native_field::{Builder, FqGadget, FqVar, L, fq_value}, + native_transcript::scalar_bytes, +}; +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, + 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_pure(b, "negative y", &[p.y], |v| Ok(-v[0])); + f.relation(b, &[], &[(1, p.y), (1, y)], 0); + Affine { x: p.x, y } +} + +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, + bases: &[Base], + terms: &[(usize, Value)], + tag: &str, +) -> Affine { + 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 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::<{ 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 + }); + } + 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[..L].try_into().unwrap()), + y: FqVar(limbs[L..].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 { + 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 = 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 (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 = constant_mul(b, f, h, c); + let correction = ks.add(b, f, correction); + let neg = negate(b, f, correction); + 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_identity_duplicate_and_negated_terms() { + 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 result = result.canonical(&mut b, &f); + let encoded = + crate::recursive::native_transcript::point_bytes(&mut b, &bytes, result); + let c = b.finish(); + let mut cases: Vec<_> = [ + ([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]), + ] + .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() * ns[i]).into_affine(); + let s = 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 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 new file mode 100644 index 000000000..daebe6dbf --- /dev/null +++ b/crates/proof-compression/src/recursive/native_transcript.rs @@ -0,0 +1,328 @@ +use crate::kzg::{ + Scalar, + curve::{Fr, G1_COMPRESSED_BYTES}, +}; +use crate::recursive::{ + native_curve::PointInput, + native_field::{Builder, FqVar, W, integer, modulus}, +}; +use ark_ff::{BigInteger, PrimeField}; +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). +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_pure::<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]))) + }); + 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 + .iter() + .copied() + .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; G1_COMPRESSED_BYTES] { + let mut result = Vec::new(); + for (i, &limb) in v.0.iter().enumerate() { + let raw = b.hint_many_pure::<{ W / 8 }>("Fq limb bytes", &[limb], |v| { + let mut data = integer(v[0]).to_bytes_le(); + 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)); + 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); + 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() +} + +/// The point must have canonical coordinates, with zero coordinates at infinity. +pub fn point_bytes( + b: &mut Builder, + bytes: &ByteGadgets, + p: PointInput, +) -> [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(G1_COMPRESSED_BYTES, 0); + let sign = greater_than(b, bytes, &y, &half); + let (sign_mask, compressed_mask) = (128, 0); + let flagged = b.linear_combination( + &[ + (Scalar::ONE, x[G1_COMPRESSED_BYTES - 1].value()), + (Scalar::from_u8(64), p.infinity.value()), + (Scalar::from_u8(sign_mask), sign.value()), + ], + Scalar::from_u8(compressed_mask), + ); + let flagged = bytes.constrain_byte(b, flagged); + std::array::from_fn( + |i| { + if i == G1_COMPRESSED_BYTES - 1 { flagged } else { x[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::kzg::curve::G1Affine; + use crate::recursive::{native_curve::PointInput, native_field::FqGadget}; + 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(); + 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(); + } + w.set(v, scalar).unwrap(); + 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)); + } + 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()); + } + } + + #[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 new file mode 100644 index 000000000..6a6c67010 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier.rs @@ -0,0 +1,891 @@ +//! Fixed-verifying key ordinary KZG verification with publicly bound pairing inputs. +use crate::{ + 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, + }, +}; +#[cfg(test)] +use ark_ec::CurveGroup; +use ark_ec::pairing::Pairing; +use ark_ff::Zero; +use ark_serialize::CanonicalSerialize; +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; + +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 msm_terms: Vec, + #[cfg(test)] + pub terms: Vec>, +} + +#[derive(Clone, Copy)] +pub struct InnerVerifyingKey<'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, + 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 { + context: Context, + pairing_outputs: Vec, + pairing_keys: Vec, + terms: Vec>, + phases: 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(), + phases: self.phases.clone(), + } + } +} + +struct Context { + b: Builder, + f: FqGadget, + bytes: ByteGadgets, + inputs: Vec<(Value, ScalarSource)>, + bases: Vec, + points: Vec, + point_ends: Vec, + encodings: Vec<[ByteValue; G1_COMPRESSED_BYTES]>, + scalar_encodings: BTreeMap, +} +impl Context { + fn new(mut b: Builder) -> Self { + let f = FqGadget::new(&mut b); + let bytes = ByteGadgets::new(&mut b); + Self { + b, + f, + bytes, + inputs: vec![], + bases: vec![], + points: vec![], + point_ends: vec![], + encodings: vec![], + scalar_encodings: BTreeMap::new(), + } + } + fn scalar(&mut self, name: &str, source: ScalarSource) -> Value { + let v = self.b.input(name); + self.inputs.push((v, source)); + v + } + 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( + &mut self.b, + if value.infinity { 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 { + PointInput::new(&mut self.b, &self.f, &format!("point{id}")) + }; + 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)); + 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 { + eprintln!("Allocated {} curve points: {:?}", id + 1, self.b.stats()); + } + id + } + fn seal_point_prefix(&mut self) -> bool { + self.b.try_parallel_witness_prefix(std::mem::take(&mut self.point_ends)) + } + 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(|(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() + }) + .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) { + 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]); + } + } + } + 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() +} + +/// 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, + proof: &Proof, + claims: &[Vec], + public_locations: &[(usize, usize)], +) -> Result { + 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 SRS differs from the authenticated configuration".into(), + ); + } + 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 verifying key; it does not verify its proof or its SRS. +pub fn count( + vk: &InnerVerifyingKey<'_>, + proof: &Proof, + claims: &[Vec], + public_locations: &[(usize, usize)], +) -> Result { + let plan = Plan::from_identity( + vk, + &proof.log_degrees, + ClaimSchema::from_claims(claims, public_locations)?, + LayoutPolicy::legacy(), + )?; + plan.validate_request(proof, claims)?; + plan.count() +} + +fn construct( + vk: &InnerVerifyingKey<'_>, + shape: &Shape, + claims: &ClaimSchema, + builder: Builder, +) -> Result { + 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(vk.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(vk.generator)); + 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}" + ); + 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", 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 fixed_open = + c.openings("fixed", Round::Fixed, &shape.rounds[Round::Fixed.index()]); + let mut claim_vars: Vec> = claims + .rows + .iter() + .map(|row| { + row + .iter() + .map(|slot| match slot { + ClaimSlot::Constant(value) => Some(c.b.constant(*value)), + ClaimSlot::PublicU64 => None, + }) + .collect() + }) + .collect(); + 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, vk.transcript_seed); + c.observe_constant(&mut t, circuits.len()); + for circuit in 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.rows.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 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[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); + phases.push(PhaseCounts { name: "transcript", stats: 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); + 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 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]; + } + eprintln!("AIR checks complete: {:?}", c.b.stats()); + let mut terms = vec![]; + let mut keys = vec![]; + terms.push(combine(&mut c.b, lhs)); + 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(vk.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( + &mut c.b, + &c.f, + &c.bytes, + &mut c.bases, + terms, + &format!("pairing{i}"), + ); + 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::<{ pairing::POINT_LIMB_BYTES }>().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); + } + 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 { + context: c, + pairing_outputs: outputs, + pairing_keys: keys, + terms, + phases, + }) +} + +impl Built { + #[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< + multi_stark::plonkish::Assignment, + Box, + > { + let mut w = self.circuit.witness(); + for &(v, n) in &self.inputs { + w.set(v, n)?; + } + 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) + ); + } + 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) { + 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 = 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(), + ); + 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, &srs, &proof, &claims, &[(1, 3)]).unwrap(); + let vk = InnerVerifyingKey { + 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(&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(&vk, &proof.log_degrees) + .unwrap() + .checked_compact_len() + .unwrap(), + 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 + .circuit + .tables() + .iter() + .map(|table| (table.rows().len(), table.width())) + .collect::>() + ); + assert_eq!( + counted.msm_terms, + built.terms.iter().map(Vec::len).collect::>() + ); + built.check().unwrap(); + let key = built.pairing_keys[1]; + 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]; + let slot = built.inputs.iter().position(|&(v, _)| v == public).unwrap(); + built.inputs[slot].1 += Scalar::ONE; + 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.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..9654ef928 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/bindings.rs @@ -0,0 +1,226 @@ +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 verifying key".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 verifying key. + #[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)?; + 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 + || !PairingCurve::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..92f919899 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/parallel_prefix_tests.rs @@ -0,0 +1,191 @@ +use super::*; +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}; + +struct PointFixture { + circuit: Circuit, + inputs: Vec<(Value, Scalar)>, + expected_publics: Vec, + prefix_ends: Vec, +} + +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 } + } + + 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); +} + +#[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, (2 * L + 1) * 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); + let stride = 2 * L + 1; + for (missing, corrupt) in [ + (Some(0), None), + (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(); + assert_eq!(actual, expected, "missing={missing:?}, corrupt={corrupt:?}"); + } +} 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..ef578bff4 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/plan.rs @@ -0,0 +1,368 @@ +use super::*; +use ark_ec::AffineRepr; +use shape::Shape; + +const FRONTEND_VERSION: &[u8] = b"init-kzg-recursive/bn254/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.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() + || height.ilog2() as usize > Scalar::TWO_ADICITY + }, + ) { + return Err( + "layout caps must be powers of two within scalar two-adicity".into(), + ); + } + Ok(()) + } +} + +pub(crate) struct Plan<'a> { + pub(super) vk: InnerVerifyingKey<'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 vk = InnerVerifyingKey { + 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_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() + || 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_identity( + vk: &InnerVerifyingKey<'a>, + logs: &[u8], + claims: ClaimSchema, + layout: LayoutPolicy, + ) -> Result { + if vk.transcript_kind != crate::kzg::TranscriptKind::Blake3 { + return Err( + "recursive circuit requires a BLAKE3 inner transcript".into(), + ); + } + layout.validate()?; + 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 + vk.fixed.0.iter().flatten().chain(std::iter::once(&vk.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 [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(vk, &shape, &claims, layout)?; + Ok(Self { vk: *vk, shape, claims, layout, identity }) + } + + pub(crate) fn identity(&self) -> [u8; 32] { + self.identity + } + + pub(crate) fn count(&self) -> Result { + construct(&self.vk, &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 verifying key".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, + }) + } + + /// 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())?; + 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( + vk: &InnerVerifyingKey<'_>, + 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 = ( + vk.transcript_seed, + vk.max_log_degree, + shape, + claims, + layout, + (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 vk.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![]; + vk.fixed + .0 + .serialize_compressed(&mut points) + .map_err(|error| error.to_string())?; + vk.generator + .serialize_compressed(&mut points) + .map_err(|error| error.to_string())?; + 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()); + 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..0731fc94f --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/reuse_tests.rs @@ -0,0 +1,533 @@ +use super::*; +use crate::kzg::Srs; +use ark_ec::AffineRepr; +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_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_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 vk = plan.vk; + match anchor { + 0 => vk.generator = G1Affine::identity(), + 1 => vk.g2 = G2Affine::identity(), + _ => vk.tau_g2 = G2Affine::identity(), + } + assert!(Plan::from_identity(&vk, &[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)]]; + 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_vk_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 * 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))); + 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_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()); + 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_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 vk = plan.vk; + vk.transcript_kind = crate::kzg::TranscriptKind::Keccak256; + assert!(matches!( + Plan::from_identity(&vk, &[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..366827c69 --- /dev/null +++ b/crates/proof-compression/src/recursive/native_verifier/shape.rs @@ -0,0 +1,285 @@ +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 verifying key") + }, + 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 verifying key".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 verifying key".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( + vk: &InnerVerifyingKey<'_>, + logs: &[u8], + ) -> Result { + if logs.is_empty() || logs.len() != vk.circuits.len() { + return Err("trace logs must cover every circuit".into()); + } + if vk.max_log_degree > Scalar::TWO_ADICITY + || vk.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 vk.circuits.iter().zip(logs) { + let height = + 1usize.checked_shl(u32::from(log)).ok_or("trace height overflow")?; + if usize::from(log) > vk.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(vk.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(G1_COMPRESSED_BYTES) + .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 verifying key" + .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/src/recursive/pairing.rs b/crates/proof-compression/src/recursive/pairing.rs new file mode 100644 index 000000000..eee4aef4f --- /dev/null +++ b/crates/proof-compression/src/recursive/pairing.rs @@ -0,0 +1,106 @@ +//! Canonical public encoding of the two deferred KZG pairing operands. +//! +//! 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, + 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); + { + 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])); + } + } + + #[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()); + } + } + + #[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/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/fixtures/groth16/proof.bin b/crates/proof-compression/tests/fixtures/groth16/proof.bin new file mode 100644 index 000000000..5678e0330 Binary files /dev/null and b/crates/proof-compression/tests/fixtures/groth16/proof.bin differ 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 000000000..7db88d4e8 Binary files /dev/null and b/crates/proof-compression/tests/fixtures/groth16/verifying-key.bin differ diff --git a/crates/proof-compression/tests/fixtures/init-fri/claim.bin b/crates/proof-compression/tests/fixtures/init-fri/claim.bin new file mode 100644 index 000000000..945e3d028 Binary files /dev/null and b/crates/proof-compression/tests/fixtures/init-fri/claim.bin differ 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 000000000..dce84daf2 Binary files /dev/null and b/crates/proof-compression/tests/fixtures/init-fri/outer-claims.bin differ 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 000000000..ef63009fb Binary files /dev/null and b/crates/proof-compression/tests/fixtures/init-fri/outer-proof.bin differ 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 000000000..d24caf646 Binary files /dev/null and b/crates/proof-compression/tests/fixtures/init-fri/outer-vk.bin differ diff --git a/crates/proof-compression/tests/fixtures/plonk/proof.bin b/crates/proof-compression/tests/fixtures/plonk/proof.bin new file mode 100644 index 000000000..822c3af6d Binary files /dev/null and b/crates/proof-compression/tests/fixtures/plonk/proof.bin differ diff --git a/crates/proof-compression/tests/fixtures/plonk/provenance.json b/crates/proof-compression/tests/fixtures/plonk/provenance.json new file mode 100644 index 000000000..90cb5788e --- /dev/null +++ b/crates/proof-compression/tests/fixtures/plonk/provenance.json @@ -0,0 +1,8 @@ +{ + "source": "s3://argument-kzg-params-063002298335-us-east-1-an/bn254/plonk/init/2p24/4f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff368/", + "archive_manifest_sha256": "18a1ef97033721a578bee16fa0db1f67108f8dc08d8e6e2c7f964f4bf291d286", + "key_sha256": "4f27f4cb17c22d0e73de231ffbda679314c76a2103f24468af5c700b599ff368", + "proof_sha256": "06084a415648171e93bc638e49c111f75e67ef24c24186543d91e9398f42b438", + "claim_digest": "c019cf542c84a8bc161b397194dec92edc7f7616235d2e7245b23f799bcbed80", + "anchors": "1fa4be93b5e7f7e674d5059b63554fab99638b304ed8310e9fa44c281ac9b03b1a01ae7fac6228e39d3cb5a5e71fd31160f3241e79a5f48ffb3737e6c389b7211800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c212c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b2a344fad01c2ed0ed73142ae1752429eaea515c6f3f6b941103cc21c2308e1cb22f1acbb03c4508760c2430af35865e7cdf9f3eb1224504fdcc3708ddb954a480efd30ac7b6f8d0d3ccbc2207587c2acbad1532dc0293f0d034cf8258cd428b3159f15b842ba9c8449aa3268f981010d4c7142e5193473d80b464e964845c3f8" +} diff --git a/crates/proof-compression/tests/fixtures/plonk/verifying-key.bin b/crates/proof-compression/tests/fixtures/plonk/verifying-key.bin new file mode 100644 index 000000000..dfe15b0bf Binary files /dev/null and b/crates/proof-compression/tests/fixtures/plonk/verifying-key.bin differ 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/integration/kzg_sharding.rs b/crates/proof-compression/tests/integration/kzg_sharding.rs new file mode 100644 index 000000000..edf077817 --- /dev/null +++ b/crates/proof-compression/tests/integration/kzg_sharding.rs @@ -0,0 +1,121 @@ +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), + ); + 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, + &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/integration/kzg_streaming.rs b/crates/proof-compression/tests/integration/kzg_streaming.rs new file mode 100644 index 000000000..50546fa40 --- /dev/null +++ b/crates/proof-compression/tests/integration/kzg_streaming.rs @@ -0,0 +1,227 @@ +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; +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(); + 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!( + 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), + ); + 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(); +} + +#[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), + ); + 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/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/integration/parity_fixture.rs b/crates/proof-compression/tests/integration/parity_fixture.rs new file mode 100644 index 000000000..fb6fb902b --- /dev/null +++ b/crates/proof-compression/tests/integration/parity_fixture.rs @@ -0,0 +1,174 @@ +//! Pin the inner statement and fixed shape before building its circuit verifier. + +use crate::support::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/integration/pipeline_root.rs b/crates/proof-compression/tests/integration/pipeline_root.rs new file mode 100644 index 000000000..0db6f36df --- /dev/null +++ b/crates/proof-compression/tests/integration/pipeline_root.rs @@ -0,0 +1,356 @@ +//! 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, +}; +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, +) { + 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, + 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_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 = || { + (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); + check_root_directory( + &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()); + 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()); + } +} + +#[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_root_directory( + 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-root-directory-{}-{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::check_root(&input, &output)?; + 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"] +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 }, + ) + }; + 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)?; + let verifier = RecursiveVerifier::from_bytes(&verifier.to_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/integration/plonkish_batch.rs b/crates/proof-compression/tests/integration/plonkish_batch.rs new file mode 100644 index 000000000..3232e99d8 --- /dev/null +++ b/crates/proof-compression/tests/integration/plonkish_batch.rs @@ -0,0 +1,341 @@ +use crate::support::parity as 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/integration/plonkish_hash.rs b/crates/proof-compression/tests/integration/plonkish_hash.rs new file mode 100644 index 000000000..584d605fe --- /dev/null +++ b/crates/proof-compression/tests/integration/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/integration/plonkish_pcs.rs b/crates/proof-compression/tests/integration/plonkish_pcs.rs new file mode 100644 index 000000000..f0bb1463c --- /dev/null +++ b/crates/proof-compression/tests/integration/plonkish_pcs.rs @@ -0,0 +1,142 @@ +use crate::support::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/integration/plonkish_plan.rs b/crates/proof-compression/tests/integration/plonkish_plan.rs new file mode 100644 index 000000000..3a5f759f5 --- /dev/null +++ b/crates/proof-compression/tests/integration/plonkish_plan.rs @@ -0,0 +1,672 @@ +//! 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; +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", .. }) + )); +} + +#[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, ..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/integration/plonkish_verifier_algebra.rs b/crates/proof-compression/tests/integration/plonkish_verifier_algebra.rs new file mode 100644 index 000000000..dd81a9f26 --- /dev/null +++ b/crates/proof-compression/tests/integration/plonkish_verifier_algebra.rs @@ -0,0 +1,552 @@ +use crate::support::parity; +use crate::support::parity_algebra as adapter; + +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 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()); + 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/integration/plonkish_verifier_security.rs b/crates/proof-compression/tests/integration/plonkish_verifier_security.rs new file mode 100644 index 000000000..bd03824c7 --- /dev/null +++ b/crates/proof-compression/tests/integration/plonkish_verifier_security.rs @@ -0,0 +1,196 @@ +use crate::support::verifier_audit as 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/integration/root_profile.rs b/crates/proof-compression/tests/integration/root_profile.rs new file mode 100644 index 000000000..3030b807f --- /dev/null +++ b/crates/proof-compression/tests/integration/root_profile.rs @@ -0,0 +1,265 @@ +use crate::support::root_profile as 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/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/integration/support/parity.rs b/crates/proof-compression/tests/integration/support/parity.rs new file mode 100644 index 000000000..dd7638c5e --- /dev/null +++ b/crates/proof-compression/tests/integration/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/integration/support/parity_algebra.rs b/crates/proof-compression/tests/integration/support/parity_algebra.rs new file mode 100644 index 000000000..c9edb5518 --- /dev/null +++ b/crates/proof-compression/tests/integration/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/integration/support/root_profile.rs b/crates/proof-compression/tests/integration/support/root_profile.rs new file mode 100644 index 000000000..62aec9f34 --- /dev/null +++ b/crates/proof-compression/tests/integration/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/integration/support/verifier_audit.rs b/crates/proof-compression/tests/integration/support/verifier_audit.rs new file mode 100644 index 000000000..d4d87ca09 --- /dev/null +++ b/crates/proof-compression/tests/integration/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, + ) +} 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 000000000..8936898da Binary files /dev/null and b/crates/proof-compression/verifying-keys/0.1/groth16-development-verifying-key.bin differ diff --git a/crates/proof-compression/verifying-keys/0.1/layout.json b/crates/proof-compression/verifying-keys/0.1/layout.json new file mode 100644 index 000000000..5b8da59c4 --- /dev/null +++ b/crates/proof-compression/verifying-keys/0.1/layout.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/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 000000000..6e7546747 Binary files /dev/null and b/crates/proof-compression/verifying-keys/0.1/plonk-verifying-key.bin differ 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 000000000..5d075d40a Binary files /dev/null and b/crates/proof-compression/verifying-keys/0.1/recursive-vk.bin differ diff --git a/docs/aiur-gpu-proving.md b/docs/aiur-gpu-proving.md index 7d730ae91..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,22 +18,34 @@ 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. 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 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`. +The build detects the installed GPUs and compiles only their distinct SM +architectures. Set `MULTI_STARK_CUDA_ARCHS` explicitly for cross builds or +hosts without a GPU (`120` is Blackwell, `80` is the CI target). + +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`. + +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 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 never searches `/usr/lib/x86_64-linux-gnu`; the statically linked CUDA @@ -66,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 --trace-shards \ - --lanes 4 --exec-jobs 3 --max-ram 230 --out-ixes mathlib-proven.ixes \ - > lanes.out 2> lanes.err + ix prove --ixe mathlib.ixe --ixes mathlib.ixes \ + --gpus 4 --exec-jobs 3 --max-ram 230 --out-ixes mathlib-proven.ixes \ + > 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 @@ -84,24 +96,25 @@ 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 -`ix aggregate --trace-shards --subtree ` for its subtree; the lanes +with `ix prove --trace-shards --shards ` for a GPU's claims and +`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 --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: - **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. @@ -117,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%. @@ -180,11 +193,11 @@ 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. | -| `--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 TeXRay output for compression. | ## 6. Benchmarking a multi-GPU run @@ -193,34 +206,39 @@ 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 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