diff --git a/.gitignore b/.gitignore index 639b122..6d46840 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.env +.vscode +.starkli +.DS_Store ####### Scarb ####### diff --git a/src/lib.cairo b/src/lib.cairo index 5c79614..447038d 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -19,6 +19,14 @@ pub mod staker; #[cfg(test)] mod staker_test; +pub mod staker_v2; +#[cfg(test)] +mod staker_v2_test; + +pub mod staker_log; +#[cfg(test)] +pub mod staker_log_test; + mod interfaces { pub(crate) mod erc20; } diff --git a/src/staker_log.cairo b/src/staker_log.cairo new file mode 100644 index 0000000..d71d07f --- /dev/null +++ b/src/staker_log.cairo @@ -0,0 +1,178 @@ +use starknet::storage::{ + Mutable, StorageAsPath, StorageBase, StoragePointerReadAccess, StoragePointerWriteAccess, + Vec, VecTrait, MutableVecTrait +}; + +use starknet::storage_access::{StorePacking}; +use starknet::{get_block_timestamp}; + + +pub type StakingLog = Vec; + +const TWO_POW_32: u64 = 0x100000000_u64; +const MASK_32_BITS: u128 = 0x100000000_u128 - 1; +const TWO_POW_160: u256 = 0x10000000000000000000000000000000000000000; + +#[derive(Drop, Serde, Copy)] +pub(crate) struct StakingLogRecord { + pub(crate) timestamp: u64, + // Only 128+32=160 bits are used + pub(crate) time_weighted_total_staked_sum: u256, + pub(crate) seconds_per_total_staked_sum: u256, +} + +#[generate_trait] +impl InternalStakingLogOperations of InternalLogOperations { + fn search_recursive( + self: @StorageBase, timestamp: u64, left: u64, right: u64, + ) -> Option<(StakingLogRecord, u64)> { + let log = self.as_path(); + + if left > right { + return Option::None; + } + + let center = (right + left) / 2; + let record = log.at(center).read(); + + if record.timestamp <= timestamp { + let res = self + .search_recursive(timestamp, center + 1, right) + .unwrap_or((record, center)); + + Option::Some(res) + } else { + self.search_recursive(timestamp, left, center - 1) + } + } +} + +#[generate_trait] +pub impl StakingLogOperations of LogOperations { + + fn find_record_on_or_before_timestamp( + self: @StorageBase, timestamp: u64, + ) -> Option<(StakingLogRecord, u64)> { + let log = self.as_path(); + if log.len() == 0 { + return Option::None; + } + + if log.at(0).read().timestamp > timestamp { + return Option::None; + } + + return self.search_recursive(timestamp, 0, log.len() - 1); + } + + fn log_change( + self: StorageBase>, amount: u128, total_staked_before_change: u128, + ) { + let log = self.as_path(); + + let block_timestamp = get_block_timestamp(); + + if log.len() == 0 { + log + .append() + .write( + StakingLogRecord { + timestamp: block_timestamp, + time_weighted_total_staked_sum: 0_u256, + seconds_per_total_staked_sum: 0_u64.into(), + }, + ); + + return; + } + + let last_record_ptr = log.at(log.len() - 1); + + let mut last_record = last_record_ptr.read(); + + let mut record = if last_record.timestamp == block_timestamp { + // update record + last_record_ptr + } else { + // create new record + log.append() + }; + + // Might be zero + let seconds_diff = block_timestamp - last_record.timestamp; + + let time_weighted_total_staked = total_staked_before_change.into() * seconds_diff.into(); + + let staked_seconds_per_total_staked: u256 = if total_staked_before_change == 0 { + 0_u64.into() + } else { + let res = u256 { low: 0, high: seconds_diff.into() } + / total_staked_before_change.into(); + res + }; + + // Add a new record. + record + .write( + StakingLogRecord { + timestamp: block_timestamp, + time_weighted_total_staked_sum: last_record.time_weighted_total_staked_sum + + time_weighted_total_staked, + seconds_per_total_staked_sum: last_record.seconds_per_total_staked_sum + + staked_seconds_per_total_staked, + }, + ); + } +} + +// +// Storage layout for StakingLogRecord +// + +pub(crate) impl StakingLogRecordStorePacking of StorePacking { + fn pack(value: StakingLogRecord) -> (felt252, felt252) { + let val1: felt252 = pack_u64_u256_tuple( + value.timestamp, value.time_weighted_total_staked_sum, + ); + + let val2: felt252 = value.seconds_per_total_staked_sum.try_into().unwrap(); + + (val1, val2) + } + + fn unpack(value: (felt252, felt252)) -> StakingLogRecord { + let (packed_ts_time_weighted_total_staked, seconds_per_total_staked_sum) = value; + let (timestamp, time_weighted_total_staked_sum) = unpack_u64_u256_tuple( + packed_ts_time_weighted_total_staked, + ); + + StakingLogRecord { + timestamp: timestamp, + time_weighted_total_staked_sum: time_weighted_total_staked_sum, + seconds_per_total_staked_sum: seconds_per_total_staked_sum.try_into().unwrap(), + } + } +} + +pub(crate) fn pack_u64_u256_tuple(val1: u64, val2: u256) -> felt252 { + let cumulative_total_staked_high_32_bits: u128 = val2.high & MASK_32_BITS; + u256 { + high: val1.into() * TWO_POW_32.into() + cumulative_total_staked_high_32_bits.into(), + low: val2.low, + } + .try_into() + .unwrap() +} + +pub(crate) fn unpack_u64_u256_tuple(value: felt252) -> (u64, u256) { + let packed_ts_total_staked_u256: u256 = value.into(); + + let cumulative_total_staked = u256 { + high: packed_ts_total_staked_u256.high & MASK_32_BITS, low: packed_ts_total_staked_u256.low, + }; + + return ( + (packed_ts_total_staked_u256.high / TWO_POW_32.into()).try_into().unwrap(), + cumulative_total_staked, + ); +} diff --git a/src/staker_log_test.cairo b/src/staker_log_test.cairo new file mode 100644 index 0000000..63e5934 --- /dev/null +++ b/src/staker_log_test.cairo @@ -0,0 +1,33 @@ +use crate::staker_log::{pack_u64_u256_tuple, unpack_u64_u256_tuple}; + +const MASK_32_BITS: u128 = 0x100000000_u128 - 1; +const MASK_64_BITS: u128 = 0x10000000000000000_u128 - 1; +const MASK_160_BITS: u256 = 0x10000000000000000000000000000000000000000 - 1; + + +fn assert_packs_and_unpacks(timestamp: u64, total_staked: u256) { + let packed: u256 = pack_u64_u256_tuple(timestamp, total_staked).into(); + + let first_160_bits: u256 = packed & MASK_160_BITS; + + let shifted_160_bits_right: u128 = packed.high / (MASK_32_BITS + 1); + + let last_64_bits: u64 = (shifted_160_bits_right & MASK_64_BITS).try_into().unwrap(); + assert_eq!(first_160_bits, total_staked); + assert_eq!(last_64_bits, timestamp); + + let (unpacked_timestamp, unpacked_cumulative_total_staked) = unpack_u64_u256_tuple( + packed.try_into().unwrap(), + ); + assert_eq!(unpacked_timestamp, timestamp); + assert_eq!(unpacked_cumulative_total_staked, total_staked); +} + +#[test] +fn test_staking_log_packing() { + assert_packs_and_unpacks(0_u64, 0_u256); + assert_packs_and_unpacks(10_u64, 50_u256); + assert_packs_and_unpacks( + 0xffffffffffffffff_u64, 0xffffffffffffffffffffffffffffffffffffffff_u256, + ) +} diff --git a/src/staker_v2.cairo b/src/staker_v2.cairo new file mode 100644 index 0000000..3fdcad8 --- /dev/null +++ b/src/staker_v2.cairo @@ -0,0 +1,484 @@ +use starknet::{ContractAddress}; + +#[starknet::interface] +pub trait IStakerV2 { + // Returns the token this staker references. + fn get_token(self: @TContractState) -> ContractAddress; + + // Returns the amount staked from the staker to the delegate. + fn get_staked( + self: @TContractState, staker: ContractAddress, delegate: ContractAddress, + ) -> u128; + + // Transfers the approved amount of token from the caller into this contract and delegates it to + // the given address. + fn stake(ref self: TContractState, delegate: ContractAddress); + + // Transfers the specified amount of token from the caller into this contract and delegates the + // voting weight to the specified delegate. + fn stake_amount(ref self: TContractState, delegate: ContractAddress, amount: u128); + + // Unstakes and withdraws all of the tokens delegated by the sender to the delegate from the + // contract to the given recipient address. + fn withdraw(ref self: TContractState, delegate: ContractAddress, recipient: ContractAddress); + + // Unstakes and withdraws the specified amount of tokens delegated by the sender to the delegate + // from the contract to the given recipient address. + fn withdraw_amount( + ref self: TContractState, + delegate: ContractAddress, + recipient: ContractAddress, + amount: u128, + ); + + // Gets the currently delegated amount of token. Note this is not flash-loan resistant. + fn get_delegated(self: @TContractState, delegate: ContractAddress) -> u128; + + // Gets how much delegated tokens an address has at a certain timestamp. + fn get_delegated_at(self: @TContractState, delegate: ContractAddress, timestamp: u64) -> u128; + + // Gets the cumulative delegated amount * seconds for an address at a certain timestamp. + fn get_delegated_cumulative( + self: @TContractState, delegate: ContractAddress, timestamp: u64, + ) -> u256; + + // Gets the average amount delegated over the given period, where end > start and end <= current + // time. + fn get_average_delegated( + self: @TContractState, delegate: ContractAddress, start: u64, end: u64, + ) -> u128; + + // Gets the average amount delegated over the last period seconds. + fn get_average_delegated_over_last( + self: @TContractState, delegate: ContractAddress, period: u64, + ) -> u128; + + // Calculates snapshot for seconds_per_total_staked_sum (val) at given timestamp (ts). + // If timestamp if before first record, returns 0. + // If timestamp is between records, calculates Δt = (ts - record.ts) where record is + // first record in log before timestamp, then calculates total amount using the + // weighted_total_staked diff diveded by time diff. + // If timestamp is after last record, calculates Δt = (ts - last_record.ts) and + // takes total_staked from storage and adds Δt / total_staked to accumulator. + // In case total_staked is 0 this method turns is to 1 to simplify calculations + fn get_seconds_per_total_staked_sum_at(self: @TContractState, timestamp: u64) -> u256; + + // Calculates snapshot for time_weighted_total_staked (val) at given timestamp (ts). + // Does pretty much the same as `get_seconds_per_total_staked_sum_at` but simpler due to + // absence of FP division. + fn get_time_weighted_total_staked_sum_at(self: @TContractState, timestamp: u64) -> u256; + + fn get_total_staked_at(self: @TContractState, timestamp: u64) -> u128; + + fn get_average_total_staked_over_period(self: @TContractState, start: u64, end: u64) -> u128; + + fn get_user_share_of_total_staked_over_period( + self: @TContractState, staked: u128, start: u64, end: u64, + ) -> u128; +} + +#[starknet::contract] +pub mod Staker { + use starknet::storage::MutableVecTrait; +use core::num::traits::zero::{Zero}; + use crate::staker_log::{LogOperations, StakingLog}; + use governance::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::{ + Vec, VecTrait, + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePathEntry, + StoragePointerReadAccess, StoragePointerWriteAccess, + }; + + use starknet::{ + ContractAddress, get_block_timestamp, get_caller_address, get_contract_address, + storage_access::{StorePacking}, + }; + use super::{IStakerV2}; + + #[derive(Copy, Drop, PartialEq, Debug)] + pub struct DelegatedSnapshot { + pub timestamp: u64, + pub delegated_cumulative: u256, + } + + const TWO_POW_64: u128 = 0x10000000000000000; + const TWO_POW_192: u256 = 0x1000000000000000000000000000000000000000000000000; + const TWO_POW_192_DIVISOR: NonZero = 0x1000000000000000000000000000000000000000000000000; + const TWO_POW_127: u128 = 0x80000000000000000000000000000000_u128; + + pub(crate) impl DelegatedSnapshotStorePacking of StorePacking { + fn pack(value: DelegatedSnapshot) -> felt252 { + assert(value.delegated_cumulative < TWO_POW_192, 'MAX_DELEGATED_CUMULATIVE'); + (value.delegated_cumulative + + u256 { high: value.timestamp.into() * TWO_POW_64, low: 0 }) + .try_into() + .unwrap() + } + + fn unpack(value: felt252) -> DelegatedSnapshot { + let (timestamp, delegated_cumulative) = DivRem::div_rem( + value.into(), TWO_POW_192_DIVISOR, + ); + DelegatedSnapshot { timestamp: timestamp.low.try_into().unwrap(), delegated_cumulative } + } + } + + #[storage] + struct Storage { + token: IERC20Dispatcher, + // owner, delegate => amount + staked: Map<(ContractAddress, ContractAddress), u128>, + amount_delegated: Map, + delegated_cumulative_snapshot: Map>, + total_staked: u128, + staking_log: StakingLog, + } + + #[constructor] + fn constructor(ref self: ContractState, token: IERC20Dispatcher) { + self.token.write(token); + } + + #[derive(starknet::Event, PartialEq, Debug, Drop)] + pub struct Staked { + pub from: ContractAddress, + pub amount: u128, + pub delegate: ContractAddress, + } + + #[derive(starknet::Event, PartialEq, Debug, Drop)] + pub struct Withdrawn { + pub from: ContractAddress, + pub delegate: ContractAddress, + pub to: ContractAddress, + pub amount: u128, + } + + #[derive(starknet::Event, Drop)] + #[event] + enum Event { + Staked: Staked, + Withdrawn: Withdrawn, + } + + #[generate_trait] + impl InternalMethods of InternalMethodsTrait { + fn insert_snapshot( + ref self: ContractState, address: ContractAddress, timestamp: u64, + ) -> u128 { + let amount_delegated = self.amount_delegated.read(address); + + let delegate_snapshots_entry = self.delegated_cumulative_snapshot.entry(address); + let num_snapshots = delegate_snapshots_entry.len(); + + if num_snapshots > 0 { + + let last_snapshot = delegate_snapshots_entry.at(num_snapshots - 1).read(); + + // if we haven't just snapshotted this address + if (last_snapshot.timestamp != timestamp) { + delegate_snapshots_entry + .append() + .write(DelegatedSnapshot { + timestamp, + delegated_cumulative: last_snapshot.delegated_cumulative + + ((timestamp - last_snapshot.timestamp).into() * amount_delegated.into()), + }); + } + } else { + // record this timestamp as the first snapshot + delegate_snapshots_entry + .append() + .write(DelegatedSnapshot { + timestamp, + delegated_cumulative: 0 + }); + }; + + amount_delegated + } + + fn find_delegated_cumulative( + self: @ContractState, + delegate: ContractAddress, + min_index: u64, + max_index_exclusive: u64, + timestamp: u64, + ) -> u256 { + let snapshots_path = self.delegated_cumulative_snapshot.entry(delegate); + + if (min_index == (max_index_exclusive - 1)) { + let snapshot = snapshots_path.at(min_index).read(); + return if (snapshot.timestamp > timestamp) { + 0 + } else { + let difference = timestamp - snapshot.timestamp; + + let delegated_amount = if (snapshots_path.len() <= min_index + 1) { + self.amount_delegated.read(delegate) + } else { + let next = snapshots_path.at(min_index + 1).read(); + ((next.delegated_cumulative - snapshot.delegated_cumulative) + / (next.timestamp - snapshot.timestamp).into()) + .try_into() + .unwrap() + }; + + snapshot.delegated_cumulative + (difference.into() * delegated_amount).into() + }; + } + let mid = (min_index + max_index_exclusive) / 2; + + let snapshot = snapshots_path.at(mid).read(); + + if (timestamp == snapshot.timestamp) { + return snapshot.delegated_cumulative; + } + + // timestamp we are looking for is before snapshot + if (timestamp < snapshot.timestamp) { + self.find_delegated_cumulative(delegate, min_index, mid, timestamp) + } else { + self.find_delegated_cumulative(delegate, mid, max_index_exclusive, timestamp) + } + } + } + + #[abi(embed_v0)] + impl StakerImpl of IStakerV2 { + fn get_token(self: @ContractState) -> ContractAddress { + self.token.read().contract_address + } + + fn get_staked( + self: @ContractState, staker: ContractAddress, delegate: ContractAddress, + ) -> u128 { + self.staked.read((staker, delegate)) + } + + fn stake(ref self: ContractState, delegate: ContractAddress) { + self + .stake_amount( + delegate, + self + .token + .read() + .allowance(get_caller_address(), get_contract_address()) + .try_into() + .expect('ALLOWANCE_OVERFLOW'), + ); + } + + fn stake_amount(ref self: ContractState, delegate: ContractAddress, amount: u128) { + let from = get_caller_address(); + let token = self.token.read(); + + assert( + token.transferFrom(from, get_contract_address(), amount.into()), + 'TRANSFER_FROM_FAILED', + ); + + let key = (from, delegate); + self.staked.write((from, delegate), amount + self.staked.read(key)); + self + .amount_delegated + .write(delegate, self.insert_snapshot(delegate, get_block_timestamp()) + amount); + + let current_total_staked = self.total_staked.read(); + + self.total_staked.write(current_total_staked + amount); + self.staking_log.log_change(amount, current_total_staked); + + self.emit(Staked { from, delegate, amount }); + } + + fn withdraw( + ref self: ContractState, delegate: ContractAddress, recipient: ContractAddress, + ) { + self + .withdraw_amount( + delegate, recipient, self.staked.read((get_caller_address(), delegate)), + ) + } + + fn withdraw_amount( + ref self: ContractState, + delegate: ContractAddress, + recipient: ContractAddress, + amount: u128, + ) { + let from = get_caller_address(); + let key = (from, delegate); + let staked = self.staked.read(key); + assert(staked >= amount, 'INSUFFICIENT_AMOUNT_STAKED'); + self.staked.write(key, staked - amount); + self + .amount_delegated + .write(delegate, self.insert_snapshot(delegate, get_block_timestamp()) - amount); + assert(self.token.read().transfer(recipient, amount.into()), 'TRANSFER_FAILED'); + + let total_staked = self.total_staked.read(); + self.total_staked.write(total_staked - amount); + self.staking_log.log_change(amount, total_staked); + + self.emit(Withdrawn { from, delegate, to: recipient, amount }); + } + + fn get_delegated(self: @ContractState, delegate: ContractAddress) -> u128 { + self.amount_delegated.read(delegate) + } + + fn get_delegated_at( + self: @ContractState, delegate: ContractAddress, timestamp: u64, + ) -> u128 { + (self.get_delegated_cumulative(delegate, timestamp) + - self.get_delegated_cumulative(delegate, timestamp - 1)) + .try_into() + .unwrap() + } + + fn get_delegated_cumulative( + self: @ContractState, delegate: ContractAddress, timestamp: u64, + ) -> u256 { + assert(timestamp <= get_block_timestamp(), 'FUTURE'); + + let num_snapshots = self.delegated_cumulative_snapshot.entry(delegate).len(); + return if (num_snapshots.is_zero()) { + 0 + } else { + self + .find_delegated_cumulative( + delegate: delegate, + min_index: 0, + max_index_exclusive: num_snapshots, + timestamp: timestamp, + ) + }; + } + + fn get_average_delegated( + self: @ContractState, delegate: ContractAddress, start: u64, end: u64, + ) -> u128 { + assert(end > start, 'ORDER'); + assert(end <= get_block_timestamp(), 'FUTURE'); + + let start_snapshot = self.get_delegated_cumulative(delegate, start); + let end_snapshot = self.get_delegated_cumulative(delegate, end); + + ((end_snapshot - start_snapshot) / (end - start).into()).try_into().unwrap() + } + + fn get_average_delegated_over_last( + self: @ContractState, delegate: ContractAddress, period: u64, + ) -> u128 { + let now = get_block_timestamp(); + self.get_average_delegated(delegate, now - period, now) + } + + // Check interface for detailed description. + fn get_seconds_per_total_staked_sum_at(self: @ContractState, timestamp: u64) -> u256 { + let record = self.staking_log.find_record_on_or_before_timestamp(timestamp); + + if let Option::Some((record, idx)) = record { + let total_staked = if (idx == self.staking_log.len() - 1) { + // if last record found + self.total_staked.read() + } else { + // This helps to avoid couple of FP divisions. + let next_record = self.staking_log.at(idx + 1).read(); + let time_weighted_total_staked_sum_diff = next_record + .time_weighted_total_staked_sum + - record.time_weighted_total_staked_sum; + let timestamp_diff = next_record.timestamp - record.timestamp; + (time_weighted_total_staked_sum_diff / timestamp_diff.into()) + .try_into() + .unwrap() + }; + + let seconds_diff = timestamp - record.timestamp; + + let seconds_per_total_staked: u256 = if total_staked == 0 { + seconds_diff.into() // as if total_staked is 1 + } else { + // Divide u64 by u128 + u256 { low: 0, high: seconds_diff.into() } / total_staked.into() + }; + + // Sum fixed posits + return record.seconds_per_total_staked_sum + seconds_per_total_staked; + } + + return 0_u256; + } + + fn get_time_weighted_total_staked_sum_at(self: @ContractState, timestamp: u64) -> u256 { + let record = self.staking_log.find_record_on_or_before_timestamp(timestamp); + + if let Option::Some((record, idx)) = record { + let total_staked = if (idx == self.staking_log.len() - 1) { + // if last rescord found + self.total_staked.read() + } else { + let next_record = self.staking_log.at(idx + 1).read(); + let time_weighted_total_staked_sum_diff = next_record + .time_weighted_total_staked_sum + - record.time_weighted_total_staked_sum; + let timestamp_diff = next_record.timestamp - record.timestamp; + (time_weighted_total_staked_sum_diff / timestamp_diff.into()) + .try_into() + .unwrap() + }; + + let seconds_diff = timestamp - record.timestamp; + let time_weighted_total_staked: u256 = total_staked.into() * seconds_diff.into(); + + return record.time_weighted_total_staked_sum + time_weighted_total_staked; + } + + return 0_u256; + } + + fn get_total_staked_at(self: @ContractState, timestamp: u64) -> u128 { + let record = self.staking_log.find_record_on_or_before_timestamp(timestamp); + if let Option::Some((record, idx)) = record { + if (idx == self.staking_log.len() - 1) { + self.total_staked.read() + } else { + let next_record = self.staking_log.at(idx + 1).read(); + let time_weighted_total_staked_sum_diff = next_record + .time_weighted_total_staked_sum + - record.time_weighted_total_staked_sum; + let timestamp_diff = next_record.timestamp - record.timestamp; + (time_weighted_total_staked_sum_diff / timestamp_diff.into()) + .try_into() + .unwrap() + } + } else { + 0_u128 + } + } + + fn get_average_total_staked_over_period( + self: @ContractState, start: u64, end: u64, + ) -> u128 { + assert(end > start, 'ORDER'); + + let start_snapshot = self.get_time_weighted_total_staked_sum_at(start); + let end_snapshot = self.get_time_weighted_total_staked_sum_at(end); + let period_length = end - start; + + ((end_snapshot - start_snapshot) / period_length.into()).try_into().unwrap() + } + + fn get_user_share_of_total_staked_over_period( + self: @ContractState, staked: u128, start: u64, end: u64, + ) -> u128 { + assert(end > start, 'ORDER'); + + let start_snapshot = self.get_seconds_per_total_staked_sum_at(start); + let end_snapshot = self.get_seconds_per_total_staked_sum_at(end); + + staked * ((end_snapshot - start_snapshot) * 100).high / (end - start).into() + } + } +} diff --git a/src/staker_v2_test.cairo b/src/staker_v2_test.cairo new file mode 100644 index 0000000..5b92cea --- /dev/null +++ b/src/staker_v2_test.cairo @@ -0,0 +1,598 @@ +use core::num::traits::zero::{Zero}; +use governance::execution_state_test::{assert_pack_unpack}; +use governance::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; +use governance::staker_v2::{ + IStakerV2Dispatcher, IStakerV2DispatcherTrait, Staker, + Staker::{DelegatedSnapshot, DelegatedSnapshotStorePacking}, +}; + +use governance::test::test_token::{TestToken, deploy as deploy_token}; +use starknet::testing::{pop_log, set_block_timestamp}; +use starknet::{contract_address_const, get_contract_address, syscalls::deploy_syscall}; + +pub(crate) fn setup(amount: u256) -> (IStakerV2Dispatcher, IERC20Dispatcher) { + let token = deploy_token(get_contract_address(), amount); + let (staker_address, _) = deploy_syscall( + Staker::TEST_CLASS_HASH.try_into().unwrap(), + 0, + array![token.contract_address.into()].span(), + true, + ) + .expect('DEPLOY_TK_FAILED'); + return (IStakerV2Dispatcher { contract_address: staker_address }, token); +} + +mod stake_withdraw { + use super::{ + IERC20DispatcherTrait, IStakerV2DispatcherTrait, Staker, TestToken, Zero, + contract_address_const, get_contract_address, pop_log, setup, + }; + + #[test] + fn test_takes_approved_token() { + let (staker, token) = setup(1000); + + token.approve(staker.contract_address, 500); + staker.stake(contract_address_const::<'delegate'>()); + + assert_eq!( + staker.get_staked(get_contract_address(), contract_address_const::<'delegate'>()), 500, + ); + assert_eq!(staker.get_staked(get_contract_address(), Zero::zero()), 0); + assert_eq!( + staker.get_staked(contract_address_const::<'delegate'>(), get_contract_address()), 0, + ); + // pop the transfer from 0 to deployer + pop_log::(token.contract_address).unwrap(); + assert_eq!( + pop_log::(token.contract_address), + Option::Some( + TestToken::Transfer { + from: get_contract_address(), to: staker.contract_address, value: 500, + }, + ), + ); + assert_eq!( + pop_log::(staker.contract_address), + Option::Some( + Staker::Staked { + from: get_contract_address(), + amount: 500, + delegate: contract_address_const::<'delegate'>(), + }, + ), + ); + } + + #[test] + #[should_panic(expected: ('ALLOWANCE_OVERFLOW', 'ENTRYPOINT_FAILED'))] + fn test_fails_allowance_large() { + let (staker, token) = setup(1000); + + token.approve(staker.contract_address, u256 { high: 1, low: 0 }); + staker.stake(contract_address_const::<'delegate'>()); + } + + #[test] + #[should_panic(expected: ('INSUFFICIENT_TF_BALANCE', 'ENTRYPOINT_FAILED', 'ENTRYPOINT_FAILED'))] + fn test_fails_insufficient_balance() { + let (staker, token) = setup(1000); + + token.approve(staker.contract_address, 1001); + staker.stake(contract_address_const::<'delegate'>()); + } +} + +#[test] +fn test_staker_delegated_snapshot_store_pack() { + assert_eq!( + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { timestamp: 0, delegated_cumulative: 0 }, + ), + 0, + ); + assert_eq!( + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { timestamp: 0, delegated_cumulative: 1 }, + ), + 1, + ); + assert_eq!( + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { timestamp: 1, delegated_cumulative: 0 }, + ), + 0x1000000000000000000000000000000000000000000000000, + ); + assert_eq!( + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { timestamp: 1, delegated_cumulative: 1 }, + ), + 0x1000000000000000000000000000000000000000000000001, + ); + assert_eq!( + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { + timestamp: 576460752303423488, + delegated_cumulative: 6277101735386680763835789423207666416102355444464034512895, + }, + ), + 3618502788666131113263695016908177884250476444008934042335404944711319814143, + ); +} + +#[test] +fn test_staker_delegated_snapshot_store_unpack() { + assert_eq!( + DelegatedSnapshotStorePacking::unpack(0), + DelegatedSnapshot { timestamp: 0, delegated_cumulative: 0 }, + ); + assert_eq!( + DelegatedSnapshotStorePacking::unpack(1), + DelegatedSnapshot { timestamp: 0, delegated_cumulative: 1 }, + ); + assert_eq!( + DelegatedSnapshotStorePacking::unpack(0x1000000000000000000000000000000000000000000000000), + DelegatedSnapshot { timestamp: 1, delegated_cumulative: 0 }, + ); + assert_eq!( + DelegatedSnapshotStorePacking::unpack(0x1000000000000000000000000000000000000000000000001), + DelegatedSnapshot { timestamp: 1, delegated_cumulative: 1 }, + ); + assert_eq!( + DelegatedSnapshotStorePacking::unpack( + 3618502788666131113263695016908177884250476444008934042335404944711319814143, + ), + DelegatedSnapshot { + timestamp: 576460752303423488, + delegated_cumulative: 6277101735386680763835789423207666416102355444464034512895 // 2**192 - 1 + }, + ); + assert_eq!( + DelegatedSnapshotStorePacking::unpack( + // max felt252 + 3618502788666131213697322783095070105623107215331596699973092056135872020480, + ), + DelegatedSnapshot { timestamp: 576460752303423505, delegated_cumulative: 0 }, + ); +} + +#[test] +fn test_staker_delegated_snapshot_store_pack_unpack() { + assert_pack_unpack(DelegatedSnapshot { timestamp: 0, delegated_cumulative: 0 }); + assert_pack_unpack(DelegatedSnapshot { timestamp: 0, delegated_cumulative: 1 }); + assert_pack_unpack(DelegatedSnapshot { timestamp: 1, delegated_cumulative: 0 }); + assert_pack_unpack(DelegatedSnapshot { timestamp: 1, delegated_cumulative: 1 }); + assert_pack_unpack( + DelegatedSnapshot { + timestamp: 0, + delegated_cumulative: 0x1000000000000000000000000000000000000000000000000 - 1, + }, + ); + assert_pack_unpack( + DelegatedSnapshot { timestamp: 576460752303423505, delegated_cumulative: 0 }, + ); + assert_pack_unpack( + DelegatedSnapshot { + timestamp: 576460752303423504, + delegated_cumulative: 0x1000000000000000000000000000000000000000000000000 - 1, + }, + ); +} + +#[test] +#[should_panic(expected: ('Option::unwrap failed.',))] +fn test_staker_delegated_snapshot_pack_max_timestamp_and_delegated() { + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { timestamp: 576460752303423505, delegated_cumulative: 1 }, + ); +} + +#[test] +#[should_panic(expected: ('Option::unwrap failed.',))] +fn test_staker_delegated_snapshot_pack_max_timestamp_plus_one() { + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { timestamp: 576460752303423506, delegated_cumulative: 0 }, + ); +} + +#[test] +#[should_panic(expected: ('MAX_DELEGATED_CUMULATIVE',))] +fn test_staker_delegated_snapshot_pack_max_delegated_cumulative() { + DelegatedSnapshotStorePacking::pack( + DelegatedSnapshot { + timestamp: 0, delegated_cumulative: 0x1000000000000000000000000000000000000000000000000, + }, + ); +} + +#[test] +#[should_panic(expected: ('ORDER', 'ENTRYPOINT_FAILED'))] +fn test_get_average_delegated_order_same() { + let (staker, _) = setup(12345); + + staker.get_average_delegated(contract_address_const::<12345>(), 0, 0); +} + +#[test] +#[should_panic(expected: ('ORDER', 'ENTRYPOINT_FAILED'))] +fn test_get_average_delegated_order_backwards() { + let (staker, _) = setup(12345); + + staker.get_average_delegated(contract_address_const::<12345>(), 1, 0); +} + +#[test] +#[should_panic(expected: ('FUTURE', 'ENTRYPOINT_FAILED'))] +fn test_get_average_delegated_future() { + let (staker, _) = setup(12345); + + staker.get_average_delegated(contract_address_const::<12345>(), 0, 1); +} + +#[test] +#[should_panic(expected: ('FUTURE', 'ENTRYPOINT_FAILED'))] +fn test_get_average_delegated_future_non_zero() { + let (staker, _) = setup(12345); + + set_block_timestamp(5); + + staker.get_average_delegated(contract_address_const::<12345>(), 4, 6); +} + +#[test] +fn test_approve_sets_allowance() { + let (_, erc20) = setup(12345); + + let spender = contract_address_const::<12345>(); + erc20.approve(spender, 5151); + assert(erc20.allowance(get_contract_address(), spender) == 5151, 'allowance'); +} + +#[test] +fn test_delegate_count_lags() { + let (staker, token) = setup(12345); + let delegatee = contract_address_const::<12345>(); + + token.approve(staker.contract_address, 12345); + + set_block_timestamp(2); + + assert(staker.get_delegated_at(delegatee, 1) == 0, 'b second before'); + assert(staker.get_delegated_at(delegatee, 2) == 0, 'b second of'); + staker.stake(delegatee); + assert(staker.get_delegated_at(delegatee, 1) == 0, 'a second of'); + assert(staker.get_delegated_at(delegatee, 2) == 0, 'a second of'); + + set_block_timestamp(4); + + assert(staker.get_delegated_at(delegatee, 3) == 12345, 'a second after'); + assert(staker.get_delegated_at(delegatee, 4) == 12345, 'a 2 seconds after'); +} + +#[test] +fn test_get_delegated_cumulative() { + let (staker, token) = setup(12345); + let delegatee = contract_address_const::<12345>(); + + token.approve(staker.contract_address, 12345); + + set_block_timestamp(2); + + staker.stake(delegatee); + set_block_timestamp(4); + + assert(staker.get_delegated_cumulative(delegatee, 1) == 0, 'second before'); + assert(staker.get_delegated_cumulative(delegatee, 2) == 0, 'second of'); + assert(staker.get_delegated_cumulative(delegatee, 3) == 12345, 'second after'); + assert(staker.get_delegated_cumulative(delegatee, 4) == 24690, '2 seconds after'); +} + +#[test] +#[should_panic(expected: ('FUTURE', 'ENTRYPOINT_FAILED'))] +fn test_get_delegated_cumulative_fails_future() { + let (staker, _) = setup(12345); + + staker.get_delegated_cumulative(delegate: contract_address_const::<12345>(), timestamp: 1); +} + +#[test] +#[should_panic(expected: ('FUTURE', 'ENTRYPOINT_FAILED'))] +fn test_get_delegated_cumulative_fails_future_non_zero_ts() { + let (staker, _) = setup(12345); + + set_block_timestamp(5); + + staker.get_delegated_cumulative(delegate: contract_address_const::<12345>(), timestamp: 6); +} + +#[test] +#[should_panic(expected: ('FUTURE', 'ENTRYPOINT_FAILED'))] +fn test_get_delegated_at_fails_future() { + let (staker, _) = setup(12345); + + staker.get_delegated_at(delegate: contract_address_const::<12345>(), timestamp: 1); +} + +#[test] +#[should_panic(expected: ('FUTURE', 'ENTRYPOINT_FAILED'))] +fn test_get_delegated_at_fails_future_non_zero_ts() { + let (staker, _) = setup(12345); + + set_block_timestamp(5); + + staker.get_delegated_at(delegate: contract_address_const::<12345>(), timestamp: 6); +} + +#[test] +fn test_get_average_delegated() { + let (staker, token) = setup(12345); + let delegatee = contract_address_const::<12345>(); + + set_block_timestamp(10); + + assert(staker.get_average_delegated(delegatee, 1, 2) == 0, '1-2'); + assert(staker.get_average_delegated(delegatee, 2, 3) == 0, '2-3'); + assert(staker.get_average_delegated(delegatee, 3, 4) == 0, '3-4'); + assert(staker.get_average_delegated(delegatee, 4, 5) == 0, '4-5'); + assert(staker.get_average_delegated(delegatee, 4, 10) == 0, '4-10'); + assert(staker.get_average_delegated(delegatee, 0, 10) == 0, '4-10'); + + // rewind to delegate at ts 2 + set_block_timestamp(2); + token.approve(staker.contract_address, 12345); + staker.stake(delegatee); + set_block_timestamp(10); + + assert(staker.get_average_delegated(delegatee, 1, 2) == 0, '1-2 after'); + assert(staker.get_average_delegated(delegatee, 2, 3) == 12345, '2-3 after'); + assert(staker.get_average_delegated(delegatee, 3, 4) == 12345, '3-4 after'); + assert(staker.get_average_delegated(delegatee, 4, 5) == 12345, '4-5 after'); + assert(staker.get_average_delegated(delegatee, 4, 10) == 12345, '4-10 after'); + + // rewind to undelegate at 8 + set_block_timestamp(8); + staker.withdraw_amount(delegatee, recipient: contract_address_const::<0>(), amount: 12345); + + set_block_timestamp(12); + assert(staker.get_average_delegated(delegatee, 4, 10) == 8230, 'average (4 sec * 12345)/6'); +} + +#[test] +fn test_transfer_delegates_moved() { + let (staker, token) = setup(12345); + let delegatee = contract_address_const::<12345>(); + + set_block_timestamp(2); + token.approve(staker.contract_address, 12345); + staker.stake(delegatee); + staker.withdraw_amount(delegatee, contract_address_const::<3456>(), 500); + set_block_timestamp(5); + + assert_eq!(staker.get_delegated(delegatee), (12345 - 500)); + assert_eq!(staker.get_average_delegated(delegatee, 0, 5), ((3 * (12345 - 500)) / 5)); +} + +#[test] +fn test_delegate_undelegate() { + let (staker, token) = setup(12345); + let delegatee = contract_address_const::<12345>(); + + set_block_timestamp(2); + token.approve(staker.contract_address, 12345); + staker.stake(delegatee); + + set_block_timestamp(5); + staker.withdraw_amount(delegatee, Zero::zero(), 12345); + set_block_timestamp(8); + + assert(staker.get_delegated(delegatee) == 0, 'delegated'); + assert(staker.get_average_delegated(delegatee, 0, 8) == ((3 * 12345) / 8), 'average'); + + assert(staker.get_delegated_at(delegatee, timestamp: 1) == 0, 'at 1'); + assert(staker.get_delegated_at(delegatee, timestamp: 2) == 0, 'at 2'); + assert(staker.get_delegated_at(delegatee, timestamp: 3) == 12345, 'at 3'); + assert(staker.get_delegated_at(delegatee, timestamp: 4) == 12345, 'at 4'); + assert(staker.get_delegated_at(delegatee, timestamp: 5) == 12345, 'at 5'); + assert(staker.get_delegated_at(delegatee, timestamp: 6) == 0, 'at 6'); +} + +mod staker_staked_seconds_per_total_staked_calculation { + use starknet::{get_caller_address}; + + use super::{ + IERC20DispatcherTrait, IStakerV2DispatcherTrait, contract_address_const, set_block_timestamp, + setup, + }; + + #[test] + fn test_should_return_0_if_no_data_found() { + let (staker, _) = setup(10000); + + assert_eq!( + staker.get_seconds_per_total_staked_sum_at(0), u256 { high: 0, low: 0_u128.into() }, + ); + assert_eq!( + staker.get_seconds_per_total_staked_sum_at(1000), u256 { high: 0, low: 0_u128.into() }, + ); + } + + #[test] + #[should_panic(expected: ('INSUFFICIENT_AMOUNT_STAKED', 'ENTRYPOINT_FAILED'))] + fn test_raises_error_if_no_history_exists_and_withdrawal_happens() { + // TODO(biatcode): This test accidentally tests other + // functionality and should be refactored + + let (staker, token) = setup(10000); + + // Caller is token owner + let token_owner = get_caller_address(); + + // Adress to delegate tokens to + let delegatee = contract_address_const::<1234567890>(); + + token.approve(staker.contract_address, 10000); + + set_block_timestamp(1); + staker.stake_amount(delegatee, 1000); + set_block_timestamp(2); + staker.withdraw_amount(delegatee, token_owner, 500); + set_block_timestamp(3); + staker.stake_amount(delegatee, 1000); + set_block_timestamp(4); + staker.withdraw_amount(delegatee, token_owner, 2000); + } + + #[test] + fn test_check_total_staked_calculations() { + let (staker, token) = setup(1000); + + // Caller is token owner + let delegatee = contract_address_const::<1234567890>(); + + assert_eq!(staker.get_total_staked_at(0), 0); + assert_eq!(staker.get_total_staked_at(100), 0); + + set_block_timestamp(10); + token.approve(staker.contract_address, 100); + staker.stake(delegatee); + + set_block_timestamp(15); + token.approve(staker.contract_address, 300); + staker.stake(delegatee); + + set_block_timestamp(20); + token.approve(staker.contract_address, 200); + staker.stake(delegatee); + + set_block_timestamp(40); + token.approve(staker.contract_address, 100); + staker.stake(delegatee); + + set_block_timestamp(65); + token.approve(staker.contract_address, 300); + staker.stake(delegatee); + + assert_eq!(staker.get_total_staked_at(0), 0); + assert_eq!(staker.get_total_staked_at(5), 0); + assert_eq!(staker.get_total_staked_at(9), 0); + assert_eq!(staker.get_total_staked_at(10), 100); + assert_eq!(staker.get_total_staked_at(11), 100); + assert_eq!(staker.get_total_staked_at(14), 100); + assert_eq!(staker.get_total_staked_at(15), 400); + assert_eq!(staker.get_total_staked_at(19), 400); + assert_eq!(staker.get_total_staked_at(20), 600); + assert_eq!(staker.get_total_staked_at(30), 600); + assert_eq!(staker.get_total_staked_at(39), 600); + assert_eq!(staker.get_total_staked_at(40), 700); + assert_eq!(staker.get_total_staked_at(64), 700); + assert_eq!(staker.get_total_staked_at(65), 1000); + assert_eq!(staker.get_total_staked_at(100), 1000); + } + + #[test] + fn test_get_time_weighted_total_staked_sum_at() { + let (staker, token) = setup(1000); + + // Caller is token owner + let token_owner = get_caller_address(); + let delegatee = contract_address_const::<1234567890>(); + + assert_eq!(staker.get_time_weighted_total_staked_sum_at(0), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(100), 0); + + set_block_timestamp(10); + token.approve(staker.contract_address, 100); + staker.stake(delegatee); + + assert_eq!(staker.get_time_weighted_total_staked_sum_at(0), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(9), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(10), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(11), 100); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(12), 200); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(13), 300); + + set_block_timestamp(15); + token.approve(staker.contract_address, 300); + staker.stake(delegatee); + + set_block_timestamp(20); + token.approve(staker.contract_address, 200); + staker.stake(delegatee); + + set_block_timestamp(40); + token.approve(staker.contract_address, 100); + staker.stake(delegatee); + + set_block_timestamp(65); + token.approve(staker.contract_address, 300); + staker.stake(delegatee); + + set_block_timestamp(70); + + staker.withdraw(delegatee, token_owner); + + assert_eq!(staker.get_time_weighted_total_staked_sum_at(0), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(5), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(9), 0); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(10), 0); // 100/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(11), 100); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(14), 400); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(15), 500); // 400/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(19), 2100); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(20), 2500); // 600/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(30), 8500); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(39), 13900); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(40), 14500); // 700/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(64), 31300); + assert_eq!(staker.get_time_weighted_total_staked_sum_at(65), 32000); // 1000/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(70), 37000); // 0/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(100), 37000); // 0/s + assert_eq!(staker.get_time_weighted_total_staked_sum_at(200), 37000); // 0/s + } + + #[test] + fn test_should_stake_10000_tokens_for_5_seconds_adding_10000_every_second_to_staked_seconds() { + let (staker, token) = setup(1000); + + // Caller is token owner + let token_owner = get_caller_address(); + let delegatee = contract_address_const::<1234567890>(); + + set_block_timestamp(10); + token.approve(staker.contract_address, 10); + staker.stake(delegatee); + + set_block_timestamp(15); + token.approve(staker.contract_address, 10); + staker.stake(delegatee); + + set_block_timestamp(20); + staker.withdraw(delegatee, token_owner); + + set_block_timestamp(30); + token.approve(staker.contract_address, 30); + staker.stake(delegatee); + + set_block_timestamp(40); + staker.withdraw(delegatee, token_owner); + + assert_eq!(staker.get_seconds_per_total_staked_sum_at(0), 0); + assert_eq!(staker.get_seconds_per_total_staked_sum_at(10), 0); + assert_eq!( + staker.get_seconds_per_total_staked_sum_at(15), + u256 { low: 0x80000000000000000000000000000000_u128, high: 0_u128 }, + ); // 1/2 + assert_eq!( + staker.get_seconds_per_total_staked_sum_at(20), + u256 { low: 0xC0000000000000000000000000000000, high: 0_u128 }, + ); // 3/4 + assert_eq!( + staker.get_seconds_per_total_staked_sum_at(30), + u256 { low: 0xC0000000000000000000000000000000, high: 0_u128 }, + ); // 3/4 + assert_eq!( + staker.get_seconds_per_total_staked_sum_at(40), + u256 { low: 0x15555555555555555555555555555555, high: 1_u128 }, + ); // 1 + 1/12 + } +}