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}