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        grantor: &T::AccountId,
35    ) {
36        match self.allocation {
37            EmissionAllocation::Streams(streams) => {
38                for stream in streams.keys() {
39                    AccumulatedStreamAmounts::<T>::remove((grantor, stream, &permission_id));
40                }
41            }
42            EmissionAllocation::FixedAmount(amount) if last_executed.is_none() => {
43                T::Currency::unreserve(grantor, 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 grantor
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: &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));
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) 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, &permission_id),
122            Some(accumulated.saturating_add(delegated_amount)),
123        );
124    }
125}
126
127pub(crate) fn do_auto_distribution<T: Config>(
128    emission_scope: &EmissionScope<T>,
129    permission_id: H256,
130    current_block: BlockNumberFor<T>,
131    contract: &PermissionContract<T>,
132) {
133    match emission_scope.distribution {
134        DistributionControl::Automatic(threshold) => {
135            let accumulated = match &emission_scope.allocation {
136                EmissionAllocation::Streams(streams) => streams
137                    .keys()
138                    .filter_map(|id| {
139                        AccumulatedStreamAmounts::<T>::get((&contract.grantor, id, permission_id))
140                    })
141                    .fold(BalanceOf::<T>::zero(), |acc, e| acc.saturating_add(e)), // The Balance AST does not enforce the Sum trait
142                EmissionAllocation::FixedAmount(amount) => *amount,
143            };
144
145            if accumulated >= threshold {
146                do_distribute_emission::<T>(permission_id, contract, DistributionReason::Automatic);
147            }
148        }
149
150        DistributionControl::AtBlock(target_block) if current_block > target_block => {
151            // As we only verify once every 10 blocks, we have to check if current block
152            // is GTE to the target block. To avoid, triggering on every block,
153            // we also verify that the last execution occurred before the target block
154            // (or haven't occurred at all)
155            if contract
156                .last_execution
157                .is_some_and(|last_execution| last_execution >= target_block)
158            {
159                return;
160            }
161
162            do_distribute_emission::<T>(permission_id, contract, DistributionReason::Automatic);
163        }
164
165        DistributionControl::Interval(interval) => {
166            let last_execution = contract.last_execution.unwrap_or(contract.created_at);
167            if current_block.saturating_sub(last_execution) < interval {
168                return;
169            }
170
171            do_distribute_emission::<T>(permission_id, contract, DistributionReason::Automatic);
172        }
173
174        // Manual distribution doesn't need auto-processing
175        _ => {}
176    }
177}
178
179#[derive(Clone, Copy, Debug)]
180pub(crate) enum DistributionReason {
181    Automatic,
182    Manual,
183}
184
185/// Distribute accumulated emissions for a permission
186pub(crate) fn do_distribute_emission<T: Config>(
187    permission_id: PermissionId,
188    contract: &PermissionContract<T>,
189    reason: DistributionReason,
190) {
191    #[allow(irrefutable_let_patterns)]
192    let PermissionScope::Emission(emission_scope) = &contract.scope
193    else {
194        return;
195    };
196
197    let total_weight =
198        FixedU128::from_u32(emission_scope.targets.values().map(|w| *w as u32).sum());
199    if total_weight.is_zero() {
200        return;
201    }
202
203    match &emission_scope.allocation {
204        EmissionAllocation::Streams(streams) => {
205            let streams = streams.keys().filter_map(|id| {
206                let acc =
207                    AccumulatedStreamAmounts::<T>::get((&contract.grantor, id, permission_id))?;
208
209                // You cannot remove the stream from the storage as
210                // it's needed in the accumulation code
211                AccumulatedStreamAmounts::<T>::set(
212                    (&contract.grantor, id, permission_id),
213                    Some(Zero::zero()),
214                );
215
216                if acc.is_zero() {
217                    None
218                } else {
219                    // For percentage allocations, mint new tokens
220                    // This is safe because we're only distributing a percentage of
221                    // tokens that were already allocated to emission rewards
222                    Some((id, T::Currency::issue(acc)))
223                }
224            });
225
226            for (stream, mut imbalance) in streams {
227                do_distribute_to_targets(
228                    &mut imbalance,
229                    permission_id,
230                    contract,
231                    emission_scope,
232                    Some(stream),
233                    total_weight,
234                    reason,
235                );
236
237                let remainder = imbalance.peek();
238                if !remainder.is_zero() {
239                    AccumulatedStreamAmounts::<T>::mutate(
240                        (&contract.grantor, stream, permission_id),
241                        |acc| {
242                            if let Some(acc_value) = acc {
243                                *acc_value = acc_value.saturating_add(remainder);
244                            } else {
245                                *acc = Some(remainder)
246                            }
247                        },
248                    );
249                }
250            }
251        }
252        EmissionAllocation::FixedAmount(amount) => {
253            if contract.last_execution.is_some() {
254                // The fixed amount was already distributed
255                return;
256            }
257
258            // For fixed amount allocations, transfer from reserved funds
259            let _ = T::Currency::unreserve(&contract.grantor, *amount);
260            let mut imbalance = T::Currency::withdraw(
261                &contract.grantor,
262                *amount,
263                WithdrawReasons::TRANSFER,
264                ExistenceRequirement::KeepAlive,
265            )
266            .unwrap_or_else(|_| NegativeImbalanceOf::<T>::zero());
267
268            do_distribute_to_targets(
269                &mut imbalance,
270                permission_id,
271                contract,
272                emission_scope,
273                None,
274                total_weight,
275                reason,
276            );
277        }
278    }
279
280    Permissions::<T>::mutate(permission_id, |maybe_contract| {
281        if let Some(c) = maybe_contract {
282            c.last_execution = Some(<frame_system::Pallet<T>>::block_number());
283            c.execution_count = c.execution_count.saturating_add(1);
284        }
285    });
286}
287
288fn do_distribute_to_targets<T: Config>(
289    imbalance: &mut NegativeImbalanceOf<T>,
290    permission_id: PermissionId,
291    contract: &PermissionContract<T>,
292    emission_scope: &EmissionScope<T>,
293    stream: Option<&StreamId>,
294    total_weight: FixedU128,
295    reason: DistributionReason,
296) {
297    let initial_balance = imbalance.peek();
298    let total_initial_amount =
299        FixedU128::from_inner(initial_balance.try_into().unwrap_or_default());
300    if total_initial_amount.is_zero() {
301        return;
302    }
303
304    for (target, weight) in emission_scope.targets.iter() {
305        let target_weight = FixedU128::from_u32(*weight as u32);
306        let target_amount = total_initial_amount
307            .saturating_mul(target_weight)
308            .const_checked_div(total_weight)
309            .unwrap_or_default();
310
311        if target_amount.is_zero() {
312            continue;
313        }
314
315        let target_amount =
316            BalanceOf::<T>::try_from(target_amount.into_inner()).unwrap_or_default();
317        let mut imbalance = imbalance.extract(target_amount);
318
319        if let Some(stream) = stream {
320            // Process recursive accumulation here, only deposit what remains
321            do_accumulate_emissions::<T>(target, stream, &mut imbalance);
322        }
323
324        T::Currency::resolve_creating(target, imbalance);
325    }
326
327    let amount = initial_balance.saturating_sub(imbalance.peek());
328    if !amount.is_zero() {
329        <Pallet<T>>::deposit_event(match reason {
330            DistributionReason::Automatic => Event::AutoDistributionExecuted {
331                grantor: contract.grantor.clone(),
332                grantee: contract.grantee.clone(),
333                permission_id,
334                stream_id: None,
335                amount,
336            },
337            DistributionReason::Manual => Event::PermissionExecuted {
338                grantor: contract.grantor.clone(),
339                grantee: contract.grantee.clone(),
340                permission_id,
341                stream_id: None,
342                amount,
343            },
344        });
345    }
346}