1use codec::{Decode, Encode, MaxEncodedLen};
2use pallet_torus0::namespace::NamespacePricingConfig;
3use polkadot_sdk::{
4 frame_election_provider_support::Get,
5 frame_support::{
6 dispatch::DispatchResult, ensure, storage::with_storage_layer, traits::Currency,
7 },
8 polkadot_sdk_frame::{prelude::BlockNumberFor, traits::CheckedAdd},
9 sp_core::{ConstU32, U256},
10 sp_runtime::{
11 traits::Saturating, BoundedBTreeMap, DispatchError, FixedPointNumber, FixedU128, Percent,
12 },
13 sp_std::{collections::btree_set::BTreeSet, vec::Vec},
14 sp_tracing::error,
15};
16
17use crate::{
18 frame::traits::ExistenceRequirement, AccountIdOf, BalanceOf, BoundedBTreeSet, BoundedVec,
19 DaoTreasuryAddress, DebugNoBound, Error, GlobalGovernanceConfig, GovernanceConfiguration,
20 NotDelegatingVotingPower, Proposals, TypeInfo, UnrewardedProposals,
21};
22
23pub type ProposalId = u64;
24
25#[derive(Clone, DebugNoBound, TypeInfo, Decode, Encode, MaxEncodedLen)]
27#[scale_info(skip_type_params(T))]
28pub struct Proposal<T: crate::Config> {
29 pub id: ProposalId,
30 pub proposer: AccountIdOf<T>,
31 pub expiration_block: BlockNumberFor<T>,
32 pub data: ProposalData<T>,
34 pub status: ProposalStatus<T>,
35 pub metadata: BoundedVec<u8, ConstU32<256>>,
36 pub proposal_cost: BalanceOf<T>,
37 pub creation_block: BlockNumberFor<T>,
38}
39
40impl<T: crate::Config> Proposal<T> {
41 #[must_use]
43 pub fn is_active(&self) -> bool {
44 matches!(self.status, ProposalStatus::Open { .. })
45 }
46
47 pub fn execution_block(&self) -> BlockNumberFor<T> {
52 match self.data {
53 ProposalData::Emission { .. } => self.creation_block.saturating_add(
54 U256::from(21_600)
55 .try_into()
56 .ok()
57 .expect("this is a safe conversion"),
58 ),
59 _ => self.expiration_block,
60 }
61 }
62
63 pub fn accept(
65 mut self,
66 block: BlockNumberFor<T>,
67 stake_for: BalanceOf<T>,
68 stake_against: BalanceOf<T>,
69 ) -> DispatchResult {
70 ensure!(self.is_active(), crate::Error::<T>::ProposalIsFinished);
71
72 self.status = ProposalStatus::Accepted {
73 block,
74 stake_for,
75 stake_against,
76 };
77
78 Proposals::<T>::insert(self.id, &self);
79 crate::Pallet::<T>::deposit_event(crate::Event::ProposalAccepted(self.id));
80
81 self.execute_proposal()?;
82
83 Ok(())
84 }
85
86 fn execute_proposal(self) -> DispatchResult {
88 let _ = <T as crate::Config>::Currency::transfer(
90 &crate::DaoTreasuryAddress::<T>::get(),
91 &self.proposer,
92 self.proposal_cost,
93 ExistenceRequirement::AllowDeath,
94 );
95
96 match self.data {
97 ProposalData::GlobalParams(data) => {
98 let GlobalParamsData {
99 min_name_length,
100 max_name_length,
101 min_weight_control_fee,
102 min_staking_fee,
103 dividends_participation_weight,
104 namespace_pricing_config,
105 proposal_cost,
106 } = data;
107
108 pallet_torus0::MinNameLength::<T>::set(min_name_length);
109 pallet_torus0::MaxNameLength::<T>::set(max_name_length);
110 pallet_torus0::DividendsParticipationWeight::<T>::set(
111 dividends_participation_weight,
112 );
113 pallet_torus0::FeeConstraints::<T>::mutate(|constraints| {
114 constraints.min_weight_control_fee =
115 Percent::from_percent(min_weight_control_fee);
116 constraints.min_staking_fee = Percent::from_percent(min_staking_fee);
117 });
118 pallet_torus0::NamespacePricingConfig::<T>::set(namespace_pricing_config);
119 crate::GlobalGovernanceConfig::<T>::mutate(|config| {
120 config.proposal_cost = proposal_cost;
121 });
122 }
123
124 ProposalData::TransferDaoTreasury { account, amount } => {
125 <T as crate::Config>::Currency::transfer(
126 &DaoTreasuryAddress::<T>::get(),
127 &account,
128 amount,
129 ExistenceRequirement::AllowDeath,
130 )
131 .map_err(|_| crate::Error::<T>::InternalError)?;
132 }
133
134 ProposalData::Emission {
135 recycling_percentage,
136 treasury_percentage,
137 incentives_ratio,
138 } => {
139 pallet_emission0::EmissionRecyclingPercentage::<T>::set(recycling_percentage);
140 crate::TreasuryEmissionFee::<T>::set(treasury_percentage);
141 pallet_emission0::IncentivesRatio::<T>::set(incentives_ratio);
142 }
143
144 ProposalData::GlobalCustom => {}
145 }
146
147 Ok(())
148 }
149
150 pub fn refuse(
152 mut self,
153 block: BlockNumberFor<T>,
154 stake_for: BalanceOf<T>,
155 stake_against: BalanceOf<T>,
156 ) -> DispatchResult {
157 ensure!(self.is_active(), crate::Error::<T>::ProposalIsFinished);
158
159 self.status = ProposalStatus::Refused {
160 block,
161 stake_for,
162 stake_against,
163 };
164
165 Proposals::<T>::insert(self.id, &self);
166 crate::Pallet::<T>::deposit_event(crate::Event::ProposalRefused(self.id));
167
168 Ok(())
169 }
170
171 pub fn expire(mut self, block_number: BlockNumberFor<T>) -> DispatchResult {
173 ensure!(self.is_active(), crate::Error::<T>::ProposalIsFinished);
174 ensure!(
175 block_number >= self.expiration_block,
176 crate::Error::<T>::InvalidProposalFinalizationParameters
177 );
178
179 self.status = ProposalStatus::Expired;
180
181 Proposals::<T>::insert(self.id, &self);
182 crate::Pallet::<T>::deposit_event(crate::Event::ProposalExpired(self.id));
183
184 Ok(())
185 }
186}
187
188#[derive(Clone, DebugNoBound, TypeInfo, Decode, Encode, MaxEncodedLen, PartialEq, Eq)]
189#[scale_info(skip_type_params(T))]
190pub enum ProposalStatus<T: crate::Config> {
191 Open {
197 votes_for: BoundedBTreeSet<AccountIdOf<T>, ConstU32<{ u32::MAX }>>,
199 votes_against: BoundedBTreeSet<AccountIdOf<T>, ConstU32<{ u32::MAX }>>,
201 stake_for: BalanceOf<T>,
203 stake_against: BalanceOf<T>,
205 },
206 Accepted {
208 block: BlockNumberFor<T>,
209 stake_for: BalanceOf<T>,
211 stake_against: BalanceOf<T>,
213 },
214 Refused {
216 block: BlockNumberFor<T>,
217 stake_for: BalanceOf<T>,
219 stake_against: BalanceOf<T>,
221 },
222 Expired,
224}
225
226#[derive(Clone, DebugNoBound, TypeInfo, Decode, Encode, MaxEncodedLen, PartialEq, Eq)]
230#[scale_info(skip_type_params(T))]
231pub struct GlobalParamsData<T: crate::Config> {
232 pub min_name_length: u16,
233 pub max_name_length: u16,
234 pub min_weight_control_fee: u8,
235 pub min_staking_fee: u8,
236 pub dividends_participation_weight: Percent,
237 pub namespace_pricing_config: NamespacePricingConfig<T>,
238 pub proposal_cost: BalanceOf<T>,
239}
240
241impl<T: crate::Config> GlobalParamsData<T> {
242 pub fn validate(&self) -> DispatchResult {
243 ensure!(
244 self.min_name_length > 1,
245 crate::Error::<T>::InvalidMinNameLength
246 );
247
248 ensure!(
249 (self.max_name_length as u32) < T::MaxAgentNameLengthConstraint::get(),
250 crate::Error::<T>::InvalidMaxNameLength
251 );
252
253 ensure!(
254 self.min_weight_control_fee <= 100,
255 crate::Error::<T>::InvalidMinWeightControlFee
256 );
257
258 ensure!(
259 self.min_staking_fee <= 100,
260 crate::Error::<T>::InvalidMinStakingFee
261 );
262
263 ensure!(
264 self.proposal_cost <= 50_000_000_000_000_000_000_000,
265 crate::Error::<T>::InvalidProposalCost
266 );
267
268 Ok(())
269 }
270}
271
272#[derive(Clone, DebugNoBound, TypeInfo, Decode, Encode, MaxEncodedLen, PartialEq, Eq)]
274#[scale_info(skip_type_params(T))]
275pub enum ProposalData<T: crate::Config> {
276 GlobalParams(GlobalParamsData<T>),
278 GlobalCustom,
281 Emission {
283 recycling_percentage: Percent,
285 treasury_percentage: Percent,
288 incentives_ratio: Percent,
291 },
292 TransferDaoTreasury {
294 account: AccountIdOf<T>,
295 amount: BalanceOf<T>,
296 },
297}
298
299impl<T: crate::Config> ProposalData<T> {
300 #[must_use]
303 pub fn required_stake(&self) -> Percent {
304 match self {
305 Self::Emission { .. } => Percent::from_parts(10),
306 Self::GlobalCustom | Self::TransferDaoTreasury { .. } => Percent::from_parts(50),
307 Self::GlobalParams { .. } => Percent::from_parts(40),
308 }
309 }
310}
311
312#[derive(DebugNoBound, TypeInfo, Decode, Encode, MaxEncodedLen, PartialEq, Eq)]
313#[scale_info(skip_type_params(T))]
314pub struct UnrewardedProposal<T: crate::Config> {
315 pub block: BlockNumberFor<T>,
316 pub votes_for: BoundedBTreeMap<AccountIdOf<T>, BalanceOf<T>, ConstU32<{ u32::MAX }>>,
317 pub votes_against: BoundedBTreeMap<AccountIdOf<T>, BalanceOf<T>, ConstU32<{ u32::MAX }>>,
318}
319
320#[allow(clippy::too_many_arguments)]
322pub fn add_global_params_proposal<T: crate::Config>(
323 proposer: AccountIdOf<T>,
324 data: GlobalParamsData<T>,
325 metadata: Vec<u8>,
326) -> DispatchResult {
327 data.validate()?;
328 let data = ProposalData::<T>::GlobalParams(data);
329
330 add_proposal::<T>(proposer, data, metadata)
331}
332
333pub fn add_global_custom_proposal<T: crate::Config>(
335 proposer: AccountIdOf<T>,
336 metadata: Vec<u8>,
337) -> DispatchResult {
338 add_proposal(proposer, ProposalData::<T>::GlobalCustom, metadata)
339}
340
341pub fn add_dao_treasury_transfer_proposal<T: crate::Config>(
343 proposer: AccountIdOf<T>,
344 value: BalanceOf<T>,
345 destination_key: AccountIdOf<T>,
346 metadata: Vec<u8>,
347) -> DispatchResult {
348 let data = ProposalData::<T>::TransferDaoTreasury {
349 account: destination_key,
350 amount: value,
351 };
352
353 add_proposal::<T>(proposer, data, metadata)
354}
355
356pub fn add_emission_proposal<T: crate::Config>(
359 proposer: AccountIdOf<T>,
360 recycling_percentage: Percent,
361 treasury_percentage: Percent,
362 incentives_ratio: Percent,
363 metadata: Vec<u8>,
364) -> DispatchResult {
365 ensure!(
366 recycling_percentage
367 .checked_add(&treasury_percentage)
368 .is_some(),
369 crate::Error::<T>::InvalidEmissionProposalData
370 );
371
372 let data = ProposalData::<T>::Emission {
373 recycling_percentage,
374 treasury_percentage,
375 incentives_ratio,
376 };
377
378 add_proposal::<T>(proposer, data, metadata)
379}
380
381fn add_proposal<T: crate::Config>(
383 proposer: AccountIdOf<T>,
384 data: ProposalData<T>,
385 metadata: Vec<u8>,
386) -> DispatchResult {
387 ensure!(
388 !metadata.is_empty(),
389 crate::Error::<T>::ProposalDataTooSmall
390 );
391 ensure!(
392 metadata.len() <= 256,
393 crate::Error::<T>::ProposalDataTooLarge
394 );
395
396 let config = GlobalGovernanceConfig::<T>::get();
397
398 let cost = config.proposal_cost;
399 <T as crate::Config>::Currency::transfer(
400 &proposer,
401 &crate::DaoTreasuryAddress::<T>::get(),
402 cost,
403 ExistenceRequirement::AllowDeath,
404 )
405 .map_err(|_| crate::Error::<T>::NotEnoughBalanceToApply)?;
406
407 let proposal_id: u64 = crate::Proposals::<T>::iter()
408 .count()
409 .try_into()
410 .map_err(|_| crate::Error::<T>::InternalError)?;
411
412 let current_block = <polkadot_sdk::frame_system::Pallet<T>>::block_number();
413
414 let proposal = Proposal::<T> {
415 id: proposal_id,
416 proposer,
417 expiration_block: current_block.saturating_add(config.proposal_expiration),
418 data,
419 status: ProposalStatus::Open {
420 votes_for: BoundedBTreeSet::new(),
421 votes_against: BoundedBTreeSet::new(),
422 stake_for: 0,
423 stake_against: 0,
424 },
425 metadata: BoundedVec::truncate_from(metadata),
426 proposal_cost: cost,
427 creation_block: current_block,
428 };
429
430 crate::Proposals::<T>::insert(proposal_id, proposal);
431
432 Ok(())
433}
434
435pub fn tick_proposals<T: crate::Config>(block_number: BlockNumberFor<T>) {
438 let block_number_u64: u64 = block_number
439 .try_into()
440 .ok()
441 .expect("blocknumber wont be greater than 2^64");
442 if block_number_u64 % 100 != 0 {
443 return;
444 }
445
446 let not_delegating = NotDelegatingVotingPower::<T>::get().into_inner();
447
448 let proposals = Proposals::<T>::iter().filter(|(_, p)| p.is_active());
449
450 for (id, proposal) in proposals {
451 let res = with_storage_layer(|| tick_proposal(¬_delegating, block_number, proposal));
452 if let Err(err) = res {
453 error!("failed to tick proposal {id}: {err:?}, skipping...");
454 }
455 }
456}
457
458fn get_minimum_stake_to_execute_with_percentage<T: crate::Config>(
461 threshold: Percent,
462) -> BalanceOf<T> {
463 let stake = pallet_torus0::TotalStake::<T>::get();
464 threshold.mul_floor(stake)
465}
466
467fn tick_proposal<T: crate::Config>(
470 not_delegating: &BTreeSet<T::AccountId>,
471 block_number: BlockNumberFor<T>,
472 mut proposal: Proposal<T>,
473) -> DispatchResult {
474 let ProposalStatus::Open {
475 votes_for,
476 votes_against,
477 ..
478 } = &proposal.status
479 else {
480 return Err(Error::<T>::ProposalIsFinished.into());
481 };
482
483 let votes_for: Vec<(AccountIdOf<T>, BalanceOf<T>)> = votes_for
484 .iter()
485 .cloned()
486 .map(|id| {
487 let stake = calc_stake::<T>(not_delegating, &id);
488 (id, stake)
489 })
490 .collect();
491 let votes_against: Vec<(AccountIdOf<T>, BalanceOf<T>)> = votes_against
492 .iter()
493 .cloned()
494 .map(|id| {
495 let stake = calc_stake::<T>(not_delegating, &id);
496 (id, stake)
497 })
498 .collect();
499
500 let stake_for_sum: BalanceOf<T> = votes_for.iter().map(|(_, stake)| stake).sum();
501 let stake_against_sum: BalanceOf<T> = votes_against.iter().map(|(_, stake)| stake).sum();
502
503 if block_number < proposal.expiration_block {
504 if let ProposalStatus::Open {
505 stake_for,
506 stake_against,
507 ..
508 } = &mut proposal.status
509 {
510 *stake_for = stake_for_sum;
511 *stake_against = stake_against_sum;
512 }
513 Proposals::<T>::set(proposal.id, Some(proposal.clone()));
514 }
515
516 if block_number < proposal.execution_block() {
517 return Ok(());
518 }
519
520 let total_stake = stake_for_sum.saturating_add(stake_against_sum);
521 let minimal_stake_to_execute =
522 get_minimum_stake_to_execute_with_percentage::<T>(proposal.data.required_stake());
523
524 if total_stake >= minimal_stake_to_execute {
525 create_unrewarded_proposal::<T>(proposal.id, block_number, votes_for, votes_against);
526 if stake_against_sum > stake_for_sum {
527 proposal.refuse(block_number, stake_for_sum, stake_against_sum)
528 } else {
529 proposal.accept(block_number, stake_for_sum, stake_against_sum)
530 }
531 } else if block_number >= proposal.expiration_block {
532 create_unrewarded_proposal::<T>(proposal.id, block_number, votes_for, votes_against);
533 proposal.expire(block_number)
534 } else {
535 Ok(())
536 }
537}
538
539type AccountStakes<T> = BoundedBTreeMap<AccountIdOf<T>, BalanceOf<T>, ConstU32<{ u32::MAX }>>;
540
541fn create_unrewarded_proposal<T: crate::Config>(
544 proposal_id: u64,
545 block_number: BlockNumberFor<T>,
546 votes_for: Vec<(AccountIdOf<T>, BalanceOf<T>)>,
547 votes_against: Vec<(AccountIdOf<T>, BalanceOf<T>)>,
548) {
549 let mut reward_votes_for = BoundedBTreeMap::new();
550 for (key, value) in votes_for {
551 let _ = reward_votes_for.try_insert(key, value);
552 }
553
554 let mut reward_votes_against: AccountStakes<T> = BoundedBTreeMap::new();
555 for (key, value) in votes_against {
556 let _ = reward_votes_against.try_insert(key, value);
557 }
558
559 UnrewardedProposals::<T>::insert(
560 proposal_id,
561 UnrewardedProposal::<T> {
562 block: block_number,
563 votes_for: reward_votes_for,
564 votes_against: reward_votes_against,
565 },
566 );
567}
568
569#[inline]
572fn calc_stake<T: crate::Config>(
573 not_delegating: &BTreeSet<T::AccountId>,
574 voter: &T::AccountId,
575) -> BalanceOf<T> {
576 let own_stake: BalanceOf<T> = if !not_delegating.contains(voter) {
577 0
578 } else {
579 pallet_torus0::stake::sum_staking_to::<T>(voter)
580 };
581
582 let delegated_stake = pallet_torus0::stake::get_staked_by_vector::<T>(voter)
583 .into_iter()
584 .filter(|(staker, _)| !not_delegating.contains(staker))
585 .map(|(_, stake)| stake)
586 .sum();
587
588 own_stake.saturating_add(delegated_stake)
589}
590
591pub fn tick_proposal_rewards<T: crate::Config>(block_number: BlockNumberFor<T>) {
593 let governance_config = crate::GlobalGovernanceConfig::<T>::get();
594
595 let block_number: u64 = block_number
596 .try_into()
597 .ok()
598 .expect("blocknumber wont be greater than 2^64");
599 let proposal_reward_interval: u64 = governance_config
600 .proposal_reward_interval
601 .try_into()
602 .ok()
603 .expect("blocknumber wont be greater than 2^64");
604
605 let reached_interval = block_number
606 .checked_rem(proposal_reward_interval)
607 .is_some_and(|r| r == 0);
608 if !reached_interval {
609 return;
610 }
611
612 let mut n = 0u16;
613 let mut account_stakes: AccountStakes<T> = BoundedBTreeMap::new();
614 let mut total_allocation = FixedU128::from_inner(0);
615 for (proposal_id, unrewarded_proposal) in UnrewardedProposals::<T>::iter() {
616 let proposal_block: u64 = unrewarded_proposal
617 .block
618 .try_into()
619 .ok()
620 .expect("blocknumber wont be greater than 2^64");
621
622 if proposal_block < block_number.saturating_sub(proposal_reward_interval) {
624 continue;
625 }
626
627 for (acc_id, stake) in unrewarded_proposal
628 .votes_for
629 .into_iter()
630 .chain(unrewarded_proposal.votes_against.into_iter())
631 {
632 let curr_stake = *account_stakes.get(&acc_id).unwrap_or(&0u128);
633 let _ = account_stakes.try_insert(acc_id, curr_stake.saturating_add(stake));
634 }
635
636 match get_reward_allocation::<T>(&governance_config, n) {
637 Ok(allocation) => total_allocation = total_allocation.saturating_add(allocation),
638 Err(err) => {
639 error!("could not get reward allocation for proposal {proposal_id}: {err:?}");
640 continue;
641 }
642 }
643
644 UnrewardedProposals::<T>::remove(proposal_id);
645 n = n.saturating_add(1);
646 }
647
648 distribute_proposal_rewards::<T>(
649 account_stakes,
650 total_allocation,
651 governance_config.max_proposal_reward_treasury_allocation,
652 );
653}
654
655pub fn get_reward_allocation<T: crate::Config>(
657 governance_config: &GovernanceConfiguration<T>,
658 n: u16,
659) -> Result<FixedU128, DispatchError> {
660 let treasury_address = DaoTreasuryAddress::<T>::get();
661 let treasury_balance = <T as crate::Config>::Currency::free_balance(&treasury_address);
662
663 let allocation_percentage = governance_config.proposal_reward_treasury_allocation;
664 let max_allocation = governance_config.max_proposal_reward_treasury_allocation;
665
666 let mut allocation = FixedU128::from_inner(
667 allocation_percentage
668 .mul_floor(treasury_balance)
669 .min(max_allocation),
670 );
671
672 if n > 0 {
673 let mut base = FixedU128::from_inner((1.5 * FixedU128::DIV as f64) as u128);
674 let mut result = FixedU128::from_u32(1);
675 let mut remaining = n;
676
677 while remaining > 0 {
678 if remaining % 2 == 1 {
679 result = result.const_checked_mul(base).unwrap_or(result);
680 }
681 base = base.const_checked_mul(base).unwrap_or_default();
682 remaining /= 2;
683 }
684
685 allocation = allocation.const_checked_div(result).unwrap_or(allocation);
686 }
687
688 Ok(allocation)
689}
690
691fn distribute_proposal_rewards<T: crate::Config>(
693 account_stakes: AccountStakes<T>,
694 total_allocation: FixedU128,
695 max_proposal_reward_treasury_allocation: BalanceOf<T>,
696) {
697 if total_allocation > FixedU128::from_inner(max_proposal_reward_treasury_allocation) {
700 error!("total allocation exceeds max proposal reward treasury allocation");
701 return;
702 }
703
704 use polkadot_sdk::frame_support::sp_runtime::traits::IntegerSquareRoot;
705
706 let dao_treasury_address = DaoTreasuryAddress::<T>::get();
707 let account_sqrt_stakes: Vec<_> = account_stakes
708 .into_iter()
709 .map(|(acc_id, stake)| (acc_id, stake.integer_sqrt()))
710 .collect();
711
712 let total_stake: BalanceOf<T> = account_sqrt_stakes.iter().map(|(_, stake)| *stake).sum();
713 let total_stake = FixedU128::from_inner(total_stake);
714
715 for (acc_id, stake) in account_sqrt_stakes.into_iter() {
716 let percentage = FixedU128::from_inner(stake)
717 .const_checked_div(total_stake)
718 .unwrap_or_default();
719
720 let reward = total_allocation
721 .const_checked_mul(percentage)
722 .unwrap_or_default()
723 .into_inner();
724
725 if let Err(err) = <T as crate::Config>::Currency::transfer(
727 &dao_treasury_address,
728 &acc_id,
729 reward,
730 ExistenceRequirement::AllowDeath,
731 ) {
732 error!("could not transfer proposal reward: {err:?}")
733 }
734 }
735}