diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d152778e1d..70baef5c84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,9 @@ jobs: - name: Run Unit Tests run: 'cargo test --lib && cargo test --doc' + - name: Run Rust-to-Solidity slashing assertions + run: pnpm rust:test:slashing + - name: Cancel workflow on failure if: failure() run: gh run cancel ${{ github.run_id }} @@ -1152,7 +1155,7 @@ jobs: zk_prover_e2e: needs: [detect_changes, build_circuits] if: needs.detect_changes.outputs.zk == 'true' - timeout-minutes: 30 + timeout-minutes: 60 runs-on: ${{ github.repository == 'theinterfold/interfold' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && format('runs-on={0}-{1}-{2}/runner=4cpu-linux-x64/ram=16', github.run_id, github.run_attempt, @@ -1233,6 +1236,12 @@ jobs: - name: Run ZK prover e2e tests run: cargo test -p e3-zk-prover --test local_e2e_tests -- --nocapture --test-threads=1 + - name: Verify fold accumulators and the correlated node proof + timeout-minutes: 30 + run: + cargo test --locked -p e3-zk-prover --test fold_accumulators_e2e_tests --test node_fold_correlated_e2e_tests -- --include-ignored + --nocapture --test-threads=1 + build_e3_support_dev: needs: [detect_changes] if: needs.detect_changes.outputs.build_e3_support_dev == 'true' @@ -1330,6 +1339,10 @@ jobs: - name: Run the tests run: pnpm sdk:test + - name: Verify SDK encryption proofs and reject altered bindings + timeout-minutes: 15 + run: pnpm sdk:test:proofs:prepared + - name: Upload SDK artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/agent/CONTEXT.md b/agent/CONTEXT.md index f51d0a88da..477144ec47 100644 --- a/agent/CONTEXT.md +++ b/agent/CONTEXT.md @@ -59,8 +59,12 @@ Run from repo root via pnpm scripts — not raw cargo/nargo/hardhat. | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Install / build all | `pnpm i` · `pnpm build` | | Build Rust | `pnpm rust:build` (cargo `--locked --release`; prebuilds EVM fixtures) | -| Test everything | `pnpm test` (evm → rust → sdk → noir) | +| Test everything | `pnpm test` (EVM, Rust, required proof/slashing suites, SDK, Noir) | | Test one layer | `pnpm evm:test` · `pnpm rust:test` · `pnpm sdk:test` · `pnpm noir:test` | +| SDK proof verification | `pnpm sdk:test:proofs` (prepare circuits, generate one proof, verify bindings and reject tampering) | +| Prepared SDK proof tests | `pnpm sdk:test:proofs:prepared` (reuse the current SDK build or prepared circuit set) | +| Rust proof integration | `pnpm rust:test:proofs` (prepared insecure-512/minimum circuits and `bb`) | +| Rust slashing integration | `pnpm rust:test:slashing` (compiled contract artifacts and `anvil`) | | Integration tests | `pnpm test:integration [name]` (`--no-prebuild` to skip binary build) | | Lint / format | `pnpm lint` · `pnpm format` / `pnpm format:check` | | Build circuits | `pnpm build:circuits [--preset …] [--committee …]` (needs `nargo` + `bb`; `interfold noir setup` installs them) | @@ -70,6 +74,20 @@ Run from repo root via pnpm scripts — not raw cargo/nargo/hardhat. | Prepare release branch | `pnpm bump:versions X.Y.Z` | | Tag merged release | `pnpm release:tag X.Y.Z` from updated `main` | +## Test preparation + +`pnpm sdk:test` runs the fast SDK suites without circuit preparation. The proof API tests mock the +prover boundary. They do not claim to verify cryptographic proofs. The separate proof suite verifies +a real proof against the compiled verification key and rejects altered public inputs and proof +bytes. + +Before `pnpm rust:test:proofs` or `pnpm test`, run +`pnpm build:circuits --preset insecure-512 --committee minimum --skip-if-built`. This prepares one +consistent set of inner and recursive circuits. Before `pnpm rust:test:slashing`, run +`pnpm evm:build`. The named Rust integration suites fail if a required tool or artifact is missing. +Ordinary Rust test runs report these integration tests as ignored. CI explicitly selects them. The +full test command reuses the prepared circuits for SDK proof verification. + ## Chain-Specific BFV Config The protocol release can carry more than one circuit artifact set. Current deployments use this diff --git a/crates/net/src/event_buffer/actor.rs b/crates/net/src/event_buffer/actor.rs index b0ce667afb..37384cdef3 100644 --- a/crates/net/src/event_buffer/actor.rs +++ b/crates/net/src/event_buffer/actor.rs @@ -25,6 +25,8 @@ pub const DEFAULT_MAX_BUFFERED_NET_BYTES: usize = 256 * 1024 * 1024; pub struct NetEventBufferHandle { readiness: oneshot::Receiver>, + #[cfg(test)] + actor: actix::Addr, } impl NetEventBufferHandle { @@ -78,7 +80,14 @@ impl NetEventBuffer { // Subscribe to InterfoldEvent on the bus bus.subscribe(EventType::SyncEnded, addr.clone().recipient()); - (output, NetEventBufferHandle { readiness }) + ( + output, + NetEventBufferHandle { + readiness, + #[cfg(test)] + actor: addr, + }, + ) } fn handle_interfold_event(&mut self, msg: InterfoldEvent) -> Result<()> { diff --git a/crates/net/src/event_buffer/tests.rs b/crates/net/src/event_buffer/tests.rs index ab81c58800..8c8601bef4 100644 --- a/crates/net/src/event_buffer/tests.rs +++ b/crates/net/src/event_buffer/tests.rs @@ -26,9 +26,26 @@ use libp2p::{ }; use tokio::{ sync::{broadcast, mpsc}, - time::{sleep, timeout}, + time::timeout, }; +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Message)] +#[rtype(result = "usize")] +struct BufferedEventCount; + +impl Handler for NetEventBuffer { + type Result = usize; + + fn handle(&mut self, _: BufferedEventCount, _: &mut actix::Context) -> usize { + match &self.state { + NetEventBufferState::Syncing { events, .. } => events.len(), + state => panic!("expected startup buffering, got {state:?}"), + } + } +} + fn sync_and_connection_control_events() -> Vec { let (command_tx, _command_rx) = mpsc::channel(1); vec![ @@ -96,24 +113,30 @@ async fn test_buffers_until_sync_ended() -> Result<()> { input_tx.send(event1.clone()).unwrap(); input_tx.send(event2.clone()).unwrap(); - // Give actor time to process - sleep(Duration::from_millis(10)).await; - - // Verify no events forwarded yet (should timeout) + // Wait for observable actor progress, then check that no event was forwarded. + timeout(DELIVERY_TIMEOUT, async { + while handle.actor.send(BufferedEventCount).await? != 2 { + tokio::task::yield_now().await; + } + Ok::<_, anyhow::Error>(()) + }) + .await + .context("network events did not reach the startup buffer")??; assert!( - timeout(Duration::from_millis(50), output_rx.recv()) - .await - .is_err(), + matches!( + output_rx.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), "Events should be buffered, not forwarded during sync" ); // Send SyncEnded event bus.publish_without_context(SyncEnded::new()).unwrap(); - handle.wait_until_running().await?; + timeout(DELIVERY_TIMEOUT, handle.wait_until_running()).await??; // Now buffered events should be forwarded - let received1 = output_rx.recv().await.unwrap(); - let received2 = output_rx.recv().await.unwrap(); + let received1 = timeout(DELIVERY_TIMEOUT, output_rx.recv()).await??; + let received2 = timeout(DELIVERY_TIMEOUT, output_rx.recv()).await??; assert!( matches!(received1, NetEvent::GossipData(GossipData::GossipBytes(ref bytes)) if bytes == &vec![1, 2, 3]) @@ -126,7 +149,7 @@ async fn test_buffers_until_sync_ended() -> Result<()> { let event3 = NetEvent::GossipData(GossipData::GossipBytes(vec![7, 8, 9])); input_tx.send(event3.clone()).unwrap(); - let received3 = tokio::time::timeout(tokio::time::Duration::from_millis(100), output_rx.recv()) + let received3 = timeout(DELIVERY_TIMEOUT, output_rx.recv()) .await .expect("Event should be forwarded immediately after sync") .unwrap(); @@ -150,7 +173,7 @@ async fn startup_buffer_overflow_fails_readiness_without_dropping_oldest() -> Re input_tx.send(NetEvent::GossipData(GossipData::GossipBytes(vec![1])))?; input_tx.send(NetEvent::GossipData(GossipData::GossipBytes(vec![2])))?; - let error = timeout(Duration::from_secs(1), handle.wait_until_running()) + let error = timeout(DELIVERY_TIMEOUT, handle.wait_until_running()) .await .context("network buffer did not report overflow")? .expect_err("overflow must fail startup readiness") @@ -176,7 +199,7 @@ async fn startup_buffer_enforces_estimated_payload_bytes() -> Result<()> { input_tx.send(event)?; - let error = timeout(Duration::from_secs(1), handle.wait_until_running()) + let error = timeout(DELIVERY_TIMEOUT, handle.wait_until_running()) .await .context("network buffer did not report byte overflow")? .expect_err("byte overflow must fail startup readiness") @@ -212,8 +235,8 @@ async fn sync_control_burst_does_not_lag_or_consume_the_application_buffer() -> event_tx.send(NetEvent::GossipData(GossipData::GossipBytes(vec![7])))?; bus.publish_without_context(SyncEnded::new())?; - handle.wait_until_running().await?; - let forwarded = timeout(Duration::from_secs(5), output_rx.recv()).await??; + timeout(DELIVERY_TIMEOUT, handle.wait_until_running()).await??; + let forwarded = timeout(DELIVERY_TIMEOUT, output_rx.recv()).await??; assert!(matches!( forwarded, NetEvent::GossipData(GossipData::GossipBytes(bytes)) if bytes == vec![7] diff --git a/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs b/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs index 1ad6670079..145896ea07 100644 --- a/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs +++ b/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs @@ -128,13 +128,13 @@ fn c6_fold_total_slots_from_compiled_json() -> usize { } #[test] +#[ignore = "requires compiled circuits; run pnpm rust:test:proofs"] fn c3_fold_compiled_abi_has_consistent_slot_count() { if !c3_fold_json_path().exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", c3_fold_json_path().display() ); - return; } let slots = c3_fold_total_slots_from_compiled_json(); assert!(slots > 0, "C3_SLOTS inferred from ABI should be positive"); @@ -143,13 +143,13 @@ fn c3_fold_compiled_abi_has_consistent_slot_count() { } #[test] +#[ignore = "requires compiled circuits; run pnpm rust:test:proofs"] fn c6_fold_compiled_abi_has_consistent_slot_count() { if !c6_fold_json_path().exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", c6_fold_json_path().display() ); - return; } let slots = c6_fold_total_slots_from_compiled_json(); assert!(slots > 0, "C6 slots inferred from ABI should be positive"); @@ -158,6 +158,7 @@ fn c6_fold_compiled_abi_has_consistent_slot_count() { } #[test] +#[ignore = "requires compiled circuits; run pnpm rust:test:proofs"] fn node_fold_pipeline_compiled_json_load() { let mut missing = Vec::new(); for &c in NODE_FOLD_PIPELINE { @@ -167,11 +168,10 @@ fn node_fold_pipeline_compiled_json_load() { } } if !missing.is_empty() { - println!( - "skipping: missing compiled JSON(s) (run `pnpm build:circuits --group recursive_aggregation`): {:?}", + panic!( + "missing required test prerequisite: missing compiled JSON(s) (run `pnpm build:circuits --group recursive_aggregation`): {:?}", missing ); - return; } for &c in NODE_FOLD_PIPELINE { let path = recursive_aggregation_compiled_json_path(c); @@ -181,14 +181,16 @@ fn node_fold_pipeline_compiled_json_load() { } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn recursive_aggregation_default_artifacts_staged() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; if !c3_fold_json_path().exists() { - println!("skipping: {} not found", c3_fold_json_path().display()); - return; + panic!( + "missing required test prerequisite: {} not found", + c3_fold_json_path().display() + ); } let (backend, temp) = setup_test_prover(&bb).await; @@ -216,16 +218,18 @@ async fn recursive_aggregation_default_artifacts_staged() { } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn recursive_aggregation_c6_fold_kernel_artifacts_staged() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; let kernel_json = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../circuits/bin/recursive_aggregation/c6_fold_kernel/target/c6_fold_kernel.json"); if !kernel_json.exists() { - println!("skipping: {} not found", kernel_json.display()); - return; + panic!( + "missing required test prerequisite: {} not found", + kernel_json.display() + ); } let (backend, temp) = setup_test_prover(&bb).await; @@ -253,18 +257,17 @@ async fn recursive_aggregation_c6_fold_kernel_artifacts_staged() { } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn node_fold_pipeline_recursive_aggregation_artifacts_staged() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; let gate = recursive_aggregation_compiled_json_path(CircuitName::NodeFold); if !gate.exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", gate.display() ); - return; } let (backend, temp) = setup_test_prover(&bb).await; @@ -343,27 +346,26 @@ async fn setup_c3_fold_with_inner_share_encryption() -> Option<( } /// Expected C3 fold slot count when circuits are compiled for the minimum committee (N=3, T=1). -const MINIMUM_C3_FOLD_SLOTS: usize = 2; +const MINIMUM_C3_FOLD_SLOTS: usize = 6; /// Expected C6 fold slot count when circuits are compiled for the minimum committee (N=3, T=1). const MINIMUM_C6_FOLD_SLOTS: usize = 2; #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn c3_fold_sequential_proves_and_verifies() { let Some((_backend, _temp, prover, circuit, sample_a, sample_b, preset)) = setup_c3_fold_with_inner_share_encryption().await else { - println!("skipping: bb not found or prerequisites missing"); - return; + panic!("missing required test prerequisite: bb not found or prerequisites missing"); }; let total_slots = c3_fold_total_slots_from_compiled_json(); if total_slots != MINIMUM_C3_FOLD_SLOTS { - println!( - "skipping c3_fold_sequential_proves_and_verifies: circuits compiled for \ + panic!( + "c3_fold_sequential_proves_and_verifies: circuits compiled for \ non-minimum committee (total_slots={total_slots}, expected {MINIMUM_C3_FOLD_SLOTS}). \ Rebuild with `pnpm build:circuits --committee minimum` to run this test." ); - return; } let artifacts_dir = preset.artifacts_dir_for_committee("minimum"); @@ -460,22 +462,21 @@ async fn setup_c6_fold_with_inner_threshold_share_decryption() -> Option<( } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn c6_fold_sequential_proves_and_verifies() { let Some((_backend, _temp, prover, circuit, sample_a, sample_b, preset)) = setup_c6_fold_with_inner_threshold_share_decryption().await else { - println!("skipping: bb not found or prerequisites missing"); - return; + panic!("missing required test prerequisite: bb not found or prerequisites missing"); }; let total_slots = c6_fold_total_slots_from_compiled_json(); if total_slots != MINIMUM_C6_FOLD_SLOTS { - println!( - "skipping c6_fold_sequential_proves_and_verifies: circuits compiled for \ + panic!( + "c6_fold_sequential_proves_and_verifies: circuits compiled for \ non-minimum committee (total_slots={total_slots}, expected {MINIMUM_C6_FOLD_SLOTS}). \ Rebuild with `pnpm build:circuits --committee minimum` to run this test." ); - return; } let artifacts_dir = preset.artifacts_dir_for_committee("minimum"); let inner_e3_a = "e3-c6fold-inner-0"; diff --git a/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs b/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs index 45d3c49f36..8bc414eef1 100644 --- a/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs +++ b/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs @@ -129,27 +129,24 @@ fn triplicate_honest_rows(mut d: ShareDecryptionCircuitData) -> ShareDecryptionC } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn node_fold_correlated_sparse_self_slot_proves_and_verifies() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; - if require_minimum_circuits().is_none() { - return; - } + require_minimum_circuits() + .expect("rebuild required circuits with pnpm build:circuits --committee minimum"); let gate = recursive_aggregation_compiled_json_path(CircuitName::NodeFold); if !gate.exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", gate.display() ); - return; } if !c3_fold_json_path().exists() { - println!("skipping: c3_fold.json not found"); - return; + panic!("missing required test prerequisite: c3_fold.json not found"); } let committee = CiphernodesCommitteeSize::Minimum.values(); @@ -300,7 +297,10 @@ async fn node_fold_correlated_sparse_self_slot_proves_and_verifies() { let dkg_pk = fhe::bfv::PublicKey::new(&dkg_sk, &mut rng); let total_slots = c3_fold_total_slots_from_compiled_json(); - assert_eq!(total_slots, 6, "Micro / insecure preset uses 3×2 C3 slots"); + assert_eq!( + total_slots, 6, + "Minimum / insecure preset uses 3×2 C3 slots" + ); let slots_per_party = total_slots / committee.n; let own_party_id = 0usize; diff --git a/crates/zk-prover/tests/slashing_integration_tests.rs b/crates/zk-prover/tests/slashing_integration_tests.rs index f1213ef37b..6f25a12020 100644 --- a/crates/zk-prover/tests/slashing_integration_tests.rs +++ b/crates/zk-prover/tests/slashing_integration_tests.rs @@ -30,9 +30,9 @@ //! //! On-chain tests require: //! - `anvil` on PATH (from Foundry) -//! - Compiled Hardhat artifacts: `cd packages/interfold-contracts && npx hardhat compile` +//! - Compiled Hardhat artifacts: `pnpm evm:build` //! -//! Run with: `cargo test -p e3-zk-prover --test slashing_integration_tests` +//! Run with: `pnpm rust:test:slashing` mod common; @@ -51,7 +51,8 @@ use e3_events::{ SignedProofPayload, }; use e3_utils::utility_types::ArcBytes; -use std::path::PathBuf; +use serde::Deserialize; +use std::{collections::BTreeMap, path::PathBuf, sync::OnceLock}; // ── Contract ABI definitions (bytecodes loaded from Hardhat artifacts at runtime) ── @@ -76,6 +77,26 @@ sol! { function setBondingRegistry(address newBondingRegistry) external; function setCiphernodeRegistry(address newCiphernodeRegistry) external; function setInterfold(address newInterfold) external; + struct SlashProposal { + uint256 e3Id; + address operator; + bytes32 reason; + uint256 ticketAmount; + uint256 ciphernodeBondAmount; + bool executed; + bool appealed; + bool resolved; + bool appealUpheld; + uint256 proposedAt; + uint256 executableAt; + address proposer; + bytes32 proofHash; + bool proofVerified; + bool banNode; + bool affectsCommittee; + uint8 failureReason; + } + function getSlashProposal(uint256 proposalId) external view returns (SlashProposal memory); function totalProposals() external view returns (uint256); function isBanned(address node) external view returns (bool); @@ -87,6 +108,18 @@ sol! { error DuplicateEvidence(); } + #[sol(rpc)] + contract MockSlashingInterfold { + function snapshotDependencies(address manager, uint256 e3Id, uint256 lifecycleDeadline) external; + } + + #[sol(rpc)] + contract MockSlashingBondingRegistry { + function ticketPenaltyRequested() external view returns (uint256); + function bondPenaltyRequested() external view returns (uint256); + function openLocks() external view returns (uint256); + } + #[sol(rpc)] contract MockCiphernodeRegistry { function setCommitteeNodes(uint256 e3Id, address[] calldata nodes) external; @@ -97,61 +130,92 @@ sol! { // ── Helpers ── -/// No-op contract deployment bytecode. -/// -/// Deploys a contract whose runtime is a single STOP opcode. -/// All calls to this contract succeed with empty return data, making it -/// suitable as a mock for any interface that only has void-returning functions -/// (e.g., IInterfold.onE3Failed). -const NOOP_DEPLOY_BYTECODE: &[u8] = &[ - 0x60, 0x01, // PUSH1 0x01 (runtime size) - 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) - 0x60, 0x00, // PUSH1 0x00 (memory destination) - 0x39, // CODECOPY - 0x60, 0x01, // PUSH1 0x01 (return size) - 0x60, 0x00, // PUSH1 0x00 (return offset) - 0xf3, // RETURN - 0x00, // -- runtime: STOP -- -]; - -/// Mock contract that returns 32 zero bytes for any call. -/// -/// EVM memory is zero-initialized, so `RETURN(0x00, 0x20)` returns 32 zero bytes. -/// Suitable as a mock for interfaces that return a single `uint256` -/// (e.g., `IBondingRegistry.slashTicketBalance` returns `uint256`). -const RETURNER_DEPLOY_BYTECODE: &[u8] = &[ - 0x60, 0x05, // PUSH1 0x05 (runtime size) - 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) - 0x60, 0x00, // PUSH1 0x00 (memory destination) - 0x39, // CODECOPY - 0x60, 0x05, // PUSH1 0x05 (return size) - 0x60, 0x00, // PUSH1 0x00 (return offset) - 0xf3, // RETURN - // -- runtime: return 32 zero bytes -- - 0x60, 0x20, // PUSH1 0x20 - 0x60, 0x00, // PUSH1 0x00 - 0xf3, // RETURN -]; - -fn contracts_artifacts_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) +#[derive(Deserialize)] +struct LinkReference { + start: usize, + length: usize, +} + +#[derive(Deserialize)] +struct ContractArtifact { + bytecode: String, + #[serde(rename = "linkReferences", default)] + links: BTreeMap>>, +} + +struct SlashingArtifacts { + manager: ContractArtifact, + registry: Vec, + evidence_library: Vec, + interfold: Vec, + bonding: Vec, +} + +fn read_artifact(subpath: &str) -> ContractArtifact { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../packages/interfold-contracts/artifacts/contracts") + .join(subpath); + let raw = std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "Cannot read {}: {error}. Run pnpm evm:build.", + path.display() + ) + }); + serde_json::from_str(&raw) + .unwrap_or_else(|error| panic!("Invalid artifact {}: {error}", path.display())) } -fn read_artifact_bytecode(subpath: &str) -> Option> { - let path = contracts_artifacts_dir().join(subpath); - let json_str = std::fs::read_to_string(&path).ok()?; - let json: serde_json::Value = serde_json::from_str(&json_str).ok()?; - let hex_str = json["bytecode"].as_str()?; - let clean = hex_str.strip_prefix("0x").unwrap_or(hex_str); - hex::decode(clean).ok() +fn decode_bytecode(artifact: &ContractArtifact) -> Vec { + let bytes = hex::decode( + artifact + .bytecode + .strip_prefix("0x") + .unwrap_or(&artifact.bytecode), + ) + .expect("Artifact bytecode must be linked hexadecimal"); + assert!(!bytes.is_empty(), "Artifact deployment bytecode is empty"); + bytes +} + +fn load_slashing_artifacts() -> &'static SlashingArtifacts { + static ARTIFACTS: OnceLock = OnceLock::new(); + ARTIFACTS.get_or_init(|| SlashingArtifacts { + manager: read_artifact("slashing/SlashingManager.sol/SlashingManager.json"), + registry: decode_bytecode(&read_artifact( + "test/MockCiphernodeRegistry.sol/MockCiphernodeRegistry.json", + )), + evidence_library: decode_bytecode(&read_artifact( + "lib/SlashingEvidenceLib.sol/SlashingEvidenceLib.json", + )), + interfold: decode_bytecode(&read_artifact( + "test/MockSlashingInterfold.sol/MockSlashingInterfold.json", + )), + bonding: decode_bytecode(&read_artifact( + "test/MockSlashingBondingRegistry.sol/MockSlashingBondingRegistry.json", + )), + }) } -/// Load contract bytecodes, returning None if any are missing. -fn load_slashing_artifacts() -> Option<(Vec, Vec)> { - let sm = read_artifact_bytecode("slashing/SlashingManager.sol/SlashingManager.json")?; - let mr = read_artifact_bytecode("test/MockCiphernodeRegistry.sol/MockCiphernodeRegistry.json")?; - Some((sm, mr)) +fn link_manager_bytecode(artifact: &ContractArtifact, library: Address) -> Vec { + let mut bytecode = artifact + .bytecode + .strip_prefix("0x") + .unwrap_or(&artifact.bytecode) + .to_owned(); + for libraries in artifact.links.values() { + for (name, references) in libraries { + assert_eq!(name, "SlashingEvidenceLib", "Unexpected linked library"); + for reference in references { + assert_eq!( + reference.length, 20, + "A linked address must occupy 20 bytes" + ); + let start = reference.start * 2; + bytecode.replace_range(start..start + 40, &hex::encode(library)); + } + } + } + hex::decode(bytecode).expect("SlashingManager bytecode must be fully linked") } /// Deploy a contract on the connected provider. @@ -818,28 +882,27 @@ fn test_attestation_evidence_encoding() { // ════════════════════════════════════════════════════════════════════════════ /// Deploy SlashingManager and configure dependencies. -/// Returns (SlashingManager contract instance, admin address). +/// Returns the manager and the collateral-call recorder addresses. async fn deploy_and_configure( provider: &impl Provider, - sm_bytecode: &[u8], + sm_artifact: &ContractArtifact, mock_registry_addr: Address, ) -> (Address, Address) { let accounts = provider.get_accounts().await.unwrap(); let admin = accounts[0]; - // Deploy noop for interfold (void functions) - let noop_addr = deploy_contract(provider, NOOP_DEPLOY_BYTECODE, &[]).await; - // Deploy returner for bondingRegistry (slashTicketBalance returns uint256) - let returner_addr = deploy_contract(provider, RETURNER_DEPLOY_BYTECODE, &[]).await; - - // Deploy SlashingManager(initialDelay, admin) — use 0 delay for local tests + let artifacts = load_slashing_artifacts(); + let interfold_addr = deploy_contract(provider, &artifacts.interfold, &[]).await; + let bonding_addr = deploy_contract(provider, &artifacts.bonding, &[]).await; + let library_addr = deploy_contract(provider, &artifacts.evidence_library, &[]).await; + let bytecode = link_manager_bytecode(sm_artifact, library_addr); let sm_args = (0u64, admin).abi_encode(); - let sm_addr = deploy_contract(provider, sm_bytecode, &sm_args).await; + let sm_addr = deploy_contract(provider, &bytecode, &sm_args).await; // Configure dependencies via admin functions let slashing_mgr = SlashingManager::new(sm_addr, provider); slashing_mgr - .setBondingRegistry(returner_addr) + .setBondingRegistry(bonding_addr) .send() .await .unwrap() @@ -855,7 +918,7 @@ async fn deploy_and_configure( .await .unwrap(); slashing_mgr - .setInterfold(noop_addr) + .setInterfold(interfold_addr) .send() .await .unwrap() @@ -863,7 +926,20 @@ async fn deploy_and_configure( .await .unwrap(); - (sm_addr, admin) + // Each test uses one of these E3 IDs. Snapshot the request-time dependencies. + let interfold = MockSlashingInterfold::new(interfold_addr, provider); + let (_, deadline) = current_vote_window(provider).await; + for e3_id in [7u64, 42u64] { + interfold + .snapshotDependencies(sm_addr, U256::from(e3_id), deadline) + .send() + .await + .expect("Snapshot dependencies transaction") + .get_receipt() + .await + .expect("Snapshot dependencies receipt"); + } + (sm_addr, bonding_addr) } /// **Lane A attestation flow**: 3 committee members vote on a fault, quorum @@ -872,22 +948,14 @@ async fn deploy_and_configure( /// Proves the complete Rust→Solidity attestation signing pipeline works: /// vote_digest → sign_message_sync → abi.encode evidence → proposeSlash → _verifyAttestationEvidence #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_valid_attestation_executes_slash() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!( - "skipping: contract artifacts not found \ - (run `npx hardhat compile` in packages/interfold-contracts)" - ); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -907,7 +975,8 @@ async fn test_onchain_valid_attestation_executes_slash() { let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); // Deploy and configure SlashingManager - let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; + let (sm_addr, _bonding) = + deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; let slashing_mgr = SlashingManager::new(sm_addr, &provider); let e3_id: u64 = 42; @@ -1050,7 +1119,7 @@ async fn test_onchain_valid_attestation_executes_slash() { "proposeSlash should succeed with valid attestation quorum" ); - // Verify proposal was created and executed + // Verify proposal creation and execution independently. let proposals_after = slashing_mgr .totalProposals() .call() @@ -1062,26 +1131,39 @@ async fn test_onchain_valid_attestation_executes_slash() { "should have 1 proposal after slash" ); - println!( - "PASS: valid attestation quorum → slash executed — attestation signing pipeline verified" + let proposal = slashing_mgr + .getSlashProposal(U256::ZERO) + .call() + .await + .unwrap(); + assert!(proposal.executed, "The proposal must be executed"); + assert!(proposal.proofVerified, "The attestation must be verified"); + assert_eq!(proposal.e3Id, U256::from(e3_id)); + assert_eq!(proposal.operator, operator_addr); + let bonding = MockSlashingBondingRegistry::new(_bonding, &provider); + assert_eq!( + bonding.ticketPenaltyRequested().call().await.unwrap(), + proposal.ticketAmount ); + assert_eq!( + bonding.bondPenaltyRequested().call().await.unwrap(), + proposal.ciphernodeBondAmount + ); + assert_eq!(bonding.openLocks().call().await.unwrap(), U256::ZERO); + + println!("PASS: attestation verified and proposal executed against collateral-call mocks"); } /// Tests that insufficient attestations (below threshold M) cause revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_insufficient_attestations_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1198,19 +1280,14 @@ async fn test_onchain_insufficient_attestations_reverts() { /// Tests that a voter not in the committee causes revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_voter_not_in_committee_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1321,19 +1398,14 @@ async fn test_onchain_voter_not_in_committee_reverts() { /// Tests that an invalid vote signature (signed by wrong key) causes revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_invalid_vote_signature_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1456,19 +1528,14 @@ async fn test_onchain_invalid_vote_signature_reverts() { /// The contract requires voters in strictly ascending address order to prevent /// the same voter from being counted twice. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_duplicate_voter_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1583,19 +1650,14 @@ async fn test_onchain_duplicate_voter_reverts() { /// Tests that replaying the same evidence causes revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_duplicate_evidence_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1742,26 +1804,18 @@ async fn test_onchain_duplicate_evidence_reverts() { /// must produce calldata that `SlashingManager._verifyAttestationEvidence` /// accepts. This is the canonical "actor → Solidity" end-to-end test. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_actor_signed_vote_accepted() { use e3_events::{AccusationOutcome, AccusationQuorumReached, AccusationVote, ProofType}; use e3_evm::encode_attestation_evidence; use e3_slashing::AccusationManager; if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!( - "skipping: contract artifacts not found \ - (run `npx hardhat compile` in packages/interfold-contracts)" - ); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); diff --git a/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts b/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts index 81d5ac766a..59463003c7 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { expect, describe, it } from 'vitest' +import { bytesToHex } from 'viem' import { extractSignatureComponents, generateMerkleProof, generateMerkleTree, hashLeaf } from '../src/utils' import { SLOT_ADDRESS } from './constants' import { generateTestLeaves } from './helpers' @@ -21,11 +22,9 @@ describe('Utils', () => { }) describe('generateMerkleTree', () => { - it('Should generate a merkle tree', () => { - const leaves = generateTestLeaves([{ address: SLOT_ADDRESS, balance: 100n }]) - const tree = generateMerkleTree(leaves) - - expect(tree.root).toBeDefined() + it('matches the known root for an odd number of leaves', () => { + const tree = generateMerkleTree([1n, 2n, 3n]) + expect(tree.root).toBe(13816780880028945690020260331303642730075999758909899334839547418969502592169n) }) }) @@ -47,6 +46,7 @@ describe('Utils', () => { } expect(tree.verifyProof(unpaddedProof)).toBe(true) + expect(tree.verifyProof({ ...unpaddedProof, leaf: hashLeaf(address, balance + 1n) })).toBe(false) }) it('Should return path indices in least-significant-bit-first order', () => { @@ -81,10 +81,10 @@ describe('Utils', () => { it('Should extract signature components correctly', async () => { const { messageHash, publicKeyX, publicKeyY, signature: extractedSignature } = await extractSignatureComponents(MASK_SIGNATURE) - expect(messageHash).toBeInstanceOf(Uint8Array) - expect(publicKeyX).toBeInstanceOf(Uint8Array) - expect(publicKeyY).toBeInstanceOf(Uint8Array) - expect(extractedSignature).toBeInstanceOf(Uint8Array) + expect(bytesToHex(messageHash)).toBe('0x136f9726bf0927af0b8be9fd5b24fe25ee8047f7940e9efc359d7caf154110fd') + expect(bytesToHex(publicKeyX)).toBe('0x803f440eb94e8a18831bb33268d20363b8c6e632fe425de5a9b16e6caa2d6bf6') + expect(bytesToHex(publicKeyY)).toBe('0x7d8572b3029dbc17a0021271fee5faf58f1367104b96df09d923892984acf77e') + expect(bytesToHex(extractedSignature)).toBe(MASK_SIGNATURE.slice(0, 130)) }) }) }) diff --git a/package.json b/package.json index 4540ed284f..f29b1b7591 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "provenance:manifest": "tsx scripts/generate-provenance-manifest.ts", "check:verifiers": "tsx scripts/generate-verifiers.ts --circuits dkg_aggregator,decryption_aggregator --check", "test:circuit-tooling": "tsx --test scripts/circuit-artifacts.test.ts", - "test": "pnpm evm:test && pnpm rust:test && pnpm sdk:test && pnpm noir:test", + "test": "pnpm evm:test && pnpm rust:test && pnpm rust:test:slashing && pnpm rust:test:proofs && pnpm sdk:test && pnpm sdk:test:proofs:prepared && pnpm noir:test", "test:integration": "cd ./tests/integration && ./test.sh", "coverage": "pnpm evm:coverage", "prepare": "husky", @@ -43,6 +43,8 @@ "ciphernode:add": "cd packages/interfold-contracts && pnpm ciphernode:admin-add", "ciphernode:remove": "cd packages/interfold-contracts && pnpm ciphernode:remove", "rust:test": "cd crates && ./scripts/test.sh", + "rust:test:proofs": "cargo test --locked -p e3-zk-prover --test fold_accumulators_e2e_tests --test node_fold_correlated_e2e_tests -- --include-ignored --nocapture --test-threads=1", + "rust:test:slashing": "cargo test --locked -p e3-zk-prover --test slashing_integration_tests -- --include-ignored --nocapture --test-threads=1", "noir:test": "./scripts/test-circuits.sh", "noir:lint": "./scripts/lint-circuits.sh", "rust:build": "cargo build --locked --release", @@ -70,6 +72,8 @@ "react:build": "cd packages/interfold-react && pnpm build", "sdk:build": "cd packages/interfold-sdk && pnpm build", "sdk:test": "cd packages/interfold-sdk && pnpm test", + "sdk:test:proofs": "cd packages/interfold-sdk && pnpm test:proofs", + "sdk:test:proofs:prepared": "pnpm -C packages/interfold-sdk test:proofs:prepared", "sdk:release": "cd packages/interfold-sdk && pnpm release", "wasm:release": "cd crates/wasm && pnpm release", "config:release": "cd packages/interfold-config && pnpm release", diff --git a/packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol b/packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol new file mode 100644 index 0000000000..926e74c8b8 --- /dev/null +++ b/packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +/// @notice Records slashing calls without holding or transferring collateral. +contract MockSlashingBondingRegistry { + uint64 public constant bondingAssetConfigurationVersion = 1; + uint256 public ticketPenaltyRequested; + uint256 public bondPenaltyRequested; + uint256 public openLocks; + + function snapshotSlashRouteDestination( + uint256, + address, + address + ) external {} + + function openSlashLock(uint256, uint256, address) external { + openLocks++; + } + + function closeSlashLock(uint256, address) external { + openLocks--; + } + + function slashTicketBalance( + address, + uint256 amount, + bytes32 + ) external returns (uint256) { + ticketPenaltyRequested += amount; + return 0; + } + + function slashCiphernodeBond( + address, + uint256 amount, + bytes32 + ) external returns (uint256) { + bondPenaltyRequested += amount; + return 0; + } +} diff --git a/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts b/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts index 6613b0f0e2..5cb52b7fc8 100644 --- a/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts +++ b/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts @@ -8,7 +8,7 @@ // SortitionCommitteeFinalized event rename, and append-only parameter sets. import { expect } from "chai"; -import { BFV_PARAMS_DEFAULT, deployInterfoldSystem, ethers } from "../fixtures"; +import { BFV_PARAMS_DEFAULT, deployInterfoldSystem, ethers, networkHelpers } from "../fixtures"; async function deployAll() { const sys = await deployInterfoldSystem({ @@ -31,7 +31,7 @@ async function deployAll() { describe("Governance — access control, bounds & events", function () { describe("Ownable2Step + renounceOwnership disabled", function () { it("Interfold: transferOwnership is two-step", async function () { - const { interfold, other, ownerAddress } = await deployAll(); + const { interfold, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await interfold.transferOwnership(otherAddress); expect(await interfold.owner()).to.equal(ownerAddress); @@ -41,7 +41,7 @@ describe("Governance — access control, bounds & events", function () { }); it("CiphernodeRegistry: transferOwnership is two-step", async function () { - const { ciphernodeRegistry, other, ownerAddress } = await deployAll(); + const { ciphernodeRegistry, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await ciphernodeRegistry.transferOwnership(otherAddress); expect(await ciphernodeRegistry.owner()).to.equal(ownerAddress); @@ -51,7 +51,7 @@ describe("Governance — access control, bounds & events", function () { }); it("BondingRegistry: transferOwnership is two-step", async function () { - const { bondingRegistry, other, ownerAddress } = await deployAll(); + const { bondingRegistry, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await bondingRegistry.transferOwnership(otherAddress); expect(await bondingRegistry.owner()).to.equal(ownerAddress); @@ -61,7 +61,7 @@ describe("Governance — access control, bounds & events", function () { }); it("E3RefundManager: transferOwnership is two-step", async function () { - const { e3RefundManager, other, ownerAddress } = await deployAll(); + const { e3RefundManager, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await e3RefundManager.transferOwnership(otherAddress); expect(await e3RefundManager.owner()).to.equal(ownerAddress); @@ -71,7 +71,7 @@ describe("Governance — access control, bounds & events", function () { }); it("InterfoldToken: renounceOwnership reverts", async function () { - const { ciphernodeBondToken } = await deployAll(); + const { ciphernodeBondToken } = await networkHelpers.loadFixture(deployAll); await expect( ciphernodeBondToken.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -81,14 +81,14 @@ describe("Governance — access control, bounds & events", function () { }); it("InterfoldTicketToken: renounceOwnership reverts", async function () { - const { ticketToken } = await deployAll(); + const { ticketToken } = await networkHelpers.loadFixture(deployAll); await expect( ticketToken.renounceOwnership(), ).to.be.revertedWithCustomError(ticketToken, "RenounceOwnershipDisabled"); }); it("Interfold: renounceOwnership reverts", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); await expect(interfold.renounceOwnership()).to.be.revertedWithCustomError( interfold, "RenounceOwnershipDisabled", @@ -96,7 +96,7 @@ describe("Governance — access control, bounds & events", function () { }); it("CiphernodeRegistry: renounceOwnership reverts", async function () { - const { ciphernodeRegistry } = await deployAll(); + const { ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); await expect( ciphernodeRegistry.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -106,7 +106,7 @@ describe("Governance — access control, bounds & events", function () { }); it("BondingRegistry: renounceOwnership reverts", async function () { - const { bondingRegistry } = await deployAll(); + const { bondingRegistry } = await networkHelpers.loadFixture(deployAll); await expect( bondingRegistry.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -116,7 +116,7 @@ describe("Governance — access control, bounds & events", function () { }); it("E3RefundManager: renounceOwnership reverts", async function () { - const { e3RefundManager } = await deployAll(); + const { e3RefundManager } = await networkHelpers.loadFixture(deployAll); await expect( e3RefundManager.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -128,7 +128,7 @@ describe("Governance — access control, bounds & events", function () { describe("Interfold bounds exposed", function () { it("setMaxDuration reverts above MAX_DURATION_CAP", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); const cap = await interfold.MAX_DURATION_CAP(); await expect( interfold.setMaxDuration(cap + 1n), @@ -136,7 +136,7 @@ describe("Governance — access control, bounds & events", function () { }); it("exposes MAX_TIMEOUT_WINDOW / MAX_COMMITTEE_SIZE / MAX_*_BPS", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); expect(await interfold.MAX_DURATION_CAP()).to.equal( 365n * 24n * 60n * 60n, ); @@ -151,7 +151,7 @@ describe("Governance — access control, bounds & events", function () { describe("registry & bonding bounds", function () { it("setSortitionSubmissionWindow reverts when out of bounds", async function () { - const { ciphernodeRegistry } = await deployAll(); + const { ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); await expect( ciphernodeRegistry.setSortitionSubmissionWindow(0), ).to.be.revertedWithCustomError( @@ -168,7 +168,7 @@ describe("Governance — access control, bounds & events", function () { }); it("BondingRegistry.setExitDelay reverts when out of bounds", async function () { - const { bondingRegistry } = await deployAll(); + const { bondingRegistry } = await networkHelpers.loadFixture(deployAll); const min = await bondingRegistry.MIN_EXIT_DELAY(); await expect( bondingRegistry.setExitDelay(min - 1n), @@ -180,7 +180,7 @@ describe("Governance — access control, bounds & events", function () { }); it("keeps exit delay longer than the sortition window", async function () { - const { bondingRegistry, ciphernodeRegistry } = await deployAll(); + const { bondingRegistry, ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); const minimumExitDelay = await bondingRegistry.MIN_EXIT_DELAY(); const randomnessTimeout = await ciphernodeRegistry.randomnessRequestTimeout(); @@ -208,12 +208,12 @@ describe("Governance — access control, bounds & events", function () { describe("bps and appeal-window caps exposed", function () { it("E3RefundManager exposes MAX_PROTOCOL_BPS", async function () { - const { e3RefundManager } = await deployAll(); + const { e3RefundManager } = await networkHelpers.loadFixture(deployAll); expect(await e3RefundManager.MAX_PROTOCOL_BPS()).to.equal(5_000n); }); it("SlashingManager exposes MAX_APPEAL_WINDOW", async function () { - const { slashingManager } = await deployAll(); + const { slashingManager } = await networkHelpers.loadFixture(deployAll); expect(await slashingManager.MAX_APPEAL_WINDOW()).to.equal( 30n * 24n * 60n * 60n, ); @@ -222,7 +222,7 @@ describe("Governance — access control, bounds & events", function () { describe("BondingRegistry distributor cap", function () { it("reverts after MAX_AUTHORIZED_DISTRIBUTORS, succeeds after revoke", async function () { - const { bondingRegistry } = await deployAll(); + const { bondingRegistry } = await networkHelpers.loadFixture(deployAll); const cap = await bondingRegistry.MAX_AUTHORIZED_DISTRIBUTORS(); const distributors: string[] = []; for (let i = 0; i < Number(cap); i++) { @@ -244,7 +244,7 @@ describe("Governance — access control, bounds & events", function () { describe("PkVerifierSet event", function () { it("emits PkVerifierSet when setPkVerifier is called", async function () { - const { interfold, mocks } = await deployAll(); + const { interfold, mocks } = await networkHelpers.loadFixture(deployAll); const schemeId = ethers.id("pk-verifier-event"); const verifier = await mocks.pkVerifier.getAddress(); await expect(interfold.setPkVerifier(schemeId, verifier)) @@ -253,7 +253,7 @@ describe("Governance — access control, bounds & events", function () { }); it("rejects verifiers compiled for another committee", async function () { - const { interfold, ciphernodeRegistry } = await deployAll(); + const { interfold, ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); const circuitVerifier = await ethers.deployContract( "MockCircuitVerifier", ); @@ -295,7 +295,7 @@ describe("Governance — access control, bounds & events", function () { describe("SlashingManager setter events", function () { it("emits BondingRegistryUpdated", async function () { - const { slashingManager } = await deployAll(); + const { slashingManager } = await networkHelpers.loadFixture(deployAll); const target = ethers.Wallet.createRandom().address; await expect(slashingManager.setBondingRegistry(target)).to.emit( slashingManager, @@ -306,7 +306,7 @@ describe("Governance — access control, bounds & events", function () { describe("SortitionCommitteeFinalized event rename", function () { it("ABI exposes SortitionCommitteeFinalized but not CommitteeFinalized", async function () { - const { ciphernodeRegistry } = await deployAll(); + const { ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); expect( ciphernodeRegistry.interface.getEvent("SortitionCommitteeFinalized"), ).to.not.equal(null); @@ -320,7 +320,7 @@ describe("Governance — access control, bounds & events", function () { describe("active parameter set", function () { it("is append-only", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); await expect(interfold.setParamSet(0, BFV_PARAMS_DEFAULT)) .to.be.revertedWithCustomError(interfold, "ParamSetAlreadyRegistered") .withArgs(0); diff --git a/packages/interfold-sdk/package.json b/packages/interfold-sdk/package.json index eb71440323..3fa479ed8f 100644 --- a/packages/interfold-sdk/package.json +++ b/packages/interfold-sdk/package.json @@ -46,7 +46,8 @@ "dev": "tsup --watch", "clean": "rm -rf dist", "test": "vitest --run", - "pretest": "pnpm compile:circuits", + "test:proofs": "pnpm compile:circuits && pnpm test:proofs:prepared", + "test:proofs:prepared": "vitest --run --config vitest.proofs.config.ts", "prerelease": "pnpm clean && pnpm build", "release": "pnpm publish --access=public" }, diff --git a/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts b/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts index 0efbc98993..cbfa1359a1 100644 --- a/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts +++ b/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts @@ -20,27 +20,14 @@ export const SDK_CIRCUIT_COMMITTEE = 'minimum' // runtime (not in browsers or web workers, even when `process` is polyfilled). const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null -let checked = false - /** - * SDK encryption artifacts are built for the minimum committee preset by default. - * Fail fast when `circuits/bin/.active-preset.json` points at another committee - * (e.g. after benchmark runs with `--committee small`). - * - * In browser environments this is a no-op (circuit files don't exist client-side). - * - * The Node-only check runs asynchronously (fire-and-forget) so this function can - * stay synchronous for its module-load-time caller while keeping the browser - * bundle free of Node builtins. In Node a mismatch surfaces as an unhandled - * rejection, which still terminates the process — preserving the fail-fast. + * Check the local SDK artifact selection before proof generation. + * Browser bundles contain their artifacts and do not use the local stamp. + * Await this check so a missing or mismatched stamp rejects the proof request. */ -export function assertSdkMinimumCircuits(): void { - if (checked || !isNode) { - checked = true - return - } - checked = true - void assertNodeCircuits() +export async function assertSdkMinimumCircuits(): Promise { + if (!isNode) return + await assertNodeCircuits() } async function assertNodeCircuits(): Promise { @@ -84,9 +71,10 @@ async function assertNodeCircuits(): Promise { ) } - let active: { committee?: string } + let active: { committee?: string; preset?: string } try { - active = JSON.parse(raw) as { committee?: string } + active = JSON.parse(raw) as { committee?: string; preset?: string } + if (active === null || typeof active !== 'object' || Array.isArray(active)) throw new Error('Invalid stamp object') } catch { throw new SDKError( `Could not parse ${activePresetPath} — run \`pnpm -C packages/interfold-sdk compile:circuits\`.`, @@ -101,4 +89,8 @@ async function assertNodeCircuits(): Promise { 'SDK_CIRCUIT_COMMITTEE_MISMATCH', ) } + + if (active.preset !== 'insecure-512') { + throw new SDKError('SDK encryption circuits require the insecure-512 preset.', 'SDK_CIRCUIT_PRESET_MISMATCH') + } } diff --git a/packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts b/packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts new file mode 100644 index 0000000000..baaac17d87 --- /dev/null +++ b/packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts @@ -0,0 +1,94 @@ +// 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 { Barretenberg, UltraHonkBackend, type ProofData } from '@aztec/bb.js' +import userDataEncryptionCt0Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' +import userDataEncryptionCt1Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' +import userDataEncryptionCircuit from '../../../../circuits/bin/threshold/target/user_data_encryption.json' +import { CompiledCircuit, Noir } from '@noir-lang/noir_js' +import { proofToFields } from '../utils' + +import type { CircuitInputs } from './user-data-encryption' + +export const proveUserDataEncryption = async (circuitInputs: CircuitInputs): Promise => { + const api = await Barretenberg.new() + + try { + await api.initSRSChonk(2 ** 21) // fold circuit needs 2^21 points; default is 2^20 + + const { witness: userDataEncryptionCt0Witness } = await executeCircuit(userDataEncryptionCt0Circuit as CompiledCircuit, { + pk0is: circuitInputs.pk0is, + ct0is: circuitInputs.ct0is, + u: circuitInputs.u, + e0: circuitInputs.e0, + e0is: circuitInputs.e0is, + e0_quotients: circuitInputs.e0_quotients, + k1: circuitInputs.k1, + r1is: circuitInputs.r1is, + r2is: circuitInputs.r2is, + }) + const { witness: userDataEncryptionCt1Witness } = await executeCircuit(userDataEncryptionCt1Circuit as CompiledCircuit, { + pk1is: circuitInputs.pk1is, + ct1is: circuitInputs.ct1is, + u: circuitInputs.u, + e1: circuitInputs.e1, + p1is: circuitInputs.p1is, + p2is: circuitInputs.p2is, + }) + + const userDataEncryptionCt0Backend = new UltraHonkBackend((userDataEncryptionCt0Circuit as CompiledCircuit).bytecode, api) + const userDataEncryptionCt1Backend = new UltraHonkBackend((userDataEncryptionCt1Circuit as CompiledCircuit).bytecode, api) + + const { proof: userDataEncryptionCt0Proof, publicInputs: userDataEncryptionCt0PublicInputs } = + await userDataEncryptionCt0Backend.generateProof(userDataEncryptionCt0Witness, { + verifierTarget: 'noir-recursive-no-zk', + }) + const { proof: userDataEncryptionCt1Proof, publicInputs: userDataEncryptionCt1PublicInputs } = + await userDataEncryptionCt1Backend.generateProof(userDataEncryptionCt1Witness, { + verifierTarget: 'noir-recursive-no-zk', + }) + + const userDataEncryptionCt0Artifacts = await userDataEncryptionCt0Backend.generateRecursiveProofArtifacts( + userDataEncryptionCt0Proof, + userDataEncryptionCt0PublicInputs.length, + { + verifierTarget: 'noir-recursive-no-zk', + }, + ) + const userDataEncryptionCt1Artifacts = await userDataEncryptionCt1Backend.generateRecursiveProofArtifacts( + userDataEncryptionCt1Proof, + userDataEncryptionCt1PublicInputs.length, + { + verifierTarget: 'noir-recursive-no-zk', + }, + ) + + const { witness: userDataEncryptionWitness } = await executeCircuit(userDataEncryptionCircuit as CompiledCircuit, { + ct0_verification_key: userDataEncryptionCt0Artifacts.vkAsFields, + ct0_proof: proofToFields(userDataEncryptionCt0Proof), + ct0_public_inputs: userDataEncryptionCt0PublicInputs, + ct0_key_hash: userDataEncryptionCt0Artifacts.vkHash, + ct1_verification_key: userDataEncryptionCt1Artifacts.vkAsFields, + ct1_proof: proofToFields(userDataEncryptionCt1Proof), + ct1_public_inputs: userDataEncryptionCt1PublicInputs, + ct1_key_hash: userDataEncryptionCt1Artifacts.vkHash, + }) + + const userDataEncryptionBackend = new UltraHonkBackend((userDataEncryptionCircuit as CompiledCircuit).bytecode, api) + + return await userDataEncryptionBackend.generateProof(userDataEncryptionWitness, { + verifierTarget: 'noir-recursive-no-zk', + }) + } finally { + api.destroy() + } +} + +const executeCircuit = async (circuit: CompiledCircuit, inputs: any): Promise<{ witness: Uint8Array; returnValue: any }> => { + const noir = new Noir(circuit as CompiledCircuit) + + return noir.execute(inputs) +} diff --git a/packages/interfold-sdk/src/crypto/user-data-encryption.ts b/packages/interfold-sdk/src/crypto/user-data-encryption.ts index d043414d0d..0f2c42bd0a 100644 --- a/packages/interfold-sdk/src/crypto/user-data-encryption.ts +++ b/packages/interfold-sdk/src/crypto/user-data-encryption.ts @@ -4,123 +4,39 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { Barretenberg, UltraHonkBackend, type ProofData } from '@aztec/bb.js' -import userDataEncryptionCt0Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' -import userDataEncryptionCt1Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' -import userDataEncryptionCircuit from '../../../../circuits/bin/threshold/target/user_data_encryption.json' -import { CompiledCircuit, Noir } from '@noir-lang/noir_js' +import type { ProofData } from '@aztec/bb.js' import { assertSdkMinimumCircuits } from '../circuits/assert-minimum-circuits' -import { proofToFields } from '../utils' - -assertSdkMinimumCircuits() // Conversion to Noir types -export type Field = string +export type Field = string | number + +export interface PolynomialInput { + coefficients: Field[] +} /** * Describes the inputs to Greco circuit */ export interface CircuitInputs { - pk0is: string[][] - pk1is: string[][] - ct0is: string[][] - ct1is: string[][] - u: string[] - e0: string[] - e1: string[] - e0is: string[][] - e0_quotients: string[][] - k1: string[] - r1is: string[][] - r2is: string[][] - p1is: string[][] - p2is: string[][] - pk_commitment: string + pk0is: PolynomialInput[] + pk1is: PolynomialInput[] + ct0is: PolynomialInput[] + ct1is: PolynomialInput[] + u: PolynomialInput + e0: PolynomialInput + e1: PolynomialInput + e0is: PolynomialInput[] + e0_quotients: PolynomialInput[] + k1: PolynomialInput + r1is: PolynomialInput[] + r2is: PolynomialInput[] + p1is: PolynomialInput[] + p2is: PolynomialInput[] } -/** - * Generate a proof for a given circuit and circuit inputs - * @dev Defaults to the UltraHonkBackend - * @param circuitInputs - The circuit inputs - * @param circuit - The circuit - * @returns The proof - */ +/** Load the circuit artifacts only when a caller requests a proof. */ export const generateProof = async (circuitInputs: CircuitInputs): Promise => { - const api = await Barretenberg.new() - - try { - await api.initSRSChonk(2 ** 21) // fold circuit needs 2^21 points; default is 2^20 - - const { witness: userDataEncryptionCt0Witness } = await executeCircuit(userDataEncryptionCt0Circuit as CompiledCircuit, { - pk0is: circuitInputs.pk0is, - ct0is: circuitInputs.ct0is, - u: circuitInputs.u, - e0: circuitInputs.e0, - e0is: circuitInputs.e0is, - e0_quotients: circuitInputs.e0_quotients, - k1: circuitInputs.k1, - r1is: circuitInputs.r1is, - r2is: circuitInputs.r2is, - }) - const { witness: userDataEncryptionCt1Witness } = await executeCircuit(userDataEncryptionCt1Circuit as CompiledCircuit, { - pk1is: circuitInputs.pk1is, - ct1is: circuitInputs.ct1is, - u: circuitInputs.u, - e1: circuitInputs.e1, - p1is: circuitInputs.p1is, - p2is: circuitInputs.p2is, - }) - - const userDataEncryptionCt0Backend = new UltraHonkBackend((userDataEncryptionCt0Circuit as CompiledCircuit).bytecode, api) - const userDataEncryptionCt1Backend = new UltraHonkBackend((userDataEncryptionCt1Circuit as CompiledCircuit).bytecode, api) - - const { proof: userDataEncryptionCt0Proof, publicInputs: userDataEncryptionCt0PublicInputs } = - await userDataEncryptionCt0Backend.generateProof(userDataEncryptionCt0Witness, { - verifierTarget: 'noir-recursive-no-zk', - }) - const { proof: userDataEncryptionCt1Proof, publicInputs: userDataEncryptionCt1PublicInputs } = - await userDataEncryptionCt1Backend.generateProof(userDataEncryptionCt1Witness, { - verifierTarget: 'noir-recursive-no-zk', - }) - - const userDataEncryptionCt0Artifacts = await userDataEncryptionCt0Backend.generateRecursiveProofArtifacts( - userDataEncryptionCt0Proof, - userDataEncryptionCt0PublicInputs.length, - { - verifierTarget: 'noir-recursive-no-zk', - }, - ) - const userDataEncryptionCt1Artifacts = await userDataEncryptionCt1Backend.generateRecursiveProofArtifacts( - userDataEncryptionCt1Proof, - userDataEncryptionCt1PublicInputs.length, - { - verifierTarget: 'noir-recursive-no-zk', - }, - ) - - const { witness: userDataEncryptionWitness } = await executeCircuit(userDataEncryptionCircuit as CompiledCircuit, { - ct0_verification_key: userDataEncryptionCt0Artifacts.vkAsFields, - ct0_proof: proofToFields(userDataEncryptionCt0Proof), - ct0_public_inputs: userDataEncryptionCt0PublicInputs, - ct0_key_hash: userDataEncryptionCt0Artifacts.vkHash, - ct1_verification_key: userDataEncryptionCt1Artifacts.vkAsFields, - ct1_proof: proofToFields(userDataEncryptionCt1Proof), - ct1_public_inputs: userDataEncryptionCt1PublicInputs, - ct1_key_hash: userDataEncryptionCt1Artifacts.vkHash, - }) - - const userDataEncryptionBackend = new UltraHonkBackend((userDataEncryptionCircuit as CompiledCircuit).bytecode, api) - - return await userDataEncryptionBackend.generateProof(userDataEncryptionWitness, { - verifierTarget: 'noir-recursive-no-zk', - }) - } finally { - api.destroy() - } -} - -const executeCircuit = async (circuit: CompiledCircuit, inputs: any): Promise<{ witness: Uint8Array; returnValue: any }> => { - const noir = new Noir(circuit as CompiledCircuit) - - return noir.execute(inputs) + await assertSdkMinimumCircuits() + const { proveUserDataEncryption } = await import('./user-data-encryption-prover') + return proveUserDataEncryption(circuitInputs) } diff --git a/packages/interfold-sdk/tests/circuit-selection.test.ts b/packages/interfold-sdk/tests/circuit-selection.test.ts new file mode 100644 index 0000000000..78f1b1fa97 --- /dev/null +++ b/packages/interfold-sdk/tests/circuit-selection.test.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { assertSdkMinimumCircuits } from '../src/circuits/assert-minimum-circuits' + +const { readFileSync } = vi.hoisted(() => ({ readFileSync: vi.fn() })) +vi.mock('node:fs', async () => { + const fs = await vi.importActual('node:fs') + return { + ...fs, + readFileSync: (...args: Parameters) => + args[0].toString().endsWith('.active-preset.json') ? readFileSync(...args) : fs.readFileSync(...args), + } +}) + +describe('SDK circuit selection', () => { + beforeEach(() => { + readFileSync.mockReset() + }) + + it('accepts the supported preset and committee', async () => { + readFileSync.mockReturnValue(JSON.stringify({ preset: 'insecure-512', committee: 'minimum' })) + await expect(assertSdkMinimumCircuits()).resolves.toBeUndefined() + }) + + it('rejects missing artifacts through the awaited request', async () => { + readFileSync.mockImplementation(() => { + throw new Error('ENOENT') + }) + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_STAMP_MISSING' }) + }) + + it.each(['{', 'null', '[]', '"invalid"'])('rejects invalid stamp %s', async (stamp) => { + readFileSync.mockReturnValue(stamp) + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_STAMP_INVALID' }) + }) + + it.each(['micro', 'small', undefined])('rejects committee %s', async (committee) => { + readFileSync.mockReturnValue(JSON.stringify({ preset: 'insecure-512', committee })) + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_COMMITTEE_MISMATCH' }) + }) + + it('rechecks the selection after another build changes the stamp', async () => { + readFileSync.mockReturnValueOnce(JSON.stringify({ preset: 'insecure-512', committee: 'minimum' })) + readFileSync.mockReturnValueOnce(JSON.stringify({ preset: 'secure-8192', committee: 'minimum' })) + await assertSdkMinimumCircuits() + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_PRESET_MISMATCH' }) + }) +}) diff --git a/packages/interfold-sdk/tests/integration/encryption-proof.test.ts b/packages/interfold-sdk/tests/integration/encryption-proof.test.ts new file mode 100644 index 0000000000..8eff34c0be --- /dev/null +++ b/packages/interfold-sdk/tests/integration/encryption-proof.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { Barretenberg, UltraHonkBackend, UltraHonkVerifierBackend, type ProofData } from '@aztec/bb.js' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { bytesToBigInt, createPublicClient, http, toHex, zeroAddress } from 'viem' +import { hardhat } from 'viem/chains' +import { InterfoldSDK } from '../../src/interfold-sdk' +import circuit from '../../../../circuits/bin/threshold/target/user_data_encryption.json' +import ct0Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' +import ct1Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' + +const options = { verifierTarget: 'noir-recursive-no-zk' } as const +const sdk = new InterfoldSDK({ + publicClient: createPublicClient({ chain: hardhat, transport: http() }), + contracts: { interfold: zeroAddress, ciphernodeRegistry: zeroAddress, feeToken: zeroAddress }, + thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', +}) + +describe('real encryption proof', () => { + let api: Barretenberg | undefined + let verifier: UltraHonkVerifierBackend + let verificationKey: Uint8Array + let proof: ProofData + let publicKeyCommitment: bigint + let ciphertextCommitment: bigint + let innerKeyHashes: bigint[] + + beforeAll(async () => { + const publicKey = await sdk.generatePublicKey() + // Reuse one proof for positive and negative checks. Do not regenerate it per assertion. + const result = await sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) + proof = result.proof + publicKeyCommitment = bytesToBigInt(await sdk.computePublicKeyCommitment(publicKey)) + ciphertextCommitment = bytesToBigInt(await sdk.computeCiphertextCommitment(result.encryptedData)) + + api = await Barretenberg.new() + await api.initSRSChonk(2 ** 21) + verificationKey = await new UltraHonkBackend(circuit.bytecode, api).getVerificationKey(options) + verifier = new UltraHonkVerifierBackend(api) + innerKeyHashes = [] + for (const innerCircuit of [ct0Circuit, ct1Circuit]) { + const artifacts = await new UltraHonkBackend(innerCircuit.bytecode, api).generateRecursiveProofArtifacts(new Uint8Array(), 0, options) + innerKeyHashes.push(BigInt(artifacts.vkHash)) + } + }) + + afterAll(async () => { + await api?.destroy() + }) + + it('verifies against the compiled verification key and exact PK/ciphertext bindings', async () => { + expect(proof.publicInputs).toHaveLength(5) + expect(proof.publicInputs.slice(0, 4).map(BigInt)).toEqual([...innerKeyHashes, publicKeyCommitment, ciphertextCommitment]) + expect(await verifier.verifyProof({ ...proof, verificationKey }, options)).toBe(true) + }) + + it.each([0, 1, 2, 3, 4])('rejects an altered public input at position %i', async (index) => { + const publicInputs = [...proof.publicInputs] + publicInputs[index] = toHex(BigInt(publicInputs[index]) ^ 1n, { size: 32 }) + expect(await verifier.verifyProof({ ...proof, publicInputs, verificationKey }, options)).toBe(false) + }) + + it('rejects altered proof contents', async () => { + const altered = proof.proof.slice() + altered[altered.length - 1] ^= 1 + await expect(verifier.verifyProof({ ...proof, proof: altered, verificationKey }, options)).rejects.toThrow( + 'Deserialized point is not on the curve', + ) + }) +}) diff --git a/packages/interfold-sdk/tests/proof-api.test.ts b/packages/interfold-sdk/tests/proof-api.test.ts new file mode 100644 index 0000000000..359e6a8fea --- /dev/null +++ b/packages/interfold-sdk/tests/proof-api.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPublicClient, http, zeroAddress } from 'viem' +import { hardhat } from 'viem/chains' +import { InterfoldSDK } from '../src/interfold-sdk' +import { generateProof, type CircuitInputs } from '../src/crypto/user-data-encryption' + +// These tests check API forwarding. The integration suite verifies real proofs. +vi.mock('../src/crypto/user-data-encryption', () => ({ generateProof: vi.fn() })) + +const sdk = new InterfoldSDK({ + publicClient: createPublicClient({ chain: hardhat, transport: http() }), + contracts: { interfold: zeroAddress, ciphernodeRegistry: zeroAddress, feeToken: zeroAddress }, + thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', +}) + +describe('proof API forwarding', () => { + let publicKey: Uint8Array + let expectedKeyInputs: Pick + let encodeCoefficient: (value: bigint) => bigint + const proof = { proof: new Uint8Array([1, 2, 3]), publicInputs: ['0x01'] } + + beforeAll(async () => { + publicKey = await sdk.generatePublicKey() + expectedKeyInputs = (await sdk.encryptNumberAndGenInputs(1n, publicKey)).circuitInputs + const params = await sdk.getThresholdBfvParamsSet() + const fieldModulus = 21888242871839275222246405745257275088548364400416034343698204186575808495617n + const qModT = params.moduli.reduce((product, modulus) => product * modulus, 1n) % params.plaintextModulus + encodeCoefficient = (value) => { + const residue = (qModT * value) % params.plaintextModulus + const centered = residue > params.plaintextModulus / 2n ? residue - params.plaintextModulus : residue + return (centered + fieldModulus) % fieldModulus + } + }) + + beforeEach(() => { + vi.mocked(generateProof).mockReset().mockResolvedValue(proof) + }) + + it.each(['number', 'vector'] as const)('forwards the %s witness and returns the proof unchanged', async (kind) => { + const result = + kind === 'number' + ? await sdk.encryptNumberAndGenProof(1n, publicKey) + : await sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) + + expect(generateProof).toHaveBeenCalledOnce() + const [inputs] = vi.mocked(generateProof).mock.calls[0] + expect(inputs.pk0is).toEqual(expectedKeyInputs.pk0is) + expect(inputs.pk1is).toEqual(expectedKeyInputs.pk1is) + const expectedPlaintext = Array(512).fill(0n) + expectedPlaintext[511] = encodeCoefficient(1n) + if (kind === 'vector') expectedPlaintext[510] = encodeCoefficient(2n) + expect(inputs.k1.coefficients.map(BigInt)).toEqual(expectedPlaintext) + expect(inputs.ct0is).toHaveLength(2) + expect(inputs.ct1is).toHaveLength(2) + expect(result.proof).toBe(proof) + expect(await sdk.computeCiphertextCommitment(result.encryptedData)).toHaveLength(32) + }) + + it.each(['number', 'vector'] as const)('propagates %s proof-generation failures', async (kind) => { + const failure = new Error('proof generation failed') + vi.mocked(generateProof).mockRejectedValueOnce(failure) + const request = + kind === 'number' + ? sdk.encryptNumberAndGenProof(1n, publicKey) + : sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) + await expect(request).rejects.toBe(failure) + }) +}) diff --git a/packages/interfold-sdk/tests/sdk.test.ts b/packages/interfold-sdk/tests/sdk.test.ts index 66947c1987..019cdbc322 100644 --- a/packages/interfold-sdk/tests/sdk.test.ts +++ b/packages/interfold-sdk/tests/sdk.test.ts @@ -4,13 +4,18 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { InterfoldSDK } from '../src/interfold-sdk' import { zeroAddress } from 'viem' import { hardhat } from 'viem/chains' import { generatePublicKey, encryptNumber as standaloneEncryptNumber, encryptVector as standaloneEncryptVector } from '../src/crypto' +let publicKey: Uint8Array +beforeAll(async () => { + publicKey = await generatePublicKey('INSECURE_THRESHOLD_512') +}) + describe('encryptNumber', () => { describe('trbfv', () => { // create SDK with default config @@ -27,31 +32,18 @@ describe('encryptNumber', () => { }) it('should encrypt a number without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() const value = await sdk.encryptNumber(10n, publicKey) expect(value).to.be.an.instanceof(Uint8Array) expect(value.length).to.equal(9_242) // TODO: test the encryption is correct }) - it('should encrypt a number and generate a proof without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() - - const value = await sdk.encryptNumberAndGenProof(1n, publicKey) - - expect(value).to.be.an.instanceof(Object) - expect(value.encryptedData).to.be.an.instanceof(Uint8Array) - expect(value.proof).to.be.an.instanceOf(Object) - }, 9999999) - it('should encrypt a vector of numbers without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() const value = await sdk.encryptVector(new BigUint64Array([1n, 2n]), publicKey) expect(value).to.be.an.instanceof(Uint8Array) expect(value.length).to.equal(9_242) }) it('should validate a committee public key against its on-chain commitment', async () => { - const publicKey = await sdk.generatePublicKey() const commitment = await sdk.computePublicKeyCommitment(publicKey) expect(await sdk.validatePublicKeyCommitment(publicKey, commitment)).to.equal(true) @@ -63,36 +55,23 @@ describe('encryptNumber', () => { }) it('should compute a SAFE commitment for encrypted data', async () => { - const publicKey = await sdk.generatePublicKey() const ciphertext = await sdk.encryptNumber(10n, publicKey) const commitment = await sdk.computeCiphertextCommitment(ciphertext) expect(commitment).to.be.an.instanceof(Uint8Array) expect(commitment.length).to.equal(32) }) - - it('should encrypt a vector and generate a proof without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() - - const value = await sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) - - expect(value).to.be.an.instanceof(Object) - expect(value.encryptedData).to.be.an.instanceof(Uint8Array) - expect(value.proof).to.be.an.instanceOf(Object) - }, 9999999) }) describe('standalone encryption (no blockchain setup)', () => { it('should encrypt a number using standalone functions', async () => { - const pk = await generatePublicKey('INSECURE_THRESHOLD_512') - const ct = await standaloneEncryptNumber(10n, pk, 'INSECURE_THRESHOLD_512') + const ct = await standaloneEncryptNumber(10n, publicKey, 'INSECURE_THRESHOLD_512') expect(ct).to.be.an.instanceof(Uint8Array) expect(ct.length).to.equal(9_242) }) it('should encrypt a vector using standalone functions', async () => { - const pk = await generatePublicKey('INSECURE_THRESHOLD_512') - const ct = await standaloneEncryptVector(new BigUint64Array([1n, 2n]), pk, 'INSECURE_THRESHOLD_512') + const ct = await standaloneEncryptVector(new BigUint64Array([1n, 2n]), publicKey, 'INSECURE_THRESHOLD_512') expect(ct).to.be.an.instanceof(Uint8Array) expect(ct.length).to.equal(9_242) }) diff --git a/packages/interfold-sdk/vitest.config.ts b/packages/interfold-sdk/vitest.config.ts new file mode 100644 index 0000000000..6a083e8e3f --- /dev/null +++ b/packages/interfold-sdk/vitest.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/*.test.ts'], + }, +}) diff --git a/packages/interfold-sdk/vitest.proofs.config.ts b/packages/interfold-sdk/vitest.proofs.config.ts new file mode 100644 index 0000000000..ab7a9f0cdd --- /dev/null +++ b/packages/interfold-sdk/vitest.proofs.config.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/integration/*.test.ts'], + // Real recursive proofs share one worker to bound memory and setup cost. + poolOptions: { forks: { singleFork: true } }, + pool: 'forks', + hookTimeout: 600_000, + testTimeout: 120_000, + }, +})