diff --git a/.gitignore b/.gitignore index 62c899d4c4..7f36659013 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ circuits/benchmarks/results_*/raw/ # Generated by crates/support/methods/build.rs. Holds a machine-local guest ELF path, # so it is never committed. crates/support/tests/Elf.sol +.vercel +.vercel-env-backups/ diff --git a/Cargo.lock b/Cargo.lock index 244f203b7a..293d3106b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,7 +95,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "flate2", "foldhash 0.1.5", @@ -210,7 +210,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "foldhash 0.1.5", "futures-core", @@ -455,7 +455,7 @@ dependencies = [ "alloy-tx-macros", "auto_impl", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "k256 0.13.4", "once_cell", @@ -584,7 +584,7 @@ dependencies = [ "alloy-serde", "auto_impl", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "serde", "serde_with", @@ -665,7 +665,7 @@ dependencies = [ "alloy-sol-types", "async-trait", "auto_impl", - "derive_more", + "derive_more 2.1.1", "futures-utils-wasm", "serde", "serde_json", @@ -716,7 +716,7 @@ dependencies = [ "bytes", "cfg-if", "const-hex", - "derive_more", + "derive_more 2.1.1", "foldhash 0.2.0", "hashbrown 0.16.1", "indexmap 2.14.0", @@ -767,7 +767,7 @@ dependencies = [ "either", "futures", "futures-utils-wasm", - "lru", + "lru 0.13.0", "parking_lot 0.12.5", "pin-project", "reqwest", @@ -809,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" dependencies = [ "alloy-rlp-derive", - "arrayvec", + "arrayvec 0.7.6", "bytes", ] @@ -897,7 +897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388cf910e66bd4f309a81ef746dcf8f9bca2226e3577890a8d56c5839225cf46" dependencies = [ "alloy-primitives", - "derive_more", + "derive_more 2.1.1", "serde", "serde_with", ] @@ -913,7 +913,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-serde", - "derive_more", + "derive_more 2.1.1", "rand 0.8.5", "serde", "strum 0.27.2", @@ -1091,7 +1091,7 @@ dependencies = [ "alloy-primitives", "auto_impl", "base64 0.22.1", - "derive_more", + "derive_more 2.1.1", "futures", "futures-utils-wasm", "parking_lot 0.12.5", @@ -1166,8 +1166,8 @@ checksum = "428aa0f0e0658ff091f8f667c406e034b431cb10abd39de4f507520968acc499" dependencies = [ "alloy-primitives", "alloy-rlp", - "arrayvec", - "derive_more", + "arrayvec 0.7.6", + "derive_more 2.1.1", "nybbles", "serde", "smallvec", @@ -1415,7 +1415,7 @@ dependencies = [ "ark-ff-macros 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", - "arrayvec", + "arrayvec 0.7.6", "digest 0.10.7", "educe", "itertools 0.13.0", @@ -1618,7 +1618,7 @@ checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ "ark-serialize-derive 0.5.0", "ark-std 0.5.0", - "arrayvec", + "arrayvec 0.7.6", "digest 0.10.7", "num-bigint 0.4.6", ] @@ -1714,6 +1714,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -1809,6 +1818,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-global-executor" version = "2.4.1" @@ -1853,6 +1873,35 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -1864,6 +1913,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-std" version = "1.13.1" @@ -1963,6 +2030,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-take" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1998,6 +2071,51 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "avail-rust-client" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46069ba8315a805aa78fe55ef47b6eabe8196d36d8f3dece75aed9274c427d88" +dependencies = [ + "avail-rust-core", + "const-hex", + "frame-metadata 20.0.0", + "futures", + "impl-serde", + "parity-scale-codec", + "reqwest", + "scale-info", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "avail-rust-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44ba0d18f0ad92c3223be561221d439a85aa071a0d3defa5e846c4c06e269d5e" +dependencies = [ + "const-hex", + "impl-serde", + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-decode", + "scale-encode", + "scale-info", + "schnorrkel", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-core", + "subxt-rpcs", + "subxt-signer", + "thiserror 2.0.18", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -2041,6 +2159,12 @@ dependencies = [ "match-lookup", ] +[[package]] +name = "base58" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" + [[package]] name = "base64" version = "0.22.1" @@ -2074,6 +2198,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -2171,6 +2306,26 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq 0.1.5", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec 0.7.6", + "constant_time_eq 0.4.2", +] + [[package]] name = "blake3" version = "1.8.3" @@ -2178,13 +2333,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.7.6", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.4.2", "cpufeatures 0.2.17", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -2427,6 +2591,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -2439,6 +2609,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -2500,6 +2681,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout 0.1.4", + "zeroize", ] [[package]] @@ -2585,6 +2767,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "commitlog" version = "0.2.0" @@ -2698,12 +2890,24 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.10.0" @@ -2734,6 +2938,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -2876,6 +3090,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -2938,6 +3161,31 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher 0.4.4", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "ctr" version = "0.9.2" @@ -2984,6 +3232,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -3004,6 +3262,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -3032,6 +3304,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -3091,7 +3374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3150,13 +3433,57 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3165,7 +3492,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version 0.4.1", @@ -3302,6 +3629,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dtoa" version = "1.0.11" @@ -3613,12 +3946,28 @@ dependencies = [ ] [[package]] -name = "e3-entrypoint" +name = "e3-data-availability" version = "0.14.0" dependencies = [ - "actix", - "actix-web", - "alloy", + "alloy-primitives", + "alloy-sol-types", + "anyhow", + "async-trait", + "avail-rust-client", + "hex", + "reqwest", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "e3-entrypoint" +version = "0.14.0" +dependencies = [ + "actix", + "actix-web", + "alloy", "alloy-primitives", "anyhow", "bincode", @@ -3706,11 +4055,14 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "bincode", "bloom", + "e3-bfv-client", "e3-ciphernode-builder", "e3-config", "e3-crypto", "e3-data", + "e3-data-availability", "e3-entrypoint", "e3-events", "e3-evm", @@ -4022,6 +4374,9 @@ dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "hex", + "rand 0.9.2", + "rand_chacha 0.9.0", + "risc0-bigint2", "sha3", "taceo-poseidon2", ] @@ -4407,6 +4762,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -4619,7 +4988,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "auto_impl", "bytes", ] @@ -4630,7 +4999,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "auto_impl", "bytes", ] @@ -4847,6 +5216,43 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frame-decode" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cb8796f93fa038f979a014234d632e9688a120e745f936e2635123c77537f7" +dependencies = [ + "frame-metadata 21.0.0", + "parity-scale-codec", + "scale-decode", + "scale-info", + "scale-type-resolver", + "sp-crypto-hashing", +] + +[[package]] +name = "frame-metadata" +version = "20.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26de808fa6461f2485dc51811aefed108850064994fb4a62b3ac21ffa62ac8df" +dependencies = [ + "cfg-if", + "parity-scale-codec", + "scale-info", + "serde", +] + +[[package]] +name = "frame-metadata" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dfd1d7eae1d94e32e869e2fb272d81f52dd8db57820a373adb83ea24d7d862" +dependencies = [ + "cfg-if", + "parity-scale-codec", + "scale-info", +] + [[package]] name = "fs2" version = "0.4.3" @@ -5058,6 +5464,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand 0.8.5", + "rand_core 0.6.4", +] + [[package]] name = "ghash" version = "0.5.1" @@ -5084,7 +5500,7 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "url", ] @@ -5200,6 +5616,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", + "allocator-api2", + "serde", ] [[package]] @@ -5275,7 +5693,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", ] [[package]] @@ -5340,6 +5758,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -5358,6 +5786,17 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + [[package]] name = "http" version = "0.2.12" @@ -5668,7 +6107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdf9d64cfcf380606e64f9a0bcf493616b65331199f984151a6fa11a7b3cde38" dependencies = [ "async-io", - "core-foundation", + "core-foundation 0.9.4", "fnv", "futures", "if-addrs", @@ -5729,12 +6168,30 @@ dependencies = [ "parity-scale-codec", ] +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +dependencies = [ + "parity-scale-codec", +] + [[package]] name = "impl-more" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "impl-serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a143eada6a1ec4aefa5049037a26a6d597bfd64f8c026d07b77133e02b7dd0b" +dependencies = [ + "serde", +] + [[package]] name = "impl-trait-for-tuples" version = "0.2.3" @@ -5746,6 +6203,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "include_bytes_aligned" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee796ad498c8d9a1d68e477df8f754ed784ef875de1414ebdaf169f70a6a784" + [[package]] name = "indenter" version = "0.3.4" @@ -5775,6 +6238,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + [[package]] name = "indicatif" version = "0.17.11" @@ -5970,6 +6439,50 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -5990,13 +6503,68 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpsee" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c4b1f204b655b36b24dc4939af20366c649431d4711863bbbae5c495f3eeb4" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core 0.24.11", + "jsonrpsee-types 0.24.11", + "jsonrpsee-ws-client", +] + [[package]] name = "jsonrpsee" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" dependencies = [ - "jsonrpsee-core", + "jsonrpsee-core 0.26.0", +] + +[[package]] +name = "jsonrpsee-client-transport" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e1420b1792cff778e2a1ebaa44115f156ee62a94dd106eaa51163f037d2023" +dependencies = [ + "base64 0.22.1", + "futures-util", + "http 1.4.0", + "jsonrpsee-core 0.24.11", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49bfa9334963e1c85866b39dff3ffcc81f1c286eb23334267c5cb97677543a4" +dependencies = [ + "async-trait", + "futures-timer", + "futures-util", + "jsonrpsee-types 0.24.11", + "pin-project", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", ] [[package]] @@ -6008,7 +6576,7 @@ dependencies = [ "async-trait", "futures-util", "http 1.4.0", - "jsonrpsee-types", + "jsonrpsee-types 0.26.0", "pin-project", "serde", "serde_json", @@ -6018,6 +6586,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "jsonrpsee-types" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d86fc943f81dab0ecdd6c0240b6e0f55ad57a2ea9ad8ad7efe8456fb9cc7a4" +dependencies = [ + "http 1.4.0", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonrpsee-types" version = "0.26.0" @@ -6030,6 +6610,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jsonrpsee-ws-client" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df5bd5c38c0906a6e8b3a38c8c22cc8525fda25fd1a03a3fe010686aea66b70" +dependencies = [ + "http 1.4.0", + "jsonrpsee-client-transport", + "jsonrpsee-core 0.24.11", + "jsonrpsee-types 0.24.11", + "url", +] + [[package]] name = "k256" version = "0.13.4" @@ -6088,6 +6681,16 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "keccak-hash" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1b8590eb6148af2ea2d75f38e7d29f5ca970d5a4df456b3ef19b8b415d0264" +dependencies = [ + "primitive-types 0.13.1", + "tiny-keccak", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -6526,6 +7129,54 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64 0.22.1", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.8.5", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + [[package]] name = "libssh2-sys" version = "0.3.1" @@ -6611,6 +7262,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru" version = "0.13.0" @@ -6682,6 +7342,18 @@ dependencies = [ "libc", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak 0.1.5", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "mime" version = "0.3.17" @@ -6755,6 +7427,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "multi-stash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" + [[package]] name = "multiaddr" version = "0.18.2" @@ -6823,7 +7501,7 @@ source = "git+https://github.com/noir-lang/noir?tag=v1.0.0-beta.26#40d6574f851d9 dependencies = [ "acvm", "fm", - "jsonrpsee", + "jsonrpsee 0.26.0", "noir_greybox_fuzzer", "noirc_abi", "noirc_artifacts", @@ -6850,10 +7528,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -6949,6 +7627,12 @@ dependencies = [ "libc", ] +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + [[package]] name = "noir_greybox_fuzzer" version = "1.0.0-beta.26" @@ -7221,8 +7905,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "num-integer" -version = "0.1.46" +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ @@ -7391,6 +8086,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -7540,7 +8241,7 @@ version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "bitvec", "byte-slice-cast", "const_format", @@ -7639,6 +8340,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", + "password-hash", +] + [[package]] name = "pear" version = "0.2.9" @@ -7717,7 +8429,7 @@ checksum = "9cd31dcfdbbd7431a807ef4df6edd6473228e94d5c805e8cf671227a21bad068" dependencies = [ "anyhow", "clap", - "itertools 0.14.0", + "itertools 0.15.0", "proc-macro2", "quote", "rand 0.8.5", @@ -7886,6 +8598,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -7992,10 +8715,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" dependencies = [ "fixed-hash", - "impl-codec", + "impl-codec 0.6.0", "uint 0.9.5", ] +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "impl-codec 0.7.1", + "impl-serde", + "scale-info", + "uint 0.10.0", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -8329,7 +9065,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -8674,6 +9410,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "risc0-bigint2" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87f5f7494a2242cead2750b7ce2b8522c1be83dee268479f1c12ed521eaf595" +dependencies = [ + "include_bytes_aligned", + "stability", +] + [[package]] name = "rlp" version = "0.5.2" @@ -8738,7 +9484,7 @@ dependencies = [ "num-integer", "num-traits", "parity-scale-codec", - "primitive-types", + "primitive-types 0.12.2", "proptest", "rand 0.8.5", "rand 0.9.2", @@ -8859,6 +9605,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -8867,6 +9614,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.6.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -8877,6 +9636,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.6.0", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -8906,6 +9692,16 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ruzstd" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5174a470eeb535a721ae9fdd6e291c2411a906b96592182d05217591d5c5cf7b" +dependencies = [ + "byteorder", + "derive_more 0.99.20", +] + [[package]] name = "rw-stream-sink" version = "0.4.0" @@ -8970,6 +9766,15 @@ dependencies = [ "safe-regex-compiler", ] +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "same-file" version = "1.0.6" @@ -8979,6 +9784,124 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scale-bits" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27243ab0d2d6235072b017839c5f0cd1a3b1ce45c0f7a715363b0c7d36c76c94" +dependencies = [ + "parity-scale-codec", + "scale-info", + "scale-type-resolver", + "serde", +] + +[[package]] +name = "scale-decode" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d6ed61699ad4d54101ab5a817169259b5b0efc08152f8632e61482d8a27ca3d" +dependencies = [ + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-bits", + "scale-decode-derive", + "scale-type-resolver", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-decode-derive" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cb245f7fdb489e7ba43a616cbd34427fe3ba6fe0edc1d0d250085e6c84f3ec" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "scale-encode" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a976d73564a59e482b74fd5d95f7518b79ca8c8ca5865398a4d629dd15ee50" +dependencies = [ + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-bits", + "scale-encode-derive", + "scale-type-resolver", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-encode-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17020f2d59baabf2ddcdc20a4e567f8210baf089b8a8d4785f5fd5e716f92038" +dependencies = [ + "darling 0.20.11", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "bitvec", + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", + "serde", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "scale-type-resolver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0cded6518aa0bd6c1be2b88ac81bf7044992f0f154bfbabd5ad34f43512abcb" +dependencies = [ + "scale-info", + "smallvec", +] + +[[package]] +name = "scale-value" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b64809a541e8d5a59f7a9d67cc700cdf5d7f907932a83a0afdedc90db07ccb" +dependencies = [ + "either", + "parity-scale-codec", + "scale-bits", + "scale-decode", + "scale-encode", + "scale-type-resolver", + "thiserror 2.0.18", +] + [[package]] name = "scc" version = "2.4.0" @@ -9021,12 +9944,43 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.6", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core 0.6.4", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sdd" version = "3.0.10" @@ -9083,6 +10037,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -9090,7 +10053,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -9098,9 +10074,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -9331,6 +10307,19 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -9546,44 +10535,186 @@ dependencies = [ ] [[package]] -name = "smol_str" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d" -dependencies = [ - "borsh", - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" +name = "smol" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "libc", - "windows-sys 0.52.0", + "async-channel 2.5.0", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", ] [[package]] -name = "socket2" -version = "0.6.2" +name = "smol_str" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d" dependencies = [ - "libc", - "windows-sys 0.60.2", + "borsh", + "serde", ] [[package]] -name = "spki" -version = "0.7.3" +name = "smoldot" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "966e72d77a3b2171bb7461d0cb91f43670c63558c62d7cf42809cae6c8b6b818" dependencies = [ - "base64ct", - "der 0.7.10", -] + "arrayvec 0.7.6", + "async-lock", + "atomic-take", + "base64 0.22.1", + "bip39", + "blake2-rfc", + "bs58", + "chacha20 0.9.1", + "crossbeam-queue", + "derive_more 0.99.20", + "ed25519-zebra", + "either", + "event-listener 5.4.1", + "fnv", + "futures-lite", + "futures-util", + "hashbrown 0.14.5", + "hex", + "hmac 0.12.1", + "itertools 0.13.0", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint 0.4.6", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand 0.8.5", + "rand_chacha 0.3.1", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot-light" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a33b06891f687909632ce6a4e3fd7677b24df930365af3d0bcb078310129f3f" +dependencies = [ + "async-channel 2.5.0", + "async-lock", + "base64 0.22.1", + "blake2-rfc", + "bs58", + "derive_more 0.99.20", + "either", + "event-listener 5.4.1", + "fnv", + "futures-channel", + "futures-lite", + "futures-util", + "hashbrown 0.14.5", + "hex", + "itertools 0.13.0", + "log", + "lru 0.12.5", + "parking_lot 0.12.5", + "pin-project", + "rand 0.8.5", + "rand_chacha 0.3.1", + "serde", + "serde_json", + "siphasher", + "slab", + "smol", + "smoldot", + "zeroize", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "httparse", + "log", + "rand 0.8.5", + "sha1", +] + +[[package]] +name = "sp-crypto-hashing" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9927a7f81334ed5b8a98a4a978c81324d12bd9713ec76b5c68fd410174c5eb" +dependencies = [ + "blake2b_simd", + "byteorder", + "digest 0.10.7", + "sha2 0.10.9", + "sha3", + "twox-hash", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] [[package]] name = "spki" @@ -9595,6 +10726,16 @@ dependencies = [ "der 0.8.1", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -9607,6 +10748,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string-interner" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6a0d765f5807e98a091107bae0a56ea3799f66a5de47b2c84c94a39c09974e" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", +] + [[package]] name = "strsim" version = "0.11.1" @@ -9658,6 +10809,118 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subxt-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66ef00be9d64885ec94e478a58e4e39d222024b20013ae7df4fc6ece545391aa" +dependencies = [ + "base58", + "blake2 0.10.6", + "derive-where", + "frame-decode", + "frame-metadata 20.0.0", + "hashbrown 0.14.5", + "hex", + "impl-serde", + "keccak-hash", + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-bits", + "scale-decode", + "scale-encode", + "scale-info", + "scale-value", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-metadata", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "subxt-lightclient" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce07c2515b2e63b85ec3043fe4461b287af0615d4832c2fe6e81ba780b906bc0" +dependencies = [ + "futures", + "futures-util", + "serde", + "serde_json", + "smoldot-light", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "subxt-metadata" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff4591673600c4388e21305788282414d26c791b4dee21b7cb0b19c10076f98" +dependencies = [ + "frame-decode", + "frame-metadata 20.0.0", + "hashbrown 0.14.5", + "parity-scale-codec", + "scale-info", + "sp-crypto-hashing", + "thiserror 2.0.18", +] + +[[package]] +name = "subxt-rpcs" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba7494d250d65dc3439365ac5e8e0fbb9c3992e6e84b7aa01d69e082249b8b8" +dependencies = [ + "derive-where", + "frame-metadata 20.0.0", + "futures", + "hex", + "impl-serde", + "jsonrpsee 0.24.11", + "parity-scale-codec", + "primitive-types 0.13.1", + "serde", + "serde_json", + "subxt-lightclient", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "subxt-signer" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a2370298a210ed1df26152db7209a85e0ed8cfbce035309c3b37f7b61755377" +dependencies = [ + "base64 0.22.1", + "bip39", + "cfg-if", + "crypto_secretbox", + "hex", + "hmac 0.12.1", + "parity-scale-codec", + "pbkdf2", + "regex", + "schnorrkel", + "scrypt", + "secp256k1", + "secrecy", + "serde", + "serde_json", + "sha2 0.10.9", + "sp-crypto-hashing", + "subxt-core", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "syn" version = "1.0.109" @@ -9730,7 +10993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -10033,6 +11296,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -10262,6 +11526,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" @@ -10272,6 +11546,8 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", @@ -10279,6 +11555,7 @@ dependencies = [ "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -10327,6 +11604,18 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "digest 0.10.7", + "rand 0.8.5", + "static_assertions", +] + [[package]] name = "typenum" version = "1.20.1" @@ -10384,6 +11673,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -10634,6 +11932,55 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmi" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50386c99b9c32bd2ed71a55b6dd4040af2580530fae8bdb9a6576571a80d0cca" +dependencies = [ + "arrayvec 0.7.6", + "multi-stash", + "num-derive", + "num-traits", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "wasmi_collections" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c128c039340ffd50d4195c3f8ce31aac357f06804cfc494c8b9508d4b30dca4" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23b3a7f6c8c3ceeec6b83531ee61f0013c56e51cbf2b14b0f213548b23a4b41" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -10668,6 +12015,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.9", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -10828,6 +12193,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -10873,6 +12247,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -10921,6 +12310,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -10939,6 +12334,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -10957,6 +12358,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -10987,6 +12394,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -11005,6 +12418,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -11023,6 +12442,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -11041,6 +12466,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -11129,6 +12560,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index 7eb9f2b841..97d9bbd6f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/dashboard", "crates/crypto", "crates/data", + "crates/data-availability", "crates/interfoldup", "crates/entrypoint", "crates/events", @@ -85,6 +86,7 @@ e3-dashboard = { version = "0.14.0", path = "./crates/dashboard" } e3-ciphernode-builder = { version = "0.14.0", path = "./crates/ciphernode-builder" } e3-crypto = { version = "0.14.0", path = "./crates/crypto" } e3-data = { version = "0.14.0", path = "./crates/data" } +e3-data-availability = { version = "0.14.0", path = "./crates/data-availability" } e3-hamt = { version = "0.14.0", path = "./crates/hamt" } e3-request = { version = "0.14.0", path = "./crates/request" } e3-sdk = { version = "0.14.0", path = "./crates/sdk" } @@ -132,6 +134,7 @@ alloy-primitives = { version = "=1.4.1", default-features = false, features = [ ] } alloy-sol-types = "=1.4.1" alloy-dyn-abi = "=1.4.1" +avail-rust-client = "=0.5.1" argon2 = "=0.5.3" ark-ff = "0.5.0" ark-bn254 = "0.5.0" diff --git a/README.md b/README.md index 81a33eaf1b..af33d7e993 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,9 @@ sequenceDiagram participant RandomnessProvider participant E3Program participant ComputeProvider + participant CiphertextVerifier participant DecryptionVerifier + participant Ciphernodes Users->>Interfold: request(parameters) Interfold->>E3Program: validate(e3ProgramParams) @@ -291,23 +293,34 @@ sequenceDiagram Interfold->>Interfold: Set expiration and committeePublicKey Interfold-->>Users: success - Users->>Interfold: publishInput(e3Id, data) - Interfold->>E3Program: validateInput(msg.sender, data) - E3Program-->>Interfold: input, success - Interfold->>Interfold: Store input - Interfold-->>Users: success - - Users->>Interfold: publishCiphertextOutput(e3Id, data) - Interfold->>DecryptionVerifier: verify(e3Id, data) - DecryptionVerifier-->>Interfold: output, success + Users->>CRISPServer: stage encrypted input + Noir proof + CRISPServer-->>Users: signed compact commitment + Users->>E3Program: publishInput(e3Id, commitment) + E3Program-->>Users: InputCommitted + CRISPServer->>Avail: submit_data(encryptedInput) + CRISPServer->>E3Program: finalizeInput(e3Id, VectorX receipt) + E3Program-->>CRISPServer: InputPublished + + ComputeProvider-->>CRISPServer: aggregateCiphertext + RISC Zero proof + CRISPServer->>E3Program: verify output proof (read only) + CRISPServer->>Avail: submit_data(aggregateCiphertext) + CRISPServer->>Interfold: publishCiphertextOutput(e3Id, reference + VectorX receipt) + Interfold->>E3Program: verifyDataAvailability(contentHash, receipt) + Interfold->>CiphertextVerifier: verify compute proof + CiphertextVerifier-->>Interfold: success + Interfold->>E3Program: verify compute proof + E3Program-->>Interfold: success Interfold->>Interfold: Store ciphertextOutput - Interfold-->>Users: success + Interfold-->>CRISPServer: success - Users->>Interfold: publishPlaintextOutput(e3Id, data) + Ciphernodes->>Avail: retrieve aggregateCiphertext + Ciphernodes->>Interfold: publishPlaintextOutput(e3Id, data) Interfold->>E3Program: verify(e3Id, data) E3Program-->>Interfold: output, success + Interfold->>DecryptionVerifier: verify plaintext proof + DecryptionVerifier-->>Interfold: success Interfold->>Interfold: Store plaintextOutput - Interfold-->>Users: success + Interfold-->>Ciphernodes: success ``` ## ๐Ÿš€ Release Process diff --git a/agent/INVARIANTS.md b/agent/INVARIANTS.md index 12351499ea..ac3ff39dac 100644 --- a/agent/INVARIANTS.md +++ b/agent/INVARIANTS.md @@ -254,12 +254,16 @@ design citation alone does not establish current runtime behavior. deadline watermark. The time-based floor decreases after old windows expire, and the BondingRegistry cannot clear its registry pointer. โ€” `flow-trace/02`, `03`, `06`; INDEX Z-37 - **E3 program allowlist:** production initialization registers one deployed E3 program and assigns - Interfold ownership to the configured protocol owner. Later registrations are append-only and - owner-only. Every registered address must contain runtime code. `MockE3Program` is the stateless - launch option. It has no administrative controls and applies no application rules. The - request-time BFV ciphertext verifier and decryption verifier remain mandatory. Its mutable failure - controls live only in `MockE3ProgramHarness`. โ€” `Interfold.sol`; `MockE3Program.sol`; - `flow-trace/03` + Interfold ownership to the configured protocol owner. Later registration and retirement are + owner-only. Retirement closes only new request admission; existing E3s keep their snapshotted + program. Every registered address must contain runtime code. `MockE3Program` is the stateless + bootstrap option. It has no administrative controls and applies no application rules. Its + deterministic test receipt is not production data availability, so requests remain paused until a + production program is registered and wired. The request-time BFV ciphertext verifier and + decryption verifier remain mandatory. Its mutable failure controls live only in + `MockE3ProgramHarness`. A protocol upgrade that makes the program interface incompatible must + retire every incompatible bootstrap program before requests resume. โ€” `Interfold.sol`; + `MockE3Program.sol`; `flow-trace/03` ### Deadlines @@ -437,10 +441,30 @@ design citation alone does not establish current runtime behavior. skips the comparison accepts a result computed over any input set. โ€” `flow-trace/04` - **A Secure Process derives its leaves; it never receives them, and never drops one.** `MerkleTreeBuilder::compute_leaf_hashes` builds every leaf from the ciphertexts it was given and - pushes one per published input, whatever the E3 program's policy decides about computing over it. - Both rules are applied by `e3-compute-provider` rather than delegated: a received root can + pushes one per on-chain input leaf, whatever the E3 program's policy decides about computing over + it. Both rules are applied by `e3-compute-provider` rather than delegated: a received root can disagree with the data it claims to describe, and a missing leaf changes the root and makes the result unpublishable. โ€” `flow-trace/04` +- **A CRISP input is committed before it is finalized, but computation requires both.** + `publishInput` verifies the Noir proof and the configured service's EIP-712 storage attestation, + then reserves the leaf and index so a later input can name it as its parent. `finalizeInput` must + prove VectorX availability for the exact committed content hash. The input tuple cannot change + between the calls, and `CRISPProgram.verify` must reject while any committed input remains + pending. โ€” `flow-trace/08` +- **CRISP input envelopes use one flat ABI parameter sequence.** The SDK uses `encodeAbiParameters` + for the six fields. The server uses parameter decoding for that sequence and parameter encoding + for the signed commitment payload that Solidity reads through `abi.decode`. Treating the envelope + as one wrapped struct adds a leading tuple offset and breaks the boundary. โ€” `flow-trace/08` +- **Avail-backed CRISP rounds leave a usable voting interval.** `CRISPProgram.validate` derives the + worst-case key time from the E3 timeout snapshot and the request-time Registry windows. The + commitment cutoff must be at least one hour after both that time and the configured input start. + The server repeats the production minimum as an early configuration check, but it is not the + security boundary. โ€” `flow-trace/08` +- **Avail references name exact application bytes.** The application content hash is + `keccak256(rawBytes)`, which is also the `leaf` returned by Avail's proof API. The official bridge + hashes that value once more when it verifies the submitted-data Merkle root. Solidity binds the + proof API leaf to `contentHash`, and every reader hashes retrieved raw bytes again before decoding + or proving over them. An App ID or RPC response is not a correctness proof. โ€” `flow-trace/08` - **The leaf layout and input selection are the E3 program's, not the crate's.** They are supplied as an `InputPolicy`, because a leaf must match whatever that program builds on chain and no two programs need agree, and because "what does a second input for the same participant mean?" has no @@ -449,7 +473,7 @@ design citation alone does not establish current runtime behavior. `policy()` beside `fhe_processor`. โ€” `flow-trace/04` - **CRISP binds bytes, commitment, slot and parent into its leaf, and selects the end of each slot's chain.** `CRISPProgram.inputLeaf` is - `sha256(sha256(bytes) || commitment || slot || parentIndexPlusOne) mod SNARK_SCALAR_FIELD` and + `sha256(keccak256(bytes) || commitment || slot || parentIndexPlusOne) mod SNARK_SCALAR_FIELD` and `e3_user_program::policy` rebuilds it byte for byte; a divergence makes every root mismatch and nothing else would catch it, so both sides pin the same vector (`program/tests/input_leaf.rs`, `tests/input-leaf.test.ts`) and `onchain_root_agreement.rs` asserts Rust reproduces a root a real @@ -497,11 +521,14 @@ design citation alone does not establish current runtime behavior. padded ciphertext would otherwise share a commitment with its two-component prefix while threshold decryption rejects it, failing the round as a `DecryptionTimeout` billed to the ciphernodes. โ€” `flow-trace/04` -- **Client PK commitment binding (C-01):** serialized PK event bytes are an untrusted transport - hint; indexers store the decoded key only when its recomputed commitment equals the on-chain - (C5-proven) value. Proof-backed committee publication never accepts key bytes. Public-key - candidates are bounded, permissionless, and repeatable, so an invalid candidate cannot block a - later valid one. โ€” INDEX concerns #33, Z-31 +- **Client PK commitment binding (C-01):** Serialized PK event bytes are an untrusted transport + hint. Consumers decode the bytes with the request-time threshold BFV parameters. Consumers store + the key only when its recomputed commitment equals the on-chain (C5-proven) value. Proof-backed + committee publication never accepts key bytes. Public-key candidates are bounded and gated to + request-time committee members while the E3 remains in `KeyPublished`. Retained expelled members + can still repair transport, but their bytes receive no extra trust. Terminal E3s cannot create new + durable assemblies after cleanup. Consumers accept at most one candidate per member, so an invalid + candidate cannot block a valid candidate from another member. โ€” INDEX concerns #33, Z-31 - **No proof-disabled bypass (C-02):** both final verifier calls are mandatory in production; `skip_proof_aggregation` works only under the `test-only-skip-proof-aggregation` Cargo feature; production verifiers reject placeholder C5/C7 proofs. โ€” INDEX concern #32 @@ -562,6 +589,12 @@ design citation alone does not establish current runtime behavior. from its missing EventStore suffix, and the final snapshot drain preserves event order. An older contextual write must never replace newer admission state or move a covered-prefix cursor backward. โ€” INDEX concern #43 +- Before actor hydration, startup checks each persisted request context against finalized Ethereum + lifecycle state. A complete E3 or a non-slashing failed E3 must not resume local protocol work. A + failed E3 that requires accusation or slashing work must retain its context. An unavailable or + unknown canonical result must fail startup. If the E3 exists at chain head but not yet at the + finalized block, recovery keeps the context and waits; finality lag is not an unknown E3. โ€” INDEX + concern #48 - EventStore replay preserves durable sequence inside each aggregate. It uses HLC order only to choose between the next events of different aggregates. A late event can have an older remote HLC and must not move ahead of an earlier local sequence from the same aggregate. โ€” INDEX concern #43 diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index 0f07126f71..d13ad80911 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -11,6 +11,7 @@ | 5 | [05_FAILURE_REFUND_SLASHING.md](05_FAILURE_REFUND_SLASHING.md) | Timeout-based failure detection, `markE3Failed`, `processE3Failure`. Fault-attributed refunds: requester/DP/CP failures pay completed work from service fee escrow; supplier/ciphernode failures return all service fee escrow and compensate honest nodes from ticket slashes. The flat randomness fee is not refundable escrow. Off-chain accusation, Lane A/B slashing, and slashed-fund routing. | | 6 | [06_DEACTIVATION_AND_COMPLETION.md](06_DEACTIVATION_AND_COMPLETION.md) | Voluntary deactivation (ticket/ciphernode bond withdrawal), full deregistration (IMT removal), E3 happy-path completion, node shutdown, sync/restart, exit queue timing, ban/unban. | | 7 | [07_UPGRADES.md](07_UPGRADES.md) | Recommended rolling releases, mandatory node-only cutovers, combined contract/protocol upgrades, release acknowledgement, resume checks, wire compatibility, and rollback. | +| 8 | [08_DATA_AVAILABILITY.md](08_DATA_AVAILABILITY.md) | CRISP's two-step input commitment, Avail and VectorX finalization, aggregate-ciphertext publication, exact deadline simulation, restart recovery, and remaining availability trust. | --- @@ -198,7 +199,7 @@ _Found during source-code cross-referencing of these trace documents._ | 3 | `gracePeriod` is NOT added to deadline checks in `_checkFailureCondition()`. All timeout checks compare `block.timestamp` directly against the raw deadline. `gracePeriod` is only validated in `_setTimeoutConfig` but never referenced in failure detection. | Interfold.sol:860-887 | 05_FAILURE | | 5 | `E3Requested` event is `(uint256 e3Id, E3 e3, bytes32 indexed cryptoConfigId)` โ€” the configuration ID identifies the scheme, parameter hash, and circuit version used by the request. | IInterfold.sol | 03_E3_REQUEST | | 6 | `finalizeCommittee()` requires `block.timestamp > committeeDeadline`; ticket submissions remain valid through the deadline. | CiphernodeRegistryOwnable.sol | 03_E3_REQUEST | -| 7 | `publishCommittee()` is permissionless. DKG proof verification and the single-publish guard protect the proof-backed commitment. `publishCommitteePublicKey()` separately accepts repeatable, bounded transport candidates. | CiphernodeRegistryOwnable.sol | 04_DKG | +| 7 | `publishCommittee()` is permissionless. DKG proof verification and the single-publish guard protect the proof-backed commitment. `publishCommitteePublicKey()` separately accepts bounded transport candidates from request-time committee members. | CiphernodeRegistryOwnable.sol | 04_DKG | | 8 | `CommitteeProofPublished` carries the verified proof. `CommitteePublished` carries each distinct serialized key candidate and its proven commitment; the compatibility `proof` field is empty. Clients validate candidates before use. | CiphernodeRegistryOwnable.sol | 04_DKG | | 9 | `_validateNodeEligibility` calls `bondingRegistry.getTicketBalanceAtBlock()` (not `ticketToken.getPastVotes()` directly). | CiphernodeRegistryOwnable.sol:668 | 03_E3_REQUEST | | 10 | Lane A slashing uses **attestation-based** verification (committee quorum votes), not direct ZK proof re-verification on-chain. `proposeSlash()` decodes voter addresses, agrees, data hashes, and ECDSA signatures โ€” not ZK proofs. | SlashingManager.sol | 05_FAILURE | @@ -233,12 +234,12 @@ them are in the reference app (`examples/CRISP`), not the protocol. | 1 | **Deregister-before-slash race** | Resolved | One unresolved-proposal counter covers both lanes. Every authorized current or retained historical slashing manager participates in the BondingRegistry exit gate, so rotation cannot release collateral belonging to an in-flight E3. Ticket withdrawal, ciphernode bond unbonding, deregistration, and exit claims remain blocked until execution, upheld appeal, or permissionless expiry terminates the proposal. | | 2 | **Committee publication decentralized** | Resolved | `publishCommittee()` is permissionless. Off-chain role selection chooses the active aggregator, while on-chain C5 proof verification and the single-publish guard prevent invalid or duplicate committee publication. | | 3 | **`gracePeriod` is dead code** | Medium | `gracePeriod` is stored and validated during config updates but never actually used in any timeout check. Either the deadlines already bake in sufficient buffer, or this is a missing feature. | -| 5 | **Active-job load balancing bug fixed** | Info | Every node builds the same buffered winner set from complete request-time ticket ranges. A selected node applies `active_jobs` only when it decides whether to submit its canonical winning ticket. `E3Failed` and terminal `E3StageChanged` events release local workload slots. | +| 5 | **Active-job load balancing bug fixed** | Info | Every node builds the same buffered winner set from complete request-time ticket ranges. A selected node applies `active_jobs` only when it decides whether to submit its canonical winning ticket. The first committee publication increments the selected nodes; duplicate or conflicting replay for that E3 does not increment them again. `E3Failed` and terminal `E3StageChanged` events release local workload slots. Startup rebuilds the cached counters from the persisted open-committee map, so stale counters from an older replay cannot suppress a valid ticket. | | 6 | **Committee member expulsion** | Resolved | `SlashingManager` can call `expelCommitteeMember()` mid-DKG. The `Sortition` actor enriches the raw `CommitteeMemberExpelled` event with the expelled member's `party_id` (resolved from its stored `Committee` list) and re-publishes it. `ThresholdKeyshare` uses the enriched `party_id` to update its collectors. The public-key keyshare gate and aggregation reducer normalize both expulsion records and self-reported keyshare nodes to `alloy::Address`, so an expelled member cannot re-enter by changing address casing. `ThresholdKeyshare` itself does not hold committee state. | | Z-03 | **Ban eligibility synchronization** | Resolved | Every completed ban or unban refreshes the affected registered operator in `BondingRegistry`. Active status includes bans from all retained slashing managers, so a banned operator stops counting toward committee capacity and cannot submit new tickets. Existing finalized committees change only through E3-scoped expulsion. | | 7 | **ProofRequestActor failure bridge fixed** | Info | `ProofRequestActor` no longer leaves proof publication suppressed under log-only "will not be published" exits. `ComputeRequestError` and local proof-signing failures for DKG-path proofs (`C0` through `C5`) now emit `E3Failed { failed_at_stage: CommitteeFinalized, reason: DKGInvalidShares }`, while decryption-path proofs (`C6` and `C7`) emit `E3Failed { failed_at_stage: CiphertextReady, reason: DecryptionInvalidShares }`. | | 8 | **Settlement receipts isolated** | Resolved | Durable EVM receipts such as `RewardCredited` and `RewardClaimed` are global audit/projection facts. The E3 router no longer sends them into a completed per-E3 context, so reward fan-out cannot reopen a finished E3 or produce false `AlreadyCompleted` failures. | -| 9 | **Replay-safe compute effects** | Resolved | `ComputeEffectGate` subscribes before EventStore replay, buffers `ComputeRequest`s while effects are disabled, deduplicates equivalent requests, prefers the newest hydrated retry, cancels terminal E3 work, and releases pending effects only after `EffectsEnabled`. This closes the mid-E3 compute-loss window without changing durable event order. | +| 9 | **Replay-safe compute effects** | Resolved | `ComputeEffectGate` subscribes before EventStore replay, starts from the durable lifecycle snapshot, buffers `ComputeRequest`s while effects are disabled, deduplicates equivalent requests, prefers the newest hydrated retry, cancels terminal E3 work, discards DKG jobs after `KeyPublished`, and releases pending effects only after `EffectsEnabled`. This closes the mid-E3 compute-loss window and prevents obsolete proof work from blocking recovery without changing durable event order. | | 10 | **Restarted active aggregator decryption recovery** | Resolved | A node killed after key publication but before ciphertext publication could miss the one-shot `AggregatorChanged` role event before its plaintext buffer existed, lack recovered committee dependencies when ciphertext arrived, or rehydrate without the per-E3 `CommitmentConsistencyChecker` needed to answer C6 verification gates. `ThresholdPlaintextAggregatorExtension` records aggregator role, remembers pending ciphertext until committee facts arrive, recovers full committee data from public-key state/replayed public events, recovers the honest subset from public-key or threshold-keyshare state, `CommitmentConsistencyCheckerExtension` recreates its per-E3 checker from recovered meta, and restart recovery never blocks the router on synchronous store reads. | | 11 | **Large EventStore replay startup failure** | Resolved | Restart replay now awaits each EventBus handler result before submitting the next persisted event. A backlog larger than the 2,560-entry EventBus mailbox no longer fails startup with `SendError::Full`; progress is logged every 10,000 EventBus-handled events. The EventBus still fans out with unacknowledged `do_send`, and replay still materializes the full range in memory, so end-to-end queue and memory bounds remain open. | | 12 | **Canonical DKG H/N aggregation shape** | Resolved | The Rust DKG aggregation path no longer asserts that honest proof count `H` equals full committee size `N`. Every supported preset has `H < N`; validation now requires exactly `H` NodeFold proofs with unique in-range party IDs and exactly `N` ordered committee addresses, matching the producer and compiled Noir witness. The removed assertion affected debug/test binaries; circuit semantics and public inputs are unchanged. | @@ -266,17 +267,18 @@ them are in the reference app (`examples/CRISP`), not the protocol. | 34 | **Cross-E3 decryption-proof replay (AUD C-03)** | Resolved | Every secret-bearing C6 proof commits to a domain over `(chainId, Interfold address, e3Id, committeeHash, ciphertextOutputHash, committeePublicKey)`. C6 folding requires one common domain, the final DecryptionAggregator proof exposes it, and the BFV wrapper rejects any domain that differs from the value recomputed by `Interfold`. The wrapper also receives `e3Id` separately and checks each proof party's SK/ESM commitments against the registry's stored DKG anchors. This prevents cross-chain, cross-deployment, cross-E3, cross-committee, cross-ciphertext, and cross-key replay without a global consumed-proof storage ledger. | | 35 | **Bond-owner transfer lock bypass (N-1)** | Resolved | `acceptBondOwner` checks the previous owner's wallet balance and remaining bonds before it moves the operator's FOLD credit. The transfer reverts if that total is less than `lockedBalanceOf(previousOwner)`. A new owner cannot convert the previous owner's locked FOLD into an unlocked exit payout. | | 36 | **Terminal DKG timeout recovery (AUD M-42)** | Resolved | Each fatal threshold-keyshare collector timeout commits `KeyshareState::Failed` before it publishes `E3Failed`. Startup hydrates this terminal state, and `EffectsEnabled` redrives the identical failure payload instead of resuming stale DKG work. | -| 37 | **E3 program registration authority (AUD M-43)** | Resolved | `registerE3Program` is owner-only. Untrusted callers cannot add arbitrary contracts to the append-only request allowlist. | +| 37 | **E3 program admission authority (AUD M-43)** | Resolved | Registration and retirement are owner-only. Retirement blocks only new requests and does not change the program snapshotted by an existing E3. An activation batch retires an incompatible bootstrap mock before requests resume. | | 38 | **Initial E3 program bootstrap (AUD M-39)** | Resolved | Production configuration requires one deployed E3 program. `Interfold.initialize` registers it before ownership transfers to the Safe, so launch does not depend on a later multisig transaction. | | 39 | **Mainnet and Sepolia P2P overlay collision** | Resolved | Stable 32-byte network IDs now scope Identify, gossip, Kademlia, and historical sync. Peers remain staged until exact Identify and capability checks pass. Gossip uses application validation and a versioned envelope bound to network, deployment, aggregate, event ID, and payload hash. Historical sync transfers only chain-bound aggregates allowed by the active network policy and validates every event before EventBus admission; local aggregate 0 cannot enter peer fetch or recovery. The initial unscoped protocol is not offered as a fallback because it cannot distinguish networks. Profile defaults use separate mainnet and Sepolia DNS bootstrap aliases; no retired bootstrap address is compiled into the node. Unreachable peers are evicted and temporarily quarantined, and unavailable bootstrap peers use quiet background retries. Legacy events found in the wrong aggregate store are ignored without deleting operator data. | | 40 | **Fail-closed P2P admission lifecycle** | Resolved | Production network policies require at least one deployment and cannot infer unrestricted access from an empty map. Identify admission retains every simultaneous connection result. Compatibility failures are permanent, but Identify timeouts use a short transient cooldown so bootstrap retries can recover. Each gossip publish has a bounded result timeout; no-peer failures use the longer network-join retry window, while invalid payloads fail without retry. Historical-sync validation failures return a terminal protocol error instead of leaving the requester to time out. | | 41 | **Canonical completion and replay-safe result publication** | Resolved | `PlaintextAggregated` is a local durable publication intent, not a gossip artifact or a terminal lifecycle fact. Peer events cannot create unknown E3 contexts. Only confirmed EVM completion drives `E3RequestComplete`. Interfold and registry writer gates rebuild local result intents during replay, defer them until `EffectsEnabled`, start submissions only under the active aggregator role, coalesce by E3, retain retryable failures, and use contract preflights for cross-restart idempotency. Request cleanup cannot erase unfinished publication. Public RPC ingestion waits one block automatically; loopback development RPCs read the head, with no operator setting. | -| 42 | **Restart-safe active-aggregator failover** | Resolved | Every committee member persists validated public-key and decryption-share inputs in its normal aggregation state. `AggregationInputsReady` starts the durable 10-minute budget only after that node can resume the phase. Only the active party launches effects; promotion redrives the persisted phase, while demotion rejects late worker results. Replay preserves an unchanged ready deadline, processes overdue work after `EffectsEnabled`, and clears phase-local skips on canonical progress. A pre-readiness snapshot migrates by clearing its false timers and skip set. Selected nodes also rebuild canonical DKG/decryption deadline watches and stagger `markE3Failed` attempts by party ID. | +| 42 | **Restart-safe active-aggregator failover** | Resolved | Every committee member persists validated public-key and decryption-share inputs in its normal aggregation state. `AggregationInputsReady` starts the durable 10-minute budget only after that node can resume the phase. Only the active party launches effects; promotion redrives the persisted phase, while demotion rejects late worker results. Replay preserves an unchanged ready deadline, processes overdue work after `EffectsEnabled`, and clears phase-local skips on canonical progress. A pre-readiness snapshot migrates by clearing its false timers and skip set. Nodes rebuild every on-chain failure watch from durable lifecycle and request-time registry state. Selected members stagger by party ID; nodes without a party ID wait for the permissionless grace period. | | 43 | **Crash-safe per-E3 effect redrive** | Resolved | EventStore replay keeps durable sequence order inside each aggregate and uses HLC order only between aggregate heads. The canonical request-router checkpoint keeps the highest sequence seen on live and recovery paths. A trailing checkpoint advances from only its missing EventStore suffix, preserving active E3 admission state. Shutdown drains open cross-aggregate snapshot batches in event order, so an older batch cannot overwrite newer state. Versioned threshold-keyshare and aggregation records retain collector inputs, proof jobs, verified bundles, final publication intents, and causal contexts. `EffectsEnabled` creates new worker correlations and re-publishes determined outputs idempotently. Missing required recovery data fails startup instead of leaving an actor waiting on process-local state. | | 44 | **Empty-network restart completion** | Resolved | Process infrastructure events are excluded from EventStore replay, and the current `NetReady` listener is armed before the transport starts. A prior empty `HistoricalNetSyncEventsReceived` cannot occupy the payload-derived EventBus dedup key and suppress the fresh completion awaited by the current boot. A node with no active E3s or eligible peer-sync aggregates now advances through `SyncEffect`, publishes `SyncEnded`, and reaches live mode. | | 45 | **Snapshot-derived local participation recovery** | Resolved | Startup reconciles selector and finalized-committee snapshots, prunes terminal E3s, fails on contradictory committees or missing metadata, and directly seeds aggregator roles, party IDs, verifier caches, and DHT interests. The router applies each derived local selection at the fenced `SyncEffect` boundary after effects attach. Recovery does not append synthetic `CiphernodeSelected` or `AggregatorChanged` events on every boot. | | 46 | **Durable pre-DKG and slashing orchestration** | Resolved | Versioned recovery records retain sortition seeds, typed requests, early membership changes, committee-finalizer request contexts and tickets, and per-chain slash intents. Effects stay disabled during replay. Startup re-arms timers and outboxes afterward, retries temporary timestamp or transaction failures, and clears work on canonical completion. A bounded EventStore projection creates only missing recovery records before the actors attach. | | 47 | **Restarted per-E3 slashing recipients** | Resolved | Context hydration recreates the active `AccusationManager` and `CommitmentConsistencyChecker` from durable committee and E3 metadata. The router owns their scoped lifecycle, so duplicate global subscriptions do not accumulate. In-flight accusation votes and timers remain process-local and peers must resend valid messages before their signed deadline. | +| 48 | **Terminal non-slashing failure recovery** | Resolved | `NoInputsReceived`, compute-provider expiry/failure, requester cancellation, and deadline failures publish the common request-completion signal because they have no ciphernode accusation flow. Startup checks each persisted context against finalized Ethereum state before actor hydration and removes terminal work that has no remaining slashing duty. An E3 present at chain head but not yet finalized keeps its context; an E3 absent at both remains an error. Failed E3s cannot restart DKG or proof work after a node restart. | | Z-01 | **Committee finalization authority (Zenith #1)** | Resolved | `markE3Failed()` rejects a ready committee through its request-time DKG cutoff. The registry can finalize it through that cutoff. Afterward, `markE3Failed()` can fail an unfinalized E3 under the configured caller policy. | | Z-02 | **Zero-value honest-node refunds (Zenith #2)** | Resolved | The refund manager credits an unclaimable honest-node allocation to the request-time treasury. The contract does not create zero-value node claims. | | Z-04 | **Committee collateral exits (Zenith #4)** | Resolved | The request-time registry records each finalized member as an unresolved committee obligation in `BondingRegistry`. An operator may deregister, but queued collateral cannot leave while any selected committee remains nonterminal. Anyone can release the complete committee after its E3 succeeds or fails. Registry replacement remains blocked until all obligations are released. | @@ -295,7 +297,7 @@ them are in the reference app (`examples/CRISP`), not the protocol. | Z-28 | **Immutable sortition snapshots (Zenith #28)** | Resolved | Every E3 uses request-time eligibility and ticket balances from `requestBlock - 1` plus a frozen ticket price. The request-time count, Rust selection, and Solidity submission checks consume the same state. Current activity remains an additional liveness gate. | | Z-29 | **Non-canonical plaintext inputs (Zenith #29)** | Resolved | The BFV wrapper rejects non-canonical BN254 aliases and coefficients outside the exact 64-bit plaintext representation before hashing or proof verification. | | Z-30 | **Canonical committee address hash (Zenith #30)** | Resolved | Solidity hashes every byte of each ordered 20-byte address. A shared known vector now matches the Solidity and Rust implementations. | -| Z-31 | **Recoverable public-key transport (Zenith #31)** | Resolved | The one-shot committee transaction accepts only the C5-proven commitment, proof, and DKG anchors. Serialized key candidates use a separate bounded, permissionless, repeatable path, and clients accept only a candidate whose semantic commitment matches the stored value. | +| Z-31 | **Recoverable public-key transport (Zenith #31)** | Resolved | The one-shot committee transaction accepts only the C5-proven commitment, proof, and DKG anchors. Serialized key candidates use a separate bounded path gated to request-time committee members, including retained expelled members for transport recovery. Consumers accept at most one candidate per member and use only a candidate whose semantic commitment matches the stored value. | | Z-32 | **Atomic committee viability loss (Zenith #32)** | Resolved | An expulsion below H atomically fails every nonterminal E3 with the existing `InsufficientCommitteeMembers` reason; this preserves the persisted enum layout. A failed callback rolls back the complete slash. Terminal E3s still allow later slashes. Key, ciphertext, and plaintext publication each require a currently viable request-time committee. | | Z-33 | **Registry-bound DKG fold attestations (Zenith #33)** | Resolved | Each E3 freezes its registry and verifier. Startup loads saved contexts for active E3s before event replay. Signers, aggregators, publication routing, and on-chain verification use the same addresses. A wrong-domain attestation is rejected before aggregation. | | Z-34 | **False verifier result rejection (Zenith #34)** | Resolved | Committee-key and plaintext publication require their verifier to return `true`. An ABI-compatible verifier that returns `false` cannot be mistaken for successful proof verification. | diff --git a/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md b/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md index cf489bb6c4..21c6c1672c 100644 --- a/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md +++ b/agent/flow-trace/03_E3_REQUEST_AND_COMMITTEE.md @@ -619,11 +619,14 @@ A ready committee must finalize at or before its absolute DKG deadline. 10. **E3 program bootstrap and governance**: The production deploy requires one deployed E3 program. `Interfold.initialize` registers it before it transfers ownership to `protocolOwner`. For DAO-owned deployments, `protocolOwner` is the DAO, not a Safe. Every registration rejects an - address without runtime code. After initialization, only the owner can append another program. - The deployment can create `MockE3Program` as the initial program. This stateless program accepts - the active BFV scheme and applies no application rules. It has no owner, controller, or mutable + address without runtime code. After initialization, only the owner can register or retire a + program. Retirement closes new request admission without changing existing E3 records. The + deployment can create `MockE3Program` as the initial program. This stateless program accepts the + active BFV scheme and applies no application rules. It has no owner, controller, or mutable configuration. The request-time ciphertext verifier and decryption verifier still verify the - protocol proofs. + protocol proofs. Its deterministic data-availability receipt is only for tests. Requests remain + paused until a production E3 program is registered and wired, and an interface-incompatible + bootstrap mock is retired. --- diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index 44ea99f060..0b41bdbe6a 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -691,9 +691,13 @@ phase. โ”‚ โ””โ”€ If that transaction is mined with a failed receipt, the writer reads the โ”‚ commitment again. An equal commitment from another aggregator completes โ”‚ the step; a different commitment stays an error - โ””โ”€ Calls contract.publishCommitteePublicKey(e3_id, publicKey) after the - commitment is available, including after restart - โ†’ A terminal result clears the intent; a retryable failure keeps it and retries after 30s + โ””โ”€ Splits the serialized key into deterministic 90 KiB chunks and calls + contract.publishCommitteePublicKey(e3_id, candidateHash, index, count, + totalLength, chunk) for every chunk after the commitment is available + โ†’ A terminal result clears the in-memory intent; a retryable failure keeps it and retries + after 30s + โ†’ RPC request-size rejection and permanent contract or payload errors are terminal for the + running writer. They produce one final error instead of an unbounded 30-second retry loop โ†’ A restart replays the intent, so an unfinished publication still reaches the chain. E3RequestComplete that arrives before EffectsEnabled comes from that same replay and drops the intent: a completed request published its candidate in an earlier run, and @@ -742,12 +746,11 @@ phase. โ”‚ โ”‚ 8. Emit CommitteeProofPublished( โ”‚ โ”‚ โ”‚ e3Id, c.topNodes, pkCommitment, proof) โ”‚ โ”‚ โ”‚ โ”‚ - โ”‚ โ”‚ publishCommitteePublicKey(e3Id, publicKey) { โ”‚ + โ”‚ โ”‚ publishCommitteePublicKey(e3Id, hash, i, n, len, chunk) { โ”‚ โ”‚ โ”‚ 1. require the proven commitment โ”‚ - โ”‚ โ”‚ 2. require 0 < publicKey.length <= 256 KiB โ”‚ - โ”‚ โ”‚ 3. Emit CommitteePublished with the candidate, โ”‚ - โ”‚ โ”‚ stored commitment, and empty compatibility โ”‚ - โ”‚ โ”‚ proof field โ”‚ + โ”‚ โ”‚ 2. require caller is a request-time committee member โ”‚ + โ”‚ โ”‚ 3. require len <= 512 KiB and canonical 90 KiB chunks โ”‚ + โ”‚ โ”‚ 4. Emit CommitteePublicKeyChunkPublished โ”‚ โ”‚ โ”‚ } โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` @@ -763,17 +766,18 @@ an attestation. The registry uses the same frozen verifier when the committee pu attestation from another registry or verifier therefore fails even when both registries use the same E3 ID and committee. -The serialized `publicKey` event field is a transport hint, not on-chain authority. Proof-backed -committee publication does not accept it. A separate permissionless function emits bounded key -candidates and remains usable after an invalid candidate, so a front-run transaction cannot consume -the only transport slot. Before `e3-indexer` stores it in `E3.committee_public_key`, it decodes the -BFV key, recomputes the circuit's public-key commitment using the request's parameter set, and -requires equality with the event's on-chain `pkCommitment`. TypeScript event consumers receive the -same `pkCommitment` and use `InterfoldSDK.validatePublicKeyCommitment()` before accepting the bytes; -the default application does this before advancing to encryption. Malformed bytes or bytes for a -different key fail closed and never reach first-party encryption clients. Production verifies the -C5-backed final DKG proof on-chain; the explicit test/CI skip mode works only with mock verifiers -that trust its placeholder. +The serialized key is transported in Ethereum event chunks; it is not on-chain authority. Only a +request-time committee member can emit chunks while the E3 remains in `KeyPublished`. This includes +a retained expelled member, whose bytes receive no extra trust but can still repair availability. +Terminal E3s reject new chunks, so late publishers cannot recreate assemblies after cleanup. +Consumers accept the first candidate hash from each member. The ciphernode coordinator and +`e3-indexer` group the canonical chunks by E3, publisher, and candidate hash. They require a +complete sequence, check `keccak256(serializedKey) == candidateHash`, decode the BFV key, recompute +the circuit's public-key commitment with the request-time parameter set, and require equality with +the proven on-chain `pkCommitment`. Only then do they produce the existing `CommitteePublished` +runtime event or store the key for encryption. Invalid candidates do not consume another committee +member's candidate. Production also verifies the C5-backed final DKG proof on-chain; the explicit +test/CI skip mode works only with mock verifiers. > **C-08 (BfvPkVerifier domain binding) โ€” implemented** The wrapper exposes a > `verify(e3Id, committeeRoot, sortedNodes, pkCommitment, committeeHash, proof)` signature. @@ -806,12 +810,27 @@ comparison. ``` Data providers submit encrypted inputs: โ”‚ -โ””โ”€ e3Program.publishInput(e3Id, encryptedData) - โ†’ Must be within inputWindow [start, end] - โ†’ Encrypted under the committee's aggregate public key - โ†’ Only M+1 committee members can collectively decrypt +โ”œโ”€ Server validates the Noir proof and durably stores the exact ciphertext +โ”œโ”€ Server signs the chain-bound input ID only after storage succeeds +โ”œโ”€ e3Program.publishInput(e3Id, proofCommitment) +โ”‚ โ†’ Must be before inputCommitmentDeadline +โ”‚ โ†’ Verifies the Noir proof, content hash, SAFE commitment, and server signature +โ”‚ โ†’ Reserves the input leaf and index immediately +โ”‚ โ†’ Emits InputCommitted and increments pendingInputCount +โ”œโ”€ Server publishes the stored ciphertext to Avail with submit_data +โ”œโ”€ VectorX anchors that Avail block on Ethereum +โ””โ”€ e3Program.finalizeInput(e3Id, inputTuple, vectorXProof) + โ†’ Verifies availability of the exact keccak256(ciphertext) + โ†’ Emits InputPublished and decrements pendingInputCount + โ†’ Can be called by anyone; the voter does not stay online ``` +The final three hours of the input window accept finalizations but no new proof commitments. The +input leaf is reserved in the first transaction so later masks and revotes can extend it while +VectorX is pending. Computation waits for the original input-window end and for +`pendingInputCount == 0`. See [08_DATA_AVAILABILITY.md](08_DATA_AVAILABILITY.md) for the exact +deadlines, recovery flow, and remaining trust. + ### Ciphertext Output Publication The RISC Zero guest commits nine 32-byte fields in this order: chain ID, Interfold address, E3 ID, @@ -869,11 +888,13 @@ are unaffected: `TryConvertFrom` expands the seed into `c[1]` before the convert ``` Compute provider runs computation on encrypted data: โ”‚ -โ””โ”€ Interfold.publishCiphertextOutput(e3Id, ciphertextOutput, ciphertextCommitment, proof) +โ”œโ”€ Publish the aggregate ciphertext bytes to Avail +โ”œโ”€ Wait for the VectorX proof +โ””โ”€ Interfold.publishCiphertextOutput(e3Id, encodedOutputReference) โ”‚ โ”‚ โ”Œโ”€โ”€โ”€ ON-CHAIN (Interfold.sol) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ publishCiphertextOutput(e3Id, output, commitment, proof) { โ”‚ +โ”‚ โ”‚ publishCiphertextOutput(e3Id, encodedReference) { โ”‚ โ”‚ โ”‚ 0. enter the shared publication reentrancy guard โ”‚ โ”‚ โ”‚ 1. require(stage == KeyPublished) โ”‚ โ”‚ โ”‚ 2. require(block.timestamp <= computeDeadline) โ”‚ @@ -883,9 +904,8 @@ Compute provider runs computation on encrypted data: โ”‚ โ”‚ โ†’ Can only publish once โ”‚ โ”‚ โ”‚ 5. require(activeCount >= threshold[0]) โ”‚ โ”‚ โ”‚ โ†’ The request-time committee is still viable โ”‚ -โ”‚ โ”‚ 6. Save output hash and SAFE commitment โ”‚ -โ”‚ โ”‚ Set stage and decryption deadline โ”‚ -โ”‚ โ”‚ โ†’ A later revert restores all prior state โ”‚ +โ”‚ โ”‚ 6. E3 program verifies the VectorX/Avail receipt โ”‚ +โ”‚ โ”‚ and requires receipt.contentHash == output hash โ”‚ โ”‚ โ”‚ 7. schemeVerifier.verify(...) โ”‚ โ”‚ โ”‚ โ†’ Checks the protocol fields in the compute receiptโ”‚ โ”‚ โ”‚ โ†’ Must return true โ”‚ @@ -893,13 +913,19 @@ Compute provider runs computation on encrypted data: โ”‚ โ”‚ โ†’ Checks the application fields in the same receiptโ”‚ โ”‚ โ”‚ โ†’ Must return true โ”‚ โ”‚ โ”‚ โ†’ Cannot re-enter ciphertext or plaintext publicationโ”‚ -โ”‚ โ”‚ 9. Confirm the stage is still CiphertextReady โ”‚ -โ”‚ โ”‚ 10. Emit CiphertextOutputPublished(...) โ”‚ +โ”‚ โ”‚ 9. Save output hash and SAFE commitment โ”‚ +โ”‚ โ”‚ Set stage and decryption deadline โ”‚ +โ”‚ โ”‚ 10. Emit CiphertextOutputReferencePublished(...) โ”‚ โ”‚ โ”‚ 11. Emit E3StageChanged(CiphertextReady) โ”‚ โ”‚ โ”‚ } โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` +The accepted event records the content hash and stable Avail coordinates, not the ciphertext bytes. +Ciphernodes replay that durable reference without network access. After recovery enables effects, +they fetch the named Avail block, find bytes with the exact Keccak hash, and emit the existing +runtime `CiphertextOutputPublished` event. Failed retrieval is retried and never substitutes bytes. + `onCommitteePublished` stores the committee key and starts the compute clock. The compute deadline is `max(block.timestamp, inputWindow[1]) + requestTimeComputeWindow`. A late key publication does not consume the compute provider's allotted window, and publication still waits until the input @@ -1318,8 +1344,12 @@ present in the running ABI catalog is exposed as `UnknownEvmLog` with raw topics During restart, `ComputeEffectGate` observes replay before compute workers are effects-enabled. It buffers and deduplicates `ComputeRequest`s, prefers the newest regenerated request, cancels terminal -E3 work, and releases pending jobs only after `EffectsEnabled`. The gate changes effect timing, not -durable event order or audit state. +E3 work, and releases pending jobs only after `EffectsEnabled`. The gate starts with the durable E3 +lifecycle snapshot. If an E3 has already reached `KeyPublished`, it discards replayed DKG and DKG +proof jobs because the chain has made that work obsolete; decryption jobs remain eligible. C1-C3 and +C6 verification share one compute-request variant, so the gate uses the signed proof type to keep C6 +threshold-decryption verification eligible. The gate changes effect timing, not durable event order +or audit state. `CiphernodeSelector` also observes replay before it enables failover effects. Its versioned repository stores a readiness-gated phase, assigned party, absolute deadline, and locally @@ -1339,6 +1369,31 @@ publication, and only an active aggregator can start a retained submission. `Pla not gossiped or returned by historical peer sync; only the producing node can create this EVM write intent. +The CRISP server writes its request record at `E3Requested` and writes the generic E3 record only +after the indexer verifies the committee public key against the on-chain commitment. Current-round +lookup uses the request record, so a round remains visible while its key is pending. CRISP activates +the round only when both records exist. Either handler can complete the activation after their +records converge, and deferred checks cover slow live-handler ordering. Duplicate request and +committee events do not reset the round, replace indexed output, or resubmit an already-matching +Merkle root. The shared Interfold contract also emits requests for other E3 programs. The CRISP +indexer ignores those requests before it creates a round or makes a program-specific RPC call. An +old program's historical round therefore cannot stop a fresh CRISP backfill. + +Startup rebuilds deadline callbacks for active and expired rounds and releases an interrupted +compute submission for retry. The compute transition is atomic, and a synchronous program-server +request error releases the claim to `Expired` so a later deadline callback can retry it. Compute +submission is at-least-once across a restart because the HTTP response or webhook can be lost. A +retry can repeat proof work, but it cannot publish a second result: `Interfold` accepts ciphertext +output only from `KeyPublished`, and the callback treats an E3 that already reached +`CiphertextReady` or `Complete` as success. + +Secure-8192 committee public-key bytes do not use one oversized transaction. `publishCommittee` +first records the proof-backed commitment. The selected publisher then sends the bytes through +bounded `publishCommitteePublicKey` chunks. Readers assemble one canonical chunk set and accept the +key only when its recomputed commitment equals the value already stored on chain. `KeyPublished` +alone therefore does not mean that a client has usable key bytes. The application becomes ready only +after the complete key publication arrives and passes that commitment check. + ### What the compute-provider crate guarantees, and what an E3 program decides `e3-compute-provider` is shared by every E3 program, so it holds only what is true for all of them: @@ -1379,20 +1434,19 @@ index there, laid out as `abi.encodePacked(address, uint40)`. ### Input leaf binding and per-slot selection -An E3 program verifies a proof over the ciphertext **commitment** when an input is published. The -proof never sees the serialized ciphertext the event carries, so the two can disagree, and neither -the contract nor the circuit can tell: the commitment is a Poseidon sponge over the ciphertext's CRT -limbs (~49k field elements at the secure preset), and the circuit cannot reproduce the fhe.rs -serialization. The guest is the first place both representations exist at once. +An E3 program verifies a proof over the ciphertext **commitment** when an input is committed. The +proof also exposes the Keccak hash of the serialized ciphertext, split across two field elements. +The contract cannot deserialize the ciphertext or reproduce its Poseidon commitment. The guest is +the first place both representations exist at once. `CRISPProgram.inputLeaf` therefore binds four values: ```text -leaf = sha256(sha256(encryptedVote) || encryptedVoteCommitment || slotAddress || parentIndexPlusOne) +leaf = sha256(keccak256(encryptedVote) || encryptedVoteCommitment || slotAddress || parentIndexPlusOne) mod SNARK_SCALAR_FIELD ``` -- the **bytes**, so a submitter cannot publish a valid commitment beside unrelated data; +- the **content hash**, so a submitter cannot pair a valid commitment with unrelated bytes; - the **commitment**, so any commitment cannot be paired with any ciphertext; - the **slot**, because the tree is append-only and the guest selects per slot โ€” an unbound slot would let a prover re-group entries and change which one wins; @@ -1405,10 +1459,9 @@ vector (`program/tests/input_leaf.rs` and `tests/input-leaf.test.ts`), and contract produced, from a fixture generated by `tests/input-tree-e2e.test.ts`. A one-byte divergence would make every root mismatch and nothing else would detect it. -SHA-256 rather than Keccak: the zkVM accelerates SHA-256 inline, while its Keccak accelerator emits -a proof assumption the host must prove separately and compose. The extra on-chain cost is about 67k -gas on a transaction that already carries the ciphertext โ€” a secure-preset ciphertext is about 348 -KB, so calldata and log data dominate by orders of magnitude. +Keccak is used for the serialized ciphertext so the digest can match a content hash exposed by an +external data-availability receipt. SHA-256 remains the outer hash because the zkVM accelerates it +inline. The guest recomputes both hashes from the ciphertext bytes that it consumes. **The input tree is append-only.** `_processVote` always inserts and never updates in place. That is a security property, not a storage choice: the mask path requires no signature, so anyone can write @@ -1477,10 +1530,10 @@ empty ciphertext does not deserialize. That is only reachable when no honest inp indistinguishable from a round that received none, which the protocol resolves as `NoInputsReceived`. -`InputPublished` carries the slot, the commitment, and the parent alongside the bytes. All three -were already public โ€” the slot and the parent are plaintext `publishInput` arguments, and -`getSlotIndex` and `inputCommitmentOf` expose the rest โ€” so emitting them leaks nothing and saves -every consumer from parsing transaction calldata. +`InputCommitted` carries the slot, commitment, content hash, parent, and reserved index. The later +`InputPublished` event repeats the tuple with the VectorX-verified Avail coordinates. Neither event +carries the ciphertext bytes. Indexers fetch those bytes and reject them unless their Keccak hash +matches the on-chain content hash. ### One relation for voting, updating, and masking diff --git a/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md b/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md index d2ef27d8cc..f6b9de533c 100644 --- a/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md +++ b/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md @@ -26,12 +26,15 @@ actually slashed and does not require an oracle or relabel one ERC-20 as another Anyone can call `markE3Failed()` when a deadline is missed. A ready committee remains finalizable through its absolute DKG deadline. It can fail if it remains unfinalized after that deadline. -For the aggregator-owned DKG and decryption stages, each selected ciphernode reconstructs a -canonical deadline watch from `CiphernodeSelected` and `E3StageChanged` during replay. After -`EffectsEnabled`, it reads the deadline from `Interfold`, staggers its attempt by canonical party -ID, confirms that the stage and failure condition still match, and calls `markE3Failed`. A canonical -stage change cancels the old watch. If a node restarts after the deadline, the party-ID stagger is -applied from restart time so all committee wallets do not submit at once. +The Interfold writer watches every stage that `failureCondition` supports: `Requested`, +`CommitteeFinalized`, `KeyPublished`, and `CiphertextReady`. Startup restores the stage from the +durable lifecycle map and restores the request-time registry from the DKG context. A finalized +committee member staggers its attempt by canonical party ID. A `Requested` E3 has no active +committee yet, so every node waits until the failure grace period ends and uses the permissionless +path. Before submission, the writer confirms that the stage and failure condition still match. A +canonical stage change cancels the old watch and invalidates any older stage-discovery RPC. After a +restart, finalized members keep their party-ID stagger and non-members remain outside the protected +grace window. If an honest-node allocation is smaller than the node count, the refund manager credits it to the request-time treasury instead of creating zero-value claims. @@ -149,7 +152,9 @@ charge the protocol-funded subscription. Runtime note: `processE3Failure()` is a permissionless cleanup path. The Rust `InterfoldSolWriter` may auto-submit it from any effects-enabled node on the same chain, and it must not depend on active-aggregator designation because failures can happen before committee finalization or while the -current aggregator is offline. +current aggregator is offline. A restart restores failed E3 IDs from the durable lifecycle map. The +writer retries transient failures and treats `NoPaymentToRefund` as proof that another account has +already processed the escrow. ```text Anyone calls: Interfold.processE3Failure(e3Id) @@ -1293,16 +1298,18 @@ When CommitteeMemberExpelled event arrives from EVM: โ”‚ โ””โ”€ Stores the expelled node as `alloy::Address` and removes/blocks keyshares by parsed โ”‚ address, so differently cased self-reported node strings cannot bypass expulsion โ”‚ -โ””โ”€ When E3Failed(timeout) / E3StageChanged(Complete) arrives: +โ””โ”€ When a terminal non-slashing E3Failed / E3StageChanged(Complete) arrives: โ”‚ โ”œโ”€ E3Router (central cleanup orchestrator): - โ”‚ โ”œโ”€ E3Failed with a timeout reason (CommitteeFormationTimeout, DKGTimeout, - โ”‚ โ”‚ ComputeTimeout, DecryptionTimeout) โ†’ publishes E3RequestComplete + โ”‚ โ”œโ”€ E3Failed with a timeout, requester, or external-provider reason + โ”‚ โ”‚ (CommitteeFormationTimeout, DKGTimeout, ComputeTimeout, + โ”‚ โ”‚ DecryptionTimeout, NoInputsReceived, ComputeProviderExpired, + โ”‚ โ”‚ ComputeProviderFailed, RequesterCancelled) โ†’ publishes E3RequestComplete โ”‚ โ”‚ โ†’ Single cleanup signal for all per-E3 actors โ”‚ โ”‚ NOTE: E3Failed with a misbehaviour reason (DKGInvalidShares, etc.) does โ”‚ โ”‚ NOT trigger E3RequestComplete โ€” the accusation/slashing lifecycle must โ”‚ โ”‚ complete first. - โ”‚ โ””โ”€ E3StageChanged(Failed) and E3Failed(timeout) arriving after context teardown + โ”‚ โ””โ”€ E3StageChanged(Failed) and the same non-slashing E3Failed arriving after teardown โ”‚ are silently ignored (expected on-chain lag) โ”‚ โ”œโ”€ CommitteeFinalizer (direct handler โ€” semantic work): diff --git a/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md b/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md index 2284d52e74..b2a9c23811 100644 --- a/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md +++ b/agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md @@ -249,12 +249,14 @@ On restart: โ”‚ โ†’ restores complete CRC-valid, decodable frames whose tail index write was lost โ”‚ โ†’ rejects indexed corruption, decode failure, gaps, and offset mismatches โ”œโ”€ Builder recovery before actors start: -โ”‚ 1. Check the storage schema and repair the request-router admission projection +โ”‚ 1. Check the storage schema and reconcile the request-router admission checkpoint โ”‚ โ†’ The checkpoint is stored at the canonical root key, not below a router-local namespace โ”‚ โ†’ Each aggregate cursor keeps the highest sequence observed, even when contextual snapshot โ”‚ writes arrive out of HLC or sequence order โ”‚ โ†’ If the checkpoint trails the snapshot cut, only the missing EventStore suffix is applied โ”‚ to the existing admission state +โ”‚ โ†’ A checkpoint that covers the snapshot cut is never moved backward +โ”‚ โ†’ Missing context snapshots or cursor disagreement fail startup before actors attach โ”‚ 2. Backfill missing versioned recovery records from the EventStore โ”‚ โ†’ Sortition inputs, committee-finalizer inputs/tickets, and slash intents are reconstructed โ”‚ โ†’ Existing versioned records are not replaced @@ -490,8 +492,9 @@ publish that exclusion leaves the intent retryable. The registry writer rebuilds ticket, committee-finalization, and public-key submission gates from durable local events. It does not submit during replay. After `EffectsEnabled`, it retries temporary RPC or contract-ordering failures, treats already-landed transactions as success, and stops retrying -a ticket after a permanent eligibility or deadline result. The Interfold writer applies the same -pattern to plaintext publication. +a ticket after a permanent eligibility or deadline result. It also stops a public-key submission +after an RPC request-size rejection or a permanent payload or contract error. The Interfold writer +applies the same pattern to plaintext publication. The request router uses one checkpoint at `//router/recovery_checkpoint` for its active contexts, completed set, and all aggregate cursors. Per-E3 context snapshots remain below their own router @@ -504,6 +507,12 @@ backward. Replay preserves durable sequence inside each aggregate and uses HLC o aggregate heads. The final snapshot drain writes open cross-aggregate batches in their original event order, so an older batch cannot overwrite the newest checkpoint during shutdown. +Ethereum lifecycle events remain the canonical terminal input. A same-version restart restores the +durable checkpoint, replays its missing EventStore suffix, and then ingests missing historical EVM +events through normal chain synchronization. Startup does not run a separate per-context Ethereum +repair query. The production cutover starts nodes with empty protocol databases, so it does not +carry the inconsistent projections written by intermediate Sepolia binaries. + --- ## Rust-Side: E3 Lifecycle Coordinator (durable stage tracking) diff --git a/agent/flow-trace/08_DATA_AVAILABILITY.md b/agent/flow-trace/08_DATA_AVAILABILITY.md new file mode 100644 index 0000000000..1bc33d417c --- /dev/null +++ b/agent/flow-trace/08_DATA_AVAILABILITY.md @@ -0,0 +1,277 @@ +# CRISP Data Availability and Deadlines + +## Why this flow exists + +Secure BFV public keys and ciphertexts are too large for one Ethereum transaction. CRISP therefore +uses three different transports: + +- The committee public key is split into bounded Ethereum event chunks. Its C5 proof commitment is + already on Ethereum. +- Voter ciphertexts are published to Avail. Ethereum accepts their references only after VectorX + proves that the exact bytes were included. +- The aggregate ciphertext uses the same Avail and VectorX receipt, after RISC Zero proves the + computation. + +Consumers assemble a complete public-key candidate and check its content hash. They decode the key +with the E3 threshold parameters and compare its C5 commitment with the proven on-chain value. + +The application content address is `keccak256(exact serialized bytes)`. Avail's proof API returns +that value as `leaf`. The official bridge hashes `leaf` once more when it checks the submitted-data +Merkle root. The Solidity adapter first requires `leaf == contentHash`, then calls the bridge. Every +reader also re-hashes the retrieved bytes against `contentHash` before use. + +The SDK encodes each six-field staging envelope as a flat ABI parameter sequence. The server decodes +that sequence as function parameters, removes the ciphertext, and adds the signed expiry. It returns +a seven-field commitment payload that `CRISPProgram.publishInput` reads through Solidity +`abi.decode`. + +## Two-step voter flow + +```text +voter creates ciphertext and Noir proof + | + v +CRISP server validates the proof and stores the exact bytes durably + | + v +server signs InputAvailability(e3Id, inputId, expiresAt) + | + v +publishInput(proof, contentHash, commitment, slot, parent, expiresAt, signature) + | + +--> verifies the Noir proof and server signature + +--> reserves the input leaf and index immediately + +--> increments pendingInputCount + +--> emits InputCommitted + | + v +server submits the stored bytes to Avail + | + v +VectorX anchors the Avail range on Ethereum + | + v +finalizeInput(tuple, VectorX proof) + | + +--> proves availability of the exact contentHash + +--> marks the reserved input PUBLISHED + +--> decrements pendingInputCount + +--> emits InputPublished with Avail coordinates +``` + +The voter does not stay online for VectorX. On Ethereum mainnet, the voter pays only for the compact +`publishInput` transaction. The server owns the durable Avail and `finalizeInput` job. Sepolia and +local development can relay the compact transaction for the voter. + +The leaf is reserved in the first transaction so a revote or mask can name it as its parent while +VectorX is still finalizing. The server that signed the input already has the exact bytes and +indexes them when it sees `InputCommitted`. Other indexers wait for `InputPublished`, retrieve the +bytes from Avail, and verify their hash. + +`CRISPProgram.verify` refuses the aggregate proof while `pendingInputCount` is nonzero. A content +hash without an accepted VectorX receipt can therefore never enter the final computation. + +The aggregate callback uses the same two-proof order. Before the server spends Avail funds, it calls +`CRISPProgram.verify` as an Ethereum read with the output hash, SAFE commitment, and RISC Zero +proof. Only an output that passes that exact on-chain verifier becomes a durable Avail job. The job +ID excludes the proof seal, so another valid seal for the same output is an idempotent retry instead +of a second paid publication. The server also refuses the job while the input window is open: a +proof over the current root could otherwise become stale after another vote, after the Avail fee was +already paid. The compute server retries a transiently failed callback five times. + +## Deadline simulation + +The production timeout maxima before a committee key can exist are: + +| Phase | Maximum | Time since request | +| ----------------- | ---------: | -----------------: | +| Chainlink VRF | 1 hour | 1 hour | +| Ticket submission | 10 minutes | 1 hour 10 minutes | +| DKG | 6 hours | 7 hours 10 minutes | + +CRISP reserves the final 3 hours of the input window for VectorX. It also guarantees at least 1 hour +in which a voter can create a new proof after a worst-case committee setup: + +```text +1h VRF + 10m sortition + 6h DKG + 1h voting + 3h finalization = 40,200 seconds +``` + +With those production defaults, `E3_DURATION` must be at least 40,200 seconds. A short rehearsal can +use 43,200 seconds (12 hours), which leaves 1 hour 50 minutes for new commitments after the +worst-case key publication. The Interfold DAO launch configuration uses five days. That leaves 4 +days 13 hours 50 minutes for new commitments under the same worst case. The server does not +hard-code these totals. At startup, it reads the registry's randomness and sortition windows, +Interfold's DKG window, and CRISP's voting and finalization windows. It refuses to start when +`E3_DURATION` is shorter than their current sum. Test deployments with shorter on-chain windows can +therefore use a correspondingly shorter round. + +The three-hour tail is an operating target, not a promise from VectorX. Avail documents a 20-second +block time and says VectorX bridges one range every 360 blocks. One complete range is therefore +about two hours, before proof generation and Ethereum inclusion. The extra hour is normal-case +margin. If that margin is not enough, an already committed input remains recoverable through the +inclusive compute deadline. No new input is admitted during that recovery period. + +- [Avail block and finalization timing](https://docs.availproject.org/docs/da/build/turbo-da) +- [VectorX 360-block range](https://docs.availproject.org/docs/da/build/vectorx) + +The CRISP request paths currently start the input window 20 or 60 seconds after they read the chain +time. The exact timestamps therefore shift by that small start buffer. The table below uses `T0` as +the input-window start. The contract calculates from the actual request timestamp and actual input +window, so it does not rely on this approximation. + +For a 12-hour input window starting at `T0`: + +| Boundary | Timestamp | +| ------------------------------------------------------------- | ---------------: | +| Worst-case key publication | `T0 + 25,800s` | +| Last instant before commitment cutoff | `< T0 + 32,400s` | +| Input window ends | `T0 + 43,200s` | +| Compute deadline | `T0 + 648,000s` | +| Latest decryption deadline after a last-second compute output | `T0 + 669,600s` | + +This is below Interfold's 30-day maximum lifecycle reservation. + +The boundaries are intentional: + +- `publishInput` requires `timestamp < inputCommitmentDeadline`. +- The availability service signs `InputAvailability(e3Id, inputId, expiresAt)` only after it stores + the complete ciphertext. The promise expires after 10 minutes if no Ethereum commitment lands. A + retry receives a new promise only after the old one expires and the server releases its bytes. +- The final 3-hour tail accepts no new proof commitments. +- `finalizeInput` normally completes in that tail. A delayed receipt can recover while the E3 is + still `KeyPublished` and `timestamp <= computeDeadline`. +- RISC Zero does not start at the input-window end while any input is pending. +- A late `InputPublished` event wakes computation after all pending inputs reach zero. +- The aggregate job starts only when more than 3 hours remain before the compute deadline. +- Interfold accepts the aggregate output only after the input window ends and no later than the + compute deadline. + +Late input finalization is best-effort recovery, not a new seven-day availability promise. The +contract can accept a receipt through `computeDeadline`, but the E3 can complete only if enough of +the compute window remains to produce the RISC Zero proof, publish the aggregate ciphertext to +Avail, wait for its VectorX proof, and submit the output on Ethereum. The server therefore refuses +to start an aggregate Avail job unless more than three hours remain. Operators must alert well +before that cutoff instead of treating `computeDeadline` as a useful finalization target. + +The boundary tests use the contract timestamp directly. They cover these cases: + +| Case | Result | +| ------------------------------------------------------ | -------- | +| Commit at `commitmentDeadline - 1` | Accepted | +| Commit at `commitmentDeadline` | Rejected | +| Finalize an existing input at `computeDeadline` | Accepted | +| Finalize at `computeDeadline + 1` | Rejected | +| Compute while one input is committed but not finalized | Rejected | +| Compute after the last pending input is finalized | Allowed | + +The simulation also exposed an RPC race. A wallet transaction can land just before the commitment +cutoff while a load-balanced RPC still returns the older contract state just after the cutoff. The +worker must not conclude that the transaction failed from that mixed view. It now waits for an +Ethereum finalized block at or after the exclusive commitment cutoff and checks `isInputCommitted` +at that block. The same rule applies to input and output publication: a job is marked failed only +after a finalized block strictly after the inclusive compute deadline still lacks the publication. +Until then, the worker keeps the durable job recoverable. + +The service does not release an expired promise based on its local clock or an unfinalized chain +head. It waits for an Ethereum finalized block at or after `expiresAt`, then checks the historical +`isInputCommitted` state at that block. A commitment mined before expiry therefore survives even +when the service observes it later. If the finalized state contains no commitment, the service +releases the ciphertext and lets the voter stage the original proof again for a fresh promise. + +The old four-hour CRISP duration was unsafe. In the worst case, the input commitment cutoff arrived +before the committee key existed. Both the server and `CRISPProgram.validate` now refuse an unsafe +window. The contract derives the latest key time from the request's frozen DKG timeout and the +request-time Registry VRF and sortition windows. It also accounts for a deliberately delayed input +start, so calling the contract without the CRISP server cannot bypass the rule. + +## Restart and failure behavior + +- Each ciphernode stores partial public-key assemblies, selected candidates, and unresolved + ciphertext-output references in a chain-scoped recovery projection. A restart after the event + snapshot boundary resumes the missing chunks or retrieval instead of waiting for old logs that + will not replay. +- Every staged object and job state is in the server's persistent Sled database before the server + signs an input. The object has one content-addressed copy; job metadata does not duplicate it. +- The browser keeps the exact encoded ballot with its durable job pointer. If the server loses its + job database, the browser re-stages the same commitment instead of creating a second ciphertext + and leaving the first on-chain commitment unresolved. +- The server checks the one-megabyte object limit before it accepts an input commitment or creates + an output job. An oversized object cannot reserve a leaf that Avail will always reject. +- The job worker retries every 30 seconds and runs at most four job steps at once. The outer + eight-minute bound is longer than Avail's internal finality wait, so it stops a hung request + without cancelling a valid slow submission. +- On restart, the worker resumes proof commitments, Avail submissions, VectorX polling, Ethereum + finalization, and retrieval from the saved state. +- Ethereum state is checked before each write. A transaction that landed before a crash is not sent + again. +- A round and voting slot can have only one signed input that is still waiting for its Ethereum + commitment. The service also refuses new jobs when unfinished objects reach the configured byte + limit. Failed jobs release their bytes, and successful Avail jobs use Avail as the recovery + source. +- The browser status endpoint performs a bounded Ethereum reconciliation. If the wallet commitment + landed before the browser closed, a reload advances the durable job instead of asking the voter to + sign and submit the same transaction again. +- A deadline failure is conclusive only after the finalized Ethereum state crosses the relevant + boundary. This prevents a stale RPC read from stranding a transaction that landed on time. +- If a timeout interrupts an Avail submission after broadcast but before its receipt is saved, a + retry can pay for a duplicate publication. The content hash remains the same, so this affects cost + but not correctness. +- The server verifies an aggregate RISC Zero proof before it creates an Avail output job. An + arbitrary caller of the output webhook cannot spend the Avail account on an invalid output. +- The compute server retries a transient callback five times, but this callback is not a durable + outbox. If that process exits after it receives a proof but before CRISP accepts the callback, + operators must recover the result or resubmit the computation. The durable Avail worker starts + only after CRISP receives the callback. This is a pre-existing compute-server recovery limit, not + an Avail proof bypass. +- If VectorX never produces a valid proof, the input remains pending. CRISP refuses computation and + Interfold eventually fails the E3 at the compute deadline. +- Input retrieval retries while the round can still compute. References are removed when the + aggregate ciphertext is published, the round finishes, or Ethereum marks the E3 failed. A retry + limit during an active round would turn a temporary Avail outage into permanent data loss. + +The public HTTP boundary does not expose RPC or database error text. Contract reverts caused by a +ballot return a stable client error. Provider and storage failures return a retryable service error. +Failed admission returns its global relay reservation. + +## Remaining trust and operations + +VectorX provides the final correctness and availability proof. The server signature is an earlier +liveness promise: it proves that the configured service received and durably stored the exact +ciphertext before Ethereum reserves the leaf. + +If the availability signer is compromised, it can sign a hash without retaining the bytes. The +resulting pending input can stop the round until the compute timeout. It cannot make Ethereum accept +different bytes because `finalizeInput` still requires the VectorX proof for the committed hash. + +Production therefore needs: + +- a persistent, backed-up server volume; +- a pending-object limit sized for the largest supported round and the available disk; +- a protected Ethereum availability-signer key; +- a separate funded Avail account and registered App ID; +- monitored Ethereum, Avail, and bridge API endpoints; +- alerts for pending jobs, signer balance, Avail balance, and the commitment/finalization deadlines; +- `E3_DURATION=43200` and `AVAIL_PROOF_LEAD_SECONDS=10800`. + +No fallback changes the data source after its hash is known. Adding such a fallback would require a +new, explicitly bound proof path and a separate review. + +## Fast-machine acceptance gates + +The normal unit and contract suites do not reproduce the production RISC Zero image. Before this +branch can deploy, use the durable Interfold revision pinned in both support manifests and run the +pinned Docker build. The generated `ImageID.sol` must be reviewed and then used by both the BFV +ciphertext verifier and CRISP program deployment. A native build is not an acceptable substitute. + +After the image is rebuilt, run the full local CRISP Playwright flow and one Sepolia round with real +Avail Turing and VectorX. Observe this complete event order: + +```text +InputCommitted + -> Avail finalized + -> InputPublished + -> RISC Zero completed + -> aggregate Avail finalized + -> CiphertextOutputReferencePublished + -> plaintext completion +``` diff --git a/crates/Dockerfile b/crates/Dockerfile index 0bd83fbd0f..9332a69c96 100644 --- a/crates/Dockerfile +++ b/crates/Dockerfile @@ -53,6 +53,7 @@ COPY crates/config/Cargo.toml ./config/Cargo.toml COPY crates/console/Cargo.toml ./console/Cargo.toml COPY crates/crypto/Cargo.toml ./crypto/Cargo.toml COPY crates/data/Cargo.toml ./data/Cargo.toml +COPY crates/data-availability/Cargo.toml ./data-availability/Cargo.toml COPY crates/interfoldup/Cargo.toml ./interfoldup/Cargo.toml COPY crates/entrypoint/Cargo.toml ./entrypoint/Cargo.toml COPY crates/events/Cargo.toml ./events/Cargo.toml diff --git a/crates/aggregator/src/ext.rs b/crates/aggregator/src/ext.rs index 85076da538..eecb297d22 100644 --- a/crates/aggregator/src/ext.rs +++ b/crates/aggregator/src/ext.rs @@ -30,7 +30,7 @@ use e3_keyshare::ThresholdKeyshareRepositoryFactory; use e3_request::{ E3Context, E3ContextSnapshot, E3Extension, TypedKey, DKG_FOLD_ATTESTATION_CONTEXT_KEY, META_KEY, }; -use e3_sortition::Sortition; +use e3_sortition::{FinalizedCommitteesRepositoryFactory, Sortition}; use e3_zk_helpers::CiphernodesCommitteeSize; use std::collections::{BTreeSet, HashMap}; @@ -543,6 +543,27 @@ async fn recover_committee_dependencies_from_publickey_state( Ok(()) } +async fn recover_committee_dependencies_from_sortition_state( + ctx: &mut E3Context, + e3_id: &E3id, +) -> Result<()> { + if ctx.get_dependency(COMMITTEE_ADDRESSES_KEY).is_some() { + return Ok(()); + } + + let repo = ctx.repositories().finalized_committees(); + let Some(committees) = repo.read().await? else { + return Ok(()); + }; + let Some(committee) = committees.get(e3_id) else { + return Ok(()); + }; + + let addresses = committee_addresses_from_node_strings(committee.members())?; + ctx.set_dependency(COMMITTEE_ADDRESSES_KEY, addresses); + Ok(()) +} + async fn recover_honest_committee_dependencies_from_keyshare_state( ctx: &mut E3Context, e3_id: &E3id, @@ -690,6 +711,7 @@ impl E3Extension for ThresholdPlaintextAggregatorExtension { async fn hydrate(&self, ctx: &mut E3Context, snapshot: &E3ContextSnapshot) -> Result<()> { let e3_id = ctx.e3_id.clone(); + recover_committee_dependencies_from_sortition_state(ctx, &e3_id).await?; recover_committee_dependencies_from_publickey_state(ctx, &e3_id).await?; recover_honest_committee_dependencies_from_keyshare_state(ctx, &e3_id).await?; @@ -776,7 +798,7 @@ mod tests { use super::*; use alloy::primitives::address; use e3_data::{DataStore, InMemStore}; - use e3_events::{OrderedSet, Seed}; + use e3_events::{Committee, OrderedSet, Seed}; use e3_fhe::Fhe; use e3_fhe_params::BfvPreset; use e3_request::{ContextRepositoryFactory, E3ContextParams, E3Meta}; @@ -963,6 +985,41 @@ mod tests { Ok(()) } + #[actix::test] + async fn recovers_full_committee_from_finalized_sortition_state() -> Result<()> { + let e3_id = E3id::new("42", 1); + let store = DataStore::from_in_mem(&InMemStore::new(false).start()); + let mut ctx = E3Context::from_params(E3ContextParams { + repository: store.repositories().context(&e3_id), + e3_id: e3_id.clone(), + extensions: Arc::new(Vec::new()), + }); + let committee_nodes = vec![ + "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65".to_string(), + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC".to_string(), + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".to_string(), + ]; + ctx.repositories() + .finalized_committees() + .write_sync(&HashMap::from([( + e3_id.clone(), + Committee::new(committee_nodes), + )])) + .await?; + + recover_committee_dependencies_from_sortition_state(&mut ctx, &e3_id).await?; + + assert_eq!( + ctx.get_dependency(COMMITTEE_ADDRESSES_KEY), + Some(&vec![ + address!("0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65"), + address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), + ]) + ); + Ok(()) + } + #[test] fn recovers_honest_committee_addresses_from_generating_c5_state() -> Result<()> { let state = generating_c5_state(); diff --git a/crates/bfv-client/src/client.rs b/crates/bfv-client/src/client.rs index 7f26a20377..2e1a995b18 100644 --- a/crates/bfv-client/src/client.rs +++ b/crates/bfv-client/src/client.rs @@ -9,10 +9,11 @@ use e3_fhe_params::{try_build_bfv_params_arc, BfvParamSet, BfvPreset}; use e3_zk_helpers::circuits::threshold::user_data_encryption::circuit::UserDataEncryptionCircuitData; use e3_zk_helpers::circuits::threshold::user_data_encryption::Inputs as UserDataEncryptionInputs; use e3_zk_helpers::circuits::Computation; -use fhe::bfv::{Ciphertext, Encoding, Plaintext, PublicKey, SecretKey}; +use fhe::bfv::{BfvParameters, Ciphertext, Encoding, Plaintext, PublicKey, SecretKey}; use fhe::Error as FheError; use fhe_traits::{DeserializeParametrized, FheEncoder, FheEncrypter, Serialize}; use rand::rng; +use std::sync::Arc; fn build_client_params( degree: usize, @@ -223,14 +224,25 @@ pub fn compute_ct_commitment( plaintext_modulus: u64, moduli: Vec, ) -> Result<[u8; 32]> { - use e3_zk_helpers::circuits::threshold::user_data_encryption::utils::compute_ciphertext_commitment; - let params = build_client_params(degree, plaintext_modulus, &moduli, None)?; - let ct = Ciphertext::from_bytes(&ct, ¶ms) + compute_ct_commitment_with_params(&ct, ¶ms) +} + +/// Computes a ciphertext commitment with BFV parameters that the caller already built. +/// +/// Use this function when one operation checks multiple ciphertexts with the same parameters. It +/// avoids rebuilding the large BFV parameter tables for every ciphertext. +pub fn compute_ct_commitment_with_params( + ct: &[u8], + params: &Arc, +) -> Result<[u8; 32]> { + use e3_zk_helpers::circuits::threshold::user_data_encryption::utils::compute_ciphertext_commitment; + + let ct = Ciphertext::from_bytes(ct, params) .map_err(|e| anyhow!("Error deserializing ciphertext: {}", e))?; - let commitment = compute_ciphertext_commitment(¶ms, &ct) + let commitment = compute_ciphertext_commitment(params, &ct) .map_err(|e| anyhow!("Error computing ciphertext commitment: {}", e))?; Ok(commitment) diff --git a/crates/bfv-client/src/lib.rs b/crates/bfv-client/src/lib.rs index 5f1891f497..5537b4186b 100644 --- a/crates/bfv-client/src/lib.rs +++ b/crates/bfv-client/src/lib.rs @@ -12,8 +12,8 @@ use thiserror::Error as ThisError; pub use client::VerifiableEncryptionResult; pub use client::{ - bfv_encrypt, bfv_verifiable_encrypt, compute_ct_commitment, compute_pk_commitment, - validate_pk_commitment, + bfv_encrypt, bfv_verifiable_encrypt, compute_ct_commitment, compute_ct_commitment_with_params, + compute_pk_commitment, validate_pk_commitment, }; #[derive(ThisError, Debug)] diff --git a/crates/ciphernode-builder/src/ciphernode_builder.rs b/crates/ciphernode-builder/src/ciphernode_builder.rs index a1f17e1c6f..103c7f822a 100644 --- a/crates/ciphernode-builder/src/ciphernode_builder.rs +++ b/crates/ciphernode-builder/src/ciphernode_builder.rs @@ -24,17 +24,17 @@ use e3_aggregator::{ use e3_config::{chain_config::ChainConfig, NetworkProfile}; use e3_crypto::Cipher; use e3_data::{InMemStore, RepositoriesFactory}; -use e3_events::DkgFoldAttestationContext; +use e3_events::{hlc::Hlc, DkgFoldAttestationContext}; use e3_events::{ - AggregateConfig, AggregateId, BusHandle, E3id, EventBus, EventBusConfig, EventSubscriber, - EventType, EvmEventConfig, InterfoldEvent, + AggregateConfig, AggregateId, BusHandle, E3Stage, E3id, EventBus, EventBusConfig, + EventSubscriber, EventType, EvmEventConfig, InterfoldEvent, }; use e3_evm::{ ensure_node_release, fetch_accusation_vote_validity, fetch_randomness_providers, BondingRegistrySolReader, CiphernodeRegistrySol, CiphernodeRegistrySolReader, - EvmChainGatewayHandle, InterfoldSolReader, InterfoldSolWriter, ProviderConfig, - RandomnessProviderSolReader, SlashingManagerSolReader, SlashingManagerSolWriter, - SlashingWriterRepositoryFactory, + DataAvailabilityCoordinator, DataAvailabilityRepositoryFactory, EvmChainGatewayHandle, + InterfoldSolReader, InterfoldSolWriter, ProviderConfig, RandomnessProviderSolReader, + SlashingManagerSolReader, SlashingManagerSolWriter, SlashingWriterRepositoryFactory, }; use e3_fhe::ext::FheExtension; use e3_keyshare::ext::ThresholdKeyshareExtension; @@ -73,6 +73,7 @@ struct EvmStartupRecovery<'a> { dkg_fold_contexts_by_e3: &'a HashMap, active_aggregators: &'a HashMap, selected_party_ids: &'a HashMap, + lifecycle_stages: &'a HashMap, committee_finalizer: &'a CommitteeFinalizerRecoveryState, } @@ -597,6 +598,11 @@ impl CiphernodeBuilder { self.contract_components.slashing_manager, ) .await?; + let lifecycle_stages = repositories + .e3_lifecycle() + .read() + .await? + .unwrap_or_default(); let dkg_fold_contexts_by_e3 = load_dkg_fold_attestation_contexts(&repositories).await?; let mut provider_cache = @@ -621,7 +627,9 @@ impl CiphernodeBuilder { // Resolve node address and enable the bus let addr = provider_cache.ensure_signer().await?.address().to_string(); - let bus = event_system.handle()?.enable(&addr); + let bus = event_system + .handle()? + .enable_with_hlc(event_clock(&addr, &resolved_chain_ids)); if self.logging { let logger_name = self.name.as_deref().unwrap_or("ciphernode"); @@ -673,6 +681,7 @@ impl CiphernodeBuilder { dkg_fold_contexts_by_e3: &dkg_fold_contexts_by_e3, active_aggregators: &selector_state.is_aggregator, selected_party_ids: &selected_party_ids, + lifecycle_stages: &lifecycle_stages, committee_finalizer: &committee_finalizer_recovery, }, ) @@ -694,6 +703,7 @@ impl CiphernodeBuilder { &dkg_fold_contexts_by_e3, &accusation_vote_validity_by_chain, &selector_state, + &lifecycle_stages, ) .await?; @@ -918,6 +928,7 @@ impl CiphernodeBuilder { dkg_fold_contexts_by_e3: &HashMap, accusation_vote_validity_by_chain: &HashMap, selector_state: &CiphernodeSelectorState, + lifecycle_stages: &HashMap, ) -> Result { let recovered_selections = recovered_ciphernode_selections(selector_state, addr)?; let mut e3_builder = @@ -945,7 +956,7 @@ impl CiphernodeBuilder { // โ”€โ”€ Threshold keyshare + ZK actors โ”€โ”€ if let Some(KeyshareKind::Threshold) = self.keyshare { - let _ = self.ensure_multithread(bus); + let _ = self.ensure_multithread(bus, lifecycle_stages); let backend = self .zk_backend .as_ref() @@ -991,7 +1002,7 @@ impl CiphernodeBuilder { e3_builder = e3_builder.with(FheExtension::create(bus, &self.rng)); info!("Setting up PublicKeyAggregationExtension"); - let _ = self.ensure_multithread(bus); + let _ = self.ensure_multithread(bus, lifecycle_stages); e3_builder = e3_builder.with(PublicKeyAggregatorExtension::create(bus)); if self.keyshare.is_none() { @@ -1015,7 +1026,7 @@ impl CiphernodeBuilder { // โ”€โ”€ Threshold plaintext aggregation โ”€โ”€ if self.threshold_plaintext_agg { info!("Setting up ThresholdPlaintextAggregatorExtension"); - let _ = self.ensure_multithread(bus); + let _ = self.ensure_multithread(bus, lifecycle_stages); e3_builder = e3_builder.with(ThresholdPlaintextAggregatorExtension::create( bus, sortition, @@ -1108,7 +1119,11 @@ impl CiphernodeBuilder { NetworkPolicy::new(profile, deployments) } - fn ensure_multithread(&mut self, bus: &BusHandle) -> Addr { + fn ensure_multithread( + &mut self, + bus: &BusHandle, + lifecycle_stages: &HashMap, + ) -> Addr { if let Some(cached) = self.multithread_cache.clone() { return cached; } @@ -1131,6 +1146,7 @@ impl CiphernodeBuilder { task_pool, self.multithread_report.clone(), backend, + lifecycle_stages.clone(), ) } else { Multithread::attach( @@ -1139,6 +1155,7 @@ impl CiphernodeBuilder { self.cipher.clone(), task_pool, self.multithread_report.clone(), + lifecycle_stages.clone(), ) }; @@ -1187,6 +1204,19 @@ fn validate_vrf_chain_id(chain_id: u64) -> Result<()> { Ok(()) } +fn event_clock(node_id: &str, chain_ids: &[u64]) -> Hlc { + let clock = Hlc::from_str(node_id); + // Local EVM tests advance block timestamps. Public chains keep the default drift fence. + if !chain_ids.is_empty() + && chain_ids + .iter() + .all(|chain_id| matches!(chain_id, 1_337 | 31_337)) + { + return clock.with_max_drift(u64::MAX); + } + clock +} + /// Build delay configuration for a specific chain fn create_aggregate_delay(chain: &ChainConfig, actual_chain_id: u64) -> (AggregateId, Duration) { let aggregate_id = AggregateId::from_chain_id(Some(actual_chain_id)); @@ -1225,6 +1255,7 @@ async fn setup_evm_system( dkg_fold_contexts_by_e3, active_aggregators, selected_party_ids, + lifecycle_stages, committee_finalizer, } = recovery; let mut evm_config = EvmEventConfig::new(); @@ -1232,6 +1263,19 @@ async fn setup_evm_system( for chain in chains.iter().filter(|chain| chain.enabled.unwrap_or(true)) { let provider = provider_cache.ensure_read_provider(chain).await?; let chain_id = provider.chain_id(); + if contract_components.interfold && chain.data_availability.is_none() { + anyhow::bail!( + "chain '{}' has Interfold enabled but no data_availability reader; protocol v3 nodes must be able to retrieve proof-backed ciphertext outputs", + chain.name + ); + } + DataAvailabilityCoordinator::attach( + bus, + chain_id, + chain.data_availability.as_ref(), + repositories.data_availability_recovery(chain_id), + ) + .await?; if contract_components.ciphernode_registry { validate_vrf_chain_id(chain_id)?; } @@ -1261,12 +1305,39 @@ async fn setup_evm_system( .filter(|(e3_id, _)| e3_id.chain_id() == chain_id) .map(|(e3_id, party_id)| (e3_id.clone(), *party_id)) .collect(); + let chain_request_registries = dkg_fold_contexts_by_e3 + .iter() + .filter(|(e3_id, _)| e3_id.chain_id() == chain_id) + .map(|(e3_id, context)| (e3_id.clone(), context.registry)) + .collect(); + let chain_failure_stages = lifecycle_stages + .iter() + .filter(|(e3_id, stage)| { + e3_id.chain_id() == chain_id + && matches!( + stage, + E3Stage::Requested + | E3Stage::CommitteeFinalized + | E3Stage::KeyPublished + | E3Stage::CiphertextReady + ) + }) + .map(|(e3_id, stage)| (e3_id.clone(), stage.clone())) + .collect(); + let chain_failure_settlements = lifecycle_stages + .iter() + .filter(|(e3_id, stage)| e3_id.chain_id() == chain_id && **stage == E3Stage::Failed) + .map(|(e3_id, _)| e3_id.clone()) + .collect(); InterfoldSolWriter::attach_with_recovery( bus, write_provider.clone(), contract.address()?, chain_active_aggregators, chain_party_ids, + chain_request_registries, + chain_failure_stages, + chain_failure_settlements, ); system.with_contract(contract.address()?, move |next| { InterfoldSolReader::setup(&next).recipient() @@ -1427,15 +1498,18 @@ async fn wait_for_evm_gateways(gateways: Vec) -> Result<( #[cfg(test)] mod tests { use super::{ - create_aggregate_delay, reconcile_committee_snapshots, recovered_ciphernode_selections, - validate_vrf_chain_id, + create_aggregate_delay, event_clock, reconcile_committee_snapshots, + recovered_ciphernode_selections, validate_vrf_chain_id, }; use e3_config::{ chain_config::ChainConfig, contract::{Contract, ContractAddresses}, rpc::RpcAuth, }; - use e3_events::{Committee, E3Stage, E3id, Seed}; + use e3_events::{ + hlc::{HlcError, HlcMethods, HlcTimestamp}, + Committee, E3Stage, E3id, Seed, + }; use e3_fhe_params::BfvPreset; use e3_request::E3Meta; use e3_sortition::CiphernodeSelectorState; @@ -1462,6 +1536,7 @@ mod tests { }, finalization_ms, chain_id: Some(1), + data_availability: None, } } @@ -1495,6 +1570,23 @@ mod tests { assert!(error.to_string().contains("Ethereum mainnet")); } + #[test] + fn local_event_clock_allows_time_travel_only_on_dev_chains() { + let future_timestamp = |clock: &e3_events::hlc::Hlc| { + let now = clock.tick().unwrap(); + HlcTimestamp::new(now.ts + 3_600_000_000, 0, now.node + 1) + }; + + let local = event_clock("local", &[31_337]); + assert!(local.receive(&future_timestamp(&local)).is_ok()); + + let public = event_clock("public", &[1]); + assert!(matches!( + public.receive(&future_timestamp(&public)), + Err(HlcError::DriftExceeded { .. }) + )); + } + #[test] fn startup_reconciles_committees() { let selector_only = E3id::new("1", 1); diff --git a/crates/ciphernode-builder/src/event_system.rs b/crates/ciphernode-builder/src/event_system.rs index cf61efd4bf..f307d3956e 100644 --- a/crates/ciphernode-builder/src/event_system.rs +++ b/crates/ciphernode-builder/src/event_system.rs @@ -433,9 +433,9 @@ mod tests { use e3_events::EventContext; use e3_events::EventId; use e3_events::EventSource; + use e3_events::FlushPendingSnapshots; use e3_events::StoreKeys; use e3_events::SyncEnded; - use e3_events::Tick; use e3_events::TsAgg; use e3_test_helpers::with_tracing; use std::time::Duration; @@ -630,12 +630,7 @@ mod tests { // Lets store some data on a plain datastore info!("Writing to /foo/name with no context"); datastore.scope("/foo/name").write("Fred".to_string()); - // Note there is some eventual consistency here we have to wait - assert_eq!(datastore.scope("/foo/name").read::().await?, None); - info!("Wait one tick"); - - // Let's wait until all events are settled only takes a tick - sleep(Duration::from_millis(1)).await; + buffer.send(FlushPendingSnapshots).await??; // These inserts should not be buffered and should be available assert_eq!( @@ -668,8 +663,7 @@ mod tests { info!("Publishing SyncEnded event to turn on SnapshotBuffer. This should send the seq=1 batch to the timelock..."); // Publishing SyncEnded should turn on the SnapshotBuffer seq 2 handle.publish(SyncEnded::new(), ec.clone())?; - - sleep(Duration::from_millis(1)).await; + handle.flush_event_pipeline().await?; info!("Mutating persistable state to create inserts using seq=2"); @@ -683,20 +677,17 @@ mod tests { .sequence(2); persistable.try_mutate(&ec, |_| Ok("Liz".to_string()))?; - sleep(Duration::from_millis(1)).await; info!("Mutation complete"); - // SnapshotBuffer is not cleared unless new events are published + // Flush through sequence 2 before the next event opens sequence 3. Timelock behavior has + // separate mock-clock tests; this integration test must not depend on wall-clock timing. + buffer.send(FlushPendingSnapshots).await??; // Get a timestamp for the events below let ts = handle.ts()?; // Push a few other events seq 3 - // This sends the previous batch for seq2 to the timelock queue handle.publish_without_context(TestEvent::new("yellow", 1))?; - - // Wait a second for the timelock to be checked - sleep(Duration::from_secs(2)).await; - buffer.try_send(Tick)?; + handle.flush_event_pipeline().await?; // Check now info!("Reading from /foo/name and expecting it to be correct."); @@ -716,7 +707,7 @@ mod tests { // Publish a few other events handle.publish_without_context(TestEvent::new("red", 1))?; handle.publish_without_context(TestEvent::new("white", 1))?; - sleep(Duration::from_millis(100)).await; + handle.flush_event_pipeline().await?; // Get the event logs from the listener let logs = listener.send(GetLogs).await?; diff --git a/crates/compute-provider/src/compute_input.rs b/crates/compute-provider/src/compute_input.rs index eba7b85b93..eaa7b61cf5 100644 --- a/crates/compute-provider/src/compute_input.rs +++ b/crates/compute-provider/src/compute_input.rs @@ -7,11 +7,25 @@ use crate::ciphertext_output::ComputeResult; use crate::merkle_tree_builder::MerkleTreeBuilder; use crate::policy::InputPolicy; +#[cfg(test)] use e3_bfv_client::client::compute_ct_commitment; -use e3_fhe_params::decode_bfv_params; +use e3_bfv_client::client::compute_ct_commitment_with_params; +use e3_fhe_params::decode_bfv_params_arc; +use fhe::bfv::BfvParameters; use sha3::{Digest, Keccak256}; +use std::sync::Arc; -pub type FHEProcessor = fn(&FHEInputs) -> Vec; +pub type FHEProcessor = for<'a> fn(&FHEProcessorInput<'a>) -> Vec; + +/// Inputs passed to an E3 program's homomorphic processor. +/// +/// The secure process builds BFV parameters once and shares them with the processor. Building the +/// secure parameter tables is expensive inside a zkVM, and decoding the same immutable bytes twice +/// adds no verification. +pub struct FHEProcessorInput<'a> { + pub ciphertexts: &'a [(Vec, u64)], + pub params: &'a Arc, +} #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct FHEInputs { @@ -98,7 +112,7 @@ impl ComputeInput { fhe_processor: FHEProcessor, policy: InputPolicy, ) -> Result<(ComputeResult, Vec), ComputeError> { - let params = decode_bfv_params(&self.fhe_inputs.params) + let params = decode_bfv_params_arc(&self.fhe_inputs.params) .map_err(|e| ComputeError::DecodeParams(e.to_string()))?; if !self.published.is_empty() && self.published.len() != self.fhe_inputs.ciphertexts.len() { @@ -121,19 +135,15 @@ impl ComputeInput { // The processor sees only what the policy selected. Both the root above and this set are // functions of values the root binds, so any prover over the same published inputs reaches // the same result. - let processed_ciphertext = (fhe_processor)(&FHEInputs { - ciphertexts: selected, - params: self.fhe_inputs.params.clone(), + let processed_ciphertext = (fhe_processor)(&FHEProcessorInput { + ciphertexts: &selected, + params: ¶ms, }); let processed_hash = Keccak256::digest(&processed_ciphertext).to_vec(); - let ciphertext_commitment = compute_ct_commitment( - processed_ciphertext.clone(), - params.degree(), - params.plaintext(), - params.moduli().to_vec(), - ) - .map_err(|e| ComputeError::OutputCommitment(e.to_string()))? - .to_vec(); + let ciphertext_commitment = + compute_ct_commitment_with_params(&processed_ciphertext, ¶ms) + .map_err(|e| ComputeError::OutputCommitment(e.to_string()))? + .to_vec(); let params_hash = Keccak256::digest(&self.fhe_inputs.params).to_vec(); Ok(( @@ -158,14 +168,11 @@ mod tests { use fhe_traits::{FheEncoder, FheEncrypter, Serialize as FheSerialize}; use rand::SeedableRng; use rand_chacha::ChaCha8Rng; - use std::sync::Arc; - - fn sum_processor(inputs: &FHEInputs) -> Vec { - let params = Arc::new(decode_bfv_params(&inputs.params).unwrap()); - let mut sum = Ciphertext::zero(¶ms); - for (bytes, _) in &inputs.ciphertexts { + fn sum_processor(inputs: &FHEProcessorInput<'_>) -> Vec { + let mut sum = Ciphertext::zero(inputs.params); + for (bytes, _) in inputs.ciphertexts { use fhe_traits::DeserializeParametrized; - sum += &Ciphertext::from_bytes(bytes, ¶ms).unwrap(); + sum += &Ciphertext::from_bytes(bytes, inputs.params).unwrap(); } sum.to_bytes() } @@ -207,7 +214,7 @@ mod tests { #[test] fn the_root_is_derived_from_the_processed_ciphertexts() { let inputs = encrypted_inputs(&[1, 1, 1]); - let params = decode_bfv_params(&inputs.params).unwrap(); + let params = decode_bfv_params_arc(&inputs.params).unwrap(); let result = process(inputs.clone(), InputPolicy::default()).unwrap(); @@ -238,7 +245,7 @@ mod tests { #[test] fn the_default_policy_uses_the_ciphertext_commitment_and_keeps_every_input() { let inputs = encrypted_inputs(&[4, 5]); - let params = decode_bfv_params(&inputs.params).unwrap(); + let params = decode_bfv_params_arc(&inputs.params).unwrap(); let mut builder = MerkleTreeBuilder::new(2); let selected = builder @@ -265,7 +272,7 @@ mod tests { Vec::new() } let inputs = encrypted_inputs(&[1, 2, 3]); - let params = decode_bfv_params(&inputs.params).unwrap(); + let params = decode_bfv_params_arc(&inputs.params).unwrap(); let mut builder = MerkleTreeBuilder::new(3); let selected = builder @@ -295,7 +302,7 @@ mod tests { vec![99] } let inputs = encrypted_inputs(&[1]); - let params = decode_bfv_params(&inputs.params).unwrap(); + let params = decode_bfv_params_arc(&inputs.params).unwrap(); let error = MerkleTreeBuilder::new(1) .compute_leaf_hashes( @@ -337,7 +344,7 @@ mod tests { fn the_default_policy_reports_the_index_of_an_undecodable_input() { let mut inputs = encrypted_inputs(&[1, 1]); inputs.ciphertexts[1].0 = vec![0xff; 8]; - let params = decode_bfv_params(&inputs.params).unwrap(); + let params = decode_bfv_params_arc(&inputs.params).unwrap(); let error = MerkleTreeBuilder::new(2) .compute_leaf_hashes(&inputs, &[], ¶ms, InputPolicy::default()) @@ -435,7 +442,11 @@ mod tests { ); // And it is genuinely the selected subset, not the whole set. - let over_everything = sum_processor(&inputs); + let params = decode_bfv_params_arc(&inputs.params).unwrap(); + let over_everything = sum_processor(&FHEProcessorInput { + ciphertexts: &inputs.ciphertexts, + params: ¶ms, + }); assert_ne!( ciphertext, over_everything, "the excluded input must not be in the published ciphertext" diff --git a/crates/compute-provider/src/merkle_tree_builder.rs b/crates/compute-provider/src/merkle_tree_builder.rs index 9e1ba4e457..703993e48e 100644 --- a/crates/compute-provider/src/merkle_tree_builder.rs +++ b/crates/compute-provider/src/merkle_tree_builder.rs @@ -8,12 +8,13 @@ use crate::compute_input::{ComputeError, FHEInputs, PublishedData}; use crate::policy::{InputPolicy, PublishedInput}; use ark_bn254::Fr; use ark_ff::{BigInt, BigInteger}; -use e3_bfv_client::client::compute_ct_commitment; +use e3_bfv_client::client::compute_ct_commitment_with_params; use fhe::bfv::BfvParameters; use light_poseidon::{Poseidon, PoseidonHasher}; use num_bigint::BigUint; use num_traits::Num; use std::str::FromStr; +use std::sync::Arc; use zk_kit_imt::imt::IMT; pub struct MerkleTreeBuilder { @@ -56,12 +57,9 @@ impl MerkleTreeBuilder { &mut self, inputs: &FHEInputs, published: &[PublishedData], - params: &BfvParameters, + params: &Arc, policy: InputPolicy, ) -> Result, u64)>, ComputeError> { - let degree = params.degree(); - let plaintext_modulus = params.plaintext(); - let moduli = params.moduli().to_vec(); let empty = PublishedData::default(); let entries: Vec = inputs @@ -79,13 +77,7 @@ impl MerkleTreeBuilder { // published bytes back to what the E3 program proved, and it needs the BFV // parameters. A ciphertext that does not deserialize yields `None`, which is an // unusable input rather than a failure โ€” the bytes are untrusted. - recomputed: compute_ct_commitment( - ciphertext.clone(), - degree, - plaintext_modulus, - moduli.clone(), - ) - .ok(), + recomputed: compute_ct_commitment_with_params(ciphertext, params).ok(), } }) .collect(); diff --git a/crates/config/protocol-release.toml b/crates/config/protocol-release.toml index 0b4e2fab0e..319881cb73 100644 --- a/crates/config/protocol-release.toml +++ b/crates/config/protocol-release.toml @@ -2,5 +2,5 @@ # Increase protocol_version when contracts, events, cryptography, or protocol behavior become # incompatible. Increase node_generation for a mandatory node-only release. -protocol_version = 2 +protocol_version = 3 node_generation = 1 diff --git a/crates/config/src/chain_config.rs b/crates/config/src/chain_config.rs index 73f114be81..8367900075 100644 --- a/crates/config/src/chain_config.rs +++ b/crates/config/src/chain_config.rs @@ -17,6 +17,21 @@ use tracing::error; const PUBLIC_RPC_CONFIRMATIONS: u64 = 1; +#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DataAvailabilityMode { + Avail, + MockHttp, +} + +/// Read configuration for content-addressed objects referenced by Ethereum events. +#[derive(Debug, Clone, PartialEq, Hash, Eq, Deserialize, Serialize)] +pub struct DataAvailabilityConfig { + pub mode: DataAvailabilityMode, + /// Avail HTTP RPC for `avail`, or the local object-service URL for `mock_http`. + pub rpc_url: String, +} + #[derive(Debug, Clone, PartialEq, Hash, Eq, Deserialize, Serialize)] pub struct ChainConfig { pub enabled: Option, @@ -27,6 +42,8 @@ pub struct ChainConfig { pub contracts: ContractAddresses, pub finalization_ms: Option, pub chain_id: Option, + #[serde(default)] + pub data_availability: Option, } impl ChainConfig { @@ -108,6 +125,7 @@ mod tests { }, finalization_ms: None, chain_id: Some(1), + data_availability: None, } } diff --git a/crates/config/src/network.rs b/crates/config/src/network.rs index 809956db62..fbe46c92d4 100644 --- a/crates/config/src/network.rs +++ b/crates/config/src/network.rs @@ -287,6 +287,7 @@ mod tests { }, finalization_ms: None, chain_id, + data_availability: None, } } diff --git a/crates/config/src/program_config.rs b/crates/config/src/program_config.rs index edb9128609..7d98502eb7 100644 --- a/crates/config/src/program_config.rs +++ b/crates/config/src/program_config.rs @@ -17,27 +17,33 @@ pub struct BoundlessConfig { pub private_key: String, #[serde(default)] pub pinata_jwt: Option, + /// Public gateway base URL used in Boundless program and input references. + /// + /// Use a dedicated gateway for production. The shared Pinata gateway can accept a HEAD + /// request and then rate-limit the full object download that a prover needs. + #[serde(default)] + pub ipfs_gateway_url: Option, #[serde(default)] pub program_url: Option, #[serde(default = "default_true")] pub onchain: bool, - // --- Offer params (all optional, fall back to defaults in build_offer_params) --- - /// Minimum price in ETH (default: 0.00005) + // --- Offer parameters (all optional; the support host supplies the defaults) --- + /// Minimum price in ETH (default: 0.00005). #[serde(default)] pub min_price_eth: Option, - /// Maximum price in ETH (default: 0.002) + /// Maximum price in ETH (default: 0.004). #[serde(default)] pub max_price_eth: Option, - /// Total timeout in seconds (default: 600 = 10 min) + /// Total timeout in seconds (default: 28800 = 8 hours). #[serde(default)] pub timeout_secs: Option, - /// Lock timeout in seconds (default: 300 = 5 min) + /// Lock timeout in seconds (default: 14400 = 4 hours). #[serde(default)] pub lock_timeout_secs: Option, - /// Ramp-up period in seconds (default: 60 = 1 min) + /// Ramp-up period in seconds (default: 7200 = 2 hours). #[serde(default)] pub ramp_up_secs: Option, - /// Lock collateral in ZKC (default: 2.0) + /// Lock collateral in ZKC (default: 100.0). #[serde(default)] pub lock_collateral_zkc: Option, } @@ -82,3 +88,33 @@ impl ProgramConfig { self.dev.unwrap_or(false) } } + +#[cfg(test)] +mod tests { + use super::ProgramConfig; + + #[test] + fn deserializes_dedicated_ipfs_gateway() { + let config: ProgramConfig = serde_yaml::from_str( + r#" +risc0: + risc0_dev_mode: 0 + boundless: + rpc_url: "https://base.example" + private_key: "demo" + pinata_jwt: "demo" + ipfs_gateway_url: "https://dedicated.example" +"#, + ) + .expect("program config must deserialize"); + + let boundless = config + .risc0() + .and_then(|risc0| risc0.boundless.as_ref()) + .expect("Boundless config must be present"); + assert_eq!( + boundless.ipfs_gateway_url.as_deref(), + Some("https://dedicated.example") + ); + } +} diff --git a/crates/data-availability/Cargo.toml b/crates/data-availability/Cargo.toml new file mode 100644 index 0000000000..b5473c0ca7 --- /dev/null +++ b/crates/data-availability/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "e3-data-availability" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Interfold content-addressed data-availability clients" +repository.workspace = true +publish = false + +[dependencies] +alloy-primitives = { workspace = true } +alloy-sol-types = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +avail-rust-client = { workspace = true, optional = true } +hex = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } + +[features] +default = [] +avail-submit = ["dep:avail-rust-client"] +avail-read = ["dep:avail-rust-client"] + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/data-availability/src/avail.rs b/crates/data-availability/src/avail.rs new file mode 100644 index 0000000000..f039ebfa40 --- /dev/null +++ b/crates/data-availability/src/avail.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +use super::*; +use anyhow::ensure; +use avail_rust_client::{ + avail, ext::codec::Decode, Client, HasHeader, Keypair, Options, SecretUri, +}; +use std::str::FromStr; + +fn validate_rpc_endpoint(endpoint: &str) -> Result<()> { + let url = reqwest::Url::parse(endpoint).context("invalid Avail RPC URL")?; + ensure!( + matches!(url.scheme(), "http" | "https"), + "Avail RPC URL must use HTTP or HTTPS; avail-rust-client does not accept WebSocket URLs" + ); + Ok(()) +} + +/// Avail reader that finds a `submit_data` call in the Ethereum-verified block by content hash. +#[derive(Clone)] +pub struct AvailReader { + endpoint: String, +} + +impl AvailReader { + pub fn new(endpoint: impl Into) -> Result { + let endpoint = endpoint.into().trim().to_owned(); + validate_rpc_endpoint(&endpoint)?; + Ok(Self { endpoint }) + } +} + +#[async_trait] +impl DataAvailabilityReader for AvailReader { + async fn retrieve(&self, reference: DataReference) -> Result> { + let client = tokio::time::timeout(Duration::from_secs(20), Client::new(&self.endpoint)) + .await + .context("timed out while connecting to Avail")??; + let calls = tokio::time::timeout( + Duration::from_secs(30), + client + .block(reference.block_number) + .extrinsics() + .all::(Default::default()), + ) + .await + .context("timed out while reading the Avail block")??; + + for call in calls { + if keccak256(&call.call.data).0 == reference.content_hash { + return verify_retrieved_bytes(reference, call.call.data); + } + } + bail!( + "Avail block {} does not contain object 0x{}", + reference.block_number, + hex::encode(reference.content_hash) + ) + } +} + +/// Avail `submit_data` publisher paired with the official VectorX bridge API. +pub struct AvailPublisher { + endpoint: String, + app_id: u32, + signer: Keypair, + bridge: VectorXBridgeApi, + // Avail account nonces are sequential. The client resolves the next nonce from chain state, + // so concurrent submissions from one signer can otherwise select the same nonce. + submission_lock: tokio::sync::Mutex<()>, +} + +impl AvailPublisher { + pub fn new( + endpoint: impl Into, + app_id: u32, + secret_uri: &str, + bridge_api: impl Into, + destination_chain_id: u64, + ) -> Result { + let endpoint = endpoint.into().trim().to_owned(); + validate_rpc_endpoint(&endpoint)?; + let secret = SecretUri::from_str(secret_uri).context("invalid Avail secret URI")?; + let signer = Keypair::from_uri(&secret).context("invalid Avail signing key")?; + Ok(Self { + endpoint, + app_id, + signer, + bridge: VectorXBridgeApi::new(bridge_api, destination_chain_id)?, + submission_lock: tokio::sync::Mutex::new(()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reader_requires_the_http_transport_used_by_avail_rust_client() { + assert!(AvailReader::new("https://avail.example/rpc").is_ok()); + let error = AvailReader::new("wss://avail.example/ws") + .err() + .expect("WebSocket transport must be rejected"); + assert!(error.to_string().contains("HTTP or HTTPS")); + } +} + +#[async_trait] +impl DataAvailabilityPublisher for AvailPublisher { + async fn publish(&self, bytes: &[u8]) -> Result { + validate_object_bytes(bytes)?; + // Keep the lock through finality. A second nonce lookup before the first transaction is + // finalized can return the same nonce on RPCs that ignore their pending pool. + let _submission = self.submission_lock.lock().await; + let client = tokio::time::timeout(Duration::from_secs(20), Client::new(&self.endpoint)) + .await + .context("timed out while connecting to Avail")??; + let tx = client.tx().data_availability().submit_data(bytes); + let submitted = tokio::time::timeout( + Duration::from_secs(30), + tx.sign_and_submit(&self.signer, Options::new(self.app_id)), + ) + .await + .context("timed out while submitting data to Avail")??; + let receipt = tokio::time::timeout(Duration::from_secs(300), submitted.receipt(false)) + .await + .context("timed out while waiting for Avail finality")?? + .context("Avail transaction expired before finalization")?; + let events = tokio::time::timeout(Duration::from_secs(30), receipt.events()) + .await + .context("timed out while checking the finalized Avail transaction")??; + if !events.is_extrinsic_success_present() { + bail!("Avail submit_data transaction failed in finalized block"); + } + + Ok(PendingPublication { + content_hash: keccak256(bytes).0, + block_hash: format!("{:#x}", receipt.block_hash), + block_number: receipt.block_height, + extrinsic_index: receipt.ext_index, + }) + } + + async fn proof(&self, publication: &PendingPublication) -> Result { + self.bridge.proof(publication).await + } +} + +// Keep these imports checked against the SDK's typed-call API at compile time. +fn _typed_submit_data() {} diff --git a/crates/data-availability/src/lib.rs b/crates/data-availability/src/lib.rs new file mode 100644 index 0000000000..01b2906afe --- /dev/null +++ b/crates/data-availability/src/lib.rs @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! Content-addressed data-availability transport used by Interfold applications. +//! +//! Ethereum verifies an Avail/VectorX receipt before it records a reference. Readers still hash +//! the bytes after retrieval. The receipt proves that the committed bytes were published; the +//! reader-side hash prevents a faulty RPC from substituting different bytes. + +use alloy_primitives::{keccak256, B256}; +use alloy_sol_types::{sol, SolValue}; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Maximum object size accepted by the application transport. +/// +/// This matches Avail's current one MiB `submit_data` bound and is deliberately checked before +/// any network request. +pub const MAX_OBJECT_BYTES: usize = 1024 * 1024; + +/// Stable coordinates recorded by the Ethereum application after receipt verification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DataReference { + pub content_hash: [u8; 32], + pub block_number: u32, + pub leaf_index: u128, +} + +/// Avail transaction coordinates needed while the VectorX proof is being produced. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingPublication { + pub content_hash: [u8; 32], + pub block_hash: String, + pub block_number: u32, + pub extrinsic_index: u32, +} + +/// Result returned when the VectorX bridge API has produced an Ethereum proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProofStatus { + Pending, + Ready { + reference: DataReference, + abi_proof: Vec, + }, +} + +/// Retrieval boundary used by ciphernodes and application servers. +#[async_trait] +pub trait DataAvailabilityReader: Send + Sync { + async fn retrieve(&self, reference: DataReference) -> Result>; +} + +/// Publication boundary used by an application availability service. +#[async_trait] +pub trait DataAvailabilityPublisher: Send + Sync { + async fn publish(&self, bytes: &[u8]) -> Result; + async fn proof(&self, publication: &PendingPublication) -> Result; +} + +/// Re-hash retrieved bytes against their Ethereum-verified reference. +pub fn verify_retrieved_bytes(reference: DataReference, bytes: Vec) -> Result> { + validate_object_bytes(&bytes)?; + let actual = keccak256(&bytes); + if actual.0 != reference.content_hash { + bail!( + "data-availability object hash mismatch: expected 0x{}, got {actual}", + hex::encode(reference.content_hash) + ); + } + Ok(bytes) +} + +/// Validate an object before any durable commitment or network publication. +pub fn validate_object_bytes(bytes: &[u8]) -> Result<()> { + if bytes.is_empty() { + bail!("data-availability object is empty"); + } + if bytes.len() > MAX_OBJECT_BYTES { + bail!( + "data-availability object is {} bytes; maximum is {MAX_OBJECT_BYTES}", + bytes.len() + ); + } + Ok(()) +} + +sol! { + /// Must stay ABI-identical to `IAvailBridge.MerkleProofInput`. + struct AvailMerkleProofInput { + bytes32[] dataRootProof; + bytes32[] leafProof; + bytes32 rangeHash; + uint256 dataRootIndex; + bytes32 blobRoot; + bytes32 bridgeRoot; + bytes32 leaf; + uint256 leafIndex; + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeProofResponse { + data_root_proof: Vec, + leaf_proof: Vec, + range_hash: B256, + data_root_index: u64, + blob_root: B256, + bridge_root: B256, + leaf: B256, + leaf_index: u64, +} + +impl BridgeProofResponse { + fn into_status(self, expected: [u8; 32]) -> Result { + // Avail's proof response exposes `keccak256(raw_data)` as `leaf`. The bridge hashes this + // value once more while verifying it against the submitted-data Merkle root. Keep the + // response boundary and bridge boundary distinct: passing the second hash here would make + // the bridge hash it a third time and reject every real proof. + let expected_leaf = B256::from(expected); + if self.leaf != expected_leaf { + bail!( + "bridge proof leaf mismatch: expected 0x{}, got {}", + hex::encode(expected_leaf), + self.leaf + ); + } + let data_root_index = alloy_primitives::U256::from(self.data_root_index); + let leaf_index = alloy_primitives::U256::from(self.leaf_index); + let leaf_index_u128: u128 = leaf_index + .try_into() + .context("Avail leafIndex does not fit in uint128")?; + let input = AvailMerkleProofInput { + dataRootProof: self.data_root_proof, + leafProof: self.leaf_proof, + rangeHash: self.range_hash, + dataRootIndex: data_root_index, + blobRoot: self.blob_root, + bridgeRoot: self.bridge_root, + leaf: self.leaf, + leafIndex: leaf_index, + }; + Ok(ProofStatus::Ready { + reference: DataReference { + content_hash: expected, + // The bridge adapter derives and verifies the block number. The bridge API does + // not include it, so the publisher fills this from its finalized receipt. + block_number: 0, + leaf_index: leaf_index_u128, + }, + abi_proof: input.abi_encode(), + }) + } +} + +/// HTTP client for the official Avail VectorX bridge API. +#[derive(Clone)] +pub struct VectorXBridgeApi { + client: reqwest::Client, + base_url: reqwest::Url, + destination_chain_id: u64, +} + +impl VectorXBridgeApi { + pub fn new(base_url: impl Into, destination_chain_id: u64) -> Result { + let base_url = base_url.into(); + let base_url = base_url.trim(); + if base_url.is_empty() { + bail!("VectorX bridge API URL is empty"); + } + let base_url = reqwest::Url::parse(&format!("{}/", base_url.trim_end_matches('/'))) + .context("VectorX bridge API URL is invalid")?; + if !matches!(base_url.scheme(), "http" | "https") { + bail!("VectorX bridge API URL must use HTTP or HTTPS"); + } + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build VectorX bridge API client")?; + Ok(Self { + client, + base_url, + destination_chain_id, + }) + } + + fn proof_url(&self, publication: &PendingPublication) -> Result { + let mut url = self + .base_url + .join(&format!("v1/proof/{}", self.destination_chain_id)) + .context("failed to build VectorX bridge proof URL")?; + url.query_pairs_mut() + .append_pair("block_hash", &publication.block_hash) + .append_pair("index", &publication.extrinsic_index.to_string()); + Ok(url) + } + + pub async fn proof(&self, publication: &PendingPublication) -> Result { + let url = self.proof_url(publication)?; + let response = self + .client + .get(url) + .send() + .await + .context("VectorX bridge proof request failed")?; + + if response.status() == reqwest::StatusCode::NOT_FOUND + || response.status() == reqwest::StatusCode::ACCEPTED + { + return Ok(ProofStatus::Pending); + } + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("VectorX bridge proof request returned {status}: {body}"); + } + + let proof: BridgeProofResponse = response + .json() + .await + .context("VectorX bridge proof response is invalid")?; + let mut status = proof.into_status(publication.content_hash)?; + if let ProofStatus::Ready { reference, .. } = &mut status { + reference.block_number = publication.block_number; + } + Ok(status) + } +} + +/// Simple HTTP object reader used by the deterministic local mock. +#[derive(Clone)] +pub struct HttpObjectReader { + client: reqwest::Client, + base_url: String, +} + +impl HttpObjectReader { + pub fn new(base_url: impl Into) -> Result { + let base_url = base_url.into().trim_end_matches('/').to_owned(); + if base_url.is_empty() { + bail!("mock data-availability URL is empty"); + } + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build mock data-availability client")?; + Ok(Self { client, base_url }) + } +} + +#[async_trait] +impl DataAvailabilityReader for HttpObjectReader { + async fn retrieve(&self, reference: DataReference) -> Result> { + let url = format!( + "{}/objects/0x{}", + self.base_url, + hex::encode(reference.content_hash) + ); + let response = self + .client + .get(url) + .send() + .await + .context("mock data-availability request failed")? + .error_for_status() + .context("mock data-availability object is unavailable")?; + let bytes = response + .bytes() + .await + .context("failed to read mock data-availability object")? + .to_vec(); + verify_retrieved_bytes(reference, bytes) + } +} + +#[cfg(any(feature = "avail-read", feature = "avail-submit"))] +mod avail; + +#[cfg(any(feature = "avail-read", feature = "avail-submit"))] +pub use avail::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retrieved_bytes_must_match_the_reference() { + let bytes = b"available object".to_vec(); + let reference = DataReference { + content_hash: keccak256(&bytes).0, + block_number: 7, + leaf_index: 3, + }; + assert_eq!( + verify_retrieved_bytes(reference, bytes.clone()).unwrap(), + bytes + ); + assert!(verify_retrieved_bytes(reference, b"substitute".to_vec()).is_err()); + } + + #[test] + fn official_bridge_response_shape_encodes_for_solidity() { + let mut response: BridgeProofResponse = serde_json::from_str( + r#"{ + "dataRootProof":["0x0395f21560a9ccc1f2aa972601250256fbdb20fd936e1723397ff8d5e4f07b5d"], + "leafProof":["0x00017cadd87ec12039f98d646afaa33ed843056ad12f5e971cc81be15d00c26f"], + "rangeHash":"0x21c402a3ccf8df26cb720c6d2fb409f04c809adef7a9a852e463cca83588f4fb", + "dataRootIndex":48, + "blobRoot":"0x511030804f9768c9d5c4826cdc7eba25ba0fd8e73ea32467e5fad547397620f8", + "bridgeRoot":"0xf6c807bc73a637957a61d620bd5e4ef8c7dd234e5fc96dfb6d6041bbe2947782", + "leaf":"0xe17de7631392427460102691ba8a22adf5fb410548e50d6c636bf1f96840c3c3", + "leafIndex":0, + "blockHash":"0x5bc7bd3a4793132007d6d0d9c55dc2ded2fe721a49bd771c1d290e6a3c6ec237" + }"#, + ) + .unwrap(); + let expected = keccak256(b"available object").0; + response.leaf = B256::from(expected); + let ProofStatus::Ready { + reference, + abi_proof, + } = response.into_status(expected).unwrap() + else { + panic!("a complete bridge proof must be ready"); + }; + + let decoded = AvailMerkleProofInput::abi_decode(&abi_proof).unwrap(); + assert_eq!(decoded.leaf, B256::from(expected)); + assert_eq!(decoded.dataRootIndex, alloy_primitives::U256::from(48)); + assert_eq!(reference.content_hash, expected); + assert_eq!(reference.leaf_index, 0); + } + + #[test] + fn bridge_proof_url_matches_the_official_api_route() { + let api = VectorXBridgeApi::new("https://turing-bridge-api.avail.so/", 11155111) + .expect("official bridge URL must be valid"); + let publication = PendingPublication { + content_hash: [0; 32], + block_hash: "0x5bc7bd3a".to_owned(), + block_number: 42, + extrinsic_index: 5, + }; + + assert_eq!( + api.proof_url(&publication).unwrap().as_str(), + "https://turing-bridge-api.avail.so/v1/proof/11155111?block_hash=0x5bc7bd3a&index=5" + ); + } +} diff --git a/crates/entrypoint/src/nodes/process_manager.rs b/crates/entrypoint/src/nodes/process_manager.rs index 3765970d5a..c38076ca3e 100644 --- a/crates/entrypoint/src/nodes/process_manager.rs +++ b/crates/entrypoint/src/nodes/process_manager.rs @@ -340,6 +340,32 @@ impl From for ProcessManager { mod tests { use super::*; + async fn wait_for_status(manager: &ProcessManager, id: &str, expected: ProcessStatus) { + let observed = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let status = manager.status(id).await; + if status == expected { + return status; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("process {id} did not reach {expected:?}")); + + assert_eq!(observed, expected); + } + + async fn wait_for_file(path: &std::path::Path) { + tokio::time::timeout(Duration::from_secs(5), async { + while !path.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("file was not created: {}", path.display())); + } + #[tokio::test] async fn exited_child_is_not_reported_as_started_and_can_be_started_again() { let commands = CommandMap::from([( @@ -352,35 +378,34 @@ mod tests { let manager = ProcessManager::from(commands); manager.start("short").await.unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - assert_eq!( - manager.status("short").await, - ProcessStatus::Exited { code: Some(7) } - ); + wait_for_status(&manager, "short", ProcessStatus::Exited { code: Some(7) }).await; manager.start("short").await.unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - assert_eq!( - manager.status("short").await, - ProcessStatus::Exited { code: Some(7) } - ); + wait_for_status(&manager, "short", ProcessStatus::Exited { code: Some(7) }).await; } #[tokio::test] async fn stop_sends_sigterm_before_forcing_termination() { let directory = tempfile::tempdir().unwrap(); let marker = directory.path().join("terminated"); - let script = format!( - "trap 'echo terminated > {} ; exit 0' TERM; while true; do sleep 0.05; done", - marker.display() - ); + let ready = directory.path().join("ready"); + let script = r#"trap 'printf "terminated\n" > "$1"; exit 0' TERM; : > "$2"; while :; do sleep 0.05; done"#; let commands = CommandMap::from([( "long".to_string(), - ("sh".to_string(), vec!["-c".to_string(), script]), + ( + "sh".to_string(), + vec![ + "-c".to_string(), + script.to_string(), + "process-manager-test".to_string(), + marker.to_string_lossy().into_owned(), + ready.to_string_lossy().into_owned(), + ], + ), )]); let manager = ProcessManager::from(commands); manager.start("long").await.unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; + wait_for_file(&ready).await; manager.stop("long").await.unwrap(); diff --git a/crates/events/src/interfold_event/ciphertext_output_reference_published.rs b/crates/events/src/interfold_event/ciphertext_output_reference_published.rs new file mode 100644 index 0000000000..355eb1107e --- /dev/null +++ b/crates/events/src/interfold_event/ciphertext_output_reference_published.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +use crate::E3id; +use actix::Message; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// Ethereum-verified reference to an aggregate ciphertext stored on a DA layer. +#[derive(Message, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct CiphertextOutputReferencePublished { + pub e3_id: E3id, + pub content_hash: [u8; 32], + pub ciphertext_commitment: [u8; 32], + pub availability_block: u32, + pub availability_leaf_index: u128, +} + +impl Display for CiphertextOutputReferencePublished { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "e3_id: {}, content_hash: 0x{}, availability_block: {}, availability_leaf_index: {}", + self.e3_id, + hex::encode(self.content_hash), + self.availability_block, + self.availability_leaf_index + ) + } +} diff --git a/crates/events/src/interfold_event/committee_public_key_chunk_published.rs b/crates/events/src/interfold_event/committee_public_key_chunk_published.rs new file mode 100644 index 0000000000..ed7e67c90d --- /dev/null +++ b/crates/events/src/interfold_event/committee_public_key_chunk_published.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +use crate::E3id; +use actix::Message; +use e3_utils::ArcBytes; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; + +/// One Ethereum-backed transport chunk of a committee public-key candidate. +#[derive(Message, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[rtype(result = "()")] +pub struct CommitteePublicKeyChunkPublished { + pub e3_id: E3id, + pub publisher: String, + pub candidate_hash: [u8; 32], + pub nodes: Vec, + pub pk_commitment: [u8; 32], + pub chunk_index: u16, + pub chunk_count: u16, + pub total_length: u32, + pub chunk: ArcBytes, +} + +impl Display for CommitteePublicKeyChunkPublished { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "e3_id: {}, publisher: {}, chunk: {}/{}, bytes: {}", + self.e3_id, + self.publisher, + self.chunk_index + 1, + self.chunk_count, + self.chunk.len() + ) + } +} diff --git a/crates/events/src/interfold_event/e3_failed.rs b/crates/events/src/interfold_event/e3_failed.rs index 023e2d7edd..b6ddc57b68 100644 --- a/crates/events/src/interfold_event/e3_failed.rs +++ b/crates/events/src/interfold_event/e3_failed.rs @@ -43,9 +43,16 @@ impl FailureReason { /// Returns true when the E3 can stop without an accusation or slash flow. pub fn ends_without_slashing(&self) -> bool { - self.is_timeout() || matches!(self, Self::RequesterCancelled) + self.is_timeout() + || matches!( + self, + Self::NoInputsReceived + | Self::ComputeProviderExpired + | Self::ComputeProviderFailed + | Self::RequesterCancelled + ) } -} +} /// E3 lifecycle stage #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/events/src/interfold_event/mod.rs b/crates/events/src/interfold_event/mod.rs index 40ca678efa..38e6594a5b 100644 --- a/crates/events/src/interfold_event/mod.rs +++ b/crates/events/src/interfold_event/mod.rs @@ -17,12 +17,14 @@ mod ciphernode_deregistration_requested; mod ciphernode_removed; mod ciphernode_selected; mod ciphertext_output_published; +mod ciphertext_output_reference_published; mod commitment_consistency; mod committee_activation_changed; mod committee_finalize_requested; mod committee_finalized; mod committee_formation_failed; mod committee_member_excluded; +mod committee_public_key_chunk_published; mod committee_published; mod committee_requested; mod committee_viability_updated; @@ -98,12 +100,14 @@ pub use ciphernode_deregistration_requested::*; pub use ciphernode_removed::*; pub use ciphernode_selected::*; pub use ciphertext_output_published::*; +pub use ciphertext_output_reference_published::*; pub use commitment_consistency::*; pub use committee_activation_changed::*; pub use committee_finalize_requested::*; pub use committee_finalized::*; pub use committee_formation_failed::*; pub use committee_member_excluded::*; +pub use committee_public_key_chunk_published::*; pub use committee_published::*; pub use committee_requested::*; pub use committee_viability_updated::*; @@ -354,6 +358,8 @@ pub enum InterfoldEventData { // Append new durable variants to preserve existing enum discriminants in persisted logs. CommitteeMemberExcluded(CommitteeMemberExcluded), AggregationInputsReady(AggregationInputsReady), + CommitteePublicKeyChunkPublished(CommitteePublicKeyChunkPublished), + CiphertextOutputReferencePublished(CiphertextOutputReferencePublished), } impl InterfoldEventData { @@ -679,6 +685,12 @@ impl InterfoldEventData { InterfoldEventData::DkgFoldAttestationContextEstablished(ref data) => { Some(data.e3_id.clone()) } + InterfoldEventData::CommitteePublicKeyChunkPublished(ref data) => { + Some(data.e3_id.clone()) + } + InterfoldEventData::CiphertextOutputReferencePublished(ref data) => { + Some(data.e3_id.clone()) + } _ => None, } } @@ -791,7 +803,9 @@ impl_event_types!( BondOwnerSet, DkgFoldAttestationContextEstablished, CommitteeMemberExcluded, - AggregationInputsReady + AggregationInputsReady, + CommitteePublicKeyChunkPublished, + CiphertextOutputReferencePublished ); impl TryFrom<&InterfoldEvent> for InterfoldError { diff --git a/crates/events/src/store_keys.rs b/crates/events/src/store_keys.rs index 359fbdfbc6..e1a66379aa 100644 --- a/crates/events/src/store_keys.rs +++ b/crates/events/src/store_keys.rs @@ -81,6 +81,10 @@ impl StoreKeys { format!("//evm_writers/slashing/{chain_id}/recovery/v1") } + pub fn data_availability_recovery(chain_id: u64) -> String { + format!("//data_availability/{chain_id}/recovery/v1") + } + pub fn eth_private_key() -> String { String::from("//eth_private_key") } diff --git a/crates/evm-helpers/src/contracts.rs b/crates/evm-helpers/src/contracts.rs index c70c08c6cf..81d444fc47 100644 --- a/crates/evm-helpers/src/contracts.rs +++ b/crates/evm-helpers/src/contracts.rs @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use alloy::providers::fillers::BlobGasFiller; +use alloy::sol_types::SolValue; use alloy::{ network::{Ethereum, EthereumWallet}, primitives::{Address, Bytes, B256, U256}, @@ -130,6 +131,14 @@ sol! { uint256 decryptionDeadline; } + struct CiphertextOutputReference { + bytes32 contentHash; + bytes32 ciphertextCommitment; + bytes computeProof; + bytes availabilityProof; + } + + #[derive(Debug)] #[sol(rpc)] contract Interfold { @@ -137,7 +146,10 @@ sol! { mapping(address e3Program => bool allowed) public e3Programs; function request(E3RequestParams calldata requestParams) external returns (uint256 e3Id, E3 memory e3); function registerE3Program(address e3Program) public; - function publishCiphertextOutput(uint256 e3Id, bytes calldata ciphertextOutput, bytes32 ciphertextCommitment, bytes calldata proof) external returns (bool success); + function publishCiphertextOutput( + uint256 e3Id, + bytes calldata encodedOutputReference + ) external; function publishPlaintextOutput(uint256 e3Id, bytes calldata data, bytes calldata proof) external returns (bool success); function getE3(uint256 e3Id) external view returns (E3 memory e3); function paramSetRegistry(uint8 paramSet) external view returns (bytes memory encodedParams); @@ -213,13 +225,14 @@ pub trait InterfoldWrite { /// Enable an E3 program async fn register_e3_program(&self, e3_program: Address) -> Result; - /// Publish ciphertext output with proof + /// Publish an externally available ciphertext output and its verified receipt. async fn publish_ciphertext_output( &self, e3_id: U256, - data: Bytes, + ciphertext_output_hash: B256, ciphertext_commitment: B256, - proof: Bytes, + compute_proof: Bytes, + availability_proof: Bytes, ) -> Result; /// Publish plaintext output @@ -532,9 +545,10 @@ impl InterfoldWrite for InterfoldContract { async fn publish_ciphertext_output( &self, e3_id: U256, - data: Bytes, + ciphertext_output_hash: B256, ciphertext_commitment: B256, - proof: Bytes, + compute_proof: Bytes, + availability_proof: Bytes, ) -> Result { let _guard = NONCE_LOCK.lock().await; let wallet_addr = self @@ -543,12 +557,20 @@ impl InterfoldWrite for InterfoldContract { let nonce = get_next_nonce(&*self.provider, wallet_addr).await?; let contract = Interfold::new(self.contract_address, &self.provider); - let builder = contract - .publishCiphertextOutput(e3_id, data, ciphertext_commitment, proof) - .nonce(nonce); - let receipt = builder.send().await?.get_receipt().await?; + let output_reference = CiphertextOutputReference { + contentHash: ciphertext_output_hash, + ciphertextCommitment: ciphertext_commitment, + computeProof: compute_proof, + availabilityProof: availability_proof, + }; + let receipt = contract + .publishCiphertextOutput(e3_id, output_reference.abi_encode().into()) + .nonce(nonce) + .send() + .await? + .get_receipt() + .await?; e3_utils::require_successful_receipt("publish ciphertext output", &receipt)?; - Ok(receipt) } diff --git a/crates/evm-helpers/src/events.rs b/crates/evm-helpers/src/events.rs index 98ac984328..53cbd015f9 100644 --- a/crates/evm-helpers/src/events.rs +++ b/crates/evm-helpers/src/events.rs @@ -56,12 +56,34 @@ sol! { #[derive(Debug)] event CiphertextOutputPublished(uint256 indexed e3Id, bytes ciphertextOutput, bytes32 ciphertextCommitment); + #[derive(Debug)] + event CiphertextOutputReferencePublished( + uint256 indexed e3Id, + bytes32 contentHash, + bytes32 ciphertextCommitment, + uint32 availabilityBlock, + uint128 availabilityLeafIndex + ); + #[derive(Debug)] event PlaintextOutputPublished(uint256 indexed e3Id, bytes plaintextOutput, bytes proof); #[derive(Debug)] event CommitteePublished(uint256 indexed e3Id, address[] nodes, bytes publicKey, bytes32 pkCommitment, bytes proof); + #[derive(Debug)] + event CommitteePublicKeyChunkPublished( + uint256 indexed e3Id, + address indexed publisher, + bytes32 indexed candidateHash, + address[] nodes, + bytes32 pkCommitment, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes chunk + ); + #[derive(Debug)] enum E3Stage { None, diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index 4769a65166..5ed3671794 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -15,9 +15,11 @@ anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } bloom = { workspace = true } +e3-bfv-client = { workspace = true } e3-config = { workspace = true } e3-crypto = { workspace = true } e3-data = { workspace = true } +e3-data-availability = { workspace = true, features = ["avail-read"] } e3-events = { workspace = true } e3-fhe-params = { workspace = true } e3-sortition = { workspace = true } @@ -35,6 +37,7 @@ zeroize = { workspace = true } [dev-dependencies] alloy-dyn-abi = { workspace = true } +bincode = { workspace = true } e3-ciphernode-builder = { workspace = true } e3-entrypoint = { workspace = true } e3-events = { workspace = true, features = ["test-helpers"] } diff --git a/crates/evm/src/actors.rs b/crates/evm/src/actors.rs index 008036e901..32345c36f3 100644 --- a/crates/evm/src/actors.rs +++ b/crates/evm/src/actors.rs @@ -10,6 +10,8 @@ mod bonding_registry_sol; #[path = "ciphernode_registry/actor.rs"] mod ciphernode_registry_sol; +#[path = "data_availability/actor.rs"] +mod data_availability; #[path = "chain_gateway/actor.rs"] mod evm_chain_gateway; #[path = "chain_hub.rs"] @@ -41,6 +43,10 @@ pub use ciphernode_registry_sol::{ fetch_randomness_providers, CiphernodeRegistrySol, CiphernodeRegistrySolReader, CiphernodeRegistrySolWriter, }; +pub use data_availability::{ + DataAvailabilityCoordinator, DataAvailabilityRecoveryState, + DATA_AVAILABILITY_RECOVERY_SCHEMA_VERSION, +}; pub use evm_chain_gateway::*; pub use evm_hub::*; pub use evm_parser::*; diff --git a/crates/evm/src/ciphernode_registry/effects.rs b/crates/evm/src/ciphernode_registry/effects.rs index 3f67be7f53..a9bf412e07 100644 --- a/crates/evm/src/ciphernode_registry/effects.rs +++ b/crates/evm/src/ciphernode_registry/effects.rs @@ -3,14 +3,31 @@ //! Idempotency preflights and CiphernodeRegistry contract effects. use super::*; +use crate::contracts::IInterfold; + +alloy::sol! { + #[sol(rpc)] + interface IChunkedPublicKeyPublisher { + function publishCommitteePublicKey( + uint256 e3Id, + bytes32 candidateHash, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes calldata chunk + ) external; + } +} const TICKET_GAS_SAFETY_MULTIPLIER: u64 = 2; +const MAX_PUBLIC_KEY_BYTES: usize = 512 * 1024; +const PUBLIC_KEY_CHUNK_BYTES: usize = 90 * 1024; fn ticket_gas_limit(estimate: u64) -> u64 { estimate.saturating_mul(TICKET_GAS_SAFETY_MULTIPLIER) } -/// Report whether a contract call contains this exact parameterless custom error. +/// Report whether a contract call contains this custom-error selector. fn reverts_with(error: &anyhow::Error, selector: [u8; 4]) -> bool { contains_error_selector(&format!("{error:?}"), selector) } @@ -83,6 +100,49 @@ pub(in crate::actors::ciphernode_registry_sol) fn ticket_submission_error_is_ter .any(|selector| reverts_with(error, selector)) } +/// Return true when another committee-publication attempt cannot succeed unchanged. +pub(in crate::actors::ciphernode_registry_sol) fn committee_publication_error_is_terminal( + error: &anyhow::Error, +) -> bool { + let encoded = format!("{error:?}"); + let message = encoded.to_ascii_lowercase(); + let permanent_rpc_rejection = message.contains("oversized data") + || (message.contains("transaction size") && message.contains("limit")) + || message.contains("request entity too large") + || message.contains("content length too large") + || message.contains("function selector was not recognized"); + let permanent_local_rejection = message + .contains("mandatory dkg aggregator proof payload missing") + || message.contains("mandatory dkg attestation bundle missing") + || (message.contains("on-chain committee commitment") + && message.contains("does not match local commitment")); + let permanent_contract_rejection = [ + ICiphernodeRegistry::InvalidPublicKeyLength::SELECTOR, + ICiphernodeRegistry::PkCommitmentRequired::SELECTOR, + ICiphernodeRegistry::DkgProofRequired::SELECTOR, + ICiphernodeRegistry::InvalidDkgProof::SELECTOR, + ICiphernodeRegistry::FoldAttestationsRequired::SELECTOR, + ICiphernodeRegistry::FoldAttestationVerifierNotSet::SELECTOR, + ICiphernodeRegistry::InvalidFoldAttestation::SELECTOR, + ICiphernodeRegistry::PartyIdNotInProof::SELECTOR, + ICiphernodeRegistry::AttestationBindingCountMismatch::SELECTOR, + ICiphernodeRegistry::PartyIdOutOfBounds::SELECTOR, + ICiphernodeRegistry::InvalidProof::SELECTOR, + ICiphernodeRegistry::InvalidPublicInputsLength::SELECTOR, + ICiphernodeRegistry::VkHashMismatch::SELECTOR, + ICiphernodeRegistry::PkCommitmentMismatch::SELECTOR, + ICiphernodeRegistry::DomainBindingMismatch::SELECTOR, + IInterfold::DKGDeadlinePassed::SELECTOR, + IInterfold::InvalidStage::SELECTOR, + ICiphernodeRegistry::InvalidPublicKeyChunk::SELECTOR, + ICiphernodeRegistry::PublicKeyPublisherNotCommitteeMember::SELECTOR, + ] + .into_iter() + .any(|selector| contains_error_selector(&encoded, selector)); + + permanent_rpc_rejection || permanent_local_rejection || permanent_contract_rejection +} + /// Report whether this node's ticket is already recorded on chain. /// /// `submitTicket` reverts with `NodeAlreadySubmitted` for a sender that is @@ -293,35 +353,78 @@ pub async fn publish_committee_public_key_to_registry< ) -> Result { let e3_id_u256: U256 = e3_id.try_into()?; let public_key_bytes = Bytes::from(public_key.extract_bytes()); + anyhow::ensure!( + !public_key_bytes.is_empty(), + "committee public key is empty" + ); + anyhow::ensure!( + public_key_bytes.len() <= MAX_PUBLIC_KEY_BYTES, + "committee public key is {} bytes; maximum is {MAX_PUBLIC_KEY_BYTES}", + public_key_bytes.len() + ); - send_tx_with_retry( - "publishCommitteePublicKey", - &["CommitteeNotPublished"], - || { - let provider = provider.clone(); - let public_key_bytes = public_key_bytes.clone(); - async move { - info!("Calling: contract.publishCommitteePublicKey(..)"); - let _nonce_guard = transaction_nonce_guard(&provider).await; - let from_address = provider.provider().default_signer_address(); - let current_nonce = provider - .provider() - .get_transaction_count(from_address) - .pending() - .await?; - let contract = ICiphernodeRegistry::new(contract_address, provider.provider()); - let builder = contract - .publishCommitteePublicKey(e3_id_u256, public_key_bytes) - .nonce(current_nonce); - let pending = builder.send().await?; - drop(_nonce_guard); - let receipt = pending.get_receipt().await?; - require_successful_receipt("publish committee public key", &receipt)?; - Ok(receipt) - } - }, - ) - .await + let total_length: u32 = public_key_bytes + .len() + .try_into() + .map_err(|_| anyhow::anyhow!("committee public key exceeds uint32 length"))?; + let chunk_count: u16 = public_key_bytes + .len() + .div_ceil(PUBLIC_KEY_CHUNK_BYTES) + .try_into() + .map_err(|_| anyhow::anyhow!("committee public key has too many chunks"))?; + let candidate_hash = alloy::primitives::keccak256(&public_key_bytes); + let mut last_receipt = None; + + for (chunk_index, chunk) in public_key_bytes.chunks(PUBLIC_KEY_CHUNK_BYTES).enumerate() { + let chunk_index: u16 = chunk_index + .try_into() + .map_err(|_| anyhow::anyhow!("public-key chunk index exceeds uint16"))?; + let chunk = Bytes::copy_from_slice(chunk); + let receipt = send_tx_with_retry( + "publishCommitteePublicKey chunk", + &["CommitteeNotPublished"], + || { + let provider = provider.clone(); + let chunk = chunk.clone(); + async move { + info!( + chunk = chunk_index + 1, + total = chunk_count, + "Calling: contract.publishCommitteePublicKey(chunk)" + ); + let _nonce_guard = transaction_nonce_guard(&provider).await; + let from_address = provider.provider().default_signer_address(); + let current_nonce = provider + .provider() + .get_transaction_count(from_address) + .pending() + .await?; + let contract = + IChunkedPublicKeyPublisher::new(contract_address, provider.provider()); + let pending = contract + .publishCommitteePublicKey( + e3_id_u256, + candidate_hash, + chunk_index, + chunk_count, + total_length, + chunk, + ) + .nonce(current_nonce) + .send() + .await?; + drop(_nonce_guard); + let receipt = pending.get_receipt().await?; + require_successful_receipt("publish committee public-key chunk", &receipt)?; + Ok(receipt) + } + }, + ) + .await?; + last_receipt = Some(receipt); + } + + last_receipt.ok_or_else(|| anyhow::anyhow!("committee public key is empty")) } /// Read `CiphernodeRegistry.dkgFoldAttestationVerifier()` (EIP-712 verifying contract for fold attestations). @@ -412,8 +515,11 @@ pub async fn fetch_randomness_providers( #[cfg(test)] mod tests { - use super::{reverts_with, ticket_gas_limit, ticket_submission_error_is_terminal}; - use crate::contracts::ICiphernodeRegistry; + use super::{ + committee_publication_error_is_terminal, reverts_with, ticket_gas_limit, + ticket_submission_error_is_terminal, + }; + use crate::contracts::{ICiphernodeRegistry, IInterfold}; use alloy::sol_types::{Revert, SolError}; fn selector_error(selector: [u8; 4]) -> anyhow::Error { @@ -462,4 +568,40 @@ mod tests { assert_eq!(ticket_gas_limit(250_000), 500_000); assert_eq!(ticket_gas_limit(u64::MAX), u64::MAX); } + + #[test] + fn oversized_rpc_rejection_is_terminal() { + let error = anyhow::anyhow!( + "server returned error code -32000: oversized data: transaction size 356602, limit 131072" + ); + assert!(committee_publication_error_is_terminal(&error)); + assert!(!committee_publication_error_is_terminal(&anyhow::anyhow!( + "RPC connection reset" + ))); + assert!(!committee_publication_error_is_terminal(&selector_error( + ICiphernodeRegistry::CommitteeNotPublished::SELECTOR + ))); + } + + #[test] + fn invalid_public_key_length_is_terminal() { + assert!(committee_publication_error_is_terminal(&selector_error( + ICiphernodeRegistry::InvalidPublicKeyLength::SELECTOR + ))); + assert!(committee_publication_error_is_terminal(&selector_error( + ICiphernodeRegistry::InvalidProof::SELECTOR + ))); + assert!(committee_publication_error_is_terminal(&selector_error( + IInterfold::DKGDeadlinePassed::SELECTOR + ))); + assert!(committee_publication_error_is_terminal(&selector_error( + IInterfold::InvalidStage::SELECTOR + ))); + assert!(committee_publication_error_is_terminal(&selector_error( + ICiphernodeRegistry::InvalidPublicKeyChunk::SELECTOR + ))); + assert!(committee_publication_error_is_terminal(&selector_error( + ICiphernodeRegistry::PublicKeyPublisherNotCommitteeMember::SELECTOR + ))); + } } diff --git a/crates/evm/src/ciphernode_registry/events.rs b/crates/evm/src/ciphernode_registry/events.rs index 1ab8a1d4f3..851f90697e 100644 --- a/crates/evm/src/ciphernode_registry/events.rs +++ b/crates/evm/src/ciphernode_registry/events.rs @@ -12,9 +12,10 @@ use alloy::{ sol_types::{SolEvent, SolValue}, }; use e3_events::{ - CommitteeActivationChanged, CommitteeFinalized, CommitteeFormationFailed, CommitteePublished, - CommitteeViabilityUpdated, DkgFoldAttestationContext, DkgFoldAttestationContextEstablished, - E3id, InterfoldEventData, Seed, DKG_FOLD_ATTESTATION_CONTEXT_SCHEMA_VERSION, + CommitteeActivationChanged, CommitteeFinalized, CommitteeFormationFailed, + CommitteePublicKeyChunkPublished, CommitteePublished, CommitteeViabilityUpdated, + DkgFoldAttestationContext, DkgFoldAttestationContextEstablished, E3id, InterfoldEventData, + Seed, DKG_FOLD_ATTESTATION_CONTEXT_SCHEMA_VERSION, }; use e3_utils::ArcBytes; use tracing::{error, info, trace}; @@ -137,6 +138,33 @@ struct CommitteeFinalizedWithChainId( pub u64, ); +struct CommitteePublicKeyChunkWithChainId( + pub ICiphernodeRegistry::CommitteePublicKeyChunkPublished, + pub u64, +); + +impl From for CommitteePublicKeyChunkPublished { + fn from(value: CommitteePublicKeyChunkWithChainId) -> Self { + Self { + e3_id: E3id::new(value.0.e3Id.to_string(), value.1), + publisher: value.0.publisher.to_string(), + candidate_hash: value.0.candidateHash.into(), + nodes: value.0.nodes.iter().map(ToString::to_string).collect(), + pk_commitment: value.0.pkCommitment.into(), + chunk_index: value.0.chunkIndex, + chunk_count: value.0.chunkCount, + total_length: value.0.totalLength, + chunk: ArcBytes::from_bytes(value.0.chunk.as_ref()), + } + } +} + +impl From for InterfoldEventData { + fn from(value: CommitteePublicKeyChunkWithChainId) -> Self { + CommitteePublicKeyChunkPublished::from(value).into() + } +} + impl From for CommitteeFinalized { fn from(value: CommitteeFinalizedWithChainId) -> Self { let mut result = e3_events::CommitteeFinalized { @@ -421,6 +449,24 @@ pub(crate) fn extractor( event, chain_id, ))) } + Some(&ICiphernodeRegistry::CommitteePublicKeyChunkPublished::SIGNATURE_HASH) => { + let Ok(mut event) = + ICiphernodeRegistry::CommitteePublicKeyChunkPublished::decode_log_data(data) + else { + error!("Error parsing CommitteePublicKeyChunkPublished after topic was matched!"); + return None; + }; + let (Some(e3_id), Some(publisher), Some(candidate_hash)) = + (topics.get(1), topics.get(2), topics.get(3)) + else { + error!("CommitteePublicKeyChunkPublished is missing indexed topics"); + return None; + }; + event.e3Id = alloy::primitives::U256::from_be_bytes(e3_id.0); + event.publisher = alloy::primitives::Address::from_slice(&publisher.0[12..]); + event.candidateHash = *candidate_hash; + Some(CommitteePublicKeyChunkWithChainId(event, chain_id).into()) + } Some(&ICiphernodeRegistry::CommitteeActivationChanged::SIGNATURE_HASH) => { let Ok(mut event) = ICiphernodeRegistry::CommitteeActivationChanged::decode_log_data(data) diff --git a/crates/evm/src/ciphernode_registry/handlers.rs b/crates/evm/src/ciphernode_registry/handlers.rs index d177cbb038..54e2d943e3 100644 --- a/crates/evm/src/ciphernode_registry/handlers.rs +++ b/crates/evm/src/ciphernode_registry/handlers.rs @@ -562,11 +562,16 @@ impl Handler false } Err(err) => { + let terminal = committee_publication_error_is_terminal(&err); error!( "Failed to preflight publishCommittee: {}", format_evm_error(&err) ); - return (e3_id, false); + if terminal { + error!(e3_id = %e3_id, "Committee publication failed permanently; stopping retries"); + } + bus.err(EType::Evm, err); + return (e3_id, terminal); } Ok(true) => true, }; @@ -607,12 +612,16 @@ impl Handler match result { Ok(()) => (e3_id, true), Err(err) => { + let terminal = committee_publication_error_is_terminal(&err); error!( "Failed to publish committee data: {}", format_evm_error(&err) ); + if terminal { + error!(e3_id = %e3_id, "Committee publication failed permanently; stopping retries"); + } bus.err(EType::Evm, err); - (e3_id, false) + (e3_id, terminal) } } } diff --git a/crates/evm/src/contracts.rs b/crates/evm/src/contracts.rs index 5b87730ca4..6197c0ec44 100644 --- a/crates/evm/src/contracts.rs +++ b/crates/evm/src/contracts.rs @@ -45,6 +45,12 @@ sol! { uint256 decryptionDeadline; } + struct E3TimeoutConfig { + uint256 dkgWindow; + uint256 computeWindow; + uint256 decryptionWindow; + } + // โ”€โ”€ Write functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function publishPlaintextOutput( uint256 e3Id, @@ -61,12 +67,20 @@ sol! { function getE3Stage(uint256 e3Id) external view returns (uint8 stage); + function getFailureReason(uint256 e3Id) external view returns (uint8 reason); + function getDeadlines(uint256 e3Id) external view returns (E3Deadlines memory deadlines); + function getE3TimeoutConfig( + uint256 e3Id + ) external view returns (E3TimeoutConfig memory config); + function checkFailureCondition( uint256 e3Id ) external view returns (bool canFail, uint8 reason); + function markFailedGracePeriod() external view returns (uint256); + function nodeReleaseRegistry() external view returns (address); function bondingRegistry() external view returns (address); function ciphernodeRegistry() external view returns (address); @@ -75,6 +89,13 @@ sol! { event E3Requested(uint256 e3Id, E3 e3, bytes32 indexed cryptoConfigId); event InputPublished(uint256 indexed e3Id, bytes data, uint256 inputHash, uint256 index); event CiphertextOutputPublished(uint256 indexed e3Id, bytes ciphertextOutput, bytes32 ciphertextCommitment); + event CiphertextOutputReferencePublished( + uint256 indexed e3Id, + bytes32 contentHash, + bytes32 ciphertextCommitment, + uint32 availabilityBlock, + uint128 availabilityLeafIndex + ); event PlaintextOutputPublished(uint256 indexed e3Id, bytes plaintextOutput, bytes proof); event RewardsDistributed(uint256 indexed e3Id, address[] nodes, uint256[] amounts); event RewardCredited(uint256 indexed e3Id, address indexed account, address indexed token, uint256 amount); @@ -95,6 +116,7 @@ sol! { error E3AlreadyFailed(uint256 e3Id); error E3AlreadyComplete(uint256 e3Id); error MarkE3FailedInGracePeriod(uint256 e3Id, uint256 gracePeriodEnds); + error DKGDeadlinePassed(uint256 e3Id, uint256 deadline); } } @@ -203,11 +225,13 @@ sol! { bytes calldata dkgAttestationBundle ) external; - function publishCommitteePublicKey(uint256 e3Id, bytes calldata publicKey) external; - // โ”€โ”€ View functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function isOpen(uint256 e3Id) external view returns (bool); + function committeeThresholdMet(uint256 e3Id) external view returns (bool); + + function getCommitteeDeadline(uint256 e3Id) external view returns (uint256); + function committeePublicKey(uint256 e3Id) external view returns (bytes32 publicKeyHash); function getDkgAnchors( @@ -328,6 +352,18 @@ sol! { bytes proof ); + event CommitteePublicKeyChunkPublished( + uint256 indexed e3Id, + address indexed publisher, + bytes32 indexed candidateHash, + address[] nodes, + bytes32 pkCommitment, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes chunk + ); + event CommitteeActivationChanged(uint256 indexed e3Id, bool active); event CommitteeViabilityUpdated( @@ -368,6 +404,19 @@ sol! { error DkgProofRequired(); error InvalidDkgProof(); error FoldAttestationsRequired(); + error FoldAttestationVerifierNotSet(); + error InvalidFoldAttestation(); + error PartyIdNotInProof(); + error AttestationBindingCountMismatch(); + error PartyIdOutOfBounds(uint256 partyId, uint256 committeeSize); + error InvalidProof(); + error InvalidPublicInputsLength(); + error VkHashMismatch(); + error PkCommitmentMismatch(); + error DomainBindingMismatch(); + error InvalidPublicKeyLength(uint256 supplied, uint256 maximum); + error InvalidPublicKeyChunk(); + error PublicKeyPublisherNotCommitteeMember(); } } diff --git a/crates/evm/src/data_availability/actor.rs b/crates/evm/src/data_availability/actor.rs new file mode 100644 index 0000000000..0e61f9de43 --- /dev/null +++ b/crates/evm/src/data_availability/actor.rs @@ -0,0 +1,633 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +//! Recovery-safe assembly and retrieval for large protocol objects. + +use actix::{ + Actor, ActorContext, ActorFutureExt, AsyncContext, Context, Handler, Message, WrapFuture, +}; +use alloy::primitives::keccak256; +use e3_bfv_client::validate_pk_commitment; +use e3_config::chain_config::{DataAvailabilityConfig, DataAvailabilityMode}; +use e3_data::Repository; +use e3_data_availability::{AvailReader, DataAvailabilityReader, DataReference, HttpObjectReader}; +use e3_events::{ + prelude::*, BusHandle, CiphertextOutputPublished, CiphertextOutputReferencePublished, + CommitteePublicKeyChunkPublished, CommitteePublished, E3id, EType, EventContext, + EventPublisher, EventType, InterfoldEvent, InterfoldEventData, Sequenced, +}; +use e3_fhe_params::{BfvParamSet, BfvPreset}; +use e3_utils::{ArcBytes, MAILBOX_LIMIT}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; +use tracing::{info, warn}; + +const OUTPUT_RETRY_DELAY: Duration = Duration::from_secs(30); +const MAX_PUBLIC_KEY_BYTES: usize = 512 * 1024; +const PUBLIC_KEY_CHUNK_BYTES: usize = 90 * 1024; +pub const DATA_AVAILABILITY_RECOVERY_SCHEMA_VERSION: u32 = 2; + +type CandidateKey = (E3id, String, [u8; 32]); + +fn validate_committee_public_key( + public_key: &[u8], + expected_commitment: [u8; 32], + preset: BfvPreset, +) -> anyhow::Result<()> { + // The final committee key uses threshold parameters. DKG parameters apply only to temporary + // share-transport keys. + let params = BfvParamSet::from(preset); + validate_pk_commitment( + public_key, + expected_commitment, + params.degree, + params.plaintext_modulus, + params.moduli.to_vec(), + ) +} + +fn is_late_fact_for_terminal_e3(event: &InterfoldEventData, terminal_e3s: &HashSet) -> bool { + event + .get_e3_id() + .is_some_and(|e3_id| terminal_e3s.contains(&e3_id)) + && !matches!( + event, + InterfoldEventData::E3RequestComplete(_) | InterfoldEventData::E3Failed(_) + ) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct OutputReference { + event: CiphertextOutputReferencePublished, + cause: EventContext, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct KeyAssembly { + nodes: Vec, + pk_commitment: [u8; 32], + total_length: u32, + chunks: Vec>, +} + +/// Durable transport projection used when the EVM replay begins after a snapshot boundary. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DataAvailabilityRecoveryState { + pub schema_version: u32, + presets: HashMap, + assemblies: HashMap, + selected_candidates: HashMap<(E3id, String), [u8; 32]>, + invalid_candidates: HashSet, + published_keys: HashSet, + pending_outputs: HashMap, + resolved_outputs: HashSet, + terminal_e3s: HashSet, +} + +impl Default for DataAvailabilityRecoveryState { + fn default() -> Self { + Self { + schema_version: DATA_AVAILABILITY_RECOVERY_SCHEMA_VERSION, + presets: HashMap::new(), + assemblies: HashMap::new(), + selected_candidates: HashMap::new(), + invalid_candidates: HashSet::new(), + published_keys: HashSet::new(), + pending_outputs: HashMap::new(), + resolved_outputs: HashSet::new(), + terminal_e3s: HashSet::new(), + } + } +} + +impl KeyAssembly { + fn event_shape_is_valid(event: &CommitteePublicKeyChunkPublished) -> bool { + let total_length = event.total_length as usize; + if total_length == 0 || total_length > MAX_PUBLIC_KEY_BYTES { + return false; + } + let expected_count = total_length.div_ceil(PUBLIC_KEY_CHUNK_BYTES); + if expected_count != usize::from(event.chunk_count) + || usize::from(event.chunk_index) >= expected_count + { + return false; + } + let offset = usize::from(event.chunk_index) * PUBLIC_KEY_CHUNK_BYTES; + let expected_length = (total_length - offset).min(PUBLIC_KEY_CHUNK_BYTES); + event.chunk.len() == expected_length + } + + fn new(event: &CommitteePublicKeyChunkPublished) -> Self { + Self { + nodes: event.nodes.clone(), + pk_commitment: event.pk_commitment, + total_length: event.total_length, + chunks: vec![None; usize::from(event.chunk_count)], + } + } + + fn metadata_matches(&self, event: &CommitteePublicKeyChunkPublished) -> bool { + self.nodes == event.nodes + && self.pk_commitment == event.pk_commitment + && self.total_length == event.total_length + && self.chunks.len() == usize::from(event.chunk_count) + } + + fn insert(&mut self, event: &CommitteePublicKeyChunkPublished) -> bool { + if !self.metadata_matches(event) { + return false; + } + let Some(slot) = self.chunks.get_mut(usize::from(event.chunk_index)) else { + return false; + }; + if let Some(existing) = slot { + return existing[..] == event.chunk[..]; + } + *slot = Some(event.chunk.clone()); + true + } + + fn bytes(&self) -> Option> { + let mut bytes = Vec::with_capacity(self.total_length as usize); + for chunk in &self.chunks { + bytes.extend_from_slice(chunk.as_ref()?.as_ref()); + } + (bytes.len() == self.total_length as usize).then_some(bytes) + } +} + +#[derive(Message)] +#[rtype(result = "()")] +struct RetrieveOutput(E3id); + +/// Converts durable transport facts into the existing in-memory protocol events. +/// +/// Historical replay performs no network I/O and emits no derived events. Once +/// `EffectsEnabled` arrives, complete key assemblies and unresolved DA references resume. +pub struct DataAvailabilityCoordinator { + chain_id: u64, + bus: BusHandle, + reader: Option>, + effects_enabled: bool, + presets: HashMap, + assemblies: HashMap, + selected_candidates: HashMap<(E3id, String), [u8; 32]>, + invalid_candidates: HashSet, + published_keys: HashSet, + publishing_keys: HashSet, + pending_outputs: HashMap, + resolved_outputs: HashSet, + retrieving_outputs: HashSet, + terminal_e3s: HashSet, + recovery: Repository, +} + +impl DataAvailabilityCoordinator { + pub async fn attach( + bus: &BusHandle, + chain_id: u64, + config: Option<&DataAvailabilityConfig>, + recovery: Repository, + ) -> anyhow::Result<()> { + let reader: Option> = match config { + Some(config) => Some(match config.mode { + DataAvailabilityMode::Avail => Arc::new(AvailReader::new(&config.rpc_url)?), + DataAvailabilityMode::MockHttp => Arc::new(HttpObjectReader::new(&config.rpc_url)?), + }), + None => None, + }; + let recovered = recovery.read().await?.unwrap_or_default(); + anyhow::ensure!( + recovered.schema_version == DATA_AVAILABILITY_RECOVERY_SCHEMA_VERSION, + "unsupported data-availability recovery schema {} for chain {}", + recovered.schema_version, + chain_id + ); + let addr = Self { + chain_id, + bus: bus.clone(), + reader, + effects_enabled: false, + presets: recovered.presets, + assemblies: recovered.assemblies, + selected_candidates: recovered.selected_candidates, + invalid_candidates: recovered.invalid_candidates, + published_keys: recovered.published_keys, + publishing_keys: HashSet::new(), + pending_outputs: recovered.pending_outputs, + resolved_outputs: recovered.resolved_outputs, + retrieving_outputs: HashSet::new(), + terminal_e3s: recovered.terminal_e3s, + recovery, + } + .start(); + bus.subscribe_all( + &[ + EventType::E3Requested, + EventType::CommitteePublicKeyChunkPublished, + EventType::CommitteePublished, + EventType::CiphertextOutputReferencePublished, + EventType::CiphertextOutputPublished, + EventType::EffectsEnabled, + EventType::E3RequestComplete, + EventType::E3Failed, + EventType::Shutdown, + ], + addr.into(), + ); + Ok(()) + } + + fn recovery_state(&self) -> DataAvailabilityRecoveryState { + DataAvailabilityRecoveryState { + schema_version: DATA_AVAILABILITY_RECOVERY_SCHEMA_VERSION, + presets: self.presets.clone(), + assemblies: self.assemblies.clone(), + selected_candidates: self.selected_candidates.clone(), + invalid_candidates: self.invalid_candidates.clone(), + published_keys: self.published_keys.clone(), + pending_outputs: self.pending_outputs.clone(), + resolved_outputs: self.resolved_outputs.clone(), + terminal_e3s: self.terminal_e3s.clone(), + } + } + + fn persist(&self, cause: &EventContext) -> anyhow::Result<()> { + self.recovery + .write_with_context(&self.recovery_state(), cause) + } + + fn try_publish_keys(&mut self) { + if !self.effects_enabled { + return; + } + let keys: Vec = self.assemblies.keys().cloned().collect(); + for key in keys { + if self.invalid_candidates.contains(&key) + || self.published_keys.contains(&key.0) + || self.publishing_keys.contains(&key.0) + { + continue; + } + let Some(preset) = self.presets.get(&key.0).copied() else { + continue; + }; + let Some(assembly) = self.assemblies.get(&key) else { + continue; + }; + let Some(bytes) = assembly.bytes() else { + continue; + }; + if keccak256(&bytes).0 != key.2 { + warn!(e3_id = %key.0, publisher = %key.1, "Rejecting public-key chunks with a mismatched candidate hash"); + self.invalid_candidates.insert(key); + continue; + } + if let Err(error) = + validate_committee_public_key(&bytes, assembly.pk_commitment, preset) + { + warn!(e3_id = %key.0, publisher = %key.1, %error, "Rejecting a committee public key that does not match its proven C5 commitment"); + self.invalid_candidates.insert(key); + continue; + } + + let event = CommitteePublished { + e3_id: key.0.clone(), + nodes: assembly.nodes.clone(), + public_key: ArcBytes::from_bytes(&bytes), + proof: ArcBytes::from_bytes(&[]), + }; + self.publishing_keys.insert(key.0.clone()); + if let Err(error) = self.bus.publish_without_context(event) { + warn!(e3_id = %key.0, %error, "Could not publish the assembled committee public key"); + self.publishing_keys.remove(&key.0); + } else { + info!(e3_id = %key.0, bytes = bytes.len(), "Verified and assembled the chunked committee public key"); + } + } + } + + fn start_output(&mut self, e3_id: &E3id, ctx: &mut Context) { + if !self.effects_enabled + || self.reader.is_none() + || self.terminal_e3s.contains(e3_id) + || self.resolved_outputs.contains(e3_id) + || !self.pending_outputs.contains_key(e3_id) + || !self.retrieving_outputs.insert(e3_id.clone()) + { + return; + } + ctx.notify(RetrieveOutput(e3_id.clone())); + } + + fn cleanup(&mut self, e3_id: &E3id) { + self.presets.remove(e3_id); + self.pending_outputs.remove(e3_id); + self.retrieving_outputs.remove(e3_id); + self.published_keys.remove(e3_id); + self.publishing_keys.remove(e3_id); + self.resolved_outputs.remove(e3_id); + self.assemblies.retain(|key, _| &key.0 != e3_id); + self.selected_candidates.retain(|key, _| &key.0 != e3_id); + self.invalid_candidates.retain(|key| &key.0 != e3_id); + } +} + +impl Actor for DataAvailabilityCoordinator { + type Context = Context; + + fn started(&mut self, ctx: &mut Self::Context) { + ctx.set_mailbox_capacity(MAILBOX_LIMIT); + } +} + +impl Handler for DataAvailabilityCoordinator { + type Result = (); + + fn handle(&mut self, message: InterfoldEvent, ctx: &mut Self::Context) { + let (event, cause) = message.into_components(); + if event + .get_e3_id() + .is_some_and(|e3_id| e3_id.chain_id() != self.chain_id) + { + return; + } + if is_late_fact_for_terminal_e3(&event, &self.terminal_e3s) { + return; + } + let persists_recovery = matches!( + &event, + InterfoldEventData::E3Requested(_) + | InterfoldEventData::CommitteePublicKeyChunkPublished(_) + | InterfoldEventData::CommitteePublished(_) + | InterfoldEventData::CiphertextOutputReferencePublished(_) + | InterfoldEventData::CiphertextOutputPublished(_) + | InterfoldEventData::EffectsEnabled(_) + | InterfoldEventData::E3RequestComplete(_) + ); + match event { + InterfoldEventData::E3Requested(event) => { + self.presets.insert(event.e3_id, event.params_preset); + self.try_publish_keys(); + } + InterfoldEventData::CommitteePublicKeyChunkPublished(event) => { + if !KeyAssembly::event_shape_is_valid(&event) { + warn!(e3_id = %event.e3_id, publisher = %event.publisher, "Ignoring a malformed public-key chunk"); + return; + } + let publisher_key = (event.e3_id.clone(), event.publisher.clone()); + let selected = self + .selected_candidates + .entry(publisher_key) + .or_insert(event.candidate_hash); + if *selected != event.candidate_hash { + warn!(e3_id = %event.e3_id, publisher = %event.publisher, "Ignoring a second public-key candidate from one committee member"); + return; + } + let key = ( + event.e3_id.clone(), + event.publisher.clone(), + event.candidate_hash, + ); + if self.invalid_candidates.contains(&key) { + return; + } + let assembly = self + .assemblies + .entry(key.clone()) + .or_insert_with(|| KeyAssembly::new(&event)); + if !assembly.insert(&event) { + warn!(e3_id = %event.e3_id, publisher = %event.publisher, "Rejecting inconsistent public-key chunks"); + self.invalid_candidates.insert(key); + } + self.try_publish_keys(); + } + // A locally derived publication is durable. Replaying it prevents the coordinator + // from appending the same derived event on every restart. + InterfoldEventData::CommitteePublished(event) => { + self.publishing_keys.remove(&event.e3_id); + self.published_keys.insert(event.e3_id.clone()); + self.assemblies.retain(|key, _| key.0 != event.e3_id); + self.selected_candidates + .retain(|key, _| key.0 != event.e3_id); + self.invalid_candidates.retain(|key| key.0 != event.e3_id); + } + InterfoldEventData::CiphertextOutputReferencePublished(event) => { + let e3_id = event.e3_id.clone(); + self.pending_outputs.insert( + e3_id.clone(), + OutputReference { + event, + cause: cause.clone(), + }, + ); + if self.reader.is_none() && self.effects_enabled { + warn!(%e3_id, "Cannot retrieve a ciphertext output because data availability is not configured"); + } + self.start_output(&e3_id, ctx); + } + InterfoldEventData::CiphertextOutputPublished(event) => { + self.resolved_outputs.insert(event.e3_id.clone()); + self.pending_outputs.remove(&event.e3_id); + self.retrieving_outputs.remove(&event.e3_id); + } + InterfoldEventData::EffectsEnabled(_) => { + self.effects_enabled = true; + self.try_publish_keys(); + for e3_id in self.pending_outputs.keys().cloned().collect::>() { + self.start_output(&e3_id, ctx); + } + } + InterfoldEventData::E3RequestComplete(event) => { + self.terminal_e3s.insert(event.e3_id.clone()); + self.cleanup(&event.e3_id); + } + // `E3Failed` can be a local failure proposal which the chain has not accepted yet. + // `E3RequestComplete` is the router's durable teardown fact, so only that event makes + // this projection terminal and prevents later chain events from recreating state. + InterfoldEventData::E3Failed(_) => {} + InterfoldEventData::Shutdown(_) => ctx.stop(), + _ => {} + } + if persists_recovery { + if let Err(error) = self.persist(&cause) { + self.bus.with_ec(&cause).err(EType::Evm, error); + } + } + } +} + +impl Handler for DataAvailabilityCoordinator { + type Result = actix::ResponseActFuture; + + fn handle(&mut self, message: RetrieveOutput, _: &mut Self::Context) -> Self::Result { + let e3_id = message.0; + let Some(reader) = self.reader.clone() else { + self.retrieving_outputs.remove(&e3_id); + return Box::pin(async {}.into_actor(self)); + }; + let Some(pending) = self.pending_outputs.get(&e3_id).cloned() else { + self.retrieving_outputs.remove(&e3_id); + return Box::pin(async {}.into_actor(self)); + }; + let reference = DataReference { + content_hash: pending.event.content_hash, + block_number: pending.event.availability_block, + leaf_index: pending.event.availability_leaf_index, + }; + + Box::pin( + async move { reader.retrieve(reference).await } + .into_actor(self) + .map(move |result, actor, ctx| match result { + Ok(bytes) => { + if actor.terminal_e3s.contains(&e3_id) { + actor.retrieving_outputs.remove(&e3_id); + return; + } + let event = CiphertextOutputPublished { + e3_id: e3_id.clone(), + ciphertext_output: vec![ArcBytes::from_bytes(&bytes)], + ciphertext_commitment: pending.event.ciphertext_commitment, + }; + actor.retrieving_outputs.remove(&e3_id); + if let Err(error) = actor.bus.publish(event, pending.cause) { + warn!(%e3_id, %error, "Could not publish the retrieved ciphertext output"); + ctx.run_later(OUTPUT_RETRY_DELAY, move |actor, ctx| { + actor.start_output(&e3_id, ctx); + }); + } + } + Err(error) => { + warn!(%e3_id, %error, "Ciphertext output is not retrievable yet; retrying"); + actor.retrieving_outputs.remove(&e3_id); + ctx.run_later(OUTPUT_RETRY_DELAY, move |actor, ctx| { + actor.start_output(&e3_id, ctx); + }); + } + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use e3_bfv_client::{client::generate_public_key, compute_pk_commitment}; + + fn chunk_event(bytes: &[u8], chunk_index: u16) -> CommitteePublicKeyChunkPublished { + let chunk_count = bytes.len().div_ceil(PUBLIC_KEY_CHUNK_BYTES) as u16; + let offset = usize::from(chunk_index) * PUBLIC_KEY_CHUNK_BYTES; + let end = (offset + PUBLIC_KEY_CHUNK_BYTES).min(bytes.len()); + CommitteePublicKeyChunkPublished { + e3_id: E3id::new("7", 1), + publisher: "0x0000000000000000000000000000000000000001".to_owned(), + candidate_hash: keccak256(bytes).0, + nodes: vec!["0x0000000000000000000000000000000000000001".to_owned()], + pk_commitment: [9; 32], + chunk_index, + chunk_count, + total_length: bytes.len() as u32, + chunk: ArcBytes::from_bytes(&bytes[offset..end]), + } + } + + #[test] + fn deterministic_chunks_reassemble_in_index_order() { + let bytes = (0..(3 * PUBLIC_KEY_CHUNK_BYTES + 17)) + .map(|index| (index % 251) as u8) + .collect::>(); + let mut events = (0..bytes.len().div_ceil(PUBLIC_KEY_CHUNK_BYTES) as u16) + .map(|index| chunk_event(&bytes, index)) + .collect::>(); + let mut assembly = KeyAssembly::new(&events[0]); + + events.reverse(); + for event in &events { + assert!(KeyAssembly::event_shape_is_valid(event)); + assert!(assembly.insert(event)); + } + + assert_eq!(assembly.bytes().as_deref(), Some(bytes.as_slice())); + } + + #[test] + fn malformed_or_conflicting_chunks_are_rejected() { + let bytes = vec![3; PUBLIC_KEY_CHUNK_BYTES + 1]; + let first = chunk_event(&bytes, 0); + let mut malformed = first.clone(); + malformed.chunk = ArcBytes::from_bytes(&[3; 16]); + assert!(!KeyAssembly::event_shape_is_valid(&malformed)); + + let mut assembly = KeyAssembly::new(&first); + assert!(assembly.insert(&first)); + let mut conflicting = first; + conflicting.chunk = ArcBytes::from_bytes(&vec![4; PUBLIC_KEY_CHUNK_BYTES]); + assert!(!assembly.insert(&conflicting)); + } + + #[test] + fn terminal_e3_rejects_late_transport_facts() { + let bytes = vec![3; PUBLIC_KEY_CHUNK_BYTES + 1]; + let event = chunk_event(&bytes, 0); + let terminal_e3s = HashSet::from([event.e3_id.clone()]); + + assert!(is_late_fact_for_terminal_e3( + &InterfoldEventData::CommitteePublicKeyChunkPublished(event), + &terminal_e3s, + )); + } + + #[test] + fn partial_assembly_survives_the_repository_encoding() { + let bytes = vec![3; PUBLIC_KEY_CHUNK_BYTES + 1]; + let first = chunk_event(&bytes, 0); + let key = ( + first.e3_id.clone(), + first.publisher.clone(), + first.candidate_hash, + ); + let mut assembly = KeyAssembly::new(&first); + assert!(assembly.insert(&first)); + let mut state = DataAvailabilityRecoveryState::default(); + state.assemblies.insert(key.clone(), assembly); + state + .selected_candidates + .insert((first.e3_id, first.publisher), first.candidate_hash); + state.terminal_e3s.insert(E3id::new("8", 1)); + + let encoded = bincode::serialize(&state).expect("encode recovery state"); + let recovered: DataAvailabilityRecoveryState = + bincode::deserialize(&encoded).expect("decode recovery state"); + + assert!(recovered.assemblies.contains_key(&key)); + assert!(recovered.assemblies[&key].bytes().is_none()); + assert!(recovered.terminal_e3s.contains(&E3id::new("8", 1))); + } + + #[test] + fn threshold_public_key_is_validated_with_threshold_parameters() { + let preset = BfvPreset::InsecureThreshold512; + let params = BfvParamSet::from(preset); + let public_key = generate_public_key( + params.degree, + params.plaintext_modulus, + params.moduli.to_vec(), + ) + .expect("generate threshold public key"); + let commitment = compute_pk_commitment( + public_key.clone(), + params.degree, + params.plaintext_modulus, + params.moduli.to_vec(), + ) + .expect("compute C5 commitment"); + + validate_committee_public_key(&public_key, commitment, preset) + .expect("validate threshold public key"); + assert!(validate_committee_public_key(&public_key, [0x55; 32], preset).is_err()); + } +} diff --git a/crates/evm/src/event_decoding/catalog.rs b/crates/evm/src/event_decoding/catalog.rs index 04bed64a45..8784158152 100644 --- a/crates/evm/src/event_decoding/catalog.rs +++ b/crates/evm/src/event_decoding/catalog.rs @@ -87,6 +87,11 @@ const INTERFOLD: &[EvmEventDefinition] = &[ "CiphertextOutputPublished(uint256,bytes,bytes32)", Some(1), ), + EvmEventDefinition::new( + "CiphertextOutputReferencePublished", + "CiphertextOutputReferencePublished(uint256,bytes32,bytes32,uint32,uint128)", + Some(1), + ), EvmEventDefinition::new( "CiphertextVerifierSet", "CiphertextVerifierSet(bytes32,address)", @@ -106,6 +111,11 @@ const INTERFOLD: &[EvmEventDefinition] = &[ Some(1), ), EvmEventDefinition::new("E3ProgramRegistered", "E3ProgramRegistered(address)", None), + EvmEventDefinition::new( + "E3ProgramUnregistered", + "E3ProgramUnregistered(address)", + None, + ), EvmEventDefinition::new("E3RefundManagerSet", "E3RefundManagerSet(address)", None), EvmEventDefinition::new( "E3Requested", @@ -405,6 +415,11 @@ const CIPHERNODE_REGISTRY: &[EvmEventDefinition] = &[ "CommitteePublished(uint256,address[],bytes,bytes32,bytes)", Some(1), ), + EvmEventDefinition::new( + "CommitteePublicKeyChunkPublished", + "CommitteePublicKeyChunkPublished(uint256,address,bytes32,address[],bytes32,uint16,uint16,uint32,bytes)", + Some(1), + ), EvmEventDefinition::new( "CommitteeRequested", "CommitteeRequested(uint256,uint256,uint32[2],uint256,uint256,uint256)", diff --git a/crates/evm/src/event_decoding/error.rs b/crates/evm/src/event_decoding/error.rs index a111eb9798..4829dcf8a5 100644 --- a/crates/evm/src/event_decoding/error.rs +++ b/crates/evm/src/event_decoding/error.rs @@ -57,11 +57,13 @@ pub fn format_evm_error(err: &anyhow::Error) -> String { decode_error_from_str(&error_str).unwrap_or(error_str) } -/// Return true when an error contains exactly this parameterless custom-error selector. +/// Return true when an error contains an ABI-shaped custom-error payload with this selector. pub(crate) fn contains_error_selector(error_str: &str, selector: [u8; 4]) -> bool { - extract_all_hex_blobs(error_str) - .iter() - .any(|data| data.as_slice() == selector) + extract_all_hex_blobs(error_str).iter().any(|data| { + data.starts_with(&selector) + && data.len() >= selector.len() + && (data.len() - selector.len()).is_multiple_of(32) + }) } /// Extract all hex blobs (0x...) with at least 4 bytes (8 hex chars) from a string. @@ -155,7 +157,7 @@ mod tests { } #[test] - fn selector_match_requires_the_exact_revert_payload() { + fn selector_match_requires_an_abi_shaped_revert_payload() { let selector = ICiphernodeRegistry::CommitteeAlreadyFinalized::SELECTOR; let tx_hash = format!("{}{}", hex::encode(selector), "00".repeat(28)); assert!(!contains_error_selector( @@ -166,5 +168,13 @@ mod tests { &format!("revert data: 0x{}", hex::encode(selector)), selector )); + assert!(contains_error_selector( + &format!( + "revert data: 0x{}{}", + hex::encode(selector), + "00".repeat(32) + ), + selector + )); } } diff --git a/crates/evm/src/interfold/events.rs b/crates/evm/src/interfold/events.rs index 70c48ca99f..abe066efda 100644 --- a/crates/evm/src/interfold/events.rs +++ b/crates/evm/src/interfold/events.rs @@ -13,8 +13,8 @@ use anyhow::{anyhow, Context as _, Result}; use e3_events::E3id; use e3_events::InterfoldEventData; use e3_events::{ - E3Failed, E3Stage, E3StageChanged, FailureReason, InputPublished, PlaintextOutputPublished, - RewardClaimed, RewardCredited, RewardsDistributed, + CiphertextOutputReferencePublished, E3Failed, E3Stage, E3StageChanged, FailureReason, + InputPublished, PlaintextOutputPublished, RewardClaimed, RewardCredited, RewardsDistributed, }; use e3_fhe_params::{encode_bfv_params, BfvParamSet, BfvPreset}; use e3_trbfv::helpers::calculate_error_size; @@ -139,6 +139,29 @@ impl From for InterfoldEventData { } } +struct CiphertextOutputReferenceWithChainId( + pub IInterfold::CiphertextOutputReferencePublished, + pub u64, +); + +impl From for CiphertextOutputReferencePublished { + fn from(value: CiphertextOutputReferenceWithChainId) -> Self { + Self { + e3_id: E3id::new(value.0.e3Id.to_string(), value.1), + content_hash: value.0.contentHash.into(), + ciphertext_commitment: value.0.ciphertextCommitment.into(), + availability_block: value.0.availabilityBlock, + availability_leaf_index: value.0.availabilityLeafIndex, + } + } +} + +impl From for InterfoldEventData { + fn from(value: CiphertextOutputReferenceWithChainId) -> Self { + CiphertextOutputReferencePublished::from(value).into() + } +} + struct E3FailedWithChainId(pub IInterfold::E3Failed, pub u64); fn convert_u8_to_e3_stage(stage_u8: u8) -> E3Stage { @@ -365,6 +388,16 @@ pub(crate) fn extractor( CiphertextOutputPublishedWithChainId(event, chain_id), ))) } + Some(&IInterfold::CiphertextOutputReferencePublished::SIGNATURE_HASH) => { + let mut event = IInterfold::CiphertextOutputReferencePublished::decode_log_data(data) + .context( + "failed to decode CiphertextOutputReferencePublished after its topic matched", + )?; + event.e3Id = indexed_u256(topics, 1, "CiphertextOutputReferencePublished")?; + Ok(Some( + CiphertextOutputReferenceWithChainId(event, chain_id).into(), + )) + } Some(&IInterfold::InputPublished::SIGNATURE_HASH) => { let mut event = IInterfold::InputPublished::decode_log_data(data) .context("failed to decode InputPublished after its topic matched")?; diff --git a/crates/evm/src/interfold_writing/actor.rs b/crates/evm/src/interfold_writing/actor.rs index 7e7a1e596a..ccb7343dd3 100644 --- a/crates/evm/src/interfold_writing/actor.rs +++ b/crates/evm/src/interfold_writing/actor.rs @@ -6,10 +6,12 @@ //! Interfold contract publication boundary. -use crate::contracts::IInterfold; -use crate::domain::error_decoder::format_evm_error; -use crate::domain::plaintext_publication::failure_watch_delay; +use crate::contracts::{ICiphernodeRegistry, IInterfold}; +use crate::domain::error_decoder::{contains_error_selector, format_evm_error}; use crate::domain::plaintext_publication::validate_plaintext_output; +use crate::domain::plaintext_publication::{ + failure_watch_delay, failure_watch_party_id, FailureStageDiscoveryGate, +}; use crate::domain::publication_replay::ReplaySubmissionGate; use crate::helpers::{encode_zk_proof, transaction_nonce_guard, EthProvider}; use crate::send_tx_with_retry; @@ -21,12 +23,13 @@ use alloy::{ }; use anyhow::Result; use e3_events::{ - prelude::*, AggregatorChanged, BusHandle, CiphernodeSelected, E3RequestComplete, E3Stage, - E3StageChanged, E3id, EType, EffectsEnabled, EventType, InterfoldEvent, InterfoldEventData, - PlaintextAggregated, Proof, Shutdown, + prelude::*, AggregatorChanged, BusHandle, CiphernodeSelected, + DkgFoldAttestationContextEstablished, E3RequestComplete, E3Stage, E3StageChanged, E3id, EType, + EffectsEnabled, EventType, InterfoldEvent, InterfoldEventData, PlaintextAggregated, Proof, + Shutdown, DKG_FOLD_ATTESTATION_CONTEXT_SCHEMA_VERSION, }; use e3_utils::{require_successful_receipt, NotifySync, MAILBOX_LIMIT}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::info; @@ -44,8 +47,11 @@ pub struct InterfoldSolWriter

{ active_aggregators: HashMap, publication: ReplaySubmissionGate, committee_party_ids: HashMap, + request_registries: HashMap, failure_stages: HashMap, failure_timers: HashMap, + failure_stage_discoveries: FailureStageDiscoveryGate, + failure_settlements: ReplaySubmissionGate, } impl InterfoldSolWriter

{ @@ -60,6 +66,9 @@ impl InterfoldSolWriter

{ contract_address, HashMap::new(), HashMap::new(), + HashMap::new(), + HashMap::new(), + HashSet::new(), ) } @@ -69,7 +78,15 @@ impl InterfoldSolWriter

{ contract_address: Address, active_aggregators: HashMap, committee_party_ids: HashMap, + request_registries: HashMap, + failure_stages: HashMap, + pending_failure_settlements: HashSet, ) -> Result { + let mut failure_settlements = ReplaySubmissionGate::new(); + for e3_id in pending_failure_settlements { + failure_settlements.record(e3_id, ()); + } + Ok(Self { provider, contract_address, @@ -78,8 +95,11 @@ impl InterfoldSolWriter

{ active_aggregators, publication: ReplaySubmissionGate::new(), committee_party_ids, - failure_stages: HashMap::new(), + request_registries, + failure_stages, failure_timers: HashMap::new(), + failure_stage_discoveries: FailureStageDiscoveryGate::default(), + failure_settlements, }) } @@ -90,6 +110,9 @@ impl InterfoldSolWriter

{ contract_address, HashMap::new(), HashMap::new(), + HashMap::new(), + HashMap::new(), + HashSet::new(), ); } @@ -99,6 +122,9 @@ impl InterfoldSolWriter

{ contract_address: Address, active_aggregators: HashMap, committee_party_ids: HashMap, + request_registries: HashMap, + failure_stages: HashMap, + pending_failure_settlements: HashSet, ) { let addr = InterfoldSolWriter::new_with_recovery( bus, @@ -106,6 +132,9 @@ impl InterfoldSolWriter

{ contract_address, active_aggregators, committee_party_ids, + request_registries, + failure_stages, + pending_failure_settlements, ) .expect("failed to create InterfoldSolWriter") .start(); @@ -114,6 +143,7 @@ impl InterfoldSolWriter

{ EventType::EffectsEnabled, EventType::AggregatorChanged, EventType::CiphernodeSelected, + EventType::DkgFoldAttestationContextEstablished, EventType::PlaintextAggregated, EventType::E3StageChanged, EventType::E3RequestComplete, @@ -155,6 +185,12 @@ struct MarkFailedAtDeadline { stage: E3Stage, } +#[derive(Message, Debug, Clone)] +#[rtype(result = "()")] +struct ProcessFailedE3 { + e3_id: E3id, +} + impl Actor for InterfoldSolWriter

{ type Context = actix::Context; diff --git a/crates/evm/src/interfold_writing/effects.rs b/crates/evm/src/interfold_writing/effects.rs index dd1a4b694b..6677def5d5 100644 --- a/crates/evm/src/interfold_writing/effects.rs +++ b/crates/evm/src/interfold_writing/effects.rs @@ -3,6 +3,7 @@ //! Interfold contract reads and transaction effects. use super::*; +use alloy::sol_types::SolError; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in crate::actors::interfold_sol_writer) enum MarkFailureOutcome { @@ -11,7 +12,27 @@ pub(in crate::actors::interfold_sol_writer) enum MarkFailureOutcome { StageAdvanced, } -pub(in crate::actors::interfold_sol_writer) async fn read_aggregation_failure_stage< +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::actors::interfold_sol_writer) struct FailureSchedule { + pub deadline: u64, + pub permissionless_grace: u64, +} + +fn requested_failure_deadline( + committee_deadline: u64, + committee_threshold_met: bool, + dkg_window: u64, +) -> Result { + if committee_threshold_met { + committee_deadline + .checked_add(dkg_window) + .ok_or_else(|| anyhow::anyhow!("Requested-stage deadline overflowed")) + } else { + Ok(committee_deadline) + } +} + +pub(in crate::actors::interfold_sol_writer) async fn read_watched_failure_stage< P: Provider + WalletProvider + Clone, >( provider: EthProvider

, @@ -22,7 +43,9 @@ pub(in crate::actors::interfold_sol_writer) async fn read_aggregation_failure_st let contract = IInterfold::new(contract_address, provider.provider()); let stage = contract.getE3Stage(e3_id).call().await?; Ok(match stage { + 1 => Some(E3Stage::Requested), 2 => Some(E3Stage::CommitteeFinalized), + 3 => Some(E3Stage::KeyPublished), 4 => Some(E3Stage::CiphertextReady), _ => None, }) @@ -35,18 +58,61 @@ pub(in crate::actors::interfold_sol_writer) async fn read_failure_deadline< contract_address: Address, e3_id: E3id, stage: E3Stage, -) -> Result { + request_registry: Option

, +) -> Result { let e3_id: U256 = e3_id.try_into()?; let contract = IInterfold::new(contract_address, provider.provider()); - let deadlines = contract.getDeadlines(e3_id).call().await?; - let deadline = match stage { - E3Stage::CommitteeFinalized => deadlines.dkgDeadline, - E3Stage::CiphertextReady => deadlines.decryptionDeadline, - _ => anyhow::bail!("stage {stage:?} does not have an aggregation failure deadline"), + let deadline: u64 = match stage { + E3Stage::Requested => { + let registry_address = request_registry.ok_or_else(|| { + anyhow::anyhow!("request-time registry is unavailable for Requested E3") + })?; + let registry = ICiphernodeRegistry::new(registry_address, provider.provider()); + let committee_deadline: u64 = registry + .getCommitteeDeadline(e3_id) + .call() + .await? + .try_into() + .map_err(|_| anyhow::anyhow!("committee deadline does not fit in u64"))?; + let committee_threshold_met = registry.committeeThresholdMet(e3_id).call().await?; + let dkg_window = if committee_threshold_met { + let dkg_window: u64 = contract + .getE3TimeoutConfig(e3_id) + .call() + .await? + .dkgWindow + .try_into() + .map_err(|_| anyhow::anyhow!("DKG window does not fit in u64"))?; + dkg_window + } else { + 0 + }; + requested_failure_deadline(committee_deadline, committee_threshold_met, dkg_window)? + } + E3Stage::CommitteeFinalized | E3Stage::KeyPublished | E3Stage::CiphertextReady => { + let deadlines = contract.getDeadlines(e3_id).call().await?; + let deadline = match stage { + E3Stage::CommitteeFinalized => deadlines.dkgDeadline, + E3Stage::KeyPublished => deadlines.computeDeadline, + E3Stage::CiphertextReady => deadlines.decryptionDeadline, + _ => unreachable!(), + }; + deadline + .try_into() + .map_err(|_| anyhow::anyhow!("E3 deadline does not fit in u64"))? + } + _ => anyhow::bail!("stage {stage:?} does not have a failure deadline"), }; - deadline + let permissionless_grace = contract + .markFailedGracePeriod() + .call() + .await? .try_into() - .map_err(|_| anyhow::anyhow!("E3 deadline does not fit in u64")) + .map_err(|_| anyhow::anyhow!("mark-failed grace period does not fit in u64"))?; + Ok(FailureSchedule { + deadline, + permissionless_grace, + }) } pub(in crate::actors::interfold_sol_writer) async fn mark_e3_failed_if_due< @@ -88,9 +154,11 @@ pub(in crate::actors::interfold_sol_writer) async fn mark_e3_failed_if_due< fn failure_stage_code(stage: &E3Stage) -> Result { match stage { + E3Stage::Requested => Ok(1), E3Stage::CommitteeFinalized => Ok(2), + E3Stage::KeyPublished => Ok(3), E3Stage::CiphertextReady => Ok(4), - _ => anyhow::bail!("stage {stage:?} is not watched for aggregation failure"), + _ => anyhow::bail!("stage {stage:?} is not watched for failure"), } } @@ -181,3 +249,51 @@ pub(in crate::actors::interfold_sol_writer) async fn process_e3_failure< require_successful_receipt("process E3 failure", &receipt)?; Ok(receipt) } + +pub(in crate::actors::interfold_sol_writer) fn failure_settlement_error_is_terminal( + error: &anyhow::Error, +) -> bool { + contains_error_selector( + &format!("{error:?}"), + IInterfold::NoPaymentToRefund::SELECTOR, + ) +} + +#[cfg(test)] +mod tests { + use super::{ + failure_settlement_error_is_terminal, failure_stage_code, requested_failure_deadline, + }; + use crate::contracts::IInterfold; + use alloy::sol_types::SolError; + use e3_events::E3Stage; + + #[test] + fn all_contract_failure_stages_are_watched() { + assert_eq!(failure_stage_code(&E3Stage::Requested).unwrap(), 1); + assert_eq!(failure_stage_code(&E3Stage::CommitteeFinalized).unwrap(), 2); + assert_eq!(failure_stage_code(&E3Stage::KeyPublished).unwrap(), 3); + assert_eq!(failure_stage_code(&E3Stage::CiphertextReady).unwrap(), 4); + assert!(failure_stage_code(&E3Stage::Complete).is_err()); + } + + #[test] + fn requested_stage_uses_the_registry_deadline_and_frozen_dkg_window() { + assert_eq!(requested_failure_deadline(100, false, 50).unwrap(), 100); + assert_eq!(requested_failure_deadline(100, true, 50).unwrap(), 150); + assert!(requested_failure_deadline(u64::MAX, true, 1).is_err()); + } + + #[test] + fn settled_failure_stops_retries() { + let error = anyhow::anyhow!( + "execution reverted: 0x{}{}", + hex::encode(IInterfold::NoPaymentToRefund::SELECTOR), + "00".repeat(32) + ); + assert!(failure_settlement_error_is_terminal(&error)); + assert!(!failure_settlement_error_is_terminal(&anyhow::anyhow!( + "RPC connection reset" + ))); + } +} diff --git a/crates/evm/src/interfold_writing/handlers.rs b/crates/evm/src/interfold_writing/handlers.rs index 8b7d0d9643..edec31666d 100644 --- a/crates/evm/src/interfold_writing/handlers.rs +++ b/crates/evm/src/interfold_writing/handlers.rs @@ -5,6 +5,7 @@ use super::effects::*; use super::*; use e3_events::EventSource; +use std::collections::HashSet; const PUBLICATION_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(30); const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(30); @@ -27,12 +28,15 @@ impl InterfoldSolWriter

{ } fn try_start_failure_watch(&self, e3_id: &E3id, ctx: &mut actix::Context) { - if !self.effects_enabled || !self.committee_party_ids.contains_key(e3_id) { + if !self.effects_enabled { return; } let Some(stage) = self.failure_stages.get(e3_id).cloned() else { return; }; + if stage == E3Stage::Requested && !self.request_registries.contains_key(e3_id) { + return; + } ctx.notify(ResolveFailureDeadline { e3_id: e3_id.clone(), stage, @@ -43,14 +47,22 @@ impl InterfoldSolWriter

{ for e3_id in self.failure_stages.keys() { self.try_start_failure_watch(e3_id, ctx); } - for e3_id in self.committee_party_ids.keys() { - ctx.notify(DiscoverFailureStage { - e3_id: e3_id.clone(), - }); + let discovery_ids = self + .committee_party_ids + .keys() + .chain(self.request_registries.keys()) + .cloned() + .collect::>(); + for e3_id in discovery_ids { + ctx.notify(DiscoverFailureStage { e3_id }); + } + for e3_id in self.failure_settlements.pending_keys() { + ctx.notify(ProcessFailedE3 { e3_id }); } } fn clear_failure_watch(&mut self, e3_id: &E3id, ctx: &mut actix::Context) { + self.failure_stage_discoveries.invalidate(e3_id); self.failure_stages.remove(e3_id); if let Some(handle) = self.failure_timers.remove(e3_id) { ctx.cancel_future(handle); @@ -61,23 +73,23 @@ impl InterfoldSolWriter

{ &mut self, e3_id: E3id, stage: E3Stage, - deadline_unix_secs: u64, + schedule: FailureSchedule, ctx: &mut actix::Context, ) { if self.failure_stages.get(&e3_id) != Some(&stage) { return; } - let Some(party_id) = self.committee_party_ids.get(&e3_id).copied() else { - return; - }; if let Some(handle) = self.failure_timers.remove(&e3_id) { ctx.cancel_future(handle); } + let party_id = + failure_watch_party_id(&stage, self.committee_party_ids.get(&e3_id).copied()); let delay = failure_watch_delay( Self::now_unix_secs(), - deadline_unix_secs, + schedule.deadline, party_id, + schedule.permissionless_grace, FAILURE_PARTY_STAGGER_SECS, ); let timer_e3_id = e3_id.clone(); @@ -104,6 +116,11 @@ impl Handler InterfoldEventData::EffectsEnabled(data) => self.notify_sync(ctx, data), InterfoldEventData::AggregatorChanged(data) => self.notify_sync(ctx, data), InterfoldEventData::CiphernodeSelected(data) => self.notify_sync(ctx, data), + InterfoldEventData::DkgFoldAttestationContextEstablished(data) => { + if self.provider.chain_id() == data.e3_id.chain_id() { + ctx.notify(data); + } + } InterfoldEventData::PlaintextAggregated(data) => { // Only a locally computed result is a publication intent. Peer results are // inputs for protocol observers and must not cross the EVM write boundary. @@ -132,6 +149,7 @@ impl Handler fn handle(&mut self, _: EffectsEnabled, ctx: &mut Self::Context) -> Self::Result { self.effects_enabled = true; self.publication.enable_effects(); + self.failure_settlements.enable_effects(); self.try_start_pending_plaintexts(ctx); self.try_start_failure_watches(ctx); } @@ -155,6 +173,37 @@ impl Handler } } +impl Handler + for InterfoldSolWriter

+{ + type Result = (); + + fn handle( + &mut self, + msg: DkgFoldAttestationContextEstablished, + ctx: &mut Self::Context, + ) -> Self::Result { + if msg.schema_version != DKG_FOLD_ATTESTATION_CONTEXT_SCHEMA_VERSION { + self.request_registries.remove(&msg.e3_id); + self.bus.err( + EType::Evm, + anyhow::anyhow!( + "unsupported DKG attestation context schema {} for E3 {}", + msg.schema_version, + msg.e3_id + ), + ); + return; + } + self.request_registries + .insert(msg.e3_id.clone(), msg.context.registry); + self.try_start_failure_watch(&msg.e3_id, ctx); + if self.effects_enabled { + ctx.notify(DiscoverFailureStage { e3_id: msg.e3_id }); + } + } +} + impl Handler for InterfoldSolWriter

{ @@ -178,6 +227,7 @@ impl Handler fn handle(&mut self, msg: E3RequestComplete, ctx: &mut Self::Context) -> Self::Result { self.active_aggregators.remove(&msg.e3_id); self.committee_party_ids.remove(&msg.e3_id); + self.request_registries.remove(&msg.e3_id); self.clear_failure_watch(&msg.e3_id, ctx); } } @@ -310,12 +360,16 @@ impl Handler for Inter impl Handler for InterfoldSolWriter

{ - type Result = ResponseFuture<()>; + type Result = (); fn handle(&mut self, msg: E3StageChanged, ctx: &mut Self::Context) -> Self::Result { let e3_id = msg.e3_id.clone(); + self.failure_stage_discoveries.invalidate(&e3_id); match &msg.new_stage { - E3Stage::CommitteeFinalized | E3Stage::CiphertextReady => { + E3Stage::Requested + | E3Stage::CommitteeFinalized + | E3Stage::KeyPublished + | E3Stage::CiphertextReady => { self.failure_stages .insert(e3_id.clone(), msg.new_stage.clone()); self.try_start_failure_watch(&e3_id, ctx); @@ -323,33 +377,59 @@ impl Handler _ => self.clear_failure_watch(&e3_id, ctx), } - if !self.effects_enabled || msg.new_stage != E3Stage::Failed { - return Box::pin(async {}); + if msg.new_stage == E3Stage::Failed { + self.failure_settlements.record(e3_id.clone(), ()); + if self.effects_enabled { + ctx.notify(ProcessFailedE3 { e3_id }); + } } + } +} - Box::pin({ - let contract_address = self.contract_address; - let provider = self.provider.clone(); +impl Handler + for InterfoldSolWriter

+{ + type Result = ResponseActFuture; + + fn handle(&mut self, msg: ProcessFailedE3, _ctx: &mut Self::Context) -> Self::Result { + if self.failure_settlements.start(&msg.e3_id).is_none() { + return Box::pin(async {}.into_actor(self)); + } + + let provider = self.provider.clone(); + let contract_address = self.contract_address; + let e3_id = msg.e3_id; + Box::pin( async move { let result = process_e3_failure(provider, contract_address, e3_id.clone()).await; + (e3_id, result) + } + .into_actor(self) + .map(|(e3_id, result), actor, ctx| { + let terminal = match &result { + Ok(_) => true, + Err(error) => failure_settlement_error_is_terminal(error), + }; + actor.failure_settlements.finish(&e3_id, terminal); + match result { Ok(receipt) => { info!( - tx=%receipt.transaction_hash, + tx = %receipt.transaction_hash, e3_id = %e3_id, "Called processE3Failure" ); } - Err(err) => { - info!( - e3_id = %e3_id, - "processE3Failure did not succeed (may already be processed): {}", - format_evm_error(&err) - ); + Err(_) if terminal => { + info!(e3_id = %e3_id, "Failure settlement was already processed"); + } + Err(error) => { + actor.bus.err(EType::Evm, error); + ctx.notify_later(ProcessFailedE3 { e3_id }, FAILURE_RETRY_DELAY); } } - } - }) + }), + ) } } @@ -359,15 +439,13 @@ impl Handler; fn handle(&mut self, msg: ResolveFailureDeadline, _ctx: &mut Self::Context) -> Self::Result { - if !self.effects_enabled - || self.failure_stages.get(&msg.e3_id) != Some(&msg.stage) - || !self.committee_party_ids.contains_key(&msg.e3_id) - { + if !self.effects_enabled || self.failure_stages.get(&msg.e3_id) != Some(&msg.stage) { return Box::pin(async {}.into_actor(self)); } let provider = self.provider.clone(); let contract_address = self.contract_address; + let request_registry = self.request_registries.get(&msg.e3_id).copied(); let request = msg.clone(); Box::pin( async move { @@ -376,19 +454,20 @@ impl Handler 0 => { - actor.arm_failure_timer(request.e3_id, request.stage, deadline, ctx); + Ok(schedule) if schedule.deadline > 0 => { + actor.arm_failure_timer(request.e3_id, request.stage, schedule, ctx); } Ok(_) => { actor.bus.err( EType::Evm, - anyhow::anyhow!("canonical aggregation deadline is zero"), + anyhow::anyhow!("canonical failure deadline is zero"), ); ctx.notify_later(request, FAILURE_RETRY_DELAY); } @@ -407,29 +486,35 @@ impl Handler; fn handle(&mut self, msg: DiscoverFailureStage, _ctx: &mut Self::Context) -> Self::Result { - if !self.effects_enabled || !self.committee_party_ids.contains_key(&msg.e3_id) { + if !self.effects_enabled { return Box::pin(async {}.into_actor(self)); } let provider = self.provider.clone(); let contract_address = self.contract_address; let e3_id = msg.e3_id; + let generation = self.failure_stage_discoveries.start(e3_id.clone()); Box::pin( async move { let result = - read_aggregation_failure_stage(provider, contract_address, e3_id.clone()).await; - (e3_id, result) + read_watched_failure_stage(provider, contract_address, e3_id.clone()).await; + (e3_id, generation, result) } .into_actor(self) - .map(|(e3_id, result), actor, ctx| match result { - Ok(Some(stage)) => { - actor.failure_stages.insert(e3_id.clone(), stage); - actor.try_start_failure_watch(&e3_id, ctx); + .map(|(e3_id, generation, result), actor, ctx| { + if !actor.failure_stage_discoveries.complete(&e3_id, generation) { + return; } - Ok(None) => actor.clear_failure_watch(&e3_id, ctx), - Err(error) => { - actor.bus.err(EType::Evm, error); - ctx.notify_later(DiscoverFailureStage { e3_id }, FAILURE_RETRY_DELAY); + match result { + Ok(Some(stage)) => { + actor.failure_stages.insert(e3_id.clone(), stage); + actor.try_start_failure_watch(&e3_id, ctx); + } + Ok(None) => actor.clear_failure_watch(&e3_id, ctx), + Err(error) => { + actor.bus.err(EType::Evm, error); + ctx.notify_later(DiscoverFailureStage { e3_id }, FAILURE_RETRY_DELAY); + } } }), ) diff --git a/crates/evm/src/interfold_writing/workflow.rs b/crates/evm/src/interfold_writing/workflow.rs index cb8899710d..836bc26640 100644 --- a/crates/evm/src/interfold_writing/workflow.rs +++ b/crates/evm/src/interfold_writing/workflow.rs @@ -10,8 +10,9 @@ //! invariants hold; rejecting a malformed result is safer than a partial //! on-chain write. -use e3_events::{E3id, Proof}; +use e3_events::{E3Stage, E3id, Proof}; use e3_utils::utility_types::ArcBytes; +use std::collections::HashMap; use std::time::Duration; #[cfg(test)] @@ -61,14 +62,65 @@ pub(crate) fn validate_plaintext_output( pub(crate) fn failure_watch_delay( now_unix_secs: u64, deadline_unix_secs: u64, - party_id: u64, + party_id: Option, + permissionless_grace_secs: u64, party_stagger_secs: u64, ) -> Duration { - let deadline_wait = deadline_unix_secs - .saturating_sub(now_unix_secs) - .saturating_add(1); - let stagger = party_id.saturating_mul(party_stagger_secs); - Duration::from_secs(deadline_wait.saturating_add(stagger)) + let delay = match party_id { + Some(party_id) => deadline_unix_secs + .saturating_sub(now_unix_secs) + .saturating_add(1) + .saturating_add(party_id.saturating_mul(party_stagger_secs)), + None => { + let permissionless_at = if permissionless_grace_secs == 0 { + deadline_unix_secs.saturating_add(1) + } else { + deadline_unix_secs.saturating_add(permissionless_grace_secs) + }; + permissionless_at.saturating_sub(now_unix_secs) + } + }; + Duration::from_secs(delay) +} + +/// Return the finalized committee party that can act during the protected grace window. +/// +/// A `Requested` E3 has only provisional ticket candidates. The registry does not consider those +/// candidates active committee members, so they must wait for permissionless failure marking. +pub(crate) fn failure_watch_party_id(stage: &E3Stage, party_id: Option) -> Option { + if *stage == E3Stage::Requested { + None + } else { + party_id + } +} + +/// Reject stage-discovery results that a newer lifecycle event has superseded. +#[derive(Debug, Default)] +pub(crate) struct FailureStageDiscoveryGate { + next_generation: u64, + active: HashMap, +} + +impl FailureStageDiscoveryGate { + pub(crate) fn start(&mut self, e3_id: E3id) -> u64 { + self.next_generation = self.next_generation.wrapping_add(1); + let generation = self.next_generation; + self.active.insert(e3_id, generation); + generation + } + + pub(crate) fn invalidate(&mut self, e3_id: &E3id) { + self.active.remove(e3_id); + } + + pub(crate) fn complete(&mut self, e3_id: &E3id, generation: u64) -> bool { + if self.active.get(e3_id) != Some(&generation) { + return false; + } + self.active.remove(e3_id); + true + } } #[cfg(test)] @@ -124,9 +176,41 @@ mod tests { #[test] fn failure_watch_stagger_survives_restart() { - assert_eq!(failure_watch_delay(100, 160, 0, 15).as_secs(), 61); - assert_eq!(failure_watch_delay(100, 160, 2, 15).as_secs(), 91); - assert_eq!(failure_watch_delay(200, 160, 0, 15).as_secs(), 1); - assert_eq!(failure_watch_delay(200, 160, 2, 15).as_secs(), 31); + assert_eq!(failure_watch_delay(100, 160, Some(0), 90, 15).as_secs(), 61); + assert_eq!(failure_watch_delay(100, 160, Some(2), 90, 15).as_secs(), 91); + assert_eq!(failure_watch_delay(200, 160, Some(0), 90, 15).as_secs(), 1); + assert_eq!(failure_watch_delay(200, 160, Some(2), 90, 15).as_secs(), 31); + } + + #[test] + fn requested_stage_failure_without_party_waits_for_permissionless_grace() { + assert_eq!(failure_watch_delay(100, 160, None, 90, 15).as_secs(), 150); + assert_eq!(failure_watch_delay(200, 160, None, 90, 15).as_secs(), 50); + assert_eq!(failure_watch_delay(250, 160, None, 90, 15).as_secs(), 0); + assert_eq!(failure_watch_delay(160, 160, None, 0, 15).as_secs(), 1); + } + + #[test] + fn requested_stage_ignores_provisional_party_id() { + assert_eq!(failure_watch_party_id(&E3Stage::Requested, Some(2)), None); + assert_eq!( + failure_watch_party_id(&E3Stage::CommitteeFinalized, Some(2)), + Some(2) + ); + } + + #[test] + fn stage_discovery_ignores_superseded_results() { + let e3_id = E3id::new("7", 1); + let mut gate = FailureStageDiscoveryGate::default(); + + let first = gate.start(e3_id.clone()); + let second = gate.start(e3_id.clone()); + assert!(!gate.complete(&e3_id, first)); + assert!(gate.complete(&e3_id, second)); + + let invalidated = gate.start(e3_id.clone()); + gate.invalidate(&e3_id); + assert!(!gate.complete(&e3_id, invalidated)); } } diff --git a/crates/evm/src/publication_writing/workflow.rs b/crates/evm/src/publication_writing/workflow.rs index 2121e783db..82f416040b 100644 --- a/crates/evm/src/publication_writing/workflow.rs +++ b/crates/evm/src/publication_writing/workflow.rs @@ -92,7 +92,7 @@ mod tests { } #[test] - fn duplicate_replay_is_coalesced() { + fn duplicate_intent_cannot_start_while_submission_is_in_flight() { let mut gate = ReplaySubmissionGate::new(); gate.record(7, "first"); gate.record(7, "duplicate"); diff --git a/crates/evm/src/repo.rs b/crates/evm/src/repo.rs index 38ed9f076b..b66602603d 100644 --- a/crates/evm/src/repo.rs +++ b/crates/evm/src/repo.rs @@ -7,7 +7,7 @@ use e3_data::{Repositories, Repository}; use e3_events::StoreKeys; -use crate::{EvmReadInterfaceState, SlashingWriterRecoveryState}; +use crate::{DataAvailabilityRecoveryState, EvmReadInterfaceState, SlashingWriterRecoveryState}; pub trait EthPrivateKeyRepositoryFactory { fn eth_private_key(&self) -> Repository>; @@ -67,3 +67,22 @@ impl SlashingWriterRepositoryFactory for Repositories { ) } } + +pub trait DataAvailabilityRepositoryFactory { + fn data_availability_recovery( + &self, + chain_id: u64, + ) -> Repository; +} + +impl DataAvailabilityRepositoryFactory for Repositories { + fn data_availability_recovery( + &self, + chain_id: u64, + ) -> Repository { + Repository::new( + self.store + .scope(StoreKeys::data_availability_recovery(chain_id)), + ) + } +} diff --git a/crates/indexer/src/indexer.rs b/crates/indexer/src/indexer.rs index a0952ecdc7..74e2c077f5 100644 --- a/crates/indexer/src/indexer.rs +++ b/crates/indexer/src/indexer.rs @@ -4,12 +4,15 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use super::{models::E3, DataStore}; +use super::{ + models::{CiphertextOutputReference, E3}, + DataStore, +}; use crate::callback_queue::CallbackQueue; use crate::E3Repository; use alloy::consensus::BlockHeader; use alloy::hex; -use alloy::primitives::{keccak256, Uint}; +use alloy::primitives::{keccak256, Address, Uint}; use alloy::providers::Provider; use alloy::sol_types::{SolEvent, SolValue}; use async_trait::async_trait; @@ -21,7 +24,10 @@ use e3_evm_helpers::{ ReadWrite, }, event_listener::{EventListener, LiveProgress, NOT_PROCESSING}, - events::{CiphertextOutputPublished, CommitteePublished, PlaintextOutputPublished}, + events::{ + CiphertextOutputPublished, CiphertextOutputReferencePublished, + CommitteePublicKeyChunkPublished, CommitteePublished, PlaintextOutputPublished, + }, }; use e3_fhe_params::{decode_bfv_params, encode_bfv_params, BfvParamSet, BfvPreset}; use eyre::eyre; @@ -36,6 +42,95 @@ use tracing::{error, info, warn}; type E3Id = String; +const PUBLIC_KEY_CHUNK_BYTES: usize = 90 * 1024; +const MAX_PUBLIC_KEY_BYTES: usize = 512 * 1024; + +#[derive(Clone, Debug, Serialize, serde::Deserialize)] +struct PublicKeyChunkAssembly { + nodes: Vec

, + pk_commitment: [u8; 32], + total_length: u32, + chunk_count: u16, + chunks: Vec>>, + invalid: bool, + stored: bool, +} + +impl PublicKeyChunkAssembly { + fn event_shape_is_valid(event: &CommitteePublicKeyChunkPublished) -> bool { + let total_length = event.totalLength as usize; + if total_length == 0 || total_length > MAX_PUBLIC_KEY_BYTES { + return false; + } + let expected_count = total_length.div_ceil(PUBLIC_KEY_CHUNK_BYTES); + if expected_count != usize::from(event.chunkCount) + || usize::from(event.chunkIndex) >= expected_count + { + return false; + } + let offset = usize::from(event.chunkIndex) * PUBLIC_KEY_CHUNK_BYTES; + let expected_length = (total_length - offset).min(PUBLIC_KEY_CHUNK_BYTES); + event.chunk.len() == expected_length + } + + fn from_event(event: &CommitteePublicKeyChunkPublished) -> Self { + Self { + nodes: event.nodes.clone(), + pk_commitment: event.pkCommitment.0, + total_length: event.totalLength, + chunk_count: event.chunkCount, + chunks: vec![None; usize::from(event.chunkCount)], + invalid: false, + stored: false, + } + } + + fn accepts(&self, event: &CommitteePublicKeyChunkPublished) -> bool { + self.nodes == event.nodes + && self.pk_commitment == event.pkCommitment.0 + && self.total_length == event.totalLength + && self.chunk_count == event.chunkCount + } + + fn insert(&mut self, event: &CommitteePublicKeyChunkPublished) { + if self.invalid || self.stored || !self.accepts(event) { + self.invalid |= !self.accepts(event); + return; + } + let expected_count = (self.total_length as usize).div_ceil(PUBLIC_KEY_CHUNK_BYTES); + let offset = usize::from(event.chunkIndex) * PUBLIC_KEY_CHUNK_BYTES; + let expected_length = (self.total_length as usize) + .saturating_sub(offset) + .min(PUBLIC_KEY_CHUNK_BYTES); + if expected_count != usize::from(self.chunk_count) || event.chunk.len() != expected_length { + self.invalid = true; + return; + } + let Some(slot) = self.chunks.get_mut(usize::from(event.chunkIndex)) else { + self.invalid = true; + return; + }; + if let Some(existing) = slot { + if existing.as_slice() != event.chunk.as_ref() { + self.invalid = true; + } + } else { + *slot = Some(event.chunk.to_vec()); + } + } + + fn bytes(&self) -> Option> { + if self.invalid || self.stored { + return None; + } + let mut bytes = Vec::with_capacity(self.total_length as usize); + for chunk in &self.chunks { + bytes.extend_from_slice(chunk.as_ref()?); + } + (bytes.len() == self.total_length as usize).then_some(bytes) + } +} + #[derive(Error, Debug)] pub enum IndexerError { #[error("E3 not found: {0}")] @@ -327,6 +422,123 @@ impl InterfoldIndexer { } } +async fn store_committee_public_key( + event: CommitteePublished, + ctx: Arc>, + ignore_invalid_candidate: bool, +) -> Result { + let contract = ctx.contract(); + let db = ctx.store(); + let interfold_address = ctx.interfold_address(); + let e3_id = event.e3Id.to_string(); + + info!( + "CommitteePublished: id={}, public_key_len={}, proof_len={}", + event.e3Id, + event.publicKey.len(), + event.proof.len() + ); + + let e3 = contract.get_e3(event.e3Id).await?; + let params_preset = BfvPreset::from_on_chain_param_set(e3.paramSet).ok_or_else(|| { + eyre!( + "unsupported BFV parameter set {} for E3 {e3_id}", + e3.paramSet + ) + })?; + let e3_params = encode_bfv_params(&BfvParamSet::from(params_preset).build_arc()); + let crypto_config_id = keccak256( + ( + keccak256(b"fhe.rs:BFV"), + keccak256(&e3_params), + keccak256(b"interfold-bfv-v1"), + ) + .abi_encode(), + ); + let request_crypto_config_id = contract.get_e3_crypto_config_id(event.e3Id).await?; + if request_crypto_config_id != crypto_config_id { + return Err(eyre!( + "local circuit configuration does not match request-time config for E3 {e3_id}" + )); + } + if e3.encryptionSchemeId == keccak256("fhe.rs:BFV") { + let decoded_params = decode_bfv_params(&e3_params) + .map_err(|error| eyre!("invalid BFV parameters for E3 {e3_id}: {error}"))?; + if let Err(error) = validate_pk_commitment( + &event.publicKey, + event.pkCommitment.0, + decoded_params.degree(), + decoded_params.plaintext(), + decoded_params.moduli().to_vec(), + ) { + if ignore_invalid_candidate { + warn!("Ignoring unbound committee public-key candidate for E3 {e3_id}: {error}"); + return Ok(false); + } + return Err(eyre!( + "rejecting unbound CommitteePublished public key for E3 {e3_id}: {error}" + )); + } + } + let seed = e3.seed.to_be_bytes(); + let request_block = u64_try_from(e3.requestBlock)?; + let input_window = [ + u64_try_from(e3.inputWindow[0])?, + u64_try_from(e3.inputWindow[1])?, + ]; + + let e3_obj = E3 { + chain_id: ctx.chain_id(), + ciphertext_inputs: vec![], + ciphertext_output: vec![], + ciphertext_output_reference: None, + ciphertext_commitment: vec![], + committee_public_key: event.publicKey.to_vec(), + committee_public_key_hash: event.pkCommitment.to_vec(), + custom_params: e3.customParams.to_vec(), + e3_params: e3_params.to_vec(), + interfold_address, + encryption_scheme_id: e3.encryptionSchemeId.to_vec(), + crypto_config_id: crypto_config_id.to_vec(), + id: e3_id.clone(), + plaintext_output: vec![], + request_block, + seed, + input_window, + committee_size: e3.committeeSize, + requester: e3.requester.to_string(), + }; + + let mut repo = E3Repository::new(db, &e3_id); + if repo.set_e3_if_absent(e3_obj).await? { + info!("E3 {} created and stored", e3_id); + } else { + info!("E3 {} already has a verified committee key", e3_id); + } + Ok(true) +} + +async fn mark_public_key_assembly( + store: &mut SharedStore, + key: &str, + stored: bool, +) -> Result<()> { + store + .modify(key, move |current: Option| { + current.map(|mut assembly| { + assembly.stored = stored; + assembly.invalid = !stored; + // The verified E3 record now owns the complete key. Keep only the small + // completion marker so duplicate or replayed chunks remain idempotent. + assembly.chunks.clear(); + assembly + }) + }) + .await + .map_err(|error| eyre!("updating public-key chunk assembly failed: {error}"))?; + Ok(()) +} + impl InterfoldIndexer { pub async fn new( mut event_listener: EventListener, @@ -431,94 +643,92 @@ impl InterfoldIndexer { async fn register_committee_published(&mut self) -> Result<()> { self.add_event_handler(move |e: CommitteePublished, ctx| async move { - let contract = ctx.contract(); - let db = ctx.store(); - let interfold_address = ctx.interfold_address(); - let e3_id = e.e3Id.to_string(); - - info!( - "CommitteePublished: id={}, public_key_len={}, proof_len={}", - e.e3Id, - e.publicKey.len(), - e.proof.len() - ); - - let e3 = contract.get_e3(e.e3Id).await?; - let params_preset = - BfvPreset::from_on_chain_param_set(e3.paramSet).ok_or_else(|| { - eyre!( - "unsupported BFV parameter set {} for E3 {e3_id}", - e3.paramSet - ) - })?; - let e3_params = encode_bfv_params(&BfvParamSet::from(params_preset).build_arc()); - let crypto_config_id = keccak256( - ( - keccak256(b"fhe.rs:BFV"), - keccak256(&e3_params), - keccak256(b"interfold-bfv-v1"), - ) - .abi_encode(), - ); - let request_crypto_config_id = contract.get_e3_crypto_config_id(e.e3Id).await?; - if request_crypto_config_id != crypto_config_id { - return Err(eyre!( - "local circuit configuration does not match request-time config for E3 {e3_id}" - )); - } - if e3.encryptionSchemeId == keccak256("fhe.rs:BFV") { - let decoded_params = decode_bfv_params(&e3_params) - .map_err(|error| eyre!("invalid BFV parameters for E3 {e3_id}: {error}"))?; - validate_pk_commitment( - &e.publicKey, - e.pkCommitment.0, - decoded_params.degree(), - decoded_params.plaintext(), - decoded_params.moduli().to_vec(), - ) - .map_err(|error| { - eyre!("rejecting unbound CommitteePublished public key for E3 {e3_id}: {error}") - })?; - } - let seed = e3.seed.to_be_bytes(); - let request_block = u64_try_from(e3.requestBlock)?; - let input_window = [ - u64_try_from(e3.inputWindow[0])?, - u64_try_from(e3.inputWindow[1])?, - ]; - - let e3_obj = E3 { - chain_id: ctx.chain_id(), - ciphertext_inputs: vec![], - ciphertext_output: vec![], - ciphertext_commitment: vec![], - committee_public_key: e.publicKey.to_vec(), - committee_public_key_hash: e.pkCommitment.to_vec(), - custom_params: e3.customParams.to_vec(), - e3_params: e3_params.to_vec(), - interfold_address, - encryption_scheme_id: e3.encryptionSchemeId.to_vec(), - crypto_config_id: crypto_config_id.to_vec(), - id: e3_id.clone(), - plaintext_output: vec![], - request_block, - seed, - input_window, - committee_size: e3.committeeSize, - requester: e3.requester.to_string(), - }; - - let mut repo = E3Repository::new(db, &e3_id); - repo.set_e3(e3_obj).await?; - - info!("E3 {} created and stored", e3_id); - + // A bad legacy candidate must not hold the indexer cursor. Every candidate still passes + // the same semantic DKG commitment check before storage. + store_committee_public_key(e, ctx, true).await?; Ok(()) }) .await; Ok(()) } + async fn register_committee_public_key_chunks(&mut self) -> Result<()> { + self.add_event_handler( + move |event: CommitteePublicKeyChunkPublished, ctx| async move { + if !PublicKeyChunkAssembly::event_shape_is_valid(&event) { + warn!( + "Ignoring malformed public-key chunk from {} for E3 {}", + event.publisher, event.e3Id + ); + return Ok(()); + } + let publisher_key = format!( + "_pk_candidate:{}:{}", + event.e3Id, event.publisher + ); + let candidate_hash = event.candidateHash.0; + let mut store = ctx.store(); + let selected = store + .modify(&publisher_key, move |current: Option<[u8; 32]>| { + Some(current.unwrap_or(candidate_hash)) + }) + .await + .map_err(|error| eyre!("saving public-key candidate failed: {error}"))? + .ok_or_else(|| eyre!("public-key candidate selection disappeared"))?; + if selected != event.candidateHash.0 { + warn!( + "Ignoring a second public-key candidate from {} for E3 {}", + event.publisher, event.e3Id + ); + return Ok(()); + } + let assembly_key = format!( + "_pk_chunk:{}:{}:{:#x}", + event.e3Id, event.publisher, event.candidateHash + ); + let event_for_update = event.clone(); + let assembly = store + .modify(&assembly_key, move |current: Option| { + let mut current = current + .unwrap_or_else(|| PublicKeyChunkAssembly::from_event(&event_for_update)); + current.insert(&event_for_update); + Some(current) + }) + .await + .map_err(|error| eyre!("saving public-key chunk failed: {error}"))? + .ok_or_else(|| eyre!("public-key chunk assembly disappeared"))?; + + if assembly.invalid || assembly.stored { + return Ok(()); + } + let Some(public_key) = assembly.bytes() else { + return Ok(()); + }; + if keccak256(&public_key) != event.candidateHash { + warn!( + "Ignoring chunked committee public key with a mismatched content hash for E3 {}", + event.e3Id + ); + mark_public_key_assembly(&mut store, &assembly_key, false).await?; + return Ok(()); + } + + let synthetic = CommitteePublished { + e3Id: event.e3Id, + nodes: assembly.nodes, + publicKey: public_key.into(), + pkCommitment: assembly.pk_commitment.into(), + proof: Vec::new().into(), + }; + let stored = store_committee_public_key(synthetic, ctx, true).await?; + mark_public_key_assembly(&mut store, &assembly_key, stored).await?; + Ok(()) + }, + ) + .await; + Ok(()) + } + async fn register_ciphertext_output_published(&mut self) -> Result<()> { self.add_event_handler(move |e: CiphertextOutputPublished, ctx| async move { let store = ctx.store(); @@ -541,6 +751,29 @@ impl InterfoldIndexer { Ok(()) } + async fn register_ciphertext_output_reference_published(&mut self) -> Result<()> { + self.add_event_handler( + move |event: CiphertextOutputReferencePublished, ctx| async move { + info!( + "CiphertextOutputReferencePublished: e3_id={}, block={}, leaf_index={}", + event.e3Id, event.availabilityBlock, event.availabilityLeafIndex + ); + let mut repo = E3Repository::new(ctx.store(), event.e3Id.to_string()); + repo.set_ciphertext_output_reference( + CiphertextOutputReference { + content_hash: event.contentHash.to_vec(), + availability_block: event.availabilityBlock, + availability_leaf_index: event.availabilityLeafIndex, + }, + event.ciphertextCommitment.to_vec(), + ) + .await + }, + ) + .await; + Ok(()) + } + async fn register_plaintext_output_published(&mut self) -> Result<()> { self.add_event_handler(move |e: PlaintextOutputPublished, ctx| async move { let store = ctx.store(); @@ -630,7 +863,10 @@ impl InterfoldIndexer { async fn setup_listeners(&mut self) -> Result<()> { info!("Setting up listeners for InterfoldIndexer..."); self.register_committee_published().await?; + self.register_committee_public_key_chunks().await?; self.register_ciphertext_output_published().await?; + self.register_ciphertext_output_reference_published() + .await?; self.register_plaintext_output_published().await?; self.register_blocktime_callback_handler().await?; info!("Listeners have been setup!"); @@ -889,6 +1125,15 @@ impl InterfoldIndexer { pub fn get_store(&self) -> SharedStore { self.ctx.store.clone() } + + /// Schedule a timestamp callback without requiring an event-handler context. + pub fn schedule_at(&self, timestamp: u64, callback: F) + where + F: Fn(u64, Arc>) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.ctx.do_later(timestamp, callback); + } } pub async fn get_e3( @@ -912,3 +1157,42 @@ pub async fn get_e3( fn u64_try_from(input: Uint<256, 4>) -> Result { u64::try_from(input).map_err(|_| eyre!("larger than 64-bit")) } + +#[cfg(test)] +mod public_key_chunk_tests { + use super::{ + mark_public_key_assembly, DataStore, InMemoryStore, PublicKeyChunkAssembly, SharedStore, + }; + use alloy::primitives::Address; + use std::sync::Arc; + use tokio::sync::RwLock; + + #[tokio::test] + async fn completed_assembly_drops_temporary_chunk_bytes() { + let mut store = SharedStore::new(Arc::new(RwLock::new(InMemoryStore::new()))); + let key = "_pk_chunk:1:publisher:candidate"; + let assembly = PublicKeyChunkAssembly { + nodes: vec![Address::ZERO], + pk_commitment: [1; 32], + total_length: 4, + chunk_count: 2, + chunks: vec![Some(vec![2; 2]), Some(vec![3; 2])], + invalid: false, + stored: false, + }; + store.insert(key, &assembly).await.unwrap(); + + mark_public_key_assembly(&mut store, key, true) + .await + .unwrap(); + + let completed = store + .get::(key) + .await + .unwrap() + .unwrap(); + assert!(completed.stored); + assert!(!completed.invalid); + assert!(completed.chunks.is_empty()); + } +} diff --git a/crates/indexer/src/models.rs b/crates/indexer/src/models.rs index 313aa213fd..0e2cc4e58d 100644 --- a/crates/indexer/src/models.rs +++ b/crates/indexer/src/models.rs @@ -7,14 +7,23 @@ use e3_evm_helpers::contracts::CommitteeSize; use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct CiphertextOutputReference { + pub content_hash: Vec, + pub availability_block: u32, + pub availability_leaf_index: u128, +} + // This correlates with the information from the contract // with an addition of a chain_id -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct E3 { pub chain_id: u64, pub ciphertext_inputs: Vec<(Vec, u64)>, pub ciphertext_output: Vec, #[serde(default)] + pub ciphertext_output_reference: Option, + #[serde(default)] pub ciphertext_commitment: Vec, pub committee_public_key: Vec, pub committee_public_key_hash: Vec, diff --git a/crates/indexer/src/repo.rs b/crates/indexer/src/repo.rs index 890d967caf..4edc6389d0 100644 --- a/crates/indexer/src/repo.rs +++ b/crates/indexer/src/repo.rs @@ -4,8 +4,15 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use super::{models::E3, DataStore, SharedStore}; +use super::{ + models::{CiphertextOutputReference, E3}, + DataStore, SharedStore, +}; use eyre::Result; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; pub struct E3Repository { store: SharedStore, @@ -29,6 +36,24 @@ impl E3Repository { Ok(()) } + /// Store the initial E3 record without replacing indexed round data. + pub async fn set_e3_if_absent(&mut self, value: E3) -> Result { + let key = self.e3_key(); + let inserted = Arc::new(AtomicBool::new(false)); + let inserted_in_update = Arc::clone(&inserted); + self.store + .modify(&key, move |current: Option| match current { + Some(current) => Some(current), + None => { + inserted_in_update.store(true, Ordering::Relaxed); + Some(value.clone()) + } + }) + .await + .map_err(|e| eyre::eyre!("Could not store E3 at '{key}' due to error: {e}"))?; + Ok(inserted.load(Ordering::Relaxed)) + } + pub async fn get_e3(&self) -> Result { let key = self.e3_key(); let e3_crisp = self @@ -95,8 +120,94 @@ impl E3Repository { Ok(()) } + pub async fn set_ciphertext_output_reference( + &mut self, + reference: CiphertextOutputReference, + commitment: Vec, + ) -> Result<()> { + let key = self.e3_key(); + self.store + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + e.ciphertext_output_reference = Some(reference.clone()); + e.ciphertext_commitment = commitment.clone(); + e + }) + }) + .await + .map_err(|_| eyre::eyre!("Could not set ciphertext output reference for '{key}'"))?; + Ok(()) + } + fn e3_key(&self) -> String { let e3_id = &self.e3_id; format!("_e3:{e3_id}") } } + +#[cfg(test)] +mod tests { + use super::E3Repository; + use crate::{models::E3, InMemoryStore, SharedStore}; + use e3_evm_helpers::contracts::CommitteeSize; + use std::sync::Arc; + use tokio::sync::RwLock; + + fn e3(public_key: u8, ciphertext_output: Vec) -> E3 { + E3 { + chain_id: 1, + ciphertext_inputs: vec![(vec![3], 0)], + ciphertext_output, + ciphertext_output_reference: None, + ciphertext_commitment: vec![4], + committee_public_key: vec![public_key], + committee_public_key_hash: vec![public_key; 32], + e3_params: vec![5], + custom_params: vec![6], + interfold_address: "0x0000000000000000000000000000000000000001".to_string(), + encryption_scheme_id: vec![7; 32], + crypto_config_id: vec![8; 32], + id: "12".to_string(), + plaintext_output: vec![9], + request_block: 10, + seed: [11; 32], + input_window: [12, 13], + committee_size: CommitteeSize::Minimum, + requester: "0x0000000000000000000000000000000000000002".to_string(), + } + } + + #[tokio::test] + async fn committee_replay_does_not_replace_indexed_round_state() { + let store = SharedStore::new(Arc::new(RwLock::new(InMemoryStore::new()))); + let mut repo = E3Repository::new(store, "12"); + + assert!(repo.set_e3_if_absent(e3(1, vec![2])).await.unwrap()); + assert!(!repo.set_e3_if_absent(e3(99, vec![])).await.unwrap()); + + let stored = repo.get_e3().await.unwrap(); + assert_eq!(stored.committee_public_key, vec![1]); + assert_eq!(stored.ciphertext_output, vec![2]); + assert_eq!(stored.plaintext_output, vec![9]); + } + + #[tokio::test] + async fn output_reference_and_commitment_are_stored_together() { + let store = SharedStore::new(Arc::new(RwLock::new(InMemoryStore::new()))); + let mut repo = E3Repository::new(store, "12"); + repo.set_e3(e3(1, vec![])).await.unwrap(); + + let reference = crate::models::CiphertextOutputReference { + content_hash: vec![7; 32], + availability_block: 42, + availability_leaf_index: 9, + }; + repo.set_ciphertext_output_reference(reference.clone(), vec![8; 32]) + .await + .unwrap(); + + let stored = repo.get_e3().await.unwrap(); + assert_eq!(stored.ciphertext_output_reference, Some(reference)); + assert_eq!(stored.ciphertext_commitment, vec![8; 32]); + } +} diff --git a/crates/keyshare/src/threshold_keyshare/effects/recovery.rs b/crates/keyshare/src/threshold_keyshare/effects/recovery.rs index b82a5b01aa..aaa3b38714 100644 --- a/crates/keyshare/src/threshold_keyshare/effects/recovery.rs +++ b/crates/keyshare/src/threshold_keyshare/effects/recovery.rs @@ -6,6 +6,20 @@ use super::*; use anyhow::ensure; impl ThresholdKeyshare { + pub(in crate::actors::threshold_keyshare) fn public_key_context_is_recovered( + state: &ThresholdKeyshareState, + ) -> bool { + state.aggregated_pk.is_some() && state.decryption_domain.is_some() + } + + pub(in crate::actors::threshold_keyshare) fn needs_keyshare_republication( + state: &ThresholdKeyshareState, + recovery: &ThresholdKeyshareRecoveryState, + ) -> bool { + !Self::public_key_context_is_recovered(state) + && (recovery.keyshare_publish_authorized || state.keyshare_published) + } + pub(in crate::actors::threshold_keyshare) fn record_encryption_key( &mut self, event: &TypedEvent, @@ -215,6 +229,12 @@ impl ThresholdKeyshare { } } KeyshareState::ReadyForDecryption(_) => { + // PublicKeyAggregated is a newer durable fact than the retained C2/C3/C4 + // recovery inputs below. Replaying those superseded jobs would rebuild the + // complete DKG proof pipeline before this node can decrypt. + if Self::public_key_context_is_recovered(&state) { + return Ok(()); + } if let Some(pending) = recovery.threshold_share_pending.clone() { let (pending, pending_ec) = pending.into_components(); self.bus.publish(pending, pending_ec)?; @@ -225,14 +245,14 @@ impl ThresholdKeyshare { } if let Some(verification) = recovery.decryption_verification_complete.clone() { self.handle_share_verification_complete(verification) - } else if recovery.keyshare_publish_authorized || state.keyshare_published { + } else if Self::needs_keyshare_republication(&state, &recovery) { self.publish_keyshare_created(ec) } else { self.replay_decryption_key_shares(&recovery, self_addr) } } KeyshareState::Decrypting(_) => { - if recovery.keyshare_publish_authorized || state.keyshare_published { + if Self::needs_keyshare_republication(&state, &recovery) { self.publish_keyshare_created(ec.clone())?; } self.issue_decryption_share_request(ec) diff --git a/crates/keyshare/src/threshold_keyshare/tests.rs b/crates/keyshare/src/threshold_keyshare/tests.rs index d93915cd3b..2bf5550afe 100644 --- a/crates/keyshare/src/threshold_keyshare/tests.rs +++ b/crates/keyshare/src/threshold_keyshare/tests.rs @@ -13,9 +13,9 @@ use e3_crypto::Cipher; use e3_data::{AutoPersist, DataStore, InMemStore, Persistable, Repository}; use e3_events::{ hlc_factory::HlcFactory, BusHandle, ComputeRequestKind, E3Stage, E3id, EffectsEnabled, - EventBus, EventBusConfig, EventSource, FailureReason, HistoryCollector, InterfoldEvent, - InterfoldEventData, Sequencer, StoreEventRequested, StoreEventResponse, TakeEvents, - Unsequenced, + EventBus, EventBusConfig, EventSource, FailureReason, GetEvents, HistoryCollector, + InterfoldEvent, InterfoldEventData, Sequencer, StoreEventRequested, StoreEventResponse, + TakeEvents, Unsequenced, }; use e3_fhe_params::DEFAULT_BFV_PRESET; use std::sync::Arc; @@ -303,3 +303,61 @@ async fn restart_redrives_a_decryption_share_compute_request() -> Result<()> { Ok(()) } + +#[actix::test] +async fn restart_skips_dkg_work_after_public_key_context_is_persisted() -> Result<()> { + let e3_id = E3id::new("42", 1); + let ready = ReadyForDecryption { + pk_share: ArcBytes::from_bytes(&[1]), + sk_poly_sum: SensitiveBytes::from_encrypted(&[2]), + es_poly_sum: vec![SensitiveBytes::from_encrypted(&[3])], + signed_pk_generation_proof: None, + signed_sk_share_computation_proof: None, + signed_e_sm_share_computation_proof: None, + signed_sk_share_encryption_proofs: Vec::new(), + signed_e_sm_share_encryption_proofs: Vec::new(), + }; + let (bus, history) = test_bus(); + let (mut state, _) = test_state(&e3_id, KeyshareState::ReadyForDecryption(ready)); + state.try_mutate_without_context(|mut state| { + state.keyshare_published = true; + state.aggregated_pk = Some(ArcBytes::from_bytes(&[4])); + state.decryption_domain = Some(e3_committee_hash::DecryptionDomainContext { + interfold_address: Address::ZERO, + committee_hash: [5; 32].into(), + committee_public_key: [6; 32].into(), + }); + Ok(state) + })?; + let recovery_store = InMemStore::new(false).start(); + let recovery_repo = + Repository::::new(DataStore::from_in_mem(&recovery_store)); + let recovery = recovery_repo.send(Some(ThresholdKeyshareRecoveryState { + keyshare_publish_authorized: true, + ..Default::default() + })); + let actor = ThresholdKeyshare::new(ThresholdKeyshareParams { + bus, + cipher: Arc::new(Cipher::from_password("test-password").await?), + state, + share_enc_preset: DEFAULT_BFV_PRESET, + interfold_address: Address::ZERO, + recovery, + }) + .start(); + let effects_enabled = InterfoldEvent::::new_with_timestamp( + EffectsEnabled::new().into(), + None, + 1, + None, + EventSource::Local, + ) + .into_sequenced(1); + + actor.send(effects_enabled).await?; + actix::clock::sleep(std::time::Duration::from_millis(25)).await; + + let events = history.send(GetEvents::::new()).await?; + assert!(events.is_empty(), "restart replayed superseded DKG work"); + Ok(()) +} diff --git a/crates/multithread/src/effect_gate.rs b/crates/multithread/src/effect_gate.rs index c6b5b1eabc..62b55a2294 100644 --- a/crates/multithread/src/effect_gate.rs +++ b/crates/multithread/src/effect_gate.rs @@ -9,7 +9,7 @@ use actix::{Actor, Context, Handler, Recipient}; use e3_events::{ ComputeRequestKind, E3Stage, E3id, Event, EventContextAccessors, EventSubscriber, EventType, - InterfoldEvent, InterfoldEventData, + InterfoldEvent, InterfoldEventData, ProofType, VerifyShareProofsRequest, ZkRequest, }; use e3_utils::MAILBOX_LIMIT; use std::collections::{HashMap, HashSet}; @@ -36,18 +36,109 @@ pub(crate) struct ComputeEffectGate { enabled: bool, pending: HashMap, forwarded: HashSet, + stages: HashMap, } impl ComputeEffectGate { - fn new(target: Recipient) -> Self { + fn new(target: Recipient, initial_stages: HashMap) -> Self { Self { target, enabled: false, pending: HashMap::new(), forwarded: HashSet::new(), + stages: initial_stages, } } + fn stage_rank(stage: &E3Stage) -> u8 { + match stage { + E3Stage::None => 0, + E3Stage::Requested => 1, + E3Stage::CommitteeFinalized => 2, + E3Stage::KeyPublished => 3, + E3Stage::CiphertextReady => 4, + E3Stage::Complete => 5, + E3Stage::Failed => 6, + } + } + + fn is_dkg_request(kind: &ComputeRequestKind) -> bool { + match kind { + ComputeRequestKind::TrBFV(request) => matches!( + request, + e3_trbfv::TrBFVRequest::GenEsiSss(_) + | e3_trbfv::TrBFVRequest::GenPkShareAndSkSss(_) + | e3_trbfv::TrBFVRequest::CalculateDecryptionKey(_) + ), + ComputeRequestKind::Zk(request) => { + matches!( + request, + ZkRequest::PkBfv(_) + | ZkRequest::PkGeneration(_) + | ZkRequest::ShareComputation(_) + | ZkRequest::ShareEncryption(_) + | ZkRequest::DkgShareDecryption(_) + | ZkRequest::VerifyShareDecryptionProofs(_) + | ZkRequest::PkAggregation(_) + | ZkRequest::NodeDkgFold(_) + | ZkRequest::NodesFoldStep(_) + | ZkRequest::DkgAggregation(_) + ) || matches!( + request, + ZkRequest::VerifyShareProofs(request) + if !Self::is_threshold_decryption_verification(request) + ) + } + } + } + + /// `VerifyShareProofs` carries both DKG proofs (C1-C3) and final + /// threshold-decryption proofs (C6). Inspect the signed proof type so a + /// lifecycle filter cannot discard valid C6 work after key publication. + fn is_threshold_decryption_verification(request: &VerifyShareProofsRequest) -> bool { + let mut proofs = request + .party_proofs + .iter() + .flat_map(|party| party.signed_proofs.iter()); + let Some(first) = proofs.next() else { + return false; + }; + + first.payload.proof_type == ProofType::C6ThresholdShareDecryption + && proofs.all(|proof| proof.payload.proof_type == ProofType::C6ThresholdShareDecryption) + } + + fn request_is_obsolete(&self, e3_id: &E3id, kind: &ComputeRequestKind) -> bool { + Self::request_is_obsolete_at_stage(self.stages.get(e3_id), kind) + } + + fn request_is_obsolete_at_stage(stage: Option<&E3Stage>, kind: &ComputeRequestKind) -> bool { + match stage { + Some(E3Stage::Complete | E3Stage::Failed) => true, + Some(stage) if Self::stage_rank(stage) >= Self::stage_rank(&E3Stage::KeyPublished) => { + Self::is_dkg_request(kind) + } + _ => false, + } + } + + fn record_stage(&mut self, e3_id: E3id, new_stage: E3Stage) { + let should_advance = self + .stages + .get(&e3_id) + .map(|current| Self::stage_rank(&new_stage) > Self::stage_rank(current)) + .unwrap_or(true); + if should_advance { + self.stages.insert(e3_id.clone(), new_stage); + } + + let current_stage = self.stages.get(&e3_id).cloned(); + self.pending.retain(|(pending_id, kind), _| { + pending_id != &e3_id + || !Self::request_is_obsolete_at_stage(current_stage.as_ref(), kind) + }); + } + /// Extract the dedup key from a ComputeRequest event, if it is one. fn request_key(event: &InterfoldEvent) -> Option { match event.get_data() { @@ -63,6 +154,10 @@ impl ComputeEffectGate { /// suppressed. Returns false if the key was already forwarded. fn forward(&mut self, event: InterfoldEvent) -> bool { if let Some(key) = Self::request_key(&event) { + if self.request_is_obsolete(&key.0, &key.1) { + debug!(e3_id = %key.0, "dropping compute effect made obsolete by lifecycle stage"); + return false; + } if !self.forwarded.insert(key) { debug!("dropping duplicate compute effect (already forwarded)"); return false; @@ -72,8 +167,12 @@ impl ComputeEffectGate { true } - pub(crate) fn attach(bus: &BusHandle, target: Recipient) { - let gate = Self::new(target).start(); + pub(crate) fn attach( + bus: &BusHandle, + target: Recipient, + initial_stages: HashMap, + ) { + let gate = Self::new(target, initial_stages).start(); bus.subscribe_all( &[ EventType::ComputeRequest, @@ -141,12 +240,19 @@ impl Handler for ComputeEffectGate { } InterfoldEventData::ComputeRequest(_) => self.queue(event), InterfoldEventData::EffectsEnabled(_) => self.enable(), - InterfoldEventData::E3RequestComplete(complete) => self.cancel(&complete.e3_id), - InterfoldEventData::E3Failed(failed) => self.cancel(&failed.e3_id), - InterfoldEventData::E3StageChanged(stage) - if matches!(stage.new_stage, E3Stage::Complete | E3Stage::Failed) => - { - self.cancel(&stage.e3_id) + InterfoldEventData::E3RequestComplete(complete) => { + self.record_stage(complete.e3_id.clone(), E3Stage::Complete); + self.cancel(&complete.e3_id); + } + InterfoldEventData::E3Failed(failed) => { + self.record_stage(failed.e3_id.clone(), E3Stage::Failed); + self.cancel(&failed.e3_id); + } + InterfoldEventData::E3StageChanged(stage) => { + self.record_stage(stage.e3_id.clone(), stage.new_stage.clone()); + if matches!(stage.new_stage, E3Stage::Complete | E3Stage::Failed) { + self.cancel(&stage.e3_id); + } } _ => {} } @@ -158,9 +264,10 @@ mod tests { use super::*; use actix::{Message, ResponseFuture}; use e3_events::{ - ComputeRequest, CorrelationId, E3RequestComplete, EffectsEnabled, - EventConstructorWithTimestamp, EventSource, InterfoldEvent, PkBfvProofRequest, Unsequenced, - ZkRequest, + CircuitName, ComputeRequest, CorrelationId, E3RequestComplete, EffectsEnabled, + EventConstructorWithTimestamp, EventSource, InterfoldEvent, PartyProofsToVerify, + PkBfvProofRequest, Proof, ProofPayload, SignedProofPayload, Unsequenced, + VerifyShareProofsRequest, ZkRequest, }; use e3_fhe_params::BfvPreset; use e3_utils::ArcBytes; @@ -216,6 +323,46 @@ mod tests { .into_sequenced(1) } + fn share_verification_compute( + correlation_id: CorrelationId, + timestamp: u128, + proof_type: ProofType, + circuit: CircuitName, + ) -> InterfoldEvent { + let proof = SignedProofPayload { + payload: ProofPayload { + e3_id: E3id::new("4", 1), + proof_type, + proof: Proof::new( + circuit, + ArcBytes::from_bytes(&[1]), + ArcBytes::from_bytes(&[2]), + ), + }, + signature: ArcBytes::from_bytes(&[3]), + }; + let request = ComputeRequest::zk( + ZkRequest::VerifyShareProofs(VerifyShareProofsRequest { + party_proofs: vec![PartyProofsToVerify { + sender_party_id: 0, + signed_proofs: vec![proof], + }], + params_preset: BfvPreset::default(), + committee_size: CiphernodesCommitteeSize::Micro, + }), + correlation_id, + E3id::new("4", 1), + ); + InterfoldEvent::::new_with_timestamp( + request.into(), + None, + timestamp, + None, + EventSource::Local, + ) + .into_sequenced(1) + } + fn effects_enabled() -> InterfoldEvent { InterfoldEvent::::new_with_timestamp( EffectsEnabled::new().into(), @@ -244,7 +391,7 @@ mod tests { #[actix::test] async fn buffers_until_enabled_and_keeps_newest_semantic_retry() { let recorder = Recorder::default().start(); - let gate = ComputeEffectGate::new(recorder.clone().recipient()).start(); + let gate = ComputeEffectGate::new(recorder.clone().recipient(), HashMap::new()).start(); let stale = CorrelationId::new(); let regenerated = CorrelationId::new(); @@ -259,7 +406,7 @@ mod tests { #[actix::test] async fn terminal_e3_cancels_buffered_effects() { let recorder = Recorder::default().start(); - let gate = ComputeEffectGate::new(recorder.clone().recipient()).start(); + let gate = ComputeEffectGate::new(recorder.clone().recipient(), HashMap::new()).start(); gate.send(compute(CorrelationId::new(), 10)).await.unwrap(); gate.send(completed()).await.unwrap(); @@ -271,7 +418,7 @@ mod tests { #[actix::test] async fn drops_redriven_duplicate_after_enable() { let recorder = Recorder::default().start(); - let gate = ComputeEffectGate::new(recorder.clone().recipient()).start(); + let gate = ComputeEffectGate::new(recorder.clone().recipient(), HashMap::new()).start(); let buffered = CorrelationId::new(); let redriven = CorrelationId::new(); @@ -285,4 +432,46 @@ mod tests { gate.send(compute(redriven, 40)).await.unwrap(); assert_eq!(recorder.send(Received).await.unwrap(), vec![buffered]); } + + #[actix::test] + async fn key_published_snapshot_discards_obsolete_dkg_work() { + let recorder = Recorder::default().start(); + let stages = HashMap::from([(E3id::new("4", 1), E3Stage::KeyPublished)]); + let gate = ComputeEffectGate::new(recorder.clone().recipient(), stages).start(); + + gate.send(compute(CorrelationId::new(), 10)).await.unwrap(); + gate.send(effects_enabled()).await.unwrap(); + gate.send(compute(CorrelationId::new(), 40)).await.unwrap(); + + assert!(recorder.send(Received).await.unwrap().is_empty()); + } + + #[actix::test] + async fn key_published_snapshot_keeps_threshold_decryption_verification() { + let recorder = Recorder::default().start(); + let stages = HashMap::from([(E3id::new("4", 1), E3Stage::CiphertextReady)]); + let gate = ComputeEffectGate::new(recorder.clone().recipient(), stages).start(); + let dkg_correlation_id = CorrelationId::new(); + let correlation_id = CorrelationId::new(); + + gate.send(share_verification_compute( + dkg_correlation_id, + 5, + ProofType::C1PkGeneration, + CircuitName::PkGeneration, + )) + .await + .unwrap(); + gate.send(share_verification_compute( + correlation_id, + 10, + ProofType::C6ThresholdShareDecryption, + CircuitName::ThresholdShareDecryption, + )) + .await + .unwrap(); + gate.send(effects_enabled()).await.unwrap(); + + assert_eq!(recorder.send(Received).await.unwrap(), vec![correlation_id]); + } } diff --git a/crates/multithread/src/multithread.rs b/crates/multithread/src/multithread.rs index 3945200706..ca60e73e03 100644 --- a/crates/multithread/src/multithread.rs +++ b/crates/multithread/src/multithread.rs @@ -6,6 +6,7 @@ #![allow(clippy::result_large_err)] +use std::collections::HashMap; use std::sync::Arc; use std::thread; use std::time::Duration; @@ -28,7 +29,7 @@ use e3_events::{ ComputeResponse, DecryptedSharesAggregationProofRequest, DecryptedSharesAggregationProofResponse, DecryptionAggregationRequest, DecryptionAggregationResponse, DkgAggregationRequest, DkgAggregationResponse, - DkgShareDecryptionProofRequest, DkgShareDecryptionProofResponse, EventPublisher, + DkgShareDecryptionProofRequest, DkgShareDecryptionProofResponse, E3Stage, E3id, EventPublisher, EventSubscriber, EventType, InterfoldEvent, InterfoldEventData, NodeDkgFoldRequest, NodeDkgFoldResponse, NodesFoldStepRequest, NodesFoldStepResponse, PartyVerificationResult, PkAggregationProofRequest, PkAggregationProofResponse, PkBfvProofRequest, PkBfvProofResponse, @@ -137,10 +138,11 @@ impl Multithread { cipher: Arc, task_pool: TaskPool, report: Option>, + lifecycle_stages: HashMap, ) -> Addr { let addr = Self::new(bus.clone(), rng.clone(), cipher.clone(), task_pool, report).start(); - ComputeEffectGate::attach(bus, addr.clone().recipient()); + ComputeEffectGate::attach(bus, addr.clone().recipient(), lifecycle_stages); info!("Multithread actor waiting behind the replay-safe effect gate."); addr @@ -153,6 +155,7 @@ impl Multithread { task_pool: TaskPool, report: Option>, zk_backend: &ZkBackend, + lifecycle_stages: HashMap, ) -> Addr { let zk_prover = Arc::new(ZkProver::new(zk_backend)); let actor = Self::new(bus.clone(), rng.clone(), cipher.clone(), task_pool, report) @@ -167,7 +170,7 @@ impl Multithread { addr.clone().into(), ); - ComputeEffectGate::attach(bus, addr.clone().recipient()); + ComputeEffectGate::attach(bus, addr.clone().recipient(), lifecycle_stages); info!("Multithread actor with ZK waiting behind the replay-safe effect gate."); addr diff --git a/crates/request/src/routing/effects/recovery.rs b/crates/request/src/routing/effects/recovery.rs index 4e47923022..197c2c2fa1 100644 --- a/crates/request/src/routing/effects/recovery.rs +++ b/crates/request/src/routing/effects/recovery.rs @@ -39,8 +39,11 @@ pub fn project_request_router_event( match RequestRouter::route_with_context(event, &checkpoint.completed, has_context) { RoutingDecision::Process { e3_id, - post_forward: PostForward::Teardown, + post_forward: PostForward::Teardown | PostForward::PublishComplete, } => { + // The live router publishes E3RequestComplete after PublishComplete and then + // tears the context down. Recovery projects the resulting terminal state directly + // so it does not depend on that derived local event being present in an older log. checkpoint.contexts.retain(|context| context != &e3_id); checkpoint.completed.insert(e3_id); } diff --git a/crates/request/src/routing/workflow.rs b/crates/request/src/routing/workflow.rs index c4127fed8e..b28df0a29e 100644 --- a/crates/request/src/routing/workflow.rs +++ b/crates/request/src/routing/workflow.rs @@ -120,6 +120,10 @@ impl RequestRouter { if is_late_terminal { return RoutingDecision::Ignore; } + // Duplicate or overlapping EVM history cannot reopen a completed local context. + if msg.source() == EventSource::Evm { + return RoutingDecision::Ignore; + } return RoutingDecision::AlreadyCompleted(e3_id); } diff --git a/crates/request/src/routing/workflow_tests.rs b/crates/request/src/routing/workflow_tests.rs index b78dbe110b..99ae27f3be 100644 --- a/crates/request/src/routing/workflow_tests.rs +++ b/crates/request/src/routing/workflow_tests.rs @@ -64,13 +64,26 @@ fn completed_request_is_an_error() { let id = e3id(); let mut completed = HashSet::new(); completed.insert(id.clone()); - let msg = with_e3_id("late", id.clone()); + let msg = with_e3_id("late", id.clone()).with_source(EventSource::Local); assert_eq!( RequestRouter::route(&msg, &completed), RoutingDecision::AlreadyCompleted(id) ); } +#[test] +fn canonical_evm_history_for_completed_request_is_ignored() { + let id = e3id(); + let mut completed = HashSet::new(); + completed.insert(id.clone()); + let msg = with_e3_id("historical-chain-event", id).with_source(EventSource::Evm); + + assert_eq!( + RequestRouter::route(&msg, &completed), + RoutingDecision::Ignore + ); +} + #[test] fn settlement_receipt_is_not_routed_to_completed_context() { let id = e3id(); @@ -324,6 +337,25 @@ fn requester_cancellation_publishes_complete() { ); } +#[test] +fn requester_and_provider_failures_publish_complete() { + for reason in [ + FailureReason::NoInputsReceived, + FailureReason::ComputeProviderExpired, + FailureReason::ComputeProviderFailed, + ] { + let id = e3id(); + let msg = e3_failed(id.clone(), reason); + assert_eq!( + RequestRouter::route(&msg, &HashSet::new()), + RoutingDecision::Process { + e3_id: id, + post_forward: PostForward::PublishComplete, + } + ); + } +} + #[test] fn e3_failed_invalid_shares_does_not_complete() { // Slashable failures must NOT trigger E3RequestComplete โ€” the accusation/slashing diff --git a/crates/safe/Cargo.toml b/crates/safe/Cargo.toml index 1feb590f2e..0ad791ee12 100644 --- a/crates/safe/Cargo.toml +++ b/crates/safe/Cargo.toml @@ -12,3 +12,10 @@ ark-ff = { workspace = true } ark-bn254 = { workspace = true } taceo-poseidon2 = { version = "0.2", features = ["bn254", "t4"] } hex = { workspace = true } + +[target.'cfg(all(target_os = "zkvm", target_arch = "riscv32"))'.dependencies] +risc0-bigint2 = "=1.4.7" + +[dev-dependencies] +rand = { workspace = true } +rand_chacha = { workspace = true } diff --git a/crates/safe/src/lib.rs b/crates/safe/src/lib.rs index 58e57c5c2b..dd0d5a2761 100644 --- a/crates/safe/src/lib.rs +++ b/crates/safe/src/lib.rs @@ -24,8 +24,14 @@ use ark_bn254::Fr; use ark_ff::Zero; use sha3::{Digest, Keccak256}; +#[cfg(not(all(target_os = "zkvm", target_arch = "riscv32")))] use taceo_poseidon2::bn254::t4::permutation as poseidon2_permutation; +#[cfg(any(all(target_os = "zkvm", target_arch = "riscv32"), test))] +mod poseidon2_accel; +#[cfg(all(target_os = "zkvm", target_arch = "riscv32"))] +use poseidon2_accel::permutation as poseidon2_permutation; + /// Field type used throughout the SAFE implementation (BN254 scalar field) pub type Field = Fr; diff --git a/crates/safe/src/poseidon2_accel.rs b/crates/safe/src/poseidon2_accel.rs new file mode 100644 index 0000000000..9b964eabd1 --- /dev/null +++ b/crates/safe/src/poseidon2_accel.rs @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! RISC Zero-accelerated implementation of the exact BN254 Poseidon2 permutation used by SAFE. + +use ark_bn254::Fr; +use ark_ff::{BigInt, PrimeField}; + +const WIDTH: usize = 4; +const MODULUS: [u32; 8] = [ + 0xf0000001, 0x43e1f593, 0x79b97091, 0x2833e848, 0x8181585d, 0xb85045b6, 0xe131a029, 0x30644e72, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FastField([u32; 8]); + +impl FastField { + const ZERO: Self = Self([0; 8]); + + fn from_ark(value: Fr) -> Self { + let words = value.into_bigint().0; + Self([ + words[0] as u32, + (words[0] >> 32) as u32, + words[1] as u32, + (words[1] >> 32) as u32, + words[2] as u32, + (words[2] >> 32) as u32, + words[3] as u32, + (words[3] >> 32) as u32, + ]) + } + + fn into_ark(self) -> Fr { + Fr::from_bigint(BigInt([ + u64::from(self.0[0]) | (u64::from(self.0[1]) << 32), + u64::from(self.0[2]) | (u64::from(self.0[3]) << 32), + u64::from(self.0[4]) | (u64::from(self.0[5]) << 32), + u64::from(self.0[6]) | (u64::from(self.0[7]) << 32), + ])) + .expect("accelerated field operation returned a non-canonical value") + } + + fn add(self, rhs: Self) -> Self { + let mut output = [0u32; 8]; + let mut carry = 0u64; + for (index, value) in output.iter_mut().enumerate() { + let sum = u64::from(self.0[index]) + u64::from(rhs.0[index]) + carry; + *value = sum as u32; + carry = sum >> 32; + } + debug_assert_eq!(carry, 0, "two BN254 values cannot overflow 256 bits"); + + if !less_than(&output, &MODULUS) { + subtract_modulus(&mut output); + } + Self(output) + } + + fn double(self) -> Self { + self.add(self) + } + + #[cfg(all(target_os = "zkvm", target_arch = "riscv32"))] + fn multiply(self, rhs: Self) -> Self { + let mut output = [0u32; 8]; + risc0_bigint2::field::modmul_256(&self.0, &rhs.0, &MODULUS, &mut output); + Self(output) + } + + #[cfg(not(all(target_os = "zkvm", target_arch = "riscv32")))] + fn multiply(self, rhs: Self) -> Self { + Self::from_ark(self.into_ark() * rhs.into_ark()) + } + + fn pow_five(self) -> Self { + let squared = self.multiply(self); + let fourth = squared.multiply(squared); + self.multiply(fourth) + } +} + +fn less_than(lhs: &[u32; 8], rhs: &[u32; 8]) -> bool { + for index in (0..8).rev() { + if lhs[index] != rhs[index] { + return lhs[index] < rhs[index]; + } + } + false +} + +fn subtract_modulus(value: &mut [u32; 8]) { + let mut borrow = false; + for index in 0..8 { + let (first, first_borrow) = value[index].overflowing_sub(MODULUS[index]); + let (second, second_borrow) = first.overflowing_sub(u32::from(borrow)); + value[index] = second; + borrow = first_borrow || second_borrow; + } + debug_assert!(!borrow, "the value must be at least the modulus") +} + +const ROUNDS_F: usize = 8; +const ROUNDS_P: usize = 56; + +const MAT_DIAG_M_1: [FastField; WIDTH] = [ + FastField([ + 0x19d3b6e7, 0xb56821fd, 0x29ca1d7f, 0x0d03f989, 0x4bd9490c, 0x04b1e03b, 0x006ea38b, + 0x10dc6e9c, + ]), + FastField([ + 0xb45a740b, 0xa86b38cf, 0xd4dd9b84, 0x99df9756, 0xa30b3bb5, 0x0149b3d0, 0x6a44df3e, + 0x0c28145b, + ]), + FastField([ + 0x141cac15, 0x70067d00, 0x60e35961, 0xb21f75bb, 0x50392798, 0xb2c7645a, 0x38791518, + 0x00544b83, + ]), + FastField([ + 0x33ee428b, 0x13bc5344, 0xb8fa8526, 0x52e105a3, 0x122789e3, 0x2e2e82eb, 0x5718386f, + 0x222c0117, + ]), +]; +const EXTERNAL_RC: [[FastField; WIDTH]; ROUNDS_F] = [ + // First external + [ + FastField([ + 0x69ed23e5, 0x8b0878e2, 0x4edc2623, 0x02bb8674, 0xbd5e4a43, 0x48da1d39, 0x9450b068, + 0x19b849f6, + ]), + FastField([ + 0x8dcf34d6, 0xad47f80c, 0x450acc1d, 0x20eb2cc7, 0x758f0a13, 0x7239347b, 0x27dd51bd, + 0x265ddfe1, + ]), + FastField([ + 0xb497d8aa, 0x3dfc36ba, 0x5015c2aa, 0x4108ac84, 0x5e1e5162, 0xe0f66a54, 0x472f1809, + 0x199750ec, + ]), + FastField([ + 0xc7f1cdf8, 0xd032f787, 0x5067f0ff, 0x4d743ea2, 0xf74302b1, 0x110f06a5, 0x65ac7208, + 0x157ff3fe, + ]), + ], + [ + FastField([ + 0x6ac94902, 0xfe18f489, 0x692f8bee, 0x0b15c590, 0x5fca33f1, 0x5fd35ac4, 0x4569dd9c, + 0x2e49c43c, + ]), + FastField([ + 0xfa2d1f1e, 0x2731345f, 0x73c24fa8, 0xcb2f0b69, 0x6d6506c3, 0x0d4aef2b, 0x98189052, + 0x0e35fb89, + ]), + FastField([ + 0x02e0b996, 0xc6fe7230, 0x6d667ffe, 0xa9d9e780, 0x5e944f1b, 0x05f109ae, 0xb15c4f11, + 0x251ad47c, + ]), + FastField([ + 0x9c22df4e, 0x563fa39d, 0xdd05e5f3, 0xf8beb56f, 0x60234641, 0x9873e971, 0x64d42836, + 0x13da07dc, + ]), + ], + [ + FastField([ + 0x55fd4738, 0x46e7b890, 0x89d350cd, 0xa5539396, 0xccef7483, 0x3dc00c7d, 0xe650e6d2, + 0x0c009b84, + ]), + FastField([ + 0xbefdca06, 0x203dec74, 0x6d535eb0, 0x04eb650c, 0x56f42d8b, 0x01992e39, 0xc63a854f, + 0x011f16b1, + ]), + FastField([ + 0x3f367549, 0x85df0709, 0x467ad454, 0x2f3f78d0, 0x1daa7961, 0x209d9a56, 0x383a688f, + 0x0ed69e5e, + ]), + FastField([ + 0x4c9f789b, 0x46367226, 0x5eb3d33f, 0x3aec507f, 0x472b6bbe, 0x21acad41, 0x7b0ce9e2, + 0x04dba94a, + ]), + ], + [ + FastField([ + 0xd4fa28e8, 0xce732ff1, 0x4bb50bf7, 0x6036757d, 0x1c9d237b, 0x6eb09427, 0xd840f3a1, + 0x0a3f2637, + ]), + FastField([ + 0x1182323f, 0xe54a485d, 0x569564b6, 0x39b1f075, 0x2fdb38fa, 0x8f8a1c50, 0x129eea19, + 0x259a666f, + ]), + FastField([ + 0xede0d6a1, 0x7a32fdf7, 0x1038e515, 0x7745d427, 0x4ee3a47f, 0xd8e7d06a, 0xc9b2f4c6, + 0x28bf7459, + ]), + FastField([ + 0x41432447, 0xec91bd69, 0xcce6a2ae, 0xc37c85bb, 0x489be8d4, 0x26ea200f, 0xf0570375, + 0x0a1ca941, + ]), + ], + // Second external + [ + FastField([ + 0xb1405d38, 0xf3b16ef2, 0x6be63b09, 0xab0fb85f, 0xc6f287f6, 0x77eb757b, 0x4b7a3e17, + 0x1797130f, + ]), + FastField([ + 0x5decc6e5, 0x36c66855, 0x20156d4d, 0x8c7f497c, 0xbab59e60, 0x3306c85a, 0xc04170ae, + 0x0a76225d, + ]), + FastField([ + 0x26a31a5c, 0x96174b53, 0x8acb6647, 0xf8fa76d4, 0x93209af6, 0xa1e77a7b, 0x1992d66b, + 0x1fffb9ec, + ]), + FastField([ + 0x797b9c5f, 0x0611889b, 0xc6b9c609, 0x5f8fbba6, 0x8fa538d8, 0x53b57c33, 0xc15a3f28, + 0x25721c4f, + ]), + ], + [ + FastField([ + 0xbfcaf75a, 0xeb63b982, 0x0705da95, 0xadb4c379, 0xba197216, 0x215e3d07, 0x2d5f7a41, + 0x0c817fd4, + ]), + FastField([ + 0xe52b5a96, 0x2bc15866, 0xe00a2200, 0xdf8cf86c, 0xc24970b6, 0x9f7e13c2, 0x239915d3, + 0x13abe3f5, + ]), + FastField([ + 0xb4d391ce, 0x92cd60ac, 0x29bdbd7a, 0x5c1bc3dc, 0x987a46c8, 0x12ef7f39, 0x546224ea, + 0x2106feea, + ]), + FastField([ + 0x5bb0f959, 0x57e1b334, 0xc748bc71, 0xf1ca5a28, 0xa37dab49, 0xaaa79474, 0x68a746b6, + 0x21ca8594, + ]), + ], + [ + FastField([ + 0x9e34185b, 0x8f1a4899, 0x0321662a, 0x2911d14d, 0x934194c6, 0x5cf1f0df, 0x5c1e6f0c, + 0x05ccd625, + ]), + FastField([ + 0xb09490a4, 0xea28678c, 0x7fe44fe6, 0x16c4fb26, 0x674c4c88, 0xe464d846, 0x4b70a626, + 0x0f0e34a6, + ]), + FastField([ + 0x2de0d4bf, 0x8f5b1a8a, 0x350d6483, 0x47dbfcfe, 0xa36d0e96, 0x6157794c, 0x4e25470c, + 0x0558531a, + ]), + FastField([ + 0x961f1455, 0xb72f5864, 0x3f655a60, 0x924cadad, 0x57683d18, 0xceea1251, 0x173ed2fa, + 0x09d3dca9, + ]), + ], + [ + FastField([ + 0xe5bd4335, 0x17d4c722, 0x8aaec486, 0xf23f92d6, 0xd03d218b, 0x493f866e, 0x4e8c0913, + 0x0328cbd5, + ]), + FastField([ + 0x5329d34b, 0xee3347dd, 0x9798c648, 0xe79e7bcc, 0xa7094e07, 0x23a487b1, 0xe2aff0a2, + 0x2bf07216, + ]), + FastField([ + 0x3fe412df, 0x111e11a6, 0xa6dffc82, 0xd6f78ed6, 0xcb76c316, 0x6499c583, 0x58006b73, + 0x1daf345a, + ]), + FastField([ + 0x93d2c404, 0x391e6f22, 0xb2edc7ff, 0x1ef39039, 0x0e182361, 0x46b694c6, 0x2456aaa7, + 0x17656347, + ]), + ], +]; +const INTERNAL_RC: [FastField; ROUNDS_P] = [ + FastField([ + 0x926361cf, 0xb43a26fd, 0x39f051dc, 0x5535ed15, 0xc5451285, 0x53d7fd4f, 0x8be0e930, + 0x0c6f8f95, + ]), + FastField([ + 0x9caaf811, 0x84dd57e6, 0x08e296e0, 0xa9e8a007, 0x8ac9d90a, 0xd426e812, 0x3cd17578, + 0x123106a9, + ]), + FastField([ + 0xcd2dee75, 0x7b074867, 0xf1e8f187, 0x5e8fa83f, 0xf8e84008, 0x7dd3ab52, 0xad9285d9, + 0x26e1ba52, + ]), + FastField([ + 0x6a4ae2c5, 0x4471537e, 0xf9e09586, 0xbe4d8b7b, 0x47b9c97c, 0x18a64c5c, 0x7bd133de, + 0x1cb55cad, + ]), + FastField([ + 0x6e9055d0, 0x7143f08e, 0x5060a41c, 0x2a53043d, 0x4bde7f6d, 0x0e2c7ce0, 0x6acd8f8e, + 0x1dcd73e4, + ]), + FastField([ + 0x512e5574, 0xb12b9bb4, 0x0eb4e9b9, 0x0cda294a, 0x474a4def, 0xf5852f05, 0x2f6d9c66, + 0x011003e3, + ]), + FastField([ + 0x2287ae8c, 0xd7c508dd, 0x3f58bafe, 0xbadfe590, 0x03a57dfe, 0x9ad5f20d, 0xc1d10ab2, + 0x2b1e809a, + ]), + FastField([ + 0x7bcec0a5, 0xeaa69ae8, 0xab2fc5fa, 0xef995d05, 0x5ee17ed0, 0x9fb4dac3, 0x85b73599, + 0x2539de17, + ]), + FastField([ + 0x1d77951d, 0x43982cb1, 0x1c86d46e, 0xf4e1c3d4, 0x2b3e0a0e, 0x26497f22, 0x2ef8ee01, + 0x0c246c5a, + ]), + FastField([ + 0xd03b527b, 0x3f0305f5, 0xad1a1c2f, 0xbb09e6a6, 0x7c0632ed, 0x5408148f, 0x974f68e9, + 0x192089c4, + ]), + FastField([ + 0xb5a60d85, 0x6d8fdc2f, 0x91096b75, 0x8529097d, 0xeb0d0c05, 0x6a0ee36e, 0xab68b2f0, + 0x1eae0ad8, + ]), + FastField([ + 0xc5d06bfb, 0x9768bd98, 0x0dee99e6, 0xdb6e2fdc, 0x872abc88, 0xe46f8282, 0xd0e22179, + 0x179190e5, + ]), + FastField([ + 0xa9b3cd1c, 0x6cafe794, 0xb00f31bf, 0x14528f7d, 0x7ac4b832, 0x76e9a81c, 0x90767325, + 0x29bb9e2c, + ]), + FastField([ + 0x6e691e08, 0xb10e590e, 0x882aac35, 0x52652645, 0x2464a90d, 0x403efd0c, 0x42207599, + 0x225d394e, + ]), + FastField([ + 0x4b23fd59, 0xe09efd45, 0x451c087d, 0x2be13557, 0x55b44453, 0x753d2380, 0x3c25c8cf, + 0x06476062, + ]), + FastField([ + 0x8f6b5b87, 0x922910a7, 0x42a75c10, 0x4d67f4bf, 0x716d8a39, 0x7f301c4b, 0x01df92e8, + 0x10ba3a0e, + ]), + FastField([ + 0x3f21471c, 0x361b7769, 0xc242eb9d, 0xcb511bc0, 0xb0c2a801, 0x4f9c6e96, 0x3f8451b2, + 0x0e070bf5, + ]), + FastField([ + 0x4de252fb, 0xa7f92101, 0xd2491d8a, 0xccd6cb11, 0x93821a73, 0xd39755ff, 0xb051b04d, + 0x1b94cd61, + ]), + FastField([ + 0x7d74070b, 0x0487b5aa, 0x5713bb05, 0x9d4e917d, 0x2e70230f, 0xe148787a, 0xafb8c744, + 0x1d7cb39b, + ]), + FastField([ + 0x303b17db, 0xbb74ac1f, 0x1829f701, 0x8785c296, 0x980c80ff, 0x9117d0fe, 0xbd1ab4f6, + 0x2ec93189, + ]), + FastField([ + 0x83517926, 0x82ea46bd, 0x9ae07a90, 0xeac404a1, 0x5b86275b, 0xa692bb82, 0xdd36d277, + 0x2db366bf, + ]), + FastField([ + 0x960711b8, 0xdc99cec6, 0x8450359a, 0x98527542, 0x86a68532, 0x69655cf1, 0x485db062, + 0x062100eb, + ]), + FastField([ + 0x41f5a59b, 0x00c567bf, 0xfa59e4f9, 0x20243f92, 0x8244ca11, 0x570e7f1e, 0x66614aaa, + 0x0761d33c, + ]), + FastField([ + 0x4855ad0d, 0xf7a72e49, 0x0f7de4cc, 0x5d78608a, 0x034e3f31, 0x2c2705aa, 0x114d1399, + 0x20fc411a, + ]), + FastField([ + 0x7250bc5a, 0xc3a30f31, 0xb3effb5f, 0x102c67e8, 0x9ab219ba, 0xadd9ec4e, 0xa4bdfcb5, + 0x25b5c004, + ]), + FastField([ + 0x62b37f4b, 0xd87e7dff, 0x8474155a, 0x038b186d, 0x6df6f5ed, 0xa494e58f, 0x278ed632, + 0x23b1822d, + ]), + FastField([ + 0xcc2f69e0, 0x16102a29, 0xfcfcccaa, 0x0f14d13b, 0x012499bf, 0x606c4ba9, 0x5c3f9493, + 0x22734b4c, + ]), + FastField([ + 0xad795ce5, 0x54413d3f, 0x9aa36102, 0xe5bdff40, 0x33492347, 0xe27a74dc, 0x09eb30b7, + 0x26c0c8fe, + ]), + FastField([ + 0x348ccad9, 0xbbd626df, 0x3a809829, 0x196be308, 0xfa1fbb26, 0xe88eac03, 0xb6bd7bba, + 0x070dd0cc, + ]), + FastField([ + 0xfd4250da, 0x6067c4eb, 0x46d8c5ad, 0xc2c0a6de, 0xbb28c3be, 0xb043ba78, 0xdb329b6f, + 0x12b6595b, + ]), + FastField([ + 0xb7e8d729, 0x5e33d95b, 0x275c671c, 0xc06fca9b, 0xa5876c11, 0x3bec30e7, 0xf76283d6, + 0x248d97d7, + ]), + FastField([ + 0xbd9baaaa, 0x106d15d9, 0x9ddde4aa, 0x8b45eb75, 0x4cc93931, 0x16fc6fd6, 0x9d463b08, + 0x1a306d43, + ]), + FastField([ + 0xec7c56cf, 0x0d62d3d6, 0xdc27821b, 0xf4f1b54d, 0x21cb4621, 0xced7c004, 0x2e3c38da, + 0x28a8f837, + ]), + FastField([ + 0xe1e2ce7e, 0xbc852183, 0xc829f388, 0x071ce320, 0x24d43294, 0xbb35152f, 0x17f9a8a8, + 0x00949757, + ]), + FastField([ + 0xdb2e8d65, 0xf4103246, 0xf653ae83, 0x593f74d4, 0x716480d3, 0x80fde60d, 0x3aa78f7d, + 0x04d5ee4c, + ]), + FastField([ + 0x2efde187, 0xd08495c1, 0x8822cc76, 0xc7bef54b, 0xb8ed2269, 0x6349ad6f, 0xaa03d433, + 0x2a6cf5e9, + ]), + FastField([ + 0xefcba3f3, 0xbaae48d7, 0x08fd6e43, 0xf7921808, 0xe19ddeb7, 0x9274da43, 0xaab960ba, + 0x2304d31e, + ]), + FastField([ + 0xd199f0b0, 0xe1c11d39, 0x0726fcb4, 0xbff08a7e, 0x85817249, 0xd5e70097, 0x65a4b2a6, + 0x03fd9ac8, + ]), + FastField([ + 0xd63b0b64, 0x3f7954d4, 0x20919307, 0x798afc3a, 0x55ee5044, 0x2248404d, 0xed52bbda, + 0x00b7258d, + ]), + FastField([ + 0x65e92d9a, 0x6272c5ca, 0xf3298db3, 0xb13d3a74, 0xd4bf65eb, 0xec38fca2, 0xa0771799, + 0x159f81ad, + ]), + FastField([ + 0x4264431f, 0x71e144cf, 0xa25f0c54, 0x9000130e, 0xbc28e3bb, 0x50237a75, 0x437fbc85, + 0x1ef90e67, + ]), + FastField([ + 0x2932e30d, 0x95a79ed8, 0x176b08ec, 0x8df739bc, 0x41a2d256, 0x196b49aa, 0x515e5ff0, + 0x1e65f838, + ]), + FastField([ + 0x8c94c33f, 0x6575c106, 0x570e1f82, 0xb18c844e, 0xd079ba74, 0xec6ce768, 0xef3a166c, + 0x2b1b045d, + ]), + FastField([ + 0x168bb173, 0xf1c6e07c, 0xbef715e3, 0x65dc2d73, 0x109229c1, 0x402543b1, 0x3ceb0ff6, + 0x0832e575, + ]), + FastField([ + 0x90b6ad16, 0xc5a8e3c3, 0xe8b6451b, 0xb1b841c2, 0xa37d41ba, 0x6b762ae0, 0xcedfb3dc, + 0x02f614e9, + ]), + FastField([ + 0x7e7ed705, 0x0f6a0be2, 0x77bedff4, 0x7370ebb7, 0x362cad96, 0xdd640b8e, 0x8bd46a60, + 0x0e2427d3, + ]), + FastField([ + 0x9214a53a, 0x0768bbe2, 0x98c3c7c5, 0x049f0ec0, 0x14e7ce79, 0xeb7c84d4, 0x7c670b6d, + 0x0493630b, + ]), + FastField([ + 0x5327cea9, 0x3dc06cc8, 0x55d5461a, 0x6bb15153, 0x7066c5a2, 0x4decdab1, 0xe8e48267, + 0x22ead100, + ]), + FastField([ + 0x6d2a6f16, 0xe5084e0b, 0x5626d04d, 0x583f1ae3, 0xd2554d48, 0xaae2626e, 0x655b42cd, + 0x25b3e56e, + ]), + FastField([ + 0x0cf6f9d0, 0x4b4fdc0a, 0x349e4c58, 0xb599c336, 0xe8ff13db, 0x5837a6cd, 0xda8836ef, + 0x1e32752a, + ]), + FastField([ + 0x74d412e5, 0x72a98640, 0xf05078f6, 0x23c00995, 0xf3c3455b, 0xc50f68f6, 0xc15a387c, + 0x2fa2a871, + ]), + FastField([ + 0xa7d83505, 0xcd18e7c7, 0x661bab7f, 0x54ccbf10, 0x311e889f, 0x278e1db7, 0x9a4424c9, + 0x2f569b8a, + ]), + FastField([ + 0xb246b43d, 0x44165374, 0x332ffd21, 0xa7df93f7, 0x0234c518, 0x531ade53, 0x110a8fdd, + 0x044cb455, + ]), + FastField([ + 0xa5319025, 0x78ddc723, 0xadfe1181, 0x91fe8c90, 0x7f2e42b1, 0x42024615, 0x93906d5d, + 0x227808de, + ]), + FastField([ + 0xa6800355, 0x8579d2e7, 0xe090ad4a, 0x5d03781a, 0x87357986, 0x623adead, 0x34e046bc, + 0x02fcca29, + ]), + FastField([ + 0x0d8befac, 0xcbec2e06, 0xab91a8dd, 0xbad3f3c5, 0x344a1d36, 0x6abccceb, 0xac120b87, + 0x0ef915f0, + ]), +]; + +fn matmul_external(state: &mut [FastField; WIDTH]) { + let t0 = state[0].add(state[1]); + let t1 = state[2].add(state[3]); + let t2 = state[1].double().add(t1); + let t3 = state[3].double().add(t0); + let t4 = t1.double().double().add(t3); + let t5 = t0.double().double().add(t2); + let t6 = t3.add(t5); + let t7 = t2.add(t4); + state[0] = t6; + state[1] = t5; + state[2] = t7; + state[3] = t4; +} + +fn external_round(state: &mut [FastField; WIDTH], constants: &[FastField; WIDTH]) { + for index in 0..WIDTH { + state[index] = state[index].add(constants[index]).pow_five(); + } + matmul_external(state); +} + +fn internal_round(state: &mut [FastField; WIDTH], constant: FastField) { + state[0] = state[0].add(constant).pow_five(); + let sum = state.iter().copied().fold(FastField::ZERO, FastField::add); + for index in 0..WIDTH { + state[index] = state[index].multiply(MAT_DIAG_M_1[index]).add(sum); + } +} + +pub(super) fn permutation(input: &[Fr; WIDTH]) -> [Fr; WIDTH] { + let mut state = input.map(FastField::from_ark); + matmul_external(&mut state); + + for constants in &EXTERNAL_RC[..ROUNDS_F / 2] { + external_round(&mut state, constants); + } + for constant in INTERNAL_RC { + internal_round(&mut state, constant); + } + for constants in &EXTERNAL_RC[ROUNDS_F / 2..] { + external_round(&mut state, constants); + } + + state.map(FastField::into_ark) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_ff::PrimeField; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + + fn random_field(rng: &mut ChaCha8Rng) -> Fr { + Fr::from_le_bytes_mod_order(&rng.random::<[u8; 32]>()) + } + + #[test] + fn accelerated_field_addition_reduces_at_the_modulus() { + let modulus_minus_one = Fr::from_bigint(BigInt([ + 0x43e1f593f0000000, + 0x2833e84879b97091, + 0xb85045b68181585d, + 0x30644e72e131a029, + ])) + .unwrap(); + + let reduced = FastField::from_ark(modulus_minus_one).add(FastField::from_ark(Fr::from(1))); + assert_eq!(reduced, FastField::ZERO); + } + + #[test] + fn accelerated_algorithm_matches_the_reference_permutation() { + let mut rng = ChaCha8Rng::seed_from_u64(0x1f01d); + for _ in 0..100 { + let input = [ + random_field(&mut rng), + random_field(&mut rng), + random_field(&mut rng), + random_field(&mut rng), + ]; + assert_eq!( + permutation(&input), + taceo_poseidon2::bn254::t4::permutation(&input) + ); + } + } +} diff --git a/crates/sortition/src/sortition/node_registry.rs b/crates/sortition/src/sortition/node_registry.rs index 401f4139b1..4ca8143072 100644 --- a/crates/sortition/src/sortition/node_registry.rs +++ b/crates/sortition/src/sortition/node_registry.rs @@ -16,7 +16,7 @@ use alloy::primitives::U256; use e3_events::E3id; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{hash_map::Entry, HashMap}; use tracing::{info, warn}; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -265,6 +265,9 @@ impl NodeRegistry { /// Record a published committee and increment active-job counters for each /// of its members. + /// + /// Event replay and duplicate publication candidates can report the same + /// committee more than once. Only the first report changes the counters. pub fn record_committee_published( store: &mut HashMap, e3_id: &E3id, @@ -274,7 +277,29 @@ impl NodeRegistry { let key = committee_key(e3_id); let chain_state = store.entry(chain_id).or_default(); - chain_state.e3_committees.insert(key, nodes.to_vec()); + match chain_state.e3_committees.entry(key) { + Entry::Vacant(entry) => { + entry.insert(nodes.to_vec()); + } + Entry::Occupied(entry) => { + if entry.get().as_slice() != nodes { + warn!( + chain_id, + e3_id = ?e3_id, + recorded_nodes = ?entry.get(), + replayed_nodes = ?nodes, + "Ignored a conflicting committee publication replay" + ); + } else { + info!( + chain_id, + e3_id = ?e3_id, + "Ignored a duplicate committee publication replay" + ); + } + return; + } + } for node_addr in nodes { let node = chain_state.nodes.entry(node_addr.clone()).or_default(); diff --git a/crates/sortition/src/sortition/node_registry_tests.rs b/crates/sortition/src/sortition/node_registry_tests.rs index c8e30e0af8..95415ea8f5 100644 --- a/crates/sortition/src/sortition/node_registry_tests.rs +++ b/crates/sortition/src/sortition/node_registry_tests.rs @@ -121,6 +121,33 @@ fn release_committee_jobs_is_idempotent() { assert_eq!(store[&1].nodes["0xabc"].active_jobs, 0); } +#[test] +fn duplicate_committee_publication_increments_jobs_once() { + let mut store = HashMap::new(); + let id = e3(1, "17"); + let nodes = ["0xabc".into(), "0xdef".into()]; + + NodeRegistry::record_committee_published(&mut store, &id, &nodes); + NodeRegistry::record_committee_published(&mut store, &id, &nodes); + + assert_eq!(store[&1].nodes["0xabc"].active_jobs, 1); + assert_eq!(store[&1].nodes["0xdef"].active_jobs, 1); + assert_eq!(store[&1].e3_committees.len(), 1); +} + +#[test] +fn conflicting_committee_replay_preserves_the_first_committee() { + let mut store = HashMap::new(); + let id = e3(1, "18"); + + NodeRegistry::record_committee_published(&mut store, &id, &["0xabc".into()]); + NodeRegistry::record_committee_published(&mut store, &id, &["0xdef".into()]); + + assert_eq!(store[&1].nodes["0xabc"].active_jobs, 1); + assert!(!store[&1].nodes.contains_key("0xdef")); + assert_eq!(store[&1].e3_committees[&committee_key(&id)], vec!["0xabc"]); +} + #[test] fn get_nodes_with_tickets_filters_inactive_and_empty() { let mut store = HashMap::new(); diff --git a/crates/support-scripts/ctl/container b/crates/support-scripts/ctl/container index 83e767d99f..6a0e295bc1 100755 --- a/crates/support-scripts/ctl/container +++ b/crates/support-scripts/ctl/container @@ -1,11 +1,27 @@ #!/usr/bin/env bash -GIT_SHA=$(interfold rev) +# Resolve the revision from this CLI binary, not from a running node. In an Interfold project, +# `interfold rev` is normally forwarded to the node daemon and can therefore return the revision +# of an older node while a new CLI is starting its matching support image. +GIT_SHA=$(cd / && interfold rev) CONTAINER_NAME="e3-support.1" IMAGE_REPOSITORY="${E3_SUPPORT_IMAGE_REPOSITORY:-ghcr.io/theinterfold/e3-support}" IMAGE="$IMAGE_REPOSITORY:$GIT_SHA" CACHE_PREFIX="e3-support" +SUPPORT_ENVIRONMENT=( + RISC0_DEV_MODE RPC_URL PRIVATE_KEY PINATA_JWT IPFS_GATEWAY_URL PROGRAM_URL BOUNDLESS_ONCHAIN + BOUNDLESS_MIN_PRICE_ETH BOUNDLESS_MAX_PRICE_ETH BOUNDLESS_TIMEOUT_SECS + BOUNDLESS_LOCK_TIMEOUT_SECS BOUNDLESS_RAMP_UP_SECS BOUNDLESS_LOCK_COLLATERAL_ZKC +) +DOCKER_ENVIRONMENT=() +for name in "${SUPPORT_ENVIRONMENT[@]}"; do + if [[ -n "${!name:-}" ]]; then + # Giving Docker only the name copies the inherited value without placing it in argv. + DOCKER_ENVIRONMENT+=(--env "$name") + fi +done + # Use the support image that matches the CLI revision. # This match prevents incompatible support script changes after a CLI update. # Pull the image only when the image is not available locally. @@ -44,7 +60,7 @@ fi if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then echo "Running exec $IMAGE..." - docker exec $TTY_FLAGS "$CONTAINER_NAME" bash -c "$*" + docker exec $TTY_FLAGS "${DOCKER_ENVIRONMENT[@]}" "$CONTAINER_NAME" bash -c "$*" else echo "Running start $IMAGE..." # --network=host does not work on macos for allowing the container to access @@ -57,6 +73,7 @@ else --name "$CONTAINER_NAME" \ --platform linux/amd64 \ --add-host=host.local:host-gateway \ + "${DOCKER_ENVIRONMENT[@]}" \ -p 13151:13151 \ -v "$(pwd)/.interfold/generated/contracts:/app/contracts:rw" \ -v "$(pwd)/tests:/app/tests" \ diff --git a/crates/support-scripts/ctl/start b/crates/support-scripts/ctl/start index 9e47976612..d8de24b309 100755 --- a/crates/support-scripts/ctl/start +++ b/crates/support-scripts/ctl/start @@ -1,9 +1,7 @@ #!/usr/bin/env bash -unset RISC0_DEV_MODE RPC_URL PRIVATE_KEY PINATA_JWT PROGRAM_URL BOUNDLESS_ONCHAIN -unset BOUNDLESS_MIN_PRICE_ETH BOUNDLESS_MAX_PRICE_ETH -unset BOUNDLESS_TIMEOUT_SECS BOUNDLESS_LOCK_TIMEOUT_SECS BOUNDLESS_RAMP_UP_SECS -unset BOUNDLESS_LOCK_COLLATERAL_ZKC +# Configuration normally arrives through the environment so credentials do not appear in process +# arguments. Keep the flags for direct, backwards-compatible use. while [[ $# -gt 0 ]]; do case $1 in @@ -23,6 +21,10 @@ while [[ $# -gt 0 ]]; do PINATA_JWT="$2" shift 2 ;; + --ipfs-gateway-url) + IPFS_GATEWAY_URL="$2" + shift 2 + ;; --program-url) PROGRAM_URL="$2" shift 2 @@ -65,52 +67,14 @@ done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTAINER_ARGS=("./scripts/container/start.sh") -if [[ -n "$RISC0_DEV_MODE" ]]; then - CONTAINER_ARGS+=("--risc0-dev-mode" "$RISC0_DEV_MODE") -fi - -if [[ -n "$RPC_URL" && -n "$PRIVATE_KEY" ]]; then - CONTAINER_ARGS+=("--rpc-url" "$RPC_URL") - CONTAINER_ARGS+=("--private-key" "$PRIVATE_KEY") -elif [[ -n "$RPC_URL" || -n "$PRIVATE_KEY" ]]; then +if [[ -n "${RPC_URL:-}" && -z "${PRIVATE_KEY:-}" ]] || [[ -z "${RPC_URL:-}" && -n "${PRIVATE_KEY:-}" ]]; then echo "Error: Both --rpc-url and --private-key must be provided together, or neither" exit 1 fi -if [[ -n "$PINATA_JWT" ]]; then - CONTAINER_ARGS+=("--pinata-jwt" "$PINATA_JWT") -fi - -if [[ -n "$PROGRAM_URL" ]]; then - CONTAINER_ARGS+=("--program-url" "$PROGRAM_URL") -fi - -if [[ -n "$BOUNDLESS_ONCHAIN" ]]; then - CONTAINER_ARGS+=("--boundless-onchain" "$BOUNDLESS_ONCHAIN") -fi - -if [[ -n "$BOUNDLESS_MIN_PRICE_ETH" ]]; then - CONTAINER_ARGS+=("--boundless-min-price-eth" "$BOUNDLESS_MIN_PRICE_ETH") -fi - -if [[ -n "$BOUNDLESS_MAX_PRICE_ETH" ]]; then - CONTAINER_ARGS+=("--boundless-max-price-eth" "$BOUNDLESS_MAX_PRICE_ETH") -fi - -if [[ -n "$BOUNDLESS_TIMEOUT_SECS" ]]; then - CONTAINER_ARGS+=("--boundless-timeout-secs" "$BOUNDLESS_TIMEOUT_SECS") -fi - -if [[ -n "$BOUNDLESS_LOCK_TIMEOUT_SECS" ]]; then - CONTAINER_ARGS+=("--boundless-lock-timeout-secs" "$BOUNDLESS_LOCK_TIMEOUT_SECS") -fi - -if [[ -n "$BOUNDLESS_RAMP_UP_SECS" ]]; then - CONTAINER_ARGS+=("--boundless-ramp-up-secs" "$BOUNDLESS_RAMP_UP_SECS") -fi - -if [[ -n "$BOUNDLESS_LOCK_COLLATERAL_ZKC" ]]; then - CONTAINER_ARGS+=("--boundless-lock-collateral-zkc" "$BOUNDLESS_LOCK_COLLATERAL_ZKC") -fi +export RISC0_DEV_MODE RPC_URL PRIVATE_KEY PINATA_JWT IPFS_GATEWAY_URL PROGRAM_URL BOUNDLESS_ONCHAIN +export BOUNDLESS_MIN_PRICE_ETH BOUNDLESS_MAX_PRICE_ETH +export BOUNDLESS_TIMEOUT_SECS BOUNDLESS_LOCK_TIMEOUT_SECS BOUNDLESS_RAMP_UP_SECS +export BOUNDLESS_LOCK_COLLATERAL_ZKC exec "$SCRIPT_DIR/container" "${CONTAINER_ARGS[@]}" diff --git a/crates/support-scripts/ctl/upload b/crates/support-scripts/ctl/upload index 5f83267346..9c7cc03251 100755 --- a/crates/support-scripts/ctl/upload +++ b/crates/support-scripts/ctl/upload @@ -1,12 +1,17 @@ #!/usr/bin/env bash -PINATA_JWT="" +# Configuration normally arrives through the environment so the Pinata token does not appear in +# process arguments. Keep the flags for direct, backwards-compatible use. while [[ $# -gt 0 ]]; do case $1 in --pinata-jwt) PINATA_JWT="$2" shift 2 ;; + --ipfs-gateway-url) + IPFS_GATEWAY_URL="$2" + shift 2 + ;; *) echo "Unknown argument: $1" exit 1 @@ -16,9 +21,6 @@ done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTAINER_ARGS=("./scripts/container/upload.sh") +export PINATA_JWT IPFS_GATEWAY_URL -if [[ -n "$PINATA_JWT" ]]; then - CONTAINER_ARGS+=("--pinata-jwt" "$PINATA_JWT") -fi - -exec "$SCRIPT_DIR/container" "${CONTAINER_ARGS[@]}" \ No newline at end of file +exec "$SCRIPT_DIR/container" "${CONTAINER_ARGS[@]}" diff --git a/crates/support-scripts/src/program_risc0.rs b/crates/support-scripts/src/program_risc0.rs index 611b7e3449..e6ff75c0f0 100644 --- a/crates/support-scripts/src/program_risc0.rs +++ b/crates/support-scripts/src/program_risc0.rs @@ -6,6 +6,7 @@ use std::env; +use crate::utils::run_bash_script_with_env; use crate::{ensure_script_exists, run_bash_script, traits::ProgramSupportApi}; use anyhow::{bail, Result}; use async_trait::async_trait; @@ -34,54 +35,54 @@ impl ProgramSupportApi for ProgramSupportRisc0 { bail!("start must be run with risc0 config available"); }; - let mut args: Vec = vec![ - "--risc0-dev-mode".into(), + let mut environment = vec![( + "RISC0_DEV_MODE".to_owned(), risc0_config.risc0_dev_mode.to_string(), - ]; + )]; // Boundless support if let Some(boundless) = &risc0_config.boundless { - args.extend_from_slice(&[ - "--rpc-url".into(), - boundless.rpc_url.clone(), - "--private-key".into(), - boundless.private_key.clone(), + environment.extend([ + ("RPC_URL".to_owned(), boundless.rpc_url.clone()), + ("PRIVATE_KEY".to_owned(), boundless.private_key.clone()), ]); if let Some(jwt) = &boundless.pinata_jwt { - args.extend_from_slice(&["--pinata-jwt".into(), jwt.clone()]); + environment.push(("PINATA_JWT".to_owned(), jwt.clone())); + } + + if let Some(url) = &boundless.ipfs_gateway_url { + environment.push(("IPFS_GATEWAY_URL".to_owned(), url.clone())); } if let Some(url) = &boundless.program_url { - args.extend_from_slice(&["--program-url".into(), url.clone()]); + environment.push(("PROGRAM_URL".to_owned(), url.clone())); } let onchain = if boundless.onchain { "true" } else { "false" }; - args.extend_from_slice(&["--boundless-onchain".into(), onchain.into()]); + environment.push(("BOUNDLESS_ONCHAIN".to_owned(), onchain.to_owned())); - // Offer params โ€” push flag + value as owned Strings if let Some(v) = boundless.min_price_eth { - args.extend_from_slice(&["--boundless-min-price-eth".into(), v.to_string()]); + environment.push(("BOUNDLESS_MIN_PRICE_ETH".to_owned(), v.to_string())); } if let Some(v) = boundless.max_price_eth { - args.extend_from_slice(&["--boundless-max-price-eth".into(), v.to_string()]); + environment.push(("BOUNDLESS_MAX_PRICE_ETH".to_owned(), v.to_string())); } if let Some(v) = boundless.timeout_secs { - args.extend_from_slice(&["--boundless-timeout-secs".into(), v.to_string()]); + environment.push(("BOUNDLESS_TIMEOUT_SECS".to_owned(), v.to_string())); } if let Some(v) = boundless.lock_timeout_secs { - args.extend_from_slice(&["--boundless-lock-timeout-secs".into(), v.to_string()]); + environment.push(("BOUNDLESS_LOCK_TIMEOUT_SECS".to_owned(), v.to_string())); } if let Some(v) = boundless.ramp_up_secs { - args.extend_from_slice(&["--boundless-ramp-up-secs".into(), v.to_string()]); + environment.push(("BOUNDLESS_RAMP_UP_SECS".to_owned(), v.to_string())); } if let Some(v) = boundless.lock_collateral_zkc { - args.extend_from_slice(&["--boundless-lock-collateral-zkc".into(), v.to_string()]); + environment.push(("BOUNDLESS_LOCK_COLLATERAL_ZKC".to_owned(), v.to_string())); } } - let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - run_bash_script(&cwd, &script, &arg_refs).await?; + run_bash_script_with_env(&cwd, &script, &[], &environment).await?; Ok(()) } @@ -91,17 +92,20 @@ impl ProgramSupportApi for ProgramSupportRisc0 { let script = cwd.join(".interfold/support/ctl/upload"); ensure_script_exists(&script).await?; - let mut args = vec![]; + let mut environment = vec![]; if let Some(risc0_config) = self.0.risc0() { if let Some(boundless) = &risc0_config.boundless { if let Some(jwt) = &boundless.pinata_jwt { - args.extend(["--pinata-jwt", jwt.as_str()]); + environment.push(("PINATA_JWT".to_owned(), jwt.clone())); + } + if let Some(url) = &boundless.ipfs_gateway_url { + environment.push(("IPFS_GATEWAY_URL".to_owned(), url.clone())); } } } - run_bash_script(&cwd, &script, &args).await?; + run_bash_script_with_env(&cwd, &script, &[], &environment).await?; Ok(()) } } diff --git a/crates/support-scripts/src/utils.rs b/crates/support-scripts/src/utils.rs index 73eff66c91..7d22fcbdee 100644 --- a/crates/support-scripts/src/utils.rs +++ b/crates/support-scripts/src/utils.rs @@ -4,20 +4,37 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use duct::cmd; use std::path::{Path, PathBuf}; use tokio::fs; use tokio::signal; pub async fn run_bash_script(cwd: &PathBuf, script: &Path, args: &[&str]) -> Result<()> { + run_bash_script_with_env(cwd, script, args, &[]).await +} + +pub async fn run_bash_script_with_env( + cwd: &PathBuf, + script: &Path, + args: &[&str], + environment: &[(String, String)], +) -> Result<()> { let mut cmd_args = vec!["bash".to_string(), script.to_string_lossy().to_string()]; cmd_args.extend(args.iter().map(|s| s.to_string())); // Note this will not end up on shell history - let expression = cmd("bash", &cmd_args[1..]).dir(cwd); + // `duct` includes every command argument in its checked-process error. Some support-script + // arguments contain credentials, so inspect the exit status ourselves and keep arguments out + // of every error path. + let mut expression = cmd("bash", &cmd_args[1..]).dir(cwd).unchecked(); + for (name, value) in environment { + expression = expression.env(name, value); + } - let handle = expression.start()?; + let handle = expression + .start() + .map_err(|_| anyhow!("failed to start {}", script.display()))?; tokio::select! { result = async { handle.wait() } => { @@ -29,7 +46,7 @@ pub async fn run_bash_script(cwd: &PathBuf, script: &Path, args: &[&str]) -> Res bail!("{} failed with exit code: {:?}", script.display(), output.status.code()); } } - Err(e) => Err(e.into()), + Err(_) => Err(anyhow!("failed while waiting for {}", script.display())), } } _ = signal::ctrl_c() => { @@ -39,6 +56,64 @@ pub async fn run_bash_script(cwd: &PathBuf, script: &Path, args: &[&str]) -> Res } } +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[tokio::test] + async fn failed_script_does_not_expose_arguments() -> Result<()> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let directory = std::env::temp_dir().join(format!( + "interfold-support-script-test-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).await?; + let script = directory.join("fail.sh"); + fs::write(&script, "exit 17\n").await?; + + let secret = "credential-that-must-not-appear"; + let error = run_bash_script(&directory, &script, &["--private-key", secret]) + .await + .expect_err("the script must fail"); + let message = error.to_string(); + + assert!(message.contains("failed with exit code: Some(17)")); + assert!(!message.contains(secret)); + fs::remove_dir_all(directory).await?; + Ok(()) + } + + #[tokio::test] + async fn passes_configuration_through_the_child_environment() -> Result<()> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let directory = std::env::temp_dir().join(format!( + "interfold-support-script-test-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).await?; + let script = directory.join("check-env.sh"); + fs::write( + &script, + "test \"$PRIVATE_KEY\" = \"credential-from-environment\"\n", + ) + .await?; + + run_bash_script_with_env( + &directory, + &script, + &[], + &[( + "PRIVATE_KEY".to_owned(), + "credential-from-environment".to_owned(), + )], + ) + .await?; + fs::remove_dir_all(directory).await?; + Ok(()) + } +} + pub async fn ensure_script_exists(script_path: &PathBuf) -> Result<()> { if !fs::try_exists(script_path).await? { bail!("Invalid or corrupted project. This command can only be run from within a valid Interfold project."); diff --git a/crates/support-scripts/tests/container.sh b/crates/support-scripts/tests/container.sh index d241ec0fcd..8cc9eea16b 100755 --- a/crates/support-scripts/tests/container.sh +++ b/crates/support-scripts/tests/container.sh @@ -27,7 +27,11 @@ interfold() { if [[ "${1:-}" != "rev" ]]; then return 1 fi - printf '%s\n' "$TEST_REVISION" + if [[ "$PWD" == "/" ]]; then + printf '%s\n' "$TEST_REVISION" + else + printf '%s\n' "running-node-revision" + fi } docker() { @@ -66,7 +70,10 @@ run_case() { local local_image_status="$1" local pull_status="$2" local image_repository="${3:-}" - local start_args=("${@:4}") + local start_args=() + if (( $# > 3 )); then + start_args=("${@:4}") + fi local directory directory=$(mktemp -d "$TEST_PARENT/e3-support-test.XXXXXX") @@ -75,6 +82,12 @@ run_case() { set +e CASE_OUTPUT=$( cd "$directory" || exit 1 + # Keep each case independent from credentials and support settings in the parent process. + # GitHub Actions defines PRIVATE_KEY for other jobs in this workflow. + unset RISC0_DEV_MODE RPC_URL PRIVATE_KEY PINATA_JWT IPFS_GATEWAY_URL PROGRAM_URL + unset BOUNDLESS_ONCHAIN BOUNDLESS_MIN_PRICE_ETH BOUNDLESS_MAX_PRICE_ETH + unset BOUNDLESS_TIMEOUT_SECS BOUNDLESS_LOCK_TIMEOUT_SECS BOUNDLESS_RAMP_UP_SECS + unset BOUNDLESS_LOCK_COLLATERAL_ZKC export LOCAL_IMAGE_STATUS="$local_image_status" export PULL_STATUS="$pull_status" if [[ -n "$image_repository" ]]; then @@ -82,7 +95,11 @@ run_case() { else unset E3_SUPPORT_IMAGE_REPOSITORY fi - bash "$START_SCRIPT" --risc0-dev-mode false "${start_args[@]}" 2>&1 + if (( ${#start_args[@]} > 0 )); then + bash "$START_SCRIPT" --risc0-dev-mode false "${start_args[@]}" 2>&1 + else + bash "$START_SCRIPT" --risc0-dev-mode false 2>&1 + fi ) CASE_STATUS=$? set -e @@ -127,6 +144,7 @@ run_case 0 0 "registry.example/e3-support" assert_contains "registry.example/e3-support:$TEST_REVISION" run_case 0 0 "" \ + --ipfs-gateway-url https://dedicated.example \ --boundless-min-price-eth 0.0001 \ --boundless-max-price-eth 0.004 \ --boundless-timeout-secs 2700 \ @@ -134,11 +152,38 @@ run_case 0 0 "" \ --boundless-ramp-up-secs 300 \ --boundless-lock-collateral-zkc 3.5 [[ "$CASE_STATUS" -eq 0 ]] -assert_contains "--boundless-min-price-eth 0.0001" -assert_contains "--boundless-max-price-eth 0.004" -assert_contains "--boundless-timeout-secs 2700" -assert_contains "--boundless-lock-timeout-secs 1200" -assert_contains "--boundless-ramp-up-secs 300" -assert_contains "--boundless-lock-collateral-zkc 3.5" +assert_contains "--env IPFS_GATEWAY_URL" +assert_contains "--env BOUNDLESS_MIN_PRICE_ETH" +assert_contains "--env BOUNDLESS_MAX_PRICE_ETH" +assert_contains "--env BOUNDLESS_TIMEOUT_SECS" +assert_contains "--env BOUNDLESS_LOCK_TIMEOUT_SECS" +assert_contains "--env BOUNDLESS_RAMP_UP_SECS" +assert_contains "--env BOUNDLESS_LOCK_COLLATERAL_ZKC" + +run_case 0 0 "" \ + --rpc-url https://rpc.example \ + --private-key credential-that-must-not-appear \ + --pinata-jwt token-that-must-not-appear +[[ "$CASE_STATUS" -eq 0 ]] +assert_contains "--env RPC_URL" +assert_contains "--env PRIVATE_KEY" +assert_contains "--env PINATA_JWT" +assert_not_contains "credential-that-must-not-appear" +assert_not_contains "token-that-must-not-appear" + +UPLOAD_DIRECTORY=$(mktemp -d "$TEST_PARENT/e3-support-test.XXXXXX") +TEST_DIRECTORIES+=("$UPLOAD_DIRECTORY") +PROGRAM_DIRECTORY="$UPLOAD_DIRECTORY/target/riscv-guest/methods/guests/riscv32im-risc0-zkvm-elf/release" +mkdir -p "$PROGRAM_DIRECTORY" +printf 'cached-program' > "$PROGRAM_DIRECTORY/program.bin" +sha256sum "$PROGRAM_DIRECTORY/program.bin" | awk '{print $1}' > "$UPLOAD_DIRECTORY/target/.program_hash" +printf 'https://gateway.pinata.cloud/ipfs/bafytestcid\n' > "$UPLOAD_DIRECTORY/target/.program_url" +( + cd "$UPLOAD_DIRECTORY" + bash "$REPOSITORY_ROOT/crates/support/scripts/container/upload.sh" \ + --pinata-jwt test-jwt \ + --ipfs-gateway-url https://dedicated.example/ +) +[[ "$(cat "$UPLOAD_DIRECTORY/target/.program_url")" == "https://dedicated.example/ipfs/bafytestcid" ]] echo "Support image container tests passed." diff --git a/crates/support/Cargo.lock b/crates/support/Cargo.lock index 6bade3789b..8f693c2053 100644 --- a/crates/support/Cargo.lock +++ b/crates/support/Cargo.lock @@ -2985,8 +2985,8 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "e3-bfv-client" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "anyhow", "e3-fhe-params", @@ -3000,8 +3000,8 @@ dependencies = [ [[package]] name = "e3-compute-provider" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "ark-bn254 0.4.0", "ark-ff 0.4.2", @@ -3022,8 +3022,8 @@ dependencies = [ [[package]] name = "e3-fhe-params" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -3040,8 +3040,8 @@ dependencies = [ [[package]] name = "e3-parity-matrix" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "num-bigint", "num-traits", @@ -3051,8 +3051,8 @@ dependencies = [ [[package]] name = "e3-polynomial" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "fhe-math", "num-bigint", @@ -3063,12 +3063,13 @@ dependencies = [ [[package]] name = "e3-safe" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "hex", + "risc0-bigint2", "sha3", "taceo-poseidon2", ] @@ -3143,12 +3144,13 @@ dependencies = [ "fhe", "fhe-traits", "sha2", + "sha3", ] [[package]] name = "e3-zk-helpers" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "anyhow", "ark-bn254 0.5.0", @@ -6058,6 +6060,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "risc0-bigint2" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87f5f7494a2242cead2750b7ce2b8522c1be83dee268479f1c12ed521eaf595" +dependencies = [ + "include_bytes_aligned", + "stability", +] + [[package]] name = "risc0-binfmt" version = "3.0.2" diff --git a/crates/support/Cargo.toml b/crates/support/Cargo.toml index fe6b61bdd3..1a364e3328 100644 --- a/crates/support/Cargo.toml +++ b/crates/support/Cargo.toml @@ -13,7 +13,7 @@ e3-support-app = { path = "./app" } e3-support-host = { path = "./host" } e3-user-program = { path = "./program" } e3-support-types = { path = "./types" } -e3-fhe-params = { git = "https://github.com/theinterfold/interfold", rev = "5b2ca6619a07aec6104f9b877bff948bd976b6cf" } +e3-fhe-params = { git = "https://github.com/theinterfold/interfold", rev = "5668f4c9ee0992aeb05b320eaa3b998a6c32e525" } methods = { path = "./methods" } alloy-primitives = { version = "1.3", default-features = false, features = [ @@ -39,6 +39,7 @@ risc0-zkp = { version = "=3.0.2", default-features = false } serde = { version = "1.0", features = ["derive", "std"] } serde_json = "=1.0.145" sha2 = "=0.10.9" +sha3 = "=0.10.8" # The Interfold revision must match for all Interfold dependencies in this workspace. # # The Interfold crates below are pinned by revision rather than by path because this workspace is @@ -47,32 +48,23 @@ sha2 = "=0.10.9" # so anything outside that directory is unreachable from the guest build. # # The pin therefore decides which compute-provider code the guest runs, and moving it changes the -# image ID. Bump it only together with a rebuilt crates/support/contracts/ImageID.sol and a -# refreshed ImageID.stamp.json. `pnpm check:image-id` asserts that every pin here names one -# revision and warns when that revision is not an ancestor of HEAD. +# image ID. Bump it only together with a reproducible rebuild of +# crates/support/contracts/ImageID.sol and a complete provenance manifest. # -# Why this revision: 5b2ca661 is the tip of the branch that fixed compute-input binding, and the -# guest has to run that code for the fix to mean anything on chain. +# Why this revision: 5668f4c9 contains the compute-input binding, secure-parameter fixes, +# shared-parameter path, and accelerated SAFE commitment path that the guest must run. # # It replaces c2097da6, the scope commit of the 2026-08-17 Zenith protocol audit. Sitting at the # audited baseline read as the conservative choice and was not: the audit covered six Solidity # files and no Rust at all, so crates/compute-provider and the guest were outside its scope, and # outside the mitigation review at c64bcfb8 as well. See packages/interfold-contracts/audits/README.md. -# Holding the old pin bought reproducibility rather than assurance, while excluding real fixes to -# the code the guest runs โ€” among them 5213e23d, which derives the guest input root from the -# processed ciphertexts. `pnpm check:image-id` still warns that the pin has moved off the audit -# baseline, which is true and worth seeing; it does not mean the pin is wrong. -# -# This revision is interim. It is a branch tip, and this repository squash-merges, so the commit -# stops being referenced by anything once the branch is deleted and eventually stops resolving โ€” -# at which point the guest cannot be built at all. Re-pin to the commit a release tag names as -# soon as one contains this code, and record the tag here. `pnpm check:image-id` warns until then. -# It is deliberately a `rev` and not a `tag`: a tag can be moved, and the pin decides which code -# the guest runs, so it should be content-addressed even when it names a release. +# Holding the old audit pin bought reproducibility rather than assurance while excluding real fixes +# to the code the guest runs. The revision remains content-addressed even though a permanent main +# commit retains it; a movable branch or tag is not an acceptable guest dependency. fhe = { package = "fhe", git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } fhe-traits = { git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } fhe-util = { git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } -e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "5b2ca6619a07aec6104f9b877bff948bd976b6cf" } +e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "5668f4c9ee0992aeb05b320eaa3b998a6c32e525" } tokio = { version = "=1.46.1", features = ["full"] } rand = { version = "=0.9.2" } tracing-subscriber = { version = "=0.3.20", features = ["env-filter"] } diff --git a/crates/support/README.md b/crates/support/README.md index 109683066e..38edb4538d 100644 --- a/crates/support/README.md +++ b/crates/support/README.md @@ -100,15 +100,17 @@ program: rpc_url: 'https://sepolia.base.org' # or your RPC URL private_key: '${PRIVATE_KEY}' # use env var for secrets! pinata_jwt: '${PINATA_JWT}' - program_url: 'https://gateway.pinata.cloud/ipfs/Qm...' # after upload (Step 3) + # Use a dedicated public gateway. Boundless provers download the complete input from it. + ipfs_gateway_url: 'https://your-gateway.mypinata.cloud' + program_url: 'https://your-gateway.mypinata.cloud/ipfs/Qm...' # after upload (Step 3) onchain: true # Optional auction parameters with their built-in defaults: # min_price_eth: 0.00005 - # max_price_eth: 0.002 - # timeout_secs: 600 - # lock_timeout_secs: 300 - # ramp_up_secs: 60 - # lock_collateral_zkc: 2.0 + # max_price_eth: 0.004 + # timeout_secs: 28800 + # lock_timeout_secs: 14400 + # ramp_up_secs: 7200 + # lock_collateral_zkc: 100.0 ``` ### Step 2: Compile the RISC Zero Guest Program @@ -152,14 +154,15 @@ The order matters: 3. Update the `crates/support` call sites that track the crate's API โ€” the compiler will point at them, since they built against the old revision until now. -4. Rebuild the guest against the pinned code with the RISC Zero Docker builder, and commit the - regenerated `crates/support/contracts/ImageID.sol`. +4. Rebuild the guest against the pinned code with `RISC0_USE_DOCKER=1`. The pinned Docker builder + regenerates `crates/support/contracts/ImageID.sol`; ordinary native builds deliberately do not + touch that production trust anchor. 5. Redeploy `Risc0BfvCiphertextVerifier`, and every E3 program that stores its own image ID. -Skipping step 4 leaves a deployed verifier that accepts a guest no longer matching this tree, and -nothing in the repository detects it: there is no longer an automated check that the committed image -ID is the one the current sources produce, so this order is a convention rather than something CI -enforces. The reviewer-facing procedure is `docs/pages/verifying-the-compute-provider.mdx`. +Skipping step 4 leaves a deployed verifier that accepts a guest that does not match this tree. The +provenance manifest records the committed image ID and compares it with a deployed verifier. It does +not rebuild the guest. Therefore, this build order is mandatory. See +`docs/pages/verifying-the-compute-provider.mdx` for the complete verification procedure. ### Step 3: Upload Program to IPFS (Pinata) @@ -217,13 +220,14 @@ This triggers: 1. Payment of the quoted fee in the active fee token 2. Committee selection via sortition -3. DKG (C0-C5 proofs) โ†’ committee public key published on-chain +3. DKG (C0-C5 proofs) โ†’ committee public key published in bounded Ethereum event chunks 4. Stage โ†’ `KeyPublished` ### Step 7: Encrypt Inputs & Submit to Compute Provider -The instigator encrypts data under the committee's aggregate public key, then POSTs to the program -server: +The instigator encrypts data under the committee's aggregate public key. The application publishes +large ciphertexts to its configured data-availability layer before it commits their references on +Ethereum. The compute request still POSTs the retrieved bytes to the program server: ```bash curl -X POST http://localhost:13151/run_compute \ @@ -254,10 +258,11 @@ server runs the same computation and returns a fake proof instead. ### Step 8: Webhook Handler Publishes On-Chain -The callback server (e.g., CRISP) receives the webhook and calls: +The callback server publishes the aggregate ciphertext to the configured data-availability layer, +waits for its Ethereum-verifiable receipt, and calls: ```solidity -interfold.publishCiphertextOutput(e3Id, ciphertextOutput, ciphertextCommitment, proof); +interfold.publishCiphertextOutput(e3Id, encodedOutputReference); ``` The proof binds nine values. Five identify the context: the chain, the Interfold contract, the E3, @@ -278,18 +283,24 @@ rewards distributed. `build_offer()` reads these environment variables. Defaults: -| Parameter | Env Var | Default | Description | -| ------------ | ------------------------------- | --------- | ---------------------------- | -| Min price | `BOUNDLESS_MIN_PRICE_ETH` | `0.00005` | Starting price in ETH | -| Max price | `BOUNDLESS_MAX_PRICE_ETH` | `0.002` | Maximum price in ETH | -| Timeout | `BOUNDLESS_TIMEOUT_SECS` | `600` | Total request lifetime (sec) | -| Lock timeout | `BOUNDLESS_LOCK_TIMEOUT_SECS` | `300` | Prover lock duration (sec) | -| Ramp-up | `BOUNDLESS_RAMP_UP_SECS` | `60` | Price ramp-up period (sec) | -| Collateral | `BOUNDLESS_LOCK_COLLATERAL_ZKC` | `2.0` | ZKC locked per request | +| Parameter | Env Var | Default | Description | +| ------------ | ------------------------------- | --------- | ----------------------------- | +| Min price | `BOUNDLESS_MIN_PRICE_ETH` | `0.00005` | Starting price in ETH | +| Max price | `BOUNDLESS_MAX_PRICE_ETH` | `0.004` | Maximum auction price in ETH | +| Timeout | `BOUNDLESS_TIMEOUT_SECS` | `28800` | Total request lifetime (sec) | +| Lock timeout | `BOUNDLESS_LOCK_TIMEOUT_SECS` | `14400` | Primary prover deadline (sec) | +| Ramp-up | `BOUNDLESS_RAMP_UP_SECS` | `7200` | Price ramp-up period (sec) | +| Collateral | `BOUNDLESS_LOCK_COLLATERAL_ZKC` | `100.0` | ZKC locked per request | Set the matching fields under `program.risc0.boundless` to change these values. The CLI sends each configured field through the support launcher to the container. Leave a field unset to use its -default. +default. The secure CRISP rehearsal used about 29 billion cycles. On September 3, 2026, a six-month +sample contained 421 fulfilled Boundless orders between 20 and 40 billion cycles. Their median +accepted price was about `0.00136 ETH`, and 95% were accepted by about `0.00379 ETH`. `0.002 ETH` +covered about 82% of the sample, while `0.004 ETH` covered about 96%. All comparable orders in the +sample used `100 ZKC` collateral. The `0.004 ETH` default is an auction ceiling, not the expected +charge. The requester pays the accepted lock price. This sample does not guarantee future +acceptance. Review the ceiling before using a materially larger guest or input set. --- diff --git a/crates/support/app/src/main.rs b/crates/support/app/src/main.rs index 870b2a3ce4..0dc9f04386 100644 --- a/crates/support/app/src/main.rs +++ b/crates/support/app/src/main.rs @@ -7,10 +7,11 @@ use actix_web::{middleware::Logger, web, App, HttpResponse, HttpServer, Result as ActixResult}; use e3_compute_provider::FHEInputs; use e3_compute_provider::PublishedData; -use std::sync::{Arc, OnceLock}; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use e3_support_types::{ComputeDomain, ComputeRequest, WebhookPayload}; use serde::Serialize; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; #[derive(Serialize, Debug)] struct ProcessingResponse { @@ -45,25 +46,47 @@ async fn call_webhook(callback_url: &str, payload: &WebhookPayload) -> anyhow::R println!("Sending webhook to: {}", callback_url); - let response = reqwest::Client::new() - .post(callback_url) - .json(payload) - .send() - .await?; - - println!("Webhook response status: {}", response.status()); - if !response.status().is_success() { - let error_body = response.text().await?; - println!("Webhook error response: {}", error_body); - return Err(anyhow::anyhow!( - "Webhook failed with status and body: {}", - error_body - )); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let mut last_error = None; + + for attempt in 1_u32..=5 { + match client.post(callback_url).json(payload).send().await { + Ok(response) if response.status().is_success() => { + println!("Webhook response status: {}", response.status()); + println!("โœ“ Webhook called successfully for E3 {}", e3_id); + return Ok(()); + } + Ok(response) => { + let status = response.status(); + let retryable = status.is_server_error() + || status == reqwest::StatusCode::REQUEST_TIMEOUT + || status == reqwest::StatusCode::TOO_MANY_REQUESTS; + let error_body = response + .text() + .await + .unwrap_or_else(|error| format!("could not read response body: {error}")); + let error = anyhow::anyhow!("webhook returned {status}: {error_body}"); + if !retryable { + return Err(error); + } + last_error = Some(error); + } + Err(error) => last_error = Some(error.into()), + } + + if attempt < 5 { + let delay = Duration::from_secs(1_u64 << (attempt - 1)); + println!( + "Webhook attempt {attempt} failed; retrying in {} seconds", + delay.as_secs() + ); + tokio::time::sleep(delay).await; + } } - response.error_for_status()?; - println!("โœ“ Webhook called successfully for E3 {}", e3_id); - Ok(()) + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("webhook delivery failed"))) } async fn run_computation_async( @@ -286,7 +309,9 @@ const MAX_PARENT: u64 = (1u64 << (8 * PARENT_BYTES as u64)) - 1; /// metadata the E3 program never published, and the only symptom is an input root the guest /// derives and the contract rejects. fn published_from(req: &ComputeRequest) -> ActixResult> { - if req.input_commitments.is_empty() && req.input_slots.is_empty() && req.input_parents.is_empty() + if req.input_commitments.is_empty() + && req.input_slots.is_empty() + && req.input_parents.is_empty() { return Ok(Vec::new()); } diff --git a/crates/support/contracts/ImageID.sol b/crates/support/contracts/ImageID.sol index beb9e00702..79e1a2fe44 100644 --- a/crates/support/contracts/ImageID.sol +++ b/crates/support/contracts/ImageID.sol @@ -19,5 +19,5 @@ pragma solidity ^0.8.20; library ImageID { - bytes32 public constant PROGRAM_ID = bytes32(0x7ed0ddeb0cafa64228b51399dff5ea00696c2617f28636b05bd67e6c1506eac3); + bytes32 public constant PROGRAM_ID = bytes32(0x9d3e21fd7cc08e629cb467e5ca6bfc8e1a645b483bfd91b6b1648ced259110fc); } diff --git a/crates/support/host/src/lib.rs b/crates/support/host/src/lib.rs index 2ccebf1119..3fbbb0cc3a 100644 --- a/crates/support/host/src/lib.rs +++ b/crates/support/host/src/lib.rs @@ -80,18 +80,18 @@ impl ComputeProvider for BoundlessProvider { } } -fn encode_input(input: &[u8]) -> Result, Error> { - Ok(bytemuck::pod_collect_to_vec(&risc0_zkvm::serde::to_vec( - input, - )?)) -} - fn encode_journal(result: &ComputeJournal) -> Result, Error> { Ok(bytemuck::pod_collect_to_vec(&risc0_zkvm::serde::to_vec( result, )?)) } +fn encode_guest_input(input: &ComputeGuestInput) -> Result, Error> { + // Boundless passes these bytes directly to guest stdin. A RISC Zero serde wrapper would store + // each bincode byte in a 32-bit word and would add no integrity or decoding guarantee. + serialize(input).context("Failed to serialize guest input") +} + /// Dev mode: return fake proof without executing fn fake_prove( input: &ComputeInput, @@ -130,51 +130,103 @@ fn to_output_error(e: E) -> BoundlessOutput { } } -/// Read optional environment variable as f64, returning None if unset or invalid. -fn env_opt_f64(key: &str) -> Option { - std::env::var(key).ok().and_then(|v| v.parse().ok()) +/// Read an optional floating-point environment variable. +fn env_opt_f64(key: &str) -> Result> { + match std::env::var(key) { + Ok(value) => Ok(Some( + value + .parse() + .with_context(|| format!("{key} must be a number"))?, + )), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to read {key}")), + } } -/// Read optional environment variable as u64 (seconds), returning None if unset or invalid. -fn env_opt_secs(key: &str) -> Option { - std::env::var(key).ok().and_then(|v| v.parse().ok()) +/// Read an optional whole-second environment variable. +fn env_opt_secs(key: &str) -> Result> { + match std::env::var(key) { + Ok(value) => { + Ok(Some(value.parse().with_context(|| { + format!("{key} must be a whole number of seconds") + })?)) + } + Err(std::env::VarError::NotPresent) => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to read {key}")), + } } +const DEFAULT_BOUNDLESS_MIN_PRICE_ETH: &str = "0.00005"; +const DEFAULT_BOUNDLESS_MAX_PRICE_ETH: &str = "0.004"; +const DEFAULT_BOUNDLESS_TIMEOUT_SECS: u64 = 8 * 60 * 60; +const DEFAULT_BOUNDLESS_LOCK_TIMEOUT_SECS: u64 = 4 * 60 * 60; +const DEFAULT_BOUNDLESS_RAMP_UP_SECS: u64 = 2 * 60 * 60; +const DEFAULT_BOUNDLESS_LOCK_COLLATERAL_ZKC: f64 = 100.0; + /// Build the OfferParams from environment variables, using sensible defaults. fn build_offer() -> Result { - let min_price = if let Some(v) = env_opt_f64("BOUNDLESS_MIN_PRICE_ETH") { - if v.is_sign_negative() || v.is_nan() { + build_offer_from_values( + env_opt_f64("BOUNDLESS_MIN_PRICE_ETH")?, + env_opt_f64("BOUNDLESS_MAX_PRICE_ETH")?, + env_opt_secs("BOUNDLESS_TIMEOUT_SECS")?, + env_opt_secs("BOUNDLESS_LOCK_TIMEOUT_SECS")?, + env_opt_secs("BOUNDLESS_RAMP_UP_SECS")?, + env_opt_f64("BOUNDLESS_LOCK_COLLATERAL_ZKC")?, + ) +} + +fn build_offer_from_values( + min_price_eth: Option, + max_price_eth: Option, + timeout_secs: Option, + lock_timeout_secs: Option, + ramp_up_secs: Option, + lock_collateral_zkc: Option, +) -> Result { + let min_price = if let Some(value) = min_price_eth { + if value.is_sign_negative() || !value.is_finite() { anyhow::bail!( "BOUNDLESS_MIN_PRICE_ETH must be a non-negative number, got: {}", - v + value ); } - parse_ether(&format!("{}", v)).context("Invalid BOUNDLESS_MIN_PRICE_ETH")? + parse_ether(&value.to_string()).context("Invalid BOUNDLESS_MIN_PRICE_ETH")? } else { - parse_ether("0.00005").context("Invalid default min_price")? + parse_ether(DEFAULT_BOUNDLESS_MIN_PRICE_ETH).context("Invalid default min_price")? }; - let max_price = if let Some(v) = env_opt_f64("BOUNDLESS_MAX_PRICE_ETH") { - if v.is_sign_negative() || v.is_nan() { + let max_price = if let Some(value) = max_price_eth { + if value.is_sign_negative() || !value.is_finite() { anyhow::bail!( "BOUNDLESS_MAX_PRICE_ETH must be a non-negative number, got: {}", - v + value ); } - parse_ether(&format!("{}", v)).context("Invalid BOUNDLESS_MAX_PRICE_ETH")? + parse_ether(&value.to_string()).context("Invalid BOUNDLESS_MAX_PRICE_ETH")? } else { - parse_ether("0.002").context("Invalid default max_price")? + parse_ether(DEFAULT_BOUNDLESS_MAX_PRICE_ETH).context("Invalid default max_price")? }; - let timeout = env_opt_secs("BOUNDLESS_TIMEOUT_SECS") - .map(|v| v as u32) - .unwrap_or(10 * 60); - let lock_timeout = env_opt_secs("BOUNDLESS_LOCK_TIMEOUT_SECS") - .map(|v| v as u32) - .unwrap_or(5 * 60); - let ramp_up = env_opt_secs("BOUNDLESS_RAMP_UP_SECS") - .map(|v| v as u32) - .unwrap_or(1 * 60); - let zkc = env_opt_f64("BOUNDLESS_LOCK_COLLATERAL_ZKC").unwrap_or(2.0); - if zkc.is_sign_negative() || zkc.is_nan() { + + if min_price > max_price { + anyhow::bail!("BOUNDLESS_MIN_PRICE_ETH must not exceed BOUNDLESS_MAX_PRICE_ETH"); + } + + let timeout = u32::try_from(timeout_secs.unwrap_or(DEFAULT_BOUNDLESS_TIMEOUT_SECS)) + .context("BOUNDLESS_TIMEOUT_SECS exceeds the supported range")?; + let lock_timeout = + u32::try_from(lock_timeout_secs.unwrap_or(DEFAULT_BOUNDLESS_LOCK_TIMEOUT_SECS)) + .context("BOUNDLESS_LOCK_TIMEOUT_SECS exceeds the supported range")?; + let ramp_up = u32::try_from(ramp_up_secs.unwrap_or(DEFAULT_BOUNDLESS_RAMP_UP_SECS)) + .context("BOUNDLESS_RAMP_UP_SECS exceeds the supported range")?; + + if lock_timeout == 0 || lock_timeout >= timeout { + anyhow::bail!("BOUNDLESS_LOCK_TIMEOUT_SECS must be greater than zero and less than BOUNDLESS_TIMEOUT_SECS"); + } + if ramp_up > lock_timeout { + anyhow::bail!("BOUNDLESS_RAMP_UP_SECS must not exceed BOUNDLESS_LOCK_TIMEOUT_SECS"); + } + + let zkc = lock_collateral_zkc.unwrap_or(DEFAULT_BOUNDLESS_LOCK_COLLATERAL_ZKC); + if zkc.is_sign_negative() || !zkc.is_finite() { anyhow::bail!( "BOUNDLESS_LOCK_COLLATERAL_ZKC must be a non-negative number, got: {}", zkc @@ -254,8 +306,7 @@ async fn boundless_prove_inner( domain: domain.clone(), input: input.clone(), }; - let serialized_input = serialize(&guest_input).context("Failed to serialize guest input")?; - let input_bytes = encode_input(&serialized_input).context("Failed to encode input")?; + let input_bytes = encode_guest_input(&guest_input)?; let program_url = std::env::var("PROGRAM_URL").ok(); let stdin_size = input_bytes.len(); @@ -409,7 +460,7 @@ impl ComputeProvider for Risc0Provider { domain: self.domain.clone(), input: input.clone(), }; - let encoded_input = encode_input(&serialize(&guest_input).unwrap()).unwrap(); + let encoded_input = encode_guest_input(&guest_input).unwrap(); let env = ExecutorEnv::builder() .write_slice(&encoded_input) .build() @@ -522,6 +573,7 @@ pub fn encode_compute_proof( #[cfg(test)] mod tests { use super::*; + use bincode::deserialize; use risc0_zkvm::sha::{Impl, Sha256}; fn risc0_vec32(value: &[u8]) -> Vec { @@ -533,6 +585,79 @@ mod tests { encoded } + #[test] + fn boundless_offer_defaults_fit_secure_compute() { + let offer = build_offer_from_values(None, None, None, None, None, None).unwrap(); + + assert_eq!( + offer.min_price, + Some(parse_ether(DEFAULT_BOUNDLESS_MIN_PRICE_ETH).unwrap()) + ); + assert_eq!( + offer.max_price, + Some(parse_ether(DEFAULT_BOUNDLESS_MAX_PRICE_ETH).unwrap()) + ); + assert_eq!(offer.timeout, Some(DEFAULT_BOUNDLESS_TIMEOUT_SECS as u32)); + assert_eq!( + offer.lock_timeout, + Some(DEFAULT_BOUNDLESS_LOCK_TIMEOUT_SECS as u32) + ); + assert_eq!( + offer.ramp_up_period, + Some(DEFAULT_BOUNDLESS_RAMP_UP_SECS as u32) + ); + assert_eq!( + offer.lock_collateral, + Some(parse_units("100", 18).unwrap().into()) + ); + } + + #[test] + fn boundless_offer_rejects_invalid_deadlines() { + let error = build_offer_from_values(None, None, Some(60), Some(60), None, None) + .expect_err("equal lock and total deadlines must fail"); + + assert!(error + .to_string() + .contains("BOUNDLESS_LOCK_TIMEOUT_SECS must be greater than zero")); + } + + #[test] + fn guest_input_uses_direct_bincode() { + let input = ComputeGuestInput { + domain: ComputeDomain::new( + 31_337, + "0x1111111111111111111111111111111111111111", + "7", + &[0x22; 32], + &[0x33; 32], + ) + .unwrap(), + input: ComputeInput { + fhe_inputs: FHEInputs { + ciphertexts: vec![(vec![0xaa; 32], 0)], + params: vec![0xbb; 16], + }, + published: vec![PublishedData { + commitment: Some([0xcc; 32]), + metadata: vec![0xdd; 25], + }], + }, + }; + + let encoded = encode_guest_input(&input).unwrap(); + let decoded: ComputeGuestInput = deserialize(&encoded).unwrap(); + let legacy: Vec = + bytemuck::pod_collect_to_vec(&risc0_zkvm::serde::to_vec(&encoded).unwrap()); + + assert_eq!(decoded.domain.e3_id, input.domain.e3_id); + assert_eq!( + decoded.input.fhe_inputs.ciphertexts, + input.input.fhe_inputs.ciphertexts + ); + assert_eq!(legacy.len(), encoded.len() * 4 + 4); + } + #[test] fn compute_result_journal_matches_crisp_layout() { let domain = ComputeDomain::new( diff --git a/crates/support/methods/build.rs b/crates/support/methods/build.rs index 560b548260..3ae0be8baa 100644 --- a/crates/support/methods/build.rs +++ b/crates/support/methods/build.rs @@ -22,9 +22,12 @@ use std::{ collections::HashMap, env, fs, path::{Path, PathBuf}, + process::Command, }; -use risc0_build::{embed_methods_with_options, DockerOptionsBuilder, GuestOptionsBuilder}; +use risc0_build::{ + embed_methods_with_options, DockerOptionsBuilder, GuestListEntry, GuestOptionsBuilder, +}; use risc0_build_ethereum::generate_solidity_files; // Paths where the generated Solidity files will be written. @@ -42,19 +45,13 @@ fn use_docker() -> bool { ) } -/// The guest builder image tag, derived from `ARG RISC0_TOOLCHAIN` in `crates/support/Dockerfile`. -/// -/// risc0-build does not read that Dockerfile โ€” it generates its own and, left alone, uses its -/// compiled-in default tag. For risc0-build 3.0.3 that default is `r0.1.88.0`, which carries rustc -/// 1.88, while the guest's dependency tree pins fhe.rs at an MSRV of 1.91.1. The guest then fails -/// to compile inside the container with an MSRV error, and the Dockerfile that says 1.91.1 has no -/// bearing on it. +/// Builds and returns the pinned guest-builder image tag. /// -/// Reading the tag from `ARG RISC0_TOOLCHAIN` is what ties the two together: the toolchain the -/// Dockerfile declares becomes the toolchain the ELF is actually built with, rather than the two -/// being independent values that happen to agree. +/// risc0-build generates its own Dockerfile. Its default image has the wrong Rust version for the +/// pinned fhe.rs dependency and does not contain `protoc`. Build the small checked-in layer first, +/// then tell risc0-build to use it for the deterministic guest build. fn guest_builder_tag(support_dir: &Path) -> String { - let dockerfile = support_dir.join("Dockerfile"); + let dockerfile = support_dir.join("methods/guest-builder.Dockerfile"); println!("cargo:rerun-if-changed={}", dockerfile.display()); let source = fs::read_to_string(&dockerfile).unwrap_or_else(|e| { @@ -76,7 +73,74 @@ fn guest_builder_tag(support_dir: &Path) -> String { dockerfile.display() ); - format!("r0.{toolchain}") + let tag = format!("interfold-r0.{toolchain}-protoc-v1"); + let image = format!("risczero/risc0-guest-builder:{tag}"); + let status = Command::new("docker") + .args([ + "build", + "--platform", + "linux/amd64", + "--load", + "--provenance=false", + "--tag", + &image, + "--file", + ]) + .arg(&dockerfile) + .arg(support_dir) + .status() + .unwrap_or_else(|e| panic!("cannot start Docker to build {image}: {e}")); + assert!( + status.success(), + "failed to build the pinned RISC Zero guest-builder image {image}" + ); + + tag +} + +/// Copies each Docker-built ELF to the path used by the upload command. +fn copy_docker_elves_to_release(guests: &[GuestListEntry]) { + for guest in guests { + let source = Path::new(guest.path.as_ref()); + let docker_dir = source.parent().unwrap_or_else(|| { + panic!( + "Docker guest ELF has no parent directory: {}", + source.display() + ) + }); + if docker_dir.file_name().and_then(|name| name.to_str()) != Some("docker") { + panic!( + "Docker guest ELF is outside the Docker profile: {}", + source.display() + ); + } + + let profile_root = docker_dir.parent().unwrap_or_else(|| { + panic!( + "Docker guest profile has no parent: {}", + docker_dir.display() + ) + }); + let release_dir = profile_root.join("release"); + fs::create_dir_all(&release_dir).unwrap_or_else(|e| { + panic!( + "cannot create upload artifact directory {}: {e}", + release_dir.display() + ) + }); + + let file_name = source + .file_name() + .unwrap_or_else(|| panic!("Docker guest ELF has no file name: {}", source.display())); + let destination = release_dir.join(file_name); + fs::copy(source, &destination).unwrap_or_else(|e| { + panic!( + "cannot copy Docker guest ELF from {} to {}: {e}", + source.display(), + destination.display() + ) + }); + } } fn main() { @@ -86,9 +150,13 @@ fn main() { println!("cargo:rerun-if-env-changed=RISC0_USE_DOCKER"); println!("cargo:rerun-if-changed=build.rs"); let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let reproducible = use_docker(); let mut builder = GuestOptionsBuilder::default(); - if use_docker() { + if reproducible { let support_dir = manifest_dir.join("../"); + // The official guest-builder image is linux/amd64. Set the platform for the nested Docker + // build as well, so Apple Silicon developers produce the same ELF as CI. + env::set_var("DOCKER_DEFAULT_PLATFORM", "linux/amd64"); let docker_options = DockerOptionsBuilder::default() .root_dir(support_dir.clone()) .docker_container_tag(guest_builder_tag(&support_dir)) @@ -101,13 +169,24 @@ fn main() { // Generate Rust source files for the methods crate. let guests = embed_methods_with_options(HashMap::from([("guests", guest_options)])); - if std::env::var("SKIP_SOLIDITY").unwrap_or_default() != "1" { + if reproducible { + copy_docker_elves_to_release(&guests); + } + + // A native guest build is useful for local development, but its image ID can vary with the + // host toolchain. Never let such a build replace the production trust anchor checked into the + // repository. Only the pinned Docker build may update ImageID.sol and Elf.sol. + if reproducible && std::env::var("SKIP_SOLIDITY").unwrap_or_default() != "1" { // Generate Solidity source files for use with Forge. let solidity_opts = risc0_build_ethereum::Options::default() .with_image_id_sol_path(SOLIDITY_IMAGE_ID_PATH) .with_elf_sol_path(SOLIDITY_ELF_PATH); generate_solidity_files(guests.as_slice(), &solidity_opts).unwrap(); + } else if !reproducible { + println!( + "cargo:warning=Skipping Solidity codegen for a non-reproducible native guest build" + ); } else { - println!("cargo:warning=Skipping solidity codegen (SKIP_SOLIDITY set)"); + println!("cargo:warning=Skipping Solidity codegen (SKIP_SOLIDITY set)"); } } diff --git a/crates/support/methods/guest-builder.Dockerfile b/crates/support/methods/guest-builder.Dockerfile new file mode 100644 index 0000000000..4cd48df094 --- /dev/null +++ b/crates/support/methods/guest-builder.Dockerfile @@ -0,0 +1,11 @@ +ARG RISC0_TOOLCHAIN=1.91.1 + +# Keep the base image content-addressed. The tag documents the matching RISC Zero Rust +# toolchain; the digest prevents a registry-side tag change from changing the guest ELF. +FROM risczero/risc0-guest-builder:r0.${RISC0_TOOLCHAIN}@sha256:fafb377a44e1cfca415577c48d2f7012bda99ed36f2fae27f9a663b9fe6048f0 + +# fhe.rs generates its Protobuf bindings while the guest dependency graph is compiled. +# The upstream RISC Zero guest-builder image does not include protoc. +RUN apt-get update && \ + apt-get install -y --no-install-recommends protobuf-compiler=3.12.4-1ubuntu7.22.04.6 && \ + rm -rf /var/lib/apt/lists/* diff --git a/crates/support/methods/guest/Cargo.lock b/crates/support/methods/guest/Cargo.lock index 42f60c737f..4eea77c26d 100644 --- a/crates/support/methods/guest/Cargo.lock +++ b/crates/support/methods/guest/Cargo.lock @@ -1078,8 +1078,8 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "e3-bfv-client" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "anyhow", "e3-fhe-params", @@ -1093,8 +1093,8 @@ dependencies = [ [[package]] name = "e3-compute-provider" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "ark-bn254 0.4.0", "ark-ff 0.4.2", @@ -1115,8 +1115,8 @@ dependencies = [ [[package]] name = "e3-fhe-params" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -1133,8 +1133,8 @@ dependencies = [ [[package]] name = "e3-parity-matrix" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "num-bigint", "num-traits", @@ -1144,8 +1144,8 @@ dependencies = [ [[package]] name = "e3-polynomial" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "fhe-math", "num-bigint", @@ -1156,12 +1156,13 @@ dependencies = [ [[package]] name = "e3-safe" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "hex", + "risc0-bigint2", "sha3", "taceo-poseidon2", ] @@ -1188,12 +1189,13 @@ dependencies = [ "fhe", "fhe-traits", "sha2", + "sha3", ] [[package]] name = "e3-zk-helpers" -version = "0.7.0" -source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" +version = "0.14.0" +source = "git+https://github.com/theinterfold/interfold?rev=5668f4c9ee0992aeb05b320eaa3b998a6c32e525#5668f4c9ee0992aeb05b320eaa3b998a6c32e525" dependencies = [ "anyhow", "ark-bn254 0.5.0", @@ -1586,7 +1588,6 @@ dependencies = [ name = "guests" version = "0.1.0" dependencies = [ - "anyhow", "bincode", "e3-compute-provider", "e3-support-types", @@ -2500,6 +2501,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "risc0-bigint2" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87f5f7494a2242cead2750b7ce2b8522c1be83dee268479f1c12ed521eaf595" +dependencies = [ + "include_bytes_aligned", + "stability", +] + [[package]] name = "risc0-binfmt" version = "3.0.3" diff --git a/crates/support/methods/guest/Cargo.toml b/crates/support/methods/guest/Cargo.toml index 4fd42d3f3b..eb623e33b9 100644 --- a/crates/support/methods/guest/Cargo.toml +++ b/crates/support/methods/guest/Cargo.toml @@ -16,11 +16,10 @@ risc0-zkvm = { version = "=3.0.3", default-features = false, features = ['std'] # This revision decides which compute-provider code runs inside the zkVM, so it decides the image # ID. It must match the pin in crates/support/Cargo.toml. See that file for why the dependency is # pinned by revision instead of by path. -e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "5b2ca6619a07aec6104f9b877bff948bd976b6cf" } +e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "5668f4c9ee0992aeb05b320eaa3b998a6c32e525" } e3-support-types = { path = "../../types" } e3-user-program = { path = "../../program" } bincode = "=1.3.3" -anyhow = "=1.0.98" [profile.release] lto = "thin" diff --git a/crates/support/methods/guest/src/bin/program.rs b/crates/support/methods/guest/src/bin/program.rs index 328d3080ee..be56db1ac3 100644 --- a/crates/support/methods/guest/src/bin/program.rs +++ b/crates/support/methods/guest/src/bin/program.rs @@ -4,21 +4,17 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use anyhow::{Error, Result}; use bincode::deserialize; use e3_support_types::{ComputeGuestInput, ComputeJournal}; use e3_user_program::{fhe_processor, policy}; use risc0_zkvm::guest::env; use std::io::Read; -fn decode_input(input: &[u8]) -> Result, Error> { - Ok(risc0_zkvm::serde::from_slice(input)?) -} - fn main() { let mut input_slice = Vec::::new(); env::stdin().read_to_end(&mut input_slice).unwrap(); - let input: ComputeGuestInput = deserialize(&decode_input(&input_slice).unwrap()).unwrap(); + // The host sends raw bincode bytes. RISC Zero serde is reserved for the committed journal. + let input: ComputeGuestInput = deserialize(&input_slice).unwrap(); // The policy comes from the user program, not from a default here: it decides the input-tree // leaf and which inputs count, and both have to agree with what the E3 program's contract did. diff --git a/crates/support/program/Cargo.toml b/crates/support/program/Cargo.toml index 4a0fc5fad5..bdff075e30 100644 --- a/crates/support/program/Cargo.toml +++ b/crates/support/program/Cargo.toml @@ -9,3 +9,4 @@ fhe-traits = { workspace = true } e3-compute-provider = { workspace = true } e3-fhe-params = { workspace = true, features = ["abi-encoding"] } sha2 = { workspace = true } +sha3 = { workspace = true } diff --git a/crates/support/program/src/lib.rs b/crates/support/program/src/lib.rs index dd23e25549..b3c6f30258 100644 --- a/crates/support/program/src/lib.rs +++ b/crates/support/program/src/lib.rs @@ -4,8 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use e3_compute_provider::FHEInputs; -use e3_fhe_params::decode_bfv_params_arc; +use e3_compute_provider::FHEProcessorInput; use fhe::bfv::Ciphertext; use fhe_traits::{DeserializeParametrized, Serialize}; @@ -18,12 +17,10 @@ pub fn policy() -> e3_compute_provider::InputPolicy { } /// CRISP Implementation of the CiphertextProcessor function -pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { - let params = decode_bfv_params_arc(&fhe_inputs.params).unwrap(); - - let mut sum = Ciphertext::zero(¶ms); - for ciphertext_bytes in &fhe_inputs.ciphertexts { - let ciphertext = Ciphertext::from_bytes(&ciphertext_bytes.0, ¶ms).unwrap(); +pub fn fhe_processor(fhe_inputs: &FHEProcessorInput<'_>) -> Vec { + let mut sum = Ciphertext::zero(fhe_inputs.params); + for ciphertext_bytes in fhe_inputs.ciphertexts { + let ciphertext = Ciphertext::from_bytes(&ciphertext_bytes.0, fhe_inputs.params).unwrap(); sum += &ciphertext; } @@ -39,6 +36,7 @@ pub mod policy { use e3_compute_provider::policy::{leaf_from_digest, PublishedInput}; use e3_compute_provider::{ComputeError, InputPolicy}; use sha2::{Digest, Sha256}; + use sha3::Keccak256; use std::collections::BTreeMap; /// The metadata `CRISPProgram` publishes with each input: 20-byte slot, then a 5-byte parent. @@ -78,7 +76,7 @@ pub mod policy { }) } - /// `sha256(sha256(ciphertext) || commitment || slot || parent) mod SNARK_SCALAR_FIELD`. + /// `sha256(keccak256(ciphertext) || commitment || slot || parent) mod SNARK_SCALAR_FIELD`. /// /// Must stay byte-identical to `CRISPProgram.inputLeaf`, or no root will ever match. It binds /// four things: the bytes, because the Noir proof constrains only the commitment and never sees @@ -97,7 +95,7 @@ pub mod policy { metadata_of(input)?; let mut outer = Sha256::new(); - outer.update(Sha256::digest(input.ciphertext)); + outer.update(Keccak256::digest(input.ciphertext)); outer.update(commitment); outer.update(input.metadata); Ok(leaf_from_digest(&outer.finalize())) diff --git a/crates/support/scripts/container/start.sh b/crates/support/scripts/container/start.sh index 5ed6a6526e..5c4fd7a64d 100755 --- a/crates/support/scripts/container/start.sh +++ b/crates/support/scripts/container/start.sh @@ -1,11 +1,7 @@ #!/usr/bin/env bash -# Clear any existing environment variables -unset RISC0_DEV_MODE RPC_URL PRIVATE_KEY PINATA_JWT PROGRAM_URL BOUNDLESS_ONCHAIN -unset BOUNDLESS_MIN_PRICE_ETH BOUNDLESS_MAX_PRICE_ETH -unset BOUNDLESS_TIMEOUT_SECS BOUNDLESS_LOCK_TIMEOUT_SECS BOUNDLESS_RAMP_UP_SECS BOUNDLESS_LOCK_COLLATERAL_ZKC - -# Parse command line arguments +# Configuration normally arrives through Docker's environment. Flags remain supported for direct, +# backwards-compatible use and override inherited values. POSITIONAL=() while [[ $# -gt 0 ]]; do case $1 in @@ -25,6 +21,10 @@ while [[ $# -gt 0 ]]; do export PINATA_JWT="$2" shift 2 ;; + --ipfs-gateway-url) + export IPFS_GATEWAY_URL="$2" + shift 2 + ;; --program-url) export PROGRAM_URL="$2" shift 2 diff --git a/crates/support/scripts/container/upload.sh b/crates/support/scripts/container/upload.sh index 2091d4a5fb..502086dd7f 100755 --- a/crates/support/scripts/container/upload.sh +++ b/crates/support/scripts/container/upload.sh @@ -7,6 +7,10 @@ while [[ $# -gt 0 ]]; do export PINATA_JWT="$2" shift 2 ;; + --ipfs-gateway-url) + export IPFS_GATEWAY_URL="$2" + shift 2 + ;; *) shift ;; @@ -17,6 +21,8 @@ PROGRAM_PATH="./target/riscv-guest/methods/guests/riscv32im-risc0-zkvm-elf/relea HASH_FILE="./target/.program_hash" URL_FILE="./target/.program_url" +IPFS_GATEWAY_URL="${IPFS_GATEWAY_URL:-https://gateway.pinata.cloud}" +IPFS_GATEWAY_URL="${IPFS_GATEWAY_URL%/}" if [ ! -f "$PROGRAM_PATH" ]; then echo "Error: Program not found at $PROGRAM_PATH" @@ -58,8 +64,14 @@ echo "" if [ -f "$HASH_FILE" ] && [ -f "$URL_FILE" ]; then STORED_HASH=$(cat "$HASH_FILE") if [ "$CURRENT_HASH" = "$STORED_HASH" ]; then + STORED_URL=$(cat "$URL_FILE") + STORED_CID="${STORED_URL##*/}" + PROGRAM_URL="$IPFS_GATEWAY_URL/ipfs/$STORED_CID" + if [ "$PROGRAM_URL" != "$STORED_URL" ]; then + echo "$PROGRAM_URL" > "$URL_FILE" + fi echo "Program unchanged (hash matches). Existing URL:" - cat "$URL_FILE" + echo "$PROGRAM_URL" exit 0 else echo "Program changed (hash differs). Uploading new version..." @@ -83,8 +95,8 @@ if [ -z "$CID" ]; then exit 1 fi -# Save -PROGRAM_URL="https://gateway.pinata.cloud/ipfs/$CID" +# Save. Use the same gateway that Boundless will use for uploaded inputs. +PROGRAM_URL="$IPFS_GATEWAY_URL/ipfs/$CID" echo "$CURRENT_HASH" > "$HASH_FILE" echo "$PROGRAM_URL" > "$URL_FILE" @@ -112,5 +124,6 @@ echo " boundless:" echo " rpc_url: \"https://sepolia.infura.io/v3/YOUR_KEY\"" echo " private_key: \"\${PRIVATE_KEY}\"" echo " pinata_jwt: \"\${PINATA_JWT}\"" +echo " ipfs_gateway_url: \"$IPFS_GATEWAY_URL\"" echo " program_url: \"$PROGRAM_URL\"" -echo " onchain: true" \ No newline at end of file +echo " onchain: true" diff --git a/crates/sync/src/sync/service.rs b/crates/sync/src/sync/service.rs index 96357efb63..e89807a33c 100644 --- a/crates/sync/src/sync/service.rs +++ b/crates/sync/src/sync/service.rs @@ -25,7 +25,7 @@ use e3_events::{ TicketGenerated, TypedEvent, Unsequenced, }; #[cfg(test)] -use e3_events::{EventBusBarrier, EventBusFanout, EventContextAccessors, RequestRouterCheckpoint}; +use e3_events::{EventBusBarrier, EventBusFanout, EventContextAccessors}; use e3_utils::actix::channel as actix_toolbox; use std::{ collections::{HashMap, HashSet}, diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 3a0c56090b..d04dbb17a6 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -1451,6 +1451,7 @@ async fn test_trbfv_actor() -> Result<()> { }, finalization_ms: None, chain_id: Some(1), + data_availability: None, }; // Setup ZK backend for proof generation/verification diff --git a/crates/wasm/init_node.mjs b/crates/wasm/init_node.mjs new file mode 100644 index 0000000000..ac0efe409d --- /dev/null +++ b/crates/wasm/init_node.mjs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +export default async function initializeWasm() { + // Node loads the synchronous WASM binding through the package export. +} diff --git a/crates/wasm/package.json b/crates/wasm/package.json index d34b64243b..b6414d481b 100644 --- a/crates/wasm/package.json +++ b/crates/wasm/package.json @@ -10,6 +10,7 @@ "dist/node/**", "dist/web/**", "init_node.js", + "init_node.mjs", "init_node.cjs", "init_web.js", "init.d.ts" @@ -46,7 +47,7 @@ "./init": { "node": { "types": "./init.d.ts", - "import": "./init_node.js", + "import": "./init_node.mjs", "require": "./init_node.cjs" }, "browser": { @@ -55,7 +56,7 @@ }, "default": { "types": "./init.d.ts", - "import": "./init_node.js", + "import": "./init_node.mjs", "require": "./init_node.cjs", "default": "./init_web.js" } diff --git a/dappnode/README.md b/dappnode/README.md index 1ad55d5da1..e9e1c80433 100644 --- a/dappnode/README.md +++ b/dappnode/README.md @@ -126,6 +126,10 @@ Non-secret runtime configuration is provided through environment variables: - **`RPC_URL`** (required) WebSocket RPC endpoint for the chain (e.g. `wss://ethereum-sepolia-rpc.publicnode.com`). +- **`AVAIL_RPC_URL`** Avail HTTP endpoint used to retrieve proof-backed ciphertexts. It defaults by + `CHAIN_ID` for Ethereum mainnet and Sepolia. Set it explicitly to use a managed or self-hosted + endpoint. + - **`NETWORK`** Logical network name written into the Interfold config. Default in this package: `mainnet`. diff --git a/dappnode/config.template.yaml b/dappnode/config.template.yaml index 1f919774e3..301195b0cb 100644 --- a/dappnode/config.template.yaml +++ b/dappnode/config.template.yaml @@ -16,6 +16,9 @@ chains: - name: '${NETWORK}' rpc_url: '${RPC_URL}' chain_id: ${CHAIN_ID} + data_availability: + mode: avail + rpc_url: '${AVAIL_RPC_URL}' contracts: interfold: address: '${INTERFOLD_CONTRACT}' diff --git a/dappnode/docker-compose.yml b/dappnode/docker-compose.yml index 1d63152702..8b00b8b32c 100644 --- a/dappnode/docker-compose.yml +++ b/dappnode/docker-compose.yml @@ -16,6 +16,7 @@ services: environment: # Required RPC_URL: '' + AVAIL_RPC_URL: '' # Network & node config NETWORK: 'mainnet' CHAIN_ID: '1' diff --git a/dappnode/entrypoint.sh b/dappnode/entrypoint.sh index 3d6afce188..56e3bd11b4 100644 --- a/dappnode/entrypoint.sh +++ b/dappnode/entrypoint.sh @@ -78,6 +78,18 @@ export QUIC_PORT="${QUIC_PORT:-37173}" export NODE_ADDRESS="${NODE_ADDRESS:-}" export LOG_LEVEL="${LOG_LEVEL:-info}" +# Protocol v3 stores large ciphertexts on Avail. Operators may override the public endpoint, but +# an enabled Ethereum chain must always render a reader into the ciphernode configuration. +if [ -z "${AVAIL_RPC_URL:-}" ]; then + case "${CHAIN_ID:-}" in + 1) AVAIL_RPC_URL="https://avail-rpc.publicnode.com/" ;; + 11155111) AVAIL_RPC_URL="https://turing-rpc.avail.so/rpc" ;; + *) fail "AVAIL_RPC_URL is required for chain ${CHAIN_ID:-unknown}" ;; + esac +fi +export AVAIL_RPC_URL +[[ "$AVAIL_RPC_URL" =~ ^https?:// ]] || fail "AVAIL_RPC_URL must be an HTTP URL (http:// or https://)" + case "$LOG_LEVEL" in info|debug|trace) ;; *) fail "LOG_LEVEL must be one of: info, debug, trace" ;; diff --git a/dappnode/tests/test-hardening.sh b/dappnode/tests/test-hardening.sh index fedc0fe72d..82cce582f9 100644 --- a/dappnode/tests/test-hardening.sh +++ b/dappnode/tests/test-hardening.sh @@ -140,6 +140,18 @@ assert_contains "$success_dir/data/config.yaml" 'slashing_manager:' assert_contains "$success_dir/data/config.yaml" '0x7777777777777777777777777777777777777777' assert_contains "$success_dir/data/config.yaml" 'fee_token:' assert_contains "$success_dir/data/config.yaml" '0x8888888888888888888888888888888888888888' +assert_contains "$success_dir/data/config.yaml" 'https://avail-rpc.publicnode.com/' + +# The pinned Avail client uses HTTP JSON-RPC. Rejecting WebSocket URLs at startup avoids a node +# appearing healthy while every data retrieval fails with a local BadScheme error. +invalid_avail_dir="$TEST_ROOT/invalid-avail-rpc" +mkdir -p "$invalid_avail_dir/secrets" +write_secrets "$invalid_avail_dir/secrets/secrets.json" +if run_entrypoint "$invalid_avail_dir" AVAIL_RPC_URL="wss://turing-rpc.avail.so/ws"; then + fail "WebSocket Avail RPC URL was accepted" +fi +[ ! -s "$invalid_avail_dir/calls" ] || fail "invalid Avail RPC URL invoked Interfold" +assert_contains "$invalid_avail_dir/output" 'AVAIL_RPC_URL must be an HTTP URL' # A credential command failure must propagate and must never start the node. failure_dir="$TEST_ROOT/failure" diff --git a/deploy/cn1.yaml b/deploy/cn1.yaml index 71f7314160..6b8297946b 100644 --- a/deploy/cn1.yaml +++ b/deploy/cn1.yaml @@ -4,6 +4,9 @@ node: chains: - name: 'sepolia' rpc_url: '${RPC_URL}' + data_availability: + mode: avail + rpc_url: 'https://turing-rpc.avail.so/rpc' contracts: interfold: '${SEPOLIA_INTERFOLD_ADDRESS}' ciphernode_registry: '${SEPOLIA_CIPHERNODE_REGISTRY_ADDRESS}' diff --git a/deploy/cn2.yaml b/deploy/cn2.yaml index 8258deed7e..66e3e8f12f 100644 --- a/deploy/cn2.yaml +++ b/deploy/cn2.yaml @@ -8,6 +8,9 @@ node: chains: - name: 'sepolia' rpc_url: '${RPC_URL}' + data_availability: + mode: avail + rpc_url: 'https://turing-rpc.avail.so/rpc' contracts: interfold: '${SEPOLIA_INTERFOLD_ADDRESS}' ciphernode_registry: '${SEPOLIA_CIPHERNODE_REGISTRY_ADDRESS}' diff --git a/deploy/cn3.yaml b/deploy/cn3.yaml index 7fb7b918f9..929646779f 100644 --- a/deploy/cn3.yaml +++ b/deploy/cn3.yaml @@ -8,6 +8,9 @@ node: chains: - name: 'sepolia' rpc_url: '${RPC_URL}' + data_availability: + mode: avail + rpc_url: 'https://turing-rpc.avail.so/rpc' contracts: interfold: '${SEPOLIA_INTERFOLD_ADDRESS}' ciphernode_registry: '${SEPOLIA_CIPHERNODE_REGISTRY_ADDRESS}' diff --git a/deploy/cn4.yaml b/deploy/cn4.yaml index b2c13df6ba..b678be2fcd 100644 --- a/deploy/cn4.yaml +++ b/deploy/cn4.yaml @@ -8,6 +8,9 @@ node: chains: - name: 'sepolia' rpc_url: '${RPC_URL}' + data_availability: + mode: avail + rpc_url: 'https://turing-rpc.avail.so/rpc' contracts: interfold: '${SEPOLIA_INTERFOLD_ADDRESS}' ciphernode_registry: '${SEPOLIA_CIPHERNODE_REGISTRY_ADDRESS}' diff --git a/docs/pages/CRISP/introduction.mdx b/docs/pages/CRISP/introduction.mdx index 91dda01a5c..424ed0e4d0 100644 --- a/docs/pages/CRISP/introduction.mdx +++ b/docs/pages/CRISP/introduction.mdx @@ -107,13 +107,41 @@ voting rounds. The main contract is which orchestrates the entire voting process. When a new voting round is initialized, the `validate()` function sets up round parameters and -initializes a Merkle tree to store votes efficiently. As votes are submitted, `publishInput()` -processes each encrypted vote by verifying its Noir zero-knowledge proof, ensuring votes are -correctly encrypted and voters are eligible. The round's census decides which verifier that is: -`CRISPVerifier.sol` for a census posted as a Merkle root, and `CRISPOnchainVerifier.sol` for one -read from token balances on chain. Both declare a contract named `HonkVerifier`, so they are -distinguished by file. Votes are stored in the Merkle tree with slot addresses mapping to vote -indices, allowing updates while maintaining uniqueness. +initializes a Merkle tree to store votes efficiently. A vote then uses two transactions. +`publishInput()` verifies the Noir proof and a 10-minute availability promise from the service, +commits the encrypted vote's hash, and reserves its exact Merkle-tree position. The promise starts +only after the service stores the complete ciphertext. If the wallet does not commit it before the +promise expires, the voter can stage the same vote again. The large ciphertext is stored on Avail +instead of Ethereum. After VectorX anchors the relevant Avail range on Ethereum, `finalizeInput()` +verifies the inclusion proof for that exact hash and marks the vote available. Computation cannot +start while a committed vote is waiting for finalization. + +### Proposal timing + +A CRISP proposal does not accept encrypted votes immediately. Chainlink VRF selects the committee, +the ticket window closes, and the committee completes DKG first. Voting opens when the committee +public key is ready. + +The mainnet launch permits one hour for VRF, 10 minutes for tickets, and six hours for DKG. CRISP +reserves the final three hours of the input window for Avail finalization. It also requires at least +one hour for new vote commitments after the worst-case key setup. The protocol minimum input window +is therefore 11 hours 10 minutes. + +The Interfold DAO launch configuration uses a five-day proposal input window. Even if every +committee phase uses its complete timeout, this leaves 4 days, 13 hours, and 50 minutes for new vote +commitments. Voting can open earlier when the committee key is ready sooner. + +The proposal form reads the plugin minimum, protocol maximum, committee timeouts, and CRISP timing +rules from the live contracts. It shows the guaranteed voting time and rejects a duration outside +the allowed range. The plugin starts the selected duration in the block that creates the proposal, +so time spent waiting for the wallet transaction does not shorten the window. Governance can change +these settings, so an application must not keep a separate hard-coded minimum. + +The round's census decides which Noir verifier `publishInput()` uses: `CRISPVerifier.sol` for a +census posted as a Merkle root, and `CRISPOnchainVerifier.sol` for one read from token balances on +chain. Both declare a contract named `HonkVerifier`, so they are distinguished by file. Votes are +stored in the Merkle tree with slot addresses mapping to vote indices, allowing updates while +maintaining uniqueness. After the voting period ends and FHE computation completes, the `verify()` function validates the RISC Zero proof attesting to the correctness of the homomorphic tally computation, verifying it @@ -138,10 +166,13 @@ voter inclusion in the eligibility tree. When casting a vote, the SDK encrypts votes using BFV fully homomorphic encryption under the committee's public key and generates zero-knowledge proofs using compiled Noir circuits. These proofs demonstrate that votes are correctly encrypted and that voters are eligible to participate. -The SDK also supports mask votes, generating proofs for zero-value votes that help reduce the risk -of collusion and coercion. Before submission, proofs are verified locally to prevent failed -transactions, and after voting concludes, the SDK decodes encrypted tally results into readable vote -counts. +The SDK stages the encrypted bytes with the coordination server and returns the compact commitment +that the voter signs and publishes from their wallet. The server then publishes the stored bytes to +Avail and finalizes the vote with its VectorX proof. The voter does not need to remain online for +that second step. The SDK also supports mask votes, generating proofs for zero-value votes that help +reduce the risk of collusion and coercion. Before submission, proofs are verified locally to prevent +failed transactions, and after voting concludes, the SDK decodes encrypted tally results into +readable vote counts. ## Next Steps diff --git a/docs/pages/computation-flow.mdx b/docs/pages/computation-flow.mdx index 9a75a804cd..8a1037f36c 100644 --- a/docs/pages/computation-flow.mdx +++ b/docs/pages/computation-flow.mdx @@ -65,14 +65,29 @@ Encryption (FHE) scheme, then generate and publish a shared public encryption ke During this phase, Data Providers โ€” who may include individual users, applications, or institutions โ€” encrypt their data to the CiCo's public key and publish commitments to those inputs onchain. +The input window includes committee setup, voting, and the final availability period. Voting opens +when the committee key is ready. CRISP stops new vote commitments three hours before the input +window ends. This reserved period lets VectorX finalize votes that are already committed. + +The mainnet launch settings allow up to one hour for VRF, 10 minutes for ticket submission, and six +hours for DKG. CRISP then requires at least one hour for new vote commitments. Therefore, the +protocol minimum mainnet input window is 11 hours 10 minutes. The DAO plugin can require a longer +window. Interfold also reserves seven days for computation and six hours for decryption. With its +30-day lifecycle cap, the longest mainnet input window is 22 days 18 hours. The contracts reject +windows outside these bounds. + 1. **Data Encryption**: Data Providers encrypt their inputs using the CiCo's public key. 2. **Input Validation**: Data Providers generate several Zero-Knowledge Proofs about their inputs to ensure they are valid for the requested E3. Some of these proofs are generic (e.g., proof of valid encryption) while others will be specific to your application. -3. **Submit Inputs**: Both encrypted data and ZKPs are submitted to your E3 Program contract's - `publishInput` function. The E3 Program validates the input and may add its hash to a Merkle - tree, the root of which can later be used to anchor proofs of correct execution of your E3 - Program. +3. **Commit Input**: The CRISP service stores the encrypted bytes, then signs a compact input + commitment that expires after 10 minutes. `publishInput` verifies the ZK proof, expiry, and + signature and reserves the input's tree index. If the wallet does not commit in time, the voter + can stage the same vote again. The voter can leave after the commitment transaction succeeds. +4. **Finalize Availability**: The service publishes the bytes to Avail. After VectorX anchors the + Avail block, any account calls `finalizeInput`. The E3 Program accepts only a receipt for the + exact content hash committed by the ZK proof. Computation waits until every committed input is + finalized. ```solidity function publishInput(uint256 e3Id, bytes memory data) external; @@ -86,16 +101,12 @@ deadline expires. 1. **Execution**: The CP retrieves encrypted inputs and executes the Secure Process defined in your E3 program. -2. **Publish Output**: The ciphertext output and accompanying proof are submitted to the Interfold - contract via `publishCiphertextOutput`. The Interfold contract calls your E3 Program's `verify` - function to validate the proof before accepting the output. +2. **Publish Output**: The ciphertext bytes are published to Avail. A content hash, VectorX receipt, + and compute proof are submitted to Interfold through `publishCiphertextOutput`. Interfold accepts + the reference only when the availability and compute proofs bind the same output hash. ```solidity - function publishCiphertextOutput( - uint256 e3Id, - bytes memory ciphertextOutput, - bytes memory proof - ) external returns (bool success); + function publishCiphertextOutput(uint256 e3Id, bytes calldata encodedOutputReference) external; ``` ### Phase 5: Decryption diff --git a/docs/pages/governance.mdx b/docs/pages/governance.mdx index ec50b5a432..844c66d33c 100644 --- a/docs/pages/governance.mdx +++ b/docs/pages/governance.mdx @@ -5,23 +5,29 @@ description: 'How voting power, proposals, voting, and execution work in the Int # Interfold Governance -Interfold governance is the decision-making system for the Interfold DAO, built on [Aragon OSx](https://aragon.org/osx). It uses FOLD voting power and is centered on receipt-free secret-ballot voting through [CRISP](/CRISP/introduction). +Interfold governance is the decision-making system for the Interfold DAO, built on +[Aragon OSx](https://aragon.org/osx). It uses FOLD voting power and is centered on receipt-free +secret-ballot voting through [CRISP](/CRISP/introduction). -The standard Interfold governance proposal is an IPP (Interfold Protocol Proposal), which uses CRISP for receipt-free secret-ballot voting. +The standard Interfold governance proposal is an IPP (Interfold Protocol Proposal), which uses CRISP +for receipt-free secret-ballot voting. -A FIPP (Fallback Interfold Protocol Proposal) provides a transparent voting path if CRISP cannot be used. FIPPs are strictly a fallback mechanism, not an alternative default voting mode. +A FIPP (Fallback Interfold Protocol Proposal) provides a transparent voting path if CRISP cannot be +used. FIPPs are strictly a fallback mechanism, not an alternative default voting mode. The current proposal lifecycle is: **Proposal โ†’ Community vote โ†’ Foundation approval โ†’ Execution** -This page is the reference for how voting power, proposals, voting, and execution currently work in the Interfold DAO. +This page is the reference for how voting power, proposals, voting, and execution currently work in +the Interfold DAO. --- # 1. Voting power -Voting power comes from committed FOLD, not simply FOLD held in a wallet. Each committed FOLD counts 1:1 toward voting power. +Voting power comes from committed FOLD, not simply FOLD held in a wallet. Each committed FOLD counts +1:1 toward voting power. FOLD can contribute voting power in three ways: @@ -29,15 +35,20 @@ FOLD can contribute voting power in three ways: 2. **Bonded FOLD** 3. **Vesting FOLD** -A transferable FOLD balance held in a wallet does not provide voting power unless it is committed through one of these mechanisms. +A transferable FOLD balance held in a wallet does not provide voting power unless it is committed +through one of these mechanisms. ## 1.1 Locked FOLD -FOLD can be locked through the [Interfold governance interface](https://governance.theinterfold.com/) to create a voting position. Locked FOLD only contributes voting power once it has been delegated, and the lock remains under the control of the wallet that created it. +FOLD can be locked through the +[Interfold governance interface](https://governance.theinterfold.com/) to create a voting position. +Locked FOLD only contributes voting power once it has been delegated, and the lock remains under the +control of the wallet that created it. ### Delegation -Locked FOLD can be delegated to the holder's own address or to another address, and the delegate can be changed later. Delegation applies to the holder's current and future locks. +Locked FOLD can be delegated to the holder's own address or to another address, and the delegate can +be changed later. Delegation applies to the holder's current and future locks. Delegation transfers voting power, not ownership of the underlying FOLD. @@ -51,15 +62,21 @@ Locked FOLD can be unlocked at any time, subject to a 30-day delay: ## 1.2 Bonded FOLD -FOLD bonded to a ciphernode counts automatically toward governance voting power and does not require separate delegation. +FOLD bonded to a ciphernode counts automatically toward governance voting power and does not require +separate delegation. -Bonded FOLD is already committed to network participation. Ciphernode operators post economic collateral to participate in the network and are subject to a 30-day exit delay when decommissioning their nodes. The ciphernode system also uses sUSDS-denominated tickets, which contribute to committee sortition weight. +Bonded FOLD is already committed to network participation. Ciphernode operators post economic +collateral to participate in the network and are subject to a 30-day exit delay when decommissioning +their nodes. The ciphernode system also uses sUSDS-denominated tickets, which contribute to +committee sortition weight. -See the [ciphernode documentation](/ciphernode-operators) for bonding, tickets, committee selection, and operator penalties. +See the [ciphernode documentation](/ciphernode-operators) for bonding, tickets, committee selection, +and operator penalties. ## 1.3 Vesting FOLD -Eligible vesting FOLD counts automatically toward governance voting power and does not require delegation. +Eligible vesting FOLD counts automatically toward governance voting power and does not require +delegation. Locked, bonded, and vesting FOLD have the same governance weight once active: **1 FOLD = 1 vote.** @@ -67,15 +84,18 @@ Locked, bonded, and vesting FOLD have the same governance weight once active: ** # 2. Creating proposals -Governance participants with at least 12K FOLD of active voting power can create proposals through the [Interfold governance interface](https://governance.theinterfold.com/). +Governance participants with at least 12K FOLD of active voting power can create proposals through +the [Interfold governance interface](https://governance.theinterfold.com/). -The standard proposal type is an IPP, which uses CRISP for receipt-free secret-ballot voting. FIPPs exist only as a fallback when CRISP cannot be used. +The standard proposal type is an IPP, which uses CRISP for receipt-free secret-ballot voting. FIPPs +exist only as a fallback when CRISP cannot be used. --- # 3. Voting weight and eligibility -A participant's voting weight is determined by active FOLD voting power. **1 FOLD of active voting power = 1 vote.** Locked, bonded, and vesting FOLD are not weighted differently once active. +A participant's voting weight is determined by active FOLD voting power. **1 FOLD of active voting +power = 1 vote.** Locked, bonded, and vesting FOLD are not weighted differently once active. ## 3.1 Eligibility to vote @@ -83,7 +103,8 @@ Any non-Foundation holder with active committed FOLD voting power is eligible to ## 3.2 Voting-power snapshot -Voting power for a proposal is determined from the snapshot taken immediately before proposal creation (proposal creation timestamp โˆ’ 1). The governance interface displays: +Voting power for a proposal is determined from the snapshot taken immediately before proposal +creation (proposal creation timestamp โˆ’ 1). The governance interface displays: - the connected wallet's voting power at snapshot - total voting power eligible for the proposal @@ -94,7 +115,8 @@ Changes to voting power after the snapshot do not change voting weight for that # 4. Proposal types -Interfold governance uses CRISP-based secret-ballot voting as its standard governance path. Transparent voting exists as a fallback mechanism rather than a co-equal voting mode. +Interfold governance uses CRISP-based secret-ballot voting as its standard governance path. +Transparent voting exists as a fallback mechanism rather than a co-equal voting mode. The underlying FOLD voting-power rules are the same for both proposal types. @@ -102,9 +124,13 @@ The underlying FOLD voting-power rules are the same for both proposal types. An IPP is the standard Interfold governance proposal. -IPPs use [CRISP (Coercion-Resistant Impartial Selection Protocol)](/CRISP/introduction), Interfold's reference implementation for receipt-free secret-ballot voting. CRISP applies Interfold's confidential coordination model to governance: private ballots contribute to a shared result without exposing individual votes. +IPPs use [CRISP (Coercion-Resistant Impartial Selection Protocol)](/CRISP/introduction), Interfold's +reference implementation for receipt-free secret-ballot voting. CRISP applies Interfold's +confidential coordination model to governance: private ballots contribute to a shared result without +exposing individual votes. -Ballots are encrypted client-side and tallied under encryption within an Encrypted Execution Environment (E3). Individual votes are never revealed. +Ballots are encrypted client-side and tallied under encryption within an Encrypted Execution +Environment (E3). Individual votes are never revealed. A CRISP voting round proceeds broadly as follows: @@ -118,29 +144,38 @@ A CRISP voting round proceeds broadly as follows: No single ciphernode controls output decryption. -CRISP therefore separates ballot visibility from outcome verifiability: individual choices remain confidential while the resulting decision remains verifiable. +CRISP therefore separates ballot visibility from outcome verifiability: individual choices remain +confidential while the resulting decision remains verifiable. ### Changing a vote -A participant can submit a new encrypted vote before voting closes. Only the valid vote in effect when voting closes is included in the final tally. +A participant can submit a new encrypted vote before voting closes. Only the valid vote in effect +when voting closes is included in the final tally. ### Vote masking and receipt-freeness -IPPs also support vote masking, a mechanism designed to make observable ballot activity unreliable as evidence of how someone voted. +IPPs also support vote masking, a mechanism designed to make observable ballot activity unreliable +as evidence of how someone voted. -A mask creates a valid encrypted update without changing the underlying vote. Because genuine vote updates and masks are computationally indistinguishable, an outside observer cannot reliably determine whether visible activity reflects a voter's true or final choice. +A mask creates a valid encrypted update without changing the underlying vote. Because genuine vote +updates and masks are computationally indistinguishable, an outside observer cannot reliably +determine whether visible activity reflects a voter's true or final choice. -This helps CRISP provide receipt-freeness: voters cannot produce reliable proof of how they voted, even if their onchain activity is observed. +This helps CRISP provide receipt-freeness: voters cannot produce reliable proof of how they voted, +even if their onchain activity is observed. -Detailed masking mechanics are covered in this [technical blog post](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/). +Detailed masking mechanics are covered in this +[technical blog post](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/). ## 4.2 FIPP โ€” Fallback Interfold Protocol Proposal A FIPP is the transparent fallback proposal type. -FIPPs are used strictly when CRISP is unavailable or an issue prevents the standard IPP voting process from being used. Individual votes and tallies are visible onchain. +FIPPs are used strictly when CRISP is unavailable or an issue prevents the standard IPP voting +process from being used. Individual votes and tallies are visible onchain. -FIPPs use the same FOLD voting-power model as IPPs and proceed through the same governance lifecycle after voting. +FIPPs use the same FOLD voting-power model as IPPs and proceed through the same governance lifecycle +after voting. --- @@ -152,23 +187,33 @@ Interfold governance currently uses a multi-stage process: ## 5.1 Community vote -After a proposal is created, eligible FOLD voting power determines whether it passes its voting stage. +After a proposal is created, eligible FOLD voting power determines whether it passes its voting +stage. -IPPs use receipt-free secret-ballot voting through CRISP. FIPPs use transparent voting only when the fallback mechanism is required. +IPPs use receipt-free secret-ballot voting through CRISP. FIPPs use transparent voting only when the +fallback mechanism is required. ## 5.2 Foundation approval -A proposal that passes the community vote does not execute automatically. It moves to Foundation Approval, where the Interfold Foundation Board reviews the proposal before implementation. +A proposal that passes the community vote does not execute automatically. It moves to Foundation +Approval, where the Interfold Foundation Board reviews the proposal before implementation. -Under the Foundationโ€™s governing framework, the Board is expected to approve implementation unless it reasonably determines that a defined constraint applies โ€” for example, because implementation would be unlawful, unsafe or impracticable, inconsistent with the Foundationโ€™s purposes or governing documents, breach a binding obligation, or create material legal, regulatory, security, solvency, or operational risk. +Under the Foundationโ€™s governing framework, the Board is expected to approve implementation unless +it reasonably determines that a defined constraint applies โ€” for example, because implementation +would be unlawful, unsafe or impracticable, inconsistent with the Foundationโ€™s purposes or governing +documents, breach a binding obligation, or create material legal, regulatory, security, solvency, or +operational risk. -If one of those grounds applies, the Board may reject, defer, condition, or return the proposal for modification and must publish its reasons. +If one of those grounds applies, the Board may reject, defer, condition, or return the proposal for +modification and must publish its reasons. Once the Board approves implementation, the passed proposal becomes a DAO Resolution. ## 5.3 Execution -Once the Board approves implementation, the passed proposal becomes a DAO Resolution. It can then be implemented or executed in accordance with the Foundationโ€™s governing documents and the configured Aragon OSx rules. +Once the Board approves implementation, the passed proposal becomes a DAO Resolution. It can then be +implemented or executed in accordance with the Foundationโ€™s governing documents and the configured +Aragon OSx rules. --- @@ -176,17 +221,25 @@ Once the Board approves implementation, the passed proposal becomes a DAO Resolu The Interfold DAO and the Foundation have distinct roles within the governance system. -Token holders can submit and vote on proposals concerning matters within the Foundationโ€™s purposes. Passed proposals that complete Foundation review become DAO Resolutions, which the Directors are responsible for implementing subject to their legal and fiduciary duties. +Token holders can submit and vote on proposals concerning matters within the Foundationโ€™s purposes. +Passed proposals that complete Foundation review become DAO Resolutions, which the Directors are +responsible for implementing subject to their legal and fiduciary duties. -The DAO also has direct governance powers over parts of the Foundationโ€™s structure, including electing Directors, changing the size of the Board, and removing Directors. Director-removal votes do not require Board approval. +The DAO also has direct governance powers over parts of the Foundationโ€™s structure, including +electing Directors, changing the size of the Board, and removing Directors. Director-removal votes +do not require Board approval. -Certain matters may also be designated as Reserved Matters, meaning the Foundation cannot act on them without prior approval through a DAO Resolution. +Certain matters may also be designated as Reserved Matters, meaning the Foundation cannot act on +them without prior approval through a DAO Resolution. ## Interfold Constitution -The Interfold Constitution is the charter of the Interfold DAO. It defines the DAOโ€™s governing principles, processes, and relationship with the Interfold Foundation. +The Interfold Constitution is the charter of the Interfold DAO. It defines the DAOโ€™s governing +principles, processes, and relationship with the Interfold Foundation. -Protocol users do not adopt the Constitution merely by using Interfold. Participants opt in through specified governance actions, including locking FOLD, submitting or voting on proposals, and receiving payments made under DAO Resolutions. +Protocol users do not adopt the Constitution merely by using Interfold. Participants opt in through +specified governance actions, including locking FOLD, submitting or voting on proposals, and +receiving payments made under DAO Resolutions. [**Read the Interfold Constitution โ†’**](/constitution) @@ -196,26 +249,29 @@ Protocol users do not adopt the Constitution merely by using Interfold. Particip This table is the reference for the live Interfold DAO configuration. -| Parameter | Current setting | -| --- | --- | -| Governance asset | FOLD | -| Voting-power model | 1 active FOLD = 1 vote | -| Locked FOLD | Counts after delegation | -| Bonded FOLD | Counts automatically | -| Vesting FOLD | Counts automatically | -| Lock withdrawal cooldown | 30 days | -| Voting power after withdrawal starts | Stops immediately | -| Standard proposal type | IPP | -| Standard voting | Receipt-free secret ballot through CRISP | -| Fallback proposal type | FIPP | -| Fallback voting | Transparent onchain voting | -| Foundation approval | Board review and approval required before implementation | -| Minimum voting power to vote | No additional minimum beyond active voting power | -| Minimum voting power to create a proposal | 12K FOLD | -| Voting duration | 5 days | -| Minimum participation / quorum | 2% | -| Approval threshold | >50% | -| Execution delay | None | +| Parameter | Current setting | +| ----------------------------------------- | --------------------------------------------------------- | +| Governance asset | FOLD | +| Voting-power model | 1 active FOLD = 1 vote | +| Locked FOLD | Counts after delegation | +| Bonded FOLD | Counts automatically | +| Vesting FOLD | Counts automatically | +| Lock withdrawal cooldown | 30 days | +| Voting power after withdrawal starts | Stops immediately | +| Standard proposal type | IPP | +| Standard voting | Receipt-free secret ballot through CRISP | +| Fallback proposal type | FIPP | +| Fallback voting | Transparent onchain voting | +| Foundation approval | Board review and approval required before implementation | +| Minimum voting power to vote | No additional minimum beyond active voting power | +| Minimum voting power to create a proposal | 12K FOLD | +| Proposal input window | 5 days | +| Voting opens | When the ciphernode committee key is ready | +| New vote commitment cutoff | 3 hours before the input window ends | +| Guaranteed voting time | 4 days, 13 hours, and 50 minutes with the five-day window | +| Minimum participation / quorum | 2% | +| Approval threshold | >50% | +| Execution delay | None | --- diff --git a/docs/pages/sdk.mdx b/docs/pages/sdk.mdx index 11913b4d58..1cc2cf7194 100644 --- a/docs/pages/sdk.mdx +++ b/docs/pages/sdk.mdx @@ -57,7 +57,7 @@ const sdk = new InterfoldSDK({ feeToken: import.meta.env.VITE_FEE_TOKEN_ADDRESS, }, chain: sepolia, - // 'INSECURE_THRESHOLD_512' for local dev and Sepolia; 'SECURE_THRESHOLD_8192' for production. + // Match the parameter set selected by the target E3. thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', }) ``` @@ -77,7 +77,7 @@ const sdk = InterfoldSDK.create({ }, chain: sepolia, privateKey: '0x...', // optional โ€” omit for read-only - // 'INSECURE_THRESHOLD_512' for local dev and Sepolia; 'SECURE_THRESHOLD_8192' for production. + // Match the parameter set selected by the target E3. thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', }) ``` @@ -87,24 +87,21 @@ const sdk = InterfoldSDK.create({ The `thresholdBfvParamsPresetName` field selects the BFV encryption parameters used by the client. It must match the `paramSet` index registered in the on-chain `Interfold` contract: -| Preset Name | On-chain `paramSet` | Degree | CRT moduli (L) | Security | Use case | -| ------------------------ | ------------------- | ------ | -------------- | -------- | ----------------------------- | -| `INSECURE_THRESHOLD_512` | `0` | 512 | 2 | โŒ None | Local development and Sepolia | -| `SECURE_THRESHOLD_8192` | `1` | 8192 | 3 | โœ… Full | Production deployments | +| Preset Name | On-chain `paramSet` | Degree | CRT moduli (L) | Security | Use case | +| ------------------------ | ------------------- | ------ | -------------- | -------- | -------------------------- | +| `INSECURE_THRESHOLD_512` | `0` | 512 | 2 | โŒ None | Fast local or testnet work | +| `SECURE_THRESHOLD_8192` | `1` | 8192 | 3 | โœ… Full | Production-equivalent work | #### Which preset to use on which network -| Network | Preset | `paramSet` | -| ---------------------- | ------------------------ | ---------- | -| Local development | `INSECURE_THRESHOLD_512` | `0` | -| Sepolia testnet | `INSECURE_THRESHOLD_512` | `0` | -| Mainnet and production | `SECURE_THRESHOLD_8192` | `1` | +| Network | Supported presets | +| ----------------- | ---------------------------------------------------- | +| Local development | `INSECURE_THRESHOLD_512` and `SECURE_THRESHOLD_8192` | +| Sepolia testnet | `INSECURE_THRESHOLD_512` and `SECURE_THRESHOLD_8192` | +| Ethereum mainnet | `SECURE_THRESHOLD_8192` only | -**Use `INSECURE_THRESHOLD_512` on Sepolia.** The Sepolia ciphernodes run the insecure preset, and -the circuit artifacts that ship in `@interfold/sdk` are compiled for it. The secure preset adds no -real security on a testnet that runs mock verifiers; it only makes proof generation slower and -causes a parameter mismatch. Earlier revisions of this page recommended `SECURE_THRESHOLD_8192` for -Sepolia โ€” that recommendation was wrong. +Use the preset that the target E3 selects. A Sepolia deployment can use the insecure preset for fast +tests or the secure preset for production-equivalent tests. Mainnet rejects the insecure preset. **Use `SECURE_THRESHOLD_8192` in production.** The insecure preset uses a small polynomial ring (N=512) with no cryptographic security guarantees. The secure preset (N=8192) meets standard FHE @@ -125,7 +122,7 @@ const requestParams = { BigInt(Math.floor(Date.now() / 1000) + 5 * 60), ] as const, e3Program: '0x...', - paramSet: 0, // ParamSet.Insecure512 โ€” use 1 (Secure8192) only on a secure network + paramSet: 0, // ParamSet.Insecure512. Use 1 for a secure-8192 E3. computeProviderParams: '0x', customParams: '0x', } @@ -461,8 +458,8 @@ that wraps or complements the core Interfold SDK. - **Historical events**: `sdk.getHistoricalEvents(type, fromBlock, toBlock)` fetches logs without a WebSocket provider; useful for CRON jobs or health checks. - **Publish ciphertext output**: - `sdk.publishCiphertextOutput(e3Id, ciphertextOutput, ciphertextCommitment, proof, gasLimit?)` - submits the computed ciphertext output for an E3. + `sdk.publishCiphertextOutput(e3Id, { contentHash, ciphertextCommitment, computeProof, availabilityProof }, gasLimit?)` + submits a proof-backed reference to an aggregate ciphertext published on the configured DA layer. - **Gas controls**: pass `gasLimit` to `requestE3`, `publishCiphertextOutput`, or other write methods if you want fixed limits when interacting with Interfold on L2s. - **Gas estimation**: `sdk.estimateGas(functionName, args, contractAddress, abi)` returns the diff --git a/docs/pages/tutorials/custom-zk-circuits.mdx b/docs/pages/tutorials/custom-zk-circuits.mdx index 8b359124bf..d934a4c35c 100644 --- a/docs/pages/tutorials/custom-zk-circuits.mdx +++ b/docs/pages/tutorials/custom-zk-circuits.mdx @@ -205,10 +205,19 @@ honkVerifier.verify(proof, publicInputs); The public inputs include: - `prev_ct_commitment` โ€” commitment to the previous ciphertext in the slot -- `merkle_root` โ€” the eligibility Merkle root +- `digest_hi` and `digest_lo` โ€” the two field-sized halves of the ballot authorisation digest - `slot_address` โ€” which vote slot is being written to -- `is_first_vote` โ€” whether this input extends an earlier entry in the slot, or nothing +- `merkle_root` or `voting_power` โ€” the eligibility value for the round's census mode +- `is_first_vote` โ€” whether this input names an earlier entry in the slot - `num_options` โ€” number of voting options +- `sum_ct_commitment` โ€” commitment to the encrypted vote that becomes the new slot value +- `committee_public_key_commitment` โ€” the key under which the vote must be encrypted + +The large encrypted vote is not a Noir public input and is not placed in this Ethereum transaction. +`publishInput()` commits its Keccak hash together with `sum_ct_commitment` and the slot metadata. +`finalizeInput()` later accepts only a VectorX proof that the same hash was published to Avail. The +RISC Zero tally program retrieves those bytes and recomputes both the Keccak hash and the BFV +commitment before it includes the vote. --- diff --git a/examples/CRISP/Cargo.lock b/examples/CRISP/Cargo.lock index f00c25573f..d45e28c9e2 100644 --- a/examples/CRISP/Cargo.lock +++ b/examples/CRISP/Cargo.lock @@ -52,7 +52,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more", + "derive_more 2.1.1", "futures-util", "log", "once_cell", @@ -74,7 +74,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "flate2", "foldhash 0.1.5", @@ -189,7 +189,7 @@ dependencies = [ "bytestring", "cfg-if 1.0.4", "cookie", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "foldhash 0.1.5", "futures-core", @@ -251,6 +251,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -354,7 +364,7 @@ dependencies = [ "alloy-tx-macros", "auto_impl", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "k256", "once_cell", @@ -483,11 +493,11 @@ dependencies = [ "alloy-serde", "auto_impl", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -564,7 +574,7 @@ dependencies = [ "alloy-sol-types", "async-trait", "auto_impl", - "derive_more", + "derive_more 2.1.1", "futures-utils-wasm", "serde", "serde_json", @@ -615,7 +625,7 @@ dependencies = [ "bytes", "cfg-if 1.0.4", "const-hex", - "derive_more", + "derive_more 2.1.1", "foldhash 0.2.0", "hashbrown 0.16.1", "indexmap 2.14.0", @@ -666,7 +676,7 @@ dependencies = [ "either", "futures 0.3.31", "futures-utils-wasm", - "lru", + "lru 0.13.0", "parking_lot 0.12.5", "pin-project", "reqwest", @@ -708,7 +718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc90b1e703d3c03f4ff7f48e82dd0bc1c8211ab7d079cd836a06fcfeb06651cb" dependencies = [ "alloy-rlp-derive", - "arrayvec", + "arrayvec 0.7.6", "bytes", ] @@ -796,7 +806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388cf910e66bd4f309a81ef746dcf8f9bca2226e3577890a8d56c5839225cf46" dependencies = [ "alloy-primitives", - "derive_more", + "derive_more 2.1.1", "serde", "serde_with", ] @@ -812,7 +822,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-serde", - "derive_more", + "derive_more 2.1.1", "rand 0.8.6", "serde", "strum", @@ -990,7 +1000,7 @@ dependencies = [ "alloy-primitives", "auto_impl", "base64 0.22.1", - "derive_more", + "derive_more 2.1.1", "futures 0.3.31", "futures-utils-wasm", "parking_lot 0.12.5", @@ -1065,7 +1075,7 @@ checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" dependencies = [ "alloy-primitives", "alloy-rlp", - "derive_more", + "derive_more 2.1.1", "nybbles", "serde", "smallvec", @@ -1079,7 +1089,7 @@ version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab54221eccefa254ce9f65b079c097b1796e48c21c7ce358230f8988d75392fb" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2 1.0.106", "quote 1.0.45", "syn 2.0.117", @@ -1288,7 +1298,7 @@ dependencies = [ "ark-ff-macros 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", - "arrayvec", + "arrayvec 0.7.6", "digest 0.10.7", "educe", "itertools 0.13.0", @@ -1424,7 +1434,7 @@ checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ "ark-serialize-derive 0.5.0", "ark-std 0.5.0", - "arrayvec", + "arrayvec 0.7.6", "digest 0.10.7", "num-bigint", ] @@ -1481,12 +1491,140 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if 1.0.4", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if 1.0.4", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if 1.0.4", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -1509,6 +1647,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.88" @@ -1531,6 +1675,12 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "atomic-take" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1554,6 +1704,51 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "avail-rust-client" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46069ba8315a805aa78fe55ef47b6eabe8196d36d8f3dece75aed9274c427d88" +dependencies = [ + "avail-rust-core", + "const-hex", + "frame-metadata 20.0.0", + "futures 0.3.31", + "impl-serde", + "parity-scale-codec", + "reqwest", + "scale-info", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "avail-rust-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44ba0d18f0ad92c3223be561221d439a85aa071a0d3defa5e846c4c06e269d5e" +dependencies = [ + "const-hex", + "impl-serde", + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-decode", + "scale-encode", + "scale-info", + "schnorrkel", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-core", + "subxt-rpcs", + "subxt-signer", + "thiserror 2.0.18", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -1575,6 +1770,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" + [[package]] name = "base64" version = "0.21.7" @@ -1621,6 +1822,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1679,6 +1891,44 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq 0.1.5", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec 0.7.6", + "constant_time_eq 0.4.2", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1697,6 +1947,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blst" version = "0.3.16" @@ -1730,6 +1993,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1799,6 +2071,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "0.1.10" @@ -1817,6 +2095,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if 1.0.4", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -1843,6 +2132,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.0" @@ -1889,6 +2189,25 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "config" version = "0.14.0" @@ -1997,6 +2316,24 @@ dependencies = [ "unicode-xid 0.2.6", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.6.0" @@ -2118,6 +2455,8 @@ dependencies = [ "dotenvy", "e3-bfv-client", "e3-compute-provider", + "e3-data-availability", + "e3-evm-helpers", "e3-fhe-params", "e3-sdk", "env_logger", @@ -2139,6 +2478,7 @@ dependencies = [ "sled", "thiserror 1.0.69", "tokio", + "tracing", ] [[package]] @@ -2196,6 +2536,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -2227,6 +2576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2240,44 +2590,131 @@ dependencies = [ ] [[package]] -name = "darling" -version = "0.21.3" +name = "crypto-mac" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" dependencies = [ - "darling_core", - "darling_macro", + "generic-array", + "subtle", ] [[package]] -name = "darling_core" -version = "0.21.3" +name = "crypto_secretbox" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" dependencies = [ - "fnv", - "ident_case", - "proc-macro2 1.0.106", - "quote 1.0.45", - "serde", - "strsim", - "syn 2.0.117", + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", ] [[package]] -name = "darling_macro" -version = "0.21.3" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "darling_core", - "quote 1.0.45", - "syn 2.0.117", -] - -[[package]] -name = "dashmap" -version = "6.1.0" + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2 1.0.106", + "quote 1.0.45", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2 1.0.106", + "quote 1.0.45", + "serde", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ @@ -2326,13 +2763,57 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2 1.0.106", + "quote 1.0.45", + "rustc_version 0.4.1", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", ] [[package]] @@ -2433,6 +2914,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -2474,12 +2961,27 @@ dependencies = [ "num-bigint", "num-traits", "serde", - "sha2", + "sha2 0.10.9", "sha3", "thiserror 1.0.69", "zk-kit-imt", ] +[[package]] +name = "e3-data-availability" +version = "0.14.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "anyhow", + "async-trait", + "avail-rust-client", + "hex", + "reqwest", + "serde", + "tokio", +] + [[package]] name = "e3-evm-helpers" version = "0.14.0" @@ -2577,6 +3079,7 @@ dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "hex", + "risc0-bigint2", "sha3", "taceo-poseidon2", ] @@ -2615,7 +3118,7 @@ dependencies = [ "num-bigint", "rand 0.9.2", "serde_json", - "sha2", + "sha2 0.10.9", "sha3", ] @@ -2692,6 +3195,29 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -2833,6 +3359,26 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "evm-helpers" version = "0.1.0" @@ -2864,7 +3410,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "auto_impl", "bytes", ] @@ -2875,7 +3421,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "auto_impl", "bytes", ] @@ -2935,7 +3481,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "serde", - "sha2", + "sha2 0.10.9", "tfhe-ntt", "thiserror 2.0.18", "zeroize", @@ -2963,6 +3509,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3039,6 +3591,43 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frame-decode" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cb8796f93fa038f979a014234d632e9688a120e745f936e2635123c77537f7" +dependencies = [ + "frame-metadata 21.0.0", + "parity-scale-codec", + "scale-decode", + "scale-info", + "scale-type-resolver", + "sp-crypto-hashing", +] + +[[package]] +name = "frame-metadata" +version = "20.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26de808fa6461f2485dc51811aefed108850064994fb4a62b3ac21ffa62ac8df" +dependencies = [ + "cfg-if 1.0.4", + "parity-scale-codec", + "scale-info", + "serde", +] + +[[package]] +name = "frame-metadata" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dfd1d7eae1d94e32e869e2fb272d81f52dd8db57820a373adb83ea24d7d862" +dependencies = [ + "cfg-if 1.0.4", + "parity-scale-codec", + "scale-info", +] + [[package]] name = "fs2" version = "0.4.3" @@ -3109,6 +3698,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -3132,6 +3734,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.31" @@ -3226,6 +3834,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand 0.8.6", + "rand_core 0.6.4", +] + [[package]] name = "gimli" version = "0.32.3" @@ -3307,6 +3925,11 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", + "serde", +] [[package]] name = "hashbrown" @@ -3360,7 +3983,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", ] [[package]] @@ -3372,6 +4005,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + [[package]] name = "http" version = "0.2.12" @@ -3663,12 +4307,30 @@ dependencies = [ "parity-scale-codec", ] +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +dependencies = [ + "parity-scale-codec", +] + [[package]] name = "impl-more" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "impl-serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a143eada6a1ec4aefa5049037a26a6d597bfd64f8c026d07b77133e02b7dd0b" +dependencies = [ + "serde", +] + [[package]] name = "impl-trait-for-tuples" version = "0.2.3" @@ -3680,6 +4342,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "include_bytes_aligned" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee796ad498c8d9a1d68e477df8f754ed784ef875de1414ebdaf169f70a6a784" + [[package]] name = "indenter" version = "0.3.4" @@ -3709,6 +4377,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3814,8 +4497,52 @@ dependencies = [ ] [[package]] -name = "jobserver" -version = "0.1.34" +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if 1.0.4", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ @@ -3846,6 +4573,86 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonrpsee" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c4b1f204b655b36b24dc4939af20366c649431d4711863bbbae5c495f3eeb4" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "jsonrpsee-ws-client", +] + +[[package]] +name = "jsonrpsee-client-transport" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e1420b1792cff778e2a1ebaa44115f156ee62a94dd106eaa51163f037d2023" +dependencies = [ + "base64 0.22.1", + "futures-util", + "http 1.4.1", + "jsonrpsee-core", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49bfa9334963e1c85866b39dff3ffcc81f1c286eb23334267c5cb97677543a4" +dependencies = [ + "async-trait", + "futures-timer", + "futures-util", + "jsonrpsee-types", + "pin-project", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d86fc943f81dab0ecdd6c0240b6e0f55ad57a2ea9ad8ad7efe8456fb9cc7a4" +dependencies = [ + "http 1.4.1", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonrpsee-ws-client" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df5bd5c38c0906a6e8b3a38c8c22cc8525fda25fd1a03a3fe010686aea66b70" +dependencies = [ + "http 1.4.1", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "url", +] + [[package]] name = "k256" version = "0.13.4" @@ -3857,7 +4664,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3879,6 +4686,16 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "keccak-hash" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1b8590eb6148af2ea2d75f38e7d29f5ca970d5a4df456b3ef19b8b415d0264" +dependencies = [ + "primitive-types 0.13.1", + "tiny-keccak", +] + [[package]] name = "konst" version = "0.2.20" @@ -3933,6 +4750,54 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64 0.22.1", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.8.6", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + [[package]] name = "light-poseidon" version = "0.1.0" @@ -4006,6 +4871,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru" version = "0.13.0" @@ -4048,6 +4922,18 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "mime" version = "0.3.17" @@ -4082,6 +4968,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multi-stash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" + [[package]] name = "multimap" version = "0.10.1" @@ -4121,6 +5013,12 @@ dependencies = [ "serde", ] +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + [[package]] name = "nom" version = "7.1.3" @@ -4189,6 +5087,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -4296,6 +5205,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.80" @@ -4355,7 +5270,7 @@ version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "bitvec", "byte-slice-cast", "const_format", @@ -4377,6 +5292,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.11.2" @@ -4425,6 +5346,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4437,6 +5369,17 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", + "password-hash", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4483,7 +5426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4539,6 +5482,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4555,6 +5509,31 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if 1.0.4", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4622,8 +5601,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" dependencies = [ "fixed-hash", - "impl-codec", - "uint", + "impl-codec 0.6.0", + "uint 0.9.5", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "impl-codec 0.7.1", + "impl-serde", + "scale-info", + "uint 0.10.1", ] [[package]] @@ -4894,7 +5886,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -5133,7 +6125,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -5151,6 +6143,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "risc0-bigint2" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87f5f7494a2242cead2750b7ce2b8522c1be83dee268479f1c12ed521eaf595" +dependencies = [ + "include_bytes_aligned", + "stability", +] + [[package]] name = "rlp" version = "0.5.2" @@ -5190,7 +6192,7 @@ dependencies = [ "num-integer", "num-traits", "parity-scale-codec", - "primitive-types", + "primitive-types 0.12.2", "proptest", "rand 0.8.6", "rand 0.9.2", @@ -5272,6 +6274,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5280,6 +6283,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -5290,6 +6305,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -5319,6 +6361,16 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ruzstd" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5174a470eeb535a721ae9fdd6e291c2411a906b96592182d05217591d5c5cf7b" +dependencies = [ + "byteorder", + "derive_more 0.99.20", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5326,10 +6378,146 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "schannel" -version = "0.1.29" +name = "salsa20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scale-bits" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27243ab0d2d6235072b017839c5f0cd1a3b1ce45c0f7a715363b0c7d36c76c94" +dependencies = [ + "parity-scale-codec", + "scale-info", + "scale-type-resolver", + "serde", +] + +[[package]] +name = "scale-decode" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d6ed61699ad4d54101ab5a817169259b5b0efc08152f8632e61482d8a27ca3d" +dependencies = [ + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-bits", + "scale-decode-derive", + "scale-type-resolver", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-decode-derive" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cb245f7fdb489e7ba43a616cbd34427fe3ba6fe0edc1d0d250085e6c84f3ec" +dependencies = [ + "darling 0.20.11", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "scale-encode" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a976d73564a59e482b74fd5d95f7518b79ca8c8ca5865398a4d629dd15ee50" +dependencies = [ + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-bits", + "scale-encode-derive", + "scale-type-resolver", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-encode-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17020f2d59baabf2ddcdc20a4e567f8210baf089b8a8d4785f5fd5e716f92038" +dependencies = [ + "darling 0.20.11", + "proc-macro-crate", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "bitvec", + "cfg-if 1.0.4", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", + "serde", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2 1.0.106", + "quote 1.0.45", + "syn 2.0.117", +] + +[[package]] +name = "scale-type-resolver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0cded6518aa0bd6c1be2b88ac81bf7044992f0f154bfbabd5ad34f43512abcb" +dependencies = [ + "scale-info", + "smallvec", +] + +[[package]] +name = "scale-value" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b64809a541e8d5a59f7a9d67cc700cdf5d7f907932a83a0afdedc90db07ccb" +dependencies = [ + "either", + "parity-scale-codec", + "scale-bits", + "scale-decode", + "scale-encode", + "scale-type-resolver", + "thiserror 2.0.18", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -5358,6 +6546,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.6", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core 0.6.4", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5370,6 +6577,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5406,6 +6625,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5469,6 +6697,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5560,7 +6798,7 @@ version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2 1.0.106", "quote 1.0.45", "syn 2.0.117", @@ -5598,6 +6836,19 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5629,6 +6880,15 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -5667,6 +6927,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -5698,6 +6964,113 @@ dependencies = [ "serde", ] +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smoldot" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "966e72d77a3b2171bb7461d0cb91f43670c63558c62d7cf42809cae6c8b6b818" +dependencies = [ + "arrayvec 0.7.6", + "async-lock", + "atomic-take", + "base64 0.22.1", + "bip39", + "blake2-rfc", + "bs58", + "chacha20 0.9.1", + "crossbeam-queue", + "derive_more 0.99.20", + "ed25519-zebra", + "either", + "event-listener", + "fnv", + "futures-lite", + "futures-util", + "hashbrown 0.14.5", + "hex", + "hmac 0.12.1", + "itertools 0.13.0", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand 0.8.6", + "rand_chacha 0.3.1", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot-light" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a33b06891f687909632ce6a4e3fd7677b24df930365af3d0bcb078310129f3f" +dependencies = [ + "async-channel", + "async-lock", + "base64 0.22.1", + "blake2-rfc", + "bs58", + "derive_more 0.99.20", + "either", + "event-listener", + "fnv", + "futures-channel", + "futures-lite", + "futures-util", + "hashbrown 0.14.5", + "hex", + "itertools 0.13.0", + "log", + "lru 0.12.5", + "parking_lot 0.12.5", + "pin-project", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_json", + "siphasher", + "slab", + "smol", + "smoldot", + "zeroize", +] + [[package]] name = "socket2" version = "0.5.10" @@ -5718,6 +7091,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures 0.3.31", + "httparse", + "log", + "rand 0.8.6", + "sha1 0.10.6", +] + +[[package]] +name = "sp-crypto-hashing" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9927a7f81334ed5b8a98a4a978c81324d12bd9713ec76b5c68fd410174c5eb" +dependencies = [ + "blake2b_simd", + "byteorder", + "digest 0.10.7", + "sha2 0.10.9", + "sha3", + "twox-hash", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.7.3" @@ -5728,6 +7136,16 @@ dependencies = [ "der", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote 1.0.45", + "syn 2.0.117", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5740,6 +7158,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string-interner" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6a0d765f5807e98a091107bae0a56ea3799f66a5de47b2c84c94a39c09974e" +dependencies = [ + "cfg-if 1.0.4", + "hashbrown 0.14.5", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5773,6 +7201,118 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subxt-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66ef00be9d64885ec94e478a58e4e39d222024b20013ae7df4fc6ece545391aa" +dependencies = [ + "base58", + "blake2", + "derive-where", + "frame-decode", + "frame-metadata 20.0.0", + "hashbrown 0.14.5", + "hex", + "impl-serde", + "keccak-hash", + "parity-scale-codec", + "primitive-types 0.13.1", + "scale-bits", + "scale-decode", + "scale-encode", + "scale-info", + "scale-value", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-metadata", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "subxt-lightclient" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce07c2515b2e63b85ec3043fe4461b287af0615d4832c2fe6e81ba780b906bc0" +dependencies = [ + "futures 0.3.31", + "futures-util", + "serde", + "serde_json", + "smoldot-light", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "subxt-metadata" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff4591673600c4388e21305788282414d26c791b4dee21b7cb0b19c10076f98" +dependencies = [ + "frame-decode", + "frame-metadata 20.0.0", + "hashbrown 0.14.5", + "parity-scale-codec", + "scale-info", + "sp-crypto-hashing", + "thiserror 2.0.18", +] + +[[package]] +name = "subxt-rpcs" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba7494d250d65dc3439365ac5e8e0fbb9c3992e6e84b7aa01d69e082249b8b8" +dependencies = [ + "derive-where", + "frame-metadata 20.0.0", + "futures 0.3.31", + "hex", + "impl-serde", + "jsonrpsee", + "parity-scale-codec", + "primitive-types 0.13.1", + "serde", + "serde_json", + "subxt-lightclient", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "subxt-signer" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a2370298a210ed1df26152db7209a85e0ed8cfbce035309c3b37f7b61755377" +dependencies = [ + "base64 0.22.1", + "bip39", + "cfg-if 1.0.4", + "crypto_secretbox", + "hex", + "hmac 0.12.1", + "parity-scale-codec", + "pbkdf2", + "regex", + "schnorrkel", + "scrypt", + "secp256k1", + "secrecy", + "serde", + "serde_json", + "sha2 0.10.9", + "sp-crypto-hashing", + "subxt-core", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "syn" version = "1.0.109" @@ -6101,6 +7641,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -6252,6 +7793,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "serde", + "serde_json", + "sharded-slab", + "thread_local", + "tracing-core", + "tracing-serde", ] [[package]] @@ -6279,6 +7845,18 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if 1.0.4", + "digest 0.10.7", + "rand 0.8.6", + "static_assertions", +] + [[package]] name = "typenum" version = "1.20.1" @@ -6303,6 +7881,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "uint" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unarray" version = "0.1.4" @@ -6315,6 +7905,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -6339,6 +7938,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6402,6 +8011,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -6550,6 +8169,46 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasmi" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50386c99b9c32bd2ed71a55b6dd4040af2580530fae8bdb9a6576571a80d0cca" +dependencies = [ + "arrayvec 0.7.6", + "multi-stash", + "num-derive", + "num-traits", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "wasmi_collections" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c128c039340ffd50d4195c3f8ce31aac357f06804cfc494c8b9508d4b30dca4" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23b3a7f6c8c3ceeec6b83531ee61f0013c56e51cbf2b14b0f213548b23a4b41" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6562,6 +8221,15 @@ dependencies = [ "semver 1.0.28", ] +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -6596,6 +8264,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.9", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -6636,6 +8322,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -6718,6 +8413,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6754,6 +8458,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6787,6 +8506,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6799,6 +8524,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6811,6 +8542,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6835,6 +8572,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6847,6 +8590,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6859,6 +8608,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6871,6 +8626,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7029,6 +8790,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/examples/CRISP/Cargo.toml b/examples/CRISP/Cargo.toml index 6556e323fb..19fb799377 100644 --- a/examples/CRISP/Cargo.toml +++ b/examples/CRISP/Cargo.toml @@ -34,10 +34,12 @@ bincode = { version = "=1.3.3" } bytemuck = { version = "=1.23.1" } derivative = "=2.2.0" e3-compute-provider = { path = "../../crates/compute-provider" } +e3-data-availability = { path = "../../crates/data-availability" } e3-program-server = { path = "../../crates/program-server" } e3-bfv-client = { path = "../../crates/bfv-client" } e3-zk-helpers = { path = "../../crates/zk-helpers" } e3-fhe-params = { path = "../../crates/fhe-params" } +e3-evm-helpers = { path = "../../crates/evm-helpers" } e3-sdk = { path = "../../crates/sdk", default-features = false } e3-polynomial = { path = "../../crates/polynomial" } eyre = "=0.6.12" @@ -53,4 +55,5 @@ fhe-math = { git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } fhe-util = { git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } tokio = { version = "=1.46.1", features = ["full"] } rand = { version = "0.9" } +tracing = { version = "=0.1.41" } tracing-subscriber = { version = "=0.3.20", features = ["env-filter"] } diff --git a/examples/CRISP/Readme.md b/examples/CRISP/Readme.md index 76b28b40a8..e2419fad74 100644 --- a/examples/CRISP/Readme.md +++ b/examples/CRISP/Readme.md @@ -158,7 +158,9 @@ program: rpc_url: 'https://sepolia.infura.io/v3/YOUR_KEY' # RPC endpoint private_key: 'YOUR_PRIVATE_KEY' # Wallet with funds for proving pinata_jwt: 'YOUR_PINATA_JWT' # Required for uploading programs to IPFS - program_url: 'https://gateway.pinata.cloud/ipfs/YOUR_CID' # Pre-uploaded program URL + # The gateway must allow full, unauthenticated downloads by Boundless provers. + ipfs_gateway_url: 'https://your-gateway.mypinata.cloud' + program_url: 'https://your-gateway.mypinata.cloud/ipfs/YOUR_CID' # Pre-uploaded program URL onchain: true # true = onchain requests, false = offchain ``` @@ -187,13 +189,79 @@ program URL: the full URL: ```yaml - program_url: 'https://gateway.pinata.cloud/ipfs/QmXxx...' + ipfs_gateway_url: 'https://your-gateway.mypinata.cloud' + program_url: 'https://your-gateway.mypinata.cloud/ipfs/QmXxx...' ``` > **_Important:_** Every time you modify the guest program code in `program/`, you must rebuild and > re-upload it to IPFS, then update the `program_url` in your configuration. This ensures Boundless > uses your latest program version. +### Encrypted-object data availability + +Local development uses `DATA_AVAILABILITY_MODE=mock`. The mock keeps the full input and aggregate +ciphertext in the CRISP server database and produces a deterministic local receipt. It does not +model VectorX latency or Avail fees. + +Sepolia and Ethereum mainnet use Avail. Before starting the CRISP server: + +1. Register an Avail App ID for CRISP. +2. Fund a dedicated Avail account that can pay for every `submit_data` transaction. +3. Keep the server database durable. It stores each pending publication until its VectorX proof is + available and resumes the job after a restart. +4. Deploy CRISP with `INPUT_AVAILABILITY_SIGNER` set to the Ethereum address derived from the + server's `PRIVATE_KEY`. +5. Configure an input window that covers the current on-chain committee setup, voting minimum, and + VectorX finalization tail. The server reads those values at startup. Twelve hours covers the + current production defaults with margin. + +The server signs a 10-minute commitment payload only after it stores and validates the complete +ciphertext. If the commitment does not reach Ethereum before that payload expires, the server waits +for Ethereum finality, releases the uncommitted bytes, and lets the voter stage the vote again. + +```dotenv +# Sepolia + Avail Turing +DATA_AVAILABILITY_MODE=avail +AVAIL_RPC_URL=https://turing-rpc.avail.so/rpc +AVAIL_BRIDGE_API_URL=https://turing-bridge-api.avail.so +AVAIL_APP_ID= +AVAIL_SEED= +AVAIL_PROOF_LEAD_SECONDS=10800 +# Unfinished objects are refused once they reach this capacity. Size it for the largest supported +# round and monitor the server volume. Default: 1 GiB. +DATA_AVAILABILITY_MAX_PENDING_BYTES=1073741824 +# 12 hours. Covers the current 1h VRF + 10m sortition + 6h DKG upper bound, +# at least 1h of voting, and the 3h VectorX finalization target. +E3_DURATION=43200 + +# Ethereum mainnet uses these two endpoints instead: +# AVAIL_RPC_URL=https://avail-rpc.publicnode.com/ +# AVAIL_BRIDGE_API_URL=https://bridge-api.avail.so +``` + +DAO deployments can set `DEFER_PROTOCOL_WIRING=true` to deploy CRISP before the governance wiring +transaction. On Ethereum mainnet, the deployment also requires `ALLOW_MAINNET_DEFERRED_WIRING=true` +as an explicit acknowledgement that CRISP is unusable until the DAO batch is executed and validated. +Keep E3 requests paused throughout that interval. + +The server first validates the Noir proof and durably stores the exact encrypted bytes. It signs a +compact proof commitment only after storage succeeds. On mainnet, the voter submits that commitment +from their wallet and can then leave. The server publishes the ciphertext to Avail, waits for the +official VectorX proof, and finalizes the input without the voter. The proof transaction reserves +the input's tree index immediately, so masks and revotes can still extend it during the VectorX +wait. CRISP refuses the aggregate computation while any input is not finalized. + +The service accepts only one not-yet-committed input per round and voting slot. It also limits the +total bytes held by unfinished jobs. These controls bound abandoned signed inputs without deleting +data that Ethereum already accepted. After Avail and Ethereum accept an object, the service removes +its staging copy because Avail is then the recovery source. + +The aggregate ciphertext follows the Avail and VectorX path after its RISC Zero proof is ready. + +Each accepted Ethereum reference contains `keccak256(exact bytes)`. The CRISP server and ciphernodes +re-hash retrieved bytes before they use them. An App ID helps indexing, but it is not a security +boundary. + ### Environment Variables The `pnpm dev:setup` command automatically creates `.env` files for the server and client from the diff --git a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx index eb69b8da90..85d325ee26 100644 --- a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx +++ b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx @@ -6,7 +6,7 @@ import { createGenericContext } from '@/utils/create-generic-context' import { VoteManagementContextType, VoteManagementProviderProps } from '@/context/voteManagement' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAccount, useChainId } from 'wagmi' import { VoteStateLite, VotingRound } from '@/model/vote.model' import { useInterfoldServer } from '@/hooks/interfold/useInterfoldServer' @@ -28,6 +28,8 @@ const getVoteCacheKey = (chainId: number, roundId: string, address: string): str } const nowInSeconds = (): number => Math.floor(Date.now() / 1000) +const ROUND_POLL_INITIAL_MS = 10_000 +const ROUND_POLL_MAX_MS = 60_000 const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { /** @@ -47,6 +49,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { const [txUrl, setTxUrl] = useState(undefined) const [pollResult, setPollResult] = useState(null) const [currentRoundId, setCurrentRoundId] = useState(null) + const [pendingCurrentRoundId, setPendingCurrentRoundId] = useState(null) const [displayedRoundIsFallback, setDisplayedRoundIsFallback] = useState(false) const [hasVotedInCurrentRound, setHasVotedInCurrentRound] = useState(false) @@ -67,6 +70,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { getWebResult, getCurrentRound, broadcastVote, + getVoteAvailability, } = useInterfoldServer() /// Purely local โ€” see the note on `getVoteCacheKey`. Async only to keep the signature the @@ -102,16 +106,39 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { [chainId, userAddress, currentRoundId], ) + const applyRoundState = useCallback((fetchedRoundState: VoteStateLite) => { + if (fetchedRoundState.committee_public_key.length === 1 && fetchedRoundState.committee_public_key[0] === 0) { + handleGenericError('getRoundStateLite', { + message: 'Interfold server failed generating the necessary pk bytes', + name: 'getRoundStateLite', + }) + } + + const startBlockNumber = Number(fetchedRoundState.start_block) + setRoundState({ ...fetchedRoundState, start_block: startBlockNumber }) + setVotingRound({ round_id: fetchedRoundState.id, pk_bytes: fetchedRoundState.committee_public_key }) + setPollOptions(generatePoll({ round_id: fetchedRoundState.id, emojis: fetchedRoundState.emojis })) + setRoundEndDate(convertTimestampToDate(fetchedRoundState.end_time)) + }, []) + const initialLoad = async () => { const currentRound = await getCurrentRound() if (!currentRound) return - setCurrentRoundId(currentRound.id) // If the current round has ended without a published tally, the page would // otherwise sit forever in "Over ยท Tallyingโ€ฆ". Fall back to the latest past // round that does have a tally so the user sees something useful. const fetched = await getRoundStateLiteRequest(currentRound.id) - if (!fetched) return + const confirmedRound = await getCurrentRound() + if (!confirmedRound || confirmedRound.id !== currentRound.id) { + return + } + setCurrentRoundId(currentRound.id) + if (!fetched) { + setPendingCurrentRoundId(currentRound.id) + return + } + setPendingCurrentRoundId(null) const ended = Number(fetched.end_time) <= nowInSeconds() let fallbackRoundId: string | null = null @@ -135,28 +162,168 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } setDisplayedRoundIsFallback(fallbackRoundId !== null) - await getRoundStateLite(fallbackRoundId ?? currentRound.id) + if (fallbackRoundId) { + await getRoundStateLite(fallbackRoundId) + } else { + applyRoundState(fetched) + } } const getRoundStateLite = async (roundId: string) => { const fetchedRoundState = await getRoundStateLiteRequest(roundId) - if (fetchedRoundState?.committee_public_key.length === 1 && fetchedRoundState.committee_public_key[0] === 0) { - handleGenericError('getRoundStateLite', { - message: 'Interfold server failed generating the necessary pk bytes', - name: 'getRoundStateLite', - }) - } if (fetchedRoundState) { - const startBlockNumber = Number(fetchedRoundState.start_block) - setRoundState({ ...fetchedRoundState, start_block: startBlockNumber }) - setVotingRound({ round_id: fetchedRoundState.id, pk_bytes: fetchedRoundState.committee_public_key }) - setPollOptions(generatePoll({ round_id: fetchedRoundState.id, emojis: fetchedRoundState.emojis })) - setRoundEndDate(convertTimestampToDate(fetchedRoundState.end_time)) - setCurrentRoundId(fetchedRoundState.id) + applyRoundState(fetchedRoundState) } } + const getRoundStateLiteRequestRef = useRef(getRoundStateLiteRequest) + useEffect(() => { + getRoundStateLiteRequestRef.current = getRoundStateLiteRequest + }, [getRoundStateLiteRequest]) + + const getCurrentRoundRef = useRef(getCurrentRound) + useEffect(() => { + getCurrentRoundRef.current = getCurrentRound + }, [getCurrentRound]) + + useEffect(() => { + if (currentRoundId !== null) return + + let cancelled = false + let timer: ReturnType | null = null + let delay = ROUND_POLL_INITIAL_MS + + function schedule(wait = delay) { + if (cancelled || document.hidden) return + timer = setTimeout(() => { + timer = null + void poll() + }, wait) + } + + async function poll() { + if (cancelled) return + + const currentRound = await getCurrentRoundRef.current() + if (cancelled) return + + if (currentRound) { + const fetched = await getRoundStateLiteRequestRef.current(currentRound.id) + if (cancelled) return + + // The current-round pointer can change while its state is in flight. Confirm it again + // before committing either value, or this effect stops polling on a stale round. + const confirmedRound = await getCurrentRoundRef.current() + if (cancelled) return + if (!confirmedRound || confirmedRound.id !== currentRound.id) { + schedule(1_000) + return + } + + // Fetch the state before storing the round ID. Storing the ID reruns this + // effect and cancels the current request. If we store it first, a round + // that becomes active after page load can discard its successful state + // response and leave the page in the preparing state permanently. + setCurrentRoundId(currentRound.id) + setDisplayedRoundIsFallback(false) + + if (fetched) { + applyRoundState(fetched) + setPendingCurrentRoundId(null) + } else { + setPendingCurrentRoundId(currentRound.id) + } + return + } + + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + } + + function resumeWhenVisible() { + if (!document.hidden && !cancelled && !timer) { + delay = ROUND_POLL_INITIAL_MS + void poll() + } + } + document.addEventListener('visibilitychange', resumeWhenVisible) + schedule() + return () => { + cancelled = true + if (timer) clearTimeout(timer) + document.removeEventListener('visibilitychange', resumeWhenVisible) + } + }, [currentRoundId, applyRoundState]) + + useEffect(() => { + if (!pendingCurrentRoundId) return + const pendingRoundId = pendingCurrentRoundId + + let cancelled = false + let timer: ReturnType | null = null + let delay = ROUND_POLL_INITIAL_MS + + function schedule() { + if (cancelled || document.hidden) return + timer = setTimeout(() => { + timer = null + void poll() + }, delay) + } + + async function poll() { + if (cancelled) return + + const currentRound = await getCurrentRoundRef.current() + if (cancelled) return + if (currentRound && currentRound.id !== pendingRoundId) { + // A newer round replaced the one whose key we were waiting for. Reset discovery instead + // of keeping the page attached to an old round that may never become readable. + setPendingCurrentRoundId(null) + setCurrentRoundId(null) + return + } + + const fetched = await getRoundStateLiteRequestRef.current(pendingRoundId) + if (cancelled) return + if (fetched) { + const confirmedRound = await getCurrentRoundRef.current() + if (cancelled) return + if (!confirmedRound) { + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + return + } + if (confirmedRound.id !== pendingRoundId) { + setPendingCurrentRoundId(null) + setCurrentRoundId(null) + return + } + applyRoundState(fetched) + setPendingCurrentRoundId(null) + return + } + + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + } + + function resumeWhenVisible() { + if (!document.hidden && !cancelled && !timer) { + delay = ROUND_POLL_INITIAL_MS + void poll() + } + } + document.addEventListener('visibilitychange', resumeWhenVisible) + schedule() + return () => { + cancelled = true + if (timer) clearTimeout(timer) + document.removeEventListener('visibilitychange', resumeWhenVisible) + } + }, [pendingCurrentRoundId, applyRoundState]) + const getPastPolls = async () => { try { const result = await getWebResult() @@ -212,6 +379,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { setPollOptions, initialLoad, broadcastVote, + getVoteAvailability, setVotingRound, checkVoteStatus, markVotedInRound, diff --git a/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts b/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts index 381c15a4b3..2eda253bce 100644 --- a/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts +++ b/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts @@ -29,7 +29,8 @@ export type VoteManagementContextType = { initialLoad: () => Promise getPastPolls: () => Promise setVotingRound: React.Dispatch> - broadcastVote: (vote: BroadcastVoteRequest) => Promise + broadcastVote: (vote: BroadcastVoteRequest, onJobCreated?: (jobId: string) => void) => Promise + getVoteAvailability: (jobId: string) => Promise getRoundStateLite: (roundId: string) => Promise setPastPolls: React.Dispatch> getWebResult: () => Promise diff --git a/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx b/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx index a175dd6b14..5aa6a836dc 100644 --- a/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx +++ b/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx @@ -8,6 +8,10 @@ import { useState } from 'react' import axios, { AxiosRequestConfig, Method } from 'axios' import { handleGenericError } from '@/utils/handle-generic-error' +type FetchConfig = AxiosRequestConfig & { + suppressNotFound?: boolean +} + export const useApi = () => { const [isLoading, setIsLoading] = useState(false) @@ -15,13 +19,15 @@ export const useApi = () => { url: string, method: Method = 'get', data?: U, - config?: AxiosRequestConfig, + config?: FetchConfig, ): Promise => { setIsLoading(true) + const { suppressNotFound = false, ...axiosConfig } = config ?? {} try { - const response = method === 'get' ? await axios.get(`${url}`, config) : await axios.post(`${url}`, data, config) + const response = method === 'get' ? await axios.get(`${url}`, axiosConfig) : await axios.post(`${url}`, data, axiosConfig) return response.data } catch (error) { + if (suppressNotFound && axios.isAxiosError(error) && error.response?.status === 404) return undefined handleGenericError(`API Error - ${url}`, error as Error) } finally { setIsLoading(false) diff --git a/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts b/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts index 487bd80653..8452baa1bd 100644 --- a/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts +++ b/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts @@ -17,6 +17,7 @@ import { import { useApi } from '../generic/useFetchApi' import { PollRequestResult } from '@/model/poll.model' import { ROUND_REQUESTERS } from '@/utils/constants' +import axios from 'axios' const INTERFOLD_API = import.meta.env.VITE_INTERFOLD_API @@ -28,17 +29,41 @@ const InterfoldEndpoints = { GetWebResult: `${INTERFOLD_API}/state/result`, GetWebAllResult: `${INTERFOLD_API}/state/all`, BroadcastVote: `${INTERFOLD_API}/voting/broadcast`, + GetVoteAvailability: `${INTERFOLD_API}/voting/availability`, GetVoteStatus: `${INTERFOLD_API}/voting/status`, GetEligibleVoters: `${INTERFOLD_API}/state/eligible-addresses`, GetMerkleLeaves: `${INTERFOLD_API}/state/token-holders`, } as const export const useInterfoldServer = () => { - const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetRoundStateLite, GetWebResult, GetVoteStatus } = InterfoldEndpoints + const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetVoteAvailability, GetRoundStateLite, GetWebResult, GetVoteStatus } = + InterfoldEndpoints const { fetchData, isLoading } = useApi() - const getCurrentRound = () => fetchData(GetCurrentRound, 'post', { requesters: ROUND_REQUESTERS }) - const getRoundStateLite = (round_id: string) => fetchData(GetRoundStateLite, 'post', { round_id }) - const broadcastVote = (vote: BroadcastVoteRequest) => fetchData(BroadcastVote, 'post', vote) + const getCurrentRound = () => + fetchData(GetCurrentRound, 'post', { requesters: ROUND_REQUESTERS }, { suppressNotFound: true }) + const getRoundStateLite = (round_id: string) => + fetchData(GetRoundStateLite, 'post', { round_id }, { suppressNotFound: true }) + const getVoteAvailability = async (jobId: string): Promise => { + const url = `${GetVoteAvailability}/${encodeURIComponent(jobId)}` + try { + return (await axios.get(url)).data + } catch (error) { + // A server replacement can legitimately lose its local job database. Tell the caller this + // job is gone so it can clear localStorage and submit again. Other failures are transient. + if (axios.isAxiosError(error) && error.response?.status === 404) return null + handleGenericError(`API Error - ${url}`, error as Error) + return undefined + } + } + const broadcastVote = async ( + vote: BroadcastVoteRequest, + onJobCreated?: (jobId: string) => void, + ): Promise => { + const initial = await fetchData(BroadcastVote, 'post', vote) + if (!initial) return undefined + if (initial.job_id) onJobCreated?.(initial.job_id) + return initial + } const getWebResult = () => fetchData(GetWebAllResult, 'post', { requesters: ROUND_REQUESTERS }) const getWebResultByRound = (round_id: string) => fetchData(GetWebResult, 'post', { round_id }) @@ -55,6 +80,7 @@ export const useInterfoldServer = () => { getCurrentRound, getRoundStateLite, broadcastVote, + getVoteAvailability, getVoteStatus, getEligibleVoters, getMerkleLeaves, diff --git a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts index 6d59f644c8..dff6b96a9d 100644 --- a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts +++ b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { useState, useCallback } from 'react' +import { useState, useCallback, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { useSignTypedData, usePublicClient, useChainId, useWalletClient } from 'wagmi' import type { Address } from 'viem' @@ -15,18 +15,67 @@ import { ensureCircuits } from '@/utils/circuits' import { useVoteManagementContext } from '@/context/voteManagement' import { useNotificationAlertContext } from '@/context/NotificationAlert/NotificationAlert.context.tsx' import { Poll } from '@/model/poll.model' -import { BroadcastVoteRequest, CensusMode, Vote, VoteStateLite, VotingRound } from '@/model/vote.model' +import { BroadcastVoteRequest, BroadcastVoteResponse, CensusMode, Vote, VoteStateLite, VotingRound } from '@/model/vote.model' import { useInterfoldServer } from '../interfold/useInterfoldServer' import { getRandomVoterToMask } from '@/utils/voters' import { handleGenericError } from '@/utils/handle-generic-error' import { NUM_OPTIONS } from '@/utils/constants' import { ballotTypedData, getBallotDigest, getCrispProgramAddress, getCrispRoundConfig } from '@/utils/ballotDigest' import { getRandomRegistrant, getVotingPower, isRegisteredIn } from '@/utils/onchainCensus' -import { submitVoteDirectly } from '@/utils/directVote' -import { isDirectVoteEnabled, txExplorerUrl } from '@/utils/methods' +import { submitInputCommitmentDirectly } from '@/utils/directVote' +import { txExplorerUrl } from '@/utils/methods' const INTERFOLD_API = import.meta.env.VITE_INTERFOLD_API +interface PendingAvailabilityJob { + jobId: string + isMask: boolean + encodedProof?: string +} + +const availabilityJobKey = (chainId: number, roundId: string, address: string): string => { + return `crisp-availability-${chainId}-${roundId}-${address.toLowerCase()}` +} + +const readAvailabilityJob = (key: string): PendingAvailabilityJob | undefined => { + try { + const stored = localStorage.getItem(key) + if (!stored) return undefined + const parsed: unknown = JSON.parse(stored) + if (typeof parsed !== 'object' || parsed === null || !('jobId' in parsed) || typeof parsed.jobId !== 'string') return undefined + return { + jobId: parsed.jobId, + isMask: 'isMask' in parsed && parsed.isMask === true, + encodedProof: 'encodedProof' in parsed && typeof parsed.encodedProof === 'string' ? parsed.encodedProof : undefined, + } + } catch { + return undefined + } +} + +const writeAvailabilityJob = (key: string, job: PendingAvailabilityJob): void => { + try { + localStorage.setItem(key, JSON.stringify(job)) + } catch { + // Large secure ballots can exceed a browser's storage quota. Preserve the small server job + // pointer when possible, even though a server-database loss would then need operator recovery. + try { + localStorage.setItem(key, JSON.stringify({ jobId: job.jobId, isMask: job.isMask })) + } catch { + // The durable server job remains valid. A browser with disabled storage cannot resume it + // automatically after a reload. + } + } +} + +const clearAvailabilityJob = (key: string): void => { + try { + localStorage.removeItem(key) + } catch { + // The item is already harmless after the server job reaches a terminal state. + } +} + /// The end of the slot's chain of usable entries, with the tree index the new input will name as /// its parent. Not simply the newest entry published: one whose bytes do not reproduce its /// commitment is never selected by the Secure Process and is never a valid parent, so the server @@ -108,6 +157,7 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo setTxUrl, markVotedInRound, hasVotedInCurrentRound, + getVoteAvailability, } = useVoteManagementContext() const roundState = customRoundState ?? contextRoundState @@ -125,6 +175,7 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo const [votingStep, setVotingStep] = useState('idle') const [lastActiveStep, setLastActiveStep] = useState(null) const [stepMessage, setStepMessage] = useState('') + const submissionInProgress = useRef(false) /** * Encrypt the ballot, have the voter sign the digest that binds it, then prove it. @@ -353,11 +404,6 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo const castVoteWithProof = useCallback( async (pollSelected: Poll | null, isAMask: boolean = false, maskTarget: MaskTarget = 'random') => { - if (!isAMask && !pollSelected) { - console.log('Cannot cast vote: Poll option not selected.') - showToast({ type: 'danger', message: 'Please select a poll option first.' }) - return - } if (!user || !roundState) { console.error('Cannot cast vote: Missing user or round state.') showToast({ @@ -368,7 +414,119 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo return } + // One account has one durable resume pointer per round. Do not let concurrent actions + // replace that pointer before the first request records its job ID. + if (submissionInProgress.current) return + submissionInProgress.current = true + + const pendingJobKey = availabilityJobKey(chainId, roundState.id, user.address) + + const finishCommitment = async (response: BroadcastVoteResponse, operationIsMask: boolean): Promise => { + if (response.status === 'failed_broadcast') { + throw new Error(extractCleanErrorMessage(response.message ?? undefined)) + } + + if (response.status === 'pending_commitment') { + setVotingStep('confirming') + setStepMessage('Your proof is queued for commitment. You can safely leave and check again later.') + showToast({ + type: 'success', + message: 'Vote proof queued. The relay will keep retrying it.', + }) + return false + } + + let txHash: string | undefined = response.tx_hash ?? undefined + if (response.status === 'ready_for_commitment') { + if (!walletClient || !publicClient) { + throw new Error('No wallet available to commit the vote proof') + } + + setStepMessage('Please confirm the transaction in your wallet...') + + const e3Id = BigInt(roundState.id) + const crispProgram = await getCrispProgramAddress(publicClient, roundState.interfold_address as `0x${string}`, e3Id) + if (!response.encoded_proof) { + throw new Error('Availability job is missing the input commitment payload') + } + txHash = await submitInputCommitmentDirectly( + walletClient, + publicClient, + crispProgram, + e3Id, + response.encoded_proof as `0x${string}`, + ) + } + + setVotingStep('complete') + const finalized = response.status === 'success' + setStepMessage( + finalized + ? `${operationIsMask ? 'Masking' : 'Vote'} finalized successfully!` + : `${operationIsMask ? 'Masking' : 'Vote'} committed. Availability will finalize in the background.`, + ) + + const url = txHash ? txExplorerUrl(txHash) : undefined + setTxUrl(url) + + if (!operationIsMask) markVotedInRound(roundState.id) + + showToast({ + type: 'success', + message: finalized + ? operationIsMask + ? 'Slot masked successfully' + : 'Vote finalized successfully!' + : operationIsMask + ? 'Mask committed. You can safely leave this page.' + : 'Vote committed. You can safely leave this page.', + linkUrl: url, + }) + navigate(`/result/${roundState.id}/confirmation`) + return true + } + try { + const pendingJob = readAvailabilityJob(pendingJobKey) + if (pendingJob) { + setIsMasking(pendingJob.isMask) + setIsVoting(!pendingJob.isMask) + setVotingStep('broadcasting') + setLastActiveStep('broadcasting') + setStepMessage('Checking the durable vote job...') + + const resumed = await getVoteAvailability(pendingJob.jobId) + if (resumed === null) { + // The server lost its job database. Re-stage the same bytes: a fresh ciphertext could + // leave an earlier on-chain commitment unresolved and stop the complete round. + if (!pendingJob.encodedProof) { + throw new Error('The server lost this legacy vote job. An operator must recover it before another vote is submitted.') + } + const restaged = await broadcastVote({ round_id: roundState.id, encoded_proof: pendingJob.encodedProof }, (jobId) => + writeAvailabilityJob(pendingJobKey, { ...pendingJob, jobId }), + ) + if (!restaged) throw new Error('Could not restore the pending data-availability job.') + if (restaged.status === 'failed_broadcast') clearAvailabilityJob(pendingJobKey) + if (await finishCommitment(restaged, pendingJob.isMask)) { + clearAvailabilityJob(pendingJobKey) + } + return + } else { + if (!resumed) throw new Error('Could not read the pending data-availability job.') + if (resumed.status === 'failed_broadcast') clearAvailabilityJob(pendingJobKey) + if (await finishCommitment(resumed, pendingJob.isMask)) { + clearAvailabilityJob(pendingJobKey) + } + return + } + } + + if (!isAMask && !pollSelected) { + console.log('Cannot cast vote: Poll option not selected.') + showToast({ type: 'danger', message: 'Please select a poll option first.' }) + return + } + let voteData const isOnchain = roundState.census_mode === CensusMode.Onchain @@ -434,56 +592,21 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo setVotingStep('broadcasting') setLastActiveStep('broadcasting') - let txHash: string | undefined - - if (isDirectVoteEnabled()) { - if (!walletClient || !publicClient) { - throw new Error('No wallet available to submit the vote directly') - } - - setStepMessage('Please confirm the transaction in your wallet...') - - const e3Id = BigInt(roundState.id) - const crispProgram = await getCrispProgramAddress(publicClient, roundState.interfold_address as `0x${string}`, e3Id) - txHash = await submitVoteDirectly(walletClient, publicClient, crispProgram, e3Id, encodedProof as `0x${string}`) - } else { - const voteRequest: BroadcastVoteRequest = { - round_id: roundState.id, - encoded_proof: encodedProof, - } - - const broadcastVoteResponse = await broadcastVote(voteRequest) - - if (!broadcastVoteResponse) { - throw new Error('Received no response after broadcasting vote.') - } - if (broadcastVoteResponse.status !== 'success') { - setVotingStep('error') - showToast({ - type: 'danger', - message: extractCleanErrorMessage(broadcastVoteResponse.message), - persistent: true, - }) - return - } - - txHash = broadcastVoteResponse.tx_hash + const voteRequest: BroadcastVoteRequest = { + round_id: roundState.id, + encoded_proof: encodedProof, } - - setVotingStep('complete') - setStepMessage(`${isAMask ? 'Masking' : 'Vote'} submitted successfully!`) - - const url = txHash ? txExplorerUrl(txHash) : undefined - setTxUrl(url) - - if (!isAMask) markVotedInRound(roundState.id) - - showToast({ - type: 'success', - message: isAMask ? 'Slot masked successfully' : 'Vote submitted successfully!', - linkUrl: url, + const broadcastVoteResponse = await broadcastVote(voteRequest, (jobId) => { + writeAvailabilityJob(pendingJobKey, { jobId, isMask: isAMask, encodedProof }) }) - navigate(`/result/${roundState.id}/confirmation`) + + if (!broadcastVoteResponse) { + throw new Error('Received no response after publishing vote data.') + } + if (broadcastVoteResponse.status === 'failed_broadcast') clearAvailabilityJob(pendingJobKey) + if (await finishCommitment(broadcastVoteResponse, isAMask)) { + clearAvailabilityJob(pendingJobKey) + } } catch (error) { setVotingStep('error') console.error('Vote processing failed:', error) @@ -493,6 +616,7 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo persistent: true, }) } finally { + submissionInProgress.current = false setIsVoting(false) setIsMasking(false) } @@ -511,6 +635,8 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo handleMask, handleVote, getMerkleLeaves, + getVoteAvailability, + chainId, ], ) diff --git a/examples/CRISP/client/src/model/vote.model.ts b/examples/CRISP/client/src/model/vote.model.ts index 250df49fe7..08e3e38f9a 100644 --- a/examples/CRISP/client/src/model/vote.model.ts +++ b/examples/CRISP/client/src/model/vote.model.ts @@ -32,11 +32,13 @@ export interface BroadcastVoteRequest { encoded_proof: string } -export type VoteResponseStatus = 'success' | 'failed_broadcast' +export type VoteResponseStatus = 'success' | 'pending_commitment' | 'ready_for_commitment' | 'pending_availability' | 'failed_broadcast' export interface BroadcastVoteResponse { status: VoteResponseStatus - tx_hash?: string - message?: string + tx_hash: string | null + job_id: string | null + encoded_proof: string | null + message: string | null } export interface VoteStatusRequest { diff --git a/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx b/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx index 7d249f2907..d8c3a8f268 100644 --- a/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx +++ b/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx @@ -56,6 +56,7 @@ const DailyPollSection: React.FC = ({ loading, endTime, t isLoading, getWebResultByRound, displayedRoundIsFallback, + currentRoundId, } = useVoteManagementContext() const { canRegister, isRegistered, isRegistering, register } = useRegistration() const navigate = useNavigate() @@ -226,7 +227,12 @@ const DailyPollSection: React.FC = ({ loading, endTime, t
{title}
{hasPoll &&

Choose your favorite

} - {!roundState && !isLoading &&

No active poll found. Check back when the next round opens.

} + {!roundState && !isLoading && currentRoundId && ( +

Round #{currentRoundId} is preparing its encryption key. Voting will open when the key is available.

+ )} + {!roundState && !isLoading && !currentRoundId && ( +

No active poll found. Check back when the next round opens.

+ )} {displayedRoundIsFallback && (

Showing the latest completed poll โ€” the current round is still being tallied under encryption.

)} diff --git a/examples/CRISP/client/src/utils/directVote.ts b/examples/CRISP/client/src/utils/directVote.ts index 17d2e0645c..715a3674c2 100644 --- a/examples/CRISP/client/src/utils/directVote.ts +++ b/examples/CRISP/client/src/utils/directVote.ts @@ -10,21 +10,20 @@ import type { Address, Hex, PublicClient, WalletClient } from 'viem' const PUBLISH_INPUT_ABI = parseAbi(['function publishInput(uint256 e3Id, bytes data)']) /** - * Submit an encoded input straight from the voter's wallet. + * Commit an encoded input proof straight from the voter's wallet. * - * `publishInput` is permissionless โ€” the proof inside `encodedProof` carries the slot, the - * commitment and the ciphertext, and the contract does not care who pays for the call. Simulated - * first so an input the contract would refuse costs a wallet error instead of a reverted - * transaction, mirroring what the relay does before it pays. + * `publishInput` is permissionless. The payload carries the proof, slot, ciphertext commitment, + * content hash, parent, and signed 10-minute expiry. It does not carry the ciphertext or wait for + * VectorX. The durable availability service publishes those bytes and finalizes the input later. * * @param walletClient The voter's wallet. * @param publicClient The public client, for simulation and the receipt. * @param crispProgram The CRISP program address. * @param e3Id The round. - * @param encodedProof The `encodeSolidityProof` output. + * @param encodedProof The compact proof-commitment payload returned by the availability service. * @returns The transaction hash, after one confirmation. */ -export const submitVoteDirectly = async ( +export const submitInputCommitmentDirectly = async ( walletClient: WalletClient, publicClient: PublicClient, crispProgram: Address, diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index eb67010b4c..39d8538b1d 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -6,7 +6,7 @@ use alloy::{ network::{Ethereum, EthereumWallet}, - primitives::{Address, Bytes, I256, U256}, + primitives::{Address, Bytes, B256, I256, U256}, providers::{ fillers::{ BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, @@ -29,12 +29,87 @@ sol! { function setMerkleRoot(uint256 e3_id, uint256 _root) external; function getSlotIndex(uint256 e3_id, address slot_address) external view returns (int256); function publishInput(uint256 e3_id, bytes data) external; + function finalizeInput( + uint256 e3Id, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne, + bytes availabilityProof + ) external; + function validateInputProof( + uint256 e3Id, + bytes noirProof, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne + ) external view returns (bool); + function isInputPublished( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bool); + function isInputCommitted( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bool); + function inputId( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bytes32); + function inputAvailabilityDigest(uint256 e3Id, bytes32 inputId, uint64 expiresAt) external view returns (bytes32); + function inputAvailabilitySigner() external view returns (address); + function INPUT_AVAILABILITY_ATTESTATION_TTL() external view returns (uint64); + function availabilityFinalizationWindow() external view returns (uint256); + function MIN_VOTING_DURATION() external view returns (uint256); + function pendingInputCount(uint256 e3Id) external view returns (uint40); + function inputCommitmentDeadline(uint256 e3Id) external view returns (uint256); + function verify( + uint256 e3Id, + bytes32 ciphertextOutputHash, + bytes32 ciphertextCommitment, + bytes proof + ) external view returns (bool); function getRoundData(uint256 e3_id) external view returns (uint256 merkleRoot, bytes32 paramsHash, uint256 numOptions, uint8 creditMode, uint256 inputRoot, uint40 numberOfVotes); } + + #[sol(rpc)] + contract CiphernodeRegistryTiming { + function randomnessRequestTimeout() external view returns (uint256); + function sortitionSubmissionWindow() external view returns (uint256); + } } sol! { - event InputPublished(uint256 indexed e3Id, address indexed slotAddress, bytes32 encryptedVoteCommitment, bytes encryptedVote, uint256 index, uint40 parentIndexPlusOne); + event InputCommitted( + uint256 indexed e3Id, + bytes32 indexed inputId, + address indexed slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne, + uint40 index + ); + + event InputPublished( + uint256 indexed e3Id, + address indexed slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint32 availabilityBlock, + uint128 availabilityLeafIndex, + uint256 index, + uint40 parentIndexPlusOne + ); } /// Why a `publishInput` dry run failed. @@ -53,8 +128,8 @@ pub enum SimulateError { impl std::fmt::Display for SimulateError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Reverted(message) => write!(f, "publishInput simulation reverted: {message}"), - Self::Provider(message) => write!(f, "publishInput simulation unavailable: {message}"), + Self::Reverted(message) => write!(f, "contract simulation reverted: {message}"), + Self::Provider(message) => write!(f, "contract simulation unavailable: {message}"), } } } @@ -126,9 +201,19 @@ impl CRISPContract { .get_receipt() .await?; + eyre::ensure!(receipt.status(), "setMerkleRoot transaction reverted"); + Ok(receipt) } + /// Read the Merkle root already stored for a round. + pub async fn get_merkle_root(&self, e3_id: U256) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + let round = contract.getRoundData(e3_id).call().await?; + + Ok(round.merkleRoot) + } + /// Dry-run `publishInput` as an `eth_call` from the relay's own account. /// /// The relay signs and pays for whatever it is handed, so an input that would revert โ€” a bad @@ -160,6 +245,205 @@ impl CRISPContract { } } + /// Dry-run `finalizeInput` before the relay pays for the transaction. + pub async fn simulate_finalize_input( + &self, + e3_id: U256, + slot_address: Address, + encrypted_vote_commitment: B256, + encrypted_vote_hash: B256, + parent_index_plus_one: u64, + availability_proof: Bytes, + ) -> Result<(), SimulateError> { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + match contract + .finalizeInput( + e3_id, + slot_address, + encrypted_vote_commitment, + encrypted_vote_hash, + alloy::primitives::Uint::<40, 1>::from(parent_index_plus_one), + availability_proof, + ) + .call() + .await + { + Ok(_) => Ok(()), + Err(alloy::contract::Error::TransportError(RpcError::ErrorResp(payload))) => { + let message = payload.to_string(); + if payload.as_revert_data().is_some() || message.to_lowercase().contains("revert") { + Err(SimulateError::Reverted(message)) + } else { + Err(SimulateError::Provider(message)) + } + } + Err(error) => Err(SimulateError::Provider(error.to_string())), + } + } + + /// Check a ballot before its ciphertext is published to the DA layer. + pub async fn validate_input_proof( + &self, + e3_id: U256, + noir_proof: Bytes, + slot_address: Address, + encrypted_vote_commitment: B256, + encrypted_vote_hash: B256, + parent_index_plus_one: u64, + ) -> Result<(), SimulateError> { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + match contract + .validateInputProof( + e3_id, + noir_proof, + slot_address, + encrypted_vote_commitment, + encrypted_vote_hash, + alloy::primitives::Uint::<40, 1>::from(parent_index_plus_one), + ) + .call() + .await + { + Ok(_) => Ok(()), + Err(alloy::contract::Error::TransportError(RpcError::ErrorResp(payload))) => { + let message = payload.to_string(); + if payload.as_revert_data().is_some() || message.to_lowercase().contains("revert") { + Err(SimulateError::Reverted(message)) + } else { + Err(SimulateError::Provider(message)) + } + } + Err(error) => Err(SimulateError::Provider(error.to_string())), + } + } + + /// Check whether an availability relay already submitted this exact input. + pub async fn is_input_published( + &self, + e3_id: U256, + encrypted_vote_hash: B256, + commitment: B256, + slot_address: Address, + parent_index_plus_one: u64, + ) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract + .isInputPublished( + e3_id, + encrypted_vote_hash, + commitment, + slot_address, + alloy::primitives::Uint::<40, 1>::from(parent_index_plus_one), + ) + .call() + .await?) + } + + /// Check whether the proof commitment for an exact input is already on chain. + pub async fn is_input_committed( + &self, + e3_id: U256, + encrypted_vote_hash: B256, + commitment: B256, + slot_address: Address, + parent_index_plus_one: u64, + ) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract + .isInputCommitted( + e3_id, + encrypted_vote_hash, + commitment, + slot_address, + alloy::primitives::Uint::<40, 1>::from(parent_index_plus_one), + ) + .call() + .await?) + } + + /// Read the CRISP-specific voter cutoff through the write provider used by the relay. + pub async fn input_commitment_deadline(&self, e3_id: U256) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract + .inputCommitmentDeadline(e3_id) + .call() + .await? + .try_into()?) + } + + /// Read the exact EIP-712 digest that the configured availability signer must attest. + pub async fn input_availability_digest( + &self, + e3_id: U256, + encrypted_vote_hash: B256, + commitment: B256, + slot_address: Address, + parent_index_plus_one: u64, + expires_at: u64, + ) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + let parent = alloy::primitives::Uint::<40, 1>::from(parent_index_plus_one); + let input_id = contract + .inputId(e3_id, encrypted_vote_hash, commitment, slot_address, parent) + .call() + .await?; + Ok(contract + .inputAvailabilityDigest(e3_id, input_id, expires_at) + .call() + .await?) + } + + /// Read the maximum lifetime of an input availability promise. + pub async fn input_availability_attestation_ttl(&self) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract.INPUT_AVAILABILITY_ATTESTATION_TTL().call().await?) + } + + /// Confirm that this relay's key is the signer frozen into the CRISP deployment. + pub async fn input_availability_signer(&self) -> Result
{ + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract.inputAvailabilitySigner().call().await?) + } + + pub async fn availability_finalization_window(&self) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract.availabilityFinalizationWindow().call().await?) + } + + pub async fn minimum_voting_duration(&self) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract.MIN_VOTING_DURATION().call().await?) + } + + pub async fn committee_setup_windows(&self, registry: Address) -> Result<(U256, U256)> { + let registry = CiphernodeRegistryTiming::new(registry, self.provider.as_ref()); + Ok(( + registry.randomnessRequestTimeout().call().await?, + registry.sortitionSubmissionWindow().call().await?, + )) + } + + /// Check the aggregate ciphertext and its compute proof before paying to publish it to DA. + /// + /// This calls the same CRISP verifier that Interfold calls after the availability receipt is + /// ready. The earlier check prevents an unauthenticated or malformed webhook from spending + /// the server's Avail balance on bytes that can never be accepted on Ethereum. + pub async fn validate_compute_output( + &self, + e3_id: U256, + ciphertext_output_hash: B256, + ciphertext_commitment: B256, + proof: Bytes, + ) -> Result<()> { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + let accepted = contract + .verify(e3_id, ciphertext_output_hash, ciphertext_commitment, proof) + .call() + .await?; + eyre::ensure!(accepted, "CRISP rejected the aggregate ciphertext proof"); + Ok(()) + } + // publish an input to the CRISPProgram contract pub async fn publish_input(&self, e3_id: U256, data: Bytes) -> Result { let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); @@ -170,6 +454,38 @@ impl CRISPContract { .get_receipt() .await?; + eyre::ensure!(receipt.status(), "publishInput transaction reverted"); + + Ok(receipt) + } + + /// Finalize an input after its Avail receipt is available. + pub async fn finalize_input( + &self, + e3_id: U256, + slot_address: Address, + encrypted_vote_commitment: B256, + encrypted_vote_hash: B256, + parent_index_plus_one: u64, + availability_proof: Bytes, + ) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + let receipt = contract + .finalizeInput( + e3_id, + slot_address, + encrypted_vote_commitment, + encrypted_vote_hash, + alloy::primitives::Uint::<40, 1>::from(parent_index_plus_one), + availability_proof, + ) + .send() + .await? + .get_receipt() + .await?; + + eyre::ensure!(receipt.status(), "finalizeInput transaction reverted"); + Ok(receipt) } } @@ -188,10 +504,9 @@ impl CRISPContract { /// The number of inputs `CRISPProgram` accepted for a round. /// - /// The authority on how many there are. An indexer's own count can be short: the contract - /// accepts an input while `block.timestamp == inputWindow[1]`, and the deadline callback can - /// run before that log is stored. Computing then would tally a subset and derive a root the - /// contract rejects, which fails the round with nothing to explain why. + /// The authority on how many there are. An indexer's own count can be short because the last + /// finalization log can still be in flight when the deadline callback runs. Computing then + /// would tally a subset and derive a root the contract rejects. pub async fn get_published_input_count(&self, e3_id: U256) -> Result { let contract = CRISPProgram::new(self.contract_address, self.provider.clone()); let round = contract.getRoundData(e3_id).call().await?; @@ -219,6 +534,23 @@ impl CRISPContract { Err(e) => Err(eyre::eyre!("Failed to get slot index: {}", e)), } } + + /// Read the CRISP-specific voter cutoff. The Interfold input deadline remains later so + /// already committed inputs can finish data-availability finalization. + pub async fn input_commitment_deadline(&self, e3_id: U256) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract + .inputCommitmentDeadline(e3_id) + .call() + .await? + .try_into()?) + } + + /// Number of accepted input proofs still waiting for a verified availability receipt. + pub async fn pending_input_count(&self, e3_id: U256) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); + Ok(contract.pendingInputCount(e3_id).call().await?.to::()) + } } impl

CRISPContract

{ diff --git a/examples/CRISP/interfold.config.yaml b/examples/CRISP/interfold.config.yaml index f206215861..993d9ca0b8 100644 --- a/examples/CRISP/interfold.config.yaml +++ b/examples/CRISP/interfold.config.yaml @@ -4,6 +4,9 @@ chains: - name: localhost enabled: true rpc_url: ws://localhost:8545 + data_availability: + mode: mock_http + rpc_url: http://127.0.0.1:4000/availability contracts: e3_program: address: "0x8198f5d8F8CfFE8f9C413d98a0A55aEB8ab9FbB7" @@ -27,6 +30,9 @@ chains: enabled: false rpc_url: "wss://ethereum-rpc.publicnode.com" chain_id: 1 + data_availability: + mode: avail + rpc_url: "https://avail-rpc.publicnode.com/" contracts: # CRISPProgram is not yet deployed on mainnet (packages/crisp-contracts/deployed_contracts.json # has no mainnet entry). Fill in after `pnpm deploy:contracts --network mainnet`. @@ -52,6 +58,9 @@ chains: enabled: false rpc_url: "wss://ethereum-sepolia-rpc.publicnode.com" chain_id: 11155111 + data_availability: + mode: avail + rpc_url: "https://turing-rpc.avail.so/rpc" contracts: e3_program: address: "0x8654F380760c46857188097Fa0AD0bf995603124" @@ -82,15 +91,16 @@ program: # rpc_url: "https://sepolia.infura.io/v3/YOUR_KEY" # private_key: "PRIVATE_KEY" # Use env vars for secrets # pinata_jwt: "PINATA_JWT" # For uploading programs - # program_url: "https://gateway.pinata.cloud/ipfs/QmNMRAB7DW43JSmENfzGmD96G6sqaeBBNfTVrrq5WQae3D" # Pre-uploaded program + # ipfs_gateway_url: "https://gateway.pinata.cloud" # Public full-download gateway for programs and inputs + # program_url: "https://gateway.pinata.cloud/ipfs/QmdSmZPD9ArQYY754pbvji9bfiqAmG1rmoLci1fN9vMPdF" # Guest for ImageID.sol # onchain: true # true = onchain requests, false = offchain # Optional โ€” custom auction parameters (defaults shown): # min_price_eth: 0.00005 - # max_price_eth: 0.002 - # timeout_secs: 600 - # lock_timeout_secs: 300 - # ramp_up_secs: 60 - # lock_collateral_zkc: 2.0 + # max_price_eth: 0.004 + # timeout_secs: 28800 + # lock_timeout_secs: 14400 + # ramp_up_secs: 7200 + # lock_collateral_zkc: 100.0 # Default profile: multithread uses (logical CPUs - 1) Rayon workers and the same concurrent job # limit. Reserve threads with `multithread_reserve_threads` (default 1 for Actix / libp2p). # Example override on a 16-core host: diff --git a/examples/CRISP/packages/crisp-contracts/.env.example b/examples/CRISP/packages/crisp-contracts/.env.example index 1fd7fadc1b..b880aebb09 100644 --- a/examples/CRISP/packages/crisp-contracts/.env.example +++ b/examples/CRISP/packages/crisp-contracts/.env.example @@ -4,6 +4,9 @@ MNEMONIC="" PRIVATE_KEY="" # The RPC URL for the Ethereum network (e.g., Infura, Alchemy) RPC_URL="" +# Ethereum address derived from the CRISP server's PRIVATE_KEY. The program accepts an input proof +# only after this service attests that it durably stored the matching ciphertext. +INPUT_AVAILABILITY_SIGNER="" # Whether to use mock verifier and deploy a new mock token contract USE_MOCKS= diff --git a/examples/CRISP/packages/crisp-contracts/README.md b/examples/CRISP/packages/crisp-contracts/README.md index 3202ed6be0..67de6f0df1 100644 --- a/examples/CRISP/packages/crisp-contracts/README.md +++ b/examples/CRISP/packages/crisp-contracts/README.md @@ -47,11 +47,13 @@ It exposes three main functions: (`Interfold.publishCiphertextOutput`). This function ensures that the ciphertext output is valid. CRISP uses Risc0 as the compute provider for running the FHE program, thus the proof will be a Risc0 proof. -- `publishInput` - accepts an input for the E3 instance. Data providers call it on this contract - directly. In CRISP, the data providers are the voters and the input is the vote itself. The - function checks the stage and the input window, resolves the voter's eligibility from the census, - and verifies a Noir proof over nine public inputs, which is what establishes that the ciphertext - was encrypted correctly under the committee public key +- `publishInput` - accepts the compact proof commitment for an input. A voter or relay calls it + after the CRISP availability service has durably stored the ciphertext and signed the input ID + with a 10-minute expiry. The function checks the stage, commitment cutoff, signed expiry, voter + eligibility, service signature, and Noir proof over nine public inputs. It reserves the input's + tree leaf and index immediately. `finalizeInput` later verifies the VectorX receipt for the exact + ciphertext hash without requiring the voter to remain online. The proof establishes that the + ciphertext was encrypted correctly under the committee public key (`examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol:493-554`, paths from the repository root). The verifier is the one the round's census selects: `CRISPVerifier.sol` for a census posted as a Merkle root, `CRISPOnchainVerifier.sol` for one read from token balances on diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index 2b0651f7ef..caea4e43b4 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -9,19 +9,26 @@ import { IRiscZeroVerifier } from "risc0/IRiscZeroVerifier.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IE3Program } from "@interfold/contracts/contracts/interfaces/IE3Program.sol"; import { IInterfold } from "@interfold/contracts/contracts/interfaces/IInterfold.sol"; +import { ICiphernodeRegistry } from "@interfold/contracts/contracts/interfaces/ICiphernodeRegistry.sol"; import { E3 } from "@interfold/contracts/contracts/interfaces/IE3.sol"; import { Risc0ComputeProof } from "@interfold/contracts/contracts/lib/Risc0ComputeProof.sol"; import { LazyIMTData, InternalLazyIMT } from "@zk-kit/lazy-imt.sol/InternalLazyIMT.sol"; import { SNARK_SCALAR_FIELD } from "@zk-kit/lazy-imt.sol/Constants.sol"; import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { IHonkVerifier } from "./interfaces/IHonkVerifier.sol"; import { IVotesToken } from "./interfaces/IVotesToken.sol"; import { IERC6372Clock } from "./interfaces/IERC6372Clock.sol"; +import { IDataAvailabilityVerifier } from "@interfold/contracts/contracts/interfaces/IDataAvailabilityVerifier.sol"; interface IInterfoldProgramRegistry { function e3Programs(IE3Program e3Program) external view returns (bool); } +interface IInterfoldRegistryView { + function ciphernodeRegistry() external view returns (ICiphernodeRegistry); +} + contract CRISPProgram is IE3Program, Ownable, EIP712 { using InternalLazyIMT for LazyIMTData; @@ -59,6 +66,13 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { ONCHAIN } + /// @notice Progress of one proof-bound input while its ciphertext becomes available. + enum InputStatus { + NONE, + COMMITTED, + PUBLISHED + } + /// @notice Struct to store all data related to a voting round struct RoundData { uint256 merkleRoot; @@ -76,9 +90,9 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// input is provably its owner voting again. With the history, an entry like that is simply /// never extended: the next input names the same parent, and masking continues. mapping(address slot => mapping(uint40 index => bytes32 commitment)) inputCommitment; - /// @notice Leaves already appended to this round's input tree. + /// @notice Leaves already reserved in this round's input tree. /// @dev A replay guard, not a uniqueness requirement on ballots. The proof constrains the - /// commitment, not who submits it, so anyone who observes a published input can resubmit the + /// commitment, not who submits it, so anyone who observes a committed input can resubmit the /// identical calldata: the proof still verifies and {_processVote} appends again. The tally /// does not change โ€” the replay names the same parent as the original, which is no longer the /// head, so the Secure Process drops it โ€” but the tree is fixed-depth, so enough replays reach @@ -88,6 +102,14 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// Two genuinely distinct inputs differ in bytes, commitment, slot or parent, so they differ /// here; only a byte-identical resubmission collides. mapping(uint256 leaf => bool) appendedLeaf; + /// @notice Proofs accepted before their ciphertexts receive a verified DA receipt. + /// @dev The key binds every value needed to reproduce the final input leaf. A receipt can + /// publish only the exact tuple whose Noir proof was accepted in the first transaction. + mapping(bytes32 inputId => InputStatus status) inputStatus; + /// @notice Reserved tree index plus one for every accepted input proof. + mapping(bytes32 inputId => uint40 indexPlusOne) inputIndexPlusOne; + /// @notice Inputs whose proof is accepted but whose Avail receipt is not yet verified. + uint40 pendingInputCount; LazyIMTData votes; uint256 numOptions; CreditMode creditMode; @@ -112,6 +134,8 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); /// @notice The depth of the input Merkle tree. uint8 public constant TREE_DEPTH = 20; + /// @notice Minimum time available to create new input commitments after a worst-case key setup. + uint256 public constant MIN_VOTING_DURATION = 1 hours; /// @notice Number of leading plaintext coefficients that carry the vote payload. /// @dev Must stay aligned with `@crisp-e3/sdk` and `crisp_utils` (`MAX_MSG_NON_ZERO_COEFFS`). /// The remaining coefficients up to the BFV degree are zero padding. @@ -133,6 +157,22 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// @notice Verifies ballots for `CensusMode.ONCHAIN`, whose circuit has no Merkle inputs and /// takes voting power as a public input instead. IHonkVerifier private immutable onchainHonkVerifier; + /// @notice Frozen receipt verifier for every large object accepted by this program. + IDataAvailabilityVerifier public immutable dataAvailabilityVerifier; + /// @notice Tail of the Interfold input window reserved for DA finalization. + /// @dev New proofs close this many seconds before the protocol input deadline. Existing + /// commitments can still receive a VectorX proof during the reserved tail. + uint256 public immutable availabilityFinalizationWindow; + /// @notice Service that confirms it durably received a ciphertext before its proof is accepted. + /// @dev The later VectorX receipt remains the authority for data availability. This signature + /// only prevents a caller from committing a hash while withholding the bytes from the service. + address public immutable inputAvailabilitySigner; + + /// @notice Maximum lifetime of an input availability promise. + /// @dev The service starts this period after it validates and stores the complete ciphertext. + uint64 public constant INPUT_AVAILABILITY_ATTESTATION_TTL = 10 minutes; + + bytes32 public constant INPUT_AVAILABILITY_TYPEHASH = keccak256("InputAvailability(uint256 e3Id,bytes32 inputId,uint64 expiresAt)"); /// @notice The EIP-712 type of the message a voter signs to authorise one ballot. /// @dev The digest binds the signature to the round, the slot, and this exact ciphertext. The @@ -189,27 +229,49 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// @notice Thrown when an input identical to one already published is submitted again. error InputAlreadyPublished(uint256 leaf); + error InputAlreadyCommitted(bytes32 inputId); + error InputNotCommitted(bytes32 inputId); + error InvalidInputAvailabilityAttestation(); + error InputAvailabilityAttestationExpired(uint64 expiresAt); + error InputAvailabilitySignerAddressZero(); + error InputAvailabilityPending(uint40 count); error SlotIsEmpty(); error MerkleRootNotSet(); error InvalidNumOptions(); error InputDeadlinePassed(uint256 e3Id, uint256 deadline); + error InputCommitmentDeadlinePassed(uint256 e3Id, uint256 deadline); + error InputWindowTooShort(uint256 e3Id, uint256 duration, uint256 required); + error VotingWindowTooShort(uint256 e3Id, uint256 votingStartsAt, uint256 commitmentDeadline, uint256 required); error KeyNotPublished(uint256 e3Id); error E3NotAcceptingInputs(uint256 e3Id); error InvalidComputeContext(); + error InvalidDataAvailabilityVerifier(); + error DataAvailabilityHashMismatch(bytes32 expected, bytes32 actual); // Events event InterfoldBound(address indexed interfold); - /// @notice A ciphertext input was accepted for a round. - /// @dev Carries the slot and the commitment as well as the bytes. Both are already public โ€” the - /// slot is a plaintext `publishInput` argument and `getSlotIndex` exposes it โ€” so emitting them - /// leaks nothing and saves every consumer from parsing transaction calldata. The Secure Process - /// needs the commitment to check that the published bytes are the ciphertext that was proven. + /// @notice A valid ballot proof was accepted before its ciphertext DA receipt was ready. + event InputCommitted( + uint256 indexed e3Id, + bytes32 indexed inputId, + address indexed slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne, + uint40 index + ); + + /// @notice A committed ciphertext received a verified data-availability receipt. + /// @dev The event carries the values needed to retrieve and validate the exact bytes. The slot, + /// commitment, hash, parent, and reserved index were already exposed by {InputCommitted}. event InputPublished( uint256 indexed e3Id, address indexed slotAddress, bytes32 encryptedVoteCommitment, - bytes encryptedVote, + bytes32 encryptedVoteHash, + uint32 availabilityBlock, + uint128 availabilityLeafIndex, uint256 index, uint40 parentIndexPlusOne ); @@ -225,15 +287,23 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { IRiscZeroVerifier _risc0Verifier, IHonkVerifier _honkVerifier, IHonkVerifier _onchainHonkVerifier, + IDataAvailabilityVerifier _dataAvailabilityVerifier, + uint256 _availabilityFinalizationWindow, + address _inputAvailabilitySigner, bytes32 _imageId ) Ownable(_initialOwner) EIP712("CRISP", "1") { if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero(); if (address(_honkVerifier) == address(0)) revert InvalidHonkVerifier(); if (address(_onchainHonkVerifier) == address(0)) revert InvalidHonkVerifier(); + if (address(_dataAvailabilityVerifier).code.length == 0) revert InvalidDataAvailabilityVerifier(); risc0Verifier = _risc0Verifier; honkVerifier = _honkVerifier; onchainHonkVerifier = _onchainHonkVerifier; + dataAvailabilityVerifier = _dataAvailabilityVerifier; + availabilityFinalizationWindow = _availabilityFinalizationWindow; + if (_inputAvailabilitySigner == address(0)) revert InputAvailabilitySignerAddressZero(); + inputAvailabilitySigner = _inputAvailabilitySigner; imageId = _imageId; } @@ -382,6 +452,7 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { // Delegated to its own frame rather than scoped inline: `validate` is close enough to the // stack limit that holding the six decoded values alongside the parameters exceeds it. _initRound(e3Id, customParams); + _validateInputTiming(e3Id); e3Data[e3Id].paramsHash = keccak256(e3ProgramParams); @@ -391,6 +462,28 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { return ENCRYPTION_SCHEME_ID; } + /// @notice Refuse a round that can close before a worst-case committee leaves one hour to vote. + /// @dev Interfold stores the E3 and its timeout snapshot before calling {validate}. Read those + /// exact values instead of duplicating deployment-time settings. A zero finalization window is + /// the synchronous local mock and keeps its short test rounds. + function _validateInputTiming(uint256 e3Id) internal view { + if (availabilityFinalizationWindow == 0) return; + + E3 memory e3 = interfold.getE3(e3Id); + IInterfold.E3TimeoutConfig memory timeouts = interfold.getE3TimeoutConfig(e3Id); + ICiphernodeRegistry registry = IInterfoldRegistryView(address(interfold)).ciphernodeRegistry(); + uint256 latestKeyAt = e3.requestBlock + registry.randomnessRequestTimeout() + registry.sortitionSubmissionWindow() + timeouts.dkgWindow; + uint256 votingStartsAt = e3.inputWindow[0] > latestKeyAt ? e3.inputWindow[0] : latestKeyAt; + uint256 duration = e3.inputWindow[1] - e3.inputWindow[0]; + if (duration <= availabilityFinalizationWindow) { + revert InputWindowTooShort(e3Id, duration, availabilityFinalizationWindow + 1); + } + uint256 commitmentDeadline = e3.inputWindow[1] - availabilityFinalizationWindow; + if (commitmentDeadline < votingStartsAt + MIN_VOTING_DURATION) { + revert VotingWindowTooShort(e3Id, votingStartsAt, commitmentDeadline, MIN_VOTING_DURATION); + } + } + /// @notice Decode the round configuration and record it. /// @dev One decode, every field required. `censusMode` is read as a uint and range-checked /// rather than decoded straight into the enum, so an unrecognised value gives a named error @@ -490,45 +583,164 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { } /// @inheritdoc IE3Program + /// @dev This is the first transaction of the two-step input flow. It verifies the ballot but + /// reserves its tree leaf and index immediately. The ciphertext is published separately and any + /// account calls {finalizeInput} after a valid data-availability receipt exists. function publishInput(uint256 e3Id, bytes memory data) external { - E3 memory e3 = interfold.getE3(e3Id); + E3 memory e3 = _commitmentE3(e3Id); - // check that we are in the correct stage - IInterfold.E3Stage stage = interfold.getE3Stage(e3Id); - if (stage != IInterfold.E3Stage.KeyPublished) { - revert KeyNotPublished(e3Id); + if (data.length == 0) revert EmptyInputData(); + + ( + bytes memory noirProof, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne, + uint64 availabilityAttestationExpiresAt, + bytes memory availabilityAttestation + ) = abi.decode(data, (bytes, address, bytes32, bytes32, uint40, uint64, bytes)); + + if (block.timestamp >= availabilityAttestationExpiresAt) { + revert InputAvailabilityAttestationExpired(availabilityAttestationExpiresAt); } - // check that we are not past the input deadline - if (block.timestamp > e3.inputWindow[1]) { - revert InputDeadlinePassed(e3Id, e3.inputWindow[1]); + _verifyInputProof(e3Id, e3, noirProof, slotAddress, encryptedVoteCommitment, encryptedVoteHash, parentIndexPlusOne); + + bytes32 id = inputId(e3Id, encryptedVoteHash, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); + RoundData storage round = e3Data[e3Id]; + if (round.inputStatus[id] != InputStatus.NONE) revert InputAlreadyCommitted(id); + if ( + ECDSA.recover(inputAvailabilityDigest(e3Id, id, availabilityAttestationExpiresAt), availabilityAttestation) != inputAvailabilitySigner + ) { + revert InvalidInputAvailabilityAttestation(); } - // check that we are within the input window + // Reserve the leaf and index now, not after VectorX finalizes. A later vote or mask can then + // name this input as its parent instead of every pending input competing as a first write. + uint40 voteIndex = _processVote(e3Id, slotAddress, encryptedVoteCommitment, encryptedVoteHash, parentIndexPlusOne); + round.inputStatus[id] = InputStatus.COMMITTED; + round.inputIndexPlusOne[id] = voteIndex + 1; + round.pendingInputCount++; + + emit InputCommitted(e3Id, id, slotAddress, encryptedVoteCommitment, encryptedVoteHash, parentIndexPlusOne, voteIndex); + } + + /// @notice Publish the DA receipt for a previously proven and reserved input. + /// @dev Permissionless so the voter does not need to remain online while VectorX finalizes the + /// Avail publication. The input identifier binds the receipt to the exact proof accepted by + /// {publishInput}; changing the slot, commitment, content hash, or parent selects no commitment. + function finalizeInput( + uint256 e3Id, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne, + bytes calldata availabilityProof + ) external { + _finalizationE3(e3Id); + + bytes32 id = inputId(e3Id, encryptedVoteHash, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); + RoundData storage round = e3Data[e3Id]; + if (round.inputStatus[id] != InputStatus.COMMITTED) revert InputNotCommitted(id); + + IDataAvailabilityVerifier.DataReference memory availabilityReceipt = dataAvailabilityVerifier.verifyDataAvailability( + encryptedVoteHash, + availabilityProof + ); + if (availabilityReceipt.contentHash != encryptedVoteHash) { + revert DataAvailabilityHashMismatch(encryptedVoteHash, availabilityReceipt.contentHash); + } + + round.inputStatus[id] = InputStatus.PUBLISHED; + round.pendingInputCount--; + uint40 voteIndex = round.inputIndexPlusOne[id] - 1; + + emit InputPublished( + e3Id, + slotAddress, + encryptedVoteCommitment, + encryptedVoteHash, + availabilityReceipt.blockNumber, + availabilityReceipt.leafIndex, + voteIndex, + parentIndexPlusOne + ); + } + + /// @notice Checks a ballot before its proof commitment is sent on chain. + /// @dev This checks the same proof and timing conditions as {publishInput} without modifying + /// state. The service does not pay an Avail fee until the commitment transaction is confirmed. + function validateInputProof( + uint256 e3Id, + bytes calldata noirProof, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne + ) external view returns (bool) { + E3 memory e3 = _commitmentE3(e3Id); + _verifyInputProof(e3Id, e3, noirProof, slotAddress, encryptedVoteCommitment, encryptedVoteHash, parentIndexPlusOne); + return true; + } + + /// @notice Last timestamp before which a new input proof can be committed. + /// @dev The deadline is exclusive. At the exact timestamp, the reserved finalization tail has + /// started and no new proof is accepted. + function inputCommitmentDeadline(uint256 e3Id) public view returns (uint256 deadline) { + E3 memory e3 = interfold.getE3(e3Id); + uint256 duration = e3.inputWindow[1] - e3.inputWindow[0]; + if (duration <= availabilityFinalizationWindow) { + revert InputWindowTooShort(e3Id, duration, availabilityFinalizationWindow + 1); + } + return e3.inputWindow[1] - availabilityFinalizationWindow; + } + + function _keyPublishedE3(uint256 e3Id) internal view returns (E3 memory e3) { + e3 = interfold.getE3(e3Id); + if (interfold.getE3Stage(e3Id) != IInterfold.E3Stage.KeyPublished) { + revert KeyNotPublished(e3Id); + } if (block.timestamp < e3.inputWindow[0]) { revert E3NotAcceptingInputs(e3Id); } + } - if (data.length == 0) revert EmptyInputData(); + function _commitmentE3(uint256 e3Id) internal view returns (E3 memory e3) { + e3 = _keyPublishedE3(e3Id); + uint256 deadline = inputCommitmentDeadline(e3Id); + if (block.timestamp >= deadline) { + revert InputCommitmentDeadlinePassed(e3Id, deadline); + } + } - ( - bytes memory noirProof, - address slotAddress, - bytes32 encryptedVoteCommitment, - bytes memory encryptedVote, - uint40 parentIndexPlusOne - ) = abi.decode(data, (bytes, address, bytes32, bytes, uint40)); + function _finalizationE3(uint256 e3Id) internal view returns (E3 memory e3) { + e3 = _keyPublishedE3(e3Id); + // The configured finalization tail is a normal target, not a destructive cutoff. No new proof + // can enter after the commitment deadline, and `verify` blocks computation while a receipt is + // pending. A delayed VectorX proof can therefore recover until the compute deadline. + uint256 deadline = interfold.getDeadlines(e3Id).computeDeadline; + if (block.timestamp > deadline) { + revert InputDeadlinePassed(e3Id, deadline); + } + } - // The two census families differ here and nowhere else. A Merkle round proves membership - // inside the circuit against a posted root. An ONCHAIN round reads the power from the token - // and gives it to the circuit, so the eligibility check has to happen here instead. - (bytes32 eligibility, IHonkVerifier verifier) = _eligibility(e3Id, slotAddress); + function _verifyInputProof( + uint256 e3Id, + E3 memory e3, + bytes memory noirProof, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint40 parentIndexPlusOne + ) internal view { + uint256 leaf = inputLeaf(encryptedVoteHash, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); + if (e3Data[e3Id].appendedLeaf[leaf]) revert InputAlreadyPublished(leaf); + bytes32 id = inputId(e3Id, encryptedVoteHash, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); + if (e3Data[e3Id].inputStatus[id] != InputStatus.NONE) revert InputAlreadyCommitted(id); + (bytes32 eligibility, IHonkVerifier verifier) = _eligibility(e3Id, slotAddress); bytes32 parentCommitment = _parentCommitment(e3Id, slotAddress, parentIndexPlusOne); - - uint40 voteIndex = _processVote(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, parentIndexPlusOne); - - // Set the public inputs for the proof. Order must match Noir circuit. bytes32[] memory noirPublicInputs = new bytes32[](9); noirPublicInputs[0] = parentCommitment; // A Keccak digest does not fit in one field element, so it enters the circuit as its two @@ -549,8 +761,6 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { if (!verifier.verify(noirProof, noirPublicInputs)) { revert InvalidNoirProof(); } - - emit InputPublished(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, voteIndex, parentIndexPlusOne); } /// @notice The commitment of the entry an input names as its parent. @@ -691,15 +901,15 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { return votes; } - /// @notice The index of the last input published to a slot. - /// @dev The last one *published*, which is not always the one that holds the slot. This contract + /// @notice The index of the last input committed to a slot. + /// @dev The last one *committed*, which is not always the one that holds the slot. This contract /// cannot tell whether an input's bytes deserialize to the ciphertext its commitment describes, /// so the entry at this index may be one the Secure Process will never select. A client naming a - /// parent must resolve the chain โ€” from the published bytes, or from the CRISP server's + /// parent must resolve the chain โ€” from the available bytes, or from the CRISP server's /// `state/previous-ciphertext` โ€” rather than reading it from here. /// @param e3Id The E3 program ID /// @param slotAddress The slot address - /// @return The index of the last published input, or -1 if the slot is empty + /// @return The index of the last committed input, or -1 if the slot is empty function getSlotIndex(uint256 e3Id, address slotAddress) external view returns (int40) { uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress]; return int40(storedIndexPlusOne) - 1; @@ -724,6 +934,8 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { bytes32 ciphertextCommitment, bytes memory proof ) external view override returns (bool) { + uint40 pending = e3Data[e3Id].pendingInputCount; + if (pending != 0) revert InputAvailabilityPending(pending); E3 memory e3 = interfold.getE3(e3Id); bytes32 paramsHash = getParamsHash(e3Id); bytes32 inputRoot = bytes32(e3Data[e3Id].votes._root()); @@ -746,12 +958,25 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { return true; } + /// @notice Verify availability of an aggregate ciphertext for Interfold. + /// @dev The same immutable verifier is used for inputs and outputs so one E3 cannot mix trust + /// roots. Interfold independently checks the returned content hash before recording it. + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) external view returns (IDataAvailabilityVerifier.DataReference memory receipt) { + receipt = dataAvailabilityVerifier.verifyDataAvailability(expectedContentHash, proof); + if (receipt.contentHash != expectedContentHash) { + revert DataAvailabilityHashMismatch(expectedContentHash, receipt.contentHash); + } + } + /// @notice Record one input: append its leaf and remember its commitment for later parents. function _processVote( uint256 e3Id, address slotAddress, bytes32 encryptedVoteCommitment, - bytes memory encryptedVote, + bytes32 encryptedVoteHash, uint40 parentIndexPlusOne ) internal returns (uint40 voteIndex) { RoundData storage round = e3Data[e3Id]; @@ -760,10 +985,10 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { // the mask path needs no signature โ€” replace the bytes of a vote that was already counted, // erasing it. Appending leaves the earlier entry in the tree, so the Secure Process can fall // back to it when a later entry is unusable, and nothing is lost. - uint256 leaf = inputLeaf(encryptedVote, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); + uint256 leaf = inputLeaf(encryptedVoteHash, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); // Refuse a byte-identical resubmission. Without this the tree is a free growth surface for - // anyone replaying a published input, and the round dies at tree capacity rather than at the + // anyone replaying a committed input, and the round dies at tree capacity rather than at the // input deadline. if (round.appendedLeaf[leaf]) revert InputAlreadyPublished(leaf); round.appendedLeaf[leaf] = true; @@ -775,7 +1000,7 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { round.inputCommitment[slotAddress][voteIndex] = encryptedVoteCommitment; } - /// @notice Builds the input tree leaf for one published input. + /// @notice Builds the input tree leaf for one committed input. /// @dev Binds four things the Secure Process must be able to trust: /// /// - the **bytes**, because the Noir proof constrains the commitment and never sees the @@ -786,16 +1011,68 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// - the **parent**, because that is what the guest walks the slot's chain by. An unbound parent /// would let a prover re-point entries and select a different one. /// - /// SHA-256 rather than Keccak: the zkVM accelerates SHA-256 inline, while its Keccak accelerator - /// emits a proof assumption the host must prove separately and compose. The extra on-chain cost - /// is about 67k gas on a transaction that already carries the ciphertext. + /// Keccak hashes the serialized ciphertext into the same content digest that an external data + /// availability receipt can expose. SHA-256 remains the outer hash because the zkVM accelerates + /// it inline. function inputLeaf( - bytes memory encryptedVote, + bytes32 encryptedVoteHash, bytes32 commitment, address slotAddress, uint40 parentIndexPlusOne ) public pure returns (uint256) { - return uint256(sha256(abi.encodePacked(sha256(encryptedVote), commitment, slotAddress, parentIndexPlusOne))) % SNARK_SCALAR_FIELD; + return uint256(sha256(abi.encodePacked(encryptedVoteHash, commitment, slotAddress, parentIndexPlusOne))) % SNARK_SCALAR_FIELD; + } + + /// @notice Identifier shared by the proof-commitment and DA-finalization transactions. + /// @dev Domain-separated by this contract and the E3, so an accepted commitment cannot be + /// replayed into another CRISP deployment or round. + function inputId( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) public view returns (bytes32) { + return keccak256(abi.encode("CRISP_INPUT_V1", address(this), e3Id, encryptedVoteHash, commitment, slotAddress, parentIndexPlusOne)); + } + + /// @notice EIP-712 digest signed after the availability service stores the ciphertext. + /// @dev Exposed so relays can ask this deployment for the exact chain-bound digest instead of + /// reproducing its domain separator off chain. + function inputAvailabilityDigest(uint256 e3Id, bytes32 id, uint64 expiresAt) public view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(INPUT_AVAILABILITY_TYPEHASH, e3Id, id, expiresAt))); + } + + /// @notice Number of committed inputs still waiting for a verified DA receipt. + function pendingInputCount(uint256 e3Id) external view returns (uint40) { + return e3Data[e3Id].pendingInputCount; + } + + /// @notice Whether the Noir proof for this exact input has been accepted. + /// @dev Remains true after finalization so relays recover idempotently after a restart. + function isInputCommitted( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bool) { + bytes32 id = inputId(e3Id, encryptedVoteHash, commitment, slotAddress, parentIndexPlusOne); + return e3Data[e3Id].inputStatus[id] != InputStatus.NONE; + } + + /// @notice Whether this exact input has a verified data-availability receipt. + /// @dev Availability relays use this after a restart to distinguish a transaction that landed + /// from one that still needs submission. It exposes only the same public fact as InputPublished. + function isInputPublished( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bool) { + bytes32 id = inputId(e3Id, encryptedVoteHash, commitment, slotAddress, parentIndexPlusOne); + return e3Data[e3Id].inputStatus[id] == InputStatus.PUBLISHED; } /// @notice Decode bytes to uint64 array diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockHonkVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockHonkVerifier.sol new file mode 100644 index 0000000000..98c270a272 --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockHonkVerifier.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. +pragma solidity >=0.8.27; + +import { IHonkVerifier } from "../interfaces/IHonkVerifier.sol"; + +/// @notice Test-only verifier for lifecycle tests that do not exercise Noir. +contract MockHonkVerifier is IHonkVerifier { + function verify(bytes calldata, bytes32[] calldata) external pure returns (bool) { + return true; + } +} diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol index 5439ce5310..c8c8dfce94 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol @@ -10,11 +10,17 @@ import { IInterfold } from "@interfold/contracts/contracts/interfaces/IInterfold import { IE3Program } from "@interfold/contracts/contracts/interfaces/IE3Program.sol"; import { IDecryptionVerifier } from "@interfold/contracts/contracts/interfaces/IDecryptionVerifier.sol"; import { IPkVerifier } from "@interfold/contracts/contracts/interfaces/IPkVerifier.sol"; +import { ICiphernodeRegistry } from "@interfold/contracts/contracts/interfaces/ICiphernodeRegistry.sol"; contract MockInterfold { bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); bytes public plaintextOutput; bytes32 public committeePublicKey; + uint256[2] public mockInputWindow; + uint256 public mockRequestBlock; + uint256 public mockRandomnessRequestTimeout; + uint256 public mockSortitionSubmissionWindow; + uint256 public mockDkgWindow; uint256 public nextE3Id; @@ -40,10 +46,11 @@ contract MockInterfold { /// mode and a census mode that only the caller knows, and the snapshot is taken during /// `validate`, so the params have to reach it here rather than being patched afterwards. function requestWithParams(address program, uint256 numOptions, bytes memory params) external { + mockRequestBlock = block.timestamp; e3s[nextE3Id] = E3({ seed: 0, committeeSize: IInterfold.CommitteeSize.Minimum, - requestBlock: 0, + requestBlock: mockRequestBlock, inputWindow: [uint256(0), uint256(0)], encryptionSchemeId: ENCRYPTION_SCHEME_ID, e3Program: IE3Program(address(0)), @@ -65,10 +72,11 @@ contract MockInterfold { } function _request(address program, uint256 numOptions) internal { + mockRequestBlock = block.timestamp; e3s[nextE3Id] = E3({ seed: 0, committeeSize: IInterfold.CommitteeSize.Minimum, - requestBlock: 0, + requestBlock: mockRequestBlock, inputWindow: [uint256(0), uint256(0)], encryptionSchemeId: ENCRYPTION_SCHEME_ID, e3Program: IE3Program(address(0)), @@ -96,17 +104,49 @@ contract MockInterfold { committeePublicKey = publicKeyHash; } + function setInputWindow(uint256 start, uint256 end) external { + mockInputWindow = [start, end]; + } + + function setCommitteeSetupWindows(uint256 randomness, uint256 sortition, uint256 dkg) external { + mockRandomnessRequestTimeout = randomness; + mockSortitionSubmissionWindow = sortition; + mockDkgWindow = dkg; + } + function getE3Stage(uint256) external view returns (IInterfold.E3Stage) { return IInterfold.E3Stage.KeyPublished; } + function getDeadlines(uint256) external view returns (IInterfold.E3Deadlines memory) { + uint256 inputEnd = mockInputWindow[1] == 0 ? block.timestamp + 100 : mockInputWindow[1]; + return IInterfold.E3Deadlines({ dkgDeadline: 0, computeDeadline: inputEnd + 100, decryptionDeadline: inputEnd + 200 }); + } + + function getE3TimeoutConfig(uint256) external view returns (IInterfold.E3TimeoutConfig memory) { + return IInterfold.E3TimeoutConfig({ dkgWindow: mockDkgWindow, computeWindow: 100, decryptionWindow: 100 }); + } + + function ciphernodeRegistry() external view returns (ICiphernodeRegistry) { + return ICiphernodeRegistry(address(this)); + } + + function randomnessRequestTimeout() external view returns (uint256) { + return mockRandomnessRequestTimeout; + } + + function sortitionSubmissionWindow() external view returns (uint256) { + return mockSortitionSubmissionWindow; + } + function getE3(uint256) external view returns (E3 memory) { + uint256[2] memory inputWindow = mockInputWindow[1] == 0 ? [uint256(0), block.timestamp + 100] : mockInputWindow; return E3({ seed: 0, committeeSize: IInterfold.CommitteeSize.Minimum, - requestBlock: 0, - inputWindow: [uint256(0), block.timestamp + 100], + requestBlock: mockRequestBlock, + inputWindow: inputWindow, encryptionSchemeId: ENCRYPTION_SCHEME_ID, e3Program: IE3Program(address(0)), paramSet: 0, // Insecure512 diff --git a/examples/CRISP/packages/crisp-contracts/contracts/test/MockCrispDataAvailabilityVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/test/MockCrispDataAvailabilityVerifier.sol new file mode 100644 index 0000000000..142fe64865 --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/contracts/test/MockCrispDataAvailabilityVerifier.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.8.27; + +import { MockDataAvailabilityVerifier } from "@interfold/contracts/contracts/test/MockDataAvailabilityVerifier.sol"; + +/// @notice Makes the shared local DA mock available to the CRISP Hardhat project. +contract MockCrispDataAvailabilityVerifier is MockDataAvailabilityVerifier {} diff --git a/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts b/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts index 5114f0097f..9bbfb27451 100644 --- a/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts +++ b/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts @@ -4,7 +4,13 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { getDeploymentChain, readDeploymentArgs, storeDeploymentArgs } from '@interfold/contracts/scripts' +import { + AVAIL_FINALIZATION_WINDOW_SECONDS, + AVAIL_VECTORX, + getDeploymentChain, + readDeploymentArgs, + storeDeploymentArgs, +} from '@interfold/contracts/scripts' import { Interfold__factory as InterfoldFactory } from '@interfold/contracts/types' import { readFileSync } from 'fs' @@ -13,7 +19,9 @@ import hre from 'hardhat' import { CRISPProgram__factory as CRISPProgramFactory } from '../types' import { verifierNames } from '../scripts/verifiers' -const imageIdContent = readFileSync('../../.interfold/generated/contracts/ImageID.sol', 'utf-8') +// The production guest lives in crates/support. Read the Image ID generated from that exact +// guest instead of the example project's cached copy, which can lag behind a guest change. +const imageIdContent = readFileSync(new URL('../../../../../crates/support/contracts/ImageID.sol', import.meta.url), 'utf-8') const match = imageIdContent.match(/bytes32 public constant PROGRAM_ID = bytes32\((0x[a-fA-F0-9]+)\)/) const IMAGE_ID = match ? match[1] : null @@ -37,7 +45,33 @@ export const deployCRISPContracts = async (): Promise => } const initialOwner = configuredOwner ? ethers.getAddress(configuredOwner) : ownerAddress - const useMocks = Boolean(process.env.USE_MOCKS) + const rawUseMocks = process.env.USE_MOCKS?.trim().toLowerCase() + if (rawUseMocks && rawUseMocks !== 'true' && rawUseMocks !== 'false') { + throw new Error("USE_MOCKS must be 'true', 'false', or unset") + } + const useMocks = rawUseMocks === 'true' + if (chain === 'mainnet' && useMocks) { + throw new Error('USE_MOCKS cannot be enabled for a mainnet CRISP deployment') + } + const rawDeferProtocolWiring = process.env.DEFER_PROTOCOL_WIRING?.trim().toLowerCase() + if (rawDeferProtocolWiring && rawDeferProtocolWiring !== 'true' && rawDeferProtocolWiring !== 'false') { + throw new Error("DEFER_PROTOCOL_WIRING must be 'true', 'false', or unset") + } + const deferProtocolWiring = rawDeferProtocolWiring === 'true' + const mainnetDeferralAcknowledged = process.env.ALLOW_MAINNET_DEFERRED_WIRING?.trim().toLowerCase() === 'true' + if (chain === 'mainnet' && deferProtocolWiring && !mainnetDeferralAcknowledged) { + throw new Error( + 'Mainnet protocol wiring can be deferred only with ALLOW_MAINNET_DEFERRED_WIRING=true. This acknowledgment means the deployment will exit with CRISP unusable until the DAO wiring batch is executed and validated.', + ) + } + const configuredAvailabilitySigner = process.env.INPUT_AVAILABILITY_SIGNER + const inputAvailabilitySigner = configuredAvailabilitySigner + ? ethers.getAddress(configuredAvailabilitySigner) + : useMocks || chain === 'localhost' + ? ownerAddress + : (() => { + throw new Error('INPUT_AVAILABILITY_SIGNER is required for an Avail-backed CRISP deployment') + })() const verifier = await deployVerifier(useMocks, ethers) @@ -129,6 +163,35 @@ export const deployCRISPContracts = async (): Promise => chain, ) + const useMockDataAvailability = useMocks || chain === 'localhost' + const dataAvailabilityContract = useMockDataAvailability ? 'MockCrispDataAvailabilityVerifier' : 'AvailVectorXDataAvailabilityVerifier' + let dataAvailabilityVerifier + if (useMockDataAvailability) { + dataAvailabilityVerifier = await ethers.deployContract('MockCrispDataAvailabilityVerifier') + } else { + const addresses = AVAIL_VECTORX[chain as keyof typeof AVAIL_VECTORX] + if (!addresses) { + throw new Error(`Avail/VectorX data availability is not configured for ${chain}`) + } + dataAvailabilityVerifier = await ethers.deployContract('AvailVectorXDataAvailabilityVerifier', [addresses.bridge, addresses.vectorx]) + } + await dataAvailabilityVerifier.waitForDeployment() + const dataAvailabilityVerifierAddress = await dataAvailabilityVerifier.getAddress() + storeDeploymentArgs( + { + address: dataAvailabilityVerifierAddress, + blockNumber: await ethers.provider.getBlockNumber(), + constructorArgs: useMockDataAvailability + ? {} + : { + bridge: AVAIL_VECTORX[chain as keyof typeof AVAIL_VECTORX].bridge, + vectorx: AVAIL_VECTORX[chain as keyof typeof AVAIL_VECTORX].vectorx, + }, + }, + dataAvailabilityContract, + chain, + ) + const crispFactory = await ethers.getContractFactory( CRISPProgramFactory.abi, CRISPProgramFactory.linkBytecode({ @@ -137,7 +200,16 @@ export const deployCRISPContracts = async (): Promise => owner, ) - const crisp = await crispFactory.deploy(initialOwner, verifier, honkVerifierAddress, onchainHonkVerifierAddress, IMAGE_ID) + const crisp = await crispFactory.deploy( + initialOwner, + verifier, + honkVerifierAddress, + onchainHonkVerifierAddress, + dataAvailabilityVerifierAddress, + useMockDataAvailability ? 0 : AVAIL_FINALIZATION_WINDOW_SECONDS, + inputAvailabilitySigner, + IMAGE_ID, + ) await crisp.waitForDeployment() const crispAddress = await crisp.getAddress() @@ -150,6 +222,9 @@ export const deployCRISPContracts = async (): Promise => verifierAddress: verifier, honkVerifierAddress, onchainHonkVerifierAddress, + dataAvailabilityVerifierAddress, + availabilityFinalizationWindow: useMockDataAvailability ? 0 : AVAIL_FINALIZATION_WINDOW_SECONDS, + inputAvailabilitySigner, imageId: IMAGE_ID, }, }, @@ -159,7 +234,7 @@ export const deployCRISPContracts = async (): Promise => let governanceComplete = false const interfoldAddress = readDeploymentArgs('Interfold', chain)?.address - if (interfoldAddress && (await ethers.provider.getCode(interfoldAddress)) !== '0x') { + if (interfoldAddress && (await ethers.provider.getCode(interfoldAddress)) !== '0x' && !deferProtocolWiring) { const interfold = InterfoldFactory.connect(interfoldAddress, owner) const interfoldOwner = await interfold.owner() const registered = await interfold.e3Programs(crispAddress) @@ -183,6 +258,8 @@ export const deployCRISPContracts = async (): Promise => 'CRISP integration is incomplete. Protocol governance must set the ciphertext verifier, register the program, and bind Interfold.', ) } + } else if (interfoldAddress && deferProtocolWiring) { + console.log('CRISP protocol wiring was deferred for the governance upgrade batch.') } let tokenAddress @@ -225,6 +302,7 @@ export const deployCRISPContracts = async (): Promise => Risc0BfvCiphertextVerifier: ${ciphertextVerifierAddress} HonkVerifier: ${honkVerifierAddress} OnchainHonkVerifier: ${onchainHonkVerifierAddress} + DataAvailabilityVerifier: ${dataAvailabilityVerifierAddress} CRISPProgram: ${crispAddress} TokenAddress: ${tokenAddress} SelfRegistry: ${selfRegistryAddress} diff --git a/examples/CRISP/packages/crisp-contracts/hardhat.config.ts b/examples/CRISP/packages/crisp-contracts/hardhat.config.ts index f2150030fd..636f50bd7c 100644 --- a/examples/CRISP/packages/crisp-contracts/hardhat.config.ts +++ b/examples/CRISP/packages/crisp-contracts/hardhat.config.ts @@ -155,6 +155,7 @@ const config: HardhatUserConfig = { '@interfold/contracts/contracts/verifiers/bfv/BfvDecryptionVerifier.sol', '@interfold/contracts/contracts/verifiers/bfv/Risc0BfvCiphertextVerifier.sol', '@interfold/contracts/contracts/verifiers/bfv/BfvPkVerifier.sol', + '@interfold/contracts/contracts/verifiers/AvailVectorXDataAvailabilityVerifier.sol', '@interfold/contracts/contracts/verifiers/bfv/honk/DkgAggregatorVerifier.sol', '@interfold/contracts/contracts/verifiers/bfv/honk/DecryptionAggregatorVerifier.sol', ], diff --git a/examples/CRISP/packages/crisp-contracts/package.json b/examples/CRISP/packages/crisp-contracts/package.json index cc15dc3d25..58404529ea 100644 --- a/examples/CRISP/packages/crisp-contracts/package.json +++ b/examples/CRISP/packages/crisp-contracts/package.json @@ -32,6 +32,7 @@ "ciphernode:mint:tokens": "hardhat ciphernode:mint-tokens", "ciphernode:add:self": "hardhat ciphernode:add", "clean:deployments": "hardhat utils:clean-deployments", + "predeploy:contracts": "pnpm --filter @interfold/contracts compile", "deploy:contracts": "hardhat run deploy/deploy.ts", "deploy:contracts:mock": "export USE_MOCKS=true PRINT_ENV_VARS=true && pnpm deploy:contracts", "deploy:contracts:full": "export DEPLOY_INTERFOLD=true && pnpm deploy:contracts", @@ -39,7 +40,7 @@ "governance:builder": "hardhat run deploy/create-governance-builder.ts", "test": "hardhat test mocha", "check:test-legs": "node scripts/check-test-legs.mjs", - "test:unit": "pnpm check:test-legs && hardhat test mocha tests/census-mode.test.ts tests/crisp.journal.test.ts tests/input-leaf.test.ts tests/interfold-binding.test.ts tests/self-registry.test.ts tests/tally.decoding.test.ts", + "test:unit": "pnpm check:test-legs && hardhat test mocha tests/census-mode.test.ts tests/crisp.journal.test.ts tests/input-availability-flow.test.ts tests/input-leaf.test.ts tests/interfold-binding.test.ts tests/self-registry.test.ts tests/tally.decoding.test.ts", "test:input-tree": "hardhat test mocha tests/input-tree-e2e.test.ts", "test:input-tree:poisoning": "hardhat test mocha --grep \"accepts an input whose bytes|lets an honest mask follow\" -- tests/input-tree-e2e.test.ts", "test:input-tree:canonical": "hardhat test mocha --grep \"^(?!.*(?:accepts an input whose bytes|lets an honest mask follow))\" -- tests/input-tree-e2e.test.ts", @@ -59,6 +60,7 @@ "devDependencies": { "@crisp-e3/sdk": "workspace:^", "@crisp-e3/zk-inputs": "workspace:^", + "@nomicfoundation/hardhat-ethers": "4.0.3", "@nomicfoundation/hardhat-keystore": "3.0.3", "@nomicfoundation/hardhat-toolbox-mocha-ethers": "3.0.0", "@openzeppelin/contracts": "^5.0.2", diff --git a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts index 4f7ffdea29..c429850964 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts @@ -81,6 +81,19 @@ describe('CRISPProgram census mode', function () { expect(await crispProgram.censusModeOf(5)).to.equal(TOKEN) }) + it('keeps census root publication owner-only', async () => { + const [, availabilitySigner] = await ethers.getSigners() + const program = await deployCRISPProgram({ inputAvailabilitySigner: availabilitySigner.address }) + await program.validate(7, 0, '0x', '0x', encode(CUSTOM, TOKEN)) + + await expect(program.connect(availabilitySigner).setMerkleRoot(7, 123)) + .to.be.revertedWithCustomError(program, 'OwnableUnauthorizedAccount') + .withArgs(availabilitySigner.address) + await program.setMerkleRoot(7, 123) + + expect((await program.getRoundData(7)).merkleRoot).to.equal(123) + }) + /// An unrecognised mode is a coordinator that would not know what to do. Better to refuse the /// round than to have it silently treated as a token vote. it('rejects an unknown census mode', async () => { diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts index e67b476d57..461b7cadf6 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts @@ -27,7 +27,15 @@ before(async () => { setCircuits(await loadCircuits()) }) import { expect } from 'chai' -import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, ethers } from './utils' +import { + deployCRISPProgram, + deployHonkVerifier, + deployMockInterfold, + ethers, + inputCommitmentPayload, + publishAvailableInput, + splitInputEnvelope, +} from './utils' import type { CRISPProgram, HonkVerifier, MockInterfold } from '../types' let keys = generateBFVKeys() @@ -187,7 +195,38 @@ describe('CRISP Contracts', function () { expect(pi[6], 'num_options').to.eq(BigInt(numOptionsOnChain)) expect(pi[8], 'committee_public_key').to.eq(BigInt(e3.committeePublicKey)) - await crispProgram.publishInput(publishE3Id, encodeSolidityProof(voteProof)) + const encoded = encodeSolidityProof(voteProof) + const envelope = splitInputEnvelope(encoded) + await (await crispProgram.publishInput(publishE3Id, await inputCommitmentPayload(crispProgram, publishE3Id, encoded))).wait() + await expect( + crispProgram.finalizeInput( + publishE3Id, + envelope.slotAddress, + envelope.encryptedVoteCommitment, + envelope.encryptedVoteHash, + envelope.parentIndexPlusOne, + '0xdeadbeef', + ), + ).to.be.revert(ethers) + expect((await crispProgram.getRoundData(publishE3Id)).numberOfVotes).to.equal(1n) + + await crispProgram.finalizeInput( + publishE3Id, + envelope.slotAddress, + envelope.encryptedVoteCommitment, + envelope.encryptedVoteHash, + envelope.parentIndexPlusOne, + envelope.availabilityProof, + ) + expect( + await crispProgram.isInputPublished( + publishE3Id, + envelope.encryptedVoteHash, + envelope.encryptedVoteCommitment, + envelope.slotAddress, + envelope.parentIndexPlusOne, + ), + ).to.equal(true) }) /// The regression test for the ballot binding. @@ -209,7 +248,7 @@ describe('CRISP Contracts', function () { // `voteProof` was built for `publishE3Id`. Everything else about it is valid here โ€” same // slot, same census, same committee key โ€” so only the round binding rejects it. - await expect(crispProgram.publishInput(otherE3Id, encodeSolidityProof(voteProof))).to.be.revert(ethers) + await expect(publishAvailableInput(crispProgram, otherE3Id, encodeSolidityProof(voteProof))).to.be.revert(ethers) }) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json index dce7eb7dbc..ad5ef976c4 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json +++ b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json @@ -1,6 +1,6 @@ { "note": "Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.", - "inputRoot": "0x036374502f7484886e156270a41b94633363c0faa3c6e4d8ecb23882beb4469c", + "inputRoot": "0x13dbe87eee76130d24941e92f632169edad8b3b15d4c2fb3db0edb6c786d8484", "honestIndex": 2, "inputs": [ { diff --git a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json index 64b693ed1b..85390b7768 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json +++ b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json @@ -1,6 +1,6 @@ { "note": "Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.", - "inputRoot": "0x28315053ea867b2f8f82d6e5af47b20039b4ff95eb88d9193a4ef5c2929fd077", + "inputRoot": "0x2c7281259180265185a61ccf4a2f9a3371b4b4305c0e94fbdd66865dc90e20fb", "reVoteIndex": 1, "inputs": [ { diff --git a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json index 38e9aed4e1..4298c284f4 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json +++ b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json @@ -1,7 +1,7 @@ { "note": "Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.", - "inputRoot": "0x1ade9c021ae6826e42a57bee4cd4ec59dfe37898930d0860d2cdaacf05f5064e", - "contractLeaf": "13593275176504203661465029575122777041120943298103109219065935251286905649840", + "inputRoot": "0x2239283427d5909ac49c756b1e259aafe6b3789f6f345abc8e0a3680f86ac8bc", + "contractLeaf": "2883523969204566143551511709878353874335351376926241856531314581831631092564", "inputs": [ { "encryptedVote": "0x0a8a2408021080041a80249b1f2ccac22f3b773835b809deb9f5b9038dc298d37d6a0db63a924623b30c4ceccb59f79bba107e1387265d2a565910de308ee71f9d7561473ead878d44702d0c22a01e259dde4d8e8ef02c5a84ab6b12e6a9cf99532372b6792e7074167ee3d47611fb9f643a2983b3e5ceff322d3a4cffd886818d2ca2dd40927938dc70da3240b15b22e19cd326c78de248331252d6513fdc9fbd8ec45e6fce9047dca63dca45458d84f07e243ba35d56fe5255e51e0de0096e1bb90776c00dcc5f350e98825194f643bebbdf9e3eace6bc7c5be82a7d9d398e47d38727f6840b681780d88c41ad77f646ca4ef19b8aed4232dca7570a1a0c9ac5e7eb65ae8471d9d131d64e7322b65100a332925ef3a9181c9e6bc40593cf7697bd51b07f56017257131219ad3d6e8acf874835501d36a3395a2a2a65d8ec830d52a9b35ec0fb93dcb00a94a8af55b5020fbdc4375160da973e4a7520775cea605cc1123b117d352976adfc6d069d933bec30e6d0db6f30401db5b1e309fc97a28397af4001e3cd91afb44efa65e5296460d19fad54bc92d9ac61c38fe48a90fea5d92da448bfe2839d7339a218a3ed411cd2a999a62e539f37eb1fc9a9ab35703643915f506b70c9e816c80237bdb590c1aa2fa027addc58b1f3bb2ad75f9486d909f0906c24f8e6e2f8d21918c97e8123bc475e5d6efc3ceb25c97acfe5368715d6e5b0be59ae40b21d18c3f06d6a343cd870f8611aec6a933656e68f6940e24a483b192d445198b72b67515cddf9ec7a97230993b092804689f756957b58bf268c4267ff571576364fc3fd44c57658eafb3c45bbc7883953358a00085dbe8d5767565e789be70c6390a7c98595b5353b28d76bfed0eedaae89911baf5ba43e4eeae4450493dd8f569e99a99bc2169da6a09c05e2437d25015bdc040ce6988a4bcd12a89359265a9e464626437622860437e63256f1929c5c50b60fd19e94d2e8745ed0697544fafead4a9c68326c2558222ec2b5b9db705ea04595c9e8dff9725000613e3434a4fac2ddb9bc55bf73a8efb20e09def951f496185a81ee41dec3b31fc362f41cad542f5759a658bc57109bf774b858fd5f3bdfaf64b201688f46f87f5462e6f41fdab6419d51b70fc75ba8b2d6560b52a0fbde92d5185e3ce7312da41c6aa2d090aee8aaa7e26c67b83192330b34bfaa79e046bc8ddb726f932df5b1d1a99d8a6ba06ccb95c3824a4ee979759490bc303b1a87c52e43f48ff53aa8dddfd1dc4f0d5c15903c2c33586be9fd78961db94ddaac4edcdeabcac71fc20c64de4ea7aab51eda7c85d38264d9ec273df0917534e749a28fb08d1ad9114ce0628081e13867e9aa57345b77dff55a0b51c5276a94b978fa33adcbee8eef7c192d23fd9d47302b7149d6820b7f5bab1ffaecea9be6ba4f244a8826629e2ff4bc1ac2f18c7a5ce6a44ccbbd87b41f36474b5536bc27895d209239c020405b64833963a568566010fb53a5615b026a9288b819f43fc3b390780b2954134ad14fedf3ae6c9b32d9cf469e1380f380223970264e24e24e6fc9617233eccd52a88bdcec57ed8ac1587c25b8e3f54539b4128dd871728f9e78daceb27a004696662adc4a96406c42bf41f8e364f5b3287752eb6a65be5f7b2eba3ebbf77e98c91b3b641e9958544538bb5ed88ff520d2ef6e5c883707f57c16cc233231e794c6408ddaa6c4085485ad5c3d58cea0cfdcbf69babc69aa4a90cdcaeb7f32b06c68290eaeb996650a307ff61955afda67a6d1ccd7ac126d83dce9b6c5b59108cd36f76fe990b9c2bb636308af3096c6f74a713f1adb4d0e26a16b3504567c41cba0c2dfcba2312490f666de77e379d5c0b581248664150ab9cd486934f3cb46ffd80c4aa3aed55ed90d1daccaf4a5ca1cef158fc8712af94ce4130b40be81ad46655b42565cfb325d76a513f2e8cb361227b824c69a1d0f09198e4c2555bbf3a2252eb03f1c7fe2138ca76ff8de267a67540c9bbbb99d7819cf0135d3f0e7f8d54fde562a110d721d391b96edcaa3a7d0484e3351a02d4d1a32ad87522b225d1f68de80d2607ead8017a3731f1a3421d769019bdacda2944d48b33c5c5c6d12e36fa1362fe77f42fb85384f240c0aa5c83288d5830f76161a3927754180908249b6b4f2e2cfbf96cbf4273c3c50bfb6874e56fffa167de0ff041bfe3b4fb6c4bc3ee594d22b877f5b276847e84cf6966785e1ea3c1f6dcd168aa813a4a01d90dfd94f3040b937d293b6f89ab4a286ee55a5f06b72deb2dd3cfe87eaee8cd383ead25432c1d59e1a14baf207fdb0c011e601658d4d2422a6c0a60bf65d2637f8a02a126db3659ed9cb41875ba32160a92d9946350f24cb0f120389919829e64fff8773835981b117a445782b435f91250d0e0551967fd739e5d3e304bb6c349e683ba8a0fdfaff0e79b3085ba70be08010dc9c18f75ebcfddee168a789aebb660358446d7ecb81bdf85865f70f1468d4ecd89c4be2bd9cf6db669c41ea1ed532ca3ec29d6689455eff7945f77ced597b9e5df1a2712f7efa179baaff8086b65a77aec92d5ba5f0e03c2e03a576786c9591a6fccc911f34ca0607a6591c40757a402ec685f90eddb9ddbada02a1514d96e5be395a6224f196f739a6d4a2dbb29c91e717fdb8a9111205bf7cbdb338b6420ddd4e36fbd112b4d0cdedaf6515e08e6ae853014d41c88610b183c4ac43bb4b03a6f8bff651f5ce80ccb25e7a0154749f10611f280a3d8ebfccc1dfbb15a93b8a9b56441adb58bd62ae48efbd3ebef09fefc64c6b91fb5694e2429382919745e3d2037524ff9cc726e21aa00b34dcfab09f3f36ee001233032a429ae2896344ffddec9036451c3c23ddd07e370d185a838b8265e3b7ba07a5a5909fda8ecb389d01de499b63fb2e57dd44367119d3f6f677b69f56d227a427b6ea20d0b952c3eb0a42bb248e3eaa7ce2576ee1d0068179d660e3e620a6b65c74302b992e2c86eaca367e4c95964592778d478cb1840a68cd3f86c520978e1491ea2e584acfc9de3335b66e031e4e5e897b253c3a983485fed8a6767758a329eeecf06d68f1ea448c3cb8c60cc9f5117f3102610480cd9a9128b9d7cf1123e908117ad22a3a1c5604549f8fea755c425761e53a3a7c38c44322dc88b22fbffed255acbbdc3b18487b63925fe6796099982812f68789c18ef686b40d52d8115faeb23dbadedac73692ce9c001bf6a25e668eb8395f830e69a7c07512319f5fc6201e57f13222a62a14a6b2290567fdbdc56ce7844fbc2719bc08728a60624eb8bfb25b3b5e1556841cb314a672fcadd7e484b0f45fb0dcacbb725d4eee77d665ab6d76cde1152c056f7286a2d936a0dd7df07aa7cbcf7d963a44b45db56070262cbb8517e5072a4a225271bec9cf0632112ac7a9fd5b77fdac307cfd62a07f686cf16ea040b5974ea0750b960862b576c0f7c913f7045605921abbba3e5e77e53fcd0d3c0951d8711749e15fefb5d62d6bf860c806f4529d38fb00b4aa5176afb8e1fb769cda046a90b840cd30569da859f4718062098aaa28bb2b29dfb9bedac694a34c33979b5aa2667f355deb18c1737dffacbe0b5aa6061e22ca9b2f5234ab26bcfce2e737408c8d1a2685483a8abb6f22d8e36de4ed0592251fbcd604c7837956e970270e52a2c3eaec89351c436239d8043b323e31d14049de5834735e802324fa9f916a083924b1ce20fd75ec20acbc9dfbc7b429de8e3655d6ea04c0209d80feed20ff5dbf739b843485f2cc381d6a28b5c28b3cb46f665bfd3ee3469f7e7e363200b9553893719db417f497e22832932bd288eb269b1cc0a8fa4f2d5beba1b18d33a448b4b7086b0974a767c574267742cf57a538c0826f3d9b1cddffa4d573b4fc20bbae2986b2a1ddf108be75bdb5f29e323267754e06d25f54541a01ce17177d51f484a1c75af92bf4e09d6862e18b9864403f03e1da9a6cc03fceb575b1cdbab7cccdf8bf485fb84e1d6e08c5331d195dc6bc33d341e1b41e82b37ebeefcd866df150ad9f0a41438a8b93ccb91c4fc060fbd23475327657e01bf7504874cb816cfc8d32330a5708eb98e9af79c2a5ab059bb51cb1f338cc424c50341d9c9d57b589253982da210a353e69e556afe33f959fbd86af5358f231aa7a00525ed38ae62c0a4a237082c7fb78fb514cffde59865d446827dcf60633f649a98366c5e24442857dd89c4a2bffff27d189b0fa993abcea1724602190e3f7540d5a8abe961ccbbe8f7214ad0acee6779792a52fb0fedd6311f12a67e26132d2f920a82e931a3469ffe61432a749555b4661200d43771615709ed4d7f79d881097bd9ec53c2f59c34de49321b32fa45f289e9f0c736a0e1b4a5551b18bf18d2421f2c8d06daf892e93d3d611e2135487020e4bccb034bafdf4e6d9c12f8075915bb78536b97fb61948b4e36b32400a2b09574e1a8b24fe07647e92cd08b33fe4376aaba9a610d26082fb0a0169f6c185ed507d27bc38bd78c04f2e70a0c4e229952e0f0a4b456fa066bc389b9c9402d337ef804b63b294c142a8739a1ef5d0d0b3a66d013eda477215887df3e94dd98f2989b44d9779eae122a062baa4d65cb37daba8822c82dc769eb7d5132b04d103510fb76116dd1fb9f338a2596835acf81c6b4b0b68538b1acf33c5a7eb9436eec6ede70eb5ce6b2a4bf10a42e59bde02d7292c42bdec52ae0b7d8eb485d135b11edb5a7bc09f89513dc201276a82f10db47a04f007e4dcf763d0eb2964f32460ec313d02b6d9b9a1be9289b38939c994af310bbae0b1817fd50a24ec92c0f981aaf1f33c2f3bf160d7d2126e4f4c85675d1a279e9722860dc6f3a73f38c958cfa213299cb4fda5a6c95731b3d90ebbee1b7ce43eb4685813592e50d58c62095106cff16593a31bc61d0f661d1339b1c0b305edc1768668e4ddfb3d565285199d3f2ec765120105852e5a8bf1cac1acef37ebea6dbcd8b61de8afb80eb4f82c69d9897d29f22d5d36423068447f15cbca20eaa8735859316de6121386a6ff3bd9f503d8161ad1d931e45ffa31c40047c783026ebfce2d9d525242201a0444303242b6ede68c9a6be1307ec05acc332c9b9c9e6ddb2af9d5222eb3ffb4a6a79d3ea1362de6e30e367e84d723778747fd55323567727b42d185072ad5cd48b8eacb20798c01d57bbd629a4f85367c4613b8b09470f965733f64e4fb5e0cb693b700dc75b9a37944bb9cc7e90c338a89ab23acdf1ae06f31abcb961e46d13106b47cf783f72ee70cfbd501f27db7e48dad069303ad4856c13007aaf72df33e1c409597d93cb7a72687218193dfd16d090d1e6ee090563d2c30bf58b38dc2db74738a11dde9f218202faf9546d1030fb04079efa37209dc25929dfa1416e94d5fe955b29fb03ffb7d960f111786155c13400567dfe21ee6201ffed106f97bf86fc53585e35cb2893926b94caebade6a827220bddfb018d33516360959443fc7d65de74606256751683ab56d146eac07689c004b136dd470f98d712767ccec982345f4494f9bd34c04fbb4cedbe0e5596234c06f86a606195ef529d34e37549ad3ccc9cfaa1ae0460914b2b5ff335b0ef3c234c29eb6ee74f8a09cca632f9fa8b4b084c7b93dd70dc43e9fe8edb0a3344932e7e004b0eb1c3a8e2b5ed7e0544fb2f2400416712a2ba271a92bcd467e3c9430272755f228ccc2df99fedeeaeec72ae1cf6f070e8acd1789f9162ac79742b29b6b04bf5623520af947626fb4cac8e5adfefd832eb143ef92ba6f8f1861f75d8be893aed69d9a02535675054db118f05860fe4fc5e00b960941fb4cf9f7b78df8e3a97238257e7878ad2832f804e2a613d120641dd7d8a82ec53a979fbee8e07f577a0f71775de8b57a3aa4bc55cca17f7bc2d78cc0ceaf838ca87b827b3b9e6ea7a0d3e5ec80a08809a1083bfe6b666ef96f40dd23044819928d47159d9a89492a80b7d647b9bba4c2766509ef85f958b3577e5e52ef31c276dc150fd42dbf052f566b0f9cfbd5c0dee445c611abe72a8711d1db66e8b7cf56dcc1e449c695c483da2832415a821ba260fc0234a0df6b57bf5f73a1892d5eceaf461aaef106362459b18588fb8779b39e9b785508ebb6550b486da43558f7caa8e93eb4c64a8997b9151bcbfb1d13e930a623ad5241be63ae3690d28f33a95bc2158f5de5c477979e88d050ca6ff7f23e9b1a3228e52c800c072c65af3568a6e67607939470856ccba184a4deb35ad2ecb34db258e4cd01da4993fc177ed542615eb1113fe95ecc3df10505d1c9f04dcb794640d9b083de06bf750445fa0857b636b815780a08861424d88a3ec4e420ef12dba3bde0bbb40389abc8a4031110b67ef643cc76e488fc5f501b64ac588f8b373fb0fab03dee503f4c08bb2afbd22e5f4133dd60b86f2bba1125d022d5648f9771fa651c6f5f65ec4abc613277ed3c1f43489738b36b198e46b0b7b1a1d1a90623eb6294132e8264d5636e39293de1eddf0fb9aee4ced0d5bf20010a8a2408021080041a8024e5a92e1cb1f1969ec5c85792140ab8b6e9ceca97869755ff415665c86c24ea342692878e395361a80b62e6aefa01e1c30e526855bac7ecdceba9236568b723754b49769c380a0e5a2b6d09bf59b83e6af7353db3643ed7cc3625e0d47428f7fa136178b68b6a59c682117c3a9f9595cd9e996de7bdae4bc73ad1444020f3f109b4a63789b7e81f5234862949f0337085c8468d19d9b7a1986b94448785cd4d5501c0b85ed73cf9954478f3b92cfd3dce0f8175024f5e62151d8880453cf112b0b555b87a13f955fea4ebcd521d9533a0978b403be0c114b1f53afac54fff78c7e2d852b226a4618de8a6d15545fabcaa018cbb8c4a5804548dc1868bc31946b7bbaaed1622f7500bb9ca5aee93e865b7541f06e44e991e9502711e165dbab18606ceb2ca92a5c2bac5fc651e8f30cbc09caee38e5a50ced57d0d0e3fe9f7fb40ae49eaf53052577b2f3ca2a665e14ca761a9e6a983836ef92e60e98607eb303cbfb8518505e430bf10138814d24ab1f8beb97627969043a72ae5a1991b4d8791417952c914925c37a726f196dc91825acd65f271e554cfbc81252c26cad7ebc28cd37aed5481c8ce2e788850f6cfa33bc685164f428c23f29010db213786d0bb2d6b1fee0beebe4b6f59e1ccc8557edf49d4f1c4449e02d05d424de9a66049f46c66865c0e207b01c6ab88bb2c12b2fa6eb71c3f1312de012c7c8674fa69dd84c63b55a3a26a8645c04eebde3490c68bdd4580ec8004c21d239e6e33a402bf9e83fac0f770f9e582803a74938e244f5963f467567cf634526f5b83914013afb66db360785c78406b9633ee70353ad804efa2ae8612c441c39ff2f17f15234c5dc82289ccf26fc360096120d45091221cf484bdf8a333465caa48d7629fa45dbaa16fc49e01943667f929b02019273182b61a8f30798a57077d92ea6d0e6f8e0cfe31eea4f489c53ce515219123cd1206f74c935f0c6b4efda54f323aa0f6c5eb8ed469df9f728270d994a3a8aeb6c13bd98c04f04e75c1123a67219ceb26fc52778abf81315f8915a478438dac99a6f901005cf72f3671842ea0b33328df6d894ef065d433293442076b6f2d1310bcee109ba19f76e5bf4cf88af7d9f22e55fcc3a53f357a77d127c5d12f978f008b3e9f66fd307c64b9c84d1e7e9f3b792b1633e88fa96c9f9a364e7e666857bc3ae1738a5453f1e764c04db531be62be6ae0a15b6df2907187f34b8a8c2c630485a52dd2596f9bc7988c6715e8b5f73575ed2529cbdd75c214627a1400691e1fa99676e8b4750575170f7987ef593461ee078e464aa79e82ab87e61c20de5976fbfb118f9deeb1518c04ef492f028ff7d985cfb0ebcd52a89c1543fa151f7451d08bf4fed58f0ecd0f0b3ebd27f7970d234d99b4277e1fbb4c13337fa9c03d585b18bf5979bc97a5f967dd734392c45b671b714f3a94f5a1c9a76298bac4e92ba9207d371949c341efd4e3ce1b5ef695bd5dd6ae583aca74e20aa148e4ecb10104f1174527495a0434ca967e026cd6ef7d625012a4267b85ec2ec0f83abbe7f1e4e4ea7125b47c27ad1bc9b998ae9b24fba5e9682dc5a26b6bea7380b577f5e8e7fce5f8ad7dfbcdeb0c371f9597aa76c8e51472d9fde49b9a7d773f659f33ecf2b1fb9e72f58850d6b268a6dae6ee688da4cbd8e6538d1746a26f9c0200bc9887073a74d9127fa6d7d63c14c631ee06ea847383c3f54990cf5dc3b7243ad8a413d0256722c657a31c4ddb4858a8d5262aab86c17c546d8dc94fbbaa6397718221ecd99791967b477675e20243992af20011b4501036d5fd46b93546a4135dc28f192ed57b91c4e4b62071f2a25395b98e33170a2f9bea2d1575b5639b85145474bc52b33b57656d4d5df6294d5b1f314a58cb124fb00ff91edba5737e01da3a2a613cdb12c6921f66527be51d9dd32f57729e064bb05ec86ed1db1ad06c79015613953139abb2745538501087d31b6b303130147addaa3f14d3dfc462395387418cdf43a33f65f0a4e4ec812aa6a755180317f2a8b75d45df5790f1f1798ef445ffbc9ec04a3ff80afa853614e2ac3e614d4069a20e1e58e8748f2c404083bd2b7d01df51553eed33dbc64619eb651349686797915c8c323c138a94f7c584397497b5c62581351fe58e09512ac8927dc9d37e411c699a3248db68c8fa0ced74e943a986a90fcc9825acf618525395e67b187e2abed177bf7133671413054aa97e06addf455bf4cf71ecdfcf688b12e930f58d4e3ccc2d8ede52bbe39ec820e22a0ea57cca670f07cccfaa08574c4ef34ae8e91b6de4006df16e569873d11bdc7006513af7c027e283b9f675541e469653b66ec464f43ba4d2c210d66cbfde7ba029793ce5c526c553ca274fc25043dd770133dd41bacd1747eaa8519c701c91c6cbf920acb3d02b86df0db004cd357f94561be77c7a9c96cdcb8dedeeeb36230b2fdfca41bd60fac5561d847c230f096cbf41be07c3a28a178195bf64a354f1caf32cd4fa790eb0309b757aa7839d608538d047121baa3a6abb531c52abad25968e8e9c749a9a8b85b0254ddeb09ce59d23102743cb5fc204b16cb8d7d0188df51ba6c1f199df00304ec3332a2221a3d770da1d206fe00e922ff5a7cfd866a93b4a8f4d74fcfe7910882e1a0a08a04afac3377c7e4a912ce02731c2db2f435a95c93f02b155d29b87890aa102977b2895eeb0603c2cd26c2c628b1ae1922919fadf96646b61534c645019688d16806ff216322294a72cbaf406332b4afa898ec20ecd16cbd7549a301897b90933d8438598de70d3af643088326fc08cbb68b7bf97ab05deb028e3fb59b5a28357adf7e996da60ad80f88a8238b5081cf4e0ebfb0ea11cc9d3baf718adb74d9f9bb4d2caede01ac6f51f281bb9a7419f031cd600b85533fba7ffab3271df7d17de470cc423e6766c37a553850bb5e71e8a740b31f5d0bf89975c717a57e0959e52bfe85dd7f9c9f5ab28e772f7c0a76c6279390e52537d8ac88659ed96c2570b113d5f771bf6d6f876aa81d0664d3f6da3fc7a7b66ec4f52a1c505d9ccd453c1f82d15a93b9a73ee8f588d76173daa06db2e8f90055cc7910170836c13277baaccdff7d99132b2932918b40df2ca44e5ce13a995c2d09d2d7f05664f8efaf8013bdded858e6a46cc49229dff0c1c8350e03036c5ad1766ad6c3d15aa47423e4395425366acd0fdb66c6aeb58c7f37ab0e2a2665ea7f16b3ea32cf4d1062661102c59f0b22419c3a7abbdd6d0c346f8b1cc2979d3943c0d4cefbbc3865a175221f0cfb4425afd23fe81f7cd58c6d7c19d857db9121649d55a24dd3edf48b2c5b9f51f8acfe72847ae7f93d2051ea62e9f33daf5e8aab62d5eb61b680dba6e46315155e53aa658c2d054348db8c550ba3e6299b48e2ab720a979d94f4909b217f8f789181550a16ddc7402487304224617c021f562c4d629d0138b9b3af71535e920db064ba0cd80a17e0accfb9577e8be0acbf01dec315a376cf6f1df95e3ac39a999d6087872c911e01d7e4c369dd593de550a46560a3c65aa1cfacd8070ef5226a18466ca736c636fcf9e786d7e2089c764515eec1e9290cae9b347b8cef79df9c557a3285bf2d2f105a488c70b9a5a5f1e3eb7618339ef75c4fc8bde11382f746a301b64c030845373b1f83d2aa73af4a4a74ea6a1b0a4ffae8274d4359a7bbadad9c994441f9733f975323d6ea6b392fd53fe4ce97a568211768251675b428cae6b2f1db9a76d4fac7f7d61c3290d255cb62cff665e313482f3f106a88953c197e8e080bf69c56581e5daccb3ffb58ab553e82d87d61c3b2a53aa5be53a9166a062bda63a1da595c110ab84e665e3edf5630695e020e21e5c9ced648fd081a6e642d30df3d98ed989f2d6af4ee6bab65d768bd983985bd9a7b6f7b01d06b7fdc2cb598e98ce7da77b10436d8fc31c43d950645c03339fa347247fca6d972becf6d5f09e416116775909f2ed8fbaf7ad8a66bcdfb5f17139f9df854fd5248885f675065656d108baf7b959fb2ac33d504d80c23704ee290ec2507568dff71fc27456913ed7fd1dea0d6e2fc60e4d3b72b0b8747ea10d690b4c46bf53c4547a016b1808378c41fcdf48e65f6243b6ee7f18f31fd081763333cf76f3b5349bc4fec55c887400df780da89da2c62a80523c3beef9dc31c08d450568e0d7dea884c8cd85f7e50e88542374746ece08ee33e83e6cf2826ded585d91b4ad8a184e71e8cf6437d0f5c9fad24260b1763c336cf28d1bdff69c83c1ada98d8b318ef8688bdee29b05c7b4bb86237fa698bf07fe5431bc5b1aa5dcf05e0fab27e6cf62a6fdcbd764c71cb626581b35dd7f72c5441d115a155d7668c3596c2c5590b71e3be8098d7ccaa4c69ff4b96d7ca425881d7ee3edac08d27b0e5d766d567edaec42dc472e111309ee254586821efe3425290d83ea9631781676f12733f804356ff7a4324757cf21c80331b85e6e24369c413a95451878ca61d82a974522b6d6b9c919bbbfa5e0bb5c6fd545508f9c544daf61ba51464672a358e0ba16881da7ce4f1054f74dca15c7627f61757d5472af5a04a491c80ff8182cd85ec69bc09d96680fddf6c50e8b5f5e1f2d748a045391d635d33c7953f289c8b1f8915a8174fb445420add503983e5e5dbac6be66fc4d7b8048a0b075b63aef5c20b99528bc38112fd2db08b5ba26ca844f72218d6d6d90073e2af95bf5d8cab4f3c49038aaf503689c02acda8fb5c86986f13103a76a68ca5ff39a4f894bacc642437bc408cda502e9d7fd6542e55d6844b854c583650f5633394c7262b1f7e0ec3840d082d2e6de30e98f3a5eab2978d70cf82bea467ca86114d89643e501732cf0fd1b3ce146e2dc182e55f803b0c97dfd195ffb27d11fe14c89f9653c7ab92d98885c1bc27919c389e16312c3601cf95a390418aeb3804bbdd18e2c1ca502801b7195cbb2ede320e92223c869882789a2f5dde9ff53565b8778344f6f59d831843ac9d22245c26411da11853c2b8cf4c381540298d6ef1948c33e8c75152499f1972929d92f5dfc231d9ed0c1c865a4c1e4436296452f8572eb836569852b8add6d42b8444e2b3ca4bccbaeb2d525b002d40b1f82de7a0957ea1dc610eef8f3b47c1ae896e7acfa4d40022b736909bddcf49214351d064c54664c6dddb276ad54e72cc4733005655013e47f2a622c81e017b3d46eff2aff87c49113688615752b2280efe68b358e6778304446fe5480804892eb687286795e8abd56c7b959bd750a1b7ccf3638b75b239bf5c3c05e75b99473df92b9721cfd57aa05d505a1b9134291d3fa2aad5703ff0391b19ee26136830dfc930b0261de902e87dfa4eb1ba4bc3fe01387aea1906ed4091586c92ec32148e8ca13aa2c92d776b7882f72930f2d59338707f22e3cb7664934f8e59f523548eb6bcd9093be48b54b30068511be0fed3520dc79898f70a132b055d1d2cad0b7e00a4259b1bd0d1f4b7f7a389498e2935a62b0253ead5529eb8544d9f32799e7cc8c698d01cec933fd4e90b4879ceda06452654462d47c43ed88f6a7caf1d51d97d225586bf555b4a76b08e2eb9c57bf795d5a4867845cafe99d527413dc4e43c706abb416a22f3b72e0c1fc6f2b2b6826eda824e1d516cb48095c96fbb758a675fc2468199b4213e84a7e04f88b650b7d589bd01f0b753e78a2bea6d9fafb5686bb1d6eea7b99899a1ab7d5a6207af794813d7da5b58315c1c104580c24bb9cb0abc3d9769857633616b192ccb0b489ad8a657f43da0889face7eacf53458c347b1d028d637605ec982343d86ee045b8f1b866b369892c1c06272c72bfba52db79362736d416be4f5116c127f18672132f554b40a02545b66885ba1dec483da1b2df0f11fb8dff81e3da29f938fbb219eddacb25cdd831b8934b2a832e05bbf26070c3945f12f66bd7327797e474186b2abddd964bb8870f65f234cca5b1bfe0f9162c0a72632ff1e55a7aedce5a21cbbceb7c92933d8b85a5e9ffe7ddc09289b74a7c3d79e929ce1308901a7e036a28891d4f20ce1aab1ea68ef575573ef4ac1fa716f5e1fc87b010da49f297408b9c0bd8aa4358371fd60a28c6347b0a416d58d05599cb90eb108bc80c91589b4bba379ea43c13df9a0100783bcbead346bab874e5b034df01f17fb37e9671818a6afb0e6cc9d8e679c4e2fb8c2021d12aa5624403f86f4205fb7dbd3cc7e6b2e82844995e3410a8c287dd6c99f9743d3328284808056e5b0ce028db9ca1ac85e56f2637dd9143cdd4447c77628e244c758c195f51ac02fec2573ab4ac5f323c1879682f6642a613564021aeb9fc2060932013ed9427f2e827c40332ab51ac9e42d85811d4fc09e0f06eb44bb937793a1a44cc40edbf7927d00a4709831090eafba8af6f12ef9f22ffe30439a63b941e8f1b90afba201aaeba94070f9782756e687313c7aa4ba048519a3264a64cbe5b072b569afadbe13b408d69bc3c25ed6de85a1856ef69cecb4ce2001", diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts new file mode 100644 index 0000000000..9015eae79e --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import { expect } from 'chai' +import type { CRISPProgram, HonkVerifier } from '../types' +import { + abiCoder, + deployContract, + deployCRISPProgram, + deployMockInterfold, + ethers, + increaseTimeTo, + inputCommitmentPayload, + inputCommitmentTypes, + latestTimestamp, + setNextTimestamp, +} from './utils' + +const FINALIZATION_WINDOW = 30 +const PRODUCTION_FINALIZATION_WINDOW = 10_800 + +describe('CRISP input availability flow', function () { + async function openRound() { + const mockInterfold = await deployMockInterfold() + const mockHonk = (await deployContract('MockHonkVerifier')) as unknown as HonkVerifier + const program = await deployCRISPProgram({ + mockInterfold, + honkVerifier: mockHonk, + onchainHonkVerifier: mockHonk, + availabilityFinalizationWindow: FINALIZATION_WINDOW, + }) + const now = await latestTimestamp() + const start = now + 5 + const end = start + 3_700 + await (await mockInterfold.setInputWindow(start, end)).wait() + const e3Id = await mockInterfold.nextE3Id() + await (await mockInterfold.request(await program.getAddress())).wait() + await (await mockInterfold.setCommitteePublicKey(ethers.id('committee-key'))).wait() + await (await program.setMerkleRoot(e3Id, 1)).wait() + await increaseTimeTo(start) + + return { program, mockInterfold, e3Id, start, end } + } + + async function input( + program: CRISPProgram, + e3Id: bigint, + suffix = 'one', + slotAddress = ethers.Wallet.createRandom().address, + parentIndexPlusOne = 0, + ) { + const encryptedVoteCommitment = ethers.id(`commitment-${suffix}`) + const ciphertext = ethers.hexlify(ethers.toUtf8Bytes(`ciphertext-${suffix}`)) + const encryptedVoteHash = ethers.keccak256(ciphertext) + const stagedEnvelope = abiCoder.encode( + ['bytes', 'address', 'bytes32', 'bytes32', 'uint40', 'bytes'], + ['0x01', slotAddress, encryptedVoteCommitment, encryptedVoteHash, parentIndexPlusOne, ciphertext], + ) + const commitmentPayload = await inputCommitmentPayload(program, e3Id, stagedEnvelope) + return { + program, + e3Id, + slotAddress, + encryptedVoteCommitment, + encryptedVoteHash, + ciphertext, + parentIndexPlusOne, + commitmentPayload, + } + } + + it('accepts the proof first and lets another account finalize availability later', async function () { + const { program, e3Id } = await openRound() + const ballot = await input(program, e3Id) + const [, finalizer] = await ethers.getSigners() + + await expect(program.publishInput(e3Id, ballot.commitmentPayload)).to.emit(program, 'InputCommitted') + expect(await program.isInputCommitted(e3Id, ballot.encryptedVoteHash, ballot.encryptedVoteCommitment, ballot.slotAddress, 0)).to.equal( + true, + ) + expect((await program.getRoundData(e3Id)).numberOfVotes).to.equal(1n) + expect(await program.pendingInputCount(e3Id)).to.equal(1n) + + await expect( + program + .connect(finalizer) + .finalizeInput(e3Id, ballot.slotAddress, ballot.encryptedVoteCommitment, ballot.encryptedVoteHash, 0, ballot.ciphertext), + ).to.emit(program, 'InputPublished') + + expect((await program.getRoundData(e3Id)).numberOfVotes).to.equal(1n) + expect(await program.pendingInputCount(e3Id)).to.equal(0n) + expect(await program.isInputPublished(e3Id, ballot.encryptedVoteHash, ballot.encryptedVoteCommitment, ballot.slotAddress, 0)).to.equal( + true, + ) + }) + + it('reserves indices immediately so pending masks and revotes keep their parent chain', async function () { + const { program, e3Id } = await openRound() + const slot = ethers.Wallet.createRandom().address + const first = await input(program, e3Id, 'first', slot) + await (await program.publishInput(e3Id, first.commitmentPayload)).wait() + + const second = await input(program, e3Id, 'second', slot, 1) + await (await program.publishInput(e3Id, second.commitmentPayload)).wait() + + expect((await program.getRoundData(e3Id)).numberOfVotes).to.equal(2n) + expect(await program.pendingInputCount(e3Id)).to.equal(2n) + expect(await program.getSlotIndex(e3Id, slot)).to.equal(1n) + expect(await program.inputCommitmentOf(e3Id, slot, 0)).to.equal(first.encryptedVoteCommitment) + expect(await program.inputCommitmentOf(e3Id, slot, 1)).to.equal(second.encryptedVoteCommitment) + + await expect(program.verify(e3Id, ethers.ZeroHash, ethers.ZeroHash, '0x')) + .to.be.revertedWithCustomError(program, 'InputAvailabilityPending') + .withArgs(2) + }) + + it('rejects a proof commitment not attested by the configured availability service', async function () { + const { program, e3Id } = await openRound() + const ballot = await input(program, e3Id) + const decoded = abiCoder.decode(inputCommitmentTypes, ballot.commitmentPayload) + const [, wrongSigner] = await ethers.getSigners() + const inputId = await program.inputId(e3Id, decoded[3], decoded[2], decoded[1], decoded[4]) + const network = await ethers.provider.getNetwork() + const wrongAttestation = await wrongSigner.signTypedData( + { name: 'CRISP', version: '1', chainId: network.chainId, verifyingContract: await program.getAddress() }, + { + InputAvailability: [ + { name: 'e3Id', type: 'uint256' }, + { name: 'inputId', type: 'bytes32' }, + { name: 'expiresAt', type: 'uint64' }, + ], + }, + { e3Id, inputId, expiresAt: decoded[5] }, + ) + const forged = abiCoder.encode(inputCommitmentTypes, [ + decoded[0], + decoded[1], + decoded[2], + decoded[3], + decoded[4], + decoded[5], + wrongAttestation, + ]) + + await expect(program.publishInput(e3Id, forged)).to.be.revertedWithCustomError(program, 'InvalidInputAvailabilityAttestation') + }) + + it('rejects an availability promise at its exact expiry', async function () { + const { program, e3Id } = await openRound() + const expiry = BigInt((await latestTimestamp()) + 20) + const ballot = await input(program, e3Id) + const payload = await inputCommitmentPayload( + program, + e3Id, + abiCoder.encode( + ['bytes', 'address', 'bytes32', 'bytes32', 'uint40', 'bytes'], + [ + '0x01', + ballot.slotAddress, + ballot.encryptedVoteCommitment, + ballot.encryptedVoteHash, + ballot.parentIndexPlusOne, + ballot.ciphertext, + ], + ), + expiry, + ) + + await setNextTimestamp(Number(expiry)) + await expect(program.publishInput(e3Id, payload)) + .to.be.revertedWithCustomError(program, 'InputAvailabilityAttestationExpired') + .withArgs(expiry) + }) + + it('accepts an availability promise one second before its expiry', async function () { + const { program, e3Id } = await openRound() + const expiry = BigInt((await latestTimestamp()) + 20) + const ballot = await input(program, e3Id) + const stagedEnvelope = abiCoder.encode( + ['bytes', 'address', 'bytes32', 'bytes32', 'uint40', 'bytes'], + ['0x01', ballot.slotAddress, ballot.encryptedVoteCommitment, ballot.encryptedVoteHash, ballot.parentIndexPlusOne, ballot.ciphertext], + ) + const payload = await inputCommitmentPayload(program, e3Id, stagedEnvelope, expiry) + + await setNextTimestamp(Number(expiry - 1n)) + await expect(program.publishInput(e3Id, payload)).to.emit(program, 'InputCommitted') + }) + + it('binds the availability promise expiry into the signer attestation', async function () { + const { program, e3Id } = await openRound() + const ballot = await input(program, e3Id) + const decoded = abiCoder.decode(inputCommitmentTypes, ballot.commitmentPayload) + const altered = abiCoder.encode(inputCommitmentTypes, [ + decoded[0], + decoded[1], + decoded[2], + decoded[3], + decoded[4], + decoded[5] + 1n, + decoded[6], + ]) + + await expect(program.publishInput(e3Id, altered)).to.be.revertedWithCustomError(program, 'InvalidInputAvailabilityAttestation') + }) + + it('accepts the last commitment second and closes at the finalization tail', async function () { + const { program, e3Id, end } = await openRound() + const deadline = end - FINALIZATION_WINDOW + await increaseTimeTo(deadline - 2) + const accepted = await input(program, e3Id, 'last-accepted') + const refused = await input(program, e3Id, 'first-refused') + expect(await program.inputCommitmentDeadline(e3Id)).to.equal(deadline) + + await setNextTimestamp(deadline - 1) + await expect(program.publishInput(e3Id, accepted.commitmentPayload, { gasLimit: 5_000_000 })).to.emit(program, 'InputCommitted') + + await setNextTimestamp(deadline) + await expect(program.publishInput(e3Id, refused.commitmentPayload, { gasLimit: 5_000_000 })) + .to.be.revertedWithCustomError(program, 'InputCommitmentDeadlinePassed') + .withArgs(e3Id, deadline) + }) + + it('allows delayed finalization through the exact compute deadline', async function () { + const { program, e3Id, end } = await openRound() + const ballot = await input(program, e3Id) + await (await program.publishInput(e3Id, ballot.commitmentPayload)).wait() + + await setNextTimestamp(end + 100) + await expect( + program.finalizeInput(e3Id, ballot.slotAddress, ballot.encryptedVoteCommitment, ballot.encryptedVoteHash, 0, ballot.ciphertext), + ).to.emit(program, 'InputPublished') + }) + + it('rejects delayed finalization one second after the compute deadline', async function () { + const { program, e3Id, end } = await openRound() + const ballot = await input(program, e3Id) + await (await program.publishInput(e3Id, ballot.commitmentPayload)).wait() + + await setNextTimestamp(end + 101) + await expect( + program.finalizeInput(e3Id, ballot.slotAddress, ballot.encryptedVoteCommitment, ballot.encryptedVoteHash, 0, ballot.ciphertext), + ) + .to.be.revertedWithCustomError(program, 'InputDeadlinePassed') + .withArgs(e3Id, end + 100) + }) + + it('rejects a request that cannot leave one hour to vote after committee setup', async function () { + const mockInterfold = await deployMockInterfold() + const mockHonk = (await deployContract('MockHonkVerifier')) as unknown as HonkVerifier + const program = await deployCRISPProgram({ + mockInterfold, + honkVerifier: mockHonk, + onchainHonkVerifier: mockHonk, + availabilityFinalizationWindow: FINALIZATION_WINDOW, + }) + const now = await latestTimestamp() + const start = now + 5 + const end = start + FINALIZATION_WINDOW + 3_599 + await (await mockInterfold.setInputWindow(start, end)).wait() + + await expect(mockInterfold.request(await program.getAddress())) + .to.be.revertedWithCustomError(program, 'VotingWindowTooShort') + .withArgs(0, start, end - FINALIZATION_WINDOW, 3_600) + }) + + it('accepts the exact production minimum after the full committee timeout budget', async function () { + const mockInterfold = await deployMockInterfold() + const mockHonk = (await deployContract('MockHonkVerifier')) as unknown as HonkVerifier + const program = await deployCRISPProgram({ + mockInterfold, + honkVerifier: mockHonk, + onchainHonkVerifier: mockHonk, + availabilityFinalizationWindow: PRODUCTION_FINALIZATION_WINDOW, + }) + await (await mockInterfold.setCommitteeSetupWindows(3_600, 600, 21_600)).wait() + const now = await latestTimestamp() + // setInputWindow mines one block, then request mines the block at this timestamp. + const requestAt = now + 2 + await (await mockInterfold.setInputWindow(requestAt, requestAt + 40_200)).wait() + + await (await mockInterfold.request(await program.getAddress())).wait() + expect(await mockInterfold.nextE3Id()).to.equal(1) + }) + + it('rejects rounds that do not leave time for both voting and finalization', async function () { + const { program, mockInterfold, e3Id } = await openRound() + const now = await latestTimestamp() + await (await mockInterfold.setInputWindow(now, now + FINALIZATION_WINDOW)).wait() + + await expect(program.inputCommitmentDeadline(e3Id)) + .to.be.revertedWithCustomError(program, 'InputWindowTooShort') + .withArgs(e3Id, FINALIZATION_WINDOW, FINALIZATION_WINDOW + 1) + }) +}) diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts index 1ee09167c3..57d53149f2 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts @@ -18,7 +18,7 @@ const VECTOR = { commitment: '0x' + 'ab'.repeat(32), slot: '0x' + 'cd'.repeat(20), parentIndexPlusOne: 0, - leaf: 10659496726189475271708972402425950109424933772832069858355301832948309535156n, + leaf: 2902394196295929744342726349634404286327629052839628262482747965753261350412n, } /// The input tree leaf binds the published ciphertext bytes to the commitment the Noir proof @@ -44,9 +44,7 @@ describe('CRISPProgram input leaf', function () { const parent = Buffer.alloc(5) parent.writeUIntBE(parentIndexPlusOne, 0, 5) - const inner = createHash('sha256') - .update(Buffer.from(ciphertext.slice(2), 'hex')) - .digest() + const inner = Buffer.from(ethers.getBytes(ethers.keccak256(ciphertext))) const outer = createHash('sha256') .update(inner) .update(Buffer.from(commitment.slice(2), 'hex')) @@ -57,12 +55,22 @@ describe('CRISPProgram input leaf', function () { } it('matches the shared cross-language vector', async () => { - const leaf = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, VECTOR.parentIndexPlusOne) + const leaf = await crispProgram.inputLeaf( + ethers.keccak256(VECTOR.ciphertext), + VECTOR.commitment, + VECTOR.slot, + VECTOR.parentIndexPlusOne, + ) expect(leaf).to.equal(VECTOR.leaf) }) - it('is sha256(sha256(bytes) || commitment || slot || parent) reduced into the scalar field', async () => { - const leaf = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, VECTOR.parentIndexPlusOne) + it('is sha256(keccak256(bytes) || commitment || slot || parent) reduced into the scalar field', async () => { + const leaf = await crispProgram.inputLeaf( + ethers.keccak256(VECTOR.ciphertext), + VECTOR.commitment, + VECTOR.slot, + VECTOR.parentIndexPlusOne, + ) expect(leaf).to.equal(expectedLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, VECTOR.parentIndexPlusOne)) }) @@ -71,7 +79,7 @@ describe('CRISPProgram input leaf', function () { for (let i = 0; i < 8; i += 1) { const ciphertext = ethers.hexlify(ethers.randomBytes(96)) const commitment = ethers.hexlify(ethers.randomBytes(32)) - const leaf = await crispProgram.inputLeaf(ciphertext, commitment, ethers.hexlify(ethers.randomBytes(20)), i) + const leaf = await crispProgram.inputLeaf(ethers.keccak256(ciphertext), commitment, ethers.hexlify(ethers.randomBytes(20)), i) expect(leaf).to.be.lessThan(SNARK_SCALAR_FIELD) } }) @@ -79,43 +87,45 @@ describe('CRISPProgram input leaf', function () { it('changes when the ciphertext bytes change', async () => { // This is the property the whole fix rests on: swapping the bytes beside a valid commitment // must be visible. - const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) - const b = await crispProgram.inputLeaf(VECTOR.ciphertext.replace(/0f/, '1f'), VECTOR.commitment, VECTOR.slot, 0) + const a = await crispProgram.inputLeaf(ethers.keccak256(VECTOR.ciphertext), VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(ethers.keccak256(VECTOR.ciphertext.replace(/0f/, '1f')), VECTOR.commitment, VECTOR.slot, 0) expect(a).to.not.equal(b) }) it('changes when the commitment changes', async () => { - const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) - const b = await crispProgram.inputLeaf(VECTOR.ciphertext, '0x' + 'ef'.repeat(32), VECTOR.slot, 0) + const ciphertextHash = ethers.keccak256(VECTOR.ciphertext) + const a = await crispProgram.inputLeaf(ciphertextHash, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(ciphertextHash, '0x' + 'ef'.repeat(32), VECTOR.slot, 0) expect(a).to.not.equal(b) }) it('does not let the two fields be traded off against each other', async () => { - // Hashing the bytes before concatenating means the boundary between the two fields is fixed, - // so no pair of (bytes, commitment) can be rearranged into another pair with the same leaf. - const a = await crispProgram.inputLeaf('0x' + 'aa'.repeat(32) + 'bb'.repeat(32), '0x' + '11'.repeat(32), VECTOR.slot, 0) - const b = await crispProgram.inputLeaf('0x' + 'aa'.repeat(32), '0x' + 'bb'.repeat(32), VECTOR.slot, 0) + // Both fields have a fixed 32-byte width, so their boundary cannot be shifted. + const a = await crispProgram.inputLeaf('0x' + 'aa'.repeat(32), '0x' + '11'.repeat(32), VECTOR.slot, 0) + const b = await crispProgram.inputLeaf('0x' + '11'.repeat(32), '0x' + 'aa'.repeat(32), VECTOR.slot, 0) expect(a).to.not.equal(b) }) it('accepts an empty ciphertext without reverting', async () => { - const leaf = await crispProgram.inputLeaf('0x', VECTOR.commitment, VECTOR.slot, 0) + const leaf = await crispProgram.inputLeaf(ethers.keccak256('0x'), VECTOR.commitment, VECTOR.slot, 0) expect(leaf).to.equal(expectedLeaf('0x', VECTOR.commitment, VECTOR.slot, 0)) }) it('changes when the slot changes', async () => { // The tree is append-only and the Secure Process groups entries by slot, so a prover must not // be able to move an entry to a different slot. - const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) - const b = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, '0x' + '01'.repeat(20), 0) + const ciphertextHash = ethers.keccak256(VECTOR.ciphertext) + const a = await crispProgram.inputLeaf(ciphertextHash, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(ciphertextHash, VECTOR.commitment, '0x' + '01'.repeat(20), 0) expect(a).to.not.equal(b) }) it('changes when the parent changes', async () => { // The Secure Process walks each slot's chain by the parent, so an unbound parent would let a // prover re-point entries and change which one holds the slot. - const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) - const b = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 1) + const ciphertextHash = ethers.keccak256(VECTOR.ciphertext) + const a = await crispProgram.inputLeaf(ciphertextHash, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(ciphertextHash, VECTOR.commitment, VECTOR.slot, 1) expect(a).to.not.equal(b) expect(b).to.equal(expectedLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 1)) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts index a6f829c02c..3618120f88 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts @@ -30,7 +30,7 @@ import { expect } from 'chai' import { mkdirSync, writeFileSync } from 'fs' import { dirname } from 'path' import { fileURLToPath } from 'url' -import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, ethers } from './utils' +import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, ethers, publishAvailableInput } from './utils' import type { CRISPProgram, HonkVerifier, MockInterfold } from '../types' const keys = generateBFVKeys() @@ -208,7 +208,7 @@ describe('CRISPProgram input tree (e2e)', function () { await mockInterfold.setCommitteePublicKey(ballot.publicInputs[8]) await crispProgram.setMerkleRoot(e3Id, generateMerkleTree(leaves).root) - await crispProgram.publishInput(e3Id, encodeSolidityProof(ballot)) + await publishAvailableInput(crispProgram, e3Id, encodeSolidityProof(ballot)) const [, , , , inputRoot] = await crispProgram.getRoundData(e3Id) expect(inputRoot, 'the round must have an input root after publishing').to.not.equal(0n) @@ -220,7 +220,7 @@ describe('CRISPProgram input tree (e2e)', function () { // prepared one so a divergence between them is visible rather than silent. contractLeaf: ( await crispProgram.inputLeaf( - `0x${Buffer.from(ballot.encryptedVote).toString('hex')}`, + ethers.keccak256(`0x${Buffer.from(ballot.encryptedVote).toString('hex')}`), ballot.publicInputs[7], address, ballot.parentIndexPlusOne, @@ -246,7 +246,7 @@ describe('CRISPProgram input tree (e2e)', function () { // The leaf the contract stored must be the one it computes from the published pair. This is // the value the Rust side has to match; the fixture carries it across the language boundary. const expectedLeaf = await crispProgram.inputLeaf( - record.inputs[0].encryptedVote, + ethers.keccak256(record.inputs[0].encryptedVote), record.inputs[0].commitment, record.inputs[0].slot, record.inputs[0].parentIndexPlusOne, @@ -282,16 +282,21 @@ describe('CRISPProgram input tree (e2e)', function () { // A genuine proof, published beside bytes that are not its ciphertext. Nothing on chain can // reject this, which is exactly why the Secure Process has to check it. const forged = { ...ballot, encryptedVote: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) } - await crispProgram.publishInput(forgedE3Id, encodeSolidityProof(forged)) + await publishAvailableInput(crispProgram, forgedE3Id, encodeSolidityProof(forged)) const [, , , , rootAfter] = await crispProgram.getRoundData(forgedE3Id) expect(rootAfter, 'the forged input entered the tree').to.not.equal(rootBefore) // The leaf reflects the forged bytes, so the Secure Process still reproduces the root while // being able to see that this input does not match its commitment. - const forgedLeaf = await crispProgram.inputLeaf('0xdeadbeef', ballot.publicInputs[7], address, ballot.parentIndexPlusOne) + const forgedLeaf = await crispProgram.inputLeaf( + ethers.keccak256('0xdeadbeef'), + ballot.publicInputs[7], + address, + ballot.parentIndexPlusOne, + ) const honestLeaf = await crispProgram.inputLeaf( - `0x${Buffer.from(ballot.encryptedVote).toString('hex')}`, + ethers.keccak256(`0x${Buffer.from(ballot.encryptedVote).toString('hex')}`), ballot.publicInputs[7], address, ballot.parentIndexPlusOne, @@ -323,7 +328,7 @@ describe('CRISPProgram input tree (e2e)', function () { const ballot = await buildBallot([6, 0]) await mockInterfold.setCommitteePublicKey(ballot.publicInputs[8]) await crispProgram.setMerkleRoot(appendE3Id, generateMerkleTree(leaves).root) - await crispProgram.publishInput(appendE3Id, encodeSolidityProof(ballot)) + await publishAvailableInput(crispProgram, appendE3Id, encodeSolidityProof(ballot)) const [, , , , rootAfterFirst, votesAfterFirst] = await crispProgram.getRoundData(appendE3Id) expect(votesAfterFirst).to.equal(1n) @@ -332,7 +337,7 @@ describe('CRISPProgram input tree (e2e)', function () { // A third party masks over the slot. No signature is checked on this path. const mask = await buildMaskOver(ballot.encryptedVote, 0) const poisoned = { ...mask, encryptedVote: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) } - await crispProgram.publishInput(appendE3Id, encodeSolidityProof(poisoned)) + await publishAvailableInput(crispProgram, appendE3Id, encodeSolidityProof(poisoned)) const [, , , , rootAfterSecond, votesAfterSecond] = await crispProgram.getRoundData(appendE3Id) @@ -343,7 +348,7 @@ describe('CRISPProgram input tree (e2e)', function () { // The recovery. The poisoned entry cannot be a parent, so an honest mask names index 0 โ€” the // same parent the poisoned one named โ€” and the contract accepts it. const recovery = await buildMaskOver(ballot.encryptedVote, 0) - await crispProgram.publishInput(appendE3Id, encodeSolidityProof(recovery)) + await publishAvailableInput(crispProgram, appendE3Id, encodeSolidityProof(recovery)) const [, , , , rootAfterThird, votesAfterThird] = await crispProgram.getRoundData(appendE3Id) expect(votesAfterThird, 'the recovery is a third leaf').to.equal(3n) @@ -401,10 +406,10 @@ describe('CRISPProgram input tree (e2e)', function () { const first = await buildBallot([4, 0]) await mockInterfold.setCommitteePublicKey(first.publicInputs[8]) await crispProgram.setMerkleRoot(revoteE3Id, generateMerkleTree(leaves).root) - await crispProgram.publishInput(revoteE3Id, encodeSolidityProof(first)) + await publishAvailableInput(crispProgram, revoteE3Id, encodeSolidityProof(first)) const second = await buildReVote([0, 9], first.encryptedVote, 0) - await crispProgram.publishInput(revoteE3Id, encodeSolidityProof(second)) + await publishAvailableInput(crispProgram, revoteE3Id, encodeSolidityProof(second)) const [, , , , root, votes] = await crispProgram.getRoundData(revoteE3Id) expect(votes, 'the re-vote is appended').to.equal(2n) diff --git a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts index 1129ecad3f..d23375b4fe 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts @@ -15,7 +15,14 @@ before(async () => { setCircuits(await loadCircuits()) }) import { expect } from 'chai' -import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, deployOnchainHonkVerifier, ethers } from './utils' +import { + deployCRISPProgram, + deployHonkVerifier, + deployMockInterfold, + deployOnchainHonkVerifier, + ethers, + publishAvailableInput, +} from './utils' import type { CRISPProgram, HonkVerifier, MockInterfold } from '../types' const CUSTOM = 1 @@ -228,7 +235,7 @@ describe('CRISP on-chain census', function () { it('publishes an ONCHAIN ballot end to end', async function () { await (await mockInterfold.setCommitteePublicKey(voteProof.publicInputs[8])).wait() - await crispProgram.publishInput(e3Id, encodeSolidityProof(voteProof)) + await publishAvailableInput(crispProgram, e3Id, encodeSolidityProof(voteProof)) }) /// The contract reads the power from the token rather than trusting the ballot. A proof built @@ -242,13 +249,13 @@ describe('CRISP on-chain census', function () { const inflated = await buildOnchainProof(votingPower * 2n, round) await (await mockInterfold.setCommitteePublicKey(inflated.publicInputs[8])).wait() - await expect(crispProgram.publishInput(round, encodeSolidityProof(inflated))).to.be.revert(ethers) + await expect(publishAvailableInput(crispProgram, round, encodeSolidityProof(inflated))).to.be.revert(ethers) // Positive control in the same round and the same slot: the honest power publishes. The only // difference between the two ballots is the power, so the revert above is attributable to it. const honest = await buildOnchainProof(votingPower, round) await (await mockInterfold.setCommitteePublicKey(honest.publicInputs[8])).wait() - await crispProgram.publishInput(round, encodeSolidityProof(honest)) + await publishAvailableInput(crispProgram, round, encodeSolidityProof(honest)) }) /// The divisor is what keeps token weighting meaningful. The circuit enforces diff --git a/examples/CRISP/packages/crisp-contracts/tests/utils.ts b/examples/CRISP/packages/crisp-contracts/tests/utils.ts index 8ca054968b..9331f2dc42 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/utils.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/utils.ts @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { network } from 'hardhat' +import type { HardhatEthers } from '@nomicfoundation/hardhat-ethers/types' import { zeroHash } from 'viem' import { CRISPProgram, HonkVerifier, MockInterfold, MockRISC0Verifier, PoseidonT3 } from '../types' import { verifierNames } from '../scripts/verifiers' @@ -12,8 +13,100 @@ import { verifierNames } from '../scripts/verifiers' // Non-zero address used in the tests. export const nonZeroAddress = '0xc6e7DF5E7b4f2A278906862b61205850344D4e7d' -export const { ethers } = await network.connect() +const connection = await network.connect() +export const ethers: HardhatEthers = connection.ethers export const abiCoder = ethers.AbiCoder.defaultAbiCoder() +const inputEnvelopeTypes = ['bytes', 'address', 'bytes32', 'bytes32', 'uint40', 'bytes'] as const +export const inputCommitmentTypes = ['bytes', 'address', 'bytes32', 'bytes32', 'uint40', 'uint64', 'bytes'] as const + +/** Read time from the same in-memory chain used by the exported Hardhat ethers helper. */ +export async function latestTimestamp(): Promise { + return connection.networkHelpers.time.latest() +} + +/** Advance the same in-memory chain used by the contracts under test. */ +export async function increaseTimeTo(timestamp: number): Promise { + const current = await latestTimestamp() + if (timestamp < current) { + throw new Error(`Cannot move test time backwards from ${current} to ${timestamp}`) + } + if (timestamp === current) return + await connection.networkHelpers.time.increaseTo(timestamp) +} + +/** Set the timestamp of the next transaction, for exact inclusive/exclusive boundary tests. */ +export async function setNextTimestamp(timestamp: number): Promise { + await connection.networkHelpers.time.setNextBlockTimestamp(timestamp) +} + +export function splitInputEnvelope(encoded: string) { + const [noirProof, slotAddress, encryptedVoteCommitment, encryptedVoteHash, parentIndexPlusOne, availabilityProof] = abiCoder.decode( + inputEnvelopeTypes, + encoded, + ) + return { + noirProof, + slotAddress, + encryptedVoteCommitment, + encryptedVoteHash, + parentIndexPlusOne, + availabilityProof, + } +} + +export async function inputCommitmentPayload(program: CRISPProgram, e3Id: bigint, encoded: string, expiresAt?: bigint) { + const input = splitInputEnvelope(encoded) + const [availabilitySigner] = await ethers.getSigners() + const availabilityAttestationExpiresAt = + expiresAt ?? BigInt(await latestTimestamp()) + (await program.INPUT_AVAILABILITY_ATTESTATION_TTL()) + const inputId = await program.inputId( + e3Id, + input.encryptedVoteHash, + input.encryptedVoteCommitment, + input.slotAddress, + input.parentIndexPlusOne, + ) + const network = await ethers.provider.getNetwork() + const availabilityAttestation = await availabilitySigner.signTypedData( + { + name: 'CRISP', + version: '1', + chainId: network.chainId, + verifyingContract: await program.getAddress(), + }, + { + InputAvailability: [ + { name: 'e3Id', type: 'uint256' }, + { name: 'inputId', type: 'bytes32' }, + { name: 'expiresAt', type: 'uint64' }, + ], + }, + { e3Id, inputId, expiresAt: availabilityAttestationExpiresAt }, + ) + return abiCoder.encode(inputCommitmentTypes, [ + input.noirProof, + input.slotAddress, + input.encryptedVoteCommitment, + input.encryptedVoteHash, + input.parentIndexPlusOne, + availabilityAttestationExpiresAt, + availabilityAttestation, + ]) +} + +/** Exercise the same two transactions as the production availability service. */ +export async function publishAvailableInput(program: CRISPProgram, e3Id: bigint, encoded: string) { + const input = splitInputEnvelope(encoded) + await (await program.publishInput(e3Id, await inputCommitmentPayload(program, e3Id, encoded))).wait() + return program.finalizeInput( + e3Id, + input.slotAddress, + input.encryptedVoteCommitment, + input.encryptedVoteHash, + input.parentIndexPlusOne, + input.availabilityProof, + ) +} /** * Deploy a contract and return the address. @@ -109,6 +202,8 @@ export async function deployCRISPProgram( poseidonT3?: PoseidonT3 risc0Verifier?: MockRISC0Verifier bindInterfold?: boolean + availabilityFinalizationWindow?: number + inputAvailabilitySigner?: string } = {}, ) { const poseidonT3 = contracts.poseidonT3 || (await deployPoseidonT3()) @@ -119,6 +214,7 @@ export async function deployCRISPProgram( const onchainHonkVerifier = contracts.onchainHonkVerifier || honkVerifier const mockInterfold = contracts.mockInterfold || (await deployMockInterfold()) const risc0Verifier = contracts.risc0Verifier ? await contracts.risc0Verifier.getAddress() : nonZeroAddress + const dataAvailabilityVerifier = await deployContract('MockCrispDataAvailabilityVerifier') const programFactory = await ethers.getContractFactory('CRISPProgram', { libraries: { @@ -132,6 +228,9 @@ export async function deployCRISPProgram( risc0Verifier, await honkVerifier.getAddress(), await onchainHonkVerifier.getAddress(), + await dataAvailabilityVerifier.getAddress(), + contracts.availabilityFinalizationWindow ?? 0, + contracts.inputAvailabilitySigner ?? (await owner.getAddress()), zeroHash, ) diff --git a/examples/CRISP/packages/crisp-sdk/src/api.ts b/examples/CRISP/packages/crisp-sdk/src/api.ts index 95c773376b..e151e4079e 100644 --- a/examples/CRISP/packages/crisp-sdk/src/api.ts +++ b/examples/CRISP/packages/crisp-sdk/src/api.ts @@ -15,6 +15,7 @@ import { CRISP_SERVER_STATE_RESULT_ENDPOINT, CRISP_SERVER_TOKEN_TREE_ENDPOINT, CRISP_SERVER_VOTING_BROADCAST_ENDPOINT, + CRISP_SERVER_VOTING_AVAILABILITY_ENDPOINT, CRISP_SERVER_VOTING_STATUS_ENDPOINT, CRISP_SERVER_CHAIN_HEAD_ENDPOINT, CRISP_SERVER_CHAIN_READ_ENDPOINT, @@ -136,7 +137,11 @@ export const requestNewRound = async (serverUrl: string, request: NewRoundReques }) /** - * Broadcast an encrypted vote through the CRISP server, which relays it on-chain. + * Stage an encrypted vote with the CRISP availability service. + * + * This call returns after the proof commitment is accepted or the server creates a 10-minute + * commitment payload for the voter's wallet. The server publishes the ciphertext to Avail and + * finalizes the input in the background after the commitment lands. * @param serverUrl - The base URL of the CRISP server * @param request - The vote request (round id and hex encoded proof) * @returns The broadcast result, including the transaction hash on success @@ -163,6 +168,20 @@ export const broadcastVote = async (serverUrl: string, request: BroadcastVoteReq return data } +/** + * Read a durable vote availability job without waiting for VectorX finalization. + * @param serverUrl The base URL of the CRISP server. + * @param jobId The job id returned by {@link broadcastVote}. + * @returns The current commitment or availability state. + */ +export const getVoteAvailability = async (serverUrl: string, jobId: string): Promise => { + const response = await fetch(`${serverUrl}/${CRISP_SERVER_VOTING_AVAILABILITY_ENDPOINT}/${encodeURIComponent(jobId)}`) + if (!response.ok) { + throw new Error(`Failed to read availability job (${response.status}): ${await response.text()}`) + } + return (await response.json()) as BroadcastVoteResponse +} + /** * Get the vote status for an address in a specific round. * @param serverUrl - The base URL of the CRISP server diff --git a/examples/CRISP/packages/crisp-sdk/src/constants.ts b/examples/CRISP/packages/crisp-sdk/src/constants.ts index dfa7617a2d..965934e314 100644 --- a/examples/CRISP/packages/crisp-sdk/src/constants.ts +++ b/examples/CRISP/packages/crisp-sdk/src/constants.ts @@ -13,6 +13,7 @@ export const CRISP_SERVER_STATE_RESULT_ENDPOINT = 'state/result' export const CRISP_SERVER_STATE_ALL_ENDPOINT = 'state/all' export const CRISP_SERVER_ELIGIBLE_ADDRESSES_ENDPOINT = 'state/eligible-addresses' export const CRISP_SERVER_VOTING_BROADCAST_ENDPOINT = 'voting/broadcast' +export const CRISP_SERVER_VOTING_AVAILABILITY_ENDPOINT = 'voting/availability' export const CRISP_SERVER_VOTING_STATUS_ENDPOINT = 'voting/status' export const CRISP_SERVER_ROUNDS_CURRENT_ENDPOINT = 'rounds/current' export const CRISP_SERVER_ROUNDS_PUBLIC_KEY_ENDPOINT = 'rounds/public-key' diff --git a/examples/CRISP/packages/crisp-sdk/src/sdk.ts b/examples/CRISP/packages/crisp-sdk/src/sdk.ts index d7f67ec29c..150d391858 100644 --- a/examples/CRISP/packages/crisp-sdk/src/sdk.ts +++ b/examples/CRISP/packages/crisp-sdk/src/sdk.ts @@ -19,6 +19,7 @@ import { getRoundResult, getRoundStateLite, getTokenHolderHashes, + getVoteAvailability, getVoteStatus, requestNewRound, } from './api' @@ -175,6 +176,15 @@ export class CrispSDK { return broadcastVote(this.serverUrl, request) } + /** + * Read a durable availability job without waiting for Avail or VectorX. + * @param jobId - The job id returned by `broadcastVote` + * @returns The job's current commitment or availability state + */ + async getVoteAvailability(jobId: string): Promise { + return getVoteAvailability(this.serverUrl, jobId) + } + /** * Get the vote status for an address in a specific round. * @param e3Id - The e3Id of the round diff --git a/examples/CRISP/packages/crisp-sdk/src/types.ts b/examples/CRISP/packages/crisp-sdk/src/types.ts index c7c83e1b0b..bae4cae292 100644 --- a/examples/CRISP/packages/crisp-sdk/src/types.ts +++ b/examples/CRISP/packages/crisp-sdk/src/types.ts @@ -307,7 +307,7 @@ export type BroadcastVoteRequest = { /** * The status of a vote broadcast returned by the CRISP server */ -export type VoteResponseStatus = 'success' | 'failed_broadcast' +export type VoteResponseStatus = 'success' | 'pending_commitment' | 'ready_for_commitment' | 'pending_availability' | 'failed_broadcast' /** * Type representing the response to a vote broadcast (`voting/broadcast`) @@ -315,6 +315,9 @@ export type VoteResponseStatus = 'success' | 'failed_broadcast' export type BroadcastVoteResponse = { status: VoteResponseStatus tx_hash: string | null + job_id: string | null + /** Compact proof-commitment payload, present when the voter's wallet must submit it. */ + encoded_proof: string | null message: string | null } diff --git a/examples/CRISP/packages/crisp-sdk/src/utils.ts b/examples/CRISP/packages/crisp-sdk/src/utils.ts index 985bab0c54..6eca5ab527 100644 --- a/examples/CRISP/packages/crisp-sdk/src/utils.ts +++ b/examples/CRISP/packages/crisp-sdk/src/utils.ts @@ -30,6 +30,15 @@ export const generateMerkleTree = (leaves: bigint[]): LeanIMT => { return new LeanIMT((a, b) => poseidon2([a, b]), leaves) } +/** Parse a Merkle leaf returned by the CRISP server or supplied as an integer. */ +const parseMerkleLeaf = (leaf: bigint | string): bigint => { + if (typeof leaf === 'bigint' || leaf.startsWith('0x')) return BigInt(leaf) + + // The server's persisted tree format is a fixed-width, unprefixed hexadecimal string. + // Keep accepting decimal strings for callers that used the older SDK input shape. + return /^[0-9a-fA-F]{64}$/.test(leaf) ? BigInt(`0x${leaf}`) : BigInt(leaf) +} + /** * Generate a Merkle proof for a given address to prove inclusion in the voters' list * @param balance The voter's balance @@ -38,14 +47,15 @@ export const generateMerkleTree = (leaves: bigint[]): LeanIMT => { */ export const generateMerkleProof = (balance: bigint, address: string, leaves: bigint[] | string[]): MerkleProof => { const leaf = hashLeaf(address.toLowerCase(), balance) + const parsedLeaves = leaves.map(parseMerkleLeaf) - const index = leaves.findIndex((l) => BigInt(l) === leaf) + const index = parsedLeaves.findIndex((candidate) => candidate === leaf) if (index === -1) { throw new Error('Leaf not found in the tree') } - const tree = generateMerkleTree(leaves.map((l) => BigInt(l))) + const tree = generateMerkleTree(parsedLeaves) const proof = tree.generateProof(index) diff --git a/examples/CRISP/packages/crisp-sdk/src/vote.ts b/examples/CRISP/packages/crisp-sdk/src/vote.ts index f5e7c1eed9..21fb7a8b0c 100644 --- a/examples/CRISP/packages/crisp-sdk/src/vote.ts +++ b/examples/CRISP/packages/crisp-sdk/src/vote.ts @@ -19,7 +19,7 @@ import foldCircuit from '../../../circuits/bin/fold/target/crisp_fold.json' import foldOnchainCircuit from '../../../circuits/bin/fold_onchain/target/crisp_onchain_fold.json' import userDataEncryptionCircuit from '../../../../../circuits/bin/threshold/target/user_data_encryption.json' import { requireCircuits } from './circuits' -import { bytesToHex, encodeAbiParameters, parseAbiParameters, numberToHex, getAddress } from 'viem/utils' +import { bytesToHex, encodeAbiParameters, parseAbiParameters, numberToHex, getAddress, keccak256 } from 'viem/utils' import { Hex } from 'viem' // Cached Barretenberg API instance โ€” avoids re-initialising WASM + SRS on every proof. @@ -378,12 +378,18 @@ export const encodeSolidityProof = ({ publicInputs, proof, encryptedVote, parent // 7 final_ct_commitment, 8 committee public key const slotAddress = getAddress(numberToHex(BigInt(publicInputs[3]), { size: 20 })) const encryptedVoteCommitment = publicInputs[7] as `0x${string}` + const encryptedVoteBytes = bytesToHex(encryptedVote) + const encryptedVoteHash = keccak256(encryptedVoteBytes) - return encodeAbiParameters(parseAbiParameters('bytes, address, bytes32, bytes, uint40'), [ + // The last field contains the ciphertext only while the availability service stages the job. + // The service removes it from the proof-commitment transaction, publishes it to Avail, and + // later supplies the VectorX proof to `finalizeInput`. + return encodeAbiParameters(parseAbiParameters('bytes, address, bytes32, bytes32, uint40, bytes'), [ bytesToHex(proof), slotAddress, encryptedVoteCommitment, - bytesToHex(encryptedVote), + encryptedVoteHash, parentIndexPlusOne, + encryptedVoteBytes, ]) } diff --git a/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts b/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts index a3b555a4b6..81d5ac766a 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts @@ -56,6 +56,20 @@ describe('Utils', () => { expect(proof.indices.slice(0, proof.length)).toEqual([1, 0, 1]) }) + it('accepts the fixed-width unprefixed hex leaves returned by the server', () => { + const leaves = generateTestLeaves([{ address, balance }]) + const serverLeaves = leaves.map((leaf) => leaf.toString(16).padStart(64, '0')) + const proof = generateMerkleProof(balance, address, serverLeaves) + + expect(proof.leaf).toBe(hashLeaf(address, balance)) + expect( + generateMerkleTree(leaves).verifyProof({ + ...proof.proof, + siblings: proof.proof.siblings.slice(0, proof.length), + }), + ).toBe(true) + }) + it('Should throw if the leaf does not exist in the tree', () => { expect(() => generateMerkleProof(balance, address, [])).toThrow('Leaf not found in the tree') const leaves = generateTestLeaves([{ address, balance }]) diff --git a/examples/CRISP/program/Cargo.toml b/examples/CRISP/program/Cargo.toml index c22250bb27..3adf70b3aa 100644 --- a/examples/CRISP/program/Cargo.toml +++ b/examples/CRISP/program/Cargo.toml @@ -8,12 +8,12 @@ fhe = { workspace = true } fhe-traits = { workspace = true } e3-compute-provider = { workspace = true } sha2 = "=0.10.9" +sha3 = "=0.10.8" e3-fhe-params = { workspace = true } [dev-dependencies] hex = "=0.4.3" num-bigint = "=0.4.6" serde_json = "=1.0.145" -sha3 = "=0.10.8" e3-bfv-client = { workspace = true } rand = { workspace = true } diff --git a/examples/CRISP/program/README.md b/examples/CRISP/program/README.md index 0690fc4250..2ddfe01c03 100644 --- a/examples/CRISP/program/README.md +++ b/examples/CRISP/program/README.md @@ -48,11 +48,12 @@ CRISP uses BFV fully homomorphic encryption to tally votes without revealing ind The CRISP Solidity contract implements the three `IE3Program` entry points. Interfold calls two of them. Data providers call `publishInput` on the program directly. -| Function | Called by | When called | What it does | -| -------------- | -------------- | ------------------------ | ------------------------------------------------------------------ | -| `validate` | Interfold | On E3 request | Validates request parameters (e.g. duration, eligible voters) | -| `publishInput` | Data providers | On each input submission | Checks that the submitted BFV ciphertext is well-formed | -| `verify` | Interfold | On output publication | Verifies the Risc0 proof and that the ciphertext output is correct | +| Function | Called by | When called | What it does | +| --------------- | -------------------- | ------------------------------ | ---------------------------------------------------------------------------------------- | +| `validate` | Interfold | On E3 request | Validates request parameters, including the input window | +| `publishInput` | Voter or relay | Before the commitment cutoff | Verifies the Noir proof and availability-service signature, then reserves the input leaf | +| `finalizeInput` | Availability service | After VectorX proves inclusion | Verifies availability of the committed ciphertext hash | +| `verify` | Interfold | On output publication | Verifies the Risc0 proof and that the ciphertext output is correct | ## Proof Generation diff --git a/examples/CRISP/program/src/lib.rs b/examples/CRISP/program/src/lib.rs index dd23e25549..b3c6f30258 100644 --- a/examples/CRISP/program/src/lib.rs +++ b/examples/CRISP/program/src/lib.rs @@ -4,8 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use e3_compute_provider::FHEInputs; -use e3_fhe_params::decode_bfv_params_arc; +use e3_compute_provider::FHEProcessorInput; use fhe::bfv::Ciphertext; use fhe_traits::{DeserializeParametrized, Serialize}; @@ -18,12 +17,10 @@ pub fn policy() -> e3_compute_provider::InputPolicy { } /// CRISP Implementation of the CiphertextProcessor function -pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { - let params = decode_bfv_params_arc(&fhe_inputs.params).unwrap(); - - let mut sum = Ciphertext::zero(¶ms); - for ciphertext_bytes in &fhe_inputs.ciphertexts { - let ciphertext = Ciphertext::from_bytes(&ciphertext_bytes.0, ¶ms).unwrap(); +pub fn fhe_processor(fhe_inputs: &FHEProcessorInput<'_>) -> Vec { + let mut sum = Ciphertext::zero(fhe_inputs.params); + for ciphertext_bytes in fhe_inputs.ciphertexts { + let ciphertext = Ciphertext::from_bytes(&ciphertext_bytes.0, fhe_inputs.params).unwrap(); sum += &ciphertext; } @@ -39,6 +36,7 @@ pub mod policy { use e3_compute_provider::policy::{leaf_from_digest, PublishedInput}; use e3_compute_provider::{ComputeError, InputPolicy}; use sha2::{Digest, Sha256}; + use sha3::Keccak256; use std::collections::BTreeMap; /// The metadata `CRISPProgram` publishes with each input: 20-byte slot, then a 5-byte parent. @@ -78,7 +76,7 @@ pub mod policy { }) } - /// `sha256(sha256(ciphertext) || commitment || slot || parent) mod SNARK_SCALAR_FIELD`. + /// `sha256(keccak256(ciphertext) || commitment || slot || parent) mod SNARK_SCALAR_FIELD`. /// /// Must stay byte-identical to `CRISPProgram.inputLeaf`, or no root will ever match. It binds /// four things: the bytes, because the Noir proof constrains only the commitment and never sees @@ -97,7 +95,7 @@ pub mod policy { metadata_of(input)?; let mut outer = Sha256::new(); - outer.update(Sha256::digest(input.ciphertext)); + outer.update(Keccak256::digest(input.ciphertext)); outer.update(commitment); outer.update(input.metadata); Ok(leaf_from_digest(&outer.finalize())) diff --git a/examples/CRISP/program/tests/input_leaf.rs b/examples/CRISP/program/tests/input_leaf.rs index 44f78f454a..40f39b247b 100644 --- a/examples/CRISP/program/tests/input_leaf.rs +++ b/examples/CRISP/program/tests/input_leaf.rs @@ -50,7 +50,7 @@ fn the_leaf_matches_the_contract_vector() { assert_eq!( leaf_of(&ciphertext(), &COMMITMENT, &metadata), - "17910fb4333ca8c834bafea151e216e488db0fdaba00d7acd8d15a9e4a6ba9b4", + "066ab2680bd3fc07cea15f68b8b900e880646a54ac9f4e4453d107c91154de0c", "the Rust leaf diverged from the vector CRISPProgram.inputLeaf produces" ); } @@ -63,7 +63,7 @@ fn the_leaf_changes_with_the_parent() { assert_ne!(first, second); assert_eq!( - second, "098ef4852f0e5a3ae22bee86209ba3f653af2d34632ab68fadf1525af6b26d40", + second, "0d022e529ab12c8fcba37729e02dfe95bce7c4bcb82885915057bf4ddbe41076", "the Rust leaf diverged from the vector CRISPProgram.inputLeaf produces" ); } diff --git a/examples/CRISP/program/tests/secure_process.rs b/examples/CRISP/program/tests/secure_process.rs index d65a71697e..a914669f45 100644 --- a/examples/CRISP/program/tests/secure_process.rs +++ b/examples/CRISP/program/tests/secure_process.rs @@ -11,7 +11,9 @@ //! Everything except proof generation is covered, which matters because a guest failure inside the //! zkVM surfaces only as a missing proof and a requester-billed compute timeout. -use e3_compute_provider::{ComputeError, ComputeInput, ComputeResult, FHEInputs, PublishedData}; +use e3_compute_provider::{ + ComputeError, ComputeInput, ComputeResult, FHEInputs, FHEProcessorInput, PublishedData, +}; use e3_fhe_params::{build_pair_for_preset, encode_bfv_params, BfvPreset}; use e3_user_program::fhe_processor; use e3_user_program::policy::crisp; @@ -115,6 +117,13 @@ impl Round { input.process(fhe_processor, crisp()) } + fn aggregate(&self, inputs: &FHEInputs) -> Vec { + fhe_processor(&FHEProcessorInput { + ciphertexts: &inputs.ciphertexts, + params: &self.params, + }) + } + /// Decrypts a tally ciphertext, as the ciphernode committee would. /// /// Against `self.params`, not a re-decoded copy: fhe.rs compares parameters by `Arc` identity, @@ -139,7 +148,7 @@ fn the_secure_process_tallies_well_formed_ballots() { let result = round.run(input).expect("an honest round must process"); - assert_eq!(round.decrypt_tally(&fhe_processor(&all), 2), vec![5, 5]); + assert_eq!(round.decrypt_tally(&round.aggregate(&all), 2), vec![5, 5]); assert_eq!(result.merkle_root.len(), 32); } @@ -178,7 +187,7 @@ fn a_contradicting_input_is_dropped_and_the_round_survives() { "the substituted ballot must not reach the tally" ); assert_eq!( - round.decrypt_tally(&fhe_processor(&survivor_inputs), 2), + round.decrypt_tally(&round.aggregate(&survivor_inputs), 2), vec![5, 0] ); } @@ -201,7 +210,7 @@ fn garbage_bytes_do_not_abort_the_secure_process() { assert_eq!(result.ciphertext_hash, reference.ciphertext_hash); assert_eq!( - round.decrypt_tally(&fhe_processor(&survivor_inputs), 2), + round.decrypt_tally(&round.aggregate(&survivor_inputs), 2), vec![0, 1] ); } @@ -284,7 +293,7 @@ fn a_poisoned_append_does_not_erase_the_vote_already_in_the_slot() { let reference_input = round.round_input_at(vec![honest_ballot], vec![victim]); let reference_fhe = reference_input.fhe_inputs.clone(); assert_eq!( - round.decrypt_tally(&fhe_processor(&reference_fhe), 2), + round.decrypt_tally(&round.aggregate(&reference_fhe), 2), vec![6, 0] ); assert_eq!( @@ -315,7 +324,7 @@ fn an_honest_re_vote_replaces_the_earlier_ballot() { let reference_input = round.round_input_at(vec![second], vec![voter]); let reference_fhe = reference_input.fhe_inputs.clone(); assert_eq!( - round.decrypt_tally(&fhe_processor(&reference_fhe), 2), + round.decrypt_tally(&round.aggregate(&reference_fhe), 2), vec![0, 7] ); assert_eq!( diff --git a/examples/CRISP/server/.env.example b/examples/CRISP/server/.env.example index 8e52d5dd8d..1e964f1ef4 100644 --- a/examples/CRISP/server/.env.example +++ b/examples/CRISP/server/.env.example @@ -29,11 +29,23 @@ MOCK_PK_VERIFIER_ADDRESS=0x809d550fca64d94Bd9F66E60752A544199cfAC3D MOCK_E3_PROGRAM_ADDRESS=0x4c5859f0F772848b2D91F1D83E2Fe57935348029 # E3 Config -# Defines the time interval during which users can submit their inputs -# After this interval, the computation phase starts automatically -# After activation + this interval, ciphernodes are then not responsing to -# any more decryption requests +# Local mock duration. In Avail mode, startup reads the current on-chain committee, voting, and +# finalization windows and requires E3_DURATION to cover their sum. Current production defaults +# require 40200 seconds; 43200 seconds (12 hours) adds operating margin. E3_DURATION=300 + +# Large encrypted objects use a deterministic in-process mock on local chains. For Sepolia use +# `avail`, `https://turing-rpc.avail.so/http`, and `https://turing-bridge-api.avail.so`. For +# Ethereum mainnet use an HTTPS Avail RPC and `https://bridge-api.avail.so`. +DATA_AVAILABILITY_MODE=mock +AVAIL_RPC_URL= +AVAIL_BRIDGE_API_URL= +AVAIL_APP_ID= +AVAIL_PROOF_LEAD_SECONDS=10800 +# Refuse new unfinished publications before they can fill the server disk. Default: 1 GiB. +DATA_AVAILABILITY_MAX_PENDING_BYTES=1073741824 +# Avail secret URI for the server account that pays `submit_data`. Never commit a real value. +AVAIL_SEED= # 0=InsecureThreshold512, 1=SecureThreshold8192 E3_PARAM_SET=0 # 0=Minimum, 1=Micro, 2=Small diff --git a/examples/CRISP/server/Cargo.toml b/examples/CRISP/server/Cargo.toml index 72a6028a6e..d95973f8e3 100644 --- a/examples/CRISP/server/Cargo.toml +++ b/examples/CRISP/server/Cargo.toml @@ -46,6 +46,8 @@ e3-compute-provider.workspace = true e3-sdk = { workspace = true, default-features = false, features=["full"] } e3-fhe-params = { workspace = true } e3-bfv-client = { workspace = true } +e3-data-availability = { workspace = true, features = ["avail-submit"] } +e3-evm-helpers.workspace = true evm-helpers = { path = "../crates/evm_helpers" } crisp-utils.workspace = true @@ -75,6 +77,7 @@ sled = "=0.34.7" # Logging env_logger.workspace = true log.workspace = true +tracing.workspace = true anyhow.workspace = true # Configuration diff --git a/examples/CRISP/server/Dockerfile b/examples/CRISP/server/Dockerfile index ee5dd9a2ae..1ecb152db1 100644 --- a/examples/CRISP/server/Dockerfile +++ b/examples/CRISP/server/Dockerfile @@ -2,7 +2,7 @@ ARG RUST_VERSION=1.91.1 ARG SKIP_SOLIDITY=0 -FROM rust:${RUST_VERSION}-slim-bullseye AS base-dev +FROM rust:${RUST_VERSION}-slim-bookworm AS base-dev ENV CARGO_HOME=/usr/local/cargo @@ -131,6 +131,8 @@ COPY examples/CRISP/.interfold/ examples/CRISP/.interfold/ COPY examples/CRISP/server/ examples/CRISP/server/ COPY examples/CRISP/program/ examples/CRISP/program/ COPY examples/CRISP/crates/ examples/CRISP/crates/ +COPY examples/CRISP/packages/crisp-contracts/deployed_contracts.json \ + examples/CRISP/packages/crisp-contracts/deployed_contracts.json COPY examples/CRISP/Cargo.* examples/CRISP/ COPY Cargo.* ./ COPY crates/ crates/ diff --git a/examples/CRISP/server/src/cli/commands.rs b/examples/CRISP/server/src/cli/commands.rs index 47312ed734..79ebfc44ec 100644 --- a/examples/CRISP/server/src/cli/commands.rs +++ b/examples/CRISP/server/src/cli/commands.rs @@ -15,7 +15,6 @@ use super::approve; use super::CLI_DB; use alloy::primitives::{Address, Bytes, U256}; use alloy::providers::{Provider, ProviderBuilder}; -use alloy::sol; use alloy::sol_types::SolValue; use anyhow::anyhow; use crisp::config::CONFIG; @@ -32,19 +31,6 @@ use fhe_traits::{ use rand::rng; use std::sync::Arc; -sol! { - #[sol(rpc)] - interface CiphernodeRegistryReadiness { - event CommitteePublished( - uint256 indexed e3Id, - address[] nodes, - bytes publicKey, - bytes32 pkCommitment, - bytes proof - ); - } -} - // Legacy interactive CLI flows; kept for revival alongside the HTTP server path. #[allow(dead_code)] #[derive(Debug, Deserialize, Serialize)] @@ -219,18 +205,24 @@ pub async fn get_current_timestamp() -> Result Result> { - let e3_id = U256::from_str_radix(e3_id, 10)?; - let provider = ProviderBuilder::new().connect(&CONFIG.http_rpc_url).await?; - let registry_address: Address = CONFIG.ciphernode_registry_address.parse()?; - let registry = CiphernodeRegistryReadiness::new(registry_address, provider); - let events = registry - .CommitteePublished_filter() - .from_block(0) - .topic1(e3_id) - .query() + let e3_id = U256::from_str_radix(e3_id, 10)?.to_string(); + let response = Client::new() + .post(format!( + "{}/rounds/public-key", + CONFIG.interfold_server_url_for_clients() + )) + .json(&PKRequest { + round_id: e3_id, + pk_bytes: Vec::new(), + }) + .send() .await?; - Ok(!events.is_empty()) + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(false); + } + response.error_for_status()?; + Ok(true) } pub async fn initialize_crisp_round( diff --git a/examples/CRISP/server/src/config.rs b/examples/CRISP/server/src/config.rs index 4a2c9d24fa..3b4c011207 100644 --- a/examples/CRISP/server/src/config.rs +++ b/examples/CRISP/server/src/config.rs @@ -4,11 +4,14 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use config::{Config as ConfigManager, ConfigError}; +use config::{Config as ConfigManager, ConfigError, Environment}; use dotenvy::dotenv; use once_cell::sync::Lazy; use serde::Deserialize; +const AVAIL_FINALIZATION_WINDOW_SECONDS: u64 = 10_800; +const DEFAULT_DA_PENDING_BYTES: u64 = 1024 * 1024 * 1024; + // Do not derive `Debug`: this structure owns private keys and other secrets. #[derive(Deserialize)] pub struct Config { @@ -26,6 +29,27 @@ pub struct Config { #[serde(default)] pub crisp_voting_token: Option, pub chain_id: u64, + /// `mock` for deterministic local tests or `avail` for VectorX-backed publication. + #[serde(default)] + pub data_availability_mode: Option, + #[serde(default)] + pub avail_rpc_url: Option, + #[serde(default)] + pub avail_bridge_api_url: Option, + #[serde(default)] + pub avail_app_id: Option, + /// Minimum time that must remain before the Ethereum input deadline when an Avail + /// publication starts. VectorX range proofs are asynchronous, so production must configure + /// enough headroom for its current bridge cadence and operating margin. + #[serde(default)] + pub avail_proof_lead_seconds: Option, + /// Avail signer URI. This field is intentionally excluded from debug output with the rest of + /// this configuration. + #[serde(default)] + pub avail_seed: Option, + /// Maximum bytes the service accepts for unfinished availability jobs. + #[serde(default = "default_da_pending_bytes")] + pub data_availability_max_pending_bytes: u64, pub cron_api_key: String, // E3 parameters pub e3_param_set: u8, // 0=InsecureThreshold512, 1=SecureThreshold8192 @@ -34,6 +58,8 @@ pub struct Config { pub e3_compute_provider_name: String, pub e3_compute_provider_parallel: bool, pub e3_compute_provider_batch_size: u32, + /// Optional on localhost and on deployments that do not use Etherscan-backed holder lookup. + #[serde(default)] pub etherscan_api_key: String, /// Block to start indexing from on a FRESH database. Absent means "start at the chain head", /// which is what this server did before backfill existed โ€” set it to the deployment block of @@ -78,6 +104,16 @@ pub struct Config { } impl Config { + pub fn data_availability_mode(&self) -> String { + self.data_availability_mode.clone().unwrap_or_else(|| { + if matches!(self.chain_id, 1_337 | 31_337) { + "mock".to_owned() + } else { + "avail".to_owned() + } + }) + } + /// Base URL for outbound HTTP clients (program-server webhooks, CLI, cron). /// /// `0.0.0.0` / `::` are bind addresses only; connecting to them fails (e.g. macOS `EADDRNOTAVAIL`). @@ -97,13 +133,57 @@ impl Config { dotenv().ok(); } let config: Self = ConfigManager::builder() - .add_source(config::Environment::default()) + // Example files leave optional settings blank. Treat a blank optional value as unset + // instead of trying to deserialize an empty string as `u32` or `u64`. + .add_source(Environment::default().ignore_empty(true)) .build()? .try_deserialize()?; Self::validate_e3_param_set(config.chain_id, config.e3_param_set)?; + Self::validate_data_availability( + config.chain_id, + &config.data_availability_mode(), + config + .avail_proof_lead_seconds + .unwrap_or(AVAIL_FINALIZATION_WINDOW_SECONDS), + config.data_availability_max_pending_bytes, + )?; Ok(config) } + fn validate_data_availability( + chain_id: u64, + mode: &str, + proof_lead: u64, + max_pending_bytes: u64, + ) -> Result<(), ConfigError> { + if mode == "mock" { + if !matches!(chain_id, 1_337 | 31_337) { + return Err(ConfigError::Message( + "DATA_AVAILABILITY_MODE=mock is allowed only on local development chains" + .to_owned(), + )); + } + return Ok(()); + } + if mode != "avail" { + return Err(ConfigError::Message(format!( + "unsupported DATA_AVAILABILITY_MODE '{mode}'" + ))); + } + if proof_lead == 0 { + return Err(ConfigError::Message( + "AVAIL_PROOF_LEAD_SECONDS must be greater than zero".to_owned(), + )); + } + if max_pending_bytes < e3_data_availability::MAX_OBJECT_BYTES as u64 { + return Err(ConfigError::Message(format!( + "DATA_AVAILABILITY_MAX_PENDING_BYTES must be at least {}", + e3_data_availability::MAX_OBJECT_BYTES + ))); + } + Ok(()) + } + fn validate_e3_param_set(chain_id: u64, param_set: u8) -> Result<(), ConfigError> { if param_set > 1 { return Err(ConfigError::Message(format!( @@ -119,12 +199,24 @@ impl Config { } } +const fn default_da_pending_bytes() -> u64 { + DEFAULT_DA_PENDING_BYTES +} + pub static CONFIG: Lazy = Lazy::new(|| Config::from_env().expect("Failed to load configuration")); #[cfg(test)] mod tests { use super::Config; + use config::{Config as ConfigManager, Environment}; + use serde_json::json; + use std::collections::HashMap; + + #[derive(serde::Deserialize)] + struct OptionalAvailConfig { + avail_app_id: Option, + } #[test] fn accepts_both_testnet_parameter_sets() { @@ -142,4 +234,63 @@ mod tests { fn rejects_unknown_parameter_sets() { assert!(Config::validate_e3_param_set(31_337, 2).is_err()); } + + #[test] + fn avail_requires_a_nonzero_proof_lead() { + assert!(Config::validate_data_availability(31_337, "mock", 0, 0).is_ok()); + assert!(Config::validate_data_availability(1, "avail", 10_800, 1024 * 1024).is_ok()); + assert!(Config::validate_data_availability(1, "avail", 0, 1024 * 1024).is_err()); + assert!(Config::validate_data_availability(1, "avail", 10_800, 1024).is_err()); + } + + #[test] + fn mock_data_availability_is_local_only() { + assert!(Config::validate_data_availability(1, "mock", 0, 0).is_err()); + assert!(Config::validate_data_availability(11_155_111, "mock", 0, 0).is_err()); + assert!(Config::validate_data_availability(1_337, "mock", 0, 0).is_ok()); + assert!(Config::validate_data_availability(31_337, "mock", 0, 0).is_ok()); + } + + #[test] + fn blank_optional_environment_value_is_unset() { + let source = Environment::default() + .ignore_empty(true) + .source(Some(HashMap::from([( + "AVAIL_APP_ID".to_owned(), + String::new(), + )]))); + let config: OptionalAvailConfig = ConfigManager::builder() + .add_source(source) + .build() + .unwrap() + .try_deserialize() + .unwrap(); + assert_eq!(config.avail_app_id, None); + } + + #[test] + fn etherscan_key_defaults_to_empty() { + let config: Config = serde_json::from_value(json!({ + "program_server_url": "http://127.0.0.1:3000", + "interfold_server_url": "http://127.0.0.1:4000", + "private_key": "test-key", + "http_rpc_url": "http://127.0.0.1:8545", + "ws_rpc_url": "ws://127.0.0.1:8545", + "interfold_address": "0x1", + "e3_program_address": "0x2", + "ciphernode_registry_address": "0x3", + "fee_token_address": "0x4", + "chain_id": 31_337, + "cron_api_key": "test-cron-key", + "e3_param_set": 0, + "e3_committee_size": 0, + "e3_duration": 3_600, + "e3_compute_provider_name": "test", + "e3_compute_provider_parallel": false, + "e3_compute_provider_batch_size": 1 + })) + .unwrap(); + + assert!(config.etherscan_api_key.is_empty()); + } } diff --git a/examples/CRISP/server/src/deployments.rs b/examples/CRISP/server/src/deployments.rs index 73e0d95ab6..35de1cbb73 100644 --- a/examples/CRISP/server/src/deployments.rs +++ b/examples/CRISP/server/src/deployments.rs @@ -4,11 +4,14 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -//! Reads `packages/crisp-contracts/deployed_contracts.json` for localhost dev addresses. +//! Reads `packages/crisp-contracts/deployed_contracts.json` for deployed CRISP addresses. use anyhow::{Context, Result}; use serde::Deserialize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +const EMBEDDED_DEPLOYMENTS_JSON: &str = + include_str!("../../packages/crisp-contracts/deployed_contracts.json"); #[derive(Debug, Deserialize)] struct DeploymentEntry { @@ -28,6 +31,8 @@ struct ChainDeployments { #[derive(Debug, Deserialize)] struct DeployedContractsFile { localhost: Option, + sepolia: Option, + mainnet: Option, } fn deployments_json_path() -> Result { @@ -39,16 +44,34 @@ fn deployments_json_path() -> Result { .join("deployed_contracts.json")) } -/// `MockVotingToken` address from the latest localhost deploy, if present. -pub fn localhost_mock_voting_token() -> Result> { +fn read_deployments() -> Result { let path = deployments_json_path()?; - if !path.exists() { - return Ok(None); + read_deployments_from_path(&path) +} + +fn read_deployments_from_path(path: &Path) -> Result { + if path.exists() { + let raw = + std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + return serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display())); + } + + serde_json::from_str(EMBEDDED_DEPLOYMENTS_JSON) + .context("parse embedded CRISP deployment addresses") +} + +fn chain_deployments(file: &DeployedContractsFile, chain_id: u64) -> Option<&ChainDeployments> { + match chain_id { + 1 => file.mainnet.as_ref(), + 11_155_111 => file.sepolia.as_ref(), + 31_337 | 1_337 => file.localhost.as_ref(), + _ => None, } - let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let file: DeployedContractsFile = - serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?; - Ok(file +} + +/// `MockVotingToken` address from the latest localhost deploy, if present. +pub fn localhost_mock_voting_token() -> Result> { + Ok(read_deployments()? .localhost .and_then(|c| c.mock_voting_token) .map(|e| e.address)) @@ -59,30 +82,59 @@ pub fn localhost_mock_voting_token() -> Result> { /// The open census for ONCHAIN rounds: pass it as the round's token to run a round anyone can /// register into during the input window. pub fn localhost_self_registry() -> Result> { - let path = deployments_json_path()?; - if !path.exists() { - return Ok(None); - } - let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let file: DeployedContractsFile = - serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?; - Ok(file + Ok(read_deployments()? .localhost .and_then(|c| c.self_registry) .map(|e| e.address)) } +/// `SelfRegistry` address for a deployed chain, if recorded. +pub fn self_registry_for_chain_id(chain_id: u64) -> Result> { + let file = read_deployments()?; + Ok(chain_deployments(&file, chain_id) + .and_then(|c| c.self_registry.as_ref()) + .map(|e| e.address.clone())) +} + /// `CRISPProgram` address from the latest localhost deploy, if present. pub fn localhost_crisp_program() -> Result> { - let path = deployments_json_path()?; - if !path.exists() { - return Ok(None); - } - let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let file: DeployedContractsFile = - serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?; - Ok(file + Ok(read_deployments()? .localhost .and_then(|c| c.crisp_program) .map(|e| e.address)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_deployments_parse_without_the_source_tree() { + read_deployments_from_path(Path::new("missing-deployed-contracts.json")) + .expect("embedded deployment data must parse"); + } + + #[test] + fn chain_lookup_selects_the_requested_network() { + let file: DeployedContractsFile = serde_json::from_str( + r#"{ + "localhost": {"SelfRegistry": {"address": "local"}}, + "sepolia": {"SelfRegistry": {"address": "sepolia"}}, + "mainnet": {"SelfRegistry": {"address": "mainnet"}} + }"#, + ) + .expect("synthetic deployment data must parse"); + + let address = |chain_id| { + chain_deployments(&file, chain_id) + .and_then(|chain| chain.self_registry.as_ref()) + .map(|entry| entry.address.as_str()) + }; + + assert_eq!(address(31_337), Some("local")); + assert_eq!(address(1_337), Some("local")); + assert_eq!(address(11_155_111), Some("sepolia")); + assert_eq!(address(1), Some("mainnet")); + assert_eq!(address(42), None); + } +} diff --git a/examples/CRISP/server/src/server/data_availability.rs b/examples/CRISP/server/src/server/data_availability.rs new file mode 100644 index 0000000000..38da918076 --- /dev/null +++ b/examples/CRISP/server/src/server/data_availability.rs @@ -0,0 +1,2207 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +//! Persistent publication jobs for CRISP's large encrypted objects. + +use crate::{config::Config, server::models::e3_id_to_u256}; +use alloy::{ + eips::{BlockId, BlockNumberOrTag}, + primitives::{keccak256, Address, Bytes, B256, U256}, + providers::{Provider, ProviderBuilder}, + signers::{local::PrivateKeySigner, SignerSync}, + sol, + sol_types::SolValue, +}; +use e3_data_availability::{ + AvailPublisher, AvailReader, DataAvailabilityPublisher, DataAvailabilityReader, DataReference, + PendingPublication, ProofStatus, +}; +use e3_evm_helpers::contracts::{E3Stage, InterfoldContractFactory, InterfoldRead, InterfoldWrite}; +use evm_helpers::{CRISPContract, InputPublished, SimulateError}; +use serde::{Deserialize, Serialize}; +use sled::{transaction::Transactional, Db, Tree}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex as StorageMutex}, + time::Duration, +}; +use tokio::{sync::Semaphore, task::JoinSet}; +use tracing::warn; + +const JOB_POLL_INTERVAL: Duration = Duration::from_secs(30); +// An Avail submission can use 20 seconds to connect, 30 seconds to submit, 300 seconds to +// finalize, and 30 seconds to read its events. Keep the outer bound above those inner bounds. +const JOB_STEP_TIMEOUT: Duration = Duration::from_secs(480); +const JOB_STATUS_REFRESH_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_CONCURRENT_JOB_STEPS: usize = 4; +const AVAILABILITY_JOB_SCHEMA_VERSION: u32 = 1; +const AVAILABLE_INPUT_REFERENCE_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct InputRejected(&'static str); + +fn reject_input(message: &'static str) -> anyhow::Error { + anyhow::Error::new(InputRejected(message)) +} + +fn duration_u64(value: U256, name: &str) -> anyhow::Result { + value + .try_into() + .map_err(|_| anyhow::anyhow!("{name} does not fit in u64")) +} + +fn minimum_input_duration( + randomness_window: u64, + sortition_window: u64, + dkg_window: u64, + voting_window: u64, + finalization_window: u64, +) -> anyhow::Result { + randomness_window + .checked_add(sortition_window) + .and_then(|value| value.checked_add(dkg_window)) + .and_then(|value| value.checked_add(voting_window)) + .and_then(|value| value.checked_add(finalization_window)) + .ok_or_else(|| anyhow::anyhow!("required CRISP input duration overflows u64")) +} + +/// Return a stable client message only when the caller's ballot was conclusively rejected. +pub fn input_rejection_message(error: &anyhow::Error) -> Option<&'static str> { + for cause in error.chain() { + if let Some(rejection) = cause.downcast_ref::() { + return Some(rejection.0); + } + if matches!( + cause.downcast_ref::(), + Some(SimulateError::Reverted(_)) + ) { + return Some("The vote proof or ciphertext was rejected"); + } + } + None +} + +sol! { + struct InputEnvelope { + bytes noirProof; + address slotAddress; + bytes32 encryptedVoteCommitment; + bytes32 encryptedVoteHash; + uint40 parentIndexPlusOne; + bytes availabilityProof; + } + + struct InputCommitmentEnvelope { + bytes noirProof; + address slotAddress; + bytes32 encryptedVoteCommitment; + bytes32 encryptedVoteHash; + uint40 parentIndexPlusOne; + uint64 availabilityAttestationExpiresAt; + bytes availabilityAttestation; + } + + enum StoredE3Stage { + None, + Requested, + CommitteeFinalized, + KeyPublished, + CiphertextReady, + Complete, + Failed + } + + #[sol(rpc)] + interface ICrispAvailabilityState { + function isInputCommitted( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bool); + function isInputPublished( + uint256 e3Id, + bytes32 encryptedVoteHash, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) external view returns (bool); + } + + #[sol(rpc)] + interface IInterfoldAvailabilityState { + function getE3Stage(uint256 e3Id) external view returns (StoredE3Stage); + } +} + +fn decode_input_envelope(encoded: &[u8]) -> anyhow::Result { + Ok(InputEnvelope::abi_decode_params_validate(encoded)?) +} + +fn encode_input_commitment_envelope(envelope: &InputCommitmentEnvelope) -> Vec { + envelope.abi_encode_params() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum JobKind { + Input { + e3_id: String, + staged_envelope: Vec, + deadline: u64, + commitment_deadline: u64, + }, + Output { + e3_id: String, + ciphertext_commitment: [u8; 32], + compute_proof: Vec, + deadline: u64, + }, +} + +const fn no_deadline() -> u64 { + u64::MAX +} + +impl JobKind { + fn deadline(&self) -> u64 { + match self { + Self::Input { deadline, .. } | Self::Output { deadline, .. } => *deadline, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum JobState { + Created, + AwaitingCommitment { + ethereum_payload: Vec, + #[serde(default)] + attestation_expires_at: u64, + }, + Committed { + transaction_hash: String, + }, + AwaitingProof { + publication: PendingPublication, + commitment_transaction_hash: Option, + }, + Ready { + ethereum_payload: Vec, + commitment_transaction_hash: Option, + }, + Submitted { + transaction_hash: String, + }, + Failed { + message: String, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct AvailabilityJob { + schema_version: u32, + id: String, + content_hash: [u8; 32], + kind: JobKind, + state: JobState, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AvailabilityJobView { + pub job_id: String, + pub status: String, + pub tx_hash: Option, + pub encoded_proof: Option, + pub message: Option, +} + +/// Durable work item created when Ethereum accepts an input reference. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AvailableInputReference { + schema_version: u32, + pub e3_id: String, + pub content_hash: [u8; 32], + pub availability_block: u32, + pub availability_leaf_index: u128, + pub index: u64, + pub commitment: [u8; 32], + pub slot: [u8; 20], + pub parent_index_plus_one: u64, +} + +impl AvailableInputReference { + pub fn from_event(e3_id: String, event: &InputPublished) -> Self { + Self { + schema_version: AVAILABLE_INPUT_REFERENCE_SCHEMA_VERSION, + e3_id, + content_hash: event.encryptedVoteHash.0, + availability_block: event.availabilityBlock, + availability_leaf_index: event.availabilityLeafIndex, + index: event.index.to::(), + commitment: event.encryptedVoteCommitment.0, + slot: event.slotAddress.into(), + parent_index_plus_one: event.parentIndexPlusOne.to::(), + } + } + + fn validate_schema(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.schema_version == AVAILABLE_INPUT_REFERENCE_SCHEMA_VERSION, + "unsupported available-input reference schema version {}; expected {}", + self.schema_version, + AVAILABLE_INPUT_REFERENCE_SCHEMA_VERSION + ); + Ok(()) + } + + fn key(&self) -> String { + format!("{}:{}", self.e3_id, self.index) + } + + pub fn data_reference(&self) -> DataReference { + DataReference { + content_hash: self.content_hash, + block_number: self.availability_block, + leaf_index: self.availability_leaf_index, + } + } +} + +impl From<&AvailabilityJob> for AvailabilityJobView { + fn from(job: &AvailabilityJob) -> Self { + let (status, tx_hash, encoded_proof, message) = match &job.state { + JobState::AwaitingCommitment { + ethereum_payload, .. + } => ( + "ready_for_commitment", + None, + Some(format!("0x{}", hex::encode(ethereum_payload))), + None, + ), + JobState::Created => ("pending_commitment", None, None, None), + JobState::Committed { transaction_hash } => ( + "pending_availability", + Some(transaction_hash.clone()), + None, + None, + ), + JobState::AwaitingProof { + commitment_transaction_hash, + .. + } + | JobState::Ready { + commitment_transaction_hash, + .. + } => ( + "pending_availability", + commitment_transaction_hash.clone(), + None, + None, + ), + JobState::Submitted { transaction_hash } => ( + "success", + (transaction_hash != "already-finalized").then(|| transaction_hash.clone()), + None, + None, + ), + JobState::Failed { message } => ("failed_broadcast", None, None, Some(message.clone())), + }; + Self { + job_id: job.id.clone(), + status: status.to_owned(), + tx_hash, + encoded_proof, + message, + } + } +} + +enum Backend { + Mock, + Avail { + publisher: Arc, + reader: Arc, + }, +} + +/// Owns persistent publication state and resumes incomplete jobs after restart. +#[derive(Clone)] +pub struct AvailabilityService { + jobs: Tree, + objects: Tree, + input_retrievals: Tree, + backend: Arc, + in_progress: Arc>>, + storage: Arc>, + job_slots: Arc, + chain_id: u64, + http_rpc_url: String, + private_key: String, + interfold_address: String, + e3_program_address: String, + ciphernode_registry_address: String, + input_duration_seconds: u64, + proof_lead_seconds: u64, + max_pending_bytes: u64, +} + +struct ActiveJobGuard<'a> { + jobs: &'a StorageMutex>, + id: &'a str, +} + +impl Drop for ActiveJobGuard<'_> { + fn drop(&mut self) { + self.jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(self.id); + } +} + +impl AvailabilityService { + pub fn new(db: &Db, config: &Config) -> anyhow::Result { + let mode = config.data_availability_mode(); + let backend = match mode.as_str() { + "mock" => Backend::Mock, + "avail" => { + let rpc_url = config + .avail_rpc_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("AVAIL_RPC_URL is required"))?; + Backend::Avail { + publisher: Arc::new(AvailPublisher::new( + rpc_url, + config + .avail_app_id + .ok_or_else(|| anyhow::anyhow!("AVAIL_APP_ID is required"))?, + config + .avail_seed + .as_deref() + .ok_or_else(|| anyhow::anyhow!("AVAIL_SEED is required"))?, + config + .avail_bridge_api_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("AVAIL_BRIDGE_API_URL is required"))?, + config.chain_id, + )?), + reader: Arc::new(AvailReader::new(rpc_url)?), + } + } + other => anyhow::bail!("unsupported DATA_AVAILABILITY_MODE '{other}'"), + }; + let service = Self { + jobs: db.open_tree("data-availability-jobs")?, + objects: db.open_tree("data-availability-objects")?, + input_retrievals: db.open_tree("data-availability-input-retrievals")?, + backend: Arc::new(backend), + in_progress: Arc::new(StorageMutex::new(HashSet::new())), + storage: Arc::new(StorageMutex::new(())), + job_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_JOB_STEPS)), + chain_id: config.chain_id, + http_rpc_url: config.http_rpc_url.clone(), + private_key: config.private_key.clone(), + interfold_address: config.interfold_address.clone(), + e3_program_address: config.e3_program_address.clone(), + ciphernode_registry_address: config.ciphernode_registry_address.clone(), + input_duration_seconds: config.e3_duration, + proof_lead_seconds: config.avail_proof_lead_seconds.unwrap_or(10_800), + max_pending_bytes: config.data_availability_max_pending_bytes, + }; + service.validate_storage()?; + Ok(service) + } + + /// Check local timing against the current registry, Interfold, and CRISP contract values. + pub async fn validate_onchain_configuration(&self) -> anyhow::Result<()> { + if !matches!(&*self.backend, Backend::Avail { .. }) { + return Ok(()); + } + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let onchain = duration_u64( + contract + .availability_finalization_window() + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?, + "CRISP finalization window", + )?; + anyhow::ensure!( + onchain == self.proof_lead_seconds, + "AVAIL_PROOF_LEAD_SECONDS ({}) does not match CRISPProgram.availabilityFinalizationWindow() ({onchain})", + self.proof_lead_seconds + ); + + let registry: Address = self + .ciphernode_registry_address + .parse() + .map_err(|error| anyhow::anyhow!("invalid ciphernode registry address: {error}"))?; + let (randomness, sortition) = contract + .committee_setup_windows(registry) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let interfold = + InterfoldContractFactory::create_read(&self.http_rpc_url, &self.interfold_address) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let timeouts = interfold + .get_timeout_config() + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let voting = contract + .minimum_voting_duration() + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let required = minimum_input_duration( + duration_u64(randomness, "randomness request timeout")?, + duration_u64(sortition, "sortition submission window")?, + duration_u64(timeouts.dkgWindow, "DKG window")?, + duration_u64(voting, "minimum voting duration")?, + onchain, + )?; + anyhow::ensure!( + self.input_duration_seconds >= required, + "E3_DURATION ({}) is shorter than the current on-chain committee, voting, and availability windows ({required})", + self.input_duration_seconds + ); + Ok(()) + } + + pub async fn stage_input( + &self, + e3_id: &str, + encoded_envelope: Vec, + ) -> anyhow::Result { + let mut envelope = decode_input_envelope(&encoded_envelope) + .map_err(|_| reject_input("The encoded vote envelope is invalid"))?; + e3_data_availability::validate_object_bytes(&envelope.availabilityProof) + .map_err(|_| reject_input("The encrypted vote is too large"))?; + let actual = keccak256(&envelope.availabilityProof); + if actual != envelope.encryptedVoteHash { + return Err(reject_input( + "The encrypted vote does not match its committed hash", + )); + } + let object = envelope.availabilityProof.to_vec(); + + // A proof system can produce more than one valid proof for the same public statement. + // Keep the durable job keyed by that statement, not by the proof bytes, or retrying with + // another valid proof can buy the same Avail publication twice. + let request_identity = ( + envelope.slotAddress, + envelope.encryptedVoteCommitment, + envelope.parentIndexPlusOne, + ) + .abi_encode(); + let id = self.job_id(b"input", e3_id, actual, &request_identity); + if self.load(&id)?.is_some() { + self.process(&id).await; + let existing = self.load_required(&id)?; + if !matches!(&existing.state, JobState::Failed { .. }) { + return Ok((&existing).into()); + } + } + + // Reject invalid Noir proofs before the service pays an Avail submission fee. + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + contract + .validate_input_proof( + e3_id_to_u256(e3_id).map_err(|_| reject_input("The E3 identifier is invalid"))?, + envelope.noirProof.clone(), + envelope.slotAddress, + envelope.encryptedVoteCommitment, + envelope.encryptedVoteHash, + envelope.parentIndexPlusOne.to::(), + ) + .await?; + + let (deadline, commitment_deadline) = if matches!(&*self.backend, Backend::Avail { .. }) { + let interfold = + InterfoldContractFactory::create_read(&self.http_rpc_url, &self.interfold_address) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let e3_id_value = + e3_id_to_u256(e3_id).map_err(|_| reject_input("The E3 identifier is invalid"))?; + let e3 = interfold + .get_e3(e3_id_value) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let now = self.chain_timestamp().await?; + let input_deadline: u64 = e3.inputWindow[1] + .try_into() + .map_err(|_| anyhow::anyhow!("input deadline does not fit in u64"))?; + let deadline: u64 = interfold + .get_deadlines(e3_id_value) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .computeDeadline + .try_into() + .map_err(|_| anyhow::anyhow!("compute deadline does not fit in u64"))?; + let commitment_deadline = contract + .input_commitment_deadline(e3_id_value) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + if commitment_deadline <= now { + return Err(reject_input("The vote commitment deadline has passed")); + } + anyhow::ensure!( + input_deadline.saturating_sub(commitment_deadline) >= self.proof_lead_seconds, + "the CRISP finalization tail is shorter than AVAIL_PROOF_LEAD_SECONDS" + ); + (deadline, commitment_deadline) + } else { + (no_deadline(), no_deadline()) + }; + + // The object has its own content-addressed record. Do not duplicate it inside the job or + // its staged ABI envelope. + envelope.availabilityProof = Bytes::new(); + let staged_envelope = envelope.abi_encode_params(); + let job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: id.clone(), + content_hash: actual.0, + kind: JobKind::Input { + e3_id: e3_id.to_owned(), + staged_envelope, + deadline, + commitment_deadline, + }, + state: JobState::Created, + }; + { + // Serialize admission so concurrent requests cannot both pass the slot and capacity + // checks. A slot can have one uncommitted promise at a time. Once that promise lands + // on Ethereum, a later re-vote can be staged normally. + let _storage = self + .storage + .lock() + .map_err(|_| anyhow::anyhow!("data-availability storage lock is poisoned"))?; + if let Some(existing) = self.load(&id)? { + if !matches!(&existing.state, JobState::Failed { .. }) { + return Ok((&existing).into()); + } + } + if self + .uncommitted_input_for_slot(e3_id, envelope.slotAddress, &id)? + .is_some() + { + return Err(reject_input( + "A vote for this slot is already waiting for commitment", + )); + } + // Persist the bytes and their recovery job atomically before an attestation can be + // returned. The signature promises that this service received the exact object and + // can resume after a restart. + self.store_new_job_with_object(&job, &object)?; + } + self.process(&id).await; + if matches!(&*self.backend, Backend::Mock) { + // Local mode has no external finality delay. Drive every durable phase so callers + // keep the synchronous developer experience while production remains asynchronous. + self.process(&id).await; + self.process(&id).await; + self.process(&id).await; + } + Ok((&self.load_required(&id)?).into()) + } + + pub async fn stage_output( + &self, + e3_id: &str, + ciphertext: Vec, + ciphertext_commitment: [u8; 32], + compute_proof: Vec, + ) -> anyhow::Result { + e3_data_availability::validate_object_bytes(&ciphertext)?; + let hash = keccak256(&ciphertext); + // The output statement is the E3, exact ciphertext hash, and ciphertext commitment. The + // RISC Zero seal proves that statement but is not its identity: another valid seal must be + // an idempotent retry, not another paid Avail publication. + let id = self.job_id(b"output", e3_id, hash, &ciphertext_commitment); + if let Some(job) = self.load(&id)? { + return Ok((&job).into()); + } + let deadline = if matches!(&*self.backend, Backend::Avail { .. }) { + let interfold = + InterfoldContractFactory::create_read(&self.http_rpc_url, &self.interfold_address) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let e3_id_value = e3_id_to_u256(e3_id)?; + anyhow::ensure!( + interfold + .get_e3_stage(e3_id_value) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))? + == E3Stage::KeyPublished, + "the E3 is not accepting an aggregate ciphertext" + ); + let e3 = interfold + .get_e3(e3_id_value) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let deadlines = interfold + .get_deadlines(e3_id_value) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let deadline: u64 = deadlines + .computeDeadline + .try_into() + .map_err(|_| anyhow::anyhow!("compute deadline does not fit in u64"))?; + let now = self.chain_timestamp().await?; + let input_deadline: u64 = e3.inputWindow[1] + .try_into() + .map_err(|_| anyhow::anyhow!("input deadline does not fit in u64"))?; + anyhow::ensure!( + now >= input_deadline, + "the input window is still open; the aggregate proof could become stale" + ); + anyhow::ensure!( + deadline > now.saturating_add(self.proof_lead_seconds), + "the compute deadline arrives before VectorX can safely prove this publication" + ); + deadline + } else { + no_deadline() + }; + + // `/state/add-result` is reachable over HTTP. Do not let an arbitrary caller spend the + // Avail signer balance: first execute the exact CRISP proof check that Interfold will use + // once the VectorX receipt exists. Invalid output never becomes durable work. + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + contract + .validate_compute_output( + e3_id_to_u256(e3_id)?, + hash, + B256::from(ciphertext_commitment), + Bytes::copy_from_slice(&compute_proof), + ) + .await + .map_err(|error| { + anyhow::anyhow!("the aggregate ciphertext proof is not acceptable: {error}") + })?; + + let job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: id.clone(), + content_hash: hash.0, + kind: JobKind::Output { + e3_id: e3_id.to_owned(), + ciphertext_commitment, + compute_proof, + deadline, + }, + state: JobState::Created, + }; + { + let _storage = self + .storage + .lock() + .map_err(|_| anyhow::anyhow!("data-availability storage lock is poisoned"))?; + if let Some(job) = self.load(&id)? { + return Ok((&job).into()); + } + self.store_new_job_with_object(&job, &ciphertext)?; + } + if matches!(&*self.backend, Backend::Mock) { + self.process(&id).await; + self.process(&id).await; + } + Ok((&self.load_required(&id)?).into()) + } + + /// Read a job after reconciling wallet-submitted work with Ethereum. + /// + /// A browser can close after its input commitment is mined but before the background worker + /// observes it. On reload, returning the cached `AwaitingCommitment` state would offer the same + /// transaction again. This bounded read checks the one relevant on-chain fact first. A slow RPC + /// does not make the status endpoint unavailable; the durable worker still retries normally. + pub async fn refreshed_view(&self, id: &str) -> anyhow::Result> { + let Some(mut job) = self.load(id)? else { + return Ok(None); + }; + if matches!( + &job.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ) { + return Ok(Some((&job).into())); + } + + let refresh = async { + if matches!(&job.state, JobState::AwaitingCommitment { .. }) { + if self.input_is_committed(&job).await? { + job.state = JobState::Committed { + transaction_hash: "wallet-committed".to_owned(), + }; + self.save(&job)?; + } + } else if self.ethereum_publication_exists(&job).await? { + job.state = JobState::Submitted { + transaction_hash: "already-finalized".to_owned(), + }; + self.save(&job)?; + } + anyhow::Ok(()) + }; + + match tokio::time::timeout(JOB_STATUS_REFRESH_TIMEOUT, refresh).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + warn!(job_id = id, %error, "Could not refresh availability job from Ethereum") + } + Err(_) => warn!( + job_id = id, + "Timed out while refreshing availability job from Ethereum" + ), + } + + Ok(Some((&job).into())) + } + + pub fn object(&self, hash: &str) -> anyhow::Result>> { + let hash = hash.strip_prefix("0x").unwrap_or(hash); + let key = hex::decode(hash)?; + Ok(self.objects.get(key)?.map(|value| value.to_vec())) + } + + fn object_required(&self, content_hash: [u8; 32]) -> anyhow::Result> { + self.objects + .get(content_hash)? + .map(|bytes| bytes.to_vec()) + .ok_or_else(|| { + anyhow::anyhow!( + "data-availability object 0x{} is missing", + hex::encode(content_hash) + ) + }) + } + + #[cfg(test)] + fn store_object(&self, content_hash: [u8; 32], object: &[u8]) -> anyhow::Result<()> { + anyhow::ensure!( + keccak256(object).0 == content_hash, + "data-availability object does not match its content hash" + ); + if let Some(existing) = self.objects.get(content_hash)? { + anyhow::ensure!( + existing.as_ref() == object, + "stored data-availability object does not match its content hash" + ); + return Ok(()); + } + + let used = self.objects.iter().try_fold(0u64, |used, entry| { + let (_, value) = entry?; + used.checked_add(value.len() as u64) + .ok_or_else(|| sled::Error::Unsupported("availability byte count overflow".into())) + })?; + let required = used + .checked_add(object.len() as u64) + .ok_or_else(|| anyhow::anyhow!("data-availability byte count overflows u64"))?; + anyhow::ensure!( + required <= self.max_pending_bytes, + "data-availability pending storage is full; configured limit is {} bytes", + self.max_pending_bytes + ); + + self.objects.insert(content_hash, object)?; + self.objects.flush()?; + Ok(()) + } + + /// Store a new object's bytes and recovery job as one durable admission. + /// + /// A job without its object cannot progress, while an object without a job consumes the + /// bounded pending-storage allowance forever. One sled transaction prevents either partial + /// state after a process or machine crash. + fn store_new_job_with_object( + &self, + job: &AvailabilityJob, + object: &[u8], + ) -> anyhow::Result<()> { + Self::validate_job_schema(job)?; + anyhow::ensure!( + matches!(job.state, JobState::Created), + "a new data-availability admission must start in the created state" + ); + anyhow::ensure!( + keccak256(object).0 == job.content_hash, + "data-availability object does not match its content hash" + ); + + let existing = self.objects.get(job.content_hash)?; + if let Some(existing) = &existing { + anyhow::ensure!( + existing.as_ref() == object, + "stored data-availability object does not match its content hash" + ); + } + + let used = self.objects.iter().try_fold(0u64, |used, entry| { + let (_, value) = entry?; + used.checked_add(value.len() as u64) + .ok_or_else(|| sled::Error::Unsupported("availability byte count overflow".into())) + })?; + let additional = if existing.is_some() { + 0 + } else { + object.len() as u64 + }; + let required = used + .checked_add(additional) + .ok_or_else(|| anyhow::anyhow!("data-availability byte count overflows u64"))?; + anyhow::ensure!( + required <= self.max_pending_bytes, + "data-availability pending storage is full; configured limit is {} bytes", + self.max_pending_bytes + ); + + let encoded_job = serde_json::to_vec(job)?; + (&self.objects, &self.jobs).transaction(|(objects, jobs)| { + if let Some(stored) = jobs.get(job.id.as_bytes())? { + let stored = Self::decode_job(&stored).map_err(|error| { + sled::transaction::ConflictableTransactionError::Abort( + sled::Error::Unsupported(error.to_string()), + ) + })?; + if stored.content_hash != job.content_hash { + return Err(sled::transaction::ConflictableTransactionError::Abort( + sled::Error::Unsupported( + "data-availability job ID is bound to another content hash".into(), + ), + )); + } + if matches!(&stored.state, JobState::Failed { .. }) { + if objects.get(job.content_hash)?.is_none() { + objects.insert(job.content_hash.as_slice(), object)?; + } + jobs.insert(job.id.as_bytes(), encoded_job.as_slice())?; + return Ok(()); + } + if objects.get(job.content_hash)?.is_none() { + return Err(sled::transaction::ConflictableTransactionError::Abort( + sled::Error::Unsupported( + "data-availability job exists without its object".into(), + ), + )); + } + return Ok(()); + } + + if objects.get(job.content_hash)?.is_none() { + objects.insert(job.content_hash.as_slice(), object)?; + } + jobs.insert(job.id.as_bytes(), encoded_job.as_slice())?; + Ok(()) + })?; + self.objects.flush()?; + self.jobs.flush()?; + Ok(()) + } + + fn uncommitted_input_for_slot( + &self, + e3_id: &str, + slot: Address, + except_id: &str, + ) -> anyhow::Result> { + for entry in &self.jobs { + let (_, value) = entry?; + let job = Self::decode_job(&value)?; + if job.id == except_id + || !matches!( + job.state, + JobState::Created | JobState::AwaitingCommitment { .. } + ) + { + continue; + } + let JobKind::Input { + e3_id: existing_e3, + staged_envelope, + .. + } = &job.kind + else { + continue; + }; + if existing_e3 == e3_id && decode_input_envelope(staged_envelope)?.slotAddress == slot { + return Ok(Some(job.id)); + } + } + Ok(None) + } + + /// Retrieve bytes named by a receipt that the Ethereum contract already accepted. + pub async fn retrieve(&self, reference: DataReference) -> anyhow::Result> { + if let Some(bytes) = self + .objects + .get(reference.content_hash)? + .map(|value| value.to_vec()) + { + return e3_data_availability::verify_retrieved_bytes(reference, bytes); + } + + match &*self.backend { + Backend::Mock => anyhow::bail!( + "local data-availability object 0x{} is not stored", + hex::encode(reference.content_hash) + ), + // The round repository stores a retrieved input. Do not also retain a second cache in + // the availability tree. Avail remains the source if recovery needs the object again. + Backend::Avail { reader, .. } => Ok(reader.retrieve(reference).await?), + } + } + + pub fn record_input_reference( + &self, + reference: &AvailableInputReference, + ) -> anyhow::Result<()> { + reference.validate_schema()?; + self.input_retrievals + .insert(reference.key(), serde_json::to_vec(reference)?)?; + self.input_retrievals.flush()?; + Ok(()) + } + + pub fn pending_input_references(&self) -> anyhow::Result> { + self.input_retrievals + .iter() + .map(|entry| { + let (_, value) = entry?; + Self::decode_input_reference(&value) + }) + .collect() + } + + pub fn complete_input_reference( + &self, + reference: &AvailableInputReference, + ) -> anyhow::Result<()> { + self.input_retrievals.remove(reference.key())?; + self.input_retrievals.flush()?; + Ok(()) + } + + pub async fn run(self: Arc) -> anyhow::Result<()> { + loop { + let ids = self.pending_ids()?; + let mut tasks = JoinSet::new(); + for id in ids { + // Do not create one detached task per durable job. A malicious client can stage + // many valid inputs, and an unbounded task fan-out would turn backlog into a + // memory and RPC spike. Keep only one bounded batch alive at a time. + while tasks.len() >= MAX_CONCURRENT_JOB_STEPS { + if let Some(result) = tasks.join_next().await { + if let Err(error) = result { + warn!(%error, "Data-availability job task panicked; continuing with the durable queue"); + } + } + } + let service = Arc::clone(&self); + tasks.spawn(async move { + service.process(&id).await; + }); + } + while let Some(result) = tasks.join_next().await { + if let Err(error) = result { + warn!(%error, "Data-availability job task panicked; continuing with the durable queue"); + } + } + tokio::time::sleep(JOB_POLL_INTERVAL).await; + } + } + + async fn process(&self, id: &str) { + let Ok(_permit) = Arc::clone(&self.job_slots).acquire_owned().await else { + warn!(job_id = id, "Data-availability worker is shutting down"); + return; + }; + let _active_job = { + let mut active = self + .in_progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !active.insert(id.to_owned()) { + return; + } + ActiveJobGuard { + jobs: &self.in_progress, + id, + } + }; + match tokio::time::timeout(JOB_STEP_TIMEOUT, self.process_inner(id)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => warn!(job_id = id, %error, "Data-availability job will retry"), + Err(_) => warn!( + job_id = id, + "Data-availability job step timed out and will retry" + ), + } + } + + async fn process_inner(&self, id: &str) -> anyhow::Result<()> { + let mut job = self.load_required(id)?; + let terminal = matches!( + &job.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ); + if terminal { + return Ok(()); + } + if !terminal && self.ethereum_publication_exists(&job).await? { + job.state = JobState::Submitted { + transaction_hash: "already-finalized".to_owned(), + }; + self.save(&job)?; + return Ok(()); + } + let now = self.chain_timestamp().await?; + if matches!(&*self.backend, Backend::Avail { .. }) && now > job.kind.deadline() { + // A load-balanced RPC can expose a new head while serving contract state from an + // older one. Do not strand a publication that landed at the deadline on that stale + // read. Once a finalized block after the deadline still lacks it, no later block can + // accept it and the failure is conclusive. + if let Some(block) = self + .finalized_block_past(job.kind.deadline(), false) + .await? + { + if self.ethereum_publication_exists_at(&job, block).await? { + job.state = JobState::Submitted { + transaction_hash: "already-finalized".to_owned(), + }; + } else { + job.state = JobState::Failed { + message: + "the Ethereum publication deadline passed before the availability job completed" + .to_owned(), + }; + } + self.save(&job)?; + } + return Ok(()); + } + + if let JobKind::Input { + commitment_deadline, + .. + } = &job.kind + { + if let JobState::AwaitingCommitment { + attestation_expires_at, + .. + } = &job.state + { + if now >= *attestation_expires_at && !self.input_is_committed(&job).await? { + // The contract rejects the signature at this exact timestamp. Wait for a + // finalized block at or after it before releasing the promised ciphertext. + // This preserves a commitment that landed just before the expiry boundary. + if let Some(block) = self + .finalized_block_past(*attestation_expires_at, true) + .await? + { + if self.input_is_committed_at(&job, block).await? { + job.state = JobState::Committed { + transaction_hash: "wallet-committed".to_owned(), + }; + } else { + job.state = JobState::Failed { + message: + "the input availability promise expired before Ethereum accepted its commitment" + .to_owned(), + }; + } + self.save(&job)?; + } + return Ok(()); + } + } + + let waiting_for_commitment = matches!( + &job.state, + JobState::Created | JobState::AwaitingCommitment { .. } + ); + if waiting_for_commitment + && now >= *commitment_deadline + && !self.input_is_committed(&job).await? + { + // The cutoff itself is exclusive. A finalized block at or after it contains every + // commitment that could still have succeeded. Use that historical state rather + // than a possibly stale latest-state read. + if let Some(block) = self + .finalized_block_past(*commitment_deadline, true) + .await? + { + if self.input_is_committed_at(&job, block).await? { + job.state = JobState::Committed { + transaction_hash: "wallet-committed".to_owned(), + }; + } else { + job.state = JobState::Failed { + message: + "the input proof commitment deadline passed before Ethereum accepted it" + .to_owned(), + }; + } + self.save(&job)?; + } + return Ok(()); + } + } + + match job.state.clone() { + JobState::Created => { + match &job.kind { + JobKind::Input { .. } if self.input_is_committed(&job).await? => { + job.state = JobState::Committed { + transaction_hash: "already-committed".to_owned(), + }; + } + JobKind::Input { .. } if self.chain_id == 1 => { + let (ethereum_payload, attestation_expires_at) = + self.commitment_payload(&job).await?; + job.state = JobState::AwaitingCommitment { + ethereum_payload, + attestation_expires_at, + }; + } + JobKind::Input { .. } => { + let receipt = self.submit_input_commitment(&job).await?; + job.state = JobState::Committed { + transaction_hash: receipt.transaction_hash.to_string(), + }; + } + JobKind::Output { .. } => { + job.state = self.start_availability(&job, None).await?; + } + } + self.save(&job)?; + } + JobState::AwaitingCommitment { .. } => { + if self.input_is_committed(&job).await? { + job.state = JobState::Committed { + transaction_hash: "wallet-committed".to_owned(), + }; + self.save(&job)?; + } + } + JobState::Committed { transaction_hash } => { + if matches!(&job.kind, JobKind::Input { .. }) + && matches!(&*self.backend, Backend::Avail { .. }) + { + let (finalized_block, _) = self.finalized_block().await?; + if !self.input_is_committed_at(&job, finalized_block).await? { + return Ok(()); + } + } + job.state = self + .start_availability(&job, Some(transaction_hash)) + .await?; + self.save(&job)?; + } + JobState::AwaitingProof { + publication, + commitment_transaction_hash, + } => { + let Backend::Avail { publisher, .. } = &*self.backend else { + anyhow::bail!("mock job cannot await a VectorX proof"); + }; + if let ProofStatus::Ready { abi_proof, .. } = publisher.proof(&publication).await? { + job.state = JobState::Ready { + ethereum_payload: abi_proof, + commitment_transaction_hash, + }; + self.save(&job)?; + } + } + JobState::Ready { + ethereum_payload, .. + } => match &job.kind { + JobKind::Input { .. } => { + anyhow::ensure!( + self.input_is_committed(&job).await?, + "cannot finalize an input whose proof commitment is absent" + ); + let receipt = self.finalize_input(&job, ðereum_payload).await?; + job.state = JobState::Submitted { + transaction_hash: receipt.transaction_hash.to_string(), + }; + self.save(&job)?; + } + JobKind::Output { + e3_id, + ciphertext_commitment, + compute_proof, + .. + } => { + let contract = InterfoldContractFactory::create_write( + &self.http_rpc_url, + &self.interfold_address, + &self.private_key, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let e3_id = e3_id_to_u256(e3_id)?; + let stage = contract + .get_e3_stage(e3_id) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + match stage { + E3Stage::KeyPublished => {} + E3Stage::CiphertextReady | E3Stage::Complete => { + job.state = JobState::Submitted { + transaction_hash: "already-finalized".to_owned(), + }; + self.save(&job)?; + return Ok(()); + } + E3Stage::Failed => { + job.state = JobState::Failed { + message: + "the E3 failed before its aggregate ciphertext was published" + .to_owned(), + }; + self.save(&job)?; + return Ok(()); + } + E3Stage::None | E3Stage::Requested | E3Stage::CommitteeFinalized => { + anyhow::bail!("the E3 is not ready for its aggregate ciphertext"); + } + stage => { + anyhow::bail!( + "unsupported E3 stage {stage:?} while publishing an aggregate ciphertext" + ); + } + } + let receipt = contract + .publish_ciphertext_output( + e3_id, + B256::from(job.content_hash), + B256::from(*ciphertext_commitment), + Bytes::copy_from_slice(compute_proof), + Bytes::copy_from_slice(ðereum_payload), + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + job.state = JobState::Submitted { + transaction_hash: receipt.transaction_hash.to_string(), + }; + self.save(&job)?; + } + }, + JobState::Submitted { .. } | JobState::Failed { .. } => unreachable!(), + } + Ok(()) + } + + async fn start_availability( + &self, + job: &AvailabilityJob, + commitment_transaction_hash: Option, + ) -> anyhow::Result { + let object = self.object_required(job.content_hash)?; + match &*self.backend { + Backend::Mock => Ok(JobState::Ready { + ethereum_payload: object, + commitment_transaction_hash, + }), + Backend::Avail { publisher, .. } => { + let publication = publisher.publish(&object).await?; + anyhow::ensure!( + publication.content_hash == job.content_hash, + "Avail returned a different content hash" + ); + Ok(JobState::AwaitingProof { + publication, + commitment_transaction_hash, + }) + } + } + } + + async fn commitment_payload(&self, job: &AvailabilityJob) -> anyhow::Result<(Vec, u64)> { + let JobKind::Input { + e3_id, + staged_envelope, + .. + } = &job.kind + else { + anyhow::bail!("aggregate ciphertext jobs have no input commitment payload"); + }; + let envelope = decode_input_envelope(staged_envelope)?; + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let signer: PrivateKeySigner = self + .private_key + .parse() + .map_err(|error| anyhow::anyhow!("invalid availability signer key: {error}"))?; + let configured = contract + .input_availability_signer() + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + anyhow::ensure!( + configured == signer.address(), + "the CRISP inputAvailabilitySigner does not match this service key" + ); + let ttl = contract + .input_availability_attestation_ttl() + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + anyhow::ensure!(ttl > 0, "the input availability promise lifetime is zero"); + let attestation_expires_at = self + .chain_timestamp() + .await? + .checked_add(ttl) + .ok_or_else(|| anyhow::anyhow!("input availability promise expiry overflows u64"))?; + let digest = contract + .input_availability_digest( + e3_id_to_u256(e3_id)?, + envelope.encryptedVoteHash, + envelope.encryptedVoteCommitment, + envelope.slotAddress, + envelope.parentIndexPlusOne.to::(), + attestation_expires_at, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let attestation = signer + .sign_hash_sync(&digest) + .map_err(|error| anyhow::anyhow!("failed to attest input availability: {error}"))?; + let commitment_envelope = InputCommitmentEnvelope { + noirProof: envelope.noirProof, + slotAddress: envelope.slotAddress, + encryptedVoteCommitment: envelope.encryptedVoteCommitment, + encryptedVoteHash: envelope.encryptedVoteHash, + parentIndexPlusOne: envelope.parentIndexPlusOne, + availabilityAttestationExpiresAt: attestation_expires_at, + availabilityAttestation: Bytes::copy_from_slice(&attestation.as_bytes()), + }; + Ok(( + encode_input_commitment_envelope(&commitment_envelope), + attestation_expires_at, + )) + } + + async fn submit_input_commitment( + &self, + job: &AvailabilityJob, + ) -> anyhow::Result { + let JobKind::Input { e3_id, .. } = &job.kind else { + anyhow::bail!("aggregate ciphertext jobs cannot commit an input"); + }; + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let e3_id = e3_id_to_u256(e3_id)?; + let (payload, _) = self.commitment_payload(job).await?; + let payload = Bytes::from(payload); + contract + .simulate_publish_input(e3_id, payload.clone()) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + contract + .publish_input(e3_id, payload) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + async fn finalize_input( + &self, + job: &AvailabilityJob, + availability_proof: &[u8], + ) -> anyhow::Result { + let JobKind::Input { + e3_id, + staged_envelope, + .. + } = &job.kind + else { + anyhow::bail!("aggregate ciphertext jobs cannot finalize an input"); + }; + let envelope = decode_input_envelope(staged_envelope)?; + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let e3_id = e3_id_to_u256(e3_id)?; + let availability_proof = Bytes::copy_from_slice(availability_proof); + contract + .simulate_finalize_input( + e3_id, + envelope.slotAddress, + envelope.encryptedVoteCommitment, + envelope.encryptedVoteHash, + envelope.parentIndexPlusOne.to::(), + availability_proof.clone(), + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + contract + .finalize_input( + e3_id, + envelope.slotAddress, + envelope.encryptedVoteCommitment, + envelope.encryptedVoteHash, + envelope.parentIndexPlusOne.to::(), + availability_proof, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + fn job_id( + &self, + domain: &[u8], + e3_id: &str, + content_hash: B256, + request_identity: &[u8], + ) -> String { + let mut identity = Vec::with_capacity(domain.len() + e3_id.len() + 64); + identity.extend_from_slice(domain); + identity.extend_from_slice(e3_id.as_bytes()); + identity.extend_from_slice(content_hash.as_slice()); + identity.extend_from_slice(keccak256(request_identity).as_slice()); + format!("0x{}", hex::encode(keccak256(identity))) + } + + async fn chain_timestamp(&self) -> anyhow::Result { + let block = tokio::time::timeout(Duration::from_secs(15), async { + let provider = ProviderBuilder::new().connect(&self.http_rpc_url).await?; + provider.get_block_by_number(BlockNumberOrTag::Latest).await + }) + .await + .map_err(|_| anyhow::anyhow!("timed out while reading the Ethereum head"))?? + .ok_or_else(|| anyhow::anyhow!("the Ethereum RPC returned no latest block"))?; + Ok(block.header.timestamp) + } + + /// Return a finalized block that proves a deadline has passed. + /// + /// Commitment is rejected at its exact cutoff, so `inclusive` accepts a finalized block at + /// that timestamp. Input and output finalization are valid through their exact deadline, so + /// those decisions require a strictly later finalized block. + async fn finalized_block_past( + &self, + deadline: u64, + inclusive: bool, + ) -> anyhow::Result> { + let (block_number, block_timestamp) = self.finalized_block().await?; + let passed = if inclusive { + block_timestamp >= deadline + } else { + block_timestamp > deadline + }; + Ok(passed.then_some(block_number)) + } + + async fn finalized_block(&self) -> anyhow::Result<(u64, u64)> { + let block = tokio::time::timeout(Duration::from_secs(15), async { + let provider = ProviderBuilder::new().connect(&self.http_rpc_url).await?; + provider + .get_block_by_number(BlockNumberOrTag::Finalized) + .await + }) + .await + .map_err(|_| anyhow::anyhow!("timed out while reading the finalized Ethereum head"))?? + .ok_or_else(|| anyhow::anyhow!("the Ethereum RPC returned no finalized block"))?; + Ok((block.header.number, block.header.timestamp)) + } + + async fn input_is_committed_at( + &self, + job: &AvailabilityJob, + block_number: u64, + ) -> anyhow::Result { + let JobKind::Input { + e3_id, + staged_envelope, + .. + } = &job.kind + else { + return Ok(false); + }; + let envelope = decode_input_envelope(staged_envelope)?; + let provider = ProviderBuilder::new().connect(&self.http_rpc_url).await?; + let contract = ICrispAvailabilityState::new(self.e3_program_address.parse()?, provider); + Ok(contract + .isInputCommitted( + e3_id_to_u256(e3_id)?, + envelope.encryptedVoteHash, + envelope.encryptedVoteCommitment, + envelope.slotAddress, + envelope.parentIndexPlusOne, + ) + .block(BlockId::number(block_number)) + .call() + .await?) + } + + async fn ethereum_publication_exists_at( + &self, + job: &AvailabilityJob, + block_number: u64, + ) -> anyhow::Result { + let provider = ProviderBuilder::new().connect(&self.http_rpc_url).await?; + match &job.kind { + JobKind::Input { + e3_id, + staged_envelope, + .. + } => { + let envelope = decode_input_envelope(staged_envelope)?; + let contract = + ICrispAvailabilityState::new(self.e3_program_address.parse()?, provider); + Ok(contract + .isInputPublished( + e3_id_to_u256(e3_id)?, + envelope.encryptedVoteHash, + envelope.encryptedVoteCommitment, + envelope.slotAddress, + envelope.parentIndexPlusOne, + ) + .block(BlockId::number(block_number)) + .call() + .await?) + } + JobKind::Output { e3_id, .. } => { + let contract = + IInterfoldAvailabilityState::new(self.interfold_address.parse()?, provider); + let stage = contract + .getE3Stage(e3_id_to_u256(e3_id)?) + .block(BlockId::number(block_number)) + .call() + .await?; + Ok(matches!( + stage, + StoredE3Stage::CiphertextReady | StoredE3Stage::Complete + )) + } + } + } + + async fn input_is_published(&self, job: &AvailabilityJob) -> anyhow::Result { + let JobKind::Input { + e3_id, + staged_envelope, + .. + } = &job.kind + else { + return Ok(false); + }; + let envelope = decode_input_envelope(staged_envelope)?; + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + contract + .is_input_published( + e3_id_to_u256(e3_id)?, + envelope.encryptedVoteHash, + envelope.encryptedVoteCommitment, + envelope.slotAddress, + envelope.parentIndexPlusOne.to::(), + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + async fn input_is_committed(&self, job: &AvailabilityJob) -> anyhow::Result { + let JobKind::Input { + e3_id, + staged_envelope, + .. + } = &job.kind + else { + return Ok(false); + }; + let envelope = decode_input_envelope(staged_envelope)?; + let contract = CRISPContract::new( + &self.http_rpc_url, + &self.private_key, + &self.e3_program_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + contract + .is_input_committed( + e3_id_to_u256(e3_id)?, + envelope.encryptedVoteHash, + envelope.encryptedVoteCommitment, + envelope.slotAddress, + envelope.parentIndexPlusOne.to::(), + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + async fn ethereum_publication_exists(&self, job: &AvailabilityJob) -> anyhow::Result { + match &job.kind { + JobKind::Input { .. } => self.input_is_published(job).await, + JobKind::Output { e3_id, .. } => { + let contract = InterfoldContractFactory::create_read( + &self.http_rpc_url, + &self.interfold_address, + ) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let stage = contract + .get_e3_stage(e3_id_to_u256(e3_id)?) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + Ok(matches!( + stage, + E3Stage::CiphertextReady | E3Stage::Complete + )) + } + } + } + + fn pending_ids(&self) -> anyhow::Result> { + let mut ids = Vec::new(); + for entry in &self.jobs { + let (_, value) = entry?; + let job = Self::decode_job(&value)?; + let terminal = matches!( + &job.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ); + if !terminal { + ids.push(job.id); + } + } + Ok(ids) + } + + fn load(&self, id: &str) -> anyhow::Result> { + self.jobs + .get(id.as_bytes())? + .map(|bytes| Self::decode_job(&bytes)) + .transpose() + } + + fn load_required(&self, id: &str) -> anyhow::Result { + self.load(id)? + .ok_or_else(|| anyhow::anyhow!("data-availability job {id} does not exist")) + } + + fn save(&self, job: &AvailabilityJob) -> anyhow::Result<()> { + // Admission and terminal cleanup must use the same lock. Otherwise, cleanup can decide an + // object has no live users, a new job can adopt it, and cleanup can then delete bytes that + // the new job needs. + let _storage = self + .storage + .lock() + .map_err(|_| anyhow::anyhow!("data-availability storage lock is poisoned"))?; + let mut stored = job.clone(); + Self::validate_job_schema(&stored)?; + let terminal = matches!( + stored.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ); + if terminal { + match &mut stored.kind { + JobKind::Input { + staged_envelope, .. + } => staged_envelope.clear(), + JobKind::Output { compute_proof, .. } => compute_proof.clear(), + } + } + self.jobs + .insert(stored.id.as_bytes(), serde_json::to_vec(&stored)?)?; + self.jobs.flush()?; + + let release_object = matches!(stored.state, JobState::Failed { .. }) + || (matches!(stored.state, JobState::Submitted { .. }) + && matches!(&*self.backend, Backend::Avail { .. })); + if release_object && !self.nonterminal_job_uses(stored.content_hash, &stored.id)? { + self.objects.remove(stored.content_hash)?; + self.objects.flush()?; + } + Ok(()) + } + + fn nonterminal_job_uses( + &self, + content_hash: [u8; 32], + except_id: &str, + ) -> anyhow::Result { + for entry in &self.jobs { + let (_, value) = entry?; + let job = Self::decode_job(&value)?; + if job.id != except_id + && job.content_hash == content_hash + && !matches!( + job.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ) + { + return Ok(true); + } + } + Ok(false) + } + + fn validate_storage(&self) -> anyhow::Result<()> { + self.pending_ids()?; + self.pending_input_references()?; + for entry in &self.jobs { + let (_, value) = entry?; + let job = Self::decode_job(&value)?; + if !matches!( + job.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ) { + anyhow::ensure!( + self.objects.contains_key(job.content_hash)?, + "non-terminal data-availability job {} has no stored object", + job.id + ); + } + } + if matches!(&*self.backend, Backend::Avail { .. }) { + for entry in &self.jobs { + let (_, value) = entry?; + let job = Self::decode_job(&value)?; + if matches!( + job.state, + JobState::Submitted { .. } | JobState::Failed { .. } + ) && !self.nonterminal_job_uses(job.content_hash, &job.id)? + { + self.objects.remove(job.content_hash)?; + } + } + self.objects.flush()?; + } + Ok(()) + } + + fn validate_job_schema(job: &AvailabilityJob) -> anyhow::Result<()> { + anyhow::ensure!( + job.schema_version == AVAILABILITY_JOB_SCHEMA_VERSION, + "unsupported data-availability job schema version {}; expected {}", + job.schema_version, + AVAILABILITY_JOB_SCHEMA_VERSION + ); + Ok(()) + } + + fn decode_job(bytes: &[u8]) -> anyhow::Result { + let job: AvailabilityJob = serde_json::from_slice(bytes) + .map_err(|error| anyhow::anyhow!("cannot decode a data-availability job: {error}"))?; + Self::validate_job_schema(&job)?; + Ok(job) + } + + fn decode_input_reference(bytes: &[u8]) -> anyhow::Result { + let reference: AvailableInputReference = + serde_json::from_slice(bytes).map_err(|error| { + anyhow::anyhow!("cannot decode an available-input reference: {error}") + })?; + reference.validate_schema()?; + Ok(reference) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_service(max_pending_bytes: u64) -> AvailabilityService { + let db = sled::Config::new().temporary(true).open().unwrap(); + AvailabilityService { + jobs: db.open_tree("jobs").unwrap(), + objects: db.open_tree("objects").unwrap(), + input_retrievals: db.open_tree("retrievals").unwrap(), + backend: Arc::new(Backend::Mock), + in_progress: Arc::new(StorageMutex::new(HashSet::new())), + storage: Arc::new(StorageMutex::new(())), + job_slots: Arc::new(Semaphore::new(1)), + chain_id: 31_337, + http_rpc_url: String::new(), + private_key: String::new(), + interfold_address: String::new(), + e3_program_address: String::new(), + ciphernode_registry_address: String::new(), + input_duration_seconds: 0, + proof_lead_seconds: 0, + max_pending_bytes, + } + } + + #[test] + fn active_job_guard_releases_the_job_for_retry() { + let jobs = StorageMutex::new(HashSet::from(["job".to_owned()])); + let guard = ActiveJobGuard { + jobs: &jobs, + id: "job", + }; + + drop(guard); + + assert!(jobs.lock().unwrap().is_empty()); + } + + const SDK_INPUT_ENVELOPE: &str = concat!( + "00000000000000000000000000000000000000000000000000000000000000c0", + "0000000000000000000000001111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222", + "3333333333333333333333333333333333333333333333333333333333333333", + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000100", + "0000000000000000000000000000000000000000000000000000000000000003", + "0102030000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000002", + "aabb000000000000000000000000000000000000000000000000000000000000", + ); + + #[test] + fn sdk_input_envelope_uses_solidity_parameter_encoding() { + let encoded = hex::decode(SDK_INPUT_ENVELOPE).unwrap(); + let envelope = decode_input_envelope(&encoded).unwrap(); + + assert_eq!(envelope.noirProof.as_ref(), &[1, 2, 3]); + assert_eq!( + envelope.slotAddress, + "0x1111111111111111111111111111111111111111" + .parse::() + .unwrap() + ); + assert_eq!(envelope.encryptedVoteCommitment, B256::repeat_byte(0x22)); + assert_eq!(envelope.encryptedVoteHash, B256::repeat_byte(0x33)); + assert_eq!(envelope.parentIndexPlusOne.to::(), 7); + assert_eq!(envelope.availabilityProof.as_ref(), &[0xaa, 0xbb]); + + let commitment_envelope = InputCommitmentEnvelope { + noirProof: envelope.noirProof, + slotAddress: envelope.slotAddress, + encryptedVoteCommitment: envelope.encryptedVoteCommitment, + encryptedVoteHash: envelope.encryptedVoteHash, + parentIndexPlusOne: envelope.parentIndexPlusOne, + availabilityAttestationExpiresAt: 600, + availabilityAttestation: envelope.availabilityProof, + }; + let encoded_commitment = encode_input_commitment_envelope(&commitment_envelope); + let decoded_commitment = + InputCommitmentEnvelope::abi_decode_params_validate(&encoded_commitment).unwrap(); + assert_eq!(decoded_commitment.availabilityAttestationExpiresAt, 600); + assert_eq!( + decoded_commitment.availabilityAttestation.as_ref(), + &[0xaa, 0xbb] + ); + } + + #[test] + fn only_conclusive_input_errors_have_a_client_rejection_message() { + let malformed = reject_input("The encoded vote envelope is invalid"); + assert_eq!( + input_rejection_message(&malformed), + Some("The encoded vote envelope is invalid") + ); + + let reverted = anyhow::Error::new(SimulateError::Reverted("node detail".to_owned())); + assert_eq!( + input_rejection_message(&reverted), + Some("The vote proof or ciphertext was rejected") + ); + + let provider = anyhow::Error::new(SimulateError::Provider("secret RPC detail".to_owned())); + assert_eq!(input_rejection_message(&provider), None); + } + + #[test] + fn input_duration_adds_current_onchain_windows() { + assert_eq!( + minimum_input_duration(1_200, 300, 3_600, 3_600, 10_800).unwrap(), + 19_500 + ); + assert!(minimum_input_duration(u64::MAX, 1, 0, 0, 0).is_err()); + } + + #[test] + fn durable_records_reject_unknown_or_missing_schema_versions() { + let current_job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: "job".to_owned(), + content_hash: [0x11; 32], + kind: JobKind::Output { + e3_id: "e3".to_owned(), + ciphertext_commitment: [0x22; 32], + compute_proof: vec![4, 5, 6], + deadline: 7, + }, + state: JobState::Created, + }; + let encoded = serde_json::to_vec(¤t_job).unwrap(); + assert!(AvailabilityService::decode_job(&encoded).is_ok()); + + let mut unknown_job = current_job.clone(); + unknown_job.schema_version += 1; + let error = AvailabilityService::decode_job(&serde_json::to_vec(&unknown_job).unwrap()) + .unwrap_err(); + assert!(error + .to_string() + .contains("unsupported data-availability job schema version")); + + let mut missing_version = serde_json::to_value(¤t_job).unwrap(); + missing_version + .as_object_mut() + .unwrap() + .remove("schema_version"); + let error = AvailabilityService::decode_job(&serde_json::to_vec(&missing_version).unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("schema_version")); + + let reference = AvailableInputReference { + schema_version: AVAILABLE_INPUT_REFERENCE_SCHEMA_VERSION + 1, + e3_id: "e3".to_owned(), + content_hash: [0x33; 32], + availability_block: 1, + availability_leaf_index: 2, + index: 3, + commitment: [0x44; 32], + slot: [0x55; 20], + parent_index_plus_one: 4, + }; + let error = + AvailabilityService::decode_input_reference(&serde_json::to_vec(&reference).unwrap()) + .unwrap_err(); + assert!(error + .to_string() + .contains("unsupported available-input reference schema version")); + } + + #[test] + fn legacy_uncommitted_job_without_expiry_fails_closed() { + let job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: "legacy-input".to_owned(), + content_hash: [0x11; 32], + kind: JobKind::Input { + e3_id: "1".to_owned(), + staged_envelope: vec![0x22], + deadline: 1_000, + commitment_deadline: 900, + }, + state: JobState::AwaitingCommitment { + ethereum_payload: vec![0x33], + attestation_expires_at: 600, + }, + }; + let mut encoded = serde_json::to_value(&job).unwrap(); + encoded["state"] + .as_object_mut() + .unwrap() + .remove("attestation_expires_at"); + + let decoded = + AvailabilityService::decode_job(&serde_json::to_vec(&encoded).unwrap()).unwrap(); + let JobState::AwaitingCommitment { + attestation_expires_at, + .. + } = decoded.state + else { + panic!("expected an uncommitted input job"); + }; + assert_eq!(attestation_expires_at, 0); + } + + #[test] + fn pending_object_storage_is_bounded_and_content_addressed() { + let service = test_service(4); + let first = b"abc"; + let first_hash = keccak256(first).0; + service.store_object(first_hash, first).unwrap(); + service.store_object(first_hash, first).unwrap(); + + assert!(service.store_object([0; 32], b"x").is_err()); + assert!(service.store_object(keccak256(b"de").0, b"de").is_err()); + assert_eq!(service.object_required(first_hash).unwrap(), first); + } + + #[test] + fn new_job_and_object_are_admitted_together() { + let service = test_service(10); + let object = b"ciphertext"; + let content_hash = keccak256(object).0; + let job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: "new-output".to_owned(), + content_hash, + kind: JobKind::Output { + e3_id: "1".to_owned(), + ciphertext_commitment: [0x22; 32], + compute_proof: vec![0x33], + deadline: 7, + }, + state: JobState::Created, + }; + + service.store_new_job_with_object(&job, object).unwrap(); + assert_eq!(service.object_required(content_hash).unwrap(), object); + assert_eq!( + service.load_required(&job.id).unwrap().content_hash, + content_hash + ); + + let oversized = b"x"; + let oversized_job = AvailabilityJob { + id: "over-capacity".to_owned(), + content_hash: keccak256(oversized).0, + ..job + }; + assert!(service + .store_new_job_with_object(&oversized_job, oversized) + .is_err()); + assert!(service.load(&oversized_job.id).unwrap().is_none()); + assert!(service.object_required(oversized_job.content_hash).is_err()); + } + + #[test] + fn terminal_cleanup_keeps_an_object_used_by_another_job() { + let service = test_service(1024); + let object = b"shared-ciphertext"; + let content_hash = keccak256(object).0; + let mut first = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: "first-output".to_owned(), + content_hash, + kind: JobKind::Output { + e3_id: "1".to_owned(), + ciphertext_commitment: [0x11; 32], + compute_proof: vec![0x22], + deadline: 7, + }, + state: JobState::Created, + }; + let second = AvailabilityJob { + id: "second-output".to_owned(), + kind: JobKind::Output { + e3_id: "2".to_owned(), + ciphertext_commitment: [0x33; 32], + compute_proof: vec![0x44], + deadline: 8, + }, + ..first.clone() + }; + + service.store_new_job_with_object(&first, object).unwrap(); + service.store_new_job_with_object(&second, object).unwrap(); + first.state = JobState::Failed { + message: "deadline passed".to_owned(), + }; + service.save(&first).unwrap(); + + assert_eq!(service.object_required(content_hash).unwrap(), object); + assert!(service.validate_storage().is_ok()); + } + + #[test] + fn failed_job_releases_bytes_and_large_payloads() { + let service = test_service(1024); + let object = b"ciphertext"; + let content_hash = keccak256(object).0; + service.store_object(content_hash, object).unwrap(); + let mut job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: "failed-output".to_owned(), + content_hash, + kind: JobKind::Output { + e3_id: "1".to_owned(), + ciphertext_commitment: [0x22; 32], + compute_proof: vec![0x33; 128], + deadline: 7, + }, + state: JobState::Created, + }; + service.save(&job).unwrap(); + job.state = JobState::Failed { + message: "deadline passed".to_owned(), + }; + service.save(&job).unwrap(); + + assert!(service.object_required(content_hash).is_err()); + let stored = service.load_required(&job.id).unwrap(); + let JobKind::Output { compute_proof, .. } = stored.kind else { + panic!("expected an output job"); + }; + assert!(compute_proof.is_empty()); + } + + #[test] + fn failed_input_job_can_be_staged_again() { + let service = test_service(1024); + let object = b"ciphertext"; + let content_hash = keccak256(object).0; + let mut job = AvailabilityJob { + schema_version: AVAILABILITY_JOB_SCHEMA_VERSION, + id: "retry-input".to_owned(), + content_hash, + kind: JobKind::Input { + e3_id: "1".to_owned(), + staged_envelope: vec![0x11], + deadline: 1_000, + commitment_deadline: 900, + }, + state: JobState::Created, + }; + + service.store_new_job_with_object(&job, object).unwrap(); + job.state = JobState::Failed { + message: "availability promise expired".to_owned(), + }; + service.save(&job).unwrap(); + assert!(service.object_required(content_hash).is_err()); + + let replacement = AvailabilityJob { + kind: JobKind::Input { + e3_id: "1".to_owned(), + staged_envelope: vec![0x22], + deadline: 1_000, + commitment_deadline: 900, + }, + state: JobState::Created, + ..job + }; + service + .store_new_job_with_object(&replacement, object) + .unwrap(); + + assert_eq!(service.object_required(content_hash).unwrap(), object); + let stored = service.load_required(&replacement.id).unwrap(); + assert!(matches!(stored.state, JobState::Created)); + } +} diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 15e83911ee..b97228457b 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -10,6 +10,7 @@ use crate::server::token_holders::{ get_mock_token_holders, try_fetch_requester_census, EtherscanClient, }; use crate::server::{ + data_availability::{AvailabilityService, AvailableInputReference}, models::{CensusMode, CreditMode, CurrentRound, CustomParams, TokenHolder}, program_server_request::{run_compute, RoundInputs}, repo::{CrispE3Repository, CurrentRoundRepository, InputSnapshot}, @@ -24,27 +25,45 @@ use e3_fhe_params::decode_bfv_params_arc; use e3_sdk::indexer::INDEXER_CURSOR_KEY; use e3_sdk::{ evm_helpers::{ - contracts::{InterfoldRead, ReadWrite}, + contracts::{E3Stage, InterfoldContractFactory, InterfoldRead, ReadWrite}, events::{ - CiphertextOutputPublished, CommitteePublished, E3Requested, PlaintextOutputPublished, + CiphertextOutputPublished, CiphertextOutputReferencePublished, + CommitteePublicKeyChunkPublished, CommitteePublished, E3Requested, + PlaintextOutputPublished, }, retry::call_with_retry, }, - indexer::{DataStore, InterfoldIndexer, SharedStore}, + indexer::{DataStore, IndexerContext, InterfoldIndexer, SharedStore}, }; -use evm_helpers::{CRISPContractFactory, InputPublished}; +use evm_helpers::{CRISPContractFactory, InputCommitted, InputPublished}; use eyre::Context; use log::{error, info, warn}; use num_bigint::BigUint; -use std::error::Error; use std::time::Duration; +use std::{collections::HashMap, error::Error, sync::Arc}; use tokio::time::sleep; type Result = std::result::Result>; +fn is_configured_e3_program(event_program: Address, configured_program: Address) -> bool { + event_program == configured_program +} + +fn stage_ends_input_retrieval(stage: &E3Stage) -> bool { + matches!( + stage, + E3Stage::CiphertextReady | E3Stage::Complete | E3Stage::Failed + ) +} + pub async fn register_e3_requested( indexer: InterfoldIndexer, ) -> Result> { + let configured_program: Address = CONFIG + .e3_program_address + .parse() + .with_context(|| "Invalid configured E3 program address")?; + // E3Requested indexer .add_event_handler(move |event: E3Requested, ctx| { @@ -53,10 +72,17 @@ pub async fn register_e3_requested( let mut repo = CrispE3Repository::new(store.clone(), &e3_id); let contract = ctx.contract(); + async move { + if !is_configured_e3_program(event.e3.e3Program, configured_program) { + info!( + "[e3_id={}] Ignoring E3Requested for unrelated program {}", + e3_id, event.e3.e3Program + ); + return Ok(()); + } - info!("[e3_id={}] E3Requested: {:?}", e3_id, event); + info!("[e3_id={}] E3Requested: {:?}", e3_id, event); - async move { // 0xcd6f4a4f = E3DoesNotExist() let e3 = call_with_retry("get_e3", &["0xcd6f4a4f"], || { let contract = contract.clone(); @@ -126,6 +152,18 @@ pub async fn register_e3_requested( .with_context(|| "Invalid token address")?; let input_window = [e3.inputWindow[0].to::(), e3.inputWindow[1].to::()]; + let crisp = CRISPContractFactory::create_read( + &CONFIG.http_rpc_url, + &CONFIG.e3_program_address, + ) + .await + .with_context(|| "Failed to create CRISP contract reader")?; + let voting_end_time = crisp + .input_commitment_deadline(event.e3Id) + .await + .with_context(|| { + format!("[e3_id={e3_id}] Failed to read the input commitment deadline") + })?; // The census is built one tick before the request, as the request timepoint // itself is not final when the E3 is requested. @@ -329,6 +367,7 @@ pub async fn register_e3_requested( repo.initialize_round( custom_params, e3.requester.to_string(), + voting_end_time, input_window[1], snapshot_timepoint, ) @@ -378,7 +417,7 @@ pub async fn register_e3_requested( .with_context(|| format!("[e3_id={}] Invalid E3 ID", e3_id))?; info!( - "[e3_id={}] Calling setMerkleRoot with root: {}", + "[e3_id={}] Ensuring CRISPProgram Merkle root: {}", e3_id, merkle_root_u256 ); @@ -392,19 +431,54 @@ pub async fn register_e3_requested( format!("[e3_id={}] Failed to create CRISP contract", e3_id) })?; - let receipt = contract - .set_merkle_root(e3_id_u256, merkle_root_u256) - .await - .with_context(|| { - format!("[e3_id={}] Failed to call setMerkleRoot", e3_id) - })?; - - info!( - "[e3_id={}] setMerkleRoot successful. TxHash: {:?}", - e3_id, receipt.transaction_hash - ); + let stored_root = contract.get_merkle_root(e3_id_u256).await?; + if stored_root == merkle_root_u256 { + info!( + "[e3_id={}] Merkle root is already set to the expected value", + e3_id + ); + } else if stored_root.is_zero() { + match contract + .set_merkle_root(e3_id_u256, merkle_root_u256) + .await + { + Ok(receipt) => info!( + "[e3_id={}] setMerkleRoot successful. TxHash: {:?}", + e3_id, receipt.transaction_hash + ), + Err(error) => { + // A live subscription and its overlap replay can race here. Accept + // the losing transaction only when the desired root landed. + let root_after_error = + contract.get_merkle_root(e3_id_u256).await?; + if root_after_error != merkle_root_u256 { + return Err(error).with_context(|| { + format!( + "[e3_id={}] Failed to call setMerkleRoot", + e3_id + ) + }); + } + info!( + "[e3_id={}] Merkle root was set by a concurrent handler", + e3_id + ); + } + } + } else { + return Err(eyre::eyre!( + "[e3_id={}] CRISPProgram has a different Merkle root: expected {}, got {}", + e3_id, + merkle_root_u256, + stored_root + )); + } } + // Committee and request handlers run concurrently for live logs. If the key was + // indexed while census preparation was still running, this closes that race. + activate_round_if_ready(e3_id.clone(), ctx).await?; + Ok(()) } }) @@ -412,15 +486,15 @@ pub async fn register_e3_requested( Ok(indexer) } -/// What the indexer holds for a round, measured against what `CRISPProgram` accepted. +/// What the indexer holds for a round, measured against what `CRISPProgram` committed. enum IndexedInputs { /// The indexer holds every input, and this is the snapshot it holds them in. Complete(InputSnapshot), - /// It does not, and never did within the wait. - Short { indexed: usize, published: usize }, + /// Its count does not match the contract after the bounded wait. + Mismatch { indexed: usize, published: usize }, } -/// The round's inputs, once the indexer holds every one `CRISPProgram` accepted. +/// The round's inputs, once the indexer holds every one `CRISPProgram` committed. /// /// Polls rather than reading once: the deadline callback and the last `InputPublished` handler race, /// and the gap is the few seconds it takes one log to be delivered and stored. @@ -447,12 +521,15 @@ async fn wait_for_indexed_inputs( let snapshot = repo.get_input_snapshot().await?; let indexed = snapshot.ciphertexts.len(); - if indexed >= published { + // Equality is required. Fewer entries means that an accepted input is missing. More + // entries means that the local index contains data the contract did not accept. Either + // case would make the RISC Zero input root differ from the contract's root. + if indexed == published { return Ok(IndexedInputs::Complete(snapshot)); } if attempt == ATTEMPTS { - return Ok(IndexedInputs::Short { indexed, published }); + return Ok(IndexedInputs::Mismatch { indexed, published }); } info!( @@ -473,6 +550,166 @@ async fn wait_for_indexed_inputs( /// handler, so two passes do not overlap. const DEADLINE_RETRY_OFFSETS: [u64; 3] = [60, 180, 420]; +const ROUND_ACTIVATION_RETRY_OFFSETS: [u64; 5] = [1, 5, 30, 120, 600]; + +fn deadline_attempt_times(expiration: u64, now: u64) -> [u64; 4] { + let first = expiration.max(now); + [ + first, + first.saturating_add(DEADLINE_RETRY_OFFSETS[0]), + first.saturating_add(DEADLINE_RETRY_OFFSETS[1]), + first.saturating_add(DEADLINE_RETRY_OFFSETS[2]), + ] +} + +async fn handle_e3_input_deadline_expiration_logged( + e3_id: String, + store: SharedStore, +) -> eyre::Result<()> { + if let Err(error) = handle_e3_input_deadline_expiration(e3_id.clone(), store).await { + error!("[e3_id={}] CRISP deadline pass failed: {}", e3_id, error); + } + Ok(()) +} + +async fn activate_round_if_ready( + e3_id: String, + ctx: Arc>, +) -> eyre::Result { + let store = ctx.store(); + let mut repo = CrispE3Repository::new(store.clone(), &e3_id); + if !repo.has_crisp_record().await? || !repo.has_indexed_public_key().await? { + return Ok(false); + } + + let expiration = repo.get_input_deadline().await?; + if !repo.try_start_round().await? { + return Ok(true); + } + + let now = chrono::Utc::now().timestamp().max(0) as u64; + for at in deadline_attempt_times(expiration, now) { + let e3_id = e3_id.clone(); + ctx.do_later(at, move |_, ctx| { + handle_e3_input_deadline_expiration_logged(e3_id.clone(), ctx.store()) + }); + } + + let mut current_round_repo = CurrentRoundRepository::new(store); + current_round_repo + .set_current_round(CurrentRound { id: e3_id.clone() }) + .await?; + info!( + "[e3_id={}] Activated CRISP round and registered deadline callbacks", + e3_id + ); + Ok(true) +} + +fn schedule_round_activation_retries( + e3_id: &str, + ctx: &Arc>, +) { + let now = chrono::Utc::now().timestamp().max(0) as u64; + for offset in ROUND_ACTIVATION_RETRY_OFFSETS { + let e3_id = e3_id.to_string(); + ctx.do_later(now.saturating_add(offset), move |_, ctx| { + let e3_id = e3_id.clone(); + async move { + if let Err(error) = activate_round_if_ready(e3_id.clone(), ctx).await { + error!( + "[e3_id={}] Deferred CRISP round activation failed: {}", + e3_id, error + ); + } + Ok(()) + } + }); + } +} + +async fn restore_round_deadline_callback( + indexer: &InterfoldIndexer, + store: SharedStore, + e3_id: String, + now: u64, +) -> Result<()> { + let mut repo = CrispE3Repository::new(store.clone(), &e3_id); + let mut status = repo.get_status().await?; + let has_indexed_public_key = repo.has_indexed_public_key().await?; + if status == "Active" && !has_indexed_public_key { + repo.update_status("Requested").await?; + status = "Requested".to_string(); + warn!( + "[e3_id={}] Reset an active round to pending because no verified public key is indexed", + e3_id + ); + } + if status == "Requested" && has_indexed_public_key && repo.try_start_round().await? { + let mut current_round_repo = CurrentRoundRepository::new(store.clone()); + current_round_repo + .set_current_round(CurrentRound { id: e3_id.clone() }) + .await?; + status = "Active".to_string(); + info!( + "[e3_id={}] Activated a requested round whose verified key was indexed before restart", + e3_id + ); + } + if status == "Computing" || status == "PublishingCiphertext" { + // Submission is intentionally at-least-once across the CRISP and program-server process + // boundary. A crash can lose the HTTP response or webhook, so keeping this claim would + // strand the round. A retry can repeat proof work, but it cannot publish a second result: + // Interfold accepts ciphertext output only from KeyPublished, and the callback treats an + // already-published output as success. + repo.update_status("Expired").await?; + status = "Expired".to_string(); + warn!( + "[e3_id={}] Reset an interrupted compute submission so it can be retried", + e3_id + ); + } + if status != "Active" && status != "Expired" { + return Ok(()); + } + + let expiration = repo.get_input_deadline().await?; + for at in deadline_attempt_times(expiration, now) { + let e3_id = e3_id.clone(); + indexer.schedule_at(at, move |_, ctx| { + handle_e3_input_deadline_expiration_logged(e3_id.clone(), ctx.store()) + }); + } + info!( + "[e3_id={}] Restored deadline callbacks for CRISP round in status {}", + e3_id, status + ); + Ok(()) +} + +async fn restore_round_deadline_callbacks( + indexer: &InterfoldIndexer, +) -> Result<()> { + let store = indexer.get_store(); + let round_ids = CurrentRoundRepository::new(store.clone()) + .get_round_ids() + .await?; + let now = chrono::Utc::now().timestamp().max(0) as u64; + + for e3_id in round_ids { + if let Err(error) = + restore_round_deadline_callback(indexer, store.clone(), e3_id.clone(), now).await + { + error!( + "[e3_id={}] Could not restore CRISP deadline callbacks: {}", + e3_id, error + ); + } + } + + Ok(()) +} + /// Store key holding the `INDEX_LOG_CONTRACTS` set as of the previous run. /// /// Coverage records outlive the configuration that created them, and the store has no delete. This @@ -488,35 +725,49 @@ async fn handle_e3_input_deadline_expiration( let mut repo = CrispE3Repository::new(store.clone(), &e3_id); let e3: e3_sdk::indexer::models::E3 = repo.get_e3().await?; - // A cheap skip for a retry pass over a round that already moved on, so it does not sit through - // the indexer wait below. Not the safety barrier โ€” `try_claim_computing` is, further down. - let status = repo.get_status().await?; - if status == "Computing" || status == "Finished" { - return Ok(()); + let crisp = + CRISPContractFactory::create_read(&CONFIG.http_rpc_url, &CONFIG.e3_program_address).await?; + let pending = crisp + .pending_input_count(e3_id_to_u256(&e3_id).map_err(|error| eyre::eyre!(error.to_string()))?) + .await?; + if pending != 0 { + // The input root already includes these reserved leaves, but Ethereum has not verified + // their Avail receipts. Starting RISC Zero now would waste the proof: CRISPProgram.verify + // refuses every output until this reaches zero. InputPublished recovery wakes this handler + // again as each delayed VectorX proof lands. + return Err(eyre::eyre!( + "[e3_id={}] {} input(s) still await data-availability finalization; refusing to compute", + e3_id, + pending + )); } - repo.update_status("Expired").await?; - + // This transition is atomic. A delayed callback must not move a round from + // `PublishingCiphertext` or `CiphertextPublished` back to `Expired` and compute it twice. + if !repo.try_mark_expired().await? { + return Ok(()); + } let voter_count = repo.get_vote_count().await?; // The contract is the authority on how many inputs there are, and this callback can run before - // the last of them is indexed: `publishInput` still accepts one while - // `block.timestamp == inputWindow[1]`. Computation is one-shot, so starting short would tally a - // subset and derive a root the contract rejects โ€” a failure with no other symptom. + // the last committed input is indexed. Computation is one-shot, so starting short would tally + // a subset and derive a root the contract rejects โ€” a failure with no other symptom. // // The snapshot comes back from the same call, read once. Assembling the request from separate - // reads lets an `InputPublished` event land between them, which pairs a ciphertext with another + // reads lets an input event land between them, which pairs a ciphertext with another // input's commitment and derives a root `CRISPProgram` rejects. let snapshot = match wait_for_indexed_inputs(&e3_id, &repo).await? { IndexedInputs::Complete(snapshot) => snapshot, - IndexedInputs::Short { indexed, published } => { - // Left "Expired" and unfinished on purpose, so a later pass can still compute it. The - // retries registered at `DEADLINE_RETRY_OFFSETS` are what come back to it; marking the - // round "Finished" here would tally nothing and close it for good. + IndexedInputs::Mismatch { indexed, published } => { + // Leave the round "Expired" and unfinished so a later pass can still compute it. The + // retries registered at `DEADLINE_RETRY_OFFSETS` come back to it. Marking it finished + // here would either omit an accepted input or tally local data that is not in the + // contract's root. return Err(eyre::eyre!( - "[e3_id={}] the indexer holds {} input(s) but CRISPProgram accepted {}; \ - refusing to compute over a subset. A retry pass runs at +{}s from the input \ - deadline; if every pass reports this, the indexer is behind and needs attention.", + "[e3_id={}] the indexer holds {} input(s), but CRISPProgram accepted {}; \ + refusing to compute while the counts differ. A retry pass runs at +{}s from the \ + input deadline. If every pass reports this, the index is inconsistent and needs \ + attention.", e3_id, indexed, published, @@ -531,14 +782,12 @@ async fn handle_e3_input_deadline_expiration( let votes = snapshot.ciphertexts.clone(); if voter_count > 0 && votes.is_empty() { - warn!( - "[e3_id={}] {} voter(s) recorded but no InputPublished ciphertexts indexed โ€” \ - skipping FHE compute (check CRISP indexer + on-chain publishInput)", - e3_id, voter_count - ); - repo.update_status("Finished").await?; - info!("[e3_id={}] E3 request handled successfully.", e3_id); - return Ok(()); + return Err(eyre::eyre!( + "[e3_id={}] {} active voter slot(s) are recorded, but the input snapshot is empty; \ + refusing to finish an inconsistent round", + e3_id, + voter_count + )); } if !votes.is_empty() { @@ -548,9 +797,10 @@ async fn handle_e3_input_deadline_expiration( votes.len(), voter_count ); - // The barrier. Two passes can be inside the indexer wait at once, and `run_compute` is - // one-shot, so the transition to "Computing" has to be the thing that decides which one - // proceeds โ€” in a single store operation, not a read followed by a write. + // The local concurrency barrier. Two passes can be inside the indexer wait at once, so the + // transition to "Computing" has to decide which one proceeds. It must use one store + // operation, not a read followed by a write. Restart recovery remains at-least-once because + // the contract, not this process, is the durable idempotency boundary. // // Claimed here rather than before the wait: a pass that gives up on a short index leaves // the round "Expired" so a later pass can still take it, and claiming earlier would pin it @@ -563,45 +813,69 @@ async fn handle_e3_input_deadline_expiration( return Ok(()); } - let (id, status) = run_compute( - &e3_id, - e3.chain_id, - e3.interfold_address, - e3.encryption_scheme_id, - e3.committee_public_key_hash, - e3.e3_params, - RoundInputs { - ciphertexts: snapshot.ciphertexts, - commitments: snapshot.commitments, - slots: snapshot.slots, - parents: snapshot.parents, - }, - format!( - "{}/state/add-result", - CONFIG.interfold_server_url_for_clients() - ), - ) - .await - .map_err(|e| eyre::eyre!("Error sending run compute request: {e}"))?; + let submission = async { + let (id, status) = run_compute( + &e3_id, + e3.chain_id, + e3.interfold_address, + e3.encryption_scheme_id, + e3.committee_public_key_hash, + e3.e3_params, + RoundInputs { + ciphertexts: snapshot.ciphertexts, + commitments: snapshot.commitments, + slots: snapshot.slots, + parents: snapshot.parents, + }, + format!( + "{}/state/add-result", + CONFIG.interfold_server_url_for_clients() + ), + ) + .await + .map_err(|e| eyre::eyre!("Error sending run compute request: {e}"))?; + + if id != e3_id { + return Err(eyre::eyre!( + "Computation request returned unexpected E3 ID: expected {}, got {}", + e3_id, + id + )); + } - if id != e3_id { - return Err(eyre::eyre!( - "Computation request returned unexpected E3 ID: expected {}, got {}", - e3_id, - id - )); + if status != "processing" { + return Err(eyre::eyre!( + "Computation request failed with status: {}", + status + )); + } + + Ok::<(), eyre::Report>(()) } + .await; - if status != "processing" { - return Err(eyre::eyre!( - "Computation request failed with status: {}", - status - )); + if let Err(submission_error) = submission { + if let Err(release_error) = repo.release_compute_claim().await { + error!( + "[e3_id={}] Failed to release compute claim after submission error: {}", + e3_id, release_error + ); + } + return Err(submission_error.into()); } info!("[e3_id={}] Request Computation for E3", e3_id); - repo.update_status("PublishingCiphertext").await?; + if !repo.mark_compute_submitted().await? { + let status = repo + .get_status() + .await + .unwrap_or_else(|_| "unknown".to_owned()); + warn!( + "[e3_id={}] Compute response arrived after the round advanced to {}; leaving that state unchanged", + e3_id, status + ); + } } else { info!( "[e3_id={}] E3 has no votes to decrypt. Setting status to Finished.", @@ -633,6 +907,27 @@ pub async fn register_ciphertext_output_published( Ok(indexer) } +pub async fn register_ciphertext_output_reference_published( + indexer: InterfoldIndexer, +) -> Result> { + indexer + .add_event_handler(move |event: CiphertextOutputReferencePublished, ctx| { + let store = ctx.store(); + let e3_id = event.e3Id.to_string(); + let mut repo = CrispE3Repository::new(store, &e3_id); + async move { + info!( + "[e3_id={}] Handling CiphertextOutputReferencePublished", + e3_id + ); + repo.update_status("CiphertextPublished").await?; + Ok(()) + } + }) + .await; + Ok(indexer) +} + pub async fn register_plaintext_output_published( indexer: InterfoldIndexer, ) -> Result> { @@ -667,41 +962,18 @@ pub async fn register_plaintext_output_published( pub async fn register_committee_published( indexer: InterfoldIndexer, ) -> Result> { - // CommitteePublished indexer .add_event_handler(move |event: CommitteePublished, ctx| { async move { - let store = ctx.store(); let e3_id = event.e3Id.to_string(); - let mut repo = CrispE3Repository::new(store.clone(), &e3_id); - let mut current_round_repo = CurrentRoundRepository::new(store); info!("[e3_id={}] Handling CommitteePublished", e3_id); - // Get current time - let now = get_current_timestamp_rpc().await?; - info!("[e3_id={}] Current time: {}", event.e3Id, now); - - repo.start_round().await?; - - current_round_repo - .set_current_round(CurrentRound { id: e3_id.clone() }) - .await?; - let expiration = repo.get_input_deadline().await?; - - info!("[e3_id={}] Registering hook for {}", e3_id, expiration); - // Registered once per offset, up front. A pass that finds the indexer behind - // returns without computing, and `do_later` has already dropped that callback, so - // the round would otherwise stay "Expired" for good. Every pass after the first - // returns immediately once the round is computing or finished. - for at in std::iter::once(expiration).chain( - DEADLINE_RETRY_OFFSETS - .iter() - .map(|offset| expiration + offset), - ) { - let e3_id = e3_id.clone(); - ctx.do_later(at, move |_, ctx| { - handle_e3_input_deadline_expiration(e3_id.clone(), ctx.store()) - }); + if !activate_round_if_ready(e3_id.clone(), ctx.clone()).await? { + warn!( + "[e3_id={}] Committee event arrived, but the verified public key or CRISP request record is unavailable; round remains pending", + e3_id + ); + schedule_round_activation_retries(&e3_id, &ctx); } Ok(()) @@ -711,6 +983,27 @@ pub async fn register_committee_published( Ok(indexer) } +pub async fn register_committee_public_key_chunks( + indexer: InterfoldIndexer, +) -> Result> { + indexer + .add_event_handler( + move |event: CommitteePublicKeyChunkPublished, ctx| async move { + // Do not assume chunks arrive in index order. The normal writer sends them in + // order, but the contract deliberately permits a committee member to repair any + // missing chunk. Whichever event completes the generic indexer's assembly must be + // able to activate the round. + let e3_id = event.e3Id.to_string(); + if !activate_round_if_ready(e3_id.clone(), ctx.clone()).await? { + schedule_round_activation_retries(&e3_id, &ctx); + } + Ok(()) + }, + ) + .await; + Ok(indexer) +} + pub async fn get_current_timestamp_rpc() -> eyre::Result { let provider = ProviderBuilder::new().connect(&CONFIG.http_rpc_url).await?; let block = provider @@ -723,41 +1016,324 @@ pub async fn get_current_timestamp_rpc() -> eyre::Result { pub async fn register_input_published( indexer: InterfoldIndexer, + availability: Arc, ) -> Result> { indexer .add_event_handler(move |event: InputPublished, ctx| { + let availability = Arc::clone(&availability); let e3_id = event.e3Id.to_string(); let store = ctx.store(); - let mut repo = CrispE3Repository::new(store.clone(), &e3_id); async move { - println!( - "InputPublished: e3_id={}, index={}, data=0x{}...", - event.e3Id, - event.index, - hex::encode(&event.encryptedVote[..8.min(event.encryptedVote.len())]) - ); - - // Read here so the usability of these bytes is decided once, on the write path, - // instead of on every `state/previous-ciphertext` call. - let e3 = repo.get_e3().await?; - let params = decode_bfv_params_arc(&e3.e3_params)?; + let reference = AvailableInputReference::from_event(e3_id, &event); + availability + .record_input_reference(&reference) + .map_err(|error| eyre::eyre!(error.to_string()))?; + match store_available_input( + store.clone(), + Arc::clone(&availability), + reference.clone(), + ) + .await + { + Ok(()) => { + let e3_id = reference.e3_id.clone(); + tokio::spawn(resume_expired_round(e3_id, store)); + } + Err(error) => { + warn!( + "[e3_id={}] Input {} is committed but not retrievable yet: {}", + reference.e3_id, reference.index, error + ); + } + } + Ok(()) + } + }) + .await; + Ok(indexer) +} - repo.insert_ciphertext_input( - event.encryptedVote.to_vec(), +/// Index a committed ciphertext immediately when this availability service holds its bytes. +/// +/// VectorX finalization can take hours. Reserving the input index on Ethereum preserves CRISP's +/// parent chain, and this local copy lets a later vote or mask extend that entry during the wait. +/// Other indexers that do not hold the staged object simply learn it from `InputPublished` later. +pub async fn register_input_committed( + indexer: InterfoldIndexer, + availability: Arc, +) -> Result> { + indexer + .add_event_handler(move |event: InputCommitted, ctx| { + let availability = Arc::clone(&availability); + let store = ctx.store(); + async move { + let hash = format!("0x{}", hex::encode(event.encryptedVoteHash)); + let Some(ciphertext) = availability + .object(&hash) + .map_err(|error| eyre::eyre!(error.to_string()))? + else { + // This is normal for a secondary indexer. The verified InputPublished event + // supplies Avail coordinates later; advancing the cursor is safe because no + // unverified bytes are needed for final computation. + return Ok(()); + }; + e3_data_availability::verify_retrieved_bytes( + e3_data_availability::DataReference { + content_hash: event.encryptedVoteHash.0, + block_number: 0, + leaf_index: 0, + }, + ciphertext.clone(), + ) + .map_err(|error| eyre::eyre!(error.to_string()))?; + store_input_bytes( + store, + event.e3Id.to_string(), + ciphertext, event.index.to::(), - event.encryptedVoteCommitment.into(), + event.encryptedVoteCommitment.0, event.slotAddress.into(), event.parentIndexPlusOne.to::(), - ¶ms, ) - .await?; - Ok(()) + .await + .map_err(|error| eyre::eyre!(error.to_string()))?; + Ok::<(), eyre::Report>(()) } }) .await; Ok(indexer) } +async fn store_available_input( + store: SharedStore, + availability: Arc, + reference: AvailableInputReference, +) -> Result<()> { + let ciphertext = availability.retrieve(reference.data_reference()).await?; + store_input_bytes( + store, + reference.e3_id.clone(), + ciphertext, + reference.index, + reference.commitment, + reference.slot, + reference.parent_index_plus_one, + ) + .await?; + availability.complete_input_reference(&reference)?; + info!( + "[e3_id={}] Retrieved input {} from data availability", + reference.e3_id, reference.index + ); + Ok(()) +} + +async fn store_input_bytes( + store: SharedStore, + e3_id: String, + ciphertext: Vec, + index: u64, + commitment: [u8; 32], + slot: [u8; 20], + parent_index_plus_one: u64, +) -> Result<()> { + let mut repo = CrispE3Repository::new(store, &e3_id); + let e3 = repo.get_e3().await?; + let params = decode_bfv_params_arc(&e3.e3_params)?; + repo.insert_ciphertext_input( + ciphertext, + index, + commitment, + slot, + parent_index_plus_one, + ¶ms, + ) + .await?; + Ok(()) +} + +async fn resume_expired_round(e3_id: String, store: SharedStore) { + let repo = CrispE3Repository::new(store.clone(), &e3_id); + let Ok(e3) = repo.get_e3().await else { + return; + }; + let Ok(now) = get_current_timestamp_rpc().await else { + return; + }; + if now >= e3.input_window[1] { + if let Err(error) = handle_e3_input_deadline_expiration(e3_id.clone(), store).await { + warn!( + "[e3_id={}] Could not resume computation after retrieving an input: {}", + e3_id, error + ); + } + } +} + +async fn recover_round_deadlines(store: SharedStore) { + let ids = match CurrentRoundRepository::new(store.clone()) + .get_round_ids() + .await + { + Ok(ids) => ids, + Err(error) => { + warn!("Could not recover CRISP round deadlines: {error}"); + return; + } + }; + + let mut pending = Vec::new(); + for e3_id in ids { + let repo = CrispE3Repository::new(store.clone(), &e3_id); + let Ok(status) = repo.get_status().await else { + continue; + }; + if status != "Requested" && status != "Active" && status != "Expired" { + continue; + } + let Ok(e3) = repo.get_e3().await else { + continue; + }; + pending.push((e3_id, e3.input_window[1])); + } + if pending.is_empty() { + return; + } + + // This is a fallback for restored block callbacks, not one perpetual task per historical + // round. One provider and one bounded loop cover all unfinished rounds, then the task exits. + let provider = match ProviderBuilder::new().connect(&CONFIG.http_rpc_url).await { + Ok(provider) => provider, + Err(error) => { + warn!("Could not connect the CRISP deadline recovery watchdog: {error}"); + return; + } + }; + while !pending.is_empty() { + let head = tokio::time::timeout( + Duration::from_secs(15), + provider.get_block_by_number(alloy::eips::BlockNumberOrTag::Latest), + ) + .await; + let now = match head { + Ok(Ok(Some(block))) => block.header.timestamp, + Ok(Ok(None)) => { + sleep(Duration::from_secs(30)).await; + continue; + } + Ok(Err(error)) => { + warn!("CRISP deadline recovery could not read the latest block: {error}"); + sleep(Duration::from_secs(30)).await; + continue; + } + Err(_) => { + warn!("CRISP deadline recovery timed out while reading the latest block"); + sleep(Duration::from_secs(30)).await; + continue; + } + }; + + let mut index = 0; + while index < pending.len() { + if now < pending[index].1 { + index += 1; + continue; + } + let (e3_id, _) = pending.swap_remove(index); + if let Err(error) = + handle_e3_input_deadline_expiration(e3_id.clone(), store.clone()).await + { + warn!( + "[e3_id={}] Recovered deadline handler could not start computation: {}", + e3_id, error + ); + } + } + if !pending.is_empty() { + sleep(Duration::from_secs(30)).await; + } + } +} + +async fn recover_available_inputs( + store: SharedStore, + availability: Arc, +) { + let interfold = match InterfoldContractFactory::create_read( + &CONFIG.http_rpc_url, + &CONFIG.interfold_address, + ) + .await + { + Ok(interfold) => interfold, + Err(error) => { + warn!("Could not start the available-input recovery reader: {error}"); + return; + } + }; + loop { + let mut terminal_e3s = HashMap::::new(); + let references = match availability.pending_input_references() { + Ok(references) => references, + Err(error) => { + warn!("Could not scan durable available-input references: {error}"); + sleep(Duration::from_secs(30)).await; + continue; + } + }; + for reference in references { + let round = CrispE3Repository::new(store.clone(), &reference.e3_id); + let status = round.get_status().await.ok(); + let locally_terminal = + matches!(status.as_deref(), Some("CiphertextPublished" | "Finished")); + let chain_terminal = if locally_terminal { + false + } else if let Some(terminal) = terminal_e3s.get(&reference.e3_id) { + *terminal + } else { + let terminal = match e3_id_to_u256(&reference.e3_id) { + Ok(e3_id) => { + tokio::time::timeout(Duration::from_secs(15), interfold.get_e3_stage(e3_id)) + .await + .is_ok_and(|result| { + result.as_ref().is_ok_and(stage_ends_input_retrieval) + }) + } + Err(_) => false, + }; + terminal_e3s.insert(reference.e3_id.clone(), terminal); + terminal + }; + if locally_terminal || chain_terminal { + if let Err(error) = availability.complete_input_reference(&reference) { + warn!( + "[e3_id={}] Could not remove an obsolete input reference: {}", + reference.e3_id, error + ); + } + continue; + } + match store_available_input(store.clone(), Arc::clone(&availability), reference.clone()) + .await + { + Ok(()) => { + // The scheduled deadline retries are finite. If Avail or its RPC was down + // through all of them, successful background retrieval must wake computation + // instead of leaving an otherwise complete round stranded forever. + tokio::spawn(resume_expired_round(reference.e3_id.clone(), store.clone())); + } + Err(error) => { + warn!( + "[e3_id={}] Input {} retrieval will retry: {}", + reference.e3_id, reference.index, error + ); + } + } + } + sleep(Duration::from_secs(30)).await; + } +} + /// Persist every log from a watched contract, so `/chain/logs` can answer from the store. /// /// Untyped on purpose โ€” see `log_repo`. A failed write IS propagated: the catch-up uses a handler @@ -829,6 +1405,7 @@ pub async fn start_indexer( registry_address: &str, crisp_address: &str, store: SharedStore, + availability: Arc, private_key: &str, index_start_block: Option, index_chunk_size: Option, @@ -854,16 +1431,25 @@ pub async fn start_indexer( } } + let recovery_store = store.clone(); + tokio::spawn(recover_available_inputs( + recovery_store, + Arc::clone(&availability), + )); let crisp_indexer = InterfoldIndexer::new_with_write_contract(url, &watched, store, private_key).await?; info!("CRISP: Indexer registering handlers..."); let crisp_indexer = register_e3_requested(crisp_indexer).await?; let crisp_indexer = register_ciphertext_output_published(crisp_indexer).await?; + let crisp_indexer = register_ciphertext_output_reference_published(crisp_indexer).await?; let crisp_indexer = register_plaintext_output_published(crisp_indexer).await?; let crisp_indexer = register_committee_published(crisp_indexer).await?; - let crisp_indexer = register_input_published(crisp_indexer).await?; + let crisp_indexer = register_committee_public_key_chunks(crisp_indexer).await?; + let crisp_indexer = register_input_committed(crisp_indexer, Arc::clone(&availability)).await?; + let crisp_indexer = register_input_published(crisp_indexer, availability).await?; let crisp_indexer = register_log_index(crisp_indexer, index_log_contracts).await?; + tokio::spawn(recover_round_deadlines(crisp_indexer.get_store())); info!("CRISP: Indexer finished registering handlers!"); // Resolve where indexing will ACTUALLY begin, ONCE, and drive both the backfill configuration @@ -989,6 +1575,7 @@ pub async fn start_indexer( } } + restore_round_deadline_callbacks(&crisp_indexer).await?; crisp_indexer.listen().await?; info!("CRISP: Indexer listen loop has finished!"); Ok(()) @@ -996,6 +1583,9 @@ pub async fn start_indexer( #[cfg(test)] mod custom_params_decoding_tests { + use super::{ + deadline_attempt_times, is_configured_e3_program, stage_ends_input_retrieval, E3Stage, + }; use crate::server::models::CensusMode; use alloy::dyn_abi::SolType; use alloy::primitives::{Address, U256}; @@ -1081,4 +1671,29 @@ mod custom_params_decoding_tests { let decoded = ::abi_decode(&encode(3)).unwrap(); assert!(CensusMode::try_from(decoded.5.to::()).is_err()); } + + #[test] + fn e3_requests_only_match_the_configured_program() { + let configured = Address::repeat_byte(0x11); + + assert!(is_configured_e3_program(configured, configured)); + assert!(!is_configured_e3_program( + Address::repeat_byte(0x22), + configured + )); + } + + #[test] + fn restart_spreads_overdue_deadline_attempts_from_now() { + assert_eq!(deadline_attempt_times(100, 200), [200, 260, 380, 620]); + assert_eq!(deadline_attempt_times(300, 200), [300, 360, 480, 720]); + } + + #[test] + fn terminal_chain_stages_release_input_retrievals() { + assert!(stage_ends_input_retrieval(&E3Stage::CiphertextReady)); + assert!(stage_ends_input_retrieval(&E3Stage::Complete)); + assert!(stage_ends_input_retrieval(&E3Stage::Failed)); + assert!(!stage_ends_input_retrieval(&E3Stage::KeyPublished)); + } } diff --git a/examples/CRISP/server/src/server/mod.rs b/examples/CRISP/server/src/server/mod.rs index c7f085a5bc..258642281e 100644 --- a/examples/CRISP/server/src/server/mod.rs +++ b/examples/CRISP/server/src/server/mod.rs @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. mod app_data; +mod data_availability; mod database; mod indexer; mod log_repo; @@ -21,6 +22,7 @@ use std::sync::Arc; use actix_cors::Cors; use actix_web::{middleware::Logger, web, App, HttpServer}; use app_data::AppData; +use data_availability::AvailabilityService; use database::SledDB; use e3_sdk::indexer::SharedStore; use eyre::OptionExt; @@ -36,7 +38,16 @@ pub async fn start() -> Result<(), Box> { let pathdb = std::env::current_dir()?.join("database/server"); let pathdb = pathdb.to_str().ok_or_eyre("Path could not be determined")?; - let db = SharedStore::new(Arc::new(RwLock::new(SledDB::new(pathdb)?))); + let sled_db = SledDB::new(pathdb)?; + let availability = Arc::new(AvailabilityService::new(&sled_db.db, &CONFIG)?); + availability.validate_onchain_configuration().await?; + let availability_worker = Arc::clone(&availability); + tokio::spawn(async move { + if let Err(error) = availability_worker.run().await { + eprintln!("Data-availability worker stopped: {error}"); + } + }); + let db = SharedStore::new(Arc::new(RwLock::new(sled_db))); // New indexer // Parsed once here rather than per request: the same list bounds what the indexer watches and @@ -61,6 +72,7 @@ pub async fn start() -> Result<(), Box> { tokio::spawn({ let db = db.clone(); + let availability = availability.clone(); let index_contracts = index_contracts.clone(); let index_log_contracts = index_log_contracts.clone(); async move { @@ -70,6 +82,7 @@ pub async fn start() -> Result<(), Box> { &CONFIG.ciphernode_registry_address, &CONFIG.e3_program_address, db.clone(), + availability, &CONFIG.private_key, CONFIG.index_start_block, CONFIG.index_chunk_size, @@ -85,6 +98,7 @@ pub async fn start() -> Result<(), Box> { let bind_addr = "0.0.0.0:4000"; let db_clone = db.clone(); + let availability_clone = availability.clone(); // Built once, outside the factory closure: the closure runs per worker, and a per-worker // limiter would multiply every window by the worker count. let rate_limiter = web::Data::new(rate_limit::RateLimiter::new()); @@ -105,6 +119,7 @@ pub async fn start() -> Result<(), Box> { .wrap(cors) .wrap(Logger::new(r#"%a "%r" %s %b %T"#)) .app_data(web::Data::new(AppData::new(db_clone.clone()))) + .app_data(web::Data::from(availability_clone.clone())) .app_data(rate_limiter.clone()) .app_data(chain_rate_limiter.clone()) .configure(routes::setup_routes) diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index d5e97d2324..69ff185702 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -58,6 +58,9 @@ pub struct JsonResponse { #[serde(rename_all = "snake_case")] pub enum VoteResponseStatus { Success, + PendingCommitment, + PendingAvailability, + ReadyForCommitment, FailedBroadcast, } @@ -65,6 +68,8 @@ pub enum VoteResponseStatus { pub struct VoteResponse { pub status: VoteResponseStatus, pub tx_hash: Option, + pub job_id: Option, + pub encoded_proof: Option, pub message: Option, } @@ -172,11 +177,10 @@ pub struct RoundRequest { pub cron_api_key: String, pub token_address: String, pub balance_threshold: String, - /// The census source for the round, as a `CRISPProgram.CensusMode` discriminant. Optional and - /// defaulted to the token census this route always requested, so existing cron configurations - /// keep their behavior. Pass 2 (ONCHAIN) with a registry or votes-token address to request a - /// round whose eligibility is read from the token per input โ€” for `SelfRegistry`, that is what - /// lets voters register during the input window. + /// The census source for the round, as a `CRISPProgram.CensusMode` discriminant. Optional: + /// normal token rounds default to 0 (TOKEN), and a known `SelfRegistry` defaults to + /// 2 (ONCHAIN). A `SelfRegistry` cannot use 0, because token-holder discovery would find no + /// voters and the server would have no CRISP record to show clients. #[serde(default)] pub census_mode: Option, } @@ -261,10 +265,14 @@ pub struct E3 { pub requester: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct E3Crisp { pub emojis: [String; 2], pub start_time: u64, + /// Last timestamp at which a voter can commit a new proof. The protocol input window ends + /// later so the availability service can finalize already committed ciphertexts. + #[serde(default)] + pub voting_end_time: u64, pub end_time: u64, pub status: String, pub tally: Vec, diff --git a/examples/CRISP/server/src/server/rate_limit.rs b/examples/CRISP/server/src/server/rate_limit.rs index 75732143b8..d9d499db80 100644 --- a/examples/CRISP/server/src/server/rate_limit.rs +++ b/examples/CRISP/server/src/server/rate_limit.rs @@ -12,11 +12,9 @@ //! //! - **Caller admission** ([`RateLimiter::check_caller`]) runs first, before any work, against //! one hot client. -//! - **Global reservation** ([`RateLimiter::try_reserve_global`]) caps what the relay key can -//! spend across all callers, and is taken only once a request has parsed and simulated -//! successfully โ€” right before the transaction. Consuming it earlier would let an attacker -//! spray *invalid* requests across many addresses and exhaust the global window without the -//! relay ever paying for anything, denying honest voters instead of protecting them. +//! - **Global reservation** ([`RateLimiter::try_reserve_global`]) caps durable work that can spend +//! relay funds across all callers. The route reserves before admission and returns the slot when +//! validation or infrastructure fails, so rejected traffic does not exhaust the shared window. //! //! The limits are deliberately generous for people and tight for loops: one honest ballot costs //! minutes of client-side proving, so a human cannot reach them. @@ -158,12 +156,21 @@ impl RateLimiter { /// Reserve one slot of the global transaction quota. /// - /// Call only when a transaction is about to be sent โ€” after parsing and simulation โ€” so - /// invalid traffic cannot drain the quota honest voters depend on. + /// Return this reservation if the request fails before durable work is admitted. pub fn try_reserve_global(&self) -> Result<(), RateLimitExceeded> { self.try_reserve_global_at(Instant::now()) } + /// Return a reservation when request validation or infrastructure fails before durable work + /// is admitted. Durable jobs keep their reservation because they can still spend relay funds. + pub fn release_global_reservation(&self) { + self.state + .lock() + .expect("rate limiter mutex poisoned") + .global + .pop(); + } + fn check_caller_at( &self, caller: &str, @@ -276,6 +283,14 @@ mod tests { assert_eq!(limiter.try_reserve_global_at(now), Ok(())); } + #[test] + fn failed_admission_can_return_its_global_reservation() { + let limiter = RateLimiter::with_limits(1, 1); + assert_eq!(limiter.try_reserve_global(), Ok(())); + limiter.release_global_reservation(); + assert_eq!(limiter.try_reserve_global(), Ok(())); + } + #[test] fn a_batch_is_charged_per_call_not_per_request() { let limiter = RateLimiter::with_limits(100, usize::MAX); diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index 8d7eaf0a0f..63e50ca09e 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -91,25 +91,8 @@ impl CurrentRoundRepository { for round_id in self.get_round_ids().await?.into_iter().rev() { let crisp_repo = CrispE3Repository::new(self.store.clone(), &round_id); - match crisp_repo.get_e3_state_lite().await { - Ok(state) => { - if state.requester == requester { - return Ok(Some(CurrentRound { id: round_id })); - } - } - Err(e) => { - // Expected for a round between E3Requested and CommitteePublished: the CRISP - // record and the round index are written at request time, but the `_e3:` record - // only exists once the committee publishes its key, so a freshly requested - // round is half-indexed for the duration of the DKG. Persistent repeats for - // the same round mean the key never arrived โ€” no registered ciphernodes, or a - // CommitteePublished the indexer rejected (see its log for the reason). - info!( - "Round {} is not fully indexed yet (usually: committee key pending) โ€” skipping: {:?}", - round_id, e - ); - continue; - } + if crisp_repo.is_requested_by(&requester).await? { + return Ok(Some(CurrentRound { id: round_id })); } } @@ -154,6 +137,7 @@ impl CrispE3Repository { } } + #[cfg(test)] async fn set_crisp(&mut self, value: E3Crisp) -> Result<()> { let key = self.crisp_key(); self.store @@ -179,14 +163,27 @@ impl CrispE3Repository { /// Whether this server has a record of the round at all. /// /// The CRISP record is written when `E3Requested` is indexed; the indexer's `_e3:` record only - /// lands on `CommitteePublished`. So a round mid-DKG โ€” or one whose committee never formed โ€” - /// has the first and not the second, and reads needing both come back empty. Without this the - /// two are indistinguishable, and "the committee has not published a key" reads as "no such - /// round", which is a very different thing to debug. + /// lands after a public-key byte event passes commitment verification. A round can + /// therefore have the first record and not the second during DKG or after on-chain + /// `KeyPublished`. Without this check, "verified key bytes are pending" reads as "no such + /// round". pub async fn has_crisp_record(&self) -> Result { Ok(self.try_get_crisp().await?.is_some()) } + /// Whether the request-time CRISP record belongs to `requester`. + pub async fn is_requested_by(&self, requester: &str) -> Result { + Ok(self + .try_get_crisp() + .await? + .is_some_and(|round| round.requester.eq_ignore_ascii_case(requester))) + } + + /// Whether the generic indexer stored a verified committee public key for this round. + pub async fn has_indexed_public_key(&self) -> Result { + Ok(self.try_get_e3().await?.is_some()) + } + async fn get_crisp(&self) -> Result { let key = self.crisp_key(); let e3_crisp = self @@ -196,11 +193,25 @@ impl CrispE3Repository { Ok(e3_crisp) } - pub async fn start_round(&mut self) -> Result<()> { - let mut e3_crisp = self.get_crisp().await?; - e3_crisp.start_time = chrono::Utc::now().timestamp() as u64; - e3_crisp.status = "Active".to_string(); - self.set_crisp(e3_crisp).await + /// Start a requested round once. Duplicate committee events do not reset its deadline state. + pub async fn try_start_round(&mut self) -> Result { + let key = self.crisp_key(); + let mut started = false; + let now = chrono::Utc::now().timestamp() as u64; + self.store + .modify(&key, |current: Option| { + current.map(|mut round| { + if round.status == "Requested" { + round.start_time = now; + round.status = "Active".to_owned(); + started = true; + } + round + }) + }) + .await + .map_err(|error| eyre::eyre!("Could not start CRISP round at '{key}': {error}"))?; + Ok(started) } pub async fn insert_ciphertext_input( @@ -272,15 +283,18 @@ impl CrispE3Repository { &mut self, custom_params: CustomParams, requester: String, + voting_end_time: u64, end_time: u64, snapshot_block: u64, ) -> Result<()> { - self.set_crisp(E3Crisp { + let key = self.crisp_key(); + let initial = E3Crisp { input_commitments: Vec::new(), input_slots: Vec::new(), input_parents: Vec::new(), input_usable: Vec::new(), start_time: 0u64, + voting_end_time, status: "Requested".to_string(), tally: vec![], emojis: generate_emoji(), @@ -296,8 +310,15 @@ impl CrispE3Repository { census_mode: custom_params.census_mode, end_time, snapshot_block, - }) - .await + }; + + self.store + .modify(&key, move |current: Option| { + current.or_else(|| Some(initial.clone())) + }) + .await + .map_err(|_| eyre::eyre!("Could not initialize round at '{key}'"))?; + Ok(()) } fn get_e3_repo(&self) -> E3Repository { @@ -327,7 +348,7 @@ impl CrispE3Repository { Ok(e3_crisp.num_options.parse::()?) } - /// How many slots hold at least one published entry. + /// How many slots hold at least one available, locally indexed entry. /// /// The closest thing to a participation count the server can give. A mask is /// indistinguishable from a vote by design, so per-slot activity โ€” not "who voted" โ€” is what @@ -346,12 +367,36 @@ impl CrispE3Repository { Ok(e3_crisp.status) } + /// Marks a round expired only while it is still waiting for computation. + /// + /// Deadline callbacks can overlap. A blind status write can move a round from + /// `PublishingCiphertext` back to `Expired`, which permits a second compute request. + pub async fn try_mark_expired(&mut self) -> Result { + let key = self.crisp_key(); + let mut marked = false; + self.store + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + if e.status == "Active" || e.status == "Expired" { + e.status = "Expired".to_owned(); + marked = true; + } + e + }) + }) + .await + .map_err(|_| eyre::eyre!("Could not expire round at '{key}'"))?; + Ok(marked) + } + /// Moves the round to "Computing", but only if nothing has claimed it yet. /// /// Returns whether this caller made the transition. One store operation, because `modify` is a /// read-modify-write under a single write lock: reading the status and writing it back as two /// separate awaits leaves a window where two deadline passes both observe "Expired" and both - /// start the one-shot `run_compute`, publishing two results for one round. + /// submit `run_compute` concurrently. Restart recovery can submit again because the remote + /// response might have been lost; Interfold is the durable idempotency boundary and accepts + /// only the first valid ciphertext output. pub async fn try_claim_computing(&mut self) -> Result { let key = self.crisp_key(); let mut claimed = false; @@ -359,7 +404,7 @@ impl CrispE3Repository { self.store .modify(&key, |e3_obj: Option| { e3_obj.map(|mut e| { - if e.status != "Computing" && e.status != "Finished" { + if e.status == "Expired" { e.status = "Computing".to_string(); claimed = true; } @@ -372,6 +417,52 @@ impl CrispE3Repository { Ok(claimed) } + /// Record that the program server accepted the claimed computation. + pub async fn mark_compute_submitted(&mut self) -> Result { + let key = self.crisp_key(); + let mut submitted = false; + + self.store + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + if e.status == "Computing" { + e.status = "PublishingCiphertext".to_string(); + submitted = true; + } + e + }) + }) + .await + .map_err(|_| eyre::eyre!("Could not record compute submission at '{key}'"))?; + + Ok(submitted) + } + + /// Release a compute claim when the program server refused the request. + /// + /// Only `Computing` can move back. A callback or output event may already have advanced the + /// round while the request handler was returning an error. Reverting a later state would start + /// a second computation for an output already in progress. + pub async fn release_compute_claim(&mut self) -> Result { + let key = self.crisp_key(); + let mut released = false; + + self.store + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + if e.status == "Computing" { + e.status = "Expired".to_owned(); + released = true; + } + e + }) + }) + .await + .map_err(|_| eyre::eyre!("Could not release computation for '{key}'"))?; + + Ok(released) + } + pub async fn update_status(&mut self, value: &str) -> Result<()> { let key = self.crisp_key(); @@ -421,12 +512,6 @@ impl CrispE3Repository { Ok(e3.committee_public_key) } - pub async fn get_web_result_request(&self) -> Result { - self.try_get_web_result_request() - .await? - .ok_or_else(|| eyre::eyre!("No state stored for round {}", self.e3_id)) - } - /// The round's result, or `None` when the round is not in the store. See /// [`Self::try_get_e3_state_lite`] for why both records have to be present. pub async fn try_get_web_result_request(&self) -> Result> { @@ -463,13 +548,18 @@ impl CrispE3Repository { return Ok(None); }; let snapshot_block = snapshot_block(e3.request_block, e3_crisp.snapshot_block); + let voting_end_time = if e3_crisp.voting_end_time == 0 { + e3.input_window[1] + } else { + e3_crisp.voting_end_time + }; Ok(Some(E3StateLite { emojis: e3_crisp.emojis, id: self.e3_id.clone(), status: e3_crisp.status, chain_id: e3.chain_id, start_time: e3.input_window[0], - end_time: e3.input_window[1], + end_time: voting_end_time, vote_count: count_active_slots(&e3_crisp.input_slots), start_block: e3.request_block, snapshot_block, @@ -600,7 +690,7 @@ impl CrispE3Repository { Ok(()) } - /// Whether the slot holds any published entry, from the indexed `InputPublished` events. + /// Whether the slot holds any committed entry that this server has the bytes for. /// /// Deliberately not "has this address voted" โ€” the server cannot know that. Anyone can mask /// any eligible slot, and a mask is indistinguishable from a vote, so activity is the only @@ -706,7 +796,44 @@ pub fn parse_slot_address(address: &str) -> Result<[u8; 20]> { #[cfg(test)] mod tests { - use super::{count_active_slots, parse_slot_address, snapshot_block}; + use super::{ + count_active_slots, parse_slot_address, snapshot_block, CrispE3Repository, + CurrentRoundRepository, + }; + use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp}; + use e3_sdk::indexer::{InMemoryStore, SharedStore}; + use std::sync::Arc; + use tokio::sync::RwLock; + + fn test_store() -> SharedStore { + SharedStore::new(Arc::new(RwLock::new(InMemoryStore::new()))) + } + + fn crisp_round(requester: &str, status: &str) -> E3Crisp { + E3Crisp { + emojis: ["one".to_string(), "two".to_string()], + start_time: 0, + voting_end_time: 100, + end_time: 100, + status: status.to_string(), + tally: vec![], + token_holder_hashes: vec![], + eligible_addresses: vec![], + token_address: "0x0000000000000000000000000000000000000001".to_string(), + balance_threshold: "1".to_string(), + ciphertext_inputs: vec![], + input_commitments: vec![], + input_slots: vec![], + input_usable: vec![], + input_parents: vec![], + requester: requester.to_string(), + num_options: "2".to_string(), + credit_mode: CreditMode::Constant, + credits: Some("1".to_string()), + snapshot_block: 1, + census_mode: CensusMode::Token, + } + } #[test] fn counts_each_slot_once_no_matter_how_long_its_chain_is() { @@ -743,4 +870,76 @@ mod tests { fn does_not_underflow_on_the_genesis_block() { assert_eq!(snapshot_block(0, 0), 0); } + + #[tokio::test] + async fn requested_round_is_visible_before_the_public_key_is_indexed() { + let store = test_store(); + let requester = "0x1111111111111111111111111111111111111111"; + let mut round = CrispE3Repository::new(store.clone(), "8"); + round + .set_crisp(crisp_round(requester, "Requested")) + .await + .unwrap(); + + let mut current = CurrentRoundRepository::new(store); + current.record_round("8").await.unwrap(); + + let found = current + .get_current_round_for_requester(requester.to_uppercase()) + .await + .unwrap() + .expect("the request-time CRISP record should be sufficient"); + assert_eq!(found.id, "8"); + } + + #[tokio::test] + async fn compute_claim_release_never_regresses_an_accepted_submission() { + let store = test_store(); + let mut round = CrispE3Repository::new(store, "9"); + round + .set_crisp(crisp_round("requester", "Requested")) + .await + .unwrap(); + + assert!(round.try_start_round().await.unwrap()); + assert!(!round.try_start_round().await.unwrap()); + assert!(round.try_mark_expired().await.unwrap()); + assert!(round.try_claim_computing().await.unwrap()); + assert!(!round.try_claim_computing().await.unwrap()); + assert!(round.release_compute_claim().await.unwrap()); + assert_eq!(round.get_status().await.unwrap(), "Expired"); + assert!(round.try_claim_computing().await.unwrap()); + assert!(round.mark_compute_submitted().await.unwrap()); + assert_eq!(round.get_status().await.unwrap(), "PublishingCiphertext"); + assert!(!round.release_compute_claim().await.unwrap()); + assert_eq!(round.get_status().await.unwrap(), "PublishingCiphertext"); + } + + #[tokio::test] + async fn requested_event_replay_does_not_reset_round_status() { + let store = test_store(); + let mut round = CrispE3Repository::new(store, "10"); + let params = || CustomParams { + token_address: "0x0000000000000000000000000000000000000001".to_string(), + balance_threshold: "1".to_string(), + num_options: "2".to_string(), + credit_mode: CreditMode::Constant, + credits: Some("1".to_string()), + census_mode: CensusMode::Token, + voting_power_divisor: "0".to_string(), + }; + + round + .initialize_round(params(), "requester".to_string(), 100, 100, 1) + .await + .unwrap(); + round.update_status("Finished").await.unwrap(); + round + .initialize_round(params(), "requester".to_string(), 200, 200, 2) + .await + .unwrap(); + + assert_eq!(round.get_status().await.unwrap(), "Finished"); + assert_eq!(round.get_input_deadline().await.unwrap(), 100); + } } diff --git a/examples/CRISP/server/src/server/routes/rounds.rs b/examples/CRISP/server/src/server/routes/rounds.rs index ee72248fda..d37607e86d 100644 --- a/examples/CRISP/server/src/server/routes/rounds.rs +++ b/examples/CRISP/server/src/server/routes/rounds.rs @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use crate::config::CONFIG; +use crate::deployments; use crate::server::app_data::AppData; use crate::server::indexer::get_current_timestamp_rpc; use crate::server::models::{ @@ -39,14 +40,23 @@ pub fn setup_routes(config: &mut web::ServiceConfig) { } sol! { - /// The E3 PROGRAM's input event โ€” three arguments. Not to be confused with `Interfold`'s - /// four-argument `InputPublished`, which carries an extra `inputHash`; they are different - /// events from different contracts and only one of them is what the activity feed renders. - event InputPublished(uint256 indexed e3Id, bytes data, uint256 index); + /// The CRISP program's content-addressed input event. + event InputPublished( + uint256 indexed e3Id, + address indexed slotAddress, + bytes32 encryptedVoteCommitment, + bytes32 encryptedVoteHash, + uint32 availabilityBlock, + uint128 availabilityLeafIndex, + uint256 index, + uint40 parentIndexPlusOne + ); } /// Cost charged to the caller's read window. const INPUTS_READ_COST: usize = 4; +const CENSUS_MODE_TOKEN: u64 = 0; +const CENSUS_MODE_ONCHAIN: u64 = 2; #[derive(Debug, Deserialize)] pub struct RoundInputsRequest { @@ -81,10 +91,9 @@ pub struct RoundInputsResponse { /// `/state/lite` already reports how MANY inputs a round holds, but not when each arrived or in /// which transaction, which is what the feed links to โ€” so this is not the same question. /// -/// The event's `data` is the ciphertext and is deliberately dropped: the feed renders an index, a -/// block and a link, and returning the payload would make the response orders of magnitude larger -/// for a field nothing reads. Ballots stay indistinguishable either way โ€” a mask and a vote look -/// the same here, as they do on chain. +/// The event holds only the content hash and Avail coordinates. The feed needs only the index, +/// Ethereum block, and transaction link. Ballots stay indistinguishable: a mask and a vote have +/// the same event shape. async fn round_inputs( http_request: HttpRequest, data: web::Json, @@ -205,14 +214,61 @@ mod tests { #[test] fn the_input_published_signature_is_the_program_event_not_interfold_s() { - // `InputPublished(uint256,bytes,uint256)` from the E3 PROGRAM, cross-checked against - // viem's `toEventSelector`. Interfold emits a four-argument event of the same name; using - // that one here would return an empty feed with nothing to indicate why. + // Cross-checked against viem's `toEventSelector`. Interfold has a different event with the + // same name; using that one here would return an empty feed. assert_eq!( format!("{:#x}", InputPublished::SIGNATURE_HASH), - "0xa8b9f2de7b39faeef44659f323cd6d14cfa11fbf8c4eaccfb1d6c954194656fd" + "0xbeebd5a7c46bb399523934784209bdeb6e68a004964282d7555fcc286169377c" ); } + + #[test] + fn self_registry_defaults_to_onchain_census() { + let mode = resolve_request_census_mode( + "0x00a2Aaf566593b28EDEb614b81B2Ada01327db7e", + None, + Some("0x00a2aaf566593b28edeb614b81b2ada01327db7e"), + ) + .unwrap(); + + assert_eq!(mode, CENSUS_MODE_ONCHAIN); + } + + #[test] + fn normal_token_keeps_token_census_default() { + let mode = resolve_request_census_mode( + "0x1111111111111111111111111111111111111111", + None, + Some("0x00a2aaf566593b28edeb614b81b2ada01327db7e"), + ) + .unwrap(); + + assert_eq!(mode, CENSUS_MODE_TOKEN); + } + + #[test] + fn self_registry_rejects_token_census() { + let err = resolve_request_census_mode( + "0x00a2Aaf566593b28EDEb614b81B2Ada01327db7e", + Some(CENSUS_MODE_TOKEN), + Some("0x00a2aaf566593b28edeb614b81b2ada01327db7e"), + ) + .unwrap_err(); + + assert!(err.contains("SelfRegistry rounds must use census_mode 2")); + } + + #[test] + fn unsupported_census_mode_is_rejected() { + let err = resolve_request_census_mode( + "0x1111111111111111111111111111111111111111", + Some(1), + Some("0x00a2aaf566593b28edeb614b81b2ada01327db7e"), + ) + .unwrap_err(); + + assert!(err.contains("Unsupported census mode 1")); + } } /// Request a new E3 round @@ -243,17 +299,26 @@ async fn request_new_round(data: web::Json) -> impl Responder { }); } - // TOKEN (0) and ONCHAIN (2) are the modes this route can request. BY_REQUESTER asks the - // requesting contract for its census, and the requester here is the server's own EOA, which - // cannot answer โ€” such a round would validate and then be unusable. - let census_mode = data.census_mode.unwrap_or(0); - if census_mode != 0 && census_mode != 2 { - return HttpResponse::BadRequest().json(JsonResponse { - response: format!( - "Unsupported census mode {census_mode}: this route can request 0 (TOKEN) or 2 (ONCHAIN)" - ), - }); - } + let self_registry = match deployments::self_registry_for_chain_id(CONFIG.chain_id) { + Ok(address) => address, + Err(e) => { + error!("Failed to read CRISP deployment addresses: {e}"); + return HttpResponse::InternalServerError().json(JsonResponse { + response: "Failed to read CRISP deployment configuration".to_string(), + }); + } + }; + + let census_mode = match resolve_request_census_mode( + &data.token_address, + data.census_mode, + self_registry.as_deref(), + ) { + Ok(mode) => mode, + Err(response) => { + return HttpResponse::BadRequest().json(JsonResponse { response }); + } + }; let result = initialize_crisp_round(&data.token_address, &data.balance_threshold, census_mode).await; @@ -268,6 +333,36 @@ async fn request_new_round(data: web::Json) -> impl Responder { } } +fn same_address(left: &str, right: &str) -> bool { + left.trim().eq_ignore_ascii_case(right.trim()) +} + +fn is_known_self_registry(token_address: &str, self_registry: Option<&str>) -> bool { + self_registry.is_some_and(|address| same_address(token_address, address)) +} + +fn resolve_request_census_mode( + token_address: &str, + requested: Option, + self_registry: Option<&str>, +) -> Result { + let self_registry_requested = is_known_self_registry(token_address, self_registry); + + match requested { + Some(CENSUS_MODE_TOKEN) if self_registry_requested => Err( + "SelfRegistry rounds must use census_mode 2 (ONCHAIN). census_mode 0 would try \ + token-holder discovery and make the round invisible to clients." + .to_string(), + ), + Some(mode @ (CENSUS_MODE_TOKEN | CENSUS_MODE_ONCHAIN)) => Ok(mode), + Some(mode) => Err(format!( + "Unsupported census mode {mode}: this route can request 0 (TOKEN) or 2 (ONCHAIN)" + )), + None if self_registry_requested => Ok(CENSUS_MODE_ONCHAIN), + None => Ok(CENSUS_MODE_TOKEN), + } +} + /// Get the current E3 round /// /// # Returns diff --git a/examples/CRISP/server/src/server/routes/state.rs b/examples/CRISP/server/src/server/routes/state.rs index c083e08112..366f9df6be 100644 --- a/examples/CRISP/server/src/server/routes/state.rs +++ b/examples/CRISP/server/src/server/routes/state.rs @@ -8,27 +8,30 @@ use std::str::FromStr; use crate::server::{ app_data::AppData, + data_availability::AvailabilityService, models::{ canonical_e3_id, e3_id_to_u256, GetRoundRequest, JsonResponse, PreviousCiphertextRequest, PreviousCiphertextResponse, RoundRequestWithRequester, WebhookPayload, }, - CONFIG, -}; -use actix_web::{web, HttpResponse, Responder}; -use alloy::primitives::{Address, Bytes, B256}; -use e3_sdk::evm_helpers::contracts::{ - E3Stage, InterfoldContract, InterfoldContractFactory, InterfoldRead, InterfoldWrite, ReadWrite, + rate_limit::ChainRateLimiter, }; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use alloy::primitives::Address; use log::{error, info}; +use super::chain::{admit, too_many_requests}; + +/// Upstream reads performed before a new aggregate-output job is admitted. +const OUTPUT_CALLBACK_READ_COST: usize = 6; + pub fn setup_routes(config: &mut web::ServiceConfig) { config.service( web::scope("/state") .route("/result", web::post().to(get_round_result)) .route("/all", web::post().to(get_all_round_results)) .route("/lite", web::post().to(get_round_state_lite)) - // Do we need protection on this endpoint? technically they would need to send a valid proof for it to - // be included on chain + // The handler verifies the compute proof on Ethereum before it creates an Avail job. + // Valid retries are idempotent, so this endpoint needs no separate caller identity. .route("/add-result", web::post().to(handle_program_server_result)) // Get the token holders hashes for a given round .route("/token-holders", web::post().to(get_token_holders_hashes)) @@ -59,19 +62,19 @@ fn round_not_found(e3_id: &str) -> HttpResponse { }) } -/// The round IS indexed โ€” its committee just has not published a key. +/// The round is indexed, but verified public-key bytes are not available. /// /// Same status as an unknown round, because there is still nothing to serve, but never the same /// message. The two have completely different causes: one means the request was never seen, the -/// other means DKG has not completed (or never will, for a round that failed). Reporting both as -/// "no state for round X" sent us looking for a broken indexer when the indexer was fine and the -/// ciphernodes were not. +/// other means the byte publication has not arrived or did not verify. `KeyPublished` on chain is +/// not sufficient because that stage records the proof-backed commitment before the byte event. async fn round_state_pending(store: &web::Data, e3_id: &str) -> HttpResponse { match store.e3(e3_id).has_crisp_record().await { Ok(true) => HttpResponse::NotFound().json(JsonResponse { response: format!( - "Round {e3_id} is indexed, but its committee has not published a key yet, so \ - there is no state to serve. Check whether the round has failed on chain." + "Round {e3_id} is indexed, but verified committee public-key bytes are not \ + available, so there is no state to serve. KeyPublished on chain confirms only \ + the commitment. Check the byte-publication event and the on-chain failure state." ), }), Ok(false) => round_not_found(e3_id), @@ -138,16 +141,20 @@ async fn handle_get_previous_ciphertext( /// /// # Returns /// * A JSON response indicating the success of the operation -async fn handle_program_server_result(data: web::Json) -> impl Responder { +async fn handle_program_server_result( + request: HttpRequest, + data: web::Json, + availability: web::Data, + limiter: web::Data, +) -> impl Responder { let incoming = data.into_inner(); match incoming { WebhookPayload::Failed { e3_id, error } => { error!("Computation failed for E3 ID: {}. Error: {}", e3_id, error); - // TODO: Update E3 state to indicate computation failed - // TODO: Handle ciphernode rewards for partial work - // TODO: Emit on-chain event if needed + // This callback is not authenticated. Do not let a caller move durable round state by + // claiming that the program server failed. HttpResponse::Ok().json(format!( "Computation failed for E3 ID: {}. Error: {}", @@ -181,75 +188,24 @@ async fn handle_program_server_result(data: web::Json) -> impl R return HttpResponse::BadRequest() .body("ciphertext_commitment must be exactly 32 bytes"); } + if let Err((caller, cost)) = admit(&request, &limiter, OUTPUT_CALLBACK_READ_COST) { + return too_many_requests(&caller, cost, "/state/add-result"); + } - // Create the contract - let contract: InterfoldContract = - match InterfoldContractFactory::create_write( - &CONFIG.http_rpc_url, - &CONFIG.interfold_address, - &CONFIG.private_key, - ) + let mut commitment = [0u8; 32]; + commitment.copy_from_slice(&ciphertext_commitment); + match availability + .stage_output(&e3_id, ciphertext, commitment, proof) .await - { - Ok(contract) => contract, - Err(e) => { - error!("Failed to create contract: {:?}", e); - return HttpResponse::InternalServerError() - .json(format!("Failed to create contract: {}", e)); - } - }; - - let e3_id_u256 = match e3_id_to_u256(&e3_id) { - Ok(e3_id) => e3_id, - Err(e) => return HttpResponse::BadRequest().body(e.to_string()), - }; - - // Try the direct call - let tx_result = contract - .publish_ciphertext_output( - e3_id_u256, - Bytes::from(ciphertext.clone()), - B256::from_slice(&ciphertext_commitment), - Bytes::from(proof.clone()), - ) - .await; - - let pending_tx = match tx_result { - Ok(tx) => tx, - Err(e) => { - // A revert can mean the output already landed on chain โ€” a retry of this - // webhook, or our own earlier transaction confirming first. Publication is - // this handler's goal, so an E3 already past KeyPublished is a success. - match contract.get_e3_stage(e3_id_u256).await { - Ok(stage) - if stage == E3Stage::CiphertextReady || stage == E3Stage::Complete => - { - info!( - "Ciphertext output already published for E3 ID: {} (stage: {:?})", - e3_id, stage - ); - return HttpResponse::Ok().json(format!( - "Ciphertext output already published for E3 ID: {}", - e3_id - )); - } - _ => {} - } - error!("Failed to send transaction: {:?}", e); - return HttpResponse::InternalServerError() - .json(format!("Failed to send transaction: {}", e)); + { + Ok(job) if job.status == "success" => HttpResponse::Ok().json(job), + Ok(job) => HttpResponse::Accepted().json(job), + Err(error) => { + error!("Failed to stage aggregate ciphertext: {error}"); + HttpResponse::ServiceUnavailable() + .body("Aggregate ciphertext publication is temporarily unavailable") } - }; - - info!( - "Ciphertext output published successfully for E3 ID: {} with tx: {}", - e3_id, pending_tx.transaction_hash - ); - - HttpResponse::Ok().json(format!( - "Ciphertext output published successfully for E3 ID: {}", - e3_id - )) + } } } } @@ -305,8 +261,8 @@ async fn get_all_round_results( let requesters = incoming.requesters; for e3_id in round_ids { - match store.e3(&e3_id).get_web_result_request().await { - Ok(w) => { + match store.e3(&e3_id).try_get_web_result_request().await { + Ok(Some(w)) => { if !requesters.is_empty() { // if we have any requesters to filter by, do it if requesters.contains(&w.requester) { @@ -316,15 +272,15 @@ async fn get_all_round_results( states.push(w); } } - Err(e) => { - // Expected for a round whose committee key is not published yet โ€” the `_e3:` - // record only exists after CommitteePublished. See the note in - // `get_current_round_for_requester`. + Ok(None) => { info!( - "Round {} is not fully indexed yet (usually: committee key pending) โ€” skipping: {:?}", - e3_id, e + "Round {} has no verified public-key bytes yet; skipping it", + e3_id ); - continue; + } + Err(error) => { + error!("Could not read round {e3_id} from the store: {error:?}"); + return HttpResponse::InternalServerError().body("Failed to retrieve round state"); } } } diff --git a/examples/CRISP/server/src/server/routes/voting.rs b/examples/CRISP/server/src/server/routes/voting.rs index ae665645af..b0328ec9d1 100644 --- a/examples/CRISP/server/src/server/routes/voting.rs +++ b/examples/CRISP/server/src/server/routes/voting.rs @@ -6,6 +6,7 @@ use crate::server::{ app_data::AppData, + data_availability::{input_rejection_message, AvailabilityService}, models::{ canonical_e3_id, e3_id_to_u256, VoteRequest, VoteResponse, VoteResponseStatus, VoteStatusRequest, VoteStatusResponse, @@ -16,18 +17,44 @@ use crate::server::{ }; use actix_web::{web, HttpRequest, HttpResponse, Responder}; use alloy::primitives::Bytes; -use evm_helpers::{CRISPContract, SimulateError}; -use eyre::Error; use log::{error, info, warn}; pub fn setup_routes(config: &mut web::ServiceConfig) { + config.route( + "/availability/objects/{content_hash}", + web::get().to(get_available_object), + ); config.service( web::scope("/voting") .route("/broadcast", web::post().to(broadcast_encrypted_vote)) + .route( + "/availability/{job_id}", + web::get().to(get_availability_status), + ) .route("/status", web::post().to(get_vote_status)), ); } +async fn get_available_object( + content_hash: web::Path, + availability: web::Data, +) -> impl Responder { + let normalized = content_hash.strip_prefix("0x").unwrap_or(&content_hash); + if normalized.len() != 64 || !normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return HttpResponse::BadRequest().body("Invalid content hash"); + } + match availability.object(&content_hash) { + Ok(Some(bytes)) => HttpResponse::Ok() + .content_type("application/octet-stream") + .body(bytes), + Ok(None) => HttpResponse::NotFound().finish(), + Err(error) => { + error!("Failed to read an availability object: {error}"); + HttpResponse::InternalServerError().body("Availability storage is unavailable") + } + } +} + /// Get the slot activity for an address in a specific round. /// /// Reports whether the slot holds any published entry, not whether its owner voted: a mask is @@ -99,41 +126,26 @@ async fn get_vote_status( /// # Returns /// /// * A JSON response indicating the success or failure of the operation -/// Ethereum mainnet, where the relay does not operate. -const MAINNET_CHAIN_ID: u64 = 1; - async fn broadcast_encrypted_vote( request: HttpRequest, data: web::Json, limiter: web::Data, + availability: web::Data, ) -> impl Responder { - // No relaying on mainnet: the relay key would pay real gas for anyone who posts a proof, - // which is an open faucet at mainnet prices. Voters submit `publishInput` from their own - // wallet there โ€” the function is permissionless and the proof carries everything it needs. - // Refused before the rate limiter so a refused mainnet call never consumes a window slot. - if CONFIG.chain_id == MAINNET_CHAIN_ID { - return HttpResponse::Forbidden().json(VoteResponse { - status: VoteResponseStatus::FailedBroadcast, - tx_hash: None, - message: Some( - "The relay is disabled on mainnet. Submit the vote directly from your wallet." - .to_string(), - ), - }); - } // Same identity rule as the read routes, and it matters more here: this window is what stops // one caller spending the relay's gas. A forgeable key is no key at all โ€” see `caller_id`. let caller = super::chain::identify(&request, CONFIG.trust_proxy_headers); - // Caller admission only. The global transaction quota is reserved after parsing and - // simulation, right before the relay pays โ€” reserving it here would let invalid requests - // sprayed across addresses drain it and deny honest voters. + // Caller admission only. A later global reservation is returned if validation or + // infrastructure fails before a durable availability job is admitted. if limiter.check_caller(&caller).is_err() { warn!("Rate limit (caller) refused a broadcast from {caller}"); return HttpResponse::TooManyRequests().json(VoteResponse { status: VoteResponseStatus::FailedBroadcast, tx_hash: None, + job_id: None, + encoded_proof: None, message: Some("Too many votes from this address, slow down".to_string()), }); } @@ -160,124 +172,70 @@ async fn broadcast_encrypted_vote( return HttpResponse::BadRequest().json(VoteResponse { status: VoteResponseStatus::FailedBroadcast, tx_hash: None, + job_id: None, + encoded_proof: None, message: Some("Invalid hex encoded proof".to_string()), }); } }; - // Broadcast vote to blockchain - let contract = match CRISPContract::new( - &CONFIG.http_rpc_url, - &CONFIG.private_key, - &CONFIG.e3_program_address, - ) - .await - { - Ok(c) => c, - Err(e) => { - error!("[e3_id={}] Contract creation error: {:?}", e3_key, e); - return HttpResponse::InternalServerError().json("Internal server error"); - } - }; - - // The dry run: an input the contract would revert must not reach `send`, where the relay - // pays for the revert. It costs one `eth_call` on inputs that would succeed anyway. Only a - // revert blames the input โ€” a provider failure judged nothing, so it answers retryable 503 - // rather than telling a voter their valid ballot was refused. - match contract - .simulate_publish_input(e3_id, encoded_proof.clone()) - .await - { - Ok(()) => {} - Err(SimulateError::Reverted(reason)) => { - warn!("[e3_id={}] Input refused by simulation: {}", e3_key, reason); - - return HttpResponse::BadRequest().json(VoteResponse { - status: VoteResponseStatus::FailedBroadcast, - tx_hash: None, - message: Some("Transaction was reverted by the contract".to_string()), - }); - } - Err(SimulateError::Provider(reason)) => { - error!( - "[e3_id={}] Simulation unavailable (provider failure): {}", - e3_key, reason - ); - - return HttpResponse::ServiceUnavailable().json(VoteResponse { - status: VoteResponseStatus::FailedBroadcast, - tx_hash: None, - message: Some( - "The relay could not reach the blockchain, please try again".to_string(), - ), - }); - } - } - - // The relay is about to pay; this is the point the global quota protects. + // Reserve a global slot before the service can admit work that may spend relay funds. if limiter.try_reserve_global().is_err() { warn!("Rate limit (global) refused a broadcast from {caller}"); return HttpResponse::TooManyRequests().json(VoteResponse { status: VoteResponseStatus::FailedBroadcast, tx_hash: None, + job_id: None, + encoded_proof: None, message: Some("The relay is busy, please try again shortly".to_string()), }); } - match contract.publish_input(e3_id, encoded_proof).await { - Ok(hash) => { - info!("[e3_id={}] Vote broadcasted successfully", e3_key); - HttpResponse::Ok().json(VoteResponse { - status: VoteResponseStatus::Success, - tx_hash: Some(hash.transaction_hash.to_string()), - message: Some("Vote Successful".to_string()), + match availability + .stage_input(&e3_key, encoded_proof.to_vec()) + .await + { + Ok(job) if job.status == "success" => HttpResponse::Ok().json(job), + Ok(job) => HttpResponse::Accepted().json(job), + Err(error) => { + limiter.release_global_reservation(); + if let Some(message) = input_rejection_message(&error) { + warn!("[e3_id={}] Vote rejected: {}", e3_key, error); + return HttpResponse::BadRequest().json(VoteResponse { + status: VoteResponseStatus::FailedBroadcast, + tx_hash: None, + job_id: None, + encoded_proof: None, + message: Some(message.to_string()), + }); + } + error!("[e3_id={}] Availability service failed: {}", e3_key, error); + HttpResponse::ServiceUnavailable().json(VoteResponse { + status: VoteResponseStatus::FailedBroadcast, + tx_hash: None, + job_id: None, + encoded_proof: None, + message: Some("The availability service is temporarily unavailable".to_string()), }) } - Err(e) => handle_vote_error(e).await, } } -/// Extract an error message from an error -fn extract_error_message(e: &Error) -> String { - let error_str = e.to_string(); - - if error_str.contains("Internal error") || error_str.contains("-32603") { - return "Transaction rejected by the blockchain".to_string(); - } - if error_str.contains("insufficient funds") { - return "Insufficient funds to process transaction".to_string(); - } - if error_str.contains("nonce") { - return "Transaction conflict, please try again".to_string(); - } - if error_str.contains("gas") { - return "Transaction failed due to gas issues".to_string(); - } - if error_str.contains("reverted") { - return "Transaction was reverted by the contract".to_string(); - } - if error_str.contains("timeout") || error_str.contains("Timeout") { - return "Transaction timed out, please try again".to_string(); +async fn get_availability_status( + job_id: web::Path, + availability: web::Data, +) -> impl Responder { + match availability.refreshed_view(&job_id).await { + Ok(Some(job)) => HttpResponse::Ok().json(job), + Ok(None) => HttpResponse::NotFound().finish(), + Err(error) => { + error!( + "Failed to read availability job {}: {error}", + job_id.as_str() + ); + HttpResponse::ServiceUnavailable() + .body("Availability status is temporarily unavailable") + } } - - "Transaction failed, please try again".to_string() -} - -/// Handle the vote error -/// -/// # Arguments -/// -/// * `e` - The error that occurred -async fn handle_vote_error(e: Error) -> HttpResponse { - // Log the full error for debugging - error!("Error while sending vote transaction: {:?}", e); - - let user_message = extract_error_message(&e); - - HttpResponse::InternalServerError().json(VoteResponse { - status: VoteResponseStatus::FailedBroadcast, - tx_hash: None, - message: Some(user_message), - }) } diff --git a/examples/CRISP/test/crisp.spec.ts b/examples/CRISP/test/crisp.spec.ts index e0beb7daf9..d39a64cb40 100644 --- a/examples/CRISP/test/crisp.spec.ts +++ b/examples/CRISP/test/crisp.spec.ts @@ -222,7 +222,6 @@ function log(msg: string) { async function waitForDemoPollReady(page: Page) { await page.waitForLoadState('load') await expect(page.locator("[data-test-id='poll-button-0']")).toBeVisible({ timeout: 60_000 }) - await expect(page.locator('button:has-text("Connect Wallet")')).not.toBeVisible({ timeout: 60_000 }) await expect(page.locator('.tag.live')).toBeVisible({ timeout: 60_000 }) } @@ -239,9 +238,22 @@ async function waitForWalletSession(page: Page) { async function reconnectWalletIfNeeded(page: Page, metamask: MetaMask) { const connectWalletBtn = page.locator('button:has-text("Connect Wallet")') - if (await connectWalletBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { - log('wallet disconnected โ€” reconnecting...') - await connectWalletWithRetry(page) + if (!(await connectWalletBtn.isVisible({ timeout: 3_000 }).catch(() => false))) return + + // Wagmi restores the persisted connector asynchronously after a reload. Give + // it time to finish before opening a second connection request. + const restored = await expect(connectWalletBtn) + .toHaveCount(0, { timeout: 15_000 }) + .then(() => true) + .catch(() => false) + if (restored) { + log('wallet session restored automatically') + return + } + + log('wallet disconnected โ€” reconnecting...') + const connectionRequested = await connectWalletWithRetry(page) + if (connectionRequested) { await metamask.connectToDapp() } } @@ -269,7 +281,7 @@ async function castVoteWithSignature(page: Page, metamask: MetaMask) { } } -async function connectWalletWithRetry(page: Page, maxAttempts = 3) { +async function connectWalletWithRetry(page: Page, maxAttempts = 3): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { await page.waitForLoadState('load') @@ -277,15 +289,28 @@ async function connectWalletWithRetry(page: Page, maxAttempts = 3) { const connectWalletBtn = page.locator('button:has-text("Connect Wallet")') const metamaskBtn = page.locator('button:has-text("MetaMask")') + if (!(await connectWalletBtn.isVisible().catch(() => false))) { + return false + } + // Only open the modal if MetaMask option isn't already visible if (!(await metamaskBtn.isVisible().catch(() => false))) { log(`clicking Connect Wallet (attempt ${attempt})...`) - await connectWalletBtn.click({ timeout: 10_000 }) + try { + await connectWalletBtn.click({ timeout: 10_000 }) + } catch (error) { + // The persisted connector can finish restoring between the visibility + // check and the click. In that case no connection request is needed. + if (!(await connectWalletBtn.isVisible().catch(() => false))) { + return false + } + throw error + } } log(`clicking MetaMask (attempt ${attempt})...`) await metamaskBtn.click({ timeout: 15_000 }) - return + return true } catch (error) { if (attempt === maxAttempts) throw error log(`wallet connect attempt ${attempt} failed, retrying...`) @@ -294,6 +319,8 @@ async function connectWalletWithRetry(page: Page, maxAttempts = 3) { await page.waitForTimeout(2_000) } } + + return false } test('CRISP smoke test', async ({ context, metamaskPage, extensionId }) => { @@ -325,9 +352,11 @@ test('CRISP smoke test', async ({ context, metamaskPage, extensionId }) => { await ensureHomePageLoaded(page) log(`connecting wallet via ConnectKit...`) - await connectWalletWithRetry(page) - log(`connecting to dapp...`) - await metamask.connectToDapp() + const connectionRequested = await connectWalletWithRetry(page) + if (connectionRequested) { + log(`connecting to dapp...`) + await metamask.connectToDapp() + } log(`clicking try demo...`) await page.locator('a:has-text("Try the demo")').click() @@ -338,10 +367,12 @@ test('CRISP smoke test', async ({ context, metamaskPage, extensionId }) => { log(`forcing page reload...`) await page.reload() await page.waitForLoadState('load') - log(`ensuring local anvil network after reload...`) - await metamask.switchNetwork('localwallet') - await reconnectWalletIfNeeded(page, metamask) + // The wallet fixture starts on localwallet, and reloading the application does + // not change the wallet network. Opening the extension here steals focus from + // the application while its round state is being restored. + await page.bringToFront() await waitForDemoPollReady(page) + await reconnectWalletIfNeeded(page, metamask) await waitForWalletSession(page) await castVoteWithSignature(page, metamask) await waitForVotePublication(e3id) diff --git a/packages/interfold-contracts/README.md b/packages/interfold-contracts/README.md index 91f2cac8be..3f2ef574c4 100644 --- a/packages/interfold-contracts/README.md +++ b/packages/interfold-contracts/README.md @@ -168,8 +168,9 @@ Set `deployMockE3Program` to `true` and set `e3Programs[0]` to the zero address to deploy `MockE3Program` in the same run. This stateless program applies no application-specific input or output rules. It has no owner, controller, setters, or reentrancy hooks. Interfold still verifies each BFV ciphertext proof -and committee decryption proof. Do not set `bindInitialE3Program` for this -option. +and committee decryption proof. Its deterministic receipt is for tests, not +production data availability. Keep requests paused until a production program is +registered and wired. Do not set `bindInitialE3Program` for this option. `Interfold.initialize` registers the selected program before the governance transaction executes. Later registrations require an owner transaction. diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json b/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json index f355c1f7c0..33be415ab4 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/IBondingRegistry.sol/IBondingRegistry.json @@ -2427,5 +2427,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IBondingRegistry.sol", - "buildInfoId": "solc-0_8_28-998d25c0cf0787b178a4e3411f4a8664937a9e9b" + "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json b/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json index f683ddb3a2..c7537a851e 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/ICiphernodeRegistry.sol/ICiphernodeRegistry.json @@ -201,6 +201,11 @@ "name": "InvalidFoldAttestation", "type": "error" }, + { + "inputs": [], + "name": "InvalidPublicKeyChunk", + "type": "error" + }, { "inputs": [ { @@ -315,6 +320,11 @@ "name": "PkCommitmentRequired", "type": "error" }, + { + "inputs": [], + "name": "PublicKeyPublisherNotCommitteeMember", + "type": "error" + }, { "inputs": [ { @@ -673,6 +683,67 @@ "name": "CommitteeProofPublished", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "e3Id", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "publisher", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "candidateHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address[]", + "name": "nodes", + "type": "address[]" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "pkCommitment", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "chunkIndex", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "chunkCount", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "totalLength", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "chunk", + "type": "bytes" + } + ], + "name": "CommitteePublicKeyChunkPublished", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -1581,9 +1652,29 @@ "name": "e3Id", "type": "uint256" }, + { + "internalType": "bytes32", + "name": "candidateHash", + "type": "bytes32" + }, + { + "internalType": "uint16", + "name": "chunkIndex", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "chunkCount", + "type": "uint16" + }, + { + "internalType": "uint32", + "name": "totalLength", + "type": "uint32" + }, { "internalType": "bytes", - "name": "publicKey", + "name": "chunk", "type": "bytes" } ], @@ -1871,5 +1962,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ICiphernodeRegistry.sol", - "buildInfoId": "solc-0_8_28-998d25c0cf0787b178a4e3411f4a8664937a9e9b" + "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json b/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json index 87c0e40603..d8f5fe936b 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/IInterfold.sol/IInterfold.json @@ -730,6 +730,43 @@ "name": "CiphertextOutputPublished", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "e3Id", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "contentHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "ciphertextCommitment", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "availabilityBlock", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "availabilityLeafIndex", + "type": "uint128" + } + ], + "name": "CiphertextOutputReferencePublished", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -857,6 +894,19 @@ "name": "E3ProgramRegistered", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "contract IE3Program", + "name": "e3Program", + "type": "address" + } + ], + "name": "E3ProgramUnregistered", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -2283,28 +2333,12 @@ }, { "internalType": "bytes", - "name": "ciphertextOutput", - "type": "bytes" - }, - { - "internalType": "bytes32", - "name": "ciphertextCommitment", - "type": "bytes32" - }, - { - "internalType": "bytes", - "name": "proof", + "name": "encodedOutputReference", "type": "bytes" } ], "name": "publishCiphertextOutput", - "outputs": [ - { - "internalType": "bool", - "name": "success", - "type": "bool" - } - ], + "outputs": [], "stateMutability": "nonpayable", "type": "function" }, @@ -2857,6 +2891,19 @@ ], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IE3Program", + "name": "e3Program", + "type": "address" + } + ], + "name": "unregisterE3Program", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } ], "bytecode": "0x", @@ -2865,5 +2912,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/IInterfold.sol", - "buildInfoId": "solc-0_8_28-998d25c0cf0787b178a4e3411f4a8664937a9e9b" + "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json b/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json index ef5a27f1b8..600e4140a4 100644 --- a/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json +++ b/packages/interfold-contracts/artifacts/contracts/interfaces/ISlashingManager.sol/ISlashingManager.json @@ -1597,5 +1597,5 @@ "deployedLinkReferences": {}, "immutableReferences": {}, "inputSourceName": "project/contracts/interfaces/ISlashingManager.sol", - "buildInfoId": "solc-0_8_28-998d25c0cf0787b178a4e3411f4a8664937a9e9b" + "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" } \ No newline at end of file diff --git a/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json b/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json index 7f265faf92..fa5a403b50 100644 --- a/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json +++ b/packages/interfold-contracts/artifacts/contracts/token/InterfoldTicketToken.sol/InterfoldTicketToken.json @@ -1416,5 +1416,5 @@ ] }, "inputSourceName": "project/contracts/token/InterfoldTicketToken.sol", - "buildInfoId": "solc-0_8_28-a9ff432c520a34187742acc5d51aa1e5b4a73f45" + "buildInfoId": "solc-0_8_28-b95d25dbcdea5354ca4c3fb8a90c5c3f029dfa00" } \ No newline at end of file diff --git a/packages/interfold-contracts/contracts/Interfold.sol b/packages/interfold-contracts/contracts/Interfold.sol index 85982952d6..833d8bbd14 100644 --- a/packages/interfold-contracts/contracts/Interfold.sol +++ b/packages/interfold-contracts/contracts/Interfold.sol @@ -289,7 +289,7 @@ contract Interfold is /// @inheritdoc IInterfold function request( E3RequestParams calldata requestParams - ) external returns (uint256 e3Id, E3 memory e3) { + ) external nonReentrant returns (uint256 e3Id, E3 memory e3) { if (requestsPaused) revert RequestsPaused(); _validateDependencyGraph(); // Fee-token allow-list gate: protects requesters from being @@ -340,22 +340,6 @@ contract Interfold is uint256 seed = uint256(keccak256(abi.encode(block.prevrandao, e3Id))); e3CryptoConfigIds[e3Id] = requestParams.expectedCryptoConfigId; - InterfoldPricing.recordRequestPayment( - e3Payments, - _e3FeeTokens, - _e3ProtocolShareBps, - _e3ProtocolTreasury, - _pendingTreasury, - _pricingConfig, - e3Id, - quotedFee, - feeToken - ); - - // Initialize E3 Lifecycle - _e3Stages[e3Id] = E3Stage.Requested; - _e3Requesters[e3Id] = msg.sender; - activeE3Count++; e3.seed = seed; e3.committeeSize = requestParams.committeeSize; @@ -368,6 +352,12 @@ contract Interfold is e3.customParams = requestParams.customParams; e3.requester = msg.sender; + // Programs can validate request timing against the exact E3 and timeout snapshot. Store + // the request before the external validation call; the transaction rolls this write back + // if validation fails. Its stage and requester remain unset until validation completes, + // so lifecycle entry points cannot act on this provisional record. + e3s[e3Id] = e3; + bytes32 encryptionSchemeId = requestParams.e3Program.validate( e3Id, seed, @@ -389,11 +379,20 @@ contract Interfold is address(pkVerifiers[encryptionSchemeId]) != address(0), InvalidEncryptionScheme(encryptionSchemeId) ); - e3.encryptionSchemeId = encryptionSchemeId; - e3.decryptionVerifier = decryptionVerifiers[encryptionSchemeId]; - e3.pkVerifier = pkVerifiers[encryptionSchemeId]; - // CEI: write all state before external calls below - e3s[e3Id] = e3; + // Complete the provisional record in place. Copying the full struct a second time wastes + // enough runtime bytecode to put this upgrade too close to EIP-170. + E3 storage storedE3 = e3s[e3Id]; + storedE3.encryptionSchemeId = encryptionSchemeId; + storedE3.decryptionVerifier = decryptionVerifiers[encryptionSchemeId]; + storedE3.pkVerifier = pkVerifiers[encryptionSchemeId]; + // Keep the memory event payload in sync without copying the full dynamic struct back from + // storage. Encoding the storage value directly consumes too much proxy runtime bytecode. + e3.encryptionSchemeId = storedE3.encryptionSchemeId; + e3.decryptionVerifier = storedE3.decryptionVerifier; + e3.pkVerifier = storedE3.pkVerifier; + _e3Stages[e3Id] = E3Stage.Requested; + _e3Requesters[e3Id] = msg.sender; + activeE3Count++; // Transfer fee after all validations and state changes InterfoldPricing.transferFromExact( @@ -402,6 +401,20 @@ contract Interfold is address(this), quotedFee ); + // Credit the payment only after the tokens are in custody. In particular, the external + // program-validation call above must not expose a claimable treasury balance backed by + // another E3's escrow. + InterfoldPricing.recordRequestPayment( + e3Payments, + _e3FeeTokens, + _e3ProtocolShareBps, + _e3ProtocolTreasury, + _pendingTreasury, + _pricingConfig, + e3Id, + quotedFee, + feeToken + ); require( dependencies.registry.requestCommittee( @@ -419,22 +432,17 @@ contract Interfold is /// @inheritdoc IInterfold function publishCiphertextOutput( uint256 e3Id, - bytes calldata ciphertextOutput, - bytes32 ciphertextCommitment, - bytes calldata proof - ) external nonReentrant returns (bool success) { - return - InterfoldLifecycle.publishCiphertext( - e3s, - _e3Stages, - _e3Deadlines, - address(_registryFor(e3Id)), - e3Id, - _e3TimeoutConfigs[e3Id].decryptionWindow, - ciphertextOutput, - ciphertextCommitment, - proof - ); + bytes calldata encodedOutputReference + ) external nonReentrant { + InterfoldLifecycle.publishCiphertext( + e3s, + _e3Stages, + _e3Deadlines, + address(_registryFor(e3Id)), + e3Id, + _e3TimeoutConfigs[e3Id].decryptionWindow, + encodedOutputReference + ); } /// @inheritdoc IInterfold @@ -580,9 +588,12 @@ contract Interfold is function setRandomnessFlatFee( uint192 randomnessFlatFee ) external onlyOwner { - if (randomnessFlatFee == 0) revert PaymentRequired(0); - _pricingConfig.randomnessFlatFee = randomnessFlatFee; - emit FeeAssetConfigUpdated(feeToken, feeTokenDecimals, _pricingConfig); + InterfoldPricing.setRandomnessFlatFee( + _pricingConfig, + feeToken, + feeTokenDecimals, + randomnessFlatFee + ); } /// @inheritdoc IInterfold @@ -631,6 +642,11 @@ contract Interfold is emit E3ProgramRegistered(e3Program); } + /// @inheritdoc IInterfold + function unregisterE3Program(IE3Program e3Program) external onlyOwner { + InterfoldLifecycle.unregisterE3Program(e3Programs, e3Program); + } + /// @inheritdoc IInterfold function setDecryptionVerifier( bytes32 encryptionSchemeId, diff --git a/packages/interfold-contracts/contracts/interfaces/ICiphernodeRegistry.sol b/packages/interfold-contracts/contracts/interfaces/ICiphernodeRegistry.sol index 81654ff808..fed5232a81 100644 --- a/packages/interfold-contracts/contracts/interfaces/ICiphernodeRegistry.sol +++ b/packages/interfold-contracts/contracts/interfaces/ICiphernodeRegistry.sol @@ -17,6 +17,7 @@ import { IRandomnessProvider } from "./IRandomnessProvider.sol"; * and coordinates committee selection for E3 computations */ interface ICiphernodeRegistry { + error InvalidPublicKeyChunk(); function unreleasedCommitteeCount() external view returns (uint256); /// @notice Current number of registered ciphernodes. function numCiphernodes() external view returns (uint256); @@ -107,10 +108,6 @@ interface ICiphernodeRegistry { uint256 score ); - /// @notice This event MUST be emitted when a committee is finalized - /// @param e3Id ID of the E3 computation - /// @param committee Array of selected ciphernode addresses - /// @param scores Array of sortition scores corresponding to each committee member /// @notice MUST be emitted when sortition selects a committee for an E3. /// @dev Renamed from `CommitteeFinalized` to avoid clashing with the /// canonical {IInterfold.CommitteeFinalized} surface. @@ -172,6 +169,22 @@ interface ICiphernodeRegistry { bytes proof ); + /// @notice Emitted for one deterministic chunk of a serialized committee public key. + /// @dev Consumers group chunks by `(e3Id, publisher, candidateHash)`, require a complete + /// sequence, then verify both `keccak256(publicKey) == candidateHash` and the DKG + /// `pkCommitment` before they use the key. + event CommitteePublicKeyChunkPublished( + uint256 indexed e3Id, + address indexed publisher, + bytes32 indexed candidateHash, + address[] nodes, + bytes32 pkCommitment, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes chunk + ); + /// @notice This event MUST be emitted when a committee's active status changes. /// @param e3Id ID of the E3 for which the committee status changed. /// @param active True if committee is now active, false if completed. @@ -299,6 +312,9 @@ interface ICiphernodeRegistry { /// @notice Committee has not been published yet for this E3 error CommitteeNotPublished(); + /// @notice The caller is not one of the selected committee members for this E3. + error PublicKeyPublisherNotCommitteeMember(); + /// @notice Committee has not been requested yet for this E3 error CommitteeNotRequested(); @@ -524,14 +540,23 @@ interface ICiphernodeRegistry { bytes calldata dkgAttestationBundle ) external; - /// @notice Publishes a serialized public-key candidate for a proven committee. - /// @dev Permissionless and repeatable. Consumers MUST validate the candidate - /// against the event's proven `pkCommitment`. + /// @notice Publishes one deterministic chunk of a large serialized public-key candidate. + /// @dev Selected committee members can publish while the E3 is in `KeyPublished`. The registry + /// does not assemble or store the key. Consumers accept at most one candidate per member + /// and verify it against the proven DKG commitment. /// @param e3Id ID of the E3 whose committee proof has been published. - /// @param publicKey Non-empty serialized public-key candidate. + /// @param candidateHash Keccak-256 of the complete serialized public key. + /// @param chunkIndex Zero-based index of this chunk. + /// @param chunkCount Total number of chunks in the candidate. + /// @param totalLength Total byte length of the complete candidate. + /// @param chunk Exact bytes for this chunk. function publishCommitteePublicKey( uint256 e3Id, - bytes calldata publicKey + bytes32 candidateHash, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes calldata chunk ) external; /// @notice Release committee collateral after the E3 completes or fails. diff --git a/packages/interfold-contracts/contracts/interfaces/IDataAvailabilityVerifier.sol b/packages/interfold-contracts/contracts/interfaces/IDataAvailabilityVerifier.sol new file mode 100644 index 0000000000..d13166a1f9 --- /dev/null +++ b/packages/interfold-contracts/contracts/interfaces/IDataAvailabilityVerifier.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. +pragma solidity >=0.8.27; + +/// @notice Verifies that content-addressed bytes were published to an external DA layer. +interface IDataAvailabilityVerifier { + /// @notice Stable coordinates needed to retrieve an object after verification. + struct DataReference { + bytes32 contentHash; + uint32 blockNumber; + uint128 leafIndex; + } + + /// @notice Verifies one receipt and returns its normalized retrieval coordinates. + /// @param expectedContentHash Keccak-256 of the exact raw object bytes. + /// @param proof Provider-specific proof bytes. + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) external view returns (DataReference memory receipt); +} + +/// @notice E3-program extension used when an aggregate ciphertext is stored outside Ethereum. +interface IE3ProgramDataAvailability { + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) + external + view + returns (IDataAvailabilityVerifier.DataReference memory receipt); +} diff --git a/packages/interfold-contracts/contracts/interfaces/IE3.sol b/packages/interfold-contracts/contracts/interfaces/IE3.sol index b3e0471ef2..df42937910 100644 --- a/packages/interfold-contracts/contracts/interfaces/IE3.sol +++ b/packages/interfold-contracts/contracts/interfaces/IE3.sol @@ -17,7 +17,8 @@ import { IInterfold } from "./IInterfold.sol"; * from request through completion * @param seed Random seed for committee selection and computation initialization * @param committeeSize The committee size enum value for this computation - * @param requestBlock Block number when the E3 computation was requested + * @param requestBlock EIP-6372 timestamp when the E3 computation was requested. The name is kept + * for storage and ABI compatibility. * @param inputWindow When to start and stop accepting inputs from data providers * @param encryptionSchemeId Identifier for the encryption scheme used in this computation * @param e3Program Address of the E3 Program contract that validates and verifies the computation diff --git a/packages/interfold-contracts/contracts/interfaces/IE3Program.sol b/packages/interfold-contracts/contracts/interfaces/IE3Program.sol index 5e3955c15a..28bd546f21 100644 --- a/packages/interfold-contracts/contracts/interfaces/IE3Program.sol +++ b/packages/interfold-contracts/contracts/interfaces/IE3Program.sol @@ -28,7 +28,8 @@ interface IE3Program { ) external returns (bytes32 encryptionSchemeId); /// @notice Verify the ciphertext output of an E3 computation - /// @dev This function is called by the Interfold contract when ciphertext output is published + /// @dev Interfold calls this before it stores the output or advances the E3 stage. Verify the + /// supplied arguments instead of reading the pending output from Interfold storage. /// @param e3Id ID of the E3 computation /// @param ciphertextOutputHash The keccak256 hash of output data to be verified /// @param ciphertextCommitment The SAFE commitment authenticated by the proof diff --git a/packages/interfold-contracts/contracts/interfaces/IInterfold.sol b/packages/interfold-contracts/contracts/interfaces/IInterfold.sol index e4a503f1f6..0b2ceafb1b 100644 --- a/packages/interfold-contracts/contracts/interfaces/IInterfold.sol +++ b/packages/interfold-contracts/contracts/interfaces/IInterfold.sol @@ -99,6 +99,14 @@ interface IInterfold { uint256 decryptionDeadline; } + /// @notice Proof-backed reference to a ciphertext stored on the DA layer. + struct CiphertextOutputReference { + bytes32 contentHash; + bytes32 ciphertextCommitment; + bytes computeProof; + bytes availabilityProof; + } + /// @notice All pricing-related configuration for parametric E3 fee calculation struct PricingConfig { uint256 keyGenFixedPerNode; @@ -171,6 +179,20 @@ interface IInterfold { bytes32 ciphertextCommitment ); + /// @notice Emitted when an aggregate ciphertext is stored on a verified data-availability layer. + /// @param e3Id ID of the E3. + /// @param contentHash Keccak-256 of the exact serialized ciphertext bytes. + /// @param ciphertextCommitment Circuit-compatible SAFE commitment to the decoded ciphertext. + /// @param availabilityBlock Normalized source-chain block that contains the bytes. + /// @param availabilityLeafIndex Blob leaf index verified by the availability adapter. + event CiphertextOutputReferencePublished( + uint256 indexed e3Id, + bytes32 contentHash, + bytes32 ciphertextCommitment, + uint32 availabilityBlock, + uint128 availabilityLeafIndex + ); + /// @notice This event MUST be emitted any time the `maxDuration` is set. /// @param maxDuration The maximum duration of a computation in seconds. event MaxDurationSet(uint256 maxDuration); @@ -256,11 +278,14 @@ interface IInterfold { /// @param encryptionSchemeId The ID of the encryption scheme that was enabled. event EncryptionSchemeEnabled(bytes32 encryptionSchemeId); - /// @notice This event MUST be emitted any time a E3 Program is registered. + /// @notice This event MUST be emitted any time an E3 Program is registered. /// @param e3Program The address of the E3 Program. - /// @dev Registration is append-only; programs cannot be deregistered. event E3ProgramRegistered(IE3Program e3Program); + /// @notice Emitted when governance closes new request admission for an E3 Program. + /// @param e3Program The address of the E3 Program. + event E3ProgramUnregistered(IE3Program e3Program); + /// @notice Emitted when the active BFV parameter set is registered. /// @param paramSet The param set index. /// @param encodedParams ABI-encoded BFV parameters. @@ -593,19 +618,15 @@ interface IInterfold { E3RequestParams calldata requestParams ) external returns (uint256 e3Id, E3 memory e3); - /// @notice This function should be called to publish output data for an Encrypted Execution Environment (E3). - /// @dev This function MUST emit the CiphertextOutputPublished event. + /// @notice Publishes a proof-backed reference to an aggregate ciphertext stored outside Ethereum. + /// @dev The E3 program verifies the availability receipt and the computation proof remains bound + /// to `ciphertextOutputHash` and `ciphertextCommitment`. /// @param e3Id ID of the E3. - /// @param ciphertextOutput ABI encoded output data to verify. - /// @param ciphertextCommitment Circuit-compatible SAFE commitment to the decoded BFV ciphertext. - /// @param proof ABI encoded data to verify the ciphertextOutput. - /// @return success True if the output was successfully published. + /// @param encodedOutputReference ABI-encoded {CiphertextOutputReference}. function publishCiphertextOutput( uint256 e3Id, - bytes calldata ciphertextOutput, - bytes32 ciphertextCommitment, - bytes calldata proof - ) external returns (bool success); + bytes calldata encodedOutputReference + ) external; /// @notice This function publishes the plaintext output of an Encrypted Execution Environment (E3). /// @dev This function MUST revert if the output has not been published. @@ -661,11 +682,16 @@ interface IInterfold { /// @notice Returns whether a token is currently allow-listed as an E3 fee token. function isFeeTokenAllowed(IERC20 token) external view returns (bool); - /// @notice Register an E3 Program. Append-only โ€” programs cannot be deregistered. + /// @notice Register an E3 Program for new requests. /// @dev Only the owner can register a program after initialization. /// @param e3Program The address of the E3 Program. function registerE3Program(IE3Program e3Program) external; + /// @notice Close new request admission for an E3 Program. + /// @dev Existing E3s keep their request-time program. Only the owner can unregister a program. + /// @param e3Program The address of the E3 Program. + function unregisterE3Program(IE3Program e3Program) external; + /// @notice Sets or enables a decryption verifier for a specific encryption scheme. /// @dev This function MUST revert if the verifier address is zero or already set to the same value. /// @param encryptionSchemeId The unique identifier for the encryption scheme. diff --git a/packages/interfold-contracts/contracts/interfaces/external/IAvailBridge.sol b/packages/interfold-contracts/contracts/interfaces/external/IAvailBridge.sol new file mode 100644 index 0000000000..baf3dd890a --- /dev/null +++ b/packages/interfold-contracts/contracts/interfaces/external/IAvailBridge.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; + +interface IVectorx { + function rangeStartBlocks( + bytes32 rangeHash + ) external view returns (uint32 startBlock); +} +/// @notice Minimal interface for the official Avail Ethereum bridge. +interface IAvailBridge { + struct MerkleProofInput { + bytes32[] dataRootProof; + bytes32[] leafProof; + bytes32 rangeHash; + uint256 dataRootIndex; + bytes32 blobRoot; + bytes32 bridgeRoot; + bytes32 leaf; + uint256 leafIndex; + } + + function vectorx() external view returns (IVectorx); + + function verifyBlobLeaf( + MerkleProofInput calldata input + ) external view returns (bool); +} diff --git a/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol b/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol index 0c03fd55ce..585d331d1f 100644 --- a/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol +++ b/packages/interfold-contracts/contracts/lib/InterfoldLifecycle.sol @@ -6,6 +6,7 @@ pragma solidity >=0.8.27; import { IInterfold } from "../interfaces/IInterfold.sol"; +import { IE3Program } from "../interfaces/IE3Program.sol"; import { ICiphernodeRegistry } from "../interfaces/ICiphernodeRegistry.sol"; import { IE3RefundManager } from "../interfaces/IE3RefundManager.sol"; import { IBondingRegistry } from "../interfaces/IBondingRegistry.sol"; @@ -17,6 +18,10 @@ import { import { IDecryptionVerifier } from "../interfaces/IDecryptionVerifier.sol"; import { IPkVerifier } from "../interfaces/IPkVerifier.sol"; import { ICiphertextVerifier } from "../interfaces/ICiphertextVerifier.sol"; +import { + IDataAvailabilityVerifier, + IE3ProgramDataAvailability +} from "../interfaces/IDataAvailabilityVerifier.sol"; import { E3 } from "../interfaces/IE3.sol"; import { CiphertextVerifierStorage @@ -35,6 +40,18 @@ library InterfoldLifecycle { bytes32 private constant CIPHERTEXT_VERIFIER_STORAGE_SLOT = 0xfc399dd26441dab88259cd69fffcf8b5f96dd87f2db63f29285d86101a4d1500; + /// @notice Closes new request admission for one program. + /// @dev Existing E3 records keep their request-time program address. + function unregisterE3Program( + mapping(IE3Program => bool) storage programs, + IE3Program e3Program + ) external { + if (!programs[e3Program]) + revert IInterfold.E3ProgramNotAllowed(e3Program); + programs[e3Program] = false; + emit IInterfold.E3ProgramUnregistered(e3Program); + } + /// @notice Checks the fee and circuit values accepted with a quote. function validateQuoteLimit( address actualFeeToken, @@ -259,7 +276,10 @@ library InterfoldLifecycle { revert IInterfold.DKGDeadlinePassed(e3Id, dkgDeadline); } - /// @notice Validates, verifies, and records one ciphertext output. + /// @notice Validates, verifies, and records a content-addressed ciphertext output. + /// @dev The availability adapter proves that the exact bytes named by + /// `ciphertextOutputHash` were published. The existing compute proof binds that same + /// hash and the ciphertext commitment to this E3. function publishCiphertext( mapping(uint256 e3Id => E3 e3) storage e3s, mapping(uint256 e3Id => IInterfold.E3Stage stage) storage stages, @@ -268,63 +288,101 @@ library InterfoldLifecycle { address registryAddress, uint256 e3Id, uint256 decryptionWindow, - bytes calldata ciphertextOutput, - bytes32 ciphertextCommitment, - bytes calldata proof - ) external returns (bool) { + bytes calldata encodedOutputReference + ) external { + IInterfold.CiphertextOutputReference memory outputReference = abi + .decode( + encodedOutputReference, + (IInterfold.CiphertextOutputReference) + ); + bytes32 contentHash = outputReference.contentHash; + bytes32 ciphertextCommitment = outputReference.ciphertextCommitment; E3 storage e3 = e3s[e3Id]; + _validateCiphertextReference( + e3, + stages[e3Id], + deadlines[e3Id].computeDeadline, + e3Id, + contentHash + ); + _requireViableCommittee(registryAddress, e3Id); + + IDataAvailabilityVerifier.DataReference + memory availabilityReceipt = _verifyDataAvailability( + address(e3.e3Program), + contentHash, + outputReference.availabilityProof + ); + + if ( + !_isValidCiphertextHash( + e3, + e3Id, + contentHash, + ciphertextCommitment, + outputReference.computeProof + ) + ) revert IInterfold.InvalidOutput(bytes("")); + + e3.ciphertextOutput = contentHash; + e3.ciphertextCommitment = ciphertextCommitment; + stages[e3Id] = IInterfold.E3Stage.CiphertextReady; + deadlines[e3Id].decryptionDeadline = block.timestamp + decryptionWindow; + + emit IInterfold.CiphertextOutputReferencePublished( + e3Id, + contentHash, + ciphertextCommitment, + availabilityReceipt.blockNumber, + availabilityReceipt.leafIndex + ); + emit IInterfold.E3StageChanged( + e3Id, + IInterfold.E3Stage.KeyPublished, + IInterfold.E3Stage.CiphertextReady + ); + } + + function _validateCiphertextReference( + E3 storage e3, + IInterfold.E3Stage stage, + uint256 computeDeadline, + uint256 e3Id, + bytes32 ciphertextOutputHash + ) private view { if (address(e3.e3Program) == address(0)) revert IInterfold.E3DoesNotExist(e3Id); - IInterfold.E3Stage stage = stages[e3Id]; if (stage != IInterfold.E3Stage.KeyPublished) revert IInterfold.InvalidStage( e3Id, IInterfold.E3Stage.KeyPublished, stage ); - uint256 computeDeadline = deadlines[e3Id].computeDeadline; if (computeDeadline < block.timestamp) revert IInterfold.CommitteeDutiesCompleted(e3Id, computeDeadline); if (block.timestamp < e3.inputWindow[1]) revert IInterfold.InputDeadlineNotReached(e3Id, e3.inputWindow[1]); if (e3.ciphertextOutput != bytes32(0)) revert IInterfold.CiphertextOutputAlreadyPublished(e3Id); - _requireViableCommittee(registryAddress, e3Id); - - bytes32 ciphertextOutputHash = keccak256(ciphertextOutput); - e3.ciphertextOutput = ciphertextOutputHash; - e3.ciphertextCommitment = ciphertextCommitment; - stages[e3Id] = IInterfold.E3Stage.CiphertextReady; - deadlines[e3Id].decryptionDeadline = block.timestamp + decryptionWindow; + if (ciphertextOutputHash == bytes32(0)) + revert IInterfold.InvalidOutput(bytes("")); + } - _verifyCiphertext( - e3, - e3Id, + function _verifyDataAvailability( + address e3Program, + bytes32 ciphertextOutputHash, + bytes memory availabilityProof + ) + private + view + returns (IDataAvailabilityVerifier.DataReference memory receipt) + { + receipt = IE3ProgramDataAvailability(e3Program).verifyDataAvailability( ciphertextOutputHash, - ciphertextCommitment, - ciphertextOutput, - proof - ); - - stage = stages[e3Id]; - if (stage != IInterfold.E3Stage.CiphertextReady) - revert IInterfold.InvalidStage( - e3Id, - IInterfold.E3Stage.CiphertextReady, - stage - ); - - emit IInterfold.CiphertextOutputPublished( - e3Id, - ciphertextOutput, - ciphertextCommitment + availabilityProof ); - emit IInterfold.E3StageChanged( - e3Id, - IInterfold.E3Stage.KeyPublished, - IInterfold.E3Stage.CiphertextReady - ); - return true; + if (receipt.contentHash != ciphertextOutputHash) + revert IInterfold.InvalidOutput(bytes("")); } /// @notice Sets the verifier used by future requests for one scheme. @@ -347,14 +405,13 @@ library InterfoldLifecycle { } /// @notice Freezes the configured verifier for an E3 request. - function _verifyCiphertext( + function _isValidCiphertextHash( E3 storage e3, uint256 e3Id, bytes32 ciphertextOutputHash, bytes32 ciphertextCommitment, - bytes calldata ciphertextOutput, - bytes calldata proof - ) private { + bytes memory proof + ) private returns (bool) { CiphertextVerifierStorage.RequestConfig storage config = _ciphertextVerifierLayout().requests[e3Id]; if ( @@ -368,15 +425,14 @@ library InterfoldLifecycle { ciphertextCommitment, proof ) - ) revert IInterfold.InvalidOutput(ciphertextOutput); - if ( - !e3.e3Program.verify( + ) return false; + return + e3.e3Program.verify( e3Id, ciphertextOutputHash, ciphertextCommitment, proof - ) - ) revert IInterfold.InvalidOutput(ciphertextOutput); + ); } function _requireViableCommittee( diff --git a/packages/interfold-contracts/contracts/lib/InterfoldPricing.sol b/packages/interfold-contracts/contracts/lib/InterfoldPricing.sol index b0f292bf64..f7b4d23787 100644 --- a/packages/interfold-contracts/contracts/lib/InterfoldPricing.sol +++ b/packages/interfold-contracts/contracts/lib/InterfoldPricing.sol @@ -44,6 +44,18 @@ library InterfoldPricing { uint256 amount ); + /// @notice Updates only the non-refundable randomness fee. + function setRandomnessFlatFee( + IInterfold.PricingConfig storage config, + IERC20 token, + uint8 tokenDecimals, + uint192 randomnessFlatFee + ) external { + if (randomnessFlatFee == 0) revert IInterfold.PaymentRequired(0); + config.randomnessFlatFee = randomnessFlatFee; + emit IInterfold.FeeAssetConfigUpdated(token, tokenDecimals, config); + } + /// @notice Pull an exact token amount into a custody contract. function transferFromExact( IERC20 token, diff --git a/packages/interfold-contracts/contracts/lib/RegistrySortitionLib.sol b/packages/interfold-contracts/contracts/lib/RegistrySortitionLib.sol index 126aed9b65..2fe3a5570c 100644 --- a/packages/interfold-contracts/contracts/lib/RegistrySortitionLib.sol +++ b/packages/interfold-contracts/contracts/lib/RegistrySortitionLib.sol @@ -19,6 +19,82 @@ library RegistrySortitionLib { uint256 private constant MIN_RANDOMNESS_REQUEST_TIMEOUT = 60; uint256 private constant MAX_RANDOMNESS_REQUEST_TIMEOUT = 1 days; + uint256 private constant MAX_COMMITTEE_PUBLIC_KEY_BYTES = 512 * 1024; + uint256 private constant MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES = 90 * 1024; + + /// @notice Validates and emits one deterministic public-key chunk from registry storage. + /// @dev This external library call runs with `delegatecall`, so the registry proxy remains + /// the event emitter and the original publisher remains `msg.sender`. + function publishCommitteePublicKeyChunk( + mapping(uint256 e3Id => ICiphernodeRegistry.Committee committee) + storage committees, + mapping(uint256 e3Id => bytes32 publicKeyHash) storage publicKeyHashes, + uint256 e3Id, + bytes32 candidateHash, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes calldata chunk + ) external { + _requirePublicKeyPublicationOpen(e3Id); + ICiphernodeRegistry.Committee storage committee = committees[e3Id]; + bytes32 pkCommitment = publicKeyHashes[e3Id]; + if (pkCommitment == bytes32(0)) + revert ICiphernodeRegistry.CommitteeNotPublished(); + if ( + candidateHash == bytes32(0) || + totalLength == 0 || + totalLength > MAX_COMMITTEE_PUBLIC_KEY_BYTES || + chunkCount == 0 || + chunkIndex >= chunkCount + ) revert ICiphernodeRegistry.InvalidPublicKeyChunk(); + + uint256 expectedChunkCount = (uint256(totalLength) + + MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES - + 1) / MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES; + if (chunkCount != expectedChunkCount) + revert ICiphernodeRegistry.InvalidPublicKeyChunk(); + + uint256 remaining = uint256(totalLength) - + uint256(chunkIndex) * + MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES; + uint256 expectedLength = remaining > + MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES + ? MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES + : remaining; + if (chunk.length != expectedLength) + revert ICiphernodeRegistry.InvalidPublicKeyChunk(); + if ( + committee.memberStatus[msg.sender] == + ICiphernodeRegistry.MemberStatus.None + ) revert ICiphernodeRegistry.PublicKeyPublisherNotCommitteeMember(); + + emit ICiphernodeRegistry.CommitteePublicKeyChunkPublished( + e3Id, + msg.sender, + candidateHash, + committee.topNodes, + pkCommitment, + chunkIndex, + chunkCount, + totalLength, + chunk + ); + } + + function _requirePublicKeyPublicationOpen(uint256 e3Id) private view { + // Interfold embeds its proxy address in the high 160 bits of every E3 ID. Use that frozen + // controller instead of the Registry's current dependency generation. + IInterfold interfold = IInterfold(address(uint160(e3Id >> 96))); + IInterfold.E3Stage stage = interfold.getE3Stage(e3Id); + if (stage != IInterfold.E3Stage.KeyPublished) { + revert IInterfold.InvalidStage( + e3Id, + IInterfold.E3Stage.KeyPublished, + stage + ); + } + } struct RandomnessRequest { IRandomnessProvider provider; diff --git a/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol b/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol index 2bfb3ca9f1..65cf657895 100644 --- a/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol +++ b/packages/interfold-contracts/contracts/registry/CiphernodeRegistryOwnable.sol @@ -50,6 +50,9 @@ contract CiphernodeRegistryOwnable is /// @dev Bounds the time reserved for ticket submission in one E3 lifecycle. uint256 public constant MAX_SORTITION_SUBMISSION_WINDOW = 1 days; + /// @notice Largest serialized committee public key accepted by the transport. + uint256 public constant MAX_COMMITTEE_PUBLIC_KEY_BYTES = 512 * 1024; + /// @notice Timeout used by new registries before governance changes it. uint256 private constant DEFAULT_RANDOMNESS_REQUEST_TIMEOUT = 1 hours; @@ -106,10 +109,6 @@ contract CiphernodeRegistryOwnable is uint256 public constant CIPHERNODE_TREE_WARNING_THRESHOLD = (MAX_CIPHERNODE_LEAVES * 4) / 5; - /// @notice Maximum serialized public-key candidate size. - /// @dev Covers every currently supported BFV preset, including SecureThreshold8192. - uint256 public constant MAX_COMMITTEE_PUBLIC_KEY_BYTES = 256 * 1024; - /// @notice Thrown when {addCiphernode} would push the LazyIMT past its /// configured {TREE_DEPTH} capacity. error CiphernodeTreeExhausted(); @@ -388,23 +387,21 @@ contract CiphernodeRegistryOwnable is /// @inheritdoc ICiphernodeRegistry function publishCommitteePublicKey( uint256 e3Id, - bytes calldata publicKey + bytes32 candidateHash, + uint16 chunkIndex, + uint16 chunkCount, + uint32 totalLength, + bytes calldata chunk ) external { - bytes32 pkCommitment = publicKeyHashes[e3Id]; - require(pkCommitment != bytes32(0), CommitteeNotPublished()); - - uint256 length = publicKey.length; - require( - length != 0 && length <= MAX_COMMITTEE_PUBLIC_KEY_BYTES, - InvalidPublicKeyLength(length, MAX_COMMITTEE_PUBLIC_KEY_BYTES) - ); - - emit CommitteePublished( + RegistrySortitionLib.publishCommitteePublicKeyChunk( + committees, + publicKeyHashes, e3Id, - committees[e3Id].topNodes, - publicKey, - pkCommitment, - bytes("") + candidateHash, + chunkIndex, + chunkCount, + totalLength, + chunk ); } diff --git a/packages/interfold-contracts/contracts/test/MockAvailBridge.sol b/packages/interfold-contracts/contracts/test/MockAvailBridge.sol new file mode 100644 index 0000000000..9e4d9ad9b6 --- /dev/null +++ b/packages/interfold-contracts/contracts/test/MockAvailBridge.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity >=0.8.27; + +import { + IAvailBridge, + IVectorx +} from "../interfaces/external/IAvailBridge.sol"; + +contract MockVectorX is IVectorx { + mapping(bytes32 rangeHash => uint32 startBlock) + public + override rangeStartBlocks; + + function setRangeStartBlock(bytes32 rangeHash, uint32 startBlock) external { + rangeStartBlocks[rangeHash] = startBlock; + } +} + +contract MockAvailBridge is IAvailBridge { + IVectorx public override vectorx; + bool public proofValid = true; + + constructor(IVectorx initialVectorX) { + vectorx = initialVectorX; + } + + function setVectorX(IVectorx nextVectorX) external { + vectorx = nextVectorX; + } + + function setProofValid(bool valid) external { + proofValid = valid; + } + + function verifyBlobLeaf( + MerkleProofInput calldata + ) external view override returns (bool) { + return proofValid; + } +} diff --git a/packages/interfold-contracts/contracts/test/MockCiphernodeRegistry.sol b/packages/interfold-contracts/contracts/test/MockCiphernodeRegistry.sol index 2e0ffd4fd0..70222fbecc 100644 --- a/packages/interfold-contracts/contracts/test/MockCiphernodeRegistry.sol +++ b/packages/interfold-contracts/contracts/test/MockCiphernodeRegistry.sol @@ -130,7 +130,14 @@ contract MockCiphernodeRegistry is ICiphernodeRegistry { ) external pure {} // solhint-disable-line no-empty-blocks // solhint-disable-next-line no-empty-blocks - function publishCommitteePublicKey(uint256, bytes calldata) external pure {} + function publishCommitteePublicKey( + uint256, + bytes32, + uint16, + uint16, + uint32, + bytes calldata + ) external pure {} function releaseCommittee(uint256 e3Id) external { require(_unreleasedCommittees[e3Id], "Committee already released"); @@ -390,7 +397,14 @@ contract MockCiphernodeRegistryEmptyKey is ICiphernodeRegistry { ) external pure {} // solhint-disable-line no-empty-blocks // solhint-disable-next-line no-empty-blocks - function publishCommitteePublicKey(uint256, bytes calldata) external pure {} + function publishCommitteePublicKey( + uint256, + bytes32, + uint16, + uint16, + uint32, + bytes calldata + ) external pure {} // solhint-disable-next-line no-empty-blocks function releaseCommittee(uint256) external pure {} diff --git a/packages/interfold-contracts/contracts/test/MockDataAvailabilityVerifier.sol b/packages/interfold-contracts/contracts/test/MockDataAvailabilityVerifier.sol new file mode 100644 index 0000000000..a506008838 --- /dev/null +++ b/packages/interfold-contracts/contracts/test/MockDataAvailabilityVerifier.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. +pragma solidity >=0.8.27; + +import { + IDataAvailabilityVerifier +} from "../interfaces/IDataAvailabilityVerifier.sol"; + +/// @notice Deterministic local-only replacement for Avail and VectorX. +contract MockDataAvailabilityVerifier is IDataAvailabilityVerifier { + error ContentHashMismatch(bytes32 expected, bytes32 actual); + + /// @inheritdoc IDataAvailabilityVerifier + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) external pure returns (DataReference memory receipt) { + bytes32 actual = keccak256(proof); + if (actual != expectedContentHash) { + revert ContentHashMismatch(expectedContentHash, actual); + } + receipt = DataReference(expectedContentHash, 0, 0); + } +} diff --git a/packages/interfold-contracts/contracts/test/MockE3Program.sol b/packages/interfold-contracts/contracts/test/MockE3Program.sol index d1c5daa910..43574ae1ca 100644 --- a/packages/interfold-contracts/contracts/test/MockE3Program.sol +++ b/packages/interfold-contracts/contracts/test/MockE3Program.sol @@ -6,12 +6,19 @@ pragma solidity 0.8.28; import { IE3Program } from "../interfaces/IE3Program.sol"; +import { + IDataAvailabilityVerifier, + IE3ProgramDataAvailability +} from "../interfaces/IDataAvailabilityVerifier.sol"; /// @title MockE3Program -/// @notice Provides a stateless BFV program for protocol tests on public networks. -/// @dev This contract applies no application-specific input or output rules. Interfold still -/// verifies the BFV ciphertext proof and the committee decryption proof for every E3. -contract MockE3Program is IE3Program { +/// @notice Provides a stateless BFV program for bootstrap deployments and protocol tests. +/// @dev This contract applies no application-specific input or output rules. Its deterministic +/// receipt is not production data availability. Keep requests paused until a production E3 +/// program is registered and wired. Interfold still verifies the BFV protocol proofs. +contract MockE3Program is IE3Program, IE3ProgramDataAvailability { + error InvalidDataAvailabilityProof(); + bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); /// @notice Emitted when a caller publishes test input data. @@ -46,4 +53,24 @@ contract MockE3Program is IE3Program { ) external pure returns (bool success) { return true; } + + /// @inheritdoc IE3ProgramDataAvailability + /// @dev Local and bootstrap deployments use the object bytes as their deterministic receipt. + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) + external + pure + returns (IDataAvailabilityVerifier.DataReference memory receipt) + { + if (keccak256(proof) != expectedContentHash) + revert InvalidDataAvailabilityProof(); + return + IDataAvailabilityVerifier.DataReference({ + contentHash: expectedContentHash, + blockNumber: 1, + leafIndex: 1 + }); + } } diff --git a/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol b/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol index 2addf1f7b5..b661a6666d 100644 --- a/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol +++ b/packages/interfold-contracts/contracts/test/MockE3ProgramHarness.sol @@ -7,6 +7,10 @@ pragma solidity 0.8.28; import { IE3Program } from "../interfaces/IE3Program.sol"; import { IInterfold } from "../interfaces/IInterfold.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + IDataAvailabilityVerifier +} from "../interfaces/IDataAvailabilityVerifier.sol"; /// @dev Test-only E3 program with controls used to exercise failure and reentrancy paths. contract MockE3ProgramHarness is IE3Program { @@ -18,10 +22,15 @@ contract MockE3ProgramHarness is IE3Program { IInterfold public interfold; bool public reenterPlaintextPublication; + bool public returnMismatchedAvailabilityHash; bytes public reentrantPlaintext; bytes public reentrantProof; + address public observedTreasury; + IERC20 public observedFeeToken; + uint256 public pendingTreasuryDuringValidation; mapping(uint256 e3Id => bytes32 paramsHash) public paramsHashes; + mapping(uint256 e3Id => uint256 requestTime) public validationRequestTimes; mapping(uint256 e3Id => bytes32 commitment) public expectedCiphertextCommitments; @@ -45,6 +54,18 @@ contract MockE3ProgramHarness is IE3Program { reentrantProof = proof; } + function setReturnMismatchedAvailabilityHash(bool enabled) external { + returnMismatchedAvailabilityHash = enabled; + } + + function observeTreasuryDuringValidation( + address treasury, + IERC20 token + ) external { + observedTreasury = treasury; + observedFeeToken = token; + } + function validate( uint256 e3Id, uint256, @@ -58,6 +79,15 @@ contract MockE3ProgramHarness is IE3Program { ); require(paramsHashes[e3Id] == bytes32(0), E3AlreadyInitialized()); + if (address(interfold) != address(0)) { + // Production programs can inspect the provisional E3 while validating the request. + // This assertion prevents fixtures from hiding a different production call order. + validationRequestTimes[e3Id] = interfold.getE3(e3Id).requestBlock; + if (address(observedFeeToken) != address(0)) { + pendingTreasuryDuringValidation = interfold + .pendingTreasuryClaim(observedTreasury, observedFeeToken); + } + } paramsHashes[e3Id] = keccak256(e3ProgramParams); return ENCRYPTION_SCHEME_ID; } @@ -83,9 +113,14 @@ contract MockE3ProgramHarness is IE3Program { if (address(interfold) != address(0)) { interfold.publishCiphertextOutput( e3Id, - data, - ciphertextCommitment, - data + abi.encode( + IInterfold.CiphertextOutputReference({ + contentHash: keccak256(data), + ciphertextCommitment: ciphertextCommitment, + computeProof: data, + availabilityProof: data + }) + ) ); } } @@ -109,4 +144,23 @@ contract MockE3ProgramHarness is IE3Program { } return data.length > 0; } + + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) + external + view + returns (IDataAvailabilityVerifier.DataReference memory receipt) + { + require(keccak256(proof) == expectedContentHash, InvalidInput()); + return + IDataAvailabilityVerifier.DataReference({ + contentHash: returnMismatchedAvailabilityHash + ? bytes32(uint256(expectedContentHash) ^ 1) + : expectedContentHash, + blockNumber: 1, + leafIndex: 1 + }); + } } diff --git a/packages/interfold-contracts/contracts/verifiers/AvailVectorXDataAvailabilityVerifier.sol b/packages/interfold-contracts/contracts/verifiers/AvailVectorXDataAvailabilityVerifier.sol new file mode 100644 index 0000000000..d02f7f4f86 --- /dev/null +++ b/packages/interfold-contracts/contracts/verifiers/AvailVectorXDataAvailabilityVerifier.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. +pragma solidity >=0.8.27; + +import { + IDataAvailabilityVerifier +} from "../interfaces/IDataAvailabilityVerifier.sol"; +import { + IAvailBridge, + IVectorx +} from "../interfaces/external/IAvailBridge.sol"; + +/// @notice Verifies Avail blob inclusion through the official VectorX bridge. +/// @dev The expected bridge and VectorX contracts are immutable. If Avail governance rotates the +/// bridge's VectorX pointer, this adapter fails closed and a new E3 program must be deployed. +contract AvailVectorXDataAvailabilityVerifier is IDataAvailabilityVerifier { + IAvailBridge public immutable bridge; + IVectorx public immutable vectorx; + + error InvalidBridge(); + error InvalidVectorX(); + error ContentHashMismatch(bytes32 expected, bytes32 actual); + error InvalidAvailabilityProof(); + error DataRootIndexTooLarge(uint256 value); + error LeafIndexTooLarge(uint256 value); + error BlockNumberOverflow(); + + constructor(IAvailBridge _bridge, IVectorx _vectorx) { + if (address(_bridge).code.length == 0) revert InvalidBridge(); + if (address(_vectorx).code.length == 0) revert InvalidVectorX(); + if (address(_bridge.vectorx()) != address(_vectorx)) + revert InvalidVectorX(); + bridge = _bridge; + vectorx = _vectorx; + } + + /// @inheritdoc IDataAvailabilityVerifier + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) external view returns (DataReference memory receipt) { + // Re-check on every proof. A bridge-side verifier rotation must not silently change the + // trust root of an already deployed CRISP program. + if (address(bridge.vectorx()) != address(vectorx)) + revert InvalidVectorX(); + + IAvailBridge.MerkleProofInput memory input = abi.decode( + proof, + (IAvailBridge.MerkleProofInput) + ); + // The bridge proof exposes the first hash of the raw payload. The official bridge hashes + // `input.leaf` again when it verifies the submitted-data Merkle tree. + if (input.leaf != expectedContentHash) { + revert ContentHashMismatch(expectedContentHash, input.leaf); + } + if (!bridge.verifyBlobLeaf(input)) revert InvalidAvailabilityProof(); + if (input.dataRootIndex > type(uint32).max) { + revert DataRootIndexTooLarge(input.dataRootIndex); + } + if (input.leafIndex > type(uint128).max) { + revert LeafIndexTooLarge(input.leafIndex); + } + + uint256 blockNumber = uint256( + vectorx.rangeStartBlocks(input.rangeHash) + ) + + input.dataRootIndex + + 1; + if (blockNumber > type(uint32).max) revert BlockNumberOverflow(); + + receipt = DataReference({ + contentHash: expectedContentHash, + blockNumber: uint32(blockNumber), + leafIndex: uint128(input.leafIndex) + }); + } +} diff --git a/packages/interfold-contracts/scripts/dataAvailability.ts b/packages/interfold-contracts/scripts/dataAvailability.ts new file mode 100644 index 0000000000..24ea8bc944 --- /dev/null +++ b/packages/interfold-contracts/scripts/dataAvailability.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +/** Official Avail bridge and VectorX verifier addresses accepted by this release. */ +export const AVAIL_VECTORX = { + mainnet: { + chainId: 1, + bridge: "0x054fd961708d8e2b9c10a63f6157c74458889f0a", + vectorx: "0x02993cdC11213985b9B13224f3aF289F03bf298d", + }, + sepolia: { + chainId: 11155111, + bridge: "0x967F7DdC4ec508462231849AE81eeaa68Ad01389", + vectorx: "0xe542db219a7e2b29c7aeaeace242c9a2cd528f96", + }, +} as const; + +/** Time reserved between the last accepted input proof and the Ethereum input deadline. */ +export const AVAIL_FINALIZATION_WINDOW_SECONDS = 10_800; + +/** Voting time CRISP must leave after the worst-case committee setup. */ +export const CRISP_MIN_VOTING_DURATION_SECONDS = 3_600; + +export function availVectorXForChain(chainId: number) { + const entry = Object.values(AVAIL_VECTORX).find( + (candidate) => candidate.chainId === chainId, + ); + if (!entry) { + throw new Error( + `Avail/VectorX data availability is not configured for chain ${chainId}`, + ); + } + return entry; +} diff --git a/packages/interfold-contracts/scripts/index.ts b/packages/interfold-contracts/scripts/index.ts index 8780d854fa..cade25b3ee 100644 --- a/packages/interfold-contracts/scripts/index.ts +++ b/packages/interfold-contracts/scripts/index.ts @@ -21,3 +21,4 @@ export * from "./deployAndSave/mockPkVerifier"; export * from "./deployAndSave/mockProgram"; export * from "./deployAndSave/verifiers"; export * from "./verify"; +export * from "./dataAvailability"; diff --git a/packages/interfold-contracts/scripts/protocol/actions.ts b/packages/interfold-contracts/scripts/protocol/actions.ts index d75103cb9e..e970b922c3 100644 --- a/packages/interfold-contracts/scripts/protocol/actions.ts +++ b/packages/interfold-contracts/scripts/protocol/actions.ts @@ -10,6 +10,7 @@ import { deploymentPath, governanceSafeBuilderPath, readJson, + repoRelativePath, safeBatchPath, writeJson, } from "./files"; @@ -247,8 +248,10 @@ export async function actionDeploy(): Promise { bondingRegistryProxy: config.bondingRegistryProxy, bondingRegistryProxyAdmin: config.bondingRegistryProxyAdmin, ...result.contracts, - safeTransactions: batchFile, - governanceSafeBuilder: governanceSafeBuilderFile, + safeTransactions: repoRelativePath(batchFile), + governanceSafeBuilder: governanceSafeBuilderFile + ? repoRelativePath(governanceSafeBuilderFile) + : undefined, }; const deploymentFile = deploymentPath(config); writeJson(deploymentFile, deployment); diff --git a/packages/interfold-contracts/scripts/protocol/files.ts b/packages/interfold-contracts/scripts/protocol/files.ts index f8bb7a2b61..4f71d3f54a 100644 --- a/packages/interfold-contracts/scripts/protocol/files.ts +++ b/packages/interfold-contracts/scripts/protocol/files.ts @@ -36,6 +36,10 @@ export function resolvePath(input: string): string { return path.join(repoRoot, input); } +export function repoRelativePath(input: string): string { + return path.relative(repoRoot, input).split(path.sep).join("/"); +} + export function defaultConfigPath(): string { return path.join(protocolDir, `${networkName()}-protocol.config.json`); } diff --git a/packages/interfold-contracts/scripts/protocol/nodeRelease.ts b/packages/interfold-contracts/scripts/protocol/nodeRelease.ts index dff87ad9ad..e88ef75cd3 100644 --- a/packages/interfold-contracts/scripts/protocol/nodeRelease.ts +++ b/packages/interfold-contracts/scripts/protocol/nodeRelease.ts @@ -20,6 +20,34 @@ export interface CurrentNodeRelease { releaseId: string; } +/** + * Returns whether governance must raise the on-chain release policy. + * + * A matching policy is already valid and must not be submitted again because + * `NodeReleaseRegistry` rejects no-op updates. A release that is older in either dimension is not + * safe to activate. + */ +export function requiresNodeReleasePolicyUpdate( + release: Pick, + requiredProtocolVersion: bigint, + requiredNodeGeneration: bigint, +): boolean { + const protocolVersion = BigInt(release.protocolVersion); + const nodeGeneration = BigInt(release.nodeGeneration); + if ( + protocolVersion < requiredProtocolVersion || + nodeGeneration < requiredNodeGeneration + ) { + throw new Error( + `Node release cannot move backwards from protocol ${requiredProtocolVersion}, generation ${requiredNodeGeneration} to protocol ${protocolVersion}, generation ${nodeGeneration}`, + ); + } + return ( + protocolVersion !== requiredProtocolVersion || + nodeGeneration !== requiredNodeGeneration + ); +} + export function requiredCircuitsVersion(): string { const root = getRepoRoot(); const versions = JSON.parse( diff --git a/packages/interfold-contracts/scripts/protocol/types.ts b/packages/interfold-contracts/scripts/protocol/types.ts index da02eda0f8..22133e0911 100644 --- a/packages/interfold-contracts/scripts/protocol/types.ts +++ b/packages/interfold-contracts/scripts/protocol/types.ts @@ -177,6 +177,7 @@ export interface ProtocolDeployment { bfvVerifierRoutes?: BfvVerifierRouteDeployment[]; ciphertextVerifier?: string; crispProgram?: string; + dataAvailabilityVerifier?: string; initialE3Program: string; ticketToken: string; slashingManager: string; @@ -251,6 +252,9 @@ export interface SecureCrispUpgradePlan { lifecycleLibrary: string; pricingLibrary: string; registryProxy: string; + registryProxyAdmin: string; + registryImplementation: string; + sortitionLibrary: string; nodeReleaseRegistry: string; nodeRelease: { version: string; @@ -264,6 +268,12 @@ export interface SecureCrispUpgradePlan { decryptionVerifier: string; ciphertextVerifier: string; crispProgram: string; + /** Bootstrap mock closed to new requests by this activation, when present. */ + retiredE3Program?: string; + dataAvailabilityVerifier: string; + inputAvailabilitySigner: string; + availBridge: string; + vectorx: string; bfvVerifierRoutes: BfvVerifierRouteDeployment[]; safeTransactions: string; governanceSafeBuilder?: string; diff --git a/packages/interfold-contracts/scripts/upgrade/nodeRelease.ts b/packages/interfold-contracts/scripts/upgrade/nodeRelease.ts index 751d0fdce1..11ad4ce4f3 100644 --- a/packages/interfold-contracts/scripts/upgrade/nodeRelease.ts +++ b/packages/interfold-contracts/scripts/upgrade/nodeRelease.ts @@ -7,6 +7,7 @@ import { governanceSafeBuilderPath, protocolDir, readJson, + repoRelativePath, writeJson, } from "../protocol/files"; import { currentNodeRelease } from "../protocol/nodeRelease"; @@ -184,8 +185,10 @@ async function prepare(): Promise { mandatory, nodeReleaseRegistry: deployment.nodeReleaseRegistry, release, - safeTransactions: rawBatchFile, - governanceSafeBuilder: safeBuilderFile, + safeTransactions: repoRelativePath(rawBatchFile), + governanceSafeBuilder: safeBuilderFile + ? repoRelativePath(safeBuilderFile) + : undefined, }; writeJson(planPath(config.name), plan); console.log( diff --git a/packages/interfold-contracts/scripts/upgrade/resumeSecureCrisp.ts b/packages/interfold-contracts/scripts/upgrade/resumeSecureCrisp.ts index caca965400..4148a886bf 100644 --- a/packages/interfold-contracts/scripts/upgrade/resumeSecureCrisp.ts +++ b/packages/interfold-contracts/scripts/upgrade/resumeSecureCrisp.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { connect, hasFlag } from "../protocol/cli"; +import { arg, connect, hasFlag } from "../protocol/cli"; import { deploymentPath, governanceSafeBuilderPath, @@ -24,6 +24,43 @@ import type { import { loadConfig } from "../protocol/values"; import { validateSecureCrispUpgrade } from "./validateSecureCrisp"; +type CommitteeThreshold = { + size: string; + total: string; +}; + +/** Selects the operator-capacity gate for a production resume or a named Sepolia rehearsal. */ +export function requiredActiveOperatorsForSecureCrisp( + thresholds: CommitteeThreshold[], + chainId: number, + rehearsalCommitteeSize?: string, +): bigint { + if (rehearsalCommitteeSize !== undefined) { + if (chainId !== 11155111) { + throw new Error( + "A reduced committee-capacity gate is available only for a Sepolia rehearsal", + ); + } + if (!/^\d+$/.test(rehearsalCommitteeSize)) { + throw new Error("The rehearsal committee size must be an integer ID"); + } + const selected = thresholds.find( + (threshold) => threshold.size === rehearsalCommitteeSize, + ); + if (!selected) { + throw new Error( + `Committee size ${rehearsalCommitteeSize} is not configured`, + ); + } + return BigInt(selected.total); + } + + return thresholds.reduce((maximum, threshold) => { + const total = BigInt(threshold.total); + return total > maximum ? total : maximum; + }, 0n); +} + function planPath(name: string): string { return path.join(protocolDir, `${name}.secure-crisp.upgrade.json`); } @@ -45,7 +82,7 @@ export async function prepareSecureCrispResume(): Promise { const { ethers } = await connect(); const config = loadConfig(); - if (!config.governance) { + if (config.chainId === 1 && !config.governance) { throw new Error("Aragon governance is required for mainnet resume"); } const deployment = readJson(deploymentPath(config)); @@ -59,12 +96,11 @@ export async function prepareSecureCrispResume(): Promise { config.bondingRegistryProxy, ); - const requiredActive = config.interfold.committeeThresholds.reduce( - (maximum, threshold) => { - const total = BigInt(threshold.total); - return total > maximum ? total : maximum; - }, - 0n, + const rehearsalCommitteeSize = arg("sepolia-committee-size"); + const requiredActive = requiredActiveOperatorsForSecureCrisp( + config.interfold.committeeThresholds, + config.chainId, + rehearsalCommitteeSize, ); const active = await bonding.numActiveOperators(); if (active < requiredActive) { @@ -86,20 +122,26 @@ export async function prepareSecureCrispResume(): Promise { "Resume E3 requests after secure CRISP validation and the ciphernode protocol cutover."; writeJson(rawBatchFile, batch); - const safeBuilderFile = governanceSafeBuilderPath({ - ...config, - name: `${config.name}.secure-crisp.resume`, - }); - const safeBatch = aragonAdminSafeBatch(config, txs); - safeBatch.meta.name = batch.meta.name; - safeBatch.meta.description = batch.meta.description; - writeJson(safeBuilderFile, safeBatch); + let safeBuilderFile: string | undefined; + if (config.governance) { + safeBuilderFile = governanceSafeBuilderPath({ + ...config, + name: `${config.name}.secure-crisp.resume`, + }); + const safeBatch = aragonAdminSafeBatch(config, txs); + safeBatch.meta.name = batch.meta.name; + safeBatch.meta.description = batch.meta.description; + writeJson(safeBuilderFile, safeBatch); + } if (hasFlag("propose-safe")) { + const proposalTransactions = config.governance + ? aragonAdminSafeTransactions(config, txs) + : txs; await proposeSafeBatch( config, - aragonAdminSafeTransactions(config, txs), - config.governance.proposerSafe, + proposalTransactions, + config.governance?.proposerSafe ?? config.safe, ); } @@ -107,8 +149,9 @@ export async function prepareSecureCrispResume(): Promise { Secure CRISP resume prepared node release: ${plan.nodeRelease.version} (protocol ${plan.nodeRelease.protocolVersion}) active operators: ${active}/${requiredActive} + rehearsal size: ${rehearsalCommitteeSize ?? "all configured sizes"} governance batch: ${rawBatchFile} - Aragon Safe batch: ${safeBuilderFile} + Aragon Safe batch: ${safeBuilderFile ?? "not configured"} `); } diff --git a/packages/interfold-contracts/scripts/upgrade/safeProxyUpgrade.ts b/packages/interfold-contracts/scripts/upgrade/safeProxyUpgrade.ts index 6419fb1c60..977d758aec 100644 --- a/packages/interfold-contracts/scripts/upgrade/safeProxyUpgrade.ts +++ b/packages/interfold-contracts/scripts/upgrade/safeProxyUpgrade.ts @@ -9,6 +9,7 @@ import { deploymentPath, protocolDir, readJson, + repoRelativePath, writeJson, } from "../protocol/files"; import { governanceBatch, proposeSafeBatch, safeTx } from "../protocol/safe"; @@ -145,7 +146,7 @@ export async function proposeProxyUpgrade( operator: operatorAddress, protocolOwner: config.protocolOwner, safe: config.safe, - safeTransactions: batchFile, + safeTransactions: repoRelativePath(batchFile), }; if (hasFlag("propose-safe")) { diff --git a/packages/interfold-contracts/scripts/upgrade/secureCrisp.ts b/packages/interfold-contracts/scripts/upgrade/secureCrisp.ts index 1440aeaa21..d7903fb647 100644 --- a/packages/interfold-contracts/scripts/upgrade/secureCrisp.ts +++ b/packages/interfold-contracts/scripts/upgrade/secureCrisp.ts @@ -4,6 +4,11 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { + AVAIL_FINALIZATION_WINDOW_SECONDS, + CRISP_MIN_VOTING_DURATION_SECONDS, + availVectorXForChain, +} from "../dataAvailability"; import { arg, connect, hasFlag, networkName } from "../protocol/cli"; import { BFV_PARAMS, ZERO, proxyAdminInterface } from "../protocol/constants"; import { deployBfvVerifierRoutes } from "../protocol/deployContracts"; @@ -12,6 +17,7 @@ import { governanceSafeBuilderPath, protocolDir, readJson, + repoRelativePath, repoRoot, resolvePath, writeJson, @@ -19,6 +25,7 @@ import { import { currentNodeRelease, requiredCircuitsVersion, + requiresNodeReleasePolicyUpdate, } from "../protocol/nodeRelease"; import { aragonAdminSafeBatch, @@ -39,7 +46,11 @@ import { loadConfig, requireContract, } from "../protocol/values"; -import { MAINNET_BFV_CONFIGS, PRODUCTION_BFV_CONFIG } from "../utils"; +import { + PRODUCTION_BFV_CONFIG, + activeBfvConfigForChain, + bfvConfigsForChain, +} from "../utils"; import { deployUpgradeImplementation, proxyImplementation, @@ -54,11 +65,22 @@ const crispInterface = new ethersLib.Interface([ "function owner() view returns (address)", "function imageId() view returns (bytes32)", "function risc0Verifier() view returns (address)", + "function dataAvailabilityVerifier() view returns (address)", + "function availabilityFinalizationWindow() view returns (uint256)", + "function MIN_VOTING_DURATION() view returns (uint256)", + "function inputAvailabilitySigner() view returns (address)", ]); const ciphertextInterface = new ethersLib.Interface([ "function imageId() view returns (bytes32)", "function risc0Verifier() view returns (address)", ]); +const dataAvailabilityInterface = new ethersLib.Interface([ + "function bridge() view returns (address)", + "function vectorx() view returns (address)", +]); +const availBridgeInterface = new ethersLib.Interface([ + "function vectorx() view returns (address)", +]); type CrispDeploymentRecord = Record< string, @@ -90,21 +112,33 @@ function defaultCrispDeploymentsPath(): string { export function resolveCrispAddresses(): { crispProgram: string; ciphertextVerifier: string; + dataAvailabilityVerifier: string; } { const crispProgramOverride = arg("crisp-program"); const ciphertextOverride = arg("ciphertext-verifier"); - if (Boolean(crispProgramOverride) !== Boolean(ciphertextOverride)) { + const dataAvailabilityOverride = arg("data-availability-verifier"); + if ( + new Set([ + Boolean(crispProgramOverride), + Boolean(ciphertextOverride), + Boolean(dataAvailabilityOverride), + ]).size !== 1 + ) { throw new Error( - "Pass both --crisp-program and --ciphertext-verifier, or neither", + "Pass --crisp-program, --ciphertext-verifier, and --data-availability-verifier together, or pass none", ); } - if (crispProgramOverride && ciphertextOverride) { + if (crispProgramOverride && ciphertextOverride && dataAvailabilityOverride) { return { crispProgram: address(crispProgramOverride, "CRISP program"), ciphertextVerifier: address( ciphertextOverride, "CRISP ciphertext verifier", ), + dataAvailabilityVerifier: address( + dataAvailabilityOverride, + "CRISP data-availability verifier", + ), }; } @@ -128,6 +162,13 @@ export function resolveCrispAddresses(): { deployment.Risc0BfvCiphertextVerifier?.address ?? "", "Risc0BfvCiphertextVerifier", ), + dataAvailabilityVerifier: address( + deployment.AvailVectorXDataAvailabilityVerifier?.address ?? + deployment.MockCrispDataAvailabilityVerifier?.address ?? + deployment.DataAvailabilityVerifier?.address ?? + "", + "DataAvailabilityVerifier", + ), }; } @@ -150,13 +191,14 @@ async function requireProxyAdminOwner( ethers: any, proxyAdmin: string, expectedOwner: string, + label: string, ): Promise { - await requireContract(ethers.provider, proxyAdmin, "Interfold ProxyAdmin"); + await requireContract(ethers.provider, proxyAdmin, `${label} ProxyAdmin`); const admin = await ethers.getContractAt("ProxyAdmin", proxyAdmin); const owner = await admin.owner(); if (owner.toLowerCase() !== expectedOwner.toLowerCase()) { throw new Error( - `Interfold ProxyAdmin owner mismatch: expected ${expectedOwner}, got ${owner}`, + `${label} ProxyAdmin owner mismatch: expected ${expectedOwner}, got ${owner}`, ); } } @@ -177,17 +219,29 @@ export async function prepareSecureCrispUpgrade(): Promise { const config = loadConfig(); const deployment = readJson(deploymentPath(config)); const network = await ethers.provider.getNetwork(); - if (Number(network.chainId) !== 1 || config.chainId !== 1) { - throw new Error("The secure CRISP activation script is Ethereum-only"); - } - if (deployment.chainId !== 1) { - throw new Error("The protocol deployment file is not for Ethereum mainnet"); + const chainId = Number(network.chainId); + if ( + ![1, 11155111].includes(chainId) || + config.chainId !== chainId || + deployment.chainId !== chainId + ) { + throw new Error( + "Secure CRISP activation supports matching Ethereum mainnet or Sepolia deployments", + ); } - if (!config.governance) { + if (chainId === 1 && !config.governance) { throw new Error("Aragon governance is required for mainnet activation"); } - const { crispProgram, ciphertextVerifier } = resolveCrispAddresses(); + const { crispProgram, ciphertextVerifier, dataAvailabilityVerifier } = + resolveCrispAddresses(); + const inputAvailabilitySigner = address( + arg("input-availability-signer") ?? + process.env.INPUT_AVAILABILITY_SIGNER ?? + "", + "input availability signer", + ); + const avail = availVectorXForChain(chainId); await Promise.all([ requireContract(ethers.provider, deployment.interfold, "Interfold proxy"), requireContract( @@ -206,10 +260,22 @@ export async function prepareSecureCrispUpgrade(): Promise { ciphertextVerifier, "CRISP ciphertext verifier", ), + requireContract( + ethers.provider, + dataAvailabilityVerifier, + "CRISP data-availability verifier", + ), requireProxyAdminOwner( ethers, deployment.interfoldProxyAdmin, config.protocolOwner, + "Interfold", + ), + requireProxyAdminOwner( + ethers, + deployment.ciphernodeRegistryProxyAdmin, + config.protocolOwner, + "CiphernodeRegistry", ), ]); @@ -276,16 +342,11 @@ export async function prepareSecureCrispUpgrade(): Promise { releases.requiredProtocolVersion(), releases.requiredNodeGeneration(), ]); - if (BigInt(nodeRelease.protocolVersion) <= requiredProtocolVersion) { - throw new Error( - `Secure CRISP requires a new protocol_version above ${requiredProtocolVersion}; this release declares ${nodeRelease.protocolVersion}`, - ); - } - if (BigInt(nodeRelease.nodeGeneration) < requiredNodeGeneration) { - throw new Error( - `node_generation cannot move backwards from ${requiredNodeGeneration} to ${nodeRelease.nodeGeneration}`, - ); - } + const updateNodeReleasePolicy = requiresNodeReleasePolicyUpdate( + nodeRelease, + requiredProtocolVersion, + requiredNodeGeneration, + ); const liveImplementation = await proxyImplementation( ethers, deployment.interfold, @@ -298,6 +359,18 @@ export async function prepareSecureCrispUpgrade(): Promise { `Interfold deployment record is stale: recorded ${deployment.interfoldImplementation}, live ${liveImplementation}`, ); } + const liveRegistryImplementation = await proxyImplementation( + ethers, + deployment.ciphernodeRegistry, + ); + if ( + liveRegistryImplementation.toLowerCase() !== + deployment.ciphernodeRegistryImplementation.toLowerCase() + ) { + throw new Error( + `CiphernodeRegistry deployment record is stale: recorded ${deployment.ciphernodeRegistryImplementation}, live ${liveRegistryImplementation}`, + ); + } const crispOwner = await readContract( ethers.provider, @@ -370,6 +443,94 @@ export async function prepareSecureCrispUpgrade(): Promise { "CRISP RISC Zero verifier", ); + const crispDataAvailability = await readContract( + ethers.provider, + crispProgram, + crispInterface, + "dataAvailabilityVerifier", + ); + if ( + String(crispDataAvailability).toLowerCase() !== + dataAvailabilityVerifier.toLowerCase() + ) { + throw new Error( + `CRISP data-availability verifier mismatch: expected ${dataAvailabilityVerifier}, got ${crispDataAvailability}`, + ); + } + const crispFinalizationWindow = await readContract( + ethers.provider, + crispProgram, + crispInterface, + "availabilityFinalizationWindow", + ); + if ( + BigInt(String(crispFinalizationWindow)) !== + BigInt(AVAIL_FINALIZATION_WINDOW_SECONDS) + ) { + throw new Error( + `CRISP availability finalization window mismatch: expected ${AVAIL_FINALIZATION_WINDOW_SECONDS}, got ${crispFinalizationWindow}`, + ); + } + const crispMinimumVotingDuration = await readContract( + ethers.provider, + crispProgram, + crispInterface, + "MIN_VOTING_DURATION", + ); + if ( + BigInt(String(crispMinimumVotingDuration)) !== + BigInt(CRISP_MIN_VOTING_DURATION_SECONDS) + ) { + throw new Error( + `CRISP minimum voting duration mismatch: expected ${CRISP_MIN_VOTING_DURATION_SECONDS}, got ${crispMinimumVotingDuration}`, + ); + } + const configuredInputAvailabilitySigner = String( + await readContract( + ethers.provider, + crispProgram, + crispInterface, + "inputAvailabilitySigner", + ), + ); + if ( + configuredInputAvailabilitySigner.toLowerCase() !== + inputAvailabilitySigner.toLowerCase() + ) { + throw new Error( + `CRISP input availability signer mismatch: expected ${inputAvailabilitySigner}, got ${configuredInputAvailabilitySigner}`, + ); + } + const [adapterBridge, adapterVectorX, liveBridgeVectorX] = await Promise.all([ + readContract( + ethers.provider, + dataAvailabilityVerifier, + dataAvailabilityInterface, + "bridge", + ), + readContract( + ethers.provider, + dataAvailabilityVerifier, + dataAvailabilityInterface, + "vectorx", + ), + readContract( + ethers.provider, + avail.bridge, + availBridgeInterface, + "vectorx", + ), + ]); + for (const [label, actual, expected] of [ + ["adapter bridge", adapterBridge, avail.bridge], + ["adapter VectorX", adapterVectorX, avail.vectorx], + ["live bridge VectorX", liveBridgeVectorX, avail.vectorx], + ] as const) { + if (String(actual).toLowerCase() !== expected.toLowerCase()) { + throw new Error(`${label} mismatch: expected ${expected}, got ${actual}`); + } + } + const secureParams = encodeBfvParams(BFV_PARAMS.secure8192); const currentParams = await interfold.paramSetRegistry(SECURE_PARAM_SET); if ( @@ -382,23 +543,39 @@ export async function prepareSecureCrispUpgrade(): Promise { } const [operator] = await ethers.getSigners(); + const verifierDefault = activeBfvConfigForChain(chainId); + const verifierConfigs = bfvConfigsForChain(chainId); + const registryUpgrade = await deployUpgradeImplementation( + ethers, + operator, + "ciphernodeRegistry", + deployment, + ); const interfoldUpgrade = await deployUpgradeImplementation( ethers, operator, "interfold", deployment, ); + if (!registryUpgrade.sortitionLibrary) { + throw new Error("Registry sortition library was not deployed"); + } if (!interfoldUpgrade.lifecycleLibrary || !interfoldUpgrade.pricingLibrary) { throw new Error("Interfold libraries were not deployed"); } const verifierDeployment = await deployBfvVerifierRoutes( ethers, deployment.ciphernodeRegistry, - PRODUCTION_BFV_CONFIG, - MAINNET_BFV_CONFIGS, + verifierDefault, + verifierConfigs, ); const txs: SafeTransaction[] = [ + upgradeTransaction( + deployment.ciphernodeRegistryProxyAdmin, + deployment.ciphernodeRegistry, + registryUpgrade.implementation, + ), upgradeTransaction( deployment.interfoldProxyAdmin, deployment.interfold, @@ -417,7 +594,11 @@ export async function prepareSecureCrispUpgrade(): Promise { ); } for (const threshold of config.interfold.committeeThresholds) { - const current = await interfold.committeeThresholds(BigInt(threshold.size)); + const size = BigInt(threshold.size); + const current = await Promise.all([ + interfold.committeeThresholds(size, 0n), + interfold.committeeThresholds(size, 1n), + ]); if ( current[0] !== BigInt(threshold.quorum) || current[1] !== BigInt(threshold.total) @@ -474,6 +655,24 @@ export async function prepareSecureCrispUpgrade(): Promise { ), ); } + let retiredE3Program: string | undefined; + const initialE3Program = address( + deployment.initialE3Program, + "initial E3 program", + ); + if (initialE3Program.toLowerCase() !== crispProgram.toLowerCase()) { + if (await interfold.e3Programs(initialE3Program)) { + retiredE3Program = initialE3Program; + txs.push( + safeTx( + deployment.interfold, + interfold.interface.encodeFunctionData("unregisterE3Program", [ + initialE3Program, + ]), + ), + ); + } + } if (normalizedBoundInterfold === ZERO.toLowerCase()) { txs.push( safeTx( @@ -484,30 +683,35 @@ export async function prepareSecureCrispUpgrade(): Promise { ), ); } - txs.push( - safeTx( - deployment.nodeReleaseRegistry, - releases.interface.encodeFunctionData("setRequiredNodeRelease", [ - nodeRelease.protocolVersion, - nodeRelease.nodeGeneration, - ]), - ), - ); + if (updateNodeReleasePolicy) { + txs.push( + safeTx( + deployment.nodeReleaseRegistry, + releases.interface.encodeFunctionData("setRequiredNodeRelease", [ + nodeRelease.protocolVersion, + nodeRelease.nodeGeneration, + ]), + ), + ); + } const rawBatchFile = batchPath(config); const batch = governanceBatch(config, txs); batch.meta.name = `${config.name} secure CRISP activation`; batch.meta.description = - "Install secure BFV routes, bind CRISP, and require the matching ciphernode protocol while requests remain paused."; + "Install secure BFV routes, bind CRISP, retire the incompatible initial E3 program, and require the matching ciphernode protocol while requests remain paused."; writeJson(rawBatchFile, batch); - const safeBuilderFile = governanceSafeBuilderPath({ - ...config, - name: `${config.name}.secure-crisp.upgrade`, - }); - const safeBatch = aragonAdminSafeBatch(config, txs); - safeBatch.meta.name = `${config.name} secure CRISP activation`; - writeJson(safeBuilderFile, safeBatch); + let safeBuilderFile: string | undefined; + if (config.governance) { + safeBuilderFile = governanceSafeBuilderPath({ + ...config, + name: `${config.name}.secure-crisp.upgrade`, + }); + const safeBatch = aragonAdminSafeBatch(config, txs); + safeBatch.meta.name = `${config.name} secure CRISP activation`; + writeJson(safeBuilderFile, safeBatch); + } const plan: SecureCrispUpgradePlan = { name: config.name, @@ -520,6 +724,9 @@ export async function prepareSecureCrispUpgrade(): Promise { lifecycleLibrary: interfoldUpgrade.lifecycleLibrary, pricingLibrary: interfoldUpgrade.pricingLibrary, registryProxy: deployment.ciphernodeRegistry, + registryProxyAdmin: deployment.ciphernodeRegistryProxyAdmin, + registryImplementation: registryUpgrade.implementation, + sortitionLibrary: registryUpgrade.sortitionLibrary, nodeReleaseRegistry: deployment.nodeReleaseRegistry, nodeRelease, cryptoConfigId: PRODUCTION_BFV_CONFIG.configId, @@ -528,28 +735,41 @@ export async function prepareSecureCrispUpgrade(): Promise { decryptionVerifier: verifierDeployment.decryptionVerifier, ciphertextVerifier, crispProgram, + retiredE3Program, + dataAvailabilityVerifier, + inputAvailabilitySigner, + availBridge: avail.bridge, + vectorx: avail.vectorx, bfvVerifierRoutes: verifierDeployment.bfvVerifierRoutes, - safeTransactions: rawBatchFile, - governanceSafeBuilder: safeBuilderFile, + safeTransactions: repoRelativePath(rawBatchFile), + governanceSafeBuilder: safeBuilderFile + ? repoRelativePath(safeBuilderFile) + : undefined, }; if (hasFlag("propose-safe")) { - plan.safeProposal = await proposeSafeBatch( - config, - aragonAdminSafeTransactions(config, txs), - config.governance.proposerSafe, - ); + plan.safeProposal = config.governance + ? await proposeSafeBatch( + config, + aragonAdminSafeTransactions(config, txs), + config.governance.proposerSafe, + ) + : await proposeSafeBatch(config, txs); } writeJson(planPath(config), plan); console.log(` Secure CRISP activation prepared Interfold implementation: ${plan.interfoldImplementation} + Registry implementation: ${plan.registryImplementation} PK verifier router: ${plan.pkVerifier} decryption router: ${plan.decryptionVerifier} CRISP program: ${plan.crispProgram} + retired initial program: ${plan.retiredE3Program ?? "none"} + DA verifier: ${plan.dataAvailabilityVerifier} + input availability signer: ${plan.inputAvailabilitySigner} required node release: ${plan.nodeRelease.version} (protocol ${plan.nodeRelease.protocolVersion}, generation ${plan.nodeRelease.nodeGeneration}) governance batch: ${plan.safeTransactions} - Aragon Safe batch: ${plan.governanceSafeBuilder} + Aragon Safe batch: ${plan.governanceSafeBuilder ?? "not configured"} transactions: ${txs.length} requests remain paused after execution `); diff --git a/packages/interfold-contracts/scripts/upgrade/validateSecureCrisp.ts b/packages/interfold-contracts/scripts/upgrade/validateSecureCrisp.ts index b8045adf05..0509a961d5 100644 --- a/packages/interfold-contracts/scripts/upgrade/validateSecureCrisp.ts +++ b/packages/interfold-contracts/scripts/upgrade/validateSecureCrisp.ts @@ -3,6 +3,11 @@ import { ethers as ethersLib } from "ethers"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { + AVAIL_FINALIZATION_WINDOW_SECONDS, + CRISP_MIN_VOTING_DURATION_SECONDS, + availVectorXForChain, +} from "../dataAvailability"; import { connect } from "../protocol/cli"; import { BFV_PARAMS } from "../protocol/constants"; import { @@ -26,8 +31,9 @@ import { requireContract, } from "../protocol/values"; import { - MAINNET_BFV_CONFIGS, PRODUCTION_BFV_CONFIG, + activeBfvConfigForChain, + bfvConfigsForChain, getBfvDecryptionSubCircuitVkHashPaths, getBfvPkSubCircuitVkHashPaths, readVkRecursiveHash, @@ -40,11 +46,22 @@ const crispInterface = new ethersLib.Interface([ "function interfold() view returns (address)", "function imageId() view returns (bytes32)", "function risc0Verifier() view returns (address)", + "function dataAvailabilityVerifier() view returns (address)", + "function availabilityFinalizationWindow() view returns (uint256)", + "function MIN_VOTING_DURATION() view returns (uint256)", + "function inputAvailabilitySigner() view returns (address)", ]); const ciphertextInterface = new ethersLib.Interface([ "function imageId() view returns (bytes32)", "function risc0Verifier() view returns (address)", ]); +const dataAvailabilityInterface = new ethersLib.Interface([ + "function bridge() view returns (address)", + "function vectorx() view returns (address)", +]); +const availBridgeInterface = new ethersLib.Interface([ + "function vectorx() view returns (address)", +]); function planPath(config: ProtocolConfigFile): string { return path.join(protocolDir, `${config.name}.secure-crisp.upgrade.json`); @@ -80,14 +97,20 @@ export async function validateSecureCrispUpgrade(): Promise { const deployment = readJson(deploymentFile); const plan = readJson(planPath(config)); const network = await ethers.provider.getNetwork(); + const chainId = Number(network.chainId); if ( - Number(network.chainId) !== 1 || - config.chainId !== 1 || - deployment.chainId !== 1 || - plan.chainId !== 1 + ![1, 11155111].includes(chainId) || + config.chainId !== chainId || + deployment.chainId !== chainId || + plan.chainId !== chainId ) { - throw new Error("Secure CRISP validation is Ethereum-only"); + throw new Error( + "Secure CRISP validation supports matching Ethereum mainnet or Sepolia deployments", + ); } + const avail = availVectorXForChain(chainId); + const verifierDefault = activeBfvConfigForChain(chainId); + const verifierConfigs = bfvConfigsForChain(chainId); if (plan.name !== config.name) { throw new Error( `Upgrade plan name mismatch: expected ${config.name}, got ${plan.name}`, @@ -115,6 +138,13 @@ export async function validateSecureCrispUpgrade(): Promise { deployment.ciphernodeRegistry, "CiphernodeRegistry proxy", ); + equalAddress( + plan.registryProxyAdmin, + deployment.ciphernodeRegistryProxyAdmin, + "CiphernodeRegistry ProxyAdmin", + ); + equalAddress(plan.availBridge, avail.bridge, "Avail bridge"); + equalAddress(plan.vectorx, avail.vectorx, "VectorX verifier"); equalAddress( plan.nodeReleaseRegistry, deployment.nodeReleaseRegistry, @@ -149,12 +179,17 @@ export async function validateSecureCrispUpgrade(): Promise { const codeAddresses = [ [plan.interfoldImplementation, "Interfold implementation"], + [plan.registryImplementation, "CiphernodeRegistry implementation"], + [plan.sortitionLibrary, "RegistrySortitionLib"], [plan.lifecycleLibrary, "InterfoldLifecycle"], [plan.pricingLibrary, "InterfoldPricing"], [plan.pkVerifier, "BFV PK router"], [plan.decryptionVerifier, "BFV decryption router"], [plan.ciphertextVerifier, "CRISP ciphertext verifier"], [plan.crispProgram, "CRISP program"], + [plan.dataAvailabilityVerifier, "CRISP data-availability verifier"], + [plan.availBridge, "Avail bridge"], + [plan.vectorx, "VectorX verifier"], [plan.nodeReleaseRegistry, "NodeReleaseRegistry"], ...plan.bfvVerifierRoutes.flatMap((route) => [ [route.pkVerifier, `${route.preset}/${route.committee} PK verifier`], @@ -194,6 +229,11 @@ export async function validateSecureCrispUpgrade(): Promise { plan.interfoldImplementation, "live Interfold implementation", ); + equalAddress( + await proxyImplementation(ethers, deployment.ciphernodeRegistry), + plan.registryImplementation, + "live CiphernodeRegistry implementation", + ); const interfold = await ethers.getContractAt( "Interfold", @@ -262,7 +302,11 @@ export async function validateSecureCrispUpgrade(): Promise { "secure BFV parameter set", ); for (const threshold of config.interfold.committeeThresholds) { - const actual = await interfold.committeeThresholds(BigInt(threshold.size)); + const size = BigInt(threshold.size); + const actual = await Promise.all([ + interfold.committeeThresholds(size, 0n), + interfold.committeeThresholds(size, 1n), + ]); equalValue( actual[0], BigInt(threshold.quorum), @@ -292,6 +336,21 @@ export async function validateSecureCrispUpgrade(): Promise { if (!(await interfold.e3Programs(plan.crispProgram))) { throw new Error("CRISP program is not registered"); } + const initialE3Program = deployment.initialE3Program; + if (initialE3Program.toLowerCase() !== plan.crispProgram.toLowerCase()) { + if (plan.retiredE3Program) { + equalAddress( + plan.retiredE3Program, + initialE3Program, + "retired initial E3 program", + ); + } + if (await interfold.e3Programs(initialE3Program)) { + throw new Error("Initial E3 program still accepts new requests"); + } + } else if (plan.retiredE3Program) { + throw new Error("Upgrade plan cannot retire the active CRISP program"); + } equalAddress( String( await readContract( @@ -304,6 +363,86 @@ export async function validateSecureCrispUpgrade(): Promise { deployment.interfold, "CRISP Interfold binding", ); + equalAddress( + String( + await readContract( + ethers.provider, + plan.crispProgram, + crispInterface, + "dataAvailabilityVerifier", + ), + ), + plan.dataAvailabilityVerifier, + "CRISP data-availability verifier", + ); + equalValue( + await readContract( + ethers.provider, + plan.crispProgram, + crispInterface, + "availabilityFinalizationWindow", + ), + AVAIL_FINALIZATION_WINDOW_SECONDS, + "CRISP availability finalization window", + ); + equalValue( + await readContract( + ethers.provider, + plan.crispProgram, + crispInterface, + "MIN_VOTING_DURATION", + ), + CRISP_MIN_VOTING_DURATION_SECONDS, + "CRISP minimum voting duration", + ); + equalAddress( + String( + await readContract( + ethers.provider, + plan.crispProgram, + crispInterface, + "inputAvailabilitySigner", + ), + ), + plan.inputAvailabilitySigner, + "CRISP input availability signer", + ); + equalAddress( + String( + await readContract( + ethers.provider, + plan.dataAvailabilityVerifier, + dataAvailabilityInterface, + "bridge", + ), + ), + plan.availBridge, + "adapter Avail bridge", + ); + equalAddress( + String( + await readContract( + ethers.provider, + plan.dataAvailabilityVerifier, + dataAvailabilityInterface, + "vectorx", + ), + ), + plan.vectorx, + "adapter VectorX verifier", + ); + equalAddress( + String( + await readContract( + ethers.provider, + plan.availBridge, + availBridgeInterface, + "vectorx", + ), + ), + plan.vectorx, + "live bridge VectorX verifier", + ); const crispImage = await readContract( ethers.provider, @@ -342,9 +481,9 @@ export async function validateSecureCrispUpgrade(): Promise { "CRISP RISC Zero verifier", ); - if (plan.bfvVerifierRoutes.length !== MAINNET_BFV_CONFIGS.length) { + if (plan.bfvVerifierRoutes.length !== verifierConfigs.length) { throw new Error( - `Expected ${MAINNET_BFV_CONFIGS.length} secure BFV routes, got ${plan.bfvVerifierRoutes.length}`, + `Expected ${verifierConfigs.length} BFV routes, got ${plan.bfvVerifierRoutes.length}`, ); } const pkRouter = await ethers.getContractAt( @@ -355,17 +494,13 @@ export async function validateSecureCrispUpgrade(): Promise { "BfvDecryptionVerifierRouter", plan.decryptionVerifier, ); - equalValue( - await pkRouter.h(), - PRODUCTION_BFV_CONFIG.h, - "PK router default h", - ); + equalValue(await pkRouter.h(), verifierDefault.h, "PK router default h"); equalValue( await decryptionRouter.threshold(), - PRODUCTION_BFV_CONFIG.t, + verifierDefault.t, "decryption router default threshold", ); - const expectedRouteCount = BigInt(MAINNET_BFV_CONFIGS.length); + const expectedRouteCount = BigInt(verifierConfigs.length); equalValue(await pkRouter.routeCount(), expectedRouteCount, "PK route count"); equalValue( await decryptionRouter.routeCount(), @@ -373,8 +508,8 @@ export async function validateSecureCrispUpgrade(): Promise { "decryption route count", ); - for (let index = 0; index < MAINNET_BFV_CONFIGS.length; index += 1) { - const expected = MAINNET_BFV_CONFIGS[index]; + for (let index = 0; index < verifierConfigs.length; index += 1) { + const expected = verifierConfigs[index]; const recorded = plan.bfvVerifierRoutes[index]; if ( recorded.preset !== expected.preset || @@ -462,10 +597,13 @@ export async function validateSecureCrispUpgrade(): Promise { deployment.interfoldImplementation = plan.interfoldImplementation; deployment.interfoldLifecycle = plan.lifecycleLibrary; deployment.interfoldPricing = plan.pricingLibrary; + deployment.ciphernodeRegistryImplementation = plan.registryImplementation; + deployment.registrySortitionLib = plan.sortitionLibrary; deployment.pkVerifier = plan.pkVerifier; deployment.decryptionVerifier = plan.decryptionVerifier; deployment.ciphertextVerifier = plan.ciphertextVerifier; deployment.crispProgram = plan.crispProgram; + deployment.dataAvailabilityVerifier = plan.dataAvailabilityVerifier; deployment.bfvVerifierRoutes = plan.bfvVerifierRoutes; const first = plan.bfvVerifierRoutes[0]; deployment.dkgAggregatorVerifier = first.dkgAggregatorVerifier; @@ -481,6 +619,7 @@ Secure CRISP activation validated crypto config: ${PRODUCTION_BFV_CONFIG.configId} secure BFV routes: ${plan.bfvVerifierRoutes.length} CRISP program: ${plan.crispProgram} + DA verifier: ${plan.dataAvailabilityVerifier} node protocol: ${plan.nodeRelease.protocolVersion} requests paused: true diff --git a/packages/interfold-contracts/scripts/upgrade/vrfSortition.ts b/packages/interfold-contracts/scripts/upgrade/vrfSortition.ts index 48516fcece..f8e37a87f0 100644 --- a/packages/interfold-contracts/scripts/upgrade/vrfSortition.ts +++ b/packages/interfold-contracts/scripts/upgrade/vrfSortition.ts @@ -8,6 +8,7 @@ import { governanceSafeBuilderPath, protocolDir, readJson, + repoRelativePath, writeJson, } from "../protocol/files"; import { @@ -340,8 +341,10 @@ export async function prepareVrfSortitionUpgrade(): Promise { randomnessFlatFee: config.interfold.pricing.randomnessFlatFee, randomnessProviderOwnershipAcceptanceRequired: randomness.randomnessProviderOwnershipAcceptanceRequired, - safeTransactions: rawBatchFile, - governanceSafeBuilder: safeBuilderFile, + safeTransactions: repoRelativePath(rawBatchFile), + governanceSafeBuilder: safeBuilderFile + ? repoRelativePath(safeBuilderFile) + : undefined, }; if (hasFlag("propose-safe")) { diff --git a/packages/interfold-contracts/tasks/committeePublicKey.ts b/packages/interfold-contracts/tasks/committeePublicKey.ts new file mode 100644 index 0000000000..4bfa14b557 --- /dev/null +++ b/packages/interfold-contracts/tasks/committeePublicKey.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +import { getBytes, isHexString, keccak256 } from "ethers"; + +const MAX_PUBLIC_KEY_BYTES = 512 * 1024; +const PUBLIC_KEY_CHUNK_BYTES = 90 * 1024; + +export interface CommitteePublicKeyChunk { + publisher: string; + candidateHash: string; + pkCommitment: string; + chunkIndex: number; + chunkCount: number; + totalLength: number; + chunk: string; +} + +interface CandidateAssembly { + candidateHash: string; + totalLength: number; + chunks: Array; + invalid: boolean; +} + +/** + * Reassemble the only complete transport candidate in an ordered registry log stream. + * + * This function checks the transport hash and all chunk metadata. The caller must still + * validate the returned BFV key against the semantic DKG commitment before production use. + */ +export function assembleUniqueCommitteePublicKey( + events: CommitteePublicKeyChunk[], + expectedPkCommitment: string, +): Uint8Array { + if (!isHexString(expectedPkCommitment, 32)) { + throw new Error("Expected committee public-key commitment is not bytes32"); + } + + const selectedByPublisher = new Map(); + const assemblies = new Map(); + + for (const event of events) { + const chunk = validateChunk(event, expectedPkCommitment); + if (!chunk) continue; + + const publisher = event.publisher.toLowerCase(); + const candidateHash = event.candidateHash.toLowerCase(); + const selected = selectedByPublisher.get(publisher); + if (selected && selected !== candidateHash) continue; + selectedByPublisher.set(publisher, candidateHash); + + const key = `${publisher}:${candidateHash}`; + const assembly = assemblies.get(key) ?? { + candidateHash, + totalLength: event.totalLength, + chunks: Array.from({ + length: event.chunkCount, + }).fill(undefined), + invalid: false, + }; + + if ( + assembly.totalLength !== event.totalLength || + assembly.chunks.length !== event.chunkCount + ) { + assembly.invalid = true; + } else { + const existing = assembly.chunks[event.chunkIndex]; + if (existing && !bytesEqual(existing, chunk)) { + assembly.invalid = true; + } else { + assembly.chunks[event.chunkIndex] = chunk; + } + } + assemblies.set(key, assembly); + } + + const complete = new Map(); + for (const assembly of assemblies.values()) { + if (assembly.invalid || assembly.chunks.some((chunk) => !chunk)) continue; + + const publicKey = new Uint8Array(assembly.totalLength); + let offset = 0; + for (const chunk of assembly.chunks as Uint8Array[]) { + publicKey.set(chunk, offset); + offset += chunk.length; + } + if ( + offset !== assembly.totalLength || + keccak256(publicKey).toLowerCase() !== assembly.candidateHash + ) { + continue; + } + complete.set(assembly.candidateHash, publicKey); + } + + if (complete.size === 0) { + throw new Error("No complete committee public-key candidate was published"); + } + if (complete.size > 1) { + throw new Error( + "Multiple complete committee public-key candidates require semantic BFV validation", + ); + } + return complete.values().next().value!; +} + +function validateChunk( + event: CommitteePublicKeyChunk, + expectedPkCommitment: string, +): Uint8Array | undefined { + if ( + !isHexString(event.candidateHash, 32) || + !isHexString(event.pkCommitment, 32) || + event.pkCommitment.toLowerCase() !== expectedPkCommitment.toLowerCase() || + !isHexString(event.chunk) + ) { + return undefined; + } + if ( + !Number.isInteger(event.totalLength) || + event.totalLength <= 0 || + event.totalLength > MAX_PUBLIC_KEY_BYTES + ) { + return undefined; + } + const expectedChunkCount = Math.ceil( + event.totalLength / PUBLIC_KEY_CHUNK_BYTES, + ); + if ( + !Number.isInteger(event.chunkCount) || + event.chunkCount !== expectedChunkCount || + !Number.isInteger(event.chunkIndex) || + event.chunkIndex < 0 || + event.chunkIndex >= event.chunkCount + ) { + return undefined; + } + + const chunk = getBytes(event.chunk); + const remaining = + event.totalLength - event.chunkIndex * PUBLIC_KEY_CHUNK_BYTES; + if (chunk.length !== Math.min(remaining, PUBLIC_KEY_CHUNK_BYTES)) { + return undefined; + } + return chunk; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return ( + left.length === right.length && + left.every((byte, index) => byte === right[index]) + ); +} diff --git a/packages/interfold-contracts/tasks/interfold.ts b/packages/interfold-contracts/tasks/interfold.ts index 9c9c8d560c..58213ef84f 100644 --- a/packages/interfold-contracts/tasks/interfold.ts +++ b/packages/interfold-contracts/tasks/interfold.ts @@ -9,7 +9,9 @@ import { MaxUint256, ZeroAddress, ZeroHash, + getBytes, isHexString, + keccak256, zeroPadValue, } from "ethers"; import fs from "fs"; @@ -18,6 +20,8 @@ import { ArgumentType } from "hardhat/types/arguments"; import path from "path"; import { readDeploymentArgs } from "../scripts/utils"; +import { assembleUniqueCommitteePublicKey } from "./committeePublicKey"; +import { stageMockDataAvailabilityObject } from "./mockDataAvailability"; function cryptoConfigIdForParamSet(paramSet: number): string { if (paramSet === 0) { @@ -559,12 +563,29 @@ export const publishCommittee = task( await tx.wait(); } - const publicKeyTx = await ciphernodeRegistry.publishCommitteePublicKey( - e3Id, - publicKey, - ); - console.log("Publishing committee public key... ", publicKeyTx.hash); - await publicKeyTx.wait(); + const publicKeyBytes = getBytes(publicKey); + const chunkBytes = 90 * 1024; + const chunkCount = Math.ceil(publicKeyBytes.length / chunkBytes); + const candidateHash = keccak256(publicKey); + for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) { + const chunk = publicKeyBytes.slice( + chunkIndex * chunkBytes, + (chunkIndex + 1) * chunkBytes, + ); + const publicKeyTx = await ciphernodeRegistry.publishCommitteePublicKey( + e3Id, + candidateHash, + chunkIndex, + chunkCount, + publicKeyBytes.length, + chunk, + ); + console.log( + `Publishing committee public-key chunk ${chunkIndex + 1}/${chunkCount}... `, + publicKeyTx.hash, + ); + await publicKeyTx.wait(); + } console.log(`Committee proof and public key published`); }, })) @@ -572,7 +593,7 @@ export const publishCommittee = task( export const getCommitteePublicKey = task( "committee:getPublicKey", - "Read the latest published committee public key for an E3", + "Reassemble the published committee public key for an E3", ) .addOption({ name: "e3Id", @@ -589,29 +610,32 @@ export const getCommitteePublicKey = task( .setAction(async () => ({ default: async ({ e3Id, outFile }, hre) => { const { ethers, deployment, registry } = await getRegistryConnection(hre); - const filter = registry.filters.CommitteePublished(e3Id); + const filter = registry.filters.CommitteePublicKeyChunkPublished(e3Id); const logs = await registry.queryFilter( filter, deployment.blockNumber ?? 0, "latest", ); - const event = logs.at(-1) as any; - - if (!event) { - throw new Error(`CommitteePublished event not found for e3Id=${e3Id}`); - } - - const publicKey = (event.args.publicKey ?? event.args[2]) as string; - if (!publicKey || publicKey === "0x") { - throw new Error(`Committee public key is empty for e3Id=${e3Id}`); - } + const expectedPkCommitment = await registry.committeePublicKey(e3Id); + const publicKeyBytes = assembleUniqueCommitteePublicKey( + logs.map((log: any) => ({ + publisher: log.args.publisher ?? log.args[1], + candidateHash: log.args.candidateHash ?? log.args[2], + pkCommitment: log.args.pkCommitment ?? log.args[4], + chunkIndex: Number(log.args.chunkIndex ?? log.args[5]), + chunkCount: Number(log.args.chunkCount ?? log.args[6]), + totalLength: Number(log.args.totalLength ?? log.args[7]), + chunk: log.args.chunk ?? log.args[8], + })), + expectedPkCommitment, + ); if (outFile) { ensureParentDir(outFile); - fs.writeFileSync(outFile, Buffer.from(ethers.getBytes(publicKey))); + fs.writeFileSync(outFile, Buffer.from(publicKeyBytes)); } - console.log(publicKey); + console.log(ethers.hexlify(publicKeyBytes)); }, })) .build(); @@ -697,6 +721,25 @@ export const publishCiphertext = task( defaultValue: "", type: ArgumentType.STRING, }) + .addOption({ + name: "availabilityProof", + description: + "VectorX proof bytes; defaults to the ciphertext for the local mock", + defaultValue: "", + type: ArgumentType.STRING, + }) + .addOption({ + name: "availabilityProofFile", + description: "file containing the ABI-encoded VectorX proof", + defaultValue: "", + type: ArgumentType.STRING, + }) + .addOption({ + name: "mockDataAvailabilityDirectory", + description: "test-only directory served by the local mock DA endpoint", + defaultValue: "", + type: ArgumentType.STRING, + }) .setAction(async () => ({ default: async ( { @@ -707,10 +750,13 @@ export const publishCiphertext = task( proofFile, ciphertextCommitment, ciphertextCommitmentFile, + availabilityProof, + availabilityProofFile, + mockDataAvailabilityDirectory, }, hre, ) => { - const { interfold } = await getInterfoldConnection(hre); + const { ethers, interfold } = await getInterfoldConnection(hre); let dataToSend = data; @@ -737,11 +783,50 @@ export const publishCiphertext = task( ); } + if (!isHexString(dataToSend) || dataToSend === "0x") { + throw new Error("A non-empty --data or --data-file is required"); + } + if (!isHexString(proofToSend) || proofToSend === "0x") { + throw new Error("A non-empty --proof or --proof-file is required"); + } + + let availabilityProofToSend = availabilityProof || dataToSend; + if (availabilityProofFile) { + availabilityProofToSend = fs + .readFileSync(availabilityProofFile) + .toString(); + } + if ( + !isHexString(availabilityProofToSend) || + availabilityProofToSend === "0x" + ) { + throw new Error("The availability proof must be non-empty hex bytes"); + } + + const contentHash = mockDataAvailabilityDirectory + ? stageMockDataAvailabilityObject( + mockDataAvailabilityDirectory, + dataToSend, + ) + : ethers.keccak256(dataToSend); + + const encodedOutputReference = ethers.AbiCoder.defaultAbiCoder().encode( + [ + "tuple(bytes32 contentHash,bytes32 ciphertextCommitment,bytes computeProof,bytes availabilityProof)", + ], + [ + { + contentHash, + ciphertextCommitment: commitmentToSend, + computeProof: proofToSend, + availabilityProof: availabilityProofToSend, + }, + ], + ); + const tx = await interfold.publishCiphertextOutput( e3Id, - dataToSend, - commitmentToSend, - proofToSend, + encodedOutputReference, ); console.log("Publishing ciphertext... ", tx.hash); diff --git a/packages/interfold-contracts/tasks/mockDataAvailability.ts b/packages/interfold-contracts/tasks/mockDataAvailability.ts new file mode 100644 index 0000000000..59100d6d0e --- /dev/null +++ b/packages/interfold-contracts/tasks/mockDataAvailability.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +import { getBytes, isHexString, keccak256 } from "ethers"; +import fs from "fs"; +import path from "path"; + +/** Store an object for the local HTTP data-availability fixture. */ +export function stageMockDataAvailabilityObject( + directory: string, + object: string, +): string { + if (!directory) { + throw new Error("The mock data-availability directory is empty"); + } + if (!isHexString(object) || object === "0x") { + throw new Error( + "The mock data-availability object must be non-empty hex bytes", + ); + } + + const bytes = Buffer.from(getBytes(object)); + const contentHash = keccak256(bytes); + const objectDirectory = path.resolve(directory); + const objectPath = path.join( + objectDirectory, + contentHash.slice(2).toLowerCase(), + ); + + fs.mkdirSync(objectDirectory, { recursive: true }); + if (fs.existsSync(objectPath)) { + const existing = fs.readFileSync(objectPath); + if (!existing.equals(bytes)) { + throw new Error(`The stored mock object does not match ${contentHash}`); + } + return contentHash; + } + + const temporaryPath = `${objectPath}.${process.pid}.tmp`; + try { + fs.writeFileSync(temporaryPath, bytes, { flag: "wx" }); + fs.renameSync(temporaryPath, objectPath); + } finally { + fs.rmSync(temporaryPath, { force: true }); + } + + return contentHash; +} diff --git a/packages/interfold-contracts/tasks/program.ts b/packages/interfold-contracts/tasks/program.ts index 2eff3aeb44..559f0ab876 100644 --- a/packages/interfold-contracts/tasks/program.ts +++ b/packages/interfold-contracts/tasks/program.ts @@ -9,6 +9,7 @@ import { task } from "hardhat/config"; import { ArgumentType } from "hardhat/types/arguments"; import { readDeploymentArgs } from "../scripts/utils"; +import { stageMockDataAvailabilityObject } from "./mockDataAvailability"; export const publishInput = task( "e3-program:publishInput", @@ -47,9 +48,22 @@ export const publishInput = task( defaultValue: "", type: ArgumentType.STRING, }) + .addOption({ + name: "mockDataAvailabilityDirectory", + description: "test-only directory served by the local mock DA endpoint", + defaultValue: "", + type: ArgumentType.STRING, + }) .setAction(async () => ({ default: async ( - { e3Id, data, dataFile, programAddress, ciphertextCommitmentFile }, + { + e3Id, + data, + dataFile, + programAddress, + ciphertextCommitmentFile, + mockDataAvailabilityDirectory, + }, hre, ) => { const { deployAndSaveMockProgram } = await import( @@ -88,6 +102,13 @@ export const publishInput = task( dataToSend = "0x" + file.toString("hex"); } + if (mockDataAvailabilityDirectory) { + stageMockDataAvailabilityObject( + mockDataAvailabilityDirectory, + dataToSend, + ); + } + if (ciphertextCommitmentFile) { const commitment = "0x" + fs.readFileSync(ciphertextCommitmentFile).toString("hex"); diff --git a/packages/interfold-contracts/test/DataAvailability/AvailVectorX.spec.ts b/packages/interfold-contracts/test/DataAvailability/AvailVectorX.spec.ts new file mode 100644 index 0000000000..096df4d802 --- /dev/null +++ b/packages/interfold-contracts/test/DataAvailability/AvailVectorX.spec.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { expect } from "chai"; + +import { ethers } from "../fixtures"; + +describe("AvailVectorXDataAvailabilityVerifier", function () { + const rangeHash = ethers.keccak256(ethers.toUtf8Bytes("avail-range")); + const object = ethers.toUtf8Bytes("exact encrypted object"); + const contentHash = ethers.keccak256(object); + + async function fixture() { + const vectorx = await ethers.deployContract("MockVectorX"); + await vectorx.waitForDeployment(); + const bridge = await ethers.deployContract("MockAvailBridge", [ + await vectorx.getAddress(), + ]); + await bridge.waitForDeployment(); + const verifier = await ethers.deployContract( + "AvailVectorXDataAvailabilityVerifier", + [await bridge.getAddress(), await vectorx.getAddress()], + ); + await verifier.waitForDeployment(); + await vectorx.setRangeStartBlock(rangeHash, 1_000); + return { vectorx, bridge, verifier }; + } + + function proof(overrides: Partial> = {}) { + const input = { + dataRootProof: [], + leafProof: [], + rangeHash, + dataRootIndex: 7n, + blobRoot: ethers.ZeroHash, + bridgeRoot: ethers.ZeroHash, + leaf: contentHash, + leafIndex: 12n, + ...overrides, + }; + return ethers.AbiCoder.defaultAbiCoder().encode( + [ + "tuple(bytes32[] dataRootProof,bytes32[] leafProof,bytes32 rangeHash,uint256 dataRootIndex,bytes32 blobRoot,bytes32 bridgeRoot,bytes32 leaf,uint256 leafIndex)", + ], + [input], + ); + } + + it("returns stable retrieval coordinates for the exact Avail leaf", async function () { + const { verifier } = await fixture(); + const receipt = await verifier.verifyDataAvailability(contentHash, proof()); + expect(receipt.contentHash).to.equal(contentHash); + expect(receipt.blockNumber).to.equal(1_008n); + expect(receipt.leafIndex).to.equal(12n); + }); + + it("rejects a receipt for different bytes", async function () { + const { verifier } = await fixture(); + await expect( + verifier.verifyDataAvailability( + ethers.keccak256(ethers.toUtf8Bytes("substitute")), + proof(), + ), + ).to.be.revertedWithCustomError(verifier, "ContentHashMismatch"); + }); + + it("rejects the submitted-data Merkle hash in place of the proof leaf", async function () { + const { verifier } = await fixture(); + await expect( + verifier.verifyDataAvailability( + contentHash, + proof({ leaf: ethers.keccak256(contentHash) }), + ), + ).to.be.revertedWithCustomError(verifier, "ContentHashMismatch"); + }); + + it("rejects a leaf that the Avail bridge did not verify", async function () { + const { bridge, verifier } = await fixture(); + await bridge.setProofValid(false); + await expect( + verifier.verifyDataAvailability(contentHash, proof()), + ).to.be.revertedWithCustomError(verifier, "InvalidAvailabilityProof"); + }); + + it("fails closed if the bridge rotates to a different VectorX verifier", async function () { + const { bridge, verifier } = await fixture(); + const replacement = await ethers.deployContract("MockVectorX"); + await replacement.waitForDeployment(); + await bridge.setVectorX(await replacement.getAddress()); + await expect( + verifier.verifyDataAvailability(contentHash, proof()), + ).to.be.revertedWithCustomError(verifier, "InvalidVectorX"); + }); + + it("rejects a bridge and VectorX pair that do not match at deployment", async function () { + const { bridge, verifier } = await fixture(); + const replacement = await ethers.deployContract("MockVectorX"); + await replacement.waitForDeployment(); + + await expect( + ethers.deployContract("AvailVectorXDataAvailabilityVerifier", [ + await bridge.getAddress(), + await replacement.getAddress(), + ]), + ).to.be.revertedWithCustomError(verifier, "InvalidVectorX"); + }); + + it("rejects malformed proof encoding", async function () { + const { verifier } = await fixture(); + await expect( + verifier.verifyDataAvailability(contentHash, "0xdead"), + ).to.be.revert(ethers); + }); + + it("rejects retrieval coordinates that do not fit the recorded reference", async function () { + const { vectorx, verifier } = await fixture(); + await expect( + verifier.verifyDataAvailability( + contentHash, + proof({ dataRootIndex: 1n << 32n }), + ), + ).to.be.revertedWithCustomError(verifier, "DataRootIndexTooLarge"); + await expect( + verifier.verifyDataAvailability( + contentHash, + proof({ leafIndex: 1n << 128n }), + ), + ).to.be.revertedWithCustomError(verifier, "LeafIndexTooLarge"); + + await vectorx.setRangeStartBlock(rangeHash, (1n << 32n) - 1n); + await expect( + verifier.verifyDataAvailability(contentHash, proof({ dataRootIndex: 0 })), + ).to.be.revertedWithCustomError(verifier, "BlockNumberOverflow"); + }); +}); diff --git a/packages/interfold-contracts/test/DataAvailability/CommitteePublicKeyTask.spec.ts b/packages/interfold-contracts/test/DataAvailability/CommitteePublicKeyTask.spec.ts new file mode 100644 index 0000000000..95abacda79 --- /dev/null +++ b/packages/interfold-contracts/test/DataAvailability/CommitteePublicKeyTask.spec.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +import { expect } from "chai"; +import { concat, hexlify, keccak256 } from "ethers"; + +import { + type CommitteePublicKeyChunk, + assembleUniqueCommitteePublicKey, +} from "../../tasks/committeePublicKey"; + +const commitment = `0x${"11".repeat(32)}`; +const publisherA = `0x${"aa".repeat(20)}`; +const publisherB = `0x${"bb".repeat(20)}`; + +function chunksFor( + first: Uint8Array, + second: Uint8Array, + publisher = publisherA, +): CommitteePublicKeyChunk[] { + const publicKey = concat([first, second]); + const candidateHash = keccak256(publicKey); + return [ + { + publisher, + candidateHash, + pkCommitment: commitment, + chunkIndex: 0, + chunkCount: 2, + totalLength: first.length + second.length, + chunk: hexlify(first), + }, + { + publisher, + candidateHash, + pkCommitment: commitment, + chunkIndex: 1, + chunkCount: 2, + totalLength: first.length + second.length, + chunk: hexlify(second), + }, + ]; +} + +describe("committee public-key task assembly", function () { + const first = new Uint8Array(90 * 1024).fill(0x11); + const second = new Uint8Array([0x22, 0x33]); + + it("reassembles complete chunks in any arrival order", function () { + const chunks = chunksFor(first, second).reverse(); + const result = assembleUniqueCommitteePublicKey(chunks, commitment); + + expect(hexlify(result)).to.equal(concat([first, second])); + }); + + it("rejects incomplete and corrupted candidates", function () { + const [firstChunk, secondChunk] = chunksFor(first, second); + expect(() => + assembleUniqueCommitteePublicKey([firstChunk!], commitment), + ).to.throw("No complete committee public-key candidate"); + + const corrupted = { ...secondChunk!, chunk: "0x4455" }; + expect(() => + assembleUniqueCommitteePublicKey([firstChunk!, corrupted], commitment), + ).to.throw("No complete committee public-key candidate"); + }); + + it("uses only the first valid candidate from each publisher", function () { + const firstCandidate = chunksFor(first, second); + const secondCandidate = chunksFor(first, new Uint8Array([0x44, 0x55])); + + expect(() => + assembleUniqueCommitteePublicKey( + [firstCandidate[0]!, ...secondCandidate], + commitment, + ), + ).to.throw("No complete committee public-key candidate"); + }); + + it("rejects different complete candidates without semantic validation", function () { + const firstCandidate = chunksFor(first, second, publisherA); + const secondCandidate = chunksFor( + first, + new Uint8Array([0x44, 0x55]), + publisherB, + ); + + expect(() => + assembleUniqueCommitteePublicKey( + [...firstCandidate, ...secondCandidate], + commitment, + ), + ).to.throw("Multiple complete committee public-key candidates"); + }); + + it("deduplicates the same complete candidate from two publishers", function () { + const result = assembleUniqueCommitteePublicKey( + [ + ...chunksFor(first, second, publisherA), + ...chunksFor(first, second, publisherB), + ], + commitment, + ); + + expect(hexlify(result)).to.equal(concat([first, second])); + }); +}); diff --git a/packages/interfold-contracts/test/DataAvailability/MockDataAvailabilityTask.spec.ts b/packages/interfold-contracts/test/DataAvailability/MockDataAvailabilityTask.spec.ts new file mode 100644 index 0000000000..5be7e3fdb8 --- /dev/null +++ b/packages/interfold-contracts/test/DataAvailability/MockDataAvailabilityTask.spec.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +import { expect } from "chai"; +import { keccak256 } from "ethers"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { stageMockDataAvailabilityObject } from "../../tasks/mockDataAvailability"; + +describe("mock data-availability task", function () { + let directory: string; + + beforeEach(function () { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "interfold-mock-da-")); + }); + + afterEach(function () { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("stores exact bytes under their content hash", function () { + const object = "0x01020304"; + const contentHash = stageMockDataAvailabilityObject(directory, object); + const stored = fs.readFileSync( + path.join(directory, contentHash.slice(2).toLowerCase()), + ); + + expect(contentHash).to.equal(keccak256(object)); + expect(stored.toString("hex")).to.equal(object.slice(2)); + }); + + it("accepts an identical retry", function () { + const object = "0xaabbccdd"; + const first = stageMockDataAvailabilityObject(directory, object); + const second = stageMockDataAvailabilityObject(directory, object); + + expect(second).to.equal(first); + }); + + it("rejects an empty object", function () { + expect(() => stageMockDataAvailabilityObject(directory, "0x")).to.throw( + "must be non-empty hex bytes", + ); + }); +}); diff --git a/packages/interfold-contracts/test/Deployment/ProtocolDeployment.spec.ts b/packages/interfold-contracts/test/Deployment/ProtocolDeployment.spec.ts index 409d68ed67..5a38b242ce 100644 --- a/packages/interfold-contracts/test/Deployment/ProtocolDeployment.spec.ts +++ b/packages/interfold-contracts/test/Deployment/ProtocolDeployment.spec.ts @@ -11,7 +11,10 @@ import os from "os"; import path from "path"; import { deployProtocolContracts } from "../../scripts/protocol/deployContracts"; -import { currentNodeRelease } from "../../scripts/protocol/nodeRelease"; +import { + currentNodeRelease, + requiresNodeReleasePolicyUpdate, +} from "../../scripts/protocol/nodeRelease"; import { assertValidatedVrfDeploymentMatchesPlan, assertVrfSubscription, @@ -31,6 +34,7 @@ import type { VrfSortitionUpgradePlan, } from "../../scripts/protocol/types"; import { loadConfig } from "../../scripts/protocol/values"; +import { requiredActiveOperatorsForSecureCrisp } from "../../scripts/upgrade/resumeSecureCrisp"; import { BondingRegistry__factory as BondingRegistryFactory } from "../../types"; const { ethers } = await network.connect(); @@ -46,6 +50,32 @@ describe("Protocol deployment", function () { ); }); + it("updates the node release policy only when the release advances", function () { + const release = { protocolVersion: 3, nodeGeneration: 1 }; + + expect(requiresNodeReleasePolicyUpdate(release, 2n, 1n)).to.equal(true); + expect(requiresNodeReleasePolicyUpdate(release, 3n, 1n)).to.equal(false); + expect(() => requiresNodeReleasePolicyUpdate(release, 4n, 1n)).to.throw( + "cannot move backwards", + ); + }); + + it("allows a named Sepolia rehearsal without weakening production capacity", function () { + const thresholds = [ + { size: "0", total: "3" }, + { size: "1", total: "9" }, + { size: "2", total: "19" }, + ]; + + expect(requiredActiveOperatorsForSecureCrisp(thresholds, 1)).to.equal(19n); + expect( + requiredActiveOperatorsForSecureCrisp(thresholds, 11155111, "0"), + ).to.equal(3n); + expect(() => + requiredActiveOperatorsForSecureCrisp(thresholds, 1, "0"), + ).to.throw("only for a Sepolia rehearsal"); + }); + it("requires a ciphernode restart acknowledgement before resume", function () { expect(() => requireCiphernodeRestartAcknowledgement(false)).to.throw( "Restart every ciphernode", diff --git a/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts b/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts index a7decf81a1..af03dd953a 100644 --- a/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts +++ b/packages/interfold-contracts/test/E3Lifecycle/E3Integration.spec.ts @@ -28,11 +28,12 @@ import { ignition, makeRequest, networkHelpers, + publishAvailableCiphertextOutput, setPricingConfig, signAndEncodeAttestation, } from "../fixtures"; -const { loadFixture, mine, time } = networkHelpers; +const { loadFixture, time } = networkHelpers; /** * Integration tests for E3 Refund/Timeout Mechanism @@ -167,7 +168,7 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const fee = await interfold.getE3Quote(requestParams); await requestToken.connect(signer).approve(interfoldAddress, fee); await interfold.connect(signer).request(requestParams); - await mine(1); + await time.increase(1); return { e3Id }; }; @@ -232,7 +233,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { for (const operator of [operator1, operator2, operator3]) { await registry.connect(operator).submitTicket(firstE3Id, 1); } - await time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(firstE3Id); + await time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(firstE3Id); }; @@ -240,7 +242,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { for (const operator of [operator1, operator2, operator3]) { await registry.connect(operator).submitTicket(firstE3Id, 1); } - await time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(firstE3Id); + await time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(firstE3Id); const publicKey = "0x1234567890abcdef1234567890abcdef"; const pkCommitment = ethers.keccak256(publicKey); @@ -2118,7 +2121,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const e3 = await interfold.getE3(firstE3Id); await time.increaseTo(Number(e3.inputWindow[1])); const ciphertext = "0x" + "ab".repeat(100); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertext, ethers.keccak256(ciphertext), @@ -2699,7 +2703,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const ciphertextOutput = "0x" + "ab".repeat(100); const proof = "0x1337"; - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertextOutput, ethers.keccak256(ciphertextOutput), @@ -2996,7 +3001,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const ciphertextOutput = "0x" + "ab".repeat(100); const proofBytes = "0x1337"; - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertextOutput, ethers.keccak256(ciphertextOutput), @@ -3112,7 +3118,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const ciphertextOutput = "0x" + "ab".repeat(100); const proof = "0x1337"; - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertextOutput, ethers.keccak256(ciphertextOutput), @@ -3173,7 +3180,8 @@ describe("E3 Integration - Refund/Timeout Mechanism", function () { const ciphertextOutput = "0x" + "ab".repeat(100); const proof = "0x1337"; - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertextOutput, ethers.keccak256(ciphertextOutput), diff --git a/packages/interfold-contracts/test/Interfold.spec.ts b/packages/interfold-contracts/test/Interfold.spec.ts index 8d82549b79..d751977098 100644 --- a/packages/interfold-contracts/test/Interfold.spec.ts +++ b/packages/interfold-contracts/test/Interfold.spec.ts @@ -17,6 +17,7 @@ import { ethers, makeRequest, networkHelpers, + publishAvailableCiphertextOutput, setupAndPublishCommittee, DEFAULT_TIMEOUT_CONFIG as timeoutConfig, } from "./fixtures"; @@ -443,6 +444,54 @@ describe("Interfold", function () { }); }); + describe("unregisterE3Program()", function () { + it("closes new request admission without changing existing E3 records", async function () { + const { + interfold, + notTheOwner, + request, + usdcToken, + mocks: { e3Program }, + } = await loadFixture(setup); + const e3ProgramAddress = await e3Program.getAddress(); + + await usdcToken.approve(await interfold.getAddress(), ethers.MaxUint256); + await interfold.request(request); + expect((await interfold.getE3(firstE3Id)).e3Program).to.equal( + e3ProgramAddress, + ); + + await expect( + interfold.connect(notTheOwner).unregisterE3Program(e3ProgramAddress), + ) + .to.be.revertedWithCustomError(interfold, "OwnableUnauthorizedAccount") + .withArgs(notTheOwner); + + await expect(interfold.unregisterE3Program(e3ProgramAddress)) + .to.emit(interfold, "E3ProgramUnregistered") + .withArgs(e3ProgramAddress); + expect(await interfold.e3Programs(e3ProgramAddress)).to.be.false; + expect((await interfold.getE3(firstE3Id)).e3Program).to.equal( + e3ProgramAddress, + ); + + await expect(interfold.unregisterE3Program(e3ProgramAddress)) + .to.be.revertedWithCustomError(interfold, "E3ProgramNotAllowed") + .withArgs(e3ProgramAddress); + const requestTime = await time.latest(); + const requestAfterUnregister = { + ...request, + inputWindow: [ + requestTime + 60, + requestTime + 60 + inputWindowDuration, + ] as [number, number], + }; + await expect(interfold.request(requestAfterUnregister)) + .to.be.revertedWithCustomError(interfold, "E3ProgramNotAllowed") + .withArgs(e3ProgramAddress); + }); + }); + describe("request()", function () { it("rejects a fee token that differs from the accepted quote", async function () { const { interfold, request } = await loadFixture(setup); @@ -521,6 +570,7 @@ describe("Interfold", function () { }); it("allows total duration equal to maxDuration", async function () { const { interfold, request, usdcToken } = await loadFixture(setup); + await usdcToken.approve(await interfold.getAddress(), ethers.MaxUint256); const requestAt = BigInt((await time.latest()) + 10); const maxDuration = await interfold.maxDuration(); const inputEnd = @@ -532,7 +582,6 @@ describe("Interfold", function () { ...request, inputWindow: [requestAt, inputEnd] as [bigint, bigint], }; - await usdcToken.approve(await interfold.getAddress(), ethers.MaxUint256); await time.setNextBlockTimestamp(requestAt); await interfold.request(exactDurationRequest); @@ -578,7 +627,12 @@ describe("Interfold", function () { .withArgs(ethers.ZeroAddress); }); it("instantiates a new E3", async function () { - const { interfold, request, usdcToken } = await loadFixture(setup); + const { + interfold, + request, + usdcToken, + mocks: { e3Program }, + } = await loadFixture(setup); await usdcToken.approve(await interfold.getAddress(), ethers.MaxUint256); const requestAt = BigInt((await time.latest()) + 1); const freshRequest = { @@ -603,6 +657,9 @@ describe("Interfold", function () { // bonding registry / token checkpoints across L2s with variable block // production. expect(e3.requestBlock).to.equal(block.timestamp); + expect(await e3Program.validationRequestTimes(firstE3Id)).to.equal( + block.timestamp, + ); expect(e3.decryptionVerifier).to.equal( abiCoder.decode(["address"], request.computeProviderParams)[0], ); @@ -636,7 +693,13 @@ describe("Interfold", function () { const { interfold } = await loadFixture(setup); await expect( - interfold.publishCiphertextOutput(0, "0x", ethers.ZeroHash, "0x"), + publishAvailableCiphertextOutput( + interfold, + 0, + "0x", + ethers.ZeroHash, + "0x", + ), ) .to.be.revertedWithCustomError(interfold, "E3DoesNotExist") .withArgs(0); @@ -672,14 +735,16 @@ describe("Interfold", function () { ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, proof, ); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -715,7 +780,8 @@ describe("Interfold", function () { interval: inputWindowDuration + timeoutConfig.computeWindow, }); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -751,7 +817,13 @@ describe("Interfold", function () { ]); await mine(2, { interval: inputWindowDuration }); await expect( - interfold.publishCiphertextOutput(e3Id, "0x", ethers.ZeroHash, "0x"), + publishAvailableCiphertextOutput( + interfold, + e3Id, + "0x", + ethers.ZeroHash, + "0x", + ), ).to.be.revertedWithCustomError(interfold, "InvalidOutput"); }); it("does not assign an unverified ciphertext to the committee", async function () { @@ -780,7 +852,8 @@ describe("Interfold", function () { await mocks.ciphertextVerifier.setResult(false); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -792,6 +865,92 @@ describe("Interfold", function () { expect(e3.ciphertextOutput).to.equal(ethers.ZeroHash); expect(e3.ciphertextCommitment).to.equal(ethers.ZeroHash); }); + + it("rejects an availability proof for different ciphertext bytes atomically", async function () { + const { + interfold, + request, + usdcToken, + ciphernodeRegistryContract, + operator1, + operator2, + operator3, + } = await loadFixture(setup); + const e3Id = firstE3Id; + + await makeRequest(interfold, usdcToken, { + ...request, + inputWindow: [(await time.latest()) + 20, (await time.latest()) + 100], + }); + await setupAndPublishCommittee(ciphernodeRegistryContract, e3Id, data, [ + operator1, + operator2, + operator3, + ]); + await mine(2, { interval: inputWindowDuration }); + + const mismatchedReference = abiCoder.encode( + [ + "tuple(bytes32 contentHash,bytes32 ciphertextCommitment,bytes computeProof,bytes availabilityProof)", + ], + [ + { + contentHash: ethers.keccak256(data), + ciphertextCommitment, + computeProof: proof, + availabilityProof: "0xdeadbeef", + }, + ], + ); + await expect( + interfold.publishCiphertextOutput(e3Id, mismatchedReference), + ).to.be.revert(ethers); + expect(await interfold.getE3Stage(e3Id)).to.equal(3); + expect((await interfold.getE3(e3Id)).ciphertextOutput).to.equal( + ethers.ZeroHash, + ); + }); + + it("rejects an availability receipt for a different content hash atomically", async function () { + const { + interfold, + request, + usdcToken, + ciphernodeRegistryContract, + operator1, + operator2, + operator3, + mocks, + } = await loadFixture(setup); + const e3Id = firstE3Id; + + await makeRequest(interfold, usdcToken, { + ...request, + inputWindow: [(await time.latest()) + 20, (await time.latest()) + 100], + }); + await setupAndPublishCommittee(ciphernodeRegistryContract, e3Id, data, [ + operator1, + operator2, + operator3, + ]); + await mine(2, { interval: inputWindowDuration }); + await mocks.e3Program.setReturnMismatchedAvailabilityHash(true); + + await expect( + publishAvailableCiphertextOutput( + interfold, + e3Id, + data, + ciphertextCommitment, + proof, + ), + ).to.be.revertedWithCustomError(interfold, "InvalidOutput"); + expect(await interfold.getE3Stage(e3Id)).to.equal(3); + expect((await interfold.getE3(e3Id)).ciphertextOutput).to.equal( + ethers.ZeroHash, + ); + }); + it("keeps the request-time verifier after verifier rotation", async function () { const { interfold, @@ -823,7 +982,8 @@ describe("Interfold", function () { await mine(2, { interval: inputWindowDuration }); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -834,13 +994,14 @@ describe("Interfold", function () { await mocks.ciphertextVerifier.setResult(true); await replacement.setResult(false); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, proof, ), - ).to.emit(interfold, "CiphertextOutputPublished"); + ).to.emit(interfold, "CiphertextOutputReferencePublished"); }); it("sets ciphertextOutput correctly", async function () { const { @@ -871,14 +1032,16 @@ describe("Interfold", function () { ciphertextCommitment, ); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ethers.keccak256("0xbad0"), proof, ), ).to.be.revertedWithCustomError(interfold, "InvalidOutput"); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -889,7 +1052,7 @@ describe("Interfold", function () { expect(e3.ciphertextCommitment).to.equal(ciphertextCommitment); }); - it("returns true if output is published successfully", async function () { + it("accepts a valid output reference", async function () { const { interfold, request, @@ -912,16 +1075,17 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - expect( - await interfold.publishCiphertextOutput.staticCall( + await expect( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, proof, ), - ).to.equal(true); + ).to.emit(interfold, "CiphertextOutputReferencePublished"); }); - it("emits CiphertextOutputPublished event", async function () { + it("emits the verified ciphertext reference", async function () { const { interfold, request, @@ -945,15 +1109,16 @@ describe("Interfold", function () { ]); await mine(2, { interval: inputWindowDuration }); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, proof, ), ) - .to.emit(interfold, "CiphertextOutputPublished") - .withArgs(e3Id, data, ciphertextCommitment); + .to.emit(interfold, "CiphertextOutputReferencePublished") + .withArgs(e3Id, ethers.keccak256(data), ciphertextCommitment, 1, 1); }); it("blocks plaintext publication during ciphertext verification", async function () { @@ -982,7 +1147,8 @@ describe("Interfold", function () { await mocks.e3Program.setReentrantPlaintextPublication(data, proof); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1055,7 +1221,8 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1088,7 +1255,8 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1133,7 +1301,8 @@ describe("Interfold", function () { bundle, ); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1169,7 +1338,8 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1203,7 +1373,8 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1237,7 +1408,8 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, @@ -1270,7 +1442,8 @@ describe("Interfold", function () { operator3, ]); await mine(2, { interval: inputWindowDuration }); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ciphertextCommitment, diff --git a/packages/interfold-contracts/test/MockE3Program.spec.ts b/packages/interfold-contracts/test/MockE3Program.spec.ts index d5a5f432e2..989849fa39 100644 --- a/packages/interfold-contracts/test/MockE3Program.spec.ts +++ b/packages/interfold-contracts/test/MockE3Program.spec.ts @@ -22,6 +22,7 @@ describe("MockE3Program", function () { "publishInput", "validate", "verify", + "verifyDataAvailability", ]); }); @@ -49,5 +50,14 @@ describe("MockE3Program", function () { expect(await program.validate.staticCall(1, 2, "0x", "0x", "0x")).to.equal( scheme, ); + + const object = "0x1234"; + const contentHash = ethers.keccak256(object); + expect( + await program.verifyDataAvailability.staticCall(contentHash, object), + ).to.deep.equal([contentHash, 1n, 1n]); + await expect( + program.verifyDataAvailability.staticCall(ethers.ZeroHash, object), + ).to.be.revertedWithCustomError(program, "InvalidDataAvailabilityProof"); }); }); diff --git a/packages/interfold-contracts/test/Pricing/DustRotation.spec.ts b/packages/interfold-contracts/test/Pricing/DustRotation.spec.ts index 62d5f733f7..b4a75c7659 100644 --- a/packages/interfold-contracts/test/Pricing/DustRotation.spec.ts +++ b/packages/interfold-contracts/test/Pricing/DustRotation.spec.ts @@ -12,13 +12,13 @@ import type { Signer } from "ethers"; import { ACTIVE_CRYPTO_CONFIG_ID, - SORTITION_SUBMISSION_WINDOW, DATA as data, deployInterfoldSystem, encodeMockDkgProof, ethers, networkHelpers, PROOF as proof, + publishAvailableCiphertextOutput, setPricingConfig, } from "../fixtures"; @@ -34,11 +34,12 @@ describe("Pricing โ€” per-E3 dust rotation across consecutive E3s", function () publicKey: string, operators: Signer[], ) => { - await networkHelpers.mine(1); + await time.increase(1); for (const operator of operators) { await registry.connect(operator).submitTicket(e3Id, 1); } - await time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(e3Id); + await time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(e3Id); const pkCommitment = ethers.keccak256(publicKey); await registry.publishCommittee( @@ -168,7 +169,8 @@ describe("Pricing โ€” per-E3 dust rotation across consecutive E3s", function () [operator1, operator2, operator3], ); await time.increase(inputWindowDuration + 200); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ethers.keccak256(data), diff --git a/packages/interfold-contracts/test/Pricing/Pricing.spec.ts b/packages/interfold-contracts/test/Pricing/Pricing.spec.ts index a0db955bd7..f59161470d 100644 --- a/packages/interfold-contracts/test/Pricing/Pricing.spec.ts +++ b/packages/interfold-contracts/test/Pricing/Pricing.spec.ts @@ -13,6 +13,7 @@ import { ethers, networkHelpers, PROOF as proof, + publishAvailableCiphertextOutput, setPricingConfig, setupAndPublishCommittee, } from "../fixtures"; @@ -553,7 +554,8 @@ describe("E3 Pricing", function () { // Publish ciphertext await time.increase(inputWindowDuration + 200); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ethers.keccak256(data), @@ -635,7 +637,8 @@ describe("E3 Pricing", function () { // Publish outputs await time.increase(inputWindowDuration + 200); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ethers.keccak256(data), diff --git a/packages/interfold-contracts/test/Pricing/PullPaymentsAndAllowlist.spec.ts b/packages/interfold-contracts/test/Pricing/PullPaymentsAndAllowlist.spec.ts index 613746363f..738b4dc4b8 100644 --- a/packages/interfold-contracts/test/Pricing/PullPaymentsAndAllowlist.spec.ts +++ b/packages/interfold-contracts/test/Pricing/PullPaymentsAndAllowlist.spec.ts @@ -8,7 +8,6 @@ import type { MockBlacklistUSDC } from "../../types"; import { MockFeeOnTransferToken__factory as MockFeeOnTransferTokenFactory } from "../../types"; import { ACTIVE_CRYPTO_CONFIG_ID, - SORTITION_SUBMISSION_WINDOW, currentPricingConfig, DATA as data, deployInterfoldSystem, @@ -16,6 +15,7 @@ import { ethers, networkHelpers, PROOF as proof, + publishAvailableCiphertextOutput, setPricingConfig, } from "../fixtures"; @@ -31,11 +31,12 @@ describe("Interfold โ€” pull payments + fee-token allow-list", function () { publicKey: string, operators: Signer[], ) => { - await networkHelpers.mine(1); + await time.increase(1); for (const operator of operators) { await registry.connect(operator).submitTicket(e3Id, 1); } - await time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(e3Id); + await time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(e3Id); const pkCommitment = ethers.keccak256(publicKey); await registry.publishCommittee( @@ -154,7 +155,8 @@ describe("Interfold โ€” pull payments + fee-token allow-list", function () { operator3, ]); await time.increase(inputWindowDuration + 200); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id, data, ethers.keccak256(data), @@ -209,7 +211,8 @@ describe("Interfold โ€” pull payments + fee-token allow-list", function () { [ctx.operator1, ctx.operator2, ctx.operator3], ); await time.increase(inputWindowDuration + 200); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, e3Id2, data, ethers.keccak256(data), @@ -237,6 +240,29 @@ describe("Interfold โ€” pull payments + fee-token allow-list", function () { // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ describe("M-02 โ€” treasury pull isolates failures", function () { + it("does not credit treasury until the request payment is in custody", async function () { + const ctx = await loadFixture(fixturePlain); + const { interfold, feeToken, treasury, request } = ctx; + const e3Program = await ethers.getContractAt( + "MockE3ProgramHarness", + request.e3Program, + ); + const treasuryAddress = await treasury.getAddress(); + const tokenAddress = await feeToken.getAddress(); + + await e3Program.observeTreasuryDuringValidation( + treasuryAddress, + tokenAddress, + ); + await feeToken.approve(await interfold.getAddress(), ethers.MaxUint256); + await interfold.request(request); + + expect(await e3Program.pendingTreasuryDuringValidation()).to.equal(0n); + expect( + await interfold.pendingTreasuryClaim(treasuryAddress, tokenAddress), + ).to.be.gt(0n); + }); + it("blacklisting treasury does not brick publishPlaintextOutput; other claimants unaffected", async function () { const ctx = await loadFixture(fixtureBlacklist); const { interfold, feeToken, treasury, owner } = ctx; diff --git a/packages/interfold-contracts/test/Registry/CiphernodeRegistryOwnable.spec.ts b/packages/interfold-contracts/test/Registry/CiphernodeRegistryOwnable.spec.ts index c56d4539ba..4a5f59580e 100644 --- a/packages/interfold-contracts/test/Registry/CiphernodeRegistryOwnable.spec.ts +++ b/packages/interfold-contracts/test/Registry/CiphernodeRegistryOwnable.spec.ts @@ -33,7 +33,8 @@ describe("CiphernodeRegistryOwnable", function () { registry: any, e3Id: number | bigint, ): Promise { - await networkHelpers.time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(e3Id); + await networkHelpers.time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(e3Id); } @@ -111,7 +112,7 @@ describe("CiphernodeRegistryOwnable", function () { await tokenContract.approve(await interfold.getAddress(), fee); const tx = await interfoldContract.request(requestParams); - if (mineEntropyBlock) await networkHelpers.mine(1); + if (mineEntropyBlock) await networkHelpers.time.increase(1); return tx; } @@ -931,16 +932,21 @@ describe("CiphernodeRegistryOwnable", function () { usdcToken, mockE3Program, mockDecryptionVerifier, + randomnessProvider, operator1, operator2, operator3, } = await loadFixture(setup); + await randomnessProvider.setAutoFulfill(false); await makeRequest( interfold, usdcToken, mockE3Program, mockDecryptionVerifier, ); + const requestId = await randomnessProvider.requestIdByE3Id(firstE3Id); + await networkHelpers.time.increase(1); + await randomnessProvider.fulfill(requestId, 1n); await registry.connect(operator1).submitTicket(firstE3Id, 1); await registry.connect(operator2).submitTicket(firstE3Id, 1); @@ -955,7 +961,7 @@ describe("CiphernodeRegistryOwnable", function () { ); expect(await registry.committeePublicKey(firstE3Id)).to.equal(dataHash); }); - it("lets a valid public-key candidate follow an invalid one", async function () { + it("rejects malformed chunks and accepts a committee member's valid candidate", async function () { const { registry, interfold, @@ -987,48 +993,207 @@ describe("CiphernodeRegistryOwnable", function () { "0x01", ); - const maxLength = await registry.MAX_COMMITTEE_PUBLIC_KEY_BYTES(); - await expect(registry.publishCommitteePublicKey(firstE3Id, "0x")) - .to.be.revertedWithCustomError(registry, "InvalidPublicKeyLength") - .withArgs(0, maxLength); - const oversizedKey = ethers.hexlify( - new Uint8Array(Number(maxLength) + 1), + const publishPublicKey = registry.getFunction( + "publishCommitteePublicKey(uint256,bytes32,uint16,uint16,uint32,bytes)", ); - await expect(registry.publishCommitteePublicKey(firstE3Id, oversizedKey)) - .to.be.revertedWithCustomError(registry, "InvalidPublicKeyLength") - .withArgs(maxLength + 1n, maxLength); + const maxLength = await registry.MAX_COMMITTEE_PUBLIC_KEY_BYTES(); + const observedSecure8192PublicKeyLength = 356_384n; + expect(maxLength).to.be.gte(observedSecure8192PublicKeyLength); + await expect( + publishPublicKey( + firstE3Id, + ethers.keccak256("0xdead"), + 0, + 6, + maxLength + 1n, + "0xdead", + ), + ).to.be.revertedWithCustomError(registry, "InvalidPublicKeyChunk"); + await expect( + publishPublicKey(firstE3Id, ethers.ZeroHash, 0, 1, 2, "0xdead"), + ).to.be.revertedWithCustomError(registry, "InvalidPublicKeyChunk"); + await expect( + publishPublicKey( + firstE3Id, + ethers.keccak256("0xdead"), + 1, + 1, + 2, + "0xdead", + ), + ).to.be.revertedWithCustomError(registry, "InvalidPublicKeyChunk"); + await expect( + publishPublicKey( + firstE3Id, + ethers.keccak256("0xdead"), + 0, + 2, + 2, + "0xdead", + ), + ).to.be.revertedWithCustomError(registry, "InvalidPublicKeyChunk"); + await expect( + publishPublicKey( + firstE3Id, + ethers.keccak256("0xdead"), + 1, + 2, + 90 * 1024 + 1, + "0xdead", + ), + ).to.be.revertedWithCustomError(registry, "InvalidPublicKeyChunk"); + + const candidate = "0xdead"; + const candidateHash = ethers.keccak256(candidate); await expect( registry .connect(notTheOwner) - .publishCommitteePublicKey(firstE3Id, "0xdead"), + .getFunction( + "publishCommitteePublicKey(uint256,bytes32,uint16,uint16,uint32,bytes)", + )(firstE3Id, candidateHash, 0, 1, 2, candidate), + ).to.be.revertedWithCustomError( + registry, + "PublicKeyPublisherNotCommitteeMember", + ); + await expect( + registry + .connect(operator1) + .getFunction( + "publishCommitteePublicKey(uint256,bytes32,uint16,uint16,uint32,bytes)", + )(firstE3Id, candidateHash, 0, 1, 2, candidate), ) - .to.emit(registry, "CommitteePublished") + .to.emit(registry, "CommitteePublicKeyChunkPublished") .withArgs( firstE3Id, + await operator1.getAddress(), + candidateHash, [ await operator3.getAddress(), await operator1.getAddress(), await operator2.getAddress(), ], - "0xdead", dataHash, - "0x", + 0, + 1, + 2, + candidate, ); + }); - await expect(registry.publishCommitteePublicKey(firstE3Id, data)) - .to.emit(registry, "CommitteePublished") - .withArgs( - firstE3Id, - [ - await operator3.getAddress(), - await operator1.getAddress(), - await operator2.getAddress(), - ], - data, - dataHash, - "0x", + it("rejects public-key chunks after the E3 is terminal", async function () { + const { + registry, + interfold, + usdcToken, + mockE3Program, + mockDecryptionVerifier, + operator1, + operator2, + operator3, + } = await loadFixture(setup); + await makeRequest( + interfold, + usdcToken, + mockE3Program, + mockDecryptionVerifier, + ); + await registry.connect(operator1).submitTicket(firstE3Id, 1); + await registry.connect(operator2).submitTicket(firstE3Id, 1); + await registry.connect(operator3).submitTicket(firstE3Id, 1); + await finalizeCommitteeAfterWindow(registry, firstE3Id); + await registry.publishCommittee( + firstE3Id, + dataHash, + encodeMockDkgProof(dataHash), + "0x01", + ); + + const deadlines = await interfold.getDeadlines(firstE3Id); + await networkHelpers.time.setNextBlockTimestamp( + deadlines.computeDeadline + 1n, + ); + await interfold.markE3Failed(firstE3Id); + + const candidate = "0xdead"; + await expect( + registry + .connect(operator1) + .getFunction( + "publishCommitteePublicKey(uint256,bytes32,uint16,uint16,uint32,bytes)", + )(firstE3Id, ethers.keccak256(candidate), 0, 1, 2, candidate), + ) + .to.be.revertedWithCustomError(interfold, "InvalidStage") + .withArgs(firstE3Id, 3, 6); + }); + + it("accepts a complete multi-transaction public-key candidate under the RPC size limit", async function () { + const { + registry, + interfold, + usdcToken, + mockE3Program, + mockDecryptionVerifier, + operator1, + operator2, + operator3, + } = await loadFixture(setup); + await makeRequest( + interfold, + usdcToken, + mockE3Program, + mockDecryptionVerifier, + ); + await registry.connect(operator1).submitTicket(firstE3Id, 1); + await registry.connect(operator2).submitTicket(firstE3Id, 1); + await registry.connect(operator3).submitTicket(firstE3Id, 1); + await finalizeCommitteeAfterWindow(registry, firstE3Id); + await registry.publishCommittee( + firstE3Id, + dataHash, + encodeMockDkgProof(dataHash), + "0x01", + ); + + const firstChunk = new Uint8Array(90 * 1024).fill(0x11); + const secondChunk = new Uint8Array([0x22]); + const complete = ethers.concat([firstChunk, secondChunk]); + const candidateHash = ethers.keccak256(complete); + const publish = registry + .connect(operator1) + .getFunction( + "publishCommitteePublicKey(uint256,bytes32,uint16,uint16,uint32,bytes)", ); + const firstTx = await publish.populateTransaction( + firstE3Id, + candidateHash, + 0, + 2, + firstChunk.length + secondChunk.length, + firstChunk, + ); + expect(ethers.getBytes(firstTx.data).length).to.be.lessThan(128 * 1024); + + await expect( + publish( + firstE3Id, + candidateHash, + 0, + 2, + firstChunk.length + secondChunk.length, + firstChunk, + ), + ).to.emit(registry, "CommitteePublicKeyChunkPublished"); + await expect( + publish( + firstE3Id, + candidateHash, + 1, + 2, + firstChunk.length + secondChunk.length, + secondChunk, + ), + ).to.emit(registry, "CommitteePublicKeyChunkPublished"); }); }); diff --git a/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts b/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts index cb32a5473e..3cd0ebfb07 100644 --- a/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts +++ b/packages/interfold-contracts/test/Slashing/CommitteeExpulsion.spec.ts @@ -23,15 +23,15 @@ import { COMMITTEE_THRESHOLDS_ONCHAIN, LARGE_TIMEOUT_CONFIG, ONE_DAY, - SORTITION_SUBMISSION_WINDOW, deployInterfoldSystem, encodeMockDkgProof, ethers, networkHelpers, + publishAvailableCiphertextOutput, signAndEncodeAttestation, } from "../fixtures"; -const { loadFixture, mine, time } = networkHelpers; +const { loadFixture, time } = networkHelpers; describe("Committee Expulsion & Fault Tolerance", function () { let firstE3Id: bigint; @@ -171,14 +171,15 @@ describe("Committee Expulsion & Fault Tolerance", function () { const fee = await interfold.getE3Quote(requestParams); await usdcToken.connect(requester).approve(interfoldAddress, fee); await interfold.connect(requester).request(requestParams); - await mine(1); + await time.increase(1); } async function finalizeCommittee(e3Id: bigint, operators: Signer[]) { for (const op of operators) await registry.connect(op).submitTicket(e3Id, 1); - await time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(e3Id); + await time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(e3Id); } @@ -928,7 +929,8 @@ describe("Committee Expulsion & Fault Tolerance", function () { const ciphertext = "0x" + "ab".repeat(100); await expect( - interfold.publishCiphertextOutput( + publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertext, ethers.keccak256(ciphertext), @@ -953,7 +955,8 @@ describe("Committee Expulsion & Fault Tolerance", function () { ); const ciphertext = "0x" + "ab".repeat(100); - await interfold.publishCiphertextOutput( + await publishAvailableCiphertextOutput( + interfold, firstE3Id, ciphertext, ethers.keccak256(ciphertext), diff --git a/packages/interfold-contracts/test/fixtures/helpers.ts b/packages/interfold-contracts/test/fixtures/helpers.ts index 7726357ebc..59e021db19 100644 --- a/packages/interfold-contracts/test/fixtures/helpers.ts +++ b/packages/interfold-contracts/test/fixtures/helpers.ts @@ -9,11 +9,7 @@ import type { ContractTransactionResponse, Signer } from "ethers"; import type { IInterfold, Interfold } from "../../types/contracts/Interfold"; import type { MockUSDC } from "../../types/contracts/test/MockStableToken.sol/MockUSDC"; import { ethers, networkHelpers } from "./connection"; -import { - ACTIVE_CRYPTO_CONFIG_ID, - COMMITTEE_SIZE_MINIMUM, - SORTITION_SUBMISSION_WINDOW, -} from "./constants"; +import { ACTIVE_CRYPTO_CONFIG_ID, COMMITTEE_SIZE_MINIMUM } from "./constants"; import { buildMockDkgAttestationFixtureData } from "./dkgAttestation"; const { time } = networkHelpers; @@ -66,11 +62,12 @@ export const setupAndPublishCommittee = async ( committeeProof: string = "0x", dkgAttestationBundle: string = "0x", ): Promise => { - await networkHelpers.mine(1); + await time.increase(1); for (const operator of operators) { await registry.connect(operator).submitTicket(e3Id, 1); } - await time.increase(SORTITION_SUBMISSION_WINDOW + 1); + const deadline = await registry.getCommitteeDeadline(e3Id); + await time.setNextBlockTimestamp(deadline + 1n); await registry.finalizeCommittee(e3Id); const pkCommitment = ethers.keccak256(publicKey); if (committeeProof === "0x" && dkgAttestationBundle === "0x") { @@ -99,9 +96,56 @@ export const setupAndPublishCommittee = async ( committeeProof, dkgAttestationBundle, ); - await registry.publishCommitteePublicKey(e3Id, publicKey); + const publicKeyBytes = ethers.getBytes(publicKey); + await registry + .connect(operators[0]) + .publishCommitteePublicKey( + e3Id, + ethers.keccak256(publicKey), + 0, + 1, + publicKeyBytes.length, + publicKeyBytes, + ); }; +/** Build the reference payload accepted by the protocol's DA-only output path. */ +export const ciphertextOutputReference = ( + ciphertextOutput: string, + ciphertextCommitment: string, + computeProof: string, +) => ({ + contentHash: ethers.keccak256(ciphertextOutput), + ciphertextCommitment, + computeProof, + // MockE3ProgramHarness treats the raw object as its deterministic local receipt. + availabilityProof: ciphertextOutput, +}); + +/** Publish a test ciphertext through the deterministic local DA verifier. */ +export const publishAvailableCiphertextOutput = ( + interfold: Interfold, + e3Id: number | bigint, + ciphertextOutput: string, + ciphertextCommitment: string, + computeProof: string, +) => + interfold.publishCiphertextOutput( + e3Id, + abiCoder.encode( + [ + "tuple(bytes32 contentHash,bytes32 ciphertextCommitment,bytes computeProof,bytes availabilityProof)", + ], + [ + ciphertextOutputReference( + ciphertextOutput, + ciphertextCommitment, + computeProof, + ), + ], + ), + ); + /** * Approve USDC for the quoted fee and submit an E3 request. * diff --git a/packages/interfold-dashboard/src/lib/e3.ts b/packages/interfold-dashboard/src/lib/e3.ts index 532af52a85..8d1eb2d657 100644 --- a/packages/interfold-dashboard/src/lib/e3.ts +++ b/packages/interfold-dashboard/src/lib/e3.ts @@ -26,16 +26,21 @@ const REGISTRY_COMMITTEE_FINALIZED = eventAbi(ciphernodeRegistryAbi as any, 'Sor // CRISP votes are NOT published through Interfold (its IInterfold.InputPublished // event is declared but never emitted). Each E3 program records its own inputs. -// CRISPProgram emits this on every accepted ballot; a re-vote reuses the same -// `index` (the vote's Merkle-tree leaf), so the true ballot count is the number -// of DISTINCT indexes, not the event count. +// CRISPProgram emits this after the availability proof for an accepted input succeeds. Every +// accepted input, including a re-vote, has its own tree index. Count distinct indexes so an +// overlapping log scan cannot count the same event twice. const CRISP_INPUT_PUBLISHED = { type: 'event', name: 'InputPublished', inputs: [ { name: 'e3Id', type: 'uint256', indexed: true }, - { name: 'encryptedVote', type: 'bytes', indexed: false }, + { name: 'slotAddress', type: 'address', indexed: true }, + { name: 'encryptedVoteCommitment', type: 'bytes32', indexed: false }, + { name: 'encryptedVoteHash', type: 'bytes32', indexed: false }, + { name: 'availabilityBlock', type: 'uint32', indexed: false }, + { name: 'availabilityLeafIndex', type: 'uint128', indexed: false }, { name: 'index', type: 'uint256', indexed: false }, + { name: 'parentIndexPlusOne', type: 'uint40', indexed: false }, ], } as const @@ -171,7 +176,7 @@ export type E3FullDetails = E3Summary & { resultTxHash?: `0x${string}` resultAt?: number resultBlock?: bigint - // Fees, in fee-token base units (MockUSDC, 6 decimals). feeEscrowed is the + // Fees, in fee-token base units. feeEscrowed is the // amount currently held for the E3 โ€” note Interfold zeroes it on settlement or // refund, so a completed/refunded E3 reads 0. committeeReward is the real // total paid out to the committee (only known once RewardsDistributed fires). diff --git a/packages/interfold-sdk/README.md b/packages/interfold-sdk/README.md index c9f606774b..8e7e359d6b 100644 --- a/packages/interfold-sdk/README.md +++ b/packages/interfold-sdk/README.md @@ -49,7 +49,7 @@ const sdk = new InterfoldSDK({ feeToken: '0x...', // Your ERC-20 fee token address }, chain: sepolia, - // 'INSECURE_THRESHOLD_512' for local dev and Sepolia; 'SECURE_THRESHOLD_8192' for production + // Match the parameter set selected by the target E3. thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', }) @@ -95,7 +95,7 @@ const sdk = InterfoldSDK.create({ }, chain: sepolia, privateKey: '0x...', // optional โ€” omit for read-only - // 'INSECURE_THRESHOLD_512' for local dev and Sepolia; 'SECURE_THRESHOLD_8192' for production + // Match the parameter set selected by the target E3. thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', }) ``` @@ -355,7 +355,12 @@ await sdk.requestE3({ }); // Publish ciphertext output -await sdk.publishCiphertextOutput(e3Id: bigint, ciphertextOutput: `0x${string}`, ciphertextCommitment: `0x${string}`, proof: `0x${string}`, gasLimit?: bigint); +await sdk.publishCiphertextOutput(e3Id, { + contentHash, + ciphertextCommitment, + computeProof, + availabilityProof, +}, gasLimit); // Read operations const e3Data = await sdk.getE3(e3Id: bigint); @@ -394,24 +399,41 @@ await sdk.startEventPolling(); sdk.stopEventPolling(); ``` -`CommitteePublished.publicKey` is an untrusted transport value. Validate it against the event's -on-chain commitment before using it for encryption: +Committee public keys are emitted as `CommitteePublicKeyChunkPublished` events. Reassemble the +canonical chunk sequence, check its Keccak hash, and validate the decoded key against the event's +proven `pkCommitment` before using it for encryption: ```typescript import { hexToBytes } from 'viem' +import { CommitteePublicKeyAssembler, RegistryEventType } from '@interfold/sdk' + +const assembler = new CommitteePublicKeyAssembler() + +await sdk.onInterfoldEvent( + RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED, + async (event) => { + const assembled = assembler.add(event.data) + if (!assembled) return + + if ( + !(await sdk.validatePublicKeyCommitment( + assembled.publicKey, + hexToBytes(assembled.pkCommitment), + )) + ) { + throw new Error('Committee public-key commitment mismatch') + } -await sdk.onInterfoldEvent(RegistryEventType.COMMITTEE_PUBLISHED, async (event) => { - const publicKey = hexToBytes(event.data.publicKey) - const expectedCommitment = hexToBytes(event.data.pkCommitment) - - if (!(await sdk.validatePublicKeyCommitment(publicKey, expectedCommitment))) { - throw new Error('Committee public-key commitment mismatch') - } - - // The key is now safe to pass to the encryption methods. -}) + assembler.clear(assembled.e3Id) + // assembled.publicKey is now safe to pass to the encryption methods. + }, +) ``` +The assembler tracks at most 128 E3s by default. Pass a positive limit to the constructor if the +application needs a different memory bound. Evicted incomplete keys can be rebuilt by replaying +their chunk events. + #### Encryption ```typescript @@ -468,21 +490,20 @@ interface SDKConfig { `thresholdBfvParamsPresetName` selects the BFV parameter set used for encryption. It must match the on-chain `paramSet` index registered in the Interfold contract: -| Preset name | On-chain `paramSet` index | Use case | -| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------ | -| `'INSECURE_THRESHOLD_512'` | `0` | Local development and Sepolia โ€” small polynomial degree (N=512), fast but not cryptographically secure | -| `'SECURE_THRESHOLD_8192'` | `1` | Production โ€” full security parameters (N=8192, L=3 CRT moduli) | - -| Network | Preset | `paramSet` | -| ---------------------- | -------------------------- | ---------- | -| Local development | `'INSECURE_THRESHOLD_512'` | `0` | -| Sepolia testnet | `'INSECURE_THRESHOLD_512'` | `0` | -| Mainnet and production | `'SECURE_THRESHOLD_8192'` | `1` | - -Use `'INSECURE_THRESHOLD_512'` on Sepolia: the Sepolia ciphernodes run the insecure preset, and the -circuit artifacts bundled in this package are compiled for it. Use `'SECURE_THRESHOLD_8192'` in -production, together with your own `secure-8192` circuit artifacts (see -[Proving](#proving-embedded-circuits-or-your-own)). +| Preset name | On-chain `paramSet` index | Use case | +| -------------------------- | ------------------------- | ------------------------------------------------------------------------ | +| `'INSECURE_THRESHOLD_512'` | `0` | Fast local or testnet work. This preset is not cryptographically secure. | +| `'SECURE_THRESHOLD_8192'` | `1` | Production-equivalent work with degree 8192 and three ciphertext moduli. | + +| Network | Supported presets | +| ----------------- | -------------------------------------------------------- | +| Local development | `'INSECURE_THRESHOLD_512'` and `'SECURE_THRESHOLD_8192'` | +| Sepolia testnet | `'INSECURE_THRESHOLD_512'` and `'SECURE_THRESHOLD_8192'` | +| Ethereum mainnet | `'SECURE_THRESHOLD_8192'` only | + +Use the preset that the target E3 selects. This package includes proof artifacts only for +`'INSECURE_THRESHOLD_512'`. For secure proof generation, use matching application artifacts such as +the `@crisp-e3/sdk/secure-8192` entry point, or compile your own artifacts. ### Proving: embedded circuits or your own diff --git a/packages/interfold-sdk/src/committee-public-key.ts b/packages/interfold-sdk/src/committee-public-key.ts new file mode 100644 index 0000000000..de0fd32023 --- /dev/null +++ b/packages/interfold-sdk/src/committee-public-key.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import { hexToBytes, isHex, keccak256, type Hex } from 'viem' + +import type { CommitteePublicKeyChunkPublishedData } from './events/types' + +export const MAX_COMMITTEE_PUBLIC_KEY_BYTES = 512 * 1024 +export const MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES = 90 * 1024 +export const DEFAULT_MAX_TRACKED_COMMITTEE_KEYS = 128 + +export interface AssembledCommitteePublicKey { + e3Id: bigint + nodes: string[] + pkCommitment: Hex + publicKey: Uint8Array +} + +interface Assembly { + nodes: string[] + pkCommitment: Hex + candidateHash: Hex + totalLength: number + chunks: Array +} + +/** + * Reassembles the deterministic public-key chunks emitted by the registry. + * + * The first candidate emitted by each publisher is the only candidate accepted + * from that publisher. This matches ciphernode and indexer recovery behavior. + */ +export class CommitteePublicKeyAssembler { + private readonly selectedCandidates = new Map() + private readonly assemblies = new Map() + private readonly invalidAssemblies = new Set() + private readonly completedAssemblies = new Set() + private readonly trackedE3s = new Map() + + constructor(private readonly maxTrackedE3s = DEFAULT_MAX_TRACKED_COMMITTEE_KEYS) { + if (!Number.isInteger(maxTrackedE3s) || maxTrackedE3s < 1) { + throw new Error('Maximum tracked committee-key count must be a positive integer') + } + } + + add(event: CommitteePublicKeyChunkPublishedData): AssembledCommitteePublicKey | undefined { + const e3Key = event.e3Id.toString() + this.track(e3Key) + const metadata = validateEvent(event) + const publisherKey = `${e3Key}:${event.publisher.toLowerCase()}` + const selectedCandidate = this.selectedCandidates.get(publisherKey) + if (selectedCandidate && selectedCandidate.toLowerCase() !== metadata.candidateHash.toLowerCase()) { + return undefined + } + this.selectedCandidates.set(publisherKey, metadata.candidateHash) + + const assemblyKey = `${publisherKey}:${metadata.candidateHash.toLowerCase()}` + if (this.completedAssemblies.has(assemblyKey)) return undefined + if (this.invalidAssemblies.has(assemblyKey)) return undefined + const assembly = this.assemblies.get(assemblyKey) ?? { + nodes: [...event.nodes], + pkCommitment: metadata.pkCommitment, + candidateHash: metadata.candidateHash, + totalLength: event.totalLength, + chunks: Array.from({ length: event.chunkCount }).fill(undefined), + } + + if (!metadataMatches(assembly, event, metadata.pkCommitment)) { + this.assemblies.delete(assemblyKey) + this.invalidAssemblies.add(assemblyKey) + return undefined + } + + const existing = assembly.chunks[event.chunkIndex] + if (existing && !bytesEqual(existing, metadata.chunk)) { + this.assemblies.delete(assemblyKey) + this.invalidAssemblies.add(assemblyKey) + return undefined + } + assembly.chunks[event.chunkIndex] = metadata.chunk + this.assemblies.set(assemblyKey, assembly) + + if (assembly.chunks.some((chunk) => chunk === undefined)) return undefined + + const publicKey = new Uint8Array(assembly.totalLength) + let offset = 0 + for (const chunk of assembly.chunks as Uint8Array[]) { + publicKey.set(chunk, offset) + offset += chunk.length + } + if (offset !== assembly.totalLength || keccak256(publicKey).toLowerCase() !== assembly.candidateHash.toLowerCase()) { + this.assemblies.delete(assemblyKey) + this.invalidAssemblies.add(assemblyKey) + return undefined + } + + this.completedAssemblies.add(assemblyKey) + this.assemblies.delete(assemblyKey) + return { + e3Id: event.e3Id, + nodes: assembly.nodes, + pkCommitment: assembly.pkCommitment, + publicKey, + } + } + + clear(e3Id: bigint): void { + const e3Key = e3Id.toString() + this.trackedE3s.delete(e3Key) + this.dropE3(e3Key) + } + + private track(e3Key: string): void { + this.trackedE3s.delete(e3Key) + this.trackedE3s.set(e3Key, true) + while (this.trackedE3s.size > this.maxTrackedE3s) { + const oldest = this.trackedE3s.keys().next().value + if (oldest === undefined) return + this.trackedE3s.delete(oldest) + this.dropE3(oldest) + } + } + + private dropE3(e3Key: string): void { + const prefix = `${e3Key}:` + for (const key of this.selectedCandidates.keys()) { + if (key.startsWith(prefix)) this.selectedCandidates.delete(key) + } + for (const key of this.assemblies.keys()) { + if (key.startsWith(prefix)) this.assemblies.delete(key) + } + for (const key of this.invalidAssemblies) { + if (key.startsWith(prefix)) this.invalidAssemblies.delete(key) + } + for (const key of this.completedAssemblies) { + if (key.startsWith(prefix)) this.completedAssemblies.delete(key) + } + } +} + +function validateEvent(event: CommitteePublicKeyChunkPublishedData): { + candidateHash: Hex + pkCommitment: Hex + chunk: Uint8Array +} { + if (!isBytes32(event.candidateHash) || !isBytes32(event.pkCommitment)) { + throw new Error('Committee public-key chunk contains an invalid bytes32 value') + } + if (!Number.isInteger(event.totalLength) || event.totalLength <= 0 || event.totalLength > MAX_COMMITTEE_PUBLIC_KEY_BYTES) { + throw new Error('Committee public-key total length is outside the supported range') + } + const expectedChunkCount = Math.ceil(event.totalLength / MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES) + if (!Number.isInteger(event.chunkCount) || event.chunkCount !== expectedChunkCount) { + throw new Error('Committee public-key chunk count does not match the total length') + } + if (!Number.isInteger(event.chunkIndex) || event.chunkIndex < 0 || event.chunkIndex >= event.chunkCount) { + throw new Error('Committee public-key chunk index is outside the candidate range') + } + if (!isHex(event.chunk)) { + throw new Error('Committee public-key chunk is not valid hex') + } + + const chunk = hexToBytes(event.chunk) + const remaining = event.totalLength - event.chunkIndex * MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES + const expectedLength = Math.min(remaining, MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES) + if (chunk.length !== expectedLength) { + throw new Error('Committee public-key chunk length does not match its index') + } + + return { + candidateHash: event.candidateHash as Hex, + pkCommitment: event.pkCommitment as Hex, + chunk, + } +} + +function metadataMatches(assembly: Assembly, event: CommitteePublicKeyChunkPublishedData, pkCommitment: Hex): boolean { + return ( + assembly.totalLength === event.totalLength && + assembly.chunks.length === event.chunkCount && + assembly.pkCommitment.toLowerCase() === pkCommitment.toLowerCase() && + assembly.nodes.length === event.nodes.length && + assembly.nodes.every((node, index) => node.toLowerCase() === event.nodes[index]?.toLowerCase()) + ) +} + +function isBytes32(value: string): value is Hex { + return /^0x[0-9a-fA-F]{64}$/.test(value) +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return left.length === right.length && left.every((byte, index) => byte === right[index]) +} diff --git a/packages/interfold-sdk/src/contracts/contract-client.ts b/packages/interfold-sdk/src/contracts/contract-client.ts index 31ffb505a7..9822835ccb 100644 --- a/packages/interfold-sdk/src/contracts/contract-client.ts +++ b/packages/interfold-sdk/src/contracts/contract-client.ts @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { + encodeAbiParameters, type Abi, type Chain, type Hash, @@ -19,7 +20,7 @@ import { import { privateKeyToAccount } from 'viem/accounts' import { CiphernodeRegistryOwnable__factory, Interfold__factory, InterfoldToken__factory } from '@interfold/contracts/types' -import type { ContractAddresses, E3, E3RequestParams, E3Stage, FailureReason } from './types' +import type { CiphertextOutputReference, ContractAddresses, E3, E3RequestParams, E3Stage, FailureReason } from './types' import { validateCommitteeSize } from './types' import { SDKError, cryptoConfigIdForParamSet, isValidAddress } from '../utils' @@ -199,13 +200,7 @@ export class ContractClient { } } - public async publishCiphertextOutput( - e3Id: bigint, - ciphertextOutput: `0x${string}`, - ciphertextCommitment: `0x${string}`, - proof: `0x${string}`, - gasLimit?: bigint, - ): Promise { + public async publishCiphertextOutput(e3Id: bigint, outputReference: CiphertextOutputReference, gasLimit?: bigint): Promise { if (!this.walletClient) { throw new SDKError('Wallet client required for write operations', 'NO_WALLET') } @@ -216,11 +211,26 @@ export class ContractClient { throw new SDKError('No account connected', 'NO_ACCOUNT') } + const encodedOutputReference = encodeAbiParameters( + [ + { + type: 'tuple', + components: [ + { name: 'contentHash', type: 'bytes32' }, + { name: 'ciphertextCommitment', type: 'bytes32' }, + { name: 'computeProof', type: 'bytes' }, + { name: 'availabilityProof', type: 'bytes' }, + ], + }, + ], + [outputReference], + ) + const { request } = await this.publicClient.simulateContract({ address: this.contracts.interfold, abi: Interfold__factory.abi, functionName: 'publishCiphertextOutput', - args: [e3Id, ciphertextOutput, ciphertextCommitment, proof], + args: [e3Id, encodedOutputReference], account, gas: gasLimit, }) diff --git a/packages/interfold-sdk/src/contracts/types.ts b/packages/interfold-sdk/src/contracts/types.ts index af9b1b94b6..51bf475871 100644 --- a/packages/interfold-sdk/src/contracts/types.ts +++ b/packages/interfold-sdk/src/contracts/types.ts @@ -65,6 +65,14 @@ export interface E3RequestParams extends RequestParams { maxFee?: bigint } +/** Proof-backed reference to an aggregate ciphertext published on the configured DA layer. */ +export interface CiphertextOutputReference { + contentHash: `0x${string}` + ciphertextCommitment: `0x${string}` + computeProof: `0x${string}` + availabilityProof: `0x${string}` +} + export enum E3Stage { None, Requested, diff --git a/packages/interfold-sdk/src/events/event-listener.ts b/packages/interfold-sdk/src/events/event-listener.ts index a926056323..b33947925e 100644 --- a/packages/interfold-sdk/src/events/event-listener.ts +++ b/packages/interfold-sdk/src/events/event-listener.ts @@ -60,6 +60,7 @@ export class EventListener implements SDKEventEmitter { RegistryEventType.COMMITTEE_RANDOMNESS_REQUESTED, RegistryEventType.RANDOMNESS_CIRCUIT_BREAKER_TRIPPED, RegistryEventType.COMMITTEE_PUBLISHED, + RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED, RegistryEventType.COMMITTEE_FINALIZED, RegistryEventType.INTERFOLD_SET, ]) diff --git a/packages/interfold-sdk/src/events/index.ts b/packages/interfold-sdk/src/events/index.ts index 568595584a..9361c7de2a 100644 --- a/packages/interfold-sdk/src/events/index.ts +++ b/packages/interfold-sdk/src/events/index.ts @@ -19,6 +19,7 @@ export type { E3RequestedData, E3ActivatedData, CiphertextOutputPublishedData, + CiphertextOutputReferencePublishedData, PlaintextOutputPublishedData, CiphernodeAddedData, CiphernodeRemovedData, @@ -29,6 +30,7 @@ export type { RandomnessProviderEvent, RandomnessProviderEventCallback, CommitteePublishedData, + CommitteePublicKeyChunkPublishedData, CommitteeFinalizedData, InterfoldEventData, RegistryEventData, diff --git a/packages/interfold-sdk/src/events/types.ts b/packages/interfold-sdk/src/events/types.ts index 576d466d73..f2f3871442 100644 --- a/packages/interfold-sdk/src/events/types.ts +++ b/packages/interfold-sdk/src/events/types.ts @@ -9,6 +9,7 @@ import type { Log } from 'viem' export enum InterfoldEventType { E3_REQUESTED = 'E3Requested', CIPHERTEXT_OUTPUT_PUBLISHED = 'CiphertextOutputPublished', + CIPHERTEXT_OUTPUT_REFERENCE_PUBLISHED = 'CiphertextOutputReferencePublished', PLAINTEXT_OUTPUT_PUBLISHED = 'PlaintextOutputPublished', E3_PROGRAM_REGISTERED = 'E3ProgramRegistered', ENCRYPTION_SCHEME_ENABLED = 'EncryptionSchemeEnabled', @@ -24,6 +25,7 @@ export enum RegistryEventType { COMMITTEE_RANDOMNESS_REQUESTED = 'CommitteeRandomnessRequested', RANDOMNESS_CIRCUIT_BREAKER_TRIPPED = 'RandomnessCircuitBreakerTripped', COMMITTEE_PUBLISHED = 'CommitteePublished', + COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED = 'CommitteePublicKeyChunkPublished', COMMITTEE_FINALIZED = 'SortitionCommitteeFinalized', INTERFOLD_SET = 'InterfoldSet', OWNERSHIP_TRANSFERRED = 'OwnershipTransferred', @@ -67,6 +69,14 @@ export interface CiphertextOutputPublishedData { ciphertextCommitment: string } +export interface CiphertextOutputReferencePublishedData { + e3Id: bigint + contentHash: string + ciphertextCommitment: string + availabilityBlock: number + availabilityLeafIndex: bigint +} + export interface PlaintextOutputPublishedData { e3Id: bigint plaintextOutput: string @@ -124,6 +134,18 @@ export interface CommitteePublishedData { proof: string } +export interface CommitteePublicKeyChunkPublishedData { + e3Id: bigint + publisher: string + candidateHash: string + nodes: string[] + pkCommitment: string + chunkIndex: number + chunkCount: number + totalLength: number + chunk: string +} + export interface CommitteeFinalizedData { e3Id: bigint committee: string[] @@ -133,6 +155,7 @@ export interface CommitteeFinalizedData { export interface InterfoldEventData { [InterfoldEventType.E3_REQUESTED]: E3RequestedData [InterfoldEventType.CIPHERTEXT_OUTPUT_PUBLISHED]: CiphertextOutputPublishedData + [InterfoldEventType.CIPHERTEXT_OUTPUT_REFERENCE_PUBLISHED]: CiphertextOutputReferencePublishedData [InterfoldEventType.PLAINTEXT_OUTPUT_PUBLISHED]: PlaintextOutputPublishedData [InterfoldEventType.E3_PROGRAM_REGISTERED]: { e3Program: string } [InterfoldEventType.ENCRYPTION_SCHEME_ENABLED]: { encryptionSchemeId: string } @@ -148,6 +171,7 @@ export interface RegistryEventData { [RegistryEventType.COMMITTEE_RANDOMNESS_REQUESTED]: CommitteeRandomnessRequestedData [RegistryEventType.RANDOMNESS_CIRCUIT_BREAKER_TRIPPED]: RandomnessCircuitBreakerTrippedData [RegistryEventType.COMMITTEE_PUBLISHED]: CommitteePublishedData + [RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED]: CommitteePublicKeyChunkPublishedData [RegistryEventType.COMMITTEE_FINALIZED]: CommitteeFinalizedData [RegistryEventType.INTERFOLD_SET]: { interfold: string } [RegistryEventType.OWNERSHIP_TRANSFERRED]: { previousOwner: string; newOwner: string } diff --git a/packages/interfold-sdk/src/index.ts b/packages/interfold-sdk/src/index.ts index 4c279587c7..ed79827142 100644 --- a/packages/interfold-sdk/src/index.ts +++ b/packages/interfold-sdk/src/index.ts @@ -10,8 +10,10 @@ export { InterfoldSDK } from './interfold-sdk' // Core classes export { EventListener } from './events/event-listener' export { ContractClient } from './contracts/contract-client' +export { CommitteePublicKeyAssembler, MAX_COMMITTEE_PUBLIC_KEY_BYTES, MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES } from './committee-public-key' export type { ContractClientConfig } from './contracts/contract-client' export type { EventListenerOptions } from './events/event-listener' +export type { AssembledCommitteePublicKey } from './committee-public-key' // Standalone encryption functions export { @@ -34,6 +36,7 @@ export type { ContractAddresses, E3, E3RequestParams, + CiphertextOutputReference, EventListenerConfig, EventFilter, EventCallback, @@ -53,6 +56,7 @@ export type { RandomnessProviderEvent, RandomnessProviderEventCallback, CommitteePublishedData, + CommitteePublicKeyChunkPublishedData, CommitteeFinalizedData, InterfoldEventData, RegistryEventData, diff --git a/packages/interfold-sdk/src/interfold-sdk.ts b/packages/interfold-sdk/src/interfold-sdk.ts index fa40d2121a..7a1e84c56e 100644 --- a/packages/interfold-sdk/src/interfold-sdk.ts +++ b/packages/interfold-sdk/src/interfold-sdk.ts @@ -38,7 +38,7 @@ import { DEFAULT_THRESHOLD_BFV_PARAMS_PRESET_NAME } from './constants' import type { SDKConfig } from './types' import type { AllEventTypes, EventCallback, RandomnessProviderEventCallback, RandomnessProviderEventType } from './events/types' -import type { E3, E3RequestParams } from './contracts/types' +import type { CiphertextOutputReference, E3, E3RequestParams } from './contracts/types' import { E3Stage, FailureReason } from './contracts/types' import type { BfvParams, EncryptedValueAndPublicInputs, ThresholdBfvParamsPresetName, VerifiableEncryptionResult } from './crypto/types' @@ -111,9 +111,9 @@ export class InterfoldSDK { /** * Validate serialized BFV public-key bytes before using them for encryption. * - * `CommitteePublished.publicKey` is an untrusted transport value. Consumers - * must compare its semantic BFV commitment with the event's on-chain - * `pkCommitment` before accepting it. + * A reassembled public key is an untrusted transport value. Consumers must + * compare its semantic BFV commitment with the on-chain `pkCommitment` + * before accepting it. */ public async validatePublicKeyCommitment(publicKey: Uint8Array, expectedCommitment: Uint8Array): Promise { if (expectedCommitment.length !== 32) { @@ -168,14 +168,8 @@ export class InterfoldSDK { return this.contractClient.getE3PublicKey(e3Id) } - public async publishCiphertextOutput( - e3Id: bigint, - ciphertextOutput: `0x${string}`, - ciphertextCommitment: `0x${string}`, - proof: `0x${string}`, - gasLimit?: bigint, - ): Promise { - return this.contractClient.publishCiphertextOutput(e3Id, ciphertextOutput, ciphertextCommitment, proof, gasLimit) + public async publishCiphertextOutput(e3Id: bigint, outputReference: CiphertextOutputReference, gasLimit?: bigint): Promise { + return this.contractClient.publishCiphertextOutput(e3Id, outputReference, gasLimit) } public async getE3(e3Id: bigint): Promise { diff --git a/packages/interfold-sdk/src/types.ts b/packages/interfold-sdk/src/types.ts index c29fac7835..d7f7376619 100644 --- a/packages/interfold-sdk/src/types.ts +++ b/packages/interfold-sdk/src/types.ts @@ -13,7 +13,7 @@ export type { BfvParams, ThresholdBfvParamsPresetName, VerifiableEncryptionResul export { ThresholdBfvParamsPresetNames } from './crypto/types' -export type { ContractAddresses, E3, E3RequestParams } from './contracts/types' +export type { CiphertextOutputReference, ContractAddresses, E3, E3RequestParams } from './contracts/types' export { E3Stage, FailureReason, CommitteeSize, validateCommitteeSize } from './contracts/types' export { InterfoldEventType, RandomnessProviderEventType, RegistryEventType } from './events/types' @@ -28,6 +28,7 @@ export type { E3RequestedData, E3ActivatedData, CiphertextOutputPublishedData, + CiphertextOutputReferencePublishedData, PlaintextOutputPublishedData, CiphernodeAddedData, CiphernodeRemovedData, @@ -38,6 +39,7 @@ export type { RandomnessProviderEvent, RandomnessProviderEventCallback, CommitteePublishedData, + CommitteePublicKeyChunkPublishedData, CommitteeFinalizedData, InterfoldEventData, RegistryEventData, diff --git a/packages/interfold-sdk/tests/committee-public-key.test.ts b/packages/interfold-sdk/tests/committee-public-key.test.ts new file mode 100644 index 0000000000..9ce3fc9edb --- /dev/null +++ b/packages/interfold-sdk/tests/committee-public-key.test.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import { bytesToHex, keccak256 } from 'viem' +import { describe, expect, it } from 'vitest' + +import { CommitteePublicKeyAssembler, MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES } from '../src/committee-public-key' +import type { CommitteePublicKeyChunkPublishedData } from '../src/events/types' + +const publisher = '0x0000000000000000000000000000000000000001' +const nodes = [publisher, '0x0000000000000000000000000000000000000002'] +const pkCommitment = `0x${'11'.repeat(32)}` + +function eventsFor(bytes: Uint8Array, candidateHash = keccak256(bytes), e3Id = 7n): CommitteePublicKeyChunkPublishedData[] { + const chunkCount = Math.ceil(bytes.length / MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES) + return Array.from({ length: chunkCount }, (_, chunkIndex) => ({ + e3Id, + publisher, + candidateHash, + nodes, + pkCommitment, + chunkIndex, + chunkCount, + totalLength: bytes.length, + chunk: bytesToHex( + bytes.slice(chunkIndex * MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES, (chunkIndex + 1) * MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES), + ), + })) +} + +describe('CommitteePublicKeyAssembler', () => { + it('assembles deterministic chunks in any event order', () => { + const bytes = new Uint8Array(MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES + 17).map((_, index) => index % 251) + const [first, second] = eventsFor(bytes) + const assembler = new CommitteePublicKeyAssembler() + + expect(assembler.add(second)).toBeUndefined() + const result = assembler.add(first) + + expect(result?.e3Id).toBe(7n) + expect(result?.nodes).toEqual(nodes) + expect(result?.pkCommitment).toBe(pkCommitment) + expect(result?.publicKey).toEqual(bytes) + }) + + it('accepts an identical replay but rejects a conflicting duplicate', () => { + const bytes = new Uint8Array(MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES + 1).fill(7) + const [first, second] = eventsFor(bytes) + const assembler = new CommitteePublicKeyAssembler() + + expect(assembler.add(first)).toBeUndefined() + expect(assembler.add(first)).toBeUndefined() + expect( + assembler.add({ + ...first, + chunk: bytesToHex(new Uint8Array(MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES).fill(8)), + }), + ).toBeUndefined() + expect(assembler.add(second)).toBeUndefined() + }) + + it('does not accept bytes that differ from the candidate hash', () => { + const bytes = new Uint8Array([1, 2, 3]) + const [event] = eventsFor(bytes, `0x${'22'.repeat(32)}`) + + expect(new CommitteePublicKeyAssembler().add(event)).toBeUndefined() + }) + + it('allows another publisher after a candidate fails semantic validation', () => { + const wrongBytes = new Uint8Array([1, 2, 3]) + const validBytes = new Uint8Array([4, 5, 6]) + const [wrong] = eventsFor(wrongBytes, keccak256(wrongBytes), 13n) + const [valid] = eventsFor(validBytes, keccak256(validBytes), 13n) + const assembler = new CommitteePublicKeyAssembler() + + expect(assembler.add(wrong)?.publicKey).toEqual(wrongBytes) + expect( + assembler.add({ + ...valid, + publisher: '0x2222222222222222222222222222222222222222', + })?.publicKey, + ).toEqual(validBytes) + }) + + it('evicts old partial and completed E3 state at the configured bound', () => { + const large = new Uint8Array(MAX_COMMITTEE_PUBLIC_KEY_CHUNK_BYTES + 1).fill(3) + const [oldFirst, oldSecond] = eventsFor(large, keccak256(large), 7n) + const currentBytes = new Uint8Array([4]) + const [current] = eventsFor(currentBytes, keccak256(currentBytes), 8n) + const assembler = new CommitteePublicKeyAssembler(1) + + expect(assembler.add(oldFirst)).toBeUndefined() + expect(assembler.add(current)?.e3Id).toBe(8n) + expect(assembler.add(oldSecond)).toBeUndefined() + expect(assembler.add(current)?.e3Id).toBe(8n) + }) +}) diff --git a/packages/interfold-sdk/tsup.config.js b/packages/interfold-sdk/tsup.config.js index de108c1bb4..482422269c 100644 --- a/packages/interfold-sdk/tsup.config.js +++ b/packages/interfold-sdk/tsup.config.js @@ -20,7 +20,7 @@ export default defineConfig([ }), esbuildOptions: (options) => { options.alias = { - '@interfold/wasm/init': '../../crates/wasm/init_node.js', + '@interfold/wasm/init': '../../crates/wasm/init_node.mjs', } }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d01e8172..03ee51deac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,9 @@ importers: '@crisp-e3/zk-inputs': specifier: workspace:^ version: link:../crisp-zk-inputs + '@nomicfoundation/hardhat-ethers': + specifier: 4.0.3 + version: 4.0.3(bufferutil@4.1.0)(hardhat@3.0.11(bufferutil@4.1.0)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) '@nomicfoundation/hardhat-keystore': specifier: 3.0.3 version: 3.0.3(hardhat@3.0.11(bufferutil@4.1.0)(utf-8-validate@5.0.10)) @@ -824,7 +827,7 @@ importers: version: 5.0.2(@openzeppelin/contracts@5.3.0) '@risc0/ethereum': specifier: file:lib/risc0-ethereum - version: file:templates/default/lib/risc0-ethereum + version: risc0-ethereum@file:templates/default/lib/risc0-ethereum '@types/chai': specifier: ^4.2.0 version: 4.3.20 @@ -3385,9 +3388,6 @@ packages: '@reown/appkit@1.7.8': resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} - '@risc0/ethereum@file:templates/default/lib/risc0-ethereum': - resolution: {directory: templates/default/lib/risc0-ethereum, type: directory} - '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -9368,6 +9368,9 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} + risc0-ethereum@file:templates/default/lib/risc0-ethereum: + resolution: {directory: templates/default/lib/risc0-ethereum, type: directory} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -13174,7 +13177,7 @@ snapshots: debug: 4.4.3(supports-color@5.5.0) lodash: 4.17.21 pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -13198,7 +13201,7 @@ snapshots: '@types/debug': 4.1.13 debug: 4.4.3(supports-color@5.5.0) pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -13212,7 +13215,7 @@ snapshots: '@types/debug': 4.1.13 debug: 4.4.3(supports-color@5.5.0) pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -14138,8 +14141,6 @@ snapshots: - utf-8-validate - zod - '@risc0/ethereum@file:templates/default/lib/risc0-ethereum': {} - '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/plugin-inject@5.0.5(rollup@4.62.3)': @@ -14361,7 +14362,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -21840,6 +21841,8 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 + risc0-ethereum@file:templates/default/lib/risc0-ethereum: {} + robust-predicates@3.0.3: {} rollup@4.62.3: diff --git a/scripts/check-ciphernode-docker-members.py b/scripts/check-ciphernode-docker-members.py new file mode 100644 index 0000000000..369c3741ac --- /dev/null +++ b/scripts/check-ciphernode-docker-members.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-3.0-only +# +# This file is provided WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY. + +"""Check that the ciphernode Docker cache stage includes every workspace crate.""" + +from __future__ import annotations + +import re +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 and earlier + import tomli as tomllib # type: ignore[no-redef] + + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> int: + with (ROOT / "Cargo.toml").open("rb") as cargo_file: + members = tomllib.load(cargo_file)["workspace"]["members"] + + dockerfile = (ROOT / "crates" / "Dockerfile").read_text() + copied_manifests = set( + re.findall(r"^COPY\s+(crates/[^\s]+/Cargo\.toml)\s+", dockerfile, re.MULTILINE) + ) + required_manifests = { + f"{member}/Cargo.toml" for member in members if member.startswith("crates/") + } + missing = sorted(required_manifests - copied_manifests) + + if missing: + print("check-invariants: FAILED โ€” crates/Dockerfile omits workspace manifests:") + for manifest in missing: + print(f" {manifest}") + print(" Add each manifest to the dependency-cache COPY stage.") + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index 94e1ccae4f..330d835570 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -17,6 +17,8 @@ # crates/tests. Forwarding declarations in [features] sections are fine. # 3. runtime proof-skip guard โ€” validate_proof_aggregation_mode must exist and be # called in crates/entrypoint (INVARIANTS ยงNo proof-disabled bypass, C-02). +# 4. ciphernode Docker workspace coverage โ€” every root workspace crate manifest +# must be present in the dependency-cache stage of crates/Dockerfile. # # Exit 0 when all hold, 1 otherwise. @@ -78,7 +80,12 @@ if ! grep -rq 'fn validate_proof_aggregation_mode' crates/entrypoint/src || fail=1 fi +# --- 4. ciphernode Docker workspace coverage -------------------------------------- +if [[ -n "$python_bin" ]] && ! "$python_bin" scripts/check-ciphernode-docker-members.py; then + fail=1 +fi + if ((fail == 0)); then - echo "โœ“ check:invariants: do_send=$count (โ‰ค $DO_SEND_BASELINE), skip-proof feature contained, runtime guard present" + echo "โœ“ check:invariants: do_send=$count (โ‰ค $DO_SEND_BASELINE), skip-proof feature contained, runtime guard present, ciphernode Docker workspace complete" fi exit "$fail" diff --git a/scripts/generate-provenance-manifest.ts b/scripts/generate-provenance-manifest.ts index b772995fac..26f87d8d21 100644 --- a/scripts/generate-provenance-manifest.ts +++ b/scripts/generate-provenance-manifest.ts @@ -34,7 +34,8 @@ import path from 'path' const REPO_ROOT = path.resolve(__dirname, '..') const SUPPORT = path.join(REPO_ROOT, 'crates', 'support') const IMAGE_ID_SOL = path.join(SUPPORT, 'contracts', 'ImageID.sol') -const DOCKERFILE = path.join(SUPPORT, 'Dockerfile') +const SUPPORT_DOCKERFILE = path.join(SUPPORT, 'Dockerfile') +const GUEST_BUILDER_DOCKERFILE = path.join(SUPPORT, 'methods', 'guest-builder.Dockerfile') interface Args { rpc?: string @@ -111,10 +112,9 @@ function pinnedRevisions(): Record { * builds `risczero/risc0-guest-builder:` from it. Both sources here are therefore suffixes, * and the repository prefix belongs on the outside of the choice. * - * `crates/support/methods/build.rs:90-95` always sets the second, from `ARG RISC0_TOOLCHAIN` in - * `crates/support/Dockerfile`, so `DEFAULT_DOCKER_TAG` is unreachable from this repository. - * Reading it would record `r0.1.88.0` against a build that pulled `r0.1.91.1` โ€” a builder nobody - * used, which is the outcome this function exists to avoid. + * `crates/support/methods/build.rs` builds the checked-in Protobuf layer and always sets the + * second value to `interfold-r0.-protoc-v1`. The compiled-in default is therefore + * unreachable from this repository. * * The tag is mutable and does not identify a build on its own, so record the resolved digest * whenever Docker can supply it. @@ -131,7 +131,7 @@ function builderImage(guestToolchain: string | null) { // value would otherwise name the image `risczero/risc0-guest-builder:r0.`. const toolchain = guestToolchain?.trim() || null if (!toolchain) return { tag: null, digest: null } - const suffix = process.env.RISC0_DOCKER_CONTAINER_TAG?.trim() || `r0.${toolchain}` + const suffix = process.env.RISC0_DOCKER_CONTAINER_TAG?.trim() || `interfold-r0.${toolchain}-protoc-v1` const tag = `risczero/risc0-guest-builder:${suffix}` const digest = sh('docker', ['image', 'inspect', '--format', '{{index .RepoDigests 0}}', tag]) return { tag, digest } @@ -237,9 +237,10 @@ async function chainFacts(rpc: string, verifier: string) { async function main() { const args = parseArgs(process.argv.slice(2)) - const dockerfile = readIfPresent(DOCKERFILE) - const risc0Version = firstMatch(dockerfile, /^ARG RISC0_VERSION=(.*)$/m) - const risc0Toolchain = firstMatch(dockerfile, /^ARG RISC0_TOOLCHAIN=(.*)$/m) + const supportDockerfile = readIfPresent(SUPPORT_DOCKERFILE) + const guestBuilderDockerfile = readIfPresent(GUEST_BUILDER_DOCKERFILE) + const risc0Version = firstMatch(supportDockerfile, /^ARG RISC0_VERSION=(.*)$/m) + const risc0Toolchain = firstMatch(guestBuilderDockerfile, /^ARG RISC0_TOOLCHAIN=(.*)$/m) // Lowercased for the same reason the RPC answers are: Solidity accepts either case, and this // value is compared against `deployment.onchainImageId` as a plain string. const committedImageId = firstMatch(readIfPresent(IMAGE_ID_SOL), /(0x[0-9a-fA-F]{64})/)?.toLowerCase() ?? null diff --git a/templates/default/Cargo.lock b/templates/default/Cargo.lock index e9ad6fcf13..cfc4bc4c29 100644 --- a/templates/default/Cargo.lock +++ b/templates/default/Cargo.lock @@ -1389,6 +1389,7 @@ dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "hex", + "risc0-bigint2", "sha3 0.10.8", "taceo-poseidon2", ] @@ -2237,6 +2238,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "include_bytes_aligned" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee796ad498c8d9a1d68e477df8f754ed784ef875de1414ebdaf169f70a6a784" + [[package]] name = "indexmap" version = "2.14.0" @@ -3319,6 +3326,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "risc0-bigint2" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87f5f7494a2242cead2750b7ce2b8522c1be83dee268479f1c12ed521eaf595" +dependencies = [ + "include_bytes_aligned", + "stability", +] + [[package]] name = "rlp" version = "0.5.2" @@ -3735,6 +3752,16 @@ dependencies = [ "der", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/templates/default/client/src/pages/steps/RequestComputation.tsx b/templates/default/client/src/pages/steps/RequestComputation.tsx index 62d66fc3f4..671fbc162d 100644 --- a/templates/default/client/src/pages/steps/RequestComputation.tsx +++ b/templates/default/client/src/pages/steps/RequestComputation.tsx @@ -4,15 +4,21 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { CalculatorIcon } from '@phosphor-icons/react' -import { hexToBytes } from 'viem' -import type { CommitteePublishedData } from '@interfold/sdk' +import { bytesToHex, hexToBytes } from 'viem' +import type { CommitteePublicKeyChunkPublishedData } from '@interfold/sdk' import CardContent from '../components/CardContent' import Spinner from '../components/Spinner' import ErrorDisplay from '../components/ErrorDisplay' import { useWizard, WizardStep } from '../../context/WizardContext' -import { encodeComputeProviderParams, DEFAULT_COMPUTE_PROVIDER_PARAMS, DEFAULT_E3_CONFIG, calculateInputWindow } from '@interfold/sdk' +import { + CommitteePublicKeyAssembler, + encodeComputeProviderParams, + DEFAULT_COMPUTE_PROVIDER_PARAMS, + DEFAULT_E3_CONFIG, + calculateInputWindow, +} from '@interfold/sdk' import { getContractAddresses } from '@/utils/env-config' /** @@ -32,6 +38,7 @@ const RequestComputation: React.FC = () => { const [requestSuccess, setRequestSuccess] = useState(false) const [lastTransactionHash, setLocalTransactionHash] = useState() const [showErrorDetails, setShowErrorDetails] = useState(false) + const committeeKeyAssembler = useRef(new CommitteePublicKeyAssembler()) // Set up event listeners for this step useEffect(() => { @@ -47,19 +54,22 @@ const RequestComputation: React.FC = () => { })) } - const handleCommitteePublished = async (event: { data: CommitteePublishedData }) => { - const { e3Id, publicKey, pkCommitment } = event.data + const handleCommitteeKeyChunk = async (event: { data: CommitteePublicKeyChunkPublishedData }) => { + const { e3Id } = event.data if (e3State.id === null || e3Id !== e3State.id || !sdk.sdk) return try { - const isBoundKey = await sdk.sdk.validatePublicKeyCommitment( - hexToBytes(publicKey as `0x${string}`), - hexToBytes(pkCommitment as `0x${string}`), - ) + const assembled = committeeKeyAssembler.current.add(event.data) + if (!assembled) return + + const isBoundKey = await sdk.sdk.validatePublicKeyCommitment(assembled.publicKey, hexToBytes(assembled.pkCommitment)) if (!isBoundKey) { - throw new Error(`Rejected committee public key for E3 ${e3Id}: commitment mismatch`) + console.warn(`Rejected committee public-key candidate for E3 ${e3Id}: commitment mismatch`) + return } + const publicKey = bytesToHex(assembled.publicKey) + committeeKeyAssembler.current.clear(e3Id) setE3State((prev) => { if (prev.id !== null && e3Id === prev.id) { return { @@ -71,17 +81,16 @@ const RequestComputation: React.FC = () => { return prev }) } catch (error) { - setRequestError(error) - console.error(`Rejected committee public key for E3 ${e3Id}:`, error) + console.warn(`Ignored invalid committee public-key chunk for E3 ${e3Id}:`, error) } } onInterfoldEvent(InterfoldEventType.E3_REQUESTED, handleE3Requested) - onInterfoldEvent(RegistryEventType.COMMITTEE_PUBLISHED, handleCommitteePublished) + onInterfoldEvent(RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED, handleCommitteeKeyChunk) return () => { off(InterfoldEventType.E3_REQUESTED, handleE3Requested) - off(RegistryEventType.COMMITTEE_PUBLISHED, handleCommitteePublished) + off(RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED, handleCommitteeKeyChunk) } }, [isInitialized, onInterfoldEvent, off, InterfoldEventType, RegistryEventType, setE3State, e3State.id, sdk.sdk]) diff --git a/templates/default/contracts/MyProgram.sol b/templates/default/contracts/MyProgram.sol index 3a4bbb3a7c..a908ac667d 100755 --- a/templates/default/contracts/MyProgram.sol +++ b/templates/default/contracts/MyProgram.sol @@ -10,10 +10,11 @@ import { IE3Program } from "@interfold/contracts/contracts/interfaces/IE3Program import { IInterfold } from "@interfold/contracts/contracts/interfaces/IInterfold.sol"; import { E3 } from "@interfold/contracts/contracts/interfaces/IE3.sol"; import { Risc0ComputeProof } from "@interfold/contracts/contracts/lib/Risc0ComputeProof.sol"; +import { IDataAvailabilityVerifier, IE3ProgramDataAvailability } from "@interfold/contracts/contracts/interfaces/IDataAvailabilityVerifier.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { LazyIMTData, InternalLazyIMT } from "@zk-kit/lazy-imt.sol/InternalLazyIMT.sol"; -contract MyProgram is IE3Program, Ownable { +contract MyProgram is IE3Program, IE3ProgramDataAvailability, Ownable { using InternalLazyIMT for LazyIMTData; // Constants bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); @@ -39,6 +40,7 @@ contract MyProgram is IE3Program, Ownable { error EmptyInputData(); error InputDeadlineReached(); error InvalidComputeContext(); + error DataAvailabilityHashMismatch(bytes32 expected, bytes32 actual); event InputPublished(uint256 indexed e3Id, bytes data, uint256 index); @@ -122,4 +124,15 @@ contract MyProgram is IE3Program, Ownable { verifier.verify(computeProof.seal, imageId, sha256(journal)); return true; } + + /// @notice Local-only DA verification used by the generated development stack. + /// @dev The production program must replace this with its external DA receipt verifier. + function verifyDataAvailability( + bytes32 expectedContentHash, + bytes calldata proof + ) external pure override returns (IDataAvailabilityVerifier.DataReference memory receipt) { + bytes32 actual = keccak256(proof); + if (actual != expectedContentHash) revert DataAvailabilityHashMismatch(expectedContentHash, actual); + return IDataAvailabilityVerifier.DataReference(expectedContentHash, 0, 0); + } } diff --git a/templates/default/interfold.config.yaml b/templates/default/interfold.config.yaml index 6d25465f6c..00e615bf16 100644 --- a/templates/default/interfold.config.yaml +++ b/templates/default/interfold.config.yaml @@ -1,6 +1,9 @@ chains: - name: 'localhost' rpc_url: 'ws://localhost:8545' + data_availability: + mode: mock_http + rpc_url: http://127.0.0.1:8080/availability contracts: e3_program: address: "0xcbEAF3BDe82155F56486Fb5a1072cb8baAf547cc" @@ -28,16 +31,16 @@ program: # rpc_url: "https://sepolia.infura.io/v3/YOUR_KEY" # private_key: "PRIVATE_KEY" # Use env vars for secrets # pinata_jwt: "PINATA_JWT" # For uploading programs - # program_url: "https://gateway.pinata.cloud/ipfs/QmNMRAB7DW43JSmENfzGmD96G6sqaeBBNfTVrrq5WQae3D" # Pre-uploaded program + # ipfs_gateway_url: "https://your-gateway.mypinata.cloud" # Public full-download gateway for programs and inputs + # program_url: "https://your-gateway.mypinata.cloud/ipfs/QmNMRAB7DW43JSmENfzGmD96G6sqaeBBNfTVrrq5WQae3D" # Pre-uploaded program # onchain: true # true = onchain requests, false = offchain - # # Built-in auction defaults, shown for reference. Leave these unset. - # # If you set one, `interfold program start` fails. See #1812. + # # Optional auction parameters with their built-in defaults: # # min_price_eth: 0.00005 - # # max_price_eth: 0.002 - # # timeout_secs: 600 - # # lock_timeout_secs: 300 - # # ramp_up_secs: 60 - # # lock_collateral_zkc: 2.0 + # # max_price_eth: 0.004 + # # timeout_secs: 28800 + # # lock_timeout_secs: 14400 + # # ramp_up_secs: 7200 + # # lock_collateral_zkc: 100.0 # Default profile: multithread uses (logical CPUs - 1) Rayon workers and the same concurrent job # limit. Reserve threads with `multithread_reserve_threads` (default 1 for Actix / libp2p). # Example override on a 16-core host: diff --git a/templates/default/program/src/lib.rs b/templates/default/program/src/lib.rs index 44e590fbe8..c031336df5 100644 --- a/templates/default/program/src/lib.rs +++ b/templates/default/program/src/lib.rs @@ -4,8 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use e3_compute_provider::{FHEInputs, InputPolicy}; -use e3_fhe_params::decode_bfv_params_arc; +use e3_compute_provider::{FHEProcessorInput, InputPolicy}; use fhe::bfv::Ciphertext; use fhe_traits::{DeserializeParametrized, Serialize}; @@ -22,12 +21,10 @@ pub fn policy() -> InputPolicy { } /// Implementation of the CiphertextProcessor function -pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { - let params = decode_bfv_params_arc(&fhe_inputs.params).unwrap(); - - let mut sum = Ciphertext::zero(¶ms); - for ciphertext_bytes in &fhe_inputs.ciphertexts { - let ciphertext = Ciphertext::from_bytes(&ciphertext_bytes.0, ¶ms).unwrap(); +pub fn fhe_processor(fhe_inputs: &FHEProcessorInput<'_>) -> Vec { + let mut sum = Ciphertext::zero(fhe_inputs.params); + for ciphertext_bytes in fhe_inputs.ciphertexts { + let ciphertext = Ciphertext::from_bytes(&ciphertext_bytes.0, fhe_inputs.params).unwrap(); sum += &ciphertext; } @@ -38,12 +35,13 @@ pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { mod tests { use super::*; use anyhow::Result; + use e3_compute_provider::FHEInputs; use e3_fhe_params::DEFAULT_BFV_PRESET; - use e3_fhe_params::{BfvParamSet, build_bfv_params_arc, encode_bfv_params}; + use e3_fhe_params::{build_bfv_params_arc, encode_bfv_params, BfvParamSet}; use fhe::bfv::{Encoding, Plaintext, PublicKey, SecretKey}; - use fhe_traits::{FheDecoder, FheEncoder}; use fhe_traits::FheEncrypter; use fhe_traits::{DeserializeParametrized, FheDecrypter, Serialize}; + use fhe_traits::{FheDecoder, FheEncoder}; use rand::thread_rng; #[test] @@ -80,7 +78,10 @@ mod tests { }; // Run the processor - let result = fhe_processor(&fhe_inputs); + let result = fhe_processor(&FHEProcessorInput { + ciphertexts: &fhe_inputs.ciphertexts, + params: ¶ms, + }); // Decrypt result let decrypted = secret_key.try_decrypt(&Ciphertext::from_bytes(&result, ¶ms)?)?; diff --git a/templates/default/scripts/test_integration.sh b/templates/default/scripts/test_integration.sh index 26d2d54821..144f5b111b 100755 --- a/templates/default/scripts/test_integration.sh +++ b/templates/default/scripts/test_integration.sh @@ -60,7 +60,7 @@ failed_message() { --names "TEST,EVM,MINE,CIPHER,SERVER,PROGRAM" \ --prefix-colors "blue,cyan,gray,magenta,yellow,green" \ --kill-others \ - --success first \ + --success command-TEST \ "wait-on file:/tmp/interfold_ciphernodes_ready tcp:localhost:8545 http://localhost:13151/health && export \$(interfold print-env --chain localhost) && pnpm vitest run ./tests/integration.spec.ts" \ "anvil --host 0.0.0.0 --chain-id 31337 --block-time 1 --mnemonic 'test test test test test test test test test test test junk' --silent" \ "wait-on tcp:localhost:8545 && node ./scripts/anvil-automine.mjs" \ diff --git a/templates/default/server/index.ts b/templates/default/server/index.ts index 9ad1d455af..db119d41d8 100644 --- a/templates/default/server/index.ts +++ b/templates/default/server/index.ts @@ -5,9 +5,12 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import express, { Request, Response } from 'express' -import { InterfoldSDK } from '@interfold/sdk' -import { RegistryEventType, type CommitteePublishedData } from '@interfold/sdk/events' +import { CommitteePublicKeyAssembler, InterfoldSDK } from '@interfold/sdk' +import { RegistryEventType } from '@interfold/sdk/events' +import { hexToBytes, keccak256 } from 'viem' import { hardhat } from 'viem/chains' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { handleTestInteraction } from './testHandler' import { getCheckedEnvVars } from './utils' import { callFheRunner } from './runner' @@ -46,11 +49,20 @@ async function createPrivateSDK(): Promise { } // The only state the server keeps, purely for idempotency: E3s already scheduled -// (so a repeated CommitteePublished does not double-schedule) and E3s already +// (so repeated public-key candidates do not double-schedule) and E3s already // submitted to the runner (so a run is not sent twice). const scheduled = new Set() const inFlight = new Set() const CHAIN_TIME_POLL_INTERVAL_MS = 500 +const DATA_AVAILABILITY_DIRECTORY = process.env.DATA_AVAILABILITY_DIRECTORY ?? '.interfold/data-availability' + +async function storeAvailabilityObject(contentHash: `0x${string}`, bytes: Buffer): Promise { + await mkdir(DATA_AVAILABILITY_DIRECTORY, { recursive: true }) + const path = join(DATA_AVAILABILITY_DIRECTORY, contentHash.slice(2).toLowerCase()) + const temporaryPath = `${path}.${process.pid}.tmp` + await writeFile(temporaryPath, bytes) + await rename(temporaryPath, path) +} async function waitForChainTimestamp(sdk: InterfoldSDK, target: bigint): Promise { const publicClient = sdk.getPublicClient() @@ -140,8 +152,7 @@ async function runProgram(e3Id: bigint): Promise { * When a committee publishes for an E3, schedule the FHE run for the moment the * input window closes (or run immediately if it has already passed). */ -async function handleCommitteePublishedEvent(event: { data: CommitteePublishedData }) { - const e3Id = event.data.e3Id +async function scheduleE3(e3Id: bigint) { const key = e3Id.toString() if (scheduled.has(key)) return @@ -176,12 +187,29 @@ async function handleCommitteePublishedEvent(event: { data: CommitteePublishedDa async function setupEventListeners() { const sdk = await createPrivateSDK() + const committeeKeyAssembler = new CommitteePublicKeyAssembler() console.log('๐Ÿ“ก Setting up event listeners...') - // Listen to CommitteePublished to know when an E3 is ready and when its input - // window closes; inputs themselves are read on demand at run time. - await sdk.onInterfoldEvent(RegistryEventType.COMMITTEE_PUBLISHED, handleCommitteePublishedEvent) + // Schedule computation only after the transported key matches the commitment + // accepted with the DKG proof. + await sdk.onInterfoldEvent(RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED, async (event) => { + try { + const assembled = committeeKeyAssembler.add(event.data) + if (!assembled) return + + const isBoundKey = await sdk.validatePublicKeyCommitment(assembled.publicKey, hexToBytes(assembled.pkCommitment)) + if (!isBoundKey) { + console.warn(`Ignored committee public-key candidate for E3 ${assembled.e3Id}: commitment mismatch`) + return + } + + committeeKeyAssembler.clear(assembled.e3Id) + await scheduleE3(assembled.e3Id) + } catch (error) { + console.error('Failed to process a committee public-key chunk:', error) + } + }) console.log('โœ… Event listeners set up successfully') } @@ -209,8 +237,18 @@ async function handleWebhookRequest(req: Request, res: Response) { console.log(`๐Ÿ”„ Publishing output for E3 ${e3_id}...`) + const ciphertextBytes = Buffer.from(ciphertext.slice(2), 'hex') + const contentHash = keccak256(ciphertext) + await storeAvailabilityObject(contentHash, ciphertextBytes) + const sdk = await createPrivateSDK() - await sdk.publishCiphertextOutput(BigInt(e3_id), ciphertext, ciphertext_commitment, proof) + await sdk.publishCiphertextOutput(BigInt(e3_id), { + contentHash, + ciphertextCommitment: ciphertext_commitment, + computeProof: proof, + // The local program verifies raw bytes as a deterministic mock receipt. + availabilityProof: ciphertext, + }) inFlight.delete(e3_id.toString()) console.log(`โœ… Successfully completed E3 ${e3_id}`) @@ -226,6 +264,19 @@ const app = express() app.use(express.json({ limit: '50mb' })) app.post('/', handleWebhookRequest) +app.get('/availability/objects/:contentHash', async (req, res) => { + const contentHash = req.params.contentHash + if (!/^0x[a-fA-F0-9]{64}$/.test(contentHash)) { + res.status(400).json({ error: 'contentHash must be a 32-byte hex value' }) + return + } + try { + const bytes = await readFile(join(DATA_AVAILABILITY_DIRECTORY, contentHash.slice(2).toLowerCase())) + res.type('application/octet-stream').send(bytes) + } catch { + res.status(404).json({ error: 'Object not found' }) + } +}) // This allows us to test interaction between server and program // TEST_MODE=1 pnpm dev:server diff --git a/templates/default/server/testHandler.ts b/templates/default/server/testHandler.ts index ee37c832ac..80a18cb6e5 100644 --- a/templates/default/server/testHandler.ts +++ b/templates/default/server/testHandler.ts @@ -8,8 +8,18 @@ import payload from './payload.json' import { callFheRunner } from './runner' export async function handleTestInteraction() { - let e3Id = BigInt(payload.e3_id) - let params = payload.params - let ciphertextInputs = payload.ciphertext_inputs as Array<[string, number]> - await callFheRunner(e3Id, params, ciphertextInputs) + const e3Id = BigInt(payload.e3_id) + const params = payload.params + const ciphertextInputs = payload.ciphertext_inputs as Array<[string, number]> + await callFheRunner( + e3Id, + { + chainId: Number(process.env.CHAIN_ID ?? 31_337), + interfoldAddress: process.env.INTERFOLD_CONTRACT ?? '0x0000000000000000000000000000000000000000', + encryptionSchemeId: process.env.ENCRYPTION_SCHEME_ID ?? `0x${'00'.repeat(32)}`, + committeePublicKeyHash: process.env.COMMITTEE_PUBLIC_KEY_HASH ?? `0x${'00'.repeat(32)}`, + }, + params, + ciphertextInputs, + ) } diff --git a/templates/default/tests/integration.spec.ts b/templates/default/tests/integration.spec.ts index 6bc9e6d021..a3e9c6055c 100644 --- a/templates/default/tests/integration.spec.ts +++ b/templates/default/tests/integration.spec.ts @@ -11,13 +11,14 @@ import { encodeComputeProviderParams, decodePlaintextOutput, CommitteeSize, + CommitteePublicKeyAssembler, ThresholdBfvParamsPresetNames, } from '@interfold/sdk' import { InterfoldEventType, RegistryEventType } from '@interfold/sdk/events' import type { AllEventTypes, InterfoldEvent } from '@interfold/sdk/events' import { E3Stage } from '@interfold/sdk/contracts' import type { E3 } from '@interfold/sdk/contracts' -import { createWalletClient, hexToBytes, http, parseAbi } from 'viem' +import { bytesToHex, createWalletClient, hexToBytes, http, parseAbi } from 'viem' import assert from 'assert' import { describe, expect, it } from 'vitest' @@ -62,6 +63,10 @@ type E3StateOutputPublished = E3Shared & { type E3State = E3StateRequested | E3StatePublished | E3StateOutputPublished async function setupEventListeners(sdk: InterfoldSDK, store: Map) { + const committeeKeyAssembler = new CommitteePublicKeyAssembler() + const readyCommitteeKeys = new Map() + const committeeKeyWaiters = new Map void>>() + async function waitForEvent( type: T, trigger?: () => Promise, @@ -116,24 +121,31 @@ async function setupEventListeners(sdk: InterfoldSDK, store: Map { + await sdk.onInterfoldEvent(RegistryEventType.COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED, async (event) => { const id = event.data.e3Id + if (readyCommitteeKeys.has(id)) return - const state = store.get(id) + const assembled = committeeKeyAssembler.add(event.data) + if (!assembled) return - if (!state) { - throw new Error(`State for ID '${id}'not found.`) + const isBoundKey = await sdk.validatePublicKeyCommitment(assembled.publicKey, hexToBytes(assembled.pkCommitment)) + if (!isBoundKey) { + return } - if (state.type !== 'requested') { - throw new Error(`State must be in the requested state`) - } + const state = store.get(id) + assert(state, `State for ID '${id}' was not found`) + assert.strictEqual(state.type, 'requested', 'State must be requested before the committee key is accepted') store.set(id, { - publicKey: event.data.publicKey as `0x${string}`, + publicKey: bytesToHex(assembled.publicKey), ...state, type: 'committee_published', }) + readyCommitteeKeys.set(id, assembled.publicKey) + committeeKeyAssembler.clear(id) + committeeKeyWaiters.get(id)?.forEach((resolve) => resolve(assembled.publicKey)) + committeeKeyWaiters.delete(id) }) await sdk.onInterfoldEvent(InterfoldEventType.PLAINTEXT_OUTPUT_PUBLISHED, (event) => { @@ -156,7 +168,29 @@ async function setupEventListeners(sdk: InterfoldSDK, store: Map { + const ready = readyCommitteeKeys.get(e3Id) + if (ready) return Promise.resolve(ready) + + return new Promise((resolve, reject) => { + const waiters = committeeKeyWaiters.get(e3Id) ?? new Set() + let timer: ReturnType + const done = (publicKey: Uint8Array) => { + clearTimeout(timer) + waiters.delete(done) + resolve(publicKey) + } + waiters.add(done) + committeeKeyWaiters.set(e3Id, waiters) + timer = setTimeout(() => { + waiters.delete(done) + if (waiters.size === 0) committeeKeyWaiters.delete(e3Id) + reject(new Error(`Timed out waiting for a verified committee key after ${timeoutMs}ms`)) + }, timeoutMs) + }) + } + + return { waitForEvent, waitForCommitteeKey } } describe('Integration', () => { @@ -190,7 +224,7 @@ describe('Integration', () => { }) it('completes an E3 from request through plaintext output', async () => { - const { waitForEvent } = await setupEventListeners(sdk, store) + const { waitForEvent, waitForCommitteeKey } = await setupEventListeners(sdk, store) const committeeSize = CommitteeSize.Minimum const inputWindowDurationSeconds = 300 @@ -203,7 +237,6 @@ describe('Integration', () => { ) let state - let event // Verify fee quoting works const requestParams = { @@ -259,16 +292,13 @@ describe('Integration', () => { await sleep(250) } - // Ciphernodes will publish a public key within the COMMITTEE_PUBLISHED event - console.log('Waiting for committee + DKG (CommitteePublished)...') - event = await waitForEvent(RegistryEventType.COMMITTEE_PUBLISHED, undefined, phaseTimeoutMs) - - const publicKeyBytes = hexToBytes(event.data.publicKey as `0x${string}`) + console.log('Waiting for the verified chunked committee key...') + const publicKeyBytes = await waitForCommitteeKey(state.e3Id, phaseTimeoutMs) state = store.get(state.e3Id) assert(state, 'store should have E3State but it was falsey') assert.strictEqual(state.type, 'committee_published') - assert.strictEqual(state.publicKey, event.data.publicKey) + assert.strictEqual(state.publicKey, bytesToHex(publicKeyBytes)) // Verify E3 stage after committee published const stageAfterCommittee = await sdk.getE3Stage(state.e3Id) diff --git a/tests/integration/base.sh b/tests/integration/base.sh index 3d01618413..f6757c44bd 100755 --- a/tests/integration/base.sh +++ b/tests/integration/base.sh @@ -12,6 +12,7 @@ source "$THIS_DIR/lib/utils.sh" heading "Start the EVM node" launch_evm +launch_mock_data_availability until curl -sf -X POST http://localhost:8545 -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' > /dev/null; do sleep 1 @@ -109,7 +110,12 @@ if [[ "$FULL_PROOF_AGGREGATION" == "true" ]]; then waiton "$SCRIPT_DIR/output/output.bin" heading "Publish E3 input (forwards to publishCiphertextOutput; nodes run decryption with ZK proofs)" - pnpm e3-program:publishInput --network localhost --e3-id "$E3_ID" --data-file "$SCRIPT_DIR/output/output.bin" --ciphertext-commitment-file "$SCRIPT_DIR/output/ciphertext_commitment.bin" + pnpm e3-program:publishInput \ + --network localhost \ + --e3-id "$E3_ID" \ + --data-file "$SCRIPT_DIR/output/output.bin" \ + --ciphertext-commitment-file "$SCRIPT_DIR/output/ciphertext_commitment.bin" \ + --mock-data-availability-directory "$MOCK_DATA_AVAILABILITY_DIRECTORY" heading "Wait for on-chain plaintext (BFV decryption verifier)" wait_for_plaintext_output "$E3_ID" "$SCRIPT_DIR/output/plaintext.txt" "$INTEGRATION_DKG_TIMEOUT" @@ -125,7 +131,13 @@ else waiton "$SCRIPT_DIR/output/output.bin" heading "Publish ciphertext to EVM" - pnpm e3:publishCiphertext --e3-id "$E3_ID" --network localhost --data-file "$SCRIPT_DIR/output/output.bin" --ciphertext-commitment-file "$SCRIPT_DIR/output/ciphertext_commitment.bin" --proof 0x12345678 + pnpm e3:publishCiphertext \ + --e3-id "$E3_ID" \ + --network localhost \ + --data-file "$SCRIPT_DIR/output/output.bin" \ + --ciphertext-commitment-file "$SCRIPT_DIR/output/ciphertext_commitment.bin" \ + --proof 0x12345678 \ + --mock-data-availability-directory "$MOCK_DATA_AVAILABILITY_DIRECTORY" wait_for_plaintext_output "$E3_ID" "$SCRIPT_DIR/output/plaintext.txt" fi diff --git a/tests/integration/fns.sh b/tests/integration/fns.sh index 8256b4eb7b..f079d4fe36 100644 --- a/tests/integration/fns.sh +++ b/tests/integration/fns.sh @@ -5,6 +5,7 @@ set -euo pipefail # Stricter error handling SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" PLAINTEXT="4,0" +MOCK_DATA_AVAILABILITY_DIRECTORY="$SCRIPT_DIR/output/data-availability" # Set by test.sh via export_integration_flags FULL_PROOF_AGGREGATION="${FULL_PROOF_AGGREGATION:-false}" @@ -266,12 +267,35 @@ kill_em_all() { pkill -9 -f "target/debug/interfold" || true pkill -9 -f "interfold start" || true pkill -9 -f "anvil" || true + pkill -9 -f "$SCRIPT_DIR/lib/mock-data-availability.mjs" || true } launch_evm() { anvil --host 0.0.0.0 --chain-id 31337 --block-time 1 --mnemonic 'test test test test test test test test test test test junk' --silent & } +launch_mock_data_availability() { + mkdir -p "$MOCK_DATA_AVAILABILITY_DIRECTORY" + DATA_AVAILABILITY_DIRECTORY="$MOCK_DATA_AVAILABILITY_DIRECTORY" \ + PORT=4000 \ + node "$SCRIPT_DIR/lib/mock-data-availability.mjs" & + local server_pid=$! + + for _ in {1..30}; do + if curl -sf http://127.0.0.1:4000/health >/dev/null; then + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + wait "$server_pid" + return 1 + fi + sleep 1 + done + + echo "Mock data-availability server did not become ready" >&2 + return 1 +} + ensure_process_count_equals() { local process_name="$1" local expected_count="$2" diff --git a/tests/integration/interfold.config.yaml b/tests/integration/interfold.config.yaml index ddd6881445..636edd0c1a 100644 --- a/tests/integration/interfold.config.yaml +++ b/tests/integration/interfold.config.yaml @@ -2,6 +2,9 @@ chains: - name: 'localhost' rpc_url: 'ws://localhost:8545' + data_availability: + mode: mock_http + rpc_url: http://127.0.0.1:4000/availability contracts: e3_program: address: "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" @@ -30,15 +33,16 @@ program: # rpc_url: "https://sepolia.infura.io/v3/YOUR_KEY" # private_key: "PRIVATE_KEY" # Use env vars for secrets # pinata_jwt: "PINATA_JWT" # For uploading programs - # program_url: "https://gateway.pinata.cloud/ipfs/QmNMRAB7DW43JSmENfzGmD96G6sqaeBBNfTVrrq5WQae3D" # Pre-uploaded program + # ipfs_gateway_url: "https://your-gateway.mypinata.cloud" # Public full-download gateway for programs and inputs + # program_url: "https://your-gateway.mypinata.cloud/ipfs/QmNMRAB7DW43JSmENfzGmD96G6sqaeBBNfTVrrq5WQae3D" # Pre-uploaded program # onchain: true # true = onchain requests, false = offchain # Optional โ€” custom auction parameters (defaults shown): # min_price_eth: 0.00005 - # max_price_eth: 0.002 - # timeout_secs: 600 - # lock_timeout_secs: 300 - # ramp_up_secs: 60 - # lock_collateral_zkc: 2.0 + # max_price_eth: 0.004 + # timeout_secs: 28800 + # lock_timeout_secs: 14400 + # ramp_up_secs: 7200 + # lock_collateral_zkc: 100.0 # Default profile: multithread uses (logical CPUs - 1) Rayon workers and the same concurrent job # limit. Reserve threads with `multithread_reserve_threads` (default 1 for Actix / libp2p). # Example override on a 16-core host: diff --git a/tests/integration/lib/mock-data-availability.mjs b/tests/integration/lib/mock-data-availability.mjs new file mode 100644 index 0000000000..511d795e18 --- /dev/null +++ b/tests/integration/lib/mock-data-availability.mjs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const directory = process.env.DATA_AVAILABILITY_DIRECTORY +const port = Number(process.env.PORT ?? '4000') + +if (!directory) throw new Error('DATA_AVAILABILITY_DIRECTORY is required') +if (!Number.isInteger(port) || port <= 0 || port > 65_535) throw new Error('PORT is invalid') + +const server = createServer(async (request, response) => { + const pathname = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`).pathname + + if (request.method === 'GET' && pathname === '/health') { + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('ok') + return + } + + const match = pathname.match(/^\/availability\/objects\/0x([a-fA-F0-9]{64})$/) + if (request.method !== 'GET' || !match) { + response.writeHead(404) + response.end() + return + } + + try { + const object = await readFile(path.join(directory, match[1].toLowerCase())) + response.writeHead(200, { + 'content-length': object.length, + 'content-type': 'application/octet-stream', + }) + response.end(object) + } catch { + response.writeHead(404) + response.end() + } +}) + +server.listen(port, '127.0.0.1', () => { + console.log(`Mock data-availability server listening on 127.0.0.1:${port}`) +}) + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))) +} diff --git a/tests/integration/persist.sh b/tests/integration/persist.sh index f6fceac97f..5845725544 100755 --- a/tests/integration/persist.sh +++ b/tests/integration/persist.sh @@ -12,6 +12,7 @@ source "$THIS_DIR/lib/utils.sh" heading "Start the EVM node" launch_evm +launch_mock_data_availability until curl -sf -X POST http://localhost:8545 -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' > /dev/null; do sleep 1 @@ -112,7 +113,13 @@ sleep 6 # wait for input deadline to pass waiton "$SCRIPT_DIR/output/output.bin" heading "Publish ciphertext to EVM" -pnpm e3:publishCiphertext --e3-id "$E3_ID" --network localhost --data-file "$SCRIPT_DIR/output/output.bin" --ciphertext-commitment-file "$SCRIPT_DIR/output/ciphertext_commitment.bin" --proof 0x12345678 +pnpm e3:publishCiphertext \ + --e3-id "$E3_ID" \ + --network localhost \ + --data-file "$SCRIPT_DIR/output/output.bin" \ + --ciphertext-commitment-file "$SCRIPT_DIR/output/ciphertext_commitment.bin" \ + --proof 0x12345678 \ + --mock-data-availability-directory "$MOCK_DATA_AVAILABILITY_DIRECTORY" wait_for_plaintext_output "$E3_ID" "$SCRIPT_DIR/output/plaintext.txt"