├── .gitignore ├── Cargo.toml ├── README.md ├── build.sh ├── out └── staking-contract.wasm ├── src ├── account.rs ├── core_impl.rs ├── enumeration.rs ├── internal.rs ├── lib.rs └── util.rs ├── tests └── simulation-tests │ └── main.rs └── token-test └── vbi-ft.wasm /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | /.vscode 4 | /neardev 5 | /.idea 6 | /.DS_Store -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "staking-contract" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [dependencies] 11 | near-sdk = "3.1.0" 12 | uint = { version = "0.9.3", default-features = false } 13 | 14 | 15 | [dev-dependencies] 16 | near-sdk-sim = "3.1.0" 17 | 18 | [profile.release] 19 | codegen-units = 1 20 | # Tell `rustc` to optimize for small code size. 21 | opt-level = "z" 22 | lto = true 23 | debug = false 24 | panic = "abort" 25 | # Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 26 | overflow-checks = true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Staking FT Contract 2 | 3 | ## Roadmap 4 | 5 | - [ ] 6 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release 5 | mkdir -p out 6 | cp target/wasm32-unknown-unknown/release/*.wasm out/staking-contract.wasm -------------------------------------------------------------------------------- /out/staking-contract.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearvndev/staking-contract-rs/76ec4de3f982f3359d0bf3999d3f5d6e6573b646/out/staking-contract.wasm -------------------------------------------------------------------------------- /src/account.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::Timestamp; 2 | 3 | use crate::*; 4 | 5 | 6 | #[derive(BorshDeserialize, BorshSerialize)] 7 | pub enum UpgradableAccount { 8 | Default(Account), 9 | Current(Account) 10 | } 11 | 12 | impl From for Account { 13 | fn from(account: UpgradableAccount) -> Self { 14 | match account { 15 | UpgradableAccount::Default(account) => account, 16 | UpgradableAccount::Current(account) => account 17 | } 18 | } 19 | } 20 | 21 | impl From for UpgradableAccount { 22 | fn from(account: Account) -> Self { 23 | UpgradableAccount::Current(account) 24 | } 25 | } 26 | 27 | #[derive(BorshDeserialize, BorshSerialize, PartialEq, Debug, Serialize, Deserialize)] 28 | #[serde(crate = "near_sdk::serde")] 29 | pub struct Account { 30 | pub stake_balance: Balance, 31 | pub pre_stake_balance: Balance, 32 | pub pre_reward: Balance, 33 | pub last_block_balance_change: BlockHeight, 34 | pub unstake_balance: Balance, 35 | pub unstake_start_timestamp: Timestamp, 36 | pub unstake_available_epoch_height: EpochHeight 37 | } 38 | 39 | #[derive(Serialize, Deserialize, Debug)] 40 | #[serde(crate = "near_sdk::serde")] 41 | pub struct AccountJson { 42 | pub account_id: AccountId, 43 | pub stake_balance: U128, 44 | pub unstake_balance: U128, 45 | pub reward: U128, 46 | pub can_withdraw: bool, 47 | pub start_unstake_timestamp: Timestamp, 48 | pub unstake_available_epoch: EpochHeight, 49 | pub current_epoch: EpochHeight 50 | } -------------------------------------------------------------------------------- /src/core_impl.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::Gas; 2 | 3 | use crate::*; 4 | 5 | pub const FT_TRANSFER_GAS: Gas = 10_000_000_000_000; 6 | pub const WITHDRAW_CALLBACK_GAS: Gas = 10_000_000_000_000; 7 | pub const HARVEST_CALLBACK_GAS: Gas = 10_000_000_000_000; 8 | 9 | pub trait FungibleTokenReceiver { 10 | fn ft_on_transfer(&mut self, sender_id: AccountId, amount: U128, msg: String) -> PromiseOrValue; 11 | } 12 | 13 | #[ext_contract(ext_ft_contract)] 14 | pub trait FungibleTokenCore { 15 | fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option); 16 | } 17 | 18 | #[ext_contract(ext_self)] 19 | pub trait ExtStakingContract { 20 | fn ft_transfer_callback(&mut self, amount: U128, account_id: AccountId); 21 | fn ft_withdraw_callback(&mut self, account_id: AccountId, old_account: Account); 22 | } 23 | 24 | #[near_bindgen] 25 | impl FungibleTokenReceiver for StakingContract { 26 | 27 | fn ft_on_transfer(&mut self, sender_id: AccountId, amount: U128, msg: String) -> PromiseOrValue { 28 | self.internal_deposit_and_stake(sender_id, amount.0); 29 | 30 | // return amount not used 31 | PromiseOrValue::Value(U128(0)) 32 | } 33 | } 34 | 35 | #[near_bindgen] 36 | impl StakingContract { 37 | 38 | #[payable] 39 | pub fn unstake(&mut self, amount: U128) { 40 | assert_one_yocto(); 41 | let account_id: AccountId = env::predecessor_account_id(); 42 | 43 | self.internal_unstake(account_id, amount.0); 44 | } 45 | 46 | #[payable] 47 | pub fn withdraw(&mut self) -> Promise { 48 | assert_one_yocto(); 49 | let account_id: AccountId = env::predecessor_account_id(); 50 | let old_account: Account = self.internal_withdraw(account_id.clone()); 51 | 52 | // handle transfer withdraw 53 | ext_ft_contract::ft_transfer( 54 | account_id.clone(), 55 | U128(old_account.unstake_balance), 56 | Some(String::from("Staking contract withdraw")), 57 | &self.ft_contract_id, 58 | DEPOSIT_ONE_YOCTOR, 59 | FT_TRANSFER_GAS 60 | ).then( 61 | ext_self::ft_withdraw_callback( 62 | account_id.clone(), 63 | old_account, 64 | &env::current_account_id(), 65 | NO_DEPOSIT, 66 | WITHDRAW_CALLBACK_GAS 67 | ) 68 | ) 69 | } 70 | 71 | #[payable] 72 | pub fn harvest(&mut self) -> Promise { 73 | assert_one_yocto(); 74 | let account_id: AccountId = env::predecessor_account_id(); 75 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 76 | let account: Account = Account::from(upgradable_account); 77 | 78 | let new_reward: Balance = self.internal_calculate_account_reward(&account); 79 | let current_reward: Balance = account.pre_reward + new_reward; 80 | assert!(current_reward > 0, "ERR_REWARD_EQUAL_ZERO"); 81 | 82 | // Cross contract call 83 | ext_ft_contract::ft_transfer( 84 | account_id.clone(), 85 | U128(current_reward), 86 | Some("Staking contract harvest".to_string()), 87 | &self.ft_contract_id, 88 | DEPOSIT_ONE_YOCTOR, 89 | FT_TRANSFER_GAS 90 | ).then( 91 | ext_self::ft_transfer_callback( 92 | U128(current_reward), 93 | account_id.clone(), 94 | &env::current_account_id(), 95 | NO_DEPOSIT, 96 | HARVEST_CALLBACK_GAS 97 | ) 98 | ) 99 | } 100 | 101 | #[private] 102 | pub fn ft_transfer_callback(&mut self, amount: U128, account_id: AccountId) -> U128 { 103 | assert_eq!(env::promise_results_count(), 1, "ERR_TOO_MANY_RESULTS"); 104 | match env::promise_result(0) { 105 | PromiseResult::NotReady => unreachable!(), 106 | PromiseResult::Successful(_value) => { 107 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 108 | let mut account: Account = Account::from(upgradable_account); 109 | 110 | // update account data 111 | account.pre_reward = 0; 112 | account.last_block_balance_change = env::block_index(); 113 | 114 | self.accounts.insert(&account_id, &UpgradableAccount::from(account)); 115 | self.total_paid_reward_balance += amount.0; 116 | 117 | amount 118 | }, 119 | PromiseResult::Failed => env::panic(b"ERR_CALL_FAILED"), 120 | } 121 | } 122 | 123 | #[private] 124 | pub fn ft_withdraw_callback(&mut self, account_id: AccountId, old_account: Account) -> U128 { 125 | assert_eq!(env::promise_results_count(), 1, "ERR_TOO_MANY_RESULTS"); 126 | match env::promise_result(0) { 127 | PromiseResult::NotReady => unreachable!(), 128 | PromiseResult::Successful(_value) => { 129 | U128(old_account.unstake_balance) 130 | }, 131 | PromiseResult::Failed => { 132 | // Handle rollback data 133 | self.accounts.insert(&account_id, &UpgradableAccount::from(old_account)); 134 | U128(0) 135 | }, 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /src/enumeration.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | #[derive(Serialize, Deserialize, Debug)] 4 | #[serde(crate = "near_sdk::serde")] 5 | pub struct PoolInfo { 6 | pub total_stake_balance: U128, 7 | pub total_reward: U128, 8 | pub total_stakers: U128, 9 | pub is_paused: bool 10 | } 11 | 12 | #[near_bindgen] 13 | impl StakingContract { 14 | /** 15 | * Get current reward by account_id 16 | */ 17 | pub fn get_account_reward(&self, account_id: AccountId) -> Balance { 18 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 19 | let account: Account = Account::from(upgradable_account); 20 | let new_reward = self.internal_calculate_account_reward(&account); 21 | 22 | account.pre_reward + new_reward 23 | } 24 | 25 | pub fn get_account_info(&self, account_id: AccountId) -> AccountJson { 26 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 27 | let account: Account = Account::from(upgradable_account); 28 | let new_reward = self.internal_calculate_account_reward(&account); 29 | 30 | AccountJson { 31 | account_id: account_id, 32 | stake_balance: U128(account.stake_balance), 33 | unstake_balance: U128(account.unstake_balance), 34 | reward: U128(account.pre_reward + new_reward), 35 | can_withdraw: account.unstake_available_epoch_height <= env::epoch_height(), 36 | start_unstake_timestamp: account.unstake_start_timestamp, 37 | unstake_available_epoch: account.unstake_available_epoch_height, 38 | current_epoch: env::epoch_height() 39 | } 40 | } 41 | 42 | pub fn get_pool_info(&self) -> PoolInfo { 43 | PoolInfo { 44 | total_stake_balance: U128(self.total_stake_balance), 45 | total_reward: U128(self.pre_reward + self.internal_calculate_global_reward()), 46 | total_stakers: U128(self.total_staker), 47 | is_paused: self.paused 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/internal.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | impl StakingContract { 4 | 5 | /** 6 | * User deposit FT token and stake 7 | * Handle use transfer token to staking contract 8 | * 1. validate data 9 | * 2. handle stake 10 | */ 11 | pub(crate) fn internal_deposit_and_stake(&mut self, account_id: AccountId, amount: Balance) { 12 | 13 | let upgradable_account: Option = self.accounts.get(&account_id); 14 | assert!(upgradable_account.is_some(), "ERR_NOT_FOUND_ACCOUNT"); 15 | assert!(!self.paused, "ERR_CONTRACT_PAUSED"); 16 | assert_eq!(self.ft_contract_id, env::predecessor_account_id(), "ERR_NOT_VALID_FT_CONTRACT"); 17 | 18 | // Check account exists 19 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 20 | let mut account = Account::from(upgradable_account); 21 | 22 | if account.stake_balance == 0 { 23 | self.total_staker += 1; 24 | } 25 | 26 | // if exist account, update balance and update pre data 27 | let new_reward: Balance = self.internal_calculate_account_reward(&account); 28 | 29 | // update account data 30 | account.pre_stake_balance = account.stake_balance; 31 | account.pre_reward += new_reward; 32 | account.stake_balance += amount; 33 | account.last_block_balance_change = env::block_index(); 34 | self.accounts.insert(&account_id, &UpgradableAccount::from(account)); 35 | 36 | 37 | // Update contract data 38 | let new_contract_reward: Balance = self.internal_calculate_global_reward(); 39 | self.total_stake_balance += amount; 40 | self.pre_reward += new_contract_reward; 41 | self.last_block_balance_change = env::block_index(); 42 | 43 | } 44 | 45 | pub(crate) fn internal_unstake(&mut self, account_id: AccountId, amount: Balance) { 46 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 47 | 48 | let mut account = Account::from(upgradable_account); 49 | 50 | assert!(amount <= account.stake_balance, "ERR_AMOUNT_MUST_LESS_THAN_BALANCE"); 51 | 52 | // if exist account, update balance and update pre data 53 | let new_reward: Balance = self.internal_calculate_account_reward(&account); 54 | 55 | // update account data 56 | account.pre_stake_balance = account.stake_balance; 57 | account.pre_reward += new_reward; 58 | account.stake_balance -= amount; 59 | account.last_block_balance_change = env::block_index(); 60 | account.unstake_available_epoch_height = env::epoch_height() + NUM_EPOCHS_TO_UNLOCK; 61 | account.unstake_balance += amount; 62 | account.unstake_start_timestamp = env::block_timestamp(); 63 | 64 | if account.stake_balance == 0 { 65 | self.total_staker -= 1; 66 | } 67 | 68 | // update new account data 69 | self.accounts.insert(&account_id, &UpgradableAccount::from(account)); 70 | 71 | // update contract data 72 | let new_contract_reward: Balance = self.internal_calculate_global_reward(); 73 | self.total_stake_balance -= amount; 74 | self.pre_reward += new_contract_reward; 75 | self.last_block_balance_change = env::block_index(); 76 | } 77 | 78 | pub(crate) fn internal_withdraw(&mut self, account_id: AccountId) -> Account { 79 | let upgradable_account: UpgradableAccount = self.accounts.get(&account_id).unwrap(); 80 | let account: Account = Account::from(upgradable_account); 81 | 82 | assert!(account.unstake_balance > 0, "ERR_UNSTAKE_BALANCE_IS_ZERO"); 83 | assert!(account.unstake_available_epoch_height <= env::epoch_height(), "ERR_DISABLE_WITHDRAW"); 84 | 85 | let new_account: Account = Account { 86 | pre_reward: account.pre_reward, 87 | stake_balance: account.stake_balance, 88 | pre_stake_balance: account.pre_stake_balance, 89 | last_block_balance_change: account.last_block_balance_change, 90 | unstake_balance: 0, 91 | unstake_start_timestamp: 0, 92 | unstake_available_epoch_height: 0 93 | }; 94 | 95 | self.accounts.insert(&account_id, &UpgradableAccount::from(new_account)); 96 | 97 | account 98 | } 99 | 100 | pub(crate) fn internal_calculate_account_reward(&self, account: &Account) -> Balance { 101 | let lasted_block = if self.paused { 102 | self.paused_in_block 103 | } else { 104 | env::block_index() 105 | }; 106 | let diff_block = lasted_block - account.last_block_balance_change; 107 | let reward: U256 = (U256::from(self.total_stake_balance) * U256::from(self.config.reward_numerator) * U256::from(diff_block)) / U256::from(self.config.reward_denumerator); 108 | reward.as_u128() 109 | } 110 | 111 | pub(crate) fn internal_calculate_global_reward(&self) -> Balance { 112 | let lasted_block = if self.paused { 113 | self.paused_in_block 114 | } else { 115 | env::block_index() 116 | }; 117 | let diff_block = lasted_block - self.last_block_balance_change; 118 | let reward: U256 = (U256::from(self.total_stake_balance) * U256::from(self.config.reward_numerator) * U256::from(diff_block)) / U256::from(self.config.reward_denumerator); 119 | reward.as_u128() 120 | } 121 | 122 | pub(crate) fn internal_create_account(&mut self, account: AccountId) { 123 | let new_account = Account { 124 | stake_balance: 0, 125 | pre_stake_balance: 0, 126 | pre_reward: 0, 127 | last_block_balance_change: env::block_index(), 128 | unstake_balance: 0, 129 | unstake_available_epoch_height: 0, 130 | unstake_start_timestamp: 0 131 | }; 132 | 133 | let upgrade_account = UpgradableAccount::from(new_account); 134 | 135 | self.accounts.insert(&account, &upgrade_account); 136 | } 137 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::collections::LookupMap; 2 | use near_sdk::{near_bindgen, AccountId, env, PanicOnDefault, Balance, EpochHeight, BlockHeight, BorshStorageKey, Promise, PromiseResult, PromiseOrValue, ext_contract}; 3 | use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; 4 | use near_sdk::serde::{Deserialize, Serialize}; 5 | use near_sdk::json_types::{U128}; 6 | use uint::construct_uint; 7 | 8 | construct_uint! { 9 | /// 256-bit unsigned integer. 10 | pub struct U256(4); 11 | } 12 | 13 | use crate::account::*; 14 | pub use crate::enumeration::PoolInfo; 15 | pub use crate::account::AccountJson; 16 | use crate::util::*; 17 | 18 | mod account; 19 | mod util; 20 | mod internal; 21 | mod core_impl; 22 | mod enumeration; 23 | 24 | pub const NO_DEPOSIT: Balance = 0; 25 | pub const DEPOSIT_ONE_YOCTOR: Balance = 1; 26 | pub const NUM_EPOCHS_TO_UNLOCK: EpochHeight = 1; 27 | 28 | #[derive(BorshDeserialize, BorshSerialize, Deserialize, Serialize, Clone, Copy)] 29 | #[serde(crate = "near_sdk::serde")] 30 | pub struct Config { 31 | // Percent reward per 1 block 32 | pub reward_numerator: u32, 33 | pub reward_denumerator: u64, 34 | pub total_apr: u32 35 | } 36 | 37 | impl Default for Config { 38 | fn default() -> Self { 39 | // By default APR 15% 40 | Self { reward_numerator: 715, reward_denumerator: 100000000000, total_apr: 15 } 41 | } 42 | } 43 | 44 | #[derive(BorshDeserialize, BorshSerialize, BorshStorageKey)] 45 | pub enum StorageKey { 46 | AccountKey 47 | } 48 | 49 | #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] 50 | #[near_bindgen] 51 | pub struct StakingContract { 52 | pub owner_id: AccountId, // Owner of contract 53 | pub ft_contract_id: AccountId, 54 | pub config: Config, // Config reward and apr for contract 55 | pub total_stake_balance: Balance, // Total token balance lock in contract 56 | pub total_paid_reward_balance: Balance, 57 | pub total_staker: Balance, 58 | pub pre_reward: Balance, // Pre reward before change total balance 59 | pub last_block_balance_change: BlockHeight, 60 | pub accounts: LookupMap, // List staking user 61 | pub paused: bool, // Pause staking pool with limit reward, 62 | pub paused_in_block: BlockHeight 63 | } 64 | 65 | #[near_bindgen] 66 | impl StakingContract { 67 | 68 | #[init] 69 | pub fn new_default_config(owner_id: AccountId, ft_contract_id: AccountId) -> Self { 70 | Self::new(owner_id, ft_contract_id, Config::default()) 71 | } 72 | 73 | #[init] 74 | pub fn new(owner_id: AccountId, ft_contract_id: AccountId, config: Config) -> Self { 75 | StakingContract { 76 | owner_id, 77 | ft_contract_id, 78 | config, 79 | total_stake_balance: 0, 80 | total_paid_reward_balance: 0, 81 | total_staker: 0, 82 | pre_reward: 0, 83 | last_block_balance_change: env::block_index(), 84 | accounts: LookupMap::new(StorageKey::AccountKey), 85 | paused: false, 86 | paused_in_block: 0 87 | } 88 | } 89 | 90 | pub fn get_total_pending_reward(&self) -> U128 { 91 | assert_eq!(self.owner_id, env::predecessor_account_id(), "ERR_ONLY_OWNER_CONTRACT"); 92 | U128(self.pre_reward + self.internal_calculate_global_reward()) 93 | } 94 | 95 | pub fn is_paused(&self) -> bool { 96 | self.paused 97 | } 98 | 99 | #[payable] 100 | pub fn storage_deposit(&mut self, account_id: Option) { 101 | assert_at_least_one_yocto(); 102 | let account = account_id.unwrap_or_else(|| env::predecessor_account_id()); 103 | 104 | let account_stake: Option = self.accounts.get(&account); 105 | if account_stake.is_some() { 106 | refund_deposit(0); 107 | } else { 108 | let before_storage_usage = env::storage_usage(); 109 | self.internal_create_account(account.clone()); 110 | let after_storage_usage = env::storage_usage(); 111 | 112 | refund_deposit(after_storage_usage - before_storage_usage); 113 | } 114 | } 115 | 116 | // View func get storage balance, return 0 if account need deposit to interact 117 | pub fn storage_balance_of(&self, account_id: AccountId) -> U128 { 118 | let account: Option = self.accounts.get(&account_id); 119 | if account.is_some() { 120 | U128(1) 121 | } else { 122 | U128(0) 123 | } 124 | } 125 | 126 | pub(crate) fn assert_owner(&self) { 127 | assert_eq!(env::predecessor_account_id(), self.owner_id, "Only owner contract can be access"); 128 | } 129 | 130 | #[init(ignore_state)] 131 | #[private] 132 | pub fn migrate() -> Self { 133 | let contract: StakingContract = env::state_read().expect("ERR_READ_CONTRACT_STATE"); 134 | contract 135 | } 136 | } 137 | 138 | #[cfg(all(test, not(target_arch = "wasm32")))] 139 | mod tests { 140 | use super::*; 141 | use near_sdk::json_types::ValidAccountId; 142 | use near_sdk::test_utils::{VMContextBuilder, accounts}; 143 | use near_sdk::{testing_env, MockedBlockchain}; 144 | 145 | fn get_context(is_view: bool) -> VMContextBuilder { 146 | let mut builder = VMContextBuilder::new(); 147 | builder. 148 | current_account_id(accounts(0)) 149 | .signer_account_id(accounts(0)) 150 | .predecessor_account_id(accounts(0)) 151 | .is_view(is_view); 152 | 153 | builder 154 | } 155 | 156 | #[test] 157 | fn init_default_contract_test() { 158 | let context = get_context(false); 159 | testing_env!(context.build()); 160 | 161 | let contract: StakingContract = StakingContract::new_default_config(accounts(1).to_string(), "ft_contract".to_string()); 162 | 163 | assert_eq!(contract.owner_id, accounts(1).to_string(), "Contract owner should be equal {}", accounts(1).to_string()); 164 | assert_eq!(contract.ft_contract_id, "ft_contract".to_string(), "FT contract id should be init data"); 165 | assert_eq!(contract.config.reward_numerator, Config::default().reward_numerator, "Config must be equal default"); 166 | assert_eq!(contract.paused, false); 167 | } 168 | 169 | #[test] 170 | fn init_contract_test() { 171 | let context = get_context(false); 172 | testing_env!(context.build()); 173 | 174 | let contract: StakingContract = StakingContract::new(accounts(1).to_string(), "ft_contract".to_string(), Config { 175 | reward_numerator: 1500, 176 | reward_denumerator: 10000000, 177 | total_apr: 15 178 | }); 179 | 180 | assert_eq!(contract.owner_id, accounts(1).to_string(), "Contract owner should be equal {}", accounts(1).to_string()); 181 | assert_eq!(contract.ft_contract_id, "ft_contract".to_string(), "FT contract id should be init data"); 182 | assert_eq!(contract.config.reward_numerator, 1500, "Config must be equal default"); 183 | assert_eq!(contract.config.reward_denumerator, 10000000); 184 | assert_eq!(contract.paused, false); 185 | } 186 | 187 | #[test] 188 | fn deposit_and_stake_test() { 189 | let mut context = get_context(false); 190 | context.block_index(0); 191 | testing_env!(context.build()); 192 | 193 | let mut contract: StakingContract = StakingContract::new_default_config(accounts(1).to_string(), accounts(1).to_string()); 194 | contract.internal_create_account(env::predecessor_account_id()); 195 | 196 | 197 | // Deposit and stake function call from FT contract 198 | context.predecessor_account_id(accounts(1)); 199 | testing_env!(context.build()); 200 | contract.internal_deposit_and_stake(accounts(0).to_string(), 10_000_000_000_000); 201 | 202 | context.block_index(10); 203 | context.predecessor_account_id(accounts(0)); 204 | testing_env!(context.build()); 205 | 206 | // Test deposit balance and 207 | let upgradable_account = contract.accounts.get(&accounts(0).to_string()).unwrap(); 208 | let account: Account = Account::from(upgradable_account); 209 | 210 | assert_eq!(account.stake_balance, 10_000_000_000_000); 211 | assert_eq!(account.pre_reward, 0); 212 | assert_eq!(account.pre_stake_balance, 0); 213 | assert!(contract.internal_calculate_account_reward(&account) > 0); 214 | 215 | // test contract balance 216 | assert_eq!(contract.total_stake_balance, account.stake_balance); 217 | assert_eq!(contract.total_staker, 1); 218 | assert_eq!(contract.pre_reward, 0); 219 | assert_eq!(contract.last_block_balance_change, 0); 220 | 221 | 222 | // Test update stake balance of account 223 | // Deposit and stake function call from FT contract 224 | context.predecessor_account_id(accounts(1)); 225 | testing_env!(context.build()); 226 | contract.internal_deposit_and_stake(accounts(0).to_string(), 20_000_000_000_000); 227 | 228 | 229 | context.block_index(20); 230 | context.predecessor_account_id(accounts(0)); 231 | testing_env!(context.build()); 232 | 233 | // Test deposit balance and 234 | let upgradable_account_2 = contract.accounts.get(&accounts(0).to_string()).unwrap(); 235 | let account_update: Account = Account::from(upgradable_account_2); 236 | 237 | assert_eq!(account_update.stake_balance, 30_000_000_000_000); 238 | assert!(account_update.pre_reward > 0); 239 | assert_eq!(account_update.pre_stake_balance, 10_000_000_000_000); 240 | assert_eq!(account_update.last_block_balance_change, 10); 241 | assert!(contract.internal_calculate_account_reward(&account_update) > 0); 242 | 243 | // test contract balance 244 | assert_eq!(contract.total_stake_balance, account_update.stake_balance); 245 | assert_eq!(contract.total_staker, 1); 246 | assert!(contract.pre_reward > 0); 247 | assert_eq!(contract.last_block_balance_change, 10); 248 | } 249 | 250 | #[test] 251 | fn unstake_test() { 252 | let mut context = get_context(false); 253 | context.block_index(0); 254 | testing_env!(context.build()); 255 | 256 | let mut contract: StakingContract = StakingContract::new_default_config(accounts(1).to_string(), accounts(1).to_string()); 257 | contract.internal_create_account(env::predecessor_account_id()); 258 | 259 | 260 | // Deposit and stake function call from FT contract 261 | context.predecessor_account_id(accounts(1)); 262 | testing_env!(context.build()); 263 | contract.internal_deposit_and_stake(accounts(0).to_string(), 30_000_000_000_000); 264 | 265 | context.block_index(10); 266 | context.epoch_height(10); 267 | context.predecessor_account_id(accounts(0)); 268 | testing_env!(context.build()); 269 | 270 | contract.internal_unstake(accounts(0).to_string(), 10_000_000_000_000); 271 | 272 | // Test deposit balance and 273 | let upgradable_account = contract.accounts.get(&accounts(0).to_string()).unwrap(); 274 | let account: Account = Account::from(upgradable_account); 275 | 276 | assert_eq!(account.stake_balance, 20_000_000_000_000); 277 | assert_eq!(account.unstake_balance, 10_000_000_000_000); 278 | assert_eq!(account.last_block_balance_change, 10); 279 | assert_eq!(account.unstake_available_epoch_height, 11); 280 | } 281 | 282 | #[test] 283 | fn withdraw_test() { 284 | 285 | } 286 | } -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | pub(crate) fn assert_one_yocto() { 4 | assert_eq!(env::attached_deposit(), 1, 5 | "Require attached deposit of exactly 1 yoctoNear"); 6 | } 7 | 8 | pub(crate) fn assert_at_least_one_yocto() { 9 | assert!(env::attached_deposit() >= 1, 10 | "Require attached deposit of at least 1 yoctoNear") 11 | } 12 | 13 | pub(crate) fn refund_deposit(storage_used: u64) { 14 | let required_cost = env::storage_byte_cost() * Balance::from(storage_used); 15 | let attached_deposit = env::attached_deposit(); 16 | 17 | assert!( 18 | required_cost <= attached_deposit, 19 | "Must attach {} yoctoNear to cover storage", required_cost 20 | ); 21 | 22 | let refund = attached_deposit - required_cost; 23 | 24 | if refund > 1 { 25 | Promise::new(env::predecessor_account_id()).transfer(refund); 26 | } 27 | } -------------------------------------------------------------------------------- /tests/simulation-tests/main.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::{serde_json::json, json_types::U128}; 2 | use near_sdk_sim::{init_simulator, UserAccount, DEFAULT_GAS, STORAGE_AMOUNT, to_yocto}; 3 | use near_sdk_sim::transaction::ExecutionStatus; 4 | use staking_contract::AccountJson; 5 | 6 | near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { 7 | FT_CONTRACT_WASM_FILE => "token-test/vbi-ft.wasm", 8 | STAKING_CONTRACT_WASM_FILE => "out/staking-contract.wasm" 9 | } 10 | 11 | const FT_CONTRACT_ID: &str = "ft_contract"; 12 | const FT_TOTAL_SUPPY: &str = "100000000000000000000000000000"; 13 | const FT_STAKING_CONTRACT_BALANCE: &str = "50000000000000000000000000000"; 14 | const STAKING_CONTRACT_ID: &str = "staking_contract"; 15 | const ALICE_DEPOSIT_BALANCE: &str = "10000000000000000000000000000"; 16 | 17 | pub fn init() -> (UserAccount, UserAccount, UserAccount, UserAccount) { 18 | let root = init_simulator(None); 19 | 20 | let alice = root.create_user("alice".to_string(), to_yocto("100")); 21 | 22 | // Deploy and init 1M Token 23 | let ft_contract = root.deploy_and_init( 24 | &FT_CONTRACT_WASM_FILE, 25 | FT_CONTRACT_ID.to_string(), 26 | "new_default_meta", 27 | &json!({ 28 | "owner_id": alice.account_id(), 29 | "total_supply": FT_TOTAL_SUPPY 30 | }).to_string().as_bytes(), 31 | STORAGE_AMOUNT, 32 | DEFAULT_GAS 33 | ); 34 | 35 | // Deploy and init staking contract 36 | let staking_contract = root.deploy_and_init( 37 | &STAKING_CONTRACT_WASM_FILE, 38 | STAKING_CONTRACT_ID.to_string(), 39 | "new_default_config", 40 | &json!({ 41 | "owner_id": alice.account_id(), 42 | "ft_contract_id": ft_contract.account_id() 43 | }).to_string().as_bytes(), 44 | STORAGE_AMOUNT, 45 | DEFAULT_GAS 46 | ); 47 | 48 | // storage deposit 49 | root.call( 50 | ft_contract.account_id(), 51 | "storage_deposit", 52 | &json!({ 53 | "account_id": staking_contract.account_id() 54 | }).to_string().as_bytes(), 55 | DEFAULT_GAS, 56 | to_yocto("0.01") 57 | ); 58 | 59 | // Transfer 50% total supply to staking contract 60 | alice.call( 61 | ft_contract.account_id(), 62 | "ft_transfer", 63 | &json!({ 64 | "receiver_id": staking_contract.account_id(), 65 | "amount": FT_STAKING_CONTRACT_BALANCE 66 | }).to_string().as_bytes(), 67 | DEFAULT_GAS, 68 | 1 69 | ); 70 | 71 | (root, ft_contract, staking_contract, alice) 72 | } 73 | 74 | 75 | #[test] 76 | fn init_contract_test() { 77 | let (root, ft_contract, staking_contract, alice) = init(); 78 | 79 | // test deploy ft_contract 80 | let total_suppy: String = root.view( 81 | ft_contract.account_id(), 82 | "ft_total_supply", 83 | &json!({}).to_string().as_bytes() 84 | ).unwrap_json(); 85 | 86 | println!("Total supply: {}", total_suppy); 87 | assert_eq!(FT_TOTAL_SUPPY, total_suppy, "Total supply must equal {}", FT_TOTAL_SUPPY); 88 | 89 | // test alice balance 90 | let alice_balance: String = root.view( 91 | ft_contract.account_id(), 92 | "ft_balance_of", 93 | &json!({ 94 | "account_id": alice.account_id() 95 | }).to_string().as_bytes() 96 | ).unwrap_json(); 97 | 98 | println!("Alice balance: {}", alice_balance); 99 | assert_eq!(FT_STAKING_CONTRACT_BALANCE, alice_balance, "Alice balance must equal {}", FT_STAKING_CONTRACT_BALANCE); 100 | 101 | // test staking contract balance 102 | let staking_balance: String = root.view( 103 | ft_contract.account_id(), 104 | "ft_balance_of", 105 | &json!({ 106 | "account_id": staking_contract.account_id() 107 | }).to_string().as_bytes() 108 | ).unwrap_json(); 109 | 110 | println!("Staking contract balance: {}", staking_balance); 111 | assert_eq!(FT_STAKING_CONTRACT_BALANCE, staking_balance, "Staking contract balance must equal {}", FT_STAKING_CONTRACT_BALANCE); 112 | } 113 | 114 | #[test] 115 | fn deposit_and_stake_test() { 116 | let (root, ft_contract, staking_contract, alice) = init(); 117 | 118 | // staking contract storage deposit 119 | alice.call( 120 | staking_contract.account_id(), 121 | "storage_deposit", 122 | &json!({}).to_string().as_bytes(), 123 | DEFAULT_GAS, 124 | to_yocto("0.01") 125 | ); 126 | 127 | alice.call( 128 | ft_contract.account_id(), 129 | "ft_transfer_call", 130 | &json!({ 131 | "receiver_id": staking_contract.account_id(), 132 | "amount": ALICE_DEPOSIT_BALANCE, 133 | "msg": "" 134 | }).to_string().as_bytes(), 135 | DEFAULT_GAS, 136 | 1 137 | ); 138 | 139 | let account_json: AccountJson = root.view( 140 | staking_contract.account_id(), 141 | "get_account_info", 142 | &json!({ 143 | "account_id": alice.account_id() 144 | }).to_string().as_bytes() 145 | ).unwrap_json(); 146 | 147 | assert_eq!(account_json.account_id, alice.account_id()); 148 | assert_eq!(account_json.stake_balance, U128(10000000000000000000000000000)); 149 | assert!(account_json.reward.0 > 0); 150 | assert_eq!(account_json.unstake_balance.0, 0); 151 | } 152 | 153 | #[test] 154 | fn deposit_and_stake_error_storage_test() { 155 | let (root, ft_contract, staking_contract, alice) = init(); 156 | 157 | 158 | // Deposit without storage deposit 159 | let outcome = alice.call( 160 | ft_contract.account_id(), 161 | "ft_transfer_call", 162 | &json!({ 163 | "receiver_id": staking_contract.account_id(), 164 | "amount": ALICE_DEPOSIT_BALANCE, 165 | "msg": "" 166 | }).to_string().as_bytes(), 167 | DEFAULT_GAS, 168 | 1 169 | ); 170 | 171 | // Have one error 172 | assert_eq!(outcome.promise_errors().len(), 1); 173 | 174 | // assert error type 175 | if let ExecutionStatus::Failure(error) = &outcome.promise_errors().remove(0).unwrap().outcome().status { 176 | println!("Error: {}", error.to_string()); 177 | assert!(error.to_string().contains("ERR_NOT_FOUND_ACCOUNT")); 178 | } else { 179 | unreachable!(); 180 | } 181 | 182 | } -------------------------------------------------------------------------------- /token-test/vbi-ft.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearvndev/staking-contract-rs/76ec4de3f982f3359d0bf3999d3f5d6e6573b646/token-test/vbi-ft.wasm --------------------------------------------------------------------------------