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