1#![cfg_attr(not(feature = "std"), no_std)]
56
57mod benchmarking;
58mod tests;
59
60pub mod migrations;
61pub mod weights;
62
63extern crate alloc;
64
65use sp_runtime::{
66 traits::{AccountIdConversion, BadOrigin, Hash, StaticLookup, TrailingZeroInput, Zero},
67 Percent, RuntimeDebug,
68};
69
70use alloc::{vec, vec::Vec};
71use codec::{Decode, Encode};
72use frame_support::{
73 ensure,
74 traits::{
75 ContainsLengthBound, Currency, EnsureOrigin, ExistenceRequirement::KeepAlive, Get,
76 OnUnbalanced, ReservableCurrency, SortedMembers,
77 },
78 Parameter,
79};
80use frame_system::pallet_prelude::BlockNumberFor;
81
82#[cfg(any(feature = "try-runtime", test))]
83use sp_runtime::TryRuntimeError;
84
85pub use pallet::*;
86pub use weights::WeightInfo;
87
88const LOG_TARGET: &str = "runtime::tips";
89
90pub type BalanceOf<T, I = ()> = pallet_treasury::BalanceOf<T, I>;
91pub type NegativeImbalanceOf<T, I = ()> = pallet_treasury::NegativeImbalanceOf<T, I>;
92type AccountIdLookupOf<T> = <<T as frame_system::Config>::Lookup as StaticLookup>::Source;
93
94#[derive(Clone, Eq, PartialEq, Encode, Decode, RuntimeDebug, scale_info::TypeInfo)]
97pub struct OpenTip<
98 AccountId: Parameter,
99 Balance: Parameter,
100 BlockNumber: Parameter,
101 Hash: Parameter,
102> {
103 reason: Hash,
106 who: AccountId,
108 finder: AccountId,
110 deposit: Balance,
112 closes: Option<BlockNumber>,
115 tips: Vec<(AccountId, Balance)>,
117 finders_fee: bool,
119}
120
121#[frame_support::pallet]
122pub mod pallet {
123 use super::*;
124 use frame_support::pallet_prelude::*;
125 use frame_system::pallet_prelude::*;
126
127 const STORAGE_VERSION: StorageVersion = StorageVersion::new(4);
129
130 #[pallet::pallet]
131 #[pallet::storage_version(STORAGE_VERSION)]
132 #[pallet::without_storage_info]
133 pub struct Pallet<T, I = ()>(_);
134
135 #[pallet::config]
136 pub trait Config<I: 'static = ()>: frame_system::Config + pallet_treasury::Config<I> {
137 #[allow(deprecated)]
139 type RuntimeEvent: From<Event<Self, I>>
140 + IsType<<Self as frame_system::Config>::RuntimeEvent>;
141
142 #[pallet::constant]
146 type MaximumReasonLength: Get<u32>;
147
148 #[pallet::constant]
150 type DataDepositPerByte: Get<BalanceOf<Self, I>>;
151
152 #[pallet::constant]
154 type TipCountdown: Get<BlockNumberFor<Self>>;
155
156 #[pallet::constant]
158 type TipFindersFee: Get<Percent>;
159
160 #[pallet::constant]
162 type TipReportDepositBase: Get<BalanceOf<Self, I>>;
163
164 #[pallet::constant]
166 type MaxTipAmount: Get<BalanceOf<Self, I>>;
167
168 type Tippers: SortedMembers<Self::AccountId> + ContainsLengthBound;
174
175 type OnSlash: OnUnbalanced<NegativeImbalanceOf<Self, I>>;
177
178 type WeightInfo: WeightInfo;
180 }
181
182 #[pallet::storage]
186 pub type Tips<T: Config<I>, I: 'static = ()> = StorageMap<
187 _,
188 Twox64Concat,
189 T::Hash,
190 OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
191 OptionQuery,
192 >;
193
194 #[pallet::storage]
197 pub type Reasons<T: Config<I>, I: 'static = ()> =
198 StorageMap<_, Identity, T::Hash, Vec<u8>, OptionQuery>;
199
200 #[pallet::event]
201 #[pallet::generate_deposit(pub(super) fn deposit_event)]
202 pub enum Event<T: Config<I>, I: 'static = ()> {
203 NewTip { tip_hash: T::Hash },
205 TipClosing { tip_hash: T::Hash },
207 TipClosed { tip_hash: T::Hash, who: T::AccountId, payout: BalanceOf<T, I> },
209 TipRetracted { tip_hash: T::Hash },
211 TipSlashed { tip_hash: T::Hash, finder: T::AccountId, deposit: BalanceOf<T, I> },
213 }
214
215 #[pallet::error]
216 pub enum Error<T, I = ()> {
217 ReasonTooBig,
219 AlreadyKnown,
221 UnknownTip,
223 MaxTipAmountExceeded,
225 NotFinder,
227 StillOpen,
229 Premature,
231 }
232
233 #[pallet::call]
234 impl<T: Config<I>, I: 'static> Pallet<T, I> {
235 #[pallet::call_index(0)]
252 #[pallet::weight(<T as Config<I>>::WeightInfo::report_awesome(reason.len() as u32))]
253 pub fn report_awesome(
254 origin: OriginFor<T>,
255 reason: Vec<u8>,
256 who: AccountIdLookupOf<T>,
257 ) -> DispatchResult {
258 let finder = ensure_signed(origin)?;
259 let who = T::Lookup::lookup(who)?;
260
261 ensure!(
262 reason.len() <= T::MaximumReasonLength::get() as usize,
263 Error::<T, I>::ReasonTooBig
264 );
265
266 let reason_hash = T::Hashing::hash(&reason[..]);
267 ensure!(!Reasons::<T, I>::contains_key(&reason_hash), Error::<T, I>::AlreadyKnown);
268 let hash = T::Hashing::hash_of(&(&reason_hash, &who));
269 ensure!(!Tips::<T, I>::contains_key(&hash), Error::<T, I>::AlreadyKnown);
270
271 let deposit = T::TipReportDepositBase::get() +
272 T::DataDepositPerByte::get() * (reason.len() as u32).into();
273 T::Currency::reserve(&finder, deposit)?;
274
275 Reasons::<T, I>::insert(&reason_hash, &reason);
276 let tip = OpenTip {
277 reason: reason_hash,
278 who,
279 finder,
280 deposit,
281 closes: None,
282 tips: vec![],
283 finders_fee: true,
284 };
285 Tips::<T, I>::insert(&hash, tip);
286 Self::deposit_event(Event::NewTip { tip_hash: hash });
287 Ok(())
288 }
289
290 #[pallet::call_index(1)]
307 #[pallet::weight(<T as Config<I>>::WeightInfo::retract_tip())]
308 pub fn retract_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
309 let who = ensure_signed(origin)?;
310 let tip = Tips::<T, I>::get(&hash).ok_or(Error::<T, I>::UnknownTip)?;
311 ensure!(tip.finder == who, Error::<T, I>::NotFinder);
312
313 Reasons::<T, I>::remove(&tip.reason);
314 Tips::<T, I>::remove(&hash);
315 if !tip.deposit.is_zero() {
316 let err_amount = T::Currency::unreserve(&who, tip.deposit);
317 debug_assert!(err_amount.is_zero());
318 }
319 Self::deposit_event(Event::TipRetracted { tip_hash: hash });
320 Ok(())
321 }
322
323 #[pallet::call_index(2)]
343 #[pallet::weight(<T as Config<I>>::WeightInfo::tip_new(reason.len() as u32, T::Tippers::max_len() as u32))]
344 pub fn tip_new(
345 origin: OriginFor<T>,
346 reason: Vec<u8>,
347 who: AccountIdLookupOf<T>,
348 #[pallet::compact] tip_value: BalanceOf<T, I>,
349 ) -> DispatchResult {
350 let tipper = ensure_signed(origin)?;
351 let who = T::Lookup::lookup(who)?;
352 ensure!(T::Tippers::contains(&tipper), BadOrigin);
353
354 ensure!(T::MaxTipAmount::get() >= tip_value, Error::<T, I>::MaxTipAmountExceeded);
355
356 let reason_hash = T::Hashing::hash(&reason[..]);
357 ensure!(!Reasons::<T, I>::contains_key(&reason_hash), Error::<T, I>::AlreadyKnown);
358
359 let hash = T::Hashing::hash_of(&(&reason_hash, &who));
360 Reasons::<T, I>::insert(&reason_hash, &reason);
361 Self::deposit_event(Event::NewTip { tip_hash: hash });
362 let tips = vec![(tipper.clone(), tip_value)];
363 let tip = OpenTip {
364 reason: reason_hash,
365 who,
366 finder: tipper,
367 deposit: Zero::zero(),
368 closes: None,
369 tips,
370 finders_fee: false,
371 };
372 Tips::<T, I>::insert(&hash, tip);
373 Ok(())
374 }
375
376 #[pallet::call_index(3)]
398 #[pallet::weight(<T as Config<I>>::WeightInfo::tip(T::Tippers::max_len() as u32))]
399 pub fn tip(
400 origin: OriginFor<T>,
401 hash: T::Hash,
402 #[pallet::compact] tip_value: BalanceOf<T, I>,
403 ) -> DispatchResult {
404 let tipper = ensure_signed(origin)?;
405 ensure!(T::Tippers::contains(&tipper), BadOrigin);
406
407 ensure!(T::MaxTipAmount::get() >= tip_value, Error::<T, I>::MaxTipAmountExceeded);
408
409 let mut tip = Tips::<T, I>::get(hash).ok_or(Error::<T, I>::UnknownTip)?;
410
411 if Self::insert_tip_and_check_closing(&mut tip, tipper, tip_value) {
412 Self::deposit_event(Event::TipClosing { tip_hash: hash });
413 }
414 Tips::<T, I>::insert(&hash, tip);
415 Ok(())
416 }
417
418 #[pallet::call_index(4)]
432 #[pallet::weight(<T as Config<I>>::WeightInfo::close_tip(T::Tippers::max_len() as u32))]
433 pub fn close_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
434 ensure_signed(origin)?;
435
436 let tip = Tips::<T, I>::get(hash).ok_or(Error::<T, I>::UnknownTip)?;
437 let n = tip.closes.as_ref().ok_or(Error::<T, I>::StillOpen)?;
438 ensure!(frame_system::Pallet::<T>::block_number() >= *n, Error::<T, I>::Premature);
439 Reasons::<T, I>::remove(&tip.reason);
441 Tips::<T, I>::remove(hash);
442 Self::payout_tip(hash, tip);
443 Ok(())
444 }
445
446 #[pallet::call_index(5)]
457 #[pallet::weight(<T as Config<I>>::WeightInfo::slash_tip(T::Tippers::max_len() as u32))]
458 pub fn slash_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
459 T::RejectOrigin::ensure_origin(origin)?;
460
461 let tip = Tips::<T, I>::take(hash).ok_or(Error::<T, I>::UnknownTip)?;
462
463 if !tip.deposit.is_zero() {
464 let imbalance = T::Currency::slash_reserved(&tip.finder, tip.deposit).0;
465 T::OnSlash::on_unbalanced(imbalance);
466 }
467 Reasons::<T, I>::remove(&tip.reason);
468 Self::deposit_event(Event::TipSlashed {
469 tip_hash: hash,
470 finder: tip.finder,
471 deposit: tip.deposit,
472 });
473 Ok(())
474 }
475 }
476
477 #[pallet::hooks]
478 impl<T: Config<I>, I: 'static> Hooks<BlockNumberFor<T>> for Pallet<T, I> {
479 fn integrity_test() {
480 assert!(
481 !T::TipReportDepositBase::get().is_zero(),
482 "`TipReportDepositBase` should not be zero",
483 );
484 }
485
486 #[cfg(feature = "try-runtime")]
487 fn try_state(_n: BlockNumberFor<T>) -> Result<(), TryRuntimeError> {
488 Self::do_try_state()
489 }
490 }
491}
492
493impl<T: Config<I>, I: 'static> Pallet<T, I> {
494 pub fn tips(
498 hash: T::Hash,
499 ) -> Option<OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>> {
500 Tips::<T, I>::get(hash)
501 }
502
503 pub fn reasons(hash: T::Hash) -> Option<Vec<u8>> {
505 Reasons::<T, I>::get(hash)
506 }
507
508 pub fn account_id() -> T::AccountId {
513 T::PalletId::get().into_account_truncating()
514 }
515
516 fn insert_tip_and_check_closing(
521 tip: &mut OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
522 tipper: T::AccountId,
523 tip_value: BalanceOf<T, I>,
524 ) -> bool {
525 match tip.tips.binary_search_by_key(&&tipper, |x| &x.0) {
526 Ok(pos) => tip.tips[pos] = (tipper, tip_value),
527 Err(pos) => tip.tips.insert(pos, (tipper, tip_value)),
528 }
529 Self::retain_active_tips(&mut tip.tips);
530 let threshold = T::Tippers::count().div_ceil(2);
531 if tip.tips.len() >= threshold && tip.closes.is_none() {
532 tip.closes = Some(frame_system::Pallet::<T>::block_number() + T::TipCountdown::get());
533 true
534 } else {
535 false
536 }
537 }
538
539 fn retain_active_tips(tips: &mut Vec<(T::AccountId, BalanceOf<T, I>)>) {
541 let members = T::Tippers::sorted_members();
542 let mut members_iter = members.iter();
543 let mut member = members_iter.next();
544 tips.retain(|(ref a, _)| loop {
545 match member {
546 None => break false,
547 Some(m) if m > a => break false,
548 Some(m) => {
549 member = members_iter.next();
550 if m < a {
551 continue
552 } else {
553 break true
554 }
555 },
556 }
557 });
558 }
559
560 fn payout_tip(
565 hash: T::Hash,
566 tip: OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
567 ) {
568 let mut tips = tip.tips;
569 Self::retain_active_tips(&mut tips);
570 tips.sort_by_key(|i| i.1);
571
572 let treasury = Self::account_id();
573 let max_payout = pallet_treasury::Pallet::<T, I>::pot();
574
575 let mut payout = tips[tips.len() / 2].1.min(max_payout);
576 if !tip.deposit.is_zero() {
577 let err_amount = T::Currency::unreserve(&tip.finder, tip.deposit);
578 debug_assert!(err_amount.is_zero());
579 }
580
581 if tip.finders_fee && tip.finder != tip.who {
582 let finders_fee = T::TipFindersFee::get() * payout;
584 payout -= finders_fee;
585 let res = T::Currency::transfer(&treasury, &tip.finder, finders_fee, KeepAlive);
588 debug_assert!(res.is_ok());
589 }
590
591 let res = T::Currency::transfer(&treasury, &tip.who, payout, KeepAlive);
593 debug_assert!(res.is_ok());
594 Self::deposit_event(Event::TipClosed { tip_hash: hash, who: tip.who, payout });
595 }
596
597 pub fn migrate_retract_tip_for_tip_new(module: &[u8], item: &[u8]) {
598 #[derive(Clone, Eq, PartialEq, Encode, Decode, RuntimeDebug)]
601 pub struct OldOpenTip<
602 AccountId: Parameter,
603 Balance: Parameter,
604 BlockNumber: Parameter,
605 Hash: Parameter,
606 > {
607 reason: Hash,
610 who: AccountId,
612 finder: Option<(AccountId, Balance)>,
614 closes: Option<BlockNumber>,
617 tips: Vec<(AccountId, Balance)>,
619 }
620
621 use frame_support::{migration::storage_key_iter, Twox64Concat};
622
623 let zero_account = T::AccountId::decode(&mut TrailingZeroInput::new(&[][..]))
624 .expect("infinite input; qed");
625
626 for (hash, old_tip) in storage_key_iter::<
627 T::Hash,
628 OldOpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
629 Twox64Concat,
630 >(module, item)
631 .drain()
632 {
633 let (finder, deposit, finders_fee) = match old_tip.finder {
634 Some((finder, deposit)) => (finder, deposit, true),
635 None => (zero_account.clone(), Zero::zero(), false),
636 };
637 let new_tip = OpenTip {
638 reason: old_tip.reason,
639 who: old_tip.who,
640 finder,
641 deposit,
642 closes: old_tip.closes,
643 tips: old_tip.tips,
644 finders_fee,
645 };
646 Tips::<T, I>::insert(hash, new_tip)
647 }
648 }
649
650 #[cfg(any(feature = "try-runtime", test))]
659 pub fn do_try_state() -> Result<(), TryRuntimeError> {
660 let reasons = Reasons::<T, I>::iter_keys().collect::<Vec<_>>();
661 let tips = Tips::<T, I>::iter_keys().collect::<Vec<_>>();
662
663 ensure!(
664 reasons.len() == tips.len(),
665 TryRuntimeError::Other("Equal length of entries in `Tips` and `Reasons` Storage")
666 );
667
668 for tip in Tips::<T, I>::iter_keys() {
669 let open_tip = Tips::<T, I>::get(&tip).expect("All map keys are valid; qed");
670
671 if open_tip.finders_fee {
672 ensure!(
673 !open_tip.deposit.is_zero(),
674 TryRuntimeError::Other(
675 "Tips with `finders_fee` should have non-zero `deposit`."
676 )
677 )
678 }
679
680 ensure!(
681 reasons.contains(&open_tip.reason),
682 TryRuntimeError::Other("no reason for this tip")
683 );
684 }
685 Ok(())
686 }
687}