referrerpolicy=no-referrer-when-downgrade

pallet_accumulate_and_forward/
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//! # Accumulate-and-Forward Pallet
19//!
20//! Intercepts configurable token inflows (transaction fees, dust removal, coretime revenue) on
21//! system parachains and gathers them in a local accumulation account for periodic forwarding
22//! to a configurable destination.
23//!
24//! ## Usage
25//!
26//! - **Fees**: Use [`DealWithFeesSplit`] to split fees between accumulation and other handlers
27//! - **Burns/Revenue**: Use the pallet as `OnUnbalanced<CreditOf>` handler (e.g., dust removal,
28//!   coretime revenue)
29//! Note: Direct calls to `pallet_balances::Pallet::burn()` extrinsic are not redirected to
30//! the accumulation account โ€” they still reduce total issuance directly.
31//!
32//! ## Setup
33//!
34//! The accumulation account must be pre-funded with at least the existential deposit.
35//! For new chains, include the account in the balances genesis config.
36//! For existing chains, fund it via a manual transfer.
37//!
38//! If the accumulation account is not pre-funded, deposits below ED will be silently burned.
39//!
40//! ## Forwarding
41//!
42//! `on_idle` forwards once `TransferPeriod` blocks of the configured `BlockNumberProvider` have
43//! elapsed since the last attempt and the account holds at least `MinTransferAmount` above its
44//! existential deposit. A chain may not observe every block of the provider, so the period is
45//! measured from the recorded last attempt.
46//!
47//! ## Total Issuance
48//!
49//! Accumulated funds are burnt upon forwarding (reducing `total_issuance` here) and the same
50//! funds are minted at the destination when the sent message is received.
51
52#![cfg_attr(not(feature = "std"), no_std)]
53
54#[cfg(test)]
55pub(crate) mod mock;
56#[cfg(test)]
57mod tests;
58
59#[cfg(feature = "runtime-benchmarks")]
60mod benchmarking;
61
62pub mod weights;
63pub use weights::WeightInfo;
64
65use frame_support::{
66	pallet_prelude::*,
67	sp_runtime::traits::Zero,
68	traits::{
69		fungible::{Balanced, Credit, Inspect, Unbalanced},
70		tokens::{Fortitude, Preservation},
71		Currency, Imbalance, OnUnbalanced,
72	},
73	weights::WeightMeter,
74	PalletId,
75};
76use sp_runtime::{traits::BlockNumberProvider, Percent, Saturating};
77
78pub use pallet::*;
79
80/// Trait for forwarding accumulated funds to a configured destination.
81///
82/// Implementations carry all message-construction and dispatch logic, keeping this pallet
83/// free of transport-specific dependencies.
84pub trait Forwarder<AccountId, Balance> {
85	/// Forward `amount` from `source` to the configured destination.
86	fn forward(source: AccountId, amount: Balance) -> Result<(), ()>;
87}
88
89const LOG_TARGET: &str = "runtime::accumulate-forward";
90
91/// Type alias for balance.
92pub type BalanceOf<T> =
93	<<T as Config>::Currency as Inspect<<T as frame_system::Config>::AccountId>>::Balance;
94
95#[frame_support::pallet]
96pub mod pallet {
97	use super::*;
98	use frame_support::sp_runtime::traits::AccountIdConversion;
99	use frame_system::pallet_prelude::BlockNumberFor as SystemBlockNumberFor;
100
101	/// The in-code storage version.
102	const STORAGE_VERSION: frame_support::traits::StorageVersion =
103		frame_support::traits::StorageVersion::new(1);
104
105	/// Block number type derived from the configured [`Config::BlockNumberProvider`].
106	pub type BlockNumberFor<T> =
107		<<T as Config>::BlockNumberProvider as BlockNumberProvider>::BlockNumber;
108
109	#[pallet::pallet]
110	#[pallet::storage_version(STORAGE_VERSION)]
111	pub struct Pallet<T>(_);
112
113	#[pallet::config]
114	pub trait Config: frame_system::Config {
115		/// The currency type.
116		type Currency: Inspect<Self::AccountId>
117			+ Unbalanced<Self::AccountId>
118			+ Balanced<Self::AccountId>;
119
120		/// The pallet ID used to derive the accumulation account.
121		type PalletId: Get<PalletId>;
122
123		/// The implementation responsible for forwarding accumulated funds to the destination.
124		/// Message construction and dispatch logic lives here, keeping this pallet free of
125		/// message-related dependencies.
126		type Forwarder: super::Forwarder<Self::AccountId, BalanceOf<Self>>;
127
128		/// Minimum number of blocks, as counted by [`Config::BlockNumberProvider`], between
129		/// successive forwards. Acts as a rate limiter to avoid sending too many messages.
130		#[pallet::constant]
131		type TransferPeriod: Get<BlockNumberFor<Self>>;
132
133		/// Minimum transferable balance required to trigger a forward.
134		/// This avoids forwarding very small / negligible amounts.
135		/// The accumulation account always retains its existential deposit on top of this.
136		#[pallet::constant]
137		type MinTransferAmount: Get<BalanceOf<Self>>;
138
139		/// Block number provider. Use `RelaychainDataProvider` on parachains so that
140		/// `TransferPeriod` is expressed in relay chain blocks, keeping the cadence stable. It
141		/// may not be observed at every block.
142		type BlockNumberProvider: BlockNumberProvider;
143
144		/// Weight information for the pallet's operations.
145		type WeightInfo: weights::WeightInfo;
146	}
147
148	/// Block of [`Config::BlockNumberProvider`] at which a forward was last attempted.
149	///
150	/// `None` means none was attempted yet, so the next one is not rate limited.
151	#[pallet::storage]
152	pub type LastForwardBlock<T: Config> = StorageValue<_, BlockNumberFor<T>, OptionQuery>;
153
154	#[pallet::event]
155	#[pallet::generate_deposit(pub(super) fn deposit_event)]
156	pub enum Event<T: Config> {
157		/// Successfully forwarded accumulated funds to the destination.
158		ForwardSucceeded { amount: BalanceOf<T> },
159		/// Failed to forward funds. They will remain in the accumulation account
160		/// and forwarding will be retried after another `TransferPeriod` blocks.
161		ForwardFailed { amount: BalanceOf<T> },
162	}
163
164	#[pallet::hooks]
165	impl<T: Config> Hooks<SystemBlockNumberFor<T>> for Pallet<T> {
166		fn on_idle(_block: SystemBlockNumberFor<T>, remaining_weight: Weight) -> Weight {
167			let mut meter = WeightMeter::with_limit(remaining_weight);
168
169			// Need one read for `LastForwardBlock`.
170			if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
171				return meter.consumed();
172			}
173
174			// A chain may not observe every block of the provider, so the period is measured
175			// from the recorded last attempt.
176			let block = T::BlockNumberProvider::current_block_number();
177			if let Some(last) = LastForwardBlock::<T>::get() {
178				if block.saturating_sub(last) < T::TransferPeriod::get() {
179					return meter.consumed();
180				}
181			}
182
183			// Need one read for the balance check.
184			if meter.try_consume(T::DbWeight::get().reads(1)).is_err() {
185				return meter.consumed();
186			}
187
188			let accumulation_account = Self::accumulation_account();
189			// We use `reducible_balance` with `Preservation::Preserve` to get the
190			// usable balance (excluding the ED).
191			let available_funds = T::Currency::reducible_balance(
192				&accumulation_account,
193				Preservation::Preserve,
194				Fortitude::Polite,
195			);
196
197			if available_funds < T::MinTransferAmount::get() {
198				return meter.consumed();
199			}
200
201			// Ensure there is budget for the send plus the write recording it.
202			let send_weight =
203				T::WeightInfo::send_native().saturating_add(T::DbWeight::get().writes(1));
204			if meter.try_consume(send_weight).is_err() {
205				return meter.consumed();
206			}
207
208			// Record before dispatching: a failing destination must be rate limited too.
209			LastForwardBlock::<T>::put(block);
210
211			// Attempt to forward accumulated funds.
212			match T::Forwarder::forward(accumulation_account, available_funds) {
213				Ok(()) => {
214					Self::deposit_event(Event::ForwardSucceeded { amount: available_funds });
215				},
216				Err(()) => {
217					log::debug!(
218						target: LOG_TARGET,
219						"accumulate-forward transfer of {:?} failed at block {:?}",
220						available_funds,
221						block,
222					);
223					Self::deposit_event(Event::ForwardFailed { amount: available_funds });
224				},
225			}
226
227			meter.consumed()
228		}
229
230		fn integrity_test() {
231			assert!(
232				!T::TransferPeriod::get().is_zero(),
233				"TransferPeriod must not be zero (would forward on every block, defeating the \
234				 rate limiter)"
235			);
236		}
237	}
238
239	impl<T: Config> Pallet<T> {
240		/// Get the accumulation account derived from the pallet ID.
241		///
242		/// This account accumulates funds locally before they are forwarded to the destination.
243		pub fn accumulation_account() -> T::AccountId {
244			T::PalletId::get().into_account_truncating()
245		}
246	}
247}
248
249/// Type alias for credit (negative imbalance - funds that were removed).
250/// This is for the `fungible::Balanced` trait.
251pub type CreditOf<T> = Credit<<T as frame_system::Config>::AccountId, <T as Config>::Currency>;
252
253/// A configurable fee handler that splits fees between the accumulation account and another
254/// destination.
255///
256/// - `AccumulatedPercent`: Percentage of fees to accumulate (e.g., `Percent::from_percent(0)`)
257/// - `OtherHandler`: Where to send the remaining fees (e.g., `ToAuthor`, `DealWithFees`)
258///
259/// Tips always go 100% to `OtherHandler`.
260///
261/// # Example
262///
263/// ```ignore
264/// parameter_types! {
265///     pub const AccumulateForwardFeePercent: Percent = Percent::from_percent(0); // 0% accumulated
266/// }
267///
268/// type DealWithFeesAccumulate = pallet_accumulate_and_forward::DealWithFeesSplit<
269///     Runtime,
270///     AccumulateForwardFeePercent,
271///     DealWithFees<Runtime>, // Or ToAuthor<Runtime> for relay chain
272/// >;
273///
274/// impl pallet_transaction_payment::Config for Runtime {
275///     type OnChargeTransaction = FungibleAdapter<Balances, DealWithFeesAccumulate>;
276/// }
277/// ```
278pub struct DealWithFeesSplit<T, AccumulatedPercent, OtherHandler>(
279	core::marker::PhantomData<(T, AccumulatedPercent, OtherHandler)>,
280);
281
282impl<T, AccumulatedPercent, OtherHandler> OnUnbalanced<CreditOf<T>>
283	for DealWithFeesSplit<T, AccumulatedPercent, OtherHandler>
284where
285	T: Config,
286	AccumulatedPercent: Get<Percent>,
287	OtherHandler: OnUnbalanced<CreditOf<T>>,
288{
289	fn on_unbalanceds(mut fees_then_tips: impl Iterator<Item = CreditOf<T>>) {
290		if let Some(fees) = fees_then_tips.next() {
291			let accumulated_percent = AccumulatedPercent::get();
292			let other_percent = Percent::one().saturating_sub(accumulated_percent);
293			let mut split = fees.ration(
294				accumulated_percent.deconstruct() as u32,
295				other_percent.deconstruct() as u32,
296			);
297			if let Some(tips) = fees_then_tips.next() {
298				// Tips go 100% to other handler.
299				tips.merge_into(&mut split.1);
300			}
301			if !accumulated_percent.is_zero() {
302				<Pallet<T> as OnUnbalanced<_>>::on_unbalanced(split.0);
303			}
304			OtherHandler::on_unbalanced(split.1);
305		}
306	}
307}
308
309/// Implementation of `OnUnbalanced` for the `fungible::Balanced` trait.
310///
311/// Use this on system chains to collect imbalances (e.g. coretime revenue, tx fees, dust removal)
312/// that would otherwise be burned, redirecting them to the accumulation account for later
313/// forwarding.
314///
315/// For pallets still using the legacy `Currency` trait (e.g. `pallet_identity`), use
316/// [`LegacyAdapter`] instead.
317impl<T: Config> OnUnbalanced<CreditOf<T>> for Pallet<T> {
318	fn on_nonzero_unbalanced(amount: CreditOf<T>) {
319		let accumulation_account = Self::accumulation_account();
320		let numeric_amount = amount.peek();
321
322		// Resolve should never fail because:
323		// - can_deposit on destination succeeds assuming accumulation account is pre-funded with ED
324		// - amount is guaranteed non-zero by the trait method signature
325		// The only failure would be overflow on destination or unfunded account.
326		let _ = T::Currency::resolve(&accumulation_account, amount).inspect_err(|_| {
327			frame_support::defensive!(
328				"๐Ÿšจ Failed to deposit to accumulation account - funds burned, it should never happen!"
329			);
330		});
331
332		log::debug!(
333			target: LOG_TARGET,
334			"๐Ÿ’ธ Deposited {numeric_amount:?} to accumulation account"
335		);
336	}
337}
338
339/// Type alias for legacy `NegativeImbalance` from the `Currency` trait.
340type LegacyNegativeImbalance<A, C> = <C as Currency<A>>::NegativeImbalance;
341
342/// Adapter that redirects `NegativeImbalance` from the legacy `Currency` trait to the
343/// accumulation account.
344///
345/// Cannot be implemented directly on `Pallet<T>` because the compiler cannot prove that
346/// `<C as Currency>::NegativeImbalance` and `fungible::Credit` are always distinct types,
347/// so two `OnUnbalanced` impls on the same struct are rejected.
348///
349/// Will be removed once all consumer pallets migrate to fungible traits.
350///
351/// # Example
352/// ```ignore
353/// type Slashed = pallet_accumulate_and_forward::LegacyAdapter<Runtime, Balances>;
354/// ```
355pub struct LegacyAdapter<T, C>(core::marker::PhantomData<(T, C)>);
356
357impl<T: Config, C> OnUnbalanced<LegacyNegativeImbalance<T::AccountId, C>> for LegacyAdapter<T, C>
358where
359	C: Currency<T::AccountId>,
360{
361	fn on_nonzero_unbalanced(amount: LegacyNegativeImbalance<T::AccountId, C>) {
362		let accumulation_account = Pallet::<T>::accumulation_account();
363		let numeric_amount = amount.peek();
364		// NOTE: `resolve_creating` is "infallible" because it returns `()`, but it silently burns
365		// the imbalance if it is less than ED and the destination is empty. We guard against this
366		// by making misconfigured runtimes clearly visible. See crate-level docs for the
367		// pre-funding requirement.
368		if C::total_balance(&accumulation_account).saturating_add(numeric_amount) <
369			C::minimum_balance()
370		{
371			frame_support::defensive!(
372				"๐Ÿšจ LegacyAdapter: deposit to accumulation account will be silently burned โ€” \
373				 ensure the accumulation account is pre-funded with at least ED!"
374			);
375		}
376		C::resolve_creating(&accumulation_account, amount);
377		log::debug!(
378			target: LOG_TARGET,
379			"๐Ÿ’ธ Deposited (legacy) {numeric_amount:?} to accumulation account"
380		);
381	}
382}