From 8a808009da57efb3edf0f9c7abdf4be518502e8b Mon Sep 17 00:00:00 2001 From: Siddharth Suresh Date: Fri, 20 Dec 2024 13:58:47 -0800 Subject: [PATCH] Update liquidity pool contract --- .../example-contracts/liquidity-pool.mdx | 256 ++++++++++++------ 1 file changed, 171 insertions(+), 85 deletions(-) diff --git a/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx b/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx index 26a05ece0..72bb1b9e2 100644 --- a/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx +++ b/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx @@ -59,7 +59,9 @@ cargo test You should see the output: ```sh -running 1 test +running 3 tests +test test::deposit_amount_zero_should_panic - should panic ... ok +test test::swap_reserve_one_nonzero_other_zero - should panic ... ok test test::test ... ok ``` @@ -78,16 +80,16 @@ Since our liquidity pool will be issuing its own token to establish the nuber of ```rust title=liquidity_pool/src/lib.rs #![no_std] +#![no_std] mod test; mod token; use num_integer::Roots; use soroban_sdk::{ - contract, contractimpl, contractmeta, Address, BytesN, ConversionError, Env, IntoVal, - TryFromVal, Val, + contract, contractimpl, contractmeta, Address, BytesN, ConversionError, Env, TryFromVal, Val, }; -use token::create_contract; +use token::create_share_token; #[derive(Clone, Copy)] #[repr(u32)] @@ -221,7 +223,7 @@ fn get_deposit_amounts( (desired_a, amount_b) } else { let amount_a = desired_b * reserve_a / reserve_b; - if amount_a > desired_a || desired_a < min_a { + if amount_a > desired_a || amount_a < min_a { panic!("amount_a invalid") } (amount_a, desired_b) @@ -234,62 +236,42 @@ contractmeta!( val = "Constant product AMM with a .3% swap fee" ); -pub trait LiquidityPoolTrait { - // Sets the token contract addresses for this pool - fn initialize(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address); - - // Returns the token contract address for the pool share token - fn share_id(e: Env) -> Address; - - // Deposits token_a and token_b. Also mints pool shares for the "to" Identifier. The amount minted - // is determined based on the difference between the reserves stored by this contract, and - // the actual balance of token_a and token_b for this contract. - fn deposit(e: Env, to: Address, desired_a: i128, min_a: i128, desired_b: i128, min_b: i128); - - // If "buy_a" is true, the swap will buy token_a and sell token_b. This is flipped if "buy_a" is false. - // "out" is the amount being bought, with in_max being a safety to make sure you receive at least that amount. - // swap will transfer the selling token "to" to this contract, and then the contract will transfer the buying token to "to". - fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128); - - // transfers share_amount of pool share tokens to this contract, burns all pools share tokens in this contracts, and sends the - // corresponding amount of token_a and token_b to "to". - // Returns amount of both tokens withdrawn - fn withdraw(e: Env, to: Address, share_amount: i128, min_a: i128, min_b: i128) -> (i128, i128); - - fn get_rsrvs(e: Env) -> (i128, i128); -} - #[contract] struct LiquidityPool; #[contractimpl] -impl LiquidityPoolTrait for LiquidityPool { - fn initialize(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { +impl LiquidityPool { + pub fn __constructor(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { if token_a >= token_b { panic!("token_a must be less than token_b"); } - let share_contract = create_contract(&e, token_wasm_hash, &token_a, &token_b); - token::Client::new(&e, &share_contract).initialize( - &e.current_contract_address(), - &7u32, - &"Pool Share Token".into_val(&e), - &"POOL".into_val(&e), - ); + let share_contract = create_share_token(&e, token_wasm_hash, &token_a, &token_b); put_token_a(&e, token_a); put_token_b(&e, token_b); - put_token_share(&e, share_contract.try_into().unwrap()); + put_token_share(&e, share_contract); put_total_shares(&e, 0); put_reserve_a(&e, 0); put_reserve_b(&e, 0); } - fn share_id(e: Env) -> Address { + // Returns the token contract address for the pool share token + pub fn share_id(e: Env) -> Address { get_token_share(&e) } - fn deposit(e: Env, to: Address, desired_a: i128, min_a: i128, desired_b: i128, min_b: i128) { + // Deposits token_a and token_b. Also mints pool shares for the "to" Identifier. The amount minted + // is determined based on the difference between the reserves stored by this contract, and + // the actual balance of token_a and token_b for this contract. + pub fn deposit( + e: Env, + to: Address, + desired_a: i128, + min_a: i128, + desired_b: i128, + min_b: i128, + ) { // Depositor needs to authorize the deposit to.require_auth(); @@ -329,7 +311,10 @@ impl LiquidityPoolTrait for LiquidityPool { put_reserve_b(&e, balance_b); } - fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128) { + // If "buy_a" is true, the swap will buy token_a and sell token_b. This is flipped if "buy_a" is false. + // "out" is the amount being bought, with in_max being a safety to make sure you receive at least that amount. + // swap will transfer the selling token "to" to this contract, and then the contract will transfer the buying token to "to". + pub fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128) { to.require_auth(); let (reserve_a, reserve_b) = (get_reserve_a(&e), get_reserve_b(&e)); @@ -339,6 +324,10 @@ impl LiquidityPoolTrait for LiquidityPool { (reserve_a, reserve_b) }; + if reserve_buy < out { + panic!("not enough token to buy"); + } + // First calculate how much needs to be sold to buy amount out from the pool let n = reserve_sell * out * 1000; let d = (reserve_buy - out) * 997; @@ -402,7 +391,16 @@ impl LiquidityPoolTrait for LiquidityPool { put_reserve_b(&e, new_reserve_b); } - fn withdraw(e: Env, to: Address, share_amount: i128, min_a: i128, min_b: i128) -> (i128, i128) { + // transfers share_amount of pool share tokens to this contract, burns all pools share tokens in this contracts, and sends the + // corresponding amount of token_a and token_b to "to". + // Returns amount of both tokens withdrawn + pub fn withdraw( + e: Env, + to: Address, + share_amount: i128, + min_a: i128, + min_b: i128, + ) -> (i128, i128) { to.require_auth(); // First transfer the pool shares that need to be redeemed @@ -418,7 +416,7 @@ impl LiquidityPoolTrait for LiquidityPool { let out_a = (balance_a * balance_shares) / total_shares; let out_b = (balance_b * balance_shares) / total_shares; - if out_a < min_a || out_b (i128, i128) { + pub fn get_rsrvs(e: Env) -> (i128, i128) { (get_reserve_a(&e), get_reserve_b(&e)) } } @@ -442,13 +440,13 @@ impl LiquidityPoolTrait for LiquidityPool { ```rust title=liquidity_pool/src/token.rs #![allow(unused)] -use soroban_sdk::{xdr::ToXdr, Address, Bytes, BytesN, Env}; +use soroban_sdk::{symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env, FromVal, String, Symbol}; soroban_sdk::contractimport!( file = "../token/target/wasm32-unknown-unknown/release/soroban_token_contract.wasm" ); -pub fn create_contract( +pub fn create_share_token( e: &Env, token_wasm_hash: BytesN<32>, token_a: &Address, @@ -458,9 +456,15 @@ pub fn create_contract( salt.append(&token_a.to_xdr(e)); salt.append(&token_b.to_xdr(e)); let salt = e.crypto().sha256(&salt); - e.deployer() - .with_current_contract(salt) - .deploy(token_wasm_hash) + e.deployer().with_current_contract(salt).deploy_v2( + token_wasm_hash, + ( + e.current_contract_address(), + 7u32, + String::from_val(e, &"Pool Share Token"), + String::from_val(e, &"POOL"), + ), + ) } ``` @@ -479,7 +483,7 @@ Open the `liquidity_pool/src/lib.rs` file or see the code above to follow along. ### Initialize the Contract -When this contract is first deployed, it could create a liquidity pool for _any_ pair of tokens available on Soroban. It must first be initialized with the following information: +When this contract is deployed, the constructor will automatically be called, so the following arguments must be passed int: - **`token_wasm_hash`:** The contract will end up [creating its own `POOL` token] as well as [interacting with contracts for `token_a` and `token_b`]. The way this example works is by using the [`token` example contract] for both of these jobs. When our liquidity pool contract is initialized it wants us to pass the wasm hash of the **already installed** token contract. It will then deploy a contract that will run the WASM bytecode stored at that hash as a new token contract for the `POOL` tokens. - **`token_a`:** The contract `Address` for an **already deployed** (or wrapped) token that will be held in reserve by the liquidity pool. @@ -488,15 +492,16 @@ When this contract is first deployed, it could create a liquidity pool for _any_ Bear in mind that which token is `token_a` and which is `token_b` is **not** an arbitrary distinction. In line with the Built-in Stellar liquidity pools, this contract can only make a single liquidity pool for a given set of tokens. So, the token addresses must be provided in [lexicographical order] at the time of initialization. ```rust title=liquidity_pool/src/lib.rs -fn initialize(e: Env, token_wasm_hash: BytesN<32>, taken_a: Address, token_b: Address) { +pub fn __constructor(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { if token_a >= token_b { panic!("token_a must be less than token_b"); } - // The initialization function also stores important information in the contract's instance storage + let share_contract = create_share_token(&e, token_wasm_hash, &token_a, &token_b); + put_token_a(&e, token_a); put_token_b(&e, token_b); - put_token_share(&e, share_contract.try_into().unwrap()); + put_token_share(&e, share_contract); put_total_shares(&e, 0); put_reserve_a(&e, 0); put_reserve_b(&e, 0); @@ -545,10 +550,10 @@ This liquidity pool contract will operate with a total of three different Soroba We are utilizing the compiled `token` example contract as our asset contract for the `POOL` token. This means it follows all the conventions of the [Token Interface], and can be treated just like any other token. They could be transferred, burned, minted, etc. It also means the LP developer _could_ take advantage of the administrative features such as clawbacks, authorization, and more. -The `token.rs` file contains a `create_contract` function that we will use to deploy this particular token contract. +The `token.rs` file contains a `create_share_token` function that we will use to deploy this particular token contract. ```rust title="src/token.rs" -pub fn create_contract( +pub fn create_share_token( e: &Env, token_wasm_hash: BytesN<32>, token_a: &Address, @@ -558,23 +563,15 @@ pub fn create_contract( salt.append(&token_a.to_xdr(e)); salt.append(&token_b.to_xdr(e)); let salt = e.crypto().sha256(&salt); - e.deployer() - .with_current_contract(salt) - .deploy(token_wasm_hash) -} -``` - -This `POOL` token contract is then created within the `initialize` function. - -```rust title=liquidity_pool/src/lib.rs -fn initialize(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { - let share_contract = create_contract(&e, token_wasm_hash, &token_a, &token_b); - token::Client::new(&e, &share_contract).initialize( - &e.current_contract_address(), - &7u32, - &"Pool Share Token".into_val(&e), - &"POOL".into_val(&e), - ); + e.deployer().with_current_contract(salt).deploy_v2( + token_wasm_hash, + ( + e.current_contract_address(), + 7u32, + String::from_val(e, &"Pool Share Token"), + String::from_val(e, &"POOL"), + ), + ) } ``` @@ -676,7 +673,11 @@ use soroban_sdk::{ }; fn create_token_contract<'a>(e: &Env, admin: &Address) -> token::Client<'a> { - token::Client::new(e, &e._register_stellar_asset_contract_v2(admin.clone()).address()) + token::Client::new( + e, + &e.register_stellar_asset_contract_v2(admin.clone()) + .address(), + ) } fn create_liqpool_contract<'a>( @@ -685,9 +686,13 @@ fn create_liqpool_contract<'a>( token_a: &Address, token_b: &Address, ) -> LiquidityPoolClient<'a> { - let liqpool = LiquidityPoolClient::new(e, &e.register_contract(None, crate::LiquidityPool {})); - liqpool.initialize(token_wasm_hash, token_a, token_b); - liqpool + LiquidityPoolClient::new( + e, + &e.register( + crate::LiquidityPool {}, + (token_wasm_hash.clone(), token_a, token_b), + ), + ) } fn install_token_wasm(e: &Env) -> BytesN<32> { @@ -702,8 +707,8 @@ fn test() { let e = Env::default(); e.mock_all_auths(); - let mut admin1 = Address::random(&e); - let mut admin2 = Address::random(&e); + let mut admin1 = Address::generate(&e); + let mut admin2 = Address::generate(&e); let mut token1 = create_token_contract(&e, &admin1); let mut token2 = create_token_contract(&e, &admin2); @@ -711,7 +716,7 @@ fn test() { std::mem::swap(&mut token1, &mut token2); std::mem::swap(&mut admin1, &mut admin2); } - let user1 = Address::random(&e); + let user1 = Address::generate(&e); let liqpool = create_liqpool_contract( &e, &install_token_wasm(&e), @@ -827,6 +832,79 @@ fn test() { assert_eq!(token2.balance(&liqpool.address), 0); assert_eq!(token_share.balance(&liqpool.address), 0); } + +#[test] +#[should_panic] +fn deposit_amount_zero_should_panic() { + let e = Env::default(); + e.mock_all_auths(); + + // Create contracts + let mut admin1 = Address::generate(&e); + let mut admin2 = Address::generate(&e); + + let mut token_a = create_token_contract(&e, &admin1); + let mut token_b = create_token_contract(&e, &admin2); + if &token_b.address < &token_a.address { + std::mem::swap(&mut token_a, &mut token_b); + std::mem::swap(&mut admin1, &mut admin2); + } + let liqpool = create_liqpool_contract( + &e, + &install_token_wasm(&e), + &token_a.address, + &token_b.address, + ); + + // Create a user + let user1 = Address::generate(&e); + + token_a.mint(&user1, &1000); + assert_eq!(token_a.balance(&user1), 1000); + + token_b.mint(&user1, &1000); + assert_eq!(token_b.balance(&user1), 1000); + + liqpool.deposit(&user1, &1, &0, &0, &0); +} + +#[test] +#[should_panic] +fn swap_reserve_one_nonzero_other_zero() { + let e = Env::default(); + e.mock_all_auths(); + + // Create contracts + let mut admin1 = Address::generate(&e); + let mut admin2 = Address::generate(&e); + + let mut token_a = create_token_contract(&e, &admin1); + let mut token_b = create_token_contract(&e, &admin2); + if &token_b.address < &token_a.address { + std::mem::swap(&mut token_a, &mut token_b); + std::mem::swap(&mut admin1, &mut admin2); + } + let liqpool = create_liqpool_contract( + &e, + &install_token_wasm(&e), + &token_a.address, + &token_b.address, + ); + + // Create a user + let user1 = Address::generate(&e); + + token_a.mint(&user1, &1000); + assert_eq!(token_a.balance(&user1), 1000); + + token_b.mint(&user1, &1000); + assert_eq!(token_b.balance(&user1), 1000); + + // Try to get to a situation where the reserves are 1 and 0. + // It shouldn't be possible. + token_b.transfer(&user1, &liqpool.address, &1); + liqpool.swap(&user1, &false, &1, &1); +} ``` [`liquidity_pool/src/test.rs`]: https://github.com/stellar/soroban-examples/blob/v22.0.1/liquidity_pool/src/test.rs @@ -847,7 +925,11 @@ We have abstracted into a few functions the tasks of creating token contracts, d ```rust title=liquidity_pool/src/test.rs fn create_token_contract<'a>(e: &Env, admin: &Address) -> token::Client<'a> { - token::Client::new(e, &e.register_stellar_asset_contract_v2(admin.clone()).address()) + token::Client::new( + e, + &e.register_stellar_asset_contract_v2(admin.clone()) + .address(), + ) } fn create_liqpool_contract<'a>( @@ -856,9 +938,13 @@ fn create_liqpool_contract<'a>( token_a: &Address, token_b: &Address, ) -> LiquidityPoolClient<'a> { - let liqpool = LiquidityPoolClient::new(e, &e.register_contract(None, crate::LiquidityPool {})); - liqpool.initialize(token_wasm_hash, token_a, token_b); - liqpool + LiquidityPoolClient::new( + e, + &e.register( + crate::LiquidityPool {}, + (token_wasm_hash.clone(), token_a, token_b), + ), + ) } fn install_token_wasm(e: &Env) -> BytesN<32> {