diff --git a/Cargo.lock b/Cargo.lock index 66910db9d31b7..df57755f827e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4996,6 +4996,7 @@ dependencies = [ "pallet-membership", "pallet-mmr", "pallet-multisig", + "pallet-name-service", "pallet-offences", "pallet-offences-benchmarking", "pallet-preimage", @@ -6116,6 +6117,22 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-name-service" +version = "4.0.0-dev" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-nicks" version = "4.0.0-dev" @@ -11355,7 +11372,7 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee73e6e4924fe940354b8d4d98cad5231175d615cd855b758adc658c0aac6a0" dependencies = [ - "cfg-if 0.1.10", + "cfg-if 1.0.0", "digest 0.10.2", "rand 0.8.4", "static_assertions", diff --git a/Cargo.toml b/Cargo.toml index a12910c52c018..c32f675f5564a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,7 @@ members = [ "frame/merkle-mountain-range/primitives", "frame/merkle-mountain-range/rpc", "frame/multisig", + "frame/name-service", "frame/nicks", "frame/node-authorization", "frame/offences", diff --git a/bin/node/runtime/Cargo.toml b/bin/node/runtime/Cargo.toml index 0055230295a1e..0c86dc3070815 100644 --- a/bin/node/runtime/Cargo.toml +++ b/bin/node/runtime/Cargo.toml @@ -76,6 +76,7 @@ pallet-lottery = { version = "4.0.0-dev", default-features = false, path = "../. pallet-membership = { version = "4.0.0-dev", default-features = false, path = "../../../frame/membership" } pallet-mmr = { version = "4.0.0-dev", default-features = false, path = "../../../frame/merkle-mountain-range" } pallet-multisig = { version = "4.0.0-dev", default-features = false, path = "../../../frame/multisig" } +pallet-name-service = { version = "4.0.0-dev", default-features = false, path = "../../../frame/name-service" } pallet-offences = { version = "4.0.0-dev", default-features = false, path = "../../../frame/offences" } pallet-offences-benchmarking = { version = "4.0.0-dev", path = "../../../frame/offences/benchmarking", default-features = false, optional = true } pallet-preimage = { version = "4.0.0-dev", default-features = false, path = "../../../frame/preimage" } @@ -137,6 +138,7 @@ std = [ "pallet-membership/std", "pallet-mmr/std", "pallet-multisig/std", + "pallet-name-service/std", "pallet-identity/std", "pallet-scheduler/std", "node-primitives/std", @@ -205,6 +207,7 @@ runtime-benchmarks = [ "pallet-membership/runtime-benchmarks", "pallet-mmr/runtime-benchmarks", "pallet-multisig/runtime-benchmarks", + "pallet-name-service/runtime-benchmarks", "pallet-offences-benchmarking", "pallet-preimage/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index 0aff3d8046eef..25d61c44ec4e1 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -205,7 +205,7 @@ impl frame_system::Config for Runtime { type Hash = Hash; type Hashing = BlakeTwo256; type AccountId = AccountId; - type Lookup = Indices; + type Lookup = (Indices, NameService); type Header = generic::Header; type Event = Event; type BlockHashCount = BlockHashCount; @@ -1252,6 +1252,29 @@ impl pallet_mmr::Config for Runtime { type WeightInfo = (); } +parameter_types! { + pub const BiddingPeriod: BlockNumber = 1 * DAYS; + pub const ClaimPeriod: BlockNumber = 1 * DAYS; + pub const OwnershipPeriod: BlockNumber = 365 * DAYS; + pub const MinBid: Balance = 10 * DOLLARS; +} + +impl pallet_name_service::Config for Runtime { + type AccountIndex = AccountIndex; + type Currency = Balances; + type Event = Event; + type ManagerOrigin = EnsureRoot; + type PermanenceOrigin = EnsureRoot; + type BiddingPeriod = BiddingPeriod; + type ClaimPeriod = ClaimPeriod; + type OwnershipPeriod = OwnershipPeriod; + type PaymentDestination = Treasury; + type MinBid = MinBid; + // Extensions off + type ExtensionConfig = (); + type WeightInfo = (); +} + parameter_types! { pub const LotteryPalletId: PalletId = PalletId(*b"py/lotto"); pub const MaxCalls: u32 = 10; @@ -1411,6 +1434,7 @@ construct_runtime!( ChildBounties: pallet_child_bounties, Referenda: pallet_referenda, ConvictionVoting: pallet_conviction_voting, + NameService: pallet_name_service, } ); @@ -1494,6 +1518,7 @@ mod benches { [pallet_membership, TechnicalMembership] [pallet_mmr, Mmr] [pallet_multisig, Multisig] + [pallet_name_service, NameService] [pallet_offences, OffencesBench::] [pallet_preimage, Preimage] [pallet_proxy, Proxy] diff --git a/frame/name-service/Cargo.toml b/frame/name-service/Cargo.toml new file mode 100644 index 0000000000000..5a2c04953a172 --- /dev/null +++ b/frame/name-service/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "pallet-name-service" +version = "4.0.0-dev" +authors = ["Parity Technologies "] +edition = "2018" +license = "Apache-2.0" +homepage = "https://substrate.dev" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME name service for Substrate" +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +sp-std = { version = "4.0.0-dev", default-features = false, path = "../../primitives/std" } +sp-runtime = { version = "5.0.0", default-features = false, path = "../../primitives/runtime" } +sp-core = { version = "5.0.0", default-features = false, path = "../../primitives/core" } +frame-support = { version = "4.0.0-dev", default-features = false, path = "../support" } +frame-system = { version = "4.0.0-dev", default-features = false, path = "../system" } +scale-info = { version = "2.0.0", default-features = false, features = ["derive"] } + +# Benchmarking dependencies +frame-benchmarking = { version = "4.0.0-dev", default-features = false, path = "../benchmarking", optional = true } +sp-io = { version = "5.0.0", default-features = false, path = "../../primitives/io", optional = true } + +[dev-dependencies] +pallet-balances = { version = "4.0.0-dev", path = "../balances" } +sp-io = { version = "5.0.0", path = "../../primitives/io" } + +[features] +default = ["std"] +std = [ + "codec/std", + "sp-core/std", + "sp-std/std", + "frame-support/std", + "sp-runtime/std", + "frame-system/std", +] +runtime-benchmarks = [ + "sp-io", + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] diff --git a/frame/name-service/README.md b/frame/name-service/README.md new file mode 100644 index 0000000000000..e24593e716d0f --- /dev/null +++ b/frame/name-service/README.md @@ -0,0 +1,3 @@ +A simple name service that can be used to give accounts friendly names. + +License: Apache-2.0 diff --git a/frame/name-service/src/benchmarking.rs b/frame/name-service/src/benchmarking.rs new file mode 100644 index 0000000000000..b38daf5dc31fc --- /dev/null +++ b/frame/name-service/src/benchmarking.rs @@ -0,0 +1,174 @@ +// This file is part of Substrate. + +// Copyright (C) 2019-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. + +// Benchmarks for Name Service Pallet + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; +use frame_benchmarking::{account, benchmarks, whitelisted_caller}; +use frame_support::traits::{Currency, Get, OnFinalize, OnInitialize}; +use frame_system::{Pallet as System, RawOrigin}; +use sp_runtime::traits::{One, Saturating}; +use sp_io::hashing::blake2_256; + +use crate::Pallet as NameService; +const SEED: u32 = 1; + +fn run_to_block(n: T::BlockNumber) { + while System::::block_number() < n { + NameService::::on_finalize(System::::block_number()); + System::::set_block_number(System::::block_number() + One::one()); + NameService::::on_initialize(System::::block_number()); + } +} + +benchmarks! { + // Benchmark bid with the worst possible scenario + // ie. When the bid is ongoing and new_bidder != current_bidder + bid { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + let current_bidder : T::AccountId = account("current_bidder", 0, SEED); + let new_bidder : T::AccountId = whitelisted_caller(); + let balance = T::Currency::minimum_balance().max(T::MinBid::get()).saturating_mul(100u32.into()); + T::Currency::make_free_balance_be(¤t_bidder, balance); + T::Currency::make_free_balance_be(&new_bidder, balance); + // create first bid + NameService::::bid(RawOrigin::Signed(current_bidder.clone()).into(), name_hash, T::MinBid::get())?; + let new_bid_amount = T::MinBid::get().saturating_mul(2u32.into()); + }: _(RawOrigin::Signed(new_bidder.clone()), name_hash, new_bid_amount) + verify { + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::Bidding { + who: new_bidder.clone(), + bid_end: T::BiddingPeriod::get().saturating_add(1u32.into()), + amount: new_bid_amount + }); + assert_eq!(T::Currency::total_balance(¤t_bidder), balance); + assert_eq!(T::Currency::free_balance(&new_bidder), balance.saturating_sub(new_bid_amount)); + } + + // Benchmark claim with the worst possible scenario + // ie. When a claim is valid and for (min+1) periods + claim { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + let current_bidder : T::AccountId = whitelisted_caller(); + let balance = T::Currency::minimum_balance().max(T::MinBid::get()).saturating_mul(100u32.into()); + T::Currency::make_free_balance_be(¤t_bidder, balance); + // create winning bid + NameService::::bid(RawOrigin::Signed( + current_bidder.clone()).into(), + name_hash, + T::MinBid::get())?; + run_to_block::(T::BiddingPeriod::get()); + }: _(RawOrigin::Signed(current_bidder.clone()), name_hash, 2 as u32) + verify { + let stored_data = Registration::::get(&name_hash); + let expected_expiry = T::BiddingPeriod::get() + .saturating_add(T::OwnershipPeriod::get() + .saturating_mul(2u32.into())); + assert_eq!(stored_data, NameStatus::Owned { + who: current_bidder.clone(), + expiration : Some(expected_expiry) + }); + assert_eq!(T::Currency::free_balance(¤t_bidder), + balance.saturating_sub(T::MinBid::get().saturating_mul(4u32.into()))); + } + + // Benchmark free with the worst possible scenario + // ie. When a bid has expired and not claimed + free { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + let current_bidder : T::AccountId = whitelisted_caller(); + let balance = T::Currency::minimum_balance().max(T::MinBid::get()).saturating_mul(100u32.into()); + T::Currency::make_free_balance_be(¤t_bidder, balance); + // bid for the name + NameService::::bid(RawOrigin::Signed(current_bidder.clone()).into(), name_hash, T::MinBid::get())?; + // expiry+bid+claim time + let block_to_free = T::BiddingPeriod::get() + .saturating_mul(2u32.into()) + .saturating_add(T::ClaimPeriod::get()); + run_to_block::(block_to_free.saturating_add(1u32.into())); + }: _(RawOrigin::Signed(current_bidder.clone()), name_hash) + verify { + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::default()); + assert_eq!(T::Currency::total_balance(¤t_bidder), balance.saturating_sub(T::MinBid::get())); + } + + // Benchmark assign with the worst possible scenario + // ie. When a name is Owned and target is being set + assign { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + let caller : T::AccountId = whitelisted_caller(); + // set caller as the owner of name + let state = NameStatus::>::Owned{ + who : caller.clone(), + expiration : None + }; + Registration::::insert(&name_hash, state); + }: _(RawOrigin::Signed(caller.clone()), name_hash, Some(caller.clone())) + verify { + assert_eq!(Lookup::::get(&name_hash), Some(caller.clone())); + } + + // Benchmark unassign with the worst possible scenario + // ie. When the caller is the target and can unassign + unassign { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + let caller : T::AccountId = whitelisted_caller(); + // set caller as the target of name + Lookup::::insert(&name_hash, caller.clone()); + }: _(RawOrigin::Signed(caller.clone()), name_hash) + verify { + assert_eq!(Lookup::::get(&name_hash), None); + } + + // Benchmark extend_ownership with the worst possible scenario + // ie. When the name is Owned, has an expiration date and extension is enabled + extend_ownership { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + let caller : T::AccountId = whitelisted_caller(); + let balance = T::Currency::minimum_balance() + .max(T::MinBid::get()) + .saturating_mul(100u32.into()); + T::Currency::make_free_balance_be(&caller, balance); + // set caller as the owner of name + let state = NameStatus::>::Owned{ + who : caller.clone(), + expiration : Some(0u32.into()) + }; + Registration::::insert(&name_hash, state); + }: _(RawOrigin::Signed(caller.clone()), name_hash) + verify { + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::Owned { + who: caller.clone(), + expiration : Some(100u32.into()) + }); + assert_eq!(T::Currency::free_balance(&caller), + balance.saturating_sub(T::ExtensionConfig::get().extension_fee)); + } + + impl_benchmark_test_suite!(NameService, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/frame/name-service/src/lib.rs b/frame/name-service/src/lib.rs new file mode 100644 index 0000000000000..2256919f54642 --- /dev/null +++ b/frame/name-service/src/lib.rs @@ -0,0 +1,461 @@ +// This file is part of Substrate. + +// Copyright (C) 2019-2022 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 simple name service that can be used to give accounts friendly names. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +mod benchmarking; +mod mock; +mod tests; + +#[frame_support::pallet] +pub mod pallet { + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + + use frame_support::traits::{ + Currency, ExistenceRequirement::KeepAlive, Imbalance, OnUnbalanced, ReservableCurrency, + WithdrawReasons, + }; + use sp_runtime::traits::{AtLeast32Bit, Saturating}; + + use codec::{Codec, MaxEncodedLen}; + + // The struct on which we build all of our Pallet logic. + #[pallet::pallet] + pub struct Pallet(_); + + pub trait WeightInfo {} + impl WeightInfo for () {} + + pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + type NegativeImbalanceOf = <::Currency as Currency< + ::AccountId, + >>::NegativeImbalance; + + type Name = [u8; 32]; + + #[derive(Default, RuntimeDebug)] + pub struct ExtensionConfig { + pub enabled: bool, + pub extension_period: BlockNumber, + pub extension_fee: Balance, + } + + #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] + pub enum NameStatus { + Available, + Bidding { who: AccountId, bid_end: BlockNumber, amount: Balance }, + Owned { who: AccountId, expiration: Option }, + } + + impl Default for NameStatus { + fn default() -> Self { + NameStatus::Available + } + } + + /// Maps the kitty struct to the kitty DNA. + #[pallet::storage] + pub(super) type Registration = StorageMap< + _, + Blake2_128Concat, + Name, + NameStatus>, + ValueQuery, + >; + + /// Track the kitties owned by each account. + #[pallet::storage] + pub(super) type Lookup = StorageMap<_, Blake2_128Concat, Name, T::AccountId>; + + // Your Pallet's configuration trait, representing custom external types and interfaces. + #[pallet::config] + pub trait Config: frame_system::Config { + /// An optional `AccountIndex` type for backwards compatibility. + type AccountIndex: Parameter + + Member + + MaybeSerializeDeserialize + + Codec + + Default + + AtLeast32Bit + + Copy + + MaxEncodedLen; + + /// The currency trait. + type Currency: ReservableCurrency; + + /// The overarching event type. + type Event: From> + IsType<::Event>; + + /// Origin that can have high level control over the name-service pallet. + type ManagerOrigin: EnsureOrigin; + + /// Origin that can set permanent ownership of a name to an account. + type PermanenceOrigin: EnsureOrigin; + + /// Time available between subsequent bids for a name. + type BiddingPeriod: Get; + + /// Time available after bidding has completed for the winner to claim their name. + type ClaimPeriod: Get; + + /// One ownership period, which can be multiplied through exponential deposit. + type OwnershipPeriod: Get; + + /// Handler for the unbalanced decrease when funds are burned. + type PaymentDestination: OnUnbalanced>; + + /// Minimum Bid for a name. + type MinBid: Get>; + + /// Configuration for ownership extensions of a name. + type ExtensionConfig: Get>>; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + } + + // Your Pallet's events. + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + BidPlaced(Name, T::AccountId, BalanceOf, T::BlockNumber), + NameClaimed(Name, T::AccountId, T::BlockNumber), + NameFreed(Name), + NameSet(Name), + NameAssigned(Name, T::AccountId), + NameUnassigned(Name), + } + + // Your Pallet's error messages. + #[pallet::error] + pub enum Error { + /// The current state of the name does not match this step in the state machine. + UnexpectedState, + /// The name provided does not follow the configured rules. + InvalidName, + /// The bid is invalid. + InvalidBid, + /// The claim is invalid. + InvalidClaim, + /// User is not the current bidder. + NotBidder, + /// The name has not expired in bidding or ownership. + NotExpired, + /// The name is already available. + AlreadyAvailable, + /// The name is permanent. + Permanent, + /// You are not the owner of this name. + NotOwner, + /// You are not assigned to this domain. + NotAssigned, + /// Ownership extensions are not available. + NoExtensions, + } + + // Your Pallet's callable functions. + #[pallet::call] + impl Pallet { + #[pallet::weight(0)] + pub fn set_name( + origin: OriginFor, + name: Name, + state: NameStatus>, + ) -> DispatchResult { + T::ManagerOrigin::ensure_origin(origin)?; + // TODO: Make safer with regards to setting or removing `Bidding` state. + Registration::::insert(&name, state); + Self::deposit_event(Event::::NameSet(name)); + Ok(()) + } + + #[pallet::weight(0)] + pub fn make_permanent(origin: OriginFor, name: Name) -> DispatchResult { + T::PermanenceOrigin::ensure_origin(origin)?; + Registration::::try_mutate(&name, |state| -> DispatchResult { + match state { + NameStatus::Owned { expiration, .. } => { + *expiration = None; + Ok(()) + }, + _ => Err(Error::::UnexpectedState)?, + } + })?; + Ok(()) + } + + /// Allow anyone to place a bid for a name. + #[pallet::weight(0)] + pub fn bid(origin: OriginFor, name: Name, new_bid: BalanceOf) -> DispatchResult { + let new_bidder = ensure_signed(origin)?; + ensure!(new_bid >= T::MinBid::get(), Error::::InvalidBid); + + let block_number = frame_system::Pallet::::block_number(); + let new_bid_end = block_number.saturating_add(T::BiddingPeriod::get()); + + Registration::::try_mutate(&name, |state| -> DispatchResult { + match state { + // Name is available, we can directly transition this to Bidding. + NameStatus::Available => { + T::Currency::reserve(&new_bidder, new_bid)?; + *state = NameStatus::Bidding { + who: new_bidder.clone(), + bid_end: new_bid_end, + amount: new_bid, + }; + Ok(()) + }, + // Bid is ongoing, we need to check if the new bid is valid. + NameStatus::Bidding { + who: current_bidder, + bid_end: current_bid_end, + amount: current_bid, + } => { + // New bid must be before expiration and more than the current bid. + if block_number < *current_bid_end && *current_bid < new_bid { + // Try to reserve the new amount and unreserve the old amount, handling + // the same bidder. + if new_bidder == *current_bidder { + // We check that new bid is greater than current bid, so this is + // safe. + let bid_diff = new_bid - *current_bid; + T::Currency::reserve(&new_bidder, bid_diff)?; + } else { + T::Currency::reserve(&new_bidder, new_bid)?; + T::Currency::unreserve(¤t_bidder, *current_bid); + } + *state = NameStatus::Bidding { + who: new_bidder.clone(), + bid_end: new_bid_end, + amount: new_bid, + }; + Ok(()) + } else { + Err(Error::::InvalidBid)? + } + }, + // Name is already owned, this is an invalid bid. + NameStatus::Owned { .. } => Err(Error::::InvalidBid)?, + } + })?; + Self::deposit_event(Event::::BidPlaced(name, new_bidder, new_bid, new_bid_end)); + Ok(()) + } + + /// Allow the winner of a bid to claim their name and pay their registration costs. + #[pallet::weight(0)] + pub fn claim(origin: OriginFor, name: Name, num_of_periods: u32) -> DispatchResult { + let caller = ensure_signed(origin)?; + ensure!(num_of_periods > 0, Error::::InvalidClaim); + + let block_number = frame_system::Pallet::::block_number(); + + Registration::::try_mutate(&name, |state| -> DispatchResult { + match state { + NameStatus::Available | NameStatus::Owned { .. } => + Err(Error::::InvalidClaim)?, + NameStatus::Bidding { who: current_bidder, bid_end, amount } => { + ensure!(caller == *current_bidder, Error::::NotBidder); + ensure!(*bid_end <= block_number, Error::::NotExpired); + // If user only wants 1 period, just slash the reserve we already have. + let mut credit = if num_of_periods == 1 { + NegativeImbalanceOf::::zero() + } else { + // User pays N^2 the price of the bid to own the name for N periods. + let multiplier = num_of_periods.saturating_mul(num_of_periods); + // We already have already reserved 1x deposit, so we just need to check + // they can pay the rest... + let withdraw_amount = amount.saturating_mul((multiplier - 1).into()); + T::Currency::withdraw( + current_bidder, + withdraw_amount, + WithdrawReasons::FEE, + KeepAlive, + )? + }; + // Remove the rest from reserve + credit.subsume(T::Currency::slash_reserved(current_bidder, *amount).0); + T::PaymentDestination::on_unbalanced(credit); + // Grant ownership + let ownership_expiration = block_number.saturating_add( + T::OwnershipPeriod::get().saturating_mul(num_of_periods.into()), + ); + *state = NameStatus::Owned { + who: current_bidder.clone(), + expiration: Some(ownership_expiration), + }; + Self::deposit_event(Event::::NameClaimed( + name.clone(), + caller, + ownership_expiration, + )); + Ok(()) + }, + } + })?; + Ok(()) + } + + /// Allow anyone to make a name available if it is past the claiming period or expiration + /// date. + #[pallet::weight(0)] + pub fn free(origin: OriginFor, name: Name) -> DispatchResult { + let caller = ensure_signed(origin)?; + let block_number = frame_system::Pallet::::block_number(); + + Registration::::try_mutate(&name, |state| -> DispatchResult { + match state { + // Name is already free, do nothing. + NameStatus::Available => Err(Error::::AlreadyAvailable)?, + // Name is in bidding period, check that it is past the bid expiration + claim + // period. + NameStatus::Bidding { who: current_bidder, bid_end, amount } => { + let free_block = bid_end + .saturating_add(T::BiddingPeriod::get()) + .saturating_add(T::ClaimPeriod::get()); + ensure!(free_block < block_number, Error::::NotExpired); + // Remove the bid, slashing the reserve. + let credit = T::Currency::slash_reserved(current_bidder, *amount).0; + T::PaymentDestination::on_unbalanced(credit); + *state = NameStatus::Available; + Ok(()) + }, + // Name is owned, check that it is past the ownership expiration or the current + // owner is calling this function. + NameStatus::Owned { who: current_owner, expiration: maybe_expiration } => + if let Some(expiration) = maybe_expiration { + if caller != *current_owner { + ensure!(*expiration <= block_number, Error::::NotExpired); + } + *state = NameStatus::Available; + Ok(()) + } else { + Err(Error::::Permanent)? + }, + } + })?; + + Self::deposit_event(Event::::NameFreed(name)); + Ok(()) + } + + /// Allow the owner of a name to assign or unassign a target. + #[pallet::weight(0)] + pub fn assign( + origin: OriginFor, + name: Name, + target: Option, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + + let registration = Registration::::get(&name); + let owner = match registration { + NameStatus::Available | NameStatus::Bidding { .. } => Err(Error::::NotOwner)?, + NameStatus::Owned { who, .. } => who, + }; + + ensure!(owner == caller, Error::::NotOwner); + + if let Some(account) = target { + Lookup::::insert(&name, account.clone()); + Self::deposit_event(Event::::NameAssigned(name, account)); + } else { + Lookup::::remove(&name); + Self::deposit_event(Event::::NameUnassigned(name)); + } + Ok(()) + } + + /// Allow the target of a name to unassign themselves from the name. + #[pallet::weight(0)] + pub fn unassign(origin: OriginFor, name: Name) -> DispatchResult { + let caller = ensure_signed(origin)?; + + let lookup = Lookup::::get(&name); + if let Some(account) = lookup { + ensure!(account == caller, Error::::NotAssigned); + Lookup::::remove(&name); + Self::deposit_event(Event::::NameUnassigned(name)); + } + Ok(()) + } + + #[pallet::weight(0)] + pub fn extend_ownership(origin: OriginFor, name: Name) -> DispatchResult { + // Anyone can make this call for any name on behalf of the owner. + let caller = ensure_signed(origin)?; + let ExtensionConfig { enabled, extension_period, extension_fee } = + T::ExtensionConfig::get(); + ensure!(enabled, Error::::NoExtensions); + + Registration::::try_mutate(&name, |state| -> DispatchResult { + match state { + NameStatus::Available | NameStatus::Bidding { .. } => + Err(Error::::UnexpectedState)?, + NameStatus::Owned { expiration, .. } => { + // If the name can expire... + if let Some(expiration_block) = expiration { + let credit = T::Currency::withdraw( + &caller, + extension_fee, + WithdrawReasons::FEE, + KeepAlive, + )?; + T::PaymentDestination::on_unbalanced(credit); + *expiration_block = expiration_block.saturating_add(extension_period); + Ok(()) + } else { + Err(Error::::Permanent)? + } + }, + } + })?; + Ok(()) + } + } + + // Your Pallet's internal functions. + impl Pallet {} +} + +use sp_runtime::{ + traits::{LookupError, StaticLookup}, + MultiAddress, +}; + +impl StaticLookup for Pallet { + type Source = MultiAddress; + type Target = T::AccountId; + + fn lookup(a: Self::Source) -> Result { + match a { + MultiAddress::Id(id) => Ok(id), + MultiAddress::Address32(hash) => Lookup::::get(hash).ok_or(LookupError), + _ => Err(LookupError), + } + } + + fn unlookup(a: Self::Target) -> Self::Source { + MultiAddress::Id(a) + } +} diff --git a/frame/name-service/src/mock.rs b/frame/name-service/src/mock.rs new file mode 100644 index 0000000000000..6f41022ae5921 --- /dev/null +++ b/frame/name-service/src/mock.rs @@ -0,0 +1,145 @@ +// This file is part of Substrate. + +// Copyright (C) 2018-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 + +#![cfg(test)] + +use super::*; + +use crate as pallet_name_service; +use frame_support::{ + ord_parameter_types, + pallet_prelude::*, + parameter_types, + traits::{ConstU32, ConstU64, Everything}, +}; +use sp_core::H256; +use sp_runtime::{testing::Header, traits::BlakeTwo256, Perbill}; + +use frame_system::EnsureSignedBy; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; +//type OpaqueCall = super::OpaqueCall; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Balances: pallet_balances, + NameService: pallet_name_service, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: Weight = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); +} + +type BlockNumber = u64; +type Balance = u64; + +parameter_types! { + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(1024); +} +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Call = Call; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = NameService; + type Header = Header; + type Event = Event; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl pallet_balances::Config for Test { + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type Balance = u64; + type Event = Event; + type DustRemoval = (); + type ExistentialDeposit = ConstU64<1>; + type AccountStore = System; + type WeightInfo = (); +} + +parameter_types! { + pub const BiddingPeriod: BlockNumber = 10; + pub const ClaimPeriod: BlockNumber = 5; + pub const OwnershipPeriod: BlockNumber = 100; + pub const MinBid: Balance = 5; + pub ExtensionsOn: ExtensionConfig = ExtensionConfig { + enabled: true, + extension_period: 100, + extension_fee: 5, + }; +} + +ord_parameter_types! { + pub const Manager: u64 = 100; + pub const Permanence: u64 = 200; +} + +impl Config for Test { + type AccountIndex = u32; + type Currency = Balances; + type Event = Event; + type ManagerOrigin = EnsureSignedBy; + type PermanenceOrigin = EnsureSignedBy; + type BiddingPeriod = BiddingPeriod; + type ClaimPeriod = ClaimPeriod; + type OwnershipPeriod = OwnershipPeriod; + type PaymentDestination = (); + type MinBid = MinBid; + type ExtensionConfig = ExtensionsOn; + type WeightInfo = (); +} + +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, 200), (3, 300), (4, 400), (5, 500), (6, 600)], + } + .assimilate_storage(&mut t) + .unwrap(); + t.into() +} diff --git a/frame/name-service/src/tests.rs b/frame/name-service/src/tests.rs new file mode 100644 index 0000000000000..8c11e34e95eae --- /dev/null +++ b/frame/name-service/src/tests.rs @@ -0,0 +1,389 @@ +// 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. + +//! Tests for the module. + +#![cfg(test)] + +use super::{mock::*, *}; +use frame_support::{ + assert_noop, assert_ok, + error::BadOrigin, + traits::{Currency, OnFinalize, OnInitialize}, +}; +use sp_core::blake2_256; + +use sp_runtime::MultiAddress; + +fn run_to_block(n: u64) { + while System::block_number() < n { + NameService::on_finalize(System::block_number()); + System::set_block_number(System::block_number() + 1); + NameService::on_initialize(System::block_number()); + } +} + +#[test] +fn basic_setup_works() { + new_test_ext().execute_with(|| { + assert_eq!(Balances::free_balance(&1), 100); + assert_eq!(Balances::free_balance(&2), 200); + }); +} + +#[test] +fn end_to_end_should_work() { + new_test_ext().execute_with(|| { + // This is the name we will bid on. + let name = b"shawntabrizi"; + let name_hash = blake2_256(name); + + // Name is totally available. + assert!(!Registration::::contains_key(name_hash)); + assert!(!Lookup::::contains_key(name_hash)); + + // User 1 can make an initial bid. + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 5)); + assert_eq!(Balances::free_balance(&1), 95); + + // User 2 can be outbid. + run_to_block(9); + assert_ok!(NameService::bid(Origin::signed(2), name_hash, 10)); + assert_eq!(Balances::free_balance(&1), 100); + assert_eq!(Balances::free_balance(&2), 190); + + // User 2 can win bid. (others cant bid anymore) + run_to_block(19); + assert_noop!(NameService::bid(Origin::signed(1), name_hash, 15), Error::::InvalidBid); + + // User 2 can claim bid. 2 ^ 2 = 4 * 10 = 40 total cost + assert_ok!(NameService::claim(Origin::signed(2), name_hash, 2)); + assert_eq!(Balances::free_balance(&2), 160); + + // User 2 can assign their name + assert_ok!(NameService::assign(Origin::signed(2), name_hash, Some(2))); + + // Name is totally taken. + assert!(Registration::::contains_key(name_hash)); + assert_eq!(Lookup::::get(name_hash), Some(2)); + + // Name can be used instead of AccountId + assert_ok!(Balances::transfer(Origin::signed(1), MultiAddress::Address32(name_hash), 40)); + assert_eq!(Balances::free_balance(&2), 200); + + // Name can expire + run_to_block(219); + assert_ok!(NameService::free(Origin::signed(1), name_hash)); + + // Name can be bid for again. + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 5)); + }); +} + +#[test] +fn set_name_works() { + new_test_ext().execute_with(|| { + // Test data + let name = b"shawntabrizi"; + let name_hash = blake2_256(name); + + // name setting by manager works correctly + let status = NameStatus::Owned { who: 1, expiration: None }; + assert_ok!(NameService::set_name(Origin::signed(100), name_hash.clone(), status.clone())); + + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, status); + + // non manager call should fail + assert_noop!(NameService::set_name(Origin::signed(1), name_hash, status), BadOrigin); + }) +} + +#[test] +fn bid_works() { + new_test_ext().execute_with(|| { + // Test data + let name = b"shawntabrizi"; + let name_hash = blake2_256(name); + + // call with less than MinBid should fail + assert_noop!( + NameService::bid( + Origin::signed(1), + name_hash, + ::MinBid::get() - 1 + ), + Error::::InvalidBid + ); + + // create bid works correctly + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 5)); + let stored_data = Registration::::get(&name_hash); + assert_eq!( + stored_data, + NameStatus::Bidding { + who: 1, + bid_end: ::BiddingPeriod::get(), + amount: 5 + } + ); + assert_eq!(Balances::free_balance(&1), 95); + + // another bid at same price should fail + assert_noop!(NameService::bid(Origin::signed(2), name_hash, 5), Error::::InvalidBid); + + // previous bidder should be able to raise bid + run_to_block(2); + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + let stored_data = Registration::::get(&name_hash); + assert_eq!( + stored_data, + NameStatus::Bidding { + who: 1, + bid_end: ::BiddingPeriod::get() + 2, + amount: 10 + } + ); + assert_eq!(Balances::free_balance(&1), 90); + + // another user can outbid current bidder + assert_ok!(NameService::bid(Origin::signed(2), name_hash, 20)); + let stored_data = Registration::::get(&name_hash); + assert_eq!( + stored_data, + NameStatus::Bidding { + who: 2, + bid_end: ::BiddingPeriod::get() + 2, + amount: 20 + } + ); + assert_eq!(Balances::free_balance(&1), 100); + assert_eq!(Balances::free_balance(&2), 180); + + // cannot bid on expired item + run_to_block(12); + assert_noop!(NameService::bid(Origin::signed(2), name_hash, 25), Error::::InvalidBid); + }) +} + +#[test] +fn claim_works() { + new_test_ext().execute_with(|| { + // Test data + let name = b"shawntabrizi"; + let name_hash = blake2_256(name); + + // claim to non existent name should fail + assert_noop!( + NameService::claim(Origin::signed(1), name_hash, 1), + Error::::InvalidClaim + ); + + // setup a bid to claim + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + + // claim before bid expiry should fail + assert_noop!( + NameService::claim(Origin::signed(1), name_hash, 1), + Error::::NotExpired + ); + + run_to_block(::BiddingPeriod::get()); + + // cannot invoke with less than one period + assert_noop!( + NameService::claim(Origin::signed(1), name_hash, 0), + Error::::InvalidClaim + ); + + // call by not current bidder should fail + assert_noop!(NameService::claim(Origin::signed(2), name_hash, 1), Error::::NotBidder); + + // claim by successful bidder should pass + assert_ok!(NameService::claim(Origin::signed(1), name_hash, 2)); + assert_eq!(Balances::free_balance(&1), 60); + // ensure reserves have been slashed + assert_eq!(Balances::total_balance(&1), 60); + + let stored_data = Registration::::get(&name_hash); + assert_eq!( + stored_data, + NameStatus::Owned { + who: 1, + expiration: Some( + ::OwnershipPeriod::get() * 2 + + ::BiddingPeriod::get() + ), + } + ); + + // call to previously claimed name should fail + assert_noop!( + NameService::claim(Origin::signed(1), name_hash, 1), + Error::::InvalidClaim + ); + }); +} + +#[test] +fn free_works() { + new_test_ext().execute_with(|| { + // Test data + let name = b"shawntabrizi"; + let name_hash = blake2_256(name); + + // free non existent name should fail + assert_noop!( + NameService::free(Origin::signed(1), name_hash), + Error::::AlreadyAvailable + ); + + // setup a bid to free + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + + // free a name in bidding period should fail + assert_noop!(NameService::free(Origin::signed(2), name_hash), Error::::NotExpired); + + // free should wait for claim period + run_to_block(::BiddingPeriod::get()); + assert_noop!(NameService::free(Origin::signed(2), name_hash), Error::::NotExpired); + + // call after (bid_end+bidding+claim+1) period should pass + let ideal_block = ::BiddingPeriod::get() + .saturating_add(::ClaimPeriod::get()); + run_to_block(ideal_block.saturating_add(11)); + assert_ok!(NameService::free(Origin::signed(1), name_hash)); + + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::default()); + + // original bidder reserve balance is slashed + assert_eq!(Balances::free_balance(&1), 90); + assert_eq!(Balances::reserved_balance(&1), 0); + + // setup a claimed name to free + assert_ok!(NameService::bid(Origin::signed(2), name_hash, 10)); + run_to_block(100); + assert_ok!(NameService::claim(Origin::signed(2), name_hash, 1)); + + // only current owner should be able to free non expired name + assert_noop!(NameService::free(Origin::signed(1), name_hash), Error::::NotExpired); + assert_ok!(NameService::free(Origin::signed(2), name_hash)); + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::default()); + }); +} + +#[test] +fn assign_works() { + new_test_ext().execute_with(|| { + // Test data + let name = b"shawntabrizi"; + let name_hash = blake2_256(name); + + // setup a claimed name to assign + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + run_to_block(::BiddingPeriod::get()); + assert_ok!(NameService::claim(Origin::signed(1), name_hash, 1)); + + // non owner calls should fail + assert_noop!( + NameService::assign(Origin::signed(2), name_hash, Some(4)), + Error::::NotOwner + ); + + // owner can assign accountID + assert_ok!(NameService::assign(Origin::signed(1), name_hash, Some(4))); + assert_eq!(Lookup::::get(&name_hash), Some(4)); + + // owner can unassign accountId + assert_ok!(NameService::assign(Origin::signed(1), name_hash, None)); + assert_eq!(Lookup::::get(&name_hash), None); + }); +} + +#[test] +fn unassign_works() { + new_test_ext().execute_with(|| { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + + // setup an assigned name to test + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + run_to_block(::BiddingPeriod::get()); + assert_ok!(NameService::claim(Origin::signed(1), name_hash, 1)); + assert_ok!(NameService::assign(Origin::signed(1), name_hash, Some(1))); + + // non assigned account call should fail + assert_noop!( + NameService::unassign(Origin::signed(2), name_hash), + Error::::NotAssigned + ); + + // assigned account call should pass + assert_ok!(NameService::unassign(Origin::signed(1), name_hash)); + assert_eq!(Lookup::::get(&name_hash), None); + }); +} + +#[test] +fn make_permanent_works() { + new_test_ext().execute_with(|| { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + + // setup an assigned name to test + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + run_to_block(::BiddingPeriod::get()); + assert_ok!(NameService::claim(Origin::signed(1), name_hash, 1)); + + // call from non permeance account should fail + assert_noop!(NameService::make_permanent(Origin::signed(1), name_hash), BadOrigin); + + // call from permeance accout should pass + assert_ok!(NameService::make_permanent(Origin::signed(200), name_hash)); + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::Owned { who: 1, expiration: None }); + }); +} + +#[test] +fn extend_ownership_works() { + new_test_ext().execute_with(|| { + // Test data + let name_hash = blake2_256(b"shawntabrizi"); + + // call with non claimed name should fail + assert_noop!( + NameService::extend_ownership(Origin::signed(1), name_hash), + Error::::UnexpectedState + ); + + // setup an assigned name to test + assert_ok!(NameService::bid(Origin::signed(1), name_hash, 10)); + run_to_block(::BiddingPeriod::get()); + assert_ok!(NameService::claim(Origin::signed(1), name_hash, 1)); + + // call to extend ownership should pass + assert_ok!(NameService::extend_ownership(Origin::signed(2), name_hash)); + // balance of caller should reduce by the extension fee + assert_eq!(Balances::free_balance(&2), 200 - 5); + + let stored_data = Registration::::get(&name_hash); + assert_eq!(stored_data, NameStatus::Owned { who: 1, expiration: Some(210) }); + }); +}