From 4d19e093231bff03c28e20ec2d40f9d610b5e260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ad=C3=A1n=20SDPC?= Date: Mon, 16 Oct 2023 19:56:30 +0200 Subject: [PATCH] feat(data_structures): adapt stakes tracker to latest specs fix #2398 --- data_structures/Cargo.toml | 4 + data_structures/benches/staking.rs | 85 ++++ data_structures/src/capabilities.rs | 44 ++ data_structures/src/lib.rs | 3 + data_structures/src/staking/aux.rs | 37 ++ data_structures/src/staking/constants.rs | 2 + data_structures/src/staking/errors.rs | 41 ++ data_structures/src/staking/mod.rs | 586 ++++------------------- data_structures/src/staking/simple.rs | 0 data_structures/src/staking/stake.rs | 122 +++++ data_structures/src/staking/stakes.rs | 336 +++++++++++++ 11 files changed, 773 insertions(+), 487 deletions(-) create mode 100644 data_structures/benches/staking.rs create mode 100644 data_structures/src/capabilities.rs create mode 100644 data_structures/src/staking/aux.rs create mode 100644 data_structures/src/staking/constants.rs create mode 100644 data_structures/src/staking/errors.rs create mode 100644 data_structures/src/staking/simple.rs create mode 100644 data_structures/src/staking/stake.rs create mode 100644 data_structures/src/staking/stakes.rs diff --git a/data_structures/Cargo.toml b/data_structures/Cargo.toml index fa801502b..78baa1c00 100644 --- a/data_structures/Cargo.toml +++ b/data_structures/Cargo.toml @@ -51,3 +51,7 @@ rand_distr = "0.4.3" [[bench]] name = "sort_active_identities" harness = false + +[[bench]] +name = "staking" +harness = false diff --git a/data_structures/benches/staking.rs b/data_structures/benches/staking.rs new file mode 100644 index 000000000..8bbee63f8 --- /dev/null +++ b/data_structures/benches/staking.rs @@ -0,0 +1,85 @@ +#[macro_use] +extern crate bencher; +use bencher::Bencher; +use rand::Rng; +use witnet_data_structures::staking::prelude::*; + +fn populate(b: &mut Bencher) { + let mut stakes = Stakes::::default(); + let mut i = 1; + + b.iter(|| { + let address = format!("{i}"); + let coins = i; + let epoch = i; + stakes.add_stake(address, coins, epoch).unwrap(); + + i += 1; + }); +} + +fn rank(b: &mut Bencher) { + let mut stakes = Stakes::::default(); + let mut i = 1; + + let stakers = 100_000; + let rf = 10; + + let mut rng = rand::thread_rng(); + + loop { + let coins = i; + let epoch = i; + let address = format!("{}", rng.gen::()); + + stakes.add_stake(address, coins, epoch).unwrap(); + + i += 1; + + if i == stakers { + break; + } + } + + b.iter(|| { + let rank = stakes.rank(Capability::Mining, i); + let mut top = rank.take(usize::try_from(stakers / rf).unwrap()); + let _first = top.next(); + let _last = top.last(); + + i += 1; + }) +} + +fn query_power(b: &mut Bencher) { + let mut stakes = Stakes::::default(); + let mut i = 1; + + let stakers = 100_000; + + loop { + let coins = i; + let epoch = i; + let address = format!("{i}"); + + stakes.add_stake(address, coins, epoch).unwrap(); + + i += 1; + + if i == stakers { + break; + } + } + + i = 1; + + b.iter(|| { + let address = format!("{i}"); + let _power = stakes.query_power(&address, Capability::Mining, i); + + i += 1; + }) +} + +benchmark_main!(benches); +benchmark_group!(benches, populate, rank, query_power); diff --git a/data_structures/src/capabilities.rs b/data_structures/src/capabilities.rs new file mode 100644 index 000000000..80cd8257b --- /dev/null +++ b/data_structures/src/capabilities.rs @@ -0,0 +1,44 @@ +#[repr(u8)] +#[derive(Clone, Copy, Debug)] +pub enum Capability { + /// The base block mining and superblock voting capability + Mining = 0, + /// The universal HTTP GET / HTTP POST / WIP-0019 RNG capability + Witnessing = 1, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct CapabilityMap +where + T: Default, +{ + pub mining: T, + pub witnessing: T, +} + +impl CapabilityMap +where + T: Copy + Default, +{ + #[inline] + pub fn get(&self, capability: Capability) -> T { + match capability { + Capability::Mining => self.mining, + Capability::Witnessing => self.witnessing, + } + } + + #[inline] + pub fn update(&mut self, capability: Capability, value: T) { + match capability { + Capability::Mining => self.mining = value, + Capability::Witnessing => self.witnessing = value, + } + } + + #[inline] + pub fn update_all(&mut self, value: T) { + self.mining = value; + self.witnessing = value; + } +} diff --git a/data_structures/src/lib.rs b/data_structures/src/lib.rs index 6a991c7d2..e14acd6e5 100644 --- a/data_structures/src/lib.rs +++ b/data_structures/src/lib.rs @@ -72,6 +72,9 @@ mod serialization_helpers; /// Provides convenient constants, structs and methods for handling values denominated in Wit. pub mod wit; +/// Provides support for segmented protocol capabilities. +pub mod capabilities; + lazy_static! { /// Environment in which we are running: mainnet or testnet. /// This is used for Bech32 serialization. diff --git a/data_structures/src/staking/aux.rs b/data_structures/src/staking/aux.rs new file mode 100644 index 000000000..424158164 --- /dev/null +++ b/data_structures/src/staking/aux.rs @@ -0,0 +1,37 @@ +use std::rc::Rc; +use std::sync::RwLock; + +use super::prelude::*; + +/// Type alias for a reference-counted and read-write-locked instance of `Stake`. +pub type SyncStake = Rc>>; + +/// The resulting type for all the fallible functions in this module. +pub type Result = + std::result::Result>; + +/// Couples an amount of coins and an address together. This is to be used in `Stakes` as the index +/// of the `by_coins` index.. +#[derive(Eq, Ord, PartialEq, PartialOrd)] +pub struct CoinsAndAddress { + /// An amount of coins. + pub coins: Coins, + /// The address of a staker. + pub address: Address, +} + +/// Allows telling the `census` method in `Stakes` to source addresses from its internal `by_coins` +/// following different strategies. +#[repr(u8)] +#[derive(Clone, Copy, Debug)] +pub enum CensusStrategy { + /// Retrieve all addresses, ordered by decreasing power. + All = 0, + /// Retrieve every Nth address, ordered by decreasing power. + StepBy(usize) = 1, + /// Retrieve the most powerful N addresses, ordered by decreasing power. + Take(usize) = 2, + /// Retrieve a total of N addresses, evenly distributed from the index, ordered by decreasing + /// power. + Evenly(usize) = 3, +} diff --git a/data_structures/src/staking/constants.rs b/data_structures/src/staking/constants.rs new file mode 100644 index 000000000..56543034f --- /dev/null +++ b/data_structures/src/staking/constants.rs @@ -0,0 +1,2 @@ +/// A minimum stakeable amount needs to exist to prevent spamming of the tracker. +pub const MINIMUM_STAKEABLE_AMOUNT_WITS: u64 = 100; diff --git a/data_structures/src/staking/errors.rs b/data_structures/src/staking/errors.rs new file mode 100644 index 000000000..6169073f4 --- /dev/null +++ b/data_structures/src/staking/errors.rs @@ -0,0 +1,41 @@ +use std::sync::PoisonError; + +/// All errors related to the staking functionality. +#[derive(Debug, PartialEq)] +pub enum StakesError { + /// The amount of coins being staked or the amount that remains after unstaking is below the + /// minimum stakeable amount. + AmountIsBelowMinimum { + /// The number of coins being staked or remaining after staking. + amount: Coins, + /// The minimum stakeable amount. + minimum: Coins, + }, + /// Tried to query `Stakes` for information that belongs to the past. + EpochInThePast { + /// The Epoch being referred. + epoch: Epoch, + /// The latest Epoch. + latest: Epoch, + }, + /// An operation thrown an Epoch value that overflows. + EpochOverflow { + /// The computed Epoch value. + computed: u64, + /// The maximum Epoch. + maximum: Epoch, + }, + /// Tried to query `Stakes` for the address of a staker that is not registered in `Stakes`. + IdentityNotFound { + /// The unknown address. + identity: Address, + }, + /// Tried to obtain a lock on a write-locked piece of data that is already locked. + PoisonedLock, +} + +impl From> for StakesError { + fn from(_value: PoisonError) -> Self { + StakesError::PoisonedLock + } +} diff --git a/data_structures/src/staking/mod.rs b/data_structures/src/staking/mod.rs index 44d2c5127..1a5b21418 100644 --- a/data_structures/src/staking/mod.rs +++ b/data_structures/src/staking/mod.rs @@ -1,495 +1,107 @@ -use num_traits::Zero; -use std::collections::BTreeMap; - -use crate::wit::NANOWITS_PER_WIT; -use crate::{ - chain::{Epoch, PublicKeyHash}, - wit::Wit, -}; - -/// A minimum stakeable amount needs to exist to prevent spamming of the tracker. -const MINIMUM_STAKEABLE_AMOUNT_WITS: u64 = 10; -/// A maximum coin age is enforced to prevent an actor from monopolizing eligibility by means of -/// hoarding coin age. -const MAXIMUM_COIN_AGE_EPOCHS: u64 = 53_760; - -/// Type alias that represents the power of an identity in the network on a certain epoch. -/// -/// This is expected to be used for deriving eligibility. -pub type Power = u64; - -#[derive(Debug, PartialEq)] -pub enum StakesTrackerError { - AmountIsBelowMinimum { amount: Wit, minimum: Wit }, - EpochInThePast { epoch: Epoch, latest: Epoch }, - EpochOverflow { computed: u64, maximum: Epoch }, - IdentityNotFound { identity: PublicKeyHash }, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct StakesEntry { - /// How many coins does an identity have in stake - coins: Wit, - /// The weighted average of the epochs in which the stake was added - epoch: Epoch, - /// Further entries representing coins that are queued for unstaking - exiting_coins: Vec>, -} - -impl StakesEntry { - /// Updates an entry for a given epoch with a certain amount of coins. - /// - /// - Amounts are added together. - /// - Epochs are weight-averaged, using the amounts as the weight. - /// - /// This type of averaging makes the entry equivalent to an unbounded record of all stake - /// additions and removals, without the overhead in memory and computation. - pub fn add_stake( - &mut self, - amount: Wit, - epoch: Epoch, - ) -> Result<&StakesEntry, StakesTrackerError> { - // Make sure that the amount to be staked is equal or greater than the minimum - let minimum = Wit::from_wits(MINIMUM_STAKEABLE_AMOUNT_WITS); - if amount < minimum { - return Err(StakesTrackerError::AmountIsBelowMinimum { amount, minimum }); - } - - let coins_before = self.coins; - let epoch_before = self.epoch; - - // These "products" simply use the staked amount as the weight for the weighted average - let product_before = coins_before.nanowits() * u64::from(epoch_before); - let product_added = amount.nanowits() * u64::from(epoch); - - let coins_after = coins_before + amount; - let epoch_after = (product_before + product_added) / coins_after.nanowits(); - - self.coins = coins_after; - self.epoch = - Epoch::try_from(epoch_after).map_err(|_| StakesTrackerError::EpochOverflow { - computed: epoch_after, - maximum: Epoch::MAX, - })?; - - return Ok(self); - } - - /// Derives the power of an identity in the network on a certain epoch from an entry. - /// - /// A cap on coin age is enforced, and thus the maximum power is the total supply multiplied by - /// that cap. - pub fn power(&self, epoch: Epoch) -> Power { - let age = u64::from(epoch.saturating_sub(self.epoch)).min(MAXIMUM_COIN_AGE_EPOCHS); - let nano_wits = self.coins.nanowits(); - let power = nano_wits.saturating_mul(age) / NANOWITS_PER_WIT; - - power - } - - /// Remove a certain amount of staked coins. - pub fn remove_stake(&mut self, amount: Wit) -> Result<&StakesEntry, StakesTrackerError> { - // Make sure that the amount left in staked is equal or greater than the minimum - let minimum = Wit::from_wits(MINIMUM_STAKEABLE_AMOUNT_WITS); - let coins_after = - Wit::from_nanowits(self.coins.nanowits().saturating_sub(amount.nanowits())); - if coins_after > Wit::zero() && coins_after < minimum { - return Err(StakesTrackerError::AmountIsBelowMinimum { amount, minimum }); - } - - self.coins = coins_after; - - return Ok(self); - } -} - -/// Accumulates global stats about the staking tracker. -#[derive(Debug, Default, PartialEq)] -pub struct StakingStats { - /// Represents the average amount and epoch of the staked coins. - pub average: StakesEntry, - /// The latest epoch for which there is information in the tracker. - pub latest_epoch: Epoch, -} - -#[derive(Default)] -pub struct StakesTracker { - /// The individual stake records for all identities with a non-zero stake. - entries: BTreeMap, - /// Accumulates global stats about the staking tracker, as derived from the entries. - stats: StakingStats, -} - -impl StakesTracker { - /// Register a certain amount of additional stake for a certain identity and epoch. - pub fn add_stake( - &mut self, - identity: &PublicKeyHash, - amount: Wit, - epoch: Epoch, - ) -> Result<&StakesEntry, StakesTrackerError> { - // Refuse to add a stake for an epoch in the past - let latest = self.stats.latest_epoch; - if epoch < latest { - return Err(StakesTrackerError::EpochInThePast { epoch, latest }); - } - - // Find the entry or create it, then add the stake to it - let entry = self - .entries - .entry(*identity) - .or_insert_with(StakesEntry::default) - .add_stake(amount, epoch)?; - - // Because the entry was updated, let's also update all the derived data - self.stats.latest_epoch = epoch; - self.stats.average.add_stake(amount, epoch + 1)?; - - Ok(entry) - } - - /// Tells what is the power of an identity in the network on a certain epoch. - pub fn query_power(&self, identity: &PublicKeyHash, epoch: Epoch) -> Power { - self.entries - .get(identity) - .map(|entry| entry.power(epoch)) - .unwrap_or_default() - } - - /// Tells what is the share of the power of an identity in the network on a certain epoch. - pub fn query_share(&self, identity: &PublicKeyHash, epoch: Epoch) -> f64 { - let power = self.query_power(identity, epoch); - let total_power = self.stats.average.power(epoch).max(1); - let share = (power as f64 / total_power as f64).min(1.0); - - share - } - - /// Tells how many entries are there in the tracker, paired with some other statistics. - pub fn stats(&self) -> (usize, &StakingStats) { - let entries_count = self.entries.len(); - let stats = &self.stats; - - (entries_count, stats) - } - - /// Remove a certain amount of staked coins from a given identity at a given epoch. - pub fn remove_stake( - &mut self, - identity: &PublicKeyHash, - amount: Wit, - ) -> Result, StakesTrackerError> { - // Find the entry or create it, then remove the stake from it - let entry = self - .entries - .entry(*identity) - .or_insert_with(StakesEntry::default) - .remove_stake(amount)? - .clone(); - - // If the identity is left without stake, it can be dropped from the tracker - if entry.coins == Wit::zero() { - self.entries.remove(identity); - return Ok(None); - } - - // Because the entry was updated, let's also update all the derived data - self.stats.average.remove_stake(amount)?; - - Ok(Some(entry)) - } - - /// Removes and adds an amount of stake at once, i.e. the amount remains the same, but the age - /// gets reset. - pub fn use_stake( - &mut self, - identity: &PublicKeyHash, - amount: Wit, - epoch: Epoch, - ) -> Result { - // First remove the stake - self.remove_stake(identity, amount)?; - // Then add it again at the same epoch - self.add_stake(identity, amount, epoch).cloned() - } +#![deny(missing_docs)] + +/// Auxiliary convenience types and data structures. +pub mod aux; +/// Constants related to the staking functionality. +pub mod constants; +/// Errors related to the staking functionality. +pub mod errors; +/// The data structure and related logic for stake entries. +pub mod stake; +/// The data structure and related logic for keeping track of multiple stake entries. +pub mod stakes; + +/// Module re-exporting virtually every submodule on a single level to ease importing of everything +/// staking-related. +pub mod prelude { + pub use crate::capabilities::*; + + pub use super::aux::*; + pub use super::constants::*; + pub use super::errors::*; + pub use super::stake::*; + pub use super::stakes::*; } #[cfg(test)] -mod tests { - use crate::chain::Environment; - - use super::*; - - #[test] - fn test_tracker_initialization() { - let tracker = StakesTracker::default(); - let (count, stats) = tracker.stats(); - assert_eq!(count, 0); - assert_eq!(stats, &StakingStats::default()); - } - - #[test] - fn test_add_stake() { - let mut tracker = StakesTracker::default(); - let alice = PublicKeyHash::from_bech32( - Environment::Mainnet, - "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4", - ) - .unwrap(); - let bob = PublicKeyHash::from_bech32( - Environment::Mainnet, - "wit100000000000000000000000000000000r0v4g2", - ) - .unwrap(); - - // Let's check default power and share - assert_eq!(tracker.query_power(&alice, 0), 0); - assert_eq!(tracker.query_share(&alice, 0), 0.0); - assert_eq!(tracker.query_power(&alice, 1_000), 0); - assert_eq!(tracker.query_share(&alice, 1_000), 0.0); - - // Let's make Alice stake 100 Wit at epoch 100 - let updated = tracker.add_stake(&alice, Wit::from_wits(100), 100).unwrap(); - assert_eq!( - updated, - &StakesEntry { - coins: Wit::from_wits(100), - epoch: 100, - exiting_coins: vec![], - } - ); - let (count, stats) = tracker.stats(); - assert_eq!(count, 1); - assert_eq!( - stats, - &StakingStats { - average: StakesEntry { - coins: Wit::from_wits(100), - epoch: 101, - exiting_coins: vec![], - }, - latest_epoch: 100, - } - ); - assert_eq!(tracker.query_power(&alice, 99), 0); - assert_eq!(tracker.query_share(&alice, 99), 0.0); - assert_eq!(tracker.query_power(&alice, 100), 0); - assert_eq!(tracker.query_share(&alice, 100), 0.0); - assert_eq!(tracker.query_power(&alice, 101), 100); - assert_eq!(tracker.query_share(&alice, 101), 1.0); - assert_eq!(tracker.query_power(&alice, 200), 10_000); - assert_eq!(tracker.query_share(&alice, 200), 1.0); - - // Let's make Alice stake 50 Wits at epoch 150 this time - let updated = tracker.add_stake(&alice, Wit::from_wits(50), 300).unwrap(); - assert_eq!( - updated, - &StakesEntry { - coins: Wit::from_wits(150), - epoch: 166, - exiting_coins: vec![], - } - ); - let (count, stats) = tracker.stats(); - assert_eq!(count, 1); - assert_eq!( - stats, - &StakingStats { - average: StakesEntry { - coins: Wit::from_wits(150), - epoch: 167, - exiting_coins: vec![], - }, - latest_epoch: 300, - } - ); - assert_eq!(tracker.query_power(&alice, 299), 19_950); - assert_eq!(tracker.query_share(&alice, 299), 1.0); - assert_eq!(tracker.query_power(&alice, 300), 20_100); - assert_eq!(tracker.query_share(&alice, 300), 1.0); - assert_eq!(tracker.query_power(&alice, 301), 20_250); - assert_eq!(tracker.query_share(&alice, 301), 1.0); - assert_eq!(tracker.query_power(&alice, 400), 35_100); - assert_eq!(tracker.query_share(&alice, 400), 1.0); - - // Now let's make Bob stake 50 Wits at epoch 150 this time - let updated = tracker.add_stake(&bob, Wit::from_wits(10), 1_000).unwrap(); - assert_eq!( - updated, - &StakesEntry { - coins: Wit::from_wits(10), - epoch: 1_000, - exiting_coins: vec![], - } - ); - let (count, stats) = tracker.stats(); - assert_eq!(count, 2); - assert_eq!( - stats, - &StakingStats { - average: StakesEntry { - coins: Wit::from_wits(160), - epoch: 219, - exiting_coins: vec![], - }, - latest_epoch: 1_000, - } - ); - // Before Bob stakes, Alice has all the power and share - assert_eq!(tracker.query_power(&bob, 999), 0); - assert_eq!(tracker.query_share(&bob, 999), 0.0); - assert_eq!(tracker.query_share(&alice, 999), 1.0); - assert_eq!( - tracker.query_share(&alice, 999) + tracker.query_share(&bob, 999), - 1.0 - ); - // New stakes don't change power or share in the same epoch - assert_eq!(tracker.query_power(&bob, 1_000), 0); - assert_eq!(tracker.query_share(&bob, 1_000), 0.0); - assert_eq!(tracker.query_share(&alice, 1_000), 1.0); - assert_eq!( - tracker.query_share(&alice, 1_000) + tracker.query_share(&bob, 1_000), - 1.0 - ); - // Shortly as Bob's stake gains power, Alice loses a roughly equivalent share - assert_eq!(tracker.query_power(&bob, 1_100), 1_000); - assert_eq!(tracker.query_share(&bob, 1_100), 0.007094211123723042); - assert_eq!(tracker.query_share(&alice, 1_100), 0.9938989784335982); - assert_eq!( - tracker.query_share(&alice, 1_100) + tracker.query_share(&bob, 1_100), - 1.0009931895573212 - ); - // After enough time, both's shares should become proportional to their stake, and add up to 1.0 again - assert_eq!(tracker.query_power(&bob, 1_000_000), 537600); - assert_eq!(tracker.query_share(&bob, 1_000_000), 0.0625); - assert_eq!(tracker.query_share(&alice, 1_000_000), 0.9375); - assert_eq!( - tracker.query_share(&alice, 1_000_000) + tracker.query_share(&bob, 1_000_000), - 1.0 - ); - } +pub mod test { + use super::prelude::*; #[test] - fn test_minimum_stake() { - let mut tracker = StakesTracker::default(); - let alice = PublicKeyHash::from_bech32( - Environment::Mainnet, - "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4", - ) - .unwrap(); - let error = tracker - .add_stake( - &alice, - Wit::from_wits(MINIMUM_STAKEABLE_AMOUNT_WITS - 1), - 100, - ) - .unwrap_err(); - - assert_eq!( - error, - StakesTrackerError::AmountIsBelowMinimum { - amount: Wit::from_wits(MINIMUM_STAKEABLE_AMOUNT_WITS - 1), - minimum: Wit::from_wits(MINIMUM_STAKEABLE_AMOUNT_WITS) - } - ); - } - - #[test] - fn test_maximum_coin_age() { - let mut tracker = StakesTracker::default(); - let alice = PublicKeyHash::from_bech32( - Environment::Mainnet, - "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4", - ) - .unwrap(); - tracker - .add_stake(&alice, Wit::from_wits(MINIMUM_STAKEABLE_AMOUNT_WITS), 0) - .unwrap(); - assert_eq!(tracker.query_power(&alice, 0), 0); - assert_eq!( - tracker.query_power(&alice, 1), - MINIMUM_STAKEABLE_AMOUNT_WITS - ); - assert_eq!( - tracker.query_power(&alice, MAXIMUM_COIN_AGE_EPOCHS as Epoch - 1), - MINIMUM_STAKEABLE_AMOUNT_WITS * (MAXIMUM_COIN_AGE_EPOCHS - 1) - ); - assert_eq!( - tracker.query_power(&alice, MAXIMUM_COIN_AGE_EPOCHS as Epoch), - MINIMUM_STAKEABLE_AMOUNT_WITS * MAXIMUM_COIN_AGE_EPOCHS - ); - assert_eq!( - tracker.query_power(&alice, MAXIMUM_COIN_AGE_EPOCHS as Epoch + 1), - MINIMUM_STAKEABLE_AMOUNT_WITS * MAXIMUM_COIN_AGE_EPOCHS - ); - } - - #[test] - fn test_remove_stake() { - let mut tracker = StakesTracker::default(); - let alice = PublicKeyHash::from_bech32( - Environment::Mainnet, - "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4", - ) - .unwrap(); - let updated = tracker.add_stake(&alice, Wit::from_wits(100), 100).unwrap(); - assert_eq!( - updated, - &StakesEntry { - coins: Wit::from_wits(100), - epoch: 100, - exiting_coins: vec![], - } - ); - // Removing stake should reduce the amount, but keep the age the same - let updated = tracker.remove_stake(&alice, Wit::from_wits(50)).unwrap(); - assert_eq!( - updated, - Some(StakesEntry { - coins: Wit::from_wits(50), - epoch: 100, - exiting_coins: vec![], - }) - ); - } - - #[test] - fn test_use_stake() { - let mut tracker = StakesTracker::default(); - let alice = PublicKeyHash::from_bech32( - Environment::Mainnet, - "wit1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwrt3a4", - ) - .unwrap(); - let updated = tracker.add_stake(&alice, Wit::from_wits(100), 0).unwrap(); - assert_eq!( - updated, - &StakesEntry { - coins: Wit::from_wits(100), - epoch: 0, - exiting_coins: vec![], - } - ); - // After using all the stake, the amount should stay the same, but the epoch should be reset. - let updated = tracker.use_stake(&alice, Wit::from_wits(100), 100).unwrap(); - assert_eq!( - updated, - StakesEntry { - coins: Wit::from_wits(100), - epoch: 100, - exiting_coins: vec![], - } - ); - // But if we use half the stake, again the amount should stay the same, and the epoch should - // be updated to a point in the middle. - let updated = tracker.use_stake(&alice, Wit::from_wits(50), 200).unwrap(); - assert_eq!( - updated, - StakesEntry { - coins: Wit::from_wits(100), - epoch: 150, - exiting_coins: vec![], - } + fn test_e2e() { + let mut stakes = Stakes::::with_minimum(1); + + // Alpha stakes 2 @ epoch 0 + stakes.add_stake("Alpha", 2, 0).unwrap(); + + // Nobody holds any power just yet + let rank = stakes.rank(Capability::Mining, 0).collect::>(); + assert_eq!(rank, vec![("Alpha".into(), 0)]); + + // One epoch later, Alpha starts to hold power + let rank = stakes.rank(Capability::Mining, 1).collect::>(); + assert_eq!(rank, vec![("Alpha".into(), 2)]); + + // Beta stakes 5 @ epoch 10 + stakes.add_stake("Beta", 5, 10).unwrap(); + + // Alpha is still leading, but Beta has scheduled its takeover + let rank = stakes.rank(Capability::Mining, 10).collect::>(); + assert_eq!(rank, vec![("Alpha".into(), 20), ("Beta".into(), 0)]); + + // Beta eventually takes over after epoch 16 + let rank = stakes.rank(Capability::Mining, 16).collect::>(); + assert_eq!(rank, vec![("Alpha".into(), 32), ("Beta".into(), 30)]); + let rank = stakes.rank(Capability::Mining, 17).collect::>(); + assert_eq!(rank, vec![("Beta".into(), 35), ("Alpha".into(), 34)]); + + // Gamma should never take over, even in a million epochs, because it has only 1 coin + stakes.add_stake("Gamma", 1, 30).unwrap(); + let rank = stakes + .rank(Capability::Mining, 1_000_000) + .collect::>(); + assert_eq!( + rank, + vec![ + ("Beta".into(), 4_999_950), + ("Alpha".into(), 2_000_000), + ("Gamma".into(), 999_970) + ] + ); + + // But Delta is here to change it all + stakes.add_stake("Delta", 1_000, 50).unwrap(); + let rank = stakes.rank(Capability::Mining, 50).collect::>(); + assert_eq!( + rank, + vec![ + ("Beta".into(), 200), + ("Alpha".into(), 100), + ("Gamma".into(), 20), + ("Delta".into(), 0) + ] + ); + let rank = stakes.rank(Capability::Mining, 51).collect::>(); + assert_eq!( + rank, + vec![ + ("Delta".into(), 1_000), + ("Beta".into(), 205), + ("Alpha".into(), 102), + ("Gamma".into(), 21) + ] + ); + + // If Alpha removes all of its stake, it should immediately disappear + stakes.remove_stake("Alpha", 2).unwrap(); + let rank = stakes.rank(Capability::Mining, 51).collect::>(); + assert_eq!( + rank, + vec![ + ("Delta".into(), 1_000), + ("Beta".into(), 205), + ("Gamma".into(), 21), + ] ); } } diff --git a/data_structures/src/staking/simple.rs b/data_structures/src/staking/simple.rs new file mode 100644 index 000000000..e69de29bb diff --git a/data_structures/src/staking/stake.rs b/data_structures/src/staking/stake.rs new file mode 100644 index 000000000..38fff6ae4 --- /dev/null +++ b/data_structures/src/staking/stake.rs @@ -0,0 +1,122 @@ +use std::marker::PhantomData; + +use super::prelude::*; + +/// A data structure that keeps track of a staker's staked coins and the epochs for different +/// capabilities. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct Stake +where + Address: Default, + Epoch: Default, +{ + /// An amount of staked coins. + pub coins: Coins, + /// The average epoch used to derive coin age for different capabilities. + pub epochs: CapabilityMap, + // These two phantom fields are here just for the sake of specifying generics. + phantom_address: PhantomData
, + phantom_power: PhantomData, +} + +impl Stake +where + Address: Default, + Coins: Copy + + From + + PartialOrd + + num_traits::Zero + + std::ops::Add + + std::ops::Sub + + std::ops::Mul + + std::ops::Mul, + Epoch: Copy + Default + num_traits::Saturating + std::ops::Sub, + Power: std::ops::Add + + std::ops::Div + + std::ops::Div, +{ + /// Increase the amount of coins staked by a certain staker. + /// + /// When adding stake: + /// - Amounts are added together. + /// - Epochs are weight-averaged, using the amounts as the weight. + /// + /// This type of averaging makes the entry equivalent to an unbounded record of all stake + /// additions and removals, without the overhead in memory and computation. + pub fn add_stake( + &mut self, + coins: Coins, + epoch: Epoch, + minimum_stakeable: Option, + ) -> Result { + // Make sure that the amount to be staked is equal or greater than the minimum + let minimum = minimum_stakeable.unwrap_or(Coins::from(MINIMUM_STAKEABLE_AMOUNT_WITS)); + if coins < minimum { + Err(StakesError::AmountIsBelowMinimum { + amount: coins, + minimum, + })?; + } + + let coins_before = self.coins; + let epoch_before = self.epochs.get(Capability::Mining); + + let product_before = coins_before * epoch_before; + let product_added = coins * epoch; + + let coins_after = coins_before + coins; + let epoch_after = (product_before + product_added) / coins_after; + + self.coins = coins_after; + self.epochs.update_all(epoch_after); + + Ok(coins_after) + } + + /// Construct a Stake entry from a number of coins and a capability map. This is only useful for + /// tests. + #[cfg(test)] + pub fn from_parts(coins: Coins, epochs: CapabilityMap) -> Self { + Self { + coins, + epochs, + phantom_address: Default::default(), + phantom_power: Default::default(), + } + } + + /// Derives the power of an identity in the network on a certain epoch from an entry. Most + /// normally, the epoch is the current epoch. + pub fn power(&self, capability: Capability, current_epoch: Epoch) -> Power { + self.coins * (current_epoch.saturating_sub(self.epochs.get(capability))) + } + + /// Remove a certain amount of staked coins. + pub fn remove_stake( + &mut self, + coins: Coins, + minimum_stakeable: Option, + ) -> Result { + let coins_after = self.coins.sub(coins); + + if coins_after > Coins::zero() { + let minimum = minimum_stakeable.unwrap_or(Coins::from(MINIMUM_STAKEABLE_AMOUNT_WITS)); + + if coins_after < minimum { + Err(StakesError::AmountIsBelowMinimum { + amount: coins_after, + minimum, + })?; + } + } + + self.coins = coins_after; + + Ok(self.coins) + } + + /// Set the epoch for a certain capability. Most normally, the epoch is the current epoch. + pub fn reset_age(&mut self, capability: Capability, current_epoch: Epoch) { + self.epochs.update(capability, current_epoch); + } +} diff --git a/data_structures/src/staking/stakes.rs b/data_structures/src/staking/stakes.rs new file mode 100644 index 000000000..2f79a5421 --- /dev/null +++ b/data_structures/src/staking/stakes.rs @@ -0,0 +1,336 @@ +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; + +use itertools::Itertools; + +use super::prelude::*; + +/// The main data structure that provides the "stakes tracker" functionality. +/// +/// This structure holds indexes of stake entries. Because the entries themselves are reference +/// counted and write-locked, we can have as many indexes here as we need at a negligible cost. +#[derive(Default)] +pub struct Stakes +where + Address: Default, + Epoch: Default, +{ + /// A listing of all the stakers, indexed by their address. + by_address: BTreeMap>, + /// A listing of all the stakers, indexed by their coins and address. + /// + /// Because this uses a compound key to prevent duplicates, if we want to know which addresses + /// have staked a particular amount, we just need to run a range lookup on the tree. + by_coins: BTreeMap, SyncStake>, + /// The amount of coins that can be staked or can be left staked after unstaking. + minimum_stakeable: Option, +} + +impl Stakes +where + Address: Default, + Coins: Copy + + Default + + Ord + + From + + num_traits::Zero + + std::ops::Add + + std::ops::Sub + + std::ops::Mul + + std::ops::Mul, + Address: Clone + Ord + 'static, + Epoch: Copy + Default + num_traits::Saturating + std::ops::Sub, + Power: Copy + + Default + + Ord + + std::ops::Add + + std::ops::Div + + std::ops::Div + + 'static, +{ + /// Register a certain amount of additional stake for a certain address and epoch. + pub fn add_stake( + &mut self, + address: IA, + coins: Coins, + epoch: Epoch, + ) -> Result, Address, Coins, Epoch> + where + IA: Into
, + { + let address = address.into(); + let stake_arc = self + .by_address + .entry(address.clone()) + .or_insert_with(Default::default); + + // Actually increase the number of coins + stake_arc + .write()? + .add_stake(coins, epoch, self.minimum_stakeable)?; + + // Update the position of the staker in the `by_coins` index + // If this staker was not indexed by coins, this will index it now + let key = CoinsAndAddress { + coins, + address: address.clone(), + }; + self.by_coins.remove(&key); + self.by_coins.insert(key, stake_arc.clone()); + + Ok(stake_arc.read()?.clone()) + } + + /// Obtain a list of stakers, conveniently ordered by one of several strategies. + /// + /// ## Strategies + /// + /// - `All`: retrieve all addresses, ordered by decreasing power. + /// - `StepBy`: retrieve every Nth address, ordered by decreasing power. + /// - `Take`: retrieve the most powerful N addresses, ordered by decreasing power. + /// - `Evenly`: retrieve a total of N addresses, evenly distributed from the index, ordered by + /// decreasing power. + pub fn census( + &self, + capability: Capability, + epoch: Epoch, + strategy: CensusStrategy, + ) -> Box> { + let iterator = self.rank(capability, epoch).map(|(address, _)| address); + + match strategy { + CensusStrategy::All => Box::new(iterator), + CensusStrategy::StepBy(step) => Box::new(iterator.step_by(step)), + CensusStrategy::Take(head) => Box::new(iterator.take(head)), + CensusStrategy::Evenly(count) => { + let collected = iterator.collect::>(); + let step = collected.len() / count; + + Box::new(collected.into_iter().step_by(step).take(count)) + } + } + } + + /// Tells what is the power of an identity in the network on a certain epoch. + pub fn query_power( + &self, + address: &Address, + capability: Capability, + epoch: Epoch, + ) -> Result { + Ok(self + .by_address + .get(address) + .ok_or(StakesError::IdentityNotFound { + identity: address.clone(), + })? + .read()? + .power(capability, epoch)) + } + + /// For a given capability, obtain the full list of stakers ordered by their power in that + /// capability. + pub fn rank( + &self, + capability: Capability, + current_epoch: Epoch, + ) -> impl Iterator + 'static { + self.by_coins + .iter() + .flat_map(move |(CoinsAndAddress { address, .. }, stake)| { + stake + .read() + .map(move |stake| (address.clone(), stake.power(capability, current_epoch))) + }) + .sorted_by_key(|(_, power)| *power) + .rev() + } + + /// Remove a certain amount of staked coins from a given identity at a given epoch. + pub fn remove_stake( + &mut self, + address: IA, + coins: Coins, + ) -> Result + where + IA: Into
, + { + let address = address.into(); + if let Entry::Occupied(mut by_address_entry) = self.by_address.entry(address.clone()) { + let (initial_coins, final_coins) = { + let mut stake = by_address_entry.get_mut().write()?; + + // Check the former amount of stake + let initial_coins = stake.coins; + + // Reduce the amount of stake + let final_coins = stake.remove_stake(coins, self.minimum_stakeable)?; + + (initial_coins, final_coins) + }; + + // No need to keep the entry if the stake has gone to zero + if final_coins.is_zero() { + by_address_entry.remove(); + self.by_coins.remove(&CoinsAndAddress { + coins: initial_coins, + address, + }); + } + + Ok(final_coins) + } else { + Err(StakesError::IdentityNotFound { identity: address }) + } + } + + /// Set the epoch for a certain address and capability. Most normally, the epoch is the current + /// epoch. + pub fn reset_age(&mut self, address: Address, capability: Capability, current_epoch: Epoch) { + self.by_address.entry(address).and_modify(|entry| { + entry + .write() + .unwrap() + .epochs + .update(capability, current_epoch); + }); + } + + /// Creates an instance of `Stakes` with a custom minimum stakeable amount. + pub fn with_minimum(minimum: Coins) -> Self { + Stakes { + minimum_stakeable: Some(minimum), + ..Default::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stakes_initialization() { + let stakes = Stakes::::default(); + let ranking = stakes.rank(Capability::Mining, 0).collect::>(); + assert_eq!(ranking, Vec::default()); + } + + #[test] + fn test_add_stake() { + let mut stakes = Stakes::::with_minimum(5); + let alice = "Alice".into(); + let bob = "Bob".into(); + + // Let's check default power + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 0), + Err(StakesError::IdentityNotFound { + identity: alice.clone() + }) + ); + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 1_000), + Err(StakesError::IdentityNotFound { + identity: alice.clone() + }) + ); + + // Let's make Alice stake 100 Wit at epoch 100 + assert_eq!( + stakes.add_stake(&alice, 100, 100).unwrap(), + Stake::from_parts( + 100, + CapabilityMap { + mining: 100, + witnessing: 100 + } + ) + ); + + // Let's see how Alice's stake accrues power over time + assert_eq!(stakes.query_power(&alice, Capability::Mining, 99), Ok(0)); + assert_eq!(stakes.query_power(&alice, Capability::Mining, 100), Ok(0)); + assert_eq!(stakes.query_power(&alice, Capability::Mining, 101), Ok(100)); + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 200), + Ok(10_000) + ); + + // Let's make Alice stake 50 Wits at epoch 150 this time + assert_eq!( + stakes.add_stake(&alice, 50, 300).unwrap(), + Stake::from_parts( + 150, + CapabilityMap { + mining: 166, + witnessing: 166 + } + ) + ); + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 299), + Ok(19_950) + ); + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 300), + Ok(20_100) + ); + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 301), + Ok(20_250) + ); + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 400), + Ok(35_100) + ); + + // Now let's make Bob stake 500 Wits at epoch 1000 this time + assert_eq!( + stakes.add_stake(&bob, 500, 1_000).unwrap(), + Stake::from_parts( + 500, + CapabilityMap { + mining: 1_000, + witnessing: 1_000 + } + ) + ); + + // Before Bob stakes, Alice has all the power + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 999), + Ok(124950) + ); + assert_eq!(stakes.query_power(&bob, Capability::Mining, 999), Ok(0)); + + // New stakes don't change power in the same epoch + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 1_000), + Ok(125100) + ); + assert_eq!(stakes.query_power(&bob, Capability::Mining, 1_000), Ok(0)); + + // Shortly after, Bob's stake starts to gain power + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 1_001), + Ok(125250) + ); + assert_eq!(stakes.query_power(&bob, Capability::Mining, 1_001), Ok(500)); + + // After enough time, Bob overpowers Alice + assert_eq!( + stakes.query_power(&alice, Capability::Mining, 2_000), + Ok(275_100) + ); + assert_eq!( + stakes.query_power(&bob, Capability::Mining, 2_000), + Ok(500_000) + ); + } + + #[test] + fn test_coin_age_resets() { + // TODO: complete this test! + } +}