From 201b46f175f5292c62bca88c60e652438e9c4a64 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 16 Dec 2025 11:58:03 +0800 Subject: [PATCH 1/5] feat: Custom Vesting pallet - first integration --- Cargo.lock | 8 +- Cargo.toml | 3 +- pallets/merkle-airdrop/src/benchmarking.rs | 6 +- pallets/merkle-airdrop/src/mock.rs | 25 +- pallets/vesting/Cargo.toml | 57 +++ pallets/vesting/src/benchmarking.rs | 123 +++++ pallets/vesting/src/lib.rs | 439 ++++++++++++++++++ pallets/vesting/src/mock.rs | 154 +++++++ pallets/vesting/src/tests.rs | 508 +++++++++++++++++++++ pallets/vesting/src/weights.rs | 48 ++ runtime/src/benchmarks.rs | 1 + runtime/src/configs/mod.rs | 16 +- 12 files changed, 1357 insertions(+), 31 deletions(-) create mode 100644 pallets/vesting/Cargo.toml create mode 100644 pallets/vesting/src/benchmarking.rs create mode 100644 pallets/vesting/src/lib.rs create mode 100644 pallets/vesting/src/mock.rs create mode 100644 pallets/vesting/src/tests.rs create mode 100644 pallets/vesting/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 8c992bea..891e307a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7572,16 +7572,18 @@ dependencies = [ [[package]] name = "pallet-vesting" -version = "41.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "305b437f4832bb563b660afa6549c0f0d446b668b4f098edc48d04e803badb9f" +version = "0.1.0" dependencies = [ "frame-benchmarking", "frame-support", "frame-system", "log", + "pallet-balances 40.0.1", + "pallet-timestamp", "parity-scale-codec", "scale-info", + "sp-core", + "sp-io", "sp-runtime", ] diff --git a/Cargo.toml b/Cargo.toml index f57e846a..bb0cf808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "pallets/qpow", "pallets/reversible-transfers", "pallets/scheduler", + "pallets/vesting", "pallets/wormhole", "primitives/consensus/pow", "primitives/consensus/qpow", @@ -133,6 +134,7 @@ pallet-mining-rewards = { path = "./pallets/mining-rewards", default-features = pallet-qpow = { path = "./pallets/qpow", default-features = false } pallet-reversible-transfers = { path = "./pallets/reversible-transfers", default-features = false } pallet-scheduler = { path = "./pallets/scheduler", default-features = false } +pallet-vesting = { path = "./pallets/vesting", default-features = false } pallet-wormhole = { path = "./pallets/wormhole", default-features = false } qp-dilithium-crypto = { path = "./primitives/dilithium-crypto", version = "0.2.0", default-features = false } qp-scheduler = { path = "./primitives/scheduler", default-features = false } @@ -182,7 +184,6 @@ pallet-transaction-payment-rpc = { version = "44.0.0", default-features = false pallet-transaction-payment-rpc-runtime-api = { version = "41.0.0", default-features = false } pallet-treasury = { version = "40.0.0", default-features = false } pallet-utility = { version = "41.0.0", default-features = false } -pallet-vesting = { version = "41.0.0", default-features = false } prometheus-endpoint = { version = "0.17.2", default-features = false, package = "substrate-prometheus-endpoint" } sc-basic-authorship = { version = "0.50.0", default-features = false } sc-block-builder = { version = "0.45.0", default-features = true } diff --git a/pallets/merkle-airdrop/src/benchmarking.rs b/pallets/merkle-airdrop/src/benchmarking.rs index d965bd8d..1ac5af49 100644 --- a/pallets/merkle-airdrop/src/benchmarking.rs +++ b/pallets/merkle-airdrop/src/benchmarking.rs @@ -36,7 +36,7 @@ fn calculate_expected_root_for_benchmark( #[benchmarks( where T: Send + Sync, - T: Config + pallet_vesting::Config>, + T: Config + pallet_vesting::Config, )] mod benchmarks { use super::*; @@ -71,7 +71,7 @@ mod benchmarks { NextAirdropId::::put(airdrop_id + 1); - let amount: BalanceOf = ::MinVestedTransfer::get(); + let amount: BalanceOf = 100u32.into(); // Get ED and ensure caller has sufficient balance let ed = CurrencyOf::::minimum_balance(); @@ -90,7 +90,7 @@ mod benchmarks { let caller: T::AccountId = whitelisted_caller(); let recipient: T::AccountId = account("recipient", 0, 0); - let amount: BalanceOf = ::MinVestedTransfer::get(); + let amount: BalanceOf = 100u32.into(); // 1. Calculate the initial leaf hash let leaf_hash = MerkleAirdrop::::calculate_leaf_hash_blake2(&recipient, amount); diff --git a/pallets/merkle-airdrop/src/mock.rs b/pallets/merkle-airdrop/src/mock.rs index 0a5c865c..3552a2d1 100644 --- a/pallets/merkle-airdrop/src/mock.rs +++ b/pallets/merkle-airdrop/src/mock.rs @@ -17,6 +17,7 @@ type Block = frame_system::mocking::MockBlock; frame_support::construct_runtime!( pub enum Test { System: frame_system, + Timestamp: pallet_timestamp, Vesting: pallet_vesting, Balances: pallet_balances, MerkleAirdrop: pallet_merkle_airdrop, @@ -83,21 +84,23 @@ impl pallet_balances::Config for Test { } parameter_types! { - pub const MinVestedTransfer: u64 = 1; - pub UnvestedFundsAllowedWithdrawReasons: WithdrawReasons = - WithdrawReasons::except(WithdrawReasons::TRANSFER | WithdrawReasons::RESERVE); + pub const MinimumPeriod: u64 = 1; } -impl pallet_vesting::Config for Test { - type RuntimeEvent = RuntimeEvent; - type Currency = Balances; +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; type WeightInfo = (); - type BlockNumberProvider = System; - type MinVestedTransfer = MinVestedTransfer; - type BlockNumberToBalance = ConvertInto; - type UnvestedFundsAllowedWithdrawReasons = UnvestedFundsAllowedWithdrawReasons; +} - const MAX_VESTING_SCHEDULES: u32 = 3; +parameter_types! { + pub const VestingPalletId: PalletId = PalletId(*b"vestpal_"); +} + +impl pallet_vesting::Config for Test { + type PalletId = VestingPalletId; + type WeightInfo = (); } parameter_types! { diff --git a/pallets/vesting/Cargo.toml b/pallets/vesting/Cargo.toml new file mode 100644 index 00000000..0ee6075f --- /dev/null +++ b/pallets/vesting/Cargo.toml @@ -0,0 +1,57 @@ +[package] +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "pallet-vesting" +repository.workspace = true +version = "0.1.0" + +[package.metadata.docs.rs] +targets = [ + "aarch64-apple-darwin", + "wasm32-unknown-unknown", + "x86_64-unknown-linux-gnu", +] + +[dependencies] +codec = { workspace = true, default-features = false, features = ["derive"] } +frame-benchmarking = { optional = true, workspace = true, default-features = false } +frame-support = { workspace = true, default-features = false } +frame-system = { workspace = true, default-features = false } +log = { workspace = true, default-features = false } +pallet-balances = { workspace = true, default-features = false } +pallet-timestamp = { workspace = true, default-features = false } +scale-info = { workspace = true, default-features = false, features = ["derive"] } +sp-runtime = { workspace = true, default-features = false } + +[dev-dependencies] +sp-core = { workspace = true } +sp-io = { workspace = true } + +[features] +default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "pallet-timestamp/runtime-benchmarks", +] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "log/std", + "pallet-balances/std", + "pallet-timestamp/std", + "scale-info/std", + "sp-runtime/std", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-balances/try-runtime", + "pallet-timestamp/try-runtime", +] diff --git a/pallets/vesting/src/benchmarking.rs b/pallets/vesting/src/benchmarking.rs new file mode 100644 index 00000000..c4c75ced --- /dev/null +++ b/pallets/vesting/src/benchmarking.rs @@ -0,0 +1,123 @@ +//! Benchmarking setup for pallet-vesting + +use super::*; +use crate::Pallet as Vesting; +use frame_benchmarking::{account as benchmark_account, v2::*}; +use frame_support::traits::fungible::Mutate; +use frame_system::RawOrigin; +use sp_runtime::traits::Zero; + +const SEED: u32 = 0; + +// Helper to fund an account +fn fund_account(account: &T::AccountId, amount: T::Balance) +where + T: Config + pallet_balances::Config, +{ + let _ = as Mutate>::mint_into(account, amount); +} + +#[benchmarks( + where + T: pallet_balances::Config, + T::Balance: From, + T::Moment: From, +)] +mod benchmarks { + use super::*; + + #[benchmark] + fn create_vesting_schedule() { + let caller: T::AccountId = benchmark_account("caller", 0, SEED); + let beneficiary: T::AccountId = benchmark_account("beneficiary", 0, SEED); + + // Fund the caller + let amount: T::Balance = 1_000_000_000_000u128.into(); + fund_account::(&caller, amount * 2u128.into()); + + let start: T::Moment = 1000u64.into(); + let end: T::Moment = 2000u64.into(); + + #[extrinsic_call] + create_vesting_schedule( + RawOrigin::Signed(caller.clone()), + beneficiary.clone(), + amount, + start, + end, + ); + + // Verify schedule was created + assert_eq!(ScheduleCounter::::get(), 1); + assert!(VestingSchedules::::get(1).is_some()); + } + + #[benchmark] + fn claim() { + let creator: T::AccountId = benchmark_account("creator", 0, SEED); + let beneficiary: T::AccountId = benchmark_account("beneficiary", 0, SEED); + + // Fund the creator + let amount: T::Balance = 1_000_000_000_000u128.into(); + fund_account::(&creator, amount * 2u128.into()); + + // Create a vesting schedule + let start: T::Moment = 1000u64.into(); + let end: T::Moment = 100000u64.into(); + + let _ = Vesting::::create_vesting_schedule( + RawOrigin::Signed(creator.clone()).into(), + beneficiary.clone(), + amount, + start, + end, + ); + + let schedule_id = 1u64; + + // Set timestamp to middle of vesting period so some tokens are vested + pallet_timestamp::Pallet::::set_timestamp(50000u64.into()); + + #[extrinsic_call] + claim(RawOrigin::None, schedule_id); + + // Verify tokens were claimed + let schedule = VestingSchedules::::get(schedule_id).unwrap(); + assert!(schedule.claimed > T::Balance::zero()); + } + + #[benchmark] + fn cancel_vesting_schedule() { + let creator: T::AccountId = benchmark_account("creator", 0, SEED); + let beneficiary: T::AccountId = benchmark_account("beneficiary", 0, SEED); + + // Fund the creator + let amount: T::Balance = 1_000_000_000_000u128.into(); + fund_account::(&creator, amount * 2u128.into()); + + // Create a vesting schedule + let start: T::Moment = 1000u64.into(); + let end: T::Moment = 100000u64.into(); + + let _ = Vesting::::create_vesting_schedule( + RawOrigin::Signed(creator.clone()).into(), + beneficiary.clone(), + amount, + start, + end, + ); + + let schedule_id = 1u64; + + // Set timestamp to middle of vesting period + pallet_timestamp::Pallet::::set_timestamp(50000u64.into()); + + #[extrinsic_call] + cancel_vesting_schedule(RawOrigin::Signed(creator.clone()), schedule_id); + + // Verify schedule was removed + assert!(VestingSchedules::::get(schedule_id).is_none()); + } + + impl_benchmark_test_suite!(Vesting, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs new file mode 100644 index 00000000..2c6877c6 --- /dev/null +++ b/pallets/vesting/src/lib.rs @@ -0,0 +1,439 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +pub mod weights; +pub use weights::*; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use codec::Decode; + use core::convert::TryInto; + use frame_support::{ + pallet_prelude::*, + parameter_types, + traits::{ + Currency, + ExistenceRequirement::{AllowDeath, KeepAlive}, + Get, + }, + PalletId, + }; + use frame_system::pallet_prelude::*; + use sp_runtime::{ + traits::{AccountIdConversion, Saturating}, + ArithmeticError, + }; + + #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] + pub struct VestingSchedule { + pub id: u64, // Unique id + pub creator: AccountId, // Who created the scehdule + pub beneficiary: AccountId, // Who gets the tokens + pub amount: Balance, // Total tokens to vest + pub start: Moment, // When vesting begins + pub end: Moment, // When vesting fully unlocks + pub claimed: Balance, // Tokens already claimed + } + + #[pallet::storage] + pub type VestingSchedules = StorageMap< + _, + Blake2_128Concat, + u64, // Key: schedule_id + VestingSchedule, + OptionQuery, + >; + + #[pallet::storage] + pub type ScheduleCounter = StorageValue<_, u64, ValueQuery>; + + #[pallet::config] + pub trait Config: + frame_system::Config>> + + pallet_balances::Config + + pallet_timestamp::Config + { + type PalletId: Get; + type WeightInfo: WeightInfo; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + VestingScheduleCreated(T::AccountId, T::Balance, T::Moment, T::Moment, u64), + TokensClaimed(T::AccountId, T::Balance, u64), + VestingScheduleCancelled(T::AccountId, u64), // Creator, Schedule ID + } + + #[pallet::error] + pub enum Error { + NoVestingSchedule, // No schedule exists for the caller + InvalidSchedule, // Start block >= end block + TooManySchedules, // Exceeded maximum number of schedules + NotCreator, // Caller isn’t the creator + ScheduleNotFound, // No schedule with that ID + } + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::call] + impl Pallet { + // Create a vesting schedule + #[pallet::call_index(0)] + #[pallet::weight(::WeightInfo::create_vesting_schedule())] + pub fn create_vesting_schedule( + origin: OriginFor, + beneficiary: T::AccountId, + amount: T::Balance, + start: T::Moment, + end: T::Moment, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(start < end, Error::::InvalidSchedule); + ensure!(amount > T::Balance::zero(), Error::::InvalidSchedule); + + // Transfer tokens from caller to pallet and lock them + pallet_balances::Pallet::::transfer(&who, &Self::account_id(), amount, KeepAlive)?; + + // Generate unique ID + let schedule_id = ScheduleCounter::::get().wrapping_add(1); + ScheduleCounter::::put(schedule_id); + + // Add the schedule to storage + let schedule = VestingSchedule { + creator: who, + beneficiary: beneficiary.clone(), + amount, + start, + end, + claimed: T::Balance::zero(), + id: schedule_id, + }; + VestingSchedules::::insert(schedule_id, schedule); + + Self::deposit_event(Event::VestingScheduleCreated( + beneficiary, + amount, + start, + end, + schedule_id, + )); + Ok(()) + } + + // Claim vested tokens + #[pallet::call_index(1)] + #[pallet::weight(::WeightInfo::claim())] + pub fn claim(_origin: OriginFor, schedule_id: u64) -> DispatchResult { + let mut schedule = + VestingSchedules::::get(schedule_id).ok_or(Error::::NoVestingSchedule)?; + let vested = Self::vested_amount(&schedule)?; + let claimable = vested.saturating_sub(schedule.claimed); + + if claimable > T::Balance::zero() { + schedule.claimed += claimable; + VestingSchedules::::insert(schedule_id, &schedule); + + // Transfer claimable tokens + pallet_balances::Pallet::::transfer( + &Self::account_id(), + &schedule.beneficiary, + claimable, + AllowDeath, + )?; + + Self::deposit_event(Event::TokensClaimed( + schedule.beneficiary, + claimable, + schedule_id, + )); + } + + Ok(()) + } + + #[pallet::call_index(2)] + #[pallet::weight(::WeightInfo::cancel_vesting_schedule())] + pub fn cancel_vesting_schedule(origin: OriginFor, schedule_id: u64) -> DispatchResult { + let who = ensure_signed(origin.clone())?; + + // Claim for beneficiary whatever they are currently owed + Self::claim(origin, schedule_id)?; + + let schedule = + VestingSchedules::::get(schedule_id).ok_or(Error::::ScheduleNotFound)?; + ensure!(schedule.creator == who, Error::::NotCreator); + + // Refund unclaimed amount to the creator + let unclaimed = schedule.amount.saturating_sub(schedule.claimed); + if unclaimed > T::Balance::zero() { + pallet_balances::Pallet::::transfer( + &Self::account_id(), + &who, + unclaimed, + AllowDeath, + )?; + } + + VestingSchedules::::remove(schedule_id); + + // Emit event + Self::deposit_event(Event::VestingScheduleCancelled(who, schedule_id)); + Ok(()) + } + } + + impl Pallet { + // Helper to calculate vested amount + pub fn vested_amount( + schedule: &VestingSchedule, + ) -> Result { + let now = >::get(); + // No need to convert now/start/end to u64 explicitly if T::Moment is u64-like + if now < schedule.start { + Ok(T::Balance::zero()) + } else if now >= schedule.end { + Ok(schedule.amount) + } else { + let elapsed = now.saturating_sub(schedule.start); + let duration = schedule.end.saturating_sub(schedule.start); + + // Convert amount to u64 for intermediate calculation + let amount_u64: u64 = schedule + .amount + .try_into() + .map_err(|_| DispatchError::Other("Balance to u64 conversion failed"))?; + + // Perform calculation in u64 (T::Moment-like) + let elapsed_u64: u64 = elapsed + .try_into() + .map_err(|_| DispatchError::Other("Moment to u64 conversion failed"))?; + let duration_u64: u64 = duration + .try_into() + .map_err(|_| DispatchError::Other("Moment to u64 conversion failed"))?; + let duration_safe: u64 = duration_u64.max(1); + + let vested_u64: u64 = amount_u64 + .saturating_mul(elapsed_u64) + .checked_div(duration_safe) + .ok_or(DispatchError::Arithmetic(ArithmeticError::Underflow))?; + + // Convert back to T::Balance + let vested = T::Balance::try_from(vested_u64) + .map_err(|_| DispatchError::Other("u64 to Balance conversion failed"))?; + + Ok(vested) + } + } + + // Pallet account to "hold" tokens + pub fn account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + } + + parameter_types! { + pub const VestingPalletId: PalletId = PalletId(*b"vestingp"); + } + + // Implement VestedTransfer trait for compatibility with merkle-airdrop + use frame_support::traits::VestedTransfer; + use frame_system::pallet_prelude::BlockNumberFor; + + impl VestedTransfer for Pallet + where + T::Balance: From> + TryInto, + T::Moment: From, + { + type Currency = pallet_balances::Pallet; + type Moment = BlockNumberFor; + + fn vested_transfer( + source: &T::AccountId, + dest: &T::AccountId, + amount: T::Balance, + per_block: T::Balance, + starting_block: BlockNumberFor, + ) -> DispatchResult { + // Convert block number to timestamp (milliseconds) + // Assuming 12 second blocks: block_number * 12000ms + const BLOCK_TIME_MS: u64 = 12000; + + let start_block: u64 = starting_block + .try_into() + .map_err(|_| DispatchError::Other("Block number conversion failed"))?; + let per_block_u64: u64 = per_block + .try_into() + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + let locked: u64 = amount + .try_into() + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + + let start_ms = start_block.saturating_mul(BLOCK_TIME_MS); + + // Calculate duration: total_amount / per_block = number of blocks + let duration_blocks = if per_block_u64 > 0 { + locked.saturating_div(per_block_u64) + } else { + return Err(Error::::InvalidSchedule.into()); + }; + let duration_ms = duration_blocks.saturating_mul(BLOCK_TIME_MS); + let end_ms = start_ms.saturating_add(duration_ms); + + // Transfer from source to pallet account + pallet_balances::Pallet::::transfer( + source, + &Self::account_id(), + amount, + frame_support::traits::ExistenceRequirement::KeepAlive, + )?; + + // Generate unique ID + let schedule_id = ScheduleCounter::::get().wrapping_add(1); + ScheduleCounter::::put(schedule_id); + + // Create vesting schedule + let vesting_schedule = VestingSchedule { + creator: source.clone(), + beneficiary: dest.clone(), + amount, + start: T::Moment::from(start_ms), + end: T::Moment::from(end_ms), + claimed: T::Balance::zero(), + id: schedule_id, + }; + VestingSchedules::::insert(schedule_id, vesting_schedule); + + Self::deposit_event(Event::VestingScheduleCreated( + dest.clone(), + amount, + T::Moment::from(start_ms), + T::Moment::from(end_ms), + schedule_id, + )); + + Ok(()) + } + } + + // Implement VestingSchedule trait for compatibility with merkle-airdrop + use frame_support::traits::VestingSchedule as VestingScheduleTrait; + + impl VestingScheduleTrait for Pallet + where + T::Balance: From> + TryInto, + T::Moment: From, + { + type Currency = pallet_balances::Pallet; + type Moment = BlockNumberFor; + + fn vesting_balance( + who: &T::AccountId, + ) -> Option<>::Balance> { + // Sum up all pending vested amounts for this account + let mut total_vesting = T::Balance::zero(); + + // Iterate through all schedules (this is not efficient but works) + let counter = ScheduleCounter::::get(); + for schedule_id in 1..=counter { + if let Some(schedule) = VestingSchedules::::get(schedule_id) { + if schedule.beneficiary == *who { + let remaining = schedule.amount.saturating_sub(schedule.claimed); + total_vesting = total_vesting.saturating_add(remaining); + } + } + } + + if total_vesting > T::Balance::zero() { + Some(total_vesting) + } else { + None + } + } + + fn add_vesting_schedule( + who: &T::AccountId, + locked: >::Balance, + per_block: >::Balance, + starting_block: BlockNumberFor, + ) -> DispatchResult { + // Convert block number to timestamp (milliseconds) + const BLOCK_TIME_MS: u64 = 12000; + + let start_block: u64 = starting_block + .try_into() + .map_err(|_| DispatchError::Other("Block number conversion failed"))?; + let per_block_u64: u64 = per_block + .try_into() + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + let locked_u64: u64 = locked + .try_into() + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + + let start_ms = start_block.saturating_mul(BLOCK_TIME_MS); + + // Calculate duration: total_amount / per_block = number of blocks + let duration_blocks = if per_block_u64 > 0 { + locked_u64.saturating_div(per_block_u64) + } else { + return Err(Error::::InvalidSchedule.into()); + }; + let duration_ms = duration_blocks.saturating_mul(BLOCK_TIME_MS); + let end_ms = start_ms.saturating_add(duration_ms); + + // Generate unique ID + let schedule_id = ScheduleCounter::::get().wrapping_add(1); + ScheduleCounter::::put(schedule_id); + + // Create vesting schedule - tokens should already be in pallet account + let vesting_schedule = VestingSchedule { + creator: who.clone(), + beneficiary: who.clone(), + amount: locked, + start: T::Moment::from(start_ms), + end: T::Moment::from(end_ms), + claimed: T::Balance::zero(), + id: schedule_id, + }; + VestingSchedules::::insert(schedule_id, vesting_schedule); + + Self::deposit_event(Event::VestingScheduleCreated( + who.clone(), + locked, + T::Moment::from(start_ms), + T::Moment::from(end_ms), + schedule_id, + )); + + Ok(()) + } + + fn can_add_vesting_schedule( + _who: &T::AccountId, + _locked: >::Balance, + _per_block: >::Balance, + _starting_block: BlockNumberFor, + ) -> DispatchResult { + // Our custom vesting doesn't have a limit on number of schedules + Ok(()) + } + + fn remove_vesting_schedule(_who: &T::AccountId, _schedule_index: u32) -> DispatchResult { + // This is not supported in our custom implementation + // merkle-airdrop doesn't use this method + Err(DispatchError::Other("remove_vesting_schedule not supported")) + } + } +} diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs new file mode 100644 index 00000000..c6558e26 --- /dev/null +++ b/pallets/vesting/src/mock.rs @@ -0,0 +1,154 @@ +// pallets/vesting/src/mock.rs +use core::convert::{TryFrom, TryInto}; +use frame_support::{ + parameter_types, + traits::{ConstU32, Hooks}, + PalletId, + __private::sp_io, +}; +use sp_runtime::{ + testing::H256, + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +use crate as pallet_vesting; // Your pallet + +// Define the test runtime +frame_support::construct_runtime!( + pub enum Test { + System: frame_system, + Balances: pallet_balances, + Timestamp: pallet_timestamp, + Vesting: pallet_vesting + } +); + +pub type Balance = u128; + +pub type Block = frame_system::mocking::MockBlock; + +// System config +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl frame_system::Config for Test { + type RuntimeEvent = RuntimeEvent; + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type RuntimeTask = (); + type Nonce = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Block = Block; + type BlockHashCount = BlockHashCount; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type ExtensionsWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); +} + +// Balances config +parameter_types! { + pub const ExistentialDeposit: u128 = 1; + pub const MaxLocks: u32 = 50; + pub const MaxReserves: u32 = 50; +} + +impl pallet_balances::Config for Test { + type RuntimeHoldReason = (); + type RuntimeFreezeReason = (); + type WeightInfo = (); + type Balance = Balance; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxLocks = ConstU32<50>; + type MaxReserves = (); + type MaxFreezes = ConstU32<0>; + type DoneSlashHandler = (); +} + +// Timestamp config +parameter_types! { + pub const MinimumPeriod: u64 = 1; +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; // Milliseconds + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; + type WeightInfo = (); +} + +// Vesting config +parameter_types! { + pub const VestingPalletId: PalletId = PalletId(*b"vestpal_"); + pub const MaxSchedules: u32 = 100; +} + +impl pallet_vesting::Config for Test { + type PalletId = VestingPalletId; + type WeightInfo = (); +} + +// Helper to build genesis storage +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + + pallet_balances::GenesisConfig:: { + balances: vec![(1, 100000), (2, 2000)], // Accounts 1 and 2 with funds + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); // Start at block 1 + ext.execute_with(|| { + pallet_timestamp::Pallet::::set(RuntimeOrigin::none(), 5) + .expect("Cannot set time to now") + }); // Start at block 1 + + ext +} + +pub fn run_to_block(n: u64, timestamp: u64) { + while System::block_number() < n { + let block_number = System::block_number(); + + // Run on_finalize for the current block + System::on_finalize(block_number); + // pallet_timestamp::Pallet::::on_finalize(block_number); + + // Increment block number + // println!("setting block number to {}", block_number); + System::set_block_number(block_number + 1); + + System::on_initialize(block_number + 1); + } + pallet_timestamp::Pallet::::on_finalize(n); + // println!("setting timestamp to {}", timestamp); + pallet_timestamp::Pallet::::set(RuntimeOrigin::none(), timestamp) + .expect("Cannot set time"); +} diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs new file mode 100644 index 00000000..27ed7553 --- /dev/null +++ b/pallets/vesting/src/tests.rs @@ -0,0 +1,508 @@ +use super::*; +use crate::{mock::*, VestingSchedule}; +use frame_support::{ + assert_noop, assert_ok, + traits::{Currency, ExistenceRequirement, ExistenceRequirement::AllowDeath}, +}; +use sp_runtime::{DispatchError, TokenError}; + +#[cfg(test)] +fn create_vesting_schedule>( + start: u64, + end: u64, + amount: Balance, +) -> VestingSchedule { + VestingSchedule { + creator: 1, + beneficiary: 2, + start: start.into(), + end: end.into(), + amount, + claimed: 0, + id: 1, + } +} + +#[test] +fn test_vesting_before_start() { + new_test_ext().execute_with(|| { + let schedule: VestingSchedule = create_vesting_schedule(100, 200, 1000); + let now = 50; // Before vesting starts + run_to_block(2, now); + + let vested: u128 = + Pallet::::vested_amount(&schedule).expect("Unable to compute vested amount"); + assert_eq!(vested, 0); + }); +} + +#[test] +fn test_vesting_after_end() { + new_test_ext().execute_with(|| { + let schedule: VestingSchedule = create_vesting_schedule(100, 200, 1000); + let now = 250; // After vesting ends + run_to_block(2, now); + + let vested: u128 = + Pallet::::vested_amount(&schedule).expect("Unable to compute vested amount"); + assert_eq!(vested, 1000); + }); +} + +#[test] +fn test_vesting_halfway() { + new_test_ext().execute_with(|| { + let schedule: VestingSchedule = create_vesting_schedule(100, 200, 1000); + let now = 150; // Midway through vesting + run_to_block(2, now); + + let vested: u128 = + Pallet::::vested_amount(&schedule).expect("Unable to compute vested amount"); + assert_eq!(vested, 500); // 50% of 1000 + }); +} + +#[test] +fn test_vesting_start_equals_end() { + new_test_ext().execute_with(|| { + let schedule: VestingSchedule = create_vesting_schedule(100, 100, 1000); + let now = 100; // Edge case: start == end + run_to_block(2, now); + + let vested: u128 = + Pallet::::vested_amount(&schedule).expect("Unable to compute vested amount"); + assert_eq!(vested, 1000); // Fully vested immediately + }); +} + +#[test] +fn create_vesting_schedule_works() { + new_test_ext().execute_with(|| { + // Setup: Account 1 has 1000 tokens + let start = 1000; // 1 second from genesis + let end = 2000; // 2 seconds from genesis + let amount = 500; + + // Create a vesting schedule + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, // Beneficiary + amount, + start, + end + )); + + // Check storage + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + let num_vesting_schedules = ScheduleCounter::::get(); + assert_eq!(num_vesting_schedules, 1); + assert_eq!( + schedule, + VestingSchedule { creator: 1, beneficiary: 2, amount, start, end, claimed: 0, id: 1 } + ); + + // Check balances + assert_eq!(Balances::free_balance(1), 100000 - amount); // Sender loses tokens + assert_eq!(Balances::free_balance(Vesting::account_id()), amount); // Pallet holds tokens + }); +} + +#[test] +fn claim_vested_tokens_works() { + new_test_ext().execute_with(|| { + let start = 1000; + let end = 2000; + let amount = 500; + + // Create a vesting schedule + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end + )); + + // Set timestamp to halfway through vesting (50% vested) + run_to_block(5, 1500); + + // Claim tokens + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + + // Check claimed amount (50% of 500 = 250) + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 250); + assert_eq!(Balances::free_balance(2), 2250); // 2000 initial + 250 claimed + assert_eq!(Balances::free_balance(Vesting::account_id()), 250); // Remaining in pallet + + // Claim again at end + run_to_block(6, 2000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + + // Check full claim + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 500); + assert_eq!(Balances::free_balance(2), 2500); // All 500 claimed + assert_eq!(Balances::free_balance(Vesting::account_id()), 0); // Pallet empty + }); +} + +#[test] +fn claim_before_vesting_fails() { + new_test_ext().execute_with(|| { + let start = 1000; + let end = 2000; + let amount = 500; + + // Create a vesting schedule + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end + )); + + // Try to claim (should not do anything) + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + + // Check no changes + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 0); + assert_eq!(Balances::free_balance(2), 2000); // No tokens claimed + }); +} + +#[test] +fn non_beneficiary_cannot_claim() { + new_test_ext().execute_with(|| { + let start = 1000; + let end = 2000; + let amount = 500; + + // Start at block 1, timestamp 500 + run_to_block(1, 500); + + // Account 1 creates a vesting schedule for account 2 + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, // Beneficiary is account 2 + amount, + start, + end + )); + + // Advance to halfway through vesting (50% vested) + run_to_block(2, 1500); + + // Account 3 (not the beneficiary) tries to claim + assert_noop!(Vesting::claim(RuntimeOrigin::signed(3), 3), Error::::NoVestingSchedule); + + // Ensure nothing was claimed + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 0); + assert_eq!(Balances::free_balance(2), 2000); // No change for beneficiary + assert_eq!(Balances::free_balance(Vesting::account_id()), 500); // Tokens still in pallet + + // Beneficiary (account 2) can claim + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 250); // 50% vested + assert_eq!(Balances::free_balance(2), 2250); + }); +} + +#[test] +fn multiple_beneficiaries_claim_own_schedules() { + new_test_ext().execute_with(|| { + let start = 1000; + let end = 2000; + let amount = 500; + + // Start at block 1, timestamp 500 + run_to_block(1, 500); + + // Account 1 creates a vesting schedule for account 2 + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end + )); + + // Account 1 creates a vesting schedule for account 3 + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 3, + amount, + start, + end + )); + + // Advance to halfway through vesting (50% vested) + run_to_block(2, 1500); + + // Account 2 claims their schedule + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + let schedule2 = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule2.claimed, 250); // 50% of 500 + assert_eq!(Balances::free_balance(2), 2250); + + // Account 3 claims their schedule + assert_ok!(Vesting::claim(RuntimeOrigin::signed(3), 2)); + let schedule3 = VestingSchedules::::get(2).expect("Schedule should exist"); + assert_eq!(schedule3.claimed, 250); // 50% of 500 + assert_eq!(Balances::free_balance(3), 250); // 0 initial + 250 claimed + + // Ensure account 2’s schedule is unaffected by account 3’s claim + let schedule2 = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule2.claimed, 250); // Still only 250 claimed + + // Total in pallet account should reflect both claims + assert_eq!(Balances::free_balance(Vesting::account_id()), 500); // 1000 - 250 - 250 + }); +} + +#[test] +fn zero_amount_schedule_fails() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + assert_noop!( + Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + 0, // Zero amount + 1000, + 2000 + ), + Error::::InvalidSchedule + ); + }); +} + +#[test] +fn claim_with_empty_pallet_fails() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + assert_ok!(Vesting::create_vesting_schedule(RuntimeOrigin::signed(1), 2, 500, 1000, 2000)); + + // Drain the pallet account (simulate external interference) + assert_ok!(Balances::transfer( + &Vesting::account_id(), + &3, + Balances::free_balance(Vesting::account_id()), + ExistenceRequirement::AllowDeath + )); + + run_to_block(2, 1500); + + // Claim should fail due to insufficient funds in pallet + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(2), 1), + DispatchError::Token(TokenError::FundsUnavailable) + ); + + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 0); // No tokens claimed + }); +} + +#[test] +fn multiple_schedules_same_beneficiary() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + // Schedule 1: 500 tokens, 1000-2000 + assert_ok!(Vesting::create_vesting_schedule(RuntimeOrigin::signed(1), 2, 500, 1000, 2000)); + + // Schedule 2: 300 tokens, 1200-1800 + assert_ok!(Vesting::create_vesting_schedule(RuntimeOrigin::signed(1), 2, 300, 1200, 1800)); + + // At 1500: Schedule 1 is 50% (250), Schedule 2 is 50% (150) + run_to_block(2, 1500); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 2)); + + let schedule1 = VestingSchedules::::get(1).expect("Schedule should exist"); + let schedule2 = VestingSchedules::::get(2).expect("Schedule should exist"); + let num_schedules = ScheduleCounter::::get(); + assert_eq!(num_schedules, 2); + assert_eq!(schedule1.claimed, 250); // Schedule 1 + assert_eq!(schedule2.claimed, 150); // Schedule 2 + assert_eq!(Balances::free_balance(2), 2400); // 2000 + 250 + 150 + + // At 2000: Schedule 1 is 100% (500), Schedule 2 is 100% (300) + run_to_block(3, 2000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 2)); + + let schedule1 = VestingSchedules::::get(1).expect("Schedule should exist"); + let schedule2 = VestingSchedules::::get(2).expect("Schedule should exist"); + assert_eq!(schedule1.claimed, 500); + assert_eq!(schedule2.claimed, 300); + assert_eq!(Balances::free_balance(2), 2800); // 2000 + 500 + 300 + }); +} + +#[test] +fn small_time_window_vesting() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + 500, + 1000, + 1001 // 1ms duration + )); + + run_to_block(2, 1000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 0); // Not yet vested + + run_to_block(3, 1001); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 500); // Fully vested + }); +} + +#[test] +fn vesting_near_max_timestamp() { + new_test_ext().execute_with(|| { + let max = u64::MAX; + run_to_block(1, max - 1000); + + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + 500, + max - 500, + max + )); + + run_to_block(2, max - 250); // Halfway + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 250); // 50% vested + + run_to_block(3, max); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(2), 1)); + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + assert_eq!(schedule.claimed, 500); + }); +} + +#[test] +fn creator_insufficient_funds_fails() { + new_test_ext().execute_with(|| { + // Give account 4 a small balance (less than amount + ED) + assert_ok!(Balances::transfer( + &Vesting::account_id(), + &3, + Balances::free_balance(Vesting::account_id()), + ExistenceRequirement::AllowDeath + )); + + assert_ok!(Balances::transfer( + &1, &4, 5, // Only 5 tokens, not enough for 10 + ED + AllowDeath + )); + + run_to_block(1, 500); + + // Account 4 tries to create a vesting schedule with insufficient funds + assert_noop!( + Vesting::create_vesting_schedule( + RuntimeOrigin::signed(4), + 2, + 10, // Amount greater than 4’s balance minus ED + 1000, + 2000 + ), + DispatchError::Token(TokenError::FundsUnavailable) + ); + + // Ensure no schedule was created + let schedule = VestingSchedules::::get(1); + assert_eq!(schedule, None); + + // Check balances + assert_eq!(Balances::free_balance(4), 5); // No change + assert_eq!(Balances::free_balance(Vesting::account_id()), 0); // Nothing transferred + }); +} + +#[test] +fn creator_can_cancel_schedule() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + assert_ok!(Vesting::create_vesting_schedule(RuntimeOrigin::signed(1), 2, 500, 1000, 2000)); + + run_to_block(2, 1500); + + // Creator (account 1) cancels the schedule + assert_ok!(Vesting::cancel_vesting_schedule( + RuntimeOrigin::signed(1), + 1 // First schedule ID + )); + + // Schedule is gone + let schedule = VestingSchedules::::get(1); + assert_eq!(schedule, None); + assert_eq!(Balances::free_balance(1), 99750); // 100000 - 500 + 250 refunded + assert_eq!(Balances::free_balance(2), 2250); // 2000 + 250 claimed + assert_eq!(Balances::free_balance(Vesting::account_id()), 0); + }); +} + +#[test] +fn non_creator_cannot_cancel() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + assert_ok!(Vesting::create_vesting_schedule(RuntimeOrigin::signed(1), 2, 500, 1000, 2000)); + + // Account 3 tries to cancel (not the creator) + assert_noop!( + Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(3), 1), + Error::::NotCreator + ); + + // Schedule still exists + let schedule = VestingSchedules::::get(1).expect("Schedule should exist"); + let num_schedules = ScheduleCounter::::get(); + assert_eq!(num_schedules, 1); + assert_eq!(schedule.creator, 1); + }); +} + +#[test] +fn creator_can_cancel_after_end() { + new_test_ext().execute_with(|| { + run_to_block(1, 500); + + assert_ok!(Vesting::create_vesting_schedule(RuntimeOrigin::signed(1), 2, 500, 1000, 2000)); + + run_to_block(2, 2500); + + // Creator (account 1) cancels the schedule + assert_ok!(Vesting::cancel_vesting_schedule( + RuntimeOrigin::signed(1), + 1 // First schedule ID + )); + + // Schedule is gone + let schedule1 = VestingSchedules::::get(1); + assert_eq!(schedule1, None); + assert_eq!(Balances::free_balance(1), 99500); // 100000 - 500 + assert_eq!(Balances::free_balance(2), 2500); // 2000 + 250 claimed + assert_eq!(Balances::free_balance(Vesting::account_id()), 0); + }); +} diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs new file mode 100644 index 00000000..018cc188 --- /dev/null +++ b/pallets/vesting/src/weights.rs @@ -0,0 +1,48 @@ +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use core::marker::PhantomData; +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; + +/// Weight functions for `pallet_vesting`. +pub trait WeightInfo { + fn create_vesting_schedule() -> Weight; + fn claim() -> Weight; + fn cancel_vesting_schedule() -> Weight; +} + +/// Weights for `pallet_mining_rewards` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + fn create_vesting_schedule() -> Weight { + Weight::from_parts(10_000, 0) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + + fn claim() -> Weight { + Weight::from_parts(15_000, 0) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + + fn cancel_vesting_schedule() -> Weight { + Weight::from_parts(15_000, 0) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } +} + +// For tests +impl WeightInfo for () { + fn create_vesting_schedule() -> Weight { + Weight::from_parts(10_000, 0) + } + fn claim() -> Weight { + Weight::from_parts(10_000, 0) + } + fn cancel_vesting_schedule() -> Weight { + Weight::from_parts(10_000, 0) + } +} \ No newline at end of file diff --git a/runtime/src/benchmarks.rs b/runtime/src/benchmarks.rs index c670981c..fd525c68 100644 --- a/runtime/src/benchmarks.rs +++ b/runtime/src/benchmarks.rs @@ -29,6 +29,7 @@ frame_benchmarking::define_benchmarks!( [pallet_balances, Balances] [pallet_timestamp, Timestamp] [pallet_sudo, Sudo] + [pallet_vesting, Vesting] [pallet_reversible_transfers, ReversibleTransfers] [pallet_merkle_airdrop, MerkleAirdrop] [pallet_mining_rewards, MiningRewards] diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index e76063a3..7e6685dd 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -39,7 +39,7 @@ use frame_support::{ derive_impl, parameter_types, traits::{ AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU8, EitherOf, Get, NeverEnsureOrigin, - VariantCountOf, WithdrawReasons, + VariantCountOf, }, weights::{ constants::{RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -426,22 +426,12 @@ impl pallet_sudo::Config for Runtime { } parameter_types! { - pub const MinVestedTransfer: Balance = UNIT; - /// Unvested funds can be transferred and reserved for any other means (reserves overlap) - pub UnvestedFundsAllowedWithdrawReasons: WithdrawReasons = - WithdrawReasons::except(WithdrawReasons::TRANSFER | WithdrawReasons::RESERVE); + pub const VestingPalletId: PalletId = PalletId(*b"vestingp"); } impl pallet_vesting::Config for Runtime { - type Currency = Balances; - type RuntimeEvent = RuntimeEvent; + type PalletId = VestingPalletId; type WeightInfo = pallet_vesting::weights::SubstrateWeight; - type MinVestedTransfer = MinVestedTransfer; - type BlockNumberToBalance = ConvertInto; - type UnvestedFundsAllowedWithdrawReasons = UnvestedFundsAllowedWithdrawReasons; - type BlockNumberProvider = System; - - const MAX_VESTING_SCHEDULES: u32 = 28; } impl pallet_utility::Config for Runtime { From cc51c1159d0585ca78089442fb4c25097cd0593e Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Tue, 16 Dec 2025 15:04:40 +0800 Subject: [PATCH 2/5] feat: Vesting - more advanced schedules --- pallets/vesting/src/benchmarking.rs | 54 +++++ pallets/vesting/src/lib.rs | 267 +++++++++++++++++---- pallets/vesting/src/tests.rs | 350 +++++++++++++++++++++++++++- pallets/vesting/src/weights.rs | 20 ++ 4 files changed, 648 insertions(+), 43 deletions(-) diff --git a/pallets/vesting/src/benchmarking.rs b/pallets/vesting/src/benchmarking.rs index c4c75ced..ce81e705 100644 --- a/pallets/vesting/src/benchmarking.rs +++ b/pallets/vesting/src/benchmarking.rs @@ -119,5 +119,59 @@ mod benchmarks { assert!(VestingSchedules::::get(schedule_id).is_none()); } + #[benchmark] + fn create_vesting_schedule_with_cliff() { + let caller: T::AccountId = benchmark_account("caller", 0, SEED); + let beneficiary: T::AccountId = benchmark_account("beneficiary", 0, SEED); + + // Fund the caller + let amount: T::Balance = 1_000_000_000_000u128.into(); + fund_account::(&caller, amount * 2u128.into()); + + let cliff: T::Moment = 50000u64.into(); + let end: T::Moment = 100000u64.into(); + + #[extrinsic_call] + create_vesting_schedule_with_cliff( + RawOrigin::Signed(caller.clone()), + beneficiary.clone(), + amount, + cliff, + end, + ); + + // Verify schedule was created + assert_eq!(ScheduleCounter::::get(), 1); + assert!(VestingSchedules::::get(1).is_some()); + } + + #[benchmark] + fn create_stepped_vesting_schedule() { + let caller: T::AccountId = benchmark_account("caller", 0, SEED); + let beneficiary: T::AccountId = benchmark_account("beneficiary", 0, SEED); + + // Fund the caller + let amount: T::Balance = 1_000_000_000_000u128.into(); + fund_account::(&caller, amount * 2u128.into()); + + let start: T::Moment = 1000u64.into(); + let end: T::Moment = 100000u64.into(); + let step_duration: T::Moment = 10000u64.into(); + + #[extrinsic_call] + create_stepped_vesting_schedule( + RawOrigin::Signed(caller.clone()), + beneficiary.clone(), + amount, + start, + end, + step_duration, + ); + + // Verify schedule was created + assert_eq!(ScheduleCounter::::get(), 1); + assert!(VestingSchedules::::get(1).is_some()); + } + impl_benchmark_test_suite!(Vesting, crate::mock::new_test_ext(), crate::mock::Test); } diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index 2c6877c6..d80bd2bf 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -34,15 +34,26 @@ pub mod pallet { ArithmeticError, }; + #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] + pub enum VestingType { + /// Linear vesting - tokens unlock proportionally over time + Linear, + /// Linear vesting with cliff - nothing unlocks until cliff, then linear + LinearWithCliff { cliff: Moment }, + /// Stepped vesting - tokens unlock in equal portions at regular intervals + Stepped { step_duration: Moment }, + } + #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct VestingSchedule { - pub id: u64, // Unique id - pub creator: AccountId, // Who created the scehdule - pub beneficiary: AccountId, // Who gets the tokens - pub amount: Balance, // Total tokens to vest - pub start: Moment, // When vesting begins - pub end: Moment, // When vesting fully unlocks - pub claimed: Balance, // Tokens already claimed + pub id: u64, // Unique id + pub creator: AccountId, // Who created the scehdule + pub beneficiary: AccountId, // Who gets the tokens + pub amount: Balance, // Total tokens to vest + pub start: Moment, // When vesting begins + pub end: Moment, // When vesting fully unlocks + pub vesting_type: VestingType, // Type of vesting + pub claimed: Balance, // Tokens already claimed } #[pallet::storage] @@ -118,6 +129,7 @@ pub mod pallet { amount, start, end, + vesting_type: VestingType::Linear, claimed: T::Balance::zero(), id: schedule_id, }; @@ -193,6 +205,101 @@ pub mod pallet { Self::deposit_event(Event::VestingScheduleCancelled(who, schedule_id)); Ok(()) } + + // Create a vesting schedule with cliff + #[pallet::call_index(3)] + #[pallet::weight(::WeightInfo::create_vesting_schedule_with_cliff())] + pub fn create_vesting_schedule_with_cliff( + origin: OriginFor, + beneficiary: T::AccountId, + amount: T::Balance, + cliff: T::Moment, + end: T::Moment, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(cliff < end, Error::::InvalidSchedule); + ensure!(amount > T::Balance::zero(), Error::::InvalidSchedule); + + // Transfer tokens from caller to pallet and lock them + pallet_balances::Pallet::::transfer(&who, &Self::account_id(), amount, KeepAlive)?; + + // Generate unique ID + let schedule_id = ScheduleCounter::::get().wrapping_add(1); + ScheduleCounter::::put(schedule_id); + + // Add the schedule to storage + let schedule = VestingSchedule { + creator: who, + beneficiary: beneficiary.clone(), + amount, + start: cliff, // Start is set to cliff for calculations + end, + vesting_type: VestingType::LinearWithCliff { cliff }, + claimed: T::Balance::zero(), + id: schedule_id, + }; + VestingSchedules::::insert(schedule_id, schedule); + + Self::deposit_event(Event::VestingScheduleCreated( + beneficiary, + amount, + cliff, + end, + schedule_id, + )); + Ok(()) + } + + // Create a stepped vesting schedule + #[pallet::call_index(4)] + #[pallet::weight(::WeightInfo::create_stepped_vesting_schedule())] + pub fn create_stepped_vesting_schedule( + origin: OriginFor, + beneficiary: T::AccountId, + amount: T::Balance, + start: T::Moment, + end: T::Moment, + step_duration: T::Moment, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!(start < end, Error::::InvalidSchedule); + ensure!(amount > T::Balance::zero(), Error::::InvalidSchedule); + ensure!(step_duration > T::Moment::zero(), Error::::InvalidSchedule); + + let duration = end.saturating_sub(start); + ensure!(duration >= step_duration, Error::::InvalidSchedule); + + // Transfer tokens from caller to pallet and lock them + pallet_balances::Pallet::::transfer(&who, &Self::account_id(), amount, KeepAlive)?; + + // Generate unique ID + let schedule_id = ScheduleCounter::::get().wrapping_add(1); + ScheduleCounter::::put(schedule_id); + + // Add the schedule to storage + let schedule = VestingSchedule { + creator: who, + beneficiary: beneficiary.clone(), + amount, + start, + end, + vesting_type: VestingType::Stepped { step_duration }, + claimed: T::Balance::zero(), + id: schedule_id, + }; + VestingSchedules::::insert(schedule_id, schedule); + + Self::deposit_event(Event::VestingScheduleCreated( + beneficiary, + amount, + start, + end, + schedule_id, + )); + Ok(()) + } } impl Pallet { @@ -201,41 +308,117 @@ pub mod pallet { schedule: &VestingSchedule, ) -> Result { let now = >::get(); - // No need to convert now/start/end to u64 explicitly if T::Moment is u64-like - if now < schedule.start { - Ok(T::Balance::zero()) - } else if now >= schedule.end { - Ok(schedule.amount) - } else { - let elapsed = now.saturating_sub(schedule.start); - let duration = schedule.end.saturating_sub(schedule.start); - - // Convert amount to u64 for intermediate calculation - let amount_u64: u64 = schedule - .amount - .try_into() - .map_err(|_| DispatchError::Other("Balance to u64 conversion failed"))?; - - // Perform calculation in u64 (T::Moment-like) - let elapsed_u64: u64 = elapsed - .try_into() - .map_err(|_| DispatchError::Other("Moment to u64 conversion failed"))?; - let duration_u64: u64 = duration - .try_into() - .map_err(|_| DispatchError::Other("Moment to u64 conversion failed"))?; - let duration_safe: u64 = duration_u64.max(1); - - let vested_u64: u64 = amount_u64 - .saturating_mul(elapsed_u64) - .checked_div(duration_safe) - .ok_or(DispatchError::Arithmetic(ArithmeticError::Underflow))?; - - // Convert back to T::Balance - let vested = T::Balance::try_from(vested_u64) - .map_err(|_| DispatchError::Other("u64 to Balance conversion failed"))?; - - Ok(vested) + + match &schedule.vesting_type { + VestingType::Linear => Self::calculate_linear_vested( + now, + schedule.start, + schedule.end, + schedule.amount, + ), + VestingType::LinearWithCliff { cliff } => { + if now < *cliff { + Ok(T::Balance::zero()) + } else if now >= schedule.end { + Ok(schedule.amount) + } else { + // Linear vesting from cliff to end + Self::calculate_linear_vested(now, *cliff, schedule.end, schedule.amount) + } + }, + VestingType::Stepped { step_duration } => Self::calculate_stepped_vested( + now, + schedule.start, + schedule.end, + schedule.amount, + *step_duration, + ), + } + } + + // Calculate linear vesting + fn calculate_linear_vested( + now: T::Moment, + start: T::Moment, + end: T::Moment, + amount: T::Balance, + ) -> Result { + if now < start { + return Ok(T::Balance::zero()); + } + if now >= end { + return Ok(amount); } + + let elapsed = now.saturating_sub(start); + let duration = end.saturating_sub(start); + + // Convert to u64 for calculation + let amount_u64: u64 = amount + .try_into() + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + let elapsed_u64: u64 = elapsed + .try_into() + .map_err(|_| DispatchError::Other("Moment conversion failed"))?; + let duration_u64: u64 = duration + .try_into() + .map_err(|_| DispatchError::Other("Moment conversion failed"))?; + let duration_safe: u64 = duration_u64.max(1); + + let vested_u64: u64 = amount_u64 + .saturating_mul(elapsed_u64) + .checked_div(duration_safe) + .ok_or(DispatchError::Arithmetic(ArithmeticError::Underflow))?; + + let vested = T::Balance::try_from(vested_u64) + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + + Ok(vested) + } + + // Calculate stepped vesting + fn calculate_stepped_vested( + now: T::Moment, + start: T::Moment, + end: T::Moment, + amount: T::Balance, + step_duration: T::Moment, + ) -> Result { + if now < start { + return Ok(T::Balance::zero()); + } + if now >= end { + return Ok(amount); + } + + let elapsed = now.saturating_sub(start); + let total_duration = end.saturating_sub(start); + + // Convert to u64 for calculation + let elapsed_u64: u64 = elapsed + .try_into() + .map_err(|_| DispatchError::Other("Moment conversion failed"))?; + let step_duration_u64: u64 = step_duration + .try_into() + .map_err(|_| DispatchError::Other("Moment conversion failed"))?; + let total_duration_u64: u64 = total_duration + .try_into() + .map_err(|_| DispatchError::Other("Moment conversion failed"))?; + let amount_u64: u64 = amount + .try_into() + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + + // Calculate number of completed steps + let steps_passed = elapsed_u64 / step_duration_u64; + let total_steps = total_duration_u64.div_ceil(step_duration_u64); + + // Calculate vested amount based on completed steps + let vested_u64 = amount_u64.saturating_mul(steps_passed) / total_steps.max(1); + + let vested = T::Balance::try_from(vested_u64) + .map_err(|_| DispatchError::Other("Balance conversion failed"))?; + + Ok(vested) } // Pallet account to "hold" tokens @@ -311,6 +494,7 @@ pub mod pallet { amount, start: T::Moment::from(start_ms), end: T::Moment::from(end_ms), + vesting_type: VestingType::Linear, claimed: T::Balance::zero(), id: schedule_id, }; @@ -404,6 +588,7 @@ pub mod pallet { amount: locked, start: T::Moment::from(start_ms), end: T::Moment::from(end_ms), + vesting_type: VestingType::Linear, claimed: T::Balance::zero(), id: schedule_id, }; diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index 27ed7553..d7a4128b 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{mock::*, VestingSchedule}; +use crate::{mock::*, VestingSchedule, VestingType}; use frame_support::{ assert_noop, assert_ok, traits::{Currency, ExistenceRequirement, ExistenceRequirement::AllowDeath}, @@ -17,6 +17,7 @@ fn create_vesting_schedule>( beneficiary: 2, start: start.into(), end: end.into(), + vesting_type: VestingType::Linear, amount, claimed: 0, id: 1, @@ -98,7 +99,16 @@ fn create_vesting_schedule_works() { assert_eq!(num_vesting_schedules, 1); assert_eq!( schedule, - VestingSchedule { creator: 1, beneficiary: 2, amount, start, end, claimed: 0, id: 1 } + VestingSchedule { + creator: 1, + beneficiary: 2, + amount, + start, + end, + vesting_type: VestingType::Linear, + claimed: 0, + id: 1 + } ); // Check balances @@ -506,3 +516,339 @@ fn creator_can_cancel_after_end() { assert_eq!(Balances::free_balance(Vesting::account_id()), 0); }); } + +// ========== Cliff Vesting Tests ========== + +#[test] +fn cliff_vesting_before_cliff_returns_zero() { + new_test_ext().execute_with(|| { + let amount = 1000; + let cliff = 1000; // Cliff at timestamp 1000 + let end = 2000; + + assert_ok!(Vesting::create_vesting_schedule_with_cliff( + RuntimeOrigin::signed(1), + 2, + amount, + cliff, + end + )); + + // Set timestamp before cliff + Timestamp::set_timestamp(500); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + // Nothing is vested before cliff + assert_eq!(vested, 0); + }); +} + +#[test] +fn cliff_vesting_at_cliff_starts_linear() { + new_test_ext().execute_with(|| { + let amount = 1000; + let cliff = 1000; // Cliff at timestamp 1000 + let end = 2000; + + assert_ok!(Vesting::create_vesting_schedule_with_cliff( + RuntimeOrigin::signed(1), + 2, + amount, + cliff, + end + )); + + // Set timestamp at cliff + Timestamp::set_timestamp(1000); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + // At cliff, 0% of vesting period has elapsed (cliff to end) + assert_eq!(vested, 0); + + // Halfway between cliff and end + Timestamp::set_timestamp(1500); + let vested = Vesting::vested_amount(&schedule).unwrap(); + assert_eq!(vested, 500); // 50% of amount + }); +} + +#[test] +fn cliff_vesting_after_end_returns_full_amount() { + new_test_ext().execute_with(|| { + let amount = 1000; + let cliff = 1000; + let end = 2000; + + assert_ok!(Vesting::create_vesting_schedule_with_cliff( + RuntimeOrigin::signed(1), + 2, + amount, + cliff, + end + )); + + // Set timestamp after end + Timestamp::set_timestamp(2500); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + assert_eq!(vested, amount); + }); +} + +#[test] +fn cliff_vesting_claim_works() { + new_test_ext().execute_with(|| { + let amount = 1000; + let cliff = 1000; + let end = 2000; + + assert_ok!(Vesting::create_vesting_schedule_with_cliff( + RuntimeOrigin::signed(1), + 2, + amount, + cliff, + end + )); + + // Before cliff - cannot claim + Timestamp::set_timestamp(500); + assert_ok!(Vesting::claim(RuntimeOrigin::none(), 1)); + assert_eq!(Balances::free_balance(2), 2000); // No change + + // After cliff, halfway to end + Timestamp::set_timestamp(1500); + assert_ok!(Vesting::claim(RuntimeOrigin::none(), 1)); + assert_eq!(Balances::free_balance(2), 2500); // 2000 + 500 (50% vested) + }); +} + +// ========== Stepped Vesting Tests ========== + +#[test] +fn stepped_vesting_before_first_step() { + new_test_ext().execute_with(|| { + let amount = 1000; + let start = 1000; + let end = 5000; // 4000ms duration + let step_duration = 1000; // 4 steps + + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end, + step_duration + )); + + // Before first step + Timestamp::set_timestamp(1500); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + // 0 complete steps = 0 vested + assert_eq!(vested, 0); + }); +} + +#[test] +fn stepped_vesting_after_first_step() { + new_test_ext().execute_with(|| { + let amount = 1000; + let start = 1000; + let end = 5000; // 4000ms duration + let step_duration = 1000; // 4 steps + + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end, + step_duration + )); + + // After first step (1000ms elapsed) + Timestamp::set_timestamp(2000); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + // 1 step out of 4 = 25% + assert_eq!(vested, 250); + }); +} + +#[test] +fn stepped_vesting_after_two_steps() { + new_test_ext().execute_with(|| { + let amount = 1000; + let start = 1000; + let end = 5000; // 4000ms duration + let step_duration = 1000; // 4 steps + + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end, + step_duration + )); + + // After two steps (2000ms elapsed) + Timestamp::set_timestamp(3000); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + // 2 steps out of 4 = 50% + assert_eq!(vested, 500); + }); +} + +#[test] +fn stepped_vesting_after_all_steps() { + new_test_ext().execute_with(|| { + let amount = 1000; + let start = 1000; + let end = 5000; + let step_duration = 1000; + + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end, + step_duration + )); + + // After end + Timestamp::set_timestamp(5000); + + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + + // All vested + assert_eq!(vested, amount); + }); +} + +#[test] +fn stepped_vesting_claim_works() { + new_test_ext().execute_with(|| { + let amount = 1000; + let start = 1000; + let end = 5000; + let step_duration = 1000; // 4 steps + + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end, + step_duration + )); + + // Before first step - nothing to claim + Timestamp::set_timestamp(1500); + assert_ok!(Vesting::claim(RuntimeOrigin::none(), 1)); + assert_eq!(Balances::free_balance(2), 2000); // No change + + // After two steps + Timestamp::set_timestamp(3000); + assert_ok!(Vesting::claim(RuntimeOrigin::none(), 1)); + assert_eq!(Balances::free_balance(2), 2500); // 2000 + 500 (50% vested) + + // After all steps + Timestamp::set_timestamp(5000); + assert_ok!(Vesting::claim(RuntimeOrigin::none(), 1)); + assert_eq!(Balances::free_balance(2), 3000); // 2000 + 1000 (100% vested) + }); +} + +#[test] +fn stepped_vesting_yearly_example() { + new_test_ext().execute_with(|| { + let amount = 4000; + let start = 0; + let year_ms = 365 * 24 * 3600 * 1000; // 1 year in milliseconds + let end = 4 * year_ms; // 4 years + let step_duration = year_ms; // Annual steps + + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + amount, + start, + end, + step_duration + )); + + // After 364 days - still 0 + Timestamp::set_timestamp(364 * 24 * 3600 * 1000); + let schedule = VestingSchedules::::get(1).unwrap(); + let vested = Vesting::vested_amount(&schedule).unwrap(); + assert_eq!(vested, 0); + + // After 1 year - 25% + Timestamp::set_timestamp(year_ms); + let vested = Vesting::vested_amount(&schedule).unwrap(); + assert_eq!(vested, 1000); // 25% + + // After 2 years - 50% + Timestamp::set_timestamp(2 * year_ms); + let vested = Vesting::vested_amount(&schedule).unwrap(); + assert_eq!(vested, 2000); // 50% + + // After 3 years - 75% + Timestamp::set_timestamp(3 * year_ms); + let vested = Vesting::vested_amount(&schedule).unwrap(); + assert_eq!(vested, 3000); // 75% + + // After 4 years - 100% + Timestamp::set_timestamp(4 * year_ms); + let vested = Vesting::vested_amount(&schedule).unwrap(); + assert_eq!(vested, 4000); // 100% + }); +} + +#[test] +fn stepped_vesting_invalid_step_duration_fails() { + new_test_ext().execute_with(|| { + // step_duration = 0 should fail + assert_noop!( + Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + 1000, + 1000, + 2000, + 0 // Invalid: zero step duration + ), + Error::::InvalidSchedule + ); + + // step_duration > total duration should fail + assert_noop!( + Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(1), + 2, + 1000, + 1000, + 2000, + 2000 // Invalid: step longer than total duration + ), + Error::::InvalidSchedule + ); + }); +} diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs index 018cc188..b3506740 100644 --- a/pallets/vesting/src/weights.rs +++ b/pallets/vesting/src/weights.rs @@ -10,6 +10,8 @@ pub trait WeightInfo { fn create_vesting_schedule() -> Weight; fn claim() -> Weight; fn cancel_vesting_schedule() -> Weight; + fn create_vesting_schedule_with_cliff() -> Weight; + fn create_stepped_vesting_schedule() -> Weight; } /// Weights for `pallet_mining_rewards` using the Substrate node and recommended hardware. @@ -32,6 +34,18 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } + + fn create_vesting_schedule_with_cliff() -> Weight { + Weight::from_parts(10_000, 0) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + + fn create_stepped_vesting_schedule() -> Weight { + Weight::from_parts(10_000, 0) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } } // For tests @@ -45,4 +59,10 @@ impl WeightInfo for () { fn cancel_vesting_schedule() -> Weight { Weight::from_parts(10_000, 0) } + fn create_vesting_schedule_with_cliff() -> Weight { + Weight::from_parts(10_000, 0) + } + fn create_stepped_vesting_schedule() -> Weight { + Weight::from_parts(10_000, 0) + } } \ No newline at end of file From 07283a7e4fc73427472d4856145fd9f0e3322b31 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 17 Dec 2025 12:44:25 +0800 Subject: [PATCH 3/5] feat: Tests + Benchmarks + Weights for vesting and merkle-airdrop --- .github/workflows/pm-quality-check.yml | 1 + Cargo.lock | 1 + pallets/merkle-airdrop/Cargo.toml | 1 + pallets/merkle-airdrop/src/benchmarking.rs | 5 +- pallets/merkle-airdrop/src/lib.rs | 41 +- pallets/merkle-airdrop/src/mock.rs | 4 +- pallets/merkle-airdrop/src/tests.rs | 404 ++++++++++++++++- pallets/merkle-airdrop/src/weights.rs | 99 ++--- pallets/vesting/src/lib.rs | 60 ++- pallets/vesting/src/mock.rs | 3 +- pallets/vesting/src/tests.rs | 274 ++++++++++++ pallets/vesting/src/weights.rs | 277 +++++++++--- runtime/src/configs/mod.rs | 2 + runtime/tests/governance/vesting.rs | 488 ++++++--------------- 14 files changed, 1173 insertions(+), 487 deletions(-) diff --git a/.github/workflows/pm-quality-check.yml b/.github/workflows/pm-quality-check.yml index 1318d7dc..7eaf80da 100644 --- a/.github/workflows/pm-quality-check.yml +++ b/.github/workflows/pm-quality-check.yml @@ -43,6 +43,7 @@ jobs: - "pallets/qpow" - "pallets/reversible-transfers" - "pallets/scheduler" + - "pallets/vesting" - "pallets/wormhole" - "primitives/consensus/qpow" - "primitives/dilithium-crypto" diff --git a/Cargo.lock b/Cargo.lock index 891e307a..345744d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7129,6 +7129,7 @@ dependencies = [ "frame-system", "log", "pallet-balances 40.0.1", + "pallet-timestamp", "pallet-vesting", "parity-scale-codec", "scale-info", diff --git a/pallets/merkle-airdrop/Cargo.toml b/pallets/merkle-airdrop/Cargo.toml index 3c88c4bb..ba1bf752 100644 --- a/pallets/merkle-airdrop/Cargo.toml +++ b/pallets/merkle-airdrop/Cargo.toml @@ -29,6 +29,7 @@ sp-runtime.workspace = true [dev-dependencies] pallet-balances.features = ["std"] pallet-balances.workspace = true +pallet-timestamp.workspace = true pallet-vesting.workspace = true sp-core.workspace = true sp-io.workspace = true diff --git a/pallets/merkle-airdrop/src/benchmarking.rs b/pallets/merkle-airdrop/src/benchmarking.rs index 1ac5af49..96d5891b 100644 --- a/pallets/merkle-airdrop/src/benchmarking.rs +++ b/pallets/merkle-airdrop/src/benchmarking.rs @@ -90,7 +90,10 @@ mod benchmarks { let caller: T::AccountId = whitelisted_caller(); let recipient: T::AccountId = account("recipient", 0, 0); - let amount: BalanceOf = 100u32.into(); + // Use large amount to cover existential deposit requirements + // 10 billion = 10 * 1_000_000_000 + let amount: BalanceOf = 1000u32.into(); + let amount = amount.saturating_mul(10_000_000u32.into()); // 1. Calculate the initial leaf hash let leaf_hash = MerkleAirdrop::::calculate_leaf_hash_blake2(&recipient, amount); diff --git a/pallets/merkle-airdrop/src/lib.rs b/pallets/merkle-airdrop/src/lib.rs index b3b2651d..3a27656d 100644 --- a/pallets/merkle-airdrop/src/lib.rs +++ b/pallets/merkle-airdrop/src/lib.rs @@ -467,6 +467,7 @@ pub mod pallet { ); ensure!(airdrop_metadata.balance >= amount, Error::::InsufficientAirdropBalance); + ensure!(!amount.is_zero(), Error::::InsufficientAirdropBalance); // Mark as claimed before performing the transfer Claimed::::insert(airdrop_id, &recipient, ()); @@ -477,25 +478,33 @@ pub mod pallet { } }); - let per_block = if let Some(vesting_period) = airdrop_metadata.vesting_period { - amount + // If there's no vesting period, do a direct transfer + // Otherwise, use vested_transfer for gradual unlocking + if let Some(vesting_period) = airdrop_metadata.vesting_period { + let per_block = amount .checked_div(&T::BlockNumberToBalance::convert(vesting_period)) - .ok_or(Error::::InsufficientAirdropBalance)? - } else { - amount - }; + .ok_or(Error::::InsufficientAirdropBalance)?; - let current_block = T::BlockNumberProvider::current_block_number(); - let vesting_start = - current_block.saturating_add(airdrop_metadata.vesting_delay.unwrap_or_default()); + let current_block = T::BlockNumberProvider::current_block_number(); + let vesting_start = current_block + .saturating_add(airdrop_metadata.vesting_delay.unwrap_or_default()); - T::Vesting::vested_transfer( - &Self::account_id(), - &recipient, - amount, - per_block, - vesting_start, - )?; + T::Vesting::vested_transfer( + &Self::account_id(), + &recipient, + amount, + per_block, + vesting_start, + )?; + } else { + // No vesting - direct transfer from airdrop pallet account to recipient + CurrencyOf::::transfer( + &Self::account_id(), + &recipient, + amount, + frame_support::traits::ExistenceRequirement::AllowDeath, + )?; + } Self::deposit_event(Event::Claimed { airdrop_id, account: recipient, amount }); diff --git a/pallets/merkle-airdrop/src/mock.rs b/pallets/merkle-airdrop/src/mock.rs index 3552a2d1..a3bdd6cb 100644 --- a/pallets/merkle-airdrop/src/mock.rs +++ b/pallets/merkle-airdrop/src/mock.rs @@ -1,7 +1,7 @@ use crate as pallet_merkle_airdrop; use frame_support::{ parameter_types, - traits::{ConstU32, Everything, WithdrawReasons}, + traits::{ConstU32, Everything}, PalletId, }; use frame_system::{self as system}; @@ -96,11 +96,13 @@ impl pallet_timestamp::Config for Test { parameter_types! { pub const VestingPalletId: PalletId = PalletId(*b"vestpal_"); + pub const MaxSchedulesPerBeneficiary: u32 = 50; } impl pallet_vesting::Config for Test { type PalletId = VestingPalletId; type WeightInfo = (); + type MaxSchedulesPerBeneficiary = MaxSchedulesPerBeneficiary; } parameter_types! { diff --git a/pallets/merkle-airdrop/src/tests.rs b/pallets/merkle-airdrop/src/tests.rs index a142b6c5..7abc8c16 100644 --- a/pallets/merkle-airdrop/src/tests.rs +++ b/pallets/merkle-airdrop/src/tests.rs @@ -2,11 +2,7 @@ use crate::{mock::*, Error, Event}; use codec::Encode; -use frame_support::{ - assert_noop, assert_ok, - traits::{InspectLockableCurrency, LockIdentifier}, - BoundedVec, -}; +use frame_support::{assert_noop, assert_ok, BoundedVec}; use sp_core::blake2_256; use sp_runtime::TokenError; @@ -34,8 +30,6 @@ fn calculate_parent_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { blake2_256(&combined) } -const VESTING_ID: LockIdentifier = *b"vesting "; - #[test] fn create_airdrop_works() { new_test_ext().execute_with(|| { @@ -137,9 +131,10 @@ fn claim_works() { System::assert_last_event(Event::Claimed { airdrop_id: 0, account: 2, amount: 500 }.into()); assert_eq!(MerkleAirdrop::is_claimed(0, 2), ()); - assert_eq!(Balances::balance_locked(VESTING_ID, &2), 500); // Unlocked + // Note: Custom vesting holds tokens in pallet account, not locked on user account - assert_eq!(Balances::free_balance(2), 500); + // User doesn't get tokens immediately - they're in vesting schedule + assert_eq!(Balances::free_balance(2), 0); assert_eq!(Balances::free_balance(MerkleAirdrop::account_id()), 501); // 1 (initial) + 1000 // (funded) - 500 (claimed) }); @@ -391,7 +386,9 @@ fn claim_updates_balances_correctly() { bounded_proof(vec![leaf2]) )); - assert_eq!(Balances::free_balance(2), initial_account_balance + 500); + // User doesn't get tokens immediately - they're in vesting schedule + assert_eq!(Balances::free_balance(2), initial_account_balance); + // Tokens are transferred from airdrop to vesting pallet assert_eq!( Balances::free_balance(MerkleAirdrop::account_id()), initial_pallet_balance - 500 @@ -429,20 +426,18 @@ fn multiple_users_can_claim() { // User 1 claims let proof1 = bounded_proof(vec![leaf2, leaf3]); assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 2, 5000, proof1)); - assert_eq!(Balances::free_balance(2), 5000); // free balance but it's locked for vesting - assert_eq!(Balances::balance_locked(VESTING_ID, &2), 5000); + // Tokens are in vesting schedule, not user account + assert_eq!(Balances::free_balance(2), 0); // User 2 claims let proof2 = bounded_proof(vec![leaf1, leaf3]); assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 3, 3000, proof2)); - assert_eq!(Balances::free_balance(3), 3000); - assert_eq!(Balances::balance_locked(VESTING_ID, &3), 3000); + assert_eq!(Balances::free_balance(3), 0); // User 3 claims let proof3 = bounded_proof(vec![parent1]); assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, 4, 2000, proof3)); - assert_eq!(Balances::free_balance(4), 2000); - assert_eq!(Balances::balance_locked(VESTING_ID, &4), 2000); + assert_eq!(Balances::free_balance(4), 0); assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 1); @@ -589,3 +584,380 @@ fn delete_airdrop_after_claims_works() { ); }); } + +#[test] +fn cannot_use_proof_from_different_airdrop() { + // SECURITY: Prevents proof replay attacks across different airdrops + // Attack scenario: Attacker sees valid claim in airdrop A, + // tries to reuse same proof in airdrop B + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let amount1: u64 = 1000; + + // Create two different airdrops with different merkle roots + let leaf1 = calculate_leaf_hash(&account1, amount1); + let leaf2 = calculate_leaf_hash(&3, 500); + let merkle_root_a = calculate_parent_hash(&leaf1, &leaf2); + let merkle_root_b = [0xff; 32]; // Completely different root + + // Airdrop A + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root_a, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 2000)); + + // Airdrop B + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root_b, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 1, 2000)); + + // Valid proof for airdrop A + let proof_a = bounded_proof(vec![leaf2]); + + // Claim from A should work + assert_ok!(MerkleAirdrop::claim( + RuntimeOrigin::none(), + 0, + account1, + amount1, + proof_a.clone() + )); + + // SECURITY: Try to reuse same proof in airdrop B - should FAIL + assert_noop!( + MerkleAirdrop::claim( + RuntimeOrigin::none(), + 1, // Different airdrop! + account1, + amount1, + proof_a + ), + Error::::InvalidProof + ); + }); +} + +#[test] +fn cannot_modify_amount_with_valid_proof() { + // SECURITY: Attacker tries to claim more by modifying amount + // but keeping the same proof - should fail + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let correct_amount: u64 = 1000; + let inflated_amount: u64 = 10000; // 10x more! + + let leaf1 = calculate_leaf_hash(&account1, correct_amount); + let leaf2 = calculate_leaf_hash(&3, 500); + let merkle_root = calculate_parent_hash(&leaf1, &leaf2); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 20000)); + + let proof = bounded_proof(vec![leaf2]); + + // Try to claim with inflated amount but same proof + assert_noop!( + MerkleAirdrop::claim( + RuntimeOrigin::none(), + 0, + account1, + inflated_amount, // Wrong amount! + proof + ), + Error::::InvalidProof + ); + }); +} + +#[test] +fn cannot_claim_with_siblings_proof() { + // SECURITY: Attacker tries to use someone else's proof + // to claim their allocation - should fail + new_test_ext().execute_with(|| { + let alice: u64 = 2; + let bob: u64 = 3; + let alice_amount: u64 = 1000; + let bob_amount: u64 = 500; + + let leaf_alice = calculate_leaf_hash(&alice, alice_amount); + let leaf_bob = calculate_leaf_hash(&bob, bob_amount); + let merkle_root = calculate_parent_hash(&leaf_alice, &leaf_bob); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 2000)); + + // Bob's proof + let bob_proof = bounded_proof(vec![leaf_alice]); + + // Alice tries to use Bob's proof - should FAIL + assert_noop!( + MerkleAirdrop::claim( + RuntimeOrigin::none(), + 0, + alice, // Alice trying... + bob_amount, // ...with Bob's amount... + bob_proof // ...and Bob's proof + ), + Error::::InvalidProof + ); + }); +} + +#[test] +fn sum_of_claims_never_exceeds_airdrop_balance() { + // INVARIANT: Total claimed ≤ Total funded + // This is critical for solvency + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let account2: u64 = 3; + let account3: u64 = 4; + let amount_each: u64 = 1000; + + // Create 3-user merkle tree + let leaf1 = calculate_leaf_hash(&account1, amount_each); + let leaf2 = calculate_leaf_hash(&account2, amount_each); + let leaf3 = calculate_leaf_hash(&account3, amount_each); + let parent1 = calculate_parent_hash(&leaf1, &leaf2); + let merkle_root = calculate_parent_hash(&parent1, &leaf3); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + + // Fund LESS than total allocations (only 2500 instead of 3000) + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 2500)); + + let initial_balance = MerkleAirdrop::airdrop_info(0).unwrap().balance; + + // First claim - should work + let proof1 = bounded_proof(vec![leaf2, leaf3]); + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, amount_each, proof1)); + + let balance_after_1 = MerkleAirdrop::airdrop_info(0).unwrap().balance; + assert_eq!(balance_after_1, initial_balance - amount_each); + + // Second claim - should work + let proof2 = bounded_proof(vec![leaf1, leaf3]); + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account2, amount_each, proof2)); + + let balance_after_2 = MerkleAirdrop::airdrop_info(0).unwrap().balance; + assert_eq!(balance_after_2, initial_balance - 2 * amount_each); + + // Third claim - should FAIL (insufficient balance) + let proof3 = bounded_proof(vec![parent1]); + assert_noop!( + MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account3, amount_each, proof3), + Error::::InsufficientAirdropBalance + ); + + // Invariant: balance remains consistent (500 left from 2500 - 2*1000) + assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 500); + }); +} + +#[test] +fn single_leaf_merkle_tree_works() { + // EDGE CASE: Airdrop with only 1 recipient (no siblings) + // Proof should be empty array + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let amount: u64 = 1000; + + // Single leaf = leaf hash is also the root + let merkle_root = calculate_leaf_hash(&account1, amount); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); + + // Empty proof for single leaf tree + let proof = bounded_proof(vec![]); + + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, amount, proof)); + + // Verify claim was successful + assert_eq!(MerkleAirdrop::is_claimed(0, account1), ()); + assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 0); + }); +} + +#[test] +fn maximum_depth_merkle_tree_works() { + // EDGE CASE: Deep merkle tree with many proof elements + // Tests gas limits and storage constraints + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let amount: u64 = 1000; + + let leaf = calculate_leaf_hash(&account1, amount); + + // Build proof with multiple levels (testing with 10 levels) + let mut proof_elements = Vec::new(); + let mut current_hash = leaf; + + for i in 0..10 { + let sibling = [i as u8; 32]; // Fake siblings for testing + proof_elements.push(sibling); + current_hash = calculate_parent_hash(¤t_hash, &sibling); + } + + let merkle_root = current_hash; + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); + + let proof = bounded_proof(proof_elements); + + // Should handle deep tree without issues + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, amount, proof)); + + assert_eq!(MerkleAirdrop::is_claimed(0, account1), ()); + }); +} + +#[test] +fn claim_with_zero_amount_should_be_rejected() { + // EDGE CASE: Attempting to claim 0 tokens + // Even with valid proof, this should be rejected or create invalid vesting + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let zero_amount: u64 = 0; + + let leaf1 = calculate_leaf_hash(&account1, zero_amount); + let leaf2 = calculate_leaf_hash(&3, 1000); + let merkle_root = calculate_parent_hash(&leaf1, &leaf2); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); + + let proof = bounded_proof(vec![leaf2]); + + // Zero amount claim will either: + // 1. Fail at vesting creation (expected) + // 2. Fail at proof verification (if amount affects hash) + // 3. Succeed but create meaningless vesting (edge case) + let result = MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, zero_amount, proof); + + // We expect this to fail somehow (either InvalidProof or vesting error) + assert!(result.is_err(), "Zero amount claim should not succeed"); + }); +} + +#[test] +fn last_claim_exactly_zeroes_balance() { + // EDGE CASE: Last user claims exactly remaining balance + // No dust should remain + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let account2: u64 = 3; + let amount1: u64 = 700; + let amount2: u64 = 300; + + let leaf1 = calculate_leaf_hash(&account1, amount1); + let leaf2 = calculate_leaf_hash(&account2, amount2); + let merkle_root = calculate_parent_hash(&leaf1, &leaf2); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + + // Fund exactly the sum of allocations + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, amount1 + amount2)); + + // First claim + let proof1 = bounded_proof(vec![leaf2]); + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, amount1, proof1)); + + assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, amount2); + + // Last claim should zero the balance + let proof2 = bounded_proof(vec![leaf1]); + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account2, amount2, proof2)); + + // Balance should be EXACTLY zero (no dust) + assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 0); + }); +} + +#[test] +fn can_fund_after_partial_claims() { + // SCENARIO: Airdrop is partially claimed, then more funds added + // New claims should work with updated balance + new_test_ext().execute_with(|| { + let account1: u64 = 2; + let account2: u64 = 3; + let amount_each: u64 = 1000; + + let leaf1 = calculate_leaf_hash(&account1, amount_each); + let leaf2 = calculate_leaf_hash(&account2, amount_each); + let merkle_root = calculate_parent_hash(&leaf1, &leaf2); + + assert_ok!(MerkleAirdrop::create_airdrop( + RuntimeOrigin::signed(1), + merkle_root, + None, + None + )); + + // Initial funding (only 1000) + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); + + // First claim - succeeds + let proof1 = bounded_proof(vec![leaf2]); + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account1, amount_each, proof1)); + + assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 0); + + // Second claim would fail due to insufficient balance + let proof2 = bounded_proof(vec![leaf1]); + assert_noop!( + MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account2, amount_each, proof2.clone()), + Error::::InsufficientAirdropBalance + ); + + // Top up the airdrop + assert_ok!(MerkleAirdrop::fund_airdrop(RuntimeOrigin::signed(1), 0, 1000)); + + // Now second claim should succeed + assert_ok!(MerkleAirdrop::claim(RuntimeOrigin::none(), 0, account2, amount_each, proof2)); + + assert_eq!(MerkleAirdrop::airdrop_info(0).unwrap().balance, 0); + }); +} diff --git a/pallets/merkle-airdrop/src/weights.rs b/pallets/merkle-airdrop/src/weights.rs index c0213e38..c6516181 100644 --- a/pallets/merkle-airdrop/src/weights.rs +++ b/pallets/merkle-airdrop/src/weights.rs @@ -18,27 +18,30 @@ //! Autogenerated weights for `pallet_merkle_airdrop` //! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 47.2.0 -//! DATE: 2025-06-24, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2025-12-17, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `MacBook-Pro-4.local`, CPU: `` +//! HOSTNAME: `coldbook.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` // Executed Command: -// frame-omni-bencher -// v1 +// ./target/release/quantus-node // benchmark // pallet // --runtime // ./target/release/wbuild/quantus-runtime/quantus_runtime.wasm // --pallet -// pallet-merkle-airdrop +// pallet_merkle_airdrop // --extrinsic // * +// --steps +// 50 +// --repeat +// 20 +// --output +// ./pallets/merkle-airdrop/src/weights_new.rs // --template // ./.maintain/frame-weight-template.hbs -// --output -// ./pallets/merkle-airdrop/src/weights.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -66,10 +69,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) fn create_airdrop() -> Weight { // Proof Size summary in bytes: - // Measured: `6` + // Measured: `152` // Estimated: `1489` - // Minimum execution time: 7_000_000 picoseconds. - Weight::from_parts(8_000_000, 1489) + // Minimum execution time: 39_000_000 picoseconds. + Weight::from_parts(40_000_000, 1489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -79,10 +82,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn fund_airdrop() -> Weight { // Proof Size summary in bytes: - // Measured: `262` + // Measured: `578` // Estimated: `3593` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(42_000_000, 3593) + // Minimum execution time: 33_000_000 picoseconds. + Weight::from_parts(35_000_000, 3593) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -90,25 +93,19 @@ impl WeightInfo for SubstrateWeight { /// Proof: `MerkleAirdrop::Claimed` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Vesting` (r:1 w:1) - /// Proof: `Vesting::Vesting` (`max_values`: None, `max_size`: Some(1057), added: 3532, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Balances::Locks` (r:1 w:1) - /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) - /// Storage: `Balances::Freezes` (r:1 w:0) - /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) - /// The range of component `p` is `[0, 100]`. + /// The range of component `p` is `[0, 4096]`. fn claim(p: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `441` + // Measured: `626` // Estimated: `6196` - // Minimum execution time: 73_000_000 picoseconds. - Weight::from_parts(74_879_630, 6196) - // Standard Error: 1_851 - .saturating_add(Weight::from_parts(368_666, 0).saturating_mul(p.into())) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(6_u64)) + // Minimum execution time: 39_000_000 picoseconds. + Weight::from_parts(42_521_631, 6196) + // Standard Error: 379 + .saturating_add(Weight::from_parts(298_012, 0).saturating_mul(p.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) } /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) @@ -116,10 +113,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn delete_airdrop() -> Weight { // Proof Size summary in bytes: - // Measured: `262` + // Measured: `498` // Estimated: `3593` - // Minimum execution time: 39_000_000 picoseconds. - Weight::from_parts(39_000_000, 3593) + // Minimum execution time: 31_000_000 picoseconds. + Weight::from_parts(33_000_000, 3593) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -133,10 +130,10 @@ impl WeightInfo for () { /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) fn create_airdrop() -> Weight { // Proof Size summary in bytes: - // Measured: `6` + // Measured: `152` // Estimated: `1489` - // Minimum execution time: 7_000_000 picoseconds. - Weight::from_parts(8_000_000, 1489) + // Minimum execution time: 39_000_000 picoseconds. + Weight::from_parts(40_000_000, 1489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -146,10 +143,10 @@ impl WeightInfo for () { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn fund_airdrop() -> Weight { // Proof Size summary in bytes: - // Measured: `262` + // Measured: `578` // Estimated: `3593` - // Minimum execution time: 40_000_000 picoseconds. - Weight::from_parts(42_000_000, 3593) + // Minimum execution time: 33_000_000 picoseconds. + Weight::from_parts(35_000_000, 3593) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } @@ -157,25 +154,19 @@ impl WeightInfo for () { /// Proof: `MerkleAirdrop::Claimed` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Vesting` (r:1 w:1) - /// Proof: `Vesting::Vesting` (`max_values`: None, `max_size`: Some(1057), added: 3532, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Balances::Locks` (r:1 w:1) - /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) - /// Storage: `Balances::Freezes` (r:1 w:0) - /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) - /// The range of component `p` is `[0, 100]`. + /// The range of component `p` is `[0, 4096]`. fn claim(p: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `441` + // Measured: `626` // Estimated: `6196` - // Minimum execution time: 73_000_000 picoseconds. - Weight::from_parts(74_879_630, 6196) - // Standard Error: 1_851 - .saturating_add(Weight::from_parts(368_666, 0).saturating_mul(p.into())) - .saturating_add(RocksDbWeight::get().reads(7_u64)) - .saturating_add(RocksDbWeight::get().writes(6_u64)) + // Minimum execution time: 39_000_000 picoseconds. + Weight::from_parts(42_521_631, 6196) + // Standard Error: 379 + .saturating_add(Weight::from_parts(298_012, 0).saturating_mul(p.into())) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) } /// Storage: `MerkleAirdrop::AirdropInfo` (r:1 w:1) /// Proof: `MerkleAirdrop::AirdropInfo` (`max_values`: None, `max_size`: Some(110), added: 2585, mode: `MaxEncodedLen`) @@ -183,10 +174,10 @@ impl WeightInfo for () { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn delete_airdrop() -> Weight { // Proof Size summary in bytes: - // Measured: `262` + // Measured: `498` // Estimated: `3593` - // Minimum execution time: 39_000_000 picoseconds. - Weight::from_parts(39_000_000, 3593) + // Minimum execution time: 31_000_000 picoseconds. + Weight::from_parts(33_000_000, 3593) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index d80bd2bf..706c62a3 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -47,7 +47,7 @@ pub mod pallet { #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct VestingSchedule { pub id: u64, // Unique id - pub creator: AccountId, // Who created the scehdule + pub creator: AccountId, // Who created the schedule pub beneficiary: AccountId, // Who gets the tokens pub amount: Balance, // Total tokens to vest pub start: Moment, // When vesting begins @@ -68,6 +68,16 @@ pub mod pallet { #[pallet::storage] pub type ScheduleCounter = StorageValue<_, u64, ValueQuery>; + /// Number of vesting schedules per beneficiary + #[pallet::storage] + pub type BeneficiaryScheduleCount = StorageMap< + _, + Blake2_128Concat, + T::AccountId, // beneficiary + u32, // count of active schedules + ValueQuery, + >; + #[pallet::config] pub trait Config: frame_system::Config>> @@ -76,6 +86,10 @@ pub mod pallet { { type PalletId: Get; type WeightInfo: WeightInfo; + + /// Maximum number of vesting schedules per beneficiary + #[pallet::constant] + type MaxSchedulesPerBeneficiary: Get; } #[pallet::event] @@ -115,6 +129,13 @@ pub mod pallet { ensure!(start < end, Error::::InvalidSchedule); ensure!(amount > T::Balance::zero(), Error::::InvalidSchedule); + // Check if beneficiary has reached the maximum number of schedules + let current_count = BeneficiaryScheduleCount::::get(&beneficiary); + ensure!( + current_count < T::MaxSchedulesPerBeneficiary::get(), + Error::::TooManySchedules + ); + // Transfer tokens from caller to pallet and lock them pallet_balances::Pallet::::transfer(&who, &Self::account_id(), amount, KeepAlive)?; @@ -135,6 +156,11 @@ pub mod pallet { }; VestingSchedules::::insert(schedule_id, schedule); + // Increment beneficiary schedule count + BeneficiaryScheduleCount::::mutate(&beneficiary, |count| { + *count = count.saturating_add(1); + }); + Self::deposit_event(Event::VestingScheduleCreated( beneficiary, amount, @@ -199,8 +225,16 @@ pub mod pallet { )?; } + // Store beneficiary before removing schedule + let beneficiary = schedule.beneficiary.clone(); + VestingSchedules::::remove(schedule_id); + // Decrement beneficiary schedule count + BeneficiaryScheduleCount::::mutate(&beneficiary, |count| { + *count = count.saturating_sub(1); + }); + // Emit event Self::deposit_event(Event::VestingScheduleCancelled(who, schedule_id)); Ok(()) @@ -221,6 +255,13 @@ pub mod pallet { ensure!(cliff < end, Error::::InvalidSchedule); ensure!(amount > T::Balance::zero(), Error::::InvalidSchedule); + // Check if beneficiary has reached the maximum number of schedules + let current_count = BeneficiaryScheduleCount::::get(&beneficiary); + ensure!( + current_count < T::MaxSchedulesPerBeneficiary::get(), + Error::::TooManySchedules + ); + // Transfer tokens from caller to pallet and lock them pallet_balances::Pallet::::transfer(&who, &Self::account_id(), amount, KeepAlive)?; @@ -241,6 +282,11 @@ pub mod pallet { }; VestingSchedules::::insert(schedule_id, schedule); + // Increment beneficiary schedule count + BeneficiaryScheduleCount::::mutate(&beneficiary, |count| { + *count = count.saturating_add(1); + }); + Self::deposit_event(Event::VestingScheduleCreated( beneficiary, amount, @@ -271,6 +317,13 @@ pub mod pallet { let duration = end.saturating_sub(start); ensure!(duration >= step_duration, Error::::InvalidSchedule); + // Check if beneficiary has reached the maximum number of schedules + let current_count = BeneficiaryScheduleCount::::get(&beneficiary); + ensure!( + current_count < T::MaxSchedulesPerBeneficiary::get(), + Error::::TooManySchedules + ); + // Transfer tokens from caller to pallet and lock them pallet_balances::Pallet::::transfer(&who, &Self::account_id(), amount, KeepAlive)?; @@ -291,6 +344,11 @@ pub mod pallet { }; VestingSchedules::::insert(schedule_id, schedule); + // Increment beneficiary schedule count + BeneficiaryScheduleCount::::mutate(&beneficiary, |count| { + *count = count.saturating_add(1); + }); + Self::deposit_event(Event::VestingScheduleCreated( beneficiary, amount, diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs index c6558e26..85e3ed1e 100644 --- a/pallets/vesting/src/mock.rs +++ b/pallets/vesting/src/mock.rs @@ -105,12 +105,13 @@ impl pallet_timestamp::Config for Test { // Vesting config parameter_types! { pub const VestingPalletId: PalletId = PalletId(*b"vestpal_"); - pub const MaxSchedules: u32 = 100; + pub const MaxSchedulesPerBeneficiary: u32 = 50; } impl pallet_vesting::Config for Test { type PalletId = VestingPalletId; type WeightInfo = (); + type MaxSchedulesPerBeneficiary = MaxSchedulesPerBeneficiary; } // Helper to build genesis storage diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index d7a4128b..8e84ed4c 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -852,3 +852,277 @@ fn stepped_vesting_invalid_step_duration_fails() { ); }); } + +// ========== Schedule Limit Tests ========== + +#[test] +fn schedule_count_increments_on_create() { + new_test_ext().execute_with(|| { + let creator = 1; + let beneficiary = 2; + + // Initial count should be 0 + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 0); + + // Create first schedule + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 1); + + // Create second schedule + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 2); + }); +} + +#[test] +fn schedule_count_decrements_on_cancel() { + new_test_ext().execute_with(|| { + let creator = 1; + let beneficiary = 2; + + // Create two schedules + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 2); + + // Cancel first schedule + assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(creator), 1)); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 1); + + // Cancel second schedule + assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(creator), 2)); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 0); + }); +} + +#[test] +fn cannot_exceed_max_schedules_per_beneficiary() { + new_test_ext().execute_with(|| { + let creator = 1; + let beneficiary = 2; + + // MaxSchedulesPerBeneficiary is 50 in mock + // Create 50 schedules (should succeed) + for i in 0..50 { + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000 + i as u64, + 2000 + i as u64 + )); + } + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + + // Try to create 51st schedule (should fail) + assert_noop!( + Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + ), + Error::::TooManySchedules + ); + }); +} + +#[test] +fn limit_applies_per_beneficiary() { + new_test_ext().execute_with(|| { + let creator = 1; + let beneficiary1 = 2; + let beneficiary2 = 3; + + // Create 50 schedules for beneficiary1 + for _ in 0..50 { + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary1, + 1000, + 1000, + 2000 + )); + } + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary1), 50); + + // beneficiary1 is at limit + assert_noop!( + Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary1, + 1000, + 1000, + 2000 + ), + Error::::TooManySchedules + ); + + // But beneficiary2 should still be able to create schedules + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary2, + 1000, + 1000, + 2000 + )); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary2), 1); + }); +} + +#[test] +fn limit_applies_to_all_vesting_types() { + new_test_ext().execute_with(|| { + let creator = 1; + let beneficiary = 2; + + // Create 48 linear schedules + for _ in 0..48 { + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + } + + // Create 1 cliff schedule + assert_ok!(Vesting::create_vesting_schedule_with_cliff( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1500, + 2000 + )); + + // Create 1 stepped schedule (total = 50) + assert_ok!(Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000, + 100 + )); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + + // Any type should now fail + assert_noop!( + Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + ), + Error::::TooManySchedules + ); + + assert_noop!( + Vesting::create_vesting_schedule_with_cliff( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1500, + 2000 + ), + Error::::TooManySchedules + ); + + assert_noop!( + Vesting::create_stepped_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000, + 100 + ), + Error::::TooManySchedules + ); + }); +} + +#[test] +fn can_create_more_after_cancelling() { + new_test_ext().execute_with(|| { + let creator = 1; + let beneficiary = 2; + + // Create 50 schedules (at limit) + for _ in 0..50 { + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + } + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + + // Cannot create more + assert_noop!( + Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + ), + Error::::TooManySchedules + ); + + // Cancel one schedule + assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(creator), 1)); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 49); + + // Now can create one more + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(creator), + beneficiary, + 1000, + 1000, + 2000 + )); + + assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + }); +} diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs index b3506740..aa2bb3f7 100644 --- a/pallets/vesting/src/weights.rs +++ b/pallets/vesting/src/weights.rs @@ -1,68 +1,237 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +//! Autogenerated weights for `pallet_vesting` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2025-12-17, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `coldbook.local`, CPU: `` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/quantus-node +// benchmark +// pallet +// --runtime +// ./target/release/wbuild/quantus-runtime/quantus_runtime.wasm +// --pallet +// pallet_vesting +// --extrinsic +// * +// --steps +// 50 +// --repeat +// 20 +// --output +// ./pallets/vesting/src/weights_new.rs +// --template +// ./.maintain/frame-weight-template.hbs + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] -use core::marker::PhantomData; use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; -/// Weight functions for `pallet_vesting`. +/// Weight functions needed for `pallet_vesting`. pub trait WeightInfo { - fn create_vesting_schedule() -> Weight; - fn claim() -> Weight; - fn cancel_vesting_schedule() -> Weight; - fn create_vesting_schedule_with_cliff() -> Weight; - fn create_stepped_vesting_schedule() -> Weight; + fn create_vesting_schedule() -> Weight; + fn claim() -> Weight; + fn cancel_vesting_schedule() -> Weight; + fn create_vesting_schedule_with_cliff() -> Weight; + fn create_stepped_vesting_schedule() -> Weight; } -/// Weights for `pallet_mining_rewards` using the Substrate node and recommended hardware. +/// Weights for `pallet_vesting` using the Substrate node and recommended hardware. pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { - fn create_vesting_schedule() -> Weight { - Weight::from_parts(10_000, 0) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) - } - - fn claim() -> Weight { - Weight::from_parts(15_000, 0) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - - fn cancel_vesting_schedule() -> Weight { - Weight::from_parts(15_000, 0) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - - fn create_vesting_schedule_with_cliff() -> Weight { - Weight::from_parts(10_000, 0) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) - } - - fn create_stepped_vesting_schedule() -> Weight { - Weight::from_parts(10_000, 0) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) - } + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::ScheduleCounter` (r:1 w:1) + /// Proof: `Vesting::ScheduleCounter` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::VestingSchedules` (r:0 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + fn create_vesting_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `272` + // Estimated: `6196` + // Minimum execution time: 69_000_000 picoseconds. + Weight::from_parts(70_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vesting::VestingSchedules` (r:1 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn claim() -> Weight { + // Proof Size summary in bytes: + // Measured: `985` + // Estimated: `6196` + // Minimum execution time: 71_000_000 picoseconds. + Weight::from_parts(73_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `Vesting::VestingSchedules` (r:1 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:3 w:3) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + fn cancel_vesting_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `1161` + // Estimated: `8799` + // Minimum execution time: 141_000_000 picoseconds. + Weight::from_parts(142_000_000, 8799) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::ScheduleCounter` (r:1 w:1) + /// Proof: `Vesting::ScheduleCounter` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::VestingSchedules` (r:0 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + fn create_vesting_schedule_with_cliff() -> Weight { + // Proof Size summary in bytes: + // Measured: `272` + // Estimated: `6196` + // Minimum execution time: 69_000_000 picoseconds. + Weight::from_parts(70_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::ScheduleCounter` (r:1 w:1) + /// Proof: `Vesting::ScheduleCounter` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::VestingSchedules` (r:0 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + fn create_stepped_vesting_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `272` + // Estimated: `6196` + // Minimum execution time: 62_000_000 picoseconds. + Weight::from_parts(70_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } } -// For tests +// For backwards compatibility and tests. impl WeightInfo for () { - fn create_vesting_schedule() -> Weight { - Weight::from_parts(10_000, 0) - } - fn claim() -> Weight { - Weight::from_parts(10_000, 0) - } - fn cancel_vesting_schedule() -> Weight { - Weight::from_parts(10_000, 0) - } - fn create_vesting_schedule_with_cliff() -> Weight { - Weight::from_parts(10_000, 0) - } - fn create_stepped_vesting_schedule() -> Weight { - Weight::from_parts(10_000, 0) - } -} \ No newline at end of file + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::ScheduleCounter` (r:1 w:1) + /// Proof: `Vesting::ScheduleCounter` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::VestingSchedules` (r:0 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + fn create_vesting_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `272` + // Estimated: `6196` + // Minimum execution time: 69_000_000 picoseconds. + Weight::from_parts(70_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vesting::VestingSchedules` (r:1 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn claim() -> Weight { + // Proof Size summary in bytes: + // Measured: `985` + // Estimated: `6196` + // Minimum execution time: 71_000_000 picoseconds. + Weight::from_parts(73_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: `Vesting::VestingSchedules` (r:1 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:3 w:3) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + fn cancel_vesting_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `1161` + // Estimated: `8799` + // Minimum execution time: 141_000_000 picoseconds. + Weight::from_parts(142_000_000, 8799) + .saturating_add(RocksDbWeight::get().reads(6_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::ScheduleCounter` (r:1 w:1) + /// Proof: `Vesting::ScheduleCounter` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::VestingSchedules` (r:0 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + fn create_vesting_schedule_with_cliff() -> Weight { + // Proof Size summary in bytes: + // Measured: `272` + // Estimated: `6196` + // Minimum execution time: 69_000_000 picoseconds. + Weight::from_parts(70_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Vesting::BeneficiaryScheduleCount` (r:1 w:1) + /// Proof: `Vesting::BeneficiaryScheduleCount` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::ScheduleCounter` (r:1 w:1) + /// Proof: `Vesting::ScheduleCounter` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::VestingSchedules` (r:0 w:1) + /// Proof: `Vesting::VestingSchedules` (`max_values`: None, `max_size`: Some(153), added: 2628, mode: `MaxEncodedLen`) + fn create_stepped_vesting_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `272` + // Estimated: `6196` + // Minimum execution time: 62_000_000 picoseconds. + Weight::from_parts(70_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } +} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 7e6685dd..ca6c9d9c 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -427,11 +427,13 @@ impl pallet_sudo::Config for Runtime { parameter_types! { pub const VestingPalletId: PalletId = PalletId(*b"vestingp"); + pub const MaxSchedulesPerBeneficiary: u32 = 50; } impl pallet_vesting::Config for Runtime { type PalletId = VestingPalletId; type WeightInfo = pallet_vesting::weights::SubstrateWeight; + type MaxSchedulesPerBeneficiary = MaxSchedulesPerBeneficiary; } impl pallet_utility::Config for Runtime { diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs index 02f7700f..45076d6d 100644 --- a/runtime/tests/governance/vesting.rs +++ b/runtime/tests/governance/vesting.rs @@ -4,19 +4,23 @@ mod tests { use codec::Encode; use frame_support::{ assert_ok, - traits::{Bounded, Currency, VestingSchedule}, + traits::{Bounded, Currency}, }; use pallet_conviction_voting::{AccountVote, Vote}; - use pallet_vesting::VestingInfo; use quantus_runtime::{ Balances, ConvictionVoting, Preimage, Referenda, RuntimeCall, RuntimeOrigin, System, - Utility, Vesting, DAYS, UNIT, + Timestamp, Utility, Vesting, UNIT, }; use sp_runtime::{ traits::{BlakeTwo256, Hash}, MultiAddress, }; + // Timestamp constants (in milliseconds) + const MINUTE_MS: u64 = 60 * 1000; + const HOUR_MS: u64 = 60 * MINUTE_MS; + const DAY_MS: u64 = 24 * HOUR_MS; + /// Test case: Grant application through referendum with vesting payment schedule /// /// Scenario: @@ -35,15 +39,10 @@ mod tests { // Give voters some balance for voting Balances::make_free_balance_be(&voter1, 1000 * UNIT); Balances::make_free_balance_be(&voter2, 1000 * UNIT); - Balances::make_free_balance_be(&proposer, 10000 * UNIT); // Proposer needs more funds for vesting transfer + Balances::make_free_balance_be(&proposer, 10000 * UNIT); // Step 1: Create a treasury proposal for referendum let grant_amount = 1000 * UNIT; - let vesting_period = 30; // Fast test: 30 blocks instead of 30 days - let per_block = grant_amount / vesting_period as u128; - - // Create the vesting info for later implementation - let vesting_info = VestingInfo::new(grant_amount, per_block, 1); // Treasury call for referendum approval let treasury_call = RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { @@ -53,14 +52,7 @@ mod tests { valid_from: None, }); - // Note: Two-stage process - referendum approves principle, implementation follows - let _vesting_call = RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(beneficiary.clone()), - schedule: vesting_info, - }); - // Two-stage governance flow: referendum approves treasury spend principle - // Implementation details (like vesting schedule) handled in separate execution phase let referendum_call = treasury_call; // Step 2: Submit preimage for the referendum call @@ -113,89 +105,35 @@ mod tests { )); // Step 5: Wait for referendum to pass and execute - // Fast forward blocks for voting period + confirmation period (using fast governance - // timing) - let blocks_to_advance = 2 + 2 + 2 + 2 + 1; // prepare + decision + confirm + enactment + 1 + let blocks_to_advance = 2 + 2 + 2 + 2 + 1; TestCommons::run_to_block(System::block_number() + blocks_to_advance); - // The referendum should now be approved and treasury spend executed - - // Step 6: Implementation phase - after referendum approval, implement with vesting - // This demonstrates a realistic two-stage governance pattern: - // 1. Community votes on grant approval (principle) - // 2. Treasury council/governance implements with appropriate safeguards (vesting) - // This separation allows for community input on allocation while maintaining - // implementation flexibility - println!("Referendum approved treasury spend. Now implementing vesting..."); - // Implementation of the approved grant with vesting schedule - // This would typically be done by treasury council or automated system - assert_ok!(Vesting::force_vested_transfer( - RuntimeOrigin::root(), - MultiAddress::Id(proposer.clone()), - MultiAddress::Id(beneficiary.clone()), - vesting_info, - )); - - let initial_balance = Balances::free_balance(&beneficiary); - let locked_balance = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - - println!("Beneficiary balance: {:?}", initial_balance); - println!("Locked balance: {:?}", locked_balance); - - assert!(locked_balance > 0, "Vesting should have been created"); - - // Step 7: Test vesting unlock over time - let initial_block = System::block_number(); - let initial_locked_amount = locked_balance; // Save the initial locked amount - - // Check initial state - println!("Initial balance: {:?}", initial_balance); - println!("Initial locked: {:?}", locked_balance); - println!("Initial block: {:?}", initial_block); - - // Fast forward a few blocks and check unlocking - TestCommons::run_to_block(initial_block + 10); - - // Check after some blocks - let mid_balance = Balances::free_balance(&beneficiary); - let mid_locked = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - - println!("Mid balance: {:?}", mid_balance); - println!("Mid locked: {:?}", mid_locked); + // Step 6: Implementation phase - create vesting schedule using custom vesting pallet + let now = Timestamp::get(); + let vesting_duration = 30 * DAY_MS; // 30 days vesting + let start_time = now; + let end_time = now + vesting_duration; - // The test should pass if vesting is working correctly - // mid_locked should be less than the initial locked amount - assert!( - mid_locked < initial_locked_amount, - "Some funds should be unlocked over time: initial_locked={:?}, mid_locked={:?}", - initial_locked_amount, - mid_locked - ); - - // Fast-forward to end of vesting period - TestCommons::run_to_block(initial_block + vesting_period + 1); - - // All funds should be unlocked - let final_balance = Balances::free_balance(&beneficiary); - let final_locked = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - - println!("Final balance: {:?}", final_balance); - println!("Final locked: {:?}", final_locked); + // Create vesting schedule from proposer to beneficiary + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(proposer.clone()), + beneficiary.clone(), + grant_amount, + start_time, + end_time, + )); - assert_eq!(final_locked, 0, "All funds should be unlocked"); - // Note: In the vesting pallet, when funds are fully vested, they become available - // but the balance might not increase if the initial transfer was part of the vesting - // The main assertion is that the vesting worked correctly (final_locked == 0) - println!("Vesting test completed successfully - funds are fully unlocked"); + println!("Vesting schedule created successfully"); + println!("Note: Custom vesting uses timestamp-based vesting with manual claiming"); + println!("In production, beneficiary would claim() after vesting period passes"); }); } /// Test case: Multi-milestone grant with multiple vesting schedules /// /// Scenario: Grant paid out in multiple tranches (milestones) - /// after achieving specific goals #[test] fn test_milestone_based_grant_with_multiple_vesting() { TestCommons::new_fast_governance_test_ext().execute_with(|| { @@ -204,82 +142,127 @@ mod tests { Balances::make_free_balance_be(&grantor, 10000 * UNIT); - // Atomic milestone funding: all operations succeed or fail together let milestone1_amount = 300 * UNIT; let milestone2_amount = 400 * UNIT; let milestone3_amount = 300 * UNIT; - let milestone1_vesting = VestingInfo::new(milestone1_amount, milestone1_amount / 30, 1); - let milestone2_vesting = - VestingInfo::new(milestone2_amount, milestone2_amount / 60, 31); - - // Create batch call for all milestone operations - let _milestone_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Milestone 1: Initial funding with short vesting - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone1_vesting, - }), - // Milestone 2: Mid-term funding with longer vesting - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone2_vesting, - }), - // Milestone 3: Immediate payment - RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { - dest: MultiAddress::Id(grantee.clone()), - value: milestone3_amount, - }), - ], - }); + let now = Timestamp::get(); - // Execute all milestones atomically + // Create multiple vesting schedules for different milestones let calls = vec![ - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone1_vesting, + // Milestone 1: Short vesting (30 days) + RuntimeCall::Vesting(pallet_vesting::Call::create_vesting_schedule { + beneficiary: grantee.clone(), + amount: milestone1_amount, + start: now, + end: now + 30 * DAY_MS, }), - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: milestone2_vesting, + // Milestone 2: Longer vesting (60 days) + RuntimeCall::Vesting(pallet_vesting::Call::create_vesting_schedule { + beneficiary: grantee.clone(), + amount: milestone2_amount, + start: now + 31 * DAY_MS, + end: now + 91 * DAY_MS, }), + // Milestone 3: Immediate payment RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death { dest: MultiAddress::Id(grantee.clone()), value: milestone3_amount, }), ]; - assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantor.clone()), calls)); - // Check that multiple vesting schedules are active - let vesting_schedules = Vesting::vesting(grantee.clone()).unwrap(); - assert_eq!(vesting_schedules.len(), 2, "Should have 2 active vesting schedules"); + assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantor.clone()), calls)); - // Fast forward and verify unlocking patterns - TestCommons::run_to_block(40); // Past first vesting period + println!("Multiple milestone vesting schedules created successfully"); - let balance_after_first = Balances::free_balance(&grantee); + // Verify grantee received immediate payment + let initial_balance = Balances::free_balance(&grantee); assert!( - balance_after_first >= milestone1_amount + milestone3_amount, - "First milestone and immediate payment should be available" + initial_balance >= milestone3_amount, + "Immediate milestone payment should be available" ); - // Fast forward past second vesting period - TestCommons::run_to_block(100); + println!("Multi-milestone grant test completed - schedules created successfully"); + }); + } - let final_balance = Balances::free_balance(&grantee); - let expected_total = milestone1_amount + milestone2_amount + milestone3_amount; - assert!(final_balance >= expected_total, "All grant funds should be available"); + /// Test case: Treasury proposal with vesting integration + #[test] + fn test_treasury_auto_vesting_integration() { + TestCommons::new_fast_governance_test_ext().execute_with(|| { + let beneficiary = TestCommons::account_id(1); + let treasury = TestCommons::account_id(2); + let amount = 1000 * UNIT; + + Balances::make_free_balance_be(&treasury, 5000 * UNIT); + + let now = Timestamp::get(); + let vesting_duration = 30 * DAY_MS; + + // In practice, treasury would be funded separately + // We simulate treasury having funds by making it a signed account + // Then treasury creates vesting schedule + + // Treasury creates vesting schedule for beneficiary + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(treasury.clone()), + beneficiary.clone(), + amount, + now, + now + vesting_duration, + )); + + println!("Treasury + vesting integration successful"); + println!("Treasury can create vesting schedules for approved grants"); }); } - /// Test case: Realistic grant process with Tech Collective milestone evaluation + /// Test case: Emergency vesting cancellation /// - /// Scenario: - /// 1. Initial referendum approves entire grant plan - /// 2. For each milestone: grantee delivers proof → Tech Collective votes via referenda → - /// payment released - /// 3. Tech Collective determines vesting schedule based on milestone quality/risk assessment + /// Scenario: Creator can cancel vesting schedule and recover remaining funds + #[test] + fn test_emergency_vesting_cancellation() { + TestCommons::new_fast_governance_test_ext().execute_with(|| { + let grantee = TestCommons::account_id(1); + let grantor = TestCommons::account_id(2); + + Balances::make_free_balance_be(&grantor, 2000 * UNIT); + + let total_amount = 1000 * UNIT; + let now = Timestamp::get(); + let vesting_duration = 100 * DAY_MS; + + // Create vesting schedule + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(grantor.clone()), + grantee.clone(), + total_amount, + now, + now + vesting_duration, + )); + + println!("Vesting schedule created"); + + let grantor_balance_before_cancel = Balances::free_balance(&grantor); + + // Emergency: creator cancels the vesting schedule + // In custom vesting, cancel will automatically claim for beneficiary first, + // then return unclaimed funds to creator + assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(grantor.clone()), 1)); + + let grantor_balance_after_cancel = Balances::free_balance(&grantor); + + // Grantor should have recovered funds (minus any claimed by beneficiary) + assert!( + grantor_balance_after_cancel >= grantor_balance_before_cancel, + "Creator should recover remaining funds after cancellation" + ); + + println!("Emergency cancellation successful - creator can cancel and recover funds"); + }); + } + + /// Test case: Progressive milestone governance with Tech Collective #[test] fn test_progressive_milestone_referenda() { TestCommons::new_fast_governance_test_ext().execute_with(|| { @@ -287,14 +270,12 @@ mod tests { let proposer = TestCommons::account_id(2); let voter1 = TestCommons::account_id(3); let voter2 = TestCommons::account_id(4); - - // Tech Collective members - technical experts who evaluate milestones let tech_member1 = TestCommons::account_id(5); let tech_member2 = TestCommons::account_id(6); let tech_member3 = TestCommons::account_id(7); let treasury_account = TestCommons::account_id(8); - // Setup balances for governance participation + // Setup balances Balances::make_free_balance_be(&voter1, 2000 * UNIT); Balances::make_free_balance_be(&voter2, 2000 * UNIT); Balances::make_free_balance_be(&proposer, 15000 * UNIT); @@ -351,7 +332,7 @@ mod tests { frame_support::traits::schedule::DispatchTime::After(1) )); - // Community votes on the grant plan + // Community votes assert_ok!(ConvictionVoting::vote( RuntimeOrigin::signed(voter1.clone()), 0, @@ -376,81 +357,46 @@ mod tests { } )); - let blocks_to_advance = 2 + 2 + 2 + 2 + 1; // Fast governance timing: prepare + decision + confirm + enactment + 1 + let blocks_to_advance = 2 + 2 + 2 + 2 + 1; TestCommons::run_to_block(System::block_number() + blocks_to_advance); println!("✅ Grant plan approved by referendum!"); - // === STEP 2: Tech Collective milestone evaluations via referenda === + // === STEP 2: Tech Collective milestone evaluations === + let now = Timestamp::get(); - // === MILESTONE 1: Tech Collective Decision === println!("=== MILESTONE 1: Tech Collective Decision ==="); - - println!("📋 Grantee delivers milestone 1: Basic protocol implementation"); TestCommons::run_to_block(System::block_number() + 10); - // Tech Collective evaluates and decides on milestone 1 payment - let milestone1_vesting = VestingInfo::new( + // Tech Collective creates vesting for milestone 1 (60-day vesting) + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(treasury_account.clone()), + grantee.clone(), milestone1_amount, - milestone1_amount / 60, // Fast test: 60 blocks instead of 60 days - System::block_number() + 1, - ); - - println!("🔍 Tech Collective evaluates milestone 1..."); - - // Tech Collective implements milestone payment directly (as technical body with - // authority) In practice this could be through their own governance or automated - // after technical review - assert_ok!(Vesting::force_vested_transfer( - RuntimeOrigin::root(), /* Tech Collective has root-level authority for technical - * decisions */ - MultiAddress::Id(treasury_account.clone()), - MultiAddress::Id(grantee.clone()), - milestone1_vesting, + now, + now + 60 * DAY_MS, )); println!("✅ Tech Collective approved milestone 1 with 60-day vesting"); - let milestone1_locked = Vesting::vesting_balance(&grantee).unwrap_or(0); - println!("Grantee locked (vesting): {:?}", milestone1_locked); - assert!(milestone1_locked > 0, "Milestone 1 should be vesting"); - - // === MILESTONE 2: Tech Collective Decision === println!("=== MILESTONE 2: Tech Collective Decision ==="); - TestCommons::run_to_block(System::block_number() + 20); - println!("📋 Grantee delivers milestone 2: Advanced features + benchmarks"); - // Reduced vesting due to high quality - let milestone2_vesting = VestingInfo::new( + // Milestone 2 with reduced vesting (30 days) due to good quality + assert_ok!(Vesting::create_vesting_schedule( + RuntimeOrigin::signed(treasury_account.clone()), + grantee.clone(), milestone2_amount, - milestone2_amount / 30, // Fast test: 30 blocks instead of 30 days - System::block_number() + 1, - ); - - println!("🔍 Tech Collective evaluates milestone 2 (high quality work)..."); - - // Tech Collective approves with reduced vesting due to excellent work - assert_ok!(Vesting::force_vested_transfer( - RuntimeOrigin::root(), - MultiAddress::Id(treasury_account.clone()), - MultiAddress::Id(grantee.clone()), - milestone2_vesting, + now + 20 * DAY_MS, + now + 50 * DAY_MS, )); println!("✅ Tech Collective approved milestone 2 with reduced 30-day vesting"); - // === MILESTONE 3: Final Tech Collective Decision === println!("=== MILESTONE 3: Final Tech Collective Decision ==="); - TestCommons::run_to_block(System::block_number() + 20); - println!( - "📋 Grantee delivers final milestone: Production deployment + maintenance plan" - ); - println!("🔍 Tech Collective evaluates final milestone (project completion)..."); - - // Immediate payment for completed project - no vesting needed + // Final milestone - immediate payment assert_ok!(Balances::transfer_allow_death( RuntimeOrigin::signed(treasury_account.clone()), MultiAddress::Id(grantee.clone()), @@ -459,161 +405,17 @@ mod tests { println!("✅ Tech Collective approved final milestone with immediate payment"); - // === Verify Tech Collective governance worked === + // Verify governance worked let final_balance = Balances::free_balance(&grantee); - let remaining_locked = Vesting::vesting_balance(&grantee).unwrap_or(0); - - println!("Final grantee balance: {:?}", final_balance); - println!("Remaining locked: {:?}", remaining_locked); - - let vesting_schedules = Vesting::vesting(grantee.clone()).unwrap_or_default(); - assert!( - !vesting_schedules.is_empty(), - "Should have active vesting schedules from Tech Collective decisions" - ); - assert!( final_balance >= milestone3_amount, - "Tech Collective milestone process should have provided controlled funding" + "Tech Collective process should have provided controlled funding" ); println!("🎉 Tech Collective governance process completed successfully!"); - println!(" - One community referendum approved the overall grant plan"); - println!(" - Tech Collective evaluated each milestone with technical expertise"); - println!(" - Vesting schedules determined by technical quality assessment:"); - println!(" * Milestone 1: 60-day vesting (conservative, early stage)"); - println!(" * Milestone 2: 30-day vesting (high confidence, quality work)"); - println!(" * Milestone 3: Immediate payment (project completed successfully)"); - }); - } - - /// Test case: Treasury proposal with automatic vesting integration - /// - /// Scenario: Treasury spend and vesting creation executed atomically - /// through batch calls for integrated fund management - #[test] - fn test_treasury_auto_vesting_integration() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - let beneficiary = TestCommons::account_id(1); - let amount = 1000 * UNIT; - - // Create atomic treasury spend + vesting creation through batch calls - let vesting_info = VestingInfo::new(amount, amount / (30 * DAYS) as u128, 1); - - let _treasury_vesting_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Treasury spend - RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { - asset_kind: Box::new(()), - amount, - beneficiary: Box::new(MultiAddress::Id(beneficiary.clone())), - valid_from: None, - }), - // Vesting creation as part of same atomic transaction - RuntimeCall::Vesting(pallet_vesting::Call::force_vested_transfer { - source: MultiAddress::Id(beneficiary.clone()), /* Simplified - in - * practice treasury - * account */ - target: MultiAddress::Id(beneficiary.clone()), - schedule: vesting_info, - }), - ], - }); - - // Execute atomic treasury spend + vesting batch - let calls = vec![ - RuntimeCall::TreasuryPallet(pallet_treasury::Call::spend { - asset_kind: Box::new(()), - amount, - beneficiary: Box::new(MultiAddress::Id(beneficiary.clone())), - valid_from: None, - }), - RuntimeCall::Vesting(pallet_vesting::Call::force_vested_transfer { - source: MultiAddress::Id(beneficiary.clone()), - target: MultiAddress::Id(beneficiary.clone()), - schedule: vesting_info, - }), - ]; - assert_ok!(Utility::batch_all(RuntimeOrigin::root(), calls)); - - // Verify the integration worked - let locked_amount = Vesting::vesting_balance(&beneficiary).unwrap_or(0); - assert!(locked_amount > 0, "Vesting should be active"); - }); - } - - /// Test case: Emergency vesting operations with batch calls - /// - /// Scenario: Emergency handling of vesting schedules through - /// atomic batch operations for intervention scenarios - #[test] - fn test_emergency_vesting_cancellation() { - TestCommons::new_fast_governance_test_ext().execute_with(|| { - let grantee = TestCommons::account_id(1); - let grantor = TestCommons::account_id(2); - - Balances::make_free_balance_be(&grantor, 2000 * UNIT); - - // Create vesting schedule with atomic batch call setup - let total_amount = 1000 * UNIT; - let vesting_info = VestingInfo::new(total_amount, total_amount / 100, 1); - - // Example of comprehensive grant setup through batch operations - let _grant_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Initial grant setup - RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: vesting_info, - }), - // Could include additional setup calls (metadata, tracking, etc.) - ], - }); - - let calls = vec![RuntimeCall::Vesting(pallet_vesting::Call::vested_transfer { - target: MultiAddress::Id(grantee.clone()), - schedule: vesting_info, - })]; - assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantor.clone()), calls)); - - // Let some time pass and some funds unlock - TestCommons::run_to_block(50); - - let balance_before_cancellation = Balances::free_balance(&grantee); - let locked_before = Vesting::vesting_balance(&grantee).unwrap_or(0); - - assert!(locked_before > 0, "Should still have locked funds"); - - // Emergency intervention through atomic batch operations - let _emergency_batch = RuntimeCall::Utility(pallet_utility::Call::batch_all { - calls: vec![ - // Emergency action: schedule management operations - RuntimeCall::Vesting(pallet_vesting::Call::merge_schedules { - schedule1_index: 0, - schedule2_index: 0, - }), - // Could include additional emergency measures like fund recovery or - // notifications - ], - }); - - // Execute emergency intervention if vesting exists - if !Vesting::vesting(grantee.clone()).unwrap().is_empty() { - let calls = vec![RuntimeCall::Vesting(pallet_vesting::Call::merge_schedules { - schedule1_index: 0, - schedule2_index: 0, - })]; - assert_ok!(Utility::batch_all(RuntimeOrigin::signed(grantee.clone()), calls)); - } - - let balance_after = Balances::free_balance(&grantee); - - // Verify that emergency operations maintained system integrity - // (In practice, this would involve more sophisticated intervention mechanisms) - assert!( - balance_after >= balance_before_cancellation, - "Emergency handling should maintain or improve user's position" - ); + println!(" - Community referendum approved overall grant plan"); + println!(" - Tech Collective evaluated each milestone"); + println!(" - Vesting schedules created based on quality assessment"); }); } } From b6560d9fdc5e8028be7dbca5db3f40d599bc1f54 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Wed, 17 Dec 2025 13:06:28 +0800 Subject: [PATCH 4/5] fix: Vesting - clippy --- pallets/vesting/src/tests.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index 8e84ed4c..6dce1a2c 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -862,7 +862,7 @@ fn schedule_count_increments_on_create() { let beneficiary = 2; // Initial count should be 0 - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 0); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 0); // Create first schedule assert_ok!(Vesting::create_vesting_schedule( @@ -873,7 +873,7 @@ fn schedule_count_increments_on_create() { 2000 )); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 1); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 1); // Create second schedule assert_ok!(Vesting::create_vesting_schedule( @@ -884,7 +884,7 @@ fn schedule_count_increments_on_create() { 2000 )); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 2); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 2); }); } @@ -910,17 +910,17 @@ fn schedule_count_decrements_on_cancel() { 2000 )); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 2); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 2); // Cancel first schedule assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(creator), 1)); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 1); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 1); // Cancel second schedule assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(creator), 2)); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 0); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 0); }); } @@ -942,7 +942,7 @@ fn cannot_exceed_max_schedules_per_beneficiary() { )); } - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 50); // Try to create 51st schedule (should fail) assert_noop!( @@ -976,7 +976,7 @@ fn limit_applies_per_beneficiary() { )); } - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary1), 50); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary1), 50); // beneficiary1 is at limit assert_noop!( @@ -999,7 +999,7 @@ fn limit_applies_per_beneficiary() { 2000 )); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary2), 1); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary2), 1); }); } @@ -1039,7 +1039,7 @@ fn limit_applies_to_all_vesting_types() { 100 )); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 50); // Any type should now fail assert_noop!( @@ -1095,7 +1095,7 @@ fn can_create_more_after_cancelling() { )); } - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 50); // Cannot create more assert_noop!( @@ -1112,7 +1112,7 @@ fn can_create_more_after_cancelling() { // Cancel one schedule assert_ok!(Vesting::cancel_vesting_schedule(RuntimeOrigin::signed(creator), 1)); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 49); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 49); // Now can create one more assert_ok!(Vesting::create_vesting_schedule( @@ -1123,6 +1123,6 @@ fn can_create_more_after_cancelling() { 2000 )); - assert_eq!(BeneficiaryScheduleCount::::get(&beneficiary), 50); + assert_eq!(BeneficiaryScheduleCount::::get(beneficiary), 50); }); } From 72cbdb0a3a99e4d1dc96a4da0bb7ced5fe6fc78c Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 18 Dec 2025 12:46:55 +0800 Subject: [PATCH 5/5] fix: encoding proof in merkle-airdrop test --- pallets/merkle-airdrop/src/tests.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pallets/merkle-airdrop/src/tests.rs b/pallets/merkle-airdrop/src/tests.rs index 7abc8c16..984c35f1 100644 --- a/pallets/merkle-airdrop/src/tests.rs +++ b/pallets/merkle-airdrop/src/tests.rs @@ -11,12 +11,10 @@ fn bounded_proof(proof: Vec<[u8; 32]>) -> BoundedVec<[u8; 32], MaxProofs> { } // Helper function to calculate a leaf hash for testing +// Uses tuple encode to match pallet implementation fn calculate_leaf_hash(account: &u64, amount: u64) -> [u8; 32] { - let account_bytes = account.encode(); - let amount_bytes = amount.encode(); - let leaf_data = [&account_bytes[..], &amount_bytes[..]].concat(); - - blake2_256(&leaf_data) + let bytes = (account, amount).encode(); // Tuple encode - matches pallet! + blake2_256(&bytes) } // Helper function to calculate a parent hash for testing