From 90668c35a8556c4e77fce9fb4e6e0de931c7f872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Tue, 3 Dec 2024 14:34:23 -0300 Subject: [PATCH] feat: AMM (#10153) Opening in favor of https://github.com/AztecProtocol/aztec-packages/pull/8644 due to @benesjan being away on vacation, and that PR using Graphite. Work here starts from commit a404b58e7d049ee7a56310702046f03a624fd1ee, which has been squashed into 81d7607d9d551aea4d6de78ec3ff535ec5d5a29a. This is the first implementation of Uniswap v2 style AMM that provides identity privacy. The contract is a single pool for two tokens with a fixed 0.3% swap fee. Adding and removing liquidity is done proportionally to the current ratio, resulting in no price impact and therefore no fees. Swaps can be performed by specifying either the amount in or the amount out. All three operations are completed in a single transaction each by leveraging partial notes. I created https://github.com/AztecProtocol/aztec-packages/issues/10225 to track pending work. Some of the tasks in that epic are only work that arises from the AMM, but are not technically required to have a fully functioning system: this PR already achieves that. Only a happy-path end to end test is included here, since TXE currently lacks some of the features required in order to properly deal with partial notes. We should later write those as this will be a good test of TXE's capabilities and user experience, given the complexity of the setup. --- I added the e2e to be run on CI on all branches since it combines multiple complex features, and likely contains our largest transactions yet. --- .../src/core/libraries/ConstantsGen.sol | 4 +- .../aztec-nr/aztec/src/macros/mod.nr | 8 +- noir-projects/noir-contracts/Nargo.toml | 1 + .../contracts/amm_contract/Nargo.toml | 9 + .../contracts/amm_contract/src/config.nr | 29 + .../contracts/amm_contract/src/lib.nr | 96 ++++ .../contracts/amm_contract/src/main.nr | 531 ++++++++++++++++++ .../lending_contract/src/interest_math.nr | 2 +- .../contracts/token_contract/src/main.nr | 5 +- .../src/test/transfer_to_private.nr | 8 +- .../crates/types/src/constants.nr | 2 +- scripts/ci/get_e2e_jobs.sh | 1 + yarn-project/circuits.js/src/constants.gen.ts | 4 +- .../end-to-end/scripts/e2e_test_config.yml | 1 + yarn-project/end-to-end/src/e2e_amm.test.ts | 338 +++++++++++ 15 files changed, 1023 insertions(+), 16 deletions(-) create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/Nargo.toml create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/src/config.nr create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/src/lib.nr create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/src/main.nr create mode 100644 yarn-project/end-to-end/src/e2e_amm.test.ts 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); + }); + }); +});