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 Debug, Percent,
68};
69
70use alloc::{vec, vec::Vec};
71use codec::{Decode, Encode};
72use frame_support::{
73 dispatch::DispatchResult,
74 ensure,
75 traits::{
76 ContainsLengthBound, Currency, EnsureOrigin, ExistenceRequirement::KeepAlive, Get,
77 OnUnbalanced, ReservableCurrency, SortedMembers,
78 },
79 Parameter,
80};
81use frame_system::pallet_prelude::BlockNumberFor;
82
83#[cfg(any(feature = "try-runtime", test))]
84use sp_runtime::TryRuntimeError;
85
86pub use pallet::*;
87pub use weights::WeightInfo;
88
89const LOG_TARGET: &str = "runtime::tips";
90
91pub type BalanceOf<T, I = ()> = pallet_treasury::BalanceOf<T, I>;
92pub type NegativeImbalanceOf<T, I = ()> = pallet_treasury::NegativeImbalanceOf<T, I>;
93type AccountIdLookupOf<T> = <<T as frame_system::Config>::Lookup as StaticLookup>::Source;
94
95#[derive(Clone, Eq, PartialEq, Encode, Decode, Debug, scale_info::TypeInfo)]
98pub struct OpenTip<
99 AccountId: Parameter,
100 Balance: Parameter,
101 BlockNumber: Parameter,
102 Hash: Parameter,
103> {
104 reason: Hash,
107 who: AccountId,
109 finder: AccountId,
111 deposit: Balance,
113 closes: Option<BlockNumber>,
116 tips: Vec<(AccountId, Balance)>,
118 finders_fee: bool,
120}
121
122#[frame_support::pallet]
123pub mod pallet {
124 use super::*;
125 use frame_support::pallet_prelude::*;
126 use frame_system::pallet_prelude::*;
127
128 const STORAGE_VERSION: StorageVersion = StorageVersion::new(4);
130
131 #[pallet::pallet]
132 #[pallet::storage_version(STORAGE_VERSION)]
133 #[pallet::without_storage_info]
134 pub struct Pallet<T, I = ()>(_);
135
136 #[pallet::config]
137 pub trait Config<I: 'static = ()>: frame_system::Config + pallet_treasury::Config<I> {
138 #[allow(deprecated)]
140 type RuntimeEvent: From<Event<Self, I>>
141 + IsType<<Self as frame_system::Config>::RuntimeEvent>;
142
143 #[pallet::constant]
147 type MaximumReasonLength: Get<u32>;
148
149 #[pallet::constant]
151 type DataDepositPerByte: Get<BalanceOf<Self, I>>;
152
153 #[pallet::constant]
155 type TipCountdown: Get<BlockNumberFor<Self>>;
156
157 #[pallet::constant]
159 type TipFindersFee: Get<Percent>;
160
161 #[pallet::constant]
163 type TipReportDepositBase: Get<BalanceOf<Self, I>>;
164
165 #[pallet::constant]
167 type MaxTipAmount: Get<BalanceOf<Self, I>>;
168
169 type Tippers: SortedMembers<Self::AccountId> + ContainsLengthBound;
175
176 type OnSlash: OnUnbalanced<NegativeImbalanceOf<Self, I>>;
178
179 type WeightInfo: WeightInfo;
181 }
182
183 #[pallet::storage]
187 pub type Tips<T: Config<I>, I: 'static = ()> = StorageMap<
188 _,
189 Twox64Concat,
190 T::Hash,
191 OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
192 OptionQuery,
193 >;
194
195 #[pallet::storage]
198 pub type Reasons<T: Config<I>, I: 'static = ()> =
199 StorageMap<_, Identity, T::Hash, Vec<u8>, OptionQuery>;
200
201 #[pallet::event]
202 #[pallet::generate_deposit(pub(super) fn deposit_event)]
203 pub enum Event<T: Config<I>, I: 'static = ()> {
204 NewTip { tip_hash: T::Hash },
206 TipClosing { tip_hash: T::Hash },
208 TipClosed { tip_hash: T::Hash, who: T::AccountId, payout: BalanceOf<T, I> },
210 TipRetracted { tip_hash: T::Hash },
212 TipSlashed { tip_hash: T::Hash, finder: T::AccountId, deposit: BalanceOf<T, I> },
214 }
215
216 #[pallet::error]
217 pub enum Error<T, I = ()> {
218 ReasonTooBig,
220 AlreadyKnown,
222 UnknownTip,
224 MaxTipAmountExceeded,
226 NotFinder,
228 StillOpen,
230 Premature,
232 NoActiveTippers,
234 }
235
236 #[pallet::call]
237 impl<T: Config<I>, I: 'static> Pallet<T, I> {
238 #[pallet::call_index(0)]
255 #[pallet::weight(<T as Config<I>>::WeightInfo::report_awesome(reason.len() as u32))]
256 pub fn report_awesome(
257 origin: OriginFor<T>,
258 reason: Vec<u8>,
259 who: AccountIdLookupOf<T>,
260 ) -> DispatchResult {
261 let finder = ensure_signed(origin)?;
262 let who = T::Lookup::lookup(who)?;
263
264 ensure!(
265 reason.len() <= T::MaximumReasonLength::get() as usize,
266 Error::<T, I>::ReasonTooBig
267 );
268
269 let reason_hash = T::Hashing::hash(&reason[..]);
270 ensure!(!Reasons::<T, I>::contains_key(&reason_hash), Error::<T, I>::AlreadyKnown);
271 let hash = T::Hashing::hash_of(&(&reason_hash, &who));
272 ensure!(!Tips::<T, I>::contains_key(&hash), Error::<T, I>::AlreadyKnown);
273
274 let deposit = T::TipReportDepositBase::get() +
275 T::DataDepositPerByte::get() * (reason.len() as u32).into();
276 T::Currency::reserve(&finder, deposit)?;
277
278 Reasons::<T, I>::insert(&reason_hash, &reason);
279 let tip = OpenTip {
280 reason: reason_hash,
281 who,
282 finder,
283 deposit,
284 closes: None,
285 tips: vec![],
286 finders_fee: true,
287 };
288 Tips::<T, I>::insert(&hash, tip);
289 Self::deposit_event(Event::NewTip { tip_hash: hash });
290 Ok(())
291 }
292
293 #[pallet::call_index(1)]
310 #[pallet::weight(<T as Config<I>>::WeightInfo::retract_tip())]
311 pub fn retract_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
312 let who = ensure_signed(origin)?;
313 let tip = Tips::<T, I>::get(&hash).ok_or(Error::<T, I>::UnknownTip)?;
314 ensure!(tip.finder == who, Error::<T, I>::NotFinder);
315
316 Reasons::<T, I>::remove(&tip.reason);
317 Tips::<T, I>::remove(&hash);
318 if !tip.deposit.is_zero() {
319 let err_amount = T::Currency::unreserve(&who, tip.deposit);
320 debug_assert!(err_amount.is_zero());
321 }
322 Self::deposit_event(Event::TipRetracted { tip_hash: hash });
323 Ok(())
324 }
325
326 #[pallet::call_index(2)]
346 #[pallet::weight(<T as Config<I>>::WeightInfo::tip_new(reason.len() as u32, T::Tippers::max_len() as u32))]
347 pub fn tip_new(
348 origin: OriginFor<T>,
349 reason: Vec<u8>,
350 who: AccountIdLookupOf<T>,
351 #[pallet::compact] tip_value: BalanceOf<T, I>,
352 ) -> DispatchResult {
353 let tipper = ensure_signed(origin)?;
354 let who = T::Lookup::lookup(who)?;
355 ensure!(T::Tippers::contains(&tipper), BadOrigin);
356
357 ensure!(T::MaxTipAmount::get() >= tip_value, Error::<T, I>::MaxTipAmountExceeded);
358
359 let reason_hash = T::Hashing::hash(&reason[..]);
360 ensure!(!Reasons::<T, I>::contains_key(&reason_hash), Error::<T, I>::AlreadyKnown);
361
362 let hash = T::Hashing::hash_of(&(&reason_hash, &who));
363 Reasons::<T, I>::insert(&reason_hash, &reason);
364 Self::deposit_event(Event::NewTip { tip_hash: hash });
365 let tips = vec![(tipper.clone(), tip_value)];
366 let tip = OpenTip {
367 reason: reason_hash,
368 who,
369 finder: tipper,
370 deposit: Zero::zero(),
371 closes: None,
372 tips,
373 finders_fee: false,
374 };
375 Tips::<T, I>::insert(&hash, tip);
376 Ok(())
377 }
378
379 #[pallet::call_index(3)]
401 #[pallet::weight(<T as Config<I>>::WeightInfo::tip(T::Tippers::max_len() as u32))]
402 pub fn tip(
403 origin: OriginFor<T>,
404 hash: T::Hash,
405 #[pallet::compact] tip_value: BalanceOf<T, I>,
406 ) -> DispatchResult {
407 let tipper = ensure_signed(origin)?;
408 ensure!(T::Tippers::contains(&tipper), BadOrigin);
409
410 ensure!(T::MaxTipAmount::get() >= tip_value, Error::<T, I>::MaxTipAmountExceeded);
411
412 let mut tip = Tips::<T, I>::get(hash).ok_or(Error::<T, I>::UnknownTip)?;
413
414 if Self::insert_tip_and_check_closing(&mut tip, tipper, tip_value) {
415 Self::deposit_event(Event::TipClosing { tip_hash: hash });
416 }
417 Tips::<T, I>::insert(&hash, tip);
418 Ok(())
419 }
420
421 #[pallet::call_index(4)]
435 #[pallet::weight(<T as Config<I>>::WeightInfo::close_tip(T::Tippers::max_len() as u32))]
436 pub fn close_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
437 ensure_signed(origin)?;
438
439 let tip = Tips::<T, I>::get(hash).ok_or(Error::<T, I>::UnknownTip)?;
440 let n = tip.closes.as_ref().ok_or(Error::<T, I>::StillOpen)?;
441 ensure!(frame_system::Pallet::<T>::block_number() >= *n, Error::<T, I>::Premature);
442 Reasons::<T, I>::remove(&tip.reason);
444 Tips::<T, I>::remove(hash);
445 Self::payout_tip(hash, tip)
446 }
447
448 #[pallet::call_index(5)]
459 #[pallet::weight(<T as Config<I>>::WeightInfo::slash_tip(T::Tippers::max_len() as u32))]
460 pub fn slash_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
461 T::RejectOrigin::ensure_origin(origin)?;
462
463 let tip = Tips::<T, I>::take(hash).ok_or(Error::<T, I>::UnknownTip)?;
464
465 if !tip.deposit.is_zero() {
466 let imbalance = T::Currency::slash_reserved(&tip.finder, tip.deposit).0;
467 T::OnSlash::on_unbalanced(imbalance);
468 }
469 Reasons::<T, I>::remove(&tip.reason);
470 Self::deposit_event(Event::TipSlashed {
471 tip_hash: hash,
472 finder: tip.finder,
473 deposit: tip.deposit,
474 });
475 Ok(())
476 }
477 }
478
479 #[pallet::hooks]
480 impl<T: Config<I>, I: 'static> Hooks<BlockNumberFor<T>> for Pallet<T, I> {
481 fn integrity_test() {
482 assert!(
483 !T::TipReportDepositBase::get().is_zero(),
484 "`TipReportDepositBase` should not be zero",
485 );
486 }
487
488 #[cfg(feature = "try-runtime")]
489 fn try_state(_n: BlockNumberFor<T>) -> Result<(), TryRuntimeError> {
490 Self::do_try_state()
491 }
492 }
493}
494
495impl<T: Config<I>, I: 'static> Pallet<T, I> {
496 pub fn tips(
500 hash: T::Hash,
501 ) -> Option<OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>> {
502 Tips::<T, I>::get(hash)
503 }
504
505 pub fn reasons(hash: T::Hash) -> Option<Vec<u8>> {
507 Reasons::<T, I>::get(hash)
508 }
509
510 pub fn account_id() -> T::AccountId {
515 T::PalletId::get().into_account_truncating()
516 }
517
518 fn insert_tip_and_check_closing(
523 tip: &mut OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
524 tipper: T::AccountId,
525 tip_value: BalanceOf<T, I>,
526 ) -> bool {
527 match tip.tips.binary_search_by_key(&&tipper, |x| &x.0) {
528 Ok(pos) => tip.tips[pos] = (tipper, tip_value),
529 Err(pos) => tip.tips.insert(pos, (tipper, tip_value)),
530 }
531 Self::retain_active_tips(&mut tip.tips);
532 let threshold = T::Tippers::count().div_ceil(2);
533 if tip.tips.len() >= threshold && tip.closes.is_none() {
534 tip.closes = Some(frame_system::Pallet::<T>::block_number() + T::TipCountdown::get());
535 true
536 } else {
537 false
538 }
539 }
540
541 fn retain_active_tips(tips: &mut Vec<(T::AccountId, BalanceOf<T, I>)>) {
543 let members = T::Tippers::sorted_members();
544 let mut members_iter = members.iter();
545 let mut member = members_iter.next();
546 tips.retain(|(ref a, _)| loop {
547 match member {
548 None => break false,
549 Some(m) if m > a => break false,
550 Some(m) => {
551 member = members_iter.next();
552 if m < a {
553 continue;
554 } else {
555 break true;
556 }
557 },
558 }
559 });
560 }
561
562 fn payout_tip(
567 hash: T::Hash,
568 tip: OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
569 ) -> DispatchResult {
570 let mut tips = tip.tips;
571 Self::retain_active_tips(&mut tips);
572 tips.sort_by_key(|i| i.1);
573
574 let treasury = Self::account_id();
575 let max_payout = pallet_treasury::Pallet::<T, I>::pot();
576
577 let mut payout = tips
578 .get(tips.len() / 2)
579 .ok_or(Error::<T, I>::NoActiveTippers)?
580 .1
581 .min(max_payout);
582 if !tip.deposit.is_zero() {
583 let err_amount = T::Currency::unreserve(&tip.finder, tip.deposit);
584 debug_assert!(err_amount.is_zero());
585 }
586
587 if tip.finders_fee && tip.finder != tip.who {
588 let finders_fee = T::TipFindersFee::get() * payout;
590 payout -= finders_fee;
591 let res = T::Currency::transfer(&treasury, &tip.finder, finders_fee, KeepAlive);
594 debug_assert!(res.is_ok());
595 }
596
597 let res = T::Currency::transfer(&treasury, &tip.who, payout, KeepAlive);
599 debug_assert!(res.is_ok());
600 Self::deposit_event(Event::TipClosed { tip_hash: hash, who: tip.who, payout });
601 Ok(())
602 }
603
604 pub fn migrate_retract_tip_for_tip_new(module: &[u8], item: &[u8]) {
605 #[derive(Clone, Eq, PartialEq, Encode, Decode, Debug)]
608 pub struct OldOpenTip<
609 AccountId: Parameter,
610 Balance: Parameter,
611 BlockNumber: Parameter,
612 Hash: Parameter,
613 > {
614 reason: Hash,
617 who: AccountId,
619 finder: Option<(AccountId, Balance)>,
621 closes: Option<BlockNumber>,
624 tips: Vec<(AccountId, Balance)>,
626 }
627
628 use frame_support::{migration::storage_key_iter, Twox64Concat};
629
630 let zero_account = T::AccountId::decode(&mut TrailingZeroInput::new(&[][..]))
631 .expect("infinite input; qed");
632
633 for (hash, old_tip) in storage_key_iter::<
634 T::Hash,
635 OldOpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
636 Twox64Concat,
637 >(module, item)
638 .drain()
639 {
640 let (finder, deposit, finders_fee) = match old_tip.finder {
641 Some((finder, deposit)) => (finder, deposit, true),
642 None => (zero_account.clone(), Zero::zero(), false),
643 };
644 let new_tip = OpenTip {
645 reason: old_tip.reason,
646 who: old_tip.who,
647 finder,
648 deposit,
649 closes: old_tip.closes,
650 tips: old_tip.tips,
651 finders_fee,
652 };
653 Tips::<T, I>::insert(hash, new_tip)
654 }
655 }
656
657 #[cfg(any(feature = "try-runtime", test))]
666 pub fn do_try_state() -> Result<(), TryRuntimeError> {
667 let reasons = Reasons::<T, I>::iter_keys().collect::<Vec<_>>();
668 let tips = Tips::<T, I>::iter_keys().collect::<Vec<_>>();
669
670 ensure!(
671 reasons.len() == tips.len(),
672 TryRuntimeError::Other("Equal length of entries in `Tips` and `Reasons` Storage")
673 );
674
675 for tip in Tips::<T, I>::iter_keys() {
676 let open_tip = Tips::<T, I>::get(&tip).expect("All map keys are valid; qed");
677
678 if open_tip.finders_fee {
679 ensure!(
680 !open_tip.deposit.is_zero(),
681 TryRuntimeError::Other(
682 "Tips with `finders_fee` should have non-zero `deposit`."
683 )
684 )
685 }
686
687 ensure!(
688 reasons.contains(&open_tip.reason),
689 TryRuntimeError::Other("no reason for this tip")
690 );
691 }
692 Ok(())
693 }
694}