Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions circuits/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ 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
`jubjub-elgamal` to v0.6.0 [#311]
Comment on lines +15 to +18

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: One entry holds six dependency updates, the note_pk_p constraint shows twice, and the identity rejection of input note keys has no entry.

Context: The repo rule is "One fact per entry". The released sections list one dependency per line, and jubjub-schnorr already has its own line here. core/CHANGELOG.md:21-22 packs four updates the same way. The Changed entry at lines 12-14 and the Fixed entry at line 23 both record the subgroup check on note_pk_p. verify_signature_double of jubjub-schnorr 0.7.0 rejects an identity note_pk or note_pk_p, and 0.7.0-rc.0 accepted both. A note whose note_pk is the identity was spendable with the secret key zero, and now it is unspendable. The changelog records the identity rejection for the sender key only. The new link definitions [#313], [#314], [#311] break the descending order of the block.

Fix: Write one entry per dependency in both changelogs. Remove note_pk_p from the Changed entry and keep the Fixed entry. Add an entry that the circuit rejects an identity input note_pk or note_pk_p. Sort the new link definitions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #327: one entry per dependency in both changelogs, note_pk_p dropped from the Changed entry (the Fixed entry stays), an entry added for the circuit rejecting an identity input note_pk/note_pk_p, and the new link definitions sorted.

- Raise the MSRV to Rust 1.96.1 [#302]

### 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

### Changed
Expand Down Expand Up @@ -150,6 +162,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Update `poseidon-merkle` to v0.6 [#179]

<!-- ISSUES -->
[#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
[#280]: https://github.com/dusk-network/phoenix/issues/280
[#255]: https://github.com/dusk-network/phoenix/issues/255
Expand Down
14 changes: 7 additions & 7 deletions circuits/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
8 changes: 8 additions & 0 deletions circuits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CAUTION: With dusk-plonk 0.24, rusk cannot compile the 3- and 4-input transfer circuits with its production CRS, and only test docs record this.

Context: Since dusk-plonk #975, the prover key needs capacity through degree n + 9 instead of n + 6. rusk-recovery caches the descriptions of TxCircuit<NOTES_TREE_DEPTH, 3> and TxCircuit<NOTES_TREE_DEPTH, 4> in rusk-recovery/src/keys/circuits.rs. check_circuits_cache in rusk-recovery/src/keys.rs compiles them with PUB_PARAMS, the CRS that rusk-profile pins as CRS_17_HASH. That is the file that CRS_HASH pins here.

The doc of EXTENDED_CRS_HASH says that the extended file is the production file with three more powers appended, and the module doc of verifier_data.rs says that they are powers of the same secret. The ceremony output holds 1 << 21 powers, and commit 384608ae of dusk-network/trusted-setup raises its conversion limit to (1 << 17) + 9. That conversion is the likely source of the file. The change does not name it, and nothing shows how to reproduce EXTENDED_CRS_HASH. #311, which the dependency entries of both changelogs link, names the extended CRS for tests only.

Severity-rationale: The CRS requirement is new with this change and blocks the rusk upgrade. The source of the extended file needs a maintainer answer before the file goes to production.

Fix: Open a tracking issue for the extended CRS, and link it from the README and from #311. The issue states that rusk needs the file in production to compile the 3- and 4-input TxCircuit. It names the source of the three powers, for example the conversion at trusted-setup 384608ae, so that anyone can reproduce EXTENDED_CRS_HASH. It also gives the pairing check e([τ^(i+1)]₁, [1]₂) = e([τ^i]₁, [τ]₂) that ties each new power to the production ones. It also says where rusk-recovery gets the file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extended CRS is now tracked in #329, with its source and production plan written down: it is the production CRS plus three powers (144 bytes), from the same ceremony output (trusted-setup contribution 15) converted at (1<<17)+9 — the limit raised in trusted-setup 384608ae — so τ is unchanged and every existing key and proof stays valid. #329 gives the pairing check e([τ^(i+1)]₁, [1]₂) = e([τ^i]₁, [τ]₂) that ties each new power to the production ones, and notes that the node's recovery step must ship the file (old file kept for pre-upgrade nodes).

To avoid rebasing the approved stack below it, the README link to #329 and the three notes below are folded into the release PR #327.


```sh
PHOENIX_EXTENDED_CRS=/path/to/devnet-piecrust-plus9.crs make test
```
80 changes: 53 additions & 27 deletions circuits/src/circuit_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,7 +66,7 @@ impl<const H: usize, const I: usize> Circuit for TxCircuit<H, I> {
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(
Expand All @@ -87,7 +88,7 @@ impl<const H: usize, const I: usize> Circuit for TxCircuit<H, I> {
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()
Expand Down Expand Up @@ -133,13 +134,13 @@ impl<const H: usize, const I: usize> Circuit for TxCircuit<H, I> {
// 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 =
Expand All @@ -156,7 +157,7 @@ impl<const H: usize, const I: usize> Circuit for TxCircuit<H, I> {
// Verify: 4. Minting
composer.assert_equal_point(
expected_value_commitment,
computed_value_commitment,
computed_value_commitment.into(),
);
}

Expand Down Expand Up @@ -206,28 +207,38 @@ impl<const H: usize, const I: usize> Circuit for TxCircuit<H, I> {
}
}

/// 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<const H: usize> InputNoteInfo<H> {
fn append_to_circuit(
&self,
composer: &mut Composer,
) -> (
WitnessPoint,
WitnessPoint,
Witness,
Witness,
Witness,
Witness,
Witness,
Witness,
WitnessPoint,
WitnessPoint,
) {
) -> Result<InputNoteWitnesses, PlonkError> {
// 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));
Expand All @@ -237,10 +248,15 @@ impl<const H: usize> InputNoteInfo<H> {
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,
Expand All @@ -251,6 +267,16 @@ impl<const H: usize> InputNoteInfo<H> {
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<JubJubExtended>,
) -> Result<TorsionFreeWitnessPoint, PlonkError> {
let point = composer.append_point(point)?;
Ok(composer.assert_torsion_free_point(point))
}
40 changes: 27 additions & 13 deletions circuits/src/sender_enc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Comment thread
HDauven marked this conversation as resolved.

let blinder_A_0 = composer.append_witness(sender_blinder[0][0]);
let blinder_B_0 = composer.append_witness(sender_blinder[0][1]);
Expand Down Expand Up @@ -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> {
Expand All @@ -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(())
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading