diff --git a/.gitignore b/.gitignore index 87b85e2..baec0c8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,11 @@ Cargo.lock **/*.rs.bk .idea +.DS_Store neardev res/ref_exchange_local.wasm res/ref_farming_local.wasm + +build_docker_own.sh \ No newline at end of file diff --git a/ref-exchange/Cargo.toml b/ref-exchange/Cargo.toml index 8e7b241..1da0802 100644 --- a/ref-exchange/Cargo.toml +++ b/ref-exchange/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ref-exchange" -version = "1.0.1" +version = "1.4.1" authors = ["Illia Polosukhin "] edition = "2018" publish = false @@ -16,3 +16,5 @@ near-contract-standards = "3.1.0" [dev-dependencies] near-sdk-sim = "3.1.0" test-token = { path = "../test-token" } +rand = "0.8" +rand_pcg = "0.3" diff --git a/ref-exchange/build_docker_own.sh b/ref-exchange/build_docker_own.sh new file mode 100755 index 0000000..b50a231 --- /dev/null +++ b/ref-exchange/build_docker_own.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Exit script as soon as a command fails. +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +NAME="build_ref_exchange_own" + +if docker ps -a --format '{{.Names}}' | grep -Eq "^${NAME}\$"; then + echo "Container exists" +else +docker create \ + --mount type=bind,source=$DIR/..,target=/host \ + --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ + --name=$NAME \ + -w /host/ref-exchange \ + -e RUSTFLAGS='-C link-arg=-s' \ + -it \ + nearprotocol/contract-builder \ + /bin/bash +fi + +docker start $NAME +docker exec -it $NAME /bin/bash -c "rustup toolchain install stable-2020-10-08; rustup default stable-2020-10-08; rustup target add wasm32-unknown-unknown; cargo build --target wasm32-unknown-unknown --release" + +mkdir -p res +cp $DIR/../target/wasm32-unknown-unknown/release/ref_exchange.wasm $DIR/../res/ref_exchange_release.wasm + diff --git a/ref-exchange/release_notes.md b/ref-exchange/release_notes.md index 41447eb..e66ef09 100644 --- a/ref-exchange/release_notes.md +++ b/ref-exchange/release_notes.md @@ -1,16 +1,26 @@ # Release Notes -### Version 0.2.1 +### Version 1.4.1 +1. Introduce Stable-Swap-Pool; + +### Version 1.4.0 +1. Make exchange fee and referal fee inclusive in total fee; +2. Make exchange fee (in the form of lp shares) belongs to contract itself; + +### Version 1.3.1 +1. Apply HOTFIX in v1.0.3; + +### Version 1.3.0 --- -1. Support for direct swap -Allows to swap with a single transaction without needing to deposit / withdraw. Not even storage deposits are required for the pool, if force=1 is passed (but FE must make sure that receiver is registered in the outgoing token). +1. feature instant swap; +Allows to swap with a single transaction without needing to deposit / withdraw. Not even storage deposits are required for the pool (inner account not touched). But FE must make sure that receiver is registered in the outgoing token, or they would go to inner account or lost-found account. Example usage: ```bash contract.ft_transfer_call( to_va(swap()), to_yocto("1").into(), None, - "{{\"force\": 0, \"actions\": [{{\"pool_id\": 0, \"token_in\": \"dai\", \"token_out\": \"eth\", \"min_amount_out\": \"1\"}}]}}".to_string() + "{{\"actions\": [{{\"pool_id\": 0, \"token_in\": \"dai\", \"token_out\": \"eth\", \"min_amount_out\": \"1\"}}]}}".to_string() ), ``` Specifically for TokenReceiverMessage message parameters are: @@ -19,9 +29,6 @@ Example usage: /// Alternative to deposit + execute actions call. Execute { referral_id: Option, - /// If force != 0, doesn't require user to even have account. In case of failure to deposit to the user's outgoing balance, tokens will be returned to the exchange and can be "saved" via governance. - /// If force == 0, the account for this user still have been registered. If deposit of outgoing tokens will fail, it will deposit it back into the account. - force: u8, /// List of sequential actions. actions: Vec, }, @@ -29,9 +36,27 @@ Example usage: ``` where Action is either SwapAction or any future action added there. -2. Allow function access key to trade if all tokens are whitelisted -There are two changes: - * register / unregister tokens for the user requires a 1 yocto Near deposit to prevent access keys whitelisted tokens. - * `swap` function supports 0 attached deposit, but all tokens must be already registered or globally whitelisted. +### Version 1.2.0 +--- +1. upgrade inner account; + * inner account upgrade to use `UnorderedMap`; + * keep exist deposits in `legacy_tokens` in `HashMap`; + * move it to `tokens` in `UnorderedMap` when deposit or withdraw token; + +### Version 1.1.0 +--- +1. feature Guardians; + * guardians are managed by owner; + * guardians and owner can switch contract state to Paused; + * owner can resume the contract; + * guardians and owner can manager global whitelist; + * a new view method metadata to show overall info includes version, owner, guardians, state, pool counts. + +### Version 1.0.3 +--- +1. HOTFIX -- increase ft_transfer GAS from 10T to 20T; +### Version 1.0.2 +--- +1. fixed storage_withdraw bug; \ No newline at end of file diff --git a/ref-exchange/src/account_deposit.rs b/ref-exchange/src/account_deposit.rs index 646a4d5..b712399 100644 --- a/ref-exchange/src/account_deposit.rs +++ b/ref-exchange/src/account_deposit.rs @@ -1,6 +1,7 @@ //! Account deposit is information per user about their balances in the exchange. use std::collections::HashMap; +use near_sdk::collections::UnorderedMap; use near_contract_standards::fungible_token::core_impl::ext_fungible_token; use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; @@ -9,7 +10,7 @@ use near_sdk::{ assert_one_yocto, env, near_bindgen, AccountId, Balance, PromiseResult, StorageUsage, }; - +use crate::legacy::AccountV1; use crate::utils::{ext_self, GAS_FOR_FT_TRANSFER, GAS_FOR_RESOLVE_TRANSFER}; use crate::*; @@ -25,74 +26,144 @@ const U32_STORAGE: StorageUsage = 4; const ACC_ID_STORAGE: StorageUsage = 64; /// As a key, 4 bytes length would be added to the head const ACC_ID_AS_KEY_STORAGE: StorageUsage = ACC_ID_STORAGE + 4; +const KEY_PREFIX_ACC: StorageUsage = 64; /// As a near_sdk::collection key, 1 byte for prefiex const ACC_ID_AS_CLT_KEY_STORAGE: StorageUsage = ACC_ID_AS_KEY_STORAGE + 1; // ACC_ID: the Contract accounts map key length // + VAccount enum: 1 byte // + U128_STORAGE: near_amount storage +// + U32_STORAGE: legacy_tokens HashMap length // + U32_STORAGE: tokens HashMap length // + U64_STORAGE: storage_used pub const INIT_ACCOUNT_STORAGE: StorageUsage = - ACC_ID_AS_CLT_KEY_STORAGE + 1 + U128_STORAGE + U32_STORAGE + U64_STORAGE; + ACC_ID_AS_CLT_KEY_STORAGE + 1 + U128_STORAGE + U32_STORAGE + U32_STORAGE + U64_STORAGE; #[derive(BorshDeserialize, BorshSerialize)] pub enum VAccount { + V1(AccountV1), Current(Account), } -impl From for VAccount { - fn from(account: Account) -> Self { - VAccount::Current(account) +impl VAccount { + /// Upgrades from other versions to the currently used version. + pub fn into_current(self, account_id: &AccountId) -> Account { + match self { + VAccount::Current(account) => account, + VAccount::V1(account) => account.into_current(account_id), + } } } -impl From for Account { - fn from(v_account: VAccount) -> Self { - match v_account { - VAccount::Current(account) => {account}, - } +impl From for VAccount { + fn from(account: Account) -> Self { + VAccount::Current(account) } } /// Account deposits information and storage cost. -#[derive(BorshSerialize, BorshDeserialize, Default, Clone)] +#[derive(BorshSerialize, BorshDeserialize)] pub struct Account { /// Native NEAR amount sent to the exchange. /// Used for storage right now, but in future can be used for trading as well. pub near_amount: Balance, /// Amounts of various tokens deposited to this account. - pub tokens: HashMap, + pub legacy_tokens: HashMap, + pub tokens: UnorderedMap, pub storage_used: StorageUsage, } +impl Account { + pub fn new(account_id: &AccountId) -> Self { + Account { + near_amount: 0, + legacy_tokens: HashMap::new(), + tokens: UnorderedMap::new(StorageKey::AccountTokens { + account_id: account_id.clone(), + }), + storage_used: 0, + } + } + + pub fn get_balance(&self, token_id: &AccountId) -> Option { + if let Some(token_balance) = self.tokens.get(token_id) { + Some(token_balance) + } else if let Some(legacy_token_balance) = self.legacy_tokens.get(token_id) { + Some(*legacy_token_balance) + } else { + None + } + } + pub fn get_tokens(&self) -> Vec { + let mut a: Vec = self.tokens.keys().collect(); + let b: Vec = self.legacy_tokens + .keys() + .cloned() + .collect(); + a.extend(b); + a + } + /// Deposit amount to the balance of given token, + /// if given token not register and not enough storage, deposit fails + pub(crate) fn deposit_with_storage_check(&mut self, token: &AccountId, amount: Balance) -> bool { + if let Some(balance) = self.tokens.get(token) { + // token has been registered, just add without storage check, + let new_balance = balance + amount; + self.tokens.insert(token, &new_balance); + true + } else if let Some(x) = self.legacy_tokens.get_mut(token) { + // token has been registered, just add without storage check + *x += amount; + true + } else { + // check storage after insert, if fail should unregister the token + self.tokens.insert(token, &(amount)); + if self.storage_usage() <= self.near_amount { + true + } else { + self.tokens.remove(token); + false + } + } + } -impl Account { /// Deposit amount to the balance of given token. pub(crate) fn deposit(&mut self, token: &AccountId, amount: Balance) { - if let Some(x) = self.tokens.get_mut(token) { - *x = *x + amount; + if let Some(x) = self.legacy_tokens.remove(token) { + // need convert to tokens + self.tokens.insert(token, &(amount + x)); + } else if let Some(x) = self.tokens.get(token) { + self.tokens.insert(token, &(amount + x)); } else { - self.tokens.insert(token.clone(), amount); + self.tokens.insert(token, &amount); } } /// Withdraw amount of `token` from the internal balance. /// Panics if `amount` is bigger than the current balance. pub(crate) fn withdraw(&mut self, token: &AccountId, amount: Balance) { - let value = *self.tokens.get(token).expect(ERR21_TOKEN_NOT_REG); - assert!(value >= amount, "{}", ERR22_NOT_ENOUGH_TOKENS); - self.tokens.insert(token.clone(), value - amount); + if let Some(x) = self.legacy_tokens.remove(token) { + // need convert to + assert!(x >= amount, "{}", ERR22_NOT_ENOUGH_TOKENS); + self.tokens.insert(token, &(x - amount)); + } else if let Some(x) = self.tokens.get(token) { + assert!(x >= amount, "{}", ERR22_NOT_ENOUGH_TOKENS); + self.tokens.insert(token, &(x - amount)); + } else { + env::panic(ERR21_TOKEN_NOT_REG.as_bytes()); + } } // [AUDIT_01] /// Returns amount of $NEAR necessary to cover storage used by this data structure. pub fn storage_usage(&self) -> Balance { (INIT_ACCOUNT_STORAGE + - self.tokens.len() as u64 * (ACC_ID_AS_KEY_STORAGE + U128_STORAGE)) as u128 + self.legacy_tokens.len() as u64 * (ACC_ID_AS_KEY_STORAGE + U128_STORAGE) + + self.tokens.len() as u64 * (KEY_PREFIX_ACC + ACC_ID_AS_KEY_STORAGE + U128_STORAGE) + ) as u128 * env::storage_byte_cost() } @@ -122,12 +193,11 @@ impl Account { } /// Registers given token and set balance to 0. - /// Fails if not enough amount to cover new storage usage. pub(crate) fn register(&mut self, token_ids: &Vec) { for token_id in token_ids { let t = token_id.as_ref(); - if !self.tokens.contains_key(t) { - self.tokens.insert(t.clone(), 0); + if self.get_balance(t).is_none() { + self.tokens.insert(t, &0); } } } @@ -135,6 +205,8 @@ impl Account { /// Unregisters `token_id` from this account balance. /// Panics if the `token_id` balance is not 0. pub(crate) fn unregister(&mut self, token_id: &AccountId) { + let amount = self.legacy_tokens.remove(token_id).unwrap_or_default(); + assert_eq!(amount, 0, "{}", ERR24_NON_ZERO_TOKEN_BALANCE); let amount = self.tokens.remove(token_id).unwrap_or_default(); assert_eq!(amount, 0, "{}", ERR24_NON_ZERO_TOKEN_BALANCE); } @@ -147,10 +219,11 @@ impl Contract { #[payable] pub fn register_tokens(&mut self, token_ids: Vec) { assert_one_yocto(); + self.assert_contract_running(); let sender_id = env::predecessor_account_id(); - let mut deposits = self.internal_unwrap_account(&sender_id); - deposits.register(&token_ids); - self.internal_save_account(&sender_id, deposits); + let mut account = self.internal_unwrap_account(&sender_id); + account.register(&token_ids); + self.internal_save_account(&sender_id, account); } /// Unregister given token from user's account deposit. @@ -158,12 +231,13 @@ impl Contract { #[payable] pub fn unregister_tokens(&mut self, token_ids: Vec) { assert_one_yocto(); + self.assert_contract_running(); let sender_id = env::predecessor_account_id(); - let mut deposits = self.internal_unwrap_account(&sender_id); + let mut account = self.internal_unwrap_account(&sender_id); for token_id in token_ids { - deposits.unregister(token_id.as_ref()); + account.unregister(token_id.as_ref()); } - self.internal_save_account(&sender_id, deposits); + self.internal_save_account(&sender_id, account); } /// Withdraws given token from the deposits of given user. @@ -177,16 +251,18 @@ impl Contract { unregister: Option, ) -> Promise { assert_one_yocto(); + self.assert_contract_running(); let token_id: AccountId = token_id.into(); let amount: u128 = amount.into(); + assert!(amount > 0, "{}", ERR29_ILLEGAL_WITHDRAW_AMOUNT); let sender_id = env::predecessor_account_id(); - let mut deposits = self.internal_unwrap_account(&sender_id); + let mut account = self.internal_unwrap_account(&sender_id); // Note: subtraction and deregistration will be reverted if the promise fails. - deposits.withdraw(&token_id, amount); + account.withdraw(&token_id, amount); if unregister == Some(true) { - deposits.unregister(&token_id); + account.unregister(&token_id); } - self.internal_save_account(&sender_id, deposits); + self.internal_save_account(&sender_id, account); self.internal_send_tokens(&sender_id, &token_id, amount) } @@ -207,22 +283,38 @@ impl Contract { PromiseResult::NotReady => unreachable!(), PromiseResult::Successful(_) => {} PromiseResult::Failed => { - // This reverts the changes from withdraw function. If account doesn't exit, deposits to the owner's account. - if let Some(va) = self.accounts.get(&sender_id) { - let mut account: Account = va.into(); - account.deposit(&token_id, amount.0); - self.internal_save_account(&sender_id, account); + // This reverts the changes from withdraw function. + // If account doesn't exit, deposits to the owner's account as lostfound. + let mut failed = false; + if let Some(mut account) = self.internal_get_account(&sender_id) { + if account.deposit_with_storage_check(&token_id, amount.0) { + // cause storage already checked, here can directly save + self.accounts.insert(&sender_id, &account.into()); + } else { + // we can ensure that internal_get_account here would NOT cause a version upgrade, + // cause it is callback, the account must be the current version or non-exist, + // so, here we can just leave it without insert, won't cause storage collection inconsistency. + env::log( + format!( + "Account {} has not enough storage. Depositing to owner.", + sender_id + ) + .as_bytes(), + ); + failed = true; + } } else { env::log( format!( - "Account {} is not registered or not enough storage. Depositing to owner.", + "Account {} is not registered. Depositing to owner.", sender_id ) .as_bytes(), ); - let mut owner_account = self.internal_unwrap_account(&self.owner_id); - owner_account.deposit(&token_id, amount.0); - self.internal_save_account(&self.owner_id.clone(), owner_account); + failed = true; + } + if failed { + self.internal_lostfound(&token_id, amount.0); } } }; @@ -238,6 +330,22 @@ impl Contract { self.accounts.insert(&account_id, &account.into()); } + /// save token to owner account as lostfound, no need to care about storage + /// only global whitelisted token can be stored in lost-found + pub(crate) fn internal_lostfound(&mut self, token_id: &AccountId, amount: u128) { + if self.whitelisted_tokens.contains(token_id) { + // TODO: consider change owner_id to env::current_account_id + let mut lostfound = self.internal_unwrap_or_default_account(&self.owner_id); + lostfound.deposit(token_id, amount); + // TODO: consider change owner_id to env::current_account_id + self.accounts.insert(&self.owner_id, &lostfound.into()); + } else { + env::panic("ERR: non-whitelisted token can NOT deposit into lost-found.".as_bytes()); + } + + } + + /// Registers account in deposited amounts with given amount of $NEAR. /// If account already exists, adds amount to it. /// This should be used when it's known that storage is prepaid. @@ -247,6 +355,21 @@ impl Contract { self.internal_save_account(&account_id, account); } + /// storage withdraw + pub(crate) fn internal_storage_withdraw(&mut self, account_id: &AccountId, amount: Balance) -> u128 { + let mut account = self.internal_unwrap_account(&account_id); + let available = account.storage_available(); + assert!(available > 0, "ERR_NO_STORAGE_CAN_WITHDRAW"); + let mut withdraw_amount = amount; + if amount == 0 { + withdraw_amount = available; + } + assert!(withdraw_amount <= available, "ERR_STORAGE_WITHDRAW_TOO_MUCH"); + account.near_amount -= withdraw_amount; + self.internal_save_account(&account_id, account); + withdraw_amount + } + /// Record deposit of some number of tokens to this contract. /// Fails if account is not registered or if token isn't whitelisted. pub(crate) fn internal_deposit( @@ -257,7 +380,8 @@ impl Contract { ) { let mut account = self.internal_unwrap_account(sender_id); assert!( - self.whitelisted_tokens.contains(token_id) || account.tokens.contains_key(token_id), + self.whitelisted_tokens.contains(token_id) + || account.get_balance(token_id).is_some(), "{}", ERR12_TOKEN_NOT_WHITELISTED ); @@ -265,18 +389,20 @@ impl Contract { self.internal_save_account(&sender_id, account); } - pub fn internal_unwrap_account(&self, account_id: &AccountId) -> Account { + pub fn internal_get_account(&self, account_id: &AccountId) -> Option { self.accounts .get(account_id) + .map(|va| va.into_current(account_id)) + } + + pub fn internal_unwrap_account(&self, account_id: &AccountId) -> Account { + self.internal_get_account(account_id) .expect(errors::ERR10_ACC_NOT_REGISTERED) - .into() } pub fn internal_unwrap_or_default_account(&self, account_id: &AccountId) -> Account { - self.accounts - .get(account_id) - .unwrap_or(Account::default().into()) - .into() + self.internal_get_account(account_id) + .unwrap_or_else(|| Account::new(account_id)) } /// Returns current balance of given token for given user. If there is nothing recorded, returns 0. @@ -285,10 +411,9 @@ impl Contract { sender_id: &AccountId, token_id: &AccountId, ) -> Balance { - self.accounts - .get(sender_id) - .and_then(|va| {let a: Account = va.into(); a.tokens.get(token_id).cloned()}) - .unwrap_or_default() + self.internal_get_account(sender_id) + .and_then(|x| x.get_balance(token_id)) + .unwrap_or(0) } /// Sends given amount to given user and if it fails, returns it back to user's balance. diff --git a/ref-exchange/src/fees.rs b/ref-exchange/src/admin_fee.rs similarity index 90% rename from ref-exchange/src/fees.rs rename to ref-exchange/src/admin_fee.rs index 3670215..6b83b43 100644 --- a/ref-exchange/src/fees.rs +++ b/ref-exchange/src/admin_fee.rs @@ -1,7 +1,7 @@ use near_sdk::{env, AccountId}; /// Maintain information about fees. -pub struct SwapFees { +pub struct AdminFees { /// Basis points of the fee for exchange. pub exchange_fee: u32, /// Basis points of the fee for referrer. @@ -10,9 +10,9 @@ pub struct SwapFees { pub referral_id: Option, } -impl SwapFees { +impl AdminFees { pub fn new(exchange_fee: u32) -> Self { - SwapFees { + AdminFees { exchange_fee, exchange_id: env::current_account_id(), referral_fee: 0, diff --git a/ref-exchange/src/errors.rs b/ref-exchange/src/errors.rs index 37b48fc..b829952 100644 --- a/ref-exchange/src/errors.rs +++ b/ref-exchange/src/errors.rs @@ -18,6 +18,8 @@ pub const ERR25_CALLBACK_POST_WITHDRAW_INVALID: &str = // pub const ERR26_ACCESS_KEY_NOT_ALLOWED: &str = "E26: access key not allowed"; pub const ERR27_DEPOSIT_NEEDED: &str = "E27: attach 1yN to swap tokens not in whitelist"; +pub const ERR28_WRONG_MSG_FORMAT: &str = "E28: Illegal msg in ft_transfer_call"; +pub const ERR29_ILLEGAL_WITHDRAW_AMOUNT: &str = "E29: Illegal withdraw amount"; // Liquidity operations. @@ -25,6 +27,34 @@ pub const ERR31_ZERO_AMOUNT: &str = "E31: adding zero amount"; pub const ERR32_ZERO_SHARES: &str = "E32: minting zero shares"; // [AUDIT_07] pub const ERR33_TRANSFER_TO_SELF: &str = "E33: transfer to self"; +pub const ERR34_INSUFFICIENT_LP_SHARES: &str = "E34: insufficient lp shares"; + // Action result. pub const ERR41_WRONG_ACTION_RESULT: &str = "E41: wrong action result type"; + +// Contract Level +pub const ERR51_CONTRACT_PAUSED: &str = "E51: contract paused"; + +// Swap +pub const ERR60_DECIMAL_ILLEGAL: &str = "E60: illegal decimal"; +pub const ERR61_AMP_ILLEGAL: &str = "E61: illegal amp"; +pub const ERR62_FEE_ILLEGAL: &str = "E62: illegal fee"; +pub const ERR63_MISSING_TOKEN: &str = "E63: missing token"; +pub const ERR64_TOKENS_COUNT_ILLEGAL: &str = "E64: illegal tokens count"; +pub const ERR65_INIT_TOKEN_BALANCE: &str = "E65: init token balance should be non-zero"; +pub const ERR66_INVARIANT_CALC_ERR: &str = "E66: encounter err when calc invariant D"; +pub const ERR67_LPSHARE_CALC_ERR: &str = "E67: encounter err when calc lp shares"; +pub const ERR68_SLIPPAGE: &str = "E68: slippage error"; +pub const ERR69_MIN_RESERVE: &str = "E69: pool reserved token balance less than MIN_RESERVE"; +pub const ERR70_SWAP_OUT_CALC_ERR: &str = "E70: encounter err when calc swap out"; +pub const ERR71_SWAP_DUP_TOKENS: &str = "E71: illegal swap with duplicated tokens"; + +// pool manage +pub const ERR81_AMP_IN_LOCK: &str = "E81: amp is currently in lock"; +pub const ERR82_INSUFFICIENT_RAMP_TIME: &str = "E82: insufficient ramp time"; +pub const ERR83_INVALID_AMP_FACTOR: &str = "E83: invalid amp factor"; +pub const ERR84_AMP_LARGE_CHANGE: &str = "E84: amp factor change is too large"; + +// Permissions +pub const ERR100_NOT_ALLOWED: &str = "E100: no permission to invoke this"; diff --git a/ref-exchange/src/legacy.rs b/ref-exchange/src/legacy.rs index 8430173..7532455 100644 --- a/ref-exchange/src/legacy.rs +++ b/ref-exchange/src/legacy.rs @@ -1 +1,49 @@ //! This modules captures all the code needed to migrate from previous version. +use std::collections::HashMap; +use near_sdk::collections::{UnorderedMap, Vector, LookupMap, UnorderedSet}; +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::{AccountId, Balance, StorageUsage, near_bindgen, PanicOnDefault}; +use crate::account_deposit::{Account, VAccount}; +use crate::StorageKey; +use crate::pool::Pool; + +/// Account deposits information and storage cost. +#[derive(BorshSerialize, BorshDeserialize, Default, Clone)] +pub struct AccountV1 { + /// Native NEAR amount sent to the exchange. + /// Used for storage right now, but in future can be used for trading as well. + pub near_amount: Balance, + /// Amounts of various tokens deposited to this account. + pub tokens: HashMap, + pub storage_used: StorageUsage, +} + +impl AccountV1 { + pub fn into_current(&self, account_id: &AccountId) -> Account { + Account { + near_amount: self.near_amount, + legacy_tokens: self.tokens.clone(), + tokens: UnorderedMap::new(StorageKey::AccountTokens { + account_id: account_id.clone(), + }), + storage_used: self.storage_used, + } + } +} + +#[near_bindgen] +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] +pub struct ContractV1 { + /// Account of the owner. + pub owner_id: AccountId, + /// Exchange fee, that goes to exchange itself (managed by governance). + pub exchange_fee: u32, + /// Referral fee, that goes to referrer in the call. + pub referral_fee: u32, + /// List of all the pools. + pub pools: Vector, + /// Accounts registered, keeping track all the amounts deposited, storage and more. + pub accounts: LookupMap, + /// Set of whitelisted tokens by "owner". + pub whitelisted_tokens: UnorderedSet, +} diff --git a/ref-exchange/src/lib.rs b/ref-exchange/src/lib.rs index a039d4f..93d6254 100644 --- a/ref-exchange/src/lib.rs +++ b/ref-exchange/src/lib.rs @@ -1,8 +1,10 @@ use std::convert::TryInto; +use std::fmt; use near_contract_standards::storage_management::{ StorageBalance, StorageBalanceBounds, StorageManagement, }; +use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::collections::{LookupMap, UnorderedSet, Vector}; use near_sdk::json_types::{ValidAccountId, U128}; @@ -15,23 +17,23 @@ use crate::account_deposit::{Account, VAccount}; pub use crate::action::SwapAction; use crate::action::{Action, ActionResult}; use crate::errors::*; -use crate::fees::SwapFees; +use crate::admin_fee::AdminFees; use crate::pool::Pool; use crate::simple_pool::SimplePool; use crate::stable_swap::StableSwapPool; use crate::utils::check_token_duplicates; -pub use crate::views::PoolInfo; +pub use crate::views::{PoolInfo, ContractMetadata}; mod account_deposit; mod action; mod errors; -mod fees; +pub mod admin_fee; mod legacy; mod multi_fungible_token; mod owner; mod pool; mod simple_pool; -mod stable_swap; +pub mod stable_swap; mod storage_impl; mod token_receiver; mod utils; @@ -45,6 +47,24 @@ pub(crate) enum StorageKey { Accounts, Shares { pool_id: u32 }, Whitelist, + Guardian, + AccountTokens {account_id: AccountId}, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Eq, PartialEq, Clone)] +#[serde(crate = "near_sdk::serde")] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +pub enum RunningState { + Running, Paused +} + +impl fmt::Display for RunningState { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + RunningState::Running => write!(f, "Running"), + RunningState::Paused => write!(f, "Paused"), + } + } } #[near_bindgen] @@ -62,6 +82,10 @@ pub struct Contract { accounts: LookupMap, /// Set of whitelisted tokens by "owner". whitelisted_tokens: UnorderedSet, + /// Set of guardians. + guardians: UnorderedSet, + /// Running state + state: RunningState, } #[near_bindgen] @@ -75,6 +99,8 @@ impl Contract { pools: Vector::new(StorageKey::Pools), accounts: LookupMap::new(StorageKey::Accounts), whitelisted_tokens: UnorderedSet::new(StorageKey::Whitelist), + guardians: UnorderedSet::new(StorageKey::Guardian), + state: RunningState::Running, } } @@ -82,13 +108,14 @@ impl Contract { /// Attached NEAR should be enough to cover the added storage. #[payable] pub fn add_simple_pool(&mut self, tokens: Vec, fee: u32) -> u64 { + self.assert_contract_running(); check_token_duplicates(&tokens); self.internal_add_pool(Pool::SimplePool(SimplePool::new( self.pools.len() as u32, tokens, - fee + self.exchange_fee + self.referral_fee, - self.exchange_fee, - self.referral_fee, + fee, + 0, + 0, ))) } @@ -96,15 +123,18 @@ impl Contract { pub fn add_stable_swap_pool( &mut self, tokens: Vec, + decimals: Vec, fee: u32, amp_factor: u64, ) -> u64 { + assert!(self.is_owner_or_guardians(), "{}", ERR100_NOT_ALLOWED); check_token_duplicates(&tokens); self.internal_add_pool(Pool::StableSwapPool(StableSwapPool::new( self.pools.len() as u32, tokens, + decimals, amp_factor as u128, - fee + self.exchange_fee + self.referral_fee, + fee, ))) } @@ -119,6 +149,7 @@ impl Contract { actions: Vec, referral_id: Option, ) -> ActionResult { + self.assert_contract_running(); let sender_id = env::predecessor_account_id(); let mut account = self.internal_unwrap_account(&sender_id); // Validate that all tokens are whitelisted if no deposit (e.g. trade with access key). @@ -126,7 +157,7 @@ impl Contract { for action in &actions { for token in action.tokens() { assert!( - account.tokens.contains_key(&token) + account.get_balance(&token).is_some() || self.whitelisted_tokens.contains(&token), "{}", // [AUDIT_05] @@ -147,6 +178,7 @@ impl Contract { /// If no attached deposit, outgoing tokens used in swaps must be whitelisted. #[payable] pub fn swap(&mut self, actions: Vec, referral_id: Option) -> U128 { + self.assert_contract_running(); assert_ne!(actions.len(), 0, "ERR_AT_LEAST_ONE_SWAP"); U128( self.execute_actions( @@ -168,6 +200,7 @@ impl Contract { amounts: Vec, min_amounts: Option>, ) { + self.assert_contract_running(); assert!( env::attached_deposit() > 0, "Requires attached deposit of at least 1 yoctoNEAR" @@ -180,12 +213,6 @@ impl Contract { pool.add_liquidity( &sender_id, &mut amounts, - SwapFees { - exchange_fee: self.exchange_fee, - exchange_id: self.owner_id.clone(), - referral_fee: 0, - referral_id: None, - }, ); if let Some(min_amounts) = min_amounts { // Check that all amounts are above request min amounts in case of front running that changes the exchange rate. @@ -204,10 +231,47 @@ impl Contract { self.internal_check_storage(prev_storage); } + #[payable] + pub fn add_stable_liquidity( + &mut self, + pool_id: u64, + amounts: Vec, + min_shares: U128, + ) -> U128 { + self.assert_contract_running(); + assert!( + env::attached_deposit() > 0, + "Requires attached deposit of at least 1 yoctoNEAR" + ); + let prev_storage = env::storage_usage(); + let sender_id = env::predecessor_account_id(); + let amounts: Vec = amounts.into_iter().map(|amount| amount.into()).collect(); + let mut pool = self.pools.get(pool_id).expect("ERR_NO_POOL"); + // Add amounts given to liquidity first. It will return the balanced amounts. + let mint_shares = pool.add_stable_liquidity( + &sender_id, + &amounts, + min_shares.into(), + AdminFees::new(self.exchange_fee), + ); + let mut deposits = self.internal_unwrap_or_default_account(&sender_id); + let tokens = pool.tokens(); + // Subtract amounts from deposits. This will fail if there is not enough funds for any of the tokens. + for i in 0..tokens.len() { + deposits.withdraw(&tokens[i], amounts[i]); + } + self.internal_save_account(&sender_id, deposits); + self.pools.replace(pool_id, &pool); + self.internal_check_storage(prev_storage); + + mint_shares.into() + } + /// Remove liquidity from the pool into general pool of liquidity. #[payable] pub fn remove_liquidity(&mut self, pool_id: u64, shares: U128, min_amounts: Vec) { assert_one_yocto(); + self.assert_contract_running(); let prev_storage = env::storage_usage(); let sender_id = env::predecessor_account_id(); let mut pool = self.pools.get(pool_id).expect("ERR_NO_POOL"); @@ -218,12 +282,6 @@ impl Contract { .into_iter() .map(|amount| amount.into()) .collect(), - SwapFees { - exchange_fee: self.exchange_fee, - exchange_id: self.owner_id.clone(), - referral_fee: 0, - referral_id: None, - }, ); self.pools.replace(pool_id, &pool); let tokens = pool.tokens(); @@ -238,10 +296,55 @@ impl Contract { } self.internal_save_account(&sender_id, deposits); } + + #[payable] + pub fn remove_liquidity_by_tokens( + &mut self, pool_id: u64, + amounts: Vec, + max_burn_shares: U128 + ) -> U128 { + assert_one_yocto(); + self.assert_contract_running(); + let prev_storage = env::storage_usage(); + let sender_id = env::predecessor_account_id(); + let mut pool = self.pools.get(pool_id).expect("ERR_NO_POOL"); + let burn_shares = pool.remove_liquidity_by_tokens( + &sender_id, + amounts + .clone() + .into_iter() + .map(|amount| amount.into()) + .collect(), + max_burn_shares.into(), + AdminFees::new(self.exchange_fee), + ); + self.pools.replace(pool_id, &pool); + let tokens = pool.tokens(); + let mut deposits = self.internal_unwrap_or_default_account(&sender_id); + for i in 0..tokens.len() { + deposits.deposit(&tokens[i], amounts[i].into()); + } + // Freed up storage balance from LP tokens will be returned to near_balance. + if prev_storage > env::storage_usage() { + deposits.near_amount += + (prev_storage - env::storage_usage()) as Balance * env::storage_byte_cost(); + } + self.internal_save_account(&sender_id, deposits); + + burn_shares.into() + } } /// Internal methods implementation. impl Contract { + + fn assert_contract_running(&self) { + match self.state { + RunningState::Running => (), + _ => env::panic(ERR51_CONTRACT_PAUSED.as_bytes()), + }; + } + /// Check how much storage taken costs and refund the left over back. fn internal_check_storage(&self, prev_storage: StorageUsage) { let storage_cost = env::storage_usage() @@ -251,7 +354,12 @@ impl Contract { // println!("need: {}, attached: {}", storage_cost, env::attached_deposit()); let refund = env::attached_deposit() .checked_sub(storage_cost) - .expect("ERR_STORAGE_DEPOSIT"); + .expect( + format!( + "ERR_STORAGE_DEPOSIT need {}, attatched {}", + storage_cost, env::attached_deposit() + ).as_str() + ); if refund > 0 { Promise::new(env::predecessor_account_id()).transfer(refund); } @@ -260,9 +368,11 @@ impl Contract { /// Adds given pool to the list and returns it's id. /// If there is not enough attached balance to cover storage, fails. /// If too much attached - refunds it back. - fn internal_add_pool(&mut self, pool: Pool) -> u64 { + fn internal_add_pool(&mut self, mut pool: Pool) -> u64 { let prev_storage = env::storage_usage(); let id = self.pools.len() as u64; + // exchange share was registered at creation time + pool.share_register(&env::current_account_id()); self.pools.push(&pool); self.internal_check_storage(prev_storage); id @@ -331,9 +441,9 @@ impl Contract { amount_in, token_out, min_amount_out, - SwapFees { + AdminFees { exchange_fee: self.exchange_fee, - exchange_id: self.owner_id.clone(), + exchange_id: env::current_account_id(), referral_fee: self.referral_fee, referral_id: referral_id.clone(), }, @@ -358,7 +468,7 @@ mod tests { fn setup_contract() -> (VMContextBuilder, Contract) { let mut context = VMContextBuilder::new(); testing_env!(context.predecessor_account_id(accounts(0)).build()); - let contract = Contract::new(accounts(0), 4, 1); + let contract = Contract::new(accounts(0), 1600, 400); (context, contract) } @@ -487,7 +597,7 @@ mod tests { // Get price from pool :0 1 -> 2 tokens. let expected_out = contract.get_return(0, accounts(1), one_near.into(), accounts(2)); - assert_eq!(expected_out.0, 1662497915624478906119726); + assert_eq!(expected_out.0, 1663192997082117548978741); testing_env!(context .predecessor_account_id(accounts(3)) @@ -529,7 +639,7 @@ mod tests { // Exchange fees left in the pool as liquidity + 1m from transfer. assert_eq!( contract.get_pool_total_shares(0).0, - 33337501041992301475 + 1_000_000 + 33336806279123620258 + 1_000_000 ); contract.withdraw( @@ -773,8 +883,8 @@ mod tests { ], None, ); - // Roundtrip returns almost everything except 0.3% fee. - assert_eq!(contract.get_deposit(acc, accounts(1)).0, 1_000_000 - 7); + // Roundtrip returns almost everything except 0.25% fee. + assert_eq!(contract.get_deposit(acc, accounts(1)).0, 1_000_000 - 6); } #[test] diff --git a/ref-exchange/src/multi_fungible_token.rs b/ref-exchange/src/multi_fungible_token.rs index 2a8a21d..c55e5b1 100644 --- a/ref-exchange/src/multi_fungible_token.rs +++ b/ref-exchange/src/multi_fungible_token.rs @@ -1,4 +1,4 @@ -use near_contract_standards::fungible_token::metadata::{FungibleTokenMetadata}; +use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; use near_sdk::json_types::{ValidAccountId, U128}; use near_sdk::{ext_contract, near_bindgen, Balance, PromiseOrValue}; @@ -36,7 +36,7 @@ enum TokenOrPool { /// This is used to parse token_id fields in mft protocol used in ref, /// So, if we choose #nn as a partern, should announce it in mft protocol. /// cause : is not allowed in a normal account id, it can be a partern leading char -fn try_identify_pool_id(token_id: &String) ->Result { +fn try_identify_pool_id(token_id: &String) -> Result { if token_id.starts_with(":") { if let Ok(pool_id) = str::parse::(&token_id[1..token_id.len()]) { Ok(pool_id) @@ -82,16 +82,8 @@ impl Contract { ); } TokenOrPool::Token(token_id) => { - let mut sender_account: Account = self - .accounts - .get(&sender_id) - .expect(ERR10_ACC_NOT_REGISTERED) - .into(); - let mut receiver_account: Account = self - .accounts - .get(receiver_id) - .expect(ERR10_ACC_NOT_REGISTERED) - .into(); + let mut sender_account: Account = self.internal_unwrap_account(&sender_id); + let mut receiver_account: Account = self.internal_unwrap_account(&receiver_id); sender_account.withdraw(&token_id, amount); receiver_account.deposit(&token_id, amount); @@ -143,6 +135,7 @@ impl Contract { /// Fails if token_id is not a pool. #[payable] pub fn mft_register(&mut self, token_id: String, account_id: ValidAccountId) { + self.assert_contract_running(); let prev_storage = env::storage_usage(); match parse_token_id(token_id) { TokenOrPool::Token(_) => env::panic(b"ERR_INVALID_REGISTER"), @@ -166,6 +159,7 @@ impl Contract { memo: Option, ) { assert_one_yocto(); + self.assert_contract_running(); self.internal_mft_transfer( token_id, &env::predecessor_account_id(), @@ -185,6 +179,7 @@ impl Contract { msg: String, ) -> PromiseOrValue { assert_one_yocto(); + self.assert_contract_running(); let sender_id = env::predecessor_account_id(); self.internal_mft_transfer( token_id.clone(), @@ -245,6 +240,7 @@ impl Contract { let refund_to = if self.accounts.get(&sender_id).is_some() { sender_id } else { + // TODO: consider change owner_id to env::current_account_id self.owner_id.clone() }; self.internal_mft_transfer(token_id, &receiver_id, &refund_to, refund_amount, None); @@ -255,15 +251,22 @@ impl Contract { pub fn mft_metadata(&self, token_id: String) -> FungibleTokenMetadata { match parse_token_id(token_id) { - TokenOrPool::Pool(pool_id) => FungibleTokenMetadata { - // [AUDIT_08] - spec: "mft-1.0.0".to_string(), - name: format!("ref-pool-{}", pool_id), - symbol: format!("REF-POOL-{}", pool_id), - icon: None, - reference: None, - reference_hash: None, - decimals: 24, + TokenOrPool::Pool(pool_id) => { + let pool = self.pools.get(pool_id).expect("ERR_NO_POOL"); + let decimals = match pool.kind().as_str() { + "STABLE_SWAP" => 18, + _ => 24, + }; + FungibleTokenMetadata { + // [AUDIT_08] + spec: "mft-1.0.0".to_string(), + name: format!("ref-pool-{}", pool_id), + symbol: format!("REF-POOL-{}", pool_id), + icon: None, + reference: None, + reference_hash: None, + decimals, + } }, TokenOrPool::Token(_token_id) => unimplemented!(), } diff --git a/ref-exchange/src/owner.rs b/ref-exchange/src/owner.rs index e5c5a55..71b73c7 100644 --- a/ref-exchange/src/owner.rs +++ b/ref-exchange/src/owner.rs @@ -3,6 +3,7 @@ use near_sdk::json_types::WrappedTimestamp; use crate::*; +use crate::utils::FEE_DIVISOR; #[near_bindgen] impl Contract { @@ -17,10 +18,49 @@ impl Contract { self.owner_id.clone() } + /// Extend guardians. Only can be called by owner. + #[payable] + pub fn extend_guardians(&mut self, guardians: Vec) { + self.assert_owner(); + for guardian in guardians { + self.guardians.insert(guardian.as_ref()); + } + } + + /// Remove guardians. Only can be called by owner. + pub fn remove_guardians(&mut self, guardians: Vec) { + self.assert_owner(); + for guardian in guardians { + self.guardians.remove(guardian.as_ref()); + } + } + + /// Change state of contract, Only can be called by owner or guardians. + #[payable] + pub fn change_state(&mut self, state: RunningState) { + assert_one_yocto(); + assert!(self.is_owner_or_guardians(), "ERR_NOT_ALLOWED"); + + if self.state != state { + if state == RunningState::Running { + // only owner can resume the contract + self.assert_owner(); + } + env::log( + format!( + "Contract state changed from {} to {} by {}", + self.state, state, env::predecessor_account_id() + ) + .as_bytes(), + ); + self.state = state; + } + } + /// Extend whitelisted tokens with new tokens. Only can be called by owner. #[payable] pub fn extend_whitelisted_tokens(&mut self, tokens: Vec) { - self.assert_owner(); + assert!(self.is_owner_or_guardians(), "ERR_NOT_ALLOWED"); for token in tokens { self.whitelisted_tokens.insert(token.as_ref()); } @@ -28,20 +68,56 @@ impl Contract { /// Remove whitelisted token. Only can be called by owner. pub fn remove_whitelisted_tokens(&mut self, tokens: Vec) { - self.assert_owner(); + assert!(self.is_owner_or_guardians(), "ERR_NOT_ALLOWED"); for token in tokens { self.whitelisted_tokens.remove(token.as_ref()); } } + pub fn modify_admin_fee(&mut self, exchange_fee: u32, referral_fee: u32) { + self.assert_owner(); + assert!(exchange_fee + referral_fee <= FEE_DIVISOR, "ERR_ILLEGAL_FEE"); + self.exchange_fee = exchange_fee; + self.referral_fee = referral_fee; + } + + /// Remove exchange fee liqudity to owner's inner account. + /// without any storage and fee. + #[payable] + pub fn remove_exchange_fee_liquidity(&mut self, pool_id: u64, shares: U128, min_amounts: Vec) { + assert_one_yocto(); + self.assert_owner(); + self.assert_contract_running(); + let ex_id = env::current_account_id(); + let owner_id = self.owner_id.clone(); + let mut pool = self.pools.get(pool_id).expect("ERR_NO_POOL"); + let amounts = pool.remove_liquidity( + &ex_id, + shares.into(), + min_amounts + .into_iter() + .map(|amount| amount.into()) + .collect(), + ); + self.pools.replace(pool_id, &pool); + let tokens = pool.tokens(); + let mut deposits = self.internal_unwrap_or_default_account(&owner_id); + for i in 0..tokens.len() { + deposits.deposit(&tokens[i], amounts[i]); + } + self.internal_save_account(&owner_id, deposits); + } + /// Migration function from v2 to v2. /// For next version upgrades, change this function. #[init(ignore_state)] // [AUDIT_09] #[private] pub fn migrate() -> Self { - let contract: Contract = env::state_read().expect("ERR_NOT_INITIALIZED"); - contract + let mut prev: Contract = env::state_read().expect("ERR_NOT_INITIALIZED"); + prev.exchange_fee = 1600; + prev.referral_fee = 400; + prev } pub(crate) fn assert_owner(&self) { @@ -78,6 +154,11 @@ impl Contract { self.assert_owner(); self.pools.replace(pool_id, &pool); } + + pub(crate) fn is_owner_or_guardians(&self) -> bool { + env::predecessor_account_id() == self.owner_id + || self.guardians.contains(&env::predecessor_account_id()) + } } #[cfg(target_arch = "wasm32")] diff --git a/ref-exchange/src/pool.rs b/ref-exchange/src/pool.rs index 4016326..82b7699 100644 --- a/ref-exchange/src/pool.rs +++ b/ref-exchange/src/pool.rs @@ -1,7 +1,7 @@ use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::{AccountId, Balance}; -use crate::fees::SwapFees; +use crate::admin_fee::AdminFees; use crate::simple_pool::SimplePool; use crate::stable_swap::StableSwapPool; use crate::utils::SwapVolume; @@ -37,11 +37,23 @@ impl Pool { &mut self, sender_id: &AccountId, amounts: &mut Vec, - fees: SwapFees, ) -> Balance { match self { Pool::SimplePool(pool) => pool.add_liquidity(sender_id, amounts), - Pool::StableSwapPool(pool) => pool.add_liquidity(sender_id, amounts, &fees), + Pool::StableSwapPool(_) => unimplemented!(), + } + } + + pub fn add_stable_liquidity( + &mut self, + sender_id: &AccountId, + amounts: &Vec, + min_shares: Balance, + admin_fee: AdminFees, + ) -> Balance { + match self { + Pool::SimplePool(_) => unimplemented!(), + Pool::StableSwapPool(pool) => pool.add_liquidity(sender_id, amounts, min_shares, &admin_fee), } } @@ -51,12 +63,27 @@ impl Pool { sender_id: &AccountId, shares: Balance, min_amounts: Vec, - fees: SwapFees, ) -> Vec { match self { Pool::SimplePool(pool) => pool.remove_liquidity(sender_id, shares, min_amounts), Pool::StableSwapPool(pool) => { - pool.remove_liquidity(sender_id, shares, min_amounts, &fees) + pool.remove_liquidity_by_shares(sender_id, shares, min_amounts) + } + } + } + + /// Removes liquidity from underlying pool. + pub fn remove_liquidity_by_tokens( + &mut self, + sender_id: &AccountId, + amounts: Vec, + max_burn_shares: Balance, + admin_fee: AdminFees, + ) -> Balance { + match self { + Pool::SimplePool(_) => unimplemented!(), + Pool::StableSwapPool(pool) => { + pool.remove_liquidity_by_tokens(sender_id, amounts, max_burn_shares, &admin_fee) } } } @@ -91,6 +118,14 @@ impl Pool { } } + /// Returns given pool's share price. + pub fn get_share_price(&self) -> u128 { + match self { + Pool::SimplePool(_) => unimplemented!(), + Pool::StableSwapPool(pool) => pool.get_share_price(), + } + } + /// Swaps given number of token_in for token_out and returns received amount. pub fn swap( &mut self, @@ -98,14 +133,14 @@ impl Pool { amount_in: Balance, token_out: &AccountId, min_amount_out: Balance, - fees: SwapFees, + admin_fee: AdminFees, ) -> Balance { match self { Pool::SimplePool(pool) => { - pool.swap(token_in, amount_in, token_out, min_amount_out, fees) + pool.swap(token_in, amount_in, token_out, min_amount_out, &admin_fee) } Pool::StableSwapPool(pool) => { - pool.swap(token_in, amount_in, token_out, min_amount_out, &fees) + pool.swap(token_in, amount_in, token_out, min_amount_out, &admin_fee) } } } diff --git a/ref-exchange/src/simple_pool.rs b/ref-exchange/src/simple_pool.rs index 7eccea7..388aa96 100644 --- a/ref-exchange/src/simple_pool.rs +++ b/ref-exchange/src/simple_pool.rs @@ -1,15 +1,15 @@ use std::cmp::min; -use crate::StorageKey; use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::collections::LookupMap; use near_sdk::json_types::ValidAccountId; use near_sdk::{env, AccountId, Balance}; +use crate::StorageKey; +use crate::admin_fee::AdminFees; use crate::errors::{ ERR13_LP_NOT_REGISTERED, ERR14_LP_ALREADY_REGISTERED, ERR31_ZERO_AMOUNT, ERR32_ZERO_SHARES, }; -use crate::fees::SwapFees; use crate::utils::{ add_to_collection, integer_sqrt, SwapVolume, FEE_DIVISOR, INIT_SHARES_SUPPLY, U256, }; @@ -29,9 +29,9 @@ pub struct SimplePool { pub volumes: Vec, /// Fee charged for swap (gets divided by FEE_DIVISOR). pub total_fee: u32, - /// Portion of the fee going to exchange. + /// Obsolete, reserve to simplify upgrade. pub exchange_fee: u32, - /// Portion of the fee going to referral. + /// Obsolete, reserve to simplify upgrade. pub referral_fee: u32, /// Shares of the pool by liquidity providers. pub shares: LookupMap, @@ -48,15 +48,11 @@ impl SimplePool { referral_fee: u32, ) -> Self { assert!( - total_fee < FEE_DIVISOR && (exchange_fee + referral_fee) <= total_fee, + total_fee < FEE_DIVISOR, "ERR_FEE_TOO_LARGE" ); // [AUDIT_10] - assert_eq!( - token_account_ids.len(), - NUM_TOKENS, - "ERR_SHOULD_HAVE_2_TOKENS" - ); + assert_eq!(token_account_ids.len(), NUM_TOKENS, "ERR_SHOULD_HAVE_2_TOKENS"); Self { token_account_ids: token_account_ids.iter().map(|a| a.clone().into()).collect(), amounts: vec![0u128; token_account_ids.len()], @@ -65,7 +61,9 @@ impl SimplePool { exchange_fee, referral_fee, // [AUDIT_11] - shares: LookupMap::new(StorageKey::Shares { pool_id: id }), + shares: LookupMap::new(StorageKey::Shares { + pool_id: id, + }), shares_total_supply: 0, } } @@ -191,7 +189,7 @@ impl SimplePool { result.push(amount); } if prev_shares_amount == shares { - // [AUDIT_13] Never unregister an LP when liquidity is removed. + // [AUDIT_13] Never unregister a LP when he removed all his liquidity. self.shares.insert(&sender_id, &0); } else { self.shares @@ -275,7 +273,7 @@ impl SimplePool { amount_in: Balance, token_out: &AccountId, min_amount_out: Balance, - fees: SwapFees, + admin_fee: &AdminFees, ) -> Balance { assert_ne!(token_in, token_out, "ERR_SAME_TOKEN_SWAP"); let in_idx = self.token_index(token_in); @@ -305,19 +303,19 @@ impl SimplePool { let numerator = (new_invariant - prev_invariant) * U256::from(self.shares_total_supply); // Allocate exchange fee as fraction of total fee by issuing LP shares proportionally. - if self.exchange_fee > 0 && numerator > U256::zero() { - let denominator = new_invariant * self.total_fee / self.exchange_fee; - self.mint_shares(&fees.exchange_id, (numerator / denominator).as_u128()); + if admin_fee.exchange_fee > 0 && numerator > U256::zero() { + let denominator = new_invariant * FEE_DIVISOR / admin_fee.exchange_fee; + self.mint_shares(&admin_fee.exchange_id, (numerator / denominator).as_u128()); } // If there is referral provided and the account already registered LP, allocate it % of LP rewards. - if let Some(referral_id) = fees.referral_id { - if self.referral_fee > 0 + if let Some(referral_id) = &admin_fee.referral_id { + if admin_fee.referral_fee > 0 && numerator > U256::zero() - && self.shares.contains_key(&referral_id) + && self.shares.contains_key(referral_id) { - let denominator = new_invariant * self.total_fee / self.referral_fee; - self.mint_shares(&referral_id, (numerator / denominator).as_u128()); + let denominator = new_invariant * FEE_DIVISOR / admin_fee.referral_fee; + self.mint_shares(referral_id, (numerator / denominator).as_u128()); } } @@ -357,7 +355,7 @@ mod tests { one_near, accounts(2).as_ref(), 1, - SwapFees { + &AdminFees { exchange_fee: 0, exchange_id: accounts(3).as_ref().clone(), referral_fee: 0, @@ -407,7 +405,7 @@ mod tests { one_near, accounts(2).as_ref(), 1, - SwapFees { + &AdminFees { exchange_fee: 100, exchange_id: accounts(3).as_ref().clone(), referral_fee: 0, @@ -437,7 +435,9 @@ mod tests { exchange_fee: 5, referral_fee: 1, shares_total_supply: 35967818779820559673547466, - shares: LookupMap::new(StorageKey::Shares { pool_id: 0 }), + shares: LookupMap::new(StorageKey::Shares { + pool_id: 0, + }), }; let mut amounts = vec![145782, 1]; let _ = pool.add_liquidity(&accounts(2).to_string(), &mut amounts); diff --git a/ref-exchange/src/stable_swap/curve.md b/ref-exchange/src/stable_swap/curve.md new file mode 100644 index 0000000..d0cf0d4 --- /dev/null +++ b/ref-exchange/src/stable_swap/curve.md @@ -0,0 +1,111 @@ + +# math for stable swap pool +## About the D +--- + +### The equation: + +$$ +An^n \sum x_{i} + D = ADn^n + \frac{D^{n+1}}{n^n \prod x_{i}} +$$ + +$$ +f(D) = \frac{D^{n+1}}{n^n \prod x_{i}} + (An^n - 1)D - An^n \sum x_{i} = 0 +$$ + +$$ +f'(D) = \frac{n+1}{n^n \prod x_{i}} D^n + An^n - 1 = 0 +$$ + +### Solve for D using Newton method +--- + +Newton's method to solve for D: +$$ +D_{k+1} = D_{k} - \frac{f(D_{k})}{f'(D_{k})} +$$ + + +Let +$$ +D_{prod} = \frac{D^{n+1}}{n^n \prod x_{i}} +$$ + +Then, +$$ +D_{k+1} = \frac{D_k(An^n \sum x_{i} + nD_{k,prod})} {D_{k}(An^n - 1) + (n+1)D_{k,prod}} +$$ + +### Specialize +--- +Our conditions: + +$ n = 2 $ + +$ \sum x_i = (x+y) $ + +$ \prod x_i = (xy) $ + +So, +$$ +D_{prod} = \frac{D^{3}}{4xy} +$$ + +$$ +D_{k+1} = \frac{D_k(4A(x+y) + 2D_{k,prod})} {D_{k}(4A - 1) + 3D_{k,prod}} +$$ + +## About the y +--- +Let's withdraw y from $x_i$ : +$$ +\sum x_i = y + \sum x'_i +$$ + +$$ +\prod x_i = y \prod x'_i +$$ + +Assume we know $D$, $A$ and $x_i$, let's solve for $y$: + +$$ +An^n (y+\sum x'_{i}) + D = ADn^n + \frac{D^{n+1}}{n^n y \prod x'_i} +$$ +Make it to be $f(y)$: +$$ +An^ny^2 + [An^n\sum x'_i - (An^n-1)D]y = \frac{D^{n+1}}{n^n\prod x'_i} +$$ + +Simplify to $f(y)$ is: +$$ +y^2 + (\sum x'_i + \frac{D}{An^n} - D)y = \frac{D^{n+1}}{An^{2n}\prod x'_i} +$$ + +And $f'(y)$ is: +$$ +2y + \sum x'_i + \frac{D}{An^n} - D = 0 +$$ + +### Solve for y using Newton method +--- + +Newton's method to solve for D: + +$$ +y_{k+1} = y_{k} - \frac{f(y_{k})}{f'(y_{k})} +$$ + +Let's define $b$, $c$ as: + +$$ +b = \sum x'_i + \frac{D}{An^n} +$$ + +$$ +c = \frac{D^{n+1}}{An^{2n}\prod x'_i} +$$ + +Then: +$$ +y_{k+1} = \frac{y_k^2 + c}{2y_k + b - D} +$$ \ No newline at end of file diff --git a/ref-exchange/src/stable_swap/fee_strategy.md b/ref-exchange/src/stable_swap/fee_strategy.md new file mode 100644 index 0000000..57f0ef5 --- /dev/null +++ b/ref-exchange/src/stable_swap/fee_strategy.md @@ -0,0 +1,125 @@ +# FEE Strategy in REF Stable Swap Pool +## Fee Structure + +```rust +// fee rate in bps +pub struct StableSwapPool { + // ... ... + + // total fee rate + pub total_fee: u32, + + // ... ... +} + +// used in math of stable swap +pub struct Fees { + pub trade_fee: u32, // equal to total_fee above + pub admin_fee: u32, // based on trade_fee amount. +} + +/// details of admin_fee, +/// admin_fee = exchange_fee + referral_fee +pub struct AdminFees { + /// Basis points of the fee for exchange. + pub exchange_fee: u32, + /// Basis points of the fee for referrer. + pub referral_fee: u32, + pub exchange_id: AccountId, + pub referral_id: Option, +} +``` + +## Actions Involved +### Add liquidity +* D0 = original invariant D; +* D1 = D after adding deposit tokens; +* D2 = D after adding deposit tokens (subtract with fee per token); + +Relations: D1 >= D2 >= D0; + +```bash +share_increased = share_supply * (D1-D0)/D0; +share_mint_for_user = share_supply * (D2-D0)/D0; +share_fee_parts = share_increased - share_mint_for_user; +``` +**exchange_fee:** +```bash +share_mint_for_ex = share_fee_parts * exchange_fee / FEE_DIVISOR; +``` +**referral_fee:** +```bash +share_mint_for_re = share_fee_parts * referral_fee / FEE_DIVISOR; +``` + +**Remark** +A portion of share does NOT mint: +```bash +share_gap = share_fee_parts - share_mint_for_ex - share_mint_for_re +``` +This gap actually promotes the unit share value, that is to say, will benefit to all LP of this pool. + +***Note:*** +* Why charge fee when adding/removing liquidity? + imbalanced token in/out won't be good for the pool, so need fee; +* Fee algorithm when adding/removing liquidity? + Based on the difference between real token in/out and an ideal in/out amount per token. + +### Remove liquidity +**Remove by share won't involve any fee;** +*Cause there is no difference with ideal token amount* + +**Remove by token amounts** +Same as add liquidity, +* D0 = original invariant D; +* D1 = D after remove tokens; +* D2 = D after remove tokens (subtract with fee per token); + +Relations: D0 >= D1 >= D2; + +```bash +share_decreased = share_supply * (D0-D1)/D0; +share_burn_for_user = share_supply * (D0-D2)/D0; +share_fee_parts = share_burn_for_user - share_decreased; +``` +**exchange_fee:** +```bash +share_mint_for_ex = share_fee_parts * exchange_fee / FEE_DIVISOR; +``` +**referral_fee:** +```bash +share_mint_for_re = share_fee_parts * referral_fee / FEE_DIVISOR; +``` + +**Remark** +A portion of share was over burned: +```bash +share_gap = share_fee_parts - share_mint_for_ex - share_mint_for_re +``` +This gap actually promotes the unit share value, that is to say, will benefit to all LP of this pool. + +## Swap +Given that Alice want swap dX tokenA to get tokenB, then: +dY is the out-amount of tokenB to keep D unchanged and despite any fees; +`trading_fee_amount = dY * trade_fee / FEE_DIVISOR` +Alice actually got `dY - trading_fee_amount` tokenB. +We have: +`admin_fee_amount = trading_fee_amount * admin_fee / FEE_DIVISOR` + +If referral and its account is valid, referral got: +`referral_tokenB = admin_fee_amount * referral_fee / (referral_fee + exchange_fee)` + +Exchange would got: +`exchange_tokenB = admin_fee_amount - referral_tokenB` +That is to say, the exchange got all admin fee if referral is invalid. + +Both referral and exchange pour their tokenB back to pool as an adding liquidity process with 0 fee. That is the way they got their fee incoming as shares. + +**Remark** +A portion of TokenB was sustained in pool: +```bash +tokenB_gap = trading_fee_amount - admin_fee_amount +``` +This gap actually promotes the unit share value, that is to say, will benefit to all LP of this pool. + + diff --git a/ref-exchange/src/stable_swap/math.rs b/ref-exchange/src/stable_swap/math.rs index e5ac8e9..4f8bbe6 100644 --- a/ref-exchange/src/stable_swap/math.rs +++ b/ref-exchange/src/stable_swap/math.rs @@ -2,11 +2,9 @@ ///! Large part of the code was taken from https://github.com/saber-hq/stable-swap/blob/master/stable-swap-math/src/curve.rs use near_sdk::{Balance, Timestamp}; -use crate::fees::SwapFees; +use crate::admin_fee::AdminFees; use crate::utils::{FEE_DIVISOR, U256}; -/// Number of coins in the pool. -pub const N_COINS: u32 = 2; /// Minimum ramp duration. pub const MIN_RAMP_DURATION: Timestamp = 86400; /// Min amplification coefficient. @@ -23,18 +21,21 @@ pub struct Fees { } impl Fees { - pub fn new(total_fee: u32, fees: &SwapFees) -> Self { + pub fn new(total_fee: u32, fees: &AdminFees) -> Self { Self { - trade_fee: total_fee - fees.exchange_fee, - admin_fee: fees.exchange_fee, + trade_fee: total_fee, + admin_fee: fees.exchange_fee + fees.referral_fee, } } + + pub fn zero() -> Self { + Self { + trade_fee: 0, + admin_fee: 0, + } + } + pub fn trade_fee(&self, amount: Balance) -> Balance { - println!( - "trade fee: {} {}", - amount * (self.trade_fee as u128) / (FEE_DIVISOR as u128), - amount - ); amount * (self.trade_fee as u128) / (FEE_DIVISOR as u128) } @@ -94,27 +95,6 @@ impl StableSwap { } } - fn compute_next_d( - &self, - amp_factor: u128, - d_init: U256, - d_prod: U256, - sum_x: Balance, - ) -> Option { - let ann = amp_factor.checked_mul(N_COINS.into())?; - let leverage = (sum_x as u128).checked_mul(ann.into())?; - // d = (ann * sum_x + d_prod * n_coins) * d / ((ann - 1) * d + (n_coins + 1) * d_prod) - let numerator = d_init.checked_mul( - d_prod - .checked_mul(N_COINS.into())? - .checked_add(leverage.into())?, - )?; - let denominator = d_init - .checked_mul(ann.checked_sub(1)?.into())? - .checked_add(d_prod.checked_mul((N_COINS + 1).into())?)?; - numerator.checked_div(denominator) - } - /// Compute the amplification coefficient (A) pub fn compute_amp_factor(&self) -> Option { if self.current_ts < self.stop_ramp_ts { @@ -154,28 +134,45 @@ impl StableSwap { /// Compute stable swap invariant (D) /// Equation: /// A * sum(x_i) * n**n + D = A * D * n**n + D**(n+1) / (n**n * prod(x_i)) - pub fn compute_d(&self, amount_a: Balance, amount_b: Balance) -> Option { - let sum_x = amount_a.checked_add(amount_b)?; // sum(x_i), a.k.a S + pub fn compute_d(&self, c_amounts: &Vec) -> Option { + let n_coins = c_amounts.len() as u128; + let sum_x = c_amounts.iter().fold(0, |sum, i| sum + i); if sum_x == 0 { Some(0.into()) } else { let amp_factor = self.compute_amp_factor()?; - let amount_a_times_coins = amount_a.checked_mul(N_COINS.into())?; - let amount_b_times_coins = amount_b.checked_mul(N_COINS.into())?; - - // Newton's method to approximate D + // println!("#Debug amp_factor: {}", amp_factor); let mut d_prev: U256; let mut d: U256 = sum_x.into(); for _ in 0..256 { + // $ D_{k,prod} = \frac{D_k^{n+1}}{n^n \prod x_{i}} = \frac{D^3}{4xy} $ let mut d_prod = d; - d_prod = d_prod - .checked_mul(d)? - .checked_div(amount_a_times_coins.into())?; - d_prod = d_prod - .checked_mul(d)? - .checked_div(amount_b_times_coins.into())?; + for c_amount in c_amounts { + d_prod = d_prod.checked_mul(d)? + .checked_div((c_amount * n_coins + 1).into())?; // +1 to prevent divided by zero + } d_prev = d; - d = self.compute_next_d(amp_factor, d, d_prod, sum_x)?; + // println!("#Debug d_prod: {:?}", d_prod); + + let ann = amp_factor.checked_mul(n_coins.checked_pow(n_coins as u32)?.into())?; + // println!("#Debug ann: {}", ann); + // println!("#Debug sum_x: {}", sum_x); + // let ann = amp_factor.checked_mul(n_coins.into())?; + let leverage = (U256::from(sum_x)).checked_mul(ann.into())?; + // println!("#Debug leverage: {:?}", leverage); + // d = (ann * sum_x + d_prod * n_coins) * d_prev / ((ann - 1) * d_prev + (n_coins + 1) * d_prod) + let numerator = d_prev.checked_mul( + d_prod + .checked_mul(n_coins.into())? + .checked_add(leverage.into())?, + )?; + // println!("#Debug numerator: {:?}", numerator); + let denominator = d_prev + .checked_mul(ann.checked_sub(1)?.into())? + .checked_add(d_prod.checked_mul((n_coins + 1).into())?)?; + // println!("#Debug denominator: {:?}", denominator); + d = numerator.checked_div(denominator)?; + // Equality with the precision of 1 if d > d_prev { if d.checked_sub(d_prev)? <= 1.into() { @@ -185,37 +182,42 @@ impl StableSwap { break; } } - + // println!("D: {:?}", d); Some(d) } } /// Compute the amount of LP tokens to mint after a deposit + /// return pub fn compute_lp_amount_for_deposit( &self, - deposit_amount_a: Balance, - deposit_amount_b: Balance, - swap_amount_a: Balance, - swap_amount_b: Balance, - pool_token_supply: Balance, + deposit_c_amounts: &Vec, // deposit tokens in comparable precision, + old_c_amounts: &Vec, // current in-pool tokens in comparable precision, + pool_token_supply: Balance, // current share supply fees: &Fees, - ) -> Option { + ) -> Option<(Balance, Balance)> { + let n_coins = old_c_amounts.len(); + // Initial invariant - let d_0 = self.compute_d(swap_amount_a, swap_amount_b)?; - let old_balances = [swap_amount_a, swap_amount_b]; - let mut new_balances = [ - swap_amount_a.checked_add(deposit_amount_a)?, - swap_amount_b.checked_add(deposit_amount_b)?, - ]; + let d_0 = self.compute_d(old_c_amounts)?; + // println!("[compute_lp_amount_for_deposit] d_0: {:?}", d_0); + // println!("[compute_lp_amount_for_deposit] deposit_c_amounts: {:?}", deposit_c_amounts); + + let mut new_balances = vec![0_u128; n_coins]; + for (index, value) in deposit_c_amounts.iter().enumerate() { + new_balances[index] = old_c_amounts[index].checked_add(*value)?; + } + // println!("[compute_lp_amount_for_deposit] new_balances: {:?}", new_balances); // Invariant after change - let d_1 = self.compute_d(new_balances[0], new_balances[1])?; + let d_1 = self.compute_d(&new_balances)?; + // println!("[compute_lp_amount_for_deposit] d_1: {:?}", d_1); if d_1 <= d_0 { None } else { // Recalculate the invariant accounting for fees for i in 0..new_balances.len() { let ideal_balance = d_1 - .checked_mul(old_balances[i].into())? + .checked_mul(old_c_amounts[i].into())? .checked_div(d_0)? .as_u128(); let difference = if ideal_balance > new_balances[i] { @@ -223,45 +225,72 @@ impl StableSwap { } else { new_balances[i].checked_sub(ideal_balance)? }; - let fee = fees.normalized_trade_fee(N_COINS, difference); + let fee = fees.normalized_trade_fee(n_coins as u32, difference); new_balances[i] = new_balances[i].checked_sub(fee)?; } - let d_2 = self.compute_d(new_balances[0], new_balances[1])?; - Some( - U256::from(pool_token_supply) - .checked_mul(d_2.checked_sub(d_0)?)? - .checked_div(d_0)? - .as_u128(), - ) + let d_2 = self.compute_d(&new_balances)?; + + // d1 > d2 > d0, + // (d2-d0) => mint_shares (charged fee), + // (d1-d0) => diff_shares (without fee), + // (d1-d2) => fee part, + // diff_shares = mint_shares + fee part + + let mint_shares = U256::from(pool_token_supply) + .checked_mul(d_2.checked_sub(d_0)?)? + .checked_div(d_0)? + .as_u128(); + + let diff_shares = U256::from(pool_token_supply) + .checked_mul(d_1.checked_sub(d_0)?)? + .checked_div(d_0)? + .as_u128(); + + // println!( + // "[compute_lp_amount_for_deposit] mint_shares: {}, fee_parts: {}", + // mint_shares, diff_shares-mint_shares + // ); + Some((mint_shares, diff_shares-mint_shares)) } } - /// Compute swap amount `y` in proportion to `x` - /// Solve for y: - /// y**2 + y * (sum' - (A*n**n - 1) * D / (A * n**n)) = D ** (n + 1) / (n ** (2 * n) * prod' * A) - /// y**2 + b*y = c - pub fn compute_y_raw(&self, x: Balance, d: U256) -> Option { + /// Compute new amount of token 'y' with new amount of token 'x' + /// return new y_token amount according to the equation + pub fn compute_y( + &self, + x_c_amount: Balance, // new x_token amount in comparable precision, + current_c_amounts: &Vec, // in-pool tokens amount in comparable precision, + index_x: usize, // x token's index + index_y: usize, // y token's index + ) -> Option { + let n_coins = current_c_amounts.len() as u128; let amp_factor = self.compute_amp_factor()?; - let ann = amp_factor.checked_mul(N_COINS.into())?; // A * n ** n - - // sum' = prod' = x - // c = D ** (n + 1) / (n ** (2 * n) * prod' * A) - let mut c = d - .checked_mul(d)? - .checked_div(x.checked_mul(N_COINS.into())?.into())?; + // let ann = amp_factor.checked_mul(n_coins as u128)?; + let ann = amp_factor.checked_mul(n_coins.checked_pow(n_coins as u32)?.into())?; + // invariant + let d = self.compute_d(current_c_amounts)?; + let mut s_ = x_c_amount; + let mut c = d.checked_mul(d)?.checked_div(x_c_amount.into())?; + for (idx, c_amount) in current_c_amounts.iter().enumerate() { + if idx != index_x && idx != index_y { + s_ += *c_amount; + c = c.checked_mul(d)? + .checked_div((*c_amount).into())?; + } + } c = c .checked_mul(d)? - .checked_div(ann.checked_mul(N_COINS.into())?.into())?; - // b = sum' - (A*n**n - 1) * D / (A * n**n) - let b = d.checked_div(ann.into())?.checked_add(x.into())?; // d is subtracted on line 147 + .checked_div(ann.checked_mul((n_coins as u128).checked_pow(n_coins as u32)?.into())?.into())?; + + let b = d.checked_div(ann.into())?.checked_add(s_.into())?; // d will be subtracted later // Solve for y by approximating: y**2 + b*y = c let mut y_prev: U256; let mut y = d; for _ in 0..256 { y_prev = y; - // y = (y * y + c) / (2 * y + b - d); + // $ y_{k+1} = \frac{y_k^2 + c}{2y_k + b - D} $ let y_numerator = y.checked_pow(2.into())?.checked_add(c)?; let y_denominator = y.checked_mul(2.into())?.checked_add(b)?.checked_sub(d)?; y = y_numerator.checked_div(y_denominator)?; @@ -276,86 +305,108 @@ impl StableSwap { Some(y) } - /// Compute swap amount `y` in proportion to `x` - pub fn compute_y(&self, x: Balance, d: U256) -> u128 { - self.compute_y_raw(x, d).unwrap().as_u128() - } - /// Calculate withdrawal amount when withdrawing only one type of token - /// Calculation: - /// 1. Get current D - /// 2. Solve Eqn against y_i for D - _token_amount - pub fn compute_withdraw_one( + /// given token_out user want get and total tokens in pool and lp token supply, + /// return + /// all amounts are in c_amount (comparable amount) + pub fn compute_lp_amount_for_withdraw( &self, - pool_token_amount: Balance, - pool_token_supply: Balance, - swap_base_amount: Balance, // Same denomination of token to be withdrawn - swap_quote_amount: Balance, // Counter denomination of token to be withdrawn + withdraw_c_amounts: &Vec, // withdraw tokens in comparable precision, + old_c_amounts: &Vec, // in-pool tokens comparable amounts vector, + pool_token_supply: Balance, // total share supply fees: &Fees, ) -> Option<(Balance, Balance)> { - let d_0 = self.compute_d(swap_base_amount, swap_quote_amount)?; - let d_1 = d_0.checked_sub( - U256::from(pool_token_amount) - .checked_mul(d_0)? - .checked_div(pool_token_supply.into())?, - )?; - let new_y = self.compute_y(swap_quote_amount, d_1); - - // expected_base_amount = swap_base_amount * d_1 / d_0 - new_y; - let expected_base_amount = U256::from(swap_base_amount) - .checked_mul(d_1)? - .checked_div(d_0)? - .as_u128() - .checked_sub(new_y)?; - // expected_quote_amount = swap_quote_amount - swap_quote_amount * d_1 / d_0; - let expected_quote_amount = swap_quote_amount.checked_sub( - U256::from(swap_quote_amount) - .checked_mul(d_1)? + let n_coins = old_c_amounts.len(); + // Initial invariant, D0 + let d_0 = self.compute_d(old_c_amounts)?; + // println!("[compute_lp_amount_for_withdraw] d_0: {:?}", d_0); + + // real invariant after withdraw, D1 + let mut new_balances = vec![0_u128; n_coins]; + for (index, value) in withdraw_c_amounts.iter().enumerate() { + new_balances[index] = old_c_amounts[index].checked_sub(*value)?; + } + // println!("[compute_lp_amount_for_withdraw] new_balances: {:?}", new_balances); + let d_1 = self.compute_d(&new_balances)?; + // println!("[compute_lp_amount_for_withdraw] d_1: {:?}", d_1); + + // compare ideal token portions from D1 with withdraws, to calculate diff fee. + if d_1 >= d_0 { + None + } else { + // Recalculate the invariant accounting for fees + for i in 0..new_balances.len() { + let ideal_balance = d_1 + .checked_mul(old_c_amounts[i].into())? + .checked_div(d_0)? + .as_u128(); + let difference = if ideal_balance > new_balances[i] { + ideal_balance.checked_sub(new_balances[i])? + } else { + new_balances[i].checked_sub(ideal_balance)? + }; + let fee = fees.normalized_trade_fee(n_coins as u32, difference); + // new_balance is for calculation D2, the one with fee charged + new_balances[i] = new_balances[i].checked_sub(fee)?; + } + + let d_2 = self.compute_d(&new_balances)?; + + // d0 > d1 > d2, + // (d0-d2) => burn_shares (plus fee), + // (d0-d1) => diff_shares (without fee), + // (d1-d2) => fee part, + // burn_shares = diff_shares + fee part + let burn_shares = U256::from(pool_token_supply) + .checked_mul(d_0.checked_sub(d_2)?)? .checked_div(d_0)? - .as_u128(), - )?; - // new_base_amount = swap_base_amount - expected_base_amount * fee / fee_denominator; - let new_base_amount = swap_base_amount - .checked_sub(fees.normalized_trade_fee(N_COINS, expected_base_amount))?; - // new_quote_amount = swap_quote_amount - expected_quote_amount * fee / fee_denominator; - let new_quote_amount = swap_quote_amount - .checked_sub(fees.normalized_trade_fee(N_COINS, expected_quote_amount))?; - let dy = new_base_amount - .checked_sub(self.compute_y(new_quote_amount, d_1))? - .checked_sub(1)?; // Withdraw less to account for rounding errors - let dy_0 = swap_base_amount.checked_sub(new_y)?; - - Some((dy, dy_0 - dy)) + .as_u128(); + let diff_shares = U256::from(pool_token_supply) + .checked_mul(d_0.checked_sub(d_1)?)? + .checked_div(d_0)? + .as_u128(); + + // println!("[compute_lp_amount_for_withdraw] burn_shares: {}, fee_parts: {}", + // burn_shares, burn_shares-diff_shares); + Some((burn_shares, burn_shares-diff_shares)) + } + } /// Compute SwapResult after an exchange + /// all tokens in and out with comparable precision pub fn swap_to( &self, - source_amount: Balance, - swap_source_amount: Balance, - swap_destination_amount: Balance, + token_in_idx: usize, // token_in index in token vector, + token_in_amount: Balance, // token_in amount in comparable precision (1e18), + token_out_idx: usize, // token_out index in token vector, + current_c_amounts: &Vec, // in-pool tokens comparable amounts vector, fees: &Fees, ) -> Option { let y = self.compute_y( - swap_source_amount.checked_add(source_amount)?, - self.compute_d(swap_source_amount, swap_destination_amount)?, - ); - let dy = swap_destination_amount.checked_sub(y)?; - let dy_fee = fees.trade_fee(dy); - let admin_fee = fees.admin_trade_fee(dy_fee); - - let amount_swapped = dy.checked_sub(dy_fee)?; - let new_destination_amount = swap_destination_amount + token_in_amount + current_c_amounts[token_in_idx], + current_c_amounts, + token_in_idx, + token_out_idx, + )?.as_u128(); + + let dy = current_c_amounts[token_out_idx].checked_sub(y)?; + let trade_fee = fees.trade_fee(dy); + let admin_fee = fees.admin_trade_fee(trade_fee); + let amount_swapped = dy.checked_sub(trade_fee)?; + + let new_destination_amount = current_c_amounts[token_out_idx] .checked_sub(amount_swapped)? .checked_sub(admin_fee)?; - let new_source_amount = swap_source_amount.checked_add(source_amount)?; + let new_source_amount = current_c_amounts[token_in_idx] + .checked_add(token_in_amount)?; Some(SwapResult { new_source_amount, new_destination_amount, amount_swapped, admin_fee, - fee: dy_fee, + fee: trade_fee, }) } } diff --git a/ref-exchange/src/stable_swap/mod.rs b/ref-exchange/src/stable_swap/mod.rs index f301bba..7cfde7d 100644 --- a/ref-exchange/src/stable_swap/mod.rs +++ b/ref-exchange/src/stable_swap/mod.rs @@ -3,21 +3,28 @@ use near_sdk::collections::LookupMap; use near_sdk::json_types::ValidAccountId; use near_sdk::{env, AccountId, Balance, Timestamp}; -use crate::errors::{ERR13_LP_NOT_REGISTERED, ERR14_LP_ALREADY_REGISTERED}; -use crate::fees::SwapFees; +use crate::admin_fee::AdminFees; +use crate::errors::*; use crate::stable_swap::math::{ - Fees, StableSwap, SwapResult, MAX_AMP, MAX_AMP_CHANGE, MIN_AMP, MIN_RAMP_DURATION, N_COINS, + Fees, StableSwap, SwapResult, MAX_AMP, MAX_AMP_CHANGE, MIN_AMP, MIN_RAMP_DURATION, }; -use crate::utils::{add_to_collection, SwapVolume, FEE_DIVISOR}; +use crate::utils::{add_to_collection, SwapVolume, FEE_DIVISOR, U256}; use crate::StorageKey; -mod math; +pub mod math; + +pub const MIN_DECIMAL: u8 = 1; +pub const MAX_DECIMAL: u8 = 18; +pub const TARGET_DECIMAL: u8 = 18; +pub const MIN_RESERVE: u128 = 1; #[derive(BorshSerialize, BorshDeserialize)] pub struct StableSwapPool { /// List of tokens in the pool. pub token_account_ids: Vec, - /// How much NEAR this contract has. + /// Each decimals for tokens in the pool + pub token_decimals: Vec, + /// token amounts in original decimal. pub amounts: Vec, /// Volumes accumulated by this pool. pub volumes: Vec, @@ -41,21 +48,23 @@ impl StableSwapPool { pub fn new( id: u32, token_account_ids: Vec, + token_decimals: Vec, amp_factor: u128, total_fee: u32, ) -> Self { + for decimal in token_decimals.clone().into_iter() { + assert!(decimal <= MAX_DECIMAL, "{}", ERR60_DECIMAL_ILLEGAL); + assert!(decimal >= MIN_DECIMAL, "{}", ERR60_DECIMAL_ILLEGAL); + } assert!( amp_factor >= MIN_AMP && amp_factor <= MAX_AMP, - "ERR_WRONG_AMP" - ); - assert_eq!( - token_account_ids.len() as u32, - math::N_COINS, - "ERR_WRONG_TOKEN_COUNT" + "{}", + ERR61_AMP_ILLEGAL ); - assert!(total_fee < FEE_DIVISOR, "ERR_FEE_TOO_LARGE"); + assert!(total_fee < FEE_DIVISOR, "{}", ERR62_FEE_ILLEGAL); Self { token_account_ids: token_account_ids.iter().map(|a| a.clone().into()).collect(), + token_decimals, amounts: vec![0u128; token_account_ids.len()], volumes: vec![SwapVolume::default(); token_account_ids.len()], total_fee, @@ -68,12 +77,22 @@ impl StableSwapPool { } } - /// Returns token index for given pool. + fn get_invariant(&self) -> StableSwap { + StableSwap::new( + self.init_amp_factor, + self.target_amp_factor, + env::block_timestamp(), + self.init_amp_time, + self.stop_amp_time, + ) + } + + /// Returns token index for given token account_id. fn token_index(&self, token_id: &AccountId) -> usize { self.token_account_ids .iter() .position(|id| id == token_id) - .expect("ERR_MISSING_TOKEN") + .expect(ERR63_MISSING_TOKEN) } /// Returns given pool's total fee. @@ -86,117 +105,260 @@ impl StableSwapPool { self.volumes.clone() } + /// Get per lp token price, with 1e8 precision + pub fn get_share_price(&self) -> u128 { + let mut c_current_amounts = self.amounts.clone(); + let mut sum_token = 0_u128; + for (index, value) in self.token_decimals.iter().enumerate() { + let factor = 10_u128 + .checked_pow((TARGET_DECIMAL - value) as u32) + .unwrap(); + c_current_amounts[index] *= factor; + sum_token += c_current_amounts[index]; + } + + U256::from(sum_token) + .checked_mul(100000000.into()) + .unwrap() + .checked_div(self.shares_total_supply.into()) + .unwrap() + .as_u128() + } + /// Add liquidity into the pool. - /// Allows to add liquidity of a subset of tokens. + /// Allows to add liquidity of a subset of tokens, + /// by set other tokens balance into 0. pub fn add_liquidity( &mut self, sender_id: &AccountId, - amounts: &mut Vec, - fees: &SwapFees, + amounts: &Vec, + min_shares: Balance, + fees: &AdminFees, ) -> Balance { - assert_eq!( - amounts.len(), - self.token_account_ids.len(), - "ERR_WRONG_TOKEN_COUNT" - ); - let invariant = StableSwap::new( - self.init_amp_factor, - self.target_amp_factor, - env::block_timestamp(), - self.init_amp_time, - self.stop_amp_time, - ); - let new_shares = if self.shares_total_supply == 0 { - // Bootstrapping the pool. - invariant - .compute_d(amounts[0], amounts[1]) - .expect("ERR_CALC_FAILED") - .as_u128() + let n_coins = self.token_account_ids.len(); + assert_eq!(amounts.len(), n_coins, "{}", ERR64_TOKENS_COUNT_ILLEGAL); + + let invariant = self.get_invariant(); + + // make amounts into comparable-amounts + let mut c_amounts = amounts.clone(); + let mut c_current_amounts = self.amounts.clone(); + for (index, value) in self.token_decimals.iter().enumerate() { + let factor = 10_u128 + .checked_pow((TARGET_DECIMAL - value) as u32) + .unwrap(); + c_amounts[index] *= factor; + c_current_amounts[index] *= factor; + } + + let (new_shares, fee_part) = if self.shares_total_supply == 0 { + // Bootstrapping the pool, request providing all non-zero balances, + // and all fee free. + for c_amount in &c_amounts { + assert!(*c_amount > 0, "{}", ERR65_INIT_TOKEN_BALANCE); + } + ( + invariant + .compute_d(&c_amounts) + .expect(ERR66_INVARIANT_CALC_ERR) + .as_u128(), + 0, + ) } else { + // Subsequent add liquidity will charge fee according to difference with ideal balance portions invariant .compute_lp_amount_for_deposit( - amounts[0], - amounts[1], - self.amounts[0], - self.amounts[1], + &c_amounts, + &c_current_amounts, self.shares_total_supply, &Fees::new(self.total_fee, &fees), ) - // TODO: proper error - .expect("ERR_CALC_FAILED") + .expect(ERR67_LPSHARE_CALC_ERR) }; - // TODO: add slippage check on the LP tokens. - self.amounts[0] += amounts[0]; - self.amounts[1] += amounts[1]; + //slippage check on the LP tokens. + assert!(new_shares >= min_shares, "{}", ERR68_SLIPPAGE); + + for i in 0..n_coins { + self.amounts[i] = self.amounts[i].checked_add(amounts[i]).unwrap(); + } self.mint_shares(sender_id, new_shares); + env::log( + format!( + "Mint {} shares for {}, fee is {} shares", + new_shares, sender_id, fee_part, + ) + .as_bytes(), + ); + + if fee_part > 0 { + // referral fee + if let Some(referral) = &fees.referral_id { + if self.shares.get(referral).is_some() { + let referral_share = fee_part * fees.referral_fee as u128 / FEE_DIVISOR as u128; + self.mint_shares(referral, referral_share); + env::log( + format!("Referral {} got {} shares", referral, referral_share).as_bytes(), + ); + } + } + // exchange fee + let exchange_share = fee_part * fees.exchange_fee as u128 / FEE_DIVISOR as u128; + self.mint_shares(&fees.exchange_id, exchange_share); + env::log( + format!("Admin {} got {} shares", &fees.exchange_id, exchange_share).as_bytes(), + ); + } new_shares } - /// Remove liquidity from the pool. - /// Allows to remove liquidity of a subset of tokens, by providing 0 in `min_amount` for the tokens to not withdraw. - pub fn remove_liquidity( + /// balanced removal of liquidity would be free of charge. + pub fn remove_liquidity_by_shares( &mut self, sender_id: &AccountId, shares: Balance, min_amounts: Vec, - fees: &SwapFees, ) -> Vec { - assert_eq!( - min_amounts.len(), - self.token_account_ids.len(), - "ERR_WRONG_TOKEN_COUNT" - ); - let prev_shares_amount = self.shares.get(&sender_id).expect("ERR_NO_SHARES"); - assert!(prev_shares_amount >= shares, "ERR_NOT_ENOUGH_SHARES"); - let mut result = vec![0u128; N_COINS as usize]; - let invariant = StableSwap::new( - self.init_amp_factor, - self.target_amp_factor, - env::block_timestamp(), - self.init_amp_time, - self.stop_amp_time, + let n_coins = self.token_account_ids.len(); + assert_eq!(min_amounts.len(), n_coins, "{}", ERR64_TOKENS_COUNT_ILLEGAL); + let prev_shares_amount = self.shares.get(&sender_id).expect(ERR13_LP_NOT_REGISTERED); + assert!( + prev_shares_amount >= shares, + "{}", + ERR34_INSUFFICIENT_LP_SHARES ); - let mut fee_amounts = vec![0u128; N_COINS as usize]; - let stable_swap_fees = Fees::new(self.total_fee, &fees); - for (idx, min_amount) in min_amounts.iter().enumerate() { - if *min_amount != 0 { - let (amount_out, fee) = invariant - .compute_withdraw_one( - shares, - self.shares_total_supply, - self.amounts[idx], - self.amounts[1 - idx], - &stable_swap_fees, + let mut result = vec![0u128; n_coins]; + + // println!("[remove_liquidity_by_shares] prev_shares_amount {}", prev_shares_amount); + // println!("[remove_liquidity_by_shares] burn_shares_amount {}", shares); + // println!("[remove_liquidity_by_shares] total_shares {}", self.shares_total_supply); + // println!("[remove_liquidity_by_shares] in-pool tokens {:?}", self.amounts); + for i in 0..n_coins { + result[i] = U256::from(self.amounts[i]) + .checked_mul(shares.into()) + .unwrap() + .checked_div(self.shares_total_supply.into()) + .unwrap() + .as_u128(); + assert!(result[i] >= min_amounts[i], "{}", ERR68_SLIPPAGE); + self.amounts[i] = self.amounts[i].checked_sub(result[i]).unwrap(); + assert!( + self.amounts[i] >= MIN_RESERVE + .checked_mul( + 10_u128 + .checked_pow(self.token_decimals[i] as u32) + .unwrap() ) - .expect("ERR_CALC"); - assert!(amount_out >= *min_amount, "ERR_SLIPPAGE"); - fee_amounts[idx] += fee; - result[idx] = amount_out; - } - } - println!("fees: {:?}", fee_amounts); - for i in 0..N_COINS { - self.amounts[i as usize] = self.amounts[i as usize] - .checked_sub(result[i as usize]) - .expect("ERR_CALC"); + .unwrap(), + "{}", + ERR69_MIN_RESERVE + ); } + self.burn_shares(&sender_id, prev_shares_amount, shares); + // println!("[remove_liquidity_by_shares] got tokens {:?}", result); + // println!("[remove_liquidity_by_shares] Burned {} shares from {} by given shares", shares, sender_id); env::log( format!( - "{} shares of liquidity removed: receive back {:?}", - shares, - result - .iter() - .zip(self.token_account_ids.iter()) - .map(|(amount, token_id)| format!("{} {}", amount, token_id)) - .collect::>(), + "LP {} remove {} shares to gain tokens {:?}", + sender_id, shares, result ) .as_bytes(), ); + result } + + /// Remove liquidity from the pool by fixed tokens-out, + /// allows to remove liquidity of a subset of tokens, by providing 0 in `amounts`. + /// Fee will be charged according to diff between ideal token portions. + pub fn remove_liquidity_by_tokens( + &mut self, + sender_id: &AccountId, + amounts: Vec, + max_burn_shares: Balance, + fees: &AdminFees, + ) -> Balance { + let n_coins = self.token_account_ids.len(); + assert_eq!(amounts.len(), n_coins, "{}", ERR64_TOKENS_COUNT_ILLEGAL); + let prev_shares_amount = self.shares.get(&sender_id).expect(ERR13_LP_NOT_REGISTERED); + + // make amounts into comparable-amounts + let mut c_amounts = amounts.clone(); + let mut c_current_amounts = self.amounts.clone(); + for (index, value) in self.token_decimals.iter().enumerate() { + let factor = 10_u128 + .checked_pow((TARGET_DECIMAL - value) as u32) + .unwrap(); + c_amounts[index] *= factor; + c_current_amounts[index] *= factor; + } + + let invariant = self.get_invariant(); + let trade_fee = Fees::new(self.total_fee, &fees); + + let (burn_shares, fee_part) = invariant + .compute_lp_amount_for_withdraw( + &c_amounts, + &c_current_amounts, + self.shares_total_supply, + &trade_fee, + ) + .expect(ERR67_LPSHARE_CALC_ERR); + + assert!( + burn_shares <= prev_shares_amount, + "{}", + ERR34_INSUFFICIENT_LP_SHARES + ); + assert!(burn_shares <= max_burn_shares, "{}", ERR68_SLIPPAGE); + + for i in 0..n_coins { + self.amounts[i] = self.amounts[i].checked_sub(amounts[i]).unwrap(); + assert!( + self.amounts[i] >= MIN_RESERVE + .checked_mul( + 10_u128 + .checked_pow(self.token_decimals[i] as u32) + .unwrap() + ) + .unwrap(), + "{}", + ERR69_MIN_RESERVE + ); + } + self.burn_shares(&sender_id, prev_shares_amount, burn_shares); + env::log( + format!( + "LP {} removed {} shares by given tokens, and fee is {} shares", + sender_id, burn_shares, fee_part + ) + .as_bytes(), + ); + + if fee_part > 0 { + // referral fee + if let Some(referral) = &fees.referral_id { + if self.shares.get(referral).is_some() { + let referral_share = fee_part * fees.referral_fee as u128 / FEE_DIVISOR as u128; + self.mint_shares(referral, referral_share); + env::log( + format!("Referral {} got {} shares", referral, referral_share).as_bytes(), + ); + } + } + // exchange fee + let exchange_share = fee_part * fees.exchange_fee as u128 / FEE_DIVISOR as u128; + self.mint_shares(&fees.exchange_id, exchange_share); + env::log( + format!("Admin {} got {} shares", &fees.exchange_id, exchange_share).as_bytes(), + ); + } + + burn_shares + } + /// Returns number of tokens in outcome, given amount. /// Tokens are provided as indexes into token list for given pool. fn internal_get_return( @@ -204,23 +366,50 @@ impl StableSwapPool { token_in: usize, amount_in: Balance, token_out: usize, - fees: &SwapFees, + fees: &AdminFees, ) -> SwapResult { - let invariant = StableSwap::new( - self.init_amp_factor, - self.target_amp_factor, - env::block_timestamp(), - self.init_amp_time, - self.stop_amp_time, - ); - invariant + // make amounts into comparable-amounts + let mut c_amount_in = amount_in; + let mut c_current_amounts = self.amounts.clone(); + for (index, value) in self.token_decimals.iter().enumerate() { + let factor = 10_u128 + .checked_pow((TARGET_DECIMAL - value) as u32) + .unwrap(); + c_current_amounts[index] *= factor; + if index == token_in { + c_amount_in *= factor; + } + } + + let invariant = self.get_invariant(); + + let mut ret = invariant .swap_to( - amount_in, - self.amounts[token_in], - self.amounts[token_out], + token_in, + c_amount_in, + token_out, + &c_current_amounts, &Fees::new(self.total_fee, &fees), ) - .expect("ERR_CALC") + .expect(ERR70_SWAP_OUT_CALC_ERR); + + let factor_x = 10_u128 + .checked_pow((TARGET_DECIMAL - self.token_decimals[token_in]) as u32) + .unwrap(); + let factor_y = 10_u128 + .checked_pow((TARGET_DECIMAL - self.token_decimals[token_out]) as u32) + .unwrap(); + ret.new_source_amount = ret.new_source_amount.checked_div(factor_x.into()).unwrap(); + + let total_y = (ret.new_destination_amount + ret.amount_swapped + ret.fee) + .checked_div(factor_y.into()) + .unwrap(); + ret.amount_swapped = ret.amount_swapped.checked_div(factor_y.into()).unwrap(); + ret.admin_fee = ret.admin_fee.checked_div(factor_y.into()).unwrap(); + ret.fee = ret.fee.checked_div(factor_y.into()).unwrap(); + // fix rounding error by subtraction. + ret.new_destination_amount = total_y - ret.amount_swapped - ret.fee; + ret } /// Returns how much token you will receive if swap `token_amount_in` of `token_in` for `token_out`. @@ -229,7 +418,7 @@ impl StableSwapPool { token_in: &AccountId, amount_in: Balance, token_out: &AccountId, - fees: &SwapFees, + fees: &AdminFees, ) -> Balance { self.internal_get_return( self.token_index(token_in), @@ -248,30 +437,121 @@ impl StableSwapPool { amount_in: Balance, token_out: &AccountId, min_amount_out: Balance, - fees: &SwapFees, + fees: &AdminFees, ) -> Balance { - assert_ne!(token_in, token_out, "ERR_SAME_TOKEN_SWAP"); + assert_ne!(token_in, token_out, "{}", ERR71_SWAP_DUP_TOKENS); let in_idx = self.token_index(token_in); let out_idx = self.token_index(token_out); let result = self.internal_get_return(in_idx, amount_in, out_idx, &fees); - assert!(result.amount_swapped >= min_amount_out, "ERR_MIN_AMOUNT"); + assert!( + result.amount_swapped >= min_amount_out, + "{}", + ERR68_SLIPPAGE + ); env::log( format!( - "Swapped {} {} for {} {}", - amount_in, token_in, result.amount_swapped, token_out + "Swapped {} {} for {} {}, total fee {}, admin fee {}", + amount_in, token_in, result.amount_swapped, token_out, result.fee, result.admin_fee ) .as_bytes(), ); self.amounts[in_idx] = result.new_source_amount; self.amounts[out_idx] = result.new_destination_amount; + assert!( + self.amounts[out_idx] >= MIN_RESERVE + .checked_mul( + 10_u128 + .checked_pow(self.token_decimals[out_idx] as u32) + .unwrap() + ) + .unwrap(), + "{}", + ERR69_MIN_RESERVE + ); + // Keeping track of volume per each input traded separately. + self.volumes[in_idx].input.0 += amount_in; + self.volumes[out_idx].output.0 += result.amount_swapped; + + // handle admin / referral fee. + if fees.referral_fee + fees.exchange_fee > 0 { + let mut fee_token = 0_u128; + // referral fee + if let Some(referral) = &fees.referral_id { + if self.shares.get(referral).is_some() { + fee_token = result.admin_fee * fees.referral_fee as u128 + / (fees.referral_fee + fees.exchange_fee) as u128; + if fee_token > 0 { + let referral_share = + self.admin_fee_to_liquidity(referral, out_idx, fee_token); + env::log( + format!( + "Referral {} got {} shares from {} {}", + referral, + referral_share, + fee_token, + self.token_account_ids[out_idx] + ) + .as_bytes(), + ); + } + } + } + // exchange fee = admin_fee - referral_fee + fee_token = result.admin_fee - fee_token; + if fee_token > 0 { + let exchange_share = + self.admin_fee_to_liquidity(&fees.exchange_id, out_idx, fee_token); + env::log( + format!( + "Admin {} got {} shares from {} {}", + &fees.exchange_id, + exchange_share, + fee_token, + self.token_account_ids[out_idx] + ) + .as_bytes(), + ); + } + } - // TODO: add admin / referral fee here. + result.amount_swapped + } - // mint - println!("{:?}", self.amounts); + /// convert admin_fee into shares without any fee. + /// return share minted this time for the admin/refferal. + fn admin_fee_to_liquidity( + &mut self, + sender_id: &AccountId, + token_id: usize, + amount: Balance, + ) -> Balance { + let invariant = self.get_invariant(); + + // make amounts into comparable-amounts + let mut c_amounts = vec![0_u128; self.amounts.len()]; + c_amounts[token_id] = amount; + let mut c_current_amounts = self.amounts.clone(); + for (index, value) in self.token_decimals.iter().enumerate() { + let factor = 10_u128 + .checked_pow((TARGET_DECIMAL - value) as u32) + .unwrap(); + c_amounts[index] *= factor; + c_current_amounts[index] *= factor; + } - result.amount_swapped + let (new_shares, _) = invariant + .compute_lp_amount_for_deposit( + &c_amounts, + &c_current_amounts, + self.shares_total_supply, + &Fees::zero(), + ) + .expect(ERR67_LPSHARE_CALC_ERR); + self.amounts[token_id] += amount; + + self.mint_shares(sender_id, new_shares); + new_shares } /// Mint new shares for given user. @@ -310,11 +590,11 @@ impl StableSwapPool { /// Transfers shares from predecessor to receiver. pub fn share_transfer(&mut self, sender_id: &AccountId, receiver_id: &AccountId, amount: u128) { - let balance = self.shares.get(&sender_id).expect("ERR_NO_SHARES"); + let balance = self.shares.get(&sender_id).expect(ERR13_LP_NOT_REGISTERED); if let Some(new_balance) = balance.checked_sub(amount) { self.shares.insert(&sender_id, &new_balance); } else { - env::panic(b"ERR_NOT_ENOUGH_SHARES"); + env::panic(ERR34_INSUFFICIENT_LP_SHARES.as_bytes()); } let balance_out = self .shares @@ -343,11 +623,13 @@ impl StableSwapPool { let current_time = env::block_timestamp(); assert!( current_time >= self.init_amp_time + MIN_RAMP_DURATION, - "ERR_RAMP_LOCKED" + "{}", + ERR81_AMP_IN_LOCK ); assert!( future_amp_time >= current_time + MIN_RAMP_DURATION, - "ERR_INSUFFICIENT_RAMP_TIME" + "{}", + ERR82_INSUFFICIENT_RAMP_TIME ); let invariant = StableSwap::new( self.init_amp_factor, @@ -356,16 +638,20 @@ impl StableSwapPool { self.init_amp_time, self.stop_amp_time, ); - let amp_factor = invariant.compute_amp_factor().expect("ERR_CALC"); + let amp_factor = invariant + .compute_amp_factor() + .expect(ERR66_INVARIANT_CALC_ERR); assert!( future_amp_factor > 0 && future_amp_factor < MAX_AMP, - "ERR_INVALID_AMP_FACTOR" + "{}", + ERR83_INVALID_AMP_FACTOR ); assert!( (future_amp_factor >= amp_factor && future_amp_factor <= amp_factor * MAX_AMP_CHANGE) || (future_amp_factor < amp_factor && future_amp_factor * MAX_AMP_CHANGE >= amp_factor), - "ERR_AMP_LARGE_CHANGE" + "{}", + ERR84_AMP_LARGE_CHANGE ); self.init_amp_factor = amp_factor; self.init_amp_time = current_time; @@ -383,7 +669,9 @@ impl StableSwapPool { self.init_amp_time, self.stop_amp_time, ); - let amp_factor = invariant.compute_amp_factor().expect("ERR_CALC"); + let amp_factor = invariant + .compute_amp_factor() + .expect(ERR65_INIT_TOKEN_BALANCE); self.init_amp_factor = amp_factor; self.target_amp_factor = amp_factor; self.init_amp_time = current_time; @@ -395,7 +683,8 @@ impl StableSwapPool { mod tests { use near_sdk::test_utils::{accounts, VMContextBuilder}; use near_sdk::{testing_env, MockedBlockchain}; - use near_sdk_sim::to_yocto; + use std::convert::TryInto; + // use near_sdk_sim::to_yocto; use super::*; @@ -409,74 +698,262 @@ mod tests { accounts(token_in).as_ref(), amount_in, accounts(token_out).as_ref(), - 1, - &SwapFees::zero(), + 0, + &AdminFees::zero(), ) } #[test] - fn test_basics() { + fn test_stable_julia_01() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 1000, 0); + assert_eq!( + pool.tokens(), + vec![accounts(1).to_string(), accounts(2).to_string()] + ); + + let mut amounts = vec![100000000000, 100000000000]; + let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + + let out = swap(&mut pool, 1, 10000000000, 2); + assert_eq!(out, 9999495232); + assert_eq!(pool.amounts, vec![110000000000, 90000504768]); + } + + #[test] + fn test_stable_julia_02() { let mut context = VMContextBuilder::new(); testing_env!(context.predecessor_account_id(accounts(0)).build()); - let fees = SwapFees::zero(); - let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], 1, 0); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 1000, 0); assert_eq!( pool.tokens(), vec![accounts(1).to_string(), accounts(2).to_string()] ); - let mut amounts = vec![to_yocto("5"), to_yocto("10")]; - let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, &fees); + let mut amounts = vec![100000000000, 100000000000]; + let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + + let out = swap(&mut pool, 1, 0, 2); + assert_eq!(out, 0); + assert_eq!(pool.amounts, vec![100000000000, 100000000000]); + } - let out = swap(&mut pool, 1, to_yocto("1"), 2); - assert_eq!(out, 1313682630255414606428571); - assert_eq!(pool.amounts, vec![to_yocto("6"), 8686317369744585393571429]); + #[test] + fn test_stable_julia_03() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 1000, 0); + assert_eq!( + pool.tokens(), + vec![accounts(1).to_string(), accounts(2).to_string()] + ); + + let mut amounts = vec![100000000000, 100000000000]; + let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + + let out = swap(&mut pool, 1, 1, 2); + assert_eq!(out, 1); + assert_eq!(pool.amounts, vec![100000000001, 99999999999]); + } + + #[test] + fn test_stable_julia_04() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 1000, 0); + assert_eq!( + pool.tokens(), + vec![accounts(1).to_string(), accounts(2).to_string()] + ); + + let mut amounts = vec![100000000000, 100000000000]; + let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + + let out = swap(&mut pool, 1, 100000000000, 2); + assert_eq!(out, 98443663539); + assert_eq!(pool.amounts, vec![200000000000, 1556336461]); + } + + #[test] + fn test_stable_julia_05() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 1000, 0); + assert_eq!( + pool.tokens(), + vec![accounts(1).to_string(), accounts(2).to_string()] + ); + + let mut amounts = vec![100000000000, 100000000000]; + let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + + let out = swap(&mut pool, 1, 99999000000, 2); + assert_eq!(out, 98443167413); + assert_eq!(pool.amounts, vec![199999000000, 1556832587]); + } + + #[test] + fn test_stable_max() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new( + 0, + vec![ + "aone.near".try_into().unwrap(), + "atwo.near".try_into().unwrap(), + "athree.near".try_into().unwrap(), + "afour.near".try_into().unwrap(), + "afive.near".try_into().unwrap(), + "asix.near".try_into().unwrap(), + "aseven.near".try_into().unwrap(), + "aeight.near".try_into().unwrap(), + "anine.near".try_into().unwrap(), + ], + vec![ + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + ], + 1000, + 0 + ); + assert_eq!( + pool.tokens(), + vec![ + "aone.near".to_string(), + "atwo.near".to_string(), + "athree.near".to_string(), + "afour.near".to_string(), + "afive.near".to_string(), + "asix.near".to_string(), + "aseven.near".to_string(), + "aeight.near".to_string(), + "anine.near".to_string(), + ] + ); + + let mut amounts = vec![ + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + 100000000000_000000, + ]; + let share = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + assert_eq!(share, 900000000000_000000000000000000); + // let out = swap(&mut pool, 1, 99999000000, 2); + // assert_eq!(out, 98443167413); + // assert_eq!(pool.amounts, vec![199999000000, 1556832587]); + } + + #[test] + fn test_stable_basics() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let fees = AdminFees::zero(); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 10000, 0); + assert_eq!( + pool.tokens(), + vec![accounts(1).to_string(), accounts(2).to_string()] + ); + + let mut amounts = vec![5000000, 10000000]; + let _ = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + + let out = swap(&mut pool, 1, 1000000, 2); + assert_eq!(out, 1000031); + assert_eq!(pool.amounts, vec![6000000, 8999969]); let out2 = swap(&mut pool, 2, out, 1); - assert_eq!(out2, to_yocto("1") + 2); // due to precision difference. - assert_eq!(pool.amounts, vec![to_yocto("5") - 2, to_yocto("10")]); + assert_eq!(out2, 999999); // due to precision difference. + assert_eq!(pool.amounts, vec![5000001, 10000000]); // Add only one side of the capital. - let mut amounts2 = vec![to_yocto("5"), to_yocto("0")]; - let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts2, &fees); - - // Withdraw on another side of the capital. - let amounts_out = - pool.remove_liquidity(accounts(0).as_ref(), num_shares, vec![0, 1], &fees); - assert_eq!(amounts_out, vec![0, to_yocto("5")]); + let mut amounts2 = vec![5000000, 0]; + let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts2, 1, &fees); + + // Withdraw on same side of the capital. + let shares_burned = pool.remove_liquidity_by_tokens( + accounts(0).as_ref(), + vec![5000000, 0], + num_shares, + &fees, + ); + assert_eq!(shares_burned, num_shares); + + // Add only one side of the capital, and withdraw by share + let mut amounts2 = vec![5000000, 0]; + let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts2, 1, &fees); + + let tokens = pool.remove_liquidity_by_shares(accounts(0).as_ref(), num_shares, vec![1, 1]); + assert_eq!(tokens[0], 2500023); + assert_eq!(tokens[1], 2500023); + + // Add only one side of the capital, and withdraw from another side + let mut amounts2 = vec![5000000, 0]; + let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts2, 1, &fees); + let shares_burned = pool.remove_liquidity_by_tokens( + accounts(0).as_ref(), + vec![0, 5000000 - 1200], + num_shares, + &fees, + ); + // as imbalance withdraw, will lose a little amount token + assert!(shares_burned < num_shares); } /// Test everything with fees. #[test] - fn test_with_fees() { + fn test_stable_with_fees() { let mut context = VMContextBuilder::new(); testing_env!(context.predecessor_account_id(accounts(0)).build()); - let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], 1, 2000); - let mut amounts = vec![to_yocto("5"), to_yocto("10")]; - let fees = SwapFees::new(1000); - let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, &fees); + let mut pool = + StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 10000, 2000); + let mut amounts = vec![5000000, 10000000]; + let fees = AdminFees::new(1000); // 10% exchange fee + println!("before add_liquidity"); + let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + println!("end of add_liquidity"); let amount_out = pool.swap( accounts(1).as_ref(), - to_yocto("1"), + 1000000, accounts(2).as_ref(), 1, &fees, ); println!("swap out: {}", amount_out); - let amounts_out = - pool.remove_liquidity(accounts(0).as_ref(), num_shares, vec![1, 1], &fees); - println!("amount out: {:?}", amounts_out); + let tokens = pool.remove_liquidity_by_shares(accounts(0).as_ref(), num_shares/2, vec![1, 1]); + assert_eq!(tokens[0], 2996052); + assert_eq!(tokens[1], 4593934); } /// Test that adding and then removing all of the liquidity leaves the pool empty and with no shares. #[test] - fn test_add_transfer_remove_liquidity() { + #[should_panic(expected = "E69: pool reserved token balance less than MIN_RESERVE")] + fn test_stable_add_transfer_remove_liquidity() { let mut context = VMContextBuilder::new(); testing_env!(context.predecessor_account_id(accounts(0)).build()); - let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], 1, 0); - let mut amounts = vec![to_yocto("5"), to_yocto("10")]; - let fees = SwapFees::zero(); - let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, &fees); - assert_eq!(amounts, vec![to_yocto("5"), to_yocto("10")]); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 10000, 0); + let mut amounts = vec![5000000, 10000000]; + let fees = AdminFees::zero(); + let num_shares = pool.add_liquidity(accounts(0).as_ref(), &mut amounts, 1, &fees); + assert_eq!(amounts, vec![5000000, 10000000]); assert!(num_shares > 1); assert_eq!(num_shares, pool.share_balance_of(accounts(0).as_ref())); assert_eq!(pool.share_total_balance(), num_shares); @@ -490,34 +967,23 @@ mod tests { // Remove all liquidity. testing_env!(context.predecessor_account_id(accounts(3)).build()); - let out_amounts = - pool.remove_liquidity(accounts(3).as_ref(), num_shares, vec![1, 1], &fees); - - // Check it's all taken out. Due to precision there is ~1 yN. - assert_eq!( - vec![amounts[0], amounts[1]], - vec![out_amounts[0] + 1, out_amounts[1] + 1] - ); - assert_eq!(pool.share_total_balance(), 0); - assert_eq!(pool.share_balance_of(accounts(0).as_ref()), 0); - assert_eq!(pool.share_balance_of(accounts(3).as_ref()), 0); - assert_eq!(pool.amounts, vec![1, 1]); + pool.remove_liquidity_by_shares(accounts(3).as_ref(), num_shares, vec![1, 1]); } /// Test ramping up amplification factor, ramping it even more and then stopping. #[test] - fn test_ramp_amp() { + fn test_stable_ramp_amp() { let mut context = VMContextBuilder::new(); testing_env!(context.predecessor_account_id(accounts(0)).build()); - let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], 1, 0); + let mut pool = StableSwapPool::new(0, vec![accounts(1), accounts(2)], vec![6, 6], 10000, 0); let start_ts = 1_000_000_000; testing_env!(context.block_timestamp(start_ts).build()); - pool.ramp_amplification(5, start_ts + MIN_RAMP_DURATION * 10); + pool.ramp_amplification(50000, start_ts + MIN_RAMP_DURATION * 10); testing_env!(context .block_timestamp(start_ts + MIN_RAMP_DURATION * 3) .build()); - pool.ramp_amplification(15, start_ts + MIN_RAMP_DURATION * 20); + pool.ramp_amplification(150000, start_ts + MIN_RAMP_DURATION * 20); testing_env!(context .block_timestamp(start_ts + MIN_RAMP_DURATION * 5) .build()); diff --git a/ref-exchange/src/storage_impl.rs b/ref-exchange/src/storage_impl.rs index 44418e6..152c9a5 100644 --- a/ref-exchange/src/storage_impl.rs +++ b/ref-exchange/src/storage_impl.rs @@ -9,6 +9,7 @@ impl StorageManagement for Contract { account_id: Option, registration_only: Option, ) -> StorageBalance { + self.assert_contract_running(); let amount = env::attached_deposit(); let account_id = account_id .map(|a| a.into()) @@ -43,12 +44,11 @@ impl StorageManagement for Contract { #[payable] fn storage_withdraw(&mut self, amount: Option) -> StorageBalance { assert_one_yocto(); + self.assert_contract_running(); let account_id = env::predecessor_account_id(); - let account_deposit = self.internal_unwrap_account(&account_id); - let available = account_deposit.storage_available(); - let amount = amount.map(|a| a.0).unwrap_or(available); - assert!(amount <= available, "ERR_STORAGE_WITHDRAW_TOO_MUCH"); - Promise::new(account_id.clone()).transfer(amount); + let amount = amount.unwrap_or(U128(0)).0; + let withdraw_amount = self.internal_storage_withdraw(&account_id, amount); + Promise::new(account_id.clone()).transfer(withdraw_amount); self.storage_balance_of(account_id.try_into().unwrap()) .unwrap() } @@ -57,10 +57,10 @@ impl StorageManagement for Contract { #[payable] fn storage_unregister(&mut self, force: Option) -> bool { assert_one_yocto(); + self.assert_contract_running(); let account_id = env::predecessor_account_id(); - if let Some(account_deposit) = self.accounts.get(&account_id) { + if let Some(account_deposit) = self.internal_get_account(&account_id) { // TODO: figure out force option logic. - let account_deposit: Account = account_deposit.into(); assert!( account_deposit.tokens.is_empty(), "ERR_STORAGE_UNREGISTER_TOKENS_NOT_EMPTY" @@ -81,11 +81,9 @@ impl StorageManagement for Contract { } fn storage_balance_of(&self, account_id: ValidAccountId) -> Option { - self.accounts - .get(account_id.as_ref()) - .map(|deposits| + self.internal_get_account(account_id.as_ref()) + .map(|account| { - let account: Account = deposits.into(); StorageBalance { total: U128(account.near_amount), available: U128(account.storage_available()), diff --git a/ref-exchange/src/token_receiver.rs b/ref-exchange/src/token_receiver.rs index f11894d..5fb6a42 100644 --- a/ref-exchange/src/token_receiver.rs +++ b/ref-exchange/src/token_receiver.rs @@ -1,9 +1,12 @@ + use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::{serde_json, PromiseOrValue}; use crate::*; +pub const VIRTUAL_ACC: &str = "@"; + /// Message parameters to receive via token function call. #[derive(Serialize, Deserialize)] #[serde(crate = "near_sdk::serde")] @@ -12,102 +15,76 @@ enum TokenReceiverMessage { /// Alternative to deposit + execute actions call. Execute { referral_id: Option, - /// If force != 0, doesn't require user to even have account. In case of failure to deposit to the user's outgoing balance, tokens will be returned to the exchange and can be "saved" via governance. - /// If force == 0, the account for this user still have been registered. If deposit of outgoing tokens will fail, it will deposit it back into the account. - force: u8, /// List of sequential actions. actions: Vec, }, } impl Contract { - /// Executes set of actions on potentially virtual account. + /// Executes set of actions on virtual account. /// Returns amounts to send to the sender directly. fn internal_direct_actions( &mut self, token_in: AccountId, amount_in: Balance, - sender_id: &AccountId, - force: bool, referral_id: Option, actions: &[Action], ) -> Vec<(AccountId, Balance)> { - // [AUDIT_12] always save back account for a resident user - let mut is_resident_user: bool = true; - let mut initial_account: Account = self.accounts.get(sender_id).unwrap_or_else(|| { - is_resident_user = false; - if !force { - env::panic(ERR10_ACC_NOT_REGISTERED.as_bytes()); - } else { - Account::default().into() - } - }).into(); - initial_account.deposit(&token_in, amount_in); - let mut account = initial_account.clone(); + + // let @ be the virtual account + let mut account: Account = Account::new(&String::from(VIRTUAL_ACC)); + + account.deposit(&token_in, amount_in); let _ = self.internal_execute_actions( &mut account, &referral_id, &actions, - // [AUDIT_02] ActionResult::Amount(U128(amount_in)), ); + let mut result = vec![]; - for (token, amount) in account.tokens.clone().into_iter() { - let value = initial_account.tokens.get(&token); - // Remove tokens that were transient from the account. - if amount == 0 && value.is_none() { - account.tokens.remove(&token); - } else { - let initial_amount = *value.unwrap_or(&0); - if amount > initial_amount { - result.push((token.clone(), amount - initial_amount)); - account.tokens.insert(token, initial_amount); - } + for (token, amount) in account.tokens.to_vec() { + if amount > 0 { + result.push((token.clone(), amount)); } } - // [AUDIT_12] always save back account for a resident user - if is_resident_user { - // To avoid race conditions, we actually going to insert 0 to all changed tokens and save that. - self.internal_save_account(sender_id, account); - } + account.tokens.clear(); + result } + } #[near_bindgen] -#[allow(unreachable_code)] impl FungibleTokenReceiver for Contract { /// Callback on receiving tokens by this contract. /// `msg` format is either "" for deposit or `TokenReceiverMessage`. + #[allow(unreachable_code)] fn ft_on_transfer( &mut self, sender_id: ValidAccountId, amount: U128, msg: String, ) -> PromiseOrValue { + self.assert_contract_running(); let token_in = env::predecessor_account_id(); if msg.is_empty() { // Simple deposit. self.internal_deposit(sender_id.as_ref(), &token_in, amount.into()); PromiseOrValue::Value(U128(0)) } else { - // [AUDIT14] shutdown instant swap from interface - env::panic(b"Instant Swap Feature Not Open Yet"); - + // instant swap let message = - serde_json::from_str::(&msg).expect("ERR_MSG_WRONG_FORMAT"); + serde_json::from_str::(&msg).expect(ERR28_WRONG_MSG_FORMAT); match message { TokenReceiverMessage::Execute { referral_id, - force, actions, } => { let referral_id = referral_id.map(|x| x.to_string()); let out_amounts = self.internal_direct_actions( token_in, amount.0, - sender_id.as_ref(), - force != 0, referral_id, &actions, ); @@ -120,4 +97,4 @@ impl FungibleTokenReceiver for Contract { } } } -} +} \ No newline at end of file diff --git a/ref-exchange/src/utils.rs b/ref-exchange/src/utils.rs index 7e6603a..2ac9df8 100644 --- a/ref-exchange/src/utils.rs +++ b/ref-exchange/src/utils.rs @@ -9,13 +9,13 @@ use uint::construct_uint; /// Attach no deposit. pub const NO_DEPOSIT: u128 = 0; -/// hotfix_insuffient_gas_for_mft_resolve_transfer, increase from 5T to 20T +/// hotfix_insuffient_gas_for_mft_resolve_transfer. pub const GAS_FOR_RESOLVE_TRANSFER: Gas = 20_000_000_000_000; pub const GAS_FOR_FT_TRANSFER_CALL: Gas = 25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER; -/// Amount of gas for fungible token transfers. -pub const GAS_FOR_FT_TRANSFER: Gas = 10_000_000_000_000; +/// Amount of gas for fungible token transfers, increased to 20T to support AS token contracts. +pub const GAS_FOR_FT_TRANSFER: Gas = 20_000_000_000_000; /// Fee divisor, allowing to provide fee in bps. pub const FEE_DIVISOR: u32 = 10_000; diff --git a/ref-exchange/src/views.rs b/ref-exchange/src/views.rs index ba4348d..33cd1d3 100644 --- a/ref-exchange/src/views.rs +++ b/ref-exchange/src/views.rs @@ -9,8 +9,30 @@ use near_sdk::{near_bindgen, AccountId}; use crate::utils::SwapVolume; use crate::*; -#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[derive(Serialize)] #[serde(crate = "near_sdk::serde")] +#[cfg_attr(not(target_arch = "wasm32"), derive(Deserialize, Debug))] +pub struct ContractMetadata { + pub version: String, + pub owner: AccountId, + pub guardians: Vec, + pub pool_count: u64, + pub state: RunningState, + pub exchange_fee: u32, + pub referral_fee: u32, +} + +#[derive(Serialize, Deserialize, PartialEq)] +#[serde(crate = "near_sdk::serde")] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +pub struct RefStorageState { + pub deposit: U128, + pub usage: U128, +} + +#[derive(Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug, PartialEq))] pub struct PoolInfo { /// Pool kind. pub pool_kind: String, @@ -48,6 +70,25 @@ impl From for PoolInfo { #[near_bindgen] impl Contract { + + /// Return contract basic info + pub fn metadata(&self) -> ContractMetadata { + ContractMetadata { + version: env!("CARGO_PKG_VERSION").to_string(), + owner: self.owner_id.clone(), + guardians: self.guardians.to_vec(), + pool_count: self.pools.len(), + state: self.state.clone(), + exchange_fee: self.exchange_fee, + referral_fee: self.referral_fee, + } + } + + /// Only get guardians info + pub fn get_guardians(&self) -> Vec { + self.guardians.to_vec() + } + /// Returns semver of this contract. pub fn version(&self) -> String { env!("CARGO_PKG_VERSION").to_string() @@ -80,6 +121,10 @@ impl Contract { self.pools.get(pool_id).expect("ERR_NO_POOL").get_volumes() } + pub fn get_pool_share_price(&self, pool_id: u64) -> U128 { + self.pools.get(pool_id).expect("ERR_NO_POOL").get_share_price().into() + } + /// Returns number of shares given account has in given pool. pub fn get_pool_shares(&self, pool_id: u64, account_id: ValidAccountId) -> U128 { self.pools @@ -101,16 +146,15 @@ impl Contract { /// Returns balances of the deposits for given user outside of any pools. /// Returns empty list if no tokens deposited. pub fn get_deposits(&self, account_id: ValidAccountId) -> HashMap { - self.accounts - .get(account_id.as_ref()) - .map(|va| { - let a: Account = va.into(); - a.tokens - .into_iter() - .map(|(acc, bal)| (acc, U128(bal))) - .collect() - }) - .unwrap_or_default() + let wrapped_account = self.internal_get_account(account_id.as_ref()); + if let Some(account) = wrapped_account { + account.get_tokens() + .iter() + .map(|token| (token.clone(), U128(account.get_balance(token).unwrap()))) + .collect() + } else { + HashMap::new() + } } /// Returns balance of the deposit for given user outside of any pools. @@ -138,13 +182,24 @@ impl Contract { } /// Get specific user whitelisted tokens. - pub fn get_user_whitelisted_tokens(&self, account_id: &AccountId) -> Vec { - self.accounts - .get(&account_id) - .map(|va| { - let a: Account = va.into(); - a.tokens.keys().cloned().collect() - }) + pub fn get_user_whitelisted_tokens(&self, account_id: ValidAccountId) -> Vec { + self.internal_get_account(account_id.as_ref()) + .map(|x| x.get_tokens()) .unwrap_or_default() } + + /// Get user's storage deposit and needed in the account of current version + pub fn get_user_storage_state(&self, account_id: ValidAccountId) -> Option { + let acc = self.internal_get_account(account_id.as_ref()); + if let Some(account) = acc { + Some( + RefStorageState { + deposit: U128(account.near_amount), + usage: U128(account.storage_usage()), + } + ) + } else { + None + } + } } diff --git a/ref-exchange/stable_swap.md b/ref-exchange/stable_swap.md new file mode 100644 index 0000000..7d4bc4e --- /dev/null +++ b/ref-exchange/stable_swap.md @@ -0,0 +1,58 @@ +# Stable Swap Pool Instruction + +## Logic +--- +It is for swapping among stable coins. + +The stable swap pool can have more than 2 kinds of tokens (maximum 9 kinds of tokens with each 100 billion balances in simulation test ENV). + +The decimal of each token must in [1, 18]. + +The most likely first stable pool in REF would be [nDAI, nUSDT, nUSDC]. + +## Interfaces +--- +### Create Stable Swap Pool +Only owner or guardians of the ref-exchange contract can create stable swap pool. +```Bash +near call ref-exchange.testnet add_stable_swap_pool '{"tokens": ["ndai.testnet", "nusdt.testnet", "nusdc.testnet"], "decimals": [18, 6, 6], "fee": 25, "amp_factor": 100000}' --account_id=owner.testnet --amount=1 +# it will return pool_id +``` + +### Add Initial Liquidity +This interface is only for stable swap pools. Anyone can supply initial liquidity, but all tokens should be filled. +```Bash +# add 1 dai, 1 usdt and 1 usdc as initial liquidity with minimum 3 lpt shares +near call ref-exchange.testnet add_stable_liquidity '{"pool_id": 100, "amounts": ["1000000000000000000", "1000000", "1000000"], "min_shares": "3000000000000000000"}' --account_id=owner.testnet --amount=1 +# will return actually minted lpt shares +``` + +### Add Subsequent Liquidity +This interface is only for stable swap pools. Anyone can supply subsequent liquidity with subset of tokens. +```Bash +# add 100 dai, 10 usdt and 0 usdc with minimum 103 lpt shares +near call ref-exchange.testnet add_stable_liquidity '{"pool_id": 100, "amounts": ["100000000000000000000", "10000000", "0"], "min_shares": "103000000000000000000"}' --account_id=user.testnet --amount=1 +# will return actually minted lpt shares +``` + +### Withdraw Liquidity by Share +Anyone can withdraw liquidity by shares. the output less than `min_amounts` would cause TX failure. +```Bash +# withdraw 100 shares with min_amount 10 balance each +near call ref-exchange.testnet remove_liquidity '{"pool_id": 100, "shares": "100000000000000000000", "min_amounts": ["10000000000000000000", "10000000", "10000000"]}' --account_id=user.testnet --amount=0.000000000000000001 +``` + +### Withdraw Liquidity by Tokens +This interface is only for stable swap pools. Anyone can withdraw liquidity by tokens. It will return designated tokens to user's inner account, but if burned shares more than `max_burn_shares` would cause TX failure. +```Bash +# withdraw 50 nUSDT with max_burn_shares 60 balance +near call ref-exchange.testnet remove_liquidity_by_tokens '{"pool_id": 100, "max_burn_shares": "60000000000000000000", "amounts": ["0", "50000000", "0"]}' --account_id=user.testnet --amount=0.000000000000000001 +# will return actually burned lpt shares +``` + +### Swap in Stable Swap Pool +This interface is same as regular pool. +```Bash +# swap 1 nusdt to nusdc and if output is less than 0.99 the TX would failure +near call ref-exchange.testnet swap '{"actions": [{"pool_id": 100, "token_in": "nusdt.testnet", "amount_in": "1000000", "token_out": "nusdc.testnet", "min_amount_out": "990000"}], "referral_id": "referral.testnet"}' --account_id=user.testnet --amount=0.000000000000000001 +``` diff --git a/ref-exchange/tests/common/mod.rs b/ref-exchange/tests/common/mod.rs new file mode 100644 index 0000000..fab870e --- /dev/null +++ b/ref-exchange/tests/common/mod.rs @@ -0,0 +1 @@ +pub mod utils; \ No newline at end of file diff --git a/ref-exchange/tests/common/utils.rs b/ref-exchange/tests/common/utils.rs new file mode 100644 index 0000000..8b05628 --- /dev/null +++ b/ref-exchange/tests/common/utils.rs @@ -0,0 +1,500 @@ +use std::collections::HashMap; +use std::convert::TryFrom; + +use near_sdk::json_types::{ValidAccountId, U128}; +use near_sdk::serde_json::{Value, from_value}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::AccountId; +use near_sdk_sim::{ + call, deploy, init_simulator, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, +}; + +use ref_exchange::{ContractContract as Exchange, PoolInfo, ContractMetadata}; +use test_token::ContractContract as TestToken; + +near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { + TEST_TOKEN_WASM_BYTES => "../res/test_token.wasm", + PREV_EXCHANGE_WASM_BYTES => "../res/ref_exchange_131.wasm", + EXCHANGE_WASM_BYTES => "../res/ref_exchange_release.wasm", +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct RefStorageState { + pub deposit: U128, + pub usage: U128, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct StorageBalance { + pub total: U128, + pub available: U128, +} + +// pub fn should_fail(r: ExecutionResult) { +// println!("{:?}", r.status()); +// match r.status() { +// ExecutionStatus::Failure(_) => {} +// _ => panic!("Should fail"), +// } +// } + +pub fn show_promises(r: &ExecutionResult) { + for promise in r.promise_results() { + println!("{:?}", promise); + } +} + +pub fn get_logs(r: &ExecutionResult) -> Vec { + let mut logs: Vec = vec![]; + r.promise_results().iter().map( + |ex| ex.as_ref().unwrap().logs().iter().map( + |x| logs.push(x.clone()) + ).for_each(drop) + ).for_each(drop); + logs +} + +pub fn get_error_count(r: &ExecutionResult) -> u32 { + r.promise_errors().len() as u32 +} + +pub fn get_error_status(r: &ExecutionResult) -> String { + format!("{:?}", r.promise_errors()[0].as_ref().unwrap().status()) +} + +pub fn test_token( + root: &UserAccount, + token_id: AccountId, + accounts_to_register: Vec, +) -> ContractAccount { + let t = deploy!( + contract: TestToken, + contract_id: token_id, + bytes: &TEST_TOKEN_WASM_BYTES, + signer_account: root + ); + call!(root, t.new()).assert_success(); + call!( + root, + t.mint(to_va(root.account_id.clone()), to_yocto("1000000000").into()) + ) + .assert_success(); + for account_id in accounts_to_register { + call!( + root, + t.storage_deposit(Some(to_va(account_id)), None), + deposit = to_yocto("1") + ) + .assert_success(); + } + t +} + +//***************************** +// View functions +//***************************** + +/// tell a user if he has registered to given ft token +pub fn is_register_to_token( + token: &ContractAccount, + account_id: ValidAccountId +) -> bool { + let sb = view!(token.storage_balance_of(account_id)).unwrap_json_value(); + if let Value::Null = sb { + false + } else { + true + } +} + +/// get user's ft balance of given token +pub fn balance_of(token: &ContractAccount, account_id: &AccountId) -> u128 { + view!(token.ft_balance_of(to_va(account_id.clone()))).unwrap_json::().0 +} + +/// get ref-exchange's metadata +pub fn get_metadata(pool: &ContractAccount) -> ContractMetadata { + view!(pool.metadata()).unwrap_json::() +} + +/// get ref-exchange's version +pub fn get_version(pool: &ContractAccount) -> String { + view!(pool.version()).unwrap_json::() +} + +/// get ref-exchange's pool count +pub fn get_num_of_pools(pool: &ContractAccount) -> u64 { + view!(pool.get_number_of_pools()).unwrap_json::() +} + +/// get ref-exchange's all pool info +pub fn get_pools(pool: &ContractAccount) -> Vec { + view!(pool.get_pools(0, 100)).unwrap_json::>() +} + +/// get ref-exchange's pool info +pub fn get_pool(pool: &ContractAccount, pool_id: u64) -> PoolInfo { + view!(pool.get_pool(pool_id)) + .unwrap_json::() +} + +pub fn get_deposits( + pool: &ContractAccount, + account_id: ValidAccountId +) -> HashMap { + view!(pool.get_deposits(account_id)).unwrap_json::>() +} + +/// get ref-exchange's whitelisted tokens +pub fn get_whitelist(pool: &ContractAccount) -> Vec { + view!(pool.get_whitelisted_tokens()).unwrap_json::>() +} + +/// get ref-exchange's user whitelisted tokens +pub fn get_user_tokens(pool: &ContractAccount, account_id: ValidAccountId) -> Vec { + view!(pool.get_user_whitelisted_tokens(account_id)).unwrap_json::>() +} + +pub fn get_storage_balance( + pool: &ContractAccount, + account_id: ValidAccountId +) -> Option { + let sb = view!(pool.storage_balance_of(account_id)).unwrap_json_value(); + if let Value::Null = sb { + None + } else { + // near_sdk::serde_json:: + let ret: StorageBalance = from_value(sb).unwrap(); + Some(ret) + } +} + +pub fn get_storage_state( + pool: &ContractAccount, + account_id: ValidAccountId +) -> Option { + let sb = view!(pool.get_user_storage_state(account_id)).unwrap_json_value(); + if let Value::Null = sb { + None + } else { + let ret: RefStorageState = from_value(sb).unwrap(); + Some(ret) + } +} + +pub fn mft_balance_of( + pool: &ContractAccount, + token_or_pool: &str, + account_id: &AccountId, +) -> u128 { + view!(pool.mft_balance_of(token_or_pool.to_string(), to_va(account_id.clone()))) + .unwrap_json::() + .0 +} + +pub fn mft_total_supply( + pool: &ContractAccount, + token_or_pool: &str, +) -> u128 { + view!(pool.mft_total_supply(token_or_pool.to_string())) + .unwrap_json::() + .0 +} + +pub fn pool_share_price( + pool: &ContractAccount, + pool_id: u64, +) -> u128 { + view!(pool.get_pool_share_price(pool_id)) + .unwrap_json::() + .0 +} + +//************************************ + +pub fn dai() -> AccountId { + "dai001".to_string() +} + +pub fn eth() -> AccountId { + "eth002".to_string() +} + +pub fn usdt() -> AccountId { + "usdt".to_string() +} + +pub fn usdc() -> AccountId { + "usdc".to_string() +} + +pub fn swap() -> AccountId { + "swap".to_string() +} + +pub fn to_va(a: AccountId) -> ValidAccountId { + ValidAccountId::try_from(a).unwrap() +} + +pub fn setup_pool_with_liquidity() -> ( + UserAccount, + UserAccount, + ContractAccount, + ContractAccount, + ContractAccount, + ContractAccount, +) { + let root = init_simulator(None); + let owner = root.create_user("owner".to_string(), to_yocto("100")); + let pool = deploy!( + contract: Exchange, + contract_id: swap(), + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(to_va("owner".to_string()), 4, 1) + ); + let token1 = test_token(&root, dai(), vec![swap()]); + let token2 = test_token(&root, eth(), vec![swap()]); + let token3 = test_token(&root, usdt(), vec![swap()]); + call!( + owner, + pool.extend_whitelisted_tokens(vec![to_va(dai()), to_va(eth()), to_va(usdt())]) + ); + call!( + root, + pool.add_simple_pool(vec![to_va(dai()), to_va(eth())], 25), + deposit = to_yocto("1") + ) + .assert_success(); + call!( + root, + pool.add_simple_pool(vec![to_va(eth()), to_va(usdt())], 25), + deposit = to_yocto("1") + ) + .assert_success(); + call!( + root, + pool.add_simple_pool(vec![to_va(usdt()), to_va(dai())], 25), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + root, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + owner, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + root, + token1.ft_transfer_call(to_va(swap()), to_yocto("105").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + root, + token2.ft_transfer_call(to_va(swap()), to_yocto("110").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + root, + token3.ft_transfer_call(to_va(swap()), to_yocto("110").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + root, + pool.add_liquidity(0, vec![U128(to_yocto("10")), U128(to_yocto("20"))], None), + deposit = to_yocto("0.0007") + ) + .assert_success(); + call!( + root, + pool.add_liquidity(1, vec![U128(to_yocto("20")), U128(to_yocto("10"))], None), + deposit = to_yocto("0.0007") + ) + .assert_success(); + call!( + root, + pool.add_liquidity(2, vec![U128(to_yocto("10")), U128(to_yocto("10"))], None), + deposit = to_yocto("0.0007") + ) + .assert_success(); + (root, owner, pool, token1, token2, token3) +} + +pub fn setup_stable_pool_with_liquidity( + tokens: Vec, + amounts: Vec, + decimals: Vec, + pool_fee: u32, + amp: u64, +) -> ( + UserAccount, + UserAccount, + ContractAccount, + Vec>, +) { + let root = init_simulator(None); + let owner = root.create_user("owner".to_string(), to_yocto("100")); + let pool = deploy!( + contract: Exchange, + contract_id: swap(), + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(owner.valid_account_id(), 1600, 400) + ); + + let mut token_contracts: Vec> = vec![]; + for token_name in &tokens { + token_contracts.push(test_token(&root, token_name.clone(), vec![swap()])); + } + + call!( + owner, + pool.extend_whitelisted_tokens( + (&token_contracts).into_iter().map(|x| x.valid_account_id()).collect() + ) + ); + call!( + owner, + pool.add_stable_swap_pool( + (&token_contracts).into_iter().map(|x| x.valid_account_id()).collect(), + decimals, + pool_fee, + amp + ), + deposit = to_yocto("1")) + .assert_success(); + + call!( + root, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + owner, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + for (idx, amount) in amounts.clone().into_iter().enumerate() { + let c = token_contracts.get(idx).unwrap(); + call!( + root, + c.ft_transfer_call( + pool.valid_account_id(), + U128(amount), + None, + "".to_string() + ), + deposit = 1 + ) + .assert_success(); + } + + call!( + root, + pool.add_stable_liquidity(0, amounts.into_iter().map(|x| U128(x)).collect(), U128(1)), + deposit = to_yocto("0.0007") + ) + .assert_success(); + (root, owner, pool, token_contracts) +} + +pub fn mint_and_deposit_token( + user: &UserAccount, + token: &ContractAccount, + ex: &ContractAccount, + amount: u128, +) { + call!( + user, + token.mint(user.valid_account_id(), U128(amount)) + ) + .assert_success(); + call!( + user, + ex.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + call!( + user, + token.ft_transfer_call( + ex.valid_account_id(), + U128(amount), + None, + "".to_string() + ), + deposit = 1 + ) + .assert_success(); +} + +pub fn setup_exchange(root: &UserAccount, exchange_fee: u32, referral_fee: u32) -> ( + UserAccount, + ContractAccount, +) { + let owner = root.create_user("owner".to_string(), to_yocto("100")); + let pool = deploy!( + contract: Exchange, + contract_id: swap(), + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(to_va("owner".to_string()), exchange_fee, referral_fee) + ); + (owner, pool) +} + +pub fn whitelist_token( + owner: &UserAccount, + ex: &ContractAccount, + tokens: Vec, +) { + call!( + owner, + ex.extend_whitelisted_tokens(tokens) + ).assert_success(); +} + +pub fn deposit_token( + user: &UserAccount, + ex: &ContractAccount, + tokens: Vec<&ContractAccount>, + amounts: Vec, +) { + for (idx, token) in tokens.into_iter().enumerate() { + call!( + user, + ex.storage_deposit(None, None), + deposit = to_yocto("0.1") + ) + .assert_success(); + call!( + user, + token.ft_transfer_call( + ex.valid_account_id(), + U128(amounts[idx]), + None, + "".to_string() + ), + deposit = 1 + ) + .assert_success(); + } +} \ No newline at end of file diff --git a/ref-exchange/tests/fuzzy/constants.rs b/ref-exchange/tests/fuzzy/constants.rs new file mode 100644 index 0000000..5b22aaf --- /dev/null +++ b/ref-exchange/tests/fuzzy/constants.rs @@ -0,0 +1,24 @@ +pub const TOKENS: [&str; 10] = ["ref", "dai", "usdt", "usdc", "weth", "wnear", "1inch", "grt", "oct", "uni"]; + +pub const EVERY_PREFERENCE_NUM: i32 = 1; +pub const INIT_ACCOUNT_FOR_TOKEN: u64 = 200; + +pub const INIT_TOKEN_TO_SWAP_POOL_LIMIT: u64 = 100; +pub const ADD_LIQUIDITY_LIMIT: u64 = 20; +pub const REMOVE_LIQUIDITY_LIMIT: u64 = 20; +pub const FEE_LIMIT: i32 = 30; + +pub const FUZZY_NUM: usize = 2; +pub const OPERATION_NUM: i32 = 10; +pub const AMOUNT_IN_LIMIT: u128 = 10; +pub const TRANSFER_AMOUNT_LIMIT: u128 = 20; + +pub const LP_LIMIT: u128 = 10; +pub const STABLE_TOKENS: [&str; 3] = ["dai001", "usdt", "usdc"]; +pub const DECIMALS: [u8; 3] = [18, 6, 6]; +pub const TARGET_DECIMAL: u8 = 18; + +pub const ONE_LPT: u128 = 1000000000000000000; +pub const ONE_DAI: u128 = 1000000000000000000; +pub const ONE_USDT: u128 = 1000000; +pub const ONE_USDC: u128 = 1000000; \ No newline at end of file diff --git a/ref-exchange/tests/fuzzy/create_simple_pool.rs b/ref-exchange/tests/fuzzy/create_simple_pool.rs new file mode 100644 index 0000000..ee775bb --- /dev/null +++ b/ref-exchange/tests/fuzzy/create_simple_pool.rs @@ -0,0 +1,32 @@ +use near_sdk_sim::{ + call, to_yocto, view, ContractAccount, UserAccount, +}; +use ref_exchange::{ContractContract as Exchange, PoolInfo}; +use rand::Rng; +use rand_pcg::Pcg32; +use crate::fuzzy::types::*; +use crate::fuzzy::utils::*; +use crate::fuzzy::constants::*; + +pub fn create_simple_pool(ctx: &mut OperationContext, rng: &mut Pcg32, root: &UserAccount, operator: &Operator, pool :&ContractAccount){ + let (token1, token2) = get_token_pair(rng); + + if !ctx.token_contract_account.contains_key(&token1){ + let token_contract1 = test_token(root, token1.clone(), vec![swap()], vec![&operator.user]); + ctx.token_contract_account.insert(token1.clone(), token_contract1); + } + if !ctx.token_contract_account.contains_key(&token2){ + let token_contract2 = test_token(root, token2.clone(), vec![swap()], vec![&operator.user]); + ctx.token_contract_account.insert(token2.clone(), token_contract2); + } + + let fee = rng.gen_range(5..FEE_LIMIT); + let pool_id = call!( + &operator.user, + pool.add_simple_pool(vec![to_va(token1.clone()), to_va(token2.clone())], fee as u32), + deposit = to_yocto("1") + ) + .unwrap_json::(); + + println!("user: {} ,pool_id: {}, pool_info: {:?}", operator.user.account_id.clone(), pool_id, view!(pool.get_pool(pool_id)).unwrap_json::()); +} \ No newline at end of file diff --git a/ref-exchange/tests/fuzzy/direct_swap.rs b/ref-exchange/tests/fuzzy/direct_swap.rs new file mode 100644 index 0000000..f6c1e2e --- /dev/null +++ b/ref-exchange/tests/fuzzy/direct_swap.rs @@ -0,0 +1,157 @@ +use near_sdk_sim::{ + call, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, +}; +use near_sdk::json_types::U128; +use ref_exchange::{ContractContract as Exchange, PoolInfo}; +use rand::Rng; +use rand_pcg::Pcg32; +use crate::fuzzy::{ + types::*, + utils::*, + liquidity_manage::*, + constants::* +}; + +fn pack_action( + pool_id: u64, + token_in: &str, + token_out: &str, + amount_in: Option, + min_amount_out: u128, +) -> String { + if let Some(amount_in) = amount_in { + format!( + "{{\"pool_id\": {}, \"token_in\": \"{}\", \"amount_in\": \"{}\", \"token_out\": \"{}\", \"min_amount_out\": \"{}\"}}", + pool_id, token_in, amount_in, token_out, min_amount_out + ) + } else { + format!( + "{{\"pool_id\": {}, \"token_in\": \"{}\", \"token_out\": \"{}\", \"min_amount_out\": \"{}\"}}", + pool_id, token_in, token_out, min_amount_out + ) + } +} + +fn direct_swap_action( + ctx: &mut OperationContext, + user: &UserAccount, + token: &String, + actions: Vec, + transfer_amount: u128 +) -> ExecutionResult { + let token_contract = ctx.token_contract_account.get(token).unwrap(); + let actions_str = actions.join(", "); + let msg_str = format!("{{\"actions\": [{}]}}", actions_str); + call!( + user, + token_contract.ft_transfer_call(to_va(swap()), transfer_amount.into(), None, msg_str), + deposit = 1 + ) +} + +pub fn do_direct_swap(ctx: &mut OperationContext, rng: &mut Pcg32, root: &UserAccount, operator: &Operator, pool :&ContractAccount, simple_pool_count: u64){ + let simple_pool_id = if simple_pool_count == 0 { 0 } else { rng.gen_range(0..simple_pool_count) }; + let simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + + let tokens = &simple_pool_info.token_account_ids; + + let is_shuffle:i8 = rng.gen(); + + let (token_in, token_out) = if is_shuffle % 2 == 1 { + (tokens.get(0).unwrap(), tokens.get(1).unwrap()) + }else{ + (tokens.get(1).unwrap(), tokens.get(0).unwrap()) + }; + + let amount_in = to_yocto("10"); + // let transfer_amount = rng.gen_range(1..TRANSFER_AMOUNT_LIMIT); + let transfer_amount = to_yocto(&TRANSFER_AMOUNT_LIMIT.to_string()); + + let min_amount_out = to_yocto("1"); + + println!("amount_in: {}, transfer_amount:{}", amount_in, transfer_amount); + + let action = pack_action( + simple_pool_id, + token_in, + token_out, + Some(amount_in), + min_amount_out, + ); + + loop { + + let simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + + let token_in_pool_amount = get_token_amount_in_pool(&simple_pool_info, token_in); + let token_out_pool_amount = get_token_amount_in_pool(&simple_pool_info, token_out); + + let test_token_in_amount = get_test_token_amount(ctx, operator, token_in); + let test_token_out_amount = get_test_token_amount(ctx, operator, token_out); + + + let mut scenario = DSScenario::Normal; + if test_token_in_amount == 0{ + scenario = DSScenario::TokenInZero; + }else if test_token_out_amount == 0{ + scenario = DSScenario::TokenOutZero; + }else if token_in_pool_amount == 0 || token_out_pool_amount == 0 { + scenario = DSScenario::LiquidityEmpty; + } + + println!("direct_swap scenario : {:?} begin!", scenario); + + match scenario { + DSScenario::Normal => { + + let swap_amount_budget = view!(pool.get_return(simple_pool_id, to_va(token_in.clone()), U128(amount_in), to_va(token_out.clone()))).unwrap_json::().0; + + let out_come = direct_swap_action(ctx, &operator.user, token_in, vec![action.clone()], transfer_amount); + out_come.assert_success(); + + let test_token_in_amount_new = get_test_token_amount(ctx, operator, token_in); + let test_token_out_amount_new = get_test_token_amount(ctx, operator, token_out); + + if swap_amount_budget < min_amount_out { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("ERR_MIN_AMOUNT")); + assert_eq!(test_token_in_amount, test_token_in_amount_new); + assert_eq!(test_token_out_amount, test_token_out_amount_new); + }else{ + assert_eq!(test_token_in_amount - amount_in, test_token_in_amount_new); + assert_eq!(test_token_out_amount + swap_amount_budget, test_token_out_amount_new); + } + + let new_simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + println!("after pool swap current simple pool info {:?} ", new_simple_pool_info); + break; + }, + DSScenario::LiquidityEmpty => { + let out_come = direct_swap_action(ctx, &operator.user, token_in, vec![action.clone()], transfer_amount); + if amount_in > transfer_amount { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + } else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("Smart contract panicked: panicked at 'ERR_INVALID'")); + } + do_add_liquidity(ctx, rng, root, operator, pool, simple_pool_count, Some(simple_pool_id)); + }, + + DSScenario::TokenInZero => { + let out_come = direct_swap_action(ctx, &operator.user, token_in, vec![action.clone()], transfer_amount); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("Smart contract panicked: The account")); + assert!(get_error_status(&out_come).contains("is not registered")); + user_init_token_account(ctx, root, operator, token_in); + }, + DSScenario::TokenOutZero => { + user_init_token_account(ctx, root, operator, token_out); + }, + + + } + println!("direct_swap scenario : {:?} end!", scenario); + } + +} \ No newline at end of file diff --git a/ref-exchange/tests/fuzzy/liquidity_manage.rs b/ref-exchange/tests/fuzzy/liquidity_manage.rs new file mode 100644 index 0000000..6f5046c --- /dev/null +++ b/ref-exchange/tests/fuzzy/liquidity_manage.rs @@ -0,0 +1,398 @@ +use near_sdk_sim::{ + call, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, +}; +use test_token::ContractContract as TestToken; +use near_sdk::json_types::U128; +use near_sdk::AccountId; +use std::{collections::HashMap, convert::TryInto, process::id}; +use ref_exchange::{ContractContract as Exchange, PoolInfo, stable_swap::{StableSwapPool, math::StableSwap, math::Fees}, admin_fee::AdminFees}; +use rand::Rng; +use rand_pcg::Pcg32; +use crate::fuzzy::{ + types::*, + utils::*, + constants::* +}; +use std::cmp::min; +use std::panic; + +use uint::construct_uint; +construct_uint! { + pub struct U256(4); +} + +pub fn add_liquidity_action(pool :&ContractAccount, operator: &Operator, simple_pool_id: u64, liquidity1: u128, liquidity2: u128) -> ExecutionResult { + call!( + &operator.user, + pool.add_liquidity(simple_pool_id, vec![U128(liquidity1), U128(liquidity2)], None), + deposit = to_yocto("0.0009")// < 0.0009 ERR_STORAGE_DEPOSIT + ) +} + +pub fn real_liquidity(pool :&ContractAccount, pool_id: u64, amounts: Vec) -> Option<(u128, u128)>{ + let mut res = (0, 0); + let simple_pool_info = view!(pool.get_pool(pool_id)).unwrap_json::(); + + if u128::from(simple_pool_info.shares_total_supply) > 0{ + let mut fair_supply = U256::max_value(); + for i in 0..simple_pool_info.token_account_ids.len() { + fair_supply = min( + fair_supply, + U256::from(amounts[i]) * U256::from(simple_pool_info.shares_total_supply.0) / simple_pool_info.amounts[i].0, + ); + } + for i in 0..simple_pool_info.token_account_ids.len() { + let amount = (U256::from(simple_pool_info.amounts[i].0) * fair_supply + / U256::from(simple_pool_info.shares_total_supply.0)) + .as_u128(); + if i == 0 { + res.0 = amount; + }else{ + res.1 = amount; + } + } + }else{ + return None; + } + Some(res) +} + +pub fn do_add_liquidity(ctx: &mut OperationContext, rng: &mut Pcg32, root: &UserAccount, operator: &Operator, pool :&ContractAccount, simple_pool_count: u64, specified: Option){ + let simple_pool_id = match specified{ + Some(id) => id, + None => { + if simple_pool_count == 0 { 0 } else { rng.gen_range(0..simple_pool_count) } + } + }; + + let simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + + let tokens = simple_pool_info.token_account_ids; + + let (liquidity1, liquidity2) = (to_yocto(&ADD_LIQUIDITY_LIMIT.to_string()), to_yocto(&ADD_LIQUIDITY_LIMIT.to_string())); + + loop{ + let (scenario, token1_account, token2_account, token1_deposit, token2_deposit) = + current_evn_info(ctx, pool, operator, &tokens); + println!("add_liquidity scenario : {:?} begin!", scenario); + + match scenario { + Scenario::Normal => { + + let (real_liquidity1, real_liquidity2) = match real_liquidity(pool, simple_pool_id, vec![liquidity1, liquidity2]){ + Some((real_liquidity1, real_liquidity2)) => (real_liquidity1, real_liquidity2), + None => (liquidity1, liquidity2) + }; + + if token1_deposit < real_liquidity1{ + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + add_token_deposit(ctx, root, operator, tokens.get(0).unwrap(), token1_account, liquidity1, token1_deposit); + } + + if token2_deposit < real_liquidity2 { + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + add_token_deposit(ctx, root, operator, tokens.get(1).unwrap(), token2_account, liquidity2, token2_deposit); + } + + add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2).assert_success(); + + println!("add_liquidity scenario : Normal end!"); + let new_simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + println!("after add liquidity current simple pool info {:?} ", new_simple_pool_info); + break; + }, + Scenario::Token1NoAccount => { + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + + user_init_token_account(ctx, root, operator, tokens.get(0).unwrap()); + }, + Scenario::Token2NoAccount => { + let (real_liquidity1, _) = match real_liquidity(pool, simple_pool_id, vec![liquidity1, liquidity2]){ + Some((real_liquidity1, real_liquidity2)) => (real_liquidity1, real_liquidity2), + None => (liquidity1, liquidity2) + }; + let token1_deposit = view!(pool.get_deposit(to_va(operator.user.account_id.clone()), to_va(tokens.get(0).unwrap().clone()))).unwrap_json::().0; + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + + if token1_deposit != 0 && token1_deposit < real_liquidity1 { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + }else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + } + + user_init_token_account(ctx, root, operator, tokens.get(1).unwrap()); + }, + Scenario::Token1NotRegistered => { + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + + user_init_deposit_token(ctx, rng, operator, tokens.get(0).unwrap()); + }, + Scenario::Token2NotRegistered => { + let (real_liquidity1, _) = match real_liquidity(pool, simple_pool_id, vec![liquidity1, liquidity2]){ + Some((real_liquidity1, real_liquidity2)) => (real_liquidity1, real_liquidity2), + None => (liquidity1, liquidity2) + }; + let token1_deposit = view!(pool.get_deposit(to_va(operator.user.account_id.clone()), to_va(tokens.get(0).unwrap().clone()))).unwrap_json::().0; + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + + if token1_deposit != 0 && token1_deposit < real_liquidity1 { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + }else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + } + + user_init_deposit_token(ctx, rng, operator, tokens.get(1).unwrap()); + }, + Scenario::NoStorageDeposit => { + let out_come = add_liquidity_action(pool, operator, simple_pool_id, liquidity1, liquidity2); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + user_storage_deposit(pool, operator); + } + } + println!("add_liquidity scenario : {:?} end!", scenario); + } +} + +pub fn calculate_add_liquidity_out(real_pool :&ContractAccount, amounts: Vec) -> u128 { + let current_pool_info = view!(real_pool.get_pool(0)).unwrap_json::(); + let mut pool = + StableSwapPool::new(0, STABLE_TOKENS.iter().map(|&v| v.clone().to_string().try_into().unwrap()).collect(), vec![18, 6, 6], 10000, 25); + pool.amounts = current_pool_info.amounts.iter().map(|&v| v.0).collect(); + pool.token_account_ids = current_pool_info.token_account_ids; + pool.total_fee = current_pool_info.total_fee; + pool.shares_total_supply = current_pool_info.shares_total_supply.0; + + let mut amounts = amounts; + pool.add_liquidity(&"root".to_string().into(), &mut amounts, 1, &AdminFees::new(1600)) +} + +pub fn do_stable_add_liquidity(token_contracts: &Vec>, rng: &mut Pcg32, root: &UserAccount, operator: &StableOperator, pool :&ContractAccount) -> u128{ + let mut scenario = StableScenario::Normal; + + let add_amounts = vec![rng.gen_range(1..ADD_LIQUIDITY_LIMIT as u128) * ONE_DAI, + rng.gen_range(1..ADD_LIQUIDITY_LIMIT as u128) * ONE_USDT, + rng.gen_range(1..ADD_LIQUIDITY_LIMIT as u128) * ONE_USDC]; + + let min_shares = rng.gen_range(1..ADD_LIQUIDITY_LIMIT) as u128; + + let old_share = mft_balance_of(pool, ":0", &operator.user.account_id()); + + println!("do_stable_add_liquidity add_amounts : {:?}", add_amounts); + for (idx, amount) in add_amounts.clone().into_iter().enumerate() { + let token_contract = token_contracts.get(idx).unwrap(); + add_and_deposit_token(root, &operator.user, token_contract, pool, amount); + } + + let cal_share = calculate_add_liquidity_out(pool, add_amounts.clone()); + + if min_shares > cal_share { + scenario = StableScenario::Slippage; + } + + let out_come = call!( + operator.user, + pool.add_stable_liquidity(0, add_amounts.into_iter().map(|x| U128(x)).collect(), U128(min_shares)), + deposit = to_yocto("0.01") + ); + + let mut share = 0; + match scenario { + StableScenario::Normal => { + share = out_come.unwrap_json::().0; + assert_eq!(cal_share, share); + assert_eq!(mft_balance_of(pool, ":0", &operator.user.account_id()), old_share + share); + }, + StableScenario::Slippage => { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E68: slippage error")); + }, + StableScenario::InsufficientLpShares => { + } + } + share +} + +pub fn calculate_remove_liquidity_by_shares_out(real_pool :&ContractAccount, shares: u128) -> Vec { + let current_pool_info = view!(real_pool.get_pool(0)).unwrap_json::(); + let mut pool = + StableSwapPool::new(0, STABLE_TOKENS.iter().map(|&v| v.clone().to_string().try_into().unwrap()).collect(), vec![18, 6, 6], 10000, 25); + pool.amounts = current_pool_info.amounts.iter().map(|&v| v.0).collect(); + pool.token_account_ids = current_pool_info.token_account_ids; + pool.total_fee = current_pool_info.total_fee; + pool.shares_total_supply = current_pool_info.shares_total_supply.0; + + pool.remove_liquidity_by_shares(&"root".to_string().into(), shares, vec![1_u128, 1, 1]) +} + +pub fn do_stable_remove_liquidity_by_shares(token_contracts: &Vec>, rng: &mut Pcg32, root: &UserAccount, operator: &StableOperator, pool :&ContractAccount){ + let mut scenario = StableScenario::Normal; + + let min_amounts = vec![U128(1*ONE_DAI), U128(1*ONE_USDT), U128(1*ONE_USDC)]; + let remove_lp_num = rng.gen_range(1..LP_LIMIT) * ONE_LPT * 10; + + let mut user_lpt = mft_balance_of(&pool, ":0", &operator.user.account_id()); + + while user_lpt == 0 { + user_lpt = do_stable_add_liquidity(token_contracts, rng, root, operator, pool); + } + + let old_balances = view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>(); + let old_share = mft_balance_of(pool, ":0", &operator.user.account_id()); + + if user_lpt < remove_lp_num { + scenario = StableScenario::InsufficientLpShares; + }else{ + let total_supply = mft_total_supply(pool, ":0"); + let mut result = vec![0u128; STABLE_TOKENS.len()]; + let amounts = view!(pool.get_pool(0)).unwrap_json::().amounts; + for i in 0..STABLE_TOKENS.len() { + result[i] = U256::from(amounts[i].0) + .checked_mul(remove_lp_num.into()) + .unwrap() + .checked_div(total_supply.into()) + .unwrap() + .as_u128(); + if result[i] < min_amounts[i].0 { + scenario = StableScenario::Slippage; + break; + } + } + } + + let mut increase_amounts = vec![]; + if scenario == StableScenario::Normal { + increase_amounts = calculate_remove_liquidity_by_shares_out(pool, remove_lp_num); + } + + println!("user has lpt : {}, remove : {}", user_lpt, remove_lp_num); + + let out_come = call!( + operator.user, + pool.remove_liquidity(0, U128(remove_lp_num), min_amounts), + deposit = 1 + ); + + println!("do_stable_remove_liquidity_by_shares scenario : {:?} begin!", scenario); + match scenario { + StableScenario::Normal => { + out_come.assert_success(); + let user_share = mft_balance_of(pool, ":0", &operator.user.account_id()); + let balances = view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>(); + assert_eq!(user_share, old_share - remove_lp_num); + for (idx, item) in increase_amounts.iter().enumerate() { + assert_eq!(balances.get(STABLE_TOKENS[idx]).unwrap().0, + old_balances.get(STABLE_TOKENS[idx]).unwrap().0 + item); + } + }, + StableScenario::Slippage => { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E68: slippage error")); + }, + StableScenario::InsufficientLpShares => { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E34: insufficient lp shares")); + } + } + println!("do_stable_remove_liquidity_by_shares scenario : {:?} end!", scenario); +} + +pub fn calculate_remove_liquidity_by_token_out(real_pool :&ContractAccount, remove_amounts: Vec, max_burn_shares: u128) -> u128{ + let current_pool_info = view!(real_pool.get_pool(0)).unwrap_json::(); + + let mut c_amounts = remove_amounts.clone(); + let mut c_current_amounts:Vec = current_pool_info.amounts.clone().iter().map(|&v| v.0).collect(); + for (index, value) in DECIMALS.iter().enumerate() { + let factor = 10_u128 + .checked_pow((TARGET_DECIMAL - value) as u32) + .unwrap(); + c_amounts[index] *= factor; + c_current_amounts[index] *= factor; + } + + let invariant = StableSwap::new( + 10000, + 10000, + 0, + 0, + 0, + ); + if let Some((remove_lpt, free)) = invariant.compute_lp_amount_for_withdraw( + &c_amounts, + &c_current_amounts, + current_pool_info.shares_total_supply.0, + &Fees::new(current_pool_info.total_fee, &AdminFees::new(1600))){ + return remove_lpt; + } + panic!("check invariant.compute_lp_amount_for_withdraw error!"); +} + +pub fn do_stable_remove_liquidity_by_token(token_contracts: &Vec>, rng: &mut Pcg32, root: &UserAccount, operator: &StableOperator, pool :&ContractAccount){ + + let mut scenario = StableScenario::Normal; + + let remove_amounts = vec![rng.gen_range(1..REMOVE_LIQUIDITY_LIMIT as u128) * ONE_DAI, + rng.gen_range(1..REMOVE_LIQUIDITY_LIMIT as u128) * ONE_USDT, + rng.gen_range(1..REMOVE_LIQUIDITY_LIMIT as u128) * ONE_USDC]; + let max_burn_shares = rng.gen_range(1..LP_LIMIT as u128) * ONE_LPT; + let mut user_lpt = mft_balance_of(&pool, ":0", &operator.user.account_id()); + + while user_lpt == 0 { + user_lpt = do_stable_add_liquidity(token_contracts, rng, root, operator, pool); + } + + let old_balances = view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>(); + + let remove_lpt = calculate_remove_liquidity_by_token_out(pool, remove_amounts.clone(), max_burn_shares); + + if remove_lpt > user_lpt{ + scenario = StableScenario::InsufficientLpShares; + }else if remove_lpt > max_burn_shares{ + scenario = StableScenario::Slippage; + } + + println!("remove tokens: {:?}", remove_amounts); + println!("remove lpt: {} {} {}", user_lpt, remove_lpt, max_burn_shares); + + let out_come = call!( + operator.user, + pool.remove_liquidity_by_tokens(0, remove_amounts.iter().map(|&v| U128(v)).collect(), U128(max_burn_shares)), + deposit = 1 + ); + + println!("do_stable_remove_liquidity_by_token scenario : {:?} begin!", scenario); + match scenario { + StableScenario::Normal => { + out_come.assert_success(); + let current_share = mft_balance_of(&pool, ":0", &operator.user.account_id()); + assert_eq!(current_share, user_lpt - remove_lpt); + let balances = view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>(); + for (idx, item) in remove_amounts.iter().enumerate() { + assert_eq!(balances.get(STABLE_TOKENS[idx]).unwrap().0, + old_balances.get(STABLE_TOKENS[idx]).unwrap().0 + item); + } + }, + StableScenario::Slippage => { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E68: slippage error")); + }, + StableScenario::InsufficientLpShares => { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E34: insufficient lp shares")); + } + } + + println!("do_stable_remove_liquidity_by_token scenario : {:?} end!", scenario); +} \ No newline at end of file diff --git a/ref-exchange/tests/fuzzy/mod.rs b/ref-exchange/tests/fuzzy/mod.rs new file mode 100644 index 0000000..7fce88e --- /dev/null +++ b/ref-exchange/tests/fuzzy/mod.rs @@ -0,0 +1,8 @@ +pub mod create_simple_pool; +pub mod direct_swap; +pub mod pool_swap; +pub mod liquidity_manage; +pub mod types; +pub mod constants; +pub mod utils; + diff --git a/ref-exchange/tests/fuzzy/pool_swap.rs b/ref-exchange/tests/fuzzy/pool_swap.rs new file mode 100644 index 0000000..491813e --- /dev/null +++ b/ref-exchange/tests/fuzzy/pool_swap.rs @@ -0,0 +1,274 @@ +use near_sdk_sim::{ + call, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, +}; +use std::{collections::HashMap, convert::TryInto}; +use near_sdk::AccountId; +use near_sdk::json_types::U128; +use ref_exchange::{ContractContract as Exchange, PoolInfo, SwapAction, stable_swap::StableSwapPool, admin_fee::AdminFees}; +use rand::Rng; +use rand_pcg::Pcg32; +use crate::fuzzy::{ + types::*, + utils::*, + liquidity_manage::*, + constants::* +}; +use test_token::ContractContract as TestToken; + +pub fn swap_action(pool :&ContractAccount, operator: &Operator, token_in: AccountId, token_out: AccountId, amount_in: u128, simple_pool_id: u64) -> ExecutionResult{ + call!( + &operator.user, + pool.swap( + vec![SwapAction { + pool_id: simple_pool_id, + token_in: token_in, + amount_in: Some(U128(amount_in)), + token_out: token_out, + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ) +} + +pub fn do_pool_swap(ctx: &mut OperationContext, rng: &mut Pcg32, root: &UserAccount, operator: &Operator, pool :&ContractAccount, simple_pool_count: u64){ + let simple_pool_id = if simple_pool_count == 0 { 0 } else { rng.gen_range(0..simple_pool_count) }; + let simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + + let tokens = &simple_pool_info.token_account_ids; + + let is_shuffle:i8 = rng.gen(); + + let (token_in, token_out) = if is_shuffle % 2 == 1 { + (tokens.get(0).unwrap(), tokens.get(1).unwrap()) + }else{ + (tokens.get(1).unwrap(), tokens.get(0).unwrap()) + }; + + let amount_in = to_yocto(&AMOUNT_IN_LIMIT.to_string()); + + loop { + + let simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + + let token_in_pool_amount = get_token_amount_in_pool(&simple_pool_info, token_in); + let token_out_pool_amount = get_token_amount_in_pool(&simple_pool_info, token_out); + + let (scenario, token1_account, token2_account, token1_deposit, token2_deposit) = + current_evn_info(ctx, pool, operator, &tokens); + + let (token_in_amount, _token_out_amount, token_in_deposit, _token_out_deposit) = if is_shuffle % 2 == 1 { + (token1_account, token2_account, token1_deposit, token2_deposit) + }else{ + (token2_account, token1_account, token2_deposit, token1_deposit) + }; + + println!("pool_swap scenario : {:?} begin!", scenario); + + match scenario { + Scenario::Normal => { + if token_in_pool_amount == 0 || token_out_pool_amount == 0 { + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + assert_eq!(get_error_count(&out_come), 1); + if amount_in > token_in_deposit { + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + }else { + assert!(get_error_status(&out_come).contains("Smart contract panicked: panicked at 'ERR_INVALID'")); + } + do_add_liquidity(ctx, rng, root, operator, pool, simple_pool_count, Some(simple_pool_id)); + } + + let pool_deposits = view!(pool.get_deposits(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + let token_in_deposit_old = pool_deposits.get(token_in).unwrap().0; + let token_out_deposit_old = pool_deposits.get(token_out).unwrap().0; + + if amount_in > token_in_deposit_old { + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + println!("token_amount: {} need_amount: {} current_amount: {}", token_in_amount, amount_in, token_in_deposit_old); + add_token_deposit(ctx, root, operator, token_in, token_in_amount, amount_in, token_in_deposit_old); + } + + let swap_amount_budget = view!(pool.get_return(simple_pool_id, to_va(token_in.clone()), U128(amount_in), to_va(token_out.clone()))).unwrap_json::().0; + + let swap_amount_string = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id).unwrap_json::(); + let swap_amount = swap_amount_string.parse::().unwrap(); + + let pool_deposits = view!(pool.get_deposits(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + let token_out_deposit = pool_deposits.get(token_out).unwrap().0; + + assert_eq!(token_out_deposit, swap_amount + token_out_deposit_old); + assert_eq!(swap_amount, swap_amount_budget); + let new_simple_pool_info = view!(pool.get_pool(simple_pool_id)).unwrap_json::(); + println!("after pool swap current simple pool info {:?} ", new_simple_pool_info); + break; + }, + Scenario::Token1NoAccount => { + if is_shuffle % 2 == 1 { + let account_tokens = view!(pool.get_user_whitelisted_tokens(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + if account_tokens.contains(token_in) { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("Smart contract panicked: panicked at 'ERR_INVALID'")); + }else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + } + } + + user_init_token_account(ctx, root, operator, tokens.get(0).unwrap()); + }, + Scenario::Token2NoAccount => { + let account_tokens = view!(pool.get_user_whitelisted_tokens(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + if account_tokens.contains(token_in) { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("Smart contract panicked: panicked at 'ERR_INVALID'")); + }else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + } + + user_init_token_account(ctx, root, operator, tokens.get(1).unwrap()); + }, + Scenario::Token1NotRegistered => { + if token_in_pool_amount == 0 || token_out_pool_amount == 0 { + let account_tokens = view!(pool.get_user_whitelisted_tokens(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + if account_tokens.contains(token_in) { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("Smart contract panicked: panicked at 'ERR_INVALID'")); + }else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + } + } + + user_init_deposit_token(ctx, rng, operator, tokens.get(0).unwrap()); + }, + Scenario::Token2NotRegistered => { + if token_in_pool_amount == 0 || token_out_pool_amount == 0 { + let account_tokens = view!(pool.get_user_whitelisted_tokens(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + if account_tokens.contains(token_in) { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("Smart contract panicked: panicked at 'ERR_INVALID'")); + }else { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("token not registered")); + } + } + + user_init_deposit_token(ctx, rng, operator, tokens.get(1).unwrap()); + }, + Scenario::NoStorageDeposit => { + let out_come = swap_action(pool, operator, token_in.clone(), token_out.clone(), amount_in, simple_pool_id); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E10: account not registered")); + user_storage_deposit(pool, operator); + } + } + println!("pool_swap scenario : {:?} end!", scenario); + } + +} + +pub fn get_swap_info(rng: &mut Pcg32) -> (AccountId, AccountId, u128, usize, usize){ + let amount_in_unit = vec![ONE_DAI, ONE_USDT, ONE_USDC]; + let amount_in = rng.gen_range(1..AMOUNT_IN_LIMIT); + loop { + let token_in_index = rng.gen_range(0..STABLE_TOKENS.len()); + let token_out_index = rng.gen_range(0..STABLE_TOKENS.len()); + if token_in_index == token_out_index { + continue; + } + return (STABLE_TOKENS[token_in_index].to_string(), STABLE_TOKENS[token_out_index].to_string(), amount_in_unit[token_in_index] * amount_in, + token_in_index, token_out_index) + } +} + +pub fn calculate_swap_out(real_pool :&ContractAccount, token_in: &String, token_out: &String, amount_in: u128) -> u128 { + let current_pool_info = view!(real_pool.get_pool(0)).unwrap_json::(); + let mut pool = + StableSwapPool::new(0, STABLE_TOKENS.iter().map(|&v| v.clone().to_string().try_into().unwrap()).collect(), vec![18, 6, 6], 10000, 25); + pool.amounts = current_pool_info.amounts.iter().map(|&v| v.0).collect(); + pool.token_account_ids = current_pool_info.token_account_ids; + pool.total_fee = current_pool_info.total_fee; + pool.shares_total_supply = current_pool_info.shares_total_supply.0; + + pool.swap( + token_in.into(), + amount_in, + token_out.into(), + 1, + &AdminFees::new(1600), + ) +} + +pub fn do_stable_pool_swap(token_contracts: &Vec>, rng: &mut Pcg32, root: &UserAccount, operator: &StableOperator, pool :&ContractAccount){ + + let mut scenario = StableScenario::Normal; + + let balances = view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>(); + println!("current user balance: {:?}", balances); + + let (token_in, token_out, amount_in, token_in_index, token_out_index) = get_swap_info(rng); + + let token_contract = token_contracts.get(token_in_index).unwrap(); + + println!("swap {} => {} : {}", token_in, token_out, amount_in); + add_and_deposit_token(root, &operator.user, token_contract, pool, amount_in); + let balances = view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>(); + println!("current user balance: {:?}", balances); + let token_in_amount = balances.get(&token_in).unwrap().0; + let token_out_amount = balances.get(&token_out).unwrap_or(&U128(0_u128)).0; + + + let swap_out = calculate_swap_out(pool, &token_in, &token_out, amount_in); + if swap_out > view!(pool.get_pool(0)).unwrap_json::().amounts[token_out_index].0{ + scenario = StableScenario::Slippage; + } + + let out_come = call!( + operator.user, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: token_in.clone(), + amount_in: Some(U128(amount_in)), + token_out: token_out.clone(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + println!("do_stable_pool_swap scenario : {:?} begin!", scenario); + match scenario { + StableScenario::Normal => { + out_come.assert_success(); + assert_eq!(view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>().get(&token_in).unwrap().0, + token_in_amount - amount_in + ); + assert_eq!(view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>().get(&token_out).unwrap().0, + token_out_amount + swap_out + ); + }, + StableScenario::Slippage => { + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E34: insufficient lp shares")); + assert_eq!(view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>().get(&token_in).unwrap().0, + token_in_amount + ); + assert_eq!(view!(pool.get_deposits(operator.user.valid_account_id())).unwrap_json::>().get(&token_out).unwrap().0, + token_out_amount + ); + } + _ => { + panic!("do_stable_pool_swap find new StableScenario {:?}", scenario); + } + } + println!("do_stable_pool_swap scenario : {:?} end!", scenario); +} \ No newline at end of file diff --git a/ref-exchange/tests/fuzzy/types.rs b/ref-exchange/tests/fuzzy/types.rs new file mode 100644 index 0000000..43ab43c --- /dev/null +++ b/ref-exchange/tests/fuzzy/types.rs @@ -0,0 +1,77 @@ +use near_sdk::serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use near_sdk::AccountId; +use near_sdk_sim::{ + ContractAccount, UserAccount, +}; +use near_sdk::json_types::U128; +use test_token::ContractContract as TestToken; + +#[derive(Default)] +pub struct OperationContext { + pub token_contract_account: HashMap> +} + +#[derive(Debug)] +pub enum Preference { + CreateSamplePool, + DirectSwap, + PoolSwap, + AddLiquidity +} + +#[derive(Debug)] +pub enum Scenario { + Normal, + Token1NotRegistered, + Token2NotRegistered, + Token1NoAccount, + Token2NoAccount, + NoStorageDeposit, +} + +#[derive(Debug)] +pub enum DSScenario { + Normal, + LiquidityEmpty, + TokenInZero, + TokenOutZero, +} + +#[derive(Debug)] +pub struct Operator { + pub user: UserAccount, + pub preference: Preference +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct RefStorageState { + pub deposit: U128, + pub usage: U128, +} + +/** + * Related to stable swap + */ + +#[derive(Debug)] +pub enum StablePreference { + RemoveLiquidityByToken, + RemoveLiquidityByShare, + PoolSwap, + AddLiquidity +} + +#[derive(Debug)] +pub struct StableOperator { + pub user: UserAccount, + pub preference: StablePreference +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StableScenario { + Normal, + Slippage, + InsufficientLpShares +} diff --git a/ref-exchange/tests/fuzzy/utils.rs b/ref-exchange/tests/fuzzy/utils.rs new file mode 100644 index 0000000..b50ea12 --- /dev/null +++ b/ref-exchange/tests/fuzzy/utils.rs @@ -0,0 +1,513 @@ +use near_sdk_sim::{ + call, deploy, init_simulator, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, +}; +use std::collections::HashMap; +use std::os::unix::thread; +use near_sdk::serde_json::{Value, from_value}; +use std::convert::TryFrom; +use rand::Rng; +use rand_pcg::Pcg32; +use near_sdk::json_types::{ValidAccountId, U128}; +use ref_exchange::{ContractContract as Exchange, PoolInfo}; +use test_token::ContractContract as TestToken; +use near_sdk::AccountId; +use crate::fuzzy::types::*; +use crate::fuzzy::constants::*; + + + +near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { + TEST_TOKEN_WASM_BYTES => "../res/test_token.wasm", + EXCHANGE_WASM_BYTES => "../res/ref_exchange_release.wasm", +} + +// pub fn show_promises(r: &ExecutionResult) { +// for promise in r.promise_results() { +// println!("{:?}", promise); +// } +// } + +// pub fn get_logs(r: &ExecutionResult) -> Vec { +// let mut logs: Vec = vec![]; +// r.promise_results().iter().map( +// |ex| ex.as_ref().unwrap().logs().iter().map( +// |x| logs.push(x.clone()) +// ).for_each(drop) +// ).for_each(drop); +// logs +// } + + +/** + * Related to common + */ + +pub fn get_operator<'a, T>(rng: &mut Pcg32, users: &'a Vec) -> &'a T{ + let user_index = rng.gen_range(0..users.len()); + &users[user_index] +} + +/** + * Related to amm swap + */ + +pub fn get_error_count(r: &ExecutionResult) -> u32 { + r.promise_errors().len() as u32 +} + +pub fn get_error_status(r: &ExecutionResult) -> String { + format!("{:?}", r.promise_errors()[0].as_ref().unwrap().status()) +} + +pub fn get_token_pair(rng: &mut Pcg32) -> (AccountId, AccountId){ + loop { + let token1_index = rng.gen_range(0..TOKENS.len()); + let token2_index = rng.gen_range(0..TOKENS.len()); + if token1_index == token2_index { + continue; + } + let token1 = TOKENS[token1_index]; + let token2 = TOKENS[token2_index]; + return (token1.to_string(), token2.to_string()) + } +} + + +pub fn test_token( + root: &UserAccount, + token_id: AccountId, + accounts_to_register: Vec, + accounts_to_mint: Vec<&UserAccount> +) -> ContractAccount { + let t = deploy!( + contract: TestToken, + contract_id: token_id, + bytes: &TEST_TOKEN_WASM_BYTES, + signer_account: root + ); + call!(root, t.new()).assert_success(); + call!( + root, + t.mint(to_va(root.account_id.clone()), to_yocto(&format!("{}", INIT_ACCOUNT_FOR_TOKEN)).into()) + ) + .assert_success(); + + for user in accounts_to_mint{ + call!( + root, + t.mint(to_va(user.account_id.clone()), to_yocto(&format!("{}", INIT_ACCOUNT_FOR_TOKEN)).into()) + ) + .assert_success(); + } + + for account_id in accounts_to_register { + call!( + root, + t.storage_deposit(Some(to_va(account_id)), None), + deposit = to_yocto("1") + ) + .assert_success(); + } + t +} + +pub fn to_va(a: AccountId) -> ValidAccountId { + ValidAccountId::try_from(a).unwrap() +} + +pub fn swap() -> AccountId { + "swap".to_string() +} + +pub fn get_token_amount_in_pool(simple_pool_info: &PoolInfo, token_account_id: &AccountId) -> u128 { + simple_pool_info.amounts[simple_pool_info.token_account_ids.iter().position(|id| id == token_account_id).unwrap()].0 +} + +pub fn user_storage_deposit(pool :&ContractAccount, operator: &Operator){ + call!( + &operator.user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); +} + +pub fn user_init_deposit_token(ctx: &mut OperationContext, rng: &mut Pcg32, operator: &Operator, token: &AccountId) { + let init_token = rng.gen_range(10..INIT_TOKEN_TO_SWAP_POOL_LIMIT); + let token_contract2 = ctx.token_contract_account.get(token).unwrap(); + call!( + &operator.user, + token_contract2.ft_transfer_call(to_va(swap()), to_yocto(&init_token.to_string()).into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); +} + +pub fn user_init_token_account(ctx: &mut OperationContext, root: &UserAccount, operator: &Operator, token: &AccountId){ + let token_contract = ctx.token_contract_account.get(token).unwrap(); + call!( + root, + token_contract.mint(to_va(operator.user.account_id.clone()), to_yocto(&format!("{}", INIT_ACCOUNT_FOR_TOKEN)).into()) + ) + .assert_success(); +} + +pub fn add_token_deposit(ctx: &mut OperationContext, root: &UserAccount, operator: &Operator, token: &AccountId, token_amount: u128, need_value: u128, current_value: u128){ + println!("add_token_deposit"); + let token_contract = ctx.token_contract_account.get(token).unwrap(); + if token_amount < need_value - current_value { + println!("mint {} {} to {}", INIT_ACCOUNT_FOR_TOKEN, token_contract.account_id(), operator.user.account_id); + call!( + root, + token_contract.ft_transfer_call(to_va(operator.user.account_id.clone()), to_yocto(&format!("{}", INIT_ACCOUNT_FOR_TOKEN)).into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + } + println!("deposit {} {} to {}", INIT_ACCOUNT_FOR_TOKEN, token_contract.account_id(), operator.user.account_id); + call!( + &operator.user, + token_contract.ft_transfer_call(to_va(swap()), (need_value - current_value).into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); +} + +pub fn current_evn_info(ctx: &mut OperationContext, pool :&ContractAccount, operator: &Operator, tokens: &Vec) -> (Scenario, u128, u128, u128, u128){ + let storage_state = view!(pool.get_user_storage_state(operator.user.valid_account_id())).unwrap_json_value(); + if let Value::Null = storage_state { + println!("{} has no user_storage_state", operator.user.account_id); + return (Scenario::NoStorageDeposit, 0, 0, 0, 0); + } else { + let ret: RefStorageState = from_value(storage_state).unwrap(); + println!("{} user_storage_state: {:?}", operator.user.account_id, ret); + } + + let token_contract1 = ctx.token_contract_account.get(tokens.get(0).unwrap()).unwrap(); + let token_contract2 = ctx.token_contract_account.get(tokens.get(1).unwrap()).unwrap(); + let token1_account = view!(token_contract1.ft_balance_of(to_va(operator.user.account_id.clone()))).unwrap_json::().0; + println!("{} has {} balance : {}", operator.user.account_id, token_contract1.account_id(), token1_account); + if token1_account == 0{ + return (Scenario::Token1NoAccount, 0, 0, 0, 0); + } + let token2_account = view!(token_contract2.ft_balance_of(to_va(operator.user.account_id.clone()))).unwrap_json::().0; + println!("{} has {} balance : {}", operator.user.account_id, token_contract2.account_id(), token2_account); + if token2_account == 0{ + return (Scenario::Token2NoAccount, token1_account, 0, 0, 0); + } + + let pool_deposits = view!(pool.get_deposits(to_va(operator.user.account_id.clone()))).unwrap_json::>(); + + let token1_deposit = match pool_deposits.get(&token_contract1.account_id()){ + Some(d) => { + println!("{} deposits {} : {}", operator.user.account_id, token_contract1.account_id(), d.0); + d.0 + }, + None => { + println!("{} has no deposits {} !", operator.user.account_id, token_contract1.account_id()); + return (Scenario::Token1NotRegistered, token1_account, token2_account, 0, 0); + } + }; + let token2_deposit = match pool_deposits.get(&token_contract2.account_id()){ + Some(d) => { + println!("{} deposits {} : {}", operator.user.account_id, token_contract2.account_id(), d.0); + d.0 + }, + None => { + println!("{} has no deposits {} !", operator.user.account_id, token_contract2.account_id()); + return (Scenario::Token2NotRegistered, token1_account, token2_account, token1_deposit, 0); + } + }; + + (Scenario::Normal, token1_account, token2_account, token1_deposit, token2_deposit) +} + +pub fn get_test_token_amount(ctx: &mut OperationContext, operator: &Operator, token: &String) -> u128 { + let token_contract = ctx.token_contract_account.get(token).unwrap(); + let token_amount = view!(token_contract.ft_balance_of(to_va(operator.user.account_id.clone()))).unwrap_json::().0; + println!("{} has {} balance : {}", operator.user.account_id, token_contract.account_id(), token_amount); + token_amount + +} + +pub fn init_pool_env() -> ( + UserAccount, + UserAccount, + ContractAccount, + Vec +){ + + let root = init_simulator(None); + let owner = root.create_user("owner".to_string(), to_yocto("100")); + + let pool = deploy!( + contract: Exchange, + contract_id: swap(), + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(to_va("owner".to_string()), 4, 1) + ); + + call!( + root, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + owner, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + let mut users = Vec::new(); + for user_id in 0..EVERY_PREFERENCE_NUM{ + let user = Operator{ + user: root.create_user(format!("user_create_sample_pool_{}", user_id), to_yocto("100")), + preference: Preference::CreateSamplePool + }; + users.push(user); + let user = Operator{ + user: root.create_user(format!("user_direct_swap_{}", user_id), to_yocto("100")), + preference: Preference::DirectSwap + }; + users.push(user); + let user = Operator{ + user: root.create_user(format!("user_pool_swap_{}", user_id), to_yocto("100")), + preference: Preference::PoolSwap + }; + users.push(user); + let user = Operator{ + user: root.create_user(format!("user_add_liquidity_{}", user_id), to_yocto("100")), + preference: Preference::AddLiquidity + }; + users.push(user); + } + + call!( + owner, + pool.extend_whitelisted_tokens(TOKENS.map(|v| to_va(v.to_string())).to_vec()) + ); + (root, owner, pool, users) +} + +/** + * Related to stable swap + */ + +pub fn setup_stable_pool_with_liquidity_and_operators( + tokens: Vec, + amounts: Vec, + decimals: Vec, + pool_fee: u32, + amp: u64, +) -> ( + UserAccount, + UserAccount, + ContractAccount, + Vec>, + Vec +) { + let root = init_simulator(None); + let owner = root.create_user("owner".to_string(), to_yocto("100")); + let pool = deploy!( + contract: Exchange, + contract_id: swap(), + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(owner.valid_account_id(), 1600, 0) + ); + + let mut users = Vec::new(); + for user_id in 0..EVERY_PREFERENCE_NUM{ + let user = root.create_user(format!("user_remove_stable_liquidity_by_share_{}", user_id), to_yocto("100")); + call!( + user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + users.push(StableOperator{ + user, + preference: StablePreference::RemoveLiquidityByShare + }); + + let user = root.create_user(format!("user_remove_stable_liquidity_by_token_{}", user_id), to_yocto("100")); + call!( + user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + users.push(StableOperator{ + user, + preference: StablePreference::RemoveLiquidityByToken + }); + + let user = root.create_user(format!("user_pool_stable_swap_{}", user_id), to_yocto("100")); + call!( + user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + users.push(StableOperator{ + user, + preference: StablePreference::PoolSwap + }); + + let user = root.create_user(format!("user_add_stable_liquidity_{}", user_id), to_yocto("100")); + call!( + user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + users.push(StableOperator{ + user, + preference: StablePreference::AddLiquidity + }); + } + + let mut token_contracts: Vec> = vec![]; + for token_name in &tokens { + token_contracts.push(test_token(&root, token_name.clone(), vec![swap()], vec![])); + } + + call!( + owner, + pool.extend_whitelisted_tokens( + (&token_contracts).into_iter().map(|x| x.valid_account_id()).collect() + ) + ); + call!( + owner, + pool.add_stable_swap_pool( + (&token_contracts).into_iter().map(|x| x.valid_account_id()).collect(), + decimals, + pool_fee, + amp + ), + deposit = to_yocto("1")) + .assert_success(); + + call!( + root, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + owner, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + for (idx, amount) in amounts.clone().into_iter().enumerate() { + let c = token_contracts.get(idx).unwrap(); + call!( + root, + c.ft_transfer_call( + pool.valid_account_id(), + U128(amount), + None, + "".to_string() + ), + deposit = 1 + ) + .assert_success(); + } + + call!( + root, + pool.add_stable_liquidity(0, amounts.into_iter().map(|x| U128(x)).collect(), U128(1)), + deposit = to_yocto("0.0007") + ) + .assert_success(); + (root, owner, pool, token_contracts, users) +} + +pub fn dai() -> AccountId { + STABLE_TOKENS[0].to_string() +} + +pub fn usdt() -> AccountId { + STABLE_TOKENS[1].to_string() +} + +pub fn usdc() -> AccountId { + STABLE_TOKENS[2].to_string() +} +pub fn add_and_deposit_token( + root: &UserAccount, + user: &UserAccount, + token: &ContractAccount, + ex: &ContractAccount, + amount: u128, +) { + if 0 == view!(token.ft_balance_of(user.valid_account_id())).unwrap_json::().0{ + call!( + user, + token.mint(user.valid_account_id(), U128(10)) + ) + .assert_success(); + } + + call!( + root, + token.ft_transfer(user.valid_account_id(), U128(amount), None), + deposit = 1 + ) + .assert_success(); + + call!( + user, + ex.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + user, + token.ft_transfer_call( + ex.valid_account_id(), + U128(amount), + None, + "".to_string() + ), + deposit = 1 + ) + .assert_success(); +} + +pub fn mft_balance_of( + pool: &ContractAccount, + token_or_pool: &str, + account_id: &AccountId, +) -> u128 { + view!(pool.mft_balance_of(token_or_pool.to_string(), to_va(account_id.clone()))) + .unwrap_json::() + .0 +} + +pub fn mft_total_supply( + pool: &ContractAccount, + token_or_pool: &str, +) -> u128 { + view!(pool.mft_total_supply(token_or_pool.to_string())) + .unwrap_json::() + .0 +} + +pub fn pool_share_price( + pool: &ContractAccount, + pool_id: u64, +) -> u128 { + view!(pool.get_pool_share_price(pool_id)) + .unwrap_json::() + .0 +} \ No newline at end of file diff --git a/ref-exchange/tests/test_admin_fee.rs b/ref-exchange/tests/test_admin_fee.rs new file mode 100644 index 0000000..6be5514 --- /dev/null +++ b/ref-exchange/tests/test_admin_fee.rs @@ -0,0 +1,180 @@ +use near_sdk::json_types::U128; +use near_sdk::AccountId; +use std::collections::HashMap; +use near_sdk_sim::{ + call, view, to_yocto, +}; + +use ref_exchange::{PoolInfo, SwapAction}; + +use crate::common::utils::*; +pub mod common; + + + + +#[test] +fn modify_admin_fee() { + let (root, owner, pool, _, _, _) = setup_pool_with_liquidity(); + // let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + + // pool 0, 10 dai -> 20 eth; pool 1, 20 eth -> 10 usdt + + // make sure the exchange's initial admin fee is 4 & 1 bps + let metadata = get_metadata(&pool); + assert_eq!(metadata.exchange_fee, 4); + assert_eq!(metadata.referral_fee, 1); + let pool_fee = view!(pool.get_pool_fee(0)).unwrap_json::(); + assert_eq!(pool_fee, 25); + + // make sure pool info, especially total_fee and share_total_supply + assert_eq!( + view!(pool.get_pool(0)).unwrap_json::(), + PoolInfo { + pool_kind: "SIMPLE_POOL".to_string(), + token_account_ids: vec![dai(), eth()], + amounts: vec![to_yocto("10").into(), to_yocto("20").into()], + total_fee: 25, + shares_total_supply: to_yocto("1").into(), + } + ); + + // for a new pool, there is no lp token for the exchange + assert_eq!( + view!(pool.mft_balance_of(":0".to_string(), pool.valid_account_id())) + .unwrap_json::() + .0, + to_yocto("0") + ); + + let mut prev_dai = to_yocto("85"); + let mut prev_eth = to_yocto("70"); + let mut prev_usdt = to_yocto("90"); + + // swap in 1 dai to get eth + call!( + root, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(to_yocto("1"))), + token_out: eth(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ) + .assert_success(); + let balances = view!(pool.get_deposits(root.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances.get(&dai()).unwrap().0, prev_dai - to_yocto("1")); + assert_eq!(balances.get(ð()).unwrap().0, prev_eth + 1814048647419868151852693); + prev_dai -= to_yocto("1"); + prev_eth += 1814048647419868151852693; + // the exchange got some lp tokens as 4 bps in 25 bps. + assert_eq!( + view!(pool.mft_balance_of(":0".to_string(), pool.valid_account_id())) + .unwrap_json::() + .0, + 45457128392697592 + ); + + // here, we modify admin_fee to more reasonable rate, 1600 bps in 25 bps + // which is 4 bps (exchange fee) in total, + // and 1 bps (referal fee) in total. + let out_come = call!( + owner, + pool.modify_admin_fee(1600, 400) + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + + // make sure the modification succeed + let metadata = get_metadata(&pool); + assert_eq!(metadata.exchange_fee, 1600); + assert_eq!(metadata.referral_fee, 400); + let pool_fee = view!(pool.get_pool_fee(0)).unwrap_json::(); + assert_eq!(pool_fee, 25); + + // swap in 1 usdt to get eth + call!( + root, + pool.swap( + vec![SwapAction { + pool_id: 1, + token_in: usdt(), + amount_in: Some(U128(to_yocto("1"))), + token_out: eth(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ) + .assert_success(); + let balances = view!(pool.get_deposits(root.valid_account_id())) + .unwrap_json::>(); + + assert_eq!(balances.get(&usdt()).unwrap().0, prev_usdt - to_yocto("1")); + assert_eq!(balances.get(ð()).unwrap().0, prev_eth + 1814048647419868151852693); + prev_usdt -= to_yocto("1"); + prev_eth += 1814048647419868151852693; + assert_eq!( + view!(pool.mft_balance_of(":1".to_string(), pool.valid_account_id())) + .unwrap_json::() + .0, + 18182851357079036914 + ); + + // here, we remove exchange_fee liquidity + let balances = view!(pool.get_deposits(owner.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances.get(&usdt()).unwrap_or(&U128(0)).0, 0); + assert_eq!(balances.get(ð()).unwrap_or(&U128(0)).0, 0); + assert_eq!(balances.get(&dai()).unwrap_or(&U128(0)).0, 0); + + // only owner can call, and withdraw liquidity to owner's inner account + let out_come = call!( + owner, + pool.remove_exchange_fee_liquidity(0, U128(45457128392697592), vec![U128(1), U128(1)]), + deposit = 1 + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + assert_eq!( + view!(pool.mft_balance_of(":0".to_string(), pool.valid_account_id())) + .unwrap_json::() + .0, + 0 + ); + let balances = view!(pool.get_deposits(owner.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances.get(&usdt()).unwrap_or(&U128(0)).0, 0); + assert_eq!(balances.get(ð()).unwrap_or(&U128(0)).0, 826681087999039131); + assert_eq!(balances.get(&dai()).unwrap_or(&U128(0)).0, 500028389589818806); + + let out_come = call!( + owner, + pool.remove_exchange_fee_liquidity(1, U128(18182851357079036914), vec![U128(1), U128(1)]), + deposit = 1 + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + assert_eq!( + view!(pool.mft_balance_of(":0".to_string(), pool.valid_account_id())) + .unwrap_json::() + .0, + 0 + ); + let balances = view!(pool.get_deposits(owner.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances.get(&usdt()).unwrap_or(&U128(0)).0, 200007728217076967880); + assert_eq!(balances.get(ð()).unwrap_or(&U128(0)).0, 331493118860347246997); + assert_eq!(balances.get(&dai()).unwrap_or(&U128(0)).0, 500028389589818806); + + assert_eq!(prev_dai, to_yocto("84")); + assert_eq!(prev_eth, 73628097294839736303705386); + assert_eq!(prev_usdt, to_yocto("89")); +} diff --git a/ref-exchange/tests/test_errors.rs b/ref-exchange/tests/test_errors.rs new file mode 100644 index 0000000..96d8d0a --- /dev/null +++ b/ref-exchange/tests/test_errors.rs @@ -0,0 +1,653 @@ +/// Test for cases that should panic or throw an error +// ERR66 could not be tested with this approach +// ERR67 could not be tested with this approach +// ERR70 could not be tested with this approach +// ERR81 could not be tested with this approach + +use near_sdk::json_types::{U128, U64}; +use near_sdk_sim::{init_simulator, call, to_yocto, ExecutionResult}; + +use ref_exchange::SwapAction; +use crate::common::utils::*; +pub mod common; + +const ONE_LPT: u128 = 1000000000000000000; +const ONE_DAI: u128 = 1000000000000000000; +const ONE_USDT: u128 = 1000000; +const ONE_USDC: u128 = 1000000; + +fn assert_failure(outcome: ExecutionResult, error_message: &str) { + assert!(!outcome.is_ok()); + let exe_status = format!("{:?}", outcome.promise_errors()[0].as_ref().unwrap().status()); + println!("{}", exe_status); + assert!(exe_status.contains(error_message)); +} + +#[test] +fn sim_stable_e100 () { + let root = init_simulator(None); + let (_, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![swap()]); + let token2 = test_token(&root, usdt(), vec![swap()]); + let outcome = call!( + root, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 1000 + ), + deposit = to_yocto("1") + ); + assert_failure(outcome, "E100: no permission to invoke this"); +} + +#[test] +fn sim_stable_e61 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![swap()]); + let token2 = test_token(&root, usdt(), vec![swap()]); + call!( + owner, + ex.extend_whitelisted_tokens( + vec![token1.valid_account_id(), token2.valid_account_id()] + ) + ); + + // small amp + let outcome = call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 0 + ), + deposit = to_yocto("1") + ); + assert_failure(outcome, "E61: illegal amp"); + + // large amp + let outcome = call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 100_000_000 + ), + deposit = to_yocto("1") + ); + assert_failure(outcome, "E61: illegal amp"); +} + +#[test] +fn sim_stable_e62 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![swap()]); + let token2 = test_token(&root, usdt(), vec![swap()]); + call!( + owner, + ex.extend_whitelisted_tokens( + vec![token1.valid_account_id(), token2.valid_account_id()] + ) + ); + + // invalid fee + let outcome = call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 100_000, + 10000 + ), + deposit = to_yocto("1") + ); + assert_failure(outcome, "E62: illegal fee"); +} + +#[test] +fn sim_stable_e63 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![1*ONE_DAI, 1*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI), U128(1*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); + + let token3 = test_token(&root, usdc(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token3.valid_account_id()]); + deposit_token(&root, &ex, vec![&token3], vec![1*ONE_USDC]); + + let outcome = call!( + root, + ex.swap( + vec![SwapAction { + pool_id: 0, + token_in: token3.account_id(), + amount_in: Some(U128(100)), + token_out: usdt(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + assert_failure(outcome, "E63: missing token"); +} + +#[test] +fn sim_stable_e64 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![1*ONE_DAI, 1*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + // invalid amount list length + let outcome = call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI), U128(1*ONE_USDT), U128(100000)], U128(1)), + deposit = to_yocto("0.01") + ); + assert_failure(outcome, "E64: illegal tokens count"); + let outcome = call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI)], U128(1)), + deposit = to_yocto("0.01") + ); + assert_failure(outcome, "E64: illegal tokens count"); + + call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI), U128(1*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); + + let outcome = call!( + root, + ex.remove_liquidity(0, U128(1), vec![U128(1), U128(1), U128(1)]), + deposit = 1 + ); + assert_failure(outcome, "E64: illegal tokens count"); + let outcome = call!( + root, + ex.remove_liquidity(0, U128(1), vec![U128(1)]), + deposit = 1 + ); + assert_failure(outcome, "E64: illegal tokens count"); + + let outcome = call!( + root, + ex.remove_liquidity_by_tokens(0, vec![U128(1), U128(1), U128(1)], U128(1)), + deposit = 1 + ); + assert_failure(outcome, "E64: illegal tokens count"); + let outcome = call!( + root, + ex.remove_liquidity_by_tokens(0, vec![U128(1)], U128(1)), + deposit = 1 + ); + assert_failure(outcome, "E64: illegal tokens count"); +} + +#[test] +fn sim_stable_e65 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![1*ONE_DAI, 1*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + // invalid amount list length + let outcome = call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI), U128(0*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ); + assert_failure(outcome, "E65: init token balance should be non-zero"); + + + call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI), U128(1*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); +} + +#[test] +fn sim_stable_e13 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![1*ONE_DAI, 1*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + let user = root.create_user("user".to_string(), to_yocto("100")); + + let outcome = call!( + user, + ex.remove_liquidity(0, U128(1), vec![U128(1), U128(1)]), + deposit = 1 + ); + assert_failure(outcome, "E13: LP not registered"); + + let outcome = call!( + user, + ex.remove_liquidity_by_tokens(0, vec![U128(1), U128(1)], U128(1)), + deposit = 1 + ); + assert_failure(outcome, "E13: LP not registered"); + + let outcome = call!( + user, + ex.mft_transfer(":0".to_string(), root.valid_account_id(), U128(1), None), + deposit = 1 + ); + assert_failure(outcome, "E13: LP not registered"); +} + +#[test] +fn sim_stable_e34 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![1*ONE_DAI, 1*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + call!( + root, + ex.add_stable_liquidity(0, vec![U128(1*ONE_DAI), U128(1*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); + + let outcome = call!( + root, + ex.remove_liquidity(0, U128(2*ONE_LPT+1), vec![U128(1), U128(1)]), + deposit = 1 + ); + assert_failure(outcome, "E34: insufficient lp shares"); + + call!( + owner, + ex.mft_register(":0".to_string(), owner.valid_account_id()), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + root, + ex.mft_transfer(":0".to_string(), owner.valid_account_id(), U128(1*ONE_LPT), None), + deposit = 1 + ) + .assert_success(); + + let outcome = call!( + root, + ex.remove_liquidity_by_tokens(0, vec![U128(1*ONE_DAI), U128(1*ONE_USDT)], U128(1)), + deposit = 1 + ); + assert_failure(outcome, "E34: insufficient lp shares"); + + let outcome = call!( + root, + ex.mft_transfer(":0".to_string(), owner.valid_account_id(), U128(2*ONE_LPT), None), + deposit = 1 + ); + assert_failure(outcome, "E34: insufficient lp shares"); +} + +#[test] +fn sim_stable_e68 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + call!( + root, + ex.add_stable_liquidity(0, vec![U128(100*ONE_DAI), U128(100*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); + + let outcome = call!( + root, + ex.remove_liquidity(0, U128(100*ONE_LPT), vec![U128(51*ONE_DAI), U128(50*ONE_USDT)]), + deposit = 1 + ); + assert_failure(outcome, "E68: slippage error"); + + let outcome = call!( + root, + ex.remove_liquidity_by_tokens(0, vec![U128(50*ONE_DAI), U128(50*ONE_USDT)], U128(99*ONE_LPT)), + deposit = 1 + ); + assert_failure(outcome, "E68: slippage error"); + + let outcome = call!( + root, + ex.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdt(), + min_amount_out: U128(2 * ONE_USDT) + }], + None + ), + deposit = 1 + ); + assert_failure(outcome, "E68: slippage error"); +} + +#[test] +fn sim_stable_e69 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + call!( + root, + ex.add_stable_liquidity(0, vec![U128(100*ONE_DAI), U128(100*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); + + // try to withdraw all from pool + let outcome = call!( + root, + ex.remove_liquidity(0, U128(200*ONE_LPT), vec![U128(1), U128(1)]), + deposit = 1 + ); + assert_failure(outcome, "E69: pool reserved token balance less than MIN_RESERVE"); + + let outcome = call!( + root, + ex.remove_liquidity_by_tokens(0, vec![U128(100*ONE_DAI), U128(100*ONE_USDT)], U128(200*ONE_LPT)), + deposit = 1 + ); + assert_failure(outcome, "E69: pool reserved token balance less than MIN_RESERVE"); + + // remove liquidity so that the pool is small enough + call!( + root, + ex.remove_liquidity_by_tokens(0, vec![U128(99*ONE_DAI), U128(99*ONE_USDT)], U128(200*ONE_LPT)), + deposit = 1 + ) + .assert_success(); + + let outcome = call!( + root, + ex.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(99*ONE_DAI)), + token_out: usdt(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + assert_failure(outcome, "E69: pool reserved token balance less than MIN_RESERVE"); +} + +#[test] +fn sim_stable_e71 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + call!( + root, + ex.add_stable_liquidity(0, vec![U128(100*ONE_DAI), U128(100*ONE_USDT)], U128(1)), + deposit = to_yocto("0.01") + ) + .assert_success(); + + let outcome = call!( + root, + ex.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(1)), + token_out: dai(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + assert_failure(outcome, "E71: illegal swap with duplicated tokens"); +} + +#[test] +fn sim_stable_e14 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + let outcome = call!( + root, + ex.mft_register(":0".to_string(), ex.valid_account_id()), + deposit = to_yocto("1") + ); + assert_failure(outcome, "E14: LP already registered"); +} + +#[test] +fn sim_stable_e82 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + let outcome = call!( + owner, + ex.stable_swap_ramp_amp(0, 0, U64(0)) + ); + assert_failure(outcome, "E82: insufficient ramp time"); +} + +#[test] +fn sim_stable_e83 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + let runtime = root.borrow_runtime().current_block().block_timestamp; + println!("{}", runtime); + + let outcome = call!( + owner, + ex.stable_swap_ramp_amp(0, 0, U64(86400000000000)) + ); + assert_failure(outcome, "E83: invalid amp factor"); + + let outcome = call!( + owner, + ex.stable_swap_ramp_amp(0, 1_000_001, U64(86400000000000)) + ); + assert_failure(outcome, "E83: invalid amp factor"); +} + +#[test] +fn sim_stable_e84 () { + let root = init_simulator(None); + let (owner, ex) = setup_exchange(&root, 1600, 400); + let token1 = test_token(&root, dai(), vec![ex.account_id()]); + let token2 = test_token(&root, usdt(), vec![ex.account_id()]); + whitelist_token(&owner, &ex, vec![token1.valid_account_id(), token2.valid_account_id()]); + deposit_token(&root, &ex, vec![&token1, &token2], vec![101*ONE_DAI, 101*ONE_USDT]); + + call!( + owner, + ex.add_stable_swap_pool( + vec![token1.valid_account_id(), token2.valid_account_id()], + vec![18, 6], + 25, + 10000 + ), + deposit = to_yocto("1") + ).assert_success(); + + let outcome = call!( + owner, + ex.stable_swap_ramp_amp(0, 1, U64(86400000000000)) + ); + assert_failure(outcome, "E84: amp factor change is too large"); +} diff --git a/ref-exchange/tests/test_fuzz_amm.rs b/ref-exchange/tests/test_fuzz_amm.rs new file mode 100644 index 0000000..6337ce1 --- /dev/null +++ b/ref-exchange/tests/test_fuzz_amm.rs @@ -0,0 +1,79 @@ +use near_sdk_sim::{ + view, ContractAccount, UserAccount, +}; + +use ref_exchange::{ContractContract as Exchange}; + +use rand::{Rng, SeedableRng}; +use rand_pcg::Pcg32; + +mod fuzzy; +use fuzzy::{constants::*, + create_simple_pool::*, + direct_swap::*, + liquidity_manage::*, + pool_swap::*, + types::*, + utils::* +}; + +fn do_operation(ctx: &mut OperationContext, rng: &mut Pcg32, root: &UserAccount, operator: &Operator, pool :&ContractAccount){ + let simple_pool_count = view!(pool.get_number_of_pools()).unwrap_json::(); + println!("current pool num : {}", simple_pool_count); + + if simple_pool_count == 0 { + create_simple_pool(ctx, rng, root, operator, pool); + } + match operator.preference{ + Preference::CreateSamplePool => { + const NEED_REPEAT_CREATE: i8 = 1; + let repeat_create: i8 = rng.gen(); + if simple_pool_count != 0 && NEED_REPEAT_CREATE == repeat_create % 2{ + create_simple_pool(ctx, rng, root, operator, pool); + } + }, + Preference::DirectSwap => { + do_direct_swap(ctx, rng, root, operator, pool, simple_pool_count); + }, + Preference::PoolSwap => { + do_pool_swap(ctx, rng, root, operator, pool, simple_pool_count); + }, + Preference::AddLiquidity => { + do_add_liquidity(ctx, rng, root, operator, pool, simple_pool_count, None); + } + } +} + +fn generate_fuzzy_seed() -> Vec{ + let mut seeds:Vec = Vec::new(); + + let mut rng = rand::thread_rng(); + for _ in 0..FUZZY_NUM { + let seed: u64 = rng.gen(); + seeds.push(seed); + } + seeds +} + +#[test] +fn test_fuzzy_amm(){ + + let seeds = generate_fuzzy_seed(); + for seed in seeds { + + println!("*********************************************"); + println!("current seed : {}", seed); + println!("*********************************************"); + + let mut ctx = OperationContext::default(); + + let mut rng = Pcg32::seed_from_u64(seed as u64); + let (root, _owner, pool, users) = init_pool_env(); + + for i in 0..OPERATION_NUM{ + let operator = get_operator(&mut rng, &users); + println!("NO.{} : {:?}", i, operator); + do_operation(&mut ctx, &mut rng, &root, operator, &pool); + } + } +} \ No newline at end of file diff --git a/ref-exchange/tests/test_fuzzy_stable.rs b/ref-exchange/tests/test_fuzzy_stable.rs new file mode 100644 index 0000000..f781be0 --- /dev/null +++ b/ref-exchange/tests/test_fuzzy_stable.rs @@ -0,0 +1,85 @@ +use near_sdk_sim::{ + view, ContractAccount, UserAccount, +}; + +use test_token::ContractContract as TestToken; +use ref_exchange::{PoolInfo, SwapAction}; +use ref_exchange::{ContractContract as Exchange}; + +use rand::{Rng, SeedableRng}; +use rand_pcg::Pcg32; + +mod fuzzy; +use fuzzy::{constants::*, + create_simple_pool::*, + direct_swap::*, + liquidity_manage::*, + pool_swap::*, + types::*, + utils::*, + constants::* +}; + +use near_sdk::test_utils::{accounts, VMContextBuilder}; +use near_sdk::{testing_env, MockedBlockchain}; +use std::convert::TryInto; + +fn do_operation(rng: &mut Pcg32, root: &UserAccount, operator: &StableOperator, pool :&ContractAccount, token_contracts: &Vec>){ + println!("current stable pool info: {:?}", view!(pool.get_pool(0)).unwrap_json::()); + match operator.preference{ + StablePreference::RemoveLiquidityByToken => { + do_stable_remove_liquidity_by_token(token_contracts, rng, root, operator, pool); + }, + StablePreference::RemoveLiquidityByShare => { + do_stable_remove_liquidity_by_shares(token_contracts, rng, root, operator, pool); + }, + StablePreference::PoolSwap => { + do_stable_pool_swap(token_contracts, rng, root, operator, pool); + }, + StablePreference::AddLiquidity => { + do_stable_add_liquidity(token_contracts, rng, root, operator, pool); + } + } +} + +fn generate_fuzzy_seed() -> Vec{ + let mut seeds:Vec = Vec::new(); + + let mut rng = rand::thread_rng(); + for _ in 0..FUZZY_NUM { + let seed: u64 = rng.gen(); + seeds.push(seed); + } + seeds +} + +#[test] +fn test_fuzzy_stable() { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + + let seeds = generate_fuzzy_seed(); + + for seed in seeds { + + println!("*********************************************"); + println!("current seed : {}", seed); + println!("*********************************************"); + + let mut rng = Pcg32::seed_from_u64(seed as u64); + let (root, _owner, pool, token_contracts, operators) = + setup_stable_pool_with_liquidity_and_operators( + STABLE_TOKENS.iter().map(|&v| v.to_string()).collect(), + vec![100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC], + DECIMALS.to_vec(), + 25, + 10000, + ); + + for i in 0..OPERATION_NUM{ + let operator = get_operator(&mut rng, &operators); + println!("NO.{} : {:?}", i, operator); + do_operation(&mut rng, &root, operator, &pool, &token_contracts); + } + } +} \ No newline at end of file diff --git a/ref-exchange/tests/test_guardians.rs b/ref-exchange/tests/test_guardians.rs new file mode 100644 index 0000000..3dae811 --- /dev/null +++ b/ref-exchange/tests/test_guardians.rs @@ -0,0 +1,244 @@ +use near_sdk::json_types::{U128}; +use near_sdk_sim::{call, to_yocto}; + +use ref_exchange::{RunningState, SwapAction}; +use crate::common::utils::*; +pub mod common; + +#[test] +fn guardians_scenario_01() { + let (root, owner, pool, token1, _, _) = setup_pool_with_liquidity(); + let guard1 = root.create_user("guard1".to_string(), to_yocto("100")); + let guard2 = root.create_user("guard2".to_string(), to_yocto("100")); + let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + + println!("Guardians Case 0101: only owner can add guardians"); + let out_come = call!( + root, + pool.extend_guardians(vec![guard1.valid_account_id(), guard2.valid_account_id()]) + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("ERR_NOT_ALLOWED")); + let metadata = get_metadata(&pool); + assert_eq!(metadata.guardians.len(), 0); + + let out_come = call!( + owner, + pool.extend_guardians(vec![guard1.valid_account_id(), guard2.valid_account_id()]) + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let metadata = get_metadata(&pool); + assert_eq!(metadata.guardians.len(), 2); + assert_eq!(metadata.guardians.get(0).unwrap().clone(), guard1.account_id()); + assert_eq!(metadata.guardians.get(1).unwrap().clone(), guard2.account_id()); + + println!("Guardians Case 0102: only owner and guardians can manage global whitelists"); + let out_come = call!( + root, + pool.remove_whitelisted_tokens(vec![to_va(eth()), to_va(dai())]) + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("ERR_NOT_ALLOWED")); + let wl = get_whitelist(&pool); + assert_eq!(wl.len(), 3); + assert_eq!(wl.get(0).unwrap().clone(), dai()); + assert_eq!(wl.get(1).unwrap().clone(), eth()); + assert_eq!(wl.get(2).unwrap().clone(), usdt()); + + let out_come = call!( + owner, + pool.remove_whitelisted_tokens(vec![to_va(usdt()), to_va(eth()), to_va(dai())]) + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let wl = get_whitelist(&pool); + assert_eq!(wl.len(), 0); + + let out_come = call!( + owner, + pool.extend_whitelisted_tokens(vec![to_va(dai())]) + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let wl = get_whitelist(&pool); + assert_eq!(wl.len(), 1); + assert_eq!(wl.get(0).unwrap().clone(), dai()); + + let out_come = call!( + guard1, + pool.extend_whitelisted_tokens(vec![to_va(eth())]) + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let wl = get_whitelist(&pool); + assert_eq!(wl.len(), 2); + assert_eq!(wl.get(0).unwrap().clone(), dai()); + assert_eq!(wl.get(1).unwrap().clone(), eth()); + + let out_come = call!( + guard2, + pool.extend_whitelisted_tokens(vec![to_va(usdt())]) + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let wl = get_whitelist(&pool); + assert_eq!(wl.len(), 3); + assert_eq!(wl.get(0).unwrap().clone(), dai()); + assert_eq!(wl.get(1).unwrap().clone(), eth()); + assert_eq!(wl.get(2).unwrap().clone(), usdt()); + + println!("Guardians Case 0103: only owner and guardians can pause the contract"); + let out_come = call!( + root, + pool.change_state(RunningState::Paused), + deposit = 1 + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("ERR_NOT_ALLOWED")); + let metadata = get_metadata(&pool); + assert_eq!(metadata.state, RunningState::Running); + + let out_come = call!( + guard1, + pool.change_state(RunningState::Paused), + deposit = 1 + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let metadata = get_metadata(&pool); + assert_eq!(metadata.state, RunningState::Paused); + + // register user would fail + let out_come = call!( + new_user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E51: contract paused")); + + // add pool would fail + let out_come = call!( + root, + pool.add_simple_pool(vec![to_va(dai()), to_va(eth())], 25), + deposit = to_yocto("1") + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E51: contract paused")); + + // deposit token would fail + let out_come = call!( + root, + token1.ft_transfer_call(to_va(swap()), to_yocto("5").into(), None, "".to_string()), + deposit = 1 + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E51: contract paused")); + + // add liqudity would fail + let out_come = call!( + root, + pool.add_liquidity(0, vec![U128(to_yocto("10")), U128(to_yocto("20"))], None), + deposit = to_yocto("0.0007") + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E51: contract paused")); + + // swap would fail + let out_come = call!( + root, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(to_yocto("1"))), + token_out: eth(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E51: contract paused")); + + // // instant swap would fail + // call!( + // new_user, + // token2.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + // ) + // .assert_success(); + // let msg = format!( + // "{{\"pool_id\": {}, \"token_in\": \"{}\", \"token_out\": \"{}\", \"min_amount_out\": \"{}\"}}", + // 0, token2.account_id(), token1.account_id(), 1 + // ); + // let msg_str = format!("{{\"force\": 0, \"actions\": [{}]}}", msg); + // let out_come = call!( + // new_user, + // token2.ft_transfer_call(to_va(swap()), to_yocto("1").into(), None, msg_str.clone()), + // deposit = 1 + // ); + // out_come.assert_success(); + // assert_eq!(get_error_count(&out_come), 1); + // assert!(get_error_status(&out_come).contains("E51: contract paused")); + + // withdraw token would fail + let out_come = call!( + root, + pool.withdraw(to_va(eth()), U128(to_yocto("1")), None), + deposit = 1 + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E51: contract paused")); + + println!("Guardians Case 0104: only owner can resume the contract"); + let out_come = call!( + root, + pool.change_state(RunningState::Running), + deposit = 1 + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("ERR_NOT_ALLOWED")); + let metadata = get_metadata(&pool); + assert_eq!(metadata.state, RunningState::Paused); + + let out_come = call!( + guard2, + pool.change_state(RunningState::Running), + deposit = 1 + ); + assert!(!out_come.is_ok()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("ERR_NOT_ALLOWED")); + let metadata = get_metadata(&pool); + assert_eq!(metadata.state, RunningState::Paused); + + let out_come = call!( + owner, + pool.change_state(RunningState::Running), + deposit = 1 + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + let metadata = get_metadata(&pool); + assert_eq!(metadata.state, RunningState::Running); + + let out_come = call!( + new_user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); +} diff --git a/ref-exchange/tests/test_instant_swap.rs b/ref-exchange/tests/test_instant_swap.rs new file mode 100644 index 0000000..13937bc --- /dev/null +++ b/ref-exchange/tests/test_instant_swap.rs @@ -0,0 +1,497 @@ +use near_sdk::json_types::U128; +use near_sdk_sim::{ + call, to_yocto, ContractAccount, ExecutionResult, UserAccount, +}; + +use test_token::ContractContract as TestToken; + +use crate::common::utils::*; +pub mod common; + +fn pack_action( + pool_id: u32, + token_in: &str, + token_out: &str, + amount_in: Option, + min_amount_out: u128, +) -> String { + if let Some(amount_in) = amount_in { + format!( + "{{\"pool_id\": {}, \"token_in\": \"{}\", \"amount_in\": \"{}\", \"token_out\": \"{}\", \"min_amount_out\": \"{}\"}}", + pool_id, token_in, amount_in, token_out, min_amount_out + ) + } else { + format!( + "{{\"pool_id\": {}, \"token_in\": \"{}\", \"token_out\": \"{}\", \"min_amount_out\": \"{}\"}}", + pool_id, token_in, token_out, min_amount_out + ) + } +} + +fn direct_swap( + user: &UserAccount, + contract: &ContractAccount, + actions: Vec, + amount: u128, +) -> ExecutionResult { + // {{\"pool_id\": 0, \"token_in\": \"dai\", \"token_out\": \"eth\", \"min_amount_out\": \"1\"}} + let actions_str = actions.join(", "); + let msg_str = format!("{{\"actions\": [{}]}}", actions_str); + // println!("{}", msg_str); + call!( + user, + contract.ft_transfer_call(to_va(swap()), amount.into(), None, msg_str), + deposit = 1 + ) +} + +#[test] +fn instant_swap_scenario_01() { + let (root, owner, pool, token1, token2, _) = setup_pool_with_liquidity(); + let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + call!( + new_user, + token1.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + + println!("Case 0101: wrong msg"); + let out_come = direct_swap(&new_user, &token1, vec!["wrong".to_string()], to_yocto("1")); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come).contains("E28: Illegal msg in ft_transfer_call")); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("10")); + assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); + + println!("Case 0102: less then min_amount_out"); + let action = pack_action( + 0, + &token1.account_id(), + &token2.account_id(), + None, + to_yocto("1.9"), + ); + let out_come = direct_swap(&new_user, &token1, vec![action], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come) + .contains("Smart contract panicked: panicked at 'ERR_MIN_AMOUNT'")); + assert!(get_storage_balance(&pool, new_user.valid_account_id()).is_none()); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("10")); + assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); + + println!("Case 0103: non-registered user swap but not registered in token2"); + let action = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token1, vec![action], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come) + .contains("Smart contract panicked: The account new_user is not registered")); + // println!("total logs: {:#?}", get_logs(&out_come)); + // assert!(get_logs(&out_come)[2].contains("Account new_user is not registered. Depositing to owner.")); + assert!(get_storage_balance(&pool, new_user.valid_account_id()).is_none()); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("9")); + assert!( + get_deposits(&pool, owner.valid_account_id()) + .get(&token2.account_id()) + .unwrap() + .0 + > to_yocto("1.8") + ); + + call!( + new_user, + token2.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + println!("Case 0104: non-registered user swap"); + let action = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token1, vec![action], to_yocto("1")); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + // println!("{:#?}", out_come.promise_results()); + // println!("total logs: {:#?}", get_logs(&out_come)); + assert!(get_storage_balance(&pool, new_user.valid_account_id()).is_none()); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("8")); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("1.5")); +} + +#[test] +fn instant_swap_scenario_02() { + let (root, owner, pool, token1, token2, token3) = setup_pool_with_liquidity(); + let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + call!( + new_user, + token1.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + + println!("Case 0201: registered user without any deposits and non-registered to token2"); + call!( + new_user, + pool.storage_deposit(None, Some(true)), + deposit = to_yocto("1") + ) + .assert_success(); + assert_eq!( + get_storage_balance(&pool, new_user.valid_account_id()) + .unwrap() + .available + .0, + to_yocto("0") + ); + assert_eq!( + get_storage_balance(&pool, new_user.valid_account_id()) + .unwrap() + .total + .0, + to_yocto("0.00102") + ); + // println!("{:#?}", get_storage_balance(&pool, new_user.valid_account_id()).unwrap()); + let action = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token1, vec![action], to_yocto("1")); + out_come.assert_success(); + // println!("swap one logs: {:#?}", get_logs(&out_come)); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come) + .contains("Smart contract panicked: The account new_user is not registered")); + // println!("total logs: {:#?}", get_logs(&out_come)); + assert!(get_logs(&out_come)[2].contains("Account new_user has not enough storage. Depositing to owner.")); + assert_eq!( + get_storage_balance(&pool, new_user.valid_account_id()) + .unwrap() + .available + .0, + to_yocto("0") + ); + assert_eq!( + get_storage_balance(&pool, new_user.valid_account_id()) + .unwrap() + .total + .0, + to_yocto("0.00102") + ); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("9")); + assert!( + get_deposits(&pool, owner.valid_account_id()) + .get(&token2.account_id()) + .unwrap() + .0 + > to_yocto("1.8") + ); + assert!(get_deposits(&pool, new_user.valid_account_id()) + .get(&token1.account_id()) + .is_none()); + assert!(get_deposits(&pool, new_user.valid_account_id()) + .get(&token2.account_id()) + .is_none()); + + println!("Case 0202: registered user without any deposits"); + call!( + new_user, + token2.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("9")); + assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("10")); + let action = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token1, vec![action], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + // println!("total logs: {:#?}", get_logs(&out_come)); + assert_eq!(get_error_count(&out_come), 0); + assert_eq!( + get_storage_balance(&pool, new_user.valid_account_id()) + .unwrap() + .available + .0, + 0 + ); + assert_eq!( + get_storage_balance(&pool, new_user.valid_account_id()) + .unwrap() + .total + .0, + to_yocto("0.00102") + ); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("8")); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("11.5")); + + println!("Case 0203: registered user with token already deposited"); + call!( + new_user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + call!( + new_user, + token1.ft_transfer_call(to_va(swap()), to_yocto("5").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + new_user, + token2.ft_transfer_call(to_va(swap()), to_yocto("5").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + assert_eq!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token1.account_id()) + .unwrap() + .0, + to_yocto("5") + ); + assert_eq!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token2.account_id()) + .unwrap() + .0, + to_yocto("5") + ); + let action = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token1, vec![action], to_yocto("1")); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + assert_eq!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token1.account_id()) + .unwrap() + .0, + to_yocto("5") + ); + assert_eq!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token2.account_id()) + .unwrap() + .0, + to_yocto("5") + ); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("2")); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("7.7")); + + println!("Case 0204: deposit token is not in action"); + call!( + new_user, + token3.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + let action = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token3, vec![action], to_yocto("1")); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 1); + // println!("{}", get_error_status(&out_come)); + assert!(get_error_status(&out_come).contains("E21: token not registered")); + assert_eq!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token1.account_id()) + .unwrap() + .0, + to_yocto("5") + ); + assert_eq!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token2.account_id()) + .unwrap() + .0, + to_yocto("5") + ); +} + +#[test] +fn instant_swap_scenario_03() { + let (root, owner, pool, token1, token2, token3) = setup_pool_with_liquidity(); + let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + call!( + new_user, + token1.mint(to_va(new_user.account_id.clone()), U128(to_yocto("5"))) + ) + .assert_success(); + call!( + new_user, + token2.mint(to_va(new_user.account_id.clone()), U128(to_yocto("5"))) + ) + .assert_success(); + call!( + new_user, + token3.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + call!( + new_user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + call!( + new_user, + token3.ft_transfer_call(to_va(swap()), to_yocto("5").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + new_user, + pool.storage_withdraw(None), + deposit = 1 + ) + .assert_success(); + + + println!("Case 0301: two actions with one output token"); + let action1 = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let action2 = pack_action(1, &token2.account_id(), &token3.account_id(), None, 1); + let out_come = direct_swap(&new_user, &token1, vec![action1, action2], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 0); + + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("4")); + assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("5")); + // println!("token3 {}", balance_of(&token3, &new_user.account_id)); + assert!(balance_of(&token3, &new_user.account_id) > to_yocto("5.8")); + + println!("Case 0302: two actions with tow output token"); + let action1 = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let action2 = pack_action(1, &token2.account_id(), &token3.account_id(), Some(to_yocto("1")), 1); + let out_come = direct_swap(&new_user, &token1, vec![action1, action2], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 0); + + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("3")); + // println!("token2 {}", balance_of(&token2, &new_user.account_id)); + // println!("token3 {}", balance_of(&token3, &new_user.account_id)); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("5.5")); + assert!(balance_of(&token3, &new_user.account_id) > to_yocto("6.2")); + + println!("Case 0303: two actions with two output token and send back token#2 fail"); + call!(new_user, token2.storage_unregister(Some(true)), deposit = 1).assert_success(); + assert!(!is_register_to_token(&token2, new_user.valid_account_id())); + let action1 = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let action2 = pack_action(1, &token2.account_id(), &token3.account_id(), Some(to_yocto("1")), 1); + let out_come = direct_swap(&new_user, &token1, vec![action1, action2], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come) + .contains("Smart contract panicked: The account new_user is not registered")); + + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("2")); + assert!( + get_deposits(&pool, owner.valid_account_id()) + .get(&token2.account_id()) + .unwrap() + .0 + > to_yocto("0.27") + ); + // println!("token3 {}", balance_of(&token3, &new_user.account_id)); + assert!(balance_of(&token3, &new_user.account_id) > to_yocto("6.598")); + + println!("Case 0304: two actions with two output token and send back token#3 fail"); + call!( + new_user, + token2.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + call!(new_user, token3.storage_unregister(Some(true)), deposit = 1).assert_success(); + assert!(!is_register_to_token(&token3, new_user.valid_account_id())); + let action1 = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let action2 = pack_action( + 1, + &token2.account_id(), + &token3.account_id(), + Some(to_yocto("1")), + 1, + ); + let out_come = direct_swap(&new_user, &token1, vec![action1, action2], to_yocto("1")); + out_come.assert_success(); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come) + .contains("Smart contract panicked: The account new_user is not registered")); + + assert!( + get_deposits(&pool, new_user.valid_account_id()) + .get(&token3.account_id()) + .unwrap() + .0 + > to_yocto("5.33") + ); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("1")); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("10.09")); + + println!("Case 0305: two actions with the second one insurfficent"); + let action1 = pack_action(0, &token1.account_id(), &token2.account_id(), None, 1); + let action2 = pack_action( + 1, + &token2.account_id(), + &token3.account_id(), + Some(to_yocto("1.2")), + 1, + ); + let out_come = direct_swap(&new_user, &token1, vec![action1, action2], to_yocto("1")); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 1); + // println!("{}", get_error_status(&out_come)); + assert!(get_error_status(&out_come).contains("E22: not enough tokens in deposit")); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("1")); +} + +#[test] +fn instant_swap_scenario_04() { + const ONE_DAI: u128 = 1000000000000000000; + const ONE_USDT: u128 = 1000000; + const ONE_USDC: u128 = 1000000; + let (root, owner, pool, tokens) = + setup_stable_pool_with_liquidity( + vec![dai(), usdt(), usdc()], + vec![100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC], + vec![18, 6, 6], + 25, + 10000, + ); + let tokens = &tokens; + let user = root.create_user("user".to_string(), to_yocto("100")); + let token_in = &tokens[0]; + let token_out = &tokens[1]; + call!(user, token_in.mint(user.valid_account_id(), U128(10*ONE_DAI))).assert_success(); + + println!("Case 0401: non-registered user stable swap but not registered in token2"); + let action = pack_action(0, &tokens[0].account_id(), &tokens[1].account_id(), None, 1); + let out_come = direct_swap(&user, &tokens[0], vec![action], 1*ONE_DAI); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 1); + assert!(get_error_status(&out_come) + .contains("Smart contract panicked: The account user is not registered")); + assert!(get_storage_balance(&pool, user.valid_account_id()).is_none()); + assert_eq!(balance_of(&tokens[0], &user.account_id), 9*ONE_DAI); + + assert_eq!( + get_deposits(&pool, owner.valid_account_id()) + .get(&token_out.account_id()) + .unwrap() + .0, + 997499 + ); + + println!("Case 0402: non-registered user stable swap"); + call!( + user, + token_out.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + let action = pack_action(0, &tokens[0].account_id(), &tokens[1].account_id(), None, 1); + let out_come = direct_swap(&user, &tokens[0], vec![action], 1*ONE_DAI); + out_come.assert_success(); + assert_eq!(get_error_count(&out_come), 0); + assert!(get_storage_balance(&pool, user.valid_account_id()).is_none()); + assert_eq!(balance_of(&token_in, &user.account_id), 8*ONE_DAI); + assert_eq!(balance_of(&token_out, &user.account_id), 997499); +} \ No newline at end of file diff --git a/ref-exchange/tests/test_migrate.rs b/ref-exchange/tests/test_migrate.rs index 69c0f2c..87e1028 100644 --- a/ref-exchange/tests/test_migrate.rs +++ b/ref-exchange/tests/test_migrate.rs @@ -1,13 +1,16 @@ use std::convert::TryFrom; -use near_sdk::json_types::ValidAccountId; +use near_sdk::json_types::{ValidAccountId}; use near_sdk_sim::{deploy, init_simulator, to_yocto}; -use ref_exchange::ContractContract as Exchange; +use ref_exchange::{ContractContract as Exchange, RunningState}; + +use crate::common::utils::*; +pub mod common; near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { - PREV_EXCHANGE_WASM_BYTES => "../res/ref_exchange_local.wasm", - EXCHANGE_WASM_BYTES => "../res/ref_exchange_local.wasm", + PREV_EXCHANGE_WASM_BYTES => "../res/ref_exchange_131.wasm", + EXCHANGE_WASM_BYTES => "../res/ref_exchange_release.wasm", } #[test] @@ -41,6 +44,12 @@ fn test_upgrade() { 0, ) .assert_success(); + let metadata = get_metadata(&pool); + // println!("{:#?}", metadata); + assert_eq!(metadata.version, "1.4.1".to_string()); + assert_eq!(metadata.exchange_fee, 1600); + assert_eq!(metadata.referral_fee, 400); + assert_eq!(metadata.state, RunningState::Running); // Upgrade to the same code migration is skipped. root.call( @@ -51,4 +60,4 @@ fn test_upgrade() { 0, ) .assert_success(); -} +} \ No newline at end of file diff --git a/ref-exchange/tests/test_stable_pool.rs b/ref-exchange/tests/test_stable_pool.rs new file mode 100644 index 0000000..5036330 --- /dev/null +++ b/ref-exchange/tests/test_stable_pool.rs @@ -0,0 +1,359 @@ +use std::collections::HashMap; + +use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; +use near_sdk::json_types::{U128}; +use near_sdk::AccountId; +use near_sdk_sim::{ + call, view, to_yocto +}; + +use ref_exchange::{PoolInfo, SwapAction}; +use crate::common::utils::*; +pub mod common; + + +const ONE_LPT: u128 = 1000000000000000000; +const ONE_DAI: u128 = 1000000000000000000; +const ONE_USDT: u128 = 1000000; +const ONE_USDC: u128 = 1000000; + + +#[test] +fn sim_stable_swap() { + let (root, _owner, pool, tokens) = + setup_stable_pool_with_liquidity( + vec![dai(), usdt(), usdc()], + vec![100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC], + vec![18, 6, 6], + 25, + 10000, + ); + let tokens = &tokens; + assert_eq!( + view!(pool.get_pool(0)).unwrap_json::(), + PoolInfo { + pool_kind: "STABLE_SWAP".to_string(), + token_account_ids: tokens.into_iter().map(|x| x.account_id()).collect(), + amounts: vec![U128(100000*ONE_DAI), U128(100000*ONE_USDT), U128(100000*ONE_USDC)], + total_fee: 25, + shares_total_supply: U128(300000*ONE_LPT), + } + ); + assert_eq!( + view!(pool.mft_metadata(":0".to_string())) + .unwrap_json::() + .name, + "ref-pool-0" + ); + assert_eq!( + view!(pool.mft_balance_of(":0".to_string(), to_va(root.account_id.clone()))) + .unwrap_json::() + .0, + 300000*ONE_LPT + ); + let balances = view!(pool.get_deposits(root.valid_account_id())) + .unwrap_json::>(); + let balances = balances.values().cloned().collect::>(); + assert_eq!(balances, vec![U128(0), U128(0), U128(0)]); + + let c = tokens.get(0).unwrap(); + call!( + root, + c.ft_transfer_call(pool.valid_account_id(), U128(2 * ONE_DAI), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + + let out_come = call!( + root, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdc(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + + let out_come = call!( + root, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdt(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + + let balances = view!(pool.get_deposits(root.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances[&dai()].0, 0); + assert_eq!(balances[&usdt()].0, 997499); + assert_eq!(balances[&usdc()].0, 997499); + + assert_eq!( + view!(pool.get_pool(0)).unwrap_json::(), + PoolInfo { + pool_kind: "STABLE_SWAP".to_string(), + token_account_ids: tokens.into_iter().map(|x| x.account_id()).collect(), + amounts: vec![U128(100002*ONE_DAI), U128(99999*ONE_USDT+2500), U128(99999*ONE_USDC+2500)], + total_fee: 25, + shares_total_supply: U128(300000*ONE_LPT+997999990125778), + } + ); +} + +#[test] +fn sim_stable_lp() { + let (root, _owner, pool, tokens) = + setup_stable_pool_with_liquidity( + vec![dai(), usdt(), usdc()], + vec![100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC], + vec![18, 6, 6], + 25, + 10000, + ); + let tokens = &tokens; + let last_share_price = pool_share_price(&pool, 0); + let last_lpt_supply = mft_total_supply(&pool, ":0"); + + // add more liquidity with balanced tokens + let user1 = root.create_user("user1".to_string(), to_yocto("100")); + mint_and_deposit_token(&user1, &tokens[0], &pool, 500*ONE_DAI); + mint_and_deposit_token(&user1, &tokens[1], &pool, 500*ONE_USDT); + mint_and_deposit_token(&user1, &tokens[2], &pool, 500*ONE_USDC); + let out_come = call!( + user1, + pool.add_stable_liquidity(0, vec![U128(500*ONE_DAI), U128(500*ONE_USDT), U128(500*ONE_USDC)], U128(1)), + deposit = to_yocto("0.0007") + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(pool_share_price(&pool, 0), last_share_price); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply + 1500*ONE_LPT); + let last_lpt_supply = last_lpt_supply + 1500*ONE_LPT; + + // remove by shares + let out_come = call!( + user1, + pool.remove_liquidity(0, U128(300*ONE_LPT), vec![U128(1*ONE_DAI), U128(1*ONE_USDT), U128(1*ONE_USDC)]), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1200*ONE_LPT); + let balances = view!(pool.get_deposits(user1.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances[&dai()].0, 100*ONE_DAI); + assert_eq!(balances[&usdt()].0, 100*ONE_USDT); + assert_eq!(balances[&usdc()].0, 100*ONE_USDC); + assert_eq!(pool_share_price(&pool, 0), last_share_price); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply - 300*ONE_LPT); + let last_lpt_supply = last_lpt_supply - 300*ONE_LPT; + + // add more liquidity with imba tokens + let user2 = root.create_user("user2".to_string(), to_yocto("100")); + mint_and_deposit_token(&user2, &tokens[0], &pool, 100*ONE_DAI); + mint_and_deposit_token(&user2, &tokens[1], &pool, 200*ONE_USDT); + mint_and_deposit_token(&user2, &tokens[2], &pool, 400*ONE_USDC); + let out_come = call!( + user2, + pool.add_stable_liquidity(0, vec![U128(100*ONE_DAI), U128(200*ONE_USDT), U128(400*ONE_USDC)], U128(1)), + deposit = to_yocto("0.0014") // 0.0007 for one lp and double it for admin fee + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + + assert_eq!( + view!(pool.get_pool(0)).unwrap_json::(), + PoolInfo { + pool_kind: "STABLE_SWAP".to_string(), + token_account_ids: tokens.into_iter().map(|x| x.account_id()).collect(), + amounts: vec![U128(100500*ONE_DAI), U128(100600*ONE_USDT), U128(100800*ONE_USDC)], + total_fee: 25, + shares_total_supply: U128(301200*ONE_LPT+699699997426210330025+47999999735823255), + } + ); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1200*ONE_LPT); + assert_eq!(mft_balance_of(&pool, ":0", &user2.account_id()), 699699997426210330025); + assert!(pool_share_price(&pool, 0) > last_share_price); + let last_share_price = pool_share_price(&pool, 0); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply + 699699997426210330025 + 47999999735823255); + let last_lpt_supply = last_lpt_supply + 699699997426210330025 + 47999999735823255; + + // remove by tokens + let out_come = call!( + user1, + pool.remove_liquidity_by_tokens(0, vec![U128(1*ONE_DAI), U128(500*ONE_USDT), U128(1*ONE_USDC)], U128(550*ONE_LPT)), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 697401508719920229455); + let balances = view!(pool.get_deposits(user1.valid_account_id())) + .unwrap_json::>(); + assert_eq!(balances[&dai()].0, 101*ONE_DAI); + assert_eq!(balances[&usdt()].0, 600*ONE_USDT); + assert_eq!(balances[&usdc()].0, 101*ONE_USDC); + assert_eq!( + view!(pool.get_pool(0)).unwrap_json::(), + PoolInfo { + pool_kind: "STABLE_SWAP".to_string(), + token_account_ids: tokens.into_iter().map(|x| x.account_id()).collect(), + amounts: vec![U128(100499*ONE_DAI), U128(100100*ONE_USDT), U128(100799*ONE_USDC)], + total_fee: 25, + shares_total_supply: U128(last_lpt_supply-502598491280079770545+95823884420348155), + } + ); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1200*ONE_LPT-502598491280079770545); + assert_eq!(mft_balance_of(&pool, ":0", &user2.account_id()), 699699997426210330025); + assert!(pool_share_price(&pool, 0) > last_share_price); + let last_share_price = pool_share_price(&pool, 0); + let last_lpt_supply = last_lpt_supply - 502598491280079770545 + 95823884420348155; + + // tansfer some to other + let out_come = call!( + user1, + pool.mft_transfer(":0".to_string(), user2.valid_account_id(), U128(100*ONE_LPT), None), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1100*ONE_LPT-502598491280079770545); + assert_eq!(mft_balance_of(&pool, ":0", &user2.account_id()), 799699997426210330025); + assert_eq!(pool_share_price(&pool, 0), last_share_price); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply); + + // other remove by shares trigger slippage + let out_come = call!( + user2, + pool.remove_liquidity(0, U128(300*ONE_LPT), vec![U128(1*ONE_DAI), U128(298*ONE_USDT), U128(1*ONE_USDC)]), + deposit = 1 + ); + assert!(!out_come.is_ok()); + let ex_status = format!("{:?}", out_come.promise_errors()[0].as_ref().unwrap().status()); + // println!("ex_status: {}", ex_status); + assert!(ex_status.contains("E68: slippage error")); + assert_eq!(pool_share_price(&pool, 0), last_share_price); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply); + + // other remove by tokens trigger slippage + let out_come = call!( + user2, + pool.remove_liquidity_by_tokens(0, vec![U128(1*ONE_DAI), U128(298*ONE_USDT), U128(1*ONE_USDC)], U128(300*ONE_LPT)), + deposit = 1 + ); + assert!(!out_come.is_ok()); + let ex_status = format!("{:?}", out_come.promise_errors()[0].as_ref().unwrap().status()); + assert!(ex_status.contains("E68: slippage error")); + assert_eq!(pool_share_price(&pool, 0), last_share_price); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply); + + // user2 remove by share + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1100*ONE_LPT-502598491280079770545); + assert_eq!(mft_balance_of(&pool, ":0", &user2.account_id()), 799699997426210330025); + let out_come = call!( + user2, + pool.remove_liquidity(0, U128(300*ONE_LPT), vec![U128(1*ONE_DAI), U128(1*ONE_USDT), U128(1*ONE_USDC)]), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1100*ONE_LPT-502598491280079770545); + assert_eq!(mft_balance_of(&pool, ":0", &user2.account_id()), 499699997426210330025); + assert_eq!(pool_share_price(&pool, 0), last_share_price); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply-300*ONE_LPT); + let last_lpt_supply = last_lpt_supply - 300*ONE_LPT; + + // user2 remove by tokens + let out_come = call!( + user2, + pool.remove_liquidity_by_tokens(0, vec![U128(498*ONE_DAI), U128(0*ONE_USDT), U128(0*ONE_USDC)], U128(499*ONE_LPT)), + deposit = 1 + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user1.account_id()), 1100*ONE_LPT-502598491280079770545); + assert_eq!(mft_balance_of(&pool, ":0", &user2.account_id()), 499699997426210330025-498596260775994577944); + assert_eq!(mft_total_supply(&pool, ":0"), last_lpt_supply-498596260775994577944+95600058313591488); + assert!(pool_share_price(&pool, 0) > last_share_price); + let last_share_price = pool_share_price(&pool, 0); + println!("share_price: {}", last_share_price); + + // add massive liquidity (100 billion) + let user3 = root.create_user("user3".to_string(), to_yocto("100")); + mint_and_deposit_token(&user3, &tokens[0], &pool, 100_000_000_000*ONE_DAI); + mint_and_deposit_token(&user3, &tokens[1], &pool, 100_000_000_000*ONE_USDT); + mint_and_deposit_token(&user3, &tokens[2], &pool, 100_000_000_000*ONE_USDC); + let out_come = call!( + user3, + pool.add_stable_liquidity(0, vec![U128(100_000_000_000*ONE_DAI), U128(100_000_000_000*ONE_USDT), U128(100_000_000_000*ONE_USDC)], U128(1)), + deposit = to_yocto("0.0007") + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user3.account_id()), 299997852136734902917811274863); + assert_eq!(mft_total_supply(&pool, ":0"), 299998296064761697388281990824); + let last_share_price = pool_share_price(&pool, 0); + println!("share_price: {}", last_share_price); +} + +#[test] +fn sim_stable_max_liquidity() { + let (root, _owner, pool, tokens) = + setup_stable_pool_with_liquidity( + vec![dai(), usdt(), usdc(), + "dai1".to_string(), "usdt1".to_string(), "usdc1".to_string(), + "dai2".to_string(), "usdt2".to_string(), "usdc2".to_string(), + ], + vec![ + 100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC, + 100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC, + 100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC + ], + vec![18, 6, 6, 18, 6, 6, 18, 6, 6], + 25, + 10000, + ); + let tokens = &tokens; + + // add massive liquidity (100 billion) + let user = root.create_user("user".to_string(), to_yocto("100")); + mint_and_deposit_token(&user, &tokens[0], &pool, 100_000_000_000*ONE_DAI); + mint_and_deposit_token(&user, &tokens[1], &pool, 100_000_000_000*ONE_USDT); + mint_and_deposit_token(&user, &tokens[2], &pool, 100_000_000_000*ONE_USDC); + mint_and_deposit_token(&user, &tokens[3], &pool, 100_000_000_000*ONE_DAI); + mint_and_deposit_token(&user, &tokens[4], &pool, 100_000_000_000*ONE_USDT); + mint_and_deposit_token(&user, &tokens[5], &pool, 100_000_000_000*ONE_USDC); + mint_and_deposit_token(&user, &tokens[6], &pool, 100_000_000_000*ONE_DAI); + mint_and_deposit_token(&user, &tokens[7], &pool, 100_000_000_000*ONE_USDT); + mint_and_deposit_token(&user, &tokens[8], &pool, 100_000_000_000*ONE_USDC); + let out_come = call!( + user, + pool.add_stable_liquidity(0, vec![ + U128(100_000_000_000*ONE_DAI), U128(100_000_000_000*ONE_USDT), U128(100_000_000_000*ONE_USDC), + U128(100_000_000_000*ONE_DAI), U128(100_000_000_000*ONE_USDT), U128(100_000_000_000*ONE_USDC), + U128(100_000_000_000*ONE_DAI), U128(100_000_000_000*ONE_USDT), U128(100_000_000_000*ONE_USDC) + ], U128(1)), + deposit = to_yocto("0.0007") + ); + out_come.assert_success(); + println!("{:#?}", get_logs(&out_come)); + assert_eq!(mft_balance_of(&pool, ":0", &user.account_id()), 900000000000000000000000000000); + assert_eq!(mft_total_supply(&pool, ":0"), 900000900000000000000000000000); + let last_share_price = pool_share_price(&pool, 0); + println!("share_price: {}", last_share_price); +} diff --git a/ref-exchange/tests/test_stable_swap_robert.rs b/ref-exchange/tests/test_stable_swap_robert.rs new file mode 100644 index 0000000..22ea814 --- /dev/null +++ b/ref-exchange/tests/test_stable_swap_robert.rs @@ -0,0 +1,242 @@ +use std::collections::HashMap; +use std::convert::TryFrom; + +use near_contract_standards::fungible_token::metadata::FungibleTokenMetadata; +use near_sdk::json_types::{ValidAccountId, U128}; +use near_sdk::AccountId; +use near_sdk_sim::transaction::ExecutionStatus; +use near_sdk_sim::{ + call, deploy, init_simulator, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, +}; + +use ref_exchange::{ContractContract as Exchange, PoolInfo, SwapAction}; +use test_token::ContractContract as TestToken; + +near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { + TEST_TOKEN_WASM_BYTES => "../res/test_token.wasm", + EXCHANGE_WASM_BYTES => "../res/ref_exchange_release.wasm", +} + +const ONE_LPT: u128 = 1000000000000000000; +const ONE_DAI: u128 = 1000000000000000000; +const ONE_USDT: u128 = 1000000; +const ONE_USDC: u128 = 1000000; + +pub fn should_fail(r: ExecutionResult) { + println!("{:?}", r.status()); + match r.status() { + ExecutionStatus::Failure(_) => {} + _ => panic!("Should fail"), + } +} + +pub fn show_promises(r: ExecutionResult) { + for promise in r.promise_results() { + println!("{:?}", promise); + } +} + +fn test_token( + root: &UserAccount, + token_id: AccountId, + accounts_to_register: Vec, +) -> ContractAccount { + let t = deploy!( + contract: TestToken, + contract_id: token_id, + bytes: &TEST_TOKEN_WASM_BYTES, + signer_account: root + ); + call!(root, t.new()).assert_success(); + call!( + root, + t.mint(root.valid_account_id(), to_yocto("1000").into()) + ) + .assert_success(); + for account_id in accounts_to_register { + call!( + root, + t.storage_deposit(Some(to_va(account_id)), None), + deposit = to_yocto("1") + ) + .assert_success(); + } + t +} + +fn balance_of(token: &ContractAccount, account_id: &AccountId) -> u128 { + view!(token.ft_balance_of(to_va(account_id.clone()))) + .unwrap_json::() + .0 +} + +fn mft_balance_of( + pool: &ContractAccount, + token_or_pool: &str, + account_id: &AccountId, +) -> u128 { + view!(pool.mft_balance_of(token_or_pool.to_string(), to_va(account_id.clone()))) + .unwrap_json::() + .0 +} + +fn dai() -> AccountId { + "dai".to_string() +} + +fn usdt() -> AccountId { + "usdt".to_string() +} + +fn usdc() -> AccountId { + "usdc".to_string() +} + +fn swap() -> AccountId { + "swap".to_string() +} + +fn to_va(a: AccountId) -> ValidAccountId { + ValidAccountId::try_from(a).unwrap() +} + +fn setup_stable_pool_with_liquidity() -> ( + UserAccount, + UserAccount, + ContractAccount, + ContractAccount, + ContractAccount, + ContractAccount, +) { + let root = init_simulator(None); + let owner = root.create_user("owner".to_string(), to_yocto("100")); + let pool = deploy!( + contract: Exchange, + contract_id: swap(), + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(owner.valid_account_id(), 1600, 400) + ); + let token1 = test_token(&root, dai(), vec![swap()]); + let token2 = test_token(&root, usdt(), vec![swap()]); + let token3 = test_token(&root, usdc(), vec![swap()]); + call!( + owner, + pool.extend_whitelisted_tokens( + vec![ + token1.valid_account_id(), + token2.valid_account_id(), + token3.valid_account_id() + ] + ) + ); + call!( + owner, + pool.add_stable_swap_pool( + vec![ + token1.valid_account_id(), + token2.valid_account_id(), + token3.valid_account_id() + ], + vec![18, 6, 6], + 25, + 10000 + ), + deposit = to_yocto("1")) + .assert_success(); + + call!( + root, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + owner, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + + call!( + root, + token1.ft_transfer_call(pool.valid_account_id(), U128(100000*ONE_DAI), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + root, + token2.ft_transfer_call(pool.valid_account_id(), U128(100000*ONE_USDT), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + root, + token3.ft_transfer_call(pool.valid_account_id(), U128(100000*ONE_USDC), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + root, + pool.add_stable_liquidity(0, vec![U128(100000*ONE_DAI), U128(100000*ONE_USDT), U128(100000*ONE_USDC)], U128(1)), + deposit = to_yocto("0.0007") + ) + .assert_success(); + (root, owner, pool, token1, token2, token3) +} + + +#[test] +fn robert_sim_stable_swap() { + let (root, _owner, pool, token1, token2, token3) = setup_stable_pool_with_liquidity(); + assert_eq!( + view!(pool.get_pool(0)).unwrap_json::(), + PoolInfo { + pool_kind: "STABLE_SWAP".to_string(), + token_account_ids: vec![token1.account_id(), token2.account_id(), token3.account_id()], + amounts: vec![U128(100000*ONE_DAI), U128(100000*ONE_USDT), U128(100000*ONE_USDC)], + total_fee: 25, + shares_total_supply: U128(300000*ONE_LPT), + } + ); + assert_eq!( + view!(pool.mft_metadata(":0".to_string())) + .unwrap_json::() + .name, + "ref-pool-0" + ); + assert_eq!( + view!(pool.mft_balance_of(":0".to_string(), to_va(root.account_id.clone()))) + .unwrap_json::() + .0, + 300000*ONE_LPT + ); + let balances = view!(pool.get_deposits(root.valid_account_id())) + .unwrap_json::>(); + let balances = balances.values().cloned().collect::>(); + assert_eq!(balances, vec![U128(0), U128(0), U128(0)]); + + call!( + root, + token1.ft_transfer_call(pool.valid_account_id(), U128(ONE_DAI), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + + call!( + root, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdc(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ) + .assert_success(); +} diff --git a/ref-exchange/tests/test_storage.rs b/ref-exchange/tests/test_storage.rs new file mode 100644 index 0000000..ba02bc6 --- /dev/null +++ b/ref-exchange/tests/test_storage.rs @@ -0,0 +1,325 @@ +/// The storage in REF consists of inner-account storage (A storage) and LP-token storage (T storage). +/// For A storage: +/// Basic cost is 0.00102 Near (102 bytes), +/// Each token cost is 0.00148 Near (148 bytes), +/// Following actions will examine A storage: +/// ft::ft_transfer_call to deposit token into, +/// [withdraw], [register_tokens], [unregister_tokens], +/// For T storage: +/// Each pool has its own LP token, +/// Each lp as a token holder would do storage_register, in REF, that is, +/// lp can call explicitly [mft_register], suggested deposit amount is 0.005, unused part would refund, +/// lp can call [add_liquidity], suggested deposit amount is 0.005, unused part would refund, +/// The contract self would be registered by pool creator +/// when [add_simple_pool] and [add_stable_swap_pool], +/// suggested deposit amount is 0.01, unused part would refund +use near_sdk::json_types::{U128}; +use near_sdk_sim::{call, to_yocto}; + +use ref_exchange::SwapAction; +use crate::common::utils::*; +pub mod common; + +const ONE_LPT: u128 = 1000000000000000000; +const ONE_DAI: u128 = 1000000000000000000; +const ONE_USDT: u128 = 1000000; +const ONE_USDC: u128 = 1000000; + +#[test] +fn storage_scenario_01() { + let (root, _, pool, token1, _, _) = setup_pool_with_liquidity(); + let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + + println!("Storage Case 0101: withdraw MAX using None"); + call!( + new_user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + let sb = get_storage_balance(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(sb.total.0, to_yocto("1")); + assert_eq!(sb.total.0 - sb.available.0, to_yocto("0.00102")); + let orig_user_balance = new_user.account().unwrap().amount; + + // withdraw as much storage near as he can + let out_come = call!( + new_user, + pool.storage_withdraw(None), + deposit = 1 + ); + out_come.assert_success(); + + let sb = get_storage_balance(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(sb.total.0, to_yocto("0.00102")); + assert_eq!(sb.available.0, to_yocto("0")); + // println!("{}", new_user.account().unwrap().amount - orig_user_balance); + assert!( + new_user.account().unwrap().amount - orig_user_balance > + to_yocto("0.998") + ); + + println!("Storage Case 0102: deposit token would fail with insufficient storage deposit"); + call!( + new_user, + token1.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + let out_come = call!( + new_user, + token1.ft_transfer_call(to_va(swap()), to_yocto("5").into(), None, "".to_string()), + deposit = 1 + ); + out_come.assert_success(); + let ex_status = format!("{:?}", out_come.promise_errors()[0].as_ref().unwrap().status()); + assert!(ex_status.contains("E11: insufficient $NEAR storage deposit")); + + println!("Storage Case 0103: deposit token would success with enough storage deposit"); + call!( + new_user, + pool.storage_deposit(None, Some(false)), + deposit = to_yocto("1") + ) + .assert_success(); + let out_come = call!( + new_user, + token1.ft_transfer_call(to_va(swap()), to_yocto("5").into(), None, "".to_string()), + deposit = 1 + ); + out_come.assert_success(); + assert_eq!(out_come.promise_errors().len(), 0); + + println!("Storage Case 0104: storage withdraw more than available"); + let prev_sb = get_storage_balance(&pool, new_user.valid_account_id()).unwrap(); + let orig_user_balance = new_user.account().unwrap().amount; + // println!("{:#?}", prev_sb); + + let out_come = call!( + new_user, + pool.storage_withdraw(Some(U128(to_yocto("1")))), + deposit = 1 + ); + assert!(!out_come.is_ok()); + + let sb = get_storage_balance(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(sb.total.0, prev_sb.total.0); + assert_eq!(sb.available.0, prev_sb.available.0); + assert!(new_user.account().unwrap().amount < orig_user_balance); + + println!("Storage Case 0105: storage withdraw specific amount"); + let prev_sb = get_storage_balance(&pool, new_user.valid_account_id()).unwrap(); + let orig_user_balance = new_user.account().unwrap().amount; + + let out_come = call!( + new_user, + pool.storage_withdraw(Some(U128(to_yocto("0.5")))), + deposit = 1 + ); + out_come.assert_success(); + + let sb = get_storage_balance(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(sb.total.0, prev_sb.total.0 - to_yocto("0.5")); + assert_eq!(sb.available.0, prev_sb.available.0 - to_yocto("0.5")); + assert!(new_user.account().unwrap().amount - orig_user_balance > to_yocto("0.499")); + +} + + +#[test] +fn storage_scenario_02() { + let (root, _owner, pool, tokens) = + setup_stable_pool_with_liquidity( + vec![dai(), usdt(), usdc()], + vec![100000*ONE_DAI, 100000*ONE_USDT, 100000*ONE_USDC], + vec![18, 6, 6], + 25, + 10000, + ); + let tokens = &tokens; + + // prepare a new user with 3 tokens storage 102 + 3 * 148 = 102 + 444 = 546 + let new_user = root.create_user("new_user1".to_string(), to_yocto("100")); + mint_and_deposit_token(&new_user, &tokens[0], &pool, 500*ONE_DAI); + mint_and_deposit_token(&new_user, &tokens[1], &pool, 500*ONE_USDT); + mint_and_deposit_token(&new_user, &tokens[2], &pool, 500*ONE_USDC); + let ss = get_storage_state(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(ss.usage.0, to_yocto("0.00546")); + + // appending balanced liqudity with basic lp register storage fee + println!("Storage Case 0201: appending balanced liqudity need deposit storage"); + call!( + new_user, + pool.add_stable_liquidity(0, vec![U128(10*ONE_DAI), U128(10*ONE_USDT), U128(10*ONE_USDC)], U128(1)), + deposit = to_yocto("0.00074") + ) + .assert_success(); + let ss = get_storage_state(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(ss.usage.0, to_yocto("0.00546")); + + // appending imba liquidity with extra storage fee for exchange share + println!("Storage Case 0202: appending imba liqudity need deposit storage"); + let out_come = call!( + new_user, + pool.add_stable_liquidity(0, vec![U128(5*ONE_DAI), U128(10*ONE_USDT), U128(15*ONE_USDC)], U128(1)), + deposit = to_yocto("0.00074") + ); + out_come.assert_success(); + let ss = get_storage_state(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(ss.usage.0, to_yocto("0.00546")); + + // remove liquidity by share + println!("Storage Case 0203: remove liqudity by share"); + let out_come = call!( + new_user, + pool.remove_liquidity(0, U128(10*ONE_LPT), vec![U128(3*ONE_DAI), U128(3*ONE_USDT), U128(3*ONE_USDC)]), + deposit = 1 + ); + out_come.assert_success(); + let ss = get_storage_state(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(ss.usage.0, to_yocto("0.00546")); + + // remove liquidity by token + println!("Storage Case 0204: remove liqudity by token"); + let out_come = call!( + new_user, + pool.remove_liquidity_by_tokens(0, vec![U128(10*ONE_DAI), U128(1*ONE_USDT), U128(1*ONE_USDC)], U128(13*ONE_LPT)), + deposit = 1 + ); + out_come.assert_success(); + let ss = get_storage_state(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(ss.usage.0, to_yocto("0.00546")); + + // swap + println!("Storage Case 0205: swap would fail if storage insufficient"); + let out_come = call!( + new_user, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdc(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + out_come.assert_success(); + // println!("{:#?}", get_logs(&out_come)); + let ss = get_storage_state(&pool, new_user.valid_account_id()).unwrap(); + assert_eq!(ss.usage.0, to_yocto("0.00546")); + + let user2 = root.create_user("user2".to_string(), to_yocto("100")); + mint_and_deposit_token(&user2, &tokens[0], &pool, 500*ONE_DAI); + + let out_come = call!( + user2, + pool.storage_withdraw(None), + deposit = 1 + ); + out_come.assert_success(); + + let ss = get_storage_state(&pool, user2.valid_account_id()).unwrap(); + assert_eq!(ss.deposit.0, to_yocto("0.00250")); + assert_eq!(ss.usage.0, to_yocto("0.00250")); + + let out_come = call!( + user2, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdc(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + assert!(!out_come.is_ok()); + let ex_status = format!("{:?}", out_come.promise_errors()[0].as_ref().unwrap().status()); + // println!("{}", ex_status); + assert!(ex_status.contains("E11: insufficient $NEAR storage deposit")); + + call!( + user2, + pool.storage_deposit(None, Some(false)), + deposit = to_yocto("0.00148") + ) + .assert_success(); + let ss = get_storage_state(&pool, user2.valid_account_id()).unwrap(); + assert_eq!(ss.deposit.0, to_yocto("0.00398")); + assert_eq!(ss.usage.0, to_yocto("0.00250")); + + let out_come = call!( + user2, + pool.swap( + vec![SwapAction { + pool_id: 0, + token_in: dai(), + amount_in: Some(U128(ONE_DAI)), + token_out: usdc(), + min_amount_out: U128(1) + }], + None + ), + deposit = 1 + ); + out_come.assert_success(); + let ss = get_storage_state(&pool, user2.valid_account_id()).unwrap(); + assert_eq!(ss.deposit.0, to_yocto("0.00398")); + assert_eq!(ss.usage.0, to_yocto("0.00398")); + + println!("Storage Case 0206: transfer lp would fail if receiver not registered"); + let user3 = root.create_user("user3".to_string(), to_yocto("100")); + let out_come = call!( + new_user, + pool.mft_transfer(":0".to_string(), user3.valid_account_id(), U128(5*ONE_LPT), None), + deposit = 1 + ); + assert!(!out_come.is_ok()); + let ex_status = format!("{:?}", out_come.promise_errors()[0].as_ref().unwrap().status()); + assert!(ex_status.contains("E13: LP not registered")); + + println!("Storage Case 0207: remove liquidity would fail if not enough storage for received token"); + let out_come = call!( + new_user, + pool.mft_register(":0".to_string(), user3.valid_account_id()), + deposit = to_yocto("0.00074") + ); + out_come.assert_success(); + let out_come = call!( + new_user, + pool.mft_transfer(":0".to_string(), user3.valid_account_id(), U128(5*ONE_LPT), None), + deposit = 1 + ); + out_come.assert_success(); + let out_come = call!( + user3, + pool.remove_liquidity(0, U128(5*ONE_LPT), vec![U128(1*ONE_DAI), U128(1*ONE_USDT), U128(1*ONE_USDC)]), + deposit = 1 + ); + assert!(!out_come.is_ok()); + let ex_status = format!("{:?}", out_come.promise_errors()[0].as_ref().unwrap().status()); + assert!(ex_status.contains("E11: insufficient $NEAR storage deposit")); + assert!(get_storage_state(&pool, user3.valid_account_id()).is_none()); + + call!( + user3, + pool.storage_deposit(None, None), + deposit = to_yocto("0.00546") + ) + .assert_success(); + + let out_come = call!( + user3, + pool.remove_liquidity(0, U128(5*ONE_LPT), vec![U128(1*ONE_DAI), U128(1*ONE_USDT), U128(1*ONE_USDC)]), + deposit = 1 + ); + out_come.assert_success(); + let ss = get_storage_state(&pool, user3.valid_account_id()).unwrap(); + assert_eq!(ss.deposit.0, to_yocto("0.00546")); + assert_eq!(ss.usage.0, to_yocto("0.00546")); +} diff --git a/ref-exchange/tests/test_swap.rs b/ref-exchange/tests/test_swap.rs index 3be9d01..6d700fa 100644 --- a/ref-exchange/tests/test_swap.rs +++ b/ref-exchange/tests/test_swap.rs @@ -14,7 +14,7 @@ use test_token::ContractContract as TestToken; near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { TEST_TOKEN_WASM_BYTES => "../res/test_token.wasm", - EXCHANGE_WASM_BYTES => "../res/ref_exchange_local.wasm", + EXCHANGE_WASM_BYTES => "../res/ref_exchange_release.wasm", } pub fn should_fail(r: ExecutionResult) { @@ -164,7 +164,7 @@ fn test_swap() { pool_kind: "SIMPLE_POOL".to_string(), token_account_ids: vec![dai(), eth()], amounts: vec![to_yocto("5").into(), to_yocto("10").into()], - total_fee: 30, + total_fee: 25, shares_total_supply: to_yocto("1").into(), } ); @@ -205,7 +205,7 @@ fn test_swap() { .unwrap_json::>(); assert_eq!( balances.get(ð()).unwrap(), - &U128(to_yocto("100") + 1662497915624478906119726) + &U128(to_yocto("100") + 1663192997082117548978741) ); assert_eq!(balances.get(&dai()).unwrap(), &U128(to_yocto("99"))); @@ -333,77 +333,72 @@ fn test_withdraw_failure() { assert_eq!(balances_after.get(&dai()).unwrap(), &to_yocto("105").into()); } -// fn direct_swap(user: &UserAccount, contract: &ContractAccount, force: u8) { -// call!( -// user, -// contract.ft_transfer_call( -// to_va(swap()), -// to_yocto("1").into(), -// None, -// format!("{{\"force\": {}, \"actions\": [{{\"pool_id\": 0, \"token_in\": \"dai\", \"token_out\": \"eth\", \"min_amount_out\": \"1\"}}]}}", force) -// ), -// deposit = 1 -// ).assert_success(); -// } - -// /// Test swap without deposit/withdraw. -// #[test] -// fn test_direct_swap() { -// let (root, owner, pool, token1, token2) = setup_pool_with_liquidity(); -// let new_user = root.create_user("new_user".to_string(), to_yocto("100")); -// call!( -// new_user, -// token1.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) -// ) -// .assert_success(); - -// // Test wrong format and that it returns all tokens back. -// call!( -// new_user, -// token1.ft_transfer_call(to_va(swap()), to_yocto("10").into(), None, "{}".to_string()), -// deposit = 1 -// ) -// .assert_success(); -// assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("10")); -// assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); - -// // Test that if non-force and account doesn't exist for this user, it fails on swap and returns everything. -// direct_swap(&new_user, &token1, 0); -// assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("10")); -// assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); - -// // Test that if force and token2 account doesn't exist, the balance of token1 is taken, owner received token2. -// direct_swap(&new_user, &token1, 1); -// assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("9")); -// assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); -// assert!(mft_balance_of(&pool, &token2.account_id(), &owner.account_id) > to_yocto("1")); - -// // Test that if force and token2 account exists, everything works. -// call!( -// new_user, -// token2.storage_deposit(None, None), -// deposit = to_yocto("1") -// ) -// .assert_success(); -// direct_swap(&new_user, &token1, 1); -// assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("8")); -// assert!(balance_of(&token2, &new_user.account_id) > to_yocto("1")); - -// // Test that if force is false and account in pool and token2 account exist, everything works. -// call!( -// new_user, -// pool.storage_deposit(None, None), -// deposit = to_yocto("1") -// ) -// .assert_success(); -// direct_swap(&new_user, &token1, 0); -// assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("7")); -// assert!(balance_of(&token2, &new_user.account_id) > to_yocto("2")); - -// // Test that if force is false, account in pool exists but token2 account doesn't exist, final balance is in the pool. -// call!(new_user, token2.storage_unregister(Some(true)), deposit = 1).assert_success(); -// direct_swap(&new_user, &token1, 0); -// assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("6")); -// assert_eq!(balance_of(&token2, &new_user.account_id), 0); -// assert!(mft_balance_of(&pool, &token2.account_id(), &new_user.account_id) > to_yocto("0.5")); -// } +fn direct_swap(user: &UserAccount, contract: &ContractAccount) { + call!( + user, + contract.ft_transfer_call( + to_va(swap()), + to_yocto("1").into(), + None, + format!("{{\"actions\": [{{\"pool_id\": 0, \"token_in\": \"dai\", \"token_out\": \"eth\", \"min_amount_out\": \"1\"}}]}}") + ), + deposit = 1 + ).assert_success(); +} + +/// Test swap without deposit/withdraw. +#[test] +fn test_direct_swap() { + let (root, owner, pool, token1, token2) = setup_pool_with_liquidity(); + let new_user = root.create_user("new_user".to_string(), to_yocto("100")); + call!( + new_user, + token1.mint(to_va(new_user.account_id.clone()), U128(to_yocto("10"))) + ) + .assert_success(); + + // Test wrong format and that it returns all tokens back. + call!( + new_user, + token1.ft_transfer_call(to_va(swap()), to_yocto("10").into(), None, "{}".to_string()), + deposit = 1 + ) + .assert_success(); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("10")); + assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); + + // Test that token2 account doesn't exist, the balance of token1 is taken, owner received token2. + direct_swap(&new_user, &token1); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("9")); + assert_eq!(balance_of(&token2, &new_user.account_id), to_yocto("0")); + assert!(mft_balance_of(&pool, &token2.account_id(), &owner.account_id) > to_yocto("1")); + + // Test that token2 account exists, everything works. + call!( + new_user, + token2.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + direct_swap(&new_user, &token1); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("8")); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("1")); + + // Test that account in pool and token2 account exist, everything works. + call!( + new_user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + direct_swap(&new_user, &token1); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("7")); + assert!(balance_of(&token2, &new_user.account_id) > to_yocto("2")); + + // Test that account in pool exists but token2 account doesn't exist, final balance is in the pool. + call!(new_user, token2.storage_unregister(Some(true)), deposit = 1).assert_success(); + direct_swap(&new_user, &token1); + assert_eq!(balance_of(&token1, &new_user.account_id), to_yocto("6")); + assert_eq!(balance_of(&token2, &new_user.account_id), 0); + assert!(mft_balance_of(&pool, &token2.account_id(), &new_user.account_id) > to_yocto("0.5")); +} diff --git a/ref-farming/Cargo.toml b/ref-farming/Cargo.toml index fdfb428..167cf48 100644 --- a/ref-farming/Cargo.toml +++ b/ref-farming/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ref_farming" -version = "1.0.1" +version = "1.0.2" authors = ["Marco Sun "] edition = "2018" diff --git a/ref-farming/release_notes.md b/ref-farming/release_notes.md new file mode 100644 index 0000000..da833f4 --- /dev/null +++ b/ref-farming/release_notes.md @@ -0,0 +1,8 @@ +# Release Notes + +### Version 1.0.2 +1. 80T gas for seed withdraw resolve, 10T gas for reward withdraw resolve; +2. add get_user_storage_state interface; + +### Version 1.0.1 +1. Increase estimate gas for resolve transfer to 20T; \ No newline at end of file diff --git a/ref-farming/src/actions_of_reward.rs b/ref-farming/src/actions_of_reward.rs index 2887c3d..f646d75 100644 --- a/ref-farming/src/actions_of_reward.rs +++ b/ref-farming/src/actions_of_reward.rs @@ -83,7 +83,7 @@ impl Contract { token_id: AccountId, sender_id: AccountId, amount: U128, - ) { + ) -> U128 { assert_eq!( env::promise_results_count(), 1, @@ -100,6 +100,7 @@ impl Contract { ) .as_bytes(), ); + amount.into() } PromiseResult::Failed => { env::log( @@ -113,8 +114,9 @@ impl Contract { let mut farmer = self.get_farmer(&sender_id); farmer.get_ref_mut().add_reward(&token_id, amount.0); self.data_mut().farmers.insert(&sender_id, &farmer); + 0.into() } - }; + } } } diff --git a/ref-farming/src/actions_of_seed.rs b/ref-farming/src/actions_of_seed.rs index 5e88ee0..1f104da 100644 --- a/ref-farming/src/actions_of_seed.rs +++ b/ref-farming/src/actions_of_seed.rs @@ -5,7 +5,7 @@ use near_sdk::{AccountId, Balance, PromiseResult}; use crate::utils::{ assert_one_yocto, ext_multi_fungible_token, ext_fungible_token, - ext_self, wrap_mft_token_id, parse_seed_id, GAS_FOR_FT_TRANSFER, GAS_FOR_RESOLVE_TRANSFER + ext_self, wrap_mft_token_id, parse_seed_id, GAS_FOR_FT_TRANSFER, GAS_FOR_RESOLVE_WITHDRAW_SEED }; use crate::errors::*; use crate::farm_seed::SeedType; @@ -41,7 +41,7 @@ impl Contract { amount.into(), &env::current_account_id(), 0, - GAS_FOR_RESOLVE_TRANSFER, + GAS_FOR_RESOLVE_WITHDRAW_SEED, )); } SeedType::MFT => { @@ -61,7 +61,7 @@ impl Contract { amount.into(), &env::current_account_id(), 0, - GAS_FOR_RESOLVE_TRANSFER, + GAS_FOR_RESOLVE_WITHDRAW_SEED, )); } } @@ -74,7 +74,7 @@ impl Contract { seed_id: SeedId, sender_id: AccountId, amount: U128, - ) { + ) -> U128 { assert_eq!( env::promise_results_count(), 1, @@ -102,6 +102,7 @@ impl Contract { farmer.get_ref_mut().add_seed(&seed_id, amount); self.data_mut().seeds.insert(&seed_id, &farm_seed); self.data_mut().farmers.insert(&sender_id, &farmer); + 0.into() }, PromiseResult::Successful(_) => { env::log( @@ -111,8 +112,9 @@ impl Contract { ) .as_bytes(), ); + amount.into() } - }; + } } #[private] @@ -121,7 +123,7 @@ impl Contract { seed_id: SeedId, sender_id: AccountId, amount: U128, - ) { + ) -> U128 { assert_eq!( env::promise_results_count(), 1, @@ -149,6 +151,7 @@ impl Contract { farmer.get_ref_mut().add_seed(&seed_id, amount); self.data_mut().seeds.insert(&seed_id, &farm_seed); self.data_mut().farmers.insert(&sender_id, &farmer); + 0.into() }, PromiseResult::Successful(_) => { env::log( @@ -158,8 +161,9 @@ impl Contract { ) .as_bytes(), ); + amount.into() } - }; + } } } diff --git a/ref-farming/src/utils.rs b/ref-farming/src/utils.rs index 252fdf6..2a20adc 100644 --- a/ref-farming/src/utils.rs +++ b/ref-farming/src/utils.rs @@ -11,8 +11,10 @@ pub const MIN_SEED_DEPOSIT: u128 = 1_000_000_000_000_000_000; pub const MAX_ACCOUNT_LENGTH: u128 = 64; /// Amount of gas for fungible token transfers. pub const GAS_FOR_FT_TRANSFER: Gas = 10_000_000_000_000; -/// hotfix_insuffient_gas_for_mft_resolve_transfer, increase from 5T to 20T -pub const GAS_FOR_RESOLVE_TRANSFER: Gas = 20_000_000_000_000; +/// Amount of gas for reward token transfers resolve. +pub const GAS_FOR_RESOLVE_TRANSFER: Gas = 10_000_000_000_000; +/// Amount of gas for seed token transfers resolve. +pub const GAS_FOR_RESOLVE_WITHDRAW_SEED: Gas = 80_000_000_000_000; pub const MFT_TAG: &str = "@"; diff --git a/ref-farming/src/view.rs b/ref-farming/src/view.rs index 2f8ae63..6421578 100644 --- a/ref-farming/src/view.rs +++ b/ref-farming/src/view.rs @@ -29,6 +29,14 @@ pub struct Metadata { pub reward_count: U64, } +#[derive(Serialize, Deserialize, PartialEq)] +#[serde(crate = "near_sdk::serde")] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] +pub struct StorageState { + pub deposit: U128, + pub usage: U128, +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[serde(crate = "near_sdk::serde")] pub struct FarmInfo { @@ -287,4 +295,17 @@ impl Contract { String::from("0") } } + + /// Get farmer's storage deposit and needed in the account of current version + pub fn get_user_storage_state(&self, account_id: ValidAccountId) -> Option { + let (locked, deposited) = self.internal_farmer_storage(account_id.as_ref()); + if locked > 0 { + Some(StorageState { + deposit: U128(deposited), + usage: U128(locked), + }) + } else { + None + } + } } diff --git a/res/ref_exchange_131.wasm b/res/ref_exchange_131.wasm new file mode 100755 index 0000000..7acfa21 Binary files /dev/null and b/res/ref_exchange_131.wasm differ diff --git a/res/ref_exchange_release.wasm b/res/ref_exchange_release.wasm index ddbbb08..ff50566 100755 Binary files a/res/ref_exchange_release.wasm and b/res/ref_exchange_release.wasm differ diff --git a/res/ref_farming_release.wasm b/res/ref_farming_release.wasm index 00710e7..06045b3 100755 Binary files a/res/ref_farming_release.wasm and b/res/ref_farming_release.wasm differ