diff --git a/Cargo.lock b/Cargo.lock index 24cb07ee6307d..9944660da045a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12223,6 +12223,7 @@ dependencies = [ "sp-core 28.0.0", "sp-io 30.0.0", "sp-runtime 31.0.1", + "sp-staking", ] [[package]] diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index b0297576b3bce..7525e009b9b77 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -344,11 +344,23 @@ impl pallet_staking_async_rc_client::Config for Runtime { parameter_types! { pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); + /// Minimum time (ms) between issuance drips. 60s = drip at most once per minute. + pub const IssuanceCadence: u64 = 60_000; + /// Safety ceiling (ms) for elapsed time in a single drip. Prevents over-minting after stalls. + pub const MaxElapsedPerDrip: u64 = 600_000; } impl pallet_dap::Config for Runtime { type Currency = Balances; type PalletId = DapPalletId; + /// Noop — DAP does not mint until budget drip is enabled. + type IssuanceCurve = (); + type BudgetRecipients = (pallet_dap::Pallet,); + type Time = pallet_timestamp::Pallet; + type IssuanceCadence = IssuanceCadence; + type MaxElapsedPerDrip = MaxElapsedPerDrip; + type BudgetOrigin = frame_system::EnsureRoot; + type WeightInfo = (); } #[derive(Encode, Decode)] diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/tests/tests.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/tests/tests.rs index 356160ff91306..b7c14c4552171 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/tests/tests.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/tests/tests.rs @@ -2526,7 +2526,9 @@ mod dap { #[test] fn tx_fees_go_to_dap_buffer() { let alice = AccountId::from(Sr25519Keyring::Alice); - let buffer = pallet_dap::Pallet::::buffer_account(); + let buffer = as sp_staking::budget::BudgetRecipient< + AccountId, + >>::pot_account(); let ed = ExistentialDeposit::get(); ExtBuilder::::default() @@ -2564,7 +2566,9 @@ mod dap { fn dust_removal_goes_to_dap_buffer() { let alice = AccountId::from(ALICE); let bob = AccountId::from(BOB); - let buffer = pallet_dap::Pallet::::buffer_account(); + let buffer = as sp_staking::budget::BudgetRecipient< + AccountId, + >>::pot_account(); let ed = ExistentialDeposit::get(); let dust = ed / 2; diff --git a/prdoc/pr_11527.prdoc b/prdoc/pr_11527.prdoc new file mode 100644 index 0000000000000..3cf2b0aa2b34e --- /dev/null +++ b/prdoc/pr_11527.prdoc @@ -0,0 +1,24 @@ +title: Add issuance drip and budget distribution to pallet-dap +doc: +- audience: Runtime Dev + description: |- + Adds issuance drip and budget distribution to `pallet-dap`. DAP mints new tokens on a + configurable cadence via `IssuanceCurve` and distributes them to registered + `BudgetRecipient`s according to a governance-updatable allocation map. + + Includes `set_budget_allocation` extrinsic, `OnUnbalanced` slash handling with buffer + deactivation, safety ceiling on elapsed time, and a V1→V2 migration struct (not yet applied). + + All runtimes are configured with a noop `IssuanceCurve` (`()` impl that returns 0) so there + is no behavior change. Minting will be enabled when staking is integrated with DAP. +crates: +- name: pallet-dap + bump: major +- name: sp-staking + bump: minor +- name: asset-hub-westend-runtime + bump: major +- name: pallet-staking-async-parachain-runtime + bump: patch +- name: pallet-ahm-test + bump: patch diff --git a/substrate/frame/dap/Cargo.toml b/substrate/frame/dap/Cargo.toml index 5256ef04f28ba..a5b0640fbe763 100644 --- a/substrate/frame/dap/Cargo.toml +++ b/substrate/frame/dap/Cargo.toml @@ -22,6 +22,7 @@ frame-system = { workspace = true } log = { workspace = true } scale-info = { features = ["derive"], workspace = true } sp-runtime = { workspace = true } +sp-staking = { workspace = true } [dev-dependencies] pallet-balances = { workspace = true, default-features = true } @@ -40,6 +41,7 @@ std = [ "sp-core/std", "sp-io/std", "sp-runtime/std", + "sp-staking/std", ] runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", @@ -47,6 +49,7 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "sp-runtime/runtime-benchmarks", + "sp-staking/runtime-benchmarks", ] try-runtime = [ "frame-support/try-runtime", diff --git a/substrate/frame/dap/src/benchmarking.rs b/substrate/frame/dap/src/benchmarking.rs new file mode 100644 index 0000000000000..7453fb281025a --- /dev/null +++ b/substrate/frame/dap/src/benchmarking.rs @@ -0,0 +1,78 @@ +// 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. + +//! Benchmarks for pallet-dap. + +use super::*; +use frame_benchmarking::v2::*; +use frame_support::traits::Time; +use frame_system::RawOrigin; +use sp_staking::budget::BudgetRecipientList; + +#[benchmarks] +mod benchmarks { + use super::*; + + /// Build a valid allocation from registered recipients, distributing evenly and giving + /// the remainder to the last recipient to ensure the sum is exactly 100%. + fn build_even_allocation() -> BudgetAllocationMap { + let recipients = T::BudgetRecipients::recipients(); + let count = recipients.len() as u32; + let mut allocations = BudgetAllocationMap::new(); + + for (i, (key, _)) in recipients.into_iter().enumerate() { + let perbill = if i as u32 == count - 1 { + let used: u32 = allocations.values().map(|p| p.deconstruct()).sum(); + Perbill::from_parts(Perbill::one().deconstruct().saturating_sub(used)) + } else { + Perbill::from_rational(1u32, count) + }; + allocations.try_insert(key, perbill).expect("bounded by MAX_BUDGET_RECIPIENTS"); + } + + allocations + } + + #[benchmark] + fn set_budget_allocation() { + let allocations = build_even_allocation::(); + + #[extrinsic_call] + _(RawOrigin::Root, allocations.clone()); + + assert_eq!(BudgetAllocation::::get(), allocations); + } + + #[benchmark] + fn drip_issuance() { + let allocations = build_even_allocation::(); + BudgetAllocation::::put(allocations); + + // Seed the timestamp so the drip fires. + let now: u64 = T::Time::now().saturated_into(); + let past = now.saturating_sub(T::IssuanceCadence::get() + 1); + LastIssuanceTimestamp::::put(past); + + #[block] + { + Pallet::::drip_issuance(); + } + + // Timestamp should be updated. + assert!(LastIssuanceTimestamp::::get() > past); + } +} diff --git a/substrate/frame/dap/src/lib.rs b/substrate/frame/dap/src/lib.rs index 5b8caa22f60f4..f8bf6a73f458f 100644 --- a/substrate/frame/dap/src/lib.rs +++ b/substrate/frame/dap/src/lib.rs @@ -17,51 +17,69 @@ //! # Dynamic Allocation Pool (DAP) Pallet //! -//! Intercepts native token burns (staking slashes, transaction fees, dust removal, reward -//! remainders, EVM gas rounding) on AssetHub and redirects them into a buffer account instead -//! of destroying them. -//! The buffer account must be pre-funded with at least ED (existential deposit), e.g., via -//! balances genesis config or a transfer. If the buffer account is not pre-funded, deposits -//! below ED will be silently burned. +//! Generic issuance drip and distribution engine. //! -//! Incoming funds are deactivated to exclude them from governance voting. -//! When DAP distributes funds (e.g., to validators, nominators, treasury, collators), those funds -//! must be reactivated before transfer. +//! ## Key Responsibilities: //! -//! - **Burns**: Use `Dap` as `OnUnbalanced` handler for any burn source (e.g., `type Slash = Dap`, -//! `type DustRemoval = Dap`, `type OnBurn = Dap`) -//! Note: Direct calls to `pallet_balances::Pallet::burn()` extrinsic are not redirected to -//! the buffer — they still reduce total issuance directly. +//! - **Issuance Drip**: Mints new tokens on a configurable cadence (per-block or every N minutes) +//! based on an [`IssuanceCurve`]. +//! - **Budget Distribution**: Distributes minted issuance across registered +//! [`sp_staking::budget::BudgetRecipient`]s according to a governance-updatable +//! `BoundedBTreeMap` that must sum to exactly 100%. +//! - **Burn Collection**: Implements `OnUnbalanced` to intercept any burn source wired to it +//! (staking slashes, transaction fees, dust removal, EVM gas rounding, etc.) and redirect funds +//! into the buffer account. Incoming funds are deactivated to exclude them from governance +//! voting. #![cfg_attr(not(feature = "std"), no_std)] +pub mod migrations; +pub mod weights; + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; + #[cfg(test)] pub(crate) mod mock; #[cfg(test)] mod tests; +extern crate alloc; + +use alloc::vec::Vec; +use codec::DecodeWithMemTracking; use frame_support::{ defensive, pallet_prelude::*, traits::{ - fungible::{Balanced, Credit, Inspect, Unbalanced}, - Imbalance, OnUnbalanced, + fungible::{Balanced, Credit, Inspect, Mutate, Unbalanced}, + Imbalance, OnUnbalanced, Time, }, PalletId, }; +use sp_runtime::{traits::Zero, BoundedBTreeMap, Perbill, SaturatedConversion, Saturating}; +use sp_staking::budget::{BudgetKey, BudgetRecipientList, IssuanceCurve}; pub use pallet::*; const LOG_TARGET: &str = "runtime::dap"; +/// Maximum number of budget recipients. +pub const MAX_BUDGET_RECIPIENTS: u32 = 16; + /// Type alias for balance. pub type BalanceOf = <::Currency as Inspect<::AccountId>>::Balance; +/// Type alias for the budget allocation map. +pub type BudgetAllocationMap = BoundedBTreeMap>; + #[frame_support::pallet] pub mod pallet { use super::*; + use crate::weights::WeightInfo; use frame_support::{sp_runtime::traits::AccountIdConversion, traits::StorageVersion}; + use frame_system::pallet_prelude::*; /// The in-code storage version. const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); @@ -71,32 +89,310 @@ pub mod pallet { pub struct Pallet(_); #[pallet::config] - pub trait Config: frame_system::Config { + pub trait Config: frame_system::Config>> { /// The currency type (new fungible traits). type Currency: Inspect + + Mutate + Unbalanced + Balanced; /// The pallet ID used to derive the buffer account. - /// - /// Each runtime should configure a unique ID to avoid collisions if multiple - /// DAP instances are used. #[pallet::constant] type PalletId: Get; + + /// Issuance curve: computes how much to mint given total issuance and elapsed time. + type IssuanceCurve: IssuanceCurve>; + + /// Registered budget recipients. Each element provides a unique key and pot account. + /// + /// Wired in the runtime as a tuple, e.g.: + /// ```ignore + /// type BudgetRecipients = (Dap, StakerRewardRecipient, ValidatorIncentiveRecipient); + /// ``` + type BudgetRecipients: BudgetRecipientList; + + /// Time provider (typically `pallet_timestamp`). + /// + /// `Moment` must represent milliseconds. + type Time: Time; + + /// Minimum elapsed time (ms) between issuance drips. + /// + /// - `0` = drip every block + /// - `60_000` = drip every minute (Recommended) + /// + /// Should be small relative to era length. + #[pallet::constant] + type IssuanceCadence: Get; + + /// Safety ceiling: maximum elapsed time (ms) considered in a single drip. + /// + /// If more time has passed than this, elapsed is clamped to this value. + /// Prevents accidental over-minting from bugs, misconfiguration, or long + /// periods without blocks. + #[pallet::constant] + type MaxElapsedPerDrip: Get; + + /// Origin that can update budget allocation percentages. + type BudgetOrigin: EnsureOrigin; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: crate::weights::WeightInfo; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Inflation dripped and distributed to budget recipients. + IssuanceMinted { + /// Total amount minted in this drip. + total_minted: BalanceOf, + /// Elapsed time (ms) since last drip. + elapsed_millis: u64, + }, + /// Budget allocation was updated via governance. + BudgetAllocationUpdated { + /// The new budget allocation map. + allocations: BudgetAllocationMap, + }, + /// An unexpected/defensive event was triggered. + Unexpected(UnexpectedKind), + } + + /// Defensive/unexpected errors/events. + #[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, DebugNoBound)] + pub enum UnexpectedKind { + /// Failed to mint issuance. + MintFailed, + /// Elapsed time was clamped at the safety ceiling. + ElapsedClamped { + /// The actual elapsed time in milliseconds. + actual_elapsed: u64, + /// The ceiling that was applied. + ceiling: u64, + }, + } + + /// Budget allocation map: `BudgetKey -> Perbill`. + /// + /// Keys must correspond to registered `BudgetRecipients`. Sum of values must be + /// exactly `Perbill::one()` (100%). Recipients not included receive nothing. + #[pallet::storage] + pub type BudgetAllocation = StorageValue<_, BudgetAllocationMap, ValueQuery>; + + /// Timestamp (ms) of the last issuance drip. + /// + /// On existing chains, this must be seeded via + /// [`migrations::MigrateV1ToV2`] to prevent incorrect minting on the first drip. + #[pallet::storage] + pub type LastIssuanceTimestamp = StorageValue<_, u64, ValueQuery>; + + #[pallet::error] + pub enum Error { + /// A key in the budget allocation does not match any registered recipient. + UnknownBudgetKey, + /// Budget allocation percentages do not sum to exactly 100%. + BudgetNotExact, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(_n: BlockNumberFor) -> Weight { + Self::drip_issuance() + } + + fn integrity_test() { + assert!( + T::MaxElapsedPerDrip::get() > T::IssuanceCadence::get(), + "MaxElapsedPerDrip must be greater than IssuanceCadence, \ + otherwise every drip would be clamped below the cadence threshold." + ); + + // Ensure BudgetRecipients have no duplicate keys. + let mut keys: Vec<_> = + T::BudgetRecipients::recipients().into_iter().map(|(k, _)| k).collect(); + keys.sort(); + assert!( + keys.windows(2).all(|w| w[0] != w[1]), + "Duplicate BudgetRecipient key detected" + ); + } + + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + // TODO(ank4n): Re-enable after this migration is included in runtime. + // Self::do_try_state() + Ok(()) + } + } + + #[pallet::call] + impl Pallet { + /// Set the budget allocation map. + /// + /// Each key must match a registered `BudgetRecipient`. The sum of all percentages + /// must be exactly 100%. Recipients not included in the map receive nothing. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::set_budget_allocation())] + pub fn set_budget_allocation( + origin: OriginFor, + new_allocations: BudgetAllocationMap, + ) -> DispatchResult { + T::BudgetOrigin::ensure_origin(origin)?; + + // Validate all keys are registered recipients. + let registered: Vec<_> = + T::BudgetRecipients::recipients().into_iter().map(|(k, _)| k).collect(); + for key in new_allocations.keys() { + ensure!(registered.contains(key), Error::::UnknownBudgetKey); + } + + // Validate sum == 100%. Use u64 to avoid overflow when summing deconstructed Perbills. + let total_parts: u64 = new_allocations.values().map(|p| p.deconstruct() as u64).sum(); + ensure!(total_parts == Perbill::one().deconstruct() as u64, Error::::BudgetNotExact); + + BudgetAllocation::::put(new_allocations.clone()); + Self::deposit_event(Event::BudgetAllocationUpdated { allocations: new_allocations }); + + Ok(()) + } } impl Pallet { - /// Get the DAP buffer account - /// NOTE: We may need more accounts in the future, for instance, to manage the strategic - /// reserve. We will add them as necessary, generating them with additional seed. - pub fn buffer_account() -> T::AccountId { + /// The DAP buffer account. + /// + /// Collects any burn source wired to it (staking slashes, unclaimed rewards, etc.) + /// and its explicit budget allocation share. + pub(crate) fn buffer_account() -> T::AccountId { T::PalletId::get().into_account_truncating() } + + /// Deactivate funds on buffer inflow. + pub(crate) fn deactivate_buffer_funds(amount: BalanceOf) { + >::deactivate(amount); + } + + /// Core issuance drip logic, called from `on_initialize`. + pub(crate) fn drip_issuance() -> Weight { + let now_moment = T::Time::now(); + let now: u64 = now_moment.saturated_into(); + let last = LastIssuanceTimestamp::::get(); + let mut elapsed = now.saturating_sub(last); + + let cadence = T::IssuanceCadence::get(); + if cadence > 0 && elapsed < cadence { + return T::DbWeight::get().reads(2); + } + + // First block after genesis: initialize timestamp, don't drip. + // For existing chains, use `migrations::MigrateV1ToV2` to seed this + // value from ActiveEra.start so this branch is never hit post-upgrade. + if last == 0 { + LastIssuanceTimestamp::::put(now); + return T::DbWeight::get().reads_writes(2, 2); + } + + // Apply safety ceiling on elapsed time. + let max_elapsed = T::MaxElapsedPerDrip::get(); + if elapsed > max_elapsed { + Self::deposit_event(Event::Unexpected(UnexpectedKind::ElapsedClamped { + actual_elapsed: elapsed, + ceiling: max_elapsed, + })); + elapsed = max_elapsed; + } + + let total_issuance = T::Currency::total_issuance(); + let issuance = T::IssuanceCurve::issue(total_issuance, elapsed); + // Always advance the clock so elapsed time doesn't accumulate across skipped drips. + LastIssuanceTimestamp::::put(now); + + if issuance.is_zero() { + return T::DbWeight::get().reads_writes(3, 3); + } + + // Distribute according to budget map. + let budget = BudgetAllocation::::get(); + if budget.is_empty() { + // TODO: Add defensive! panic once budget is always configured. + log::warn!( + target: LOG_TARGET, + "BudgetAllocation is empty — no issuance will be distributed" + ); + return T::DbWeight::get().reads_writes(4, 4); + } + let recipients = T::BudgetRecipients::recipients(); + let mut total_minted = BalanceOf::::zero(); + + let buffer = Self::buffer_account(); + for (key, account) in &recipients { + let perbill = budget.get(key).copied().unwrap_or(Perbill::zero()); + let amount = perbill.mul_floor(issuance); + if !amount.is_zero() { + if let Err(_) = T::Currency::mint_into(account, amount) { + Self::deposit_event(Event::Unexpected(UnexpectedKind::MintFailed)); + defensive!("Issuance mint should not fail"); + } else { + total_minted = total_minted.saturating_add(amount); + if *account == buffer { + Self::deactivate_buffer_funds(amount); + } + } + } + } + + // Rounding dust from Perbill::mul_floor is not minted. + + Self::deposit_event(Event::IssuanceMinted { total_minted, elapsed_millis: elapsed }); + + log::debug!( + target: LOG_TARGET, + "Issuance drip: total={issuance:?}, elapsed={elapsed}ms" + ); + + T::WeightInfo::drip_issuance() + } + } + + #[cfg(any(test, feature = "try-runtime"))] + impl Pallet { + #[allow(dead_code)] + pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { + Self::check_budget_allocation() + } + + /// Checks that `BudgetAllocation` is consistent: + /// - Every key in `BudgetAllocation` must be a registered recipient. + /// - Allocation percentages must sum to exactly 100%. + fn check_budget_allocation() -> Result<(), sp_runtime::TryRuntimeError> { + let allocation = BudgetAllocation::::get(); + + ensure!(!allocation.is_empty(), "BudgetAllocation is empty"); + + let registered: Vec = + T::BudgetRecipients::recipients().into_iter().map(|(k, _)| k).collect(); + + // Every allocation key must be a registered recipient. + for key in allocation.keys() { + ensure!( + registered.contains(key), + "BudgetAllocation contains key not in BudgetRecipients" + ); + } + + // Allocation must sum to exactly 100%. + let total_parts: u64 = allocation.values().map(|p| p.deconstruct() as u64).sum(); + ensure!( + total_parts == Perbill::one().deconstruct() as u64, + "BudgetAllocation does not sum to 100%" + ); + + Ok(()) + } } } /// Type alias for credit (negative imbalance - funds that were slashed/removed). -/// This is for the `fungible::Balanced` trait as used by staking-async. pub type CreditOf = Credit<::AccountId, ::Currency>; /// Implementation of OnUnbalanced for the fungible::Balanced trait. @@ -121,9 +417,8 @@ impl OnUnbalanced> for Pallet { ); }) .inspect(|_| { - // Mark funds as inactive so they don't participate in governance voting. - // Only deactivate on success; if resolve failed, tokens were burned. - >::deactivate(numeric_amount); + // Deactivate on success; if resolve failed, tokens were burned. + Self::deactivate_buffer_funds(numeric_amount); log::debug!( target: LOG_TARGET, "💸 Deposited slash of {numeric_amount:?} to DAP buffer" @@ -131,3 +426,15 @@ impl OnUnbalanced> for Pallet { }); } } + +/// DAP exposes its buffer as a budget recipient so it can receive an explicit +/// allocation share (in addition to the implicit remainder). +impl sp_staking::budget::BudgetRecipient for Pallet { + fn budget_key() -> BudgetKey { + BudgetKey::truncate_from(b"buffer".to_vec()) + } + + fn pot_account() -> T::AccountId { + Self::buffer_account() + } +} diff --git a/substrate/frame/dap/src/migrations.rs b/substrate/frame/dap/src/migrations.rs new file mode 100644 index 0000000000000..d8fe5efb2e4c8 --- /dev/null +++ b/substrate/frame/dap/src/migrations.rs @@ -0,0 +1,95 @@ +// 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. + +//! DAP pallet migrations. + +use super::*; +use frame_support::traits::UncheckedOnRuntimeUpgrade; + +/// V1 to V2 migration: initializes `LastIssuanceTimestamp` and seeds `BudgetAllocation`. +/// +/// - `T`: DAP pallet config +/// - `P`: `Get` providing the initial timestamp (e.g. active era start from staking) +/// - `B`: `Get` providing the initial budget allocation +/// +/// NOTE: This migration should be applied when staking changes are integrated to support +/// budget drip. The storage version bump (V1 → V2) happens at that point. +pub type MigrateV1ToV2 = frame_support::migrations::VersionedMigration< + 1, + 2, + InnerMigrateV1ToV2, + pallet::Pallet, + ::DbWeight, +>; + +/// Inner (unversioned) migration logic. Use [`MigrateV1ToV2`] instead. +pub struct InnerMigrateV1ToV2(core::marker::PhantomData<(T, P, B)>); + +impl, B: Get> UncheckedOnRuntimeUpgrade + for InnerMigrateV1ToV2 +{ + fn on_runtime_upgrade() -> frame_support::weights::Weight { + let mut weight = T::DbWeight::get().reads(2); + + // Seed LastIssuanceTimestamp (idempotent). + let current_ts = LastIssuanceTimestamp::::get(); + if current_ts == 0 { + let ts = P::get(); + LastIssuanceTimestamp::::put(ts); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + log::info!(target: LOG_TARGET, "Initialized LastIssuanceTimestamp to {ts}"); + } + + // Seed BudgetAllocation (idempotent). + let current_budget = BudgetAllocation::::get(); + if current_budget.is_empty() { + BudgetAllocation::::put(B::get()); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + log::info!(target: LOG_TARGET, "Initialized BudgetAllocation with default budget"); + } + + weight + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + frame_support::ensure!( + LastIssuanceTimestamp::::get() == 0 || BudgetAllocation::::get().is_empty(), + "Migration not needed: LastIssuanceTimestamp and BudgetAllocation already set" + ); + Ok(alloc::vec::Vec::new()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: alloc::vec::Vec) -> Result<(), sp_runtime::TryRuntimeError> { + frame_support::ensure!( + LastIssuanceTimestamp::::get() != 0, + "LastIssuanceTimestamp should be non-zero after migration" + ); + + let budget = BudgetAllocation::::get(); + frame_support::ensure!(!budget.is_empty(), "BudgetAllocation should be non-empty"); + + let total: u64 = budget.values().map(|p| p.deconstruct() as u64).sum(); + frame_support::ensure!( + total == Perbill::one().deconstruct() as u64, + "BudgetAllocation must sum to 100%" + ); + + Ok(()) + } +} diff --git a/substrate/frame/dap/src/mock.rs b/substrate/frame/dap/src/mock.rs index d8ff8e0d27571..1ce2160cb09f8 100644 --- a/substrate/frame/dap/src/mock.rs +++ b/substrate/frame/dap/src/mock.rs @@ -21,9 +21,10 @@ use crate::{self as pallet_dap, Config}; use frame_support::{ derive_impl, parameter_types, sp_runtime::traits::AccountIdConversion, PalletId, }; -use sp_runtime::BuildStorage; +use sp_runtime::{traits::IdentityLookup, BuildStorage}; type Block = frame_system::mocking::MockBlock; +pub type AccountId = u64; frame_support::construct_runtime!( pub enum Test { @@ -36,6 +37,8 @@ frame_support::construct_runtime!( #[derive_impl(frame_system::config_preludes::TestDefaultConfig)] impl frame_system::Config for Test { type Block = Block; + type AccountId = AccountId; + type Lookup = IdentityLookup; type AccountData = pallet_balances::AccountData; } @@ -48,18 +51,83 @@ impl pallet_balances::Config for Test { parameter_types! { pub const DapPalletId: PalletId = PalletId(*b"dap/buff"); pub const ExistentialDeposit: u64 = 10; + pub const IssuanceCadence: u64 = 60_000; // 60 seconds + pub const MaxElapsedPerDrip: u64 = 600_000; // 10 minutes +} + +/// Returns 100 per 60_000ms elapsed (proportional). +pub struct TestIssuanceCurve; +impl sp_staking::budget::IssuanceCurve for TestIssuanceCurve { + fn issue(_total_issuance: u64, elapsed_millis: u64) -> u64 { + // 100 per minute (60_000ms) + (100u128 * elapsed_millis as u128 / 60_000u128) as u64 + } +} + +parameter_types! { + pub static MockTime: u64 = 0; +} + +impl frame_support::traits::Time for MockTime { + type Moment = u64; + fn now() -> u64 { + Self::get() + } +} + +/// Test budget recipient: staker rewards pot (account 500). +pub struct TestStakerRecipient; +impl sp_staking::budget::BudgetRecipient for TestStakerRecipient { + fn budget_key() -> sp_staking::budget::BudgetKey { + sp_staking::budget::BudgetKey::truncate_from(b"staker_rewards".to_vec()) + } + fn pot_account() -> AccountId { + 500 + } +} + +/// Test budget recipient: validator incentive pot (account 501). +pub struct TestValidatorIncentiveRecipient; +impl sp_staking::budget::BudgetRecipient for TestValidatorIncentiveRecipient { + fn budget_key() -> sp_staking::budget::BudgetKey { + sp_staking::budget::BudgetKey::truncate_from(b"validator_incentive".to_vec()) + } + fn pot_account() -> AccountId { + 501 + } } impl Config for Test { type Currency = Balances; type PalletId = DapPalletId; + type IssuanceCurve = TestIssuanceCurve; + type BudgetRecipients = (Dap, TestStakerRecipient, TestValidatorIncentiveRecipient); + type Time = MockTime; + type IssuanceCadence = IssuanceCadence; + type MaxElapsedPerDrip = MaxElapsedPerDrip; + type BudgetOrigin = frame_system::EnsureRoot; + type WeightInfo = (); +} + +/// Sets a default budget allocation mimicking what the migration would do. +pub fn set_default_budget_allocation() { + use sp_runtime::{BoundedBTreeMap, Perbill}; + use sp_staking::budget::BudgetRecipient; + + let mut map = BoundedBTreeMap::new(); + map.try_insert(Dap::budget_key(), Perbill::from_percent(15)).unwrap(); + map.try_insert(TestStakerRecipient::budget_key(), Perbill::from_percent(85)) + .unwrap(); + map.try_insert(TestValidatorIncentiveRecipient::budget_key(), Perbill::from_percent(0)) + .unwrap(); + crate::BudgetAllocation::::put(map); } -pub fn new_test_ext(fund_buffer: bool) -> sp_io::TestExternalities { +fn new_test_ext_inner(fund_buffer: bool) -> sp_io::TestExternalities { let mut balances = vec![(1, 100), (2, 200), (3, 300)]; if fund_buffer { - let buffer: u64 = DapPalletId::get().into_account_truncating(); + let buffer: AccountId = DapPalletId::get().into_account_truncating(); balances.push((buffer, ExistentialDeposit::get())); } @@ -67,5 +135,27 @@ pub fn new_test_ext(fund_buffer: bool) -> sp_io::TestExternalities { pallet_balances::GenesisConfig:: { balances, ..Default::default() } .assimilate_storage(&mut t) .unwrap(); - t.into() + let mut ext: sp_io::TestExternalities = t.into(); + + ext.execute_with(|| { + // Initialize time to simulate "genesis already happened". + MockTime::set(1_000_000); + // Initialize LastIssuanceTimestamp so drip doesn't skip first call. + crate::LastIssuanceTimestamp::::put(1_000_000); + }); + + ext +} + +pub fn build_and_execute(fund_buffer: bool, test: impl FnOnce()) { + let mut ext = new_test_ext_inner(fund_buffer); + ext.execute_with(test); + ext.execute_with(|| { + Dap::do_try_state().unwrap(); + }); +} + +/// Asserts that `do_try_state` fails. Use after intentionally corrupting storage. +pub fn assert_try_state_invalid() { + assert!(Dap::do_try_state().is_err()); } diff --git a/substrate/frame/dap/src/tests/budget.rs b/substrate/frame/dap/src/tests/budget.rs new file mode 100644 index 0000000000000..90520b81968bc --- /dev/null +++ b/substrate/frame/dap/src/tests/budget.rs @@ -0,0 +1,140 @@ +// 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. + +//! Tests for budget allocation functionality. +use super::{budget_map, key}; +use crate::{ + mock::{ + assert_try_state_invalid, build_and_execute, set_default_budget_allocation, Dap, + RuntimeOrigin, System, Test, + }, + BudgetAllocation, Error, Event, +}; +use frame_support::{assert_noop, assert_ok}; +use sp_runtime::Perbill; + +#[test] +fn set_budget_allocation_works_with_root() { + build_and_execute(true, || { + System::set_block_number(1); + + let allocs = + budget_map(&[(b"buffer", 20), (b"staker_rewards", 60), (b"validator_incentive", 20)]); + + assert_ok!(Dap::set_budget_allocation(RuntimeOrigin::root(), allocs.clone())); + + assert_eq!(BudgetAllocation::::get(), allocs); + System::assert_has_event(Event::BudgetAllocationUpdated { allocations: allocs }.into()); + }); +} + +#[test] +fn set_budget_allocation_rejects_unknown_key() { + build_and_execute(true, || { + // GIVEN: default budget allocation with known keys (buffer, staker_rewards, + // validator_incentive). + set_default_budget_allocation(); + + // WHEN: trying to set an allocation with an unknown key. + let allocs = budget_map(&[(b"unknown_key", 50)]); + + // THEN: rejected. + assert_noop!( + Dap::set_budget_allocation(RuntimeOrigin::root(), allocs), + Error::::UnknownBudgetKey + ); + }); +} + +#[test] +fn set_budget_allocation_rejects_over_100_percent() { + build_and_execute(true, || { + set_default_budget_allocation(); + + // WHEN: allocations sum to 110%. + let allocs = budget_map(&[(b"buffer", 50), (b"staker_rewards", 60)]); + + // THEN: rejected. + assert_noop!( + Dap::set_budget_allocation(RuntimeOrigin::root(), allocs), + Error::::BudgetNotExact + ); + }); +} + +#[test] +fn set_budget_allocation_rejects_under_100_percent() { + build_and_execute(true, || { + set_default_budget_allocation(); + + // WHEN: allocations sum to only 50%. + let allocs = budget_map(&[(b"staker_rewards", 50)]); + + // THEN: rejected. + assert_noop!( + Dap::set_budget_allocation(RuntimeOrigin::root(), allocs), + Error::::BudgetNotExact + ); + }); +} + +#[test] +fn set_budget_allocation_requires_budget_origin() { + build_and_execute(true, || { + set_default_budget_allocation(); + + let allocs = budget_map(&[(b"staker_rewards", 80)]); + + assert_noop!( + Dap::set_budget_allocation(RuntimeOrigin::signed(1), allocs), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn try_state_detects_unknown_key_in_allocation() { + build_and_execute(true, || { + set_default_budget_allocation(); + + // Corrupt: inject an unregistered key. + let mut corrupt = budget_map(&[(b"staker_rewards", 100)]); + corrupt.try_insert(key(b"rogue_key"), Perbill::from_percent(0)).unwrap(); + BudgetAllocation::::put(corrupt); + + assert_try_state_invalid(); + + // Restore valid state for post-test try_state. + set_default_budget_allocation(); + }); +} + +#[test] +fn try_state_detects_allocation_not_summing_to_100() { + build_and_execute(true, || { + set_default_budget_allocation(); + + // Corrupt: allocations don't sum to 100%. + let corrupt = budget_map(&[(b"buffer", 10), (b"staker_rewards", 50)]); + BudgetAllocation::::put(corrupt); + + assert_try_state_invalid(); + + // Restore valid state for post-test try_state. + set_default_budget_allocation(); + }); +} diff --git a/substrate/frame/dap/src/tests/drip.rs b/substrate/frame/dap/src/tests/drip.rs new file mode 100644 index 0000000000000..c71bd7461cf08 --- /dev/null +++ b/substrate/frame/dap/src/tests/drip.rs @@ -0,0 +1,208 @@ +// 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. + +//! Tests for issuance drip and distribution. + +use super::budget_map; +use crate::{ + mock::{ + build_and_execute, set_default_budget_allocation, Balances, Dap, MockTime, RuntimeOrigin, + System, Test, + }, + Event, +}; +use frame_support::{assert_ok, traits::fungible::Inspect}; +use sp_runtime::BuildStorage; + +fn advance_time_and_drip(elapsed_ms: u64) { + let now = MockTime::get(); + MockTime::set(now + elapsed_ms); + Dap::drip_issuance(); +} + +#[test] +fn drip_distributes_according_to_budget() { + build_and_execute(true, || { + System::set_block_number(1); + + // GIVEN: 60% staker, 25% validator incentive, 15% buffer + let allocs = + budget_map(&[(b"staker_rewards", 60), (b"validator_incentive", 25), (b"buffer", 15)]); + assert_ok!(Dap::set_budget_allocation(RuntimeOrigin::root(), allocs)); + + let staker_pot = 500; // TestStakerRecipient pot account + let incentive_pot = 501; // TestValidatorIncentiveRecipient pot account + let buffer = Dap::buffer_account(); + + let staker_before = Balances::balance(&staker_pot); + let incentive_before = Balances::balance(&incentive_pot); + let buffer_before = Balances::balance(&buffer); + + // WHEN: 60 seconds elapse → TestIssuanceCurve returns 100 + advance_time_and_drip(60_000); + + // THEN: 60% of 100 = 60 to stakers, 25% = 25 to incentive, 15% = 15 to buffer + assert_eq!(Balances::balance(&staker_pot) - staker_before, 60); + assert_eq!(Balances::balance(&incentive_pot) - incentive_before, 25); + assert_eq!(Balances::balance(&buffer) - buffer_before, 15); + + System::assert_has_event( + Event::::IssuanceMinted { total_minted: 100, elapsed_millis: 60_000 }.into(), + ); + }); +} + +#[test] +fn drip_skips_when_cadence_not_reached() { + build_and_execute(true, || { + set_default_budget_allocation(); + System::set_block_number(1); + let buffer = Dap::buffer_account(); + let buffer_before = Balances::balance(&buffer); + + // WHEN: only 30 seconds pass (cadence = 60s) + advance_time_and_drip(30_000); + + // THEN: nothing minted + assert_eq!(Balances::balance(&buffer), buffer_before); + }); +} + +#[test] +fn drip_fires_after_cadence_reached() { + build_and_execute(true, || { + System::set_block_number(1); + + // Set 100% to buffer. + let allocs = budget_map(&[(b"buffer", 100)]); + assert_ok!(Dap::set_budget_allocation(RuntimeOrigin::root(), allocs)); + + let buffer = Dap::buffer_account(); + let buffer_before = Balances::balance(&buffer); + + // WHEN: 30s passes (no drip), then another 30s (total 60s, drip fires) + advance_time_and_drip(30_000); + assert_eq!(Balances::balance(&buffer), buffer_before); + + advance_time_and_drip(30_000); + // 60s elapsed total → TestIssuanceCurve returns 100. All to buffer. + assert_eq!(Balances::balance(&buffer) - buffer_before, 100); + }); +} + +#[test] +fn no_drip_when_budget_not_set() { + build_and_execute(true, || { + System::set_block_number(1); + + // GIVEN: no budget allocation set. + + let staker_pot = 500; // TestStakerRecipient pot account + let balance_before = Balances::balance(&staker_pot); + + // WHEN: drip fires with empty budget — no panic, just early return. + advance_time_and_drip(60_000); + + // THEN: no funds distributed. + assert_eq!(Balances::balance(&staker_pot), balance_before); + + // Restore for post-test try_state. + set_default_budget_allocation(); + }); +} + +#[test] +fn try_state_fails_with_empty_allocation() { + build_and_execute(true, || { + // BudgetAllocation is empty — try_state should catch this. + assert!(Dap::do_try_state().is_err()); + + // Set valid allocation so post-test try_state passes. + set_default_budget_allocation(); + }); +} + +#[test] +fn elapsed_ceiling_is_applied() { + build_and_execute(true, || { + System::set_block_number(1); + + // Set 100% to buffer. + let allocs = budget_map(&[(b"buffer", 100)]); + assert_ok!(Dap::set_budget_allocation(RuntimeOrigin::root(), allocs)); + + let buffer = Dap::buffer_account(); + let buffer_before = Balances::balance(&buffer); + + // WHEN: 20 minutes pass but MaxElapsedPerDrip = 600_000ms (10 minutes) + // Without clamping: 1_200_000ms → TestIssuanceCurve returns 2000 + // With clamping: 600_000ms → TestIssuanceCurve returns 1000 + advance_time_and_drip(1_200_000); + + // THEN: issuance based on clamped elapsed (1000, not 2000) + assert_eq!(Balances::balance(&buffer) - buffer_before, 1000); + + // AND: ElapsedClamped event emitted + System::assert_has_event( + Event::::Unexpected(crate::UnexpectedKind::ElapsedClamped { + actual_elapsed: 1_200_000, + ceiling: 600_000, + }) + .into(), + ); + }); +} + +#[test] +fn first_block_initializes_timestamp_without_dripping() { + // Test that when LastIssuanceTimestamp is 0 (genesis), it initializes without dripping. + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + pallet_balances::GenesisConfig:: { balances: vec![(1, 100)], ..Default::default() } + .assimilate_storage(&mut t) + .unwrap(); + let mut ext: sp_io::TestExternalities = t.into(); + + ext.execute_with(|| { + // LastIssuanceTimestamp defaults to 0 (not initialized) + assert_eq!(crate::LastIssuanceTimestamp::::get(), 0); + + MockTime::set(1_000_000); + Dap::drip_issuance(); + + // Timestamp should be set but nothing minted. + assert_eq!(crate::LastIssuanceTimestamp::::get(), 1_000_000); + // Total issuance unchanged (only the initial 100 balance). + assert_eq!(Balances::total_issuance(), 100); + }); +} + +#[test] +fn drip_emits_issuance_minted_event() { + build_and_execute(true, || { + System::set_block_number(1); + + // Set 100% to buffer so drip distributes. + let allocs = budget_map(&[(b"buffer", 100)]); + assert_ok!(Dap::set_budget_allocation(RuntimeOrigin::root(), allocs)); + + advance_time_and_drip(60_000); + + System::assert_has_event( + Event::::IssuanceMinted { total_minted: 100, elapsed_millis: 60_000 }.into(), + ); + }); +} diff --git a/substrate/frame/dap/src/tests/genesis.rs b/substrate/frame/dap/src/tests/genesis.rs index 29dfbfdc0b9fc..c4982751763a1 100644 --- a/substrate/frame/dap/src/tests/genesis.rs +++ b/substrate/frame/dap/src/tests/genesis.rs @@ -23,7 +23,9 @@ type DapPallet = crate::Pallet; #[test] fn genesis_creates_buffer_account() { - new_test_ext(true).execute_with(|| { + build_and_execute(true, || { + set_default_budget_allocation(); + let buffer = DapPallet::buffer_account(); // Buffer account should exist after genesis (created via inc_providers) assert!(System::account_exists(&buffer)); diff --git a/substrate/frame/dap/src/tests/mod.rs b/substrate/frame/dap/src/tests/mod.rs index 620775068be6e..c43552de496db 100644 --- a/substrate/frame/dap/src/tests/mod.rs +++ b/substrate/frame/dap/src/tests/mod.rs @@ -17,5 +17,23 @@ //! Tests for the DAP pallet. +mod budget; +mod drip; mod genesis; mod on_unbalanced; + +use crate::BudgetAllocationMap; +use sp_runtime::{BoundedBTreeMap, Perbill}; +use sp_staking::budget::BudgetKey; + +fn key(name: &[u8]) -> BudgetKey { + BudgetKey::truncate_from(name.to_vec()) +} + +fn budget_map(entries: &[(&[u8], u32)]) -> BudgetAllocationMap { + let mut map = BoundedBTreeMap::new(); + for (name, pct) in entries { + map.try_insert(key(name), Perbill::from_percent(*pct)).unwrap(); + } + map +} diff --git a/substrate/frame/dap/src/tests/on_unbalanced.rs b/substrate/frame/dap/src/tests/on_unbalanced.rs index 1d28e3a16ffa1..c19e80e40285a 100644 --- a/substrate/frame/dap/src/tests/on_unbalanced.rs +++ b/substrate/frame/dap/src/tests/on_unbalanced.rs @@ -17,7 +17,7 @@ //! OnUnbalanced tests for the DAP pallet. -use crate::mock::{new_test_ext, Balances, Test}; +use crate::mock::{build_and_execute, set_default_budget_allocation, Balances, Test}; use frame_support::traits::{ fungible::{Balanced, Inspect}, tokens::{Fortitude, Precision, Preservation}, @@ -29,7 +29,9 @@ type DapPallet = crate::Pallet; #[test] #[should_panic(expected = "Failed to deposit slash to DAP buffer")] fn on_unbalanced_panics_when_buffer_not_funded_and_deposit_below_ed() { - new_test_ext(false).execute_with(|| { + build_and_execute(false, || { + set_default_budget_allocation(); + let buffer = DapPallet::buffer_account(); let ed = >::minimum_balance(); @@ -37,7 +39,7 @@ fn on_unbalanced_panics_when_buffer_not_funded_and_deposit_below_ed() { assert_eq!(Balances::free_balance(buffer), 0); // When: deposit < ED -> triggers defensive panic - let credit = >::withdraw( + let credit = >::withdraw( &1, ed - 1, Precision::Exact, @@ -51,7 +53,9 @@ fn on_unbalanced_panics_when_buffer_not_funded_and_deposit_below_ed() { #[test] fn on_unbalanced_creates_buffer_when_not_funded_and_deposit_at_least_ed() { - new_test_ext(false).execute_with(|| { + build_and_execute(false, || { + set_default_budget_allocation(); + let buffer = DapPallet::buffer_account(); let ed = >::minimum_balance(); @@ -59,7 +63,7 @@ fn on_unbalanced_creates_buffer_when_not_funded_and_deposit_at_least_ed() { assert_eq!(Balances::free_balance(buffer), 0); // When: deposit >= ED - let credit = >::withdraw( + let credit = >::withdraw( &1, ed, Precision::Exact, @@ -76,18 +80,24 @@ fn on_unbalanced_creates_buffer_when_not_funded_and_deposit_at_least_ed() { #[test] fn slash_to_dap_accumulates_multiple_slashes_to_buffer() { - new_test_ext(true).execute_with(|| { + build_and_execute(true, || { + set_default_budget_allocation(); + let buffer = DapPallet::buffer_account(); let ed = >::minimum_balance(); - // Given: buffer has ED, users have balances (1: 100, 2: 200, 3: 300) + let alice = 1; // slashable user + let bob = 2; // slashable user + let charlie = 3; // slashable user + + // Given: buffer has ED, users have balances (alice: 100, bob: 200, charlie: 300) assert_eq!(Balances::free_balance(buffer), ed); let initial_active = >::active_issuance(); let initial_total = >::total_issuance(); // When: multiple slashes occur via OnUnbalanced (simulating staking slashes) - let credit1 = >::withdraw( - &1, + let credit1 = >::withdraw( + &alice, 30, Precision::Exact, Preservation::Preserve, @@ -96,8 +106,8 @@ fn slash_to_dap_accumulates_multiple_slashes_to_buffer() { .unwrap(); DapPallet::on_unbalanced(credit1); - let credit2 = >::withdraw( - &2, + let credit2 = >::withdraw( + &bob, 20, Precision::Exact, Preservation::Preserve, @@ -106,8 +116,8 @@ fn slash_to_dap_accumulates_multiple_slashes_to_buffer() { .unwrap(); DapPallet::on_unbalanced(credit2); - let credit3 = >::withdraw( - &3, + let credit3 = >::withdraw( + &charlie, 50, Precision::Exact, Preservation::Preserve, @@ -116,21 +126,24 @@ fn slash_to_dap_accumulates_multiple_slashes_to_buffer() { .unwrap(); DapPallet::on_unbalanced(credit3); - // Then: buffer has ED + all slashes - assert_eq!(Balances::free_balance(buffer), ed + 100); + // Then: buffer accumulated all slashes + assert_eq!(Balances::free_balance(&buffer), ed + 100); // And: users lost their slashed amounts - assert_eq!(Balances::free_balance(1), 100 - 30); - assert_eq!(Balances::free_balance(2), 200 - 20); - assert_eq!(Balances::free_balance(3), 300 - 50); + assert_eq!(Balances::free_balance(alice), 100 - 30); + assert_eq!(Balances::free_balance(bob), 200 - 20); + assert_eq!(Balances::free_balance(charlie), 300 - 50); // And: active issuance decreased by 100 (funds deactivated in DAP buffer) assert_eq!(>::active_issuance(), initial_active - 100); // When: slash with zero amount (no-op) - let credit = >::issue(0); + let credit = Balances::issue(0); DapPallet::on_unbalanced(credit); + // Then: buffer unchanged (still ED + 100) + assert_eq!(Balances::free_balance(&buffer), ed + 100); + // And: total issuance unchanged (funds moved, not created/destroyed) assert_eq!(>::total_issuance(), initial_total); diff --git a/substrate/frame/dap/src/weights.rs b/substrate/frame/dap/src/weights.rs new file mode 100644 index 0000000000000..4429681241221 --- /dev/null +++ b/substrate/frame/dap/src/weights.rs @@ -0,0 +1,44 @@ +// 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. + +//! Placeholder weights for `pallet_dap`. +//! +//! These weights are not benchmarked. Replace with actual benchmarked weights +//! via `frame-omni-bencher` before deploying to production. + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use frame_support::weights::Weight; + +/// Weight functions needed for `pallet_dap`. +pub trait WeightInfo { + fn set_budget_allocation() -> Weight; + fn drip_issuance() -> Weight; +} + +/// Default weights (not benchmarked). +impl WeightInfo for () { + fn set_budget_allocation() -> Weight { + Weight::zero() + } + fn drip_issuance() -> Weight { + Weight::zero() + } +} diff --git a/substrate/frame/staking-async/integration-tests/src/ah/mock.rs b/substrate/frame/staking-async/integration-tests/src/ah/mock.rs index ba6e83f88cba7..95e1fc69f4295 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/mock.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/mock.rs @@ -509,11 +509,28 @@ impl pallet_staking_async_rc_client::Config for Runtime { parameter_types! { pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); + pub const DapIssuanceCadence: u64 = 60_000; + pub const DapMaxElapsedPerDrip: u64 = 600_000; + pub static MockTime: u64 = 0; +} + +impl frame_support::traits::Time for MockTime { + type Moment = u64; + fn now() -> u64 { + Self::get() + } } impl pallet_dap::Config for Runtime { type Currency = Balances; type PalletId = DapPalletId; + type IssuanceCurve = (); + type BudgetRecipients = (pallet_dap::Pallet,); + type Time = MockTime; + type IssuanceCadence = DapIssuanceCadence; + type MaxElapsedPerDrip = DapMaxElapsedPerDrip; + type BudgetOrigin = frame_system::EnsureRoot; + type WeightInfo = (); } parameter_types! { diff --git a/substrate/frame/staking-async/integration-tests/src/ah/test.rs b/substrate/frame/staking-async/integration-tests/src/ah/test.rs index 9119c9145d707..80c6878e9cbe8 100644 --- a/substrate/frame/staking-async/integration-tests/src/ah/test.rs +++ b/substrate/frame/staking-async/integration-tests/src/ah/test.rs @@ -718,7 +718,9 @@ fn on_offence_current_era_instant_apply() { let _ = staking_events_since_last_call(); // Record initial state for DAP verification - let dap_buffer = pallet_dap::Pallet::::buffer_account(); + let dap_buffer = as sp_staking::budget::BudgetRecipient< + AccountId, + >>::pot_account(); let initial_dap_balance = Balances::free_balance(&dap_buffer); let initial_total_issuance = Balances::total_issuance(); diff --git a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs index cdb4a6feba55c..1296fc73781e8 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs @@ -493,11 +493,20 @@ impl pallet_staking_async_rc_client::Config for Runtime { parameter_types! { pub const DapPalletId: frame_support::PalletId = frame_support::PalletId(*b"dap/buff"); + pub const DapIssuanceCadence: u64 = 60_000; + pub const DapMaxElapsedPerDrip: u64 = 600_000; } impl pallet_dap::Config for Runtime { type Currency = Balances; type PalletId = DapPalletId; + type IssuanceCurve = (); + type BudgetRecipients = (pallet_dap::Pallet,); + type Time = pallet_timestamp::Pallet; + type IssuanceCadence = DapIssuanceCadence; + type MaxElapsedPerDrip = DapMaxElapsedPerDrip; + type BudgetOrigin = frame_system::EnsureRoot; + type WeightInfo = (); } parameter_types! { diff --git a/substrate/primitives/staking/src/budget.rs b/substrate/primitives/staking/src/budget.rs index c8c7caf8633df..60bf6cf08b9fa 100644 --- a/substrate/primitives/staking/src/budget.rs +++ b/substrate/primitives/staking/src/budget.rs @@ -41,6 +41,12 @@ pub trait IssuanceCurve { fn issue(total_issuance: Balance, elapsed_millis: u64) -> Balance; } +impl IssuanceCurve for () { + fn issue(_total_issuance: Balance, _elapsed_millis: u64) -> Balance { + Default::default() + } +} + /// A recipient of inflation budget. /// /// Pallets that want a share of inflation implement this trait, providing a unique key