diff --git a/listings/ch01-applications/constant_product_amm/.gitignore b/listings/ch01-applications/constant_product_amm/.gitignore new file mode 100644 index 00000000..eb5a316c --- /dev/null +++ b/listings/ch01-applications/constant_product_amm/.gitignore @@ -0,0 +1 @@ +target diff --git a/listings/ch01-applications/constant_product_amm/Scarb.lock b/listings/ch01-applications/constant_product_amm/Scarb.lock new file mode 100644 index 00000000..c06b045c --- /dev/null +++ b/listings/ch01-applications/constant_product_amm/Scarb.lock @@ -0,0 +1,14 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "constant_product_amm" +version = "0.1.0" +dependencies = [ + "openzeppelin", +] + +[[package]] +name = "openzeppelin" +version = "0.8.0-beta.0" +source = "git+https://github.com/OpenZeppelin/cairo-contracts.git?tag=v0.8.0-beta.0#4c95981a7178e43730f152c85a92336dc6a22d62" diff --git a/listings/ch01-applications/constant_product_amm/Scarb.toml b/listings/ch01-applications/constant_product_amm/Scarb.toml new file mode 100644 index 00000000..2cdd0e03 --- /dev/null +++ b/listings/ch01-applications/constant_product_amm/Scarb.toml @@ -0,0 +1,9 @@ +[package] +name = "constant_product_amm" +version = "0.1.0" + +[dependencies] +starknet = ">=2.3.0" +openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v0.8.0-beta.0" } + +[[target.starknet-contract]] diff --git a/listings/ch01-applications/constant_product_amm/src/contracts.cairo b/listings/ch01-applications/constant_product_amm/src/contracts.cairo new file mode 100644 index 00000000..5bc4843b --- /dev/null +++ b/listings/ch01-applications/constant_product_amm/src/contracts.cairo @@ -0,0 +1,270 @@ +// ANCHOR: ConstantProductAmmContract +use starknet::ContractAddress; + +#[starknet::interface] +trait IConstantProductAmm { + fn swap(ref self: TContractState, token_in: ContractAddress, amount_in: u256) -> u256; + fn add_liquidity(ref self: TContractState, amount0: u256, amount1: u256) -> u256; + fn remove_liquidity(ref self: TContractState, shares: u256) -> (u256, u256); +} + +#[starknet::contract] +mod ConstantProductAmm { + use core::traits::Into; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ + ContractAddress, get_caller_address, get_contract_address, contract_address_const + }; + use integer::u256_sqrt; + + #[storage] + struct Storage { + token0: IERC20Dispatcher, + token1: IERC20Dispatcher, + reserve0: u256, + reserve1: u256, + total_supply: u256, + balance_of: LegacyMap::, + // Fee 0 - 1000 (0% - 100%, 1 decimal places) + // E.g. 3 = 0.3% + fee: u16, + } + + #[constructor] + fn constructor( + ref self: ContractState, token0: ContractAddress, token1: ContractAddress, fee: u16 + ) { + // assert(fee <= 1000, 'fee > 1000'); + self.token0.write(IERC20Dispatcher { contract_address: token0 }); + self.token1.write(IERC20Dispatcher { contract_address: token1 }); + self.fee.write(fee); + } + + #[generate_trait] + impl PrivateFunctions of PrivateFunctionsTrait { + fn _mint(ref self: ContractState, to: ContractAddress, amount: u256) { + self.balance_of.write(to, self.balance_of.read(to) + amount); + self.total_supply.write(self.total_supply.read() + amount); + } + + fn _burn(ref self: ContractState, from: ContractAddress, amount: u256) { + self.balance_of.write(from, self.balance_of.read(from) - amount); + self.total_supply.write(self.total_supply.read() - amount); + } + + fn _update(ref self: ContractState, reserve0: u256, reserve1: u256) { + self.reserve0.write(reserve0); + self.reserve1.write(reserve1); + } + + #[inline(always)] + fn select_token(self: @ContractState, token: ContractAddress) -> bool { + assert( + token == self.token0.read().contract_address + || token == self.token1.read().contract_address, + 'invalid token' + ); + token == self.token0.read().contract_address + } + + #[inline(always)] + fn min(x: u256, y: u256) -> u256 { + if (x <= y) { + x + } else { + y + } + } + } + + #[external(v0)] + impl ConstantProductAmm of super::IConstantProductAmm { + fn swap(ref self: ContractState, token_in: ContractAddress, amount_in: u256) -> u256 { + assert(amount_in > 0, 'amount in = 0'); + let is_token0: bool = self.select_token(token_in); + + let (token0, token1): (IERC20Dispatcher, IERC20Dispatcher) = ( + self.token0.read(), self.token1.read() + ); + let (reserve0, reserve1): (u256, u256) = (self.reserve0.read(), self.reserve1.read()); + let ( + token_in, token_out, reserve_in, reserve_out + ): (IERC20Dispatcher, IERC20Dispatcher, u256, u256) = + if (is_token0) { + (token0, token1, reserve0, reserve1) + } else { + (token1, token0, reserve1, reserve0) + }; + + let caller = get_caller_address(); + let this = get_contract_address(); + token_in.transfer_from(caller, this, amount_in); + + // How much dy for dx? + // xy = k + // (x + dx)(y - dy) = k + // y - dy = k / (x + dx) + // y - k / (x + dx) = dy + // y - xy / (x + dx) = dy + // (yx + ydx - xy) / (x + dx) = dy + // ydx / (x + dx) = dy + + let amount_in_with_fee = (amount_in * (1000 - self.fee.read().into()) / 1000); + let amount_out = (reserve_out * amount_in_with_fee) / (reserve_in + amount_in_with_fee); + + token_out.transfer(caller, amount_out); + + self._update(self.token0.read().balance_of(this), self.token1.read().balance_of(this)); + amount_out + } + + fn add_liquidity(ref self: ContractState, amount0: u256, amount1: u256) -> u256 { + let caller = get_caller_address(); + let this = get_contract_address(); + let (token0, token1): (IERC20Dispatcher, IERC20Dispatcher) = ( + self.token0.read(), self.token1.read() + ); + + token0.transfer_from(caller, this, amount0); + token1.transfer_from(caller, this, amount1); + + // How much dx, dy to add? + // + // xy = k + // (x + dx)(y + dy) = k' + // + // No price change, before and after adding liquidity + // x / y = (x + dx) / (y + dy) + // + // x(y + dy) = y(x + dx) + // x * dy = y * dx + // + // x / y = dx / dy + // dy = y / x * dx + + let (reserve0, reserve1): (u256, u256) = (self.reserve0.read(), self.reserve1.read()); + if (reserve0 > 0 || reserve1 > 0) { + assert(reserve0 * amount1 == reserve1 * amount0, 'x / y != dx / dy'); + } + + // How much shares to mint? + // + // f(x, y) = value of liquidity + // We will define f(x, y) = sqrt(xy) + // + // L0 = f(x, y) + // L1 = f(x + dx, y + dy) + // T = total shares + // s = shares to mint + // + // Total shares should increase proportional to increase in liquidity + // L1 / L0 = (T + s) / T + // + // L1 * T = L0 * (T + s) + // + // (L1 - L0) * T / L0 = s + + // Claim + // (L1 - L0) / L0 = dx / x = dy / y + // + // Proof + // --- Equation 1 --- + // (L1 - L0) / L0 = (sqrt((x + dx)(y + dy)) - sqrt(xy)) / sqrt(xy) + // + // dx / dy = x / y so replace dy = dx * y / x + // + // --- Equation 2 --- + // Equation 1 = (sqrt(xy + 2ydx + dx^2 * y / x) - sqrt(xy)) / sqrt(xy) + // + // Multiply by sqrt(x) / sqrt(x) + // Equation 2 = (sqrt(x^2y + 2xydx + dx^2 * y) - sqrt(x^2y)) / sqrt(x^2y) + // = (sqrt(y)(sqrt(x^2 + 2xdx + dx^2) - sqrt(x^2)) / (sqrt(y)sqrt(x^2)) + // sqrt(y) on top and bottom cancels out + // + // --- Equation 3 --- + // Equation 2 = (sqrt(x^2 + 2xdx + dx^2) - sqrt(x^2)) / (sqrt(x^2) + // = (sqrt((x + dx)^2) - sqrt(x^2)) / sqrt(x^2) + // = ((x + dx) - x) / x + // = dx / x + // Since dx / dy = x / y, + // dx / x = dy / y + // + // Finally + // (L1 - L0) / L0 = dx / x = dy / y + + let total_supply = self.total_supply.read(); + let shares = if (total_supply == 0) { + u256_sqrt(amount0 * amount1).into() + } else { + PrivateFunctions::min( + amount0 * total_supply / reserve0, amount1 * total_supply / reserve1 + ) + }; + assert(shares > 0, 'shares = 0'); + self._mint(caller, shares); + + self._update(self.token0.read().balance_of(this), self.token1.read().balance_of(this)); + shares + } + + fn remove_liquidity(ref self: ContractState, shares: u256) -> (u256, u256) { + let caller = get_caller_address(); + let this = get_contract_address(); + let (token0, token1): (IERC20Dispatcher, IERC20Dispatcher) = ( + self.token0.read(), self.token1.read() + ); + + // Claim + // dx, dy = amount of liquidity to remove + // dx = s / T * x + // dy = s / T * y + // + // Proof + // Let's find dx, dy such that + // v / L = s / T + // + // where + // v = f(dx, dy) = sqrt(dxdy) + // L = total liquidity = sqrt(xy) + // s = shares + // T = total supply + // + // --- Equation 1 --- + // v = s / T * L + // sqrt(dxdy) = s / T * sqrt(xy) + // + // Amount of liquidity to remove must not change price so + // dx / dy = x / y + // + // replace dy = dx * y / x + // sqrt(dxdy) = sqrt(dx * dx * y / x) = dx * sqrt(y / x) + // + // Divide both sides of Equation 1 with sqrt(y / x) + // dx = s / T * sqrt(xy) / sqrt(y / x) + // = s / T * sqrt(x^2) = s / T * x + // + // Likewise + // dy = s / T * y + + // bal0 >= reserve0 + // bal1 >= reserve1 + let (bal0, bal1): (u256, u256) = (token0.balance_of(this), token1.balance_of(this)); + + let total_supply = self.total_supply.read(); + let (amount0, amount1): (u256, u256) = ( + (shares * bal0) / total_supply, (shares * bal1) / total_supply + ); + assert(amount0 > 0 && amount1 > 0, 'amount0 or amount1 = 0'); + + self._burn(caller, shares); + self._update(bal0 - amount0, bal1 - amount1); + + token0.transfer(caller, amount0); + token1.transfer(caller, amount1); + (amount0, amount1) + } + } +} +// ANCHOR_END: ConstantProductAmmContract + + diff --git a/listings/ch01-applications/constant_product_amm/src/lib.cairo b/listings/ch01-applications/constant_product_amm/src/lib.cairo new file mode 100644 index 00000000..0e88faff --- /dev/null +++ b/listings/ch01-applications/constant_product_amm/src/lib.cairo @@ -0,0 +1,4 @@ +mod contracts; + +#[cfg(test)] +mod tests; diff --git a/listings/ch01-applications/constant_product_amm/src/tests.cairo b/listings/ch01-applications/constant_product_amm/src/tests.cairo new file mode 100644 index 00000000..e41e52f1 --- /dev/null +++ b/listings/ch01-applications/constant_product_amm/src/tests.cairo @@ -0,0 +1,142 @@ +mod tests { + use core::option::OptionTrait; + use core::traits::TryInto; + use openzeppelin::token::erc20::{ + ERC20, interface::IERC20Dispatcher, interface::IERC20DispatcherTrait + }; + use openzeppelin::utils::serde::SerializedAppend; + use openzeppelin::tests::utils; + + use constant_product_amm::contracts::{ + ConstantProductAmm, IConstantProductAmmDispatcher, IConstantProductAmmDispatcherTrait + }; + use starknet::{ + deploy_syscall, ContractAddress, get_caller_address, get_contract_address, + contract_address_const + }; + use starknet::testing::set_contract_address; + use starknet::class_hash::Felt252TryIntoClassHash; + + use debug::PrintTrait; + + const BANK: felt252 = 0x123; + const INITIAL_SUPPLY: u256 = 10_000; + + #[derive(Drop, Copy)] + struct Deployment { + contract: IConstantProductAmmDispatcher, + token0: IERC20Dispatcher, + token1: IERC20Dispatcher + } + + fn deploy_erc20( + name: felt252, symbol: felt252, recipient: ContractAddress, initial_supply: u256 + ) -> (ContractAddress, IERC20Dispatcher) { + let mut calldata = array![]; + calldata.append_serde(name); + calldata.append_serde(symbol); + calldata.append_serde(initial_supply); + calldata.append_serde(recipient); + + let address = utils::deploy(ERC20::TEST_CLASS_HASH, calldata); + (address, IERC20Dispatcher { contract_address: address }) + } + + fn setup() -> Deployment { + let recipient: ContractAddress = BANK.try_into().unwrap(); + let (token0_address, token0) = deploy_erc20('Token0', 'T0', recipient, INITIAL_SUPPLY); + let (token1_address, token1) = deploy_erc20('Token1', 'T1', recipient, INITIAL_SUPPLY); + + // 0.3% fee + let fee: u16 = 3; + + let mut calldata: Array:: = array![]; + calldata.append(token0_address.into()); + calldata.append(token1_address.into()); + calldata.append(fee.into()); + + let (contract_address, _) = starknet::deploy_syscall( + ConstantProductAmm::TEST_CLASS_HASH.try_into().unwrap(), 0, calldata.span(), false + ) + .unwrap(); + // Or with OpenZeppelin helper: + // let contract_address = utils::deploy(ConstantProductAmm::TEST_CLASS_HASH, calldata); + Deployment { contract: IConstantProductAmmDispatcher { contract_address }, token0, token1 } + } + + fn add_liquidity(deploy: Deployment, amount: u256) -> u256 { + assert(amount <= INITIAL_SUPPLY, 'amount > INITIAL_SUPPLY'); + + let provider: ContractAddress = BANK.try_into().unwrap(); + set_contract_address(provider); + + deploy.token0.approve(deploy.contract.contract_address, amount); + deploy.token1.approve(deploy.contract.contract_address, amount); + + deploy.contract.add_liquidity(amount, amount) + } + + #[test] + #[available_gas(20000000)] + fn should_deploy() { + let deploy = setup(); + let bank: ContractAddress = BANK.try_into().unwrap(); + + assert(deploy.token0.balance_of(bank) == INITIAL_SUPPLY, 'Wrong balance token0'); + assert(deploy.token1.balance_of(bank) == INITIAL_SUPPLY, 'Wrong balance token1'); + } + + #[test] + #[available_gas(20000000)] + fn should_add_liquidity() { + let deploy = setup(); + let shares = add_liquidity(deploy, INITIAL_SUPPLY / 2); + + let provider: ContractAddress = BANK.try_into().unwrap(); + assert(deploy.token0.balance_of(provider) == INITIAL_SUPPLY / 2, 'Wrong balance token0'); + assert(deploy.token1.balance_of(provider) == INITIAL_SUPPLY / 2, 'Wrong balance token1'); + assert(shares > 0, 'Wrong shares'); + } + + #[test] + #[available_gas(20000000)] + fn should_remove_liquidity() { + let deploy = setup(); + let shares = add_liquidity(deploy, INITIAL_SUPPLY / 2); + let provider: ContractAddress = BANK.try_into().unwrap(); + + deploy.contract.remove_liquidity(shares); + + assert(deploy.token0.balance_of(provider) == INITIAL_SUPPLY, 'Wrong balance token0'); + assert(deploy.token1.balance_of(provider) == INITIAL_SUPPLY, 'Wrong balance token1'); + } + + #[test] + #[available_gas(20000000)] + fn should_swap() { + let deploy = setup(); + let shares = add_liquidity(deploy, INITIAL_SUPPLY / 2); + + let provider: ContractAddress = BANK.try_into().unwrap(); + let user = contract_address_const::<0x1>(); + + // Provider send some token0 to user + set_contract_address(provider); + let amount = deploy.token0.balance_of(provider) / 2; + deploy.token0.transfer(user, amount); + + // user swap for token1 using AMM liquidity + set_contract_address(user); + deploy.token0.approve(deploy.contract.contract_address, amount); + deploy.contract.swap(deploy.token0.contract_address, amount); + let amount_token1_received = deploy.token1.balance_of(user); + assert(amount_token1_received > 0, 'Swap: wrong balance token1'); + + // User can swap back token1 to token0 + // As each swap has a 0.3% fee, user will receive less token0 + deploy.token1.approve(deploy.contract.contract_address, amount_token1_received); + deploy.contract.swap(deploy.token1.contract_address, amount_token1_received); + let amount_token0_received = deploy.token0.balance_of(user); + assert(amount_token0_received < amount, 'Swap: wrong balance token0'); + } +} diff --git a/src/SUMMARY.md b/src/SUMMARY.md index e9075d93..c32252a2 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -34,6 +34,7 @@ Summary - [Upgradeable Contract](./ch01/upgradeable_contract.md) - [Defi Vault](./ch01/simple_vault.md) - [ERC20 Token](./ch01/erc20.md) + - [Constant Product AMM](./ch01/constant-product-amm.md) # Advanced concepts diff --git a/src/ch01/constant-product-amm.md b/src/ch01/constant-product-amm.md new file mode 100644 index 00000000..fffa3c9d --- /dev/null +++ b/src/ch01/constant-product-amm.md @@ -0,0 +1,9 @@ +# Constant Product AMM + +This is the Cairo adaptation of the [Solidity by example Constant Product AMM](https://solidity-by-example.org/defi/constant-product-amm/). + +```rust +{{#include ../listings/ch01-applications/constant_product_amm/src/constant_product_amm.cairo:ConstantProductAmmContract}} +``` + +Play with this contract in [Remix](https://remix.ethereum.org/?#activate=Starknet&url=https://github.com/NethermindEth/StarknetByExample/blob/main/listings/ch01-applications/constant_product_amm/src/constant_product_amm.cairo). \ No newline at end of file