From f8dc60dcb2cb957eb1cc4ea65e56d6db8b6d6d1a Mon Sep 17 00:00:00 2001 From: CodingCas Date: Mon, 2 Dec 2024 16:48:23 +0100 Subject: [PATCH] staking (#290) * Init: staking component * WIP: added more function signatures in the IStaking interface * created event enum and enum variant structs * implemented initializer() to be used in constructors * implemented read functions * WIP: created helper functions in the InternalImpl * FEAT: staking component implemented * MOD: added custom Errors * LINTING: scarb fmt * MOD: emitted event for stake(), withdraw() and get_reward() functions * MOD: modified notify_reward_amount() and added transfer_from() * FIXESL fixed get_reward() impl to update rewards before calculating user rewards * MOD: changed get_reward() to claim_reward() for clarity. * FEAT: implemented staking_reward contract to use StakingComponent * TEST: completed test setup for staking contract and component * TEST: implemented test_notify_reward_amount() and test_stake() * TEST: implemented test_stake(), test_rewards_earned(), test_claim_reward() and test_withdraw() * MOD: created interfaces.cairo and modified imports. * MOD: uncommented some test mods * MOD: added requested review changes * pulled from main * merge main --- .../cairo/.snfoundry_cache/.prev_tests_failed | 28 +- onchain/cairo/.tool-versions | 1 + onchain/cairo/src/lib.cairo | 5 +- onchain/cairo/src/staking.cairo | 3 + onchain/cairo/src/staking/interfaces.cairo | 46 +++ onchain/cairo/src/staking/mocks.cairo | 2 + .../cairo/src/staking/mocks/mock_erc20.cairo | 144 +++++++ .../src/staking/mocks/staking_rewards.cairo | 34 ++ onchain/cairo/src/staking/staking.cairo | 312 ++++++++++++++ onchain/cairo/src/tests/staking_tests.cairo | 384 ++++++++++++++++++ onchain/cairo/src/types/keys_types.cairo | 2 +- 11 files changed, 945 insertions(+), 16 deletions(-) create mode 100644 onchain/cairo/.tool-versions create mode 100644 onchain/cairo/src/staking.cairo create mode 100644 onchain/cairo/src/staking/interfaces.cairo create mode 100644 onchain/cairo/src/staking/mocks.cairo create mode 100644 onchain/cairo/src/staking/mocks/mock_erc20.cairo create mode 100644 onchain/cairo/src/staking/mocks/staking_rewards.cairo create mode 100644 onchain/cairo/src/staking/staking.cairo create mode 100644 onchain/cairo/src/tests/staking_tests.cairo diff --git a/onchain/cairo/.snfoundry_cache/.prev_tests_failed b/onchain/cairo/.snfoundry_cache/.prev_tests_failed index 99d6e529..d2d6a781 100644 --- a/onchain/cairo/.snfoundry_cache/.prev_tests_failed +++ b/onchain/cairo/.snfoundry_cache/.prev_tests_failed @@ -1,14 +1,14 @@ -afk::tests::launchpad_tests::launchpad_tests::launchpad_test_calculation -afk::tests::launchpad_tests::launchpad_tests::test_get_coin_amount_by_quote_amount_for_buy_steps -afk::tests::launchpad_tests::launchpad_tests::test_get_coin_launch -afk::tests::launchpad_tests::launchpad_tests::test_get_share_key_of_user -afk::tests::launchpad_tests::launchpad_tests::test_launch_token -afk::tests::launchpad_tests::launchpad_tests::test_launch_token_with_uncreated_token -afk::tests::launchpad_tests::launchpad_tests::test_sell_coin_when_quote_amount_is_greater_than_liquidity_raised -afk::tests::launchpad_tests::launchpad_tests::test_sell_coin_when_share_too_low -afk::tests::launchpad_tests::launchpad_tests::test_set_protocol_fee_percent_non_admin -afk::tests::launchpad_tests::launchpad_tests::test_buy_coin_with_different_supply -afk::tests::launchpad_tests::launchpad_tests::launchpad_integration -afk::tests::launchpad_tests::launchpad_tests::launchpad_buy_all -afk::tests::launchpad_tests::launchpad_tests::test_launchpad_end_to_end -afk::tests::launchpad_tests::launchpad_tests::launchpad_end_to_end \ No newline at end of file +afk::tests::launchpad_tests::launchpad_tests::launchpad_test_calculation +afk::tests::launchpad_tests::launchpad_tests::test_get_coin_amount_by_quote_amount_for_buy_steps +afk::tests::launchpad_tests::launchpad_tests::test_get_coin_launch +afk::tests::launchpad_tests::launchpad_tests::test_get_share_key_of_user +afk::tests::launchpad_tests::launchpad_tests::test_launch_token +afk::tests::launchpad_tests::launchpad_tests::test_launch_token_with_uncreated_token +afk::tests::launchpad_tests::launchpad_tests::test_sell_coin_when_quote_amount_is_greater_than_liquidity_raised +afk::tests::launchpad_tests::launchpad_tests::test_sell_coin_when_share_too_low +afk::tests::launchpad_tests::launchpad_tests::test_set_protocol_fee_percent_non_admin +afk::tests::launchpad_tests::launchpad_tests::test_buy_coin_with_different_supply +afk::tests::launchpad_tests::launchpad_tests::launchpad_integration +afk::tests::launchpad_tests::launchpad_tests::launchpad_buy_all +afk::tests::launchpad_tests::launchpad_tests::test_launchpad_end_to_end +afk::tests::launchpad_tests::launchpad_tests::launchpad_end_to_end diff --git a/onchain/cairo/.tool-versions b/onchain/cairo/.tool-versions new file mode 100644 index 00000000..ec3fd10b --- /dev/null +++ b/onchain/cairo/.tool-versions @@ -0,0 +1 @@ +scarb 2.8.5 diff --git a/onchain/cairo/src/lib.cairo b/onchain/cairo/src/lib.cairo index 4651c386..08513673 100644 --- a/onchain/cairo/src/lib.cairo +++ b/onchain/cairo/src/lib.cairo @@ -7,6 +7,9 @@ pub mod keys; pub mod math; pub mod sha256; pub mod social; + +pub mod staking; + pub mod utils; pub mod launchpad { @@ -122,5 +125,5 @@ pub mod tests { pub mod tap_tests; pub mod utils; pub mod vault_tests; + pub mod staking_tests; } - diff --git a/onchain/cairo/src/staking.cairo b/onchain/cairo/src/staking.cairo new file mode 100644 index 00000000..1e50b174 --- /dev/null +++ b/onchain/cairo/src/staking.cairo @@ -0,0 +1,3 @@ +pub mod staking; +pub mod mocks; +pub mod interfaces; diff --git a/onchain/cairo/src/staking/interfaces.cairo b/onchain/cairo/src/staking/interfaces.cairo new file mode 100644 index 00000000..d1c82f62 --- /dev/null +++ b/onchain/cairo/src/staking/interfaces.cairo @@ -0,0 +1,46 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC20 { + fn name(self: @TContractState) -> ByteArray; + fn symbol(self: @TContractState) -> ByteArray; + fn decimals(self: @TContractState) -> u8; + + fn total_supply(self: @TContractState) -> u256; + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256; + + fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; + fn transfer_from(ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool; + + fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; +} + +#[starknet::interface] +pub trait IStaking { + fn set_rewards_duration(ref self: TContractState, duration: u256); + fn notify_reward_amount(ref self: TContractState, amount: u256); + fn stake(ref self: TContractState, amount: u256); + fn withdraw(ref self: TContractState, amount: u256); + fn claim_reward(ref self: TContractState); + + fn last_time_reward_applicable(self: @TContractState) -> u256; + fn reward_per_token(self: @TContractState) -> u256; + fn rewards_earned(self: @TContractState, account: ContractAddress) -> u256; + + fn staking_token(self: @TContractState) -> ContractAddress; + fn rewards_token(self: @TContractState) -> ContractAddress; + fn duration(self: @TContractState) -> u256; + fn finish_at(self: @TContractState) -> u256; + fn updated_at(self: @TContractState) -> u256; + fn reward_rate(self: @TContractState) -> u256; + fn reward_per_token_stored(self: @TContractState) -> u256; + fn user_reward_per_token_paid(self: @TContractState, user: ContractAddress) -> u256; + fn rewards(self: @TContractState, user: ContractAddress) -> u256; + fn total_supply(self: @TContractState) -> u256; + fn balance_of(self: @TContractState, user: ContractAddress) -> u256; + fn owner(self: @TContractState) -> ContractAddress; + + fn return_block_timestamp(self: @TContractState) -> u256; +} \ No newline at end of file diff --git a/onchain/cairo/src/staking/mocks.cairo b/onchain/cairo/src/staking/mocks.cairo new file mode 100644 index 00000000..413e9667 --- /dev/null +++ b/onchain/cairo/src/staking/mocks.cairo @@ -0,0 +1,2 @@ +pub mod staking_rewards; +pub mod mock_erc20; \ No newline at end of file diff --git a/onchain/cairo/src/staking/mocks/mock_erc20.cairo b/onchain/cairo/src/staking/mocks/mock_erc20.cairo new file mode 100644 index 00000000..58645a82 --- /dev/null +++ b/onchain/cairo/src/staking/mocks/mock_erc20.cairo @@ -0,0 +1,144 @@ +#[starknet::contract] +pub mod MockToken { + use starknet::event::EventEmitter; + use starknet::{ContractAddress, get_caller_address}; + use core::starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess, Map, StoragePathEntry}; + use crate::staking::interfaces::IERC20; + use core::num::traits::Zero; + + #[storage] + pub struct Storage { + balances: Map, + allowances: Map<(ContractAddress, ContractAddress), u256>, // Mapping<(owner, spender), amount> + token_name: ByteArray, + symbol: ByteArray, + decimal: u8, + total_supply: u256, + owner: ContractAddress, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Transfer: Transfer, + Approval: Approval, + } + + #[derive(Drop, starknet::Event)] + pub struct Transfer { + #[key] + from: ContractAddress, + #[key] + to: ContractAddress, + amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Approval { + #[key] + owner: ContractAddress, + #[key] + spender: ContractAddress, + value: u256 + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.token_name.write("Staking Token"); + self.symbol.write("SKT"); + self.decimal.write(18); + self.owner.write(get_caller_address()); + } + + #[abi(embed_v0)] + impl MockTokenImpl of IERC20 { + fn total_supply(self: @ContractState) -> u256 { + self.total_supply.read() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + let balance = self.balances.entry(account).read(); + + balance + } + + fn allowance(self: @ContractState, owner: ContractAddress, spender: ContractAddress) -> u256 { + let allowance = self.allowances.entry((owner, spender)).read(); + + allowance + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + let sender = get_caller_address(); + + let sender_prev_balance = self.balances.entry(sender).read(); + let recipient_prev_balance = self.balances.entry(recipient).read(); + + assert(sender_prev_balance >= amount, 'Insufficient amount'); + + self.balances.entry(sender).write(sender_prev_balance - amount); + self.balances.entry(recipient).write(recipient_prev_balance + amount); + + assert(self.balances.entry(recipient).read() > recipient_prev_balance, 'Transaction failed'); + + self.emit(Transfer { from: sender, to: recipient, amount }); + + true + } + + fn transfer_from(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool { + let spender = get_caller_address(); + + let spender_allowance = self.allowances.entry((sender, spender)).read(); + let sender_balance = self.balances.entry(sender).read(); + let recipient_balance = self.balances.entry(recipient).read(); + + assert(amount <= spender_allowance, 'amount exceeds allowance'); + assert(amount <= sender_balance, 'amount exceeds balance'); + + self.allowances.entry((sender, spender)).write(spender_allowance - amount); + self.balances.entry(sender).write(sender_balance - amount); + self.balances.entry(recipient).write(recipient_balance + amount); + + self.emit(Transfer { from: sender, to: recipient, amount }); + + true + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool { + let caller = get_caller_address(); + + self.allowances.entry((caller, spender)).write(amount); + + self.emit(Approval { owner: caller, spender, value: amount }); + + true + } + + fn name(self: @ContractState) -> ByteArray { + self.token_name.read() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.symbol.read() + } + + fn decimals(self: @ContractState) -> u8 { + self.decimal.read() + } + + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool { + let previous_total_supply = self.total_supply.read(); + let previous_balance = self.balances.entry(recipient).read(); + + self.total_supply.write(previous_total_supply + amount); + self.balances.entry(recipient).write(previous_balance + amount); + + let zero_address = Zero::zero(); + + self.emit(Transfer { from: zero_address, to: recipient, amount }); + + true + } + } +} \ No newline at end of file diff --git a/onchain/cairo/src/staking/mocks/staking_rewards.cairo b/onchain/cairo/src/staking/mocks/staking_rewards.cairo new file mode 100644 index 00000000..5badde61 --- /dev/null +++ b/onchain/cairo/src/staking/mocks/staking_rewards.cairo @@ -0,0 +1,34 @@ +#[starknet::contract] +mod StakingRewards { + use crate::staking::staking::StakingComponent; + use starknet::ContractAddress; + + component!(path: StakingComponent, storage: staking, event: StakingEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + staking: StakingComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + StakingEvent: StakingComponent::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, + owner: ContractAddress, + staking_token: ContractAddress, + reward_token: ContractAddress + ) { + self.staking._initializer(owner, staking_token, reward_token); + } + + #[abi(embed_v0)] + impl StakingImpl = StakingComponent::StakingImpl; + impl StakingInternalImpl = StakingComponent::InternalImpl; +} \ No newline at end of file diff --git a/onchain/cairo/src/staking/staking.cairo b/onchain/cairo/src/staking/staking.cairo new file mode 100644 index 00000000..54a97646 --- /dev/null +++ b/onchain/cairo/src/staking/staking.cairo @@ -0,0 +1,312 @@ +#[starknet::component] +pub mod StakingComponent { + use core::num::traits::Zero; + use core::starknet::storage::{ + StoragePointerReadAccess, StoragePointerWriteAccess, Map, StoragePathEntry + }; + use core::starknet::{ + ContractAddress, get_block_timestamp, contract_address_const, get_caller_address, + get_contract_address + }; + use crate::staking::interfaces::{IStaking, IERC20Dispatcher, IERC20DispatcherTrait}; + + const ONE_E18: u256 = 1000000000000000000_u256; + + #[storage] + struct Storage { + owner: ContractAddress, + staking_token: ContractAddress, + rewards_token: ContractAddress, + duration: u256, + finish_at: u256, + updated_at: u256, + reward_rate: u256, + reward_per_token_stored: u256, + user_reward_per_token_paid: Map, + rewards: Map, + total_supply: u256, + balance_of: Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + StakedSuccessful: StakedSuccessful, + WithdrawalSuccessful: WithdrawalSuccessful, + RewardsWithdrawn: RewardsWithdrawn + } + + #[derive(Drop, starknet::Event)] + struct StakedSuccessful { + user: ContractAddress, + amount: u256 + } + + #[derive(Drop, starknet::Event)] + struct WithdrawalSuccessful { + user: ContractAddress, + amount: u256 + } + + #[derive(Drop, starknet::Event)] + struct RewardsWithdrawn { + user: ContractAddress, + amount: u256 + } + + pub mod Errors { + pub const NOT_AUTHORIZED: felt252 = 'Not authorized owner'; + pub const REWARD_DURATION_NOT_FINISHED: felt252 = 'Reward duration not finished'; + pub const REWARD_RATE_IS_ZERO: felt252 = 'Reward rate = 0'; + pub const REWARD_AMOUNT_GREATER_THAN_CONTRACT_BALANCE: felt252 = 'Reward amount > balance'; + pub const AMOUNT_IS_ZERO: felt252 = 'Amount = 0'; + pub const INSUFFICIENT_STAKE_BALANCE: felt252 = 'Insufficient stake balance'; + pub const TRANSFER_FAILED: felt252 = 'Transfer failed'; + } + + #[embeddable_as(StakingImpl)] + impl Staking< + TContractState, +HasComponent + > of IStaking> { + fn set_rewards_duration(ref self: ComponentState, duration: u256) { + let caller = get_caller_address(); + assert(caller == self.owner.read(), Errors::NOT_AUTHORIZED); + + let block_timestamp: u256 = get_block_timestamp().try_into().unwrap(); + assert(self.finish_at.read() < block_timestamp, Errors::REWARD_DURATION_NOT_FINISHED); + + self.duration.write(duration); + } + + fn notify_reward_amount(ref self: ComponentState, amount: u256) { + let caller = get_caller_address(); + let this_contract = get_contract_address(); + + assert(caller == self.owner.read(), Errors::NOT_AUTHORIZED); + + let zero_address = self.zero_address(); + + self._update_reward(zero_address); + + let block_timestamp: u256 = get_block_timestamp().try_into().unwrap(); + + let rewards_token = IERC20Dispatcher { contract_address: self.rewards_token.read() }; + + let transfer_from = rewards_token.transfer_from(caller, this_contract, amount); + assert(transfer_from, Errors::TRANSFER_FAILED); + + if block_timestamp >= self.finish_at.read() { + self.reward_rate.write(amount / self.duration.read()) + } else { + let remaining_rewards = (self.finish_at.read() - block_timestamp) + * self.reward_rate.read(); + + self.reward_rate.write((amount + remaining_rewards) / self.duration.read()); + } + + assert(self.reward_rate.read() > 0, Errors::REWARD_RATE_IS_ZERO); + assert( + self.reward_rate.read() + * self.duration.read() <= rewards_token.balance_of(this_contract), + Errors::REWARD_AMOUNT_GREATER_THAN_CONTRACT_BALANCE + ); + + self.finish_at.write(get_block_timestamp().try_into().unwrap() + self.duration.read()); + self.updated_at.write(get_block_timestamp().try_into().unwrap()); + } + + fn stake(ref self: ComponentState, amount: u256) { + let caller = get_caller_address(); + let this_contract = get_contract_address(); + + self._update_reward(caller); + + assert(amount > 0, Errors::AMOUNT_IS_ZERO); + let staking_token = IERC20Dispatcher { contract_address: self.staking_token.read() }; + let transfer = staking_token.transfer_from(caller, this_contract, amount); + + assert(transfer, Errors::TRANSFER_FAILED); + + let prev_stake = self.balance_of.entry(caller).read(); + self.balance_of.entry(caller).write(prev_stake + amount); + + let prev_supply = self.total_supply.read(); + self.total_supply.write(prev_supply + amount); + + self.emit(StakedSuccessful { + user: caller, + amount + }); + } + + fn withdraw(ref self: ComponentState, amount: u256) { + let caller = get_caller_address(); + + self._update_reward(caller); + + assert(amount > 0, Errors::AMOUNT_IS_ZERO); + + let prev_stake = self.balance_of.entry(caller).read(); + assert(prev_stake >= amount, Errors::INSUFFICIENT_STAKE_BALANCE); + self.balance_of.entry(caller).write(prev_stake - amount); + + let prev_supply = self.total_supply.read(); + self.total_supply.write(prev_supply - amount); + + let staking_token = IERC20Dispatcher { contract_address: self.staking_token.read() }; + let transfer = staking_token.transfer(caller, amount); + + assert(transfer, Errors::TRANSFER_FAILED); + + self.emit(WithdrawalSuccessful { + user: caller, + amount + }); + } + + fn claim_reward(ref self: ComponentState) { + let caller = get_caller_address(); + + self._update_reward(caller); + + let reward = self.rewards.entry(caller).read(); + + if reward > 0 { + self.rewards.entry(caller).write(0); + let transfer = IERC20Dispatcher { contract_address: self.rewards_token.read() } + .transfer(caller, reward); + + assert(transfer, Errors::TRANSFER_FAILED); + } + + self.emit(RewardsWithdrawn { + user: caller, + amount: reward + }); + } + + + //////////////////// Read Functions //////////////////// + fn last_time_reward_applicable(self: @ComponentState) -> u256 { + let block_timestamp: u256 = get_block_timestamp().try_into().unwrap(); + self.min(self.finish_at.read(), block_timestamp) + } + + fn reward_per_token(self: @ComponentState) -> u256 { + if self.total_supply.read() == 0 { + self.reward_per_token_stored.read() + } else { + self.reward_per_token_stored.read() + + (self.reward_rate.read() + * (self.last_time_reward_applicable() - self.updated_at.read()) + * ONE_E18) + / self.total_supply.read() + } + } + + fn rewards_earned(self: @ComponentState, account: ContractAddress) -> u256 { + ((self.balance_of.entry(account).read() + * (self.reward_per_token() - self.user_reward_per_token_paid.entry(account).read())) + / ONE_E18) + + self.rewards.entry(account).read() + } + + fn staking_token(self: @ComponentState) -> ContractAddress { + self.staking_token.read() + } + + fn rewards_token(self: @ComponentState) -> ContractAddress { + self.rewards_token.read() + } + + fn duration(self: @ComponentState) -> u256 { + self.duration.read() + } + + fn finish_at(self: @ComponentState) -> u256 { + self.finish_at.read() + } + + fn updated_at(self: @ComponentState) -> u256 { + self.updated_at.read() + } + + fn reward_rate(self: @ComponentState) -> u256 { + self.reward_rate.read() + } + + fn reward_per_token_stored(self: @ComponentState) -> u256 { + self.reward_per_token_stored.read() + } + + fn user_reward_per_token_paid( + self: @ComponentState, user: ContractAddress + ) -> u256 { + self.user_reward_per_token_paid.entry(user).read() + } + + fn rewards(self: @ComponentState, user: ContractAddress) -> u256 { + self.rewards.entry(user).read() + } + + fn total_supply(self: @ComponentState) -> u256 { + self.total_supply.read() + } + + fn balance_of(self: @ComponentState, user: ContractAddress) -> u256 { + self.balance_of.entry(user).read() + } + + fn owner(self: @ComponentState) -> ContractAddress { + self.owner.read() + } + + fn return_block_timestamp(self: @ComponentState) -> u256 { + get_block_timestamp().try_into().unwrap() + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, +HasComponent + > of InternalTrait { + /// Initializes the contract by setting the owner, staking_token and reward_token. + /// To prevent reinitialization, this should only be used inside of a contract's + /// constructor. + fn _initializer( + ref self: ComponentState, + owner: ContractAddress, + staking_token: ContractAddress, + reward_token: ContractAddress + ) { + self.owner.write(owner); + self.staking_token.write(staking_token); + self.rewards_token.write(reward_token); + } + + fn _update_reward(ref self: ComponentState, account: ContractAddress) { + self.reward_per_token_stored.write(self.reward_per_token()); + self.updated_at.write(self.last_time_reward_applicable()); + + if account.is_non_zero() { + self.rewards.entry(account).write(self.rewards_earned(account)); + self + .user_reward_per_token_paid + .entry(account) + .write(self.reward_per_token_stored.read()); + } + } + + fn min(self: @ComponentState, x: u256, y: u256) -> u256 { + if x <= y { + x + } else { + y + } + } + + fn zero_address(self: @ComponentState) -> ContractAddress { + contract_address_const::<0>() + } + } +} diff --git a/onchain/cairo/src/tests/staking_tests.cairo b/onchain/cairo/src/tests/staking_tests.cairo new file mode 100644 index 00000000..dc3e75ad --- /dev/null +++ b/onchain/cairo/src/tests/staking_tests.cairo @@ -0,0 +1,384 @@ +use starknet::ContractAddress; +use snforge_std::{ + declare, ContractClassTrait, DeclareResultTrait, + start_cheat_caller_address, stop_cheat_caller_address, + start_cheat_block_timestamp_global, stop_cheat_block_timestamp_global +}; + +const ONE_E18: u256 = 1000000000000000000_u256; + +use afk::staking::interfaces::{IERC20Dispatcher, IERC20DispatcherTrait, IStakingDispatcher, IStakingDispatcherTrait}; + +fn deploy_token(name: ByteArray) -> ContractAddress { + let contract = declare("MockToken").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap(); + contract_address +} + +fn deploy_staking_contract(name: ByteArray, staking_token: ContractAddress, reward_token: ContractAddress) -> ContractAddress { + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mut constructor_calldata = ArrayTrait::new(); + + constructor_calldata.append(owner.into()); + constructor_calldata.append(staking_token.into()); + constructor_calldata.append(reward_token.into()); + + let contract = declare(name).unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap(); + + contract_address +} + +#[test] +fn test_token_mint() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + + let staking_token = IERC20Dispatcher { contract_address: staking_token_address }; + let reward_token = IERC20Dispatcher { contract_address: reward_token_address }; + + let receiver: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mint_amount: u256 = 10000_u256; + staking_token.mint(receiver, mint_amount); + reward_token.mint(receiver, mint_amount); + + assert!(staking_token.balance_of(receiver) == mint_amount, "wrong staking token balance"); + assert!(reward_token.balance_of(receiver) == mint_amount, "wrong reward token balance"); + assert!(staking_token.balance_of(receiver) > 0, "balance failed to increase"); + assert!(reward_token.balance_of(receiver) > 0, "balance didn't increase"); +} + +#[test] +fn test_staking_constructor() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + assert!(staking_contract.owner() == owner, "wrong owner"); + assert!(staking_contract.staking_token() == staking_token_address, "wrong staking token address"); + assert!(staking_contract.rewards_token() == reward_token_address, "wrong reward token address"); +} + +#[test] +#[should_panic(expected: ('Not authorized owner',))] +fn test_set_reward_duration_should_panic() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let duration: u256 = 1800_u256; + + staking_contract.set_rewards_duration(duration); +} + +#[test] +fn test_set_rewards_duration() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + let duration: u256 = 1800_u256; + + // using a block timestamp cheat to avoid get_block_timestamp() from returning 0: which is default on test environment + start_cheat_block_timestamp_global(1698152400); + + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.set_rewards_duration(duration); + stop_cheat_caller_address(staking_contract_address); + + assert!(staking_contract.duration() == duration, "duration not properly set"); + + stop_cheat_block_timestamp_global(); +} + +#[test] +fn test_notify_reward_amount() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_token = IERC20Dispatcher { contract_address: staking_token_address }; + let reward_token = IERC20Dispatcher { contract_address: reward_token_address }; + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mint_amount: u256 = 10000_u256 * ONE_E18; + staking_token.mint(owner, mint_amount); + reward_token.mint(owner, mint_amount); + + assert!(staking_token.balance_of(owner) == mint_amount, "wrong staking token balance"); + assert!(reward_token.balance_of(owner) == mint_amount, "wrong reward token balance"); + + // Approve staking contract to spend reward token + start_cheat_caller_address(reward_token_address, owner); + reward_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(reward_token_address); + assert!(reward_token.allowance(owner, staking_contract_address) == mint_amount, "reward token approval failed"); + + let duration: u256 = 1800_u256; + + // using a block timestamp cheat to avoid get_block_timestamp() from returning 0: which is default on test environment + start_cheat_block_timestamp_global(1698152400); + + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.set_rewards_duration(duration); + staking_contract.notify_reward_amount(mint_amount); + stop_cheat_caller_address(staking_contract_address); + + assert!(staking_contract.duration() == duration, "duration not properly set"); + assert!(staking_contract.finish_at() == staking_contract.return_block_timestamp() + duration, "reward notification failed"); + + stop_cheat_block_timestamp_global(); +} + +#[test] +fn test_stake() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_token = IERC20Dispatcher { contract_address: staking_token_address }; + let reward_token = IERC20Dispatcher { contract_address: reward_token_address }; + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mint_amount: u256 = 10000_u256 * ONE_E18; + staking_token.mint(owner, mint_amount); + reward_token.mint(owner, mint_amount); + + assert!(staking_token.balance_of(owner) == mint_amount, "wrong staking token balance"); + assert!(reward_token.balance_of(owner) == mint_amount, "wrong reward token balance"); + + // Approve staking contract to spend reward token + start_cheat_caller_address(reward_token_address, owner); + reward_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(reward_token_address); + assert!(reward_token.allowance(owner, staking_contract_address) == mint_amount, "reward token approval failed"); + + // Approve staking contract to spend staking token. + start_cheat_caller_address(staking_token_address, owner); + staking_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(staking_token_address); + assert!(staking_token.allowance(owner, staking_contract_address) == mint_amount, "staking token approval failed"); + + let duration: u256 = 1800_u256; + let stake_amount = 100_u256 * ONE_E18; + + // using a block timestamp cheat to avoid get_block_timestamp() from returning 0: which is default on test environment + start_cheat_block_timestamp_global(1698152400); + + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.set_rewards_duration(duration); + staking_contract.notify_reward_amount(mint_amount); + staking_contract.stake(stake_amount); + stop_cheat_caller_address(staking_contract_address); + + assert!(staking_contract.total_supply() == stake_amount, "stake failed"); + assert!(staking_token.balance_of(staking_contract_address) == stake_amount, "stake didn't work"); + + stop_cheat_block_timestamp_global(); +} + +#[test] +fn test_rewards_earned() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_token = IERC20Dispatcher { contract_address: staking_token_address }; + let reward_token = IERC20Dispatcher { contract_address: reward_token_address }; + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mint_amount: u256 = 10000_u256 * ONE_E18; + staking_token.mint(owner, mint_amount); + reward_token.mint(owner, mint_amount); + + assert!(staking_token.balance_of(owner) == mint_amount, "wrong staking token balance"); + assert!(reward_token.balance_of(owner) == mint_amount, "wrong reward token balance"); + + // Approve staking contract to spend reward token + start_cheat_caller_address(reward_token_address, owner); + reward_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(reward_token_address); + assert!(reward_token.allowance(owner, staking_contract_address) == mint_amount, "reward token approval failed"); + + // Approve staking contract to spend staking token. + start_cheat_caller_address(staking_token_address, owner); + staking_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(staking_token_address); + assert!(staking_token.allowance(owner, staking_contract_address) == mint_amount, "staking token approval failed"); + + let duration: u256 = 1800_u256; + let stake_amount = 100_u256 * ONE_E18; + + // using a block timestamp cheat to prevent get_block_timestamp() from returning 0: which is default on test environment + start_cheat_block_timestamp_global(1698152400); + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.set_rewards_duration(duration); + staking_contract.notify_reward_amount(mint_amount); + staking_contract.stake(stake_amount); + stop_cheat_caller_address(staking_contract_address); + stop_cheat_block_timestamp_global(); + + // Using a 10mins increased block_timestamp to stake again + start_cheat_block_timestamp_global(1698153000); + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.stake(stake_amount); + stop_cheat_caller_address(staking_contract_address); + + assert!(staking_contract.total_supply() == stake_amount + stake_amount, "stake failed"); + assert!(staking_token.balance_of(staking_contract_address) == stake_amount + stake_amount, "stake didn't work"); + + // testing assert that user earnings increased + assert!(staking_contract.rewards_earned(owner) > 0, "earnings didn't increase"); + assert!(staking_contract.rewards(owner) > 0, "rewards didn't increase"); + + assert!(staking_contract.reward_per_token_stored() > 0, "wrong reward_per_token_stored"); + assert!(staking_contract.user_reward_per_token_paid(owner) > 0, "wrong user_reward_per_token_paid"); + + + stop_cheat_block_timestamp_global(); +} + +#[test] +fn test_claim_reward() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_token = IERC20Dispatcher { contract_address: staking_token_address }; + let reward_token = IERC20Dispatcher { contract_address: reward_token_address }; + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mint_amount: u256 = 10000_u256 * ONE_E18; + staking_token.mint(owner, mint_amount); + reward_token.mint(owner, mint_amount); + + assert!(staking_token.balance_of(owner) == mint_amount, "wrong staking token balance"); + assert!(reward_token.balance_of(owner) == mint_amount, "wrong reward token balance"); + + // Approve staking contract to spend reward token + start_cheat_caller_address(reward_token_address, owner); + reward_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(reward_token_address); + assert!(reward_token.allowance(owner, staking_contract_address) == mint_amount, "reward token approval failed"); + + // Approve staking contract to spend staking token. + start_cheat_caller_address(staking_token_address, owner); + staking_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(staking_token_address); + assert!(staking_token.allowance(owner, staking_contract_address) == mint_amount, "staking token approval failed"); + + let duration: u256 = 1800_u256; + let stake_amount = 100_u256 * ONE_E18; + + // using a block timestamp cheat to prevent get_block_timestamp() from returning 0: which is default on test environment + start_cheat_block_timestamp_global(1698152400); + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.set_rewards_duration(duration); + staking_contract.notify_reward_amount(mint_amount); + staking_contract.stake(stake_amount); + stop_cheat_caller_address(staking_contract_address); + stop_cheat_block_timestamp_global(); + assert!(reward_token.balance_of(owner) == 0, "reward notification failed"); + + // Using a 10mins increased block_timestamp to stake again + start_cheat_block_timestamp_global(1698153000); + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.stake(stake_amount); + stop_cheat_caller_address(staking_contract_address); + + assert!(staking_contract.total_supply() == stake_amount + stake_amount, "stake failed"); + assert!(staking_token.balance_of(staking_contract_address) == stake_amount + stake_amount, "stake didn't work"); + + // testing assert that user earnings increased + assert!(staking_contract.rewards_earned(owner) > 0, "earnings didn't increase"); + assert!(staking_contract.rewards(owner) > 0, "rewards didn't increase"); + assert!(staking_contract.reward_per_token_stored() > 0, "wrong reward_per_token_stored"); + assert!(staking_contract.user_reward_per_token_paid(owner) > 0, "wrong user_reward_per_token_paid"); + + // Testing user claiming rewards + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.claim_reward(); + stop_cheat_caller_address(staking_contract_address); + assert!(reward_token.balance_of(owner) > 0, "reward claiming failed"); + + stop_cheat_block_timestamp_global(); +} + +#[test] +fn test_withdraw() { + let staking_token_address = deploy_token("StakingToken"); + let reward_token_address = deploy_token("RewardToken"); + let staking_contract_address = deploy_staking_contract("StakingRewards", staking_token_address, reward_token_address); + + let staking_token = IERC20Dispatcher { contract_address: staking_token_address }; + let reward_token = IERC20Dispatcher { contract_address: reward_token_address }; + let staking_contract = IStakingDispatcher { contract_address: staking_contract_address }; + + let owner: ContractAddress = starknet::contract_address_const::<0x123626789>(); + + let mint_amount: u256 = 10000_u256 * ONE_E18; + staking_token.mint(owner, mint_amount); + reward_token.mint(owner, mint_amount); + + assert!(staking_token.balance_of(owner) == mint_amount, "wrong staking token balance"); + assert!(reward_token.balance_of(owner) == mint_amount, "wrong reward token balance"); + + // Approve staking contract to spend reward token + start_cheat_caller_address(reward_token_address, owner); + reward_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(reward_token_address); + assert!(reward_token.allowance(owner, staking_contract_address) == mint_amount, "reward token approval failed"); + + // Approve staking contract to spend staking token. + start_cheat_caller_address(staking_token_address, owner); + staking_token.approve(staking_contract_address, mint_amount); + stop_cheat_caller_address(staking_token_address); + assert!(staking_token.allowance(owner, staking_contract_address) == mint_amount, "staking token approval failed"); + + let duration: u256 = 1800_u256; + let stake_amount = 100_u256 * ONE_E18; + + // using a block timestamp cheat to avoid get_block_timestamp() from returning 0: which is default on test environment + start_cheat_block_timestamp_global(1698152400); + + // Mocking owner address + // Stake and assert that staking contract balance increased and owner's balance decreased + start_cheat_caller_address(staking_contract_address, owner); + staking_contract.set_rewards_duration(duration); + staking_contract.notify_reward_amount(mint_amount); + staking_contract.stake(stake_amount); + + let balance_after_stake = staking_token.balance_of(owner); + + assert!(staking_contract.total_supply() == stake_amount, "stake failed"); + assert!(staking_token.balance_of(staking_contract_address) == stake_amount, "stake didn't work"); + assert!(balance_after_stake == mint_amount - stake_amount, "stake didn't work"); + + // Withdraw from staking contract and assert that staking contract balance decreased and owner's balance increased + staking_contract.withdraw(stake_amount); + assert!(staking_contract.total_supply() == 0, "withdraw failed"); + assert!(staking_token.balance_of(staking_contract_address) == 0, "withdraw didn't work"); + assert!(staking_token.balance_of(owner) == balance_after_stake + stake_amount, "wrong user balance after withdraw"); + + stop_cheat_caller_address(staking_contract_address); + stop_cheat_block_timestamp_global(); +} \ No newline at end of file diff --git a/onchain/cairo/src/types/keys_types.cairo b/onchain/cairo/src/types/keys_types.cairo index 70053183..d36ef89e 100644 --- a/onchain/cairo/src/types/keys_types.cairo +++ b/onchain/cairo/src/types/keys_types.cairo @@ -118,7 +118,7 @@ pub fn get_current_price(key: @Keys, supply: u256, amount_to_buy: u256) -> u256 pub fn get_linear_price( // key: @Keys, - key: Keys, supply: u256, // amount_to_buy: u256 +key: Keys, supply: u256, // amount_to_buy: u256 ) -> u256 { let step_increase_linear = key.token_quote.step_increase_linear.clone(); let initial_key_price = key.token_quote.initial_key_price.clone();