diff --git a/.gitignore b/.gitignore index 116566e..934c234 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..7d41603 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -16,6 +16,10 @@ pub mod governor; mod governor_test; pub mod staker; + +pub mod staker_log; +#[cfg(test)] +pub mod staker_log_test; #[cfg(test)] mod staker_test; diff --git a/src/staker.cairo b/src/staker.cairo index 4943f4c..dd8e7e4 100644 --- a/src/staker.cairo +++ b/src/staker.cairo @@ -52,22 +52,48 @@ pub trait IStaker { 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 + // TODO: this should be a part of StakingLog + 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 core::num::traits::zero::{Zero}; + use crate::staker_log::{LogOperations, StakingLog}; use governance::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::VecTrait; use starknet::storage::{ Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; + use starknet::{ - get_block_timestamp, get_caller_address, get_contract_address, + ContractAddress, get_block_timestamp, get_caller_address, get_contract_address, storage_access::{StorePacking}, }; - use super::{ContractAddress, IStaker}; - + use super::{IStaker}; #[derive(Copy, Drop, PartialEq, Debug)] pub struct DelegatedSnapshot { @@ -78,6 +104,7 @@ pub mod Staker { 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 { @@ -104,6 +131,8 @@ pub mod Staker { amount_delegated: Map, delegated_cumulative_num_snapshots: Map, delegated_cumulative_snapshot: Map>, + total_staked: u128, + staking_log: StakingLog, } #[constructor] @@ -253,6 +282,12 @@ pub mod Staker { 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 }); } @@ -280,6 +315,11 @@ pub mod Staker { .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 }); } @@ -333,5 +373,111 @@ pub mod Staker { 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_log.cairo b/src/staker_log.cairo new file mode 100644 index 0000000..c804f3f --- /dev/null +++ b/src/staker_log.cairo @@ -0,0 +1,175 @@ +use starknet::storage::MutableVecTrait; +use starknet::storage::{ + Mutable, StorageAsPath, StorageBase, StoragePointerReadAccess, StoragePointerWriteAccess, +}; + +use starknet::storage::{Vec, VecTrait}; +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] +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; + } + // TODO: Discuss with Moody, maybe it's worth to store that one globally + if log.at(0).read().timestamp > timestamp { + return Option::None; + } + + let mut left = 0; + let mut right = log.len() - 1; + + // To avoid reading from the storage multiple times. + let mut result_ptr: Option<(StakingLogRecord, u64)> = Option::None; + + while (left <= right) { + let center = (right + left) / 2; + let record_ptr = log.at(center); + let record = record_ptr.read(); + + if record.timestamp <= timestamp { + result_ptr = Option::Some((record, center)); + left = center + 1; + } else { + right = center - 1; + }; + }; + + if let Option::Some((result, idx)) = result_ptr { + return Option::Some((result, idx)); + } + + return Option::None; + } + + 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_test.cairo b/src/staker_test.cairo index faf0f74..2764d60 100644 --- a/src/staker_test.cairo +++ b/src/staker_test.cairo @@ -5,6 +5,7 @@ use governance::staker::{ IStakerDispatcher, IStakerDispatcherTrait, 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}; @@ -394,3 +395,204 @@ fn test_delegate_undelegate() { 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, IStakerDispatcherTrait, 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 + } +}