From 7451b0633283567a1ba4d4bf1ac606665c4d3d10 Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Thu, 8 Oct 2026 18:07:32 +0200 Subject: [PATCH 1/4] phoenix: Update to dusk-jubjub 0.16 and dusk-plonk 0.24 Move both crates to dusk-bls12_381 and dusk-jubjub 0.16, dusk-poseidon 0.44.0, jubjub-elgamal 0.6.0 and jubjub-schnorr 0.7.0, and the circuits to dusk-plonk 0.24.0 and poseidon-merkle 0.11.0. Both manifests move in this commit, as the workspace only resolves with both crates on the same stack. The jubjub-schnorr requirement names the release: 0.7.0-rc.0 also admits the release candidate, which is built on dusk-jubjub 0.15. The changelog drops the entry for the unreleased bump to dusk-jubjub 0.15.2, which never shipped and which 0.16 replaces. Adapt core to the API changes: - `hash_to_scalar` takes a domain; pass `None` for the legacy construction every existing note key is derived with. - `dhke` returns a `CtOption`, empty for an identity or small-order point. Value decryption returns `Error::InvalidEncryption` for such an `R`, and note creation expects a receiver key `A` of prime order. - `Encryption::encrypt` returns a `Result`, rejecting a degenerate encryption. Sender encryption expects it to succeed, which takes a nonzero blinder and key points of prime order. New note tests cover the panics for an identity receiver key `A` and for a zero sender blinder, and the `Error::InvalidEncryption` for an identity `R`. The signature gadgets of jubjub-schnorr 0.7.0 reject an identity key, so the transaction circuit now rejects a sender public key whose `A` or `B` is the identity. The circuits compile again once the commit constraining their points to the prime-order subgroup adapts them to the typed point APIs of dusk-plonk 0.24 and jubjub-schnorr 0.7.0. --- circuits/CHANGELOG.md | 10 ++++ circuits/Cargo.toml | 14 ++--- core/CHANGELOG.md | 11 +++- core/Cargo.toml | 10 ++-- core/src/keys.rs | 5 +- core/src/note.rs | 31 ++++++++-- core/tests/note_test.rs | 129 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 190 insertions(+), 20 deletions(-) diff --git a/circuits/CHANGELOG.md b/circuits/CHANGELOG.md index 88e2881..962b0e8 100644 --- a/circuits/CHANGELOG.md +++ b/circuits/CHANGELOG.md @@ -9,8 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Update `jubjub-schnorr` to v0.7.0 [#311] +- Update `dusk-bls12_381` and `dusk-jubjub` to v0.16, `dusk-plonk` to v0.24.0, + `dusk-poseidon` to v0.44.0, `poseidon-merkle` to v0.11.0 and + `jubjub-elgamal` to v0.6.0 [#311] - Raise the MSRV to Rust 1.96.1 [#302] +### Fixed + +- Reject a sender public key whose `A` or `B` is the identity [#314] + ## [0.8.1] - 2026-06-15 ### Changed @@ -150,6 +158,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `poseidon-merkle` to v0.6 [#179] +[#314]: https://github.com/dusk-network/phoenix/issues/314 +[#311]: https://github.com/dusk-network/phoenix/issues/311 [#302]: https://github.com/dusk-network/phoenix/issues/302 [#280]: https://github.com/dusk-network/phoenix/issues/280 [#255]: https://github.com/dusk-network/phoenix/issues/255 diff --git a/circuits/Cargo.toml b/circuits/Cargo.toml index 114b33e..cc2a5e2 100644 --- a/circuits/Cargo.toml +++ b/circuits/Cargo.toml @@ -14,15 +14,15 @@ edition.workspace = true [dependencies] dusk-bytes = "0.1" phoenix-core = { version = "0.35.1", default-features = false, path = "../core" } -dusk-bls12_381 = { version = "0.14", default-features = false } -dusk-jubjub = { version = "0.15", default-features = false } -poseidon-merkle = "0.9.0-rc.0" -dusk-poseidon = "0.42.0-rc.0" -jubjub-schnorr = "0.7.0-rc.0" -jubjub-elgamal = "0.5.0" +dusk-bls12_381 = { version = "0.16", default-features = false } +dusk-jubjub = { version = "0.16", default-features = false } +poseidon-merkle = "0.11.0" +dusk-poseidon = "0.44.0" +jubjub-schnorr = "0.7.0" +jubjub-elgamal = "0.6.0" rkyv = { version = "0.7", default-features = false, optional = true } bytecheck = { version = "0.6", default-features = false, optional = true } -dusk-plonk = { version = "0.22.1", default-features = false, optional = true } +dusk-plonk = { version = "0.24.0", default-features = false, optional = true } [dev-dependencies] rand = { version = "0.8", default-features = false, features = ["std_rng"] } diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 9be8e31..10c7b72 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -17,6 +17,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Update `jubjub-schnorr` to v0.7.0 [#311] +- Update `dusk-bls12_381` and `dusk-jubjub` to v0.16, `dusk-poseidon` to + v0.44.0 and `jubjub-elgamal` to v0.6.0 [#311] +- Panic in `Note::new`, `Note::transparent`, `Note::obfuscated` and + `Sender::encrypt` when an encryption degenerates, which takes a zero sender + blinder or a key point that is not of prime order [#311] +- Return `Error::InvalidEncryption` from `Note::value`, `Note::value_blinder` + and `Note::value_opening` for an obfuscated note whose `R` is the identity or + of small order [#311] - Remove the unused direct `hex` and `assert_matches` dependencies [#308] - Remove the unused direct `base64` dependency [#306] - Raise the MSRV to Rust 1.96.1 [#302] @@ -26,7 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 value blinder, ignoring the supplied blinder [#290] - Return `Error::CommitmentMismatch` when a recovered note opening does not match its value commitment [#290] -- Update `dusk-jubjub` to v0.15.2 for its torsion-rejecting point decoder [#288] ### Fixed @@ -474,6 +482,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Canonical implementation shielded by feature. +[#311]: https://github.com/dusk-network/phoenix/issues/311 [#308]: https://github.com/dusk-network/phoenix/issues/308 [#306]: https://github.com/dusk-network/phoenix/issues/306 [#302]: https://github.com/dusk-network/phoenix/issues/302 diff --git a/core/Cargo.toml b/core/Cargo.toml index 0ade8ab..4716e87 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -12,13 +12,13 @@ edition.workspace = true [dependencies] rand_core = { version = "0.6", default-features = false } dusk-bytes = "0.1" -dusk-bls12_381 = { version = "0.14", default-features = false } -dusk-jubjub = { version = "0.15.2", default-features = false, features = [ +dusk-bls12_381 = { version = "0.16", default-features = false } +dusk-jubjub = { version = "0.16", default-features = false, features = [ "zeroize", ] } -dusk-poseidon = "0.42.0-rc.0" -jubjub-schnorr = "0.7.0-rc.0" -jubjub-elgamal = "0.5.0" +dusk-poseidon = "0.44.0" +jubjub-schnorr = "0.7.0" +jubjub-elgamal = "0.6.0" subtle = { version = "2.6", default-features = false } ff = { version = "0.13", default-features = false } aes-gcm = { version = "0.10", default-features = false, features = [ diff --git a/core/src/keys.rs b/core/src/keys.rs index 4aef965..5c8682a 100644 --- a/core/src/keys.rs +++ b/core/src/keys.rs @@ -12,6 +12,9 @@ pub mod view; /// Hashes a JubJub's ExtendedPoint into a JubJub's Scalar using the JubJub's /// hash to scalar function +/// +/// Uses the legacy construction without a domain separator, which every +/// existing note key is derived with. pub fn hash(p: &JubJubExtended) -> JubJubScalar { - JubJubScalar::hash_to_scalar(&JubJubAffine::from(p).to_bytes()) + JubJubScalar::hash_to_scalar(None, &JubJubAffine::from(p).to_bytes()) } diff --git a/core/src/note.rs b/core/src/note.rs index 576bf8c..fbb5aba 100644 --- a/core/src/note.rs +++ b/core/src/note.rs @@ -105,6 +105,11 @@ impl Note { /// /// For transparent notes, `value_blinder` is ignored and the canonical /// zero blinder is used instead. + /// + /// # Panics + /// + /// Panics if an encryption degenerates, which takes a zero sender blinder + /// or a key point that is not of prime order. pub fn new( rng: &mut R, note_type: NoteType, @@ -134,7 +139,8 @@ impl Note { value_enc } NoteType::Obfuscated => { - let shared_secret = dhke(&r, receiver_pk.A()); + let shared_secret = dhke(&r, receiver_pk.A()) + .expect("The receiver key `A` is of prime order."); let value_blinder = BlsScalar::from(value_blinder); let mut plaintext = value.to_bytes().to_vec(); @@ -166,6 +172,10 @@ impl Note { /// The blinding factor will be constant zero since the value commitment /// exists only to shield the value. The value is not hidden for transparent /// notes, so this can be trivially treated as a constant. + /// + /// # Panics + /// + /// Panics as [`Note::new`] does. pub fn transparent( rng: &mut R, sender_pk: &PublicKey, @@ -217,6 +227,10 @@ impl Note { /// commitment of the note. The tuple (value, value_blinder), known by /// the caller of this function, must be later used to prove the /// knowledge of the value commitment of this note. + /// + /// # Panics + /// + /// Panics as [`Note::new`] does. pub fn obfuscated( rng: &mut R, sender_pk: &PublicKey, @@ -320,7 +334,9 @@ impl Note { vk: &ViewKey, ) -> Result<(u64, JubJubScalar), Error> { let R = self.stealth_address.R(); - let shared_secret = dhke(vk.a(), R); + // An identity or small-order `R` yields no shared secret. + let shared_secret = Option::::from(dhke(vk.a(), R)) + .ok_or(Error::InvalidEncryption)?; let salt = self.stealth_address.to_bytes(); @@ -558,6 +574,11 @@ pub enum Sender { impl Sender { /// Create a new [`Sender`] enum by encrypting the sender's [`PublicKey`] in /// a way that only the receiver of the note can decrypt. + /// + /// # Panics + /// + /// Panics if an encryption degenerates, which takes a zero blinder or a + /// point that is not of prime order. pub fn encrypt( note_pk: &NotePublicKey, sender_pk: &PublicKey, @@ -568,14 +589,16 @@ impl Sender { sender_pk.A(), None, &blinder[0], - ); + ) + .expect("The sender key `A` encrypts to the note public key."); let (sender_enc_B, _) = ElGamal::encrypt( note_pk.as_ref(), sender_pk.B(), None, &blinder[1], - ); + ) + .expect("The sender key `B` encrypts to the note public key."); let sender_enc_A: (JubJubAffine, JubJubAffine) = (sender_enc_A.c1().into(), sender_enc_A.c2().into()); diff --git a/core/tests/note_test.rs b/core/tests/note_test.rs index e43f1b8..f82d9ec 100644 --- a/core/tests/note_test.rs +++ b/core/tests/note_test.rs @@ -4,14 +4,15 @@ // // Copyright (c) DUSK NETWORK. All rights reserved. +use dusk_bls12_381::BlsScalar; use dusk_bytes::Serializable; use dusk_jubjub::{ Fq, GENERATOR_EXTENDED, JubJubAffine, JubJubExtended, JubJubScalar, }; use ff::Field; use phoenix_core::{ - Error, Note, NoteType, PublicKey, SecretKey, Sender, StealthAddress, - ViewKey, value_commitment, + Error, NOTE_VAL_ENC_SIZE, Note, NoteType, PublicKey, SecretKey, Sender, + StealthAddress, ViewKey, aes, value_commitment, }; use rand::rngs::StdRng; use rand::{RngCore, SeedableRng}; @@ -426,3 +427,127 @@ fn note_not_owned() { assert!(note.value(Some(¬_owner_vk)).is_err()); } + +#[test] +#[should_panic(expected = "The receiver key `A` is of prime order.")] +fn obfuscated_note_panics_on_identity_receiver_key() { + let mut rng = StdRng::seed_from_u64(0xc0b); + + let sender_pk = PublicKey::from(&SecretKey::random(&mut rng)); + let receiver_pk = PublicKey::from(&SecretKey::random(&mut rng)); + let receiver_pk = + PublicKey::new(JubJubExtended::identity(), *receiver_pk.B()); + let value_blinder = JubJubScalar::random(&mut rng); + let sender_blinder = [ + JubJubScalar::random(&mut rng), + JubJubScalar::random(&mut rng), + ]; + + Note::obfuscated( + &mut rng, + &sender_pk, + &receiver_pk, + 25, + value_blinder, + sender_blinder, + ); +} + +/// Creates a transparent note with `sender_blinder`. +fn transparent_note_with_sender_blinder(sender_blinder: [JubJubScalar; 2]) { + let mut rng = StdRng::seed_from_u64(0xc0b); + + let sender_pk = PublicKey::from(&SecretKey::random(&mut rng)); + let receiver_pk = PublicKey::from(&SecretKey::random(&mut rng)); + + Note::transparent(&mut rng, &sender_pk, &receiver_pk, 25, sender_blinder); +} + +#[test] +#[should_panic( + expected = "The sender key `A` encrypts to the note public key." +)] +fn note_panics_on_zero_sender_blinder_for_a() { + transparent_note_with_sender_blinder([ + JubJubScalar::zero(), + JubJubScalar::one(), + ]); +} + +#[test] +#[should_panic( + expected = "The sender key `B` encrypts to the note public key." +)] +fn note_panics_on_zero_sender_blinder_for_b() { + transparent_note_with_sender_blinder([ + JubJubScalar::one(), + JubJubScalar::zero(), + ]); +} + +#[test] +fn obfuscated_note_with_identity_r_has_no_opening() { + let mut rng = StdRng::seed_from_u64(0xc0b); + + let sender_pk = PublicKey::from(&SecretKey::random(&mut rng)); + let receiver_sk = SecretKey::random(&mut rng); + let receiver_pk = PublicKey::from(&receiver_sk); + let receiver_vk = ViewKey::from(&receiver_sk); + + let value = 25; + let value_blinder = JubJubScalar::random(&mut rng); + let sender_blinder = [ + JubJubScalar::random(&mut rng), + JubJubScalar::random(&mut rng), + ]; + let note = Note::obfuscated( + &mut rng, + &sender_pk, + &receiver_pk, + value, + value_blinder, + sender_blinder, + ); + + // An identity `R`, with the opening encrypted under the shared secret it + // gives, the identity: without the check of `R`, the opening decrypts and + // matches the value commitment. + let stealth_address = StealthAddress::from_raw_unchecked( + JubJubExtended::identity(), + *note.stealth_address().note_pk(), + ); + let mut plaintext = value.to_bytes().to_vec(); + plaintext.extend_from_slice(&BlsScalar::from(value_blinder).to_bytes()); + let value_enc: [u8; NOTE_VAL_ENC_SIZE] = aes::encrypt( + &JubJubAffine::identity(), + &stealth_address.to_bytes(), + &plaintext, + &mut rng, + ) + .expect("the opening encrypts"); + + // The stealth address follows the note type and the value commitment, + // and the value encryption follows it and the position. + let mut bytes = note.to_bytes(); + let stealth_start = 1 + JubJubAffine::SIZE; + let value_enc_start = stealth_start + StealthAddress::SIZE + u64::SIZE; + bytes[stealth_start..stealth_start + StealthAddress::SIZE] + .copy_from_slice(&stealth_address.to_bytes()); + bytes[value_enc_start..value_enc_start + NOTE_VAL_ENC_SIZE] + .copy_from_slice(&value_enc); + let note = Note::from_bytes(&bytes).expect("the note decodes"); + assert_eq!(note.stealth_address(), &stealth_address); + + assert!(matches!( + note.value_opening(Some(&receiver_vk)), + Err(Error::InvalidEncryption) + )); + assert!(matches!( + note.value(Some(&receiver_vk)), + Err(Error::InvalidEncryption) + )); + assert!(matches!( + note.value_blinder(Some(&receiver_vk)), + Err(Error::InvalidEncryption) + )); +} From d771600bc8e0f00cead3b4a68eb009ea6a51e13e Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Thu, 8 Oct 2026 18:15:33 +0200 Subject: [PATCH 2/4] circuits: Constrain circuit points to the prime-order subgroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every key the prover supplies to the transaction circuit now goes through Plonk's torsion-free assertion, once, where it enters: the input `note_pk` and `note_pk_p`, and the sender public key. The sender keys feed both the signature and the ElGamal gadgets from that single check. The public output note keys, which the sender key is encrypted to, are checked in-circuit too. The signature nonces need no check of their own: each signature equation sets its nonce equal to `u·G + c·pk`, with the key `pk` in the subgroup. For `note_pk_p`, the check makes the nullifier, which hashes it, depend on the note alone. The new regressions add a small-order component to each checked key: `note_pk_p` for every input variant, and `note_pk`, the sender keys `A` and `B` and the two output note keys. Each recomputes the signatures, the note, its nullifier and the encryptions that depend on the key, so that every other equation holds, and checks that the transaction is unsatisfiable. As a control, the same construction with the identity in place of the small-order component proves. This adapts the circuits to dusk-plonk 0.24 and jubjub-schnorr 0.7.0, whose gadgets take subgroup keys: these checks feed their typed point APIs. Replace the deprecated `component_range::<32>` with `component_range_bits::<64>`, which emits the same gates. --- circuits/CHANGELOG.md | 5 + circuits/src/circuit_impl.rs | 80 +++++--- circuits/src/sender_enc.rs | 40 ++-- circuits/tests/common/mod.rs | 12 +- circuits/tests/torsion.rs | 360 +++++++++++++++++++++++++++++++++++ 5 files changed, 455 insertions(+), 42 deletions(-) create mode 100644 circuits/tests/torsion.rs diff --git a/circuits/CHANGELOG.md b/circuits/CHANGELOG.md index 962b0e8..759b7e3 100644 --- a/circuits/CHANGELOG.md +++ b/circuits/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Constrain `note_pk`, `note_pk_p`, the sender public key and the output note + public keys to the prime-order subgroup where they enter the transaction + circuit [#313] - Update `jubjub-schnorr` to v0.7.0 [#311] - Update `dusk-bls12_381` and `dusk-jubjub` to v0.16, `dusk-plonk` to v0.24.0, `dusk-poseidon` to v0.44.0, `poseidon-merkle` to v0.11.0 and @@ -17,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Reject an input `note_pk_p` with a small-order component [#313] - Reject a sender public key whose `A` or `B` is the identity [#314] ## [0.8.1] - 2026-06-15 @@ -158,6 +162,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `poseidon-merkle` to v0.6 [#179] +[#313]: https://github.com/dusk-network/phoenix/issues/313 [#314]: https://github.com/dusk-network/phoenix/issues/314 [#311]: https://github.com/dusk-network/phoenix/issues/311 [#302]: https://github.com/dusk-network/phoenix/issues/302 diff --git a/circuits/src/circuit_impl.rs b/circuits/src/circuit_impl.rs index 2220cf2..a4f70a3 100644 --- a/circuits/src/circuit_impl.rs +++ b/circuits/src/circuit_impl.rs @@ -7,9 +7,10 @@ //! The [`Circuit`] trait implementation on [`TxCircuit`]. use dusk_bls12_381::BlsScalar; -use dusk_jubjub::{GENERATOR, GENERATOR_NUMS}; +use dusk_jubjub::{GENERATOR, GENERATOR_NUMS, JubJubExtended}; use dusk_plonk::prelude::{ - Circuit, Composer, Constraint, Error as PlonkError, Witness, WitnessPoint, + Circuit, Composer, Constraint, Error as PlonkError, + TorsionFreeWitnessPoint, Witness, WitnessPoint, }; use dusk_poseidon::{Domain, HashGadget}; use jubjub_schnorr::gadgets; @@ -65,7 +66,7 @@ impl Circuit for TxCircuit { signature_u, signature_r, signature_r_p, - ) = input_note_info.append_to_circuit(composer); + ) = input_note_info.append_to_circuit(composer)?; // Verify: 2. Ownership gadgets::verify_signature_double( @@ -87,7 +88,7 @@ impl Circuit for TxCircuit { composer.assert_equal(computed_nullifier, nullifier); // Perform a range check ([0, 2^64 - 1]) on the value of the note - composer.component_range::<32>(value); + composer.component_range_bits::<64>(value); // Sum up all the input note values let constraint = Constraint::new() @@ -133,13 +134,13 @@ impl Circuit for TxCircuit { // Append the witnesses to the circuit let value = composer.append_witness(output_note_info.value); // Append the value-commitment as public input - let expected_value_commitment = - composer.append_public_point(output_note_info.value_commitment); + let expected_value_commitment = composer + .append_public_point(output_note_info.value_commitment)?; let value_blinder = composer.append_witness(output_note_info.value_blinder); // Perform a range check ([0, 2^64 - 1]) on the value of the note - composer.component_range::<32>(value); + composer.component_range_bits::<64>(value); // Sum up all the output note values let constraint = @@ -156,7 +157,7 @@ impl Circuit for TxCircuit { // Verify: 4. Minting composer.assert_equal_point( expected_value_commitment, - computed_value_commitment, + computed_value_commitment.into(), ); } @@ -206,28 +207,38 @@ impl Circuit for TxCircuit { } } +/// The witnesses [`InputNoteInfo::append_to_circuit`] appends: `note_pk`, +/// `note_pk_p`, `note_type`, `pos`, `value`, `value_blinder`, `nullifier` and +/// the signature's `u`, `R` and `R'`. +type InputNoteWitnesses = ( + TorsionFreeWitnessPoint, + TorsionFreeWitnessPoint, + Witness, + Witness, + Witness, + Witness, + Witness, + Witness, + WitnessPoint, + WitnessPoint, +); + impl InputNoteInfo { fn append_to_circuit( &self, composer: &mut Composer, - ) -> ( - WitnessPoint, - WitnessPoint, - Witness, - Witness, - Witness, - Witness, - Witness, - Witness, - WitnessPoint, - WitnessPoint, - ) { + ) -> Result { // Append the nullifier as public-input let nullifier = composer.append_public(self.nullifier); - let note_pk = composer - .append_point(*self.note.stealth_address().note_pk().as_ref()); - let note_pk_p = composer.append_point(self.note_pk_p); + // Each key is prover-supplied, so its subgroup membership is + // constrained where it enters the circuit. For `note_pk_p` this makes + // the nullifier, which hashes it, depend on the note alone. + let note_pk = append_subgroup_point( + composer, + *self.note.stealth_address().note_pk().as_ref(), + )?; + let note_pk_p = append_subgroup_point(composer, self.note_pk_p)?; let note_type = composer .append_witness(BlsScalar::from(self.note.note_type() as u64)); @@ -237,10 +248,15 @@ impl InputNoteInfo { let value_blinder = composer.append_witness(self.value_blinder); let signature_u = composer.append_witness(*self.signature.u()); - let signature_r = composer.append_point(self.signature.R()); - let signature_r_p = composer.append_point(self.signature.R_prime()); + // The signature equation sets `R = u·G + c·note_pk`, with `note_pk` in + // the subgroup, so `R` is in it without a separate check. + let signature_r = composer.append_point(*self.signature.R())?; + // The signature equation sets `R' = u·G' + c·note_pk_p`, with + // `note_pk_p` in the subgroup, so `R'` is in it without a separate + // check. + let signature_r_p = composer.append_point(*self.signature.R_prime())?; - ( + Ok(( note_pk, note_pk_p, note_type, @@ -251,6 +267,16 @@ impl InputNoteInfo { signature_u, signature_r, signature_r_p, - ) + )) } } + +/// Appends a prover-supplied point and constrains it to the prime-order +/// subgroup. +pub(crate) fn append_subgroup_point( + composer: &mut Composer, + point: impl Into, +) -> Result { + let point = composer.append_point(point)?; + Ok(composer.assert_torsion_free_point(point)) +} diff --git a/circuits/src/sender_enc.rs b/circuits/src/sender_enc.rs index 9ee562e..af03bf6 100644 --- a/circuits/src/sender_enc.rs +++ b/circuits/src/sender_enc.rs @@ -12,6 +12,8 @@ use jubjub_elgamal as elgamal; use jubjub_schnorr::{Signature as SchnorrSignature, gadgets}; use phoenix_core::{OUTPUT_NOTES, PublicKey}; +use crate::circuit_impl::append_subgroup_point; + /// Gadget to prove a valid origin for a given transaction. pub(crate) fn gadget( composer: &mut Composer, @@ -26,14 +28,21 @@ pub(crate) fn gadget( payload_hash: Witness, ) -> Result<(), Error> { // VERIFY A SIGNATURE FOR EACH KEY 'A' AND 'B' - let sender_pk_A = composer.append_point(sender_pk.A()); - let sender_pk_B = composer.append_point(sender_pk.B()); + // The keys are prover-supplied, so their subgroup membership is + // constrained where they enter the circuit. The signature gadgets + // additionally constrain them to be non-identity. + let sender_pk_A = append_subgroup_point(composer, *sender_pk.A())?; + let sender_pk_B = append_subgroup_point(composer, *sender_pk.B())?; let sig_A_u = composer.append_witness(*signatures.0.u()); - let sig_A_R = composer.append_point(signatures.0.R()); + // The signature equation sets `R = u·G + c·A`, with `A` in the subgroup, + // so `R` is in it without a separate check. + let sig_A_R = composer.append_point(*signatures.0.R())?; let sig_B_u = composer.append_witness(*signatures.1.u()); - let sig_B_R = composer.append_point(signatures.1.R()); + // The signature equation sets `R = u·G + c·B`, with `B` in the subgroup, + // so `R` is in it without a separate check. + let sig_B_R = composer.append_point(*signatures.1.R())?; gadgets::verify_signature( composer, @@ -51,8 +60,13 @@ pub(crate) fn gadget( )?; // ENCRYPT EACH KEY 'A' and 'B' USING EACH OUTPUT 'NPK' - let note_pk_0 = composer.append_public_point(output_npk[0]); - let note_pk_1 = composer.append_public_point(output_npk[1]); + // The output note-pks are public inputs. Their subgroup membership is + // constrained in-circuit rather than left to every verifier, since they + // are the ElGamal keys the sender public key is encrypted to. + let note_pk_0 = composer.append_public_point(output_npk[0])?; + let note_pk_0 = composer.assert_torsion_free_point(note_pk_0); + let note_pk_1 = composer.append_public_point(output_npk[1])?; + let note_pk_1 = composer.assert_torsion_free_point(note_pk_1); let blinder_A_0 = composer.append_witness(sender_blinder[0][0]); let blinder_B_0 = composer.append_witness(sender_blinder[0][1]); @@ -87,9 +101,9 @@ pub(crate) fn gadget( fn assert_sender_enc( composer: &mut Composer, - sender_pk_A: WitnessPoint, - sender_pk_B: WitnessPoint, - note_pk: WitnessPoint, + sender_pk_A: TorsionFreeWitnessPoint, + sender_pk_B: TorsionFreeWitnessPoint, + note_pk: TorsionFreeWitnessPoint, blinder: (Witness, Witness), sender_enc: [(JubJubAffine, JubJubAffine); 2], ) -> Result<(), Error> { @@ -112,11 +126,11 @@ fn assert_sender_enc( let sender_enc_A = sender_enc[0]; let sender_enc_B = sender_enc[1]; - composer.assert_equal_public_point(enc_A_c1, sender_enc_A.0); - composer.assert_equal_public_point(enc_A_c2, sender_enc_A.1); + composer.assert_equal_public_point(enc_A_c1.into(), sender_enc_A.0)?; + composer.assert_equal_public_point(enc_A_c2.into(), sender_enc_A.1)?; - composer.assert_equal_public_point(enc_B_c1, sender_enc_B.0); - composer.assert_equal_public_point(enc_B_c2, sender_enc_B.1); + composer.assert_equal_public_point(enc_B_c1.into(), sender_enc_B.0)?; + composer.assert_equal_public_point(enc_B_c2.into(), sender_enc_B.1)?; Ok(()) } diff --git a/circuits/tests/common/mod.rs b/circuits/tests/common/mod.rs index 72ff334..4645a7c 100644 --- a/circuits/tests/common/mod.rs +++ b/circuits/tests/common/mod.rs @@ -83,6 +83,8 @@ pub static LABEL: &[u8; 12] = b"dusk-network"; pub const HEIGHT: usize = 17; +// Each test binary reads a different subset of the fields. +#[allow(dead_code)] pub struct TestingParameters { pub pp: PublicParameters, pub input_notes_info: Vec>, @@ -90,6 +92,7 @@ pub struct TestingParameters { pub root: BlsScalar, pub deposit: u64, pub max_fee: u64, + pub sender_sk: SecretKey, pub sender_pk: PublicKey, pub output_npk: [JubJubAffine; OUTPUT_NOTES], pub signatures: (SchnorrSignature, SchnorrSignature), @@ -153,6 +156,7 @@ pub static TP: LazyLock = LazyLock::new(|| { root, deposit, max_fee, + sender_sk, sender_pk, output_npk, signatures: (sig_a, sig_b), @@ -236,6 +240,8 @@ fn create_test_input_notes_information( input_notes_info } +// Each test binary uses a different subset of the helpers. +#[allow(dead_code)] pub fn create_output_note_information( rng: &mut (impl RngCore + CryptoRng), value: u64, @@ -251,7 +257,8 @@ pub fn create_output_note_information( TP.sender_pk.A(), None, &sender_blinder_a, - ); + ) + .expect("the sender key encrypts to the note public key"); let sender_blinder_b = sender_blinder[1]; let (sender_enc_b, _) = ElGamal::encrypt( @@ -259,7 +266,8 @@ pub fn create_output_note_information( TP.sender_pk.B(), None, &sender_blinder_b, - ); + ) + .expect("the sender key encrypts to the note public key"); let sender_enc_a = (sender_enc_a.c1().into(), sender_enc_a.c2().into()); let sender_enc_b = (sender_enc_b.c1().into(), sender_enc_b.c2().into()); diff --git a/circuits/tests/torsion.rs b/circuits/tests/torsion.rs new file mode 100644 index 0000000..e5d48a1 --- /dev/null +++ b/circuits/tests/torsion.rs @@ -0,0 +1,360 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Regressions for the subgroup checks of the transaction circuit. +//! +//! The circuit constrains every key the prover supplies, and the note public +//! keys of the outputs, to the prime-order subgroup where they enter it. Each +//! test adds an offset to one of these keys and recomputes what depends on the +//! key, the signatures, the note, its nullifier and the encryptions, so that +//! every other equation of the circuit holds. With the identity as the offset, +//! the transaction must prove, which checks the construction; with the +//! order-two point `T`, it must be unsatisfiable. +//! +//! The signature nonces need no check of their own: each signature equation +//! sets its nonce equal to `u·G + c·pk`, with the key `pk` in the subgroup. + +#![cfg(feature = "plonk")] +#![allow(non_snake_case)] + +use std::sync::LazyLock; + +use dusk_bls12_381::BlsScalar; +use dusk_bytes::Serializable; +use dusk_jubjub::{ + GENERATOR_EXTENDED, GENERATOR_NUMS_EXTENDED, JubJubAffine, JubJubExtended, + JubJubScalar, +}; +use dusk_plonk::prelude::{Compiler, Error as PlonkError, PublicParameters}; +use dusk_poseidon::{Domain, Hash}; +use ff::Field; +use jubjub_schnorr::{ + PublicKey as NotePublicKey, Signature as SchnorrSignature, SignatureDouble, +}; +use phoenix_circuits::TxCircuit; +use phoenix_core::{Note, PublicKey, StealthAddress, value_commitment}; +use poseidon_merkle::{Item, Tree}; +use rand::SeedableRng; +use rand::rngs::StdRng; + +mod common; +use common::*; + +/// A test SRS that fits every input variant. Soundness does not depend on the +/// SRS, and the production CRS only fits the 1- and 2-input circuits under +/// `dusk-plonk`'s blinding degree. +static PP: LazyLock = LazyLock::new(|| { + PublicParameters::setup(1 << 17, &mut StdRng::seed_from_u64(0x7042)) + .expect("failed to generate the test SRS") +}); + +/// The order-two point `(0, -1)`. +fn order_two_point() -> JubJubExtended { + let point = JubJubExtended::from(JubJubAffine::from_raw_unchecked( + BlsScalar::zero(), + -BlsScalar::one(), + )); + assert!(!bool::from(point.is_identity())); + assert!(bool::from(point.double().is_identity())); + point +} + +/// The key a test adds an offset to. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shifted { + /// `note_pk` of the last input. + NotePk, + /// `note_pk_p` of the first input. + NotePkP, + /// `A` of the sender public key. + SenderA, + /// `B` of the sender public key. + SenderB, + /// The note public key of the given output. + OutputNotePk(usize), +} + +/// The double-signature challenge of `jubjub-schnorr`. +fn double_challenge( + R: &JubJubExtended, + R_p: &JubJubExtended, + pk: &JubJubExtended, + pk_p: &JubJubExtended, + message: BlsScalar, +) -> JubJubScalar { + let [R_x, R_y] = R.to_hash_inputs(); + let [R_p_x, R_p_y] = R_p.to_hash_inputs(); + let [pk_x, pk_y] = pk.to_hash_inputs(); + let [pk_p_x, pk_p_y] = pk_p.to_hash_inputs(); + let domain = BlsScalar::from(u64::from_be_bytes(*b"JJSCHDBL")); + + Hash::digest_truncated( + Domain::Other, + &[ + domain, R_x, R_y, R_p_x, R_p_y, pk_x, pk_y, pk_p_x, pk_p_y, message, + ], + )[0] +} + +/// The single-signature challenge of `jubjub-schnorr`. +fn challenge( + R: &JubJubExtended, + pk: &JubJubExtended, + message: BlsScalar, +) -> JubJubScalar { + let [R_x, R_y] = R.to_hash_inputs(); + let [pk_x, pk_y] = pk.to_hash_inputs(); + + Hash::digest_truncated(Domain::Other, &[R_x, R_y, pk_x, pk_y, message])[0] +} + +/// A nonce `r` whose challenge `c` is even, and `c`. A signature with an even +/// challenge holds for a key plus `T` as well, as `c · T` vanishes. +fn even_challenge( + challenge: impl Fn(JubJubScalar) -> JubJubScalar, +) -> (JubJubScalar, JubJubScalar) { + let mut rng = StdRng::seed_from_u64(0xe7e); + loop { + let r = JubJubScalar::random(&mut rng); + let c = challenge(r); + if c.to_bytes()[0] & 1 == 0 { + return (r, c); + } + } +} + +/// A double signature of the payload hash with `sk`, for the keys `pk` and +/// `pk_p`. +fn sign_double( + sk: &JubJubScalar, + pk: &JubJubExtended, + pk_p: &JubJubExtended, +) -> SignatureDouble { + let (r, c) = even_challenge(|r| { + let R = GENERATOR_EXTENDED * r; + let R_p = GENERATOR_NUMS_EXTENDED * r; + double_challenge(&R, &R_p, pk, pk_p, TP.payload_hash) + }); + let u = r - c * sk; + let (R, R_p) = (GENERATOR_EXTENDED * r, GENERATOR_NUMS_EXTENDED * r); + + // Both equations the circuit checks hold. + assert_eq!(GENERATOR_EXTENDED * u + pk * c, R); + assert_eq!(GENERATOR_NUMS_EXTENDED * u + pk_p * c, R_p); + + let mut bytes = [0u8; SignatureDouble::SIZE]; + bytes[..32].copy_from_slice(&u.to_bytes()); + bytes[32..64].copy_from_slice(&JubJubAffine::from(R).to_bytes()); + bytes[64..].copy_from_slice(&JubJubAffine::from(R_p).to_bytes()); + SignatureDouble::from_bytes(&bytes) + .expect("the nonces are prime-order points") +} + +/// A signature of the payload hash with `sk`, for the key `pk`. +fn sign(sk: &JubJubScalar, pk: &JubJubExtended) -> SchnorrSignature { + let (r, c) = even_challenge(|r| { + challenge(&(GENERATOR_EXTENDED * r), pk, TP.payload_hash) + }); + let u = r - c * sk; + let R = GENERATOR_EXTENDED * r; + + // The equation the circuit checks holds. + assert_eq!(GENERATOR_EXTENDED * u + pk * c, R); + + let mut bytes = [0u8; SchnorrSignature::SIZE]; + bytes[..32].copy_from_slice(&u.to_bytes()); + bytes[32..].copy_from_slice(&JubJubAffine::from(R).to_bytes()); + SchnorrSignature::from_bytes(&bytes) + .expect("the nonce is a prime-order point") +} + +/// The ElGamal encryption of `message` to `key` with `blinder`, as the +/// circuit computes it. +fn encrypt( + key: &JubJubExtended, + message: &JubJubExtended, + blinder: &JubJubScalar, +) -> (JubJubAffine, JubJubAffine) { + let c1 = GENERATOR_EXTENDED * blinder; + let c2 = message + key * blinder; + (c1.into(), c2.into()) +} + +/// The `I`-input transaction spending the first test notes, with `offset` +/// added to the key `shifted` names. What depends on that key is recomputed +/// for it, so that every other equation of the circuit holds. +fn transaction( + shifted: Shifted, + offset: JubJubExtended, +) -> TxCircuit { + // `offset` for the key `shifted` names, the identity for the others. + let offset_of = |key: Shifted| { + if key == shifted { + offset + } else { + JubJubExtended::identity() + } + }; + let mut rng = StdRng::seed_from_u64(0xc0b); + + let mut inputs: [InputNoteInfo; I] = + core::array::from_fn(|i| TP.input_notes_info[i].clone()); + if shifted == Shifted::NotePk { + let input = &mut inputs[I - 1]; + let stealth_address = *input.note.stealth_address(); + let note_sk = TP.sender_sk.gen_note_sk(&stealth_address); + let note_pk = stealth_address.note_pk().as_ref() + offset; + let stealth_address = StealthAddress::from_raw_unchecked( + *stealth_address.R(), + NotePublicKey::from_raw_unchecked(note_pk), + ); + + let mut note = Note::transparent_stealth( + stealth_address, + input.value, + *input.note.sender(), + ); + note.set_pos(*input.note.pos()); + input.note = note; + input.signature = + sign_double(note_sk.as_ref(), ¬e_pk, &input.note_pk_p.into()); + } + if shifted == Shifted::NotePkP { + let input = &mut inputs[0]; + let note_sk = TP.sender_sk.gen_note_sk(input.note.stealth_address()); + let note_pk = *input.note.stealth_address().note_pk().as_ref(); + let note_pk_p = JubJubExtended::from(input.note_pk_p) + offset; + + let [x, y] = note_pk_p.to_hash_inputs(); + let pos = BlsScalar::from(*input.note.pos()); + input.nullifier = Hash::digest(Domain::Other, &[x, y, pos])[0]; + input.note_pk_p = note_pk_p.into(); + input.signature = sign_double(note_sk.as_ref(), ¬e_pk, ¬e_pk_p); + } + + // The inputs in a tree of their own. + let mut tree = Tree::<(), HEIGHT>::new(); + for input in &inputs { + let item = Item { + hash: input.note.hash(), + data: (), + }; + tree.insert(*input.note.pos(), item); + } + for input in &mut inputs { + input.merkle_opening = tree + .opening(*input.note.pos()) + .expect("the note is in the tree"); + } + + let A = TP.sender_pk.A() + offset_of(Shifted::SenderA); + let B = TP.sender_pk.B() + offset_of(Shifted::SenderB); + let mut signatures = TP.signatures; + if shifted == Shifted::SenderA { + signatures.0 = sign(TP.sender_sk.a(), &A); + } + if shifted == Shifted::SenderB { + signatures.1 = sign(TP.sender_sk.b(), &B); + } + + // The inputs hold 25 each, the fee and the deposit take 10. + let values = [25 * I as u64 - 15, 5]; + let output_notes_info = core::array::from_fn(|k| { + let note_pk = JubJubExtended::from(TP.output_npk[k]) + + offset_of(Shifted::OutputNotePk(k)); + let value_blinder = JubJubScalar::random(&mut rng); + let sender_blinder = TP.sender_blinder[k]; + + OutputNoteInfo { + value: values[k], + value_commitment: value_commitment(values[k], value_blinder), + value_blinder, + note_pk: note_pk.into(), + sender_enc: [ + encrypt(¬e_pk, &A, &sender_blinder[0]), + encrypt(¬e_pk, &B, &sender_blinder[1]), + ], + sender_blinder, + } + }); + + TxCircuit { + input_notes_info: inputs, + output_notes_info, + payload_hash: TP.payload_hash, + root: tree.root().hash, + deposit: TP.deposit, + max_fee: TP.max_fee, + sender_pk: PublicKey::new(A, B), + signatures, + } +} + +/// For each key in `shifted`, proves the `I`-input transaction with the +/// identity added to the key, as a control of the construction, then checks +/// that the same construction with `T` added is unsatisfiable. +fn assert_rejected(shifted: &[Shifted]) { + let mut rng = StdRng::seed_from_u64(0x7042); + + let (prover, verifier) = + Compiler::compile::>(&PP, LABEL) + .expect("failed to compile circuit"); + + for &shifted in shifted { + let control = transaction::(shifted, JubJubExtended::identity()); + let (proof, public_inputs) = + prover.prove(&mut rng, &control).unwrap_or_else(|err| { + panic!("{shifted:?} with a zero offset must prove: {err:?}") + }); + verifier + .verify(&proof, &public_inputs) + .unwrap_or_else(|err| { + panic!("{shifted:?} with a zero offset must verify: {err:?}") + }); + + let result = prover + .prove(&mut rng, &transaction::(shifted, order_two_point())); + assert!( + matches!(result, Err(PlonkError::CircuitUnsatisfied)), + "{shifted:?} with a small-order component must be unsatisfiable" + ); + } +} + +#[test] +fn note_pk_p_rejected_1_2() { + assert_rejected::<1>(&[Shifted::NotePkP]); +} + +#[test] +fn note_pk_p_rejected_2_2() { + assert_rejected::<2>(&[Shifted::NotePkP]); +} + +#[test] +fn note_pk_p_rejected_3_2() { + assert_rejected::<3>(&[Shifted::NotePkP]); +} + +#[test] +fn note_pk_p_rejected_4_2() { + assert_rejected::<4>(&[Shifted::NotePkP]); +} + +#[test] +fn note_pk_rejected() { + assert_rejected::<2>(&[Shifted::NotePk]); +} + +#[test] +fn sender_key_rejected() { + assert_rejected::<2>(&[Shifted::SenderA, Shifted::SenderB]); +} + +#[test] +fn output_note_pk_rejected() { + assert_rejected::<2>(&[Shifted::OutputNotePk(0), Shifted::OutputNotePk(1)]); +} From fa9a1a15cc811617837633f82c23843ec636ee85 Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Sat, 10 Oct 2026 04:05:21 +0200 Subject: [PATCH 3/4] circuits: Test against the extended CRS when available dusk-plonk 0.24 needs 2^17 + 9 powers to compile the 3- and 4-input circuits, three more than the production CRS has. Their transaction and verifier-data tests now compile with the CRS extended by those powers, read from the file `PHOENIX_EXTENDED_CRS` names and checked against its SHA-256, and skip with a notice on stderr when it is not given. The 1- and 2-input tests keep the production CRS. The extended CRS is the production CRS with three more powers of the same secret appended. Until it is hosted next to the production CRS, the README documents the variable. --- circuits/README.md | 8 ++++ circuits/tests/common/mod.rs | 72 ++++++++++++++++++++++++++++++--- circuits/tests/transaction.rs | 20 +++++++-- circuits/tests/verifier_data.rs | 34 +++++++++++++--- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/circuits/README.md b/circuits/README.md index 8e02993..f740a08 100644 --- a/circuits/README.md +++ b/circuits/README.md @@ -9,3 +9,11 @@ This library contains the implementation of the Phoenix-circuits, to prove, in z 3. Nullification: the nullifier is calculated correctly. 4. Minting: the value commitment for the newly minted notes are computed correctly. 5. Balance integrity: the sum of the values of all spent notes is equal to the sum of the values of all minted notes + the gas fee + a deposit, where a deposit refers to funds being transfered to a contract. + +## Tests + +The circuit tests compile the 1- and 2-input circuits with the production CRS, which they download to `~/.dusk/rusk/devnet-piecrust.crs` once. The 3- and 4-input circuits need the CRS extended to `2^17 + 9` powers, three more than the production CRS has. Until that file is hosted next to the production CRS, their tests skip with a notice unless `PHOENIX_EXTENDED_CRS` names a local copy, which is checked against its SHA-256: + +```sh +PHOENIX_EXTENDED_CRS=/path/to/devnet-piecrust-plus9.crs make test +``` diff --git a/circuits/tests/common/mod.rs b/circuits/tests/common/mod.rs index 4645a7c..6a46bc2 100644 --- a/circuits/tests/common/mod.rs +++ b/circuits/tests/common/mod.rs @@ -5,7 +5,7 @@ // Copyright (c) DUSK NETWORK. All rights reserved. use std::fs; -use std::io::Read; +use std::io::{Read, Write}; use std::path::PathBuf; use std::sync::LazyLock; @@ -45,15 +45,20 @@ fn verify_crs(data: &[u8]) -> bool { hash == CRS_HASH } +/// The production CRS, read from the cache or downloaded. +/// +/// It has `2^17 + 6` powers, which fit the 1- and 2-input circuits only: +/// `dusk-plonk` 0.24 needs `2^17 + 9` for the 3- and 4-input circuits, see +/// [`EXTENDED_PP`]. pub fn load_production_crs() -> PublicParameters { let cache_path = crs_cache_path(); // try loading from cache - if let Ok(data) = fs::read(&cache_path) { - if verify_crs(&data) { - return PublicParameters::from_slice(&data) - .expect("failed to deserialize cached CRS"); - } + if let Ok(data) = fs::read(&cache_path) + && verify_crs(&data) + { + return PublicParameters::from_slice(&data) + .expect("failed to deserialize cached CRS"); } // download from network @@ -79,6 +84,61 @@ pub fn load_production_crs() -> PublicParameters { .expect("failed to deserialize downloaded CRS") } +/// The variable naming the file of the extended CRS. +const EXTENDED_CRS_VAR: &str = "PHOENIX_EXTENDED_CRS"; + +/// SHA-256 of the production CRS extended to `2^17 + 9` powers of the same +/// secret: the production file with three more powers appended. +const EXTENDED_CRS_HASH: &str = + "888fbab6c9d267c26199d9fd1a8c21f315590459ae180bc9e6a27aa433479d03"; + +/// The extended CRS, read from the file `PHOENIX_EXTENDED_CRS` names and +/// checked against its SHA-256, or `None` if the variable is unset. +/// +/// The 3- and 4-input circuits need it. Until it is hosted publicly next to +/// the production CRS, their tests skip when it is not given. +pub static EXTENDED_PP: LazyLock> = + LazyLock::new(|| { + let path = std::env::var_os(EXTENDED_CRS_VAR)?; + let data = fs::read(&path).unwrap_or_else(|e| { + panic!("failed to read the extended CRS at {path:?}: {e}") + }); + let hash = format!("{:x}", Sha256::digest(&data)); + assert_eq!(hash, EXTENDED_CRS_HASH, "extended CRS hash mismatch"); + + Some( + PublicParameters::from_slice(&data) + .expect("failed to deserialize the extended CRS"), + ) + }); + +/// The extended CRS, or `None` after telling that `test` skips without it. +/// +/// The notice goes to the process' stderr, so it shows without +/// `--nocapture`. +pub fn extended_crs(test: &str) -> Option<&'static PublicParameters> { + let pp = EXTENDED_PP.as_ref(); + if pp.is_none() { + let _ = writeln!( + std::io::stderr(), + "SKIPPED {test}: needs the CRS extended to 2^17 + 9 powers; set \ + {EXTENDED_CRS_VAR} to its path" + ); + } + pp +} + +/// The CRS to compile an `I`-input circuit with: the production CRS for one +/// and two inputs, the extended CRS for more, see [`extended_crs`]. +// Each test binary uses a different subset of the helpers. +#[allow(dead_code)] +pub fn crs(test: &str) -> Option<&'static PublicParameters> { + match I { + 0..=2 => Some(&TP.pp), + _ => extended_crs(test), + } +} + pub static LABEL: &[u8; 12] = b"dusk-network"; pub const HEIGHT: usize = 17; diff --git a/circuits/tests/transaction.rs b/circuits/tests/transaction.rs index 0d6ba05..94de3d1 100644 --- a/circuits/tests/transaction.rs +++ b/circuits/tests/transaction.rs @@ -16,10 +16,13 @@ use common::*; #[test] fn test_transfer_circuit_1_2() { + let Some(pp) = crs::<1>("test_transfer_circuit_1_2") else { + return; + }; let mut rng = StdRng::seed_from_u64(0xc0b); let (prover, verifier) = - Compiler::compile::>(&TP.pp, LABEL) + Compiler::compile::>(pp, LABEL) .expect("failed to compile circuit"); let input_notes_info = [TP.input_notes_info[0].clone()]; @@ -63,10 +66,13 @@ fn test_transfer_circuit_1_2() { #[test] fn test_transfer_circuit_2_2() { + let Some(pp) = crs::<2>("test_transfer_circuit_2_2") else { + return; + }; let mut rng = StdRng::seed_from_u64(0xc0b); let (prover, verifier) = - Compiler::compile::>(&TP.pp, LABEL) + Compiler::compile::>(pp, LABEL) .expect("failed to compile circuit"); let input_notes_info = [ @@ -113,10 +119,13 @@ fn test_transfer_circuit_2_2() { #[test] fn test_transfer_circuit_3_2() { + let Some(pp) = crs::<3>("test_transfer_circuit_3_2") else { + return; + }; let mut rng = StdRng::seed_from_u64(0xc0b); let (prover, verifier) = - Compiler::compile::>(&TP.pp, LABEL) + Compiler::compile::>(pp, LABEL) .expect("failed to compile circuit"); let input_notes_info = [ @@ -164,10 +173,13 @@ fn test_transfer_circuit_3_2() { #[test] fn test_transfer_circuit_4_2() { + let Some(pp) = crs::<4>("test_transfer_circuit_4_2") else { + return; + }; let mut rng = StdRng::seed_from_u64(0xc0b); let (prover, verifier) = - Compiler::compile::>(&TP.pp, LABEL) + Compiler::compile::>(pp, LABEL) .expect("failed to compile circuit"); let input_notes_info = [ diff --git a/circuits/tests/verifier_data.rs b/circuits/tests/verifier_data.rs index 4574919..10ddef4 100644 --- a/circuits/tests/verifier_data.rs +++ b/circuits/tests/verifier_data.rs @@ -36,13 +36,17 @@ const VD_EXEC_4_2: &[u8] = include_bytes!( ); fn prove_and_verify_against_stored_vd( + test: &str, input_notes_info: [InputNoteInfo; I], output_values: [u64; 2], stored_vd: &[u8], ) { + let Some(pp) = crs::(test) else { + return; + }; let mut rng = StdRng::seed_from_u64(0xc0b); - let (prover, _) = Compiler::compile::>(&TP.pp, LABEL) + let (prover, _) = Compiler::compile::>(pp, LABEL) .expect("failed to compile circuit"); let output_notes_info = [ @@ -87,7 +91,12 @@ fn prove_and_verify_against_stored_vd( #[test] fn test_verifier_data_1_2() { let input_notes_info = [TP.input_notes_info[0].clone()]; - prove_and_verify_against_stored_vd(input_notes_info, [10, 5], VD_EXEC_1_2); + prove_and_verify_against_stored_vd( + "test_verifier_data_1_2", + input_notes_info, + [10, 5], + VD_EXEC_1_2, + ); } #[test] @@ -96,7 +105,12 @@ fn test_verifier_data_2_2() { TP.input_notes_info[0].clone(), TP.input_notes_info[1].clone(), ]; - prove_and_verify_against_stored_vd(input_notes_info, [35, 5], VD_EXEC_2_2); + prove_and_verify_against_stored_vd( + "test_verifier_data_2_2", + input_notes_info, + [35, 5], + VD_EXEC_2_2, + ); } #[test] @@ -106,7 +120,12 @@ fn test_verifier_data_3_2() { TP.input_notes_info[1].clone(), TP.input_notes_info[2].clone(), ]; - prove_and_verify_against_stored_vd(input_notes_info, [35, 30], VD_EXEC_3_2); + prove_and_verify_against_stored_vd( + "test_verifier_data_3_2", + input_notes_info, + [35, 30], + VD_EXEC_3_2, + ); } #[test] @@ -117,5 +136,10 @@ fn test_verifier_data_4_2() { TP.input_notes_info[2].clone(), TP.input_notes_info[3].clone(), ]; - prove_and_verify_against_stored_vd(input_notes_info, [60, 30], VD_EXEC_4_2); + prove_and_verify_against_stored_vd( + "test_verifier_data_4_2", + input_notes_info, + [60, 30], + VD_EXEC_4_2, + ); } From a1030fd6bab069ee3311ca3efe25be14c47054ce Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Sat, 10 Oct 2026 04:13:43 +0200 Subject: [PATCH 4/4] circuits: Check proofs against the verifier data of the current circuits The verifier-data tests checked new proofs against the verifier data deployed in rusk's transfer contract. The circuits have changed since phoenix-circuits 0.8, and their keys with them, so those checks failed. Add the verifier data of the current 1- to 4-input circuits as test assets, named as rusk-recovery names verifier data, and check that compiling the circuits reproduces it and that a proof verifies against it. The checks against the deployed verifier data now expect the proof to fail, which documents the key change. The extended CRS appends powers of the same secret to the production CRS, so the two compile the 1- and 2-input circuits to the same verifier data. A test checks this when the extended CRS is given. --- ...d915f4e3c4fd04572da1b00cda57a09050080c3.vd | Bin 0 -> 1508 bytes ...d01ac2939422d9496d27f03dbc875b5458a36a5.vd | Bin 0 -> 1516 bytes ...e8a112e78caa3f23060193f21f533ad0557b8e2.vd | Bin 0 -> 1524 bytes ...681e5651b0d5a054b9cfc656d91b83d54343f71.vd | Bin 0 -> 1500 bytes circuits/tests/verifier_data.rs | 154 ++++++++++++------ 5 files changed, 105 insertions(+), 49 deletions(-) create mode 100644 circuits/tests/assets/456f644ee101e40eea33af55ad915f4e3c4fd04572da1b00cda57a09050080c3.vd create mode 100644 circuits/tests/assets/63cacc7adb37b19b6d10066e2d01ac2939422d9496d27f03dbc875b5458a36a5.vd create mode 100644 circuits/tests/assets/929b3a0024cd60e3d56deb039e8a112e78caa3f23060193f21f533ad0557b8e2.vd create mode 100644 circuits/tests/assets/bf184dc7e0569daef4e74bc6b681e5651b0d5a054b9cfc656d91b83d54343f71.vd diff --git a/circuits/tests/assets/456f644ee101e40eea33af55ad915f4e3c4fd04572da1b00cda57a09050080c3.vd b/circuits/tests/assets/456f644ee101e40eea33af55ad915f4e3c4fd04572da1b00cda57a09050080c3.vd new file mode 100644 index 0000000000000000000000000000000000000000..e8d86b0949c0c66c3d92d1d08ac1d4e9f25556a0 GIT binary patch literal 1508 zcmdtfeJ~q#7zc1A=>(}(>ivk9iM7;pbV~H3J>9}~NrQwr$)dt2Y80JpIwV>umUvCA z?bTb_a9JyYkvE`fA9I{bI5*flNjGuq2p#_*RD50E;2Wx| z`z=e|)!W^XUUQ2zUDNx%cvqYSeSrC_ArE6O!P^eC25D~#LRy8P~oaJ!JBPwir_gvjtV>!!zwFJ7!LM~xcp^8bAa&N zvao~2?b^_yt;2|SDm}09N=De&jM3MY%wsRO$_-UbG&4j2#~^s4rV*b>q=TG=g3+>< zJAC#y<;lSij&%Vu=k6O<)M%nZmHu8~lM&{Df7eJhY@s|F`@SAE*dAARl-}`Q-5+VS z-=>>q|HXOHE`|0^O~nm(Ima02A1{&yB*|y9y%)(G&Tb=;VuKmqE4-imXhZUO82QNc z_?r5`sI)?a>g!b1DZ^Z~E=ioK_`;nyG?6h=bm=RIW9DB7^KDnJ|81Pb#l9PU-3~DpEyF8QRIMM7{ z&PK1wPAFF%MR@8YZy?^Wh7BBcR3%cSO&xIWP-Um>w!687r}>zu-l{33JI(JtHZUW6 zNvWS9K?WywhmDJ~eZmzKpW=_K{eN@DU6(&N$(<}ll|-=c0-<47Gz-s7mO(3UUOuLl z!jL%PE1w;@g?i6xO;2gp+fp>=L=8y1M@<*X%X1v@k>ACRjFj0(EE=c7TV{T};OF-3 zx)Gw79K0k4b(78CSo^o7x@`a&HPAKIcG z&mi@8+O|pA3XC6e`I14e;rSwXj-c@(BHUvui%MfHSGSW-GVhtq!VaN)e}X%`6}#9l z?NSYEUfMGl;=Bo@gHb3P=5|m`JVMkl`mY`JBLd_)RE2Qg?7t4PQhEO;CQ z2n8P0NGQ=*^(oLnBX0mWsF9xpyrQvZ0yqg=1TJY*;DC7G1t0+!01N~M0f`z{M8F21 O7}yAu0GohPpzL2WD)uS> literal 0 HcmV?d00001 diff --git a/circuits/tests/assets/63cacc7adb37b19b6d10066e2d01ac2939422d9496d27f03dbc875b5458a36a5.vd b/circuits/tests/assets/63cacc7adb37b19b6d10066e2d01ac2939422d9496d27f03dbc875b5458a36a5.vd new file mode 100644 index 0000000000000000000000000000000000000000..fbeba719e82fb18d3a2d4f5d81561b4f134abdd0 GIT binary patch literal 1516 zcmZQz00SNf#e4!nGkk#3@?e??%44+gOernS*3C;TDbFv;_OxLHOKcB_ES8Bl9G0Bn zc&o|ik-1RDsxq%WSMFPJJ|FfU_bWboFO=i=y;tvZciKtstNwepJ4P;h*D3$&A`AbQ zOH2H+%NOqR{P;`$U~Q6@K%~;elB20x{}_7{ zs{I`2zmxN}+I?MJ(bu~_(fW$io!FI=@)*knT=}jvF1UTOvL{6AAamoC>Z3eNS4#M< zhcfMX5#h9$=V#{G_uydeunuv#6&pL^*$XZa0fx%){y5Cr`G%dg+PBzQ@d6hdsMW%ir~R9)JcnvD8Y>O}n^VOqR~Lo6a6F z)nmhfjvo`gE?vCWY-jZTZLQlDm-aT9W*oezbaBQft5wH%^maV5(7q|)z^n3qZ?K+D z_W6Z-)ug+bzQ$@hbNBB*R#=yCcCfzkbP> z{wz7<<-P8ATb6yv{#DH-tQK#zA$Wc;&(k$Z(VrK5nWeey%;lAGx4XW(o32y#T*h$4 z@>y^A>$(pcO=N?9oo$O-FC!S2r`TSar5+mNTs0%2dYat&&gU;Ax1Bayx#!W(d7AGw zy*?JXVZo&5JMLZXzniJE@ZsANhbG&tF#nb}@#DMVtZlyWzkl@V*gdxA{4rT0?%S8x zH(eVq?-U4HJ;B>+KTq7%PqE60^8dYxTOUc!y<@q5`$>0`g;Q18d=}@{vh904a|`D} zvFzF>@AIbbaxXpBp`9)ICnn+k-iMrB8!PUgx0p+8xDOX~(?4EbZaDq=6t$_zJ^m}# z@E^|T@tDcbc540143g;)(uDQIRL)||$ zGW)`XnF`yaw%$tIeWyOa)7a;uknoh)@TY5PSz}qW>zKS2ZTis9HBpU&RbBU}K;W?_ zRabsFSTk)2(()U^Vyr`&Q<7iHv)Fn-TLN8W<&|fw~=`9fY zdlZz8hS0wtg&@N(NGZuEo(dLc6iq7tl literal 0 HcmV?d00001 diff --git a/circuits/tests/assets/929b3a0024cd60e3d56deb039e8a112e78caa3f23060193f21f533ad0557b8e2.vd b/circuits/tests/assets/929b3a0024cd60e3d56deb039e8a112e78caa3f23060193f21f533ad0557b8e2.vd new file mode 100644 index 0000000000000000000000000000000000000000..fbecf4abd099144e07c1ecbd2cdf8fc191768352 GIT binary patch literal 1524 zcmZQz00SNf#e4!nGkk#33SgQE%46)kky2Wmt(%uxQl4LweWRBVEYbCcZ&KuQqnhpM zyRJ@{B{9e0z*)oHJK{8Nw=>F{sl8GUl&mzgP1^d#uioIk)>O#Q)}L5F28l{q(}x^gjQWD)!@HOw`?;biO*q^CCaKs=hGbHjK>p@p|cPXWnEE2epH>`Rf|G zTW0@jFTeeLM&vyO3{|K{?3cerEB zXZp2yQ;)qylgPyNJ?CRLZS-JEuN(!_Nv7nXS+fCe|Q)D*GmgDYR0d4Dc3&izN> z!kjmggBF~h;?%vWGEU9BDj`H=_E|}p4}r5U70o;>^G$wP|FvIM&z7EVvs3c!eHy-6 z?U>Vo)Gd=zQm==pAM;znne_U*-@%rDm$V-@h4RSpsByKd{Lwja?jDV$n&k)fEi|^d z_PcD+j0&+>rT;9(){dKcHI!z(pK+>~NFbvt9ZBtMom?{W9?jQ01fQ|CQ4O z_rHAKe*0R}vBnE0S;dYnp8GA3u8H zeyN`EY`3}T?v=_HmCJj7T+zOkwfW>bk-jiXvpizMeYmKb{_*m1!|B(js7+1o@n5-y z|8Pc+|MZfbtPB0D?8T+mMC63Nu>V;l_5Qcj2d>*n$97MCpq&s@I6t9w&E*9h>i(&b z*%vO%RM;l9^;Y8UJM{sc#y%f~gr~%YKV4JH8q1bge-0*^hZ zy7J4xnrTZQcS_aci{;#FnAKLW?tXuzA?(CK#l1WFZ#a0$Kg;4d#_u|rw`1kjB;iE! z?@b9QJzLg`CT`ZAQh0;R7)>1$B>Isl8P>~{bF literal 0 HcmV?d00001 diff --git a/circuits/tests/assets/bf184dc7e0569daef4e74bc6b681e5651b0d5a054b9cfc656d91b83d54343f71.vd b/circuits/tests/assets/bf184dc7e0569daef4e74bc6b681e5651b0d5a054b9cfc656d91b83d54343f71.vd new file mode 100644 index 0000000000000000000000000000000000000000..5e18c7a1b68caed2daea4128ce8284a7b6961136 GIT binary patch literal 1500 zcmZQz00SNf#e4!nGkk#3vS6AK%43+Rol;tyt(%uxQl4LwtvwSWzGZ=JtJv+3+wPjr ze=hrEZV(V2(GYS{I`7IRHWhKf4K7CyNzUbEVs(nkBL^u`gowti<C^L9ON_TG-c{TF zl4JIcALY?0XRaw`IJHO~-f~^^Q)1^QnQzY-_uhK5dAir+XjXau1^YL@-sk^LB=k!k zSH>KlGft%&5C3!Aeq^E_lYc_#{J9cYhqN-cux~#A3vMFm#ol7Pb50~K_!xGAm2i*r6q42s#Lhw;=UDKaW9_CZB+tx*R*X(8~^F67r z`DKN9f9J2y6Zdmn&4)3O)YCIi*_B8*P=}y`ne{maj>fE9u){Y_N3~{ zF9&O;ErHxARgW*0bFX1mTfw^f{gsBW69*Od?&!ba;3@wsi{}`>>tx=Jm0Od96V1Oj zC8YE`Te3gauw}l)Jx)F~mkM6(Z(H;fT9nH8S3g&7Zr;*;O(pmtvNZ( +const VD_1_2: &[u8] = include_bytes!( + "assets/bf184dc7e0569daef4e74bc6b681e5651b0d5a054b9cfc656d91b83d54343f71.vd" +); +const VD_2_2: &[u8] = include_bytes!( + "assets/456f644ee101e40eea33af55ad915f4e3c4fd04572da1b00cda57a09050080c3.vd" +); +const VD_3_2: &[u8] = include_bytes!( + "assets/63cacc7adb37b19b6d10066e2d01ac2939422d9496d27f03dbc875b5458a36a5.vd" +); +const VD_4_2: &[u8] = include_bytes!( + "assets/929b3a0024cd60e3d56deb039e8a112e78caa3f23060193f21f533ad0557b8e2.vd" +); + +/// Compiles the `I`-input circuit to `vd`, and proves a transaction whose +/// proof verifies against `vd` and not against `deployed_vd`. +fn assert_verifier_data( test: &str, - input_notes_info: [InputNoteInfo; I], output_values: [u64; 2], - stored_vd: &[u8], + vd: &[u8], + deployed_vd: &[u8], ) { let Some(pp) = crs::(test) else { return; }; let mut rng = StdRng::seed_from_u64(0xc0b); - let (prover, _) = Compiler::compile::>(pp, LABEL) - .expect("failed to compile circuit"); + let (prover, verifier) = + Compiler::compile::>(pp, LABEL) + .expect("failed to compile circuit"); + assert!( + verifier.to_bytes() == vd, + "the circuit compiles to other verifier data than the stored one" + ); let output_notes_info = [ create_output_note_information( @@ -67,8 +98,10 @@ fn prove_and_verify_against_stored_vd( let (proof, public_inputs) = prover .prove( &mut rng, - &TxCircuit { - input_notes_info, + &TxCircuit:: { + input_notes_info: core::array::from_fn(|i| { + TP.input_notes_info[i].clone() + }), output_notes_info, payload_hash: TP.payload_hash, root: TP.root, @@ -80,66 +113,89 @@ fn prove_and_verify_against_stored_vd( ) .expect("failed to prove"); - let stored_verifier = Verifier::try_from_bytes(stored_vd) - .expect("failed to deserialize stored verifier data"); - - stored_verifier + Verifier::try_from_bytes(vd) + .expect("failed to deserialize stored verifier data") .verify(&proof, &public_inputs) .expect("proof failed to verify against stored verifier data"); + + let deployed = Verifier::try_from_bytes(deployed_vd) + .expect("failed to deserialize deployed verifier data"); + assert!( + deployed.verify(&proof, &public_inputs).is_err(), + "a proof of the current circuit verifies against the deployed \ + verifier data" + ); +} + +/// Compiles the `I`-input circuit with the production and the extended CRS, +/// to the same verifier data. +fn assert_crs_extension_keeps_vd(test: &str) { + let Some(extended) = extended_crs(test) else { + return; + }; + + let (_, production) = + Compiler::compile::>(&TP.pp, LABEL) + .expect("failed to compile circuit with the production CRS"); + let (_, extended) = + Compiler::compile::>(extended, LABEL) + .expect("failed to compile circuit with the extended CRS"); + assert!( + production.to_bytes() == extended.to_bytes(), + "the production and extended CRS compile to other verifier data" + ); } #[test] fn test_verifier_data_1_2() { - let input_notes_info = [TP.input_notes_info[0].clone()]; - prove_and_verify_against_stored_vd( + assert_verifier_data::<1>( "test_verifier_data_1_2", - input_notes_info, [10, 5], - VD_EXEC_1_2, + VD_1_2, + DEPLOYED_VD_1_2, ); } #[test] fn test_verifier_data_2_2() { - let input_notes_info = [ - TP.input_notes_info[0].clone(), - TP.input_notes_info[1].clone(), - ]; - prove_and_verify_against_stored_vd( + assert_verifier_data::<2>( "test_verifier_data_2_2", - input_notes_info, [35, 5], - VD_EXEC_2_2, + VD_2_2, + DEPLOYED_VD_2_2, ); } #[test] fn test_verifier_data_3_2() { - let input_notes_info = [ - TP.input_notes_info[0].clone(), - TP.input_notes_info[1].clone(), - TP.input_notes_info[2].clone(), - ]; - prove_and_verify_against_stored_vd( + assert_verifier_data::<3>( "test_verifier_data_3_2", - input_notes_info, [35, 30], - VD_EXEC_3_2, + VD_3_2, + DEPLOYED_VD_3_2, ); } #[test] fn test_verifier_data_4_2() { - let input_notes_info = [ - TP.input_notes_info[0].clone(), - TP.input_notes_info[1].clone(), - TP.input_notes_info[2].clone(), - TP.input_notes_info[3].clone(), - ]; - prove_and_verify_against_stored_vd( + assert_verifier_data::<4>( "test_verifier_data_4_2", - input_notes_info, [60, 30], - VD_EXEC_4_2, + VD_4_2, + DEPLOYED_VD_4_2, + ); +} + +#[test] +fn test_crs_extension_keeps_verifier_data_1_2() { + assert_crs_extension_keeps_vd::<1>( + "test_crs_extension_keeps_verifier_data_1_2", + ); +} + +#[test] +fn test_crs_extension_keeps_verifier_data_2_2() { + assert_crs_extension_keeps_vd::<2>( + "test_crs_extension_keeps_verifier_data_2_2", ); }