diff --git a/.vscode/settings.json b/.vscode/settings.json index 86a0427f790..58f7a91c1cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -168,5 +168,5 @@ "**/dest/**": true, "**/noir/noir-repo/docs/versioned_docs/**": true }, - "cmake.sourceDirectory": "${workspaceFolder}/barretenberg/cpp", + "cmake.sourceDirectory": "${workspaceFolder}/barretenberg/cpp" } diff --git a/noir-projects/aztec-nr/aztec/src/keys/public_keys.nr b/noir-projects/aztec-nr/aztec/src/keys/public_keys.nr index fe65ff9e37e..f723365df9e 100644 --- a/noir-projects/aztec-nr/aztec/src/keys/public_keys.nr +++ b/noir-projects/aztec-nr/aztec/src/keys/public_keys.nr @@ -1,6 +1,6 @@ use dep::protocol_types::{ address::PublicKeysHash, constants::GENERATOR_INDEX__PUBLIC_KEYS_HASH, hash::poseidon2_hash, - grumpkin_point::GrumpkinPoint, traits::{Deserialize, Serialize} + grumpkin_point::GrumpkinPoint, traits::{Deserialize, Serialize, Empty, is_empty} }; use crate::keys::constants::{NUM_KEY_TYPES, NULLIFIER_INDEX, INCOMING_INDEX, OUTGOING_INDEX}; @@ -13,22 +13,46 @@ struct PublicKeys { tpk_m: GrumpkinPoint, } +impl Empty for PublicKeys { + fn empty() -> Self { + PublicKeys { + npk_m : GrumpkinPoint::empty(), + ivpk_m : GrumpkinPoint::empty(), + ovpk_m : GrumpkinPoint::empty(), + tpk_m : GrumpkinPoint::empty() + } + } +} + +impl Eq for PublicKeys { + fn eq(self, other: PublicKeys) -> bool { + ( self.npk_m == other.npk_m ) & + ( self.ivpk_m == other.ivpk_m ) & + ( self.ovpk_m == other.ovpk_m ) & + ( self.tpk_m == other.tpk_m ) + } +} + impl PublicKeys { pub fn hash(self) -> PublicKeysHash { PublicKeysHash::from_field( + if is_empty(self) { + 0 + } else { poseidon2_hash( [ - self.npk_m.x, - self.npk_m.y, - self.ivpk_m.x, - self.ivpk_m.y, - self.ovpk_m.x, - self.ovpk_m.y, - self.tpk_m.x, - self.tpk_m.y, - GENERATOR_INDEX__PUBLIC_KEYS_HASH - ] + self.npk_m.x, + self.npk_m.y, + self.ivpk_m.x, + self.ivpk_m.y, + self.ovpk_m.x, + self.ovpk_m.y, + self.tpk_m.x, + self.tpk_m.y, + GENERATOR_INDEX__PUBLIC_KEYS_HASH + ] ) + } ) } diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index 4e0dae683c9..534ec69b457 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -30,6 +30,8 @@ members = [ "contracts/parent_contract", "contracts/pending_note_hashes_contract", "contracts/price_feed_contract", + "contracts/private_fpc_contract", + "contracts/private_token_contract", "contracts/schnorr_account_contract", "contracts/schnorr_hardcoded_account_contract", "contracts/schnorr_single_key_account_contract", diff --git a/noir-projects/noir-contracts/contracts/private_fpc_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/private_fpc_contract/Nargo.toml new file mode 100644 index 00000000000..94f9996c80b --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_fpc_contract/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "private_fpc_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../aztec-nr/aztec" } +authwit = { path = "../../../aztec-nr/authwit" } +private_token = { path = "../private_token_contract" } diff --git a/noir-projects/noir-contracts/contracts/private_fpc_contract/src/fee.nr b/noir-projects/noir-contracts/contracts/private_fpc_contract/src/fee.nr new file mode 100644 index 00000000000..17a4ae012e7 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_fpc_contract/src/fee.nr @@ -0,0 +1,5 @@ +use dep::aztec::context::interface::PublicContextInterface; + +pub fn calculate_fee(_context: TPublicContext) -> U128 where TPublicContext: PublicContextInterface { + U128::from_integer(1) +} 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 new file mode 100644 index 00000000000..53181f158f3 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_fpc_contract/src/main.nr @@ -0,0 +1,35 @@ +contract PrivateFPC { + use dep::aztec::protocol_types::{abis::function_selector::FunctionSelector, address::AztecAddress, traits::is_empty}; + use dep::aztec::state_vars::SharedImmutable; + use dep::private_token::PrivateToken; + use dep::aztec::context::gas::GasOpts; + + #[aztec(storage)] + struct Storage { + other_asset: SharedImmutable, + admin_npk_m_hash: SharedImmutable + } + + #[aztec(public)] + #[aztec(initializer)] + fn constructor(other_asset: AztecAddress, admin_npk_m_hash: Field) { + storage.other_asset.initialize(other_asset); + storage.admin_npk_m_hash.initialize(admin_npk_m_hash); + } + + #[aztec(private)] + fn fund_transaction_privately(amount: Field, asset: AztecAddress, nonce: Field) { + assert(asset == storage.other_asset.read_private()); + // convince the FPC we are not cheating + context.push_new_nullifier(nonce, 0); + // allow the FPC to reconstruct their fee note + context.emit_unencrypted_log(nonce); + PrivateToken::at(asset).setup_refund( + storage.admin_npk_m_hash.read_private(), + context.msg_sender(), + amount, + nonce + ).call(&mut context); + context.set_as_fee_payer(); + } +} diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/private_token_contract/Nargo.toml new file mode 100644 index 00000000000..8d410f64565 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "private_token_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../aztec-nr/aztec" } +compressed_string = { path = "../../../aztec-nr/compressed-string" } +authwit = { path = "../../../aztec-nr/authwit" } diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/main.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/main.nr new file mode 100644 index 00000000000..4ebe291150d --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/main.nr @@ -0,0 +1,235 @@ +mod types; +mod test; + +// Minimal token implementation that supports `AuthWit` accounts and private refunds + +contract PrivateToken { + use dep::compressed_string::FieldCompressedString; + use dep::aztec::{ + hash::compute_secret_hash, + prelude::{NoteGetterOptions, Map, PublicMutable, SharedImmutable, PrivateSet, AztecAddress}, + protocol_types::{ + abis::function_selector::FunctionSelector, hash::pedersen_hash, + constants::GENERATOR_INDEX__INNER_NOTE_HASH + }, + oracle::unsafe_rand::unsafe_rand, + encrypted_logs::encrypted_note_emission::{encode_and_encrypt_note, encode_and_encrypt_note_with_keys} + }; + use dep::authwit::{auth::{assert_current_call_valid_authwit, assert_current_call_valid_authwit_public}}; + use crate::types::{token_note::{TokenNote, TOKEN_NOTE_LEN}, balances_map::BalancesMap}; + use dep::std::embedded_curve_ops::EmbeddedCurvePoint; + use dep::std::ec::tecurve::affine::Point; + + #[aztec(storage)] + struct Storage { + admin: PublicMutable, + minters: Map>, + balances: BalancesMap, + total_supply: PublicMutable, + symbol: SharedImmutable, + name: SharedImmutable, + decimals: SharedImmutable, + } + + #[aztec(public)] + #[aztec(initializer)] + fn constructor(admin: AztecAddress, name: str<31>, symbol: str<31>, decimals: u8) { + assert(!admin.is_zero(), "invalid admin"); + storage.admin.write(admin); + storage.minters.at(admin).write(true); + storage.name.initialize(FieldCompressedString::from_string(name)); + storage.symbol.initialize(FieldCompressedString::from_string(symbol)); + storage.decimals.initialize(decimals); + } + + #[aztec(public)] + fn set_admin(new_admin: AztecAddress) { + assert(storage.admin.read().eq(context.msg_sender()), "caller is not admin"); + storage.admin.write(new_admin); + } + + #[aztec(public)] + fn public_get_name() -> pub FieldCompressedString { + storage.name.read_public() + } + + #[aztec(private)] + fn private_get_name() -> pub FieldCompressedString { + storage.name.read_private() + } + + unconstrained fn un_get_name() -> pub [u8; 31] { + storage.name.read_public().to_bytes() + } + + #[aztec(public)] + fn public_get_symbol() -> pub FieldCompressedString { + storage.symbol.read_public() + } + + #[aztec(private)] + fn private_get_symbol() -> pub FieldCompressedString { + storage.symbol.read_private() + } + + unconstrained fn un_get_symbol() -> pub [u8; 31] { + storage.symbol.read_public().to_bytes() + } + + #[aztec(public)] + fn public_get_decimals() -> pub u8 { + storage.decimals.read_public() + } + + #[aztec(private)] + fn private_get_decimals() -> pub u8 { + storage.decimals.read_private() + } + + unconstrained fn un_get_decimals() -> pub u8 { + storage.decimals.read_public() + } + + #[aztec(public)] + fn set_minter(minter: AztecAddress, approve: bool) { + assert(storage.admin.read().eq(context.msg_sender()), "caller is not admin"); + storage.minters.at(minter).write(approve); + } + + #[aztec(private)] + fn privately_mint_private_note(amount: Field) { + let caller = context.msg_sender(); + let header = context.get_header(); + let caller_npk_m_hash = header.get_npk_m_hash(&mut context, caller); + storage.balances.add(caller_npk_m_hash, U128::from_integer(amount)).emit(encode_and_encrypt_note(&mut context, caller, caller)); + PrivateToken::at(context.this_address()).assert_minter_and_mint(context.msg_sender(), amount).enqueue(&mut context); + } + + #[aztec(public)] + fn assert_minter_and_mint(minter: AztecAddress, amount: Field) { + assert(storage.minters.at(minter).read(), "caller is not minter"); + let supply = storage.total_supply.read() + U128::from_integer(amount); + storage.total_supply.write(supply); + } + + #[aztec(private)] + fn transfer_from(from: AztecAddress, to: AztecAddress, amount: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + // By fetching the keys here, we can avoid doing an extra read from the storage, since from_ovpk would + // be needed twice. + let header = context.get_header(); + let from_ovpk = header.get_ovpk_m(&mut context, from); + let from_ivpk = header.get_ivpk_m(&mut context, from); + let from_npk_m_hash = header.get_npk_m_hash(&mut context, from); + let to_ivpk = header.get_ivpk_m(&mut context, to); + let to_npk_m_hash = header.get_npk_m_hash(&mut context, to); + + let amount = U128::from_integer(amount); + // docs:start:increase_private_balance + // docs:start:encrypted + storage.balances.sub(from_npk_m_hash, amount).emit(encode_and_encrypt_note_with_keys(&mut context, from_ovpk, from_ivpk)); + // docs:end:encrypted + // docs:end:increase_private_balance + storage.balances.add(to_npk_m_hash, amount).emit(encode_and_encrypt_note_with_keys(&mut context, from_ovpk, to_ivpk)); + } + + #[aztec(private)] + fn transfer(to: AztecAddress, amount: Field) { + let from = context.msg_sender(); + let header = context.get_header(); + let from_ovpk = header.get_ovpk_m(&mut context, from); + let from_ivpk = header.get_ivpk_m(&mut context, from); + let from_npk_m_hash = header.get_npk_m_hash(&mut context, from); + let to_ivpk = header.get_ivpk_m(&mut context, to); + let to_npk_m_hash = header.get_npk_m_hash(&mut context, to); + + let amount = U128::from_integer(amount); + storage.balances.sub(from_npk_m_hash, amount).emit(encode_and_encrypt_note_with_keys(&mut context, from_ovpk, from_ivpk)); + storage.balances.add(to_npk_m_hash, amount).emit(encode_and_encrypt_note_with_keys(&mut context, from_ovpk, to_ivpk)); + } + + #[aztec(private)] + fn balance_of_private(owner: AztecAddress) -> pub Field { + let header = context.get_header(); + let owner_npk_m_hash = header.get_npk_m_hash(&mut context, owner); + storage.balances.to_unconstrained().balance_of(owner_npk_m_hash).to_integer() + } + + unconstrained fn balance_of_unconstrained(owner_npk_m_hash: Field) -> pub Field { + storage.balances.balance_of(owner_npk_m_hash).to_integer() + } + + #[aztec(private)] + fn setup_refund( + fee_payer_npk_m_hash: Field, + sponsored_user: AztecAddress, + funded_amount: Field, + refund_nonce: Field + ) { + assert_current_call_valid_authwit(&mut context, sponsored_user); + let header = context.get_header(); + let sponsored_user_npk_m_hash = header.get_npk_m_hash(&mut context, sponsored_user); + let sponsored_user_ovpk = header.get_ovpk_m(&mut context, sponsored_user); + let sponsored_user_ivpk = header.get_ivpk_m(&mut context, sponsored_user); + storage.balances.sub(sponsored_user_npk_m_hash, U128::from_integer(funded_amount)).emit(encode_and_encrypt_note_with_keys(&mut context, sponsored_user_ovpk, sponsored_user_ivpk)); + let points = TokenNote::generate_refund_points( + fee_payer_npk_m_hash, + sponsored_user_npk_m_hash, + funded_amount, + refund_nonce + ); + context.set_public_teardown_function( + context.this_address(), + FunctionSelector::from_signature("complete_refund(Field,Field,Field,Field)"), + [points[0].x, points[0].y, points[1].x, points[1].y] + ); + } + + #[aztec(public)] + #[aztec(internal)] + fn complete_refund( + fpc_point_x: Field, + fpc_point_y: Field, + user_point_x: Field, + user_point_y: Field + ) { + let fpc_point = EmbeddedCurvePoint { x: fpc_point_x, y: fpc_point_y, is_infinite: false }; + let user_point = EmbeddedCurvePoint { x: user_point_x, y: user_point_y, is_infinite: false }; + let tx_fee = context.transaction_fee(); + let note_hashes = TokenNote::complete_refund(fpc_point, user_point, tx_fee); + + // `compute_inner_note_hash` manually, without constructing the note + // `3` is the storage slot of the balances + context.push_new_note_hash(pedersen_hash([3, note_hashes[0]], GENERATOR_INDEX__INNER_NOTE_HASH)); + context.push_new_note_hash(pedersen_hash([3, note_hashes[1]], GENERATOR_INDEX__INNER_NOTE_HASH)); + } + + /// Internal /// + + #[aztec(public)] + #[aztec(internal)] + fn _reduce_total_supply(amount: Field) { + // Only to be called from burn. + let new_supply = storage.total_supply.read().sub(U128::from_integer(amount)); + storage.total_supply.write(new_supply); + } + + /// Unconstrained /// + + unconstrained fn admin() -> pub Field { + storage.admin.read().to_field() + } + + unconstrained fn is_minter(minter: AztecAddress) -> pub bool { + storage.minters.at(minter).read() + } + + unconstrained fn total_supply() -> pub Field { + storage.total_supply.read().to_integer() + } +} diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/test.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/test.nr new file mode 100644 index 00000000000..f606967d1ac --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/test.nr @@ -0,0 +1,2 @@ +mod basic; +mod utils; diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/test/basic.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/test/basic.nr new file mode 100644 index 00000000000..2d7c4e428c3 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/test/basic.nr @@ -0,0 +1,87 @@ +use crate::test::utils; +use dep::aztec::{ + test::helpers::cheatcodes, oracle::unsafe_rand::unsafe_rand, hash::compute_secret_hash, + prelude::NoteHeader +}; +use crate::PrivateToken; +use crate::types::token_note::TokenNote; +use dep::authwit::cheatcodes as authwit_cheatcodes; + +#[test] +unconstrained fn transfer_success() { + let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(true); + + let transfer_amount = 1_000; + + let transfer_private_from_call_interface = PrivateToken::at(token_contract_address).transfer_from(owner, recipient, transfer_amount, 1); + + authwit_cheatcodes::add_private_authwit_from_call_interface(owner, recipient, transfer_private_from_call_interface); + + cheatcodes::set_contract_address(recipient); + // Transfer tokens + env.call_private_void(transfer_private_from_call_interface); + // Check balances + utils::check_private_balance( + &mut env.private(), + token_contract_address, + owner, + mint_amount - transfer_amount + ); + utils::check_private_balance( + &mut env.private(), + token_contract_address, + recipient, + transfer_amount + ); +} + +#[test] +unconstrained fn setup_refund_success() { + let (env, token_contract_address, owner, recipient, mint_amount) = utils::setup_and_mint(true); + + let funded_amount = 1_000; + let refund_nonce = 42; + let mut context = env.private(); + let recipient_npk_m_hash = context.get_header().get_npk_m_hash(&mut context, recipient); + + let setup_refund_from_call_interface = PrivateToken::at(token_contract_address).setup_refund( + recipient_npk_m_hash, // fee payer + owner, // sponsored user + funded_amount, + refund_nonce + ); + + authwit_cheatcodes::add_private_authwit_from_call_interface(owner, recipient, setup_refund_from_call_interface); + + cheatcodes::set_contract_address(recipient); + + env.call_private_void(setup_refund_from_call_interface); + let mut context = env.private(); + let owner_npk_m_hash = context.get_header().get_npk_m_hash(&mut context, owner); + + // when the refund was set up, we would've broken the note worth mint_amount, and added back a note worth + // mint_amount - funded_amount + // then when completing the refund, we would've constructed a hash corresponding to a note worth + // funded_amount - transaction_fee + // we "know" the transaction fee was 1 (it is hardcoded in TXE oracle) + // but we need to notify TXE of the note (preimage) + env.store_note_in_cache( + &mut TokenNote { + amount: U128::from_integer(funded_amount - 1), + npk_m_hash: owner_npk_m_hash, + randomness: refund_nonce, + header: NoteHeader::empty() + }, + PrivateToken::storage().balances.slot, + token_contract_address + ); + + utils::check_private_balance( + &mut env.private(), + token_contract_address, + owner, + mint_amount - 1 + ); + // utils::check_private_balance(&mut env.private(), token_contract_address, recipient, 1) +} + diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/test/utils.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/test/utils.nr new file mode 100644 index 00000000000..ab6a658ecc9 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/test/utils.nr @@ -0,0 +1,70 @@ +use dep::aztec::{ + hash::compute_secret_hash, prelude::AztecAddress, + test::helpers::{cheatcodes, test_environment::TestEnvironment}, + protocol_types::storage::map::derive_storage_slot_in_map, + note::{note_getter::{MAX_NOTES_PER_PAGE, view_notes}, note_viewer_options::NoteViewerOptions}, + oracle::{unsafe_rand::unsafe_rand, storage::storage_read}, context::PrivateContext +}; + +use crate::{types::{token_note::TokenNote}, PrivateToken}; + +pub fn setup(with_account_contracts: bool) -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { + // Setup env, generate keys + let mut env = TestEnvironment::new(); + let (owner, recipient) = if with_account_contracts { + let owner = env.create_account_contract(1); + let recipient = env.create_account_contract(2); + // Deploy canonical auth registry + let _auth_registry = env.deploy("@aztec/noir-contracts.js/AuthRegistry").without_initializer(); + (owner, recipient) + } else { + let owner = env.create_account(); + let recipient = env.create_account(); + (owner, recipient) + }; + + // Start the test in the account contract address + cheatcodes::set_contract_address(owner); + + // Deploy token contract + let initializer_call_interface = PrivateToken::interface().constructor( + owner, + "TestToken0000000000000000000000", + "TT00000000000000000000000000000", + 18 + ); + let token_contract = env.deploy("@aztec/noir-contracts.js/PrivateToken").with_public_initializer(initializer_call_interface); + let token_contract_address = token_contract.to_address(); + env.advance_block_by(6); + (&mut env, token_contract_address, owner, recipient) +} + +pub fn setup_and_mint(with_account_contracts: bool) -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress, Field) { + // Setup + let (env, token_contract_address, owner, recipient) = setup(with_account_contracts); + let mint_amount = 10_000; + let mint_private_call_interface = PrivateToken::at(token_contract_address).privately_mint_private_note(mint_amount); + env.call_private_void(mint_private_call_interface); + env.advance_block_by(6); + + check_private_balance(&mut env.private(), token_contract_address, owner, mint_amount); + + (env, token_contract_address, owner, recipient, mint_amount) +} + +pub fn check_private_balance( + context: &mut PrivateContext, + token_contract_address: AztecAddress, + address: AztecAddress, + address_amount: Field +) { + let current_contract_address = cheatcodes::get_contract_address(); + cheatcodes::set_contract_address(token_contract_address); + + let header = context.get_header(); + let owner_npk_m_hash = header.get_npk_m_hash(context, address); + + let balance_of_private = PrivateToken::balance_of_unconstrained(owner_npk_m_hash); + assert(balance_of_private == address_amount, "Private balance is not correct"); + cheatcodes::set_contract_address(current_contract_address); +} diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/types.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/types.nr new file mode 100644 index 00000000000..ac754901ded --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/types.nr @@ -0,0 +1,2 @@ +mod balances_map; +mod token_note; diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/types/balances_map.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/types/balances_map.nr new file mode 100644 index 00000000000..7ce8857dd2d --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/types/balances_map.nr @@ -0,0 +1,132 @@ +use dep::aztec::prelude::{ + AztecAddress, NoteGetterOptions, NoteViewerOptions, NoteHeader, NoteInterface, PrivateContext, + PrivateSet, Map +}; +use dep::aztec::{ + hash::pedersen_hash, protocol_types::constants::MAX_NOTE_HASH_READ_REQUESTS_PER_CALL, + note::{ + note_getter::view_notes, note_getter_options::{SortOrder, Comparator}, + note_emission::OuterNoteEmission +}, + context::UnconstrainedContext +}; +use crate::types::token_note::{TokenNote, OwnedNote}; + +struct BalancesMap { + map: PrivateSet +} + +impl BalancesMap { + pub fn new(context: Context, storage_slot: Field) -> Self { + assert(storage_slot != 0, "Storage slot 0 not allowed. Storage slots must start from 1."); + Self { map: PrivateSet::new(context, storage_slot) } + } +} + +impl BalancesMap { + unconstrained fn balance_of( + self: Self, + owner_npk_m_hash: Field + ) -> U128 where T: NoteInterface + OwnedNote { + self.balance_of_with_offset(owner_npk_m_hash, 0) + } + + unconstrained fn balance_of_with_offset( + self: Self, + owner_npk_m_hash: Field, + offset: u32 + ) -> U128 where T: NoteInterface + OwnedNote { + let mut balance = U128::from_integer(0); + let mut options = NoteViewerOptions::new(); + + let notes = self.map.view_notes( + options.select( + T::get_owner_selector(), + owner_npk_m_hash, + Option::some(Comparator.EQ) + ) + ); + + for i in 0..options.limit { + if i < notes.len() { + balance = balance + notes.get_unchecked(i).get_amount(); + } + } + if (notes.len() == options.limit) { + balance = balance + self.balance_of_with_offset(owner_npk_m_hash, offset + options.limit); + } + + balance + } +} + +impl BalancesMap { + + pub fn to_unconstrained(self: Self) -> BalancesMap { + BalancesMap { map: PrivateSet::new(UnconstrainedContext::new(), self.map.storage_slot) } + } + + pub fn add( + self: Self, + owner_npk_m_hash: Field, + addend: U128 + ) -> OuterNoteEmission where T: NoteInterface + OwnedNote { + let mut addend_note = T::new(addend, owner_npk_m_hash); + OuterNoteEmission::new(Option::some(self.map.insert(&mut addend_note))) + } + + pub fn sub( + self: Self, + owner_npk_m_hash: Field, + subtrahend: U128 + ) -> OuterNoteEmission where T: NoteInterface + OwnedNote { + let mut options = NoteGetterOptions::with_filter(filter_notes_min_sum, subtrahend); + let notes = self.map.get_notes( + options.select( + T::get_owner_selector(), + owner_npk_m_hash, + Option::some(Comparator.EQ) + ) + ); + + let mut minuend: U128 = U128::from_integer(0); + for i in 0..options.limit { + if i < notes.len() { + let note = notes.get_unchecked(i); + + // Removes the note from the owner's set of notes. + // This will call the the `compute_nullifer` function of the `token_note` + // which require knowledge of the secret key (currently the users encryption key). + // The contract logic must ensure that the spending key is used as well. + // docs:start:remove + self.map.remove(note); + // docs:end:remove + + minuend = minuend + note.get_amount(); + } + } + + // This is to provide a nicer error msg, + // without it minuend-subtrahend would still catch it, but more generic error then. + // without the == true, it includes 'minuend.ge(subtrahend)' as part of the error. + assert(minuend >= subtrahend, "Balance too low"); + + self.add(owner_npk_m_hash, minuend - subtrahend) + } +} + +pub fn filter_notes_min_sum( + notes: [Option; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL], + min_sum: U128 +) -> [Option; MAX_NOTE_HASH_READ_REQUESTS_PER_CALL] where T: NoteInterface + OwnedNote { + let mut selected = [Option::none(); MAX_NOTE_HASH_READ_REQUESTS_PER_CALL]; + let mut sum = U128::from_integer(0); + for i in 0..notes.len() { + if notes[i].is_some() & sum < min_sum { + let note = notes[i].unwrap_unchecked(); + selected[i] = Option::some(note); + sum = sum.add(note.get_amount()); + } + } + selected +} diff --git a/noir-projects/noir-contracts/contracts/private_token_contract/src/types/token_note.nr b/noir-projects/noir-contracts/contracts/private_token_contract/src/types/token_note.nr new file mode 100644 index 00000000000..03573ede5e0 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/private_token_contract/src/types/token_note.nr @@ -0,0 +1,186 @@ +use dep::aztec::{ + prelude::{AztecAddress, NoteHeader, NoteInterface, PrivateContext}, + protocol_types::{constants::GENERATOR_INDEX__NOTE_NULLIFIER, grumpkin_point::GrumpkinPoint, hash::poseidon2_hash}, + note::utils::compute_note_hash_for_consumption, oracle::unsafe_rand::unsafe_rand, + keys::getters::get_nsk_app, note::note_getter_options::PropertySelector +}; +use dep::std::field::bn254::decompose; +use dep::std::embedded_curve_ops::{EmbeddedCurvePoint, EmbeddedCurveScalar, multi_scalar_mul, fixed_base_scalar_mul}; + +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, + sponsored_user_npk_m_hash: Field, + funded_amount: Field, + refund_nonce: Field + ) -> [EmbeddedCurvePoint; 2]; + + fn complete_refund( + fee_payer_point: EmbeddedCurvePoint, + sponsored_user_point: EmbeddedCurvePoint, + transaction_fee: Field + ) -> [Field; 2]; +} + +global TOKEN_NOTE_LEN: Field = 3; // 3 plus a header. +global TOKEN_NOTE_BYTES_LEN: Field = 3 * 32 + 64; +global G1 = EmbeddedCurvePoint { x: 1, y: 17631683881184975370165255887551781615748388533673675138860, is_infinite: false }; + +#[aztec(note)] +struct TokenNote { + // The amount of tokens in the note + amount: U128, + // The nullifying public key hash is used with the nsk_app to ensure that the note can be privately spent. + npk_m_hash: Field, + // Randomness of the note to hide its contents + randomness: Field, +} + +impl NoteInterface for TokenNote { + // docs:start:nullifier + fn compute_note_hash_and_nullifier(self, context: &mut PrivateContext) -> ( Field, Field ) { + let note_hash_for_nullify = compute_note_hash_for_consumption(self); + let secret = context.request_nsk_app(self.npk_m_hash); + let nullifier = poseidon2_hash([ + note_hash_for_nullify, + secret, + GENERATOR_INDEX__NOTE_NULLIFIER as Field, + ]); + (note_hash_for_nullify, nullifier) + } + // docs:end:nullifier + + fn compute_note_hash_and_nullifier_without_context(self) -> ( Field, Field ) { + let note_hash_for_nullify = compute_note_hash_for_consumption(self); + let secret = get_nsk_app(self.npk_m_hash); + let nullifier = poseidon2_hash([ + note_hash_for_nullify, + secret, + GENERATOR_INDEX__NOTE_NULLIFIER as Field, + ]); + (note_hash_for_nullify, nullifier) + } + + + + fn compute_note_content_hash(self) -> Field { + let (npk_lo, npk_hi) = decompose(self.npk_m_hash); + let (random_lo, random_hi) = decompose(self.randomness); + multi_scalar_mul( + [G1, G1, G1], + [EmbeddedCurveScalar { + lo: self.amount.to_integer(), + hi: 0 + }, + EmbeddedCurveScalar { + lo: npk_lo, + hi: npk_hi + }, + EmbeddedCurveScalar { + lo: random_lo, + hi: random_hi, + }] + )[0] + } +} + +impl OwnedNote for TokenNote { + fn new(amount: U128, owner_npk_m_hash: Field) -> Self { + Self { + amount, + npk_m_hash: owner_npk_m_hash, + randomness: unsafe_rand(), + header: NoteHeader::empty(), + } + } + + 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 } + } +} + +impl PrivatelyRefundable for TokenNote { + fn generate_refund_points(fee_payer_npk_m_hash: Field, sponsored_user_npk_m_hash: Field, funded_amount: Field, refund_nonce: Field) -> [EmbeddedCurvePoint; 2] { + let (refund_nonce_lo, refund_nonce_hi) = decompose(refund_nonce); + let (fee_payer_lo, fee_payer_hi) = decompose(fee_payer_npk_m_hash); + + let fee_payer_point = multi_scalar_mul( + [G1, G1], + [EmbeddedCurveScalar { + lo: fee_payer_lo, + hi: fee_payer_hi + }, + EmbeddedCurveScalar { + lo: refund_nonce_lo, + hi: refund_nonce_hi + }] + ); + + let (sponsored_user_lo, sponsored_user_hi) = decompose(sponsored_user_npk_m_hash); + let (funded_amount_lo, funded_amount_hi) = decompose(funded_amount); + let sponsored_user_point = multi_scalar_mul( + [G1, G1, G1], + [EmbeddedCurveScalar { + lo: sponsored_user_lo, + hi: sponsored_user_hi + }, + EmbeddedCurveScalar { + lo: funded_amount_lo, + hi: funded_amount_hi + }, + EmbeddedCurveScalar { + lo: refund_nonce_lo, + hi: refund_nonce_hi + }] + ); + + [EmbeddedCurvePoint { + x: fee_payer_point[0], + y: fee_payer_point[1], + is_infinite: fee_payer_point[2] == 1 + },EmbeddedCurvePoint { + x: sponsored_user_point[0], + y: sponsored_user_point[1], + is_infinite: sponsored_user_point[2] == 1 + } ] + } + + fn complete_refund(fee_payer_point: EmbeddedCurvePoint, sponsored_user_point: EmbeddedCurvePoint, transaction_fee: Field) -> [Field; 2] { + + let (transaction_fee_lo, transaction_fee_hi) = decompose(transaction_fee); + let fee_point_raw = multi_scalar_mul( + [G1], + [EmbeddedCurveScalar { + lo: transaction_fee_lo, + hi: transaction_fee_hi, + }] + ); + let fee_point = EmbeddedCurvePoint { + x: fee_point_raw[0], + y: fee_point_raw[1], + is_infinite: fee_point_raw[2] ==1 + }; + + let completed_fpc_point = fee_payer_point + fee_point; + + let completed_user_point = sponsored_user_point - fee_point; + assert_eq(completed_user_point.is_infinite, false); + + [completed_fpc_point.x, completed_user_point.x] + } +} diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/grumpkin_point.nr b/noir-projects/noir-protocol-circuits/crates/types/src/grumpkin_point.nr index 5fec6de3cd0..a996a0df6fe 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/grumpkin_point.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/grumpkin_point.nr @@ -1,4 +1,4 @@ -use crate::{traits::{Serialize, Deserialize, Hash}, hash::poseidon2_hash}; +use crate::{traits::{Serialize, Deserialize, Hash, Empty}, hash::poseidon2_hash}; global GRUMPKIN_POINT_SERIALIZED_LEN: Field = 2; @@ -35,6 +35,15 @@ impl Hash for GrumpkinPoint { } } +impl Empty for GrumpkinPoint { + fn empty() -> Self { + GrumpkinPoint { + x: 0, + y: 0 + } + } +} + impl GrumpkinPoint { pub fn new(x: Field, y: Field) -> Self { Self { x, y } diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index ed0985c1dce..e5c12ce088e 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -129,9 +129,10 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { } async verifyProof(tx: Tx): Promise { - const { proof, enqueuedPublicFunctionCalls } = tx; - const expectedCircuit: ClientProtocolArtifact = - enqueuedPublicFunctionCalls.length > 0 ? 'PrivateKernelTailToPublicArtifact' : 'PrivateKernelTailArtifact'; + const { proof, data } = tx; + const expectedCircuit: ClientProtocolArtifact = data.forPublic + ? 'PrivateKernelTailToPublicArtifact' + : 'PrivateKernelTailArtifact'; try { await this.verifyProofForCircuit(expectedCircuit, proof); diff --git a/yarn-project/circuit-types/src/mocks.ts b/yarn-project/circuit-types/src/mocks.ts index 99df4ebcfa5..224f1de36c6 100644 --- a/yarn-project/circuit-types/src/mocks.ts +++ b/yarn-project/circuit-types/src/mocks.ts @@ -58,7 +58,7 @@ export const mockTx = ( ); } - const isForPublic = totalPublicCallRequests > 0; + const isForPublic = totalPublicCallRequests > 0 || publicTeardownCallRequest.isEmpty() === false; const data = PrivateKernelTailCircuitPublicInputs.empty(); const firstNullifier = new Nullifier(new Fr(seed + 1), 0, Fr.ZERO); const noteEncryptedLogs = EncryptedNoteTxL2Logs.empty(); // Mock seems to have no new notes => no note logs diff --git a/yarn-project/circuit-types/src/tx/tx.ts b/yarn-project/circuit-types/src/tx/tx.ts index 8cb40f57d62..b71e99074d5 100644 --- a/yarn-project/circuit-types/src/tx/tx.ts +++ b/yarn-project/circuit-types/src/tx/tx.ts @@ -50,10 +50,11 @@ export class Tx { public readonly publicTeardownFunctionCall: PublicCallRequest, ) { const kernelPublicCallStackSize = data.numberOfPublicCallRequests(); - if (kernelPublicCallStackSize !== enqueuedPublicFunctionCalls.length) { + const totalPublicCalls = enqueuedPublicFunctionCalls.length + (publicTeardownFunctionCall.isEmpty() ? 0 : 1); + if (kernelPublicCallStackSize !== totalPublicCalls) { throw new Error( `Mismatch number of enqueued public function calls in kernel circuit public inputs (expected - ${kernelPublicCallStackSize}, got ${enqueuedPublicFunctionCalls.length})`, + ${kernelPublicCallStackSize}, got ${totalPublicCalls})`, ); } } diff --git a/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_private_inputs.ts b/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_private_inputs.ts index 151dbdc1e1c..8251750b9db 100644 --- a/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_private_inputs.ts +++ b/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_private_inputs.ts @@ -121,7 +121,10 @@ export class PrivateKernelTailCircuitPrivateInputs { ) {} isForPublic() { - return countAccumulatedItems(this.previousKernel.publicInputs.end.publicCallStack) > 0; + return ( + countAccumulatedItems(this.previousKernel.publicInputs.end.publicCallStack) > 0 || + !this.previousKernel.publicInputs.publicTeardownCallRequest.isEmpty() + ); } /** diff --git a/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_public_inputs.ts b/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_public_inputs.ts index c3b4276d217..c11e6981aa6 100644 --- a/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_public_inputs.ts +++ b/yarn-project/circuits.js/src/structs/kernel/private_kernel_tail_circuit_public_inputs.ts @@ -187,7 +187,8 @@ export class PrivateKernelTailCircuitPublicInputs { numberOfPublicCallRequests() { return this.forPublic ? countAccumulatedItems(this.forPublic.endNonRevertibleData.publicCallStack) + - countAccumulatedItems(this.forPublic.end.publicCallStack) + countAccumulatedItems(this.forPublic.end.publicCallStack) + + countAccumulatedItems(this.forPublic.publicTeardownCallStack) : 0; } diff --git a/yarn-project/circuits.js/src/types/public_keys.ts b/yarn-project/circuits.js/src/types/public_keys.ts index 4086261b2e6..942b834de4b 100644 --- a/yarn-project/circuits.js/src/types/public_keys.ts +++ b/yarn-project/circuits.js/src/types/public_keys.ts @@ -1,5 +1,5 @@ import { poseidon2Hash } from '@aztec/foundation/crypto'; -import { type Fr, Point } from '@aztec/foundation/fields'; +import { Fr, Point } from '@aztec/foundation/fields'; import { BufferReader, FieldReader, serializeToBuffer } from '@aztec/foundation/serialize'; import { GeneratorIndex } from '../constants.gen.js'; @@ -19,13 +19,28 @@ export class PublicKeys { ) {} hash() { - return poseidon2Hash([ - this.masterNullifierPublicKey, - this.masterIncomingViewingPublicKey, - this.masterOutgoingViewingPublicKey, - this.masterTaggingPublicKey, - GeneratorIndex.PUBLIC_KEYS_HASH, - ]); + return this.isEmpty() + ? Fr.ZERO + : poseidon2Hash([ + this.masterNullifierPublicKey, + this.masterIncomingViewingPublicKey, + this.masterOutgoingViewingPublicKey, + this.masterTaggingPublicKey, + GeneratorIndex.PUBLIC_KEYS_HASH, + ]); + } + + isEmpty() { + return ( + this.masterNullifierPublicKey.isZero() && + this.masterIncomingViewingPublicKey.isZero() && + this.masterOutgoingViewingPublicKey.isZero() && + this.masterTaggingPublicKey.isZero() + ); + } + + static empty(): PublicKeys { + return new PublicKeys(Point.ZERO, Point.ZERO, Point.ZERO, Point.ZERO); } /** diff --git a/yarn-project/end-to-end/src/e2e_fees/fees_test.ts b/yarn-project/end-to-end/src/e2e_fees/fees_test.ts index 2de57693131..cb6f8f2985b 100644 --- a/yarn-project/end-to-end/src/e2e_fees/fees_test.ts +++ b/yarn-project/end-to-end/src/e2e_fees/fees_test.ts @@ -14,7 +14,7 @@ import { createDebugLogger, } from '@aztec/aztec.js'; import { DefaultMultiCallEntrypoint } from '@aztec/aztec.js/entrypoint'; -import { EthAddress, GasSettings } from '@aztec/circuits.js'; +import { EthAddress, GasSettings, computePartialAddress } from '@aztec/circuits.js'; import { createL1Clients } from '@aztec/ethereum'; import { PortalERC20Abi } from '@aztec/l1-artifacts'; import { @@ -23,6 +23,8 @@ import { CounterContract, FPCContract, GasTokenContract, + PrivateFPCContract, + PrivateTokenContract, } from '@aztec/noir-contracts.js'; import { getCanonicalGasToken } from '@aztec/protocol-contracts/gas-token'; @@ -65,6 +67,8 @@ export class FeesTest { public gasTokenContract!: GasTokenContract; public bananaCoin!: BananaCoin; public bananaFPC!: FPCContract; + public privateToken!: PrivateTokenContract; + public privateFPC!: PrivateFPCContract; public counterContract!: CounterContract; public subscriptionContract!: AppSubscriptionContract; public gasBridgeTestHarness!: IGasBridgingTestHarness; @@ -73,6 +77,7 @@ export class FeesTest { public gasBalances!: BalancesFn; public bananaPublicBalances!: BalancesFn; public bananaPrivateBalances!: BalancesFn; + public privateTokenBalances!: BalancesFn; public readonly INITIAL_GAS_BALANCE = BigInt(1e15); public readonly ALICE_INITIAL_BANANAS = BigInt(1e12); @@ -94,6 +99,14 @@ export class FeesTest { await this.snapshotManager.teardown(); } + /** Alice mints PrivateToken */ + async mintPrivateTokens(amount: bigint) { + const balanceBefore = await this.privateToken.methods.balance_of_private(this.aliceAddress).simulate(); + await this.privateToken.methods.privately_mint_private_note(amount).send().wait(); + const balanceAfter = await this.privateToken.methods.balance_of_private(this.aliceAddress).simulate(); + expect(balanceAfter).toEqual(balanceBefore + amount); + } + /** Alice mints bananaCoin tokens privately to the target address and redeems them. */ async mintPrivateBananas(amount: bigint, address: AztecAddress) { const balanceBefore = await this.bananaCoin.methods.balance_of_private(address).simulate(); @@ -142,7 +155,7 @@ export class FeesTest { await this.applyDeployBananaTokenSnapshot(); } - private async applyInitialAccountsSnapshot() { + async applyInitialAccountsSnapshot() { await this.snapshotManager.snapshot( 'initial_accounts', addAccounts(3, this.logger), @@ -156,6 +169,11 @@ export class FeesTest { [this.aliceWallet, this.bobWallet] = this.wallets.slice(0, 2); [this.aliceAddress, this.bobAddress, this.sequencerAddress] = this.wallets.map(w => w.getAddress()); this.gasTokenContract = await GasTokenContract.at(getCanonicalGasToken().address, this.aliceWallet); + const bobInstance = await this.bobWallet.getContractInstance(this.bobAddress); + if (!bobInstance) { + throw new Error('Bob instance not found'); + } + this.aliceWallet.registerAccount(accountKeys[1][0], computePartialAddress(bobInstance)); this.coinbase = EthAddress.random(); const { publicClient, walletClient } = createL1Clients(aztecNodeConfig.rpcUrl, MNEMONIC); @@ -172,24 +190,43 @@ export class FeesTest { ); } - private async applyPublicDeployAccountsSnapshot() { + async applyPublicDeployAccountsSnapshot() { await this.snapshotManager.snapshot('public_deploy_accounts', () => publicDeployAccounts(this.aliceWallet, this.wallets), ); } - private async applyDeployGasTokenSnapshot() { - await this.snapshotManager.snapshot('deploy_gas_token', async context => { - await deployCanonicalGasToken( - new SignerlessWallet( - context.pxe, - new DefaultMultiCallEntrypoint(context.aztecNodeConfig.chainId, context.aztecNodeConfig.version), - ), - ); - }); + async applyDeployGasTokenSnapshot() { + await this.snapshotManager.snapshot( + 'deploy_gas_token', + async context => { + await deployCanonicalGasToken( + new SignerlessWallet( + context.pxe, + new DefaultMultiCallEntrypoint(context.aztecNodeConfig.chainId, context.aztecNodeConfig.version), + ), + ); + }, + async (_data, context) => { + this.gasTokenContract = await GasTokenContract.at(getCanonicalGasToken().address, this.aliceWallet); + + this.gasBalances = getBalancesFn('⛽', this.gasTokenContract.methods.balance_of_public, this.logger); + + const { publicClient, walletClient } = createL1Clients(context.aztecNodeConfig.rpcUrl, MNEMONIC); + this.gasBridgeTestHarness = await GasPortalTestingHarnessFactory.create({ + aztecNode: context.aztecNode, + pxeService: context.pxe, + publicClient: publicClient, + walletClient: walletClient, + wallet: this.aliceWallet, + logger: this.logger, + mockL1: false, + }); + }, + ); } - private async applyDeployBananaTokenSnapshot() { + async applyDeployBananaTokenSnapshot() { await this.snapshotManager.snapshot( 'deploy_banana_token', async () => { @@ -205,6 +242,46 @@ export class FeesTest { ); } + async applyPrivateTokenAndFPC() { + await this.snapshotManager.snapshot( + 'private_token_and_private_fpc', + async context => { + // Deploy token/fpc flavors for private refunds + const gasTokenContract = this.gasBridgeTestHarness.l2Token; + expect(await context.pxe.isContractPubliclyDeployed(gasTokenContract.address)).toBe(true); + + const privateToken = await PrivateTokenContract.deploy(this.aliceWallet, this.aliceAddress, 'PVT', 'PVT', 18n) + .send() + .deployed(); + + this.logger.info(`PrivateToken deployed at ${privateToken.address}`); + const adminKeyHash = this.bobWallet.getCompleteAddress().publicKeys.masterNullifierPublicKey.hash(); + + const privateFPCSent = PrivateFPCContract.deploy(this.bobWallet, privateToken.address, adminKeyHash).send(); + const privateFPC = await privateFPCSent.deployed(); + + this.logger.info(`PrivateFPC deployed at ${privateFPC.address}`); + await this.gasBridgeTestHarness.bridgeFromL1ToL2( + this.INITIAL_GAS_BALANCE, + this.INITIAL_GAS_BALANCE, + privateFPC.address, + ); + + return { + privateTokenAddress: privateToken.address, + privateFPCAddress: privateFPC.address, + }; + }, + async (data, context) => { + this.privateFPC = await PrivateFPCContract.at(data.privateFPCAddress, this.bobWallet); + this.privateToken = await PrivateTokenContract.at(data.privateTokenAddress, this.aliceWallet); + + const logger = this.logger; + this.privateTokenBalances = getBalancesFn('🕵️.private', this.privateToken.methods.balance_of_private, logger); + }, + ); + } + public async applyFPCSetupSnapshot() { await this.snapshotManager.snapshot( 'fpc_setup', @@ -238,7 +315,6 @@ export class FeesTest { const logger = this.logger; this.bananaPublicBalances = getBalancesFn('🍌.public', this.bananaCoin.methods.balance_of_public, logger); this.bananaPrivateBalances = getBalancesFn('🍌.private', this.bananaCoin.methods.balance_of_private, logger); - this.gasBalances = getBalancesFn('⛽', this.gasTokenContract.methods.balance_of_public, logger); this.getCoinbaseBalance = async () => { const { walletClient } = createL1Clients(context.aztecNodeConfig.rpcUrl, MNEMONIC); @@ -264,6 +340,16 @@ export class FeesTest { ); } + public async applyFundAliceWithPrivateTokens() { + await this.snapshotManager.snapshot( + 'fund_alice_with_private_tokens', + async () => { + await this.mintPrivateTokens(BigInt(this.ALICE_INITIAL_BANANAS)); + }, + () => Promise.resolve(), + ); + } + public async applyFundAliceWithGasToken() { await this.snapshotManager.snapshot( 'fund_alice_with_gas_token', 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 new file mode 100644 index 00000000000..81a8333c189 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_fees/private_refunds.test.ts @@ -0,0 +1,178 @@ +import { + type AztecAddress, + ExtendedNote, + type FeePaymentMethod, + type FunctionCall, + Note, + type Wallet, +} from '@aztec/aztec.js'; +import { Fr, type GasSettings } from '@aztec/circuits.js'; +import { FunctionSelector, FunctionType } from '@aztec/foundation/abi'; +import { type PrivateFPCContract, PrivateTokenContract } from '@aztec/noir-contracts.js'; + +import { expectMapping } from '../fixtures/utils.js'; +import { FeesTest } from './fees_test.js'; + +describe('e2e_fees/private_refunds', () => { + let aliceWallet: Wallet; + let aliceAddress: AztecAddress; + let privateToken: PrivateTokenContract; + let privateFPC: PrivateFPCContract; + + let InitialAlicePrivateTokens: bigint; + let InitialBobPrivateTokens: bigint; + let InitialPrivateFPCGas: bigint; + + const t = new FeesTest('private_payment_with_private_refund'); + + beforeAll(async () => { + await t.applyInitialAccountsSnapshot(); + await t.applyPublicDeployAccountsSnapshot(); + await t.applyDeployGasTokenSnapshot(); + await t.applyPrivateTokenAndFPC(); + await t.applyFundAliceWithPrivateTokens(); + ({ aliceWallet, aliceAddress, privateFPC, privateToken } = await t.setup()); + t.logger.debug(`Alice address: ${aliceAddress}`); + }); + + afterAll(async () => { + await t.teardown(); + }); + + beforeEach(async () => { + [[InitialAlicePrivateTokens, InitialBobPrivateTokens], [InitialPrivateFPCGas]] = await Promise.all([ + t.privateTokenBalances(aliceAddress, t.bobAddress), + t.gasBalances(privateFPC.address), + ]); + }); + + it('can do private payments and refunds', async () => { + const bobKeyHash = t.bobWallet.getCompleteAddress().publicKeys.masterNullifierPublicKey.hash(); + const rebateNonce = new Fr(42); + const tx = await privateToken.methods + .private_get_name() + .send({ + fee: { + gasSettings: t.gasSettings, + paymentMethod: new PrivateRefundPaymentMethod( + privateToken.address, + privateFPC.address, + aliceWallet, + rebateNonce, + bobKeyHash, + ), + }, + }) + .wait(); + + expect(tx.transactionFee).toBeGreaterThan(0); + + const refundedNoteValue = t.gasSettings.getFeeLimit().sub(new Fr(tx.transactionFee!)); + const aliceKeyHash = t.aliceWallet.getCompleteAddress().publicKeys.masterNullifierPublicKey.hash(); + const aliceRefundNote = new Note([refundedNoteValue, aliceKeyHash, rebateNonce]); + await t.aliceWallet.addNote( + new ExtendedNote( + aliceRefundNote, + t.aliceAddress, + privateToken.address, + PrivateTokenContract.storage.balances.slot, + PrivateTokenContract.notes.TokenNote.id, + tx.txHash, + ), + ); + + const bobFeeNote = new Note([new Fr(tx.transactionFee!), bobKeyHash, rebateNonce]); + await t.bobWallet.addNote( + new ExtendedNote( + bobFeeNote, + t.bobAddress, + privateToken.address, + PrivateTokenContract.storage.balances.slot, + PrivateTokenContract.notes.TokenNote.id, + tx.txHash, + ), + ); + + await expectMapping(t.gasBalances, [privateFPC.address], [InitialPrivateFPCGas - tx.transactionFee!]); + await expectMapping( + t.privateTokenBalances, + [aliceAddress, t.bobAddress], + [InitialAlicePrivateTokens - tx.transactionFee!, InitialBobPrivateTokens + tx.transactionFee!], + ); + }); +}); + +class PrivateRefundPaymentMethod implements FeePaymentMethod { + constructor( + /** + * The asset used to pay the fee. + */ + private asset: AztecAddress, + /** + * Address which will hold the fee payment. + */ + private paymentContract: AztecAddress, + + /** + * An auth witness provider to authorize fee payments + */ + private wallet: Wallet, + + /** + * A nonce to mix in with the generated notes. + * Use this to reconstruct note preimages for the PXE. + */ + private rebateNonce: Fr, + + /** + * The hash of the nullifier private key that the FPC sends notes it receives to. + */ + private feeRecipientNPKMHash: Fr, + ) {} + + /** + * The asset used to pay the fee. + * @returns The asset used to pay the fee. + */ + getAsset() { + return this.asset; + } + + getFeePayer(): Promise { + return Promise.resolve(this.paymentContract); + } + + /** + * Creates a function call to pay the fee in the given asset. + * @param gasSettings - The gas settings. + * @returns The function call to pay the fee. + */ + async getFunctionCalls(gasSettings: GasSettings): Promise { + const maxFee = gasSettings.getFeeLimit(); + + await this.wallet.createAuthWit({ + caller: this.paymentContract, + action: { + name: 'setup_refund', + args: [this.feeRecipientNPKMHash, this.wallet.getCompleteAddress().address, maxFee, this.rebateNonce], + selector: FunctionSelector.fromSignature('setup_refund(Field,(Field),Field,Field)'), + type: FunctionType.PRIVATE, + isStatic: false, + to: this.asset, + returnTypes: [], + }, + }); + + return [ + { + name: 'fund_transaction_privately', + to: this.paymentContract, + selector: FunctionSelector.fromSignature('fund_transaction_privately(Field,(Field),Field)'), + type: FunctionType.PRIVATE, + isStatic: false, + args: [maxFee, this.asset, this.rebateNonce], + returnTypes: [], + }, + ]; + } +} diff --git a/yarn-project/simulator/src/public/abstract_phase_manager.ts b/yarn-project/simulator/src/public/abstract_phase_manager.ts index f9bb9d14926..e055c85d3bf 100644 --- a/yarn-project/simulator/src/public/abstract_phase_manager.ts +++ b/yarn-project/simulator/src/public/abstract_phase_manager.ts @@ -176,12 +176,14 @@ export abstract class AbstractPhaseManager { call => revertibleCallStack.find(p => p.equals(call)) || nonRevertibleCallStack.find(p => p.equals(call)), ); + const teardownCallStack = tx.publicTeardownFunctionCall.isEmpty() ? [] : [tx.publicTeardownFunctionCall]; + if (callRequestsStack.length === 0) { return { [PublicKernelType.NON_PUBLIC]: [], [PublicKernelType.SETUP]: [], [PublicKernelType.APP_LOGIC]: [], - [PublicKernelType.TEARDOWN]: [], + [PublicKernelType.TEARDOWN]: teardownCallStack, [PublicKernelType.TAIL]: [], }; } @@ -191,8 +193,6 @@ export abstract class AbstractPhaseManager { c => revertibleCallStack.findIndex(p => p.equals(c)) !== -1, ); - const teardownCallStack = tx.publicTeardownFunctionCall.isEmpty() ? [] : [tx.publicTeardownFunctionCall]; - if (firstRevertibleCallIndex === 0) { return { [PublicKernelType.NON_PUBLIC]: [], diff --git a/yarn-project/simulator/src/public/public_processor.test.ts b/yarn-project/simulator/src/public/public_processor.test.ts index 362fd788e1d..72d0ea8b7e7 100644 --- a/yarn-project/simulator/src/public/public_processor.test.ts +++ b/yarn-project/simulator/src/public/public_processor.test.ts @@ -1017,6 +1017,80 @@ describe('public_processor', () => { expect(prover.addNewTx).toHaveBeenCalledWith(processed[0]); }); + it('runs a tx with only teardown', async function () { + const baseContractAddressSeed = 0x200; + const teardown = makePublicCallRequest(baseContractAddressSeed); + const baseContractAddress = makeAztecAddress(baseContractAddressSeed); + const tx = mockTxWithPartialState({ + numberOfNonRevertiblePublicCallRequests: 0, + numberOfRevertiblePublicCallRequests: 0, + publicCallRequests: [], + publicTeardownCallRequest: teardown, + }); + + const gasLimits = Gas.from({ l2Gas: 1e9, daGas: 1e9 }); + const teardownGas = Gas.from({ l2Gas: 1e7, daGas: 1e7 }); + tx.data.constants.txContext.gasSettings = GasSettings.from({ + gasLimits: gasLimits, + teardownGasLimits: teardownGas, + inclusionFee: new Fr(1e4), + maxFeesPerGas: { feePerDaGas: new Fr(10), feePerL2Gas: new Fr(10) }, + }); + + // Private kernel tail to public pushes teardown gas allocation into revertible gas used + tx.data.forPublic!.end = PublicAccumulatedDataBuilder.fromPublicAccumulatedData(tx.data.forPublic!.end) + .withGasUsed(teardownGas) + .build(); + tx.data.forPublic!.endNonRevertibleData = PublicAccumulatedDataBuilder.fromPublicAccumulatedData( + tx.data.forPublic!.endNonRevertibleData, + ) + .withGasUsed(Gas.empty()) + .build(); + + let simulatorCallCount = 0; + const txOverhead = 1e4; + const expectedTxFee = txOverhead + teardownGas.l2Gas * 1 + teardownGas.daGas * 1; + const transactionFee = new Fr(expectedTxFee); + const teardownGasUsed = Gas.from({ l2Gas: 1e6, daGas: 1e6 }); + + const simulatorResults: PublicExecutionResult[] = [ + // Teardown + PublicExecutionResultBuilder.fromPublicCallRequest({ + request: teardown, + nestedExecutions: [], + }).build({ + startGasLeft: teardownGas, + endGasLeft: teardownGas.sub(teardownGasUsed), + transactionFee, + }), + ]; + + publicExecutor.simulate.mockImplementation(execution => { + if (simulatorCallCount < simulatorResults.length) { + const result = simulatorResults[simulatorCallCount++]; + return Promise.resolve(result); + } else { + throw new Error(`Unexpected execution request: ${execution}, call count: ${simulatorCallCount}`); + } + }); + + const setupSpy = jest.spyOn(publicKernel, 'publicKernelCircuitSetup'); + const appLogicSpy = jest.spyOn(publicKernel, 'publicKernelCircuitAppLogic'); + const teardownSpy = jest.spyOn(publicKernel, 'publicKernelCircuitTeardown'); + const tailSpy = jest.spyOn(publicKernel, 'publicKernelCircuitTail'); + + const [processed, failed] = await processor.process([tx], 1, prover); + + expect(processed).toHaveLength(1); + expect(processed).toEqual([expectedTxByHash(tx)]); + expect(failed).toHaveLength(0); + + expect(setupSpy).toHaveBeenCalledTimes(0); + expect(appLogicSpy).toHaveBeenCalledTimes(0); + expect(teardownSpy).toHaveBeenCalledTimes(1); + expect(tailSpy).toHaveBeenCalledTimes(1); + }); + describe('with fee payer', () => { it('injects balance update with no public calls', async function () { const feePayer = AztecAddress.random(); diff --git a/yarn-project/txe/src/oracle/txe_oracle.ts b/yarn-project/txe/src/oracle/txe_oracle.ts index 567bf0a656d..045dee05203 100644 --- a/yarn-project/txe/src/oracle/txe_oracle.ts +++ b/yarn-project/txe/src/oracle/txe_oracle.ts @@ -685,7 +685,7 @@ export class TXE implements TypedOracle { Gas.test(), TxContext.empty(), /* pendingNullifiers */ [], - /* transactionFee */ Fr.ZERO, + /* transactionFee */ Fr.ONE, callContext.sideEffectCounter, ); } @@ -830,14 +830,22 @@ export class TXE implements TypedOracle { } setPublicTeardownFunctionCall( - _targetContractAddress: AztecAddress, - _functionSelector: FunctionSelector, - _argsHash: Fr, - _sideEffectCounter: number, - _isStaticCall: boolean, - _isDelegateCall: boolean, + targetContractAddress: AztecAddress, + functionSelector: FunctionSelector, + argsHash: Fr, + sideEffectCounter: number, + isStaticCall: boolean, + isDelegateCall: boolean, ): Promise { - throw new Error('Method not implemented.'); + // Definitely not right. + return this.enqueuePublicFunctionCall( + targetContractAddress, + functionSelector, + argsHash, + sideEffectCounter, + isStaticCall, + isDelegateCall, + ); } aes128Encrypt(input: Buffer, initializationVector: Buffer, key: Buffer): Buffer { diff --git a/yarn-project/txe/src/txe_service/txe_service.ts b/yarn-project/txe/src/txe_service/txe_service.ts index de25cc81299..6a0e1b17b45 100644 --- a/yarn-project/txe/src/txe_service/txe_service.ts +++ b/yarn-project/txe/src/txe_service/txe_service.ts @@ -626,6 +626,44 @@ export class TXEService { return toForeignCallResult([toArray(fields)]); } + /** + * Creates a PublicCallStackItem and sets it as the public teardown function. No function + * is actually called, since that must happen on the sequencer side. All the fields related to the result + * of the execution are empty. + * @param targetContractAddress - The address of the contract to call. + * @param functionSelector - The function selector of the function to call. + * @param argsHash - The packed arguments to pass to the function. + * @param sideEffectCounter - The side effect counter at the start of the call. + * @param isStaticCall - Whether the call is a static call. + * @returns The public call stack item with the request information. + */ + public async setPublicTeardownFunctionCall( + targetContractAddress: ForeignCallSingle, + functionSelector: ForeignCallSingle, + argsHash: ForeignCallSingle, + sideEffectCounter: ForeignCallSingle, + isStaticCall: ForeignCallSingle, + isDelegateCall: ForeignCallSingle, + ) { + const publicTeardownCallRequest = await this.typedOracle.setPublicTeardownFunctionCall( + fromSingle(targetContractAddress), + FunctionSelector.fromField(fromSingle(functionSelector)), + fromSingle(argsHash), + fromSingle(sideEffectCounter).toNumber(), + fromSingle(isStaticCall).toBool(), + fromSingle(isDelegateCall).toBool(), + ); + + const fields = [ + publicTeardownCallRequest.contractAddress.toField(), + publicTeardownCallRequest.functionSelector.toField(), + ...publicTeardownCallRequest.callContext.toFields(), + publicTeardownCallRequest.getArgsHash(), + ]; + + return toForeignCallResult([toArray(fields)]); + } + async getChainId() { return toForeignCallResult([toSingle(await this.typedOracle.getChainId())]); }