diff --git a/l1-contracts/src/core/libraries/ConstantsGen.sol b/l1-contracts/src/core/libraries/ConstantsGen.sol index 31ec0c2410a..f76deaaf78c 100644 --- a/l1-contracts/src/core/libraries/ConstantsGen.sol +++ b/l1-contracts/src/core/libraries/ConstantsGen.sol @@ -18,7 +18,7 @@ library Constants { uint256 internal constant ARGS_LENGTH = 16; uint256 internal constant MAX_NOTE_HASHES_PER_CALL = 16; uint256 internal constant MAX_NULLIFIERS_PER_CALL = 16; - uint256 internal constant MAX_PRIVATE_CALL_STACK_LENGTH_PER_CALL = 4; + uint256 internal constant MAX_PRIVATE_CALL_STACK_LENGTH_PER_CALL = 5; uint256 internal constant MAX_ENQUEUED_CALLS_PER_CALL = 16; uint256 internal constant MAX_L2_TO_L1_MSGS_PER_CALL = 2; uint256 internal constant MAX_PUBLIC_DATA_UPDATE_REQUESTS_PER_CALL = 64; @@ -200,7 +200,7 @@ library Constants { uint256 internal constant TOTAL_FEES_LENGTH = 1; uint256 internal constant TOTAL_MANA_USED_LENGTH = 1; uint256 internal constant HEADER_LENGTH = 25; - uint256 internal constant PRIVATE_CIRCUIT_PUBLIC_INPUTS_LENGTH = 731; + uint256 internal constant PRIVATE_CIRCUIT_PUBLIC_INPUTS_LENGTH = 739; uint256 internal constant PUBLIC_CIRCUIT_PUBLIC_INPUTS_LENGTH = 867; uint256 internal constant PRIVATE_CONTEXT_INPUTS_LENGTH = 38; uint256 internal constant FEE_RECIPIENT_LENGTH = 2; diff --git a/noir-projects/aztec-nr/aztec/src/macros/mod.nr b/noir-projects/aztec-nr/aztec/src/macros/mod.nr index 924c5bcf8e0..883a2028326 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/mod.nr @@ -78,9 +78,9 @@ comptime fn generate_contract_interface(m: Module) -> Quoted { $fn_stubs_quote pub fn at( - target_contract: aztec::protocol_types::address::AztecAddress + addr: aztec::protocol_types::address::AztecAddress ) -> Self { - Self { target_contract } + Self { target_contract: addr } } pub fn interface() -> Self { @@ -92,9 +92,9 @@ comptime fn generate_contract_interface(m: Module) -> Quoted { #[contract_library_method] pub fn at( - target_contract: aztec::protocol_types::address::AztecAddress + addr: aztec::protocol_types::address::AztecAddress ) -> $module_name { - $module_name { target_contract } + $module_name { target_contract: addr } } #[contract_library_method] diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index df510e99432..18ba10820a7 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "contracts/amm_contract", "contracts/app_subscription_contract", "contracts/auth_contract", "contracts/auth_registry_contract", diff --git a/noir-projects/noir-contracts/contracts/amm_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/amm_contract/Nargo.toml new file mode 100644 index 00000000000..e5c4e342ed8 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "amm_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../aztec-nr/aztec" } +token = { path = "../token_contract" } \ No newline at end of file diff --git a/noir-projects/noir-contracts/contracts/amm_contract/src/config.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/config.nr new file mode 100644 index 00000000000..c83648c4a39 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/config.nr @@ -0,0 +1,29 @@ +use dep::aztec::protocol_types::{address::AztecAddress, traits::{Deserialize, Serialize}}; + +global CONFIG_LENGTH: u32 = 3; + +/// We store the tokens of the pool in a struct such that to load it from SharedImmutable asserts only a single +/// merkle proof. +/// (Once we actually do the optimization. WIP in https://github.com/AztecProtocol/aztec-packages/pull/8022). +pub struct Config { + pub token0: AztecAddress, + pub token1: AztecAddress, + pub liquidity_token: AztecAddress, +} + +// Note: I could not get #[derive(Serialize)] to work so I had to implement it manually. +impl Serialize for Config { + fn serialize(self: Self) -> [Field; CONFIG_LENGTH] { + [self.token0.to_field(), self.token1.to_field(), self.liquidity_token.to_field()] + } +} + +impl Deserialize for Config { + fn deserialize(fields: [Field; CONFIG_LENGTH]) -> Self { + Self { + token0: AztecAddress::from_field(fields[0]), + token1: AztecAddress::from_field(fields[1]), + liquidity_token: AztecAddress::from_field(fields[2]), + } + } +} diff --git a/noir-projects/noir-contracts/contracts/amm_contract/src/lib.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/lib.nr new file mode 100644 index 00000000000..6d8e4d89790 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/lib.nr @@ -0,0 +1,96 @@ +/// Given an input amount of an asset and pair balances, returns the maximum output amount of the other asset. +pub fn get_amount_out(amount_in: U128, balance_in: U128, balance_out: U128) -> U128 { + assert(amount_in > U128::zero(), "INSUFFICIENT_INPUT_AMOUNT"); + assert((balance_in > U128::zero()) & (balance_out > U128::zero()), "INSUFFICIENT_LIQUIDITY"); + + // The expression below is: + // (amount_in * 997 * balance_out) / (balance_in * 10000 + amount_in * 997) + // which is equivalent to: + // balance_out * ((amount_in * 0.997) / (balance_in + amount_in * 0.997)) + // resulting in an implicit 0.3% fee on the amount in, as the fee tokens are not taken into consideration. + + let amount_in_with_fee = amount_in * U128::from_integer(997); + let numerator = amount_in_with_fee * balance_out; + let denominator = balance_in * U128::from_integer(1000) + amount_in_with_fee; + numerator / denominator +} + +/// Given an output amount of an asset and pair balances, returns a required input amount of the other asset. +pub fn get_amount_in(amount_out: U128, balance_in: U128, balance_out: U128) -> U128 { + assert(amount_out > U128::zero(), "INSUFFICIENT_OUTPUT_AMOUNT"); + assert((balance_in > U128::zero()) & (balance_out > U128::zero()), "INSUFFICIENT_LIQUIDITY"); + + // The expression below is: + // (balance_in * amount_out * 1000) / (balance_out - amout_out * 997) + 1 + // which is equivalent to: + // balance_in * (amount_out / (balance_in + amount_in)) * 1/0.997 + 1 + // resulting in an implicit 0.3% fee on the amount in, as the fee tokens are not taken into consideration. The +1 + // at the end ensures the rounding error favors the pool. + + let numerator = balance_in * amount_out * U128::from_integer(1000); + let denominator = (balance_out - amount_out) * U128::from_integer(997); + (numerator / denominator) + U128::from_integer(1) +} + +/// Given the desired amounts and balances of token0 and token1 returns the optimal amount of token0 and token1 to be added to the pool. +pub fn get_amounts_to_add( + amount0_max: U128, + amount1_max: U128, + amount0_min: U128, + amount1_min: U128, + balance0: U128, + balance1: U128, +) -> (U128, U128) { + // When adding tokens, both balances must grow by the same ratio, which means that their spot price is unchanged. + // Since any swaps would affect these ratios, liquidity providers supply a range of minimum and maximum balances + // they are willing to supply for each token (which translates to minimum and maximum relative prices of the + // tokens, preventing loss of value outside of this range due to e.g. front-running). + + if (balance0 == U128::zero()) | (balance1 == U128::zero()) { + // The token balances should only be zero when initializing the pool. In this scenario there is no prior ratio + // to follow so we simply transfer the full maximum balance - it is up to the caller to make sure that the ratio + // they've chosen results in a a reasonable spot price. + (amount0_max, amount1_max) + } else { + // There is a huge number of amount combinations that respect the minimum and maximum for each token, but we'll + // only consider the two scenarios in which one of the amounts is the maximum amount. + + // First we calculate the token1 amount that'd need to be supplied if we used the maximum amount for token0. + let amount1_equivalent = get_equivalent_amount(amount0_max, balance0, balance1); + if (amount1_equivalent <= amount1_max) { + assert(amount1_equivalent >= amount1_min, "AMOUNT_1_BELOW_MINIMUM"); + (amount0_max, amount1_equivalent) + } else { + // If the max amount for token0 results in a token1 amount larger than the maximum, then we try with the + // maximum token1 amount, hoping that it'll result in a token0 amount larger than the minimum. + let amount0_equivalent = get_equivalent_amount(amount1_max, balance1, balance0); + // This should never happen, as it'd imply that the maximum is lower than the minimum. + assert(amount0_equivalent <= amount0_max); + + assert(amount0_equivalent >= amount0_min, "AMOUNT_0_BELOW_MINIMUM"); + (amount0_equivalent, amount1_max) + } + } +} + +/// Returns the amount of tokens to return to a liquidity provider when they remove liquidity from the pool. +pub fn get_amounts_on_remove( + to_burn: U128, + total_supply: U128, + balance0: U128, + balance1: U128, +) -> (U128, U128) { + // Since the liquidity token tracks ownership of the pool, the liquidity provider gets a proportional share of each + // token. + (to_burn * balance0 / total_supply, to_burn * balance1 / total_supply) +} + +/// Given some amount of an asset and pair balances, returns an equivalent amount of the other asset. Tokens should be +/// added and removed from the Pool respecting this ratio. +fn get_equivalent_amount(amount0: U128, balance0: U128, balance1: U128) -> U128 { + assert((balance0 > U128::zero()) & (balance1 > U128::zero()), "INSUFFICIENT_LIQUIDITY"); + + // This is essentially the Rule of Three, since we're computing proportional ratios. Note we divide at the end to + // avoid introducing too much error due to truncation. + (amount0 * balance1) / balance0 +} diff --git a/noir-projects/noir-contracts/contracts/amm_contract/src/main.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/main.nr new file mode 100644 index 00000000000..fe405512cf4 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/main.nr @@ -0,0 +1,531 @@ +mod lib; +mod config; + +use dep::aztec::macros::aztec; + +/// ## Overview +/// This contract demonstrates how to implement an **Automated Market Maker (AMM)** that maintains **public state** +/// while still achieving **identity privacy**. However, it does **not provide function privacy**: +/// - Anyone can observe **what actions** were performed. +/// - All amounts involved are visible, but **who** performed the action remains private. +/// +/// Unlike most Ethereum AMMs, the AMM contract is not itself the token that tracks participation of liquidity +/// providers, mostly due to Noir lacking inheritance as a feature. Instead, the AMM is expected to have mint and burn +/// permission over an external token contract. +/// +/// **Note:** +/// This is purely a demonstration. The **Aztec team** does not consider this the optimal design for building a DEX. +/// +/// ## Reentrancy Guard Considerations +/// +/// ### 1. Private Functions: +/// Reentrancy protection is typically necessary if entering an intermediate state that is only valid when +/// the action completes uninterrupted. This follows the **Checks-Effects-Interactions** pattern. +/// +/// - In this contract, **private functions** do not introduce intermediate states. +/// - All operations will be fully executed in **public** without needing intermediate checks. +/// +/// ### 2. Public Functions: +/// No **reentrancy guard** is required for public functions because: +/// - All public functions are marked as **internal** with a **single callsite** - from a private function. +/// - Public functions **cannot call private functions**, eliminating the risk of reentering into them from private. +/// - Since public functions are internal-only, **external contracts cannot access them**, ensuring no external +/// contract can trigger a reentrant call. This eliminates the following attack vector: +/// `AMM.private_fn --> AMM.public_fn --> ExternalContract.fn --> AMM.public_fn`. +#[aztec] +contract AMM { + use crate::{ + config::Config, + lib::{get_amount_in, get_amount_out, get_amounts_on_remove, get_amounts_to_add}, + }; + use dep::aztec::{ + macros::{functions::{initializer, internal, private, public}, storage::storage}, + prelude::{AztecAddress, PublicImmutable}, + }; + use dep::token::Token; + + #[storage] + struct Storage { + config: PublicImmutable, + } + + /// Amount of liquidity which gets locked when liquidity is provided for the first time. Its purpose is to prevent + /// the pool from ever emptying which could lead to undefined behavior. + global MINIMUM_LIQUIDITY: U128 = U128::from_integer(1000); + /// We set it to 99 times the minimum liquidity. That way the first LP gets 99% of the value of their deposit. + global INITIAL_LIQUIDITY: U128 = U128::from_integer(99000); + + // TODO(#9480): Either deploy the liquidity contract in the constructor or verify it that it corresponds to what + // this contract expects (i.e. that the AMM has permission to mint and burn). + #[public] + #[initializer] + fn constructor(token0: AztecAddress, token1: AztecAddress, liquidity_token: AztecAddress) { + storage.config.initialize(Config { token0, token1, liquidity_token }); + } + + /// Privately adds liquidity to the pool. This function receives the minimum and maximum number of tokens the caller + /// is willing to add, in order to account for changing market conditions, and will try to add as many tokens as + /// possible. + /// + /// `nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this specific call. + /// + /// The identity of the liquidity provider is not revealed, but the action and amounts are. + #[private] + fn add_liquidity( + amount0_max: Field, + amount1_max: Field, + amount0_min: Field, + amount1_min: Field, + nonce: Field, + ) { + assert( + amount0_min.lt(amount0_max) | (amount0_min == amount0_max), + "INCORRECT_TOKEN0_LIMITS", + ); + assert( + amount1_min.lt(amount1_max) | (amount1_min == amount1_max), + "INCORRECT_TOKEN1_LIMITS", + ); + assert(0.lt(amount0_max) & 0.lt(amount1_max), "INSUFFICIENT_INPUT_AMOUNTS"); + + let config = storage.config.read(); + + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + let liquidity_token = Token::at(config.liquidity_token); + + let sender = context.msg_sender(); + + // We don't yet know how many tokens the sender will actually supply - that can only be computed during public + // execution since the amounts supplied must have the same ratio as the live balances. We therefore transfer the + // maximum amounts here, and prepare partial notes that return the change to the sender (if any). + // TODO(#10286): consider merging these two calls + token0.transfer_to_public(sender, context.this_address(), amount0_max, nonce).call( + &mut context, + ); + let refund_token0_hiding_point_slot = + token0.prepare_private_balance_increase(sender, sender).call(&mut context); + + token1.transfer_to_public(sender, context.this_address(), amount1_max, nonce).call( + &mut context, + ); + let refund_token1_hiding_point_slot = + token1.prepare_private_balance_increase(sender, sender).call(&mut context); + + // The number of liquidity tokens to mint for the caller depends on both the live balances and the amount + // supplied, both of which can only be known during public execution. We therefore prepare a partial note that + // will get completed via minting. + let liquidity_hiding_point_slot = + liquidity_token.prepare_private_balance_increase(sender, sender).call(&mut context); + + // We then complete the flow in public. Note that the type of operation and amounts will all be publicly known, + // but the identity of the caller is not revealed despite us being able to send tokens to them by completing the + // partial notees. + AMM::at(context.this_address()) + ._add_liquidity( + config, + refund_token0_hiding_point_slot, + refund_token1_hiding_point_slot, + liquidity_hiding_point_slot, + amount0_max, + amount1_max, + amount0_min, + amount1_min, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _add_liquidity( + config: Config, // We could read this in public, but it's cheaper to receive from private + refund_token0_hiding_point_slot: Field, + refund_token1_hiding_point_slot: Field, + liquidity_hiding_point_slot: Field, + amount0_max: Field, + amount1_max: Field, + amount0_min: Field, + amount1_min: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let amount0_max = U128::from_integer(amount0_max); + let amount1_max = U128::from_integer(amount1_max); + let amount0_min = U128::from_integer(amount0_min); + let amount1_min = U128::from_integer(amount1_min); + + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + let liquidity_token = Token::at(config.liquidity_token); + + // We read the current AMM balance of both tokens. Note that by the time this function is called the token + // transfers have already been completed (since those calls were enqueued before this call), and so we need to + // substract the transfer amount to get the pre-deposit balance. + let balance0_plus_amount0_max = U128::from_integer(token0 + .balance_of_public(context.this_address()) + .view(&mut context)); + let balance0 = balance0_plus_amount0_max - amount0_max; + + let balance1_plus_amount1_max = U128::from_integer(token1 + .balance_of_public(context.this_address()) + .view(&mut context)); + let balance1 = balance1_plus_amount1_max - amount1_max; + + // With the current balances known, we can calculate the token amounts to the pool, respecting the user's + // minimum deposit preferences. + let (amount0, amount1) = get_amounts_to_add( + amount0_max, + amount1_max, + amount0_min, + amount1_min, + balance0, + balance1, + ); + + // Return any excess from the original token deposits. + let refund_amount_token0 = amount0_max - amount0; + let refund_amount_token1 = amount1_max - amount1; + + // We can simply skip the refund if the amount to return is 0 in order to save gas: the partial note will + // simply stay in public storage and not be completed, but this is not an issue. + if (refund_amount_token0 > U128::zero()) { + token0 + .finalize_transfer_to_private( + refund_amount_token0.to_integer(), + refund_token0_hiding_point_slot, + ) + .call(&mut context); + } + if (refund_amount_token1 > U128::zero()) { + token1 + .finalize_transfer_to_private( + refund_amount_token1.to_integer(), + refund_token1_hiding_point_slot, + ) + .call(&mut context); + } + + // With the deposit amounts known, we can compute the number of liquidity tokens to mint and finalize the + // depositor's partial note. + let total_supply = U128::from_integer(liquidity_token.total_supply().view(&mut context)); + let liquidity_amount = if total_supply != U128::zero() { + // The liquidity token supply increases by the same ratio as the balances. In case one of the token balances + // increased with a ratio different from the other one, we simply take the smallest value. + std::cmp::min( + (amount0 * total_supply) / balance0, + (amount1 * total_supply) / balance1, + ) + } else { + // The zero total supply case (i.e. pool initialization) is special as we can't increase the supply + // proportionally. We instead set the initial liquidity to an arbitrary amount. + // We could set the initial liquidity to be equal to the pool invariant (i.e. sqrt(amount0 * amount1)) if + // we wanted to collect protocol fees over swap fees (in the style of Uniswap v2), but we choose not to in + // order to keep things simple. + + // As part of initialization, we mint some tokens to the zero address to 'lock' them (i.e. make them + // impossible to redeem), guaranteeing total supply will never be zero again. + liquidity_token + .mint_to_public(AztecAddress::zero(), MINIMUM_LIQUIDITY.to_integer()) + .call(&mut context); + + INITIAL_LIQUIDITY + }; + + assert(liquidity_amount > U128::zero(), "INSUFFICIENT_LIQUIDITY_MINTED"); + liquidity_token + .finalize_mint_to_private(liquidity_amount.to_integer(), liquidity_hiding_point_slot) + .call(&mut context); + } + + /// Privately removes liquidity from the pool. This function receives how many liquidity tokens to burn, and the + /// minimum number of tokens the caller is willing to receive, in order to account for changing market conditions. + /// + /// `nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this specific call. + /// + /// The identity of the liquidity provider is not revealed, but the action and amounts are. + #[private] + fn remove_liquidity(liquidity: Field, amount0_min: Field, amount1_min: Field, nonce: Field) { + let config = storage.config.read(); + + let liquidity_token = Token::at(config.liquidity_token); + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + + let sender = context.msg_sender(); + + // Liquidity tokens are burned when liquidity is removed in order to reduce the total supply. However, we lack + // a function to privately burn, so we instead transfer the tokens into the AMM's public balance, and them have + // the AMM publicly burn its own tokens. + // TODO(#10287): consider adding a private burn + liquidity_token.transfer_to_public(sender, context.this_address(), liquidity, nonce).call( + &mut context, + ); + + // We don't yet know how many tokens the sender will get - that can only be computed during public execution + // since the it depends on the live balances. We therefore simply prepare partial notes to the sender. + let token0_hiding_point_slot = + token0.prepare_private_balance_increase(sender, sender).call(&mut context); + let token1_hiding_point_slot = + token1.prepare_private_balance_increase(sender, sender).call(&mut context); + + // We then complete the flow in public. Note that the type of operation and amounts will all be publicly known, + // but the identity of the caller is not revealed despite us being able to send tokens to them by completing the + // partial notees. + AMM::at(context.this_address()) + ._remove_liquidity( + config, + liquidity, + token0_hiding_point_slot, + token1_hiding_point_slot, + amount0_min, + amount1_min, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _remove_liquidity( + config: Config, // We could read this in public, but it's cheaper to receive from private + liquidity: Field, + token0_hiding_point_slot: Field, + token1_hiding_point_slot: Field, + amount0_min: Field, + amount1_min: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let liquidity = U128::from_integer(liquidity); + let amount0_min = U128::from_integer(amount0_min); + let amount1_min = U128::from_integer(amount1_min); + + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + let liquidity_token = Token::at(config.liquidity_token); + + // We need the current balance of both tokens as well as the liquidity token total supply in order to compute + // the amounts to send the user. + let balance0 = U128::from_integer(token0.balance_of_public(context.this_address()).view( + &mut context, + )); + let balance1 = U128::from_integer(token1.balance_of_public(context.this_address()).view( + &mut context, + )); + let total_supply = U128::from_integer(liquidity_token.total_supply().view(&mut context)); + + // We calculate the amounts of token0 and token1 the user is entitled to based on the amount of liquidity they + // are removing, and check that they are above the minimum amounts they requested. + let (amount0, amount1) = get_amounts_on_remove(liquidity, total_supply, balance0, balance1); + assert(amount0 >= amount0_min, "INSUFFICIENT_0_AMOUNT"); + assert(amount1 >= amount1_min, "INSUFFICIENT_1_AMOUNT"); + + // We can now burn the liquidity tokens that had been privately transferred into the AMM, as well as complete + // both partial notes. + liquidity_token.burn_public(context.this_address(), liquidity.to_integer(), 0).call( + &mut context, + ); + token0.finalize_transfer_to_private(amount0.to_integer(), token0_hiding_point_slot).call( + &mut context, + ); + token1.finalize_transfer_to_private(amount1.to_integer(), token1_hiding_point_slot).call( + &mut context, + ); + } + + /// Privately swaps `amount_in` `token_in` tokens for at least `amount_out_mint` `token_out` tokens with the pool. + /// + /// `nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this specific call. + /// + /// The identity of the swapper is not revealed, but the action and amounts are. + #[private] + fn swap_exact_tokens_for_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in: Field, + amount_out_min: Field, + nonce: Field, + ) { + let config = storage.config.read(); + + assert((token_in == config.token0) | (token_in == config.token1), "TOKEN_IN_IS_INVALID"); + assert((token_out == config.token0) | (token_out == config.token1), "TOKEN_OUT_IS_INVALID"); + assert(token_in != token_out, "SAME_TOKEN_SWAP"); + + let sender = context.msg_sender(); + + // We transfer the full amount in, since it is an exact amount, and prepare a partial note for the amount out, + // which will only be known during public execution as it depends on the live balances. + // TODO(#10286): consider merging these two calls + Token::at(token_in) + .transfer_to_public(sender, context.this_address(), amount_in, nonce) + .call(&mut context); + let token_out_hiding_point_slot = Token::at(token_out) + .prepare_private_balance_increase(sender, sender) + .call(&mut context); + + AMM::at(context.this_address()) + ._swap_exact_tokens_for_tokens( + token_in, + token_out, + amount_in, + amount_out_min, + token_out_hiding_point_slot, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _swap_exact_tokens_for_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in: Field, + amount_out_min: Field, + token_out_hiding_point_slot: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let amount_in = U128::from_integer(amount_in); + let amount_out_min = U128::from_integer(amount_out_min); + + // In order to compute the amount to swap we need the live token balances. Note that at this state the token in + // transfer has already been completed as that function call was enqueued before this one. We therefore need to + // subtract the amount in to get the pre-swap balances. + let balance_in_plus_amount_in = U128::from_integer(Token::at(token_in) + .balance_of_public(context.this_address()) + .view(&mut context)); + let balance_in = balance_in_plus_amount_in - amount_in; + + let balance_out = U128::from_integer(Token::at(token_out) + .balance_of_public(context.this_address()) + .view(&mut context)); + + // We can now compute the number of tokens to transfer and complete the partial note. + let amount_out = get_amount_out(amount_in, balance_in, balance_out); + assert(amount_out >= amount_out_min, "INSUFFICIENT_OUTPUT_AMOUNT"); + + Token::at(token_out) + .finalize_transfer_to_private(amount_out.to_integer(), token_out_hiding_point_slot) + .call(&mut context); + } + + /// Privately swaps at most `amount_in_max` `token_in` tokens for `amount_out` `token_out` tokens with the pool. + /// + /// `nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this specific call. + /// + /// The identity of the swapper is not revealed, but the action and amounts are. + #[private] + fn swap_tokens_for_exact_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_out: Field, + amount_in_max: Field, + nonce: Field, + ) { + let config = storage.config.read(); + + assert((token_in == config.token0) | (token_in == config.token1), "TOKEN_IN_IS_INVALID"); + assert((token_out == config.token0) | (token_out == config.token1), "TOKEN_OUT_IS_INVALID"); + assert(token_in != token_out, "SAME_TOKEN_SWAP"); + + let sender = context.msg_sender(); + + // We don't know how many tokens we'll receive from the user, since the swap amount will only be known during + // public execution as it depends on the live balances. We therefore transfer the full maximum amount and + // prepare partial notes both for the token out and the refund. + // Technically the token out note does not need to be partial, since we do know the amount out, but we do want + // to wait until the swap has been completed before commiting the note to the tree to avoid it being spent too + // early. + // TODO(#10286): consider merging these two calls + Token::at(token_in) + .transfer_to_public(sender, context.this_address(), amount_in_max, nonce) + .call(&mut context); + let change_token_in_hiding_point_slot = + Token::at(token_in).prepare_private_balance_increase(sender, sender).call(&mut context); + + let token_out_hiding_point_slot = Token::at(token_out) + .prepare_private_balance_increase(sender, sender) + .call(&mut context); + + AMM::at(context.this_address()) + ._swap_tokens_for_exact_tokens( + token_in, + token_out, + amount_in_max, + amount_out, + change_token_in_hiding_point_slot, + token_out_hiding_point_slot, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _swap_tokens_for_exact_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in_max: Field, + amount_out: Field, + change_token_in_hiding_point_slot: Field, + token_out_hiding_point_slot: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let amount_out = U128::from_integer(amount_out); + let amount_in_max = U128::from_integer(amount_in_max); + + // In order to compute the amount to swap we need the live token balances. Note that at this state the token in + // transfer has already been completed as that function call was enqueued before this one. We therefore need to + // subtract the amount in to get the pre-swap balances. + let balance_in_plus_amount_in_max = U128::from_integer(Token::at(token_in) + .balance_of_public(context.this_address()) + .view(&mut context)); + let balance_in = balance_in_plus_amount_in_max - amount_in_max; + + let balance_out = U128::from_integer(Token::at(token_out) + .balance_of_public(context.this_address()) + .view(&mut context)); + + // We can now compute the number of tokens we need to receive and complete the partial note with the change. + let amount_in = get_amount_in(amount_out, balance_in, balance_out); + assert(amount_in <= amount_in_max, "INSUFFICIENT_OUTPUT_AMOUNT"); + + let change = amount_in_max - amount_in; + if (change > U128::zero()) { + Token::at(token_in) + .finalize_transfer_to_private(change.to_integer(), change_token_in_hiding_point_slot + ) + .call(&mut context); + } + + // Note again that we already knew the amount out, but for consistency we want to only commit this note once + // all other steps have been performed. + Token::at(token_out) + .finalize_transfer_to_private(amount_out.to_integer(), token_out_hiding_point_slot) + .call(&mut context); + } + + unconstrained fn get_amount_out_for_exact_in( + balance_in: Field, + balance_out: Field, + amount_in: Field, + ) -> Field { + // Ideally we'd call the token contract in order to read the current balance, but we can't due to #7524. + get_amount_out( + U128::from_integer(amount_in), + U128::from_integer(balance_in), + U128::from_integer(balance_out), + ) + .to_integer() + } + + unconstrained fn get_amount_in_for_exact_out( + balance_in: Field, + balance_out: Field, + amount_out: Field, + ) -> Field { + // Ideally we'd call the token contract in order to read the current balance, but we can't due to #7524. + get_amount_in( + U128::from_integer(amount_out), + U128::from_integer(balance_in), + U128::from_integer(balance_out), + ) + .to_integer() + } +} diff --git a/noir-projects/noir-contracts/contracts/lending_contract/src/interest_math.nr b/noir-projects/noir-contracts/contracts/lending_contract/src/interest_math.nr index e92c91f908d..e3e1e2e1d1b 100644 --- a/noir-projects/noir-contracts/contracts/lending_contract/src/interest_math.nr +++ b/noir-projects/noir-contracts/contracts/lending_contract/src/interest_math.nr @@ -1,7 +1,7 @@ // Binomial approximation of exponential // using lower than desired precisions for everything due to u128 limit // (1+x)^n = 1+n*x+[n/2*(n-1)]*x^2+[n/6*(n-1)*(n-2)*x^3]... -// we are loosing around almost 8 digits of precision from yearly -> daily interest +// we are losing around almost 8 digits of precision from yearly -> daily interest // dividing with 31536000 (seconds per year). // rate must be measured with higher precision than 10^9. // we use e18, and rates >= 4% yearly. Otherwise need more precision diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/main.nr b/noir-projects/noir-contracts/contracts/token_contract/src/main.nr index b2cd2094edc..fad92b5675a 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/main.nr @@ -446,8 +446,9 @@ contract Token { /// some of the finalization functions (`finalize_transfer_to_private`, `finalize_mint_to_private`). /// Returns a hiding point slot. #[private] - fn prepare_private_balance_increase(to: AztecAddress) -> Field { - let from = context.msg_sender(); + fn prepare_private_balance_increase(to: AztecAddress, from: AztecAddress) -> Field { + // TODO(#9887): ideally we'd not have `from` here, but we do need a `from` address to produce a tagging secret + // with `to`. _prepare_private_balance_increase(from, to, &mut context, storage) } // docs:end:prepare_private_balance_increase diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_to_private.nr b/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_to_private.nr index f48bfb6127e..6c2ce223916 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_to_private.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/test/transfer_to_private.nr @@ -23,7 +23,7 @@ unconstrained fn transfer_to_private_internal_orchestration() { #[test] unconstrained fn transfer_to_private_external_orchestration() { // Setup without account contracts. We are not using authwits here, so dummy accounts are enough - let (env, token_contract_address, _, recipient, amount) = + let (env, token_contract_address, owner, recipient, amount) = utils::setup_and_mint_to_public(/* with_account_contracts */ false); let note_randomness = random(); @@ -33,7 +33,7 @@ unconstrained fn transfer_to_private_external_orchestration() { // We prepare the transfer let hiding_point_slot: Field = Token::at(token_contract_address) - .prepare_private_balance_increase(recipient) + .prepare_private_balance_increase(recipient, owner) .call(&mut env.private()); // Finalize the transfer of the tokens (message sender owns the tokens in public) @@ -72,14 +72,14 @@ unconstrained fn transfer_to_private_transfer_not_prepared() { #[test(should_fail_with = "Assertion failed: attempt to subtract with underflow 'hi == high'")] unconstrained fn transfer_to_private_failure_not_an_owner() { // Setup without account contracts. We are not using authwits here, so dummy accounts are enough - let (env, token_contract_address, _, not_owner, amount) = + let (env, token_contract_address, owner, not_owner, amount) = utils::setup_and_mint_to_public(/* with_account_contracts */ false); // (For this specific test we could set a random value for the commitment and not do the call to `prepare...` // as the token balance check is before we use the value but that would made the test less robust against changes // in the contract.) let hiding_point_slot: Field = Token::at(token_contract_address) - .prepare_private_balance_increase(not_owner) + .prepare_private_balance_increase(not_owner, owner) .call(&mut env.private()); // Try transferring someone else's token balance diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index 3695c499bbf..c49164566fc 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -28,7 +28,7 @@ pub global ARGS_LENGTH: u32 = 16; // "PER CALL" CONSTANTS pub global MAX_NOTE_HASHES_PER_CALL: u32 = 16; pub global MAX_NULLIFIERS_PER_CALL: u32 = 16; -pub global MAX_PRIVATE_CALL_STACK_LENGTH_PER_CALL: u32 = 4; +pub global MAX_PRIVATE_CALL_STACK_LENGTH_PER_CALL: u32 = 5; pub global MAX_ENQUEUED_CALLS_PER_CALL: u32 = 16; pub global MAX_L2_TO_L1_MSGS_PER_CALL: u32 = 2; pub global MAX_PUBLIC_DATA_UPDATE_REQUESTS_PER_CALL: u32 = 64; diff --git a/scripts/ci/get_e2e_jobs.sh b/scripts/ci/get_e2e_jobs.sh index 2dbdb42ac40..ed6379e6a46 100755 --- a/scripts/ci/get_e2e_jobs.sh +++ b/scripts/ci/get_e2e_jobs.sh @@ -20,6 +20,7 @@ full_list=$(get_test_names) allow_list=( "e2e_2_pxes" "e2e_authwit" + "e2e_amm" "e2e_avm_simulator" "e2e_block_building" "e2e_cross_chain_messaging" diff --git a/yarn-project/circuits.js/src/constants.gen.ts b/yarn-project/circuits.js/src/constants.gen.ts index 8672f42cdea..3168b6099f6 100644 --- a/yarn-project/circuits.js/src/constants.gen.ts +++ b/yarn-project/circuits.js/src/constants.gen.ts @@ -4,7 +4,7 @@ export const MAX_FIELD_VALUE = 2188824287183927522224640574525727508854836440041 export const ARGS_LENGTH = 16; export const MAX_NOTE_HASHES_PER_CALL = 16; export const MAX_NULLIFIERS_PER_CALL = 16; -export const MAX_PRIVATE_CALL_STACK_LENGTH_PER_CALL = 4; +export const MAX_PRIVATE_CALL_STACK_LENGTH_PER_CALL = 5; export const MAX_ENQUEUED_CALLS_PER_CALL = 16; export const MAX_L2_TO_L1_MSGS_PER_CALL = 2; export const MAX_PUBLIC_DATA_UPDATE_REQUESTS_PER_CALL = 64; @@ -178,7 +178,7 @@ export const TX_REQUEST_LENGTH = 12; export const TOTAL_FEES_LENGTH = 1; export const TOTAL_MANA_USED_LENGTH = 1; export const HEADER_LENGTH = 25; -export const PRIVATE_CIRCUIT_PUBLIC_INPUTS_LENGTH = 731; +export const PRIVATE_CIRCUIT_PUBLIC_INPUTS_LENGTH = 739; export const PUBLIC_CIRCUIT_PUBLIC_INPUTS_LENGTH = 867; export const PRIVATE_CONTEXT_INPUTS_LENGTH = 38; export const FEE_RECIPIENT_LENGTH = 2; diff --git a/yarn-project/end-to-end/scripts/e2e_test_config.yml b/yarn-project/end-to-end/scripts/e2e_test_config.yml index a09f5f5a568..8a65a011708 100644 --- a/yarn-project/end-to-end/scripts/e2e_test_config.yml +++ b/yarn-project/end-to-end/scripts/e2e_test_config.yml @@ -20,6 +20,7 @@ tests: command: './scripts/e2e_compose_test.sh bench_tx_size' e2e_2_pxes: {} e2e_account_contracts: {} + e2e_amm: {} e2e_authwit: {} e2e_avm_simulator: {} e2e_blacklist_token_contract: {} diff --git a/yarn-project/end-to-end/src/e2e_amm.test.ts b/yarn-project/end-to-end/src/e2e_amm.test.ts new file mode 100644 index 00000000000..6b1d741487f --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_amm.test.ts @@ -0,0 +1,338 @@ +import { type AccountWallet, type DebugLogger, Fr, type Wallet } from '@aztec/aztec.js'; +import { AMMContract, type TokenContract } from '@aztec/noir-contracts.js'; + +import { jest } from '@jest/globals'; + +import { deployToken, mintTokensToPrivate } from './fixtures/token_utils.js'; +import { setup } from './fixtures/utils.js'; + +const TIMEOUT = 120_000; + +describe('AMM', () => { + jest.setTimeout(TIMEOUT); + + let teardown: () => Promise; + + let logger: DebugLogger; + + let adminWallet: AccountWallet; + let liquidityProvider: AccountWallet; + let otherLiquidityProvider: AccountWallet; + let swapper: AccountWallet; + + let token0: TokenContract; + let token1: TokenContract; + let liquidityToken: TokenContract; + + let amm: AMMContract; + + const INITIAL_AMM_TOTAL_SUPPLY = 100000n; + + // We need a large token amount so that the swap fee (0.3%) is observable. + const INITIAL_TOKEN_BALANCE = 1_000_000_000n; + + beforeAll(async () => { + ({ + teardown, + wallets: [adminWallet, liquidityProvider, otherLiquidityProvider, swapper], + logger, + } = await setup(4)); + + token0 = await deployToken(adminWallet, 0n, logger); + token1 = await deployToken(adminWallet, 0n, logger); + liquidityToken = await deployToken(adminWallet, 0n, logger); + + amm = await AMMContract.deploy(adminWallet, token0.address, token1.address, liquidityToken.address) + .send() + .deployed(); + + // TODO(#9480): consider deploying the token by some factory when the AMM is deployed, and making the AMM be the + // minter there. + await liquidityToken.methods.set_minter(amm.address, true).send().wait(); + + // We mint the tokens to both liquidity providers and the swapper + await mintTokensToPrivate(token0, adminWallet, liquidityProvider.getAddress(), INITIAL_TOKEN_BALANCE); + await mintTokensToPrivate(token1, adminWallet, liquidityProvider.getAddress(), INITIAL_TOKEN_BALANCE); + + await mintTokensToPrivate(token0, adminWallet, otherLiquidityProvider.getAddress(), INITIAL_TOKEN_BALANCE); + await mintTokensToPrivate(token1, adminWallet, otherLiquidityProvider.getAddress(), INITIAL_TOKEN_BALANCE); + + // Note that the swapper only holds token0, not token1 + await mintTokensToPrivate(token0, adminWallet, swapper.getAddress(), INITIAL_TOKEN_BALANCE); + }); + + afterAll(() => teardown()); + + describe('full flow', () => { + // This is an integration test in which we perform an entire run of the happy path. Thorough unit testing is not + // included. + + type Balance = { + token0: bigint; + token1: bigint; + }; + + async function getAmmBalances(): Promise { + return { + token0: await token0.methods.balance_of_public(amm.address).simulate(), + token1: await token1.methods.balance_of_public(amm.address).simulate(), + }; + } + + async function getWalletBalances(lp: Wallet): Promise { + return { + token0: await token0.withWallet(lp).methods.balance_of_private(lp.getAddress()).simulate(), + token1: await token1.withWallet(lp).methods.balance_of_private(lp.getAddress()).simulate(), + }; + } + + function assertBalancesDelta(before: Balance, after: Balance, delta: Balance) { + expect(after.token0 - before.token0).toEqual(delta.token0); + expect(after.token1 - before.token1).toEqual(delta.token1); + } + + it('add initial liquidity', async () => { + const ammBalancesBefore = await getAmmBalances(); + const lpBalancesBefore = await getWalletBalances(liquidityProvider); + + const amount0Max = lpBalancesBefore.token0; + const amount0Min = lpBalancesBefore.token0 / 2n; + const amount1Max = lpBalancesBefore.token1; + const amount1Min = lpBalancesBefore.token1 / 2n; + + // First we need to add authwits such that the AMM can transfer the tokens from the liquidity provider. These + // authwits are for the full amount, since the AMM will first transfer that to itself, and later refund any excess + // during public execution. + const nonceForAuthwits = Fr.random(); + await liquidityProvider.createAuthWit({ + caller: amm.address, + action: token0.methods.transfer_to_public( + liquidityProvider.getAddress(), + amm.address, + amount0Max, + nonceForAuthwits, + ), + }); + await liquidityProvider.createAuthWit({ + caller: amm.address, + action: token1.methods.transfer_to_public( + liquidityProvider.getAddress(), + amm.address, + amount1Max, + nonceForAuthwits, + ), + }); + + await amm + .withWallet(liquidityProvider) + .methods.add_liquidity(amount0Max, amount1Max, amount0Min, amount1Min, nonceForAuthwits) + .send() + .wait(); + + const ammBalancesAfter = await getAmmBalances(); + const lpBalancesAfter = await getWalletBalances(liquidityProvider); + + // Since the LP was the first one to enter the pool, the maximum amounts of tokens should have been deposited as + // there is no prior token ratio to follow. + assertBalancesDelta(ammBalancesBefore, ammBalancesAfter, { token0: amount0Max, token1: amount1Max }); + assertBalancesDelta(lpBalancesBefore, lpBalancesAfter, { token0: -amount0Max, token1: -amount1Max }); + + // Liquidity tokens should also be minted for the liquidity provider, as well as locked at the zero address. + const expectedLiquidityTokens = (INITIAL_AMM_TOTAL_SUPPLY * 99n) / 100n; + expect(await liquidityToken.methods.balance_of_private(liquidityProvider.getAddress()).simulate()).toEqual( + expectedLiquidityTokens, + ); + expect(await liquidityToken.methods.total_supply().simulate()).toEqual(INITIAL_AMM_TOTAL_SUPPLY); + }); + + it('add liquidity from another lp', async () => { + // This is the same as when we add liquidity for the first time, but we'll be going through a different code path + // since total supply for the liquidity token is non-zero + + const ammBalancesBefore = await getAmmBalances(); + const lpBalancesBefore = await getWalletBalances(otherLiquidityProvider); + + const liquidityTokenSupplyBefore = await liquidityToken.methods.total_supply().simulate(); + + // The pool currently has the same number of tokens for token0 and token1, since that is the ratio the first + // liquidity provider used. Our maximum values have a diferent ratio (6:5 instead of 1:1), so we will end up + // adding the maximum amount that does result in the correct ratio (i.e. using amount1Max and a 1:1 ratio). + const amount0Max = (lpBalancesBefore.token0 * 6n) / 10n; + const amount0Min = (lpBalancesBefore.token0 * 4n) / 10n; + const amount1Max = (lpBalancesBefore.token1 * 5n) / 10n; + const amount1Min = (lpBalancesBefore.token1 * 4n) / 10n; + + const expectedAmount0 = amount1Max; + const expectedAmount1 = amount1Max; + + // We again add authwits such that the AMM can transfer the tokens from the liquidity provider. These authwits are + // for the full amount, since the AMM will first transfer that to itself, and later refund any excess during + // public execution. We expect for there to be excess since our maximum amounts do not have the same balance ratio + // as the pool currently holds. + const nonceForAuthwits = Fr.random(); + await otherLiquidityProvider.createAuthWit({ + caller: amm.address, + action: token0.methods.transfer_to_public( + otherLiquidityProvider.getAddress(), + amm.address, + amount0Max, + nonceForAuthwits, + ), + }); + await otherLiquidityProvider.createAuthWit({ + caller: amm.address, + action: token1.methods.transfer_to_public( + otherLiquidityProvider.getAddress(), + amm.address, + amount1Max, + nonceForAuthwits, + ), + }); + + await amm + .withWallet(otherLiquidityProvider) + .methods.add_liquidity(amount0Max, amount1Max, amount0Min, amount1Min, nonceForAuthwits) + .send() + .wait(); + + const ammBalancesAfter = await getAmmBalances(); + const lpBalancesAfter = await getWalletBalances(otherLiquidityProvider); + + assertBalancesDelta(ammBalancesBefore, ammBalancesAfter, { token0: expectedAmount0, token1: expectedAmount1 }); + assertBalancesDelta(lpBalancesBefore, lpBalancesAfter, { token0: -expectedAmount0, token1: -expectedAmount1 }); + + // The liquidity token supply should have grown with the same proportion as the pool balances + const expectedTotalSupply = + (liquidityTokenSupplyBefore * (ammBalancesBefore.token0 + expectedAmount0)) / ammBalancesBefore.token0; + const expectedLiquidityTokens = expectedTotalSupply - INITIAL_AMM_TOTAL_SUPPLY; + + expect(await liquidityToken.methods.total_supply().simulate()).toEqual(expectedTotalSupply); + expect(await liquidityToken.methods.balance_of_private(otherLiquidityProvider.getAddress()).simulate()).toEqual( + expectedLiquidityTokens, + ); + }); + + it('swap exact tokens in', async () => { + const swapperBalancesBefore = await getWalletBalances(swapper); + const ammBalancesBefore = await getAmmBalances(); + + // The token in will be token0 + const amountIn = swapperBalancesBefore.token0 / 10n; + + // Swaps also transfer tokens into the AMM, so we provide an authwit for the full amount in. + const nonceForAuthwits = Fr.random(); + await swapper.createAuthWit({ + caller: amm.address, + action: token0.methods.transfer_to_public(swapper.getAddress(), amm.address, amountIn, nonceForAuthwits), + }); + + // We compute the expected amount out and set it as the minimum. In a real-life scenario we'd choose a slightly + // lower value to account for slippage, but since we're the only actor interacting with the AMM we can afford to + // just pass the exact value. Of course any lower value would also suffice. + const amountOutMin = await amm.methods + .get_amount_out_for_exact_in(ammBalancesBefore.token0, ammBalancesBefore.token1, amountIn) + .simulate(); + await amm + .withWallet(swapper) + .methods.swap_exact_tokens_for_tokens(token0.address, token1.address, amountIn, amountOutMin, nonceForAuthwits) + .send() + .wait(); + + // We know exactly how many tokens we're supposed to get because we know nobody else interacted with the AMM + // before we did. + const swapperBalancesAfter = await getWalletBalances(swapper); + assertBalancesDelta(swapperBalancesBefore, swapperBalancesAfter, { token0: -amountIn, token1: amountOutMin }); + }); + + it('swap exact tokens out', async () => { + const swapperBalancesBefore = await getWalletBalances(swapper); + const ammBalancesBefore = await getAmmBalances(); + + // We want to undo the previous swap (except for the fees, which we can't recover), so we try to send the full + // token1 balance (since the swapper held no token1 tokens prior to the swap). However, we're using the method + // that receives an exact amount of tokens *out*, not in, so we can't quite specify this. What we do instead is + // query the contract for how much token0 we'd get if we sent our entire token1 balance, and then request exactly + // that amount. This would fail in a real-life scenario since we'd need to account for slippage, but we can do it + // in this test environment since there's nobody else interacting with the AMM. + const amountOut = await amm.methods + .get_amount_out_for_exact_in(ammBalancesBefore.token1, ammBalancesBefore.token0, swapperBalancesBefore.token1) + .simulate(); + const amountInMax = swapperBalancesBefore.token1; + + // Swaps also transfer tokens into the AMM, so we provide an authwit for the full amount in (any change will be + // later returned, though in this case there won't be any). + const nonceForAuthwits = Fr.random(); + await swapper.createAuthWit({ + caller: amm.address, + action: token1.methods.transfer_to_public(swapper.getAddress(), amm.address, amountInMax, nonceForAuthwits), + }); + + await amm + .withWallet(swapper) + .methods.swap_tokens_for_exact_tokens(token1.address, token0.address, amountOut, amountInMax, nonceForAuthwits) + .send() + .wait(); + + // Because nobody else interacted with the AMM, we know the amount in will be the maximum (i.e. the value the + // contract returned as what we'd need to send in order to get the amount out we requested). + const swapperBalancesAfter = await getWalletBalances(swapper); + assertBalancesDelta(swapperBalancesBefore, swapperBalancesAfter, { token0: amountOut, token1: -amountInMax }); + + // We can also check that the swapper ends up with fewer tokens than they started with, since they had to pay + // swap fees during both swaps. + expect(swapperBalancesAfter.token0).toBeLessThan(INITIAL_TOKEN_BALANCE); + }); + + it('remove liquidity', async () => { + // We now withdraw all of the tokens of one of the liquidity providers by burning their entire liquidity token + // balance. + const liquidityTokenBalance = await liquidityToken + .withWallet(otherLiquidityProvider) + .methods.balance_of_private(otherLiquidityProvider.getAddress()) + .simulate(); + + // Because private burning requires first transfering the tokens into the AMM, we again need to provide an + // authwit. + const nonceForAuthwits = Fr.random(); + await otherLiquidityProvider.createAuthWit({ + caller: amm.address, + action: liquidityToken.methods.transfer_to_public( + otherLiquidityProvider.getAddress(), + amm.address, + liquidityTokenBalance, + nonceForAuthwits, + ), + }); + + // We don't bother setting the minimum amounts, since we know nobody else is interacting with the AMM. In a + // real-life scenario we'd need to choose sensible amounts to avoid losing value due to slippage. + const amount0Min = 1n; + const amount1Min = 1n; + await amm + .withWallet(otherLiquidityProvider) + .methods.remove_liquidity(liquidityTokenBalance, amount0Min, amount1Min, nonceForAuthwits) + .send() + .wait(); + + // The liquidity provider should have no remaining liquidity tokens, and should have recovered the value they + // originally deposited. + expect( + await liquidityToken + .withWallet(otherLiquidityProvider) + .methods.balance_of_private(otherLiquidityProvider.getAddress()) + .simulate(), + ).toEqual(0n); + + // We now assert that the liquidity provider ended up with more tokens than they began with. These extra tokens + // come from the swap fees paid during each of the swaps. While swap fees are always collected on the token in, + // the net fees will all be accrued on token0 due to how the swaps were orchestrated. This can be intuited by the + // fact that the swapper held no token1 initially, so it'd be impossible for them to cause an increase in the + // AMM's token1 balance. + // We perform this test using the second liquidity provider, since the first one did lose some percentage of the + // value of their deposit during setup when liquidity was locked by minting tokens for the zero address. + const lpBalancesAfter = await getWalletBalances(otherLiquidityProvider); + expect(lpBalancesAfter.token0).toBeGreaterThan(INITIAL_TOKEN_BALANCE); + expect(lpBalancesAfter.token1).toEqual(INITIAL_TOKEN_BALANCE); + }); + }); +});