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