referrerpolicy=no-referrer-when-downgrade

pallet_dap/
lib.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! # Dynamic Allocation Pool (DAP) Pallet
19//!
20//! Generic issuance drip and distribution engine.
21//!
22//! ## Key Responsibilities:
23//!
24//! - **Issuance Drip**: Mints new tokens on a configurable cadence (per-block or every N minutes)
25//!   based on an [`IssuanceCurve`].
26//! - **Budget Distribution**: Distributes minted issuance across registered
27//!   [`sp_staking::budget::BudgetRecipient`]s according to a governance-updatable
28//!   `BoundedBTreeMap<BudgetKey, Perbill>` that must sum to exactly 100%.
29//! - **Burn Collection**: Implements `OnUnbalanced` to intercept any burn source wired to it
30//!   (staking slashes, transaction fees, dust removal, EVM gas rounding, etc.) and redirect funds
31//!   into the buffer account. Incoming funds are deactivated to exclude them from governance
32//!   voting.
33
34#![cfg_attr(not(feature = "std"), no_std)]
35
36pub mod migrations;
37pub mod weights;
38pub use weights::WeightInfo;
39
40#[cfg(feature = "runtime-benchmarks")]
41pub mod benchmarking;
42
43#[cfg(test)]
44pub(crate) mod mock;
45#[cfg(test)]
46mod tests;
47
48extern crate alloc;
49
50use alloc::vec::Vec;
51use codec::DecodeWithMemTracking;
52use frame_support::{
53	defensive,
54	pallet_prelude::*,
55	traits::{
56		fungible::{Balanced, Credit, Inspect, Mutate, Unbalanced},
57		tokens::{Fortitude, Preservation},
58		Currency, Imbalance, OnUnbalanced, Time,
59	},
60	weights::WeightMeter,
61	PalletId,
62};
63use sp_runtime::{traits::Zero, BoundedBTreeMap, Perbill, SaturatedConversion, Saturating};
64use sp_staking::budget::{BudgetKey, BudgetRecipientList, IssuanceCurve};
65
66pub use pallet::*;
67
68pub use sp_dap::DAP_PALLET_ID;
69
70const LOG_TARGET: &str = "runtime::dap";
71
72/// Maximum number of budget recipients.
73pub const MAX_BUDGET_RECIPIENTS: u32 = 16;
74
75/// Type alias for balance.
76pub type BalanceOf<T> =
77	<<T as Config>::Currency as Inspect<<T as frame_system::Config>::AccountId>>::Balance;
78
79/// Type alias for the budget allocation map.
80pub type BudgetAllocationMap = BoundedBTreeMap<BudgetKey, Perbill, ConstU32<MAX_BUDGET_RECIPIENTS>>;
81
82#[frame_support::pallet]
83pub mod pallet {
84	use super::*;
85	use crate::weights::WeightInfo;
86	use frame_support::{sp_runtime::traits::AccountIdConversion, traits::StorageVersion};
87	use frame_system::pallet_prelude::*;
88
89	/// The in-code storage version.
90	const STORAGE_VERSION: StorageVersion = StorageVersion::new(2);
91
92	#[pallet::pallet]
93	#[pallet::storage_version(STORAGE_VERSION)]
94	pub struct Pallet<T>(_);
95
96	#[pallet::config]
97	pub trait Config: frame_system::Config<RuntimeEvent: From<Event<Self>>> {
98		/// The currency type (new fungible traits).
99		type Currency: Inspect<Self::AccountId>
100			+ Mutate<Self::AccountId>
101			+ Unbalanced<Self::AccountId>
102			+ Balanced<Self::AccountId>;
103
104		/// The pallet ID used to derive the buffer account.
105		#[pallet::constant]
106		type PalletId: Get<PalletId>;
107
108		/// Issuance curve: computes how much to mint given total issuance and elapsed time.
109		type IssuanceCurve: IssuanceCurve<BalanceOf<Self>>;
110
111		/// Registered budget recipients. Each element provides a unique key and pot account.
112		///
113		/// Wired in the runtime as a tuple, e.g.:
114		/// ```ignore
115		/// type BudgetRecipients = (Dap, StakerRewardRecipient, ValidatorIncentiveRecipient);
116		/// ```
117		type BudgetRecipients: BudgetRecipientList<Self::AccountId>;
118
119		/// Time provider (typically `pallet_timestamp`).
120		///
121		/// `Moment` must represent milliseconds.
122		type Time: Time;
123
124		/// Minimum elapsed time (ms) between issuance drips.
125		///
126		/// - `0` = drip every block
127		/// - `60_000` = drip every minute (Recommended)
128		///
129		/// Should be small relative to era length.
130		#[pallet::constant]
131		type IssuanceCadence: Get<u64>;
132
133		/// Safety ceiling: maximum elapsed time (ms) considered in a single drip.
134		///
135		/// If more time has passed than this, elapsed is clamped to this value.
136		/// Prevents accidental over-minting from bugs, misconfiguration, or long
137		/// periods without blocks.
138		#[pallet::constant]
139		type MaxElapsedPerDrip: Get<u64>;
140
141		/// Origin that can update budget allocation percentages.
142		type BudgetOrigin: EnsureOrigin<Self::RuntimeOrigin>;
143
144		/// Weight information for extrinsics in this pallet.
145		type WeightInfo: crate::weights::WeightInfo;
146	}
147
148	#[pallet::event]
149	#[pallet::generate_deposit(pub(super) fn deposit_event)]
150	pub enum Event<T: Config> {
151		/// Inflation dripped and distributed to budget recipients.
152		IssuanceMinted {
153			/// Total amount minted in this drip.
154			total_minted: BalanceOf<T>,
155			/// Elapsed time (ms) since last drip.
156			elapsed_millis: u64,
157		},
158		/// Budget allocation was updated via governance.
159		BudgetAllocationUpdated {
160			/// The new budget allocation map.
161			allocations: BudgetAllocationMap,
162		},
163		/// Funds were drained from the staging account into the DAP buffer.
164		StagingDrained {
165			/// Amount drained.
166			amount: BalanceOf<T>,
167		},
168		/// An unexpected/defensive event was triggered.
169		Unexpected(UnexpectedKind),
170	}
171
172	/// Defensive/unexpected errors/events.
173	#[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, DebugNoBound)]
174	pub enum UnexpectedKind {
175		/// Failed to mint issuance.
176		MintFailed,
177		/// Elapsed time was clamped at the safety ceiling.
178		ElapsedClamped {
179			/// The actual elapsed time in milliseconds.
180			actual_elapsed: u64,
181			/// The ceiling that was applied.
182			ceiling: u64,
183		},
184	}
185
186	/// Budget allocation map: `BudgetKey -> Perbill`.
187	///
188	/// Keys must correspond to registered `BudgetRecipients`. Sum of values must be
189	/// exactly `Perbill::one()` (100%). Recipients not included receive nothing.
190	#[pallet::storage]
191	pub type BudgetAllocation<T> = StorageValue<_, BudgetAllocationMap, ValueQuery>;
192
193	/// Timestamp (ms) of the last issuance drip.
194	///
195	/// On existing chains, this must be seeded via
196	/// [`migrations::MigrateV1ToV2`] to prevent incorrect minting on the first drip.
197	#[pallet::storage]
198	pub type LastIssuanceTimestamp<T> = StorageValue<_, u64, ValueQuery>;
199
200	#[pallet::error]
201	pub enum Error<T> {
202		/// A key in the budget allocation does not match any registered recipient.
203		UnknownBudgetKey,
204		/// Budget allocation percentages do not sum to exactly 100%.
205		BudgetNotExact,
206	}
207
208	#[pallet::hooks]
209	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
210		fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
211			Self::drip_issuance()
212		}
213
214		fn on_idle(_block: BlockNumberFor<T>, remaining_weight: Weight) -> Weight {
215			let mut meter = WeightMeter::with_limit(remaining_weight);
216
217			// Need at least one read (staging account balance).
218			if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
219				return meter.consumed();
220			}
221
222			let staging_account = Self::staging_account();
223			let available = T::Currency::reducible_balance(
224				&staging_account,
225				Preservation::Preserve,
226				Fortitude::Polite,
227			);
228
229			if available.is_zero() {
230				return meter.consumed();
231			}
232
233			// Need 1 read and 2 writes for the transfer, plus 1 read and 1 write for
234			// deactivate (InactiveIssuance) and 1 read for TotalIssuance.
235			if meter.try_consume(T::DbWeight::get().reads_writes(3, 3)).is_err() {
236				return meter.consumed();
237			}
238
239			let buffer = Self::buffer_account();
240			if T::Currency::transfer(&staging_account, &buffer, available, Preservation::Preserve)
241				.is_err()
242			{
243				defensive!("DAP: staging account transfer to buffer failed");
244				return meter.consumed();
245			}
246
247			Self::deactivate_buffer_funds(available);
248			Self::deposit_event(Event::StagingDrained { amount: available });
249
250			log::debug!(
251				target: LOG_TARGET,
252				"DAP: drained {available:?} from staging account to DAP buffer"
253			);
254
255			meter.consumed()
256		}
257
258		fn integrity_test() {
259			assert!(
260				T::MaxElapsedPerDrip::get() > T::IssuanceCadence::get(),
261				"MaxElapsedPerDrip must be greater than IssuanceCadence, \
262				 otherwise every drip would be clamped below the cadence threshold."
263			);
264
265			// Ensure BudgetRecipients have no duplicate keys.
266			let mut keys: Vec<_> =
267				T::BudgetRecipients::recipients().into_iter().map(|(k, _)| k).collect();
268			keys.sort();
269			assert!(
270				keys.windows(2).all(|w| w[0] != w[1]),
271				"Duplicate BudgetRecipient key detected"
272			);
273		}
274
275		#[cfg(feature = "try-runtime")]
276		fn try_state(_n: BlockNumberFor<T>) -> Result<(), sp_runtime::TryRuntimeError> {
277			// TODO(ank4n): Re-enable after this migration is included in runtime.
278			// Self::do_try_state()
279			Ok(())
280		}
281	}
282
283	#[pallet::call]
284	impl<T: Config> Pallet<T> {
285		/// Set the budget allocation map.
286		///
287		/// Each key must match a registered `BudgetRecipient`. The sum of all percentages
288		/// must be exactly 100%. Recipients not included in the map receive nothing.
289		#[pallet::call_index(0)]
290		#[pallet::weight(T::WeightInfo::set_budget_allocation())]
291		pub fn set_budget_allocation(
292			origin: OriginFor<T>,
293			new_allocations: BudgetAllocationMap,
294		) -> DispatchResult {
295			T::BudgetOrigin::ensure_origin(origin)?;
296
297			// Validate all keys are registered recipients.
298			let registered: Vec<_> =
299				T::BudgetRecipients::recipients().into_iter().map(|(k, _)| k).collect();
300			for key in new_allocations.keys() {
301				ensure!(registered.contains(key), Error::<T>::UnknownBudgetKey);
302			}
303
304			// Validate sum == 100%. Use u64 to avoid overflow when summing deconstructed Perbills.
305			let total_parts: u64 = new_allocations.values().map(|p| p.deconstruct() as u64).sum();
306			ensure!(total_parts == Perbill::one().deconstruct() as u64, Error::<T>::BudgetNotExact);
307
308			BudgetAllocation::<T>::put(new_allocations.clone());
309			Self::deposit_event(Event::BudgetAllocationUpdated { allocations: new_allocations });
310
311			Ok(())
312		}
313	}
314
315	#[pallet::view_functions]
316	impl<T: Config> Pallet<T> {
317		/// All registered budget recipients with their current allocation shares.
318		///
319		/// The `Perbill` is taken from `BudgetAllocation`; recipients absent from
320		/// the map appear with `Perbill::zero()`.
321		pub fn budget_recipients() -> Vec<(BudgetKey, T::AccountId, Perbill)> {
322			let allocation = BudgetAllocation::<T>::get();
323
324			T::BudgetRecipients::recipients()
325				.into_iter()
326				.map(|(key, account)| {
327					let share = allocation.get(&key).copied().unwrap_or(Perbill::zero());
328
329					(key, account, share)
330				})
331				.collect()
332		}
333
334		/// Account that holds burned/slashed funds before they are drained into
335		/// the DAP buffer by `on_idle`. Exposed to clients so they don't have to
336		/// re-derive the sub-account themselves.
337		pub fn staging() -> T::AccountId {
338			Self::staging_account()
339		}
340	}
341
342	impl<T: Config> Pallet<T> {
343		/// The DAP buffer account.
344		///
345		/// Collects any burn source wired to it (staking slashes, unclaimed rewards, etc.)
346		/// and its explicit budget allocation share.
347		pub fn buffer_account() -> T::AccountId {
348			T::PalletId::get().into_account_truncating()
349		}
350
351		/// The DAP staging account.
352		///
353		/// Incoming funds land here and are periodically drained and deactivated into the
354		/// DAP buffer account by `on_idle`.
355		pub fn staging_account() -> T::AccountId {
356			sp_dap::DAP_PALLET_ID.into_sub_account_truncating(sp_dap::DAP_STAGING_ACCOUNT_ID)
357		}
358
359		/// Deactivate funds on buffer inflow.
360		pub(crate) fn deactivate_buffer_funds(amount: BalanceOf<T>) {
361			<T::Currency as Unbalanced<T::AccountId>>::deactivate(amount);
362		}
363
364		/// Core issuance drip logic, called from `on_initialize`.
365		pub(crate) fn drip_issuance() -> Weight {
366			let now_moment = T::Time::now();
367			let now: u64 = now_moment.saturated_into();
368			let last = LastIssuanceTimestamp::<T>::get();
369			let mut elapsed = now.saturating_sub(last);
370
371			let cadence = T::IssuanceCadence::get();
372			if cadence > 0 && elapsed < cadence {
373				return T::DbWeight::get().reads(2);
374			}
375
376			// First block after genesis: initialize timestamp, don't drip.
377			// For existing chains, use `migrations::MigrateV1ToV2` to seed this
378			// value from ActiveEra.start so this branch is never hit post-upgrade.
379			if last == 0 {
380				LastIssuanceTimestamp::<T>::put(now);
381				return T::DbWeight::get().reads_writes(2, 2);
382			}
383
384			// Apply safety ceiling on elapsed time.
385			let max_elapsed = T::MaxElapsedPerDrip::get();
386			if elapsed > max_elapsed {
387				Self::deposit_event(Event::Unexpected(UnexpectedKind::ElapsedClamped {
388					actual_elapsed: elapsed,
389					ceiling: max_elapsed,
390				}));
391				elapsed = max_elapsed;
392			}
393
394			// Always advance the clock so elapsed time doesn't accumulate across skipped drips.
395			LastIssuanceTimestamp::<T>::put(now);
396
397			let _ = Self::mint_and_distribute(elapsed);
398			T::WeightInfo::drip_issuance()
399		}
400
401		/// Mints `IssuanceCurve::issue(total_issuance, elapsed)` and distributes the
402		/// result according to `BudgetAllocation`.
403		///
404		/// Does not read or write `LastIssuanceTimestamp`, does not enforce
405		/// `IssuanceCadence`, and does not apply the `MaxElapsedPerDrip` safety
406		/// ceiling.
407		///
408		/// Returns the total amount successfully minted. Individual recipient mint
409		/// failures emit `MintFailed` and are skipped; the function does not roll
410		/// back successful mints for earlier recipients.
411		pub(crate) fn mint_and_distribute(elapsed: u64) -> BalanceOf<T> {
412			let total_issuance = T::Currency::total_issuance();
413			let issuance = T::IssuanceCurve::issue(total_issuance, elapsed);
414
415			if issuance.is_zero() {
416				return BalanceOf::<T>::zero();
417			}
418
419			let budget = BudgetAllocation::<T>::get();
420			if budget.is_empty() {
421				// TODO: Add defensive! panic once budget is always configured.
422				log::warn!(
423					target: LOG_TARGET,
424					"BudgetAllocation is empty โ€” no issuance will be distributed"
425				);
426				return BalanceOf::<T>::zero();
427			}
428			let recipients = T::BudgetRecipients::recipients();
429			let mut total_minted = BalanceOf::<T>::zero();
430
431			let buffer = Self::buffer_account();
432			for (key, account) in &recipients {
433				let perbill = budget.get(key).copied().unwrap_or(Perbill::zero());
434				let amount = perbill.mul_floor(issuance);
435				if !amount.is_zero() {
436					if let Err(_) = T::Currency::mint_into(account, amount) {
437						Self::deposit_event(Event::Unexpected(UnexpectedKind::MintFailed));
438						defensive!("Issuance mint should not fail");
439					} else {
440						total_minted = total_minted.saturating_add(amount);
441						if *account == buffer {
442							Self::deactivate_buffer_funds(amount);
443						}
444					}
445				}
446			}
447
448			// Rounding dust from Perbill::mul_floor is not minted.
449
450			Self::deposit_event(Event::IssuanceMinted { total_minted, elapsed_millis: elapsed });
451
452			log::debug!(
453				target: LOG_TARGET,
454				"Issuance drip: issued={issuance:?}, minted={total_minted:?}, elapsed={elapsed}ms"
455			);
456
457			debug_assert!(
458				!total_minted.is_zero(),
459				"mint_and_distribute: issuance was non-zero but nothing was minted"
460			);
461
462			total_minted
463		}
464	}
465
466	#[cfg(any(test, feature = "try-runtime"))]
467	impl<T: Config> Pallet<T> {
468		#[allow(dead_code)]
469		pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> {
470			Self::check_budget_allocation()
471		}
472
473		/// Checks that `BudgetAllocation` is consistent:
474		/// - Every key in `BudgetAllocation` must be a registered recipient.
475		/// - Allocation percentages must sum to exactly 100%.
476		fn check_budget_allocation() -> Result<(), sp_runtime::TryRuntimeError> {
477			let allocation = BudgetAllocation::<T>::get();
478
479			ensure!(!allocation.is_empty(), "BudgetAllocation is empty");
480
481			let registered: Vec<BudgetKey> =
482				T::BudgetRecipients::recipients().into_iter().map(|(k, _)| k).collect();
483
484			// Every allocation key must be a registered recipient.
485			for key in allocation.keys() {
486				ensure!(
487					registered.contains(key),
488					"BudgetAllocation contains key not in BudgetRecipients"
489				);
490			}
491
492			// Allocation must sum to exactly 100%.
493			let total_parts: u64 = allocation.values().map(|p| p.deconstruct() as u64).sum();
494			ensure!(
495				total_parts == Perbill::one().deconstruct() as u64,
496				"BudgetAllocation does not sum to 100%"
497			);
498
499			Ok(())
500		}
501	}
502}
503
504/// Type alias for credit (negative imbalance - funds that were slashed/removed).
505pub type CreditOf<T> = Credit<<T as frame_system::Config>::AccountId, <T as Config>::Currency>;
506
507/// Implementation of `OnUnbalanced` for the `fungible::Balanced` trait.
508/// Example: use as `type Slash = Dap` in staking-async config.
509///
510/// For pallets still using the legacy `Currency` trait (e.g. `pallet_referenda`),
511/// use [`DapLegacyAdapter`] instead.
512impl<T: Config> OnUnbalanced<CreditOf<T>> for Pallet<T> {
513	fn on_nonzero_unbalanced(amount: CreditOf<T>) {
514		let staging = Self::staging_account();
515		let numeric_amount = amount.peek();
516
517		// Funds land in the staging account; `on_idle` will drain them into the buffer and
518		// deactivate them there.  Deactivation is intentionally deferred so that active issuance
519		// does not flicker down-then-up within the same block.
520		let _ = T::Currency::resolve(&staging, amount).inspect_err(|_| {
521			defensive!(
522				"๐Ÿšจ Failed to deposit slash to DAP staging account - funds burned, it should never happen!"
523			);
524		});
525		log::debug!(
526			target: LOG_TARGET,
527			"๐Ÿ’ธ Deposited {numeric_amount:?} to DAP staging account"
528		);
529	}
530}
531
532/// Type alias for legacy `NegativeImbalance` from the `Currency` trait.
533type LegacyNegativeImbalance<A, C> = <C as Currency<A>>::NegativeImbalance;
534
535/// Adapter that redirects `NegativeImbalance` from the legacy `Currency` trait to the DAP buffer.
536///
537/// Cannot be implemented directly on `Pallet<T>` because the compiler cannot prove that
538/// `<C as Currency>::NegativeImbalance` and `fungible::Credit` are always distinct types,
539/// so two `OnUnbalanced` impls on the same struct are rejected.
540///
541/// Will be removed once all consumer pallets migrate to fungible traits.
542///
543/// # Example
544/// ```ignore
545/// type Slash = pallet_dap::DapLegacyAdapter<Runtime, Balances>;
546/// ```
547pub struct DapLegacyAdapter<T, C>(core::marker::PhantomData<(T, C)>);
548
549impl<T: Config, C> OnUnbalanced<LegacyNegativeImbalance<T::AccountId, C>> for DapLegacyAdapter<T, C>
550where
551	C: Currency<T::AccountId, Balance = BalanceOf<T>>,
552{
553	fn on_nonzero_unbalanced(amount: LegacyNegativeImbalance<T::AccountId, C>) {
554		let staging = Pallet::<T>::staging_account();
555		let numeric_amount = amount.peek();
556		// NOTE: resolve_creating is infallible.
557		C::resolve_creating(&staging, amount);
558		log::debug!(
559			target: LOG_TARGET,
560			"๐Ÿ’ธ Deposited (legacy) {numeric_amount:?} to DAP staging account"
561		);
562	}
563}
564
565/// DAP exposes its buffer as a budget recipient so it can receive an explicit
566/// allocation share (in addition to the implicit remainder).
567impl<T: Config> sp_staking::budget::BudgetRecipient<T::AccountId> for Pallet<T> {
568	fn budget_key() -> BudgetKey {
569		BudgetKey::truncate_from(b"buffer".to_vec())
570	}
571
572	fn pot_account() -> T::AccountId {
573		Self::buffer_account()
574	}
575}