pallet_permission0/permission/
emission.rs

1use polkadot_sdk::{
2    frame_support::traits::{
3        Currency, ExistenceRequirement, Imbalance, ReservableCurrency, WithdrawReasons,
4    },
5    frame_system,
6    sp_arithmetic::FixedU128,
7    sp_runtime::traits::{Saturating, Zero},
8};
9
10use super::*;
11
12/// Type for stream ID
13pub type StreamId = H256;
14
15/// Emission-specific permission scope
16#[derive(Encode, Decode, CloneNoBound, PartialEq, TypeInfo, MaxEncodedLen, DebugNoBound)]
17#[scale_info(skip_type_params(T))]
18pub struct EmissionScope<T: Config> {
19    /// What portion of emissions this permission applies to
20    pub allocation: EmissionAllocation<T>,
21    /// Distribution control parameters
22    pub distribution: DistributionControl<T>,
23    /// Targets to receive the emissions with weights
24    pub targets: BoundedBTreeMap<T::AccountId, u16, T::MaxTargetsPerPermission>,
25    /// Whether emissions should accumulate (can be toggled by enforcement authority)
26    pub accumulating: bool,
27}
28
29impl<T: Config> EmissionScope<T> {
30    pub(super) fn cleanup(
31        self,
32        permission_id: H256,
33        last_executed: &Option<BlockNumberFor<T>>,
34        delegator: &T::AccountId,
35    ) {
36        match self.allocation {
37            EmissionAllocation::Streams(streams) => {
38                for stream in streams.keys() {
39                    AccumulatedStreamAmounts::<T>::remove((delegator, stream, &permission_id));
40                }
41            }
42            EmissionAllocation::FixedAmount(amount) if last_executed.is_none() => {
43                T::Currency::unreserve(delegator, amount);
44            }
45            _ => {}
46        }
47    }
48}
49
50/// Defines what portion of emissions the permission applies to
51#[derive(Encode, Decode, CloneNoBound, PartialEqNoBound, TypeInfo, MaxEncodedLen, DebugNoBound)]
52#[scale_info(skip_type_params(T))]
53pub enum EmissionAllocation<T: Config> {
54    /// Permission applies to a percentage of each stream
55    Streams(BoundedBTreeMap<StreamId, Percent, T::MaxStreamsPerPermission>),
56    /// Permission applies to a specific fixed amount
57    FixedAmount(BalanceOf<T>),
58}
59
60#[derive(Encode, Decode, CloneNoBound, PartialEq, TypeInfo, MaxEncodedLen, DebugNoBound)]
61#[scale_info(skip_type_params(T))]
62pub enum DistributionControl<T: Config> {
63    /// Manual distribution by the delegator
64    Manual,
65    /// Automatic distribution after accumulation threshold
66    Automatic(BalanceOf<T>),
67    /// Distribution at specific block
68    AtBlock(BlockNumberFor<T>),
69    /// Distribution at fixed intervals
70    Interval(BlockNumberFor<T>),
71}
72
73/// Accumulate emissions for a specific agent, distributes if control is met.
74pub(crate) fn do_accumulate_emissions<T: Config>(
75    agent: &T::AccountId,
76    stream_id: &StreamId,
77    imbalance: &mut NegativeImbalanceOf<T>,
78) {
79    let initial_balance = imbalance.peek();
80    let total_initial_amount =
81        FixedU128::from_inner(initial_balance.try_into().unwrap_or_default());
82    if total_initial_amount.is_zero() {
83        return;
84    }
85
86    let streams = AccumulatedStreamAmounts::<T>::iter_prefix((agent, stream_id));
87    for (permission_id, accumulated) in streams {
88        let Some(contract) = Permissions::<T>::get(permission_id) else {
89            continue;
90        };
91
92        // Only process emission permissions with percentage allocations,
93        // fixed-amount emission reserves balance upfront on permission creation
94        let PermissionScope::Emission(EmissionScope {
95            allocation: EmissionAllocation::Streams(streams),
96            accumulating,
97            ..
98        }) = contract.scope
99        else {
100            continue;
101        };
102
103        if !accumulating {
104            continue;
105        }
106
107        let Some(percentage) = streams.get(stream_id) else {
108            continue;
109        };
110
111        let delegated_amount = percentage.mul_floor(total_initial_amount.into_inner());
112        if delegated_amount.is_zero() {
113            continue;
114        }
115
116        let delegated_amount = imbalance
117            .extract(delegated_amount.try_into().unwrap_or_default())
118            .peek();
119
120        AccumulatedStreamAmounts::<T>::set(
121            (agent, stream_id, &permission_id),
122            Some(accumulated.saturating_add(delegated_amount)),
123        );
124
125        Pallet::<T>::deposit_event(Event::AccumulatedEmission {
126            permission_id,
127            stream_id: *stream_id,
128            amount: delegated_amount,
129        });
130    }
131}
132
133pub(crate) fn do_auto_distribution<T: Config>(
134    emission_scope: &EmissionScope<T>,
135    permission_id: H256,
136    current_block: BlockNumberFor<T>,
137    contract: &PermissionContract<T>,
138) -> DispatchResult {
139    match emission_scope.distribution {
140        DistributionControl::Automatic(threshold) => {
141            let accumulated = match &emission_scope.allocation {
142                EmissionAllocation::Streams(streams) => streams
143                    .keys()
144                    .filter_map(|id| {
145                        AccumulatedStreamAmounts::<T>::get((&contract.delegator, id, permission_id))
146                    })
147                    .fold(BalanceOf::<T>::zero(), |acc, e| acc.saturating_add(e)), // The Balance AST does not enforce the Sum trait
148                EmissionAllocation::FixedAmount(amount) => *amount,
149            };
150
151            if accumulated >= threshold {
152                do_distribute_emission::<T>(
153                    permission_id,
154                    contract,
155                    DistributionReason::Automatic,
156                )?;
157            }
158        }
159
160        DistributionControl::AtBlock(target_block) if current_block > target_block => {
161            // As we only verify once every 10 blocks, we have to check if current block
162            // is GTE to the target block. To avoid, triggering on every block,
163            // we also verify that the last execution occurred before the target block
164            // (or haven't occurred at all)
165            if contract
166                .last_execution()
167                .is_some_and(|last_execution| last_execution >= target_block)
168            {
169                return Ok(());
170            }
171
172            do_distribute_emission::<T>(permission_id, contract, DistributionReason::Automatic)?;
173        }
174
175        DistributionControl::Interval(interval) => {
176            let last_execution = contract.last_execution.unwrap_or(contract.created_at);
177            if current_block.saturating_sub(last_execution) < interval {
178                return Ok(());
179            }
180
181            do_distribute_emission::<T>(permission_id, contract, DistributionReason::Automatic)?;
182        }
183
184        // Manual distribution doesn't need auto-processing
185        _ => {}
186    }
187
188    Ok(())
189}
190
191#[derive(
192    Encode, Decode, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, TypeInfo, MaxEncodedLen,
193)]
194pub enum DistributionReason {
195    Automatic,
196    Manual,
197}
198
199/// Distribute accumulated emissions for a permission
200pub(crate) fn do_distribute_emission<T: Config>(
201    permission_id: PermissionId,
202    contract: &PermissionContract<T>,
203    reason: DistributionReason,
204) -> DispatchResult {
205    let PermissionScope::Emission(emission_scope) = &contract.scope else {
206        return Ok(());
207    };
208
209    let total_weight =
210        FixedU128::from_u32(emission_scope.targets.values().map(|w| *w as u32).sum());
211    if total_weight.is_zero() {
212        trace!("permission {permission_id:?} does not have enough target weight");
213        return Ok(());
214    }
215
216    match &emission_scope.allocation {
217        EmissionAllocation::Streams(streams) => {
218            let streams = streams.keys().filter_map(|stream_id| {
219                let acc = AccumulatedStreamAmounts::<T>::get((
220                    &contract.delegator,
221                    stream_id,
222                    permission_id,
223                ))?;
224
225                // You cannot remove the stream from the storage as
226                // it's needed in the accumulation code, avoid using `take`
227                AccumulatedStreamAmounts::<T>::set(
228                    (&contract.delegator, stream_id, permission_id),
229                    Some(Zero::zero()),
230                );
231
232                if acc.is_zero() {
233                    None
234                } else {
235                    // For percentage allocations, mint new tokens
236                    // This is safe because we're only distributing a percentage of
237                    // tokens that were already allocated to emission rewards
238                    Some((stream_id, T::Currency::issue(acc)))
239                }
240            });
241
242            for (stream, mut imbalance) in streams {
243                do_distribute_to_targets(
244                    &mut imbalance,
245                    permission_id,
246                    emission_scope,
247                    Some(stream),
248                    total_weight,
249                    reason,
250                );
251
252                let remainder = imbalance.peek();
253                if !remainder.is_zero() {
254                    AccumulatedStreamAmounts::<T>::mutate(
255                        (&contract.delegator, stream, permission_id),
256                        |acc| *acc = Some(acc.unwrap_or_default().saturating_add(remainder)),
257                    );
258                }
259            }
260        }
261        EmissionAllocation::FixedAmount(amount) => {
262            if contract.last_execution().is_some() {
263                // The fixed amount was already distributed
264                return Ok(());
265            }
266
267            // For fixed amount allocations, transfer from reserved funds
268            let _ = T::Currency::unreserve(&contract.delegator, *amount);
269            let mut imbalance = T::Currency::withdraw(
270                &contract.delegator,
271                *amount,
272                WithdrawReasons::TRANSFER,
273                ExistenceRequirement::KeepAlive,
274            )
275            .unwrap_or_else(|_| NegativeImbalanceOf::<T>::zero());
276
277            do_distribute_to_targets(
278                &mut imbalance,
279                permission_id,
280                emission_scope,
281                None,
282                total_weight,
283                reason,
284            );
285        }
286    }
287
288    if let Some(mut contract) = Permissions::<T>::get(permission_id) {
289        contract.tick_execution(<frame_system::Pallet<T>>::block_number())?;
290        Permissions::<T>::set(permission_id, Some(contract));
291    }
292
293    Ok(())
294}
295
296fn do_distribute_to_targets<T: Config>(
297    imbalance: &mut NegativeImbalanceOf<T>,
298    permission_id: PermissionId,
299    emission_scope: &EmissionScope<T>,
300    stream: Option<&StreamId>,
301    total_weight: FixedU128,
302    reason: DistributionReason,
303) {
304    let initial_balance = imbalance.peek();
305    let total_initial_amount =
306        FixedU128::from_inner(initial_balance.try_into().unwrap_or_default());
307    if total_initial_amount.is_zero() {
308        trace!("no amount to distribute for permission {permission_id:?} and stream {stream:?}");
309        return;
310    }
311
312    for (target, weight) in emission_scope.targets.iter() {
313        let target_weight = FixedU128::from_u32(*weight as u32);
314        let target_amount = total_initial_amount
315            .saturating_mul(target_weight)
316            .const_checked_div(total_weight)
317            .unwrap_or_default();
318
319        if target_amount.is_zero() {
320            continue;
321        }
322
323        let target_amount =
324            BalanceOf::<T>::try_from(target_amount.into_inner()).unwrap_or_default();
325        let mut imbalance = imbalance.extract(target_amount);
326
327        if let Some(stream) = stream {
328            // Process recursive accumulation here, only deposit what remains
329            do_accumulate_emissions::<T>(target, stream, &mut imbalance);
330        }
331
332        T::Currency::resolve_creating(target, imbalance);
333
334        Pallet::<T>::deposit_event(Event::EmissionDistribution {
335            permission_id,
336            stream_id: stream.cloned(),
337            target: target.clone(),
338            amount: target_amount,
339            reason,
340        });
341    }
342}