diff --git a/Cargo.lock b/Cargo.lock index c99c439eee607..42a51de70ec56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3957,6 +3957,7 @@ dependencies = [ "pallet-identity", "pallet-im-online", "pallet-indices", + "pallet-lottery", "pallet-membership", "pallet-mmr", "pallet-multisig", @@ -4704,6 +4705,21 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-lottery" +version = "2.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-membership" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 61282189da38a..12e79490ef6b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ members = [ "frame/identity", "frame/im-online", "frame/indices", + "frame/lottery", "frame/membership", "frame/merkle-mountain-range", "frame/metadata", diff --git a/bin/node/runtime/Cargo.toml b/bin/node/runtime/Cargo.toml index 4dabc5c015921..3aa906ba0fc53 100644 --- a/bin/node/runtime/Cargo.toml +++ b/bin/node/runtime/Cargo.toml @@ -59,6 +59,7 @@ pallet-grandpa = { version = "2.0.0", default-features = false, path = "../../.. pallet-im-online = { version = "2.0.0", default-features = false, path = "../../../frame/im-online" } pallet-indices = { version = "2.0.0", default-features = false, path = "../../../frame/indices" } pallet-identity = { version = "2.0.0", default-features = false, path = "../../../frame/identity" } +pallet-lottery = { version = "2.0.0", default-features = false, path = "../../../frame/lottery" } pallet-membership = { version = "2.0.0", default-features = false, path = "../../../frame/membership" } pallet-mmr = { version = "2.0.0", default-features = false, path = "../../../frame/merkle-mountain-range" } pallet-multisig = { version = "2.0.0", default-features = false, path = "../../../frame/multisig" } @@ -113,6 +114,7 @@ std = [ "pallet-im-online/std", "pallet-indices/std", "sp-inherents/std", + "pallet-lottery/std", "pallet-membership/std", "pallet-mmr/std", "pallet-multisig/std", @@ -167,6 +169,7 @@ runtime-benchmarks = [ "pallet-identity/runtime-benchmarks", "pallet-im-online/runtime-benchmarks", "pallet-indices/runtime-benchmarks", + "pallet-lottery/runtime-benchmarks", "pallet-mmr/runtime-benchmarks", "pallet-multisig/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index a36448c470811..2afa89f86c024 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -952,6 +952,25 @@ impl pallet_mmr::Config for Runtime { type WeightInfo = (); } +parameter_types! { + pub const LotteryModuleId: ModuleId = ModuleId(*b"py/lotto"); + pub const MaxCalls: usize = 10; + pub const MaxGenerateRandom: u32 = 10; +} + +impl pallet_lottery::Config for Runtime { + type ModuleId = LotteryModuleId; + type Call = Call; + type Event = Event; + type Currency = Balances; + type Randomness = RandomnessCollectiveFlip; + type ManagerOrigin = EnsureRoot; + type MaxCalls = MaxCalls; + type ValidateCall = Lottery; + type MaxGenerateRandom = MaxGenerateRandom; + type WeightInfo = pallet_lottery::weights::SubstrateWeight; +} + parameter_types! { pub const AssetDepositBase: Balance = 100 * DOLLARS; pub const AssetDepositPerZombie: Balance = 1 * DOLLARS; @@ -1009,6 +1028,7 @@ construct_runtime!( Tips: pallet_tips::{Module, Call, Storage, Event}, Assets: pallet_assets::{Module, Call, Storage, Event}, Mmr: pallet_mmr::{Module, Storage}, + Lottery: pallet_lottery::{Module, Call, Storage, Event}, } ); @@ -1291,6 +1311,7 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_identity, Identity); add_benchmark!(params, batches, pallet_im_online, ImOnline); add_benchmark!(params, batches, pallet_indices, Indices); + add_benchmark!(params, batches, pallet_lottery, Lottery); add_benchmark!(params, batches, pallet_mmr, Mmr); add_benchmark!(params, batches, pallet_multisig, Multisig); add_benchmark!(params, batches, pallet_offences, OffencesBench::); diff --git a/frame/lottery/Cargo.toml b/frame/lottery/Cargo.toml new file mode 100644 index 0000000000000..db76316c42966 --- /dev/null +++ b/frame/lottery/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "pallet-lottery" +version = "2.0.0" +authors = ["Parity Technologies "] +edition = "2018" +license = "Apache-2.0" +homepage = "https://substrate.dev" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME Participation Lottery Pallet" +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "1.3.4", default-features = false, features = ["derive"] } +sp-std = { version = "2.0.0", default-features = false, path = "../../primitives/std" } +sp-runtime = { version = "2.0.0", default-features = false, path = "../../primitives/runtime" } +frame-support = { version = "2.0.0", default-features = false, path = "../support" } +frame-system = { version = "2.0.0", default-features = false, path = "../system" } + +frame-benchmarking = { version = "2.0.0", default-features = false, path = "../benchmarking", optional = true } + +[dev-dependencies] +pallet-balances = { version = "2.0.0", path = "../balances" } +sp-core = { version = "2.0.0", path = "../../primitives/core" } +sp-io = { version = "2.0.0", path = "../../primitives/io" } + +[features] +default = ["std"] +std = [ + "codec/std", + "sp-std/std", + "frame-support/std", + "sp-runtime/std", + "frame-system/std", +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-system/runtime-benchmarks", + "frame-support/runtime-benchmarks", +] diff --git a/frame/lottery/src/benchmarking.rs b/frame/lottery/src/benchmarking.rs new file mode 100644 index 0000000000000..34a7f236c1812 --- /dev/null +++ b/frame/lottery/src/benchmarking.rs @@ -0,0 +1,192 @@ +// This file is part of Substrate. + +// Copyright (C) 2020 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Lottery pallet benchmarking. + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; + +use frame_system::RawOrigin; +use frame_support::traits::{OnInitialize, UnfilteredDispatchable}; +use frame_benchmarking::{benchmarks, account, whitelisted_caller}; +use sp_runtime::traits::{Bounded, Zero}; + +use crate::Module as Lottery; + +// Set up and start a lottery +fn setup_lottery(repeat: bool) -> Result<(), &'static str> { + let price = T::Currency::minimum_balance(); + let length = 10u32.into(); + let delay = 5u32.into(); + // Calls will be maximum length... + let mut calls = vec![ + frame_system::Call::::set_code(vec![]).into(); + T::MaxCalls::get().saturating_sub(1) + ]; + // Last call will be the match for worst case scenario. + calls.push(frame_system::Call::::remark(vec![]).into()); + let origin = T::ManagerOrigin::successful_origin(); + Lottery::::set_calls(origin.clone(), calls)?; + Lottery::::start_lottery(origin, price, length, delay, repeat)?; + Ok(()) +} + +benchmarks! { + _ { } + + buy_ticket { + let caller = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + setup_lottery::(false)?; + // force user to have a long vec of calls participating + let set_code_index: CallIndex = Lottery::::call_to_index( + &frame_system::Call::::set_code(vec![]).into() + )?; + let already_called: (u32, Vec) = ( + LotteryIndex::get(), + vec![ + set_code_index; + T::MaxCalls::get().saturating_sub(1) + ], + ); + Participants::::insert(&caller, already_called); + + let call = frame_system::Call::::remark(vec![]); + }: _(RawOrigin::Signed(caller), Box::new(call.into())) + verify { + assert_eq!(TicketsCount::get(), 1); + } + + set_calls { + let n in 0 .. T::MaxCalls::get() as u32; + let calls = vec![frame_system::Call::::remark(vec![]).into(); n as usize]; + + let call = Call::::set_calls(calls); + let origin = T::ManagerOrigin::successful_origin(); + assert!(CallIndices::get().is_empty()); + }: { call.dispatch_bypass_filter(origin)? } + verify { + if !n.is_zero() { + assert!(!CallIndices::get().is_empty()); + } + } + + start_lottery { + let price = BalanceOf::::max_value(); + let end = 10u32.into(); + let payout = 5u32.into(); + + let call = Call::::start_lottery(price, end, payout, true); + let origin = T::ManagerOrigin::successful_origin(); + }: { call.dispatch_bypass_filter(origin)? } + verify { + assert!(crate::Lottery::::get().is_some()); + } + + stop_repeat { + setup_lottery::(true)?; + assert_eq!(crate::Lottery::::get().unwrap().repeat, true); + let call = Call::::stop_repeat(); + let origin = T::ManagerOrigin::successful_origin(); + }: { call.dispatch_bypass_filter(origin)? } + verify { + assert_eq!(crate::Lottery::::get().unwrap().repeat, false); + } + + on_initialize_end { + setup_lottery::(false)?; + let winner = account("winner", 0, 0); + // User needs more than min balance to get ticket + T::Currency::make_free_balance_be(&winner, T::Currency::minimum_balance() * 10u32.into()); + // Make sure lottery account has at least min balance too + let lottery_account = Lottery::::account_id(); + T::Currency::make_free_balance_be(&lottery_account, T::Currency::minimum_balance() * 10u32.into()); + // Buy a ticket + let call = frame_system::Call::::remark(vec![]); + Lottery::::buy_ticket(RawOrigin::Signed(winner.clone()).into(), Box::new(call.into()))?; + // Kill user account for worst case + T::Currency::make_free_balance_be(&winner, 0u32.into()); + // Assert that lotto is set up for winner + assert_eq!(TicketsCount::get(), 1); + assert!(!Lottery::::pot().1.is_zero()); + }: { + // Generate `MaxGenerateRandom` numbers for worst case scenario + for i in 0 .. T::MaxGenerateRandom::get() { + Lottery::::generate_random_number(i); + } + // Start lottery has block 15 configured for payout + Lottery::::on_initialize(15u32.into()); + } + verify { + assert!(crate::Lottery::::get().is_none()); + assert_eq!(TicketsCount::get(), 0); + assert_eq!(Lottery::::pot().1, 0u32.into()); + assert!(!T::Currency::free_balance(&winner).is_zero()) + } + + on_initialize_repeat { + setup_lottery::(true)?; + let winner = account("winner", 0, 0); + // User needs more than min balance to get ticket + T::Currency::make_free_balance_be(&winner, T::Currency::minimum_balance() * 10u32.into()); + // Make sure lottery account has at least min balance too + let lottery_account = Lottery::::account_id(); + T::Currency::make_free_balance_be(&lottery_account, T::Currency::minimum_balance() * 10u32.into()); + // Buy a ticket + let call = frame_system::Call::::remark(vec![]); + Lottery::::buy_ticket(RawOrigin::Signed(winner.clone()).into(), Box::new(call.into()))?; + // Kill user account for worst case + T::Currency::make_free_balance_be(&winner, 0u32.into()); + // Assert that lotto is set up for winner + assert_eq!(TicketsCount::get(), 1); + assert!(!Lottery::::pot().1.is_zero()); + }: { + // Generate `MaxGenerateRandom` numbers for worst case scenario + for i in 0 .. T::MaxGenerateRandom::get() { + Lottery::::generate_random_number(i); + } + // Start lottery has block 15 configured for payout + Lottery::::on_initialize(15u32.into()); + } + verify { + assert!(crate::Lottery::::get().is_some()); + assert_eq!(LotteryIndex::get(), 2); + assert_eq!(TicketsCount::get(), 0); + assert_eq!(Lottery::::pot().1, 0u32.into()); + assert!(!T::Currency::free_balance(&winner).is_zero()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::{new_test_ext, Test}; + use frame_support::assert_ok; + + #[test] + fn test_benchmarks() { + new_test_ext().execute_with(|| { + assert_ok!(test_benchmark_buy_ticket::()); + assert_ok!(test_benchmark_set_calls::()); + assert_ok!(test_benchmark_start_lottery::()); + assert_ok!(test_benchmark_stop_repeat::()); + assert_ok!(test_benchmark_on_initialize_end::()); + assert_ok!(test_benchmark_on_initialize_repeat::()); + }); + } +} diff --git a/frame/lottery/src/lib.rs b/frame/lottery/src/lib.rs new file mode 100644 index 0000000000000..b8568ad269f5c --- /dev/null +++ b/frame/lottery/src/lib.rs @@ -0,0 +1,452 @@ +// This file is part of Substrate. + +// Copyright (C) 2017-2020 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! A lottery pallet that uses participation in the network to purchase tickets. +//! +//! With this pallet, you can configure a lottery, which is a pot of money that +//! users contribute to, and that is reallocated to a single user at the end of +//! the lottery period. Just like a normal lottery system, to participate, you +//! need to "buy a ticket", which is used to fund the pot. +//! +//! The unique feature of this lottery system is that tickets can only be +//! purchased by making a "valid call" dispatched through this pallet. +//! By configuring certain calls to be valid for the lottery, you can encourage +//! users to make those calls on your network. An example of how this could be +//! used is to set validator nominations as a valid lottery call. If the lottery +//! is set to repeat every month, then users would be encouraged to re-nominate +//! validators every month. A user can ony purchase one ticket per valid call +//! per lottery. +//! +//! This pallet can be configured to use dynamically set calls or statically set +//! calls. Call validation happens through the `ValidateCall` implementation. +//! This pallet provides one implementation of this using the `CallIndices` +//! storage item. You can also make your own implementation at the runtime level +//! which can contain much more complex logic, such as validation of the +//! parameters, which this pallet alone cannot do. +//! +//! This pallet uses the modulus operator to pick a random winner. It is known +//! that this might introduce a bias if the random number chosen in a range that +//! is not perfectly divisible by the total number of participants. The +//! `MaxGenerateRandom` configuration can help mitigate this by generating new +//! numbers until we hit the limit or we find a "fair" number. This is best +//! effort only. + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; +mod benchmarking; +pub mod weights; + +use sp_std::prelude::*; +use sp_runtime::{ + DispatchError, ModuleId, + traits::{AccountIdConversion, Saturating, Zero}, +}; +use frame_support::{ + Parameter, decl_module, decl_error, decl_event, decl_storage, ensure, RuntimeDebug, + dispatch::{Dispatchable, DispatchResult, GetDispatchInfo}, + traits::{ + Currency, ReservableCurrency, Get, EnsureOrigin, ExistenceRequirement::KeepAlive, Randomness, + }, +}; +use frame_support::weights::Weight; +use frame_system::ensure_signed; +use codec::{Encode, Decode}; +pub use weights::WeightInfo; + +type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; + +/// The module's config trait. +pub trait Config: frame_system::Config { + /// The Lottery's module id + type ModuleId: Get; + + /// A dispatchable call. + type Call: Parameter + Dispatchable + GetDispatchInfo + From>; + + /// The currency trait. + type Currency: ReservableCurrency; + + /// Something that provides randomness in the runtime. + type Randomness: Randomness; + + /// The overarching event type. + type Event: From> + Into<::Event>; + + /// The manager origin. + type ManagerOrigin: EnsureOrigin; + + /// The max number of calls available in a single lottery. + type MaxCalls: Get; + + /// Used to determine if a call would be valid for purchasing a ticket. + /// + /// Be conscious of the implementation used here. We assume at worst that + /// a vector of `MaxCalls` indices are queried for any call validation. + /// You may need to provide a custom benchmark if this assumption is broken. + type ValidateCall: ValidateCall; + + /// Number of time we should try to generate a random number that has no modulo bias. + /// The larger this number, the more potential computation is used for picking the winner, + /// but also the more likely that the chosen winner is done fairly. + type MaxGenerateRandom: Get; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; +} + +// Any runtime call can be encoded into two bytes which represent the pallet and call index. +// We use this to uniquely match someone's incoming call with the calls configured for the lottery. +type CallIndex = (u8, u8); + +#[derive(Encode, Decode, Default, Eq, PartialEq, RuntimeDebug)] +pub struct LotteryConfig { + /// Price per entry. + price: Balance, + /// Starting block of the lottery. + start: BlockNumber, + /// Length of the lottery (start + length = end). + length: BlockNumber, + /// Delay for choosing the winner of the lottery. (start + length + delay = payout). + /// Randomness in the "payout" block will be used to determine the winner. + delay: BlockNumber, + /// Whether this lottery will repeat after it completes. + repeat: bool, +} + +pub trait ValidateCall { + fn validate_call(call: &::Call) -> bool; +} + +impl ValidateCall for () { + fn validate_call(_: &::Call) -> bool { false } +} + +impl ValidateCall for Module { + fn validate_call(call: &::Call) -> bool { + let valid_calls = CallIndices::get(); + let call_index = match Self::call_to_index(&call) { + Ok(call_index) => call_index, + Err(_) => return false, + }; + valid_calls.iter().any(|c| call_index == *c) + } +} + +decl_storage! { + trait Store for Module as Lottery { + LotteryIndex: u32; + /// The configuration for the current lottery. + Lottery: Option>>; + /// Users who have purchased a ticket. (Lottery Index, Tickets Purchased) + Participants: map hasher(twox_64_concat) T::AccountId => (u32, Vec); + /// Total number of tickets sold. + TicketsCount: u32; + /// Each ticket's owner. + /// + /// May have residual storage from previous lotteries. Use `TicketsCount` to see which ones + /// are actually valid ticket mappings. + Tickets: map hasher(twox_64_concat) u32 => Option; + /// The calls stored in this pallet to be used in an active lottery if configured + /// by `Config::ValidateCall`. + CallIndices: Vec; + } +} + +decl_event!( + pub enum Event where + ::AccountId, + Balance = BalanceOf, + { + /// A lottery has been started! + LotteryStarted, + /// A new set of calls have been set! + CallsUpdated, + /// A winner has been chosen! + Winner(AccountId, Balance), + /// A ticket has been bought! + TicketBought(AccountId, CallIndex), + } +); + +decl_error! { + pub enum Error for Module { + /// An overflow has occurred. + Overflow, + /// A lottery has not been configured. + NotConfigured, + /// A lottery is already in progress. + InProgress, + /// A lottery has already ended. + AlreadyEnded, + /// The call is not valid for an open lottery. + InvalidCall, + /// You are already participating in the lottery with this call. + AlreadyParticipating, + /// Too many calls for a single lottery. + TooManyCalls, + /// Failed to encode calls + EncodingFailed, + } +} + +decl_module! { + pub struct Module for enum Call where origin: T::Origin, system = frame_system { + const ModuleId: ModuleId = T::ModuleId::get(); + const MaxCalls: u32 = T::MaxCalls::get() as u32; + + fn deposit_event() = default; + + /// Buy a ticket to enter the lottery. + /// + /// This extrinsic acts as a passthrough function for `call`. In all + /// situations where `call` alone would succeed, this extrinsic should + /// succeed. + /// + /// If `call` is successful, then we will attempt to purchase a ticket, + /// which may fail silently. To detect success of a ticket purchase, you + /// should listen for the `TicketBought` event. + /// + /// This extrinsic must be called by a signed origin. + #[weight = + T::WeightInfo::buy_ticket() + .saturating_add(call.get_dispatch_info().weight) + ] + fn buy_ticket(origin, call: Box<::Call>) { + let caller = ensure_signed(origin.clone())?; + call.clone().dispatch(origin).map_err(|e| e.error)?; + + let _ = Self::do_buy_ticket(&caller, &call); + } + + /// Set calls in storage which can be used to purchase a lottery ticket. + /// + /// This function only matters if you use the `ValidateCall` implementation + /// provided by this pallet, which uses storage to determine the valid calls. + /// + /// This extrinsic must be called by the Manager origin. + #[weight = T::WeightInfo::set_calls(calls.len() as u32)] + fn set_calls(origin, calls: Vec<::Call>) { + T::ManagerOrigin::ensure_origin(origin)?; + ensure!(calls.len() <= T::MaxCalls::get(), Error::::TooManyCalls); + if calls.is_empty() { + CallIndices::kill(); + } else { + let indices = Self::calls_to_indices(&calls)?; + CallIndices::put(indices); + } + Self::deposit_event(RawEvent::CallsUpdated); + } + + /// Start a lottery using the provided configuration. + /// + /// This extrinsic must be called by the `ManagerOrigin`. + /// + /// Parameters: + /// + /// * `price`: The cost of a single ticket. + /// * `length`: How long the lottery should run for starting at the current block. + /// * `delay`: How long after the lottery end we should wait before picking a winner. + /// * `repeat`: If the lottery should repeat when completed. + #[weight = T::WeightInfo::start_lottery()] + fn start_lottery(origin, + price: BalanceOf, + length: T::BlockNumber, + delay: T::BlockNumber, + repeat: bool, + ) { + T::ManagerOrigin::ensure_origin(origin)?; + Lottery::::try_mutate(|lottery| -> DispatchResult { + ensure!(lottery.is_none(), Error::::InProgress); + let index = LotteryIndex::get(); + let new_index = index.checked_add(1).ok_or(Error::::Overflow)?; + let start = frame_system::Module::::block_number(); + // Use new_index to more easily track everything with the current state. + *lottery = Some(LotteryConfig { + price, + start, + length, + delay, + repeat, + }); + LotteryIndex::put(new_index); + Ok(()) + })?; + // Make sure pot exists. + let lottery_account = Self::account_id(); + if T::Currency::total_balance(&lottery_account).is_zero() { + T::Currency::deposit_creating(&lottery_account, T::Currency::minimum_balance()); + } + Self::deposit_event(RawEvent::LotteryStarted); + } + + /// If a lottery is repeating, you can use this to stop the repeat. + /// The lottery will continue to run to completion. + /// + /// This extrinsic must be called by the `ManagerOrigin`. + #[weight = T::WeightInfo::stop_repeat()] + fn stop_repeat(origin) { + T::ManagerOrigin::ensure_origin(origin)?; + Lottery::::mutate(|mut lottery| { + if let Some(config) = &mut lottery { + config.repeat = false + } + }); + } + + fn on_initialize(n: T::BlockNumber) -> Weight { + Lottery::::mutate(|mut lottery| -> Weight { + if let Some(config) = &mut lottery { + let payout_block = config.start + .saturating_add(config.length) + .saturating_add(config.delay); + if payout_block <= n { + let (lottery_account, lottery_balance) = Self::pot(); + let ticket_count = TicketsCount::get(); + + let winning_number = Self::choose_winner(ticket_count); + let winner = Tickets::::get(winning_number).unwrap_or(lottery_account); + // Not much we can do if this fails... + let _ = T::Currency::transfer(&Self::account_id(), &winner, lottery_balance, KeepAlive); + + Self::deposit_event(RawEvent::Winner(winner, lottery_balance)); + + TicketsCount::kill(); + + if config.repeat { + // If lottery should repeat, increment index by 1. + LotteryIndex::mutate(|index| *index = index.saturating_add(1)); + // Set a new start with the current block. + config.start = n; + return T::WeightInfo::on_initialize_repeat() + } else { + // Else, kill the lottery storage. + *lottery = None; + return T::WeightInfo::on_initialize_end() + } + // We choose not need to kill Participants and Tickets to avoid a large number + // of writes at one time. Instead, data persists between lotteries, but is not used + // if it is not relevant. + } + } + return T::DbWeight::get().reads(1) + }) + } + } +} + +impl Module { + /// The account ID of the lottery pot. + /// + /// This actually does computation. If you need to keep using it, then make sure you cache the + /// value and only call this once. + pub fn account_id() -> T::AccountId { + T::ModuleId::get().into_account() + } + + /// Return the pot account and amount of money in the pot. + // The existential deposit is not part of the pot so lottery account never gets deleted. + fn pot() -> (T::AccountId, BalanceOf) { + let account_id = Self::account_id(); + let balance = T::Currency::free_balance(&account_id) + .saturating_sub(T::Currency::minimum_balance()); + + (account_id, balance) + } + + // Converts a vector of calls into a vector of call indices. + fn calls_to_indices(calls: &[::Call]) -> Result, DispatchError> { + let mut indices = Vec::with_capacity(calls.len()); + for c in calls.iter() { + let index = Self::call_to_index(c)?; + indices.push(index) + } + Ok(indices) + } + + // Convert a call to it's call index by encoding the call and taking the first two bytes. + fn call_to_index(call: &::Call) -> Result { + let encoded_call = call.encode(); + if encoded_call.len() < 2 { Err(Error::::EncodingFailed)? } + return Ok((encoded_call[0], encoded_call[1])) + } + + // Logic for buying a ticket. + fn do_buy_ticket(caller: &T::AccountId, call: &::Call) -> DispatchResult { + // Check the call is valid lottery + let config = Lottery::::get().ok_or(Error::::NotConfigured)?; + let block_number = frame_system::Module::::block_number(); + ensure!(block_number < config.start.saturating_add(config.length), Error::::AlreadyEnded); + ensure!(T::ValidateCall::validate_call(call), Error::::InvalidCall); + let call_index = Self::call_to_index(call)?; + let ticket_count = TicketsCount::get(); + let new_ticket_count = ticket_count.checked_add(1).ok_or(Error::::Overflow)?; + // Try to update the participant status + Participants::::try_mutate(&caller, |(lottery_index, participating_calls)| -> DispatchResult { + let index = LotteryIndex::get(); + // If lottery index doesn't match, then reset participating calls and index. + if *lottery_index != index { + *participating_calls = Vec::new(); + *lottery_index = index; + } else { + // Check that user is not already participating under this call. + ensure!(!participating_calls.iter().any(|c| call_index == *c), Error::::AlreadyParticipating); + } + // Check user has enough funds and send it to the Lottery account. + T::Currency::transfer(caller, &Self::account_id(), config.price, KeepAlive)?; + // Create a new ticket. + TicketsCount::put(new_ticket_count); + Tickets::::insert(ticket_count, caller.clone()); + participating_calls.push(call_index); + Ok(()) + })?; + + Self::deposit_event(RawEvent::TicketBought(caller.clone(), call_index)); + + Ok(()) + } + + // Randomly choose a winner from among the total number of participants. + fn choose_winner(total: u32) -> u32 { + let mut random_number = Self::generate_random_number(0); + + // Best effort attempt to remove bias from modulus operator. + for i in 1 .. T::MaxGenerateRandom::get() { + if random_number < u32::MAX - u32::MAX % total { + break; + } + + random_number = Self::generate_random_number(i); + } + + random_number % total + } + + // Generate a random number from a given seed. + // Note that there is potential bias introduced by using modulus operator. + // You should call this function with different seed values until the random + // number lies within `u32::MAX - u32::MAX % n`. + fn generate_random_number(seed: u32) -> u32 { + let random_seed = T::Randomness::random(&(T::ModuleId::get(), seed).encode()); + let random_number = ::decode(&mut random_seed.as_ref()) + .expect("secure hashes should always be bigger than u32; qed"); + random_number + } +} diff --git a/frame/lottery/src/mock.rs b/frame/lottery/src/mock.rs new file mode 100644 index 0000000000000..67ecb6cbb63a2 --- /dev/null +++ b/frame/lottery/src/mock.rs @@ -0,0 +1,138 @@ +// This file is part of Substrate. + +// Copyright (C) 2020 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Test utilities + +use super::*; + +use frame_support::{ + impl_outer_origin, impl_outer_dispatch, parameter_types, + traits::{OnInitialize, OnFinalize, TestRandomness}, +}; +use sp_core::H256; +use sp_runtime::{ + Perbill, + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, +}; +use frame_system::EnsureRoot; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +impl_outer_dispatch! { + pub enum Call for Test where origin: Origin { + frame_system::System, + pallet_balances::Balances, + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct Test; +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); +} + +impl frame_system::Config for Test { + type BaseCallFilter = (); + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Index = u64; + type Call = Call; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = (); + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = (); + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); +} + +parameter_types! { + pub const ExistentialDeposit: u64 = 1; +} + +impl pallet_balances::Config for Test { + type MaxLocks = (); + type Balance = u64; + type Event = (); + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); +} + +parameter_types! { + pub const LotteryModuleId: ModuleId = ModuleId(*b"py/lotto"); + pub const MaxCalls: usize = 2; + pub const MaxGenerateRandom: u32 = 10; +} + +impl Config for Test { + type ModuleId = LotteryModuleId; + type Call = Call; + type Currency = Balances; + type Randomness = TestRandomness; + type Event = (); + type ManagerOrigin = EnsureRoot; + type MaxCalls = MaxCalls; + type ValidateCall = Lottery; + type MaxGenerateRandom = MaxGenerateRandom; + type WeightInfo = (); +} + +pub type Lottery = Module; +pub type System = frame_system::Module; +pub type Balances = pallet_balances::Module; + +pub type SystemCall = frame_system::Call; +pub type BalancesCall = pallet_balances::Call; + +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + pallet_balances::GenesisConfig:: { + balances: vec![(1, 100), (2, 100), (3, 100), (4, 100), (5, 100)], + }.assimilate_storage(&mut t).unwrap(); + t.into() +} + +/// Run until a particular block. +pub fn run_to_block(n: u64) { + while System::block_number() < n { + if System::block_number() > 1 { + Lottery::on_finalize(System::block_number()); + System::on_finalize(System::block_number()); + } + System::set_block_number(System::block_number() + 1); + System::on_initialize(System::block_number()); + Lottery::on_initialize(System::block_number()); + } +} diff --git a/frame/lottery/src/tests.rs b/frame/lottery/src/tests.rs new file mode 100644 index 0000000000000..69a8a1267dd48 --- /dev/null +++ b/frame/lottery/src/tests.rs @@ -0,0 +1,261 @@ +// This file is part of Substrate. + +// Copyright (C) 2020 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Tests for the module. + +use super::*; +use mock::{ + Lottery, Balances, Test, Origin, Call, SystemCall, BalancesCall, + new_test_ext, run_to_block +}; +use sp_runtime::traits::{BadOrigin}; +use frame_support::{assert_noop, assert_ok}; +use pallet_balances::Error as BalancesError; + +#[test] +fn initial_state() { + new_test_ext().execute_with(|| { + assert_eq!(Balances::free_balance(Lottery::account_id()), 0); + assert!(crate::Lottery::::get().is_none()); + assert_eq!(Participants::::get(&1), (0, vec![])); + assert_eq!(TicketsCount::get(), 0); + assert!(Tickets::::get(0).is_none()); + }); +} + +#[test] +fn basic_end_to_end_works() { + new_test_ext().execute_with(|| { + let price = 10; + let length = 20; + let delay = 5; + let calls = vec![ + Call::Balances(BalancesCall::force_transfer(0, 0, 0)), + Call::Balances(BalancesCall::transfer(0, 0)), + ]; + + // Set calls for the lottery + assert_ok!(Lottery::set_calls(Origin::root(), calls)); + + // Start lottery, it repeats + assert_ok!(Lottery::start_lottery(Origin::root(), price, length, delay, true)); + assert!(crate::Lottery::::get().is_some()); + + assert_eq!(Balances::free_balance(&1), 100); + let call = Box::new(Call::Balances(BalancesCall::transfer(2, 20))); + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call.clone())); + // 20 from the transfer, 10 from buying a ticket + assert_eq!(Balances::free_balance(&1), 100 - 20 - 10); + assert_eq!(Participants::::get(&1).1.len(), 1); + assert_eq!(TicketsCount::get(), 1); + // 1 owns the 0 ticket + assert_eq!(Tickets::::get(0), Some(1)); + + // More ticket purchases + assert_ok!(Lottery::buy_ticket(Origin::signed(2), call.clone())); + assert_ok!(Lottery::buy_ticket(Origin::signed(3), call.clone())); + assert_ok!(Lottery::buy_ticket(Origin::signed(4), call.clone())); + assert_eq!(TicketsCount::get(), 4); + + // Go to end + run_to_block(20); + assert_ok!(Lottery::buy_ticket(Origin::signed(5), call.clone())); + // Ticket isn't bought + assert_eq!(TicketsCount::get(), 4); + + // Go to payout + run_to_block(25); + // User 1 wins + assert_eq!(Balances::free_balance(&1), 70 + 40); + // Lottery is reset and restarted + assert_eq!(TicketsCount::get(), 0); + assert_eq!(LotteryIndex::get(), 2); + assert_eq!( + crate::Lottery::::get().unwrap(), + LotteryConfig { + price, + start: 25, + length, + delay, + repeat: true, + } + ); + }); +} + +#[test] +fn set_calls_works() { + new_test_ext().execute_with(|| { + assert!(!CallIndices::exists()); + + let calls = vec![ + Call::Balances(BalancesCall::force_transfer(0, 0, 0)), + Call::Balances(BalancesCall::transfer(0, 0)), + ]; + + assert_ok!(Lottery::set_calls(Origin::root(), calls)); + assert!(CallIndices::exists()); + + let too_many_calls = vec![ + Call::Balances(BalancesCall::force_transfer(0, 0, 0)), + Call::Balances(BalancesCall::transfer(0, 0)), + Call::System(SystemCall::remark(vec![])), + ]; + + assert_noop!( + Lottery::set_calls(Origin::root(), too_many_calls), + Error::::TooManyCalls, + ); + + // Clear calls + assert_ok!(Lottery::set_calls(Origin::root(), vec![])); + assert!(CallIndices::get().is_empty()); + }); +} + +#[test] +fn start_lottery_works() { + new_test_ext().execute_with(|| { + let price = 10; + let length = 20; + let delay = 5; + + // Setup ignores bad origin + assert_noop!( + Lottery::start_lottery(Origin::signed(1), price, length, delay, false), + BadOrigin, + ); + + // All good + assert_ok!(Lottery::start_lottery(Origin::root(), price, length, delay, false)); + + // Can't open another one if lottery is already present + assert_noop!( + Lottery::start_lottery(Origin::root(), price, length, delay, false), + Error::::InProgress, + ); + }); +} + +#[test] +fn buy_ticket_works_as_simple_passthrough() { + // This test checks that even if the user could not buy a ticket, that `buy_ticket` acts + // as a simple passthrough to the real call. + new_test_ext().execute_with(|| { + // No lottery set up + let call = Box::new(Call::Balances(BalancesCall::transfer(2, 20))); + // This is just a basic transfer then + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call.clone())); + assert_eq!(Balances::free_balance(&1), 100 - 20); + assert_eq!(TicketsCount::get(), 0); + + // Lottery is set up, but too expensive to enter, so `do_buy_ticket` fails. + let calls = vec![ + Call::Balances(BalancesCall::force_transfer(0, 0, 0)), + Call::Balances(BalancesCall::transfer(0, 0)), + ]; + assert_ok!(Lottery::set_calls(Origin::root(), calls)); + + // Ticket price of 60 would kill the user's account + assert_ok!(Lottery::start_lottery(Origin::root(), 60, 10, 5, false)); + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call.clone())); + assert_eq!(Balances::free_balance(&1), 100 - 20 - 20); + assert_eq!(TicketsCount::get(), 0); + + // If call would fail, the whole thing still fails the same + let fail_call = Box::new(Call::Balances(BalancesCall::transfer(2, 1000))); + assert_noop!( + Lottery::buy_ticket(Origin::signed(1), fail_call), + BalancesError::::InsufficientBalance, + ); + + let bad_origin_call = Box::new(Call::Balances(BalancesCall::force_transfer(0, 0, 0))); + assert_noop!( + Lottery::buy_ticket(Origin::signed(1), bad_origin_call), + BadOrigin, + ); + + // User can call other txs, but doesn't get a ticket + let remark_call = Box::new(Call::System(SystemCall::remark(b"hello, world!".to_vec()))); + assert_ok!(Lottery::buy_ticket(Origin::signed(2), remark_call)); + assert_eq!(TicketsCount::get(), 0); + + let successful_call = Box::new(Call::Balances(BalancesCall::transfer(2, 1))); + assert_ok!(Lottery::buy_ticket(Origin::signed(2), successful_call)); + assert_eq!(TicketsCount::get(), 1); + }); +} + +#[test] +fn buy_ticket_works() { + new_test_ext().execute_with(|| { + // Set calls for the lottery. + let calls = vec![ + Call::System(SystemCall::remark(vec![])), + Call::Balances(BalancesCall::transfer(0, 0)), + ]; + assert_ok!(Lottery::set_calls(Origin::root(), calls)); + + + // Can't buy ticket before start + let call = Box::new(Call::Balances(BalancesCall::transfer(2, 1))); + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call.clone())); + assert_eq!(TicketsCount::get(), 0); + + // Start lottery + assert_ok!(Lottery::start_lottery(Origin::root(), 1, 20, 5, false)); + + // Go to start, buy ticket for transfer + run_to_block(5); + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call)); + assert_eq!(TicketsCount::get(), 1); + + // Can't buy another of the same ticket (even if call is slightly changed) + let call = Box::new(Call::Balances(BalancesCall::transfer(3, 30))); + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call)); + assert_eq!(TicketsCount::get(), 1); + + // Buy ticket for remark + let call = Box::new(Call::System(SystemCall::remark(b"hello, world!".to_vec()))); + assert_ok!(Lottery::buy_ticket(Origin::signed(1), call.clone())); + assert_eq!(TicketsCount::get(), 2); + + // Go to end, can't buy tickets anymore + run_to_block(20); + assert_ok!(Lottery::buy_ticket(Origin::signed(2), call.clone())); + assert_eq!(TicketsCount::get(), 2); + + // Go to payout, can't buy tickets when there is no lottery open + run_to_block(25); + assert_ok!(Lottery::buy_ticket(Origin::signed(2), call.clone())); + assert_eq!(TicketsCount::get(), 0); + assert_eq!(LotteryIndex::get(), 1); + }); +} + +#[test] +fn start_lottery_will_create_account() { + new_test_ext().execute_with(|| { + let price = 10; + let length = 20; + let delay = 5; + + assert_eq!(Balances::total_balance(&Lottery::account_id()), 0); + assert_ok!(Lottery::start_lottery(Origin::root(), price, length, delay, false)); + assert_eq!(Balances::total_balance(&Lottery::account_id()), 1); + }); +} diff --git a/frame/lottery/src/weights.rs b/frame/lottery/src/weights.rs new file mode 100644 index 0000000000000..28d5ac0945b1d --- /dev/null +++ b/frame/lottery/src/weights.rs @@ -0,0 +1,124 @@ +// This file is part of Substrate. + +// Copyright (C) 2021 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Autogenerated weights for pallet_lottery +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 2.0.0 +//! DATE: 2021-01-05, STEPS: [50, ], REPEAT: 20, LOW RANGE: [], HIGH RANGE: [] +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 128 + +// Executed Command: +// target/release/substrate +// benchmark +// --chain=dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_lottery +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./frame/lottery/src/weights.rs +// --template=./.maintain/frame-weight-template.hbs + + +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use sp_std::marker::PhantomData; + +/// Weight functions needed for pallet_lottery. +pub trait WeightInfo { + fn buy_ticket() -> Weight; + fn set_calls(n: u32, ) -> Weight; + fn start_lottery() -> Weight; + fn stop_repeat() -> Weight; + fn on_initialize_end() -> Weight; + fn on_initialize_repeat() -> Weight; +} + +/// Weights for pallet_lottery using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + fn buy_ticket() -> Weight { + (97_799_000 as Weight) + .saturating_add(T::DbWeight::get().reads(6 as Weight)) + .saturating_add(T::DbWeight::get().writes(4 as Weight)) + } + fn set_calls(n: u32, ) -> Weight { + (20_932_000 as Weight) + // Standard Error: 9_000 + .saturating_add((513_000 as Weight).saturating_mul(n as Weight)) + .saturating_add(T::DbWeight::get().writes(1 as Weight)) + } + fn start_lottery() -> Weight { + (77_600_000 as Weight) + .saturating_add(T::DbWeight::get().reads(3 as Weight)) + .saturating_add(T::DbWeight::get().writes(3 as Weight)) + } + fn stop_repeat() -> Weight { + (10_707_000 as Weight) + .saturating_add(T::DbWeight::get().reads(1 as Weight)) + .saturating_add(T::DbWeight::get().writes(1 as Weight)) + } + fn on_initialize_end() -> Weight { + (162_126_000 as Weight) + .saturating_add(T::DbWeight::get().reads(6 as Weight)) + .saturating_add(T::DbWeight::get().writes(4 as Weight)) + } + fn on_initialize_repeat() -> Weight { + (169_310_000 as Weight) + .saturating_add(T::DbWeight::get().reads(7 as Weight)) + .saturating_add(T::DbWeight::get().writes(5 as Weight)) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + fn buy_ticket() -> Weight { + (97_799_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(6 as Weight)) + .saturating_add(RocksDbWeight::get().writes(4 as Weight)) + } + fn set_calls(n: u32, ) -> Weight { + (20_932_000 as Weight) + // Standard Error: 9_000 + .saturating_add((513_000 as Weight).saturating_mul(n as Weight)) + .saturating_add(RocksDbWeight::get().writes(1 as Weight)) + } + fn start_lottery() -> Weight { + (77_600_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(3 as Weight)) + .saturating_add(RocksDbWeight::get().writes(3 as Weight)) + } + fn stop_repeat() -> Weight { + (10_707_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(1 as Weight)) + .saturating_add(RocksDbWeight::get().writes(1 as Weight)) + } + fn on_initialize_end() -> Weight { + (162_126_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(6 as Weight)) + .saturating_add(RocksDbWeight::get().writes(4 as Weight)) + } + fn on_initialize_repeat() -> Weight { + (169_310_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(7 as Weight)) + .saturating_add(RocksDbWeight::get().writes(5 as Weight)) + } +}