fix: add negative test

This commit is contained in:
Roman 2026-06-18 14:21:54 +08:00
parent ee13844b64
commit 119c867b89
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
3 changed files with 62 additions and 4 deletions

View File

@ -158,7 +158,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
// Non-signer public accounts at applied indices are set to their post-state.
for (idx, id) in public_account_ids.iter().enumerate() {
if idx >= public_post_states.len() {
break; // zip in apply_state_diff truncates to the shorter vector
break; // public_diff zips ids with post_states, truncating to the shorter vec
}
if signer_ids.contains(id) {
continue; // signer accounts also get a nonce increment afterwards

View File

@ -260,9 +260,14 @@ pub fn arb_privacy_preserving_tx(
}
// ── new_nullifiers (unique — validator check 2b) ─────────────────────────────────
// Check 6 additionally requires each digest to be a recognised commitment-set root.
// Using the live root makes the success path reachable; a random digest drives the
// check-6 rejection path.
// Check 6 additionally requires each digest to be in the commitment set's `root_history`.
// `root_history` starts *empty* on a fresh genesis state and is only seeded once a
// commitment-bearing transaction applies (`CommitmentSet::extend` inserts the post-insert
// root). So a nullifier digest set to the live root only passes check 6 on a *later*
// transaction in the sequence — after an earlier tx grew the commitment set; against the
// first tx (empty history) even the live root is rejected. We still use the live root half
// the time so the success path becomes reachable once seeded; a random digest always drives
// the check-6 rejection path.
let n_null = (u8::arbitrary(u)? as usize) % 3;
let live_root = state.commitment_set_digest();
let mut new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)> = Vec::new();

View File

@ -67,6 +67,59 @@ fn synthesized_proof_reaches_checks_5_6_and_applies() {
);
}
/// Negative counterpart to the test above: the synthesised `FakeReceipt` is a forgery that
/// must pass **only** under `RISC0_DEV_MODE`. With dev mode off, `Receipt::verify` runs the
/// real integrity check, the fake fails it, and the executor must reject the transaction at
/// check 4 — never reaching checks 56 or `apply_state_diff`.
///
/// This locks the dev-mode boundary in CI: it asserts the forgery is genuinely inert in a
/// production-mode verifier, so `synthesize_passing_proof` can never be mistaken for a
/// real-proof generator. It is the mirror of `synthesized_proof_reaches_checks_5_6_and_applies`
/// — exactly one of the two runs in any given environment (a bare `cargo test` runs this one;
/// `RISC0_DEV_MODE=1 cargo test` runs the other), so both directions are covered across CI.
#[test]
fn synthesized_proof_is_rejected_without_dev_mode() {
let dev_mode = std::env::var("RISC0_DEV_MODE").is_ok_and(|v| v == "1" || v == "true");
if dev_mode {
return;
}
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// Same well-formed message as the positive test: checks 13 are vacuous/trivially met, so a
// rejection can only come from check 4 (proof verification) failing on the fake receipt.
let aid = AccountId::new([7_u8; 32]);
let commitment = Commitment::new(&aid, &Account::default());
let message = PPMessage {
public_account_ids: vec![],
nonces: vec![],
public_post_states: vec![],
encrypted_private_post_states: vec![],
new_commitments: vec![commitment.clone()],
new_nullifiers: vec![],
block_validity_window: BlockValidityWindow::new_unbounded(),
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
};
let proof = synthesize_passing_proof(&message, &state, &[]);
let witness_set = PPWitnessSet::for_message(&message, proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
assert!(
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.is_err(),
"a synthesised fake receipt must be rejected at check 4 when RISC0_DEV_MODE is off - \
the forgery must never verify in a production-mode verifier",
);
// The rejection must also leave private state untouched (no commitment inserted).
assert!(
state.get_proof_for_commitment(&commitment).is_none(),
"a rejected transaction must not insert its commitment into the set",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Generator contract tests
//