├── res ├── whitelist.wasm ├── test_token.wasm ├── lockup_contract.wasm └── staking_factory_release.wasm ├── .gitignore ├── mock-receiver ├── build.sh ├── Cargo.toml └── src │ └── lib.rs ├── staking-farm ├── build_local.sh ├── Cargo.toml ├── build_docker.sh ├── src │ ├── account.rs │ ├── token_receiver.rs │ ├── test_utils.rs │ ├── views.rs │ ├── stake.rs │ ├── owner.rs │ ├── farm.rs │ └── internal.rs ├── tests │ ├── ft_transfer_call_receiver_out_of_gas.rs │ └── workspaces.rs └── Cargo.lock ├── staking-factory ├── build_local.sh ├── Cargo.toml ├── build_docker.sh ├── tests │ └── spec.rs └── src │ └── lib.rs ├── whitelist ├── Cargo.toml └── src │ └── lib.rs ├── test-token ├── Cargo.toml └── src │ └── lib.rs ├── Cargo.toml ├── README.md └── HowTo.md /res/whitelist.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/referencedev/staking-farm/HEAD/res/whitelist.wasm -------------------------------------------------------------------------------- /res/test_token.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/referencedev/staking-farm/HEAD/res/test_token.wasm -------------------------------------------------------------------------------- /res/lockup_contract.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/referencedev/staking-farm/HEAD/res/lockup_contract.wasm -------------------------------------------------------------------------------- /res/staking_factory_release.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/referencedev/staking-farm/HEAD/res/staking_factory_release.wasm -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea 3 | .vscode 4 | res/staking_farm_local.wasm 5 | res/staking_factory_local.wasm 6 | .DS_Store 7 | coverage/ 8 | -------------------------------------------------------------------------------- /mock-receiver/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 ../res 6 | cp target/wasm32-unknown-unknown/release/mock_receiver.wasm ../res/ 7 | -------------------------------------------------------------------------------- /staking-farm/build_local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | RUSTFLAGS='-C link-arg=-s' cargo +stable build --target wasm32-unknown-unknown --release 5 | cd .. 6 | cp target/wasm32-unknown-unknown/release/staking_farm.wasm ./res/staking_farm_local.wasm 7 | -------------------------------------------------------------------------------- /staking-factory/build_local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | RUSTFLAGS='-C link-arg=-s' cargo +stable build --target wasm32-unknown-unknown --release 5 | cd .. 6 | cp target/wasm32-unknown-unknown/release/staking_factory.wasm ./res/staking_factory_local.wasm 7 | -------------------------------------------------------------------------------- /whitelist/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "whitelist" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | near-sdk = "5.17" 11 | borsh = "1.0" 12 | 13 | [profile.release] 14 | codegen-units = 1 15 | opt-level = "z" 16 | lto = true 17 | debug = false 18 | panic = "abort" 19 | overflow-checks = true 20 | -------------------------------------------------------------------------------- /mock-receiver/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mock-receiver" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | near-sdk = "5.17" 11 | borsh = "1.0" 12 | 13 | [profile.release] 14 | codegen-units = 1 15 | opt-level = "z" 16 | lto = true 17 | debug = false 18 | panic = "abort" 19 | overflow-checks = true 20 | -------------------------------------------------------------------------------- /test-token/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test-token" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | near-sdk = "5.17" 11 | near-contract-standards = "5.17" 12 | borsh = "1.0" 13 | 14 | [profile.release] 15 | codegen-units = 1 16 | opt-level = "z" 17 | lto = true 18 | debug = false 19 | panic = "abort" 20 | overflow-checks = true 21 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "3" 3 | members = [ 4 | "./staking-farm", 5 | "./staking-factory", 6 | "./mock-receiver", 7 | "./whitelist", 8 | "./test-token", 9 | ] 10 | 11 | [profile.release] 12 | codegen-units = 1 13 | # s = optimize for binary size ("z" would additionally turn off loop vectorization) 14 | opt-level = "z" 15 | # link time optimization 16 | lto = true 17 | debug = false 18 | panic = "abort" 19 | overflow-checks = true 20 | -------------------------------------------------------------------------------- /staking-factory/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "staking-factory" 3 | version = "1.0.0" 4 | authors = ["referencedev "] 5 | edition = "2018" 6 | publish = false 7 | 8 | [lib] 9 | crate-type = ["cdylib", "rlib"] 10 | 11 | [dependencies] 12 | uint = { version = "0.9.0", default-features = false } 13 | near-sdk = { version = "4.0.0-pre.4", features = ["unstable"] } 14 | near-contract-standards = "4.0.0-pre.4" 15 | 16 | [dev-dependencies] 17 | lazy_static = "1.4.0" 18 | quickcheck = "0.9" 19 | quickcheck_macros = "0.9" 20 | log = "0.4" 21 | env_logger = { version = "0.7.1", default-features = false } 22 | near-sdk-sim = "4.0.0-pre.4" 23 | -------------------------------------------------------------------------------- /staking-farm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "staking-farm" 3 | version = "1.2.0" 4 | authors = ["referencedev "] 5 | edition = "2024" 6 | publish = false 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | uint = { version = "0.10.0", default-features = false } 13 | schemars = "0.8" 14 | near-sdk = { version = "5.17", features = ["unit-testing", "unstable"] } 15 | near-contract-standards = "5.17" 16 | 17 | [dev-dependencies] 18 | lazy_static = "1.4.0" 19 | quickcheck = "0.9" 20 | quickcheck_macros = "0.9" 21 | log = "0.4" 22 | env_logger = { version = "0.7.1", default-features = false } 23 | near-workspaces = { version = "0.10", default-features = false } 24 | tokio = { version = "1.28", features = ["full"] } 25 | serde_json = "1.0" 26 | anyhow = "1.0" 27 | -------------------------------------------------------------------------------- /staking-farm/build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit script as soon as a command fails. 4 | set -e 5 | 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 7 | 8 | NAME="build_staking_farm" 9 | 10 | if docker ps -a --format '{{.Names}}' | grep -Eq "^${NAME}\$"; then 11 | echo "Container exists" 12 | else 13 | docker create \ 14 | --mount type=bind,source=$DIR/..,target=/host \ 15 | --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ 16 | --name=$NAME \ 17 | -w /host/staking-farm \ 18 | -e RUSTFLAGS='-C link-arg=-s' \ 19 | -it \ 20 | nearprotocol/contract-builder \ 21 | /bin/bash 22 | fi 23 | 24 | docker start $NAME 25 | docker exec -it $NAME /bin/bash -c "rustup toolchain install 1.56.0; rustup default 1.56.0; rustup target add wasm32-unknown-unknown; cargo build --target wasm32-unknown-unknown --release" 26 | 27 | mkdir -p res 28 | cp $DIR/../target/wasm32-unknown-unknown/release/staking_farm.wasm $DIR/../res/staking_farm_release.wasm 29 | -------------------------------------------------------------------------------- /staking-factory/build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit script as soon as a command fails. 4 | set -e 5 | 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 7 | 8 | NAME="build_staking_factory" 9 | 10 | if docker ps -a --format '{{.Names}}' | grep -Eq "^${NAME}\$"; then 11 | echo "Container exists" 12 | else 13 | docker create \ 14 | --mount type=bind,source=$DIR/..,target=/host \ 15 | --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ 16 | --name=$NAME \ 17 | -w /host/staking-factory \ 18 | -e RUSTFLAGS='-C link-arg=-s' \ 19 | -it \ 20 | nearprotocol/contract-builder \ 21 | /bin/bash 22 | fi 23 | 24 | docker start $NAME 25 | docker exec -it $NAME /bin/bash -c "rustup toolchain install 1.56.0; rustup default 1.56.0; rustup target add wasm32-unknown-unknown; cargo build --target wasm32-unknown-unknown --release" 26 | 27 | mkdir -p res 28 | cp $DIR/../target/wasm32-unknown-unknown/release/staking_factory.wasm $DIR/../res/staking_factory_release.wasm 29 | 30 | -------------------------------------------------------------------------------- /whitelist/src/lib.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; 2 | use near_sdk::store::IterableSet; 3 | use near_sdk::{env, near_bindgen, AccountId, PanicOnDefault}; 4 | 5 | #[near_bindgen] 6 | #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] 7 | pub struct Whitelist { 8 | owner: AccountId, 9 | pools: IterableSet, 10 | } 11 | 12 | #[near_bindgen] 13 | impl Whitelist { 14 | #[init] 15 | pub fn new(foundation_account_id: AccountId) -> Self { 16 | assert!(!env::state_exists(), "Already initialized"); 17 | Self { 18 | owner: foundation_account_id, 19 | pools: IterableSet::new(b"p".to_vec()), 20 | } 21 | } 22 | 23 | pub fn add_staking_pool(&mut self, staking_pool_account_id: AccountId) { 24 | // Only owner can add 25 | assert_eq!(env::predecessor_account_id(), self.owner, "Not owner"); 26 | self.pools.insert(staking_pool_account_id); 27 | } 28 | 29 | pub fn is_whitelisted(&self, account_id: AccountId) -> bool { 30 | self.pools.contains(&account_id) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /staking-farm/src/account.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use near_sdk::{AccountId, EpochHeight, near}; 4 | 5 | use crate::{Balance, U256}; 6 | 7 | /// A type to distinguish between a balance and "stake" shares for better readability. 8 | pub type NumStakeShares = Balance; 9 | 10 | /// Inner account data of a delegate. 11 | #[near(serializers=[borsh])] 12 | #[derive(Default, Debug, PartialEq)] 13 | pub struct Account { 14 | /// The unstaked balance. It represents the amount the account has on this contract that 15 | /// can either be staked or withdrawn. 16 | pub unstaked: Balance, 17 | /// The amount of "stake" shares. Every stake share corresponds to the amount of staked balance. 18 | /// NOTE: The number of shares should always be less or equal than the amount of staked balance. 19 | /// This means the price of stake share should always be at least `1`. 20 | /// The price of stake share can be computed as `total_staked_balance` / `total_stake_shares`. 21 | pub stake_shares: NumStakeShares, 22 | /// The minimum epoch height when the withdrawn is allowed. 23 | /// This changes after unstaking action, because the amount is still locked for 3 epochs. 24 | pub unstaked_available_epoch_height: EpochHeight, 25 | /// Last claimed reward for each active farm. 26 | pub last_farm_reward_per_share: HashMap, 27 | /// Farmed tokens withdrawn from the farm but not from the contract. 28 | pub amounts: HashMap, 29 | /// Is this a burn account. 30 | /// Note: It's not persisted in the state, but initialized during internal_get_account. 31 | #[borsh(skip)] 32 | pub is_burn_account: bool, 33 | } 34 | -------------------------------------------------------------------------------- /staking-farm/src/token_receiver.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::json_types::{U64, U128}; 2 | use near_sdk::{PromiseOrValue, env, serde_json}; 3 | 4 | use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; 5 | 6 | use crate::*; 7 | 8 | const ERR_MSG_REQUIRED_FIELD: &str = "ERR_MSG_REQUIRED_FIELD"; 9 | 10 | #[derive(Serialize, Deserialize)] 11 | #[serde(crate = "near_sdk::serde")] 12 | pub struct FarmingDetails { 13 | /// Name of the farm. 14 | pub name: Option, 15 | /// Start date of the farm. 16 | /// If the farm ID is given, the new start date can only be provided if the farm hasn't started. 17 | pub start_date: Option, 18 | /// End date of the farm. 19 | pub end_date: U64, 20 | /// Existing farm ID. 21 | pub farm_id: Option, 22 | } 23 | 24 | #[near_bindgen] 25 | impl FungibleTokenReceiver for StakingContract { 26 | /// Callback on receiving tokens by this contract. 27 | /// transfer reward token with specific msg indicate 28 | /// which farm to be deposited to. 29 | fn ft_on_transfer( 30 | &mut self, 31 | sender_id: AccountId, 32 | amount: U128, 33 | msg: String, 34 | ) -> PromiseOrValue { 35 | assert!( 36 | self.authorized_farm_tokens 37 | .contains(&env::predecessor_account_id()), 38 | "ERR_NOT_AUTHORIZED_TOKEN" 39 | ); 40 | assert!( 41 | sender_id == StakingContract::internal_get_owner_id() 42 | || self.authorized_users.contains(&sender_id), 43 | "ERR_NOT_AUTHORIZED_USER" 44 | ); 45 | let message = serde_json::from_str::(&msg).expect("ERR_MSG_WRONG_FORMAT"); 46 | if let Some(farm_id) = message.farm_id { 47 | self.internal_add_farm_tokens( 48 | &env::predecessor_account_id(), 49 | farm_id, 50 | amount.0, 51 | message.start_date.map(|start_date| start_date.0), 52 | message.end_date.0, 53 | ); 54 | } else { 55 | assert!( 56 | self.active_farms.len() < MAX_NUM_ACTIVE_FARMS, 57 | "ERR_TOO_MANY_ACTIVE_FARMS" 58 | ); 59 | self.internal_deposit_farm_tokens( 60 | &env::predecessor_account_id(), 61 | message.name.expect(ERR_MSG_REQUIRED_FIELD), 62 | amount.0, 63 | message.start_date.expect(ERR_MSG_REQUIRED_FIELD).0, 64 | message.end_date.0, 65 | ); 66 | } 67 | PromiseOrValue::Value(U128(0)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /mock-receiver/src/lib.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; 2 | use near_sdk::json_types::U128; 3 | use near_sdk::{env, near_bindgen, AccountId, Gas, PanicOnDefault, PromiseOrValue}; 4 | 5 | #[near_bindgen] 6 | #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] 7 | pub struct MockReceiver { 8 | /// Mode determines behavior: 9 | /// "accept_all" - return 0 (use all tokens) 10 | /// "refund_all" - return full amount (refund everything) 11 | /// "refund_half" - return half (partial refund) 12 | /// "burn_gas" - consume excessive gas then return 0 13 | /// "panic" - panic immediately 14 | pub mode: String, 15 | } 16 | 17 | #[near_bindgen] 18 | impl MockReceiver { 19 | #[init] 20 | pub fn new(mode: String) -> Self { 21 | Self { mode } 22 | } 23 | 24 | /// Standard NEP-141 receiver callback 25 | pub fn ft_on_transfer( 26 | &mut self, 27 | sender_id: AccountId, 28 | amount: U128, 29 | msg: String, 30 | ) -> PromiseOrValue { 31 | let _sender = sender_id; 32 | let _msg = msg; 33 | 34 | match self.mode.as_str() { 35 | "accept_all" => { 36 | // Accept all tokens (return 0 unused) 37 | PromiseOrValue::Value(U128(0)) 38 | } 39 | "refund_all" => { 40 | // Refund everything 41 | PromiseOrValue::Value(amount) 42 | } 43 | "refund_half" => { 44 | // Refund half 45 | PromiseOrValue::Value(U128(amount.0 / 2)) 46 | } 47 | "burn_gas" => { 48 | // Burn a lot of gas in a loop, then try to return 49 | // This simulates running out of gas during callback 50 | let start_gas = env::used_gas(); 51 | let mut counter = 0u64; 52 | 53 | // Burn gas until we've used a significant amount 54 | // This will cause the callback to fail with "out of gas" 55 | while env::used_gas().as_gas() < start_gas.as_gas() + Gas::from_tgas(80).as_gas() { 56 | counter = counter.wrapping_add(1); 57 | // Do some work to actually consume gas 58 | if counter % 1000 == 0 { 59 | env::log_str(&format!("counter: {}", counter)); 60 | } 61 | } 62 | 63 | // Try to return 0 (accept all) - but we'll likely run out of gas first 64 | PromiseOrValue::Value(U128(0)) 65 | } 66 | "panic" => { 67 | // Explicit panic 68 | panic!("MockReceiver explicit panic"); 69 | } 70 | _ => { 71 | // Default: accept all 72 | PromiseOrValue::Value(U128(0)) 73 | } 74 | } 75 | } 76 | 77 | /// Helper to change mode 78 | pub fn set_mode(&mut self, mode: String) { 79 | self.mode = mode; 80 | } 81 | 82 | /// Helper to get current mode 83 | pub fn get_mode(&self) -> String { 84 | self.mode.clone() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test-token/src/lib.rs: -------------------------------------------------------------------------------- 1 | use near_contract_standards::fungible_token::core::FungibleTokenCore; 2 | use near_contract_standards::fungible_token::events::FtMint; 3 | use near_contract_standards::fungible_token::metadata::{ 4 | FungibleTokenMetadata, FungibleTokenMetadataProvider, FT_METADATA_SPEC, 5 | }; 6 | use near_contract_standards::fungible_token::FungibleToken; 7 | use near_contract_standards::storage_management::{ 8 | StorageBalance, StorageBalanceBounds, StorageManagement, 9 | }; 10 | use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; 11 | use near_sdk::json_types::U128; 12 | use near_sdk::{env, near_bindgen, AccountId, PanicOnDefault, PromiseOrValue, NearToken, Gas, near, ext_contract}; 13 | 14 | #[near_bindgen] 15 | #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] 16 | pub struct TestToken { 17 | pub token: FungibleToken, 18 | pub metadata: FungibleTokenMetadata, 19 | } 20 | 21 | #[near_bindgen] 22 | impl TestToken { 23 | #[init] 24 | pub fn new() -> Self { 25 | assert!(!env::state_exists(), "Already initialized"); 26 | Self { 27 | token: FungibleToken::new(b"t".to_vec()), 28 | metadata: FungibleTokenMetadata { 29 | spec: FT_METADATA_SPEC.to_string(), 30 | name: "Test Token".to_string(), 31 | symbol: "TEST".to_string(), 32 | icon: None, 33 | reference: None, 34 | reference_hash: None, 35 | decimals: 24, 36 | }, 37 | } 38 | } 39 | 40 | pub fn mint(&mut self, account_id: AccountId, amount: U128) { 41 | self.assert_owner(); 42 | if self.token.storage_balance_of(account_id.clone()).is_none() { 43 | self.token.internal_register_account(&account_id); 44 | } 45 | self.token.internal_deposit(&account_id, amount.0); 46 | FtMint { owner_id: &account_id, amount, memo: None }.emit(); 47 | } 48 | 49 | fn assert_owner(&self) { 50 | assert_eq!(env::predecessor_account_id(), env::current_account_id(), "Only owner"); 51 | } 52 | } 53 | 54 | // Implement FungibleTokenCore manually to avoid macro attribute compatibility issues 55 | #[near_bindgen] 56 | impl FungibleTokenCore for TestToken { 57 | #[payable] 58 | fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option) { 59 | // require exactly 1 yocto for security (prevents access key with allowance) 60 | near_sdk::assert_one_yocto(); 61 | let sender_id = env::predecessor_account_id(); 62 | self.token 63 | .internal_transfer(&sender_id, &receiver_id, amount.0, memo.map(|m| m.into())); 64 | } 65 | 66 | #[payable] 67 | fn ft_transfer_call( 68 | &mut self, 69 | receiver_id: AccountId, 70 | amount: U128, 71 | memo: Option, 72 | msg: String, 73 | ) -> PromiseOrValue { 74 | near_sdk::assert_one_yocto(); 75 | let sender_id = env::predecessor_account_id(); 76 | self.token 77 | .internal_transfer(&sender_id, &receiver_id, amount.0, memo.map(|m| m.into())); 78 | 79 | // Call receiver's `ft_on_transfer` and return the promise directly. 80 | // Tests don't rely on refund path, so skipping explicit resolve is acceptable here. 81 | ext_ft_receiver::ext(receiver_id) 82 | .with_static_gas(Gas::from_tgas(100)) 83 | .ft_on_transfer(sender_id, amount, msg) 84 | .into() 85 | } 86 | 87 | fn ft_total_supply(&self) -> U128 { 88 | self.token.ft_total_supply() 89 | } 90 | 91 | fn ft_balance_of(&self, account_id: AccountId) -> U128 { 92 | self.token.ft_balance_of(account_id) 93 | } 94 | } 95 | 96 | #[ext_contract(ext_ft_receiver)] 97 | trait FtReceiver { 98 | fn ft_on_transfer(&mut self, sender_id: AccountId, amount: U128, msg: String) -> PromiseOrValue; 99 | } 100 | 101 | #[near_bindgen] 102 | impl StorageManagement for TestToken { 103 | #[payable] 104 | fn storage_deposit( 105 | &mut self, 106 | account_id: Option, 107 | registration_only: Option, 108 | ) -> StorageBalance { 109 | self.token.storage_deposit(account_id, registration_only) 110 | } 111 | 112 | #[payable] 113 | fn storage_withdraw(&mut self, amount: Option) -> StorageBalance { 114 | self.token.storage_withdraw(amount) 115 | } 116 | 117 | #[payable] 118 | fn storage_unregister(&mut self, force: Option) -> bool { 119 | self.token.storage_unregister(force) 120 | } 121 | 122 | fn storage_balance_bounds(&self) -> StorageBalanceBounds { 123 | self.token.storage_balance_bounds() 124 | } 125 | 126 | fn storage_balance_of(&self, account_id: AccountId) -> Option { 127 | self.token.storage_balance_of(account_id) 128 | } 129 | } 130 | 131 | #[near_bindgen] 132 | impl FungibleTokenMetadataProvider for TestToken { 133 | fn ft_metadata(&self) -> FungibleTokenMetadata { 134 | self.metadata.clone() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stake & earn 2 | 3 | Staking farm contract allows for validators to distribute other tokens to the delegators. 4 | 5 | This allows to attract more capital to the validator while ensuring more robust token distribution of the new tokens. 6 | 7 | ## Authorized users 8 | 9 | Because of storage and computational limitations, the contract can only store a fixed number of farms. 10 | To avoid farm spam, only authorized users can deposit tokens. 11 | Owner of the contract can manage authorized users or can deposit farms itself. 12 | 13 | ## Create new farm 14 | 15 | Use `ft_transfer_call` of the token to the staking farm by an authorized user to create new farm. 16 | 17 | Farm contains next fields: 18 | - name: String, 19 | - token_id: AccountId, 20 | - amount: Balance, 21 | - start_date: Timestamp, 22 | - end_date: Timestamp, 23 | - last_distribution: RewardDistribution, 24 | 25 | ## Upgradability 26 | 27 | Staking Farm contract supports upgradability from the specific factory contract. 28 | This is done to ensure that both contract owner and general community agree on the contract upgrade before it happens. 29 | 30 | The procedure for upgrades is as follows: 31 | - Staking contract has the `factory_id` specified. This `factory_id` should be governed by the users or Foundation that users trust. 32 | - Factory contract contains whitelisted set of contracts, addressed by hash. 33 | - Contract owner calls `upgrade(contract_hash)` method, which loads contract bytecode from `factory_id` and upgrade itself in-place. 34 | 35 | To avoid potential issues with state serialization failures due to upgrades, the owner information is stored outside of the STATE storage. 36 | This ensures that if new contracts has similar `upgrade` method that doesn't use state, even if contract got into wrong state after upgrade it is resolvable. 37 | 38 | ## Burning rewards 39 | 40 | The staking reward contract has a feature to burn part of the rewards. 41 | NEAR doesn't have currently a integrated burning logic, so instead a `ZERO_ADDRESS` is used. This is an implicit address of `0` and that doesn't have any access keys: https://explorer.mainnet.near.org/accounts/0000000000000000000000000000000000000000000000000000000000000000 42 | 43 | The burning itself is done in a 3 steps: 44 | - When epoch ends and `ping` is called, the amount of rewawrds allocated to burn will be transferred to `ZERO_ADDRESS` address via shares. This shares are still staked. 45 | - Anyone can call `unstake_burn`, which will unstake all the currently staked shares on `ZERO_ADDRESS`. 46 | - After 36 hours of unstaking, anyone can call `burn` to actually transfer funds to `ZERO_ADDRESS`. 47 | 48 | This is done because transferring immediately rewards to `ZERO_ADDRESS` is impossible as they are already staked when allocated. 49 | Anyone can call `unstake_burn` and `burn`, similarly how anyone can call `ping` on the staking pool to kick the calculations. 50 | 51 | TODO: the imporvement to this method, would be to unstake that amount direclty on `ping` and just let it be burnt via the subsequent `burn` call. 52 | 53 | ## Stake shares as Fungible Token (NEP-141) 54 | 55 | This contract exposes staked shares as a standard NEAR FT (NEP-141), so users can transfer their staking position to other accounts. 56 | 57 | - Token metadata (NEP-148): 58 | - name: defaults to the full account ID of this contract (e.g. `staking.pool.near`) 59 | - symbol: defaults to the prefix of the contract account ID before the first dot (e.g. `staking`) 60 | - decimals: 24 61 | 62 | The owner can update these values at any time without a state migration, since they’re stored outside of contract STATE: 63 | 64 | - set name: `set_ft_name(name: String)` 65 | - set symbol: `set_ft_symbol(symbol: String)` 66 | 67 | Notes: 68 | - Changing metadata doesn’t affect token balances or supply. 69 | - Defaults are used unless explicitly overwritten by the owner. 70 | 71 | ### Storage (NEP-145) 72 | 73 | Explicit storage registration is required when transferring to the new user who hasn't staked with given contract. 74 | 75 | The storage interface is provided for compatibility with wallets/dapps: 76 | 77 | - `storage_balance_bounds` returns amount required to cover Account storage 78 | - `storage_balance_of` returns either amount for registered storage or 0 79 | - `storage_deposit` records storage payment but doesn't create Account 80 | - `storage_withdraw` refunds storage if Account doesn't exist anymore 81 | 82 | ### Transfer shares 83 | 84 | Transfer a specific number of shares (requires 1 yoctoNEAR): 85 | 86 | ```bash 87 | near call ft_transfer '{"receiver_id": "", "amount": ""}' --accountId --amount 0.000000000000000000000001 88 | ``` 89 | 90 | Use `ft_transfer_call` to send shares to a contract that implements `ft_on_transfer`: 91 | 92 | ```bash 93 | near call ft_transfer_call '{"receiver_id": "", "amount": "", "msg": ""}' --accountId --amount 0.000000000000000000000001 --gas 30000000000000 94 | ``` 95 | 96 | The receiver can return a numeric string for the number of shares it wants to refund. Any unused shares will be returned to the sender via `ft_resolve_transfer`. 97 | 98 | ### Supply and balances 99 | 100 | - `ft_total_supply` equals all minted stake shares minus burned shares held by the implicit burn account. 101 | - `ft_balance_of(account_id)` returns the account’s current stake share balance. 102 | - Transfers to the burn account are blocked; burning continues to work via the existing burn flow. 103 | 104 | ### Notes 105 | 106 | - Moving shares moves the right to future staking rewards and farm distributions. Before moving shares, the contract distributes pending farm rewards to both sender and receiver at their current share balances to keep accounting correct. 107 | - You still use the staking methods (`deposit`, `stake`, `unstake`, `withdraw`) for NEAR; FT only represents the transferable share units. 108 | -------------------------------------------------------------------------------- /staking-farm/tests/ft_transfer_call_receiver_out_of_gas.rs: -------------------------------------------------------------------------------- 1 | use near_workspaces::types::{Gas, NearToken}; 2 | use near_workspaces::{Account, Contract, sandbox}; 3 | use serde_json::json; 4 | 5 | const STAKING_POOL_ACCOUNT_ID: &str = "pool"; 6 | const STAKING_KEY: &str = "KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7"; 7 | const STAKING_FARM_WASM: &str = "../target/near/staking_farm/staking_farm.wasm"; 8 | 9 | struct Ctx { 10 | owner: Account, 11 | pool: Contract, 12 | } 13 | 14 | async fn init_pool( 15 | pool_initial_balance: NearToken, 16 | reward_ratio: u32, 17 | burn_ratio: u32, 18 | ) -> anyhow::Result { 19 | let worker = sandbox().await?; 20 | let owner = worker.root_account()?; 21 | 22 | // Deploy staking pool 23 | let pool_wasm = std::fs::read(STAKING_FARM_WASM)?; 24 | let pool = owner 25 | .create_subaccount(STAKING_POOL_ACCOUNT_ID) 26 | .initial_balance(pool_initial_balance) 27 | .transact() 28 | .await? 29 | .into_result()?; 30 | let pool = pool.deploy(&pool_wasm).await?.into_result()?; 31 | 32 | let reward_ratio = json!({"numerator": reward_ratio, "denominator": 10}); 33 | let burn_ratio = json!({"numerator": burn_ratio, "denominator": 10}); 34 | pool.call("new") 35 | .args_json(json!({ 36 | "owner_id": owner.id(), 37 | "stake_public_key": STAKING_KEY, 38 | "reward_fee_fraction": reward_ratio, 39 | "burn_fee_fraction": burn_ratio 40 | })) 41 | .gas(Gas::from_tgas(300)) 42 | .transact() 43 | .await? 44 | .into_result()?; 45 | 46 | Ok(Ctx { owner, pool }) 47 | } 48 | 49 | async fn create_user_and_stake( 50 | ctx: &Ctx, 51 | name: &str, 52 | stake_amount: NearToken, 53 | ) -> anyhow::Result { 54 | let user = ctx 55 | .owner 56 | .create_subaccount(name) 57 | .initial_balance(NearToken::from_near(100_000)) 58 | .transact() 59 | .await? 60 | .into_result()?; 61 | 62 | user.call(ctx.pool.id(), "deposit_and_stake") 63 | .deposit(stake_amount) 64 | .gas(Gas::from_tgas(200)) 65 | .transact() 66 | .await? 67 | .into_result()?; 68 | 69 | Ok(user) 70 | } 71 | 72 | /// Test ft_transfer_call where the receiver contract runs out of gas in the callback, ensuring resolver logic is triggered. 73 | #[tokio::test] 74 | async fn test_ft_transfer_call_receiver_out_of_gas() -> anyhow::Result<()> { 75 | let ctx = init_pool(NearToken::from_near(10_000), 0, 0).await?; 76 | 77 | // Create user and stake 78 | let user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(5_000)).await?; 79 | 80 | // Deploy the mock receiver contract (burns all gas in ft_on_transfer) 81 | let mock_receiver_wasm = std::fs::read("../target/near/mock_receiver/mock_receiver.wasm")?; 82 | let mock_receiver = ctx 83 | .owner 84 | .create_subaccount("mockreceiver") 85 | .initial_balance(NearToken::from_near(10)) 86 | .transact() 87 | .await? 88 | .into_result()?; 89 | let mock_receiver = mock_receiver 90 | .deploy(&mock_receiver_wasm) 91 | .await? 92 | .into_result()?; 93 | // Initialize and set mode to burn_gas 94 | mock_receiver 95 | .call("new") 96 | .args_json(json!({"mode": "burn_gas"})) 97 | .transact() 98 | .await? 99 | .into_result()?; 100 | 101 | // Register mock receiver for FT shares 102 | let _ = ctx 103 | .owner 104 | .call(ctx.pool.id(), "storage_deposit") 105 | .args_json(json!({ "account_id": mock_receiver.id(), "registration_only": true })) 106 | .deposit(NearToken::from_near(1)) 107 | .gas(Gas::from_tgas(50)) 108 | .transact() 109 | .await?; 110 | 111 | // Query user1 shares 112 | let user1_shares: u128 = ctx 113 | .pool 114 | .view("ft_balance_of") 115 | .args_json(json!({ "account_id": user1.id() })) 116 | .await? 117 | .json::()? 118 | .as_str() 119 | .unwrap() 120 | .parse()?; 121 | let transfer_amount = user1_shares / 5; 122 | 123 | // Perform ft_transfer_call with enough gas for transfer, but not enough for callback to succeed 124 | let result = user1 125 | .call(ctx.pool.id(), "ft_transfer_call") 126 | .args_json(json!({ 127 | "receiver_id": mock_receiver.id(), 128 | "amount": transfer_amount.to_string(), 129 | "msg": "test burn gas" 130 | })) 131 | .deposit(NearToken::from_yoctonear(1)) 132 | .gas(Gas::from_tgas(100)) 133 | .transact() 134 | .await; 135 | 136 | // The transaction should succeed, but the callback will run out of gas; resolver should treat as used (no refund) 137 | match result { 138 | Ok(outcome) => { 139 | let _ = outcome.into_result()?; 140 | } 141 | Err(e) => panic!("Transaction should succeed, got error: {:?}", e), 142 | } 143 | 144 | // Validate balances 145 | let user1_after: u128 = ctx 146 | .pool 147 | .view("ft_balance_of") 148 | .args_json(json!({ "account_id": user1.id() })) 149 | .await? 150 | .json::()? 151 | .as_str() 152 | .unwrap() 153 | .parse()?; 154 | let receiver_after: u128 = ctx 155 | .pool 156 | .view("ft_balance_of") 157 | .args_json(json!({ "account_id": mock_receiver.id() })) 158 | .await? 159 | .json::()? 160 | .as_str() 161 | .unwrap() 162 | .parse()?; 163 | assert_eq!( 164 | user1_after, 165 | user1_shares - transfer_amount, 166 | "User1 shares should decrease" 167 | ); 168 | assert_eq!( 169 | receiver_after, transfer_amount, 170 | "Receiver should get all transferred shares" 171 | ); 172 | Ok(()) 173 | } 174 | -------------------------------------------------------------------------------- /staking-farm/src/test_utils.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::AccountId; 2 | 3 | use crate::Balance; 4 | 5 | pub fn staking() -> AccountId { 6 | "staking".parse().unwrap() 7 | } 8 | 9 | pub fn alice() -> AccountId { 10 | "alice".parse().unwrap() 11 | } 12 | pub fn bob() -> AccountId { 13 | "bob".parse().unwrap() 14 | } 15 | pub fn owner() -> AccountId { 16 | "owner".parse().unwrap() 17 | } 18 | pub fn charlie() -> AccountId { 19 | "charlie".parse().unwrap() 20 | } 21 | pub fn factory() -> AccountId { 22 | "factory".parse().unwrap() 23 | } 24 | 25 | pub fn ntoy(near_amount: Balance) -> Balance { 26 | near_amount * 10u128.pow(24) 27 | } 28 | 29 | /// Rounds to nearest 30 | pub fn yton(yocto_amount: Balance) -> Balance { 31 | (yocto_amount + (5 * 10u128.pow(23))) / 10u128.pow(24) 32 | } 33 | 34 | /// Checks that two amount are within epsilon 35 | pub fn almost_equal(left: Balance, right: Balance, epsilon: Balance) -> bool { 36 | println!("{} ~= {}", left, right); 37 | if left > right { 38 | (left - right) < epsilon 39 | } else { 40 | (right - left) < epsilon 41 | } 42 | } 43 | 44 | #[macro_export] 45 | macro_rules! assert_eq_in_near { 46 | ($a:expr, $b:expr) => { 47 | assert_eq!(yton($a), yton($b)) 48 | }; 49 | ($a:expr, $b:expr, $c:expr) => { 50 | assert_eq!(yton($a), yton($b), $c) 51 | }; 52 | } 53 | 54 | #[cfg(test)] 55 | pub mod tests { 56 | use near_sdk::test_utils::VMContextBuilder; 57 | use near_sdk::{NearToken, VMContext, testing_env}; 58 | 59 | use crate::*; 60 | 61 | use super::*; 62 | 63 | pub const ONE_EPOCH_TS: u64 = 12 * 60 * 60 * 1_000_000_000; 64 | 65 | pub struct Emulator { 66 | pub contract: StakingContract, 67 | pub epoch_height: EpochHeight, 68 | pub block_index: u64, 69 | pub block_timestamp: u64, 70 | pub amount: Balance, 71 | pub locked_amount: Balance, 72 | last_total_staked_balance: Balance, 73 | last_total_stake_shares: Balance, 74 | pub context: VMContext, 75 | } 76 | 77 | pub fn zero_fee() -> Ratio { 78 | Ratio { 79 | numerator: 0, 80 | denominator: 1, 81 | } 82 | } 83 | 84 | impl Emulator { 85 | pub fn new( 86 | owner: AccountId, 87 | stake_public_key: PublicKey, 88 | reward_fee_fraction: Ratio, 89 | ) -> Self { 90 | Self::new_with_fees( 91 | owner, 92 | stake_public_key, 93 | reward_fee_fraction, 94 | Ratio { 95 | numerator: 0, 96 | denominator: 1, 97 | }, 98 | ) 99 | } 100 | 101 | pub fn new_with_fees( 102 | owner: AccountId, 103 | stake_public_key: PublicKey, 104 | reward_fee_fraction: Ratio, 105 | burn_fee_fraction: Ratio, 106 | ) -> Self { 107 | let context = VMContextBuilder::new() 108 | .current_account_id(owner.clone()) 109 | .predecessor_account_id(factory()) 110 | .signer_account_id(owner.clone()) 111 | .account_balance(NearToken::from_yoctonear(ntoy(30))) 112 | .build(); 113 | testing_env!(context.clone()); 114 | let contract = StakingContract::new( 115 | owner, 116 | stake_public_key, 117 | reward_fee_fraction, 118 | burn_fee_fraction, 119 | ); 120 | let last_total_staked_balance = contract.total_staked_balance; 121 | let last_total_stake_shares = contract.total_stake_shares; 122 | Emulator { 123 | contract, 124 | epoch_height: 0, 125 | block_timestamp: 0, 126 | block_index: 0, 127 | amount: ntoy(30), 128 | locked_amount: 0, 129 | last_total_staked_balance, 130 | last_total_stake_shares, 131 | context, 132 | } 133 | } 134 | 135 | fn verify_stake_price_increase_guarantee(&mut self) { 136 | let total_staked_balance = self.contract.total_staked_balance; 137 | let total_stake_shares = self.contract.total_stake_shares; 138 | assert!( 139 | U256::from(total_staked_balance) * U256::from(self.last_total_stake_shares) 140 | >= U256::from(self.last_total_staked_balance) * U256::from(total_stake_shares), 141 | "Price increase guarantee was violated." 142 | ); 143 | self.last_total_staked_balance = total_staked_balance; 144 | self.last_total_stake_shares = total_stake_shares; 145 | } 146 | 147 | pub fn update_context(&mut self, predecessor_account_id: AccountId, deposit: Balance) { 148 | self.verify_stake_price_increase_guarantee(); 149 | self.context = VMContextBuilder::new() 150 | .current_account_id(staking()) 151 | .predecessor_account_id(predecessor_account_id.clone()) 152 | .signer_account_id(predecessor_account_id) 153 | .attached_deposit(NearToken::from_yoctonear(deposit)) 154 | .account_balance(NearToken::from_yoctonear(self.amount)) 155 | .account_locked_balance(NearToken::from_yoctonear(self.locked_amount)) 156 | .epoch_height(self.epoch_height) 157 | .block_height(self.block_index) 158 | .block_timestamp(self.block_timestamp) 159 | .build(); 160 | testing_env!(self.context.clone()); 161 | println!( 162 | "Epoch: {}, Deposit: {}, amount: {}, locked_amount: {}", 163 | self.epoch_height, deposit, self.amount, self.locked_amount 164 | ); 165 | } 166 | 167 | pub fn simulate_stake_call(&mut self) { 168 | let total_stake = self.contract.total_staked_balance; 169 | // Stake action 170 | self.amount = self.amount + self.locked_amount - total_stake; 171 | self.locked_amount = total_stake; 172 | // Second function call action 173 | self.update_context(staking(), 0); 174 | } 175 | 176 | pub fn deposit_and_stake(&mut self, account: AccountId, amount: Balance) { 177 | self.update_context(account.clone(), amount); 178 | self.contract.deposit(); 179 | self.amount += amount; 180 | self.update_context(account, 0); 181 | self.contract.stake(U128(amount)); 182 | self.simulate_stake_call(); 183 | } 184 | 185 | pub fn skip_epochs(&mut self, num: EpochHeight) { 186 | self.epoch_height += num; 187 | self.block_index += num * 12 * 60 * 60; 188 | self.block_timestamp += num * ONE_EPOCH_TS; 189 | self.locked_amount = (self.locked_amount * (100 + u128::from(num))) / 100; 190 | self.update_context(staking(), 0); 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /staking-farm/src/views.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::json_types::{U64, U128}; 2 | use near_sdk::{AccountId, env}; 3 | 4 | use crate::Farm; 5 | use crate::internal::ZERO_ADDRESS; 6 | use crate::*; 7 | 8 | #[near(serializers=[json])] 9 | #[derive(Clone)] 10 | pub struct HumanReadableFarm { 11 | pub farm_id: u64, 12 | pub name: String, 13 | pub token_id: AccountId, 14 | pub amount: U128, 15 | pub start_date: U64, 16 | pub end_date: U64, 17 | pub active: bool, 18 | } 19 | 20 | impl HumanReadableFarm { 21 | fn from(farm_id: u64, farm: Farm) -> Self { 22 | let active = farm.is_active(); 23 | HumanReadableFarm { 24 | farm_id, 25 | name: farm.name, 26 | token_id: farm.token_id, 27 | amount: U128(farm.amount), 28 | start_date: U64(farm.start_date), 29 | end_date: U64(farm.end_date), 30 | active, 31 | } 32 | } 33 | } 34 | 35 | /// Represents an account structure readable by humans. 36 | #[near(serializers=[json])] 37 | #[derive(Debug)] 38 | pub struct HumanReadableAccount { 39 | pub account_id: AccountId, 40 | /// The unstaked balance that can be withdrawn or staked. 41 | pub unstaked_balance: U128, 42 | /// The amount balance staked at the current "stake" share price. 43 | pub staked_balance: U128, 44 | /// Whether the unstaked balance is available for withdrawal now. 45 | pub can_withdraw: bool, 46 | } 47 | 48 | /// Represents pool summary with all farms and rates applied. 49 | #[near(serializers=[json])] 50 | pub struct PoolSummary { 51 | /// Pool owner. 52 | pub owner: AccountId, 53 | /// The total staked balance. 54 | pub total_staked_balance: U128, 55 | /// The total amount to burn that will be available 56 | /// The fraction of the reward that goes to the owner of the staking pool for running the 57 | /// validator node. 58 | pub reward_fee_fraction: Ratio, 59 | /// If reward fee fraction is changing, this will be different from current. 60 | pub next_reward_fee_fraction: Ratio, 61 | /// The fraction of the reward that gets burnt. 62 | pub burn_fee_fraction: Ratio, 63 | /// Active farms that affect stakers. 64 | pub farms: Vec, 65 | } 66 | 67 | #[near] 68 | impl StakingContract { 69 | /// Returns summary of this pool. 70 | /// Can calculate rate of return of this pool with farming by: 71 | /// `farm_reward_per_day = farms.iter().map(farms.amount / (farm.end_date - farm.start_date) / DAY_IN_NS * PRICES[farm.token_id]).sum()` 72 | /// `near_reward_per_day = total_near_emission_per_day * this.total_staked_balance / total_near_staked * (1 - this.burn_fee_fraction) * (1 - this.reward_fee_fraction)` 73 | /// `total_reward_per_day = farm_reward_per_day + near_reward_per_day * NEAR_PRICE` 74 | /// `reward_rate = total_reward_per_day / (this.total_staked_balance * NEAR_PRICE)` 75 | pub fn get_pool_summary(&self) -> PoolSummary { 76 | PoolSummary { 77 | owner: StakingContract::internal_get_owner_id(), 78 | total_staked_balance: self.total_staked_balance.into(), 79 | reward_fee_fraction: self.reward_fee_fraction.current().clone(), 80 | next_reward_fee_fraction: self.reward_fee_fraction.next().clone(), 81 | burn_fee_fraction: self.burn_fee_fraction.clone(), 82 | farms: self.get_active_farms(), 83 | } 84 | } 85 | 86 | /// 87 | /// OWNER 88 | /// 89 | /// Returns current contract version. 90 | pub fn get_version(&self) -> String { 91 | Self::internal_get_version() 92 | } 93 | 94 | /// Returns current owner from the storage. 95 | pub fn get_owner_id(&self) -> AccountId { 96 | Self::internal_get_owner_id() 97 | } 98 | 99 | /// Returns current contract factory. 100 | pub fn get_factory_id(&self) -> AccountId { 101 | Self::internal_get_factory_id() 102 | } 103 | 104 | /// Return all authorized users. 105 | pub fn get_authorized_users(&self) -> Vec { 106 | self.authorized_users.to_vec() 107 | } 108 | 109 | /// Return all authorized tokens. 110 | pub fn get_authorized_farm_tokens(&self) -> Vec { 111 | self.authorized_farm_tokens.to_vec() 112 | } 113 | 114 | /// 115 | /// FARMS 116 | /// 117 | pub fn get_active_farms(&self) -> Vec { 118 | self.active_farms 119 | .iter() 120 | .map(|&index| HumanReadableFarm::from(index, self.farms.get(index).unwrap())) 121 | .collect() 122 | } 123 | 124 | pub fn get_farms(&self, from_index: u64, limit: u64) -> Vec { 125 | (from_index..std::cmp::min(from_index + limit, self.farms.len())) 126 | .map(|index| HumanReadableFarm::from(index, self.farms.get(index).unwrap())) 127 | .collect() 128 | } 129 | 130 | pub fn get_farm(&self, farm_id: u64) -> HumanReadableFarm { 131 | HumanReadableFarm::from(farm_id, self.internal_get_farm(farm_id)) 132 | } 133 | 134 | pub fn get_unclaimed_reward(&self, account_id: AccountId, farm_id: u64) -> U128 { 135 | if account_id == ZERO_ADDRESS.parse::().expect("INTERNAL FAIL") { 136 | return U128(0); 137 | } 138 | let account = self.accounts.get(&account_id).expect("ERR_NO_ACCOUNT"); 139 | let mut farm = self.farms.get(farm_id).expect("ERR_NO_FARM"); 140 | let (_rps, reward) = self.internal_unclaimed_balance(&account, farm_id, &mut farm); 141 | let prev_reward = *account.amounts.get(&farm.token_id).unwrap_or(&0); 142 | U128(reward + prev_reward) 143 | } 144 | 145 | /// 146 | /// ACCOUNT 147 | /// 148 | /// Returns the unstaked balance of the given account. 149 | pub fn get_account_unstaked_balance(&self, account_id: AccountId) -> U128 { 150 | self.get_account(account_id).unstaked_balance 151 | } 152 | 153 | /// Returns the staked balance of the given account. 154 | /// NOTE: This is computed from the amount of "stake" shares the given account has and the 155 | /// current amount of total staked balance and total stake shares on the account. 156 | pub fn get_account_staked_balance(&self, account_id: AccountId) -> U128 { 157 | self.get_account(account_id).staked_balance 158 | } 159 | 160 | /// Returns the total balance of the given account (including staked and unstaked balances). 161 | pub fn get_account_total_balance(&self, account_id: AccountId) -> U128 { 162 | let account = self.get_account(account_id); 163 | (account.unstaked_balance.0 + account.staked_balance.0).into() 164 | } 165 | 166 | /// Returns `true` if the given account can withdraw tokens in the current epoch. 167 | pub fn is_account_unstaked_balance_available(&self, account_id: AccountId) -> bool { 168 | self.get_account(account_id).can_withdraw 169 | } 170 | 171 | /// Returns the total staking balance. 172 | pub fn get_total_staked_balance(&self) -> U128 { 173 | self.total_staked_balance.into() 174 | } 175 | 176 | /// Returns the current reward fee as a fraction. 177 | pub fn get_reward_fee_fraction(&self) -> Ratio { 178 | self.reward_fee_fraction.current().clone() 179 | } 180 | 181 | /// Returns the staking public key 182 | pub fn get_staking_key(&self) -> PublicKey { 183 | self.stake_public_key.clone() 184 | } 185 | 186 | /// Returns true if the staking is paused 187 | pub fn is_staking_paused(&self) -> bool { 188 | self.paused 189 | } 190 | 191 | /// Returns human readable representation of the account for the given account ID. 192 | pub fn get_account(&self, account_id: AccountId) -> HumanReadableAccount { 193 | let account = self.internal_get_account(&account_id); 194 | HumanReadableAccount { 195 | account_id, 196 | unstaked_balance: account.unstaked.into(), 197 | staked_balance: self 198 | .staked_amount_from_num_shares_rounded_down(account.stake_shares) 199 | .into(), 200 | can_withdraw: account.unstaked_available_epoch_height <= env::epoch_height(), 201 | } 202 | } 203 | 204 | /// Returns the number of accounts that have positive balance on this staking pool. 205 | pub fn get_number_of_accounts(&self) -> u64 { 206 | self.accounts.len() 207 | } 208 | 209 | /// Returns the list of accounts 210 | pub fn get_accounts(&self, from_index: u64, limit: u64) -> Vec { 211 | let keys = self.accounts.keys_as_vector(); 212 | 213 | (from_index..std::cmp::min(from_index + limit, keys.len())) 214 | .map(|index| self.get_account(keys.get(index).unwrap())) 215 | .collect() 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /staking-factory/tests/spec.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::json_types::Base58CryptoHash; 2 | use near_sdk::serde_json::{self, json}; 3 | use near_sdk::{AccountId, PublicKey}; 4 | use near_sdk_sim::{ 5 | call, deploy, init_simulator, to_yocto, view, ContractAccount, ExecutionResult, UserAccount, 6 | }; 7 | 8 | use near_sdk_sim::transaction::ExecutionStatus; 9 | use staking_factory::{Ratio, StakingPoolFactoryContract}; 10 | 11 | const STAKING_POOL_WHITELIST_ACCOUNT_ID: &str = "staking-pool-whitelist"; 12 | const STAKING_POOL_ID: &str = "pool"; 13 | const STAKING_POOL_ACCOUNT_ID: &str = "pool.factory"; 14 | const STAKING_KEY: &str = "KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7"; 15 | const POOL_DEPOSIT: &str = "50"; 16 | 17 | near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { 18 | FACTORY_WASM_BYTES => "../res/staking_factory_release.wasm", 19 | WHITELIST_WASM_BYTES => "../res/whitelist.wasm", 20 | STAKING_FARM_1_0_0_BYTES => "../res/staking_farm_release_1.0.0.wasm", 21 | STAKING_FARM_BYTES => "../res/staking_farm_release.wasm", 22 | } 23 | 24 | type FactoryContract = ContractAccount; 25 | 26 | fn whitelist_id() -> AccountId { 27 | AccountId::new_unchecked(STAKING_POOL_WHITELIST_ACCOUNT_ID.to_string()) 28 | } 29 | 30 | fn setup_factory() -> (UserAccount, UserAccount, FactoryContract, Base58CryptoHash) { 31 | let root = init_simulator(None); 32 | let foundation = root.create_user( 33 | AccountId::new_unchecked("foundation".to_string()), 34 | to_yocto("100"), 35 | ); 36 | 37 | root.deploy_and_init( 38 | &WHITELIST_WASM_BYTES, 39 | whitelist_id(), 40 | "new", 41 | &serde_json::to_string(&json!({ "foundation_account_id": foundation.account_id() })) 42 | .unwrap() 43 | .as_bytes(), 44 | to_yocto("5"), 45 | near_sdk_sim::DEFAULT_GAS, 46 | ); 47 | let factory = deploy!( 48 | contract: StakingPoolFactoryContract, 49 | contract_id: "factory".to_string(), 50 | bytes: &FACTORY_WASM_BYTES, 51 | signer_account: root, 52 | deposit: to_yocto("200"), 53 | init_method: new(foundation.account_id.clone(), whitelist_id()) 54 | ); 55 | foundation 56 | .call( 57 | whitelist_id(), 58 | "add_factory", 59 | &serde_json::to_vec(&json!({"factory_account_id": "factory".to_string()})).unwrap(), 60 | near_sdk_sim::DEFAULT_GAS, 61 | 0, 62 | ) 63 | .assert_success(); 64 | let hash = foundation 65 | .call( 66 | factory.account_id(), 67 | "store", 68 | &STAKING_FARM_BYTES, 69 | near_sdk_sim::DEFAULT_GAS, 70 | to_yocto("5"), 71 | ) 72 | .unwrap_json::(); 73 | call!(foundation, factory.allow_contract(hash)).assert_success(); 74 | (root, foundation, factory, hash) 75 | } 76 | 77 | fn is_whitelisted(account: &UserAccount, account_id: &str) -> bool { 78 | account 79 | .view( 80 | whitelist_id(), 81 | "is_whitelisted", 82 | &serde_json::to_string(&json!({ "staking_pool_account_id": account_id })) 83 | .unwrap() 84 | .as_bytes(), 85 | ) 86 | .unwrap_json::() 87 | } 88 | 89 | fn create_staking_pool( 90 | user: &UserAccount, 91 | factory: &FactoryContract, 92 | code_hash: Base58CryptoHash, 93 | ) -> ExecutionResult { 94 | let fee = Ratio { 95 | numerator: 10, 96 | denominator: 100, 97 | }; 98 | call!( 99 | user, 100 | factory.create_staking_pool( 101 | STAKING_POOL_ID.to_string(), 102 | code_hash, 103 | user.account_id(), 104 | STAKING_KEY.parse().unwrap(), 105 | fee 106 | ), 107 | deposit = to_yocto(POOL_DEPOSIT) 108 | ) 109 | } 110 | 111 | pub fn should_fail(r: ExecutionResult) { 112 | match r.status() { 113 | ExecutionStatus::Failure(_) => {} 114 | _ => panic!("Should fail"), 115 | } 116 | } 117 | 118 | fn assert_all_success(result: ExecutionResult) { 119 | let mut all_success = true; 120 | let mut all_results = String::new(); 121 | for r in result.promise_results() { 122 | let x = r.expect("NO_RESULT"); 123 | all_results = format!("{}\n{:?}", all_results, x); 124 | all_success &= x.is_ok(); 125 | } 126 | println!("{}", all_results); 127 | assert!( 128 | all_success, 129 | "Not all promises where successful: \n\n{}", 130 | all_results 131 | ); 132 | } 133 | 134 | fn get_staking_pool_key(user: &UserAccount) -> PublicKey { 135 | user.view( 136 | AccountId::new_unchecked(STAKING_POOL_ACCOUNT_ID.to_string()), 137 | "get_staking_key", 138 | &[], 139 | ) 140 | .unwrap_json() 141 | } 142 | 143 | fn get_version(user: &UserAccount) -> String { 144 | user.view( 145 | AccountId::new_unchecked(STAKING_POOL_ACCOUNT_ID.to_string()), 146 | "get_version", 147 | &[], 148 | ) 149 | .unwrap_json() 150 | } 151 | 152 | #[test] 153 | fn create_staking_pool_success() { 154 | let (root, _foundation, factory, code_hash) = setup_factory(); 155 | let balance = to_yocto("100"); 156 | let user1 = root.create_user(AccountId::new_unchecked("user1".to_string()), balance); 157 | assert_all_success(create_staking_pool(&user1, &factory, code_hash)); 158 | let pools_created = view!(factory.get_number_of_staking_pools_created()).unwrap_json::(); 159 | assert_eq!(pools_created, 1); 160 | assert!(is_whitelisted(&root, STAKING_POOL_ACCOUNT_ID)); 161 | 162 | // The caller was charged the amount + some for fees 163 | let new_balance = user1.account().unwrap().amount; 164 | assert!(new_balance > balance - to_yocto(POOL_DEPOSIT) - to_yocto("0.02")); 165 | 166 | // Pool account was created and attached deposit was transferred + some from 30% dev fees. 167 | let acc = root 168 | .borrow_runtime() 169 | .view_account(STAKING_POOL_ACCOUNT_ID) 170 | .expect("MUST BE CREATED"); 171 | assert!(acc.amount + acc.locked > to_yocto(POOL_DEPOSIT)); 172 | 173 | // The staking key on the pool matches the one that was given. 174 | assert_eq!(get_staking_pool_key(&root), STAKING_KEY.parse().unwrap()); 175 | } 176 | 177 | fn wait_epoch(user: &UserAccount) { 178 | let epoch_height = user.borrow_runtime().cur_block.epoch_height; 179 | while user.borrow_runtime().cur_block.epoch_height == epoch_height { 180 | assert!(user.borrow_runtime_mut().produce_block().is_ok()); 181 | } 182 | } 183 | 184 | #[test] 185 | fn test_staking_pool_burn() { 186 | let (root, _foundation, factory, code_hash) = setup_factory(); 187 | create_staking_pool(&root, &factory, code_hash).assert_success(); 188 | let account_id = AccountId::new_unchecked(STAKING_POOL_ACCOUNT_ID.to_string()); 189 | assert_all_success(root.call( 190 | account_id.clone(), 191 | "deposit_and_stake", 192 | &[], 193 | near_sdk_sim::DEFAULT_GAS, 194 | to_yocto("100000000"), 195 | )); 196 | wait_epoch(&root); 197 | assert_all_success(root.call(account_id, "ping", &[], near_sdk_sim::DEFAULT_GAS, 0)); 198 | } 199 | 200 | #[test] 201 | fn test_get_code() { 202 | let (_root, _foundation, factory, code_hash) = setup_factory(); 203 | let result: Vec = view!(factory.get_code(code_hash)).unwrap(); 204 | assert_eq!(result, STAKING_FARM_BYTES.to_vec()); 205 | assert!(view!(factory.get_code([0u8; 32].into())) 206 | .unwrap_err() 207 | .to_string() 208 | .find("Contract hash is not allowed") 209 | .is_some()); 210 | } 211 | 212 | #[test] 213 | fn test_staking_pool_upgrade_from_1_0_0() { 214 | let (root, foundation, factory, code_hash) = setup_factory(); 215 | let hash_1_0_0 = foundation 216 | .call( 217 | factory.account_id(), 218 | "store", 219 | &STAKING_FARM_1_0_0_BYTES, 220 | near_sdk_sim::DEFAULT_GAS, 221 | to_yocto("5"), 222 | ) 223 | .unwrap_json::(); 224 | call!(foundation, factory.allow_contract(hash_1_0_0)).assert_success(); 225 | 226 | create_staking_pool(&root, &factory, hash_1_0_0).assert_success(); 227 | 228 | let attempted_get_version_view = root.view( 229 | AccountId::new_unchecked(STAKING_POOL_ACCOUNT_ID.to_string()), 230 | "get_version", 231 | &[], 232 | ); 233 | assert!(attempted_get_version_view.is_err()); 234 | 235 | let version_through_call: String = root 236 | .call( 237 | AccountId::new_unchecked(STAKING_POOL_ACCOUNT_ID.to_string()), 238 | "get_version", 239 | &[], 240 | near_sdk_sim::DEFAULT_GAS, 241 | 0, 242 | ) 243 | .unwrap_json(); 244 | assert_eq!(version_through_call, "staking-farm:1.0.0"); 245 | 246 | // Upgrade staking pool. 247 | assert_all_success(root.call( 248 | AccountId::new_unchecked(STAKING_POOL_ACCOUNT_ID.to_string()), 249 | "upgrade", 250 | &serde_json::to_vec(&json!({ "code_hash": code_hash })).unwrap(), 251 | near_sdk_sim::DEFAULT_GAS, 252 | 0, 253 | )); 254 | // Check that contract works. 255 | assert_eq!(get_staking_pool_key(&root), STAKING_KEY.parse().unwrap()); 256 | assert_eq!(get_version(&root), "staking-farm:1.1.0"); 257 | } 258 | -------------------------------------------------------------------------------- /staking-farm/src/stake.rs: -------------------------------------------------------------------------------- 1 | use crate::internal::{MIN_BURN_AMOUNT, ZERO_ADDRESS}; 2 | use crate::*; 3 | 4 | /// Interface for the contract itself. 5 | #[ext_contract(ext_self)] 6 | #[allow(dead_code)] 7 | pub trait SelfContract { 8 | /// A callback to check the result of the staking action. 9 | /// In case the stake amount is less than the minimum staking threshold, the staking action 10 | /// fails, and the stake amount is not changed. This might lead to inconsistent state and the 11 | /// follow withdraw calls might fail. To mitigate this, the contract will issue a new unstaking 12 | /// action in case of the failure of the first staking action. 13 | fn on_stake_action(&mut self); 14 | 15 | /// Check if reward withdrawal succeeded and if it failed, refund reward back to the user. 16 | fn callback_post_withdraw_reward( 17 | &mut self, 18 | token_id: AccountId, 19 | sender_id: AccountId, 20 | amount: U128, 21 | ); 22 | 23 | /// Callback after getting the owner of the given account. 24 | fn callback_post_get_owner( 25 | &mut self, 26 | token_id: AccountId, 27 | delegator_id: AccountId, 28 | account_id: AccountId, 29 | ) -> Promise; 30 | 31 | /// Resolve FT transfer for stake shares and refund unused amount back to sender. 32 | fn ft_resolve_transfer( 33 | &mut self, 34 | sender_id: AccountId, 35 | receiver_id: AccountId, 36 | amount: U128, 37 | ) -> U128; 38 | } 39 | 40 | #[near] 41 | impl StakingContract { 42 | /// Deposits the attached amount into the inner account of the predecessor. 43 | #[payable] 44 | pub fn deposit(&mut self) { 45 | let need_to_restake = self.internal_ping(); 46 | 47 | self.internal_deposit(); 48 | 49 | if need_to_restake { 50 | self.internal_restake(); 51 | } 52 | } 53 | 54 | /// Deposits the attached amount into the inner account of the predecessor and stakes it. 55 | #[payable] 56 | pub fn deposit_and_stake(&mut self) { 57 | self.internal_ping(); 58 | 59 | let amount = self.internal_deposit(); 60 | self.internal_stake(amount); 61 | 62 | self.internal_restake(); 63 | } 64 | 65 | /// Withdraws the entire unstaked balance from the predecessor account. 66 | /// It's only allowed if the `unstake` action was not performed in the four most recent epochs. 67 | pub fn withdraw_all(&mut self) { 68 | let need_to_restake = self.internal_ping(); 69 | 70 | let account_id = env::predecessor_account_id(); 71 | let account = self.internal_get_account(&account_id); 72 | self.internal_withdraw(&account_id, account.unstaked); 73 | 74 | if need_to_restake { 75 | self.internal_restake(); 76 | } 77 | } 78 | 79 | /// Withdraws the non staked balance for given account. 80 | /// It's only allowed if the `unstake` action was not performed in the four most recent epochs. 81 | pub fn withdraw(&mut self, amount: U128) { 82 | let need_to_restake = self.internal_ping(); 83 | 84 | let amount: Balance = amount.into(); 85 | self.internal_withdraw(&env::predecessor_account_id(), amount); 86 | 87 | if need_to_restake { 88 | self.internal_restake(); 89 | } 90 | } 91 | 92 | /// Stakes all available unstaked balance from the inner account of the predecessor. 93 | pub fn stake_all(&mut self) { 94 | // Stake action always restakes 95 | self.internal_ping(); 96 | 97 | let account_id = env::predecessor_account_id(); 98 | let account = self.internal_get_account(&account_id); 99 | self.internal_stake(account.unstaked); 100 | 101 | self.internal_restake(); 102 | } 103 | 104 | /// Stakes the given amount from the inner account of the predecessor. 105 | /// The inner account should have enough unstaked balance. 106 | pub fn stake(&mut self, amount: U128) { 107 | // Stake action always restakes 108 | self.internal_ping(); 109 | 110 | let amount: Balance = amount.into(); 111 | self.internal_stake(amount); 112 | 113 | self.internal_restake(); 114 | } 115 | 116 | /// Unstakes all staked balance from the inner account of the predecessor. 117 | /// The new total unstaked balance will be available for withdrawal in four epochs. 118 | pub fn unstake_all(&mut self) { 119 | self.internal_unstake_all(&env::predecessor_account_id()); 120 | } 121 | 122 | /// Unstakes the given amount from the inner account of the predecessor. 123 | /// The inner account should have enough staked balance. 124 | /// The new total unstaked balance will be available for withdrawal in four epochs. 125 | pub fn unstake(&mut self, amount: U128) { 126 | // Unstake action always restakes 127 | self.internal_ping(); 128 | 129 | let amount: Balance = amount.into(); 130 | self.inner_unstake(&env::predecessor_account_id(), amount); 131 | 132 | self.internal_restake(); 133 | } 134 | 135 | /// Unstakes all the tokens that must be burnt. 136 | pub fn unstake_burn(&mut self) { 137 | self.internal_unstake_all(&ZERO_ADDRESS.parse().expect("INTERNAL FAIL")); 138 | } 139 | 140 | /// Burns all the tokens that are unstaked. 141 | pub fn burn(&mut self) { 142 | let account_id: AccountId = ZERO_ADDRESS.parse().expect("INTERNAL FAIL"); 143 | let account = self.internal_get_account(&account_id); 144 | if account.unstaked > MIN_BURN_AMOUNT { 145 | // TODO: replace with burn host function when available. 146 | self.internal_withdraw(&account_id, account.unstaked); 147 | } 148 | } 149 | 150 | /*************/ 151 | /* Callbacks */ 152 | /*************/ 153 | 154 | pub fn on_stake_action(&mut self) { 155 | assert_eq!( 156 | env::current_account_id(), 157 | env::predecessor_account_id(), 158 | "Can be called only as a callback" 159 | ); 160 | 161 | assert_eq!( 162 | env::promise_results_count(), 163 | 1, 164 | "Contract expected a result on the callback" 165 | ); 166 | let stake_action_succeeded = matches!(env::promise_result(0), PromiseResult::Successful(_)); 167 | 168 | // If the stake action failed and the current locked amount is positive, then the contract 169 | // has to unstake. 170 | if !stake_action_succeeded && env::account_locked_balance() > NearToken::from_yoctonear(0) { 171 | Promise::new(env::current_account_id()) 172 | .stake(NearToken::from_yoctonear(0), self.stake_public_key.clone()); 173 | } 174 | } 175 | 176 | /// Internal transfer of stake shares between two normal accounts. 177 | /// Distributes rewards for both sides before moving shares. 178 | pub(crate) fn internal_share_transfer( 179 | &mut self, 180 | sender_id: &AccountId, 181 | receiver_id: &AccountId, 182 | amount: Balance, 183 | ) { 184 | assert!(amount > 0, "ERR_ZERO_AMOUNT"); 185 | assert!(sender_id != receiver_id, "ERR_SAME_ACCOUNT"); 186 | assert!( 187 | receiver_id != &env::current_account_id(), 188 | "ERR_TRANSFER_TO_CONTRACT" 189 | ); 190 | // Update epoch/rewards; no need to restake here. 191 | self.internal_ping(); 192 | 193 | // Sender must have enough shares. 194 | let mut sender = self.internal_get_account(sender_id); 195 | let mut receiver = self.internal_get_account(receiver_id); 196 | 197 | // Distribute rewards so future farm calculations use updated stake_shares. 198 | self.internal_distribute_all_rewards(&mut sender); 199 | self.internal_distribute_all_rewards(&mut receiver); 200 | 201 | assert!(sender.stake_shares >= amount, "ERR_INSUFFICIENT_SHARES"); 202 | sender.stake_shares -= amount; 203 | receiver.stake_shares += amount; 204 | 205 | self.internal_save_account(sender_id, &sender); 206 | self.internal_save_account(receiver_id, &receiver); 207 | } 208 | } 209 | 210 | #[near] 211 | impl StakingContract { 212 | /// Resolve FT transfer per NEP-141. Expects that receiver's ft_on_transfer returned amount to refund. 213 | #[private] 214 | pub fn ft_resolve_transfer( 215 | &mut self, 216 | sender_id: AccountId, 217 | receiver_id: AccountId, 218 | amount: U128, 219 | ) -> U128 { 220 | use near_sdk::PromiseResult; 221 | let amount: Balance = amount.0; 222 | let unused = match env::promise_result(0) { 223 | PromiseResult::Successful(value) => near_sdk::serde_json::from_slice::(&value) 224 | .map(|v| v.0) 225 | .unwrap_or(0), 226 | _ => 0, 227 | }; 228 | if unused > 0 { 229 | // Refund unused shares from receiver back to sender; clamp to originally sent amount and receiver balance. 230 | let receiver = self.internal_get_account(&receiver_id); 231 | let refund = std::cmp::min(unused, std::cmp::min(amount, receiver.stake_shares)); 232 | if refund > 0 { 233 | // Perform transfer back without callbacks. 234 | self.internal_share_transfer(&receiver_id, &sender_id, refund); 235 | return U128(refund); 236 | } 237 | } 238 | U128(0) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /staking-farm/src/owner.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::sys; 2 | 3 | use crate::*; 4 | 5 | pub const OWNER_KEY: &[u8; 5] = b"OWNER"; 6 | pub const FACTORY_KEY: &[u8; 7] = b"FACTORY"; 7 | pub const VERSION_KEY: &[u8; 7] = b"VERSION"; 8 | /// FT metadata keys stored outside of the STATE to avoid migrations. 9 | pub const FT_NAME_KEY: &[u8; 7] = b"FT_NAME"; 10 | pub const FT_SYMBOL_KEY: &[u8; 9] = b"FT_SYMBOL"; 11 | const GET_CODE_METHOD_NAME: &[u8; 8] = b"get_code"; 12 | const GET_CODE_GAS: Gas = Gas::from_tgas(50); 13 | const SELF_UPGRADE_METHOD_NAME: &[u8; 6] = b"update"; 14 | const SELF_MIGRATE_METHOD_NAME: &[u8; 7] = b"migrate"; 15 | const UPGRADE_GAS_LEFTOVER: Gas = Gas::from_tgas(5); 16 | const UPDATE_GAS_LEFTOVER: Gas = Gas::from_tgas(5); 17 | const NO_DEPOSIT: Balance = 0; 18 | 19 | const ERR_MUST_BE_OWNER: &str = "Can only be called by the owner"; 20 | const ERR_MUST_BE_SELF: &str = "Can only be called by contract itself"; 21 | const ERR_MUST_BE_FACTORY: &str = "Can only be called by staking pool factory"; 22 | 23 | ///*******************/ 24 | ///* Owner's methods */ 25 | ///*******************/ 26 | #[near] 27 | impl StakingContract { 28 | /// Storing owner in a separate storage to avoid STATE corruption issues. 29 | /// Returns previous owner if it existed. 30 | pub(crate) fn internal_set_owner(owner_id: &AccountId) -> Option { 31 | env::storage_write(OWNER_KEY, owner_id.as_bytes()); 32 | env::storage_get_evicted().map(|bytes| { 33 | String::from_utf8(bytes) 34 | .expect("INTERNAL FAIL") 35 | .parse() 36 | .expect("INTERNAL FAIL") 37 | }) 38 | } 39 | 40 | /// Store the factory in the storage independent of the STATE. 41 | pub(crate) fn internal_set_factory(factory_id: &AccountId) { 42 | env::storage_write(FACTORY_KEY, factory_id.as_bytes()); 43 | } 44 | 45 | pub(crate) fn internal_set_version() { 46 | env::storage_write(VERSION_KEY, Self::internal_get_version().as_bytes()); 47 | } 48 | 49 | pub(crate) fn internal_get_state_version() -> String { 50 | String::from_utf8(env::storage_read(VERSION_KEY).expect("MUST HAVE VERSION")) 51 | .expect("INTERNAL_FAIL") 52 | } 53 | 54 | /// Changes contract owner. Must be called by current owner. 55 | pub fn set_owner_id(owner_id: &AccountId) { 56 | let prev_owner = StakingContract::internal_set_owner(owner_id).expect("MUST HAVE OWNER"); 57 | assert_eq!( 58 | prev_owner, 59 | env::predecessor_account_id(), 60 | "MUST BE OWNER TO SET OWNER" 61 | ); 62 | } 63 | 64 | /// Owner's method. 65 | /// Updates current public key to the new given public key. 66 | pub fn update_staking_key(&mut self, stake_public_key: PublicKey) { 67 | self.assert_owner(); 68 | // When updating the staking key, the contract has to restake. 69 | let _need_to_restake = self.internal_ping(); 70 | self.stake_public_key = stake_public_key; 71 | self.internal_restake(); 72 | } 73 | 74 | /// Owner's method. 75 | /// Updates current reward fee fraction to the new given fraction. 76 | pub fn update_reward_fee_fraction(&mut self, reward_fee_fraction: Ratio) { 77 | self.assert_owner(); 78 | reward_fee_fraction.assert_valid(); 79 | 80 | let need_to_restake = self.internal_ping(); 81 | self.reward_fee_fraction.set(reward_fee_fraction); 82 | if need_to_restake { 83 | self.internal_restake(); 84 | } 85 | } 86 | 87 | /// Can only be called by the factory. 88 | /// Decreases the current burn fee fraction to the new given fraction. 89 | pub fn decrease_burn_fee_fraction(&mut self, burn_fee_fraction: Ratio) { 90 | self.assert_factory(); 91 | burn_fee_fraction.assert_valid(); 92 | assert!( 93 | u64::from(burn_fee_fraction.numerator) * u64::from(self.burn_fee_fraction.denominator) 94 | < u64::from(burn_fee_fraction.denominator) 95 | * u64::from(self.burn_fee_fraction.numerator), 96 | "The factory can only decrease the burn fee fraction" 97 | ); 98 | 99 | let need_to_restake = self.internal_ping(); 100 | self.burn_fee_fraction = burn_fee_fraction; 101 | if need_to_restake { 102 | self.internal_restake(); 103 | } 104 | } 105 | 106 | /// Owner's method. 107 | /// Pauses pool staking. 108 | pub fn pause_staking(&mut self) { 109 | self.assert_owner(); 110 | assert!(!self.paused, "The staking is already paused"); 111 | 112 | self.internal_ping(); 113 | self.paused = true; 114 | Promise::new(env::current_account_id()) 115 | .stake(NearToken::from_yoctonear(0), self.stake_public_key.clone()); 116 | } 117 | 118 | /// Owner's method. 119 | /// Resumes pool staking. 120 | pub fn resume_staking(&mut self) { 121 | self.assert_owner(); 122 | assert!(self.paused, "The staking is not paused"); 123 | 124 | self.internal_ping(); 125 | self.paused = false; 126 | self.internal_restake(); 127 | } 128 | 129 | /// Add authorized user to the current contract. 130 | pub fn add_authorized_user(&mut self, account_id: AccountId) { 131 | self.assert_owner(); 132 | self.authorized_users.insert(&account_id); 133 | } 134 | 135 | /// Remove authorized user from the current contract. 136 | pub fn remove_authorized_user(&mut self, account_id: AccountId) { 137 | self.assert_owner(); 138 | self.authorized_users.remove(&account_id); 139 | } 140 | 141 | /// Add authorized token. 142 | pub fn add_authorized_farm_token(&mut self, token_id: &AccountId) { 143 | self.assert_owner_or_authorized_user(); 144 | self.authorized_farm_tokens.insert(token_id); 145 | } 146 | 147 | /// Remove authorized token. 148 | pub fn remove_authorized_farm_token(&mut self, token_id: &AccountId) { 149 | self.assert_owner_or_authorized_user(); 150 | self.authorized_farm_tokens.remove(token_id); 151 | } 152 | 153 | /// Asserts that the method was called by the owner. 154 | pub(crate) fn assert_owner(&self) { 155 | assert_eq!( 156 | env::predecessor_account_id(), 157 | StakingContract::internal_get_owner_id(), 158 | "{}", 159 | ERR_MUST_BE_OWNER 160 | ); 161 | } 162 | 163 | /// Asserts that the method was called by the factory. 164 | pub(crate) fn assert_factory(&self) { 165 | assert_eq!( 166 | env::predecessor_account_id(), 167 | StakingContract::internal_get_factory_id(), 168 | "{}", 169 | ERR_MUST_BE_FACTORY 170 | ); 171 | } 172 | 173 | /// Asserts that the method was called by the owner or authorized user. 174 | pub(crate) fn assert_owner_or_authorized_user(&self) { 175 | let account_id = env::predecessor_account_id(); 176 | assert!( 177 | account_id == StakingContract::internal_get_owner_id() 178 | || self.authorized_users.contains(&account_id), 179 | "ERR_NOT_AUTHORIZED_USER" 180 | ); 181 | } 182 | 183 | /// Owner's method. Sets FT name stored outside of state to avoid migrations. 184 | pub fn set_ft_name(&mut self, name: String) { 185 | self.assert_owner(); 186 | env::storage_write(FT_NAME_KEY, name.as_bytes()); 187 | } 188 | 189 | /// Owner's method. Sets FT symbol stored outside of state to avoid migrations. 190 | pub fn set_ft_symbol(&mut self, symbol: String) { 191 | self.assert_owner(); 192 | env::storage_write(FT_SYMBOL_KEY, symbol.as_bytes()); 193 | } 194 | 195 | /// Read FT name from storage or default to full contract account ID. 196 | pub(crate) fn internal_get_ft_name() -> String { 197 | if let Some(bytes) = env::storage_read(FT_NAME_KEY) { 198 | String::from_utf8(bytes).expect("INTERNAL_FAIL") 199 | } else { 200 | env::current_account_id().to_string() 201 | } 202 | } 203 | 204 | /// Read FT symbol from storage or default to the prefix of contract account before '.'. 205 | pub(crate) fn internal_get_ft_symbol() -> String { 206 | if let Some(bytes) = env::storage_read(FT_SYMBOL_KEY) { 207 | String::from_utf8(bytes).expect("INTERNAL_FAIL") 208 | } else { 209 | let acc = env::current_account_id(); 210 | acc.as_str() 211 | .split('.') 212 | .next() 213 | .unwrap_or(acc.as_str()) 214 | .to_string() 215 | .to_uppercase() 216 | } 217 | } 218 | } 219 | 220 | /// Upgrade method. 221 | /// Takes `hash` as an argument. 222 | /// Calls `factory_id.get_code(hash)` first to get the code. 223 | /// Callback to `self.update(code)` to upgrade code. 224 | /// Callback after that to `self.migrate()` to migrate the state using new code. 225 | #[unsafe(no_mangle)] 226 | pub extern "C" fn upgrade() { 227 | env::setup_panic_hook(); 228 | let current_id = env::current_account_id(); 229 | let owner_id = StakingContract::internal_get_owner_id(); 230 | let factory_id = StakingContract::internal_get_factory_id(); 231 | assert_eq!( 232 | env::predecessor_account_id(), 233 | owner_id, 234 | "{}", 235 | ERR_MUST_BE_OWNER 236 | ); 237 | unsafe { 238 | // Load hash to the register 0. 239 | sys::input(0); 240 | // Create a promise for factory contract. 241 | let promise_id = sys::promise_batch_create( 242 | factory_id.as_bytes().len() as _, 243 | factory_id.as_bytes().as_ptr() as _, 244 | ); 245 | // Call method to get the code, passing register 0 as an argument. 246 | sys::promise_batch_action_function_call( 247 | promise_id, 248 | GET_CODE_METHOD_NAME.len() as _, 249 | GET_CODE_METHOD_NAME.as_ptr() as _, 250 | u64::MAX as _, 251 | 0, 252 | &NO_DEPOSIT as *const u128 as _, 253 | GET_CODE_GAS.as_gas(), 254 | ); 255 | // Add callback to actually redeploy and migrate. 256 | let callback_id = sys::promise_batch_then( 257 | promise_id, 258 | current_id.as_bytes().len() as _, 259 | current_id.as_bytes().as_ptr() as _, 260 | ); 261 | sys::promise_batch_action_function_call( 262 | callback_id, 263 | SELF_UPGRADE_METHOD_NAME.len() as _, 264 | SELF_UPGRADE_METHOD_NAME.as_ptr() as _, 265 | 0, 266 | 0, 267 | &NO_DEPOSIT as *const u128 as _, 268 | (env::prepaid_gas() 269 | .saturating_sub(env::used_gas()) 270 | .saturating_sub(GET_CODE_GAS) 271 | .saturating_sub(UPGRADE_GAS_LEFTOVER)) 272 | .as_gas(), 273 | ); 274 | sys::promise_return(callback_id); 275 | } 276 | } 277 | 278 | /// Updating current contract with the received code from factory. 279 | #[unsafe(no_mangle)] 280 | pub extern "C" fn update() { 281 | env::setup_panic_hook(); 282 | let current_id = env::current_account_id(); 283 | assert_eq!( 284 | env::predecessor_account_id(), 285 | current_id, 286 | "{}", 287 | ERR_MUST_BE_SELF 288 | ); 289 | // Load code into register 0 result from the promise. 290 | match unsafe { sys::promise_result(0, 0) } { 291 | 1 => {} 292 | // Not ready or failed. 293 | _ => env::panic_str("Failed to fetch the new code"), 294 | }; 295 | unsafe { 296 | // Update current contract with code from register 0. 297 | let promise_id = sys::promise_batch_create( 298 | current_id.as_bytes().len() as _, 299 | current_id.as_bytes().as_ptr() as _, 300 | ); 301 | // Deploy the contract code. 302 | sys::promise_batch_action_deploy_contract(promise_id, u64::MAX as _, 0); 303 | // Call promise to migrate the state. 304 | // Batched together to fail upgrade if migration fails. 305 | sys::promise_batch_action_function_call( 306 | promise_id, 307 | SELF_MIGRATE_METHOD_NAME.len() as _, 308 | SELF_MIGRATE_METHOD_NAME.as_ptr() as _, 309 | 0, 310 | 0, 311 | &NO_DEPOSIT as *const u128 as _, 312 | (env::prepaid_gas() 313 | .saturating_sub(env::used_gas()) 314 | .saturating_sub(UPDATE_GAS_LEFTOVER)) 315 | .as_gas(), 316 | ); 317 | sys::promise_return(promise_id); 318 | } 319 | } 320 | 321 | /// Empty migrate method for future use. 322 | /// Makes sure that state version is previous. 323 | /// When updating code, make sure to update what previous version actually is. 324 | #[unsafe(no_mangle)] 325 | pub extern "C" fn migrate() { 326 | env::setup_panic_hook(); 327 | assert_eq!( 328 | env::predecessor_account_id(), 329 | env::current_account_id(), 330 | "{}", 331 | ERR_MUST_BE_SELF 332 | ); 333 | 334 | // Check that state version is previous. 335 | // Will fail migration in the case of trying to skip the versions. 336 | assert_eq!( 337 | StakingContract::internal_get_state_version(), 338 | "staking-farm:1.0.0" 339 | ); 340 | StakingContract::internal_set_version(); 341 | } 342 | -------------------------------------------------------------------------------- /HowTo.md: -------------------------------------------------------------------------------- 1 | # Near: Staking and Farming Howto. 2 | 3 | This document describes how to deploy, configure and use the new Staking-Pool with Farming contract. 4 | 5 | ## How does Stake & Farm Work? 6 | 7 | Staking is the process of delegating NEAR to a staking pool that is operated by a Near validator node. 8 | Stake-Holders participate in the rewards earned by the validator node. 9 | 10 | Farming allows stake-holders to temporarily lock NEAR with a farming contract. During 11 | this time of locking the stake-holder receives a faction of tokens that are distributed 12 | by the farming contract. 13 | 14 | The new Stake & Farm contract allows both at the same time: Stake NEAR with the contract, 15 | and earn validator rewards AND farm tokens at the same time. 16 | 17 | ## Deploying the complete contract environment for Stake&Farm on the NEAR testnet. 18 | 19 | This section is of interest for developers and testers. 20 | 21 | If you are only interested in deploying a Stake&Farm Validator, jump ahead to "Deploy Stake&Farm". 22 | 23 | If you are only interested in staking with an existing Stake&Farm Validator, jump ahead to "Stake Near and Farm Tokens". 24 | 25 | ### Required tools 26 | 27 | You need: 28 | 29 | 1. GIT: https://git-scm.com/ 30 | 2. NEAR CLI: https://github.com/near/near-cli#Installation 31 | 3. Go: https://go.dev/doc/install 32 | 4. Install the nearkey tool:\ 33 | `$ go install github.com/aurora-is-near/near-api-go/cmd/nearkey`\ 34 | 6. Install the nearcall tool (only if you are deploying the whitelist and factory contracts):\ 35 | `$ go install github.com/aurora-is-near/near-api-go/cmd/nearcall`\ 36 | This tool allows you to call contract methods with arguments that are too long for the near cli. 37 | 7. Rust (only if you are deploying the whitelist and factory contracts):\ 38 | `$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`\ 39 | `$ rustup target add wasm32-unknown-unknown` 40 | 41 | ### Get a testnet account 42 | 43 | You will require a testnet account with enough testnet NEAR to follow these instructions. 44 | 45 | Create it by calling:\ 46 | `$ near login` 47 | 48 | Then follow the instructions in the browser window to create a new account and grant permissions for near cli to it. 49 | 50 | Make the account name globally available:\ 51 | `$ export MASTERACC=master.testnet` 52 | 53 | Replace *master.testnet* with the actual name of the account you just created. 54 | 55 | ### Deploy the whitelist contract 56 | 57 | Whitelisting controls which contracts can accept locked NEAR for staking/farming. The official whitelisting 58 | contracts are operated by the NEAR Foundation. We're setting up our own whitelisting contract here to create a complete 59 | testing environment. 60 | 61 | 1. Clone the Near Core Contracts repository which contains the whitelist contract:\ 62 | `$ git clone https://github.com/near/core-contracts.git` 63 | 2. The whitelist contract can be found in the directory core-contracts/whitelist:\ 64 | `$ cd core-contracts/whitelist` 65 | 3. Build the contract (this will take a moment):\ 66 | `$ ./build.sh` 67 | 4. The compiled contract can be found in the directory res now:\ 68 | `$ cd res` 69 | 5. Create the contract account for the whitelist contract:\ 70 | `$ near --masterAccount ${MASTERACC} create-account whitelist.${MASTERACC}` 71 | 6. Deploy the whitelist contract:\ 72 | `$ near --masterAccount ${MASTERACC} deploy --accountId whitelist.${MASTERACC} 73 | --wasmFile whitelist.wasm --initFunction new --initArgs '{"foundation_account_id":"'${MASTERACC}'"}' --initGas 300000000000000 `\ 74 | This deploys the whitelist contract under the name *whitelist.${MASTERACC}* and configures the controlling account 75 | to *${MASTERACC}*. 76 | 77 | ### Deploy the Factory contract 78 | 79 | A factory contract is used to create many instances of the same contract on NEAR. This is especially important 80 | for staking contracts that want to be able to receive stakes from locked NEAR. Only a factory can 81 | provide the deploy and update functions while at the same time giving some assurance that the contract is meeting 82 | security requirement of the NEAR foundation. Furthermore it greatly simplifies deployment. 83 | 84 | 1. Clone the Stake&Farm repository that contains the factory contract:\ 85 | `$ git clone https://github.com/referencedev/staking-farm.git` 86 | 2. The factory contract is located in the directory staking-farm/staking-factory:\ 87 | `$ cd staking-farm/staking-factory` 88 | 3. Build the factory contract:\ 89 | `$ ./build_local.sh` 90 | 4. The compiled contract is located in ../res/staking_factory_local.wasm.\ 91 | `$ cd ../res/` 92 | 5. Create the contract account for the factory contract:\ 93 | `$ near --masterAccount ${MASTERACC} create-account factory.${MASTERACC}` 94 | 6. Deploy the factory contract:\ 95 | `$ near --masterAccount ${MASTERACC} deploy --accountId factory.${MASTERACC} 96 | --wasmFile staking_factory.wasm --initFunction new 97 | --initArgs '{"owner_id":"'${MASTERACC}'", "staking_pool_whitelist_account_id":"whitelist.'${MASTERACC}'"}' 98 | --initGas 300000000000000 `\ 99 | This creates the contract factory that is controlled by ${MASTERACC} and is referring the whitelisting contract deployed before,\ 100 | whitelist.${MASTERACC}. 101 | 102 | ### Whitelist the contract factory 103 | 104 | The newly deployed factory needs the blessing of the whitelisting contract: 105 | 106 | `$ near --accountId ${MASTERACC} call whitelist.${MASTERACC} add_factory '{"factory_account_id":"factory.'${MASTERACC}'"}'` 107 | 108 | You can verify the success by calling: 109 | 110 | `$ near --accountId ${MASTERACC} view whitelist.${MASTERACC} is_factory_whitelisted '{"factory_account_id":"factory'${MASTERACC}'"}'` 111 | 112 | The result of that call should be "true". 113 | 114 | ### Load stake&farm contract into the contract factory. 115 | 116 | The contract factory needs actual contracts that it can deploy: 117 | 118 | 1. In the cloned Stake&Farm repository navigate to the staking-farm directory:\ 119 | `$ cd ../staking-farm` 120 | 2. Build the contract:\ 121 | `$ ./build_local.sh` 122 | 3. The compiled contract is located in ../res/staking_factory_local.wasm.\ 123 | `$ cd ../res/` 124 | 4. Deploying the contract into the factory requires the nearcall tool installed before:\ 125 | `$ near call -account ${MASTERACC} -contract factory.${MASTERACC} -args staking_farm_local.wasm`\ 126 | This will return an "Argument hash" that is required for later, and success/failure information of the deployment call.\ 127 | Make the argument hash globally available:\ 128 | `$ export CONTRACTHASH=HxT6MrNC7...` 129 | 5. Whitelist the contract hash:\ 130 | `$ near call --accountId ${MASTERACC} call factory.${MASTERACC} allow_contract '{"code_hash": "'${CONTRACTHASH}'"}'` 131 | 132 | You can verify the previous call by: 133 | 134 | `$ near --accountId ${MASTERACC} call factory.${MASTERACC} get_code '{"code_hash":"'${CONTRACTHASH}'"}'` 135 | 136 | The result of this call should be a lot of "garbage": That's the loaded contract code. 137 | 138 | The prerequisites for actually deploying a Stake&Farm contract are now in place. 139 | 140 | ## Deploy Stake&Farm 141 | 142 | 1. First, make some configuration globally available:\ 143 | `$ export CONTRACTHASH=HxT6MrNC7cQh68CZeBxiBbePSD7rxDeqeQDeHQ8n5j2M`\ 144 | `$ export FACTORY=factory01.littlefarm.testnet`\ 145 | `$ export WHITELIST=whitelist01.littlefarm.testnet`\ 146 | The above refer to an example factory installation on testnet. If you deployed a factory yourself, use the data from the above steps.\ 147 | For production deployment, NEAR will provide official values both for testnet and mainnet. 148 | 2. Create a controlling account for your stake&farm contract:\ 149 | `$ near login`\ 150 | Then follow the instructions in the browser window to create a new account and grant permissions for near cli to it. 151 | 3. Make the account name globally available:\ 152 | `$ export OWNERACC=owner.testnet`\ 153 | Replace *owner.testnet* with the actual name of the account you just created. 154 | 4. Select a name for your validator and make it globally available:\ 155 | `$ export VALIDATORNAME=myvalidator` 156 | 5. Create the validator keypair:\ 157 | `$ nearkey ${VALIDATORNAME}.${FACTORY} > validator_key.json`\ 158 | This creates a file "validator_key.json" that needs to be deployed to your nearcore node installation. 159 | 6. Copy the public_key from the validator_key.json file and make it publicly available:\ 160 | `$ export VALIDATORKEY="ed25519:eSNAthKiUM1kNFifPDCt6U83Abnak4dCRbhUeNGA9j7"` 161 | 7. Finally, call the factory to create the new stake&farm contract:\ 162 | `$ near --accountId ${OWNERACC} call ${FACTORY} create_staking_pool '{ "staking_pool_id":"'${VALIDATORNAME}'", "owner_id":"'${OWNERACC}'", "code_hash":"'${CONTRACTHASH}'", "stake_public_key":"'${VALIDATORKEY}'", "reward_fee_fraction": {"numerator": 10, "denominator": 100}}' --amount 2423 --gas 300000000000000`\ 163 | This deploys the staking contract owned by OWNERACC and keeping 10/100 (numerator/denominator) of rewards for itself while distributing the remainder to stake-holders. 164 | 8. Make the name of the new contract globally available:\ 165 | `$ export STAKINGCONTRACT=${VALIDATORNAME}.${FACTORY}` 166 | 9. Verify deployment and whitelisting:\ 167 | `$ near view ${WHITELIST} is_whitelisted '{"staking_pool_account_id":"'${STAKINGCONTRACT}''"}'`\ 168 | The result should be "true". 169 | 170 | ### Set up a farm from an example contract 171 | 172 | Farming requires a source of tokens to be farmed. The source can be any contract that implements the NEAR "Fungible Tokens" Standard (ft-1.0.0). 173 | 174 | Let's create such a contract and use it for farming in our stake&farm contract: 175 | 176 | 1. Clone the Rust near-sdk which contains example contracts:\ 177 | `$ git clone https://github.com/near/near-sdk-rs.git` 178 | 2. The example contract is located in near-sdk-rs/examples/fungible-token:\ 179 | `$ cd near-sdk-rs/examples/fungible-token` 180 | 3. Build the contract:\ 181 | `$ ./build.sh`\ 182 | The compiled contract is located in res/fungible_token.wasm. 183 | 4. Create the contract account for the token contract:\ 184 | `$ near --masterAccount ${OWNERACC} create-account token.${OWNERACC}` 185 | 5. Deploy the token contract:\ 186 | `$ near --masterAccount ${OWNERACC} deploy --accountId token.${OWNERACC} 187 | --wasmFile res/fungible_token.wasm --initGas 300000000000000` \ 188 | Be aware that the owner of the token contract does not have to be the owner of the stake&farm contract. 189 | 6. Configure the token contract:\ 190 | `$ near --accountId ${OWNERACC} call token.${OWNERACC} new '{"owner_id": "'${OWNERACC}'", "total_supply": "1000000000000000", "metadata": { "spec": "ft-1.0.0", "name": "Example Token", "symbol": "EXT", "decimals": 8 }}'`\ 191 | This creates a token with total supply, name, symbol, etc. 192 | 193 | Now that we have tokens to farm, we need to transfer some of them to the stake&farm contract: 194 | 195 | 0. The specific token for the farm needs to be whitelisted: `near call ${STAKINGCONTRACT} add_authorized_farm_token '{"token_id": "token.${OWNERACC}"} --accountId ${OWNERACC}'` 196 | 1. The stake&farm contract needs to be able to hold tokens in the token contract. This requires paying the token contract to create storage:\ 197 | `$ near call token.${OWNERACC} storage_deposit '{"account_id": "'${STAKINGCONTRACT}'"}' --accountId ${OWNERACC} --amount 0.00125` 198 | 2. Calculate the time range for the farm:\ 199 | `$ export STARTIN=360` Start farm in 360 seconds.\ 200 | `$ export DURATION=3600` Run the farm for 3600 seconds.\ 201 | `$ export STARTDATE=$(expr $(date +%s) + ${STARTIN})` Calculate the unix timestamp at which to start farming.\ 202 | `$ export ENDDATE=$(expr ${STARTDATE} + ${DURATION})"000000000"` Calculate the unix nanosecond timestamp at which to end farming.\ 203 | `$ export STARTDATE=${STARTDATE}"000000000"` Make startdate nanoseconds. 204 | 3. Create the farm in the stake&farm contract by transferring tokens to it:\ 205 | `$ near call token.${OWNERACC} ft_transfer_call '{"receiver_id": "'${STAKINGCONTRACT}'", "amount": "10000000000000", "msg": "{\"name\": \"Example Token\", \"start_date\": \"'${STARTDATE}'\", \"end_date\": \"'${ENDDATE}'\" }"}' --accountId ${OWNERACC} --amount 0.000000000000000000000001 --gas 300000000000000`\ 206 | The farm is now ready. Stake holders that have a stake in the contract between STARTDATE and ENDDATE will receive a share of the farmed tokens. 207 | 4. Verify that the farm is available:\ 208 | `$ near view ${STAKINGCONTRACT} get_farms '{ "from_index": 0, "limit": 128 }'`\ 209 | This will list the first 128 farms configured in the contract. Right now, only one farm should be returned. 210 | 211 | ## Stake Near and Farm Tokens 212 | 213 | Staking NEAR with a stake&farm contract is easy: 214 | 215 | 1. Make some settings globally available:\ 216 | `$ export STAKINGCONTRACT=validator2.factory01.littlefarm.testnet` The contract with which you want to stake.\ 217 | `$ export WHITELIST=whitelist01.littlefarm.testnet` The global whitelisting contract.\ 218 | `$ export MYACCOUNT=investment.testnet` The name of your actual NEAR account with which to stake. 219 | 2. Make sure your stake&farm contract is whitelisted:\ 220 | `$ near view ${WHITELIST} is_whitelisted '{"staking_pool_account_id":"'${STAKINGCONTRACT}''"}'`\ 221 | The result should be "true". 222 | 3. Stake some NEAR with the contract:\ 223 | `$ near call ${STAKINGCONTRACT} deposit_and_stake '' --accountId ${MYACCOUNT} --amount 30 --gas 300000000000000`\ 224 | This will stake 30 NEAR with the contract. 225 | 226 | ### Cashing out NEAR rewards 227 | 228 | 1. Unstake all NEAR (you also have the option to only unstake a fraction):\ 229 | `$ near call ${STAKINGCONTRACT} unstake_all --accountId ${MYACCOUNT} --gas=300000000000000`\ 230 | This will unstake all your stake in the contract, but the NEAR will not be available for withdrawal for 2-3 epochs. 231 | 2. Withdraw the NEAR:\ 232 | `$ near call ${STAKINGCONTRACT} withdraw_all --accountId ${MYACCOUNT} --gas=300000000000000`\ 233 | This will make your share of the rewards and your initial staking amount available in your wallet again. 234 | 235 | For more information, check [staking&delegation](https://docs.near.org/docs/develop/node/validator/staking-and-delegation). 236 | 237 | 238 | ### Cashing out farmed tokens 239 | 240 | 1. Check which farms are available in the contract:\ 241 | `$ near view ${STAKINGCONTRACT} get_farms '{ "from_index": 0, "limit": 128 }'` 242 | 2. For each farm, you can calculate how many tokens you earned. The following calls refer to "farm_id", which is the position of the farm in the list created by the previous call, starting with 0 (zero).\ 243 | For example, the first farm of the contract:\ 244 | `$ near view ${STAKINGCONTRACT} get_unclaimed_reward '{"account_id":"'${MYACCOUNT}'", "farm_id":0}'` 245 | 3. If the result of the previous call is greater than zero, you have earned tokens! Here's how to withdraw them. 246 | 247 | Each farm has a field "token_id" which refers to the fungible token contract that issued the tokens in the first place. 248 | It is important that you have storage in this contract to be able to receive tokens from it. For example, if the 249 | token_id is "token.example.testnet" you will have to create storage like this: 250 | 251 | `$ near call token.example.testnet storage_deposit '' --accountId ${OWNERACC} --amount 0.0125` 252 | 253 | Afterwards you can claim your rewards: 254 | 255 | `$ near call ${STAKINGCONTRACT} claim '{"account_id": "'${MYACCOUNT}'"}' --accountId ${OWNERACC} --gas 100000000000000 --depositYocto 1` 256 | 257 | Claim from lockup: 258 | 259 | `$ near call ${STAKINGCONTRACT} claim '{"token_id": "token.example.testnet", "delegator_id": "${LOCKUPACC}"}' --accountId ${OWNERACC} --gas 100000000000000 --depositYocto 1` 260 | where LOCKUPACC - your lockup account ending .lockup.near 261 | 262 | This will transfer the tokens earned from the first farm (farm_id:0) to your account. Please make sure that token_id and farm_id exactly match 263 | what was returned by the previous "get_farms" call. 264 | 265 | The "claim" call can be expensive, resulting in an error that signals that not enough gas was available. Increaste the gas parameter in that case and try again. 266 | 267 | # Frequently Asked Questions 268 | 269 | [TBD] 270 | 271 | -------------------------------------------------------------------------------- /staking-farm/src/farm.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::{ 2 | Timestamp, assert_one_yocto, ext_contract, is_promise_success, near, promise_result_as_success, 3 | }; 4 | 5 | use crate::stake::ext_self; 6 | use crate::*; 7 | 8 | const SESSION_INTERVAL: u64 = 1_000_000_000; 9 | const DENOMINATOR: u128 = 1_000_000_000_000_000_000_000_000; 10 | 11 | /// Amount of gas for fungible token transfers. 12 | pub const GAS_FOR_FT_TRANSFER: Gas = Gas::from_tgas(10); 13 | /// hotfix_insuffient_gas_for_mft_resolve_transfer, increase from 5T to 20T 14 | pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(20); 15 | /// Gas for calling `get_owner` method. 16 | pub const GAS_FOR_GET_OWNER: Gas = Gas::from_tgas(10); 17 | pub const GAS_LEFTOVERS: Gas = Gas::from_tgas(20); 18 | /// Get owner method on external contracts. 19 | pub const GET_OWNER_METHOD: &str = "get_owner_account_id"; 20 | 21 | /// External interface for the fungible token contract. 22 | #[ext_contract(ext_fungible_token)] 23 | #[allow(dead_code)] 24 | pub trait ExtFungibleToken { 25 | fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option); 26 | } 27 | 28 | #[near(serializers=[borsh])] 29 | #[derive(Clone, Debug)] 30 | pub struct RewardDistribution { 31 | pub undistributed: Balance, 32 | /// DEPRECATED: Unused. 33 | pub _deprecated_unclaimed: Balance, 34 | pub reward_per_share: U256, 35 | pub reward_round: u64, 36 | } 37 | 38 | #[near(serializers=[borsh])] 39 | pub struct Farm { 40 | pub name: String, 41 | pub token_id: AccountId, 42 | pub amount: Balance, 43 | pub start_date: Timestamp, 44 | pub end_date: Timestamp, 45 | pub last_distribution: RewardDistribution, 46 | } 47 | 48 | impl Farm { 49 | pub fn is_active(&self) -> bool { 50 | self.last_distribution.undistributed > 0 51 | } 52 | } 53 | 54 | #[near] 55 | impl StakingContract { 56 | pub(crate) fn internal_deposit_farm_tokens( 57 | &mut self, 58 | token_id: &AccountId, 59 | name: String, 60 | amount: Balance, 61 | start_date: Timestamp, 62 | end_date: Timestamp, 63 | ) { 64 | assert!(start_date >= env::block_timestamp(), "ERR_FARM_TOO_EARLY"); 65 | assert!(end_date > start_date + SESSION_INTERVAL, "ERR_FARM_DATE"); 66 | assert!(amount > 0, "ERR_FARM_AMOUNT_NON_ZERO"); 67 | assert!( 68 | amount / ((end_date - start_date) / SESSION_INTERVAL) as u128 > 0, 69 | "ERR_FARM_AMOUNT_TOO_SMALL" 70 | ); 71 | self.farms.push(&Farm { 72 | name, 73 | token_id: token_id.clone(), 74 | amount, 75 | start_date, 76 | end_date, 77 | last_distribution: RewardDistribution { 78 | undistributed: amount, 79 | _deprecated_unclaimed: 0, 80 | reward_per_share: U256::zero(), 81 | reward_round: 0, 82 | }, 83 | }); 84 | self.active_farms.push(self.farms.len() - 1); 85 | } 86 | 87 | pub(crate) fn internal_add_farm_tokens( 88 | &mut self, 89 | token_id: &AccountId, 90 | farm_id: u64, 91 | additional_amount: Balance, 92 | start_date: Option, 93 | end_date: Timestamp, 94 | ) { 95 | let mut farm = self.internal_get_farm(farm_id); 96 | assert_eq!(&farm.token_id, token_id, "ERR_FARM_INVALID_TOKEN_ID"); 97 | assert!(additional_amount > 0, "ERR_FARM_AMOUNT_NON_ZERO"); 98 | 99 | if let Some(distribution) = self.internal_calculate_distribution( 100 | &farm, 101 | self.total_stake_shares - self.total_burn_shares, 102 | ) { 103 | // The farm has started. 104 | assert!(start_date.is_none(), "ERR_FARM_HAS_STARTED"); 105 | farm.amount = distribution.undistributed + additional_amount; 106 | farm.start_date = env::block_timestamp(); 107 | farm.last_distribution = RewardDistribution { 108 | undistributed: farm.amount, 109 | _deprecated_unclaimed: 0, 110 | reward_per_share: distribution.reward_per_share, 111 | reward_round: 0, 112 | }; 113 | } else { 114 | // The farm hasn't started, we can replace the farm. 115 | let start_date = start_date.unwrap_or(farm.start_date); 116 | assert!(start_date >= env::block_timestamp(), "ERR_FARM_TOO_EARLY"); 117 | farm.amount += additional_amount; 118 | farm.start_date = start_date; 119 | farm.last_distribution = RewardDistribution { 120 | undistributed: farm.amount, 121 | _deprecated_unclaimed: 0, 122 | reward_per_share: U256::zero(), 123 | reward_round: 0, 124 | }; 125 | } 126 | farm.end_date = end_date; 127 | 128 | assert!( 129 | farm.end_date > farm.start_date + SESSION_INTERVAL, 130 | "ERR_FARM_DATE" 131 | ); 132 | assert!(farm.amount > 0, "ERR_FARM_AMOUNT_NON_ZERO"); 133 | assert!( 134 | farm.amount / ((farm.end_date - farm.start_date) / SESSION_INTERVAL) as u128 > 0, 135 | "ERR_FARM_AMOUNT_TOO_SMALL" 136 | ); 137 | 138 | self.farms.replace(farm_id, &farm); 139 | } 140 | 141 | pub(crate) fn internal_get_farm(&self, farm_id: u64) -> Farm { 142 | self.farms.get(farm_id).expect("ERR_NO_FARM") 143 | } 144 | 145 | fn internal_calculate_distribution( 146 | &self, 147 | farm: &Farm, 148 | total_staked: Balance, 149 | ) -> Option { 150 | if farm.start_date > env::block_timestamp() { 151 | // Farm hasn't started. 152 | return None; 153 | } 154 | let mut distribution = farm.last_distribution.clone(); 155 | if distribution.undistributed == 0 { 156 | // Farm has ended. 157 | return Some(distribution); 158 | } 159 | distribution.reward_round = (env::block_timestamp() - farm.start_date) / SESSION_INTERVAL; 160 | let reward_per_session = 161 | farm.amount / (farm.end_date - farm.start_date) as u128 * SESSION_INTERVAL as u128; 162 | let mut reward_added = (distribution.reward_round - farm.last_distribution.reward_round) 163 | as u128 164 | * reward_per_session; 165 | if farm.last_distribution.undistributed < reward_added { 166 | // Last step when the last tokens are getting distributed. 167 | reward_added = farm.last_distribution.undistributed; 168 | let increase_reward_round = (reward_added / reward_per_session) as u64; 169 | distribution.reward_round = farm.last_distribution.reward_round + increase_reward_round; 170 | if increase_reward_round as u128 * reward_per_session < reward_added { 171 | // Fix the rounding. 172 | distribution.reward_round += 1; 173 | } 174 | } 175 | distribution.undistributed -= reward_added; 176 | if total_staked == 0 { 177 | distribution.reward_per_share = U256::zero(); 178 | } else { 179 | distribution.reward_per_share = farm.last_distribution.reward_per_share 180 | + U256::from(reward_added) * U256::from(DENOMINATOR) / U256::from(total_staked); 181 | } 182 | Some(distribution) 183 | } 184 | 185 | pub(crate) fn internal_unclaimed_balance( 186 | &self, 187 | account: &Account, 188 | farm_id: u64, 189 | farm: &mut Farm, 190 | ) -> (U256, Balance) { 191 | if let Some(distribution) = self 192 | .internal_calculate_distribution(farm, self.total_stake_shares - self.total_burn_shares) 193 | { 194 | if distribution.reward_round != farm.last_distribution.reward_round { 195 | farm.last_distribution = distribution.clone(); 196 | } 197 | if !account.is_burn_account { 198 | let user_rps = account 199 | .last_farm_reward_per_share 200 | .get(&farm_id) 201 | .cloned() 202 | .unwrap_or(U256::zero()); 203 | return ( 204 | farm.last_distribution.reward_per_share, 205 | (U256::from(account.stake_shares) * (distribution.reward_per_share - user_rps) 206 | / DENOMINATOR) 207 | .as_u128(), 208 | ); 209 | } 210 | } 211 | (U256::zero(), 0) 212 | } 213 | 214 | fn internal_distribute_reward(&mut self, account: &mut Account, farm_id: u64, farm: &mut Farm) { 215 | let (new_user_rps, claim_amount) = self.internal_unclaimed_balance(account, farm_id, farm); 216 | if !account.is_burn_account { 217 | account 218 | .last_farm_reward_per_share 219 | .insert(farm_id, new_user_rps); 220 | *account.amounts.entry(farm.token_id.clone()).or_default() += claim_amount; 221 | env::log_str(&format!( 222 | "Record {} {} reward from farm #{}", 223 | claim_amount, farm.token_id, farm_id 224 | )); 225 | } 226 | } 227 | 228 | /// Distribute all rewards for the given user. 229 | pub(crate) fn internal_distribute_all_rewards(&mut self, account: &mut Account) { 230 | let old_active_farms = self.active_farms.clone(); 231 | self.active_farms = vec![]; 232 | for farm_id in old_active_farms.into_iter() { 233 | if let Some(mut farm) = self.farms.get(farm_id) { 234 | self.internal_distribute_reward(account, farm_id, &mut farm); 235 | self.farms.replace(farm_id, &farm); 236 | // TODO: currently all farms continue to be active. 237 | // if farm.is_active() { 238 | self.active_farms.push(farm_id); 239 | // } 240 | } 241 | } 242 | } 243 | 244 | fn internal_user_token_deposit( 245 | &mut self, 246 | account_id: &AccountId, 247 | token_id: &AccountId, 248 | amount: Balance, 249 | ) { 250 | let mut account = self.internal_get_account(account_id); 251 | *account.amounts.entry(token_id.clone()).or_default() += amount; 252 | self.internal_save_account(account_id, &account); 253 | } 254 | 255 | fn internal_claim( 256 | &mut self, 257 | token_id: &AccountId, 258 | claim_account_id: &AccountId, 259 | send_account_id: &AccountId, 260 | ) -> Promise { 261 | let mut account = self.internal_get_account(claim_account_id); 262 | self.internal_distribute_all_rewards(&mut account); 263 | let amount = account.amounts.remove(token_id).unwrap_or(0); 264 | assert!(amount > 0, "ERR_ZERO_AMOUNT"); 265 | env::log_str(&format!( 266 | "{} receives {} of {} from {}", 267 | send_account_id, amount, token_id, claim_account_id 268 | )); 269 | self.internal_save_account(claim_account_id, &account); 270 | ext_fungible_token::ext(token_id.clone()) 271 | .with_attached_deposit(NearToken::from_yoctonear(1)) 272 | .with_static_gas(GAS_FOR_FT_TRANSFER) 273 | .ft_transfer(send_account_id.clone(), U128(amount), None) 274 | .then( 275 | ext_self::ext(env::current_account_id()) 276 | .with_attached_deposit(NearToken::from_yoctonear(0)) 277 | .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) 278 | .callback_post_withdraw_reward( 279 | token_id.clone(), 280 | // Return funds to the account that was deducted from vs caller. 281 | claim_account_id.clone(), 282 | U128(amount), 283 | ), 284 | ) 285 | } 286 | } 287 | 288 | #[near_bindgen] 289 | impl StakingContract { 290 | /// Callback after checking owner for the delegated claim. 291 | #[private] 292 | pub fn callback_post_get_owner( 293 | &mut self, 294 | token_id: AccountId, 295 | delegator_id: AccountId, 296 | account_id: AccountId, 297 | ) -> Promise { 298 | let owner_id: AccountId = near_sdk::serde_json::from_slice( 299 | &promise_result_as_success().expect("get_owner must have result"), 300 | ) 301 | .expect("Failed to parse"); 302 | assert_eq!(owner_id, account_id, "Caller is not an owner"); 303 | self.internal_claim(&token_id, &delegator_id, &account_id) 304 | } 305 | 306 | /// Callback from depositing funds to the user's account. 307 | /// If it failed, return funds to the user's account. 308 | #[private] 309 | pub fn callback_post_withdraw_reward( 310 | &mut self, 311 | token_id: AccountId, 312 | sender_id: AccountId, 313 | amount: U128, 314 | ) { 315 | if !is_promise_success() { 316 | // This reverts the changes from the claim function. 317 | self.internal_user_token_deposit(&sender_id, &token_id, amount.0); 318 | env::log_str(&format!( 319 | "Returned {} {} to {}", 320 | amount.0, token_id, sender_id 321 | )); 322 | } 323 | } 324 | 325 | /// Claim given tokens for given account. 326 | /// If delegator is provided, it will call it's `get_owner` method to confirm that caller 327 | /// can execute on behalf of this contract. 328 | /// - Requires one yoctoNEAR. To pass to the ft_transfer call and to guarantee the full access key. 329 | #[payable] 330 | pub fn claim(&mut self, token_id: AccountId, delegator_id: Option) -> Promise { 331 | assert_one_yocto(); 332 | let account_id = env::predecessor_account_id(); 333 | if let Some(delegator_id) = delegator_id { 334 | Promise::new(delegator_id.clone()) 335 | .function_call( 336 | GET_OWNER_METHOD.to_string(), 337 | vec![], 338 | NearToken::from_yoctonear(0), 339 | GAS_FOR_GET_OWNER, 340 | ) 341 | .then( 342 | ext_self::ext(env::current_account_id()) 343 | .with_attached_deposit(NearToken::from_yoctonear(0)) 344 | .with_static_gas( 345 | env::prepaid_gas() 346 | .saturating_sub(env::used_gas()) 347 | .saturating_sub(GAS_FOR_GET_OWNER) 348 | .saturating_sub(GAS_LEFTOVERS), 349 | ) 350 | .callback_post_get_owner(token_id, delegator_id, account_id), 351 | ) 352 | } else { 353 | self.internal_claim(&token_id, &account_id, &account_id) 354 | } 355 | } 356 | 357 | /// Stops given farm at the current moment. 358 | /// Warning: IF OWNER ACCOUNT DOESN'T HAVE STORAGE, THESE FUNDS WILL BE STUCK ON THE STAKING FARM. 359 | pub fn stop_farm(&mut self, farm_id: u64) -> Promise { 360 | self.assert_owner(); 361 | let mut farm = self.internal_get_farm(farm_id); 362 | let leftover_amount = if let Some(distribution) = self.internal_calculate_distribution( 363 | &farm, 364 | self.total_stake_shares - self.total_burn_shares, 365 | ) { 366 | farm.end_date = env::block_timestamp(); 367 | farm.last_distribution = distribution; 368 | farm.last_distribution.undistributed 369 | } else { 370 | farm.amount 371 | }; 372 | farm.amount -= leftover_amount; 373 | farm.last_distribution.undistributed = 0; 374 | self.farms.replace(farm_id, &farm); 375 | ext_fungible_token::ext(farm.token_id.clone()) 376 | .with_attached_deposit(NearToken::from_yoctonear(1)) 377 | .with_static_gas(GAS_FOR_FT_TRANSFER) 378 | .ft_transfer( 379 | StakingContract::internal_get_owner_id(), 380 | U128(leftover_amount), 381 | None, 382 | ) 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /staking-farm/src/internal.rs: -------------------------------------------------------------------------------- 1 | use crate::owner::{FACTORY_KEY, OWNER_KEY}; 2 | use crate::stake::ext_self; 3 | use crate::*; 4 | use near_sdk::NearToken; 5 | use near_sdk::log; 6 | 7 | /// Zero address is implicit address that doesn't have a key for it. 8 | /// Used for burning tokens. 9 | pub const ZERO_ADDRESS: &str = "0000000000000000000000000000000000000000000000000000000000000000"; 10 | 11 | /// Minimum amount that will be sent to burn. This is to ensure there is enough storage on the other side. 12 | pub const MIN_BURN_AMOUNT: Balance = 1694457700619870000000; 13 | 14 | impl StakingContract { 15 | /********************/ 16 | /* Internal methods */ 17 | /********************/ 18 | 19 | /// Restakes the current `total_staked_balance` again. 20 | pub(crate) fn internal_restake(&mut self) { 21 | if self.paused { 22 | return; 23 | } 24 | // Stakes with the staking public key. If the public key is invalid the entire function 25 | // call will be rolled back. 26 | Promise::new(env::current_account_id()) 27 | .stake( 28 | NearToken::from_yoctonear(self.total_staked_balance), 29 | self.stake_public_key.clone(), 30 | ) 31 | .then( 32 | ext_self::ext(env::current_account_id()) 33 | .with_attached_deposit(NearToken::from_yoctonear(NO_DEPOSIT)) 34 | .with_static_gas(ON_STAKE_ACTION_GAS) 35 | .on_stake_action(), 36 | ); 37 | } 38 | 39 | pub(crate) fn internal_deposit(&mut self) -> u128 { 40 | let account_id = env::predecessor_account_id(); 41 | let mut account = self.internal_get_account(&account_id); 42 | let amount = env::attached_deposit().as_yoctonear(); 43 | account.unstaked += amount; 44 | self.internal_save_account(&account_id, &account); 45 | self.last_total_balance += amount; 46 | 47 | log!( 48 | "@{} deposited {}. New unstaked balance is {}", 49 | account_id, 50 | amount, 51 | account.unstaked 52 | ); 53 | amount 54 | } 55 | 56 | pub(crate) fn internal_withdraw(&mut self, account_id: &AccountId, amount: Balance) { 57 | assert!(amount > 0, "Withdrawal amount should be positive"); 58 | 59 | let mut account = self.internal_get_account(account_id); 60 | assert!( 61 | account.unstaked >= amount, 62 | "Not enough unstaked balance to withdraw" 63 | ); 64 | assert!( 65 | account.unstaked_available_epoch_height <= env::epoch_height(), 66 | "The unstaked balance is not yet available due to unstaking delay" 67 | ); 68 | account.unstaked -= amount; 69 | self.internal_save_account(account_id, &account); 70 | 71 | log!( 72 | "@{} withdrawing {}. New unstaked balance is {}", 73 | account_id, 74 | amount, 75 | account.unstaked 76 | ); 77 | 78 | Promise::new(account_id.clone()).transfer(NearToken::from_yoctonear(amount)); 79 | self.last_total_balance -= amount; 80 | } 81 | 82 | pub(crate) fn internal_stake(&mut self, amount: Balance) { 83 | assert!(amount > 0, "Staking amount should be positive"); 84 | 85 | let account_id = env::predecessor_account_id(); 86 | let mut account = self.internal_get_account(&account_id); 87 | 88 | // Distribute rewards from all the farms for the given user. 89 | self.internal_distribute_all_rewards(&mut account); 90 | 91 | // Calculate the number of "stake" shares that the account will receive for staking the 92 | // given amount. 93 | let num_shares = self.num_shares_from_staked_amount_rounded_down(amount); 94 | assert!( 95 | num_shares > 0, 96 | "The calculated number of \"stake\" shares received for staking should be positive" 97 | ); 98 | // The amount of tokens the account will be charged from the unstaked balance. 99 | // Rounded down to avoid overcharging the account to guarantee that the account can always 100 | // unstake at least the same amount as staked. 101 | let charge_amount = self.staked_amount_from_num_shares_rounded_down(num_shares); 102 | assert!( 103 | charge_amount > 0, 104 | "Invariant violation. Calculated staked amount must be positive, because \"stake\" share price should be at least 1" 105 | ); 106 | 107 | assert!( 108 | account.unstaked >= charge_amount, 109 | "Not enough unstaked balance to stake" 110 | ); 111 | account.unstaked -= charge_amount; 112 | account.stake_shares += num_shares; 113 | self.internal_save_account(&account_id, &account); 114 | 115 | // The staked amount that will be added to the total to guarantee the "stake" share price 116 | // never decreases. The difference between `stake_amount` and `charge_amount` is paid 117 | // from the allocated STAKE_SHARE_PRICE_GUARANTEE_FUND. 118 | let stake_amount = self.staked_amount_from_num_shares_rounded_up(num_shares); 119 | 120 | self.total_staked_balance += stake_amount; 121 | self.total_stake_shares += num_shares; 122 | 123 | log!( 124 | "@{} staking {}. Received {} new staking shares. Total {} unstaked balance and {} \ 125 | staking shares", 126 | account_id, 127 | charge_amount, 128 | num_shares, 129 | account.unstaked, 130 | account.stake_shares 131 | ); 132 | log!( 133 | "Contract total staked balance is {}. Total number of shares {}", 134 | self.total_staked_balance, 135 | self.total_stake_shares 136 | ); 137 | } 138 | 139 | pub(crate) fn inner_unstake(&mut self, account_id: &AccountId, amount: u128) { 140 | assert!(amount > 0, "Unstaking amount should be positive"); 141 | 142 | let mut account = self.internal_get_account(account_id); 143 | 144 | // Distribute rewards from all the farms for the given user. 145 | self.internal_distribute_all_rewards(&mut account); 146 | 147 | assert!( 148 | self.total_staked_balance > 0, 149 | "The contract doesn't have staked balance" 150 | ); 151 | // Calculate the number of shares required to unstake the given amount. 152 | // NOTE: The number of shares the account will pay is rounded up. 153 | let num_shares = self.num_shares_from_staked_amount_rounded_up(amount); 154 | assert!( 155 | num_shares > 0, 156 | "Invariant violation. The calculated number of \"stake\" shares for unstaking should be positive" 157 | ); 158 | assert!( 159 | account.stake_shares >= num_shares, 160 | "Not enough staked balance to unstake" 161 | ); 162 | 163 | // Calculating the amount of tokens the account will receive by unstaking the corresponding 164 | // number of "stake" shares, rounding up. 165 | let receive_amount = self.staked_amount_from_num_shares_rounded_up(num_shares); 166 | assert!( 167 | receive_amount > 0, 168 | "Invariant violation. Calculated staked amount must be positive, because \"stake\" share price should be at least 1" 169 | ); 170 | 171 | account.stake_shares -= num_shares; 172 | account.unstaked += receive_amount; 173 | account.unstaked_available_epoch_height = env::epoch_height() + NUM_EPOCHS_TO_UNLOCK; 174 | self.internal_save_account(account_id, &account); 175 | 176 | // The amount tokens that will be unstaked from the total to guarantee the "stake" share 177 | // price never decreases. The difference between `receive_amount` and `unstake_amount` is 178 | // paid from the allocated STAKE_SHARE_PRICE_GUARANTEE_FUND. 179 | let unstake_amount = self.staked_amount_from_num_shares_rounded_down(num_shares); 180 | 181 | self.total_staked_balance -= unstake_amount; 182 | self.total_stake_shares -= num_shares; 183 | if account.is_burn_account { 184 | self.total_burn_shares -= num_shares; 185 | } 186 | 187 | log!( 188 | "@{} unstaking {}. Spent {} staking shares. Total {} unstaked balance and {} \ 189 | staking shares", 190 | account_id, 191 | receive_amount, 192 | num_shares, 193 | account.unstaked, 194 | account.stake_shares 195 | ); 196 | log!( 197 | "Contract total staked balance is {}. Total number of shares {}", 198 | self.total_staked_balance, 199 | self.total_stake_shares 200 | ); 201 | } 202 | 203 | pub(crate) fn internal_unstake_all(&mut self, account_id: &AccountId) { 204 | // Unstake action always restakes 205 | self.internal_ping(); 206 | 207 | let account = self.internal_get_account(account_id); 208 | let amount = self.staked_amount_from_num_shares_rounded_down(account.stake_shares); 209 | self.inner_unstake(account_id, amount); 210 | 211 | self.internal_restake(); 212 | } 213 | 214 | /// Add given number of staked shares to the given account. 215 | fn internal_add_shares(&mut self, account_id: &AccountId, num_shares: NumStakeShares) { 216 | if num_shares > 0 { 217 | let mut account = self.internal_get_account(account_id); 218 | account.stake_shares += num_shares; 219 | self.internal_save_account(account_id, &account); 220 | // Increasing the total amount of "stake" shares. 221 | self.total_stake_shares += num_shares; 222 | } 223 | } 224 | 225 | /// Distributes rewards after the new epoch. It's automatically called before every action. 226 | /// Returns true if the current epoch height is different from the last epoch height. 227 | pub(crate) fn internal_ping(&mut self) -> bool { 228 | let epoch_height = env::epoch_height(); 229 | if self.last_epoch_height == epoch_height { 230 | return false; 231 | } 232 | self.last_epoch_height = epoch_height; 233 | 234 | // New total amount (both locked and unlocked balances). 235 | // NOTE: We need to subtract `attached_deposit` in case `ping` called from `deposit` call 236 | // since the attached deposit gets included in the `account_balance`, and we have not 237 | // accounted it yet. 238 | let total_balance = env::account_locked_balance().as_yoctonear() 239 | + env::account_balance().as_yoctonear() 240 | - env::attached_deposit().as_yoctonear(); 241 | 242 | assert!( 243 | total_balance >= self.last_total_balance, 244 | "The new total balance should not be less than the old total balance {} {}", 245 | total_balance, 246 | self.last_total_balance 247 | ); 248 | let total_reward = total_balance - self.last_total_balance; 249 | if total_reward > 0 { 250 | // The validation fee that will be burnt. 251 | let burn_fee = self.burn_fee_fraction.multiply(total_reward); 252 | 253 | // The validation fee that the contract owner takes. 254 | let owners_fee = self 255 | .reward_fee_fraction 256 | .current() 257 | .multiply(total_reward - burn_fee); 258 | 259 | // Distributing the remaining reward to the delegators first. 260 | let remaining_reward = total_reward - owners_fee - burn_fee; 261 | self.total_staked_balance += remaining_reward; 262 | 263 | // Now buying "stake" shares for the burn. 264 | let num_burn_shares = self.num_shares_from_staked_amount_rounded_down(burn_fee); 265 | self.total_burn_shares += num_burn_shares; 266 | 267 | // Now buying "stake" shares for the contract owner at the new share price. 268 | let num_owner_shares = self.num_shares_from_staked_amount_rounded_down(owners_fee); 269 | 270 | self.internal_add_shares( 271 | &ZERO_ADDRESS.parse().expect("INTERNAL FAIL"), 272 | num_burn_shares, 273 | ); 274 | self.internal_add_shares(&StakingContract::internal_get_owner_id(), num_owner_shares); 275 | 276 | // Increasing the total staked balance by the owners fee, no matter whether the owner 277 | // received any shares or not. 278 | self.total_staked_balance += owners_fee + burn_fee; 279 | 280 | log!( 281 | "Epoch {}: Contract received total rewards of {} tokens. \ 282 | New total staked balance is {}. Total number of shares {}", 283 | epoch_height, 284 | total_reward, 285 | self.total_staked_balance, 286 | self.total_stake_shares, 287 | ); 288 | if num_owner_shares > 0 || num_burn_shares > 0 { 289 | log!( 290 | "Total rewards fee is {} and burn is {} stake shares.", 291 | num_owner_shares, 292 | num_burn_shares 293 | ); 294 | } 295 | } 296 | 297 | self.last_total_balance = total_balance; 298 | true 299 | } 300 | 301 | /// Returns the number of "stake" shares rounded down corresponding to the given staked balance 302 | /// amount. 303 | /// 304 | /// price = total_staked / total_shares 305 | /// Price is fixed 306 | /// (total_staked + amount) / (total_shares + num_shares) = total_staked / total_shares 307 | /// (total_staked + amount) * total_shares = total_staked * (total_shares + num_shares) 308 | /// amount * total_shares = total_staked * num_shares 309 | /// num_shares = amount * total_shares / total_staked 310 | pub(crate) fn num_shares_from_staked_amount_rounded_down( 311 | &self, 312 | amount: Balance, 313 | ) -> NumStakeShares { 314 | assert!( 315 | self.total_staked_balance > 0, 316 | "The total staked balance can't be 0" 317 | ); 318 | (U256::from(self.total_stake_shares) * U256::from(amount) 319 | / U256::from(self.total_staked_balance)) 320 | .as_u128() 321 | } 322 | 323 | /// Returns the number of "stake" shares rounded up corresponding to the given staked balance 324 | /// amount. 325 | /// 326 | /// Rounding up division of `a / b` is done using `(a + b - 1) / b`. 327 | pub(crate) fn num_shares_from_staked_amount_rounded_up( 328 | &self, 329 | amount: Balance, 330 | ) -> NumStakeShares { 331 | assert!( 332 | self.total_staked_balance > 0, 333 | "The total staked balance can't be 0" 334 | ); 335 | ((U256::from(self.total_stake_shares) * U256::from(amount) 336 | + U256::from(self.total_staked_balance - 1)) 337 | / U256::from(self.total_staked_balance)) 338 | .as_u128() 339 | } 340 | 341 | /// Returns the staked amount rounded down corresponding to the given number of "stake" shares. 342 | pub(crate) fn staked_amount_from_num_shares_rounded_down( 343 | &self, 344 | num_shares: NumStakeShares, 345 | ) -> Balance { 346 | assert!( 347 | self.total_stake_shares > 0, 348 | "The total number of stake shares can't be 0" 349 | ); 350 | (U256::from(self.total_staked_balance) * U256::from(num_shares) 351 | / U256::from(self.total_stake_shares)) 352 | .as_u128() 353 | } 354 | 355 | /// Returns the staked amount rounded up corresponding to the given number of "stake" shares. 356 | /// 357 | /// Rounding up division of `a / b` is done using `(a + b - 1) / b`. 358 | pub(crate) fn staked_amount_from_num_shares_rounded_up( 359 | &self, 360 | num_shares: NumStakeShares, 361 | ) -> Balance { 362 | assert!( 363 | self.total_stake_shares > 0, 364 | "The total number of stake shares can't be 0" 365 | ); 366 | ((U256::from(self.total_staked_balance) * U256::from(num_shares) 367 | + U256::from(self.total_stake_shares - 1)) 368 | / U256::from(self.total_stake_shares)) 369 | .as_u128() 370 | } 371 | 372 | /// Inner method to get the given account or a new default value account. 373 | pub(crate) fn internal_get_account(&self, account_id: &AccountId) -> Account { 374 | let mut account = self.accounts.get(account_id).unwrap_or_default(); 375 | account.is_burn_account = account_id.as_str() == ZERO_ADDRESS; 376 | account 377 | } 378 | 379 | /// Inner method to save the given account for a given account ID. 380 | /// If the account balances are 0, the account is deleted instead to release storage. 381 | pub(crate) fn internal_save_account(&mut self, account_id: &AccountId, account: &Account) { 382 | if account.unstaked > 0 || account.stake_shares > 0 || !account.amounts.is_empty() { 383 | self.accounts.insert(account_id, account); 384 | } else { 385 | self.accounts.remove(account_id); 386 | } 387 | } 388 | 389 | /// Returns current contract version. 390 | pub(crate) fn internal_get_version() -> String { 391 | format!("{}:{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) 392 | } 393 | 394 | /// Returns current owner from the storage. 395 | pub(crate) fn internal_get_owner_id() -> AccountId { 396 | String::from_utf8(env::storage_read(OWNER_KEY).expect("MUST HAVE OWNER")) 397 | .expect("INTERNAL_FAIL") 398 | .parse() 399 | .expect("INTERNAL_FAIL") 400 | } 401 | 402 | /// Returns current contract factory. 403 | pub(crate) fn internal_get_factory_id() -> AccountId { 404 | String::from_utf8(env::storage_read(FACTORY_KEY).expect("MUST HAVE FACTORY")) 405 | .expect("INTERNAL_FAIL") 406 | .parse() 407 | .expect("INTERNAL_FAIL") 408 | } 409 | 410 | /// 411 | /// Internal methods to work with storage registration. 412 | /// 413 | 414 | pub(crate) fn storage_registration_key(account_id: &AccountId) -> Vec { 415 | let mut key = REGISTERED_ACCOUNT_PREFIX.to_vec(); 416 | key.extend(account_id.as_bytes()); 417 | key 418 | } 419 | 420 | pub(crate) fn storage_is_registered(account_id: &AccountId) -> bool { 421 | env::storage_has_key(&Self::storage_registration_key(account_id)) 422 | } 423 | 424 | pub(crate) fn storage_register_account(account_id: &AccountId) { 425 | env::storage_write(&Self::storage_registration_key(account_id), &[]); 426 | } 427 | 428 | pub(crate) fn storage_take_registration(account_id: &AccountId) -> bool { 429 | env::storage_remove(&Self::storage_registration_key(account_id)) 430 | } 431 | 432 | pub(crate) fn min_storage_balance() -> NearToken { 433 | let byte_cost = env::storage_byte_cost().as_yoctonear(); 434 | NearToken::from_yoctonear(byte_cost * ACCOUNT_STORAGE_BYTES as u128) 435 | } 436 | 437 | /// Assets that receiver either has an account entry or registered storage explicitly. 438 | /// If account doesn't exist, tries to take away storage registration. Panics if neither is true. 439 | /// WARNING: this method mutates the contract state around storage registration so should be used only when creating account. 440 | pub(crate) fn internal_assert_receiver_storage( 441 | &mut self, 442 | receiver_id: &AccountId, 443 | _amount_shares: Balance, 444 | ) { 445 | if self.accounts.get(receiver_id).is_some() { 446 | return; 447 | } 448 | if Self::storage_take_registration(receiver_id) { 449 | return; 450 | } 451 | env::panic_str("ERR_STORAGE_NOT_REGISTERED"); 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /staking-farm/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "Inflector" 7 | version = "0.11.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" 10 | 11 | [[package]] 12 | name = "ahash" 13 | version = "0.4.7" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" 16 | 17 | [[package]] 18 | name = "aho-corasick" 19 | version = "0.7.18" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 22 | dependencies = [ 23 | "memchr", 24 | ] 25 | 26 | [[package]] 27 | name = "autocfg" 28 | version = "1.0.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 31 | 32 | [[package]] 33 | name = "base64" 34 | version = "0.13.0" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 37 | 38 | [[package]] 39 | name = "block-buffer" 40 | version = "0.9.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" 43 | dependencies = [ 44 | "block-padding", 45 | "generic-array", 46 | ] 47 | 48 | [[package]] 49 | name = "block-padding" 50 | version = "0.2.1" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" 53 | 54 | [[package]] 55 | name = "borsh" 56 | version = "0.8.2" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "09a7111f797cc721407885a323fb071636aee57f750b1a4ddc27397eba168a74" 59 | dependencies = [ 60 | "borsh-derive", 61 | "hashbrown 0.9.1", 62 | ] 63 | 64 | [[package]] 65 | name = "borsh-derive" 66 | version = "0.8.2" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "307f3740906bac2c118a8122fe22681232b244f1369273e45f1156b45c43d2dd" 69 | dependencies = [ 70 | "borsh-derive-internal", 71 | "borsh-schema-derive-internal", 72 | "proc-macro-crate", 73 | "proc-macro2", 74 | "syn", 75 | ] 76 | 77 | [[package]] 78 | name = "borsh-derive-internal" 79 | version = "0.8.2" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "d2104c73179359431cc98e016998f2f23bc7a05bc53e79741bcba705f30047bc" 82 | dependencies = [ 83 | "proc-macro2", 84 | "quote", 85 | "syn", 86 | ] 87 | 88 | [[package]] 89 | name = "borsh-schema-derive-internal" 90 | version = "0.8.2" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "ae29eb8418fcd46f723f8691a2ac06857d31179d33d2f2d91eb13967de97c728" 93 | dependencies = [ 94 | "proc-macro2", 95 | "quote", 96 | "syn", 97 | ] 98 | 99 | [[package]] 100 | name = "bs58" 101 | version = "0.4.0" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" 104 | 105 | [[package]] 106 | name = "byteorder" 107 | version = "1.4.3" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 110 | 111 | [[package]] 112 | name = "cfg-if" 113 | version = "0.1.10" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 116 | 117 | [[package]] 118 | name = "cfg-if" 119 | version = "1.0.0" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 122 | 123 | [[package]] 124 | name = "convert_case" 125 | version = "0.4.0" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 128 | 129 | [[package]] 130 | name = "cpufeatures" 131 | version = "0.2.1" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" 134 | dependencies = [ 135 | "libc", 136 | ] 137 | 138 | [[package]] 139 | name = "crunchy" 140 | version = "0.2.2" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" 143 | 144 | [[package]] 145 | name = "derive_more" 146 | version = "0.99.16" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "40eebddd2156ce1bb37b20bbe5151340a31828b1f2d22ba4141f3531710e38df" 149 | dependencies = [ 150 | "convert_case", 151 | "proc-macro2", 152 | "quote", 153 | "rustc_version", 154 | "syn", 155 | ] 156 | 157 | [[package]] 158 | name = "digest" 159 | version = "0.9.0" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" 162 | dependencies = [ 163 | "generic-array", 164 | ] 165 | 166 | [[package]] 167 | name = "generic-array" 168 | version = "0.14.4" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" 171 | dependencies = [ 172 | "typenum", 173 | "version_check", 174 | ] 175 | 176 | [[package]] 177 | name = "hashbrown" 178 | version = "0.9.1" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" 181 | dependencies = [ 182 | "ahash", 183 | ] 184 | 185 | [[package]] 186 | name = "hashbrown" 187 | version = "0.11.2" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 190 | 191 | [[package]] 192 | name = "hex" 193 | version = "0.4.3" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 196 | 197 | [[package]] 198 | name = "indexmap" 199 | version = "1.7.0" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" 202 | dependencies = [ 203 | "autocfg", 204 | "hashbrown 0.11.2", 205 | ] 206 | 207 | [[package]] 208 | name = "itoa" 209 | version = "0.4.8" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" 212 | 213 | [[package]] 214 | name = "keccak" 215 | version = "0.1.0" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "67c21572b4949434e4fc1e1978b99c5f77064153c59d998bf13ecd96fb5ecba7" 218 | 219 | [[package]] 220 | name = "lazy_static" 221 | version = "1.4.0" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 224 | 225 | [[package]] 226 | name = "libc" 227 | version = "0.2.106" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "a60553f9a9e039a333b4e9b20573b9e9b9c0bb3a11e201ccc48ef4283456d673" 230 | 231 | [[package]] 232 | name = "memchr" 233 | version = "2.4.1" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 236 | 237 | [[package]] 238 | name = "memory_units" 239 | version = "0.4.0" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" 242 | 243 | [[package]] 244 | name = "near-contract-standards" 245 | version = "4.0.0-pre.4" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "557388f4de72fa16ca1e2c8c368f8343f077c5d284d043e93be76a3b7e737872" 248 | dependencies = [ 249 | "near-sdk", 250 | ] 251 | 252 | [[package]] 253 | name = "near-primitives-core" 254 | version = "0.4.0" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "c2b3fb5acf3a494aed4e848446ef2d6ebb47dbe91c681105d4d1786c2ee63e52" 257 | dependencies = [ 258 | "base64", 259 | "borsh", 260 | "bs58", 261 | "derive_more", 262 | "hex", 263 | "lazy_static", 264 | "num-rational", 265 | "serde", 266 | "serde_json", 267 | "sha2", 268 | ] 269 | 270 | [[package]] 271 | name = "near-rpc-error-core" 272 | version = "0.1.0" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "ffa8dbf8437a28ac40fcb85859ab0d0b8385013935b000c7a51ae79631dd74d9" 275 | dependencies = [ 276 | "proc-macro2", 277 | "quote", 278 | "serde", 279 | "serde_json", 280 | "syn", 281 | ] 282 | 283 | [[package]] 284 | name = "near-rpc-error-macro" 285 | version = "0.1.0" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "0c6111d713e90c7c551dee937f4a06cb9ea2672243455a4454cc7566387ba2d9" 288 | dependencies = [ 289 | "near-rpc-error-core", 290 | "proc-macro2", 291 | "quote", 292 | "serde", 293 | "serde_json", 294 | "syn", 295 | ] 296 | 297 | [[package]] 298 | name = "near-runtime-utils" 299 | version = "4.0.0-pre.1" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "a48d80c4ca1d4cf99bc16490e1e3d49826c150dfc4410ac498918e45c7d98e07" 302 | dependencies = [ 303 | "lazy_static", 304 | "regex", 305 | ] 306 | 307 | [[package]] 308 | name = "near-sdk" 309 | version = "4.0.0-pre.4" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "10f570e260eae9169dd5ffe0764728b872c2df79b8786dfb5e57183bd383908e" 312 | dependencies = [ 313 | "base64", 314 | "borsh", 315 | "bs58", 316 | "near-primitives-core", 317 | "near-sdk-macros", 318 | "near-sys", 319 | "near-vm-logic", 320 | "serde", 321 | "serde_json", 322 | "wee_alloc", 323 | ] 324 | 325 | [[package]] 326 | name = "near-sdk-macros" 327 | version = "4.0.0-pre.4" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "65ddca18086f1ab14ce8541e0c23f503a322d45914f2fe08f571844045d32bde" 330 | dependencies = [ 331 | "Inflector", 332 | "proc-macro2", 333 | "quote", 334 | "syn", 335 | ] 336 | 337 | [[package]] 338 | name = "near-sys" 339 | version = "0.1.0" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "f6a7aa3f46fac44416d8a93d14f30a562c4d730a1c6bf14bffafab5f475c244a" 342 | 343 | [[package]] 344 | name = "near-vm-errors" 345 | version = "4.0.0-pre.1" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "e281d8730ed8cb0e3e69fb689acee6b93cdb43824cd69a8ffd7e1bfcbd1177d7" 348 | dependencies = [ 349 | "borsh", 350 | "hex", 351 | "near-rpc-error-macro", 352 | "serde", 353 | ] 354 | 355 | [[package]] 356 | name = "near-vm-logic" 357 | version = "4.0.0-pre.1" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "e11cb28a2d07f37680efdaf860f4c9802828c44fc50c08009e7884de75d982c5" 360 | dependencies = [ 361 | "base64", 362 | "borsh", 363 | "bs58", 364 | "byteorder", 365 | "near-primitives-core", 366 | "near-runtime-utils", 367 | "near-vm-errors", 368 | "serde", 369 | "sha2", 370 | "sha3", 371 | ] 372 | 373 | [[package]] 374 | name = "num-bigint" 375 | version = "0.3.3" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" 378 | dependencies = [ 379 | "autocfg", 380 | "num-integer", 381 | "num-traits", 382 | ] 383 | 384 | [[package]] 385 | name = "num-integer" 386 | version = "0.1.44" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 389 | dependencies = [ 390 | "autocfg", 391 | "num-traits", 392 | ] 393 | 394 | [[package]] 395 | name = "num-rational" 396 | version = "0.3.2" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" 399 | dependencies = [ 400 | "autocfg", 401 | "num-bigint", 402 | "num-integer", 403 | "num-traits", 404 | "serde", 405 | ] 406 | 407 | [[package]] 408 | name = "num-traits" 409 | version = "0.2.14" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 412 | dependencies = [ 413 | "autocfg", 414 | ] 415 | 416 | [[package]] 417 | name = "opaque-debug" 418 | version = "0.3.0" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" 421 | 422 | [[package]] 423 | name = "pest" 424 | version = "2.1.3" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" 427 | dependencies = [ 428 | "ucd-trie", 429 | ] 430 | 431 | [[package]] 432 | name = "proc-macro-crate" 433 | version = "0.1.5" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" 436 | dependencies = [ 437 | "toml", 438 | ] 439 | 440 | [[package]] 441 | name = "proc-macro2" 442 | version = "1.0.32" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" 445 | dependencies = [ 446 | "unicode-xid", 447 | ] 448 | 449 | [[package]] 450 | name = "quote" 451 | version = "1.0.10" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" 454 | dependencies = [ 455 | "proc-macro2", 456 | ] 457 | 458 | [[package]] 459 | name = "regex" 460 | version = "1.5.4" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" 463 | dependencies = [ 464 | "aho-corasick", 465 | "memchr", 466 | "regex-syntax", 467 | ] 468 | 469 | [[package]] 470 | name = "regex-syntax" 471 | version = "0.6.25" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 474 | 475 | [[package]] 476 | name = "rustc_version" 477 | version = "0.3.3" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" 480 | dependencies = [ 481 | "semver", 482 | ] 483 | 484 | [[package]] 485 | name = "ryu" 486 | version = "1.0.5" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 489 | 490 | [[package]] 491 | name = "semver" 492 | version = "0.11.0" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" 495 | dependencies = [ 496 | "semver-parser", 497 | ] 498 | 499 | [[package]] 500 | name = "semver-parser" 501 | version = "0.10.2" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" 504 | dependencies = [ 505 | "pest", 506 | ] 507 | 508 | [[package]] 509 | name = "serde" 510 | version = "1.0.130" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" 513 | dependencies = [ 514 | "serde_derive", 515 | ] 516 | 517 | [[package]] 518 | name = "serde_derive" 519 | version = "1.0.130" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" 522 | dependencies = [ 523 | "proc-macro2", 524 | "quote", 525 | "syn", 526 | ] 527 | 528 | [[package]] 529 | name = "serde_json" 530 | version = "1.0.68" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "0f690853975602e1bfe1ccbf50504d67174e3bcf340f23b5ea9992e0587a52d8" 533 | dependencies = [ 534 | "indexmap", 535 | "itoa", 536 | "ryu", 537 | "serde", 538 | ] 539 | 540 | [[package]] 541 | name = "sha2" 542 | version = "0.9.8" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "b69f9a4c9740d74c5baa3fd2e547f9525fa8088a8a958e0ca2409a514e33f5fa" 545 | dependencies = [ 546 | "block-buffer", 547 | "cfg-if 1.0.0", 548 | "cpufeatures", 549 | "digest", 550 | "opaque-debug", 551 | ] 552 | 553 | [[package]] 554 | name = "sha3" 555 | version = "0.9.1" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809" 558 | dependencies = [ 559 | "block-buffer", 560 | "digest", 561 | "keccak", 562 | "opaque-debug", 563 | ] 564 | 565 | [[package]] 566 | name = "staking-farm" 567 | version = "1.0.0" 568 | dependencies = [ 569 | "near-contract-standards", 570 | "near-sdk", 571 | "uint", 572 | ] 573 | 574 | [[package]] 575 | name = "static_assertions" 576 | version = "1.1.0" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 579 | 580 | [[package]] 581 | name = "syn" 582 | version = "1.0.81" 583 | source = "registry+https://github.com/rust-lang/crates.io-index" 584 | checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" 585 | dependencies = [ 586 | "proc-macro2", 587 | "quote", 588 | "unicode-xid", 589 | ] 590 | 591 | [[package]] 592 | name = "toml" 593 | version = "0.5.8" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 596 | dependencies = [ 597 | "serde", 598 | ] 599 | 600 | [[package]] 601 | name = "typenum" 602 | version = "1.14.0" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec" 605 | 606 | [[package]] 607 | name = "ucd-trie" 608 | version = "0.1.3" 609 | source = "registry+https://github.com/rust-lang/crates.io-index" 610 | checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" 611 | 612 | [[package]] 613 | name = "uint" 614 | version = "0.9.1" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "6470ab50f482bde894a037a57064480a246dbfdd5960bd65a44824693f08da5f" 617 | dependencies = [ 618 | "byteorder", 619 | "crunchy", 620 | "hex", 621 | "static_assertions", 622 | ] 623 | 624 | [[package]] 625 | name = "unicode-xid" 626 | version = "0.2.2" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 629 | 630 | [[package]] 631 | name = "version_check" 632 | version = "0.9.3" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 635 | 636 | [[package]] 637 | name = "wee_alloc" 638 | version = "0.4.5" 639 | source = "registry+https://github.com/rust-lang/crates.io-index" 640 | checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" 641 | dependencies = [ 642 | "cfg-if 0.1.10", 643 | "libc", 644 | "memory_units", 645 | "winapi", 646 | ] 647 | 648 | [[package]] 649 | name = "winapi" 650 | version = "0.3.9" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 653 | dependencies = [ 654 | "winapi-i686-pc-windows-gnu", 655 | "winapi-x86_64-pc-windows-gnu", 656 | ] 657 | 658 | [[package]] 659 | name = "winapi-i686-pc-windows-gnu" 660 | version = "0.4.0" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 663 | 664 | [[package]] 665 | name = "winapi-x86_64-pc-windows-gnu" 666 | version = "0.4.0" 667 | source = "registry+https://github.com/rust-lang/crates.io-index" 668 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 669 | -------------------------------------------------------------------------------- /staking-factory/src/lib.rs: -------------------------------------------------------------------------------- 1 | use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; 2 | use near_sdk::collections::UnorderedSet; 3 | use near_sdk::json_types::{Base58CryptoHash, U128}; 4 | use near_sdk::serde::{Deserialize, Serialize}; 5 | use near_sdk::serde_json::json; 6 | use near_sdk::{ 7 | env, ext_contract, is_promise_success, log, near_bindgen, sys, AccountId, Balance, CryptoHash, 8 | PanicOnDefault, Promise, PromiseOrValue, PublicKey, 9 | }; 10 | 11 | /// The 4 NEAR tokens required for the storage of the staking pool. 12 | const MIN_ATTACHED_BALANCE: Balance = 4_000_000_000_000_000_000_000_000; 13 | 14 | const NEW_METHOD_NAME: &str = "new"; 15 | const ON_STAKING_POOL_CREATE: &str = "on_staking_pool_create"; 16 | 17 | /// There is no deposit balance attached. 18 | const NO_DEPOSIT: Balance = 0; 19 | 20 | /// Burn fee that whitelisted contracts take. 21 | const BURN_FEE_FRACTION: Ratio = Ratio { 22 | numerator: 0, 23 | denominator: 10, 24 | }; 25 | 26 | pub mod gas { 27 | use near_sdk::Gas; 28 | 29 | /// The base amount of gas for a regular execution. 30 | const BASE: Gas = Gas(25_000_000_000_000); 31 | 32 | /// The amount of Gas the contract will attach to the promise to create the staking pool. 33 | /// The base for the execution and the base for staking action to verify the staking key. 34 | pub const STAKING_POOL_NEW: Gas = Gas(BASE.0 * 2); 35 | 36 | /// The amount of Gas the contract will attach to the callback to itself. 37 | /// The base for the execution and the base for whitelist call or cash rollback. 38 | pub const CALLBACK: Gas = Gas(BASE.0 * 2); 39 | 40 | /// The amount of Gas the contract will attach to the promise to the whitelist contract. 41 | /// The base for the execution. 42 | pub const WHITELIST_STAKING_POOL: Gas = BASE; 43 | } 44 | 45 | #[near_bindgen] 46 | #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] 47 | pub struct StakingPoolFactory { 48 | /// Account ID that can upload new staking contracts. 49 | owner_id: AccountId, 50 | 51 | /// Account ID of the staking pool whitelist contract. 52 | staking_pool_whitelist_account_id: AccountId, 53 | 54 | /// The account ID of the staking pools created. 55 | staking_pool_account_ids: UnorderedSet, 56 | } 57 | 58 | /// Rewards fee fraction structure for the staking pool contract. 59 | #[derive(Serialize, Deserialize, Clone)] 60 | #[serde(crate = "near_sdk::serde")] 61 | pub struct Ratio { 62 | pub numerator: u32, 63 | pub denominator: u32, 64 | } 65 | 66 | impl Ratio { 67 | pub fn assert_valid(&self) { 68 | assert_ne!(self.denominator, 0, "Denominator must be a positive number"); 69 | assert!( 70 | self.numerator <= self.denominator, 71 | "The reward fee must be less or equal to 1" 72 | ); 73 | } 74 | } 75 | 76 | #[derive(Serialize)] 77 | #[serde(crate = "near_sdk::serde")] 78 | pub struct StakingPoolArgs { 79 | /// Owner account ID of the staking pool. 80 | owner_id: AccountId, 81 | /// The initial staking key. 82 | stake_public_key: PublicKey, 83 | /// The initial reward fee fraction. 84 | reward_fee_fraction: Ratio, 85 | /// Burn fee fraction. 86 | burn_fee_fraction: Ratio, 87 | } 88 | 89 | /// External interface for the callbacks to self. 90 | #[ext_contract(ext_self)] 91 | pub trait ExtSelf { 92 | fn on_staking_pool_create( 93 | &mut self, 94 | staking_pool_account_id: AccountId, 95 | attached_deposit: U128, 96 | predecessor_account_id: AccountId, 97 | ) -> Promise; 98 | } 99 | 100 | /// External interface for the whitelist contract. 101 | #[ext_contract(ext_whitelist)] 102 | pub trait ExtWhitelist { 103 | fn add_staking_pool(&mut self, staking_pool_account_id: AccountId) -> bool; 104 | } 105 | 106 | #[near_bindgen] 107 | impl StakingPoolFactory { 108 | /// Initializes the staking pool factory with the given account ID of the staking pool whitelist 109 | /// contract. 110 | #[init] 111 | pub fn new(owner_id: AccountId, staking_pool_whitelist_account_id: AccountId) -> Self { 112 | assert!( 113 | env::is_valid_account_id(owner_id.as_bytes()), 114 | "The owner account ID is invalid" 115 | ); 116 | assert!( 117 | env::is_valid_account_id(staking_pool_whitelist_account_id.as_bytes()), 118 | "The staking pool whitelist account ID is invalid" 119 | ); 120 | Self { 121 | owner_id, 122 | staking_pool_whitelist_account_id, 123 | staking_pool_account_ids: UnorderedSet::new(b"s".to_vec()), 124 | } 125 | } 126 | 127 | /// Returns the minimum amount of tokens required to attach to the function call to 128 | /// create a new staking pool. 129 | pub fn get_min_attached_balance(&self) -> U128 { 130 | MIN_ATTACHED_BALANCE.into() 131 | } 132 | 133 | /// Returns the total number of the staking pools created from this factory. 134 | pub fn get_number_of_staking_pools_created(&self) -> u64 { 135 | self.staking_pool_account_ids.len() 136 | } 137 | 138 | /// Creates a new staking pool. 139 | /// - `staking_pool_id` - the prefix of the account ID that will be used to create a new staking 140 | /// pool account. It'll be prepended to the staking pool factory account ID separated by dot. 141 | /// - `code_hash` - hash of the code that should be deployed. 142 | /// - `owner_id` - the account ID of the staking pool owner. This account will be able to 143 | /// control the staking pool, set reward fee, update staking key and vote on behalf of the 144 | /// pool. 145 | /// - `stake_public_key` - the initial staking key for the staking pool. 146 | /// - `reward_fee_fraction` - the initial reward fee fraction for the staking pool. 147 | /// - `burn_fee_fraction` - the burn fee fraction for the staking pool. 148 | #[payable] 149 | pub fn create_staking_pool( 150 | &mut self, 151 | staking_pool_id: String, 152 | code_hash: Base58CryptoHash, 153 | owner_id: AccountId, 154 | stake_public_key: PublicKey, 155 | reward_fee_fraction: Ratio, 156 | ) { 157 | assert!( 158 | env::attached_deposit() >= MIN_ATTACHED_BALANCE, 159 | "Not enough attached deposit to complete staking pool creation" 160 | ); 161 | 162 | assert!( 163 | staking_pool_id.find('.').is_none(), 164 | "The staking pool ID can't contain `.`" 165 | ); 166 | 167 | let staking_pool_account_id: AccountId = 168 | format!("{}.{}", staking_pool_id, env::current_account_id()) 169 | .parse() 170 | .unwrap(); 171 | assert!( 172 | env::is_valid_account_id(staking_pool_account_id.as_bytes()), 173 | "The staking pool account ID is invalid" 174 | ); 175 | 176 | assert!( 177 | env::is_valid_account_id(owner_id.as_bytes()), 178 | "The owner account ID is invalid" 179 | ); 180 | reward_fee_fraction.assert_valid(); 181 | 182 | assert!( 183 | self.is_contract_allowed(&code_hash), 184 | "Contract hash is not allowed" 185 | ); 186 | 187 | assert!( 188 | self.staking_pool_account_ids 189 | .insert(&staking_pool_account_id), 190 | "The staking pool account ID already exists" 191 | ); 192 | 193 | create_contract( 194 | staking_pool_account_id, 195 | code_hash.into(), 196 | StakingPoolArgs { 197 | owner_id, 198 | stake_public_key, 199 | reward_fee_fraction, 200 | burn_fee_fraction: BURN_FEE_FRACTION, 201 | }, 202 | ); 203 | } 204 | 205 | /// Callback after a staking pool was created. 206 | /// Returns the promise to whitelist the staking pool contract if the pool creation succeeded. 207 | /// Otherwise refunds the attached deposit and returns `false`. 208 | #[private] 209 | pub fn on_staking_pool_create( 210 | &mut self, 211 | staking_pool_account_id: AccountId, 212 | attached_deposit: U128, 213 | predecessor_account_id: AccountId, 214 | ) -> PromiseOrValue { 215 | let staking_pool_created = is_promise_success(); 216 | 217 | if staking_pool_created { 218 | log!( 219 | "The staking pool @{} was successfully created. Whitelisting...", 220 | staking_pool_account_id 221 | ); 222 | ext_whitelist::add_staking_pool( 223 | staking_pool_account_id, 224 | self.staking_pool_whitelist_account_id.clone(), 225 | NO_DEPOSIT, 226 | gas::WHITELIST_STAKING_POOL, 227 | ) 228 | .into() 229 | } else { 230 | self.staking_pool_account_ids 231 | .remove(&staking_pool_account_id); 232 | log!( 233 | "The staking pool @{} creation has failed. Returning attached deposit of {} to @{}", 234 | staking_pool_account_id, 235 | attached_deposit.0, 236 | predecessor_account_id 237 | ); 238 | Promise::new(predecessor_account_id).transfer(attached_deposit.0); 239 | PromiseOrValue::Value(false) 240 | } 241 | } 242 | 243 | /// Returns code at the given hash. 244 | pub fn get_code(&self, code_hash: Base58CryptoHash) { 245 | assert!( 246 | self.is_contract_allowed(&code_hash), 247 | "Contract hash is not allowed" 248 | ); 249 | let code_hash: CryptoHash = code_hash.into(); 250 | unsafe { 251 | // Check that such contract exists. 252 | assert_eq!( 253 | sys::storage_has_key(code_hash.len() as _, code_hash.as_ptr() as _), 254 | 1, 255 | "Contract doesn't exist" 256 | ); 257 | // Load the hash from storage. 258 | sys::storage_read(code_hash.len() as _, code_hash.as_ptr() as _, 0); 259 | // Return as value. 260 | sys::value_return(u64::MAX as _, 0 as _); 261 | } 262 | } 263 | 264 | /// Allow contract to be deployed. Only owner. 265 | pub fn allow_contract(&mut self, code_hash: Base58CryptoHash) { 266 | assert_eq!( 267 | env::predecessor_account_id(), 268 | self.owner_id, 269 | "ERR_MUST_BE_OWNER" 270 | ); 271 | env::storage_write(&Self::code_hash_to_key(&code_hash), &[]); 272 | } 273 | 274 | /// Disallow contract to be deployed. Only owner. 275 | pub fn disallow_contract(&mut self, code_hash: Base58CryptoHash) { 276 | assert_eq!( 277 | env::predecessor_account_id(), 278 | self.owner_id, 279 | "ERR_MUST_BE_OWNER" 280 | ); 281 | env::storage_remove(&Self::code_hash_to_key(&code_hash)); 282 | } 283 | 284 | /// Is this contract allowed to be deployed. 285 | pub fn is_contract_allowed(&self, code_hash: &Base58CryptoHash) -> bool { 286 | env::storage_has_key(&Self::code_hash_to_key(code_hash)) 287 | } 288 | 289 | /// Map code hash into a storage key. 290 | fn code_hash_to_key(code_hash: &Base58CryptoHash) -> Vec { 291 | format!( 292 | "allow:{}", 293 | near_sdk::serde_json::to_string(code_hash).unwrap() 294 | ) 295 | .into_bytes() 296 | } 297 | } 298 | 299 | fn store_contract() { 300 | unsafe { 301 | // Load input into register 0. 302 | sys::input(0); 303 | // Compute sha256 hash of register 0 and store in 1. 304 | sys::sha256(u64::MAX as _, 0 as _, 1); 305 | // Check if such blob already stored. 306 | assert_eq!( 307 | sys::storage_has_key(u64::MAX as _, 1 as _), 308 | 0, 309 | "ERR_ALREADY_EXISTS" 310 | ); 311 | // Store value of register 0 into key = register 1. 312 | sys::storage_write(u64::MAX as _, 1 as _, u64::MAX as _, 0 as _, 2); 313 | // Load register 1 into blob_hash. 314 | let blob_hash = [0u8; 32]; 315 | sys::read_register(1, blob_hash.as_ptr() as _); 316 | // Return from function value of register 1. 317 | let blob_hash_str = near_sdk::serde_json::to_string(&Base58CryptoHash::from(blob_hash)) 318 | .unwrap() 319 | .into_bytes(); 320 | sys::value_return(blob_hash_str.len() as _, blob_hash_str.as_ptr() as _); 321 | } 322 | } 323 | 324 | fn create_contract( 325 | staking_pool_account_id: AccountId, 326 | code_hash: CryptoHash, 327 | args: StakingPoolArgs, 328 | ) { 329 | let attached_deposit = env::attached_deposit(); 330 | let factory_account_id = env::current_account_id().as_bytes().to_vec(); 331 | let encoded_args = near_sdk::serde_json::to_vec(&args).expect("Failed to serialize"); 332 | let callback_args = near_sdk::serde_json::to_vec(&json!({ 333 | "staking_pool_account_id": staking_pool_account_id, 334 | "attached_deposit": format!("{}", attached_deposit), 335 | "predecessor_account_id": env::predecessor_account_id(), 336 | })) 337 | .expect("Failed to serialize"); 338 | let staking_pool_account_id = staking_pool_account_id.as_bytes().to_vec(); 339 | unsafe { 340 | // Check that such contract exists. 341 | assert_eq!( 342 | sys::storage_has_key(code_hash.len() as _, code_hash.as_ptr() as _), 343 | 1, 344 | "Contract doesn't exist" 345 | ); 346 | // Load input (wasm code) into register 0. 347 | sys::storage_read(code_hash.len() as _, code_hash.as_ptr() as _, 0); 348 | // schedule a Promise tx to account_id 349 | let promise_id = sys::promise_batch_create( 350 | staking_pool_account_id.len() as _, 351 | staking_pool_account_id.as_ptr() as _, 352 | ); 353 | // create account first. 354 | sys::promise_batch_action_create_account(promise_id); 355 | // transfer attached deposit. 356 | sys::promise_batch_action_transfer(promise_id, &attached_deposit as *const u128 as _); 357 | // deploy contract (code is taken from register 0). 358 | sys::promise_batch_action_deploy_contract(promise_id, u64::MAX as _, 0); 359 | // call `new` with given arguments. 360 | sys::promise_batch_action_function_call( 361 | promise_id, 362 | NEW_METHOD_NAME.len() as _, 363 | NEW_METHOD_NAME.as_ptr() as _, 364 | encoded_args.len() as _, 365 | encoded_args.as_ptr() as _, 366 | &NO_DEPOSIT as *const u128 as _, 367 | gas::STAKING_POOL_NEW.0, 368 | ); 369 | // attach callback to the factory. 370 | let _ = sys::promise_then( 371 | promise_id, 372 | factory_account_id.len() as _, 373 | factory_account_id.as_ptr() as _, 374 | ON_STAKING_POOL_CREATE.len() as _, 375 | ON_STAKING_POOL_CREATE.as_ptr() as _, 376 | callback_args.len() as _, 377 | callback_args.as_ptr() as _, 378 | &NO_DEPOSIT as *const u128 as _, 379 | gas::CALLBACK.0, 380 | ); 381 | sys::promise_return(promise_id); 382 | } 383 | } 384 | 385 | /// Store new staking contract, caller must pay the storage costs. 386 | /// Returns base58 of the hash of the stored contract. 387 | #[no_mangle] 388 | pub extern "C" fn store() { 389 | env::setup_panic_hook(); 390 | let prev_storage = env::storage_usage(); 391 | store_contract(); 392 | let storage_cost = (env::storage_usage() - prev_storage) as u128 * env::storage_byte_cost(); 393 | assert!( 394 | storage_cost <= env::attached_deposit(), 395 | "Must at least deposit {}", 396 | storage_cost, 397 | ); 398 | } 399 | 400 | #[cfg(test)] 401 | mod tests { 402 | use near_sdk::env::sha256; 403 | use near_sdk::test_utils::{testing_env_with_promise_results, VMContextBuilder}; 404 | use near_sdk::{testing_env, PromiseResult, VMContext}; 405 | 406 | use super::*; 407 | 408 | pub fn account_near() -> AccountId { 409 | "near".parse().unwrap() 410 | } 411 | pub fn account_whitelist() -> AccountId { 412 | "whitelist".parse().unwrap() 413 | } 414 | pub fn staking_pool_id() -> String { 415 | "pool".to_string() 416 | } 417 | pub fn account_pool() -> AccountId { 418 | "pool.factory".parse().unwrap() 419 | } 420 | pub fn account_factory() -> AccountId { 421 | "factory".parse().unwrap() 422 | } 423 | pub fn account_tokens_owner() -> AccountId { 424 | "tokens-owner".parse().unwrap() 425 | } 426 | pub fn account_pool_owner() -> AccountId { 427 | "pool-owner".parse().unwrap() 428 | } 429 | 430 | pub fn ntoy(near_amount: Balance) -> Balance { 431 | near_amount * 10u128.pow(24) 432 | } 433 | 434 | pub fn get_hash(data: &[u8]) -> Base58CryptoHash { 435 | let hash = sha256(&data); 436 | let mut result: CryptoHash = [0; 32]; 437 | result.copy_from_slice(&hash); 438 | Base58CryptoHash::from(result) 439 | } 440 | 441 | pub fn add_staking_contract(context: &mut VMContext) -> Base58CryptoHash { 442 | context.input = include_bytes!("../../res/staking_farm_release.wasm").to_vec(); 443 | let hash = get_hash(&context.input); 444 | testing_env!(context.clone()); 445 | store_contract(); 446 | context.input = vec![]; 447 | hash 448 | } 449 | 450 | #[test] 451 | fn test_create_staking_pool_success() { 452 | let mut context = VMContextBuilder::new() 453 | .current_account_id(account_factory()) 454 | .predecessor_account_id(account_near()) 455 | .build(); 456 | testing_env!(context.clone()); 457 | 458 | let mut contract = StakingPoolFactory::new(account_near(), account_whitelist()); 459 | let hash = add_staking_contract(&mut context); 460 | 461 | contract.allow_contract(hash); 462 | 463 | context.input = vec![]; 464 | context.is_view = true; 465 | testing_env!(context.clone()); 466 | assert_eq!(contract.get_min_attached_balance().0, MIN_ATTACHED_BALANCE); 467 | assert_eq!(contract.get_number_of_staking_pools_created(), 0); 468 | 469 | context.is_view = false; 470 | context.predecessor_account_id = account_tokens_owner().into(); 471 | context.attached_deposit = ntoy(31); 472 | testing_env!(context.clone()); 473 | contract.create_staking_pool( 474 | staking_pool_id(), 475 | hash, 476 | account_pool_owner(), 477 | "KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7" 478 | .parse() 479 | .unwrap(), 480 | Ratio { 481 | numerator: 10, 482 | denominator: 100, 483 | }, 484 | ); 485 | 486 | context.predecessor_account_id = account_factory().into(); 487 | context.attached_deposit = ntoy(0); 488 | testing_env_with_promise_results(context.clone(), PromiseResult::Successful(vec![])); 489 | contract.on_staking_pool_create(account_pool(), ntoy(31).into(), account_tokens_owner()); 490 | 491 | context.is_view = true; 492 | testing_env!(context.clone()); 493 | assert_eq!(contract.get_number_of_staking_pools_created(), 1); 494 | } 495 | 496 | #[test] 497 | #[should_panic(expected = "Not enough attached deposit to complete staking pool creation")] 498 | fn test_create_staking_pool_not_enough_deposit() { 499 | let mut context = VMContextBuilder::new() 500 | .current_account_id(account_factory()) 501 | .predecessor_account_id(account_near()) 502 | .build(); 503 | testing_env!(context.clone()); 504 | 505 | let mut contract = StakingPoolFactory::new(account_near(), account_whitelist()); 506 | let hash = add_staking_contract(&mut context); 507 | 508 | contract.allow_contract(hash); 509 | assert!(contract.is_contract_allowed(&hash)); 510 | 511 | // Checking the pool is still whitelisted 512 | context.is_view = true; 513 | testing_env!(context.clone()); 514 | assert_eq!(contract.get_min_attached_balance().0, MIN_ATTACHED_BALANCE); 515 | assert_eq!(contract.get_number_of_staking_pools_created(), 0); 516 | 517 | context.is_view = false; 518 | context.predecessor_account_id = account_tokens_owner().into(); 519 | context.attached_deposit = MIN_ATTACHED_BALANCE / 2; 520 | testing_env!(context.clone()); 521 | contract.create_staking_pool( 522 | staking_pool_id(), 523 | hash, 524 | account_pool_owner(), 525 | "KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7" 526 | .parse() 527 | .unwrap(), 528 | Ratio { 529 | numerator: 10, 530 | denominator: 100, 531 | }, 532 | ); 533 | } 534 | 535 | #[test] 536 | fn test_create_staking_pool_rollback() { 537 | let mut context = VMContextBuilder::new() 538 | .current_account_id(account_factory()) 539 | .predecessor_account_id(account_near()) 540 | .build(); 541 | testing_env!(context.clone()); 542 | 543 | let mut contract = StakingPoolFactory::new(account_near(), account_whitelist()); 544 | let hash = add_staking_contract(&mut context); 545 | 546 | contract.allow_contract(hash); 547 | 548 | context.is_view = true; 549 | testing_env!(context.clone()); 550 | assert_eq!(contract.get_min_attached_balance().0, MIN_ATTACHED_BALANCE); 551 | assert_eq!(contract.get_number_of_staking_pools_created(), 0); 552 | 553 | context.is_view = false; 554 | context.predecessor_account_id = account_tokens_owner().into(); 555 | context.attached_deposit = ntoy(31); 556 | testing_env!(context.clone()); 557 | contract.create_staking_pool( 558 | staking_pool_id(), 559 | hash, 560 | account_pool_owner(), 561 | "KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7" 562 | .parse() 563 | .unwrap(), 564 | Ratio { 565 | numerator: 10, 566 | denominator: 100, 567 | }, 568 | ); 569 | 570 | context.predecessor_account_id = account_factory().into(); 571 | context.attached_deposit = ntoy(0); 572 | context.account_balance += ntoy(31); 573 | testing_env_with_promise_results(context.clone(), PromiseResult::Failed); 574 | let res = contract.on_staking_pool_create( 575 | account_pool(), 576 | ntoy(31).into(), 577 | account_tokens_owner(), 578 | ); 579 | match res { 580 | PromiseOrValue::Promise(_) => panic!("Unexpected result, should return Value(false)"), 581 | PromiseOrValue::Value(value) => assert!(!value), 582 | }; 583 | 584 | context.is_view = true; 585 | testing_env!(context.clone()); 586 | assert_eq!(contract.get_number_of_staking_pools_created(), 0); 587 | } 588 | 589 | #[test] 590 | fn test_contract_disallow() { 591 | let mut context = VMContextBuilder::new() 592 | .current_account_id(account_factory()) 593 | .predecessor_account_id(account_near()) 594 | .build(); 595 | testing_env!(context.clone()); 596 | 597 | let mut contract = StakingPoolFactory::new(account_near(), account_whitelist()); 598 | let hash = add_staking_contract(&mut context); 599 | 600 | assert!(!contract.is_contract_allowed(&hash)); 601 | contract.allow_contract(hash); 602 | assert!(contract.is_contract_allowed(&hash)); 603 | contract.disallow_contract(hash); 604 | assert!(!contract.is_contract_allowed(&hash)); 605 | } 606 | } 607 | -------------------------------------------------------------------------------- /staking-farm/tests/workspaces.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use near_workspaces::types::{Gas, NearToken}; 3 | use near_workspaces::{Account, AccountId, Contract, Worker}; 4 | use serde_json::json; 5 | 6 | const STAKING_POOL_ACCOUNT_ID: &str = "pool"; 7 | const STAKING_KEY: &str = "KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7"; 8 | const ONE_SEC_IN_NS: u64 = 1_000_000_000; 9 | const WHITELIST_ACCOUNT_ID: &str = "whitelist"; 10 | const VERSION_KEY: &[u8] = b"VERSION"; 11 | const LEGACY_VERSION: &str = "staking-farm:1.0.0"; 12 | 13 | // WASM file paths (built with `cargo near build non-reproducible-wasm`) 14 | const STAKING_FARM_WASM: &str = "../target/near/staking_farm/staking_farm.wasm"; 15 | const STAKING_FACTORY_WASM: &str = "../res/staking_factory_local.wasm"; 16 | const TEST_TOKEN_WASM: &str = "../target/near/test_token/test_token.wasm"; 17 | const WHITELIST_WASM: &str = "../target/near/whitelist/whitelist.wasm"; 18 | 19 | fn token_id() -> AccountId { 20 | "token".parse().unwrap() 21 | } 22 | 23 | fn whitelist_id() -> AccountId { 24 | WHITELIST_ACCOUNT_ID.parse().unwrap() 25 | } 26 | 27 | /// Helper struct to hold common test context 28 | pub struct TestContext { 29 | pub worker: Worker, 30 | pub pool: Contract, 31 | pub token: Contract, 32 | pub owner: Account, 33 | } 34 | 35 | pub async fn init_contracts( 36 | pool_initial_balance: NearToken, 37 | reward_ratio: u32, 38 | burn_ratio: u32, 39 | ) -> anyhow::Result { 40 | let worker = near_workspaces::sandbox().await?; 41 | let owner = worker.root_account()?; 42 | 43 | // Deploy whitelist contract 44 | let whitelist_wasm = std::fs::read(WHITELIST_WASM)?; 45 | let whitelist = owner 46 | .create_subaccount(&whitelist_id().to_string()) 47 | .initial_balance(NearToken::from_near(10)) 48 | .transact() 49 | .await? 50 | .into_result()?; 51 | 52 | let whitelist = whitelist.deploy(&whitelist_wasm).await?.into_result()?; 53 | 54 | whitelist 55 | .call("new") 56 | .args_json(json!({ "foundation_account_id": owner.id() })) 57 | .gas(Gas::from_tgas(300)) 58 | .transact() 59 | .await? 60 | .into_result()?; 61 | 62 | // Deploy test token contract 63 | let token_wasm = std::fs::read(TEST_TOKEN_WASM)?; 64 | let token = owner 65 | .create_subaccount(&token_id().to_string()) 66 | .initial_balance(NearToken::from_near(10)) 67 | .transact() 68 | .await? 69 | .into_result()?; 70 | 71 | let token = token.deploy(&token_wasm).await?.into_result()?; 72 | 73 | token 74 | .call("new") 75 | .gas(Gas::from_tgas(300)) 76 | .transact() 77 | .await? 78 | .into_result()?; 79 | 80 | // Mint tokens to owner 81 | token 82 | .call("mint") 83 | .args_json(json!({ 84 | "account_id": owner.id(), 85 | "amount": NearToken::from_near(100_000).as_yoctonear().to_string() 86 | })) 87 | .gas(Gas::from_tgas(300)) 88 | .transact() 89 | .await? 90 | .into_result()?; 91 | 92 | // Deploy staking pool contract 93 | let pool_wasm = std::fs::read(STAKING_FARM_WASM)?; 94 | let pool = owner 95 | .create_subaccount(STAKING_POOL_ACCOUNT_ID) 96 | .initial_balance(pool_initial_balance) 97 | .transact() 98 | .await? 99 | .into_result()?; 100 | 101 | let pool = pool.deploy(&pool_wasm).await?.into_result()?; 102 | 103 | let reward_ratio = json!({ 104 | "numerator": reward_ratio, 105 | "denominator": 10 106 | }); 107 | let burn_ratio = json!({ 108 | "numerator": burn_ratio, 109 | "denominator": 10 110 | }); 111 | 112 | pool.call("new") 113 | .args_json(json!({ 114 | "owner_id": owner.id(), 115 | "stake_public_key": STAKING_KEY, 116 | "reward_fee_fraction": reward_ratio, 117 | "burn_fee_fraction": burn_ratio 118 | })) 119 | .gas(Gas::from_tgas(300)) 120 | .transact() 121 | .await? 122 | .into_result()?; 123 | 124 | // Register pool in token storage 125 | token 126 | .call("storage_deposit") 127 | .args_json(json!({ "account_id": pool.id() })) 128 | .deposit(NearToken::from_near(1)) 129 | .gas(Gas::from_tgas(300)) 130 | .transact() 131 | .await? 132 | .into_result()?; 133 | 134 | // Add staking pool to whitelist 135 | owner 136 | .call(whitelist.id(), "add_staking_pool") 137 | .args_json(json!({ "staking_pool_account_id": STAKING_POOL_ACCOUNT_ID })) 138 | .gas(Gas::from_tgas(300)) 139 | .transact() 140 | .await? 141 | .into_result()?; 142 | 143 | // Add authorized farm token 144 | owner 145 | .call(pool.id(), "add_authorized_farm_token") 146 | .args_json(json!({ "token_id": token.id() })) 147 | .gas(Gas::from_tgas(300)) 148 | .transact() 149 | .await? 150 | .into_result()?; 151 | 152 | Ok(TestContext { 153 | worker, 154 | pool, 155 | token, 156 | owner, 157 | }) 158 | } 159 | 160 | pub async fn storage_register( 161 | token: &Contract, 162 | account_id: &AccountId, 163 | _payer: &Account, 164 | ) -> anyhow::Result<()> { 165 | token 166 | .call("storage_deposit") 167 | .args_json(json!({ "account_id": account_id })) 168 | .deposit(NearToken::from_millinear(10)) 169 | .gas(Gas::from_tgas(300)) 170 | .transact() 171 | .await? 172 | .into_result()?; 173 | Ok(()) 174 | } 175 | 176 | async fn balance_of(token: &Contract, account_id: &AccountId) -> anyhow::Result { 177 | let result = token 178 | .view("ft_balance_of") 179 | .args_json(json!({ "account_id": account_id })) 180 | .await?; 181 | 182 | let balance: serde_json::Value = result.json()?; 183 | Ok(balance.as_str().unwrap().parse().unwrap()) 184 | } 185 | 186 | pub async fn create_user_and_stake( 187 | ctx: &TestContext, 188 | name: &str, 189 | stake_amount: NearToken, 190 | ) -> anyhow::Result { 191 | let user = ctx 192 | .owner 193 | .create_subaccount(name) 194 | .initial_balance(NearToken::from_near(100_000)) 195 | .transact() 196 | .await? 197 | .into_result()?; 198 | 199 | storage_register(&ctx.token, user.id(), &ctx.owner).await?; 200 | 201 | user.call(ctx.pool.id(), "deposit_and_stake") 202 | .deposit(stake_amount) 203 | .gas(Gas::from_tgas(200)) 204 | .transact() 205 | .await? 206 | .into_result()?; 207 | 208 | Ok(user) 209 | } 210 | 211 | async fn deploy_farm(ctx: &TestContext) -> anyhow::Result<()> { 212 | let current_time = ctx.worker.view_block().await?.timestamp(); 213 | let start_date = current_time + ONE_SEC_IN_NS * 3; 214 | let end_date = start_date + ONE_SEC_IN_NS * 5; 215 | 216 | let msg = json!({ 217 | "name": "Test", 218 | "start_date": start_date.to_string(), 219 | "end_date": end_date.to_string() 220 | }); 221 | 222 | ctx.owner 223 | .call(ctx.token.id(), "ft_transfer_call") 224 | .args_json(json!({ 225 | "receiver_id": ctx.pool.id(), 226 | "amount": NearToken::from_near(50_000).as_yoctonear().to_string(), 227 | "msg": msg.to_string() 228 | })) 229 | .deposit(NearToken::from_yoctonear(1)) 230 | .gas(Gas::from_tgas(300)) 231 | .transact() 232 | .await? 233 | .into_result()?; 234 | 235 | Ok(()) 236 | } 237 | 238 | /// Test staking and unstaking operations 239 | /// This replaces the old test_restake_fail which tested promise failures 240 | /// In workspaces, we test the actual staking flow instead 241 | #[tokio::test] 242 | async fn test_stake_operations() -> anyhow::Result<()> { 243 | let ctx = init_contracts(NearToken::from_near(10_000), 0, 0).await?; 244 | 245 | // Create a user and deposit 246 | let user = ctx 247 | .owner 248 | .create_subaccount("user1") 249 | .initial_balance(NearToken::from_near(100_000)) 250 | .transact() 251 | .await? 252 | .into_result()?; 253 | 254 | // Deposit funds 255 | user.call(ctx.pool.id(), "deposit") 256 | .deposit(NearToken::from_near(1_000)) 257 | .gas(Gas::from_tgas(200)) 258 | .transact() 259 | .await? 260 | .into_result()?; 261 | 262 | // Check unstaked balance 263 | let unstaked: serde_json::Value = ctx 264 | .pool 265 | .view("get_account_unstaked_balance") 266 | .args_json(json!({ "account_id": user.id() })) 267 | .await? 268 | .json()?; 269 | let unstaked_balance: u128 = unstaked.as_str().unwrap().parse()?; 270 | assert_eq!(unstaked_balance, NearToken::from_near(1_000).as_yoctonear()); 271 | 272 | // Stake the funds 273 | user.call(ctx.pool.id(), "stake") 274 | .args_json(json!({ "amount": NearToken::from_near(500).as_yoctonear().to_string() })) 275 | .gas(Gas::from_tgas(200)) 276 | .transact() 277 | .await? 278 | .into_result()?; 279 | 280 | // Check staked balance 281 | let staked: serde_json::Value = ctx 282 | .pool 283 | .view("get_account_staked_balance") 284 | .args_json(json!({ "account_id": user.id() })) 285 | .await? 286 | .json()?; 287 | let staked_balance: u128 = staked.as_str().unwrap().parse()?; 288 | assert_eq!(staked_balance, NearToken::from_near(500).as_yoctonear()); 289 | 290 | // Check remaining unstaked balance 291 | let unstaked: serde_json::Value = ctx 292 | .pool 293 | .view("get_account_unstaked_balance") 294 | .args_json(json!({ "account_id": user.id() })) 295 | .await? 296 | .json()?; 297 | let unstaked_balance: u128 = unstaked.as_str().unwrap().parse()?; 298 | assert_eq!(unstaked_balance, NearToken::from_near(500).as_yoctonear()); 299 | 300 | // Unstake some funds 301 | user.call(ctx.pool.id(), "unstake") 302 | .args_json(json!({ "amount": NearToken::from_near(200).as_yoctonear().to_string() })) 303 | .gas(Gas::from_tgas(200)) 304 | .transact() 305 | .await? 306 | .into_result()?; 307 | 308 | // Check staked balance after unstake 309 | let staked: serde_json::Value = ctx 310 | .pool 311 | .view("get_account_staked_balance") 312 | .args_json(json!({ "account_id": user.id() })) 313 | .await? 314 | .json()?; 315 | let staked_balance: u128 = staked.as_str().unwrap().parse()?; 316 | assert_eq!(staked_balance, NearToken::from_near(300).as_yoctonear()); 317 | 318 | // Check unstaked balance includes the unstaked amount 319 | let unstaked: serde_json::Value = ctx 320 | .pool 321 | .view("get_account_unstaked_balance") 322 | .args_json(json!({ "account_id": user.id() })) 323 | .await? 324 | .json()?; 325 | let unstaked_balance: u128 = unstaked.as_str().unwrap().parse()?; 326 | assert_eq!(unstaked_balance, NearToken::from_near(700).as_yoctonear()); 327 | 328 | // Verify account info 329 | let account: serde_json::Value = ctx 330 | .pool 331 | .view("get_account") 332 | .args_json(json!({ "account_id": user.id() })) 333 | .await? 334 | .json()?; 335 | 336 | assert_eq!(account["account_id"], user.id().to_string()); 337 | assert!( 338 | !account["can_withdraw"].as_bool().unwrap(), 339 | "Should not be able to withdraw immediately" 340 | ); 341 | 342 | Ok(()) 343 | } 344 | 345 | /// Test clean calculations without rewards and burn. 346 | #[tokio::test] 347 | async fn test_farm() -> anyhow::Result<()> { 348 | let ctx = init_contracts( 349 | NearToken::from_yoctonear(NearToken::from_near(10_000).as_yoctonear() + 1_000_000_000_000), 350 | 0, 351 | 0, 352 | ) 353 | .await?; 354 | 355 | let user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(10_000)).await?; 356 | 357 | deploy_farm(&ctx).await?; 358 | 359 | // Farm is deployed but may not be active yet since it starts 3 seconds in the future 360 | // Advance past the start time 361 | ctx.worker.fast_forward(5).await?; 362 | 363 | let active_farms: Vec = ctx.pool.view("get_active_farms").await?.json()?; 364 | 365 | // Farm should now be active 366 | assert!( 367 | !active_farms.is_empty(), 368 | "Expected at least one active farm" 369 | ); 370 | 371 | // Check unclaimed rewards 372 | let unclaimed: serde_json::Value = ctx 373 | .pool 374 | .view("get_unclaimed_reward") 375 | .args_json(json!({ "account_id": user1.id(), "farm_id": 0 })) 376 | .await? 377 | .json()?; 378 | let _unclaimed: u128 = unclaimed.as_str().unwrap().parse()?; 379 | 380 | // Advance more 381 | ctx.worker.fast_forward(2).await?; 382 | 383 | let unclaimed: serde_json::Value = ctx 384 | .pool 385 | .view("get_unclaimed_reward") 386 | .args_json(json!({ "account_id": user1.id(), "farm_id": 0 })) 387 | .await? 388 | .json()?; 389 | let unclaimed: u128 = unclaimed.as_str().unwrap().parse()?; 390 | let prev_unclaimed = unclaimed; 391 | 392 | // Claim tokens 393 | user1 394 | .call(ctx.pool.id(), "claim") 395 | .args_json(json!({ "token_id": ctx.token.id(), "receiver_id": serde_json::Value::Null })) 396 | .deposit(NearToken::from_yoctonear(1)) 397 | .gas(Gas::from_tgas(300)) 398 | .transact() 399 | .await? 400 | .into_result()?; 401 | 402 | let user_token_balance = balance_of(&ctx.token, user1.id()).await?; 403 | assert!(user_token_balance > 0, "Expected tokens to be claimed"); 404 | assert!( 405 | user_token_balance >= prev_unclaimed, 406 | "Claimed less than expected" 407 | ); 408 | 409 | Ok(()) 410 | } 411 | 412 | #[tokio::test] 413 | async fn test_all_rewards_no_burn() -> anyhow::Result<()> { 414 | let ctx = init_contracts(NearToken::from_near(5), 10, 0).await?; 415 | 416 | let owner_balance: serde_json::Value = ctx 417 | .pool 418 | .view("get_account_total_balance") 419 | .args_json(json!({ "account_id": ctx.owner.id() })) 420 | .await? 421 | .json()?; 422 | assert_eq!(owner_balance.as_str().unwrap(), "0"); 423 | 424 | let _user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(10_000)).await?; 425 | 426 | // Note: Epoch-based rewards testing requires staking simulation which is complex in sandbox 427 | // For now, we're testing that the contract initializes and accepts deposits correctly 428 | // Full epoch reward testing would require mainnet fork or more complex sandbox setup 429 | 430 | Ok(()) 431 | } 432 | 433 | #[tokio::test] 434 | async fn test_all_rewards_burn() -> anyhow::Result<()> { 435 | let ctx = init_contracts(NearToken::from_near(5), 10, 1).await?; 436 | 437 | let _user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(10_000)).await?; 438 | 439 | // Note: Epoch-based rewards testing requires staking simulation which is complex in sandbox 440 | 441 | Ok(()) 442 | } 443 | 444 | #[tokio::test] 445 | async fn test_burn_fee() -> anyhow::Result<()> { 446 | let ctx = init_contracts(NearToken::from_near(5), 1, 3).await?; 447 | 448 | let _user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(10_000)).await?; 449 | 450 | let pool_summary: serde_json::Value = ctx.pool.view("get_pool_summary").await?.json()?; 451 | assert_eq!(pool_summary["burn_fee_fraction"]["numerator"], 3); 452 | 453 | ctx.pool 454 | .call("decrease_burn_fee_fraction") 455 | .args_json(json!({ 456 | "burn_fee_fraction": { 457 | "numerator": 1, 458 | "denominator": 4 459 | } 460 | })) 461 | .transact() 462 | .await? 463 | .into_result()?; 464 | 465 | let pool_summary: serde_json::Value = ctx.pool.view("get_pool_summary").await?.json()?; 466 | assert_eq!(pool_summary["burn_fee_fraction"]["numerator"], 1); 467 | assert_eq!(pool_summary["burn_fee_fraction"]["denominator"], 4); 468 | 469 | ctx.pool 470 | .call("decrease_burn_fee_fraction") 471 | .args_json(json!({ 472 | "burn_fee_fraction": { 473 | "numerator": 0, 474 | "denominator": 1 475 | } 476 | })) 477 | .transact() 478 | .await? 479 | .into_result()?; 480 | 481 | let pool_summary: serde_json::Value = ctx.pool.view("get_pool_summary").await?.json()?; 482 | assert_eq!(pool_summary["burn_fee_fraction"]["numerator"], 0); 483 | assert_eq!(pool_summary["burn_fee_fraction"]["denominator"], 1); 484 | 485 | Ok(()) 486 | } 487 | 488 | /// Test transferring shares between accounts using FT interface 489 | #[tokio::test] 490 | async fn test_ft_share_transfer() -> anyhow::Result<()> { 491 | let ctx = init_contracts(NearToken::from_near(10_000), 0, 0).await?; 492 | 493 | // Create two users and have them stake 494 | let user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(5_000)).await?; 495 | let user2 = create_user_and_stake(&ctx, "user2", NearToken::from_near(3_000)).await?; 496 | 497 | // Get total supply before transfer 498 | let total_supply_before: serde_json::Value = ctx.pool.view("ft_total_supply").await?.json()?; 499 | let total_supply_before: u128 = total_supply_before.as_str().unwrap().parse()?; 500 | 501 | // Check initial staked balances (FT balance = stake shares with 24 decimals) 502 | let user1_shares: serde_json::Value = ctx 503 | .pool 504 | .view("ft_balance_of") 505 | .args_json(json!({ "account_id": user1.id() })) 506 | .await? 507 | .json()?; 508 | let user1_shares: u128 = user1_shares.as_str().unwrap().parse()?; 509 | 510 | let user2_shares: serde_json::Value = ctx 511 | .pool 512 | .view("ft_balance_of") 513 | .args_json(json!({ "account_id": user2.id() })) 514 | .await? 515 | .json()?; 516 | let user2_shares: u128 = user2_shares.as_str().unwrap().parse()?; 517 | 518 | assert!(user1_shares > 0, "User1 should have shares"); 519 | assert!(user2_shares > 0, "User2 should have shares"); 520 | 521 | // Transfer some shares from user1 to user2 522 | let transfer_amount = user1_shares / 4; // Transfer 25% of user1's shares 523 | 524 | user1 525 | .call(ctx.pool.id(), "ft_transfer") 526 | .args_json(json!({ 527 | "receiver_id": user2.id(), 528 | "amount": transfer_amount.to_string(), 529 | "memo": "Share transfer test" 530 | })) 531 | .deposit(NearToken::from_yoctonear(1)) 532 | .gas(Gas::from_tgas(300)) 533 | .transact() 534 | .await? 535 | .into_result()?; 536 | 537 | // Check balances after transfer 538 | let user1_shares_after: serde_json::Value = ctx 539 | .pool 540 | .view("ft_balance_of") 541 | .args_json(json!({ "account_id": user1.id() })) 542 | .await? 543 | .json()?; 544 | let user1_shares_after: u128 = user1_shares_after.as_str().unwrap().parse()?; 545 | 546 | let user2_shares_after: serde_json::Value = ctx 547 | .pool 548 | .view("ft_balance_of") 549 | .args_json(json!({ "account_id": user2.id() })) 550 | .await? 551 | .json()?; 552 | let user2_shares_after: u128 = user2_shares_after.as_str().unwrap().parse()?; 553 | 554 | // Verify the transfer 555 | assert_eq!( 556 | user1_shares_after, 557 | user1_shares - transfer_amount, 558 | "User1 should have fewer shares" 559 | ); 560 | assert_eq!( 561 | user2_shares_after, 562 | user2_shares + transfer_amount, 563 | "User2 should have more shares" 564 | ); 565 | 566 | // Verify total supply is unchanged after transfer 567 | let total_supply_after: serde_json::Value = ctx.pool.view("ft_total_supply").await?.json()?; 568 | let total_supply_after: u128 = total_supply_after.as_str().unwrap().parse()?; 569 | assert_eq!( 570 | total_supply_after, total_supply_before, 571 | "Total supply should remain constant after transfer" 572 | ); 573 | 574 | // Verify staked balances match shares (1:1 ratio initially) 575 | let user1_staked: serde_json::Value = ctx 576 | .pool 577 | .view("get_account_staked_balance") 578 | .args_json(json!({ "account_id": user1.id() })) 579 | .await? 580 | .json()?; 581 | let user1_staked: u128 = user1_staked.as_str().unwrap().parse()?; 582 | 583 | let user2_staked: serde_json::Value = ctx 584 | .pool 585 | .view("get_account_staked_balance") 586 | .args_json(json!({ "account_id": user2.id() })) 587 | .await? 588 | .json()?; 589 | let user2_staked: u128 = user2_staked.as_str().unwrap().parse()?; 590 | 591 | // Shares are in 24 decimals, staked balance is in yoctoNEAR (24 decimals) 592 | // They should match 1:1 593 | assert_eq!( 594 | user1_shares_after, user1_staked, 595 | "User1 shares should match staked balance" 596 | ); 597 | assert_eq!( 598 | user2_shares_after, user2_staked, 599 | "User2 shares should match staked balance" 600 | ); 601 | 602 | Ok(()) 603 | } 604 | 605 | /// Test ft_transfer_call with a receiver that accepts the transfer 606 | #[tokio::test] 607 | async fn test_ft_transfer_call() -> anyhow::Result<()> { 608 | let ctx = init_contracts(NearToken::from_near(10_000), 0, 0).await?; 609 | 610 | // Create two users and have them stake 611 | let user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(5_000)).await?; 612 | let user2 = create_user_and_stake(&ctx, "user2", NearToken::from_near(3_000)).await?; 613 | 614 | // Get initial balances 615 | let user1_shares_before: serde_json::Value = ctx 616 | .pool 617 | .view("ft_balance_of") 618 | .args_json(json!({ "account_id": user1.id() })) 619 | .await? 620 | .json()?; 621 | let user1_shares_before: u128 = user1_shares_before.as_str().unwrap().parse()?; 622 | 623 | let user2_shares_before: serde_json::Value = ctx 624 | .pool 625 | .view("ft_balance_of") 626 | .args_json(json!({ "account_id": user2.id() })) 627 | .await? 628 | .json()?; 629 | let user2_shares_before: u128 = user2_shares_before.as_str().unwrap().parse()?; 630 | 631 | // Transfer shares from user1 to user2 using ft_transfer_call 632 | // User2 is a regular account without ft_on_transfer implementation 633 | // The callback will fail, but since it fails (not returns an unused amount), 634 | // the transfer is considered final and shares stay with user2 635 | let transfer_amount = user1_shares_before / 4; // Transfer 25% 636 | 637 | let result = user1 638 | .call(ctx.pool.id(), "ft_transfer_call") 639 | .args_json(json!({ 640 | "receiver_id": user2.id(), 641 | "amount": transfer_amount.to_string(), 642 | "msg": "test transfer call" 643 | })) 644 | .deposit(NearToken::from_yoctonear(1)) 645 | .gas(Gas::from_tgas(300)) 646 | .transact() 647 | .await; 648 | 649 | // The transaction should complete 650 | match result { 651 | Ok(outcome) => { 652 | let _outcome = outcome.into_result()?; 653 | 654 | // Check final balances 655 | let user1_shares_after: serde_json::Value = ctx 656 | .pool 657 | .view("ft_balance_of") 658 | .args_json(json!({ "account_id": user1.id() })) 659 | .await? 660 | .json()?; 661 | let user1_shares_after: u128 = user1_shares_after.as_str().unwrap().parse()?; 662 | 663 | let user2_shares_after: serde_json::Value = ctx 664 | .pool 665 | .view("ft_balance_of") 666 | .args_json(json!({ "account_id": user2.id() })) 667 | .await? 668 | .json()?; 669 | let user2_shares_after: u128 = user2_shares_after.as_str().unwrap().parse()?; 670 | 671 | // When ft_on_transfer is not implemented, the promise fails, 672 | // but ft_resolve_transfer treats this as "0 unused" (all used), 673 | // so the transfer is final 674 | assert_eq!( 675 | user1_shares_after, 676 | user1_shares_before - transfer_amount, 677 | "User1 shares should decrease by transfer amount" 678 | ); 679 | assert_eq!( 680 | user2_shares_after, 681 | user2_shares_before + transfer_amount, 682 | "User2 shares should increase by transfer amount" 683 | ); 684 | 685 | // Verify total supply unchanged 686 | let total_supply: serde_json::Value = ctx.pool.view("ft_total_supply").await?.json()?; 687 | let total_supply: u128 = total_supply.as_str().unwrap().parse()?; 688 | assert_eq!( 689 | total_supply, 690 | // Pool also has shares from initial balance 691 | user1_shares_after 692 | + user2_shares_after 693 | + (total_supply - user1_shares_before - user2_shares_before), 694 | "Total supply should remain constant" 695 | ); 696 | } 697 | Err(e) => { 698 | panic!("Transaction should succeed: {:?}", e); 699 | } 700 | } 701 | 702 | Ok(()) 703 | } 704 | 705 | /// Test ft_transfer_call with insufficient prepaid gas: should fail before transfer occurs 706 | #[tokio::test] 707 | async fn test_ft_transfer_call_insufficient_gas() -> anyhow::Result<()> { 708 | let ctx = init_contracts(NearToken::from_near(10_000), 0, 0).await?; 709 | 710 | // Create two users and have them stake 711 | let user1 = create_user_and_stake(&ctx, "user1", NearToken::from_near(5_000)).await?; 712 | let user2 = create_user_and_stake(&ctx, "user2", NearToken::from_near(3_000)).await?; 713 | 714 | // Snapshot balances 715 | let u1_before: u128 = ctx 716 | .pool 717 | .view("ft_balance_of") 718 | .args_json(json!({ "account_id": user1.id() })) 719 | .await? 720 | .json::()? 721 | .as_str() 722 | .unwrap() 723 | .parse()?; 724 | 725 | let u2_before: u128 = ctx 726 | .pool 727 | .view("ft_balance_of") 728 | .args_json(json!({ "account_id": user2.id() })) 729 | .await? 730 | .json::()? 731 | .as_str() 732 | .unwrap() 733 | .parse()?; 734 | 735 | // Try ft_transfer_call with very small gas so the contract rejects early 736 | let amount = u1_before / 10; 737 | let outcome = user1 738 | .call(ctx.pool.id(), "ft_transfer_call") 739 | .args_json(json!({ 740 | "receiver_id": user2.id(), 741 | "amount": amount.to_string(), 742 | "msg": "any" 743 | })) 744 | .deposit(NearToken::from_yoctonear(1)) 745 | .gas(Gas::from_tgas(1)) 746 | .transact() 747 | .await; 748 | 749 | // Expect failure with the specific error message 750 | match outcome { 751 | Ok(res) => { 752 | let status = res.into_result(); 753 | assert!(status.is_err(), "Call should fail due to insufficient gas"); 754 | let err = format!("{:?}", status.err().unwrap()); 755 | assert!( 756 | err.contains("Not enough gas for the ft_transfer_call") 757 | || err.contains("Exceeded the prepaid gas."), 758 | "Unexpected error: {}", 759 | err 760 | ); 761 | } 762 | Err(e) => { 763 | // Some workspaces versions surface the failure at this layer — validate message 764 | let err = format!("{:?}", e); 765 | assert!( 766 | err.contains("Not enough gas for the ft_transfer_call") 767 | || err.contains("Exceeded the prepaid gas."), 768 | ); 769 | } 770 | } 771 | 772 | // Balances should be unchanged (transfer should not have executed) 773 | let u1_after: u128 = ctx 774 | .pool 775 | .view("ft_balance_of") 776 | .args_json(json!({ "account_id": user1.id() })) 777 | .await? 778 | .json::()? 779 | .as_str() 780 | .unwrap() 781 | .parse()?; 782 | let u2_after: u128 = ctx 783 | .pool 784 | .view("ft_balance_of") 785 | .args_json(json!({ "account_id": user2.id() })) 786 | .await? 787 | .json::()? 788 | .as_str() 789 | .unwrap() 790 | .parse()?; 791 | 792 | assert_eq!( 793 | u1_after, u1_before, 794 | "Sender balance should remain unchanged" 795 | ); 796 | assert_eq!( 797 | u2_after, u2_before, 798 | "Receiver balance should remain unchanged" 799 | ); 800 | 801 | Ok(()) 802 | } 803 | 804 | /// End-to-end test covering the owner-driven contract upgrade path via the factory 805 | #[tokio::test] 806 | async fn test_contract_upgrade_flow() -> anyhow::Result<()> { 807 | let worker = near_workspaces::sandbox().await?; 808 | let root = worker.root_account()?; 809 | 810 | let staking_wasm = std::fs::read(STAKING_FARM_WASM)?; 811 | let factory_wasm = std::fs::read(STAKING_FACTORY_WASM)?; 812 | 813 | let owner = root 814 | .create_subaccount("upgradeowner") 815 | .initial_balance(NearToken::from_near(1_000)) 816 | .transact() 817 | .await 818 | .context("create upgradeowner transact")? 819 | .into_result() 820 | .context("create upgradeowner result")?; 821 | 822 | let whitelist = root 823 | .create_subaccount("upgradewhitelist") 824 | .initial_balance(NearToken::from_near(10)) 825 | .transact() 826 | .await 827 | .context("create whitelist transact")? 828 | .into_result() 829 | .context("create whitelist result")?; 830 | 831 | // Deploy factory contract and initialize it with itself as owner so it can approve new code. 832 | let factory = root 833 | .create_subaccount("upgradefactory") 834 | .initial_balance(NearToken::from_near(1_000)) 835 | .transact() 836 | .await 837 | .context("create factory account transact")? 838 | .into_result() 839 | .context("create factory account result")?; 840 | let factory = factory 841 | .deploy(&factory_wasm) 842 | .await 843 | .context("deploy factory wasm")? 844 | .into_result() 845 | .context("deploy factory result")?; 846 | factory 847 | .call("new") 848 | .args_json(json!({ 849 | "owner_id": factory.id(), 850 | "staking_pool_whitelist_account_id": whitelist.id(), 851 | })) 852 | .gas(Gas::from_tgas(100)) 853 | .transact() 854 | .await 855 | .context("factory init transact")? 856 | .into_result() 857 | .context("factory init result")?; 858 | 859 | // Create and initialize the staking pool with the factory as predecessor so it becomes the stored factory_id. 860 | let pool = root 861 | .create_subaccount("upgradepool") 862 | .initial_balance(NearToken::from_near(2_000)) 863 | .transact() 864 | .await 865 | .context("create pool account transact")? 866 | .into_result() 867 | .context("create pool account result")?; 868 | let pool = pool 869 | .deploy(&staking_wasm) 870 | .await 871 | .context("deploy old staking wasm")? 872 | .into_result() 873 | .context("deploy old staking result")?; 874 | factory 875 | .as_account() 876 | .call(pool.id(), "new") 877 | .args_json(json!({ 878 | "owner_id": owner.id(), 879 | "stake_public_key": STAKING_KEY, 880 | "reward_fee_fraction": { "numerator": 1, "denominator": 10 }, 881 | "burn_fee_fraction": { "numerator": 0, "denominator": 10 } 882 | })) 883 | .gas(Gas::from_tgas(300)) 884 | .transact() 885 | .await 886 | .context("init staking pool via factory transact")? 887 | .into_result() 888 | .context("init staking pool via factory result")?; 889 | 890 | // Sanity-check stored factory id. 891 | let stored_factory: AccountId = pool 892 | .view("get_factory_id") 893 | .await 894 | .context("view factory id")? 895 | .json()?; 896 | assert_eq!(stored_factory, factory.id().clone()); 897 | worker 898 | .patch_state(pool.id(), VERSION_KEY, LEGACY_VERSION.as_bytes()) 899 | .await 900 | .context("patch contract version to legacy")?; 901 | 902 | // Stake to create on-chain state that must survive the upgrade. 903 | owner 904 | .call(pool.id(), "deposit_and_stake") 905 | .deposit(NearToken::from_near(10)) 906 | .gas(Gas::from_tgas(200)) 907 | .transact() 908 | .await 909 | .context("deposit_and_stake transact")? 910 | .into_result() 911 | .context("deposit_and_stake result")?; 912 | 913 | let owner_shares_before: u128 = pool 914 | .view("ft_balance_of") 915 | .args_json(json!({ "account_id": owner.id() })) 916 | .await 917 | .context("view ft_balance_of before")? 918 | .json::()? 919 | .parse()?; 920 | 921 | // Store new staking contract code in the factory for upgrade. 922 | let new_code_hash: String = factory 923 | .call("store") 924 | .args(staking_wasm.clone()) 925 | // Generously fund storage so the full WASM blob can be persisted. 926 | .deposit(NearToken::from_near(100)) 927 | .gas(Gas::from_tgas(100)) 928 | .transact() 929 | .await 930 | .context("store new staking code transact")? 931 | .json()?; 932 | factory 933 | .call("allow_contract") 934 | .args_json(json!({ "code_hash": new_code_hash })) 935 | .transact() 936 | .await 937 | .context("allow new code hash transact")? 938 | .into_result() 939 | .context("allow new code hash result")?; 940 | 941 | // Owner triggers upgrade fetching code from the trusted factory. 942 | owner 943 | .call(pool.id(), "upgrade") 944 | .args_json(json!({ "code_hash": new_code_hash })) 945 | .max_gas() 946 | .transact() 947 | .await 948 | .context("upgrade call transact")? 949 | .into_result() 950 | .context("upgrade call result")?; 951 | 952 | // Account stake must remain intact after upgrade + migration. 953 | let owner_shares_after: u128 = pool 954 | .view("ft_balance_of") 955 | .args_json(json!({ "account_id": owner.id() })) 956 | .await 957 | .context("view ft_balance_of after")? 958 | .json::()? 959 | .parse()?; 960 | assert_eq!( 961 | owner_shares_after, owner_shares_before, 962 | "Upgrade should preserve staked share balances" 963 | ); 964 | 965 | let owner_id: AccountId = pool 966 | .view("get_owner_id") 967 | .await 968 | .context("view owner id after")? 969 | .json()?; 970 | assert_eq!(owner_id, owner.id().clone()); 971 | 972 | Ok(()) 973 | } 974 | --------------------------------------------------------------------------------