diff --git a/noir-projects/aztec-nr/aztec/src/note/utils.nr b/noir-projects/aztec-nr/aztec/src/note/utils.nr index 7f28b06755aa..b40a19d18cb0 100644 --- a/noir-projects/aztec-nr/aztec/src/note/utils.nr +++ b/noir-projects/aztec-nr/aztec/src/note/utils.nr @@ -12,21 +12,20 @@ use dep::protocol_types::{ }; use dep::std::{embedded_curve_ops::multi_scalar_mul, hash::from_field_unsafe}; -pub fn compute_slotted_note_hiding_point_raw(storage_slot: Field, note_hiding_point: Point) -> Point { +pub fn compute_slotted_note_hash(note: Note) -> Field where Note: NoteInterface { + let storage_slot = note.get_header().storage_slot; + let note_hiding_point = note.compute_note_hiding_point(); + // 1. We derive the storage slot point by multiplying the storage slot with the generator G_slot. // We use the unsafe version because the multi_scalar_mul will constrain the scalars. let storage_slot_scalar = from_field_unsafe(storage_slot); let storage_slot_point = multi_scalar_mul([G_slot], [storage_slot_scalar]); // 2. Then we compute the slotted note hiding point by adding the storage slot point to the note hiding point. - storage_slot_point + note_hiding_point -} - -pub fn compute_slotted_note_hash(note: Note) -> Field where Note: NoteInterface { - let header = note.get_header(); - let note_hiding_point = note.compute_note_hiding_point(); + let slotted_note_hiding_point = storage_slot_point + note_hiding_point; - compute_slotted_note_hiding_point_raw(header.storage_slot, note_hiding_point).x + // 3. Finally, we return the slotted note hash which is the x-coordinate of the slotted note hiding point. + slotted_note_hiding_point.x } pub fn compute_siloed_nullifier( diff --git a/noir-projects/noir-contracts/contracts/private_fpc_contract/src/main.nr b/noir-projects/noir-contracts/contracts/private_fpc_contract/src/main.nr index 4c06546d7a98..14245104c449 100644 --- a/noir-projects/noir-contracts/contracts/private_fpc_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/private_fpc_contract/src/main.nr @@ -24,8 +24,8 @@ contract PrivateFPC { // convince the FPC we are not cheating context.push_nullifier(user_randomness); - // We use different randomness for fee payer to prevent a potential privay leak (see impl - // of PrivatelyRefundable for TokenNote for details). + // We use different randomness for fee payer to prevent a potential privacy leak (see description + // of `setup_refund(...)` function in TokenWithRefunds for details. let fee_payer_randomness = poseidon2_hash([user_randomness]); // We emit fee payer randomness to ensure FPC admin can reconstruct their fee note emit_randomness_as_unencrypted_log(&mut context, fee_payer_randomness); diff --git a/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/main.nr b/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/main.nr index 36e495ca6cc8..716767f2533b 100644 --- a/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/main.nr @@ -425,10 +425,26 @@ contract TokenWithRefunds { // REFUNDS SPECIFIC FUNCTIONALITY FOLLOWS use dep::aztec::{ - note::utils::compute_slotted_note_hiding_point_raw, prelude::FunctionSelector, + prelude::{FunctionSelector, NoteHeader}, protocol_types::{storage::map::derive_storage_slot_in_map, point::Point} }; - + use crate::types::token_note::TokenNoteHidingPoint; + + /// We need to use different randomness for the user and for the fee payer notes because if the randomness values + /// were the same we could fingerprint the user by doing the following: + /// 1) randomness_influence = fee_payer_point - G_npk * fee_payer_npk = + /// = (G_npk * fee_payer_npk + G_rnd * randomness) - G_npk * fee_payer_npk = + /// = G_rnd * randomness + /// 2) user_fingerprint = user_point - randomness_influence = + /// = (G_npk * user_npk + G_rnd * randomness) - G_rnd * randomness = + /// = G_npk * user_npk + /// 3) Then the second time the user would use this fee paying contract we would recover the same fingerprint + /// and link that the 2 transactions were made by the same user. Given that it's expected that only + /// a limited set of fee paying contracts will be used and they will be known, searching for fingerprints + /// by trying different fee payer npk values of these known contracts is a feasible attack. + /// + /// `fee_payer_point` and `user_point` above are public information because they are passed as args to the public + /// `complete_refund(...)` function. #[aztec(private)] fn setup_refund( fee_payer: AztecAddress, // Address of the entity which will receive the fee note. @@ -453,46 +469,80 @@ contract TokenWithRefunds { // to the user in the `complete_refund(...)` function. storage.balances.sub(user, U128::from_integer(funded_amount)).emit(encode_and_encrypt_note_with_keys(&mut context, user_ovpk, user_ivpk, user)); - // 4. We generate the refund points. - let (fee_payer_point, user_point) = TokenNote::generate_refund_points( - fee_payer_npk_m_hash, - user_npk_m_hash, - user_randomness, - fee_payer_randomness - ); - - // 5. Now we "manually" compute the slots and the slotted note hiding points + // 4. We create the partial notes for the fee payer and the user. + // --> Called "partial" because they don't have the amount set yet (that will be done in `complete_refund(...)`). + let fee_payer_partial_note = TokenNote { + header: NoteHeader::empty(), + amount: U128::zero(), + npk_m_hash: fee_payer_npk_m_hash, + randomness: fee_payer_randomness + }; + let user_partial_note = TokenNote { + header: NoteHeader::empty(), + amount: U128::zero(), + npk_m_hash: user_npk_m_hash, + randomness: user_randomness + }; + + // 5. Now we get the note hiding points. + let mut fee_payer_point = fee_payer_partial_note.to_note_hiding_point(); + let mut user_point = user_partial_note.to_note_hiding_point(); + + // 6. Now we "manually" compute the slot points and add them to hiding points. let fee_payer_balances_slot = derive_storage_slot_in_map(TokenWithRefunds::storage().balances.slot, fee_payer); let user_balances_slot = derive_storage_slot_in_map(TokenWithRefunds::storage().balances.slot, user); - let slotted_fee_payer_point = compute_slotted_note_hiding_point_raw(fee_payer_balances_slot, fee_payer_point); - let slotted_user_point = compute_slotted_note_hiding_point_raw(user_balances_slot, user_point); + // 7. We add the slot to the points --> this way we insert the notes into the balances Map under the respective key. + // TODO(#7753): Consider making slots part of the initital note hiding point creation. + fee_payer_point.add_slot(fee_payer_balances_slot); + user_point.add_slot(user_balances_slot); - // 6. Set the public teardown function to `complete_refund(...)`. Public teardown is the only time when a public + // 8. Set the public teardown function to `complete_refund(...)`. Public teardown is the only time when a public // function has access to the final transaction fee, which is needed to compute the actual refund amount. context.set_public_teardown_function( context.this_address(), - FunctionSelector::from_signature("complete_refund((Field,Field,bool),(Field,Field,bool),Field)"), + FunctionSelector::from_signature("complete_refund(((Field,Field,bool)),((Field,Field,bool)),Field)"), [ - slotted_fee_payer_point.x, slotted_fee_payer_point.y, slotted_fee_payer_point.is_infinite as Field, slotted_user_point.x, slotted_user_point.y, slotted_user_point.is_infinite as Field, funded_amount + fee_payer_point.inner.x, fee_payer_point.inner.y, fee_payer_point.inner.is_infinite as Field, user_point.inner.x, user_point.inner.y, user_point.inner.is_infinite as Field, funded_amount ] ); } + // TODO(#7728): even though the funded_amount should be a U128, we can't have that type in a contract interface due + // to serialization issues. #[aztec(public)] #[aztec(internal)] - fn complete_refund(fee_payer_point: Point, user_point: Point, funded_amount: Field) { - // 1. We get the final note hashes by calling a `complete_refund` function on the note. - // We use 1:1 exchange rate between fee juice and token so just passing transaction fee and funded amount - // to `complete_refund(...)` function is enough. - let (fee_payer_note_hash, user_note_hash) = TokenNote::complete_refund( - fee_payer_point, - user_point, - funded_amount, - context.transaction_fee() - ); + fn complete_refund( + // TODO: the following makes macros crash --> try getting it work once we migrate to metaprogramming + // mut fee_payer_point: TokenNoteHidingPoint, + // mut user_point: TokenNoteHidingPoint, + fee_payer_point_immutable: TokenNoteHidingPoint, + user_point_immutable: TokenNoteHidingPoint, + funded_amount: Field + ) { + // TODO: nuke the following 2 lines once we have mutable args + let mut fee_payer_point = fee_payer_point_immutable; + let mut user_point = user_point_immutable; + + // TODO(#7728): Remove the next line + let funded_amount = U128::from_integer(funded_amount); + let tx_fee = U128::from_integer(context.transaction_fee()); + + // 1. We check that user funded the fee payer contract with at least the transaction fee. + assert(funded_amount >= tx_fee, "funded amount not enough to cover tx fee"); + + // 2. We compute the refund amount as the difference between funded amount and tx fee. + let refund_amount = funded_amount - tx_fee; + + // 3. We add fee to the fee payer point and refund amount to the user point. + fee_payer_point.add_amount(tx_fee); + user_point.add_amount(refund_amount); + + // 4. We finalize the hiding points to get the note hashes. + let fee_payer_note_hash = fee_payer_point.finalize(); + let user_note_hash = user_point.finalize(); - // 2. At last we emit the note hashes. + // 5. At last we emit the note hashes. context.push_note_hash(fee_payer_note_hash); context.push_note_hash(user_note_hash); // --> Once the tx is settled user and fee recipient can add the notes to their pixies. diff --git a/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/test/basic.nr b/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/test/basic.nr index 395d92f503aa..e8f56417859d 100644 --- a/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/test/basic.nr +++ b/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/test/basic.nr @@ -70,7 +70,7 @@ unconstrained fn setup_refund_success() { // TODO(#7694): Ideally we would check the error message here but it's currently not supported by TXE. Once this // is supported, check the message here and delete try deleting the corresponding e2e test. -// #[test(should_fail_with = "tx fee is higher than funded amount")] +// #[test(should_fail_with = "funded amount not enough to cover tx fee")] #[test(should_fail)] unconstrained fn setup_refund_insufficient_funded_amount() { let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(true); @@ -97,6 +97,6 @@ unconstrained fn setup_refund_insufficient_funded_amount() { env.impersonate(fee_payer); - // The following should fail with "tx fee is higher than funded amount" because funded amount is 0 + // The following should fail with "funded amount not enough to cover tx fee" because funded amount is 0 env.call_private_void(setup_refund_from_call_interface); } diff --git a/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/types/token_note.nr b/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/types/token_note.nr index a15f3653a765..c9daa1239143 100644 --- a/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/types/token_note.nr +++ b/noir-projects/noir-contracts/contracts/token_with_refunds_contract/src/types/token_note.nr @@ -1,36 +1,18 @@ use dep::aztec::{ - generators::{Ga1 as G_amt, Ga2 as G_npk, Ga3 as G_rnd}, + generators::{Ga1 as G_amt, Ga2 as G_npk, Ga3 as G_rnd, G_slot}, prelude::{NoteHeader, NoteInterface, PrivateContext}, protocol_types::{ - constants::GENERATOR_INDEX__NOTE_NULLIFIER, point::Point, scalar::Scalar, - hash::poseidon2_hash_with_separator + constants::GENERATOR_INDEX__NOTE_NULLIFIER, point::{Point, POINT_LENGTH}, scalar::Scalar, + hash::poseidon2_hash_with_separator, traits::Serialize }, note::utils::compute_note_hash_for_consumption, oracle::unsafe_rand::unsafe_rand, - keys::getters::get_nsk_app, note::note_getter_options::PropertySelector + keys::getters::get_nsk_app }; use dep::std::{embedded_curve_ops::multi_scalar_mul, hash::from_field_unsafe}; trait OwnedNote { fn new(amount: U128, owner_npk_m_hash: Field) -> Self; fn get_amount(self) -> U128; - fn get_owner_npk_m_hash(self) -> Field; - fn get_owner_selector() -> PropertySelector; -} - -trait PrivatelyRefundable { - fn generate_refund_points( - fee_payer_npk_m_hash: Field, - user_npk_m_hash: Field, - user_randomness: Field, - fee_payer_randomness: Field - ) -> (Point, Point); - - fn complete_refund( - incomplete_fee_payer_point: Point, - incomplete_user_point: Point, - funded_amount: Field, - transaction_fee: Field - ) -> (Field, Field); } global TOKEN_NOTE_LEN: Field = 3; // 3 plus a header. @@ -67,21 +49,67 @@ impl NoteInterface for TokenNote { // We use the unsafe version because the multi_scalar_mul will constrain the scalars. let npk_m_hash_scalar = from_field_unsafe(self.npk_m_hash); let randomness_scalar = from_field_unsafe(self.randomness); + // TODO(benesjan): decompose amount with from_field_unsafe or constrain it fits into limb + let amount_scalar = Scalar { + lo: self.amount.to_integer(), + hi: 0 + }; // We compute the note hiding point as `G_amt * amount + G_npk * npk_m_hash + G_rnd * randomness` instead // of using pedersen or poseidon2 because it allows us to privately add and subtract from amount in public // by leveraging homomorphism. multi_scalar_mul( [G_amt, G_npk, G_rnd], - [Scalar { - lo: self.amount.to_integer(), - hi: 0 - }, - npk_m_hash_scalar, - randomness_scalar] + [amount_scalar, npk_m_hash_scalar, randomness_scalar] ) } } +impl TokenNote { + // TODO: Merge this func with `compute_note_hiding_point`. I (benesjan) didn't do it in the initial PR to not have + // to modify macros and all the related funcs in it. + fn to_note_hiding_point(self) -> TokenNoteHidingPoint { + TokenNoteHidingPoint::new(self.compute_note_hiding_point()) + } +} + +struct TokenNoteHidingPoint { + inner: Point +} + +impl TokenNoteHidingPoint { + fn new(point: Point) -> Self { + Self { inner: point } + } + + fn add_amount(&mut self, amount: U128) { + // TODO(benesjan): decompose amount with from_field_unsafe or constrain it fits into limb + let amount_scalar = Scalar { lo: amount.to_integer(), hi: 0 }; + self.inner = multi_scalar_mul([G_amt], [amount_scalar]) + self.inner; + } + + fn add_npk_m_hash(&mut self, npk_m_hash: Field) { + self.inner = multi_scalar_mul([G_npk], [from_field_unsafe(npk_m_hash)]) + self.inner; + } + + fn add_randomness(&mut self, randomness: Field) { + self.inner = multi_scalar_mul([G_rnd], [from_field_unsafe(randomness)]) + self.inner; + } + + fn add_slot(&mut self, slot: Field) { + self.inner = multi_scalar_mul([G_slot], [from_field_unsafe(slot)]) + self.inner; + } + + fn finalize(self) -> Field { + self.inner.x + } +} + +impl Serialize for TokenNoteHidingPoint { + fn serialize(self) -> [Field; POINT_LENGTH] { + self.inner.serialize() + } +} + impl Eq for TokenNote { fn eq(self, other: Self) -> bool { (self.amount == other.amount) & @@ -103,149 +131,4 @@ impl OwnedNote for TokenNote { fn get_amount(self) -> U128 { self.amount } - - fn get_owner_npk_m_hash(self) -> Field { - self.npk_m_hash - } - - fn get_owner_selector() -> PropertySelector { - PropertySelector { index: 1, offset: 0, length: 32 } - } -} - -/** - * What is happening below? - * - * First in generate_refund_points, we create two points on the grumpkin curve; - * these are going to be eventually turned into notes: - * one for the user, and one for the fee payer. - * - * So you can think of these (x, y) points as "partial notes": they encode part of the internals of the notes. - * - * This is because the compute_note_hiding_point function above defines the hiding point as: - * - * G_amt * amount + G_npk * npk_m_hash + G_rnd * randomness - * - * where G_amt, G_npk and G_rnd are generator points. Interesting point here is that we actually need to convert - * - amount - * - npk_m_hash - * - randomness - * from grumpkin Field elements - * (which have a modulus of 21888242871839275222246405745257275088548364400416034343698204186575808495617) - * into a grumpkin scalar - * (which have a modulus of 21888242871839275222246405745257275088696311157297823662689037894645226208583) - * - * The intuition for this is that the Field elements define the domain of the x, y coordinates for points on - * the curves, but the number of points on the curve is actually greater than the size of that domain. - * - * (Consider, e.g. if the curve were defined over a field of 10 elements, and each x coord had two corresponding - * y for +/-) - * - * For a bit more info, see - * https://hackmd.io/@aztec-network/ByzgNxBfd#2-Grumpkin---A-curve-on-top-of-BN-254-for-SNARK-efficient-group-operations - * - * - * Anyway, if we have a secret scalar s, and then we reveal a point s * G (G being a generator), there is no efficient - * way to deduce what s is. This is the discrete log problem. - * - * However we can still perform addition/subtraction on points! That is why we generate those two points, which are: - * incomplete_fee_payer_point := G_npk * fee_payer_npk + G_rnd * fee_payer_randomness - * incomplete_user_point := G_npk * user_npk + G_rnd * user_randomness - * - * So we pass those points into the teardown function (here) and compute a third point corresponding to the transaction - * fee as just: - * - * fee_point := G_amt * transaction_fee - * refund_point := G_amt * (funded_amount - transaction_fee) - * - * where `funded_amount` is the total amount in tokens that the sponsored user initially supplied and the transaction - * fee is the final transaction fee whose value is made available in the public teardown function. - * - * Then we arrive at the final points via addition of the fee and refund points: - * - * fee_payer_point := incomplete_fee_payer_point + fee_point = - * = (G_npk * fee_payer_npk + G_rnd * fee_payer_randomness) + G_amt * transaction_fee = - * = G_amt * transaction_fee + G_npk * fee_payer_npk + G_rnd * fee_payer_randomness - * - * user_point := incomplete_user_point + refund_point = - * = (G_npk * user_npk + G_rnd + user_randomness) + G_amt * (funded_amount - transaction_fee) = - * = G_amt * (funded_amount - transaction_fee) + G_npk * user_npk + G_rnd * user_randomness - * - * The point above matches the note_hiding_point of (and therefore *is*) notes like: - * { - * amount: (funded_amount - transaction_fee), - * npk_m_hash: user_npk, - * randomness: user_randomness - * } - * - * Why do we need different randomness for the user and the fee payer notes? - * --> This is because if the randomness values were the same we could fingerprint the user by doing the following: - * 1) randomness_influence = incomplete_fee_payer_point - G_npk * fee_payer_npk = - * = (G_npk * fee_payer_npk + G_rnd * randomness) - G_npk * fee_payer_npk = - * = G_rnd * randomness - * 2) user_fingerprint = incomplete_user_point - randomness_influence = - * = (G_npk * user_npk + G_rnd * randomness) - G_rnd * randomness = - * = G_npk * user_npk - * 3) Then the second time the user would use this fee paying contract we would recover the same fingerprint and - * link that the 2 transactions were made by the same user. Given that it's expected that only a limited set - * of fee paying contracts will be used and they will be known, searching for fingerprints by trying different - * fee payer npk values of these known contracts is a feasible attack. - */ -impl PrivatelyRefundable for TokenNote { - fn generate_refund_points(fee_payer_npk_m_hash: Field, user_npk_m_hash: Field, user_randomness: Field, fee_payer_randomness: Field) -> (Point, Point) { - // 1. To be able to multiply generators with randomness and npk_m_hash using barretneberg's (BB) blackbox - // function we first need to convert the fields to high and low limbs. - // We use the unsafe version because the multi_scalar_mul will constrain the scalars. - let fee_payer_randomness_scalar = from_field_unsafe(fee_payer_randomness); - let fee_payer_npk_m_hash_scalar = from_field_unsafe(fee_payer_npk_m_hash); - - // 2. Now that we have correct representationsn of fee payer and randomness we can compute - // `G_npk * fee_payer_npk + G_rnd * randomness`. - let incomplete_fee_payer_point = multi_scalar_mul( - [G_npk, G_rnd], - [fee_payer_npk_m_hash_scalar, fee_payer_randomness_scalar] - ); - - // 3. We do the necessary conversion for values relevant for the sponsored user point. - // We use the unsafe version because the multi_scalar_mul will constrain the scalars. - let user_npk_m_hash_scalar = from_field_unsafe(user_npk_m_hash); - let user_randomness_scalar = from_field_unsafe(user_randomness); - - // 4. We compute `G_npk * user_npk_m_hash + G_rnd * randomness`. - let incomplete_user_point = multi_scalar_mul( - [G_npk, G_rnd], - [user_npk_m_hash_scalar, user_randomness_scalar] - ); - - // 5. At last we return the points. - (incomplete_fee_payer_point, incomplete_user_point) - } - - fn complete_refund(incomplete_fee_payer_point: Point, incomplete_user_point: Point, funded_amount: Field, transaction_fee: Field) -> (Field, Field) { - // 1. We check that user funded the fee payer contract with at least the transaction fee. - assert(!funded_amount.lt(transaction_fee), "tx fee is higher than funded amount"); // funded_amout >= transaction_fee - - // 2. We convert the transaction fee and refund amount to high and low limbs to be able to use BB API. - // We use the unsafe version because the multi_scalar_mul will constrain the scalars. - let transaction_fee_scalar = from_field_unsafe(transaction_fee); - let refund_scalar = from_field_unsafe(funded_amount - transaction_fee); - - // 3. We compute the fee point as `G_amt * transaction_fee` - let fee_point = multi_scalar_mul([G_amt], [transaction_fee_scalar]); - - // 4. We compute the refund point as `G_amt * refund` - let refund_point = multi_scalar_mul([G_amt], [refund_scalar]); - - // 5. Now we leverage homomorphism to privately add the fee to fee payer point and we add refund to the user point. - let fee_payer_point = incomplete_fee_payer_point + fee_point; - let user_point = incomplete_user_point + refund_point; - - // 6. We no longer need to do any elliptic curve operations with the points so we collapse them to the final - // note hashes. - let fee_payer_note_hash = fee_payer_point.x; - let user_note_hash = user_point.x; - - // 7. Finally we return the hashes. - (fee_payer_note_hash, user_note_hash) - } } diff --git a/yarn-project/end-to-end/src/e2e_fees/private_refunds.test.ts b/yarn-project/end-to-end/src/e2e_fees/private_refunds.test.ts index bc667647710a..d449c727bcdb 100644 --- a/yarn-project/end-to-end/src/e2e_fees/private_refunds.test.ts +++ b/yarn-project/end-to-end/src/e2e_fees/private_refunds.test.ts @@ -152,7 +152,7 @@ describe('e2e_fees/private_refunds', () => { ), }, }), - ).rejects.toThrow('tx fee is higher than funded amount'); + ).rejects.toThrow('funded amount not enough to cover tx fee'); }); });