From 81d7607d9d551aea4d6de78ec3ff535ec5d5a29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Fri, 22 Nov 2024 20:03:39 +0000 Subject: [PATCH] draft: amm --- noir-projects/noir-contracts/Nargo.toml | 1 + .../contracts/amm_contract/Nargo.toml | 11 + .../contracts/amm_contract/src/lib.nr | 61 +++ .../contracts/amm_contract/src/main.nr | 477 ++++++++++++++++++ .../contracts/amm_contract/src/state.nr | 29 ++ .../contracts/amm_contract/src/test.nr | 2 + .../amm_contract/src/test/full_flow.nr | 115 +++++ .../contracts/amm_contract/src/test/utils.nr | 87 ++++ .../lending_contract/src/interest_math.nr | 2 +- yarn-project/end-to-end/src/e2e_amm.test.ts | 155 ++++++ 10 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/Nargo.toml 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 noir-projects/noir-contracts/contracts/amm_contract/src/state.nr create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/src/test.nr create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/src/test/full_flow.nr create mode 100644 noir-projects/noir-contracts/contracts/amm_contract/src/test/utils.nr create mode 100644 yarn-project/end-to-end/src/e2e_amm.test.ts 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..f2b6f48ba03 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/Nargo.toml @@ -0,0 +1,11 @@ +[package] +name = "amm_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../aztec-nr/aztec" } +token = { path = "../token_contract" } +uint_note = { path = "../../../aztec-nr/uint-note" } +authwit = { path = "../../../aztec-nr/authwit" } 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..29faca8f887 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/lib.nr @@ -0,0 +1,61 @@ +/// Given some amount of an asset and pair reserves, returns an equivalent amount of the other asset. +/// copy of https://github.com/Uniswap/v2-periphery/blob/0335e8f7e1bd1e8d8329fd300aea2ef2f36dd19f/contracts/libraries/UniswapV2Library.sol#L36 +fn get_quote(amountA: U128, reserveA: U128, reserveB: U128) -> U128 { + assert(amountA > U128::zero(), "INSUFFICIENT_AMOUNT"); + assert((reserveA > U128::zero()) & (reserveB > U128::zero()), "INSUFFICIENT_LIQUIDITY"); + (amountA * reserveB) / reserveA +} + +/// Given an input amount of an asset and pair reserves, returns the maximum output amount of the other asset. +/// copy of https://github.com/Uniswap/v2-periphery/blob/0335e8f7e1bd1e8d8329fd300aea2ef2f36dd19f/contracts/libraries/UniswapV2Library.sol#L43 +pub fn get_amount_out(amount_in: U128, reserve_in: U128, reserve_out: U128) -> U128 { + assert(amount_in > U128::zero(), "INSUFFICIENT_INPUT_AMOUNT"); + assert((reserve_in > U128::zero()) & (reserve_out > U128::zero()), "INSUFFICIENT_LIQUIDITY"); + let amount_in_with_fee = amount_in * U128::from_integer(997); + let numerator = amount_in_with_fee * reserve_out; + let denominator = reserve_in * U128::from_integer(1000) + amount_in_with_fee; + numerator / denominator +} + +/// Given an output amount of an asset and pair reserves, returns a required input amount of the other asset. +/// copy of https://github.com/Uniswap/v2-periphery/blob/0335e8f7e1bd1e8d8329fd300aea2ef2f36dd19f/contracts/libraries/UniswapV2Library.sol#L53 +pub fn get_amount_in(amount_out: U128, reserve_in: U128, reserve_out: U128) -> U128 { + assert(amount_out > U128::zero(), "INSUFFICIENT_OUTPUT_AMOUNT"); + assert((reserve_in > U128::zero()) & (reserve_out > U128::zero()), "INSUFFICIENT_LIQUIDITY"); + let numerator = reserve_in * amount_out * U128::from_integer(1000); + let denominator = (reserve_out - amount_out) * U128::from_integer(997); + (numerator / denominator) + U128::from_integer(1) +} + +/// Given the desired amounts and reserves 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_desired: U128, + amount1_desired: U128, + amount0_min: U128, + amount1_min: U128, + reserve0: U128, + reserve1: U128, +) -> (U128, U128) { + let mut amount0 = amount0_desired; + let mut amount1 = amount1_desired; + if ((reserve0 != U128::zero()) | (reserve1 != U128::zero())) { + // First calculate the optimal amount of token1 based on the desired amount of token0. + let amount1_optimal = get_quote(amount0_desired, reserve0, reserve1); + if (amount1_optimal <= amount1_desired) { + // Revert if the optimal amount of token1 is less than the desired amount of token1. + assert(amount1_optimal >= amount1_min, "INSUFFICIENT_1_AMOUNT"); + amount0 = amount0_desired; + amount1 = amount1_optimal; + } else { + // We got more amount of token1 than desired so we try repeating the process but this time by quoting + // based on token1. + let amount0_optimal = get_quote(amount1_desired, reserve1, reserve0); + assert(amount0_optimal <= amount0_desired); + assert(amount0_optimal >= amount0_min, "INSUFFICIENT_0_AMOUNT"); + amount0 = amount0_optimal; + amount1 = amount1_desired; + } + } + + (amount0, amount1) +} 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..a1a4cdb0013 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/main.nr @@ -0,0 +1,477 @@ +mod lib; +mod state; +mod test; + +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. +/// +/// **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::{lib::{get_amount_in, get_amount_out, get_amounts_to_add}, state::State}; + use dep::aztec::{ + macros::{ + events::event, + functions::{initializer, internal, private, public, view}, + storage::storage, + }, + prelude::{AztecAddress, SharedImmutable}, + }; + use dep::token::Token; + + #[storage] + struct Storage { + // The following is only needed in private but we use ShareImmutable here instead of PrivateImmutable because + // the value can be publicly known and SharedImmutable provides us with a better devex here because we don't + // have to bother with sharing the note between pixies of users. + state: SharedImmutable, + } + + /// Amount of liquidity which gets locked token_contract0l when liquidity is provided for the first ttoken_contract0purpose + /// is to prevent the pool from ever emptying which could lead to undefined behavior. + global MINIMUM_LIQUIDITY = U128::from_integer(1000); + // We set it to 9 times the minimum liquidity. That way the first LP gets 90% of the value of his deposit. + global INITIAL_LIQUIDITY = U128::from_integer(9000); + + // Note: Since we don't have inheritance it seems the easiest to deploy the standard token and use it as + // a liquidity tracking contract. This contract would be an admin of the liquidity contract. + // TODO(#9480): Either deploy the liquidity contract in the constructor or verify it that it corresponds to what + // this contract expects. + #[public] + #[initializer] + fn constructor(token0: AztecAddress, token1: AztecAddress, liquidity_token: AztecAddress) { + storage.state.initialize(State { token0, token1, liquidity_token }); + } + + /// Privately adds liquidity to the pool (identity of liquidity provider not revealed). `amount0_desired` + /// and `amount1_desired` are the amounts of tokens we ideally want to add. `amount0_min` and `amount1_min` + /// are the minimum amounts we are willing to add. `nonce` can be arbitrary non-zero value and it's here to + /// isolate authwits to this specific call. + #[private] + fn add_liquidity( + amount0_desired: Field, + amount1_desired: Field, + amount0_min: Field, + amount1_min: Field, + nonce: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let amount0_desired = U128::from_integer(amount0_desired); + let amount1_desired = U128::from_integer(amount1_desired); + + assert( + amount0_desired > U128::zero() & amount1_desired > U128::zero(), + "INSUFFICIENT_INPUT_AMOUNTS", + ); + + let state = storage.state.read_private(); + + let token0 = Token::at(state.token0); + let token1 = Token::at(state.token1); + let liquidity_token = Token::at(state.liquidity_token); + + // We transfer the desired amounts of tokens to this contract. + token0 + .transfer_to_public( + context.msg_sender(), + context.this_address(), + amount0_desired.to_integer(), + nonce, + ) + .call(&mut context); + token1 + .transfer_to_public( + context.msg_sender(), + context.this_address(), + amount1_desired.to_integer(), + nonce, + ) + .call(&mut context); + + // We may need to return some token amounts depending on public state (i.e. if the desired amounts do + // not have the same ratio as the live reserves), so we prepare partial notes for the refunds. + let refund_token0_hiding_point_slot = + token0.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + let refund_token1_hiding_point_slot = + token1.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + // We prepare a partial note for the liquidity tokens. + let liquidity_hiding_point_slot = + liquidity_token.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + + AMM::at(context.this_address()) + ._add_liquidity( + state, + refund_token0_hiding_point_slot, + refund_token1_hiding_point_slot, + liquidity_hiding_point_slot, + amount0_desired.to_integer(), + amount1_desired.to_integer(), + amount0_min, + amount1_min, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _add_liquidity( + // We pass the state as an argument in order to not have to read it from public storage again. + state: State, + refund_token0_hiding_point_slot: Field, + refund_token1_hiding_point_slot: Field, + liquidity_hiding_point_slot: Field, + amount0_desired: Field, + amount1_desired: Field, + amount0_min: Field, + amount1_min: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let amount0_desired = U128::from_integer(amount0_desired); + let amount1_desired = U128::from_integer(amount1_desired); + let amount0_min = U128::from_integer(amount0_min); + let amount1_min = U128::from_integer(amount1_min); + + let token0 = Token::at(state.token0); + let token1 = Token::at(state.token1); + let liquidity_token = Token::at(state.liquidity_token); + + let reserve0_with_amount0_desired = U128::from_integer(token0 + .balance_of_public(context.this_address()) + .view(&mut context)); + let reserve1_with_amount1_desired = U128::from_integer(token1 + .balance_of_public(context.this_address()) + .view(&mut context)); + + let reserve0 = reserve0_with_amount0_desired - amount0_desired; + let reserve1 = reserve1_with_amount1_desired - amount1_desired; + + // Calculate the amounts to be added to the pool + let (amount0, amount1) = get_amounts_to_add( + amount0_desired, + amount1_desired, + amount0_min, + amount1_min, + reserve0, + reserve1, + ); + + let refund_amount_token0 = amount0_desired - amount0; + let refund_amount_token1 = amount1_desired - amount1; + + // The refund does not need to be finalized if the refund amount is 0 --> the partial note will simply stay in + // public storage, which is fine. + if (refund_amount_token0 > U128::zero()) { + token0 + .finalize_transfer_to_private( + refund_token0_hiding_point_slot, + refund_amount_token0.to_integer(), + ) + .call(&mut context); + } + if (refund_amount_token1 > U128::zero()) { + token1 + .finalize_transfer_to_private( + refund_token1_hiding_point_slot, + refund_amount_token1.to_integer(), + ) + .call(&mut context); + } + + // Calculate the amount of liquidity tokens to mint + let total_supply = U128::from_integer(liquidity_token.total_supply().view(&mut context)); + let mut liquidity = U128::zero(); + if (total_supply == U128::zero()) { + // Since we don't collect a protocol fee (unlike Uniswap V2) we can set initial liquidity to an arbitrary + // value instead of sqrt(amount0 * amount1). + liquidity = INITIAL_LIQUIDITY; + liquidity_token + .mint_to_public(AztecAddress::zero(), MINIMUM_LIQUIDITY.to_integer()) + .call(&mut context); // permanently lock the first MINIMUM_LIQUIDITY tokens + } else { + liquidity = std::cmp::min( + amount0 * total_supply / reserve0, + amount1 * total_supply / reserve1, + ); + } + assert(liquidity > U128::zero(), "INSUFFICIENT_LIQUIDITY_MINTED"); + liquidity_token + .finalize_mint_to_private(liquidity.to_integer(), liquidity_hiding_point_slot) + .call(&mut context); + } + + /// Removes `liquidity` from the pool and transfers the tokens back to the user. `amount0_min` and `amount1_min` are + /// the minimum amounts of `token0` and `token1` the user is willing to accept. `nonce` can be arbitrary non-zero + /// value and its purpose is to isolate authwits to this specific call. + /// TODO(#8271): Type the args as U128 + #[private] + fn remove_liquidity(liquidity: Field, amount0_min: Field, amount1_min: Field, nonce: Field) { + let state = storage.state.read_private(); + + let liquidity_token = Token::at(state.liquidity_token); + let token0 = Token::at(state.token0); + let token1 = Token::at(state.token1); + + // We transfer the liquidity tokens to this contract and prepare partial notes for the output tokens. We are + // forced to first transfer into the AMM because it is not possible to burn in private - the enqueued public + // call would reveal who the owner was. The only way to preserve their identity is to first privately transfer. + liquidity_token + .transfer_to_public(context.msg_sender(), context.this_address(), liquidity, nonce) + .call(&mut context); + let token0_hiding_point_slot = + token0.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + let token1_hiding_point_slot = + token1.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + + AMM::at(context.this_address()) + ._remove_liquidity( + state, + token0_hiding_point_slot, + token1_hiding_point_slot, + liquidity, + amount0_min, + amount1_min, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _remove_liquidity( + // We pass the state as an argument in order to not have to read it from public storage again. + state: State, + token0_hiding_point_slot: Field, + token1_hiding_point_slot: Field, + liquidity: 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(state.token0); + let token1 = Token::at(state.token1); + let liquidity_token = Token::at(state.liquidity_token); + + // We get the reserves and the liquidity token total supply. + let reserve0 = U128::from_integer(token0.balance_of_public(context.this_address()).view( + &mut context, + )); + let reserve1 = 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. + let amount0 = liquidity * reserve0 / total_supply; + let amount1 = liquidity * reserve1 / total_supply; + + // We check if the amounts are greater than the minimum amounts the user is willing to accept. + assert(amount0 >= amount0_min, "INSUFFICIENT_0_AMOUNT"); + assert(amount1 >= amount1_min, "INSUFFICIENT_1_AMOUNT"); + + // At last we burn the liquidity tokens and transfer the token0 and token1 to the user. + 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, + ); + } + + /// Swaps `amount_in` of `token_in` for at least `amount_out_min` of `token_out`. The `from_0_to_1` flag indicates + /// whether we are swapping `token0` for `token1` or vice versa. `nonce` can be arbitrary non-zero value and its + /// purpose is to isolate authwits to this specific call. + /// TODO(#8271): Type the args as U128 + #[private] + fn swap_exact_tokens_for_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in: Field, + amount_out_min: Field, + nonce: Field, + ) { + let state = storage.state.read_private(); + + // We check the tokens are valid + assert(token_in != token_out); + assert((token_in == state.token0) | (token_in == state.token1)); + assert((token_out == state.token0) | (token_out == state.token1)); + + let token_in_contract = Token::at(token_in); + let token_out_contract = Token::at(token_out); + + // We transfer the `amount_in` to this contract and we prepare partial note for the output token. + token_in_contract + .transfer_to_public(context.msg_sender(), context.this_address(), amount_in, nonce) + .call(&mut context); + let token_out_hiding_point_slot = + token_out_contract.prepare_private_balance_increase(context.msg_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); + + let token_in_contract = Token::at(token_in); + let token_out_contract = Token::at(token_out); + + // We get the reserves. The `amount_in` was already transferred to this contract so we need to subtract it. + let reserve_in_with_amount_in = U128::from_integer(token_in_contract + .balance_of_public(context.this_address()) + .view(&mut context)); + let reserve_in = reserve_in_with_amount_in - amount_in; + let reserve_out = U128::from_integer(token_out_contract + .balance_of_public(context.this_address()) + .view(&mut context)); + + // Calculate the amount of output token we will get. + let amount_out = get_amount_out(amount_in, reserve_in, reserve_out); + assert(amount_out >= amount_out_min, "INSUFFICIENT_OUTPUT_AMOUNT"); + + // Transfer the output token to the user. + token_out_contract + .finalize_transfer_to_private(amount_out.to_integer(), token_out_hiding_point_slot) + .call(&mut context); + } + + /// Swaps `amount_out` of `token_out` for at most `amount_in_max` of `token_in`. The `from_0_to_1` flag indicates + /// whether we are swapping `token0` for `token1` or vice versa. `nonce` can be arbitrary non-zero value and its + /// purpose is to isolate authwits to this specific call. + #[private] + fn swap_tokens_for_exact_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_out: Field, + amount_in_max: Field, + nonce: Field, + ) { + // TODO(#8271): Type the args as U128 and nuke these ugly casts + let amount_out = U128::from_integer(amount_out); + + let state = storage.state.read_private(); + + // We check the tokens are valid + assert(token_in != token_out); + assert((token_in == state.token0) | (token_in == state.token1)); + assert((token_out == state.token0) | (token_out == state.token1)); + + let token_in_contract = Token::at(token_in); + let token_out_contract = Token::at(token_out); + + // We transfer the `amount_in_max` to this contract and we prepare partial notes for refund and for the output + // token. + token_in_contract + .transfer_to_public(context.msg_sender(), context.this_address(), amount_in_max, nonce) + .call(&mut context); + let refund_token_in_hiding_point_slot = + token_in_contract.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + let token_out_hiding_point_slot = + token_out_contract.prepare_private_balance_increase(context.msg_sender()).call(&mut context); + + AMM::at(context.this_address()) + ._swap_tokens_for_exact_tokens( + token_in, + token_out, + amount_in_max, + amount_out.to_integer(), + refund_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, + refund_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); + + let token_in_contract = Token::at(token_in); + let token_out_contract = Token::at(token_out); + + // We get the reserves. The `amount_in_max` was already transferred to this contract so we need to subtract it. + let reserve_in_with_amount_in_max = U128::from_integer(token_in_contract + .balance_of_public(context.this_address()) + .view(&mut context)); + let reserve_in = reserve_in_with_amount_in_max - amount_in_max; + let reserve_out = U128::from_integer(token_out_contract + .balance_of_public(context.this_address()) + .view(&mut context)); + + // Calculate the amount of input token needed to get the desired amount of output token. + let amount_in = get_amount_in(amount_out, reserve_in, reserve_out); + assert(amount_in <= amount_in_max, "EXCESSIVE_INPUT_AMOUNT"); + + // If less than amount_in_max of input token was needed we refund the difference. + let refund_amount = amount_in_max - amount_in; + if (refund_amount > U128::zero()) { + token_in_contract + .finalize_transfer_to_private( + refund_amount.to_integer(), + refund_token_in_hiding_point_slot, + ) + .call(&mut context); + } + + // Transfer the output token to the user. + token_out_contract + .finalize_transfer_to_private(amount_out.to_integer(), token_out_hiding_point_slot) + .call(&mut context); + } +} diff --git a/noir-projects/noir-contracts/contracts/amm_contract/src/state.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/state.nr new file mode 100644 index 00000000000..b1d126af2b4 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/state.nr @@ -0,0 +1,29 @@ +use dep::aztec::protocol_types::{address::AztecAddress, traits::{Deserialize, Serialize}}; + +global STATE_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). +struct State { + token0: AztecAddress, + token1: AztecAddress, + liquidity_token: AztecAddress, +} + +// Note: I could not get #[derive(Serialize)] to work so I had to implement it manually. +impl Serialize for State { + fn serialize(self: Self) -> [Field; STATE_LENGTH] { + [self.token0.to_field(), self.token1.to_field(), self.liquidity_token.to_field()] + } +} + +impl Deserialize for State { + fn deserialize(fields: [Field; STATE_LENGTH]) -> Self { + State { + 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/test.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/test.nr new file mode 100644 index 00000000000..28ba21602e1 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/test.nr @@ -0,0 +1,2 @@ +mod full_flow; +mod utils; diff --git a/noir-projects/noir-contracts/contracts/amm_contract/src/test/full_flow.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/test/full_flow.nr new file mode 100644 index 00000000000..7c391e0ed9b --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/test/full_flow.nr @@ -0,0 +1,115 @@ +use crate::{AMM, test::utils::setup}; +use dep::authwit::cheatcodes::add_private_authwit_from_call_interface; +use dep::token::{test::utils::{add_token_note, check_private_balance, check_public_balance}, Token}; +use aztec::oracle::random::random; +use std::test::OracleMock; + +#[test] +unconstrained fn full_flow() { + // Setup + let (env, amm_address, token0_address, token1_address, liquidity_token_address, liquidity_provider, swapper, lp_balance_0, lp_balance_1, swapper_balance_0) = + setup(); + let amm = AMM::at(amm_address); + + // ADDING LIQUIDITY + // Ideally we would like to deposit all the tokens from the liquidity provider + let amount0_desired = lp_balance_0; + let amount1_desired = lp_balance_1; + let amount0_min = lp_balance_0 / 2; + let amount1_min = lp_balance_1 / 2; + + // First we need to add authwits such that the AMM can transfer the tokens from the liquidity provider + // The only purpose of this nonce is to make the authwit unique (function args are part of authwit hash preimage) + let nonce_for_authwits = random(); + add_private_authwit_from_call_interface( + liquidity_provider, + amm_address, + Token::at(token0_address).transfer_to_public( + liquidity_provider, + amm_address, + amount0_desired, + nonce_for_authwits, + ), + ); + add_private_authwit_from_call_interface( + liquidity_provider, + amm_address, + Token::at(token1_address).transfer_to_public( + liquidity_provider, + amm_address, + amount1_desired, + nonce_for_authwits, + ), + ); + + // We fix the note randomness as we need to add the notes manually. This will go away once #8771 is implemented. + let note_randomness = random(); + let _ = OracleMock::mock("getRandomField").returns(note_randomness); + + // Now we can add liquidity + amm + .add_liquidity( + amount0_desired, + amount1_desired, + amount0_min, + amount1_min, + nonce_for_authwits, + ) + .call(&mut env.private()); + + // Since there was no liquidity in the pool before the pool should take the desired amounts of tokens in public + // The AMM should therefore hold the desired amounts + check_public_balance(token0_address, amm_address, amount0_desired); + check_public_balance(token1_address, amm_address, amount1_desired); + + // Initial liquidity amount of liquidity token should have been minted to the liquidity provider + // Since it was minted in private and #8771 is not yet implemented we need to add the note + add_token_note( + env, + liquidity_token_address, + liquidity_provider, + AMM::INITIAL_LIQUIDITY.to_integer(), + note_randomness, + ); + check_private_balance( + liquidity_token_address, + liquidity_provider, + AMM::INITIAL_LIQUIDITY.to_integer(), + ); + + // The AMM should have locked minimum liquidity to itself in public + check_public_balance( + liquidity_token_address, + amm_address, + AMM::MINIMUM_LIQUIDITY.to_integer(), + ); + + // SWAPPING + // Now we will try to swap the full balance of the swapper + let amount_in = swapper_balance_0; + // We don't care about slippage protection here so we set out min to 0 + let amount_out_min = 0; + + // We need to add authwits such that the AMM can transfer the tokens from the swapper + add_private_authwit_from_call_interface( + swapper, + amm_address, + Token::at(token0_address).transfer_to_public( + swapper, + amm_address, + amount_in, + nonce_for_authwits, + ), + ); + + // Now we can swap + amm + .swap_exact_tokens_for_tokens( + token0_address, + token1_address, + amount_in, + amount_out_min, + nonce_for_authwits, + ) + .call(&mut env.private()); +} diff --git a/noir-projects/noir-contracts/contracts/amm_contract/src/test/utils.nr b/noir-projects/noir-contracts/contracts/amm_contract/src/test/utils.nr new file mode 100644 index 00000000000..418883cdc91 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/amm_contract/src/test/utils.nr @@ -0,0 +1,87 @@ +use crate::AMM; +use dep::token::{test::utils::{check_private_balance, mint_to_private}, Token}; +use aztec::{ + keys::getters::get_public_keys, + oracle::{ + execution::{get_block_number, get_contract_address}, + random::random, + storage::storage_read, + }, + prelude::AztecAddress, + protocol_types::storage::map::derive_storage_slot_in_map, + test::helpers::{cheatcodes, test_environment::TestEnvironment}, +}; + +pub unconstrained fn setup() -> (&mut TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress, AztecAddress, AztecAddress, Field, Field, Field) { + // Setup env, generate keys + let mut env = TestEnvironment::new(); + + // For token admin we don't ever need authwits so we don't need to deploy an account contract + let token_admin = env.create_account(); + // For the liquidity provider and swapper we do need authwits + let liquidity_provider = env.create_account_contract(1); + let swapper = env.create_account_contract(2); + + // Start the test in the account contract address + env.impersonate(token_admin); + + // Deploy tokens to be swapped and a liquidity token + let token0_address = env + .deploy("./@token_contract", "Token") + .with_public_void_initializer(Token::interface().constructor( + token_admin, + "TestToken0000000000000000000000", + "TT00000000000000000000000000000", + 18, + )) + .to_address(); + + let token1_address = env + .deploy("./@token_contract", "Token") + .with_public_void_initializer(Token::interface().constructor( + token_admin, + "TestToken1000000000000000000000", + "TT10000000000000000000000000000", + 18, + )) + .to_address(); + + let liquidity_token_address = env + .deploy("./@token_contract", "Token") + .with_public_void_initializer(Token::interface().constructor( + token_admin, + "TestLiquidityToken0000000000000", + "TLT0000000000000000000000000000", + 18, + )) + .to_address(); + + let amm_address = env + .deploy_self("AMM") + .with_public_void_initializer(AMM::interface().constructor( + token0_address, + token1_address, + liquidity_token_address, + )) + .to_address(); + + // Now we mint both tokens to the liquidity provider and token0 to swapper + let lp_balance_0 = 20000; + let lp_balance_1 = 10000; + let swapper_balance_0 = 5000; + + mint_to_private(&mut env, token0_address, liquidity_provider, lp_balance_0); + check_private_balance(token0_address, liquidity_provider, lp_balance_0); + + mint_to_private(&mut env, token1_address, liquidity_provider, lp_balance_1); + check_private_balance(token1_address, liquidity_provider, lp_balance_1); + + mint_to_private(&mut env, token0_address, swapper, swapper_balance_0); + check_private_balance(token0_address, swapper, swapper_balance_0); + + env.advance_block_by(1); + ( + &mut env, amm_address, token0_address, token1_address, liquidity_token_address, + liquidity_provider, swapper, lp_balance_0, lp_balance_1, swapper_balance_0, + ) +} 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/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..0424f2c5c79 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_amm.test.ts @@ -0,0 +1,155 @@ +import { type AccountWallet, type DebugLogger, Fr } 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; + +// This is a very simple test checking only the happy path. More complete tests might be done in an unimaginably far +// future (and hence irrelevant) once I return from Patagonia. +describe('AMM', () => { + jest.setTimeout(TIMEOUT); + + let teardown: () => Promise; + + let logger: DebugLogger; + + let adminWallet: AccountWallet; + let liquidityProvider: AccountWallet; + let swapper: AccountWallet; + + let token0: TokenContract; + let token1: TokenContract; + let liquidityToken: TokenContract; + + let amm: AMMContract; + + const lpBalance0 = 20000n; + const lpBalance1 = 10000n; + const swapperBalance0 = 5000n; + + beforeAll(async () => { + let wallets: AccountWallet[]; + ({ teardown, wallets, logger } = await setup(3)); + [adminWallet, liquidityProvider, swapper] = wallets; + + 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(); + + // We mint the tokens to lp and swapper + await mintTokensToPrivate(token0, adminWallet, liquidityProvider.getAddress(), lpBalance0); + await mintTokensToPrivate(token1, adminWallet, liquidityProvider.getAddress(), lpBalance1); + await mintTokensToPrivate(token0, adminWallet, swapper.getAddress(), swapperBalance0); + }); + + afterAll(() => teardown()); + + it('full flow', async () => { + // ADDING LIQUIDITY + const amount0Desired = lpBalance0 / 2n; + const amount1Desired = lpBalance1 / 2n; + const amount0Min = lpBalance0 / 3n; + const amount1Min = lpBalance1 / 3n; + + // First we need to add authwits such that the AMM can transfer the tokens from the liquidity provider (LP). + // The only purpose of this nonce is to make the authwit unique (function args are part of authwit hash preimage) + const nonceForAuthwits = Fr.random(); + + await liquidityProvider.createAuthWit({ + caller: amm.address, + action: token0.methods.transfer_to_public( + liquidityProvider.getAddress(), + amm.address, + amount0Desired, + nonceForAuthwits, + ), + }); + await liquidityProvider.createAuthWit({ + caller: amm.address, + action: token1.methods.transfer_to_public( + liquidityProvider.getAddress(), + amm.address, + amount1Desired, + nonceForAuthwits, + ), + }); + + await amm + .withWallet(liquidityProvider) + .methods.add_liquidity(amount0Desired, amount1Desired, amount0Min, amount1Min, nonceForAuthwits) + .send() + .wait(); + + // Since the LP was the first one to enter the pool, the desired amounts of tokens should have been deposited. + expect(await token0.methods.balance_of_private(liquidityProvider.getAddress()).simulate()).toEqual( + lpBalance0 - amount0Desired, + ); + expect(await token1.methods.balance_of_private(liquidityProvider.getAddress()).simulate()).toEqual( + lpBalance1 - amount1Desired, + ); + + // The LP should now have liquidity token + expect(await liquidityToken.methods.balance_of_private(liquidityProvider.getAddress()).simulate()).toBeGreaterThan( + 0n, + ); + + // SWAPPING EXACT TOKENS FOR TOKENS + // We try swapping half of swapper's token 0 balance for token 1 + const amountIn = swapperBalance0 / 2n; + + // We need to add authwit such that the AMM can transfer the tokens from the swapper + await swapper.createAuthWit({ + caller: amm.address, + action: token0.methods.transfer_to_public(swapper.getAddress(), amm.address, amountIn, nonceForAuthwits), + }); + + // We don't care about the minimum amount of token 1 we get in this test as long as it's non-zero. + const amountOutMin = 1n; + await amm.methods + .swap_exact_tokens_for_tokens(token0.address, token1.address, amountIn, amountOutMin, nonceForAuthwits) + .send() + .wait(); + + // All the amountIn should have been swapped so LP balance0 should be decreased by that amount + const lpBalance0AfterSwap1 = await token0.methods.balance_of_private(liquidityProvider.getAddress()).simulate(); + expect(lpBalance0AfterSwap1).toEqual(lpBalance0 - amountIn); + + // At this point a user should have a non-zero balance of token 1 + const lpBalance1AfterSwap1 = await token1.methods.balance_of_private(swapper.getAddress()).simulate(); + expect(lpBalance1AfterSwap1).toBeGreaterThan(0n); + + // SWAPPING TOKENS FOR EXACT TOKENS + const amount0Out = 1000n; + // We allow the AMM to take all our token1 balance (the difference will be refunded). + const amount1InMax = lpBalance1AfterSwap1; + + // We need to add authwit such that the AMM can transfer the tokens from the swapper + await swapper.createAuthWit({ + caller: amm.address, + action: token1.methods.transfer_to_public(swapper.getAddress(), amm.address, amount1InMax, nonceForAuthwits), + }); + + await amm.methods + .swap_tokens_for_exact_tokens(token1.address, token0.address, amount0Out, amount1InMax, nonceForAuthwits) + .send() + .wait(); + + // We should have received the exact amount of token0 + expect(await token0.methods.balance_of_private(swapper.getAddress()).simulate()).toEqual( + lpBalance0AfterSwap1 + amount0Out, + ); + + // We should have received a refund of token 1 (meaning we should have more than "previous balance - amount1InMax") + expect(await token1.methods.balance_of_private(swapper.getAddress()).simulate()).toBeGreaterThan( + lpBalance1AfterSwap1 - amount1InMax, + ); + }); +});