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}