Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions prdoc/pr_11512.prdoc
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion substrate/frame/support/src/traits/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
31 changes: 31 additions & 0 deletions substrate/frame/support/src/traits/tokens/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,34 @@ pub struct IdAmount<Id, Balance> {
/// 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<AccountId, Balance> {
Comment thread
sigurpol marked this conversation as resolved.
/// 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<Self::BlockNumber>,
) -> sp_runtime::DispatchResult;
}
36 changes: 36 additions & 0 deletions substrate/frame/vesting/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,42 @@ impl<T: Config> Pallet<T> {
}
}

impl<T: Config> frame_support::traits::tokens::VestedPayout<T::AccountId, BalanceOf<T>>
for Pallet<T>
where
BalanceOf<T>: MaybeSerializeDeserialize + Debug,
{
type BlockNumber = BlockNumberFor<T>;

fn vested_transfer(
source: &T::AccountId,
dest: &T::AccountId,
amount: BalanceOf<T>,
Comment thread
Ank4n marked this conversation as resolved.
duration: BlockNumberFor<T>,
Comment thread
Ank4n marked this conversation as resolved.
start_at: Option<BlockNumberFor<T>>,
) -> 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<T: Config> VestingSchedule<T::AccountId> for Pallet<T>
where
BalanceOf<T>: MaybeSerializeDeserialize + Debug,
Expand Down
118 changes: 118 additions & 0 deletions substrate/frame/vesting/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(<Vesting as VestedPayout<_, _>>::vested_transfer(&alice, &bob, 0, 10, None));
assert_eq!(Balances::free_balance(&bob), bob_balance_before);
assert!(VestingStorage::<Test>::get(&bob).is_none());
});

// WHEN: zero duration, THEN: liquid transfer, no vesting schedule.
hypothetically!({
let amount = ED * 5;
assert_ok!(<Vesting as VestedPayout<_, _>>::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::<Test>::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!(<Vesting as VestedPayout<_, _>>::vested_transfer(
&alice,
&bob,
amount,
duration,
Some(future_block)
));
let schedule = VestingStorage::<Test>::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!(<Vesting as VestedPayout<_, _>>::vested_transfer(
&alice, &bob, amount, duration, None
));

// THEN: per_block is rounded up to 35, not floored to 34.
let schedule = VestingStorage::<Test>::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::<Identity>();
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!(<Vesting as VestedPayout<_, _>>::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::<Test>::get(&alice).unwrap();
assert_eq!(schedule.len(), 1);
assert_eq!(schedule[0].locked(), amount);
});
}
Loading