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 type RuntimeEvent: From<Event<Self, I>>
139 + IsType<<Self as frame_system::Config>::RuntimeEvent>;
140
141 #[pallet::constant]
145 type MaximumReasonLength: Get<u32>;
146
147 #[pallet::constant]
149 type DataDepositPerByte: Get<BalanceOf<Self, I>>;
150
151 #[pallet::constant]
153 type TipCountdown: Get<BlockNumberFor<Self>>;
154
155 #[pallet::constant]
157 type TipFindersFee: Get<Percent>;
158
159 #[pallet::constant]
161 type TipReportDepositBase: Get<BalanceOf<Self, I>>;
162
163 #[pallet::constant]
165 type MaxTipAmount: Get<BalanceOf<Self, I>>;
166
167 type Tippers: SortedMembers<Self::AccountId> + ContainsLengthBound;
173
174 type OnSlash: OnUnbalanced<NegativeImbalanceOf<Self, I>>;
176
177 type WeightInfo: WeightInfo;
179 }
180
181 #[pallet::storage]
185 pub type Tips<T: Config<I>, I: 'static = ()> = StorageMap<
186 _,
187 Twox64Concat,
188 T::Hash,
189 OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
190 OptionQuery,
191 >;
192
193 #[pallet::storage]
196 pub type Reasons<T: Config<I>, I: 'static = ()> =
197 StorageMap<_, Identity, T::Hash, Vec<u8>, OptionQuery>;
198
199 #[pallet::event]
200 #[pallet::generate_deposit(pub(super) fn deposit_event)]
201 pub enum Event<T: Config<I>, I: 'static = ()> {
202 NewTip { tip_hash: T::Hash },
204 TipClosing { tip_hash: T::Hash },
206 TipClosed { tip_hash: T::Hash, who: T::AccountId, payout: BalanceOf<T, I> },
208 TipRetracted { tip_hash: T::Hash },
210 TipSlashed { tip_hash: T::Hash, finder: T::AccountId, deposit: BalanceOf<T, I> },
212 }
213
214 #[pallet::error]
215 pub enum Error<T, I = ()> {
216 ReasonTooBig,
218 AlreadyKnown,
220 UnknownTip,
222 MaxTipAmountExceeded,
224 NotFinder,
226 StillOpen,
228 Premature,
230 }
231
232 #[pallet::call]
233 impl<T: Config<I>, I: 'static> Pallet<T, I> {
234 #[pallet::call_index(0)]
251 #[pallet::weight(<T as Config<I>>::WeightInfo::report_awesome(reason.len() as u32))]
252 pub fn report_awesome(
253 origin: OriginFor<T>,
254 reason: Vec<u8>,
255 who: AccountIdLookupOf<T>,
256 ) -> DispatchResult {
257 let finder = ensure_signed(origin)?;
258 let who = T::Lookup::lookup(who)?;
259
260 ensure!(
261 reason.len() <= T::MaximumReasonLength::get() as usize,
262 Error::<T, I>::ReasonTooBig
263 );
264
265 let reason_hash = T::Hashing::hash(&reason[..]);
266 ensure!(!Reasons::<T, I>::contains_key(&reason_hash), Error::<T, I>::AlreadyKnown);
267 let hash = T::Hashing::hash_of(&(&reason_hash, &who));
268 ensure!(!Tips::<T, I>::contains_key(&hash), Error::<T, I>::AlreadyKnown);
269
270 let deposit = T::TipReportDepositBase::get() +
271 T::DataDepositPerByte::get() * (reason.len() as u32).into();
272 T::Currency::reserve(&finder, deposit)?;
273
274 Reasons::<T, I>::insert(&reason_hash, &reason);
275 let tip = OpenTip {
276 reason: reason_hash,
277 who,
278 finder,
279 deposit,
280 closes: None,
281 tips: vec![],
282 finders_fee: true,
283 };
284 Tips::<T, I>::insert(&hash, tip);
285 Self::deposit_event(Event::NewTip { tip_hash: hash });
286 Ok(())
287 }
288
289 #[pallet::call_index(1)]
306 #[pallet::weight(<T as Config<I>>::WeightInfo::retract_tip())]
307 pub fn retract_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
308 let who = ensure_signed(origin)?;
309 let tip = Tips::<T, I>::get(&hash).ok_or(Error::<T, I>::UnknownTip)?;
310 ensure!(tip.finder == who, Error::<T, I>::NotFinder);
311
312 Reasons::<T, I>::remove(&tip.reason);
313 Tips::<T, I>::remove(&hash);
314 if !tip.deposit.is_zero() {
315 let err_amount = T::Currency::unreserve(&who, tip.deposit);
316 debug_assert!(err_amount.is_zero());
317 }
318 Self::deposit_event(Event::TipRetracted { tip_hash: hash });
319 Ok(())
320 }
321
322 #[pallet::call_index(2)]
342 #[pallet::weight(<T as Config<I>>::WeightInfo::tip_new(reason.len() as u32, T::Tippers::max_len() as u32))]
343 pub fn tip_new(
344 origin: OriginFor<T>,
345 reason: Vec<u8>,
346 who: AccountIdLookupOf<T>,
347 #[pallet::compact] tip_value: BalanceOf<T, I>,
348 ) -> DispatchResult {
349 let tipper = ensure_signed(origin)?;
350 let who = T::Lookup::lookup(who)?;
351 ensure!(T::Tippers::contains(&tipper), BadOrigin);
352
353 ensure!(T::MaxTipAmount::get() >= tip_value, Error::<T, I>::MaxTipAmountExceeded);
354
355 let reason_hash = T::Hashing::hash(&reason[..]);
356 ensure!(!Reasons::<T, I>::contains_key(&reason_hash), Error::<T, I>::AlreadyKnown);
357
358 let hash = T::Hashing::hash_of(&(&reason_hash, &who));
359 Reasons::<T, I>::insert(&reason_hash, &reason);
360 Self::deposit_event(Event::NewTip { tip_hash: hash });
361 let tips = vec![(tipper.clone(), tip_value)];
362 let tip = OpenTip {
363 reason: reason_hash,
364 who,
365 finder: tipper,
366 deposit: Zero::zero(),
367 closes: None,
368 tips,
369 finders_fee: false,
370 };
371 Tips::<T, I>::insert(&hash, tip);
372 Ok(())
373 }
374
375 #[pallet::call_index(3)]
397 #[pallet::weight(<T as Config<I>>::WeightInfo::tip(T::Tippers::max_len() as u32))]
398 pub fn tip(
399 origin: OriginFor<T>,
400 hash: T::Hash,
401 #[pallet::compact] tip_value: BalanceOf<T, I>,
402 ) -> DispatchResult {
403 let tipper = ensure_signed(origin)?;
404 ensure!(T::Tippers::contains(&tipper), BadOrigin);
405
406 ensure!(T::MaxTipAmount::get() >= tip_value, Error::<T, I>::MaxTipAmountExceeded);
407
408 let mut tip = Tips::<T, I>::get(hash).ok_or(Error::<T, I>::UnknownTip)?;
409
410 if Self::insert_tip_and_check_closing(&mut tip, tipper, tip_value) {
411 Self::deposit_event(Event::TipClosing { tip_hash: hash });
412 }
413 Tips::<T, I>::insert(&hash, tip);
414 Ok(())
415 }
416
417 #[pallet::call_index(4)]
431 #[pallet::weight(<T as Config<I>>::WeightInfo::close_tip(T::Tippers::max_len() as u32))]
432 pub fn close_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
433 ensure_signed(origin)?;
434
435 let tip = Tips::<T, I>::get(hash).ok_or(Error::<T, I>::UnknownTip)?;
436 let n = tip.closes.as_ref().ok_or(Error::<T, I>::StillOpen)?;
437 ensure!(frame_system::Pallet::<T>::block_number() >= *n, Error::<T, I>::Premature);
438 Reasons::<T, I>::remove(&tip.reason);
440 Tips::<T, I>::remove(hash);
441 Self::payout_tip(hash, tip);
442 Ok(())
443 }
444
445 #[pallet::call_index(5)]
456 #[pallet::weight(<T as Config<I>>::WeightInfo::slash_tip(T::Tippers::max_len() as u32))]
457 pub fn slash_tip(origin: OriginFor<T>, hash: T::Hash) -> DispatchResult {
458 T::RejectOrigin::ensure_origin(origin)?;
459
460 let tip = Tips::<T, I>::take(hash).ok_or(Error::<T, I>::UnknownTip)?;
461
462 if !tip.deposit.is_zero() {
463 let imbalance = T::Currency::slash_reserved(&tip.finder, tip.deposit).0;
464 T::OnSlash::on_unbalanced(imbalance);
465 }
466 Reasons::<T, I>::remove(&tip.reason);
467 Self::deposit_event(Event::TipSlashed {
468 tip_hash: hash,
469 finder: tip.finder,
470 deposit: tip.deposit,
471 });
472 Ok(())
473 }
474 }
475
476 #[pallet::hooks]
477 impl<T: Config<I>, I: 'static> Hooks<BlockNumberFor<T>> for Pallet<T, I> {
478 fn integrity_test() {
479 assert!(
480 !T::TipReportDepositBase::get().is_zero(),
481 "`TipReportDepositBase` should not be zero",
482 );
483 }
484
485 #[cfg(feature = "try-runtime")]
486 fn try_state(_n: BlockNumberFor<T>) -> Result<(), TryRuntimeError> {
487 Self::do_try_state()
488 }
489 }
490}
491
492impl<T: Config<I>, I: 'static> Pallet<T, I> {
493 pub fn tips(
497 hash: T::Hash,
498 ) -> Option<OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>> {
499 Tips::<T, I>::get(hash)
500 }
501
502 pub fn reasons(hash: T::Hash) -> Option<Vec<u8>> {
504 Reasons::<T, I>::get(hash)
505 }
506
507 pub fn account_id() -> T::AccountId {
512 T::PalletId::get().into_account_truncating()
513 }
514
515 fn insert_tip_and_check_closing(
520 tip: &mut OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
521 tipper: T::AccountId,
522 tip_value: BalanceOf<T, I>,
523 ) -> bool {
524 match tip.tips.binary_search_by_key(&&tipper, |x| &x.0) {
525 Ok(pos) => tip.tips[pos] = (tipper, tip_value),
526 Err(pos) => tip.tips.insert(pos, (tipper, tip_value)),
527 }
528 Self::retain_active_tips(&mut tip.tips);
529 let threshold = (T::Tippers::count() + 1) / 2;
530 if tip.tips.len() >= threshold && tip.closes.is_none() {
531 tip.closes = Some(frame_system::Pallet::<T>::block_number() + T::TipCountdown::get());
532 true
533 } else {
534 false
535 }
536 }
537
538 fn retain_active_tips(tips: &mut Vec<(T::AccountId, BalanceOf<T, I>)>) {
540 let members = T::Tippers::sorted_members();
541 let mut members_iter = members.iter();
542 let mut member = members_iter.next();
543 tips.retain(|(ref a, _)| loop {
544 match member {
545 None => break false,
546 Some(m) if m > a => break false,
547 Some(m) => {
548 member = members_iter.next();
549 if m < a {
550 continue
551 } else {
552 break true
553 }
554 },
555 }
556 });
557 }
558
559 fn payout_tip(
564 hash: T::Hash,
565 tip: OpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
566 ) {
567 let mut tips = tip.tips;
568 Self::retain_active_tips(&mut tips);
569 tips.sort_by_key(|i| i.1);
570
571 let treasury = Self::account_id();
572 let max_payout = pallet_treasury::Pallet::<T, I>::pot();
573
574 let mut payout = tips[tips.len() / 2].1.min(max_payout);
575 if !tip.deposit.is_zero() {
576 let err_amount = T::Currency::unreserve(&tip.finder, tip.deposit);
577 debug_assert!(err_amount.is_zero());
578 }
579
580 if tip.finders_fee && tip.finder != tip.who {
581 let finders_fee = T::TipFindersFee::get() * payout;
583 payout -= finders_fee;
584 let res = T::Currency::transfer(&treasury, &tip.finder, finders_fee, KeepAlive);
587 debug_assert!(res.is_ok());
588 }
589
590 let res = T::Currency::transfer(&treasury, &tip.who, payout, KeepAlive);
592 debug_assert!(res.is_ok());
593 Self::deposit_event(Event::TipClosed { tip_hash: hash, who: tip.who, payout });
594 }
595
596 pub fn migrate_retract_tip_for_tip_new(module: &[u8], item: &[u8]) {
597 #[derive(Clone, Eq, PartialEq, Encode, Decode, RuntimeDebug)]
600 pub struct OldOpenTip<
601 AccountId: Parameter,
602 Balance: Parameter,
603 BlockNumber: Parameter,
604 Hash: Parameter,
605 > {
606 reason: Hash,
609 who: AccountId,
611 finder: Option<(AccountId, Balance)>,
613 closes: Option<BlockNumber>,
616 tips: Vec<(AccountId, Balance)>,
618 }
619
620 use frame_support::{migration::storage_key_iter, Twox64Concat};
621
622 let zero_account = T::AccountId::decode(&mut TrailingZeroInput::new(&[][..]))
623 .expect("infinite input; qed");
624
625 for (hash, old_tip) in storage_key_iter::<
626 T::Hash,
627 OldOpenTip<T::AccountId, BalanceOf<T, I>, BlockNumberFor<T>, T::Hash>,
628 Twox64Concat,
629 >(module, item)
630 .drain()
631 {
632 let (finder, deposit, finders_fee) = match old_tip.finder {
633 Some((finder, deposit)) => (finder, deposit, true),
634 None => (zero_account.clone(), Zero::zero(), false),
635 };
636 let new_tip = OpenTip {
637 reason: old_tip.reason,
638 who: old_tip.who,
639 finder,
640 deposit,
641 closes: old_tip.closes,
642 tips: old_tip.tips,
643 finders_fee,
644 };
645 Tips::<T, I>::insert(hash, new_tip)
646 }
647 }
648
649 #[cfg(any(feature = "try-runtime", test))]
658 pub fn do_try_state() -> Result<(), TryRuntimeError> {
659 let reasons = Reasons::<T, I>::iter_keys().collect::<Vec<_>>();
660 let tips = Tips::<T, I>::iter_keys().collect::<Vec<_>>();
661
662 ensure!(
663 reasons.len() == tips.len(),
664 TryRuntimeError::Other("Equal length of entries in `Tips` and `Reasons` Storage")
665 );
666
667 for tip in Tips::<T, I>::iter_keys() {
668 let open_tip = Tips::<T, I>::get(&tip).expect("All map keys are valid; qed");
669
670 if open_tip.finders_fee {
671 ensure!(
672 !open_tip.deposit.is_zero(),
673 TryRuntimeError::Other(
674 "Tips with `finders_fee` should have non-zero `deposit`."
675 )
676 )
677 }
678
679 ensure!(
680 reasons.contains(&open_tip.reason),
681 TryRuntimeError::Other("no reason for this tip")
682 );
683 }
684 Ok(())
685 }
686}