Skip to main content

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