diff --git a/Cargo.lock b/Cargo.lock index 2735ea947..d59225a28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5461,6 +5461,18 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-chain-start-moment" +version = "0.1.0" +dependencies = [ + "frame-support", + "frame-system", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-core", +] + [[package]] name = "pallet-dynamic-fee" version = "4.0.0-dev" @@ -5803,6 +5815,22 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-vesting" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "mockall 0.11.2", + "once_cell", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "serde", + "sp-core", +] + [[package]] name = "parity-db" version = "0.3.13" @@ -10305,6 +10333,31 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vesting-schedule-linear" +version = "0.1.0" +dependencies = [ + "num", + "num-traits", + "parity-scale-codec", + "scale-info", + "serde", + "serde_json", +] + +[[package]] +name = "vesting-scheduling-timestamp" +version = "0.1.0" +dependencies = [ + "frame-support", + "mockall 0.11.2", + "num-traits", + "pallet-vesting", + "serde", + "serde_json", + "vesting-schedule-linear", +] + [[package]] name = "void" version = "1.0.2" diff --git a/crates/pallet-chain-start-moment/Cargo.toml b/crates/pallet-chain-start-moment/Cargo.toml new file mode 100644 index 000000000..293e436e5 --- /dev/null +++ b/crates/pallet-chain-start-moment/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pallet-chain-start-moment" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +frame-support = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +frame-system = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } + +[dev-dependencies] +pallet-timestamp = { git = "https://github.com/humanode-network/substrate", branch = "master" } +sp-core = { git = "https://github.com/humanode-network/substrate", branch = "master" } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", +] diff --git a/crates/pallet-chain-start-moment/src/lib.rs b/crates/pallet-chain-start-moment/src/lib.rs new file mode 100644 index 000000000..b1ad7a324 --- /dev/null +++ b/crates/pallet-chain-start-moment/src/lib.rs @@ -0,0 +1,69 @@ +//! A pallet that captures the moment of the chain start. + +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::traits::{StorageVersion, Time}; + +pub use self::pallet::*; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +/// The current storage version. +const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + +// We have to temporarily allow some clippy lints. Later on we'll send patches to substrate to +// fix them at their end. +#[allow(clippy::missing_docs_in_private_items)] +#[frame_support::pallet] +pub mod pallet { + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + + use super::*; + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + #[pallet::generate_store(pub(super) trait Store)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// A type representing the time moment and providing the current time. + type Time: Time; + } + + /// The captured chain start moment. + #[pallet::storage] + #[pallet::getter(fn chain_start)] + pub type ChainStart = StorageValue<_, <::Time as Time>::Moment, OptionQuery>; + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(n: BlockNumberFor) -> Weight { + if n != 1u8.into() { + return 0; + } + ::DbWeight::get().writes(1) + } + + fn on_finalize(n: BlockNumberFor) { + if n != 1u8.into() { + return; + } + + let now = T::Time::now(); + + // Ensure that the chain start is properly initialized. + assert_ne!( + now, + 0u8.into(), + "the chain start moment is zero, it is not right" + ); + + >::put(now); + } + } +} diff --git a/crates/pallet-chain-start-moment/src/mock.rs b/crates/pallet-chain-start-moment/src/mock.rs new file mode 100644 index 000000000..92d74d49f --- /dev/null +++ b/crates/pallet-chain-start-moment/src/mock.rs @@ -0,0 +1,83 @@ +//! The mock for the pallet. + +use frame_support::{ + sp_io, + sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, + }, + traits::{ConstU32, ConstU64}, +}; +use sp_core::H256; + +use crate::{self as pallet_chain_start_moment}; + +pub type UnixMilliseconds = u64; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Timestamp: pallet_timestamp::{Pallet}, + ChainStartMoment: pallet_chain_start_moment::{Pallet, Storage}, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = Event; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl pallet_timestamp::Config for Test { + type Moment = UnixMilliseconds; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<5>; + type WeightInfo = (); +} + +impl pallet_chain_start_moment::Config for Test { + type Time = Timestamp; +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let genesis_config = GenesisConfig { + system: Default::default(), + }; + new_test_ext_with(genesis_config) +} + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +pub fn new_test_ext_with(genesis_config: GenesisConfig) -> sp_io::TestExternalities { + let storage = genesis_config.build_storage().unwrap(); + storage.into() +} diff --git a/crates/pallet-chain-start-moment/src/tests.rs b/crates/pallet-chain-start-moment/src/tests.rs new file mode 100644 index 000000000..c0a4f0f2f --- /dev/null +++ b/crates/pallet-chain-start-moment/src/tests.rs @@ -0,0 +1,96 @@ +use frame_support::traits::Hooks; + +use crate::mock::*; + +fn set_timestamp(inc: UnixMilliseconds) { + Timestamp::set(Origin::none(), inc).unwrap(); +} + +fn switch_block() { + if System::block_number() != 0 { + Timestamp::on_finalize(System::block_number()); + ChainStartMoment::on_finalize(System::block_number()); + System::on_finalize(System::block_number()); + } + System::set_block_number(System::block_number() + 1); + System::on_initialize(System::block_number()); + Timestamp::on_initialize(System::block_number()); + ChainStartMoment::on_initialize(System::block_number()); +} + +/// This test verifies that the chain start moment is not set at genesis. +#[test] +fn value_is_not_set_at_genesis() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Assert the state. + assert_eq!(ChainStartMoment::chain_start(), None); + }) +} + +/// This test verifies that the chain start moment is set at the very first block. +#[test] +fn value_is_set_at_first_block() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Block 0. + // Ensure we don't have any timestamp set first. + assert_eq!(ChainStartMoment::chain_start(), None); + + // Block 0 -> 1. + switch_block(); + set_timestamp(100); + assert_eq!(ChainStartMoment::chain_start(), None,); + + // Block 1 -> 2. + switch_block(); + assert_eq!( + ChainStartMoment::chain_start(), + Some(100), + "the chain start must be set correctly right after the first block has been finalized" + ); + }) +} + +/// This test verifies that the chain start moment is not written to after the first block. +#[test] +fn value_does_not_get_written_after_the_first_block() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Block 0 -> 1. + switch_block(); + + set_timestamp(100); + + // Block 1 -> 2. + switch_block(); + + set_timestamp(106); + + // Block 2 -> 3. + switch_block(); + + // Assert the state. + assert_eq!( + ChainStartMoment::chain_start(), + Some(100), + "the chain start moment must've been recorded at the first block" + ); + }) +} + +/// This test verifies that the chain start moment is valid when capture it. +#[test] +#[should_panic = "the chain start moment is zero, it is not right"] +fn value_is_properly_checked() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Block 0 -> 1. + switch_block(); + + set_timestamp(0); + + // Block 1 -> 2. + switch_block(); + }) +} diff --git a/crates/pallet-vesting/Cargo.toml b/crates/pallet-vesting/Cargo.toml new file mode 100644 index 000000000..d21903436 --- /dev/null +++ b/crates/pallet-vesting/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "pallet-vesting" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +frame-benchmarking = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master", optional = true } +frame-support = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +frame-system = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +serde = { version = "1", optional = true } + +[dev-dependencies] +mockall = "0.11" +once_cell = "1" +pallet-balances = { git = "https://github.com/humanode-network/substrate", branch = "master" } +sp-core = { git = "https://github.com/humanode-network/substrate", branch = "master" } + +[features] +default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] +std = [ + "codec/std", + "frame-benchmarking/std", + "frame-support/std", + "frame-system/std", + "serde", +] diff --git a/crates/pallet-vesting/src/benchmarking.rs b/crates/pallet-vesting/src/benchmarking.rs new file mode 100644 index 000000000..44659adbb --- /dev/null +++ b/crates/pallet-vesting/src/benchmarking.rs @@ -0,0 +1,98 @@ +//! The benchmarks for the pallet. + +use frame_benchmarking::benchmarks; +use frame_support::traits::{ExistenceRequirement, WithdrawReasons}; +use frame_system::RawOrigin; + +use crate::*; + +/// The benchmark interface into the environment. +pub trait Interface: super::Config { + /// Obtain an Account ID. + /// + /// This is an account to unlock the vested balance for. + fn account_id() -> ::AccountId; + + /// Obtain the vesting schedule. + /// + /// This is the vesting. + fn schedule() -> ::Schedule; +} + +benchmarks! { + where_clause { + where + T: Interface + } + + unlock { + let account_id = ::account_id(); + let schedule = ::schedule(); + + let imbalance = >::deposit_creating(&account_id, 1000u32.into()); + assert_eq!(>::free_balance(&account_id), 1000u32.into()); + assert!(>::ensure_can_withdraw(&account_id, 1000u32.into(), WithdrawReasons::empty(), 0u32.into()).is_ok()); + + #[cfg(test)] + let test_data = { + use crate::mock; + + let mock_runtime_guard = mock::runtime_lock(); + + let compute_balance_under_lock_ctx = mock::MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx.expect().once().return_const(Ok(100)); + + (mock_runtime_guard, compute_balance_under_lock_ctx) + }; + + >::lock_under_vesting(&account_id, schedule)?; + assert_eq!(>::free_balance(&account_id), 1000u32.into()); + assert!(>::ensure_can_withdraw(&account_id, 1000u32.into(), WithdrawReasons::empty(), 0u32.into()).is_err()); + + #[cfg(test)] + let test_data = { + let (mock_runtime_guard, compute_balance_under_lock_ctx) = test_data; + + compute_balance_under_lock_ctx.expect().times(1..).return_const(Ok(0)); + + (mock_runtime_guard, compute_balance_under_lock_ctx) + }; + + let origin = RawOrigin::Signed(account_id.clone()); + + }: _(origin) + verify { + assert_eq!(Schedules::::get(&account_id), None); + assert_eq!(>::free_balance(&account_id), 1000u32.into()); + assert!(>::ensure_can_withdraw(&account_id, 1000u32.into(), WithdrawReasons::empty(), 0u32.into()).is_ok()); + + #[cfg(test)] + { + let (mock_runtime_guard, compute_balance_under_lock_ctx) = test_data; + + compute_balance_under_lock_ctx.checkpoint(); + + drop(mock_runtime_guard); + } + + // Clean up imbalance after ourselves. + >::settle(&account_id, imbalance, WithdrawReasons::RESERVE, ExistenceRequirement::AllowDeath).ok().unwrap(); + } + + impl_benchmark_test_suite!( + Pallet, + crate::mock::new_test_ext(), + crate::mock::Test, + ); +} + +#[cfg(test)] +impl Interface for crate::mock::Test { + fn account_id() -> ::AccountId { + 42 + } + + fn schedule() -> ::Schedule { + mock::MockSchedule + } +} diff --git a/crates/pallet-vesting/src/lib.rs b/crates/pallet-vesting/src/lib.rs new file mode 100644 index 000000000..40115e83c --- /dev/null +++ b/crates/pallet-vesting/src/lib.rs @@ -0,0 +1,233 @@ +//! Vesting. + +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::traits::{Currency, LockIdentifier, LockableCurrency, StorageVersion}; + +pub use self::pallet::*; + +pub mod traits; +pub mod weights; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +/// The current storage version. +const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + +/// The currency from a given config. +type CurrencyOf = ::Currency; +/// The Account ID from a given config. +type AccountIdOf = ::AccountId; +/// The balance from a given config. +type BalanceOf = as Currency>>::Balance; + +// We have to temporarily allow some clippy lints. Later on we'll send patches to substrate to +// fix them at their end. +#[allow(clippy::missing_docs_in_private_items)] +#[frame_support::pallet] +pub mod pallet { + use frame_support::{ + pallet_prelude::*, sp_runtime::traits::Zero, storage::transactional::in_storage_layer, + traits::WithdrawReasons, + }; + use frame_system::pallet_prelude::*; + + use super::*; + use crate::{traits::SchedulingDriver, weights::WeightInfo}; + + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Overarching event type. + type Event: From> + IsType<::Event>; + + /// Currency to claim. + type Currency: LockableCurrency<::AccountId>; + + /// The ID of the lock to use at the lockable balance. + type LockId: Get; + + /// The vesting schedule configuration type. + type Schedule: Member + Parameter + MaxEncodedLen + MaybeSerializeDeserialize; + + /// The scheduling driver to use for computing balance unlocks. + type SchedulingDriver: SchedulingDriver< + Balance = BalanceOf, + Schedule = Self::Schedule, + >; + + /// The weight information provider type. + type WeightInfo: WeightInfo; + } + + /// The schedules information. + #[pallet::storage] + #[pallet::getter(fn locks)] + pub type Schedules = + StorageMap<_, Twox64Concat, AccountIdOf, ::Schedule, OptionQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Balance was locked under vesting. + Locked { + /// Who had the balance locked. + who: T::AccountId, + /// The unlocking schedule. + schedule: T::Schedule, + /// The balance that is locked under vesting. + balance_under_lock: BalanceOf, + }, + /// Vested balance was partially unlocked. + PartiallyUnlocked { + /// Who had the balance unlocked. + who: T::AccountId, + /// The balance that still remains locked. + balance_left_under_lock: BalanceOf, + }, + /// Vesting is over and the locked balance has been fully unlocked. + FullyUnlocked { + /// Who had the vesting. + who: T::AccountId, + }, + } + + #[pallet::error] + pub enum Error { + /// Vesting is already engaged for a given account. + VestingAlreadyEngaged, + + /// No vesting is active for a given account. + NoVesting, + } + + #[pallet::call] + impl Pallet { + /// Unlock the vested balance according to the schedule. + #[pallet::weight(T::WeightInfo::unlock())] + pub fn unlock(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + Self::unlock_vested_balance(&who) + } + } + + impl Pallet { + /// Lock the balance at the given account under the specified vesting schedule. + /// + /// The amount to lock depends on the actual schedule and will be computed on the fly. + /// + /// Only one vesting balance lock per account can exist at a time. + /// + /// Locking zero balance will skip creating the lock and will directly emit + /// the "fully unlocked" event. + pub fn lock_under_vesting(who: &T::AccountId, schedule: T::Schedule) -> DispatchResult { + in_storage_layer(|| { + // Check if a given account already has vesting engaged. + if >::contains_key(who) { + return Err(>::VestingAlreadyEngaged.into()); + } + + // Compute the locked balance. + let computed_locked_balance = + T::SchedulingDriver::compute_balance_under_lock(&schedule)?; + + // Send the event announcing the lock. + Self::deposit_event(Event::Locked { + who: who.clone(), + schedule: schedule.clone(), + balance_under_lock: computed_locked_balance, + }); + + // Check if we're locking zero balance. + if computed_locked_balance == Zero::zero() { + // If we do - skip creating the schedule and locking altogether. + + // Send the unlock event. + Self::deposit_event(Event::FullyUnlocked { who: who.clone() }); + + return Ok(()); + } + + // Store the schedule. + >::insert(who, schedule); + + // Set the lock. + Self::set_lock(who, computed_locked_balance); + + Ok(()) + }) + } + + /// Unlock the vested balance on a given account according to the unlocking schedule. + /// + /// If the balance left under lock is zero, the lock is removed along with the vesting + /// information - effectively eliminating any effect this pallet has on the given account's + /// balance. + /// + /// If the balance left under lock is non-zero we readjust the lock and keep + /// the vesting information around. + pub fn unlock_vested_balance(who: &T::AccountId) -> DispatchResult { + in_storage_layer(|| { + let schedule = >::get(who).ok_or(>::NoVesting)?; + + // Compute the new locked balance. + let computed_locked_balance = + T::SchedulingDriver::compute_balance_under_lock(&schedule)?; + + // If we ended up locking the whole balance we are done with the vesting. + // Clean up the state and unlock the whole balance. + if computed_locked_balance == Zero::zero() { + // Remove the schedule. + >::remove(who); + + // Remove the balance lock. + as LockableCurrency>::remove_lock( + T::LockId::get(), + who, + ); + + // Dispatch the event. + Self::deposit_event(Event::FullyUnlocked { who: who.clone() }); + + // We're done! + return Ok(()); + } + + // Set the lock to the updated value. + Self::set_lock(who, computed_locked_balance); + + // Dispatch the event. + Self::deposit_event(Event::PartiallyUnlocked { + who: who.clone(), + balance_left_under_lock: computed_locked_balance, + }); + + Ok(()) + }) + } + + pub(crate) fn set_lock(who: &T::AccountId, balance_to_lock: BalanceOf) { + debug_assert!( + balance_to_lock != Zero::zero(), + "we must ensure that the balance is non-zero when calling this fn" + ); + + // Set the lock. + as LockableCurrency>::set_lock( + T::LockId::get(), + who, + balance_to_lock, + WithdrawReasons::all(), + ); + } + } +} diff --git a/crates/pallet-vesting/src/mock.rs b/crates/pallet-vesting/src/mock.rs new file mode 100644 index 000000000..ab010f13d --- /dev/null +++ b/crates/pallet-vesting/src/mock.rs @@ -0,0 +1,96 @@ +//! The mock for the pallet. + +use frame_support::{ + parameter_types, sp_io, + sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, + }, + traits::{ConstU32, ConstU64, LockIdentifier}, +}; +use sp_core::H256; + +use crate::{self as pallet_vesting}; + +mod utils; +pub use self::utils::*; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + Vesting: pallet_vesting::{Pallet, Call, Storage, Event}, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = Event; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl pallet_balances::Config for Test { + type Balance = u64; + type Event = Event; + type DustRemoval = (); + type ExistentialDeposit = ConstU64<1>; + type AccountStore = System; + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +parameter_types! { + pub LockId: LockIdentifier = [0u8; 8]; +} + +impl pallet_vesting::Config for Test { + type Event = Event; + type Currency = Balances; + type LockId = LockId; + type Schedule = MockSchedule; + type SchedulingDriver = MockSchedulingDriver; + type WeightInfo = (); +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let genesis_config = GenesisConfig::default(); + new_test_ext_with(genesis_config) +} + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +pub fn new_test_ext_with(genesis_config: GenesisConfig) -> sp_io::TestExternalities { + let storage = genesis_config.build_storage().unwrap(); + storage.into() +} diff --git a/crates/pallet-vesting/src/mock/utils.rs b/crates/pallet-vesting/src/mock/utils.rs new file mode 100644 index 000000000..8e805af70 --- /dev/null +++ b/crates/pallet-vesting/src/mock/utils.rs @@ -0,0 +1,56 @@ +//! Mock utils. + +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::{sp_runtime::DispatchError, Deserialize, Serialize}; +use mockall::mock; +use scale_info::TypeInfo; + +use super::*; +use crate::traits; + +#[derive( + Debug, Clone, Decode, Encode, MaxEncodedLen, TypeInfo, PartialEq, Eq, Serialize, Deserialize, +)] +pub struct MockSchedule; + +mock! { + #[derive(Debug)] + pub SchedulingDriver {} + impl traits::SchedulingDriver for SchedulingDriver { + type Balance = crate::BalanceOf; + type Schedule = MockSchedule; + + fn compute_balance_under_lock( + schedule: &::Schedule, + ) -> Result<::Balance, DispatchError>; + } +} + +pub fn runtime_lock() -> std::sync::MutexGuard<'static, ()> { + static MOCK_RUNTIME_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + // Ignore the poisoning for the tests that panic. + // We only care about concurrency here, not about the poisoning. + match MOCK_RUNTIME_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +pub trait TestExternalitiesExt { + fn execute_with_ext(&mut self, execute: E) -> R + where + E: for<'e> FnOnce(&'e ()) -> R; +} + +impl TestExternalitiesExt for frame_support::sp_io::TestExternalities { + fn execute_with_ext(&mut self, execute: E) -> R + where + E: for<'e> FnOnce(&'e ()) -> R, + { + let guard = runtime_lock(); + let result = self.execute_with(|| execute(&guard)); + drop(guard); + result + } +} diff --git a/crates/pallet-vesting/src/tests.rs b/crates/pallet-vesting/src/tests.rs new file mode 100644 index 000000000..da3f22a23 --- /dev/null +++ b/crates/pallet-vesting/src/tests.rs @@ -0,0 +1,354 @@ +//! The tests for the pallet. + +use frame_support::{assert_noop, assert_ok, sp_runtime::DispatchError}; +use mockall::predicate; + +use crate::{ + mock::{ + new_test_ext, Balances, MockSchedule, MockSchedulingDriver, Origin, System, Test, + TestExternalitiesExt, Vesting, + }, + *, +}; + +/// This test verifies that `lock_under_vesting` works in the happy path (with non-zero balance). +#[test] +fn lock_under_vesting_works() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + + // Check test preconditions. + assert!(>::get(&42).is_none()); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx + .expect() + .once() + .with(predicate::eq(MockSchedule)) + .return_const(Ok(100)); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_ok!(Vesting::lock_under_vesting(&42, MockSchedule)); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + assert!(>::get(&42).is_some()); + assert_eq!(System::events().len(), 1); + System::assert_has_event(mock::Event::Vesting(Event::Locked { + who: 42, + schedule: MockSchedule, + balance_under_lock: 100, + })); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies that `lock_under_vesting` works in the happy path (with zero balance). +#[test] +fn lock_under_vesting_works_with_zero() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + + // Check test preconditions. + assert!(>::get(&42).is_none()); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx + .expect() + .once() + .with(predicate::eq(MockSchedule)) + .return_const(Ok(0)); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_ok!(Vesting::lock_under_vesting(&42, MockSchedule)); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + assert!(>::get(&42).is_none()); + assert_eq!(System::events().len(), 2); + System::assert_has_event(mock::Event::Vesting(Event::Locked { + who: 42, + schedule: MockSchedule, + balance_under_lock: 0, + })); + System::assert_has_event(mock::Event::Vesting(Event::FullyUnlocked { who: 42 })); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies that `lock_under_vesting` does not allow engaging a lock if there is another +/// lock already present. +#[test] +fn lock_under_vesting_conflicts_with_existing_lock() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + >::set_lock(&42, 100); + >::insert(&42, MockSchedule); + + // Check test preconditions. + let schedule_before = >::get(&42).unwrap(); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx.expect().never(); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_noop!( + Vesting::lock_under_vesting(&42, MockSchedule), + >::VestingAlreadyEngaged + ); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + assert_eq!(>::get(&42).unwrap(), schedule_before); + assert_eq!(System::events().len(), 0); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies that `lock_under_vesting` can lock the balance greater than the free balance +/// available at the account. +/// This is not a part of the design, but just demonstrates this one property of the system we have +/// here. +#[test] +fn lock_under_vesting_can_lock_balance_greater_than_free_balance() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + + // Check test preconditions. + assert!(>::get(&42).is_none()); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx + .expect() + .once() + .with(predicate::eq(MockSchedule)) + .return_const(Ok(1100)); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_ok!(Vesting::lock_under_vesting(&42, MockSchedule)); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 0); + assert!(>::get(&42).is_some()); + assert_eq!(System::events().len(), 1); + System::assert_has_event(mock::Event::Vesting(Event::Locked { + who: 42, + schedule: MockSchedule, + balance_under_lock: 1100, + })); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies that `unlock` works in the happy path when we need to unlock the whole +/// balance. +#[test] +fn unlock_works_full() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + >::set_lock(&42, 100); + >::insert(&42, MockSchedule); + + // Check test preconditions. + assert!(>::get(&42).is_some()); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx + .expect() + .once() + .with(predicate::eq(MockSchedule)) + .return_const(Ok(0)); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_ok!(Vesting::unlock(Origin::signed(42))); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + assert!(>::get(&42).is_none()); + assert_eq!(System::events().len(), 1); + System::assert_has_event(mock::Event::Vesting(Event::FullyUnlocked { who: 42 })); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies that `unlock` works in the happy path when we need to unlock a fraction +/// of the balance. +#[test] +fn unlock_works_partial() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + >::set_lock(&42, 100); + >::insert(&42, MockSchedule); + + // Check test preconditions. + let schedule_before = >::get(&42).unwrap(); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx + .expect() + .once() + .with(predicate::eq(MockSchedule)) + .return_const(Ok(90)); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_ok!(Vesting::unlock(Origin::signed(42))); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 910); + assert_eq!(>::get(&42).unwrap(), schedule_before); + assert_eq!(System::events().len(), 1); + System::assert_has_event(mock::Event::Vesting(Event::PartiallyUnlocked { + who: 42, + balance_left_under_lock: 90, + })); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies that `unlock` results in a valid state after the scheduling driver +/// computation has failed. +#[test] +fn unlock_computation_failure() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + >::set_lock(&42, 100); + >::insert(&42, MockSchedule); + + // Check test preconditions. + let schedule_before = >::get(&42).unwrap(); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx + .expect() + .once() + .with(predicate::eq(MockSchedule)) + .return_const(Err(DispatchError::Other("compute_balance_under failed"))); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_noop!( + Vesting::unlock(Origin::signed(42)), + DispatchError::Other("compute_balance_under failed") + ); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 900); + assert_eq!(>::get(&42).unwrap(), schedule_before); + assert_eq!(System::events().len(), 0); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} + +/// This test verifies the `unlock` behaviour when it is called for an account that does not +/// have vesting. +#[test] +fn unlock_no_vesting_error() { + new_test_ext().execute_with_ext(|_| { + // Prepare the test state. + Balances::make_free_balance_be(&42, 1000); + + // Check test preconditions. + assert!(>::get(&42).is_none()); + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + + // Set mock expectations. + let compute_balance_under_lock_ctx = + MockSchedulingDriver::compute_balance_under_lock_context(); + compute_balance_under_lock_ctx.expect().never(); + + // Set block number to enable events. + System::set_block_number(1); + + // Invoke the function under test. + assert_noop!( + Vesting::unlock(Origin::signed(42)), + >::NoVesting, + ); + + // Assert state changes. + assert_eq!(Balances::free_balance(&42), 1000); + assert_eq!(Balances::usable_balance(&42), 1000); + assert!(>::get(&42).is_none()); + assert_eq!(System::events().len(), 0); + + // Assert mock invocations. + compute_balance_under_lock_ctx.checkpoint(); + }); +} diff --git a/crates/pallet-vesting/src/traits.rs b/crates/pallet-vesting/src/traits.rs new file mode 100644 index 000000000..b605ce6e0 --- /dev/null +++ b/crates/pallet-vesting/src/traits.rs @@ -0,0 +1,44 @@ +//! Traits we use and expose. + +use frame_support::sp_runtime::DispatchError; + +/// The scheduling driver. +/// +/// Responsible for keeping the global context needed to tell how far are we in a given schedule. +pub trait SchedulingDriver { + /// The balance type. + /// + /// The reason we use the balance type in the scheduling driver is that it allows us to have + /// perfect precision. The idea is simple: whatever we have to do to recompute the balance has + /// to return another balance. By avoiding the use of intermediate numeric representation + /// of how far are we in the schedule we eliminate the possibility of conversion and + /// rounding errors at the driver interface level. They are still possible within + /// the implementation, but at the very least they can't affect. + type Balance; + + /// The schedule configuration. + /// + /// Determines the computation parameters for a particular case. + /// + /// Schedule is supposed to provide both the initial balance and the actual scheduling + /// information. + /// This allows implementing non-trivial schedule composition logic. + type Schedule; + + /// Given the initially locked balance and the schedule configuration, relying on + /// the scheduling driver's for the notion on where are we in the schedule, + /// compute the effective balance value that has to be kept locked. + /// + /// Must be a monotonically non-increasing function with return values between + /// the `initially_locked_balance` and zero. + /// + /// Returning zero means no balance has to be locked, and can be treated as a special case by + /// the caller to remove the lock and scheduling altogether - meaning there will be no further + /// calls to this function. + /// + /// If the rounding of the resulting balance is required, it is up to the implementation how + /// this rounding is performed. It might be made configurable via [`Self::Schedule`]. + fn compute_balance_under_lock( + schedule: &Self::Schedule, + ) -> Result; +} diff --git a/crates/pallet-vesting/src/weights.rs b/crates/pallet-vesting/src/weights.rs new file mode 100644 index 000000000..fada47ec8 --- /dev/null +++ b/crates/pallet-vesting/src/weights.rs @@ -0,0 +1,15 @@ +//! The weights. + +use frame_support::dispatch::Weight; + +/// The weight information trait, to be implemented in from the benches. +pub trait WeightInfo { + /// Weight for the unlock call. + fn unlock() -> Weight; +} + +impl WeightInfo for () { + fn unlock() -> Weight { + 0 + } +} diff --git a/crates/vesting-schedule-linear/Cargo.toml b/crates/vesting-schedule-linear/Cargo.toml new file mode 100644 index 000000000..6221f2b9a --- /dev/null +++ b/crates/vesting-schedule-linear/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "vesting-schedule-linear" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = [ + "derive", + "max-encoded-len", +] } +num-traits = { version = "0.2", default-features = false } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +serde = { version = "1", features = ["derive"], optional = true } + +[dev-dependencies] +num = "0.4" +serde_json = "1" + +[features] +default = ["std"] +std = ["codec/std", "num-traits/std", "serde"] diff --git a/crates/vesting-schedule-linear/src/lib.rs b/crates/vesting-schedule-linear/src/lib.rs new file mode 100644 index 000000000..386eadcc8 --- /dev/null +++ b/crates/vesting-schedule-linear/src/lib.rs @@ -0,0 +1,248 @@ +//! The linear schedule for vesting. + +#![cfg_attr(not(feature = "std"), no_std)] + +use num_traits::{CheckedSub, Unsigned, Zero}; + +pub mod traits; + +use traits::{FracScale, FracScaleError}; + +/// The linear schedule. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + codec::Encode, + codec::Decode, + codec::MaxEncodedLen, + scale_info::TypeInfo, +)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", serde(deny_unknown_fields))] +pub struct LinearSchedule { + /// The balance to lock. + pub balance: Balance, + /// The cliff duration (counting from the starting point). + pub cliff: Duration, + /// The vesting duration (counting from after the cliff). + pub vesting: Duration, +} + +impl LinearSchedule +where + Balance: Unsigned + Copy, + Duration: PartialOrd + Unsigned + CheckedSub + Copy, +{ + /// Compute the amount of balance to lock at any given point in the schedule + /// specified by `duration_since_starting_point`. + pub fn compute_locked_balance( + &self, + duration_since_starting_point: Duration, + ) -> Result + where + S: FracScale, + { + let progress = match duration_since_starting_point.checked_sub(&self.cliff) { + // We don't have the progress yet because the cliff period did not pass yet, so + // lock the whole balance. + None => return Ok(self.balance), + Some(v) => v, + }; + + let locked_fraction = match self.vesting.checked_sub(&progress) { + // We don't have the locked fraction already because the vesting period is already + // over. + // We guarantee that we unlock everything by returning zero. + None => return Ok(Zero::zero()), + Some(v) => v, + }; + + S::frac_scale(&self.balance, &locked_fraction, &self.vesting) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::SimpleFracScaler; + + type TestLinearSchedule = LinearSchedule; + type TestScaler = SimpleFracScaler; + + #[test] + fn logic_simple() { + let schedule = TestLinearSchedule { + balance: 20, + cliff: 10, + vesting: 10, + }; + + let compute = |point| { + schedule + .compute_locked_balance::(point) + .unwrap() + }; + + assert_eq!(compute(0), 20); + assert_eq!(compute(1), 20); + assert_eq!(compute(9), 20); + assert_eq!(compute(10), 20); + assert_eq!(compute(11), 18); + assert_eq!(compute(12), 16); + assert_eq!(compute(18), 4); + assert_eq!(compute(19), 2); + assert_eq!(compute(20), 0); + assert_eq!(compute(21), 0); + assert_eq!(compute(29), 0); + assert_eq!(compute(30), 0); + assert_eq!(compute(31), 0); + assert_eq!(compute(0xfe), 0); + assert_eq!(compute(0xff), 0); + } + + #[test] + fn logic_no_cliff() { + let schedule = TestLinearSchedule { + balance: 20, + cliff: 0, + vesting: 10, + }; + + let compute = |point| { + schedule + .compute_locked_balance::(point) + .unwrap() + }; + + assert_eq!(compute(0), 20); + assert_eq!(compute(1), 18); + assert_eq!(compute(2), 16); + assert_eq!(compute(8), 4); + assert_eq!(compute(9), 2); + assert_eq!(compute(10), 0); + assert_eq!(compute(11), 0); + assert_eq!(compute(20), 0); + assert_eq!(compute(30), 0); + assert_eq!(compute(0xff), 0); + } + + #[test] + fn logic_only_cliff() { + let schedule = TestLinearSchedule { + balance: 20, + cliff: 10, + vesting: 0, + }; + + let compute = |point| { + schedule + .compute_locked_balance::(point) + .unwrap() + }; + + assert_eq!(compute(0), 20); + assert_eq!(compute(1), 20); + assert_eq!(compute(2), 20); + assert_eq!(compute(8), 20); + assert_eq!(compute(9), 20); + assert_eq!(compute(10), 0); + assert_eq!(compute(11), 0); + assert_eq!(compute(20), 0); + assert_eq!(compute(30), 0); + assert_eq!(compute(0xff), 0); + } + + #[test] + fn logic_no_lock() { + let schedule = TestLinearSchedule { + balance: 20, + cliff: 0, + vesting: 0, + }; + + let compute = |point| { + schedule + .compute_locked_balance::(point) + .unwrap() + }; + + assert_eq!(compute(0), 0); + assert_eq!(compute(1), 0); + assert_eq!(compute(2), 0); + assert_eq!(compute(0xff), 0); + } + + #[test] + fn logic_all_zeroes() { + let schedule = TestLinearSchedule { + balance: 0, + cliff: 0, + vesting: 0, + }; + + let compute = |point| { + schedule + .compute_locked_balance::(point) + .unwrap() + }; + + assert_eq!(compute(0), 0); + assert_eq!(compute(1), 0); + assert_eq!(compute(2), 0); + assert_eq!(compute(0xff), 0); + } + + #[test] + fn logic_precision() { + let schedule = LinearSchedule { + balance: 1000000000, + cliff: 10, + vesting: 9, + }; + + let compute = |point| { + schedule + .compute_locked_balance::>(point) + .unwrap() + }; + + assert_eq!(compute(0), 1000000000); + assert_eq!(compute(9), 1000000000); + assert_eq!(compute(10), 1000000000); + assert_eq!(compute(11), 888888888); + assert_eq!(compute(12), 777777777); + assert_eq!(compute(13), 666666666); + assert_eq!(compute(14), 555555555); + assert_eq!(compute(15), 444444444); + assert_eq!(compute(16), 333333333); + assert_eq!(compute(17), 222222222); + assert_eq!(compute(18), 111111111); + assert_eq!(compute(19), 0); + assert_eq!(compute(20), 0); + assert_eq!(compute(30), 0); + assert_eq!(compute(0xff), 0); + } + + #[test] + fn serde_parse() { + let val = r#"{"balance": 40, "cliff": 20, "vesting": 25}"#; + let val: TestLinearSchedule = serde_json::from_str(val).unwrap(); + assert_eq!( + val, + TestLinearSchedule { + balance: 40, + cliff: 20, + vesting: 25 + } + ); + } + + #[test] + #[should_panic = "unknown field `unknown_field`"] + fn serde_parse_does_not_allow_unknown_fields() { + let val = r#"{"balance": 40, "cliff": 20, "vesting": 25, "unknown_field": 123}"#; + let _: TestLinearSchedule = serde_json::from_str(val).unwrap(); + } +} diff --git a/crates/vesting-schedule-linear/src/traits.rs b/crates/vesting-schedule-linear/src/traits.rs new file mode 100644 index 000000000..aaa40a5f4 --- /dev/null +++ b/crates/vesting-schedule-linear/src/traits.rs @@ -0,0 +1,166 @@ +//! Traits that we use. + +use core::marker::PhantomData; + +/// An error that can happen at [`FracScale`]. +#[derive(Debug, Clone, PartialEq)] +pub enum FracScaleError { + /// An overflow occured. + Overflow, + /// A division by zero occured. + DivisionByZero, + /// Convertion from the internal computations type to the value type failed. + Conversion, +} + +/// Fractional scaler. +/// +/// Effectively represent multiplication of the value to a fraction operation: x * (a/b). +pub trait FracScale { + /// The value type to scale. + type Value; + /// The type used for the fraction nominator and denominator. + type FracPart; + + /// Compute `value` * (`nom` / `denom`). + fn frac_scale( + value: &Self::Value, + nom: &Self::FracPart, + denom: &Self::FracPart, + ) -> Result; +} + +/// Not super precise or safe, but generic scaler. +pub struct SimpleFracScaler(PhantomData<(T, Value, FracPart)>); + +impl FracScale for SimpleFracScaler +where + T: num_traits::CheckedMul + num_traits::CheckedDiv + num_traits::Zero, + Value: Into + Copy + num_traits::Zero, + FracPart: Into + Copy, + T: TryInto, +{ + type Value = Value; + type FracPart = FracPart; + + fn frac_scale( + value: &Self::Value, + nom: &Self::FracPart, + denom: &Self::FracPart, + ) -> Result { + let value = (*value).into(); + let nom = (*nom).into(); + + let upscaled = value.checked_mul(&nom).ok_or(FracScaleError::Overflow)?; + if upscaled.is_zero() { + return Ok(num_traits::Zero::zero()); + } + + let denom = (*denom).into(); + let downscaled = upscaled + .checked_div(&denom) + .ok_or(FracScaleError::DivisionByZero)?; + downscaled + .try_into() + .map_err(|_| FracScaleError::Conversion) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_frac_scaler_logic_same_size() { + let max = u8::MAX; + let tests = [ + // Ok + // - value bounds + (0, 1, 1, Ok(0)), + (max, 1, 1, Ok(max)), + (0, max, max, Ok(0)), + // - samples + (100, 0, 100, Ok(0)), + (100, 0, 0, Ok(0)), + (0xff, 1, 1, Ok(0xff)), + (0xff, 1, 2, Ok(127)), + (2, 1, 2, Ok(1)), + (2, 1, 3, Ok(0)), + // Errors + // - the denom is zero, we get what we asked for + (10, 10, 0, Err(FracScaleError::DivisionByZero)), + // - the 0xff * 0xff > 0xff and we are at u8, so we get an overflow + (max, max, max, Err(FracScaleError::Overflow)), + // - the 0xff * 2 > 0xff, with u8 this is an overflow + (max, 2, 1, Err(FracScaleError::Overflow)), + // - the 2 * 0xff > 0xff, u8, so overflow again + (2, max, 1, Err(FracScaleError::Overflow)), + ]; + + for (value, nom, denom, expected) in tests { + let actual = >::frac_scale(&value, &nom, &denom); + assert_eq!(actual, expected, "u8 {} {} {}", value, nom, denom); + } + } + + #[test] + fn simple_frac_scaler_logic_u8_to_u16() { + let max = u8::MAX; + let tests = [ + // Ok + // - value bounds + (0, 1, 1, Ok(0)), + (max, 1, 1, Ok(max)), + (0, max, max, Ok(0)), + // - samples + (100, 0, 100, Ok(0)), + (100, 0, 0, Ok(0)), + (0xff, 1, 1, Ok(0xff)), + (0xff, 1, 2, Ok(127)), + (2, 1, 2, Ok(1)), + (2, 1, 3, Ok(0)), + // - the 0xff * 0xff < 0xffff and we are at u16, so we are good + (max, max, max, Ok(max)), + // Errors + // - the denom is zero, we get what we asked for + (10, 10, 0, Err(FracScaleError::DivisionByZero)), + // - the 0xff * 2 > 0xff < 0xffff, with u16 we are good with overflow but fail at conversion + (max, 2, 1, Err(FracScaleError::Conversion)), + // - the 2 * 0xff > 0xff < 0xffff, u16, again good with overflow but fail at conversion + (2, max, 1, Err(FracScaleError::Conversion)), + ]; + + for (value, nom, denom, expected) in tests { + let actual = >::frac_scale(&value, &nom, &denom); + assert_eq!(actual, expected, "u16 u8 {} {} {}", value, nom, denom); + } + } + + #[test] + fn simple_frac_scaler_logic_biguint_u128_u64() { + let tests = [ + // Ok + (u128::MAX, u64::MAX, u64::MAX, Ok(u128::MAX)), + (0, u64::MAX, u64::MAX, Ok(0)), + (1, u64::MAX, u64::MAX, Ok(1)), + (1, u64::MAX / 2, u64::MAX, Ok(0)), + (1, u64::MAX - 1, u64::MAX, Ok(0)), + (2, u64::MAX - 1, u64::MAX, Ok(1)), + (2, u64::MAX, u64::MAX, Ok(2)), + // Err + (u128::MAX, u64::MAX, 0, Err(FracScaleError::DivisionByZero)), + (u128::MAX, u64::MAX, 1, Err(FracScaleError::Conversion)), + (u128::MAX, 2, 1, Err(FracScaleError::Conversion)), + ]; + + for (value, nom, denom, expected) in tests { + let actual = + >::frac_scale(&value, &nom, &denom); + assert_eq!( + actual, expected, + "BigUint u128 u64 {} {} {}", + value, nom, denom + ); + } + } +} diff --git a/crates/vesting-scheduling-timestamp/Cargo.toml b/crates/vesting-scheduling-timestamp/Cargo.toml new file mode 100644 index 000000000..520e6bec7 --- /dev/null +++ b/crates/vesting-scheduling-timestamp/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vesting-scheduling-timestamp" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +pallet-vesting = { version = "0.1", path = "../pallet-vesting", default-features = false } +vesting-schedule-linear = { version = "0.1", path = "../vesting-schedule-linear", default-features = false } + +frame-support = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +num-traits = { version = "0.2", default-features = false } +serde = { version = "1", optional = true } + +[dev-dependencies] +mockall = "0.11" +serde_json = "1" + +[features] +default = ["std"] +std = ["frame-support/std", "num-traits/std", "pallet-vesting/std", "serde", "vesting-schedule-linear/std"] diff --git a/crates/vesting-scheduling-timestamp/src/lib.rs b/crates/vesting-scheduling-timestamp/src/lib.rs new file mode 100644 index 000000000..0f8786838 --- /dev/null +++ b/crates/vesting-scheduling-timestamp/src/lib.rs @@ -0,0 +1,137 @@ +//! The timestamp-based scheduling for the vesting pallet. + +#![cfg_attr(not(feature = "std"), no_std)] + +use core::marker::PhantomData; + +use frame_support::{sp_runtime::DispatchError, traits::Get, BoundedVec}; +use num_traits::{CheckedAdd, CheckedSub, Unsigned, Zero}; +use vesting_schedule_linear::{ + traits::{FracScale, FracScaleError}, + LinearSchedule, +}; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +/// The adapter connects the given schedule to the timestamp scheduling driver. +pub struct Adapter(PhantomData<(T, Schedule)>); + +/// The config for the generic timestamp scheduling logic. +pub trait Config { + /// The balance to operate with. + type Balance; + + /// The timestamp representation. + type Timestamp: CheckedSub; + + /// The starting point timestamp provider. + type StartingPoint: Get>; + + /// The current timestamp provider. + type Now: Get; +} + +/// The error we return when the starting point is [`None`]. +pub const STARTING_POINT_NOT_DEFINED_ERROR: DispatchError = DispatchError::Other( + "vesting scheduling driver is not ready: vesting starting point not defined", +); +/// The error we return when the time now is before the starting point. +pub const TIME_NOW_BEFORE_THE_STARTING_POINT_ERROR: DispatchError = DispatchError::Other( + "vesting scheduling driver is not ready: time now is before the vesting starting point", +); +/// The error we return when there is an overflow in the calculations somewhere. +pub const OVERFLOW_ERROR: DispatchError = + DispatchError::Arithmetic(frame_support::sp_runtime::ArithmeticError::Overflow); +/// The error we return when there is a division by zero in the calculations somewhere. +pub const DIVISION_BY_ZERO_ERROR: DispatchError = + DispatchError::Arithmetic(frame_support::sp_runtime::ArithmeticError::DivisionByZero); + +/// Convert the `FracScaleError` to our error types. +fn convert_frac_scale_error(err: FracScaleError) -> DispatchError { + match err { + FracScaleError::Overflow | FracScaleError::Conversion => OVERFLOW_ERROR, + FracScaleError::DivisionByZero => DIVISION_BY_ZERO_ERROR, + } +} + +impl Adapter { + /// How much time has passed since the starting point. + fn compute_duration_since_starting_point() -> Result { + let starting_point = T::StartingPoint::get().ok_or(STARTING_POINT_NOT_DEFINED_ERROR)?; + T::Now::get() + .checked_sub(&starting_point) + .ok_or(TIME_NOW_BEFORE_THE_STARTING_POINT_ERROR) + } +} + +/// The config for linear timestamp scheduling. +pub trait LinearScheduleConfig: Config { + /// The fractional scaler. + /// Responsible for precision of the fractional scaling operation and rounding. + type FracScale: FracScale; +} + +impl pallet_vesting::traits::SchedulingDriver + for Adapter> +where + T::Balance: Unsigned + Copy, + T::Timestamp: Unsigned + Copy + PartialOrd, +{ + type Balance = T::Balance; + type Schedule = LinearSchedule; + + fn compute_balance_under_lock( + schedule: &Self::Schedule, + ) -> Result { + let duration_since_starting_point = Self::compute_duration_since_starting_point()?; + let balance_under_lock = schedule + .compute_locked_balance::(duration_since_starting_point) + .map_err(convert_frac_scale_error)?; + Ok(balance_under_lock) + } +} + +/// The config for multi-linear timestamp scheduling. +pub trait MultiLinearScheduleConfig: LinearScheduleConfig { + /// The max amount of schedules per account. + type MaxSchedulesPerAccount: Get; +} + +/// The multi-linear-schedule type representation. +pub type MultiLinearSchedule = + BoundedVec, MaxSchedulesPerAccount>; + +/// The multi-linear-schedule type from a given config. +pub type MultiLinearScheduleOf = MultiLinearSchedule< + ::Balance, + ::Timestamp, + ::MaxSchedulesPerAccount, +>; + +impl pallet_vesting::traits::SchedulingDriver + for Adapter> +where + T::Balance: Unsigned + Copy + Zero + CheckedAdd, + T::Timestamp: Unsigned + Copy + PartialOrd, +{ + type Balance = T::Balance; + type Schedule = MultiLinearScheduleOf; + + fn compute_balance_under_lock( + schedule: &Self::Schedule, + ) -> Result { + let duration_since_starting_point = Self::compute_duration_since_starting_point()?; + let balance = schedule + .iter() + .try_fold(Zero::zero(), |acc: Self::Balance, schedule| { + let balance = schedule + .compute_locked_balance::(duration_since_starting_point) + .map_err(convert_frac_scale_error)?; + acc.checked_add(&balance).ok_or(OVERFLOW_ERROR) + })?; + Ok(balance) + } +} diff --git a/crates/vesting-scheduling-timestamp/src/mock.rs b/crates/vesting-scheduling-timestamp/src/mock.rs new file mode 100644 index 000000000..f1462bc28 --- /dev/null +++ b/crates/vesting-scheduling-timestamp/src/mock.rs @@ -0,0 +1,56 @@ +use frame_support::traits::ConstU32; +use mockall::mock; +use vesting_schedule_linear::traits::SimpleFracScaler; + +use super::*; + +pub type Driver = Adapter>; + +pub enum Test {} + +impl Config for Test { + type Balance = u8; + type Timestamp = u8; + type StartingPoint = MockStartingPoint; + type Now = MockNow; +} + +impl LinearScheduleConfig for Test { + type FracScale = SimpleFracScaler::Balance, ::Timestamp>; +} + +impl MultiLinearScheduleConfig for Test { + type MaxSchedulesPerAccount = ConstU32<5>; +} + +mock! { + pub StartingPoint {} + impl Get> for StartingPoint { + fn get() -> Option; + } +} + +mock! { + pub Now {} + impl Get for Now { + fn get() -> u8; + } +} + +fn mocks_lock() -> std::sync::MutexGuard<'static, ()> { + static MOCK_RUNTIME_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + // Ignore the poisoning for the tests that panic. + // We only care about concurrency here, not about the poisoning. + match MOCK_RUNTIME_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +pub fn with_mocks_lock(f: impl FnOnce() -> R) -> R { + let lock = mocks_lock(); + let res = f(); + drop(lock); + res +} diff --git a/crates/vesting-scheduling-timestamp/src/tests.rs b/crates/vesting-scheduling-timestamp/src/tests.rs new file mode 100644 index 000000000..ceaf192f5 --- /dev/null +++ b/crates/vesting-scheduling-timestamp/src/tests.rs @@ -0,0 +1,211 @@ +//! Tests. + +use pallet_vesting::traits::SchedulingDriver; + +use super::*; +use crate::mock::*; + +#[test] +fn multi_linear_parsing() { + let tests = [ + (r#"[]"#, vec![]), + ( + r#"[{"balance":10,"cliff":10,"vesting":10}]"#, + vec![LinearSchedule { + balance: 10, + cliff: 10, + vesting: 10, + }], + ), + ( + r#"[{"balance":20,"cliff":30,"vesting":40},{"balance":50,"cliff":60,"vesting":70}]"#, + vec![ + LinearSchedule { + balance: 20, + cliff: 30, + vesting: 40, + }, + LinearSchedule { + balance: 50, + cliff: 60, + vesting: 70, + }, + ], + ), + ]; + + for (input, expected) in tests { + let expected: MultiLinearScheduleOf = expected.try_into().unwrap(); + let actual: MultiLinearScheduleOf = serde_json::from_str(input).unwrap(); + assert_eq!(actual, expected); + } +} + +#[test] +fn multi_linear_parsing_no_unknown() { + let input = r#"[{"balance":10,"cliff":10,"vesting":10,"unknown_field":123}]"#; + let err = serde_json::from_str::>(input).unwrap_err(); + assert_eq!( + err.to_string(), + "unknown field `unknown_field`, expected one of \ + `balance`, `cliff`, `vesting` at line 1 column 54" + ) +} + +#[test] +fn multi_linear_parsing_too_many_schedules() { + let input = r#"[ + {"balance":1,"cliff":10,"vesting":10}, + {"balance":2,"cliff":10,"vesting":10}, + {"balance":3,"cliff":10,"vesting":10}, + {"balance":4,"cliff":10,"vesting":10}, + {"balance":5,"cliff":10,"vesting":10}, + {"balance":6,"cliff":10,"vesting":10} + ]"#; + let err = serde_json::from_str::>(input).unwrap_err(); + assert_eq!(err.to_string(), "out of bounds at line 8 column 5") +} + +fn multi_linear_schedule( + schedule: impl IntoIterator, +) -> MultiLinearScheduleOf { + let vec: Vec<_> = schedule + .into_iter() + .map(|(balance, cliff, vesting)| LinearSchedule { + balance, + cliff, + vesting, + }) + .collect(); + vec.try_into().unwrap() +} + +fn compute_result( + schedule: &MultiLinearScheduleOf, + starting_point: ::Timestamp, + now: ::Timestamp, +) -> Result<::Balance, DispatchError> { + with_mocks_lock(|| { + let starting_point_context = MockStartingPoint::get_context(); + let now_context = MockNow::get_context(); + + starting_point_context + .expect() + .once() + .return_const(Some(starting_point)); + now_context.expect().once().return_const(now); + + let res = Driver::compute_balance_under_lock(schedule); + + starting_point_context.checkpoint(); + now_context.checkpoint(); + + res + }) +} + +fn compute( + schedule: &MultiLinearScheduleOf, + starting_point: ::Timestamp, + now: ::Timestamp, +) -> ::Balance { + compute_result(schedule, starting_point, now).unwrap() +} + +#[test] +fn multi_linear_logic() { + let schedule = multi_linear_schedule([(3, 0, 0), (10, 10, 10), (100, 20, 10)]); + + assert_eq!(compute(&schedule, 20, 20), 110); + assert_eq!(compute(&schedule, 20, 21), 110); + assert_eq!(compute(&schedule, 20, 22), 110); + assert_eq!(compute(&schedule, 20, 29), 110); + assert_eq!(compute(&schedule, 20, 30), 110); + assert_eq!(compute(&schedule, 20, 31), 109); + assert_eq!(compute(&schedule, 20, 32), 108); + assert_eq!(compute(&schedule, 20, 38), 102); + assert_eq!(compute(&schedule, 20, 39), 101); + assert_eq!(compute(&schedule, 20, 40), 100); + assert_eq!(compute(&schedule, 20, 41), 90); + assert_eq!(compute(&schedule, 20, 42), 80); + assert_eq!(compute(&schedule, 20, 43), 70); + assert_eq!(compute(&schedule, 20, 48), 20); + assert_eq!(compute(&schedule, 20, 49), 10); + assert_eq!(compute(&schedule, 20, 50), 0); + assert_eq!(compute(&schedule, 20, 51), 0); + assert_eq!(compute(&schedule, 20, 52), 0); + assert_eq!(compute(&schedule, 20, 0xff), 0); +} + +#[test] +fn multi_linear_returns_time_now_before_the_starting_point_error() { + let schedule = multi_linear_schedule([(3, 0, 0), (10, 10, 10), (100, 20, 10)]); + + assert_eq!( + compute_result(&schedule, 20, 10), + Err(TIME_NOW_BEFORE_THE_STARTING_POINT_ERROR) + ); +} + +#[test] +fn multi_linear_returns_starting_point_not_defined_error_error() { + let schedule = multi_linear_schedule([(3, 0, 0), (10, 10, 10), (100, 20, 10)]); + + with_mocks_lock(|| { + let starting_point_context = MockStartingPoint::get_context(); + let now_context = MockNow::get_context(); + + starting_point_context.expect().once().return_const(None); + now_context.expect().never(); + + let res = Driver::compute_balance_under_lock(&schedule); + + starting_point_context.checkpoint(); + now_context.checkpoint(); + + assert_eq!(res, Err(STARTING_POINT_NOT_DEFINED_ERROR)); + }); +} + +#[test] +fn multi_linear_starting_point_check() { + let schedule = multi_linear_schedule([(3, 0, 0), (20, 20, 20), (200, 40, 20)]); + + let assert_all = + |schedule: &MultiLinearScheduleOf, duration_since_start: u8, value: u8| { + for starting_point in [0, 10, 20, 0xff] { + let now = match starting_point.checked_add(&duration_since_start) { + None => continue, + Some(val) => val, + }; + + assert_eq!( + compute(schedule, starting_point, now), + value, + "{} {}", + duration_since_start, + value + ); + } + }; + + assert_all(&schedule, 0, 220); + assert_all(&schedule, 1, 220); + assert_all(&schedule, 2, 220); + assert_all(&schedule, 19, 220); + assert_all(&schedule, 20, 220); + assert_all(&schedule, 21, 219); + assert_all(&schedule, 22, 218); + assert_all(&schedule, 38, 202); + assert_all(&schedule, 39, 201); + assert_all(&schedule, 40, 200); + assert_all(&schedule, 41, 190); + assert_all(&schedule, 42, 180); + assert_all(&schedule, 43, 170); + assert_all(&schedule, 58, 20); + assert_all(&schedule, 59, 10); + assert_all(&schedule, 60, 0); + assert_all(&schedule, 61, 0); + assert_all(&schedule, 62, 0); + assert_all(&schedule, 0xff, 0); +}