diff --git a/Cargo.lock b/Cargo.lock index a8732adf0..e281c7a1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ dependencies = [ "pallet-timestamp", "pallet-transaction-payment", "pallet-transaction-payment-rpc-runtime-api", + "pallet-vesting", "parity-scale-codec", "precompile-bioauth", "precompile-evm-accounts-mapping", @@ -5689,6 +5690,21 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-vesting" +version = "0.1.0" +dependencies = [ + "frame-support", + "frame-system", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "serde", + "sp-arithmetic", + "sp-runtime", + "sp-std", +] + [[package]] name = "parity-db" version = "0.3.13" diff --git a/crates/humanode-runtime/Cargo.toml b/crates/humanode-runtime/Cargo.toml index 6ea817eff..adba0d04c 100644 --- a/crates/humanode-runtime/Cargo.toml +++ b/crates/humanode-runtime/Cargo.toml @@ -20,6 +20,7 @@ pallet-ethereum-chain-id = { version = "0.1", path = "../pallet-ethereum-chain-i pallet-evm-accounts-mapping = { version = "0.1", path = "../pallet-evm-accounts-mapping", default-features = false } pallet-humanode-session = { version = "0.1", path = "../pallet-humanode-session", default-features = false } pallet-pot = { version = "0.1", path = "../pallet-pot", default-features = false } +pallet-vesting = { version = "0.1", path = "../pallet-vesting", default-features = false } precompile-bioauth = { version = "0.1", path = "../precompile-bioauth", default-features = false } precompile-evm-accounts-mapping = { version = "0.1", path = "../precompile-evm-accounts-mapping", default-features = false } primitives-auth-ticket = { version = "0.1", path = "../primitives-auth-ticket", default-features = false } @@ -134,6 +135,7 @@ std = [ "pallet-timestamp/std", "pallet-transaction-payment/std", "pallet-transaction-payment-rpc-runtime-api/std", + "pallet-vesting/std", "robonode-crypto/std", "sp-application-crypto/std", "sp-api/std", @@ -171,4 +173,5 @@ try-runtime = [ "pallet-sudo/try-runtime", "pallet-timestamp/try-runtime", "pallet-transaction-payment/try-runtime", + "pallet-vesting/try-runtime", ] diff --git a/crates/pallet-vesting/Cargo.toml b/crates/pallet-vesting/Cargo.toml new file mode 100644 index 000000000..e98535cd8 --- /dev/null +++ b/crates/pallet-vesting/Cargo.toml @@ -0,0 +1,31 @@ +[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-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" } +pallet-timestamp = { 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", features = ["derive"], optional = true } +sp-arithmetic = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +sp-runtime = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +sp-std = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "frame-system/std", + "pallet-timestamp/std", + "scale-info/std", + "serde", + "sp-arithmetic/std", + "sp-runtime/std", + "sp-std/std", +] +try-runtime = ["frame-support/try-runtime"] diff --git a/crates/pallet-vesting/src/lib.rs b/crates/pallet-vesting/src/lib.rs new file mode 100644 index 000000000..e11d24746 --- /dev/null +++ b/crates/pallet-vesting/src/lib.rs @@ -0,0 +1,74 @@ +//! The vesting pallet. + +#![cfg_attr(not(feature = "std"), no_std)] + +mod moment; +mod vesting_driver; +mod vesting_driver_timestamp; +mod vesting_schedule; + +use codec::MaxEncodedLen; +use frame_support::{ + pallet_prelude::*, + storage::bounded_vec::BoundedVec, + traits::{Currency, LockIdentifier, LockableCurrency, StorageVersion, WithdrawReasons}, +}; +pub use pallet::*; +use sp_runtime::traits::{AtLeast32BitUnsigned, MaybeSerializeDeserialize, Zero}; +use vesting_schedule::VestingSchedule; + +/// Balance type alias. +type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +/// The current storage version. +const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + +/// Provides the capability to get current moment. +pub trait CurrentMoment { + /// Return current moment. + fn now() -> Moment; +} + +// 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 super::*; + + /// Configure the pallet by specifying the parameters and types on which it depends. + #[pallet::config] + pub trait Config: frame_system::Config { + /// The currency to operate with. + type Currency: LockableCurrency; + + /// Type used for expressing moment. + type Moment: Parameter + + Default + + AtLeast32BitUnsigned + + Copy + + MaybeSerializeDeserialize + + MaxEncodedLen; + + /// The vesting schedule type to operate with. + type VestingSchedule: VestingSchedule< + Self::AccountId, + Moment = Self::Moment, + Currency = Self::Currency, + >; + + /// The vesting schedule value itself. + type VestinScheduleValue: Get; + + /// Maximum number of vesting schedules an account may have at a given moment. + type MaxVestingSchedules: Get; + + /// An lock identifier for a lockable currency to be used in vesting. + type VestingLockId: Get; + } + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); +} diff --git a/crates/pallet-vesting/src/moment.rs b/crates/pallet-vesting/src/moment.rs new file mode 100644 index 000000000..ec13e6bbf --- /dev/null +++ b/crates/pallet-vesting/src/moment.rs @@ -0,0 +1,33 @@ +//! The current moment logic. + +use sp_std::marker::PhantomData; + +pub trait CurrentMoment { + fn now() -> Moment; +} + +pub type UnixMilliseconds = u64; + +pub struct TimestampMoment(PhantomData); + +impl CurrentMoment for TimestampMoment +where + R: pallet_timestamp::Config, +{ + fn now() -> UnixMilliseconds { + pallet_timestamp::Pallet::::now() + } +} + +pub type BlockNumber = u32; + +pub struct BlockNumberMoment(PhantomData); + +impl CurrentMoment for BlockNumberMoment +where + R: frame_system::Config, +{ + fn now() -> BlockNumber { + frame_system::Pallet::::block_number() + } +} diff --git a/crates/pallet-vesting/src/vesting_driver.rs b/crates/pallet-vesting/src/vesting_driver.rs new file mode 100644 index 000000000..fc9e1f515 --- /dev/null +++ b/crates/pallet-vesting/src/vesting_driver.rs @@ -0,0 +1,27 @@ +//! Vesting driver logic. + +use frame_support::traits::Currency as CurrencyT; + +use super::*; +use crate::{moment::CurrentMoment, vesting_schedule::VestingSchedule as VestingScheduleT}; + +/// [`VestingDriver`] logic. +pub trait VestingDriver> { + type CurrentMoment: CurrentMoment<>::Moment>; + /// Get the amount that is currently being vested and cannot be transferred out of this account. + fn vesting( + who: &AccountId, + vesting_info: &VestingInfo, + ) -> <>::Currency as CurrencyT>::Balance; +} + +pub struct VestingInfo, MaxSchedules> { + /// Locked amount at genesis. + pub locked: <>::Currency as CurrencyT< + AccountId, + >>::Balance, + /// Starting moment for unlocking(vesting). + pub start: >::Moment, + /// Vesting schedules. + pub schedules: BoundedVec, +} diff --git a/crates/pallet-vesting/src/vesting_driver_timestamp.rs b/crates/pallet-vesting/src/vesting_driver_timestamp.rs new file mode 100644 index 000000000..da58fde75 --- /dev/null +++ b/crates/pallet-vesting/src/vesting_driver_timestamp.rs @@ -0,0 +1,41 @@ +use frame_support::traits::Currency as CurrencyT; +use sp_runtime::traits::Saturating; +use sp_std::marker::PhantomData; + +use super::*; +use crate::{ + moment::{CurrentMoment, TimestampMoment, UnixMilliseconds}, + vesting_driver::{VestingDriver, VestingInfo}, + vesting_schedule::VestingSchedule as VestingScheduleT, +}; + +pub struct Driver(PhantomData); + +impl + VestingDriver, VestingSchedule> + for Driver +where + R: pallet_timestamp::Config, + VestingSchedule: VestingScheduleT, +{ + type CurrentMoment = TimestampMoment; + + fn vesting( + who: &AccountId, + vesting_info: &VestingInfo, + ) -> <>::Currency as CurrencyT>::Balance + { + let now = TimestampMoment::::now(); + let total_locked_now = + vesting_info + .schedules + .iter() + .fold(Zero::zero(), |total, schedule| { + schedule + .locked_at(vesting_info.locked, vesting_info.start, now) + .saturating_add(total) + }); + >::Currency::free_balance(who) + .min(total_locked_now) + } +} diff --git a/crates/pallet-vesting/src/vesting_schedule.rs b/crates/pallet-vesting/src/vesting_schedule.rs new file mode 100644 index 000000000..62ff73d63 --- /dev/null +++ b/crates/pallet-vesting/src/vesting_schedule.rs @@ -0,0 +1,137 @@ +//! Vesting schedule logic. + +use frame_support::traits::Currency as CurrencyT; +use sp_arithmetic::traits::{ + AtLeast32BitUnsigned, CheckedDiv, CheckedMul, Saturating, UniqueSaturatedFrom, + UniqueSaturatedInto, Zero, +}; + +/// [`VestingSchedule`] defines logic for currency vesting(unlocking). +pub trait VestingSchedule { + /// The type used to denote time: Timestamp, BlockNumber, etc. + type Moment; + /// The currency that this schedule applies to. + type Currency: CurrencyT; + /// An error that can occur at vesting schedule logic. + type Error; + /// Validate the schedule. + fn validate( + &self, + genesis_locked: >::Balance, + start: Self::Moment, + ) -> Result<(), Self::Error>; + /// Locked amount at provided moment. + fn locked_at( + &self, + genesis_locked: >::Balance, + start: Self::Moment, + moment: Self::Moment, + ) -> >::Balance; + /// Moment at which the schedule ends. + fn end( + &self, + genesis_locked: >::Balance, + start: Self::Moment, + ) -> Self::Moment; +} + +/// Implements linear vesting logic with cliff. +pub struct LinearWithCliff> { + /// Vesting cliff. + cliff: Moment, + /// Vesting period. + period: Moment, + /// Amount that should be unlocked per one vesting period. (!= 0) + per_period: Currency::Balance, +} + +/// An error that can occur at linear with cliff vesting schedule logic. +pub enum LinearWithCliffError { + /// We don't let `per_period` be less than 1, or else the vesting will never end. + ZeroPerPeriod, + /// Genesis locked shouldn't be zero. + ZeroGenesisLocked, +} + +impl VestingSchedule + for LinearWithCliff +where + Currency: CurrencyT, + Moment: AtLeast32BitUnsigned + Copy, +{ + type Moment = Moment; + + type Currency = Currency; + + type Error = LinearWithCliffError; + + fn validate( + &self, + genesis_locked: Currency::Balance, + _start: Self::Moment, + ) -> Result<(), Self::Error> { + if self.per_period == Zero::zero() { + return Err(LinearWithCliffError::ZeroPerPeriod); + } + if genesis_locked == Zero::zero() { + return Err(LinearWithCliffError::ZeroGenesisLocked); + } + Ok(()) + } + + fn locked_at( + &self, + genesis_locked: Currency::Balance, + start: Self::Moment, + moment: Self::Moment, + ) -> Currency::Balance { + let actual_start = start.saturating_add(self.cliff); + if actual_start > moment { + return genesis_locked; + } + + let actual_end = self.end(genesis_locked, start); + if actual_end < moment { + return Zero::zero(); + } + + let actual_vesting_time = moment.saturating_sub(actual_start); + let periods_number = >::unique_saturated_into( + actual_vesting_time + .checked_div(&self.period) + .unwrap_or_else(Zero::zero), + ); + let vested_balance = self + .per_period + .checked_mul( + &>::unique_saturated_from( + periods_number, + ), + ) + .unwrap_or_else(Zero::zero); + genesis_locked.saturating_sub(vested_balance) + } + + fn end(&self, genesis_locked: Currency::Balance, start: Self::Moment) -> Self::Moment { + let periods_number = >::unique_saturated_into( + genesis_locked + .checked_div(&self.per_period) + .unwrap_or_else(Zero::zero), + ) + if (genesis_locked % self.per_period).is_zero() { + 0 + } else { + // `per_period` does not perfectly divide `locked`, so we need an extra period to + // unlock some amount less than `per_period`. + 1 + }; + + let actual_start = start.saturating_add(self.cliff); + let actual_vesting_time = self + .period + .checked_mul( + &>::unique_saturated_from(periods_number), + ) + .unwrap_or_else(Zero::zero); + actual_start.saturating_add(actual_vesting_time) + } +}