diff --git a/prdoc/pr_11512.prdoc b/prdoc/pr_11512.prdoc new file mode 100644 index 0000000000000..cb9749de2511e --- /dev/null +++ b/prdoc/pr_11512.prdoc @@ -0,0 +1,15 @@ +title: Vested Payout trait and implementation +doc: +- audience: Runtime Dev + description: |- + Introduces a new `VestedPayout` trait in `frame-support` for transferring funds with a linear + vesting schedule. Unlike the existing `VestedTransfer` trait, callers only specify the total + amount and duration, and the implementor handles per-block computation internally. + + `pallet-vesting` provides the implementation. The per-block unlock rate is rounded up so that + vesting always completes within the specified duration, never longer. +crates: +- name: frame-support + bump: minor +- name: pallet-vesting + bump: minor diff --git a/substrate/frame/support/src/traits/tokens.rs b/substrate/frame/support/src/traits/tokens.rs index be982cd31e33a..36c659c07c1bd 100644 --- a/substrate/frame/support/src/traits/tokens.rs +++ b/substrate/frame/support/src/traits/tokens.rs @@ -34,6 +34,7 @@ pub use misc::{ AssetId, Balance, BalanceStatus, ConversionFromAssetBalance, ConversionToAssetBalance, ConvertRank, DepositConsequence, ExistenceRequirement, Fortitude, GetSalary, IdAmount, Locker, Precision, Preservation, Provenance, ProvideAssetReserves, Restriction, - UnityAssetBalanceConversion, UnityOrOuterConversion, WithdrawConsequence, WithdrawReasons, + UnityAssetBalanceConversion, UnityOrOuterConversion, VestedPayout, WithdrawConsequence, + WithdrawReasons, }; pub use pay::{Pay, PayFromAccount, PayWithFungibles, PayWithSource, PaymentStatus}; diff --git a/substrate/frame/support/src/traits/tokens/misc.rs b/substrate/frame/support/src/traits/tokens/misc.rs index 9bf061793297a..b5f34496a3c69 100644 --- a/substrate/frame/support/src/traits/tokens/misc.rs +++ b/substrate/frame/support/src/traits/tokens/misc.rs @@ -434,3 +434,34 @@ pub struct IdAmount { /// Some amount for this item. pub amount: Balance, } + +/// Transfer `amount` from `source` to `dest` and apply a linear vesting schedule that completes +/// within at most `duration` blocks starting from the current block. +/// +/// The per-block unlock rate is rounded up so that vesting never exceeds `duration` blocks. +/// +/// The implementor handles per-block unlock computation, block-number provider selection, and +/// the actual fund transfer internally. Callers only specify the total amount and duration. +/// +/// Unlike [`VestedTransfer`](super::currency::VestedTransfer), this trait is agnostic to both +/// the old [`Currency`](super::currency::Currency) trait and the new +/// [`fungible::Mutate`](super::fungible::Mutate) trait. The implementor (e.g. `pallet_vesting`) +/// chooses which currency mechanism to use internally, and callers do not need to provide +/// `per_block` or `starting_block` — only the total amount and vesting duration. +pub trait VestedPayout { + /// The block number type used to express vesting duration. + type BlockNumber; + + /// Transfer `amount` from `source` to `dest`, locked under a linear vesting schedule + /// that completes within at most `duration` blocks. + /// + /// If `start_at` is `Some`, the vesting schedule begins at that block number; + /// otherwise it begins at the current block. + fn vested_transfer( + source: &AccountId, + dest: &AccountId, + amount: Balance, + duration: Self::BlockNumber, + start_at: Option, + ) -> sp_runtime::DispatchResult; +} diff --git a/substrate/frame/vesting/src/lib.rs b/substrate/frame/vesting/src/lib.rs index 09f5f3e4431c2..06346cd176559 100644 --- a/substrate/frame/vesting/src/lib.rs +++ b/substrate/frame/vesting/src/lib.rs @@ -709,6 +709,42 @@ impl Pallet { } } +impl frame_support::traits::tokens::VestedPayout> + for Pallet +where + BalanceOf: MaybeSerializeDeserialize + Debug, +{ + type BlockNumber = BlockNumberFor; + + fn vested_transfer( + source: &T::AccountId, + dest: &T::AccountId, + amount: BalanceOf, + duration: BlockNumberFor, + start_at: Option>, + ) -> DispatchResult { + if amount.is_zero() { + return Ok(()); + } + + if duration.is_zero() { + // Zero duration means liquid transfer with no vesting schedule. + T::Currency::transfer(source, dest, amount, ExistenceRequirement::AllowDeath) + } else { + let starting_block = + start_at.unwrap_or_else(|| T::BlockNumberProvider::current_block_number()); + let duration_as_balance = T::BlockNumberToBalance::convert(duration); + // Round up so that vesting completes within `duration` blocks, not longer. + let per_block = + ((amount.saturating_add(duration_as_balance).saturating_sub(One::one())) / + duration_as_balance) + .max(One::one()); + let schedule = VestingInfo::new(amount, per_block, starting_block); + Self::do_vested_transfer(source, dest, schedule) + } + } +} + impl VestingSchedule for Pallet where BalanceOf: MaybeSerializeDeserialize + Debug, diff --git a/substrate/frame/vesting/src/tests.rs b/substrate/frame/vesting/src/tests.rs index aec6949f31838..e00dd97180b01 100644 --- a/substrate/frame/vesting/src/tests.rs +++ b/substrate/frame/vesting/src/tests.rs @@ -1262,3 +1262,121 @@ fn vested_transfer_impl_works() { ); }); } + +#[test] +fn vested_payout_edge_cases() { + use frame_support::{hypothetically, traits::tokens::VestedPayout}; + ExtBuilder::default().existential_deposit(ED).build().execute_with(|| { + let alice = 3; + let bob = 4; + + let alice_balance_before = Balances::free_balance(&alice); + let bob_balance_before = Balances::free_balance(&bob); + + // WHEN: zero amount, THEN: no-op. + hypothetically!({ + assert_ok!(>::vested_transfer(&alice, &bob, 0, 10, None)); + assert_eq!(Balances::free_balance(&bob), bob_balance_before); + assert!(VestingStorage::::get(&bob).is_none()); + }); + + // WHEN: zero duration, THEN: liquid transfer, no vesting schedule. + hypothetically!({ + let amount = ED * 5; + assert_ok!(>::vested_transfer( + &alice, &bob, amount, 0, None + )); + assert_eq!(Balances::free_balance(&alice), alice_balance_before - amount); + assert_eq!(Balances::free_balance(&bob), bob_balance_before + amount); + assert!(VestingStorage::::get(&bob).is_none()); + }); + + // WHEN: start_at is a future block, THEN: schedule starts at that block and + // nothing vests before it, but vesting kicks in once we reach that block. + hypothetically!({ + let amount = ED * 4; // 1024 + let duration = 10u64; + let future_block = 100u64; + assert_ok!(>::vested_transfer( + &alice, + &bob, + amount, + duration, + Some(future_block) + )); + let schedule = VestingStorage::::get(&bob).unwrap(); + assert_eq!(schedule.len(), 1); + assert_eq!(schedule[0].starting_block(), future_block); + assert_eq!(schedule[0].locked(), amount); + + // Before start_at: nothing has vested yet, full amount is still locked. + System::set_block_number(future_block - 1); + assert_eq!(Vesting::vesting_balance(&bob), Some(amount)); + + // At start_at: vesting begins, per_block amount is unlocked. + System::set_block_number(future_block); + assert_eq!(Vesting::vesting_balance(&bob), Some(amount)); + + // A few blocks after start_at: partial vesting. + System::set_block_number(future_block + 5); + let per_block = schedule[0].per_block(); + assert_eq!(Vesting::vesting_balance(&bob), Some(amount - per_block * 5)); + + // After start_at + duration: fully vested. + System::set_block_number(future_block + duration); + assert_eq!(Vesting::vesting_balance(&bob), Some(0)); + }); + }); +} + +#[test] +fn vested_payout_creates_schedule() { + use frame_support::traits::tokens::VestedPayout; + ExtBuilder::default().existential_deposit(ED).build().execute_with(|| { + let alice = 3; + let bob = 4; + + // Use amount that doesn't evenly divide by duration to test rounding. + // amount=1024, duration=30: floor division would give per_block=34, needing 31 blocks. + // Rounding up gives per_block=35, completing in 30 blocks (within duration). + let amount = ED * 4; // 1024 + let duration = 30u64; + + // WHEN + assert_ok!(>::vested_transfer( + &alice, &bob, amount, duration, None + )); + + // THEN: per_block is rounded up to 35, not floored to 34. + let schedule = VestingStorage::::get(&bob).unwrap(); + assert_eq!(schedule.len(), 1); + assert_eq!(schedule[0].locked(), amount); + assert_eq!(schedule[0].per_block(), 35); + + // Vesting completes within duration: ceil(1024/35) = 30 blocks <= 30. + let ending = schedule[0].ending_block_as_balance::(); + assert!(ending <= schedule[0].starting_block() + duration); + }); +} + +#[test] +fn vested_payout_self_transfer_creates_schedule() { + use frame_support::traits::tokens::VestedPayout; + ExtBuilder::default().existential_deposit(ED).build().execute_with(|| { + let alice = 3; + let balance_before = Balances::free_balance(&alice); + let amount = ED * 5; + let duration = 10u64; + + // WHEN: self-transfer (used by staking to convert holds to vesting). + assert_ok!(>::vested_transfer( + &alice, &alice, amount, duration, None + )); + + // THEN: balance unchanged (self-transfer), but vesting schedule is created. + assert_eq!(Balances::free_balance(&alice), balance_before); + let schedule = VestingStorage::::get(&alice).unwrap(); + assert_eq!(schedule.len(), 1); + assert_eq!(schedule[0].locked(), amount); + }); +}