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}