From 4cdc230dde06a1371bdb82053535ff7e19dbe13c Mon Sep 17 00:00:00 2001 From: Gavin Wood Date: Sat, 27 Feb 2021 16:11:27 +0100 Subject: [PATCH] Gilts Pallet (#8139) * Initial draft * Enlarge function drafted. * Thaw draft * Retract_bid draft * Final bits of draft impl. * Test mockup * Tests * Docs * Add benchmark scaffold * Integrate weights * All benchmarks done * Missing file * Remove stale comments * Fixes * Fixes * Allow for priority queuing. * Another test and a fix * Fixes * Fixes * cargo run --release --features=runtime-benchmarks --manifest-path=bin/node/cli/Cargo.toml -- benchmark --chain=dev --steps=50 --repeat=20 --pallet=pallet_gilt --extrinsic=* --execution=wasm --wasm-execution=compiled --heap-pages=4096 --output=./frame/gilt/src/weights.rs --template=./.maintain/frame-weight-template.hbs * Grumble * Update frame/gilt/src/tests.rs Co-authored-by: Shawn Tabrizi * Update frame/gilt/src/tests.rs Co-authored-by: Shawn Tabrizi * Grumble * Update frame/gilt/src/tests.rs Co-authored-by: Shawn Tabrizi * Update frame/gilt/src/lib.rs Co-authored-by: Shawn Tabrizi * Update frame/gilt/src/lib.rs Co-authored-by: Shawn Tabrizi * Fix unreserve ordering * Grumble * Fixes Co-authored-by: Parity Benchmarking Bot Co-authored-by: Shawn Tabrizi --- Cargo.lock | 18 + Cargo.toml | 1 + bin/node/cli/src/chain_spec.rs | 1 + bin/node/runtime/Cargo.toml | 5 +- bin/node/runtime/src/lib.rs | 30 +- bin/node/testing/src/genesis.rs | 1 + frame/gilt/Cargo.toml | 46 ++ frame/gilt/README.md | 2 + frame/gilt/src/benchmarking.rs | 136 ++++++ frame/gilt/src/lib.rs | 582 ++++++++++++++++++++++++ frame/gilt/src/mock.rs | 138 ++++++ frame/gilt/src/tests.rs | 499 ++++++++++++++++++++ frame/gilt/src/weights.rs | 164 +++++++ primitives/arithmetic/src/per_things.rs | 5 + 14 files changed, 1626 insertions(+), 2 deletions(-) create mode 100644 frame/gilt/Cargo.toml create mode 100644 frame/gilt/README.md create mode 100644 frame/gilt/src/benchmarking.rs create mode 100644 frame/gilt/src/lib.rs create mode 100644 frame/gilt/src/mock.rs create mode 100644 frame/gilt/src/tests.rs create mode 100644 frame/gilt/src/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 7cb69617b1d1f..9660e11884c86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4113,6 +4113,7 @@ dependencies = [ "pallet-democracy", "pallet-election-provider-multi-phase", "pallet-elections-phragmen", + "pallet-gilt", "pallet-grandpa", "pallet-identity", "pallet-im-online", @@ -4805,6 +4806,23 @@ dependencies = [ "sp-tasks", ] +[[package]] +name = "pallet-gilt" +version = "3.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "serde", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-grandpa" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 8873c033455a6..9a494d6aff39f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ members = [ "frame/example-offchain-worker", "frame/example-parallel", "frame/executive", + "frame/gilt", "frame/grandpa", "frame/identity", "frame/im-online", diff --git a/bin/node/cli/src/chain_spec.rs b/bin/node/cli/src/chain_spec.rs index 7de9cfd0b6aa5..db268ad105290 100644 --- a/bin/node/cli/src/chain_spec.rs +++ b/bin/node/cli/src/chain_spec.rs @@ -326,6 +326,7 @@ pub fn testnet_genesis( max_members: 999, }), pallet_vesting: Some(Default::default()), + pallet_gilt: Some(Default::default()), } } diff --git a/bin/node/runtime/Cargo.toml b/bin/node/runtime/Cargo.toml index b3672cbe7526c..7669273f0c82d 100644 --- a/bin/node/runtime/Cargo.toml +++ b/bin/node/runtime/Cargo.toml @@ -55,8 +55,9 @@ pallet-contracts = { version = "3.0.0", default-features = false, path = "../../ pallet-contracts-primitives = { version = "3.0.0", default-features = false, path = "../../../frame/contracts/common/" } pallet-contracts-rpc-runtime-api = { version = "3.0.0", default-features = false, path = "../../../frame/contracts/rpc/runtime-api/" } pallet-democracy = { version = "3.0.0", default-features = false, path = "../../../frame/democracy" } -pallet-elections-phragmen = { version = "3.0.0", default-features = false, path = "../../../frame/elections-phragmen" } pallet-election-provider-multi-phase = { version = "3.0.0", default-features = false, path = "../../../frame/election-provider-multi-phase" } +pallet-elections-phragmen = { version = "3.0.0", default-features = false, path = "../../../frame/elections-phragmen" } +pallet-gilt = { version = "3.0.0", default-features = false, path = "../../../frame/gilt" } pallet-grandpa = { version = "3.0.0", default-features = false, path = "../../../frame/grandpa" } pallet-im-online = { version = "3.0.0", default-features = false, path = "../../../frame/im-online" } pallet-indices = { version = "3.0.0", default-features = false, path = "../../../frame/indices" } @@ -112,6 +113,7 @@ std = [ "pallet-democracy/std", "pallet-elections-phragmen/std", "frame-executive/std", + "pallet-gilt/std", "pallet-grandpa/std", "pallet-im-online/std", "pallet-indices/std", @@ -170,6 +172,7 @@ runtime-benchmarks = [ "pallet-contracts/runtime-benchmarks", "pallet-democracy/runtime-benchmarks", "pallet-elections-phragmen/runtime-benchmarks", + "pallet-gilt/runtime-benchmarks", "pallet-grandpa/runtime-benchmarks", "pallet-identity/runtime-benchmarks", "pallet-im-online/runtime-benchmarks", diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index b68cf18b54148..420b34a663f64 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -1047,6 +1047,32 @@ impl pallet_assets::Config for Runtime { type WeightInfo = pallet_assets::weights::SubstrateWeight; } +parameter_types! { + pub const QueueCount: u32 = 300; + pub const MaxQueueLen: u32 = 1000; + pub const FifoQueueLen: u32 = 500; + pub const Period: BlockNumber = 30 * DAYS; + pub const MinFreeze: Balance = 100 * DOLLARS; + pub const IntakePeriod: BlockNumber = 10; + pub const MaxIntakeBids: u32 = 10; +} + +impl pallet_gilt::Config for Runtime { + type Event = Event; + type Currency = Balances; + type AdminOrigin = frame_system::EnsureRoot; + type Deficit = (); + type Surplus = (); + type QueueCount = QueueCount; + type MaxQueueLen = MaxQueueLen; + type FifoQueueLen = FifoQueueLen; + type Period = Period; + type MinFreeze = MinFreeze; + type IntakePeriod = IntakePeriod; + type MaxIntakeBids = MaxIntakeBids; + type WeightInfo = pallet_gilt::weights::SubstrateWeight; +} + construct_runtime!( pub enum Runtime where Block = Block, @@ -1090,6 +1116,7 @@ construct_runtime!( Assets: pallet_assets::{Module, Call, Storage, Event}, Mmr: pallet_mmr::{Module, Storage}, Lottery: pallet_lottery::{Module, Call, Storage, Event}, + Gilt: pallet_gilt::{Module, Call, Storage, Event, Config}, } ); @@ -1427,8 +1454,9 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_collective, Council); add_benchmark!(params, batches, pallet_contracts, Contracts); add_benchmark!(params, batches, pallet_democracy, Democracy); - add_benchmark!(params, batches, pallet_elections_phragmen, Elections); add_benchmark!(params, batches, pallet_election_provider_multi_phase, ElectionProviderMultiPhase); + add_benchmark!(params, batches, pallet_elections_phragmen, Elections); + add_benchmark!(params, batches, pallet_gilt, Gilt); add_benchmark!(params, batches, pallet_grandpa, Grandpa); add_benchmark!(params, batches, pallet_identity, Identity); add_benchmark!(params, batches, pallet_im_online, ImOnline); diff --git a/bin/node/testing/src/genesis.rs b/bin/node/testing/src/genesis.rs index 75d0d18e6ef81..b026b9530e7f4 100644 --- a/bin/node/testing/src/genesis.rs +++ b/bin/node/testing/src/genesis.rs @@ -119,5 +119,6 @@ pub fn config_endowed( max_members: 999, }), pallet_vesting: Some(Default::default()), + pallet_gilt: Some(Default::default()), } } diff --git a/frame/gilt/Cargo.toml b/frame/gilt/Cargo.toml new file mode 100644 index 0000000000000..f1e0d61158d32 --- /dev/null +++ b/frame/gilt/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "pallet-gilt" +version = "3.0.0" +authors = ["Parity Technologies "] +edition = "2018" +license = "Apache-2.0" +homepage = "https://substrate.dev" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME pallet for rewarding account freezing." +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +serde = { version = "1.0.101", optional = true } +codec = { package = "parity-scale-codec", version = "2.0.0", default-features = false, features = ["derive"] } +sp-std = { version = "3.0.0", default-features = false, path = "../../primitives/std" } +sp-runtime = { version = "3.0.0", default-features = false, path = "../../primitives/runtime" } +sp-arithmetic = { version = "3.0.0", default-features = false, path = "../../primitives/arithmetic" } +frame-benchmarking = { version = "3.0.0", default-features = false, path = "../benchmarking", optional = true } +frame-support = { version = "3.0.0", default-features = false, path = "../support" } +frame-system = { version = "3.0.0", default-features = false, path = "../system" } + +[dev-dependencies] +sp-io = { version = "3.0.0", path = "../../primitives/io" } +sp-core = { version = "3.0.0", path = "../../primitives/core" } +pallet-balances = { version = "3.0.0", path = "../balances" } + +[features] +default = ["std"] +std = [ + "serde", + "codec/std", + "sp-std/std", + "sp-runtime/std", + "sp-arithmetic/std", + "frame-benchmarking/std", + "frame-support/std", + "frame-system/std", +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] diff --git a/frame/gilt/README.md b/frame/gilt/README.md new file mode 100644 index 0000000000000..4eaddae1786e7 --- /dev/null +++ b/frame/gilt/README.md @@ -0,0 +1,2 @@ + +License: Apache-2.0 diff --git a/frame/gilt/src/benchmarking.rs b/frame/gilt/src/benchmarking.rs new file mode 100644 index 0000000000000..2ee7bffd9410e --- /dev/null +++ b/frame/gilt/src/benchmarking.rs @@ -0,0 +1,136 @@ +// 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. + +//! Benchmarks for Gilt Pallet + +#![cfg(feature = "runtime-benchmarks")] + +use sp_std::prelude::*; +use super::*; +use sp_runtime::traits::{Zero, Bounded}; +use sp_arithmetic::Perquintill; +use frame_system::RawOrigin; +use frame_benchmarking::{benchmarks, whitelisted_caller, impl_benchmark_test_suite}; +use frame_support::{traits::{Currency, Get, EnsureOrigin}, dispatch::UnfilteredDispatchable}; + +use crate::Pallet as Gilt; + +type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; + +benchmarks! { + place_bid { + let l in 0..(T::MaxQueueLen::get() - 1); + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + for i in 0..l { + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), 1)?; + } + }: _(RawOrigin::Signed(caller.clone()), T::MinFreeze::get() * BalanceOf::::from(2u32), 1) + verify { + assert_eq!(QueueTotals::::get()[0], (l + 1, T::MinFreeze::get() * BalanceOf::::from(l + 2))); + } + + place_bid_max { + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + for i in 0..T::MaxQueueLen::get() { + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), 1)?; + } + }: { + Gilt::::place_bid( + RawOrigin::Signed(caller.clone()).into(), + T::MinFreeze::get() * BalanceOf::::from(2u32), + 1, + )? + } + verify { + assert_eq!(QueueTotals::::get()[0], ( + T::MaxQueueLen::get(), + T::MinFreeze::get() * BalanceOf::::from(T::MaxQueueLen::get() + 1), + )); + } + + retract_bid { + let l in 1..T::MaxQueueLen::get(); + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, BalanceOf::::max_value()); + for i in 0..l { + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), 1)?; + } + }: _(RawOrigin::Signed(caller.clone()), T::MinFreeze::get(), 1) + verify { + assert_eq!(QueueTotals::::get()[0], (l - 1, T::MinFreeze::get() * BalanceOf::::from(l - 1))); + } + + set_target { + let call = Call::::set_target(Default::default()); + let origin = T::AdminOrigin::successful_origin(); + }: { call.dispatch_bypass_filter(origin)? } + + thaw { + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, T::MinFreeze::get() * BalanceOf::::from(3u32)); + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), 1)?; + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), 1)?; + Gilt::::enlarge(T::MinFreeze::get() * BalanceOf::::from(2u32), 2); + Active::::mutate(0, |m_g| if let Some(ref mut g) = m_g { g.expiry = Zero::zero() }); + }: _(RawOrigin::Signed(caller.clone()), 0) + verify { + assert!(Active::::get(0).is_none()); + } + + pursue_target_noop { + }: { Gilt::::pursue_target(0) } + + pursue_target_per_item { + // bids taken + let b in 1..T::MaxQueueLen::get(); + + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, T::MinFreeze::get() * BalanceOf::::from(b + 1)); + + for _ in 0..b { + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), 1)?; + } + + Call::::set_target(Perquintill::from_percent(100)) + .dispatch_bypass_filter(T::AdminOrigin::successful_origin())?; + + }: { Gilt::::pursue_target(b) } + + pursue_target_per_queue { + // total queues hit + let q in 1..T::QueueCount::get(); + + let caller: T::AccountId = whitelisted_caller(); + T::Currency::make_free_balance_be(&caller, T::MinFreeze::get() * BalanceOf::::from(q + 1)); + + for i in 0..q { + Gilt::::place_bid(RawOrigin::Signed(caller.clone()).into(), T::MinFreeze::get(), i + 1)?; + } + + Call::::set_target(Perquintill::from_percent(100)) + .dispatch_bypass_filter(T::AdminOrigin::successful_origin())?; + + }: { Gilt::::pursue_target(q) } +} + +impl_benchmark_test_suite!( + Gilt, + crate::mock::new_test_ext(), + crate::mock::Test, +); diff --git a/frame/gilt/src/lib.rs b/frame/gilt/src/lib.rs new file mode 100644 index 0000000000000..94d341f47f44e --- /dev/null +++ b/frame/gilt/src/lib.rs @@ -0,0 +1,582 @@ +// This file is part of Substrate. + +// Copyright (C) 2019-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. + +//! # Gilt Pallet +//! A pallet allowing accounts to auction for being frozen and receive open-ended +//! inflation-protection in return. +//! +//! ## Overview +//! +//! Lock up tokens, for at least as long as you offer, and be free from both inflation and +//! intermediate reward or exchange until the tokens become unlocked. +//! +//! ## Design +//! +//! Queues for each of 1-`QueueCount` periods, given in blocks (`Period`). Queues are limited in +//! size to something sensible, `MaxQueueLen`. A secondary storage item with `QueueCount` x `u32` +//! elements with the number of items in each queue. +//! +//! Queues are split into two parts. The first part is a priority queue based on bid size. The +//! second part is just a FIFO (the size of the second part is set with `FifoQueueLen`). Items are +//! always prepended so that removal is always O(1) since removal often happens many times under a +//! single weighed function (`on_initialize`) yet placing bids only ever happens once per weighed +//! function (`place_bid`). If the queue has a priority portion, then it remains sorted in order of +//! bid size so that smaller bids fall off as it gets too large. +//! +//! Account may enqueue a balance with some number of `Period`s lock up, up to a maximum of +//! `QueueCount`. The balance gets reserved. There's a minimum of `MinFreeze` to avoid dust. +//! +//! Until your bid is turned into an issued gilt you can retract it instantly and the funds are +//! unreserved. +//! +//! There's a target proportion of effective total issuance (i.e. accounting for existing gilts) +//! which the we attempt to have frozen at any one time. It will likely be gradually increased over +//! time by governance. +//! +//! As the total funds frozen under gilts drops below `FrozenFraction` of the total effective +//! issuance, then bids are taken from queues, with the queue of the greatest period taking +//! priority. If the item in the queue's locked amount is greater than the amount left to be +//! frozen, then it is split up into multiple bids and becomes partially frozen under gilt. +//! +//! Once an account's balance is frozen, it remains frozen until the owner thaws the balance of the +//! account. This may happen no earlier than queue's period after the point at which the gilt is +//! issued. +//! +//! ## Suggested Values +//! +//! - `QueueCount`: 300 +//! - `Period`: 432,000 +//! - `MaxQueueLen`: 1000 +//! - `MinFreeze`: Around CHF 100 in value. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; +mod benchmarking; +pub mod weights; + +#[frame_support::pallet] +pub mod pallet { + use sp_std::prelude::*; + use sp_arithmetic::{Perquintill, PerThing}; + use sp_runtime::traits::{Zero, Saturating, SaturatedConversion}; + use frame_support::traits::{Currency, OnUnbalanced, ReservableCurrency}; + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + pub use crate::weights::WeightInfo; + + type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; + type PositiveImbalanceOf = + <::Currency as Currency<::AccountId>>::PositiveImbalance; + type NegativeImbalanceOf = + <::Currency as Currency<::AccountId>>::NegativeImbalance; + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Overarching event type. + type Event: From> + IsType<::Event>; + + /// Currency type that this works on. + type Currency: ReservableCurrency; + + /// Origin required for setting the target proportion to be under gilt. + type AdminOrigin: EnsureOrigin; + + /// Unbalanced handler to account for funds created (in case of a higher total issuance over + /// freezing period). + type Deficit: OnUnbalanced>; + + /// Unbalanced handler to account for funds destroyed (in case of a lower total issuance + /// over freezing period). + type Surplus: OnUnbalanced>; + + /// Number of duration queues in total. This sets the maximum duration supported, which is + /// this value multiplied by `Period`. + #[pallet::constant] + type QueueCount: Get; + + /// Maximum number of items that may be in each duration queue. + #[pallet::constant] + type MaxQueueLen: Get; + + /// Portion of the queue which is free from ordering and just a FIFO. + /// + /// Must be no greater than `MaxQueueLen`. + #[pallet::constant] + type FifoQueueLen: Get; + + /// The base period for the duration queues. This is the common multiple across all + /// supported freezing durations that can be bid upon. + #[pallet::constant] + type Period: Get; + + /// The minimum amount of funds that may be offered to freeze for a gilt. Note that this + /// does not actually limit the amount which may be frozen in a gilt since gilts may be + /// split up in order to satisfy the desired amount of funds under gilts. + /// + /// It should be at least big enough to ensure that there is no possible storage spam attack + /// or queue-filling attack. + #[pallet::constant] + type MinFreeze: Get>; + + /// The number of blocks between consecutive attempts to issue more gilts in an effort to + /// get to the target amount to be frozen. + /// + /// A larger value results in fewer storage hits each block, but a slower period to get to + /// the target. + #[pallet::constant] + type IntakePeriod: Get; + + /// The maximum amount of bids that can be turned into issued gilts each block. A larger + /// value here means less of the block available for transactions should there be a glut of + /// bids to make into gilts to reach the target. + #[pallet::constant] + type MaxIntakeBids: Get; + + /// Information on runtime weights. + type WeightInfo: WeightInfo; + } + + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + pub struct Pallet(_); + + /// A single bid on a gilt, an item of a *queue* in `Queues`. + #[derive(Clone, Eq, PartialEq, Default, Encode, Decode, RuntimeDebug)] + pub struct GiltBid { + /// The amount bid. + pub amount: Balance, + /// The owner of the bid. + pub who: AccountId, + } + + /// Information representing an active gilt. + #[derive(Clone, Eq, PartialEq, Default, Encode, Decode, RuntimeDebug)] + pub struct ActiveGilt { + /// The proportion of the effective total issuance (i.e. accounting for any eventual gilt + /// expansion or contraction that may eventually be claimed). + pub proportion: Perquintill, + /// The amount reserved under this gilt. + pub amount: Balance, + /// The account to whom this gilt belongs. + pub who: AccountId, + /// The time after which this gilt can be redeemed for the proportional amount of balance. + pub expiry: BlockNumber, + } + + /// An index for a gilt. + pub type ActiveIndex = u32; + + /// Overall information package on the active gilts. + /// + /// The way of determining the net issuance (i.e. after factoring in all maturing frozen funds) + /// is: + /// + /// `total_issuance - frozen + proportion * total_issuance` + #[derive(Clone, Eq, PartialEq, Default, Encode, Decode, RuntimeDebug)] + pub struct ActiveGiltsTotal { + /// The total amount of funds held in reserve for all active gilts. + pub frozen: Balance, + /// The proportion of funds that the `frozen` balance represents to total issuance. + pub proportion: Perquintill, + /// The total number of gilts issued so far. + pub index: ActiveIndex, + /// The target proportion of gilts within total issuance. + pub target: Perquintill, + } + + /// The totals of items and balances within each queue. Saves a lot of storage reads in the + /// case of sparsely packed queues. + /// + /// The vector is indexed by duration in `Period`s, offset by one, so information on the queue + /// whose duration is one `Period` would be storage `0`. + #[pallet::storage] + pub type QueueTotals = StorageValue<_, Vec<(u32, BalanceOf)>, ValueQuery>; + + /// The queues of bids ready to become gilts. Indexed by duration (in `Period`s). + #[pallet::storage] + pub type Queues = StorageMap< + _, + Blake2_128Concat, + u32, + Vec, T::AccountId>>, + ValueQuery, + >; + + /// Information relating to the gilts currently active. + #[pallet::storage] + pub type ActiveTotal = StorageValue<_, ActiveGiltsTotal>, ValueQuery>; + + /// The currently active gilts, indexed according to the order of creation. + #[pallet::storage] + pub type Active = StorageMap< + _, + Blake2_128Concat, + ActiveIndex, + ActiveGilt, ::AccountId, ::BlockNumber>, + OptionQuery, + >; + + #[pallet::genesis_config] + #[derive(Default)] + pub struct GenesisConfig; + + #[pallet::genesis_build] + impl GenesisBuild for GenesisConfig { + fn build(&self) { + QueueTotals::::put(vec![(0, BalanceOf::::zero()); T::QueueCount::get() as usize]); + } + } + + #[pallet::event] + #[pallet::metadata(T::AccountId = "AccountId")] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A bid was successfully placed. + /// \[ who, amount, duration \] + BidPlaced(T::AccountId, BalanceOf, u32), + /// A bid was successfully removed (before being accepted as a gilt). + /// \[ who, amount, duration \] + BidRetracted(T::AccountId, BalanceOf, u32), + /// A bid was accepted as a gilt. The balance may not be released until expiry. + /// \[ index, expiry, who, amount \] + GiltIssued(ActiveIndex, T::BlockNumber, T::AccountId, BalanceOf), + /// An expired gilt has been thawed. + /// \[ index, who, original_amount, additional_amount \] + GiltThawed(ActiveIndex, T::AccountId, BalanceOf, BalanceOf), + } + + #[pallet::error] + pub enum Error { + /// The duration of the bid is less than one. + DurationTooSmall, + /// The duration is the bid is greater than the number of queues. + DurationTooBig, + /// The amount of the bid is less than the minimum allowed. + AmountTooSmall, + /// The queue for the bid's duration is full and the amount bid is too low to get in through + /// replacing an existing bid. + BidTooLow, + /// Gilt index is unknown. + Unknown, + /// Not the owner of the gilt. + NotOwner, + /// Gilt not yet at expiry date. + NotExpired, + /// The given bid for retraction is not found. + NotFound, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(n: T::BlockNumber) -> Weight { + if (n % T::IntakePeriod::get()).is_zero() { + Self::pursue_target(T::MaxIntakeBids::get()) + } else { + 0 + } + } + } + + #[pallet::call] + impl Pallet { + /// Place a bid for a gilt to be issued. + /// + /// Origin must be Signed, and account must have at least `amount` in free balance. + /// + /// - `amount`: The amount of the bid; these funds will be reserved. If the bid is + /// successfully elevated into an issued gilt, then these funds will continue to be + /// reserved until the gilt expires. Must be at least `MinFreeze`. + /// - `duration`: The number of periods for which the funds will be locked if the gilt is + /// issued. It will expire only after this period has elapsed after the point of issuance. + /// Must be greater than 1 and no more than `QueueCount`. + /// + /// Complexities: + /// - `Queues[duration].len()` (just take max). + #[pallet::weight(T::WeightInfo::place_bid_max())] + pub fn place_bid( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + duration: u32, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + + ensure!(amount >= T::MinFreeze::get(), Error::::AmountTooSmall); + let queue_count = T::QueueCount::get() as usize; + let queue_index = duration.checked_sub(1) + .ok_or(Error::::DurationTooSmall)? as usize; + ensure!(queue_index < queue_count, Error::::DurationTooBig); + + let net = Queues::::try_mutate(duration, |q| + -> Result<(u32, BalanceOf::), DispatchError> + { + let queue_full = q.len() == T::MaxQueueLen::get() as usize; + ensure!(!queue_full || q[0].amount < amount, Error::::BidTooLow); + T::Currency::reserve(&who, amount)?; + + // queue is + let mut bid = GiltBid { amount, who: who.clone() }; + let net = if queue_full { + sp_std::mem::swap(&mut q[0], &mut bid); + T::Currency::unreserve(&bid.who, bid.amount); + (0, amount - bid.amount) + } else { + q.insert(0, bid); + (1, amount) + }; + + let sorted_item_count = q.len().saturating_sub(T::FifoQueueLen::get() as usize); + if sorted_item_count > 1 { + q[0..sorted_item_count].sort_by_key(|x| x.amount); + } + + Ok(net) + })?; + QueueTotals::::mutate(|qs| { + qs.resize(queue_count, (0, Zero::zero())); + qs[queue_index].0 += net.0; + qs[queue_index].1 = qs[queue_index].1.saturating_add(net.1); + }); + Self::deposit_event(Event::BidPlaced(who.clone(), amount, duration)); + + Ok(().into()) + } + + /// Retract a previously placed bid. + /// + /// Origin must be Signed, and the account should have previously issued a still-active bid + /// of `amount` for `duration`. + /// + /// - `amount`: The amount of the previous bid. + /// - `duration`: The duration of the previous bid. + #[pallet::weight(T::WeightInfo::place_bid(T::MaxQueueLen::get()))] + pub fn retract_bid( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + duration: u32, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + + let queue_count = T::QueueCount::get() as usize; + let queue_index = duration.checked_sub(1) + .ok_or(Error::::DurationTooSmall)? as usize; + ensure!(queue_index < queue_count, Error::::DurationTooBig); + + let bid = GiltBid { amount, who }; + let new_len = Queues::::try_mutate(duration, |q| -> Result { + let pos = q.iter().position(|i| i == &bid).ok_or(Error::::NotFound)?; + q.remove(pos); + Ok(q.len() as u32) + })?; + + QueueTotals::::mutate(|qs| { + qs.resize(queue_count, (0, Zero::zero())); + qs[queue_index].0 = new_len; + qs[queue_index].1 = qs[queue_index].1.saturating_sub(bid.amount); + }); + + T::Currency::unreserve(&bid.who, bid.amount); + Self::deposit_event(Event::BidRetracted(bid.who, bid.amount, duration)); + + Ok(().into()) + } + + /// Set target proportion of gilt-funds. + /// + /// Origin must be `AdminOrigin`. + /// + /// - `target`: The target proportion of effective issued funds that should be under gilts + /// at any one time. + #[pallet::weight(T::WeightInfo::set_target())] + pub fn set_target( + origin: OriginFor, + #[pallet::compact] target: Perquintill, + ) -> DispatchResultWithPostInfo { + T::AdminOrigin::ensure_origin(origin)?; + ActiveTotal::::mutate(|totals| totals.target = target); + Ok(().into()) + } + + /// Remove an active but expired gilt. Reserved funds under gilt are freed and balance is + /// adjusted to ensure that the funds grow or shrink to maintain the equivalent proportion + /// of effective total issued funds. + /// + /// Origin must be Signed and the account must be the owner of the gilt of the given index. + /// + /// - `index`: The index of the gilt to be thawed. + #[pallet::weight(T::WeightInfo::thaw())] + pub fn thaw( + origin: OriginFor, + #[pallet::compact] index: ActiveIndex, + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + + // Look for `index` + let gilt = Active::::get(index).ok_or(Error::::Unknown)?; + // If found, check the owner is `who`. + ensure!(gilt.who == who, Error::::NotOwner); + let now = frame_system::Module::::block_number(); + ensure!(now >= gilt.expiry, Error::::NotExpired); + // Remove it + Active::::remove(index); + + // Multiply the proportion it is by the total issued. + let total_issuance = T::Currency::total_issuance(); + ActiveTotal::::mutate(|totals| { + let nongilt_issuance: u128 = total_issuance.saturating_sub(totals.frozen) + .saturated_into(); + let effective_issuance = totals.proportion.left_from_one() + .saturating_reciprocal_mul(nongilt_issuance); + let gilt_value: BalanceOf = (gilt.proportion * effective_issuance).saturated_into(); + + totals.frozen = totals.frozen.saturating_sub(gilt.amount); + totals.proportion = totals.proportion.saturating_sub(gilt.proportion); + + // Remove or mint the additional to the amount using `Deficit`/`Surplus`. + if gilt_value > gilt.amount { + // Unreserve full amount. + T::Currency::unreserve(&gilt.who, gilt.amount); + let amount = gilt_value - gilt.amount; + let deficit = T::Currency::deposit_creating(&gilt.who, amount); + T::Deficit::on_unbalanced(deficit); + } else { + if gilt_value < gilt.amount { + // We take anything reserved beyond the gilt's final value. + let rest = gilt.amount - gilt_value; + // `slash` might seem a little aggressive, but it's the only way to do it + // in case it's locked into the staking system. + let surplus = T::Currency::slash_reserved(&gilt.who, rest).0; + T::Surplus::on_unbalanced(surplus); + } + // Unreserve only its new value (less than the amount reserved). Everything + // should add up, but (defensive) in case it doesn't, unreserve takes lower + // priority over the funds. + let err_amt = T::Currency::unreserve(&gilt.who, gilt_value); + debug_assert!(err_amt.is_zero()); + } + + let e = Event::GiltThawed(index, gilt.who, gilt.amount, gilt_value); + Self::deposit_event(e); + }); + + Ok(().into()) + } + } + + impl Pallet { + /// Attempt to enlarge our gilt-set from bids in order to satisfy our desired target amount + /// of funds frozen into gilts. + pub fn pursue_target(max_bids: u32) -> Weight { + let totals = ActiveTotal::::get(); + if totals.proportion < totals.target { + let missing = totals.target.saturating_sub(totals.proportion); + + let total_issuance = T::Currency::total_issuance(); + let nongilt_issuance: u128 = total_issuance.saturating_sub(totals.frozen) + .saturated_into(); + let effective_issuance = totals.proportion.left_from_one() + .saturating_reciprocal_mul(nongilt_issuance); + let intake: BalanceOf = (missing * effective_issuance).saturated_into(); + + let (bids_taken, queues_hit) = Self::enlarge(intake, max_bids); + let first_from_each_queue = T::WeightInfo::pursue_target_per_queue(queues_hit); + let rest_from_each_queue = T::WeightInfo::pursue_target_per_item(bids_taken) + .saturating_sub(T::WeightInfo::pursue_target_per_item(queues_hit)); + first_from_each_queue + rest_from_each_queue + } else { + T::WeightInfo::pursue_target_noop() + } + } + + /// Freeze additional funds from queue of bids up to `amount`. Use at most `max_bids` + /// from the queue. + /// + /// Return the number of bids taken and the number of distinct queues taken from. + pub fn enlarge( + amount: BalanceOf, + max_bids: u32, + ) -> (u32, u32) { + let total_issuance = T::Currency::total_issuance(); + let mut remaining = amount; + let mut bids_taken = 0; + let mut queues_hit = 0; + let now = frame_system::Module::::block_number(); + + ActiveTotal::::mutate(|totals| { + QueueTotals::::mutate(|qs| { + for duration in (1..=T::QueueCount::get()).rev() { + if qs[duration as usize - 1].0 == 0 { + continue + } + let queue_index = duration as usize - 1; + let expiry = now.saturating_add(T::Period::get().saturating_mul(duration.into())); + Queues::::mutate(duration, |q| { + while let Some(mut bid) = q.pop() { + if remaining < bid.amount { + let overflow = bid.amount - remaining; + bid.amount = remaining; + q.push(GiltBid { amount: overflow, who: bid.who.clone() }); + } + let amount = bid.amount; + // Can never overflow due to block above. + remaining -= amount; + // Should never underflow since it should track the total of the bids + // exactly, but we'll be defensive. + qs[queue_index].1 = qs[queue_index].1.saturating_sub(bid.amount); + + // Now to activate the bid... + let nongilt_issuance: u128 = total_issuance.saturating_sub(totals.frozen) + .saturated_into(); + let effective_issuance = totals.proportion.left_from_one() + .saturating_reciprocal_mul(nongilt_issuance); + let n: u128 = amount.saturated_into(); + let d = effective_issuance; + let proportion = Perquintill::from_rational_approximation(n, d); + let who = bid.who; + let index = totals.index; + totals.frozen += bid.amount; + totals.proportion = totals.proportion.saturating_add(proportion); + totals.index += 1; + let e = Event::GiltIssued(index, expiry, who.clone(), amount); + Self::deposit_event(e); + let gilt = ActiveGilt { amount, proportion, who, expiry }; + Active::::insert(index, gilt); + + bids_taken += 1; + + if remaining.is_zero() || bids_taken == max_bids { + break; + } + } + queues_hit += 1; + qs[queue_index].0 = q.len() as u32; + }); + if remaining.is_zero() || bids_taken == max_bids { + break + } + } + }); + }); + (bids_taken, queues_hit) + } + } +} diff --git a/frame/gilt/src/mock.rs b/frame/gilt/src/mock.rs new file mode 100644 index 0000000000000..701c5c2f6d73b --- /dev/null +++ b/frame/gilt/src/mock.rs @@ -0,0 +1,138 @@ +// This file is part of Substrate. + +// Copyright (C) 2019-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. + +//! Test environment for Gilt pallet. + +use crate as pallet_gilt; + +use frame_support::{ + parameter_types, ord_parameter_types, traits::{OnInitialize, OnFinalize, GenesisBuild}, +}; +use sp_core::H256; +use sp_runtime::{traits::{BlakeTwo256, IdentityLookup}, testing::Header}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Module, Call, Config, Storage, Event}, + Balances: pallet_balances::{Module, Call, Config, Storage, Event}, + Gilt: pallet_gilt::{Module, Call, Config, Storage, Event}, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl frame_system::Config for Test { + type BaseCallFilter = (); + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = Event; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; +} + +parameter_types! { + pub const ExistentialDeposit: u64 = 1; +} + +impl pallet_balances::Config for Test { + type MaxLocks = (); + type Balance = u64; + type DustRemoval = (); + type Event = Event; + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); +} + +parameter_types! { + pub const QueueCount: u32 = 3; + pub const MaxQueueLen: u32 = 3; + pub const FifoQueueLen: u32 = 1; + pub const Period: u64 = 3; + pub const MinFreeze: u64 = 2; + pub const IntakePeriod: u64 = 2; + pub const MaxIntakeBids: u32 = 2; +} +ord_parameter_types! { + pub const One: u64 = 1; +} + +impl pallet_gilt::Config for Test { + type Event = Event; + type Currency = Balances; + type AdminOrigin = frame_system::EnsureSignedBy; + type Deficit = (); + type Surplus = (); + type QueueCount = QueueCount; + type MaxQueueLen = MaxQueueLen; + type FifoQueueLen = FifoQueueLen; + type Period = Period; + type MinFreeze = MinFreeze; + type IntakePeriod = IntakePeriod; + type MaxIntakeBids = MaxIntakeBids; + type WeightInfo = (); +} + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +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)], + }.assimilate_storage(&mut t).unwrap(); + GenesisBuild::::assimilate_storage(&crate::GenesisConfig, &mut t).unwrap(); + t.into() +} + +pub fn run_to_block(n: u64) { + while System::block_number() < n { + Gilt::on_finalize(System::block_number()); + Balances::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()); + Balances::on_initialize(System::block_number()); + Gilt::on_initialize(System::block_number()); + } +} diff --git a/frame/gilt/src/tests.rs b/frame/gilt/src/tests.rs new file mode 100644 index 0000000000000..637a6a8705979 --- /dev/null +++ b/frame/gilt/src/tests.rs @@ -0,0 +1,499 @@ +// This file is part of Substrate. + +// Copyright (C) 2019-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. + +//! Tests for Gilt pallet. + +use super::*; +use crate::{Error, mock::*}; +use frame_support::{assert_ok, assert_noop, dispatch::DispatchError, traits::Currency}; +use sp_arithmetic::Perquintill; +use pallet_balances::Error as BalancesError; + +#[test] +fn basic_setup_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + + for q in 0..3 { + assert!(Queues::::get(q).is_empty()); + } + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 0, + proportion: Perquintill::zero(), + index: 0, + target: Perquintill::zero(), + }); + assert_eq!(QueueTotals::::get(), vec![(0, 0); 3]); + }); +} + +#[test] +fn set_target_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + let e = DispatchError::BadOrigin; + assert_noop!(Gilt::set_target(Origin::signed(2), Perquintill::from_percent(50)), e); + assert_ok!(Gilt::set_target(Origin::signed(1), Perquintill::from_percent(50))); + + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 0, + proportion: Perquintill::zero(), + index: 0, + target: Perquintill::from_percent(50), + }); + }); +} + +#[test] +fn place_bid_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_noop!(Gilt::place_bid(Origin::signed(1), 1, 2), Error::::AmountTooSmall); + assert_noop!(Gilt::place_bid(Origin::signed(1), 101, 2), BalancesError::::InsufficientBalance); + assert_noop!(Gilt::place_bid(Origin::signed(1), 10, 4), Error::::DurationTooBig); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_eq!(Balances::reserved_balance(1), 10); + assert_eq!(Queues::::get(2), vec![GiltBid { amount: 10, who: 1 }]); + assert_eq!(QueueTotals::::get(), vec![(0, 0), (1, 10), (0, 0)]); + }); +} + +#[test] +fn place_bid_queuing_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 20, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 5, 2)); + assert_noop!(Gilt::place_bid(Origin::signed(1), 5, 2), Error::::BidTooLow); + assert_ok!(Gilt::place_bid(Origin::signed(1), 15, 2)); + assert_eq!(Balances::reserved_balance(1), 45); + + assert_ok!(Gilt::place_bid(Origin::signed(1), 25, 2)); + assert_eq!(Balances::reserved_balance(1), 60); + assert_noop!(Gilt::place_bid(Origin::signed(1), 10, 2), Error::::BidTooLow); + assert_eq!(Queues::::get(2), vec![ + GiltBid { amount: 15, who: 1 }, + GiltBid { amount: 25, who: 1 }, + GiltBid { amount: 20, who: 1 }, + ]); + assert_eq!(QueueTotals::::get(), vec![(0, 0), (3, 60), (0, 0)]); + }); +} + +#[test] +fn place_bid_fails_when_queue_full() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(3), 10, 2)); + assert_noop!(Gilt::place_bid(Origin::signed(4), 10, 2), Error::::BidTooLow); + assert_ok!(Gilt::place_bid(Origin::signed(4), 10, 3)); + }); +} + +#[test] +fn multiple_place_bids_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 3)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 10, 2)); + + assert_eq!(Balances::reserved_balance(1), 40); + assert_eq!(Balances::reserved_balance(2), 10); + assert_eq!(Queues::::get(1), vec![ + GiltBid { amount: 10, who: 1 }, + ]); + assert_eq!(Queues::::get(2), vec![ + GiltBid { amount: 10, who: 2 }, + GiltBid { amount: 10, who: 1 }, + GiltBid { amount: 10, who: 1 }, + ]); + assert_eq!(Queues::::get(3), vec![ + GiltBid { amount: 10, who: 1 }, + ]); + assert_eq!(QueueTotals::::get(), vec![(1, 10), (3, 30), (1, 10)]); + }); +} + +#[test] +fn retract_single_item_queue_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::retract_bid(Origin::signed(1), 10, 1)); + + assert_eq!(Balances::reserved_balance(1), 10); + assert_eq!(Queues::::get(1), vec![]); + assert_eq!(Queues::::get(2), vec![ GiltBid { amount: 10, who: 1 } ]); + assert_eq!(QueueTotals::::get(), vec![(0, 0), (1, 10), (0, 0)]); + }); +} + +#[test] +fn retract_with_other_and_duplicate_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 10, 2)); + + assert_ok!(Gilt::retract_bid(Origin::signed(1), 10, 2)); + assert_eq!(Balances::reserved_balance(1), 20); + assert_eq!(Balances::reserved_balance(2), 10); + assert_eq!(Queues::::get(1), vec![ + GiltBid { amount: 10, who: 1 }, + ]); + assert_eq!(Queues::::get(2), vec![ + GiltBid { amount: 10, who: 2 }, + GiltBid { amount: 10, who: 1 }, + ]); + assert_eq!(QueueTotals::::get(), vec![(1, 10), (2, 20), (0, 0)]); + }); +} + +#[test] +fn retract_non_existent_item_fails() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_noop!(Gilt::retract_bid(Origin::signed(1), 10, 1), Error::::NotFound); + assert_ok!(Gilt::place_bid(Origin::signed(1), 10, 1)); + assert_noop!(Gilt::retract_bid(Origin::signed(1), 20, 1), Error::::NotFound); + assert_noop!(Gilt::retract_bid(Origin::signed(1), 10, 2), Error::::NotFound); + assert_noop!(Gilt::retract_bid(Origin::signed(2), 10, 1), Error::::NotFound); + }); +} + +#[test] +fn basic_enlarge_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 40, 2)); + Gilt::enlarge(40, 2); + + // Takes 2/2, then stopped because it reaches its max amount + assert_eq!(Balances::reserved_balance(1), 40); + assert_eq!(Balances::reserved_balance(2), 40); + assert_eq!(Queues::::get(1), vec![ GiltBid { amount: 40, who: 1 } ]); + assert_eq!(Queues::::get(2), vec![]); + assert_eq!(QueueTotals::::get(), vec![(1, 40), (0, 0), (0, 0)]); + + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 40, + proportion: Perquintill::from_percent(10), + index: 1, + target: Perquintill::zero(), + }); + assert_eq!(Active::::get(0).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 2, + expiry: 7, + }); + }); +} + +#[test] +fn enlarge_respects_bids_limit() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 40, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(3), 40, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(4), 40, 3)); + Gilt::enlarge(100, 2); + + // Should have taken 4/3 and 2/2, then stopped because it's only allowed 2. + assert_eq!(Queues::::get(1), vec![ GiltBid { amount: 40, who: 1 } ]); + assert_eq!(Queues::::get(2), vec![ GiltBid { amount: 40, who: 3 } ]); + assert_eq!(Queues::::get(3), vec![]); + assert_eq!(QueueTotals::::get(), vec![(1, 40), (1, 40), (0, 0)]); + + assert_eq!(Active::::get(0).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 4, + expiry: 10, + }); + assert_eq!(Active::::get(1).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 2, + expiry: 7, + }); + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 80, + proportion: Perquintill::from_percent(20), + index: 2, + target: Perquintill::zero(), + }); + }); +} + +#[test] +fn enlarge_respects_amount_limit_and_will_split() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 80, 1)); + Gilt::enlarge(40, 2); + + // Takes 2/2, then stopped because it reaches its max amount + assert_eq!(Queues::::get(1), vec![ GiltBid { amount: 40, who: 1 } ]); + assert_eq!(QueueTotals::::get(), vec![(1, 40), (0, 0), (0, 0)]); + + assert_eq!(Active::::get(0).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 1, + expiry: 4, + }); + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 40, + proportion: Perquintill::from_percent(10), + index: 1, + target: Perquintill::zero(), + }); + }); +} + +#[test] +fn basic_thaw_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 1)); + Gilt::enlarge(40, 1); + run_to_block(3); + assert_noop!(Gilt::thaw(Origin::signed(1), 0), Error::::NotExpired); + run_to_block(4); + assert_noop!(Gilt::thaw(Origin::signed(1), 1), Error::::Unknown); + assert_noop!(Gilt::thaw(Origin::signed(2), 0), Error::::NotOwner); + assert_ok!(Gilt::thaw(Origin::signed(1), 0)); + + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 0, + proportion: Perquintill::zero(), + index: 1, + target: Perquintill::zero(), + }); + assert_eq!(Active::::get(0), None); + assert_eq!(Balances::free_balance(1), 100); + assert_eq!(Balances::reserved_balance(1), 0); + }); +} + +#[test] +fn thaw_when_issuance_higher_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 100, 1)); + Gilt::enlarge(100, 1); + + // Everybody else's balances goes up by 50% + Balances::make_free_balance_be(&2, 150); + Balances::make_free_balance_be(&3, 150); + Balances::make_free_balance_be(&4, 150); + + run_to_block(4); + assert_ok!(Gilt::thaw(Origin::signed(1), 0)); + + assert_eq!(Balances::free_balance(1), 150); + assert_eq!(Balances::reserved_balance(1), 0); + }); +} + +#[test] +fn thaw_when_issuance_lower_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 100, 1)); + Gilt::enlarge(100, 1); + + // Everybody else's balances goes down by 25% + Balances::make_free_balance_be(&2, 75); + Balances::make_free_balance_be(&3, 75); + Balances::make_free_balance_be(&4, 75); + + run_to_block(4); + assert_ok!(Gilt::thaw(Origin::signed(1), 0)); + + assert_eq!(Balances::free_balance(1), 75); + assert_eq!(Balances::reserved_balance(1), 0); + }); +} + +#[test] +fn multiple_thaws_works() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 60, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 50, 1)); + Gilt::enlarge(200, 3); + + // Double everyone's free balances. + Balances::make_free_balance_be(&2, 100); + Balances::make_free_balance_be(&3, 200); + Balances::make_free_balance_be(&4, 200); + + run_to_block(4); + assert_ok!(Gilt::thaw(Origin::signed(1), 0)); + assert_ok!(Gilt::thaw(Origin::signed(1), 1)); + assert_ok!(Gilt::thaw(Origin::signed(2), 2)); + + assert_eq!(Balances::free_balance(1), 200); + assert_eq!(Balances::free_balance(2), 200); + }); +} + +#[test] +fn multiple_thaws_works_in_alternative_thaw_order() { + new_test_ext().execute_with(|| { + run_to_block(1); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 60, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 50, 1)); + Gilt::enlarge(200, 3); + + // Double everyone's free balances. + Balances::make_free_balance_be(&2, 100); + Balances::make_free_balance_be(&3, 200); + Balances::make_free_balance_be(&4, 200); + + run_to_block(4); + assert_ok!(Gilt::thaw(Origin::signed(2), 2)); + assert_ok!(Gilt::thaw(Origin::signed(1), 1)); + assert_ok!(Gilt::thaw(Origin::signed(1), 0)); + + assert_eq!(Balances::free_balance(1), 200); + assert_eq!(Balances::free_balance(2), 200); + }); +} + +#[test] +fn enlargement_to_target_works() { + new_test_ext().execute_with(|| { + run_to_block(2); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 1)); + assert_ok!(Gilt::place_bid(Origin::signed(1), 40, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 40, 2)); + assert_ok!(Gilt::place_bid(Origin::signed(2), 40, 3)); + assert_ok!(Gilt::place_bid(Origin::signed(3), 40, 3)); + assert_ok!(Gilt::set_target(Origin::signed(1), Perquintill::from_percent(40))); + + run_to_block(3); + assert_eq!(Queues::::get(1), vec![ + GiltBid { amount: 40, who: 1 }, + ]); + assert_eq!(Queues::::get(2), vec![ + GiltBid { amount: 40, who: 2 }, + GiltBid { amount: 40, who: 1 }, + ]); + assert_eq!(Queues::::get(3), vec![ + GiltBid { amount: 40, who: 3 }, + GiltBid { amount: 40, who: 2 }, + ]); + assert_eq!(QueueTotals::::get(), vec![(1, 40), (2, 80), (2, 80)]); + + run_to_block(4); + // Two new gilts should have been issued to 2 & 3 for 40 each & duration of 3. + assert_eq!(Active::::get(0).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 2, + expiry: 13, + }); + assert_eq!(Active::::get(1).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 3, + expiry: 13, + + }); + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 80, + proportion: Perquintill::from_percent(20), + index: 2, + target: Perquintill::from_percent(40), + }); + + run_to_block(5); + // No change + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 80, + proportion: Perquintill::from_percent(20), + index: 2, + target: Perquintill::from_percent(40), + }); + + run_to_block(6); + // Two new gilts should have been issued to 1 & 2 for 40 each & duration of 2. + assert_eq!(Active::::get(2).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 1, + expiry: 12, + }); + assert_eq!(Active::::get(3).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 2, + expiry: 12, + + }); + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 160, + proportion: Perquintill::from_percent(40), + index: 4, + target: Perquintill::from_percent(40), + }); + + run_to_block(8); + // No change now. + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 160, + proportion: Perquintill::from_percent(40), + index: 4, + target: Perquintill::from_percent(40), + }); + + // Set target a bit higher to use up the remaining bid. + assert_ok!(Gilt::set_target(Origin::signed(1), Perquintill::from_percent(60))); + run_to_block(10); + + // Two new gilts should have been issued to 1 & 2 for 40 each & duration of 2. + assert_eq!(Active::::get(4).unwrap(), ActiveGilt { + proportion: Perquintill::from_percent(10), + amount: 40, + who: 1, + expiry: 13, + }); + + assert_eq!(ActiveTotal::::get(), ActiveGiltsTotal { + frozen: 200, + proportion: Perquintill::from_percent(50), + index: 5, + target: Perquintill::from_percent(60), + }); + }); +} diff --git a/frame/gilt/src/weights.rs b/frame/gilt/src/weights.rs new file mode 100644 index 0000000000000..f202ae47ff632 --- /dev/null +++ b/frame/gilt/src/weights.rs @@ -0,0 +1,164 @@ +// 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_gilt +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 3.0.0 +//! DATE: 2021-02-23, 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_gilt +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./frame/gilt/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_gilt. +pub trait WeightInfo { + fn place_bid(l: u32, ) -> Weight; + fn place_bid_max() -> Weight; + fn retract_bid(l: u32, ) -> Weight; + fn set_target() -> Weight; + fn thaw() -> Weight; + fn pursue_target_noop() -> Weight; + fn pursue_target_per_item(b: u32, ) -> Weight; + fn pursue_target_per_queue(q: u32, ) -> Weight; +} + +/// Weights for pallet_gilt using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + fn place_bid(l: u32, ) -> Weight { + (79_274_000 as Weight) + // Standard Error: 0 + .saturating_add((289_000 as Weight).saturating_mul(l as Weight)) + .saturating_add(T::DbWeight::get().reads(2 as Weight)) + .saturating_add(T::DbWeight::get().writes(2 as Weight)) + } + fn place_bid_max() -> Weight { + (297_825_000 as Weight) + .saturating_add(T::DbWeight::get().reads(2 as Weight)) + .saturating_add(T::DbWeight::get().writes(2 as Weight)) + } + fn retract_bid(l: u32, ) -> Weight { + (79_731_000 as Weight) + // Standard Error: 0 + .saturating_add((231_000 as Weight).saturating_mul(l as Weight)) + .saturating_add(T::DbWeight::get().reads(2 as Weight)) + .saturating_add(T::DbWeight::get().writes(2 as Weight)) + } + fn set_target() -> Weight { + (6_113_000 as Weight) + .saturating_add(T::DbWeight::get().reads(1 as Weight)) + .saturating_add(T::DbWeight::get().writes(1 as Weight)) + } + fn thaw() -> Weight { + (74_792_000 as Weight) + .saturating_add(T::DbWeight::get().reads(2 as Weight)) + .saturating_add(T::DbWeight::get().writes(2 as Weight)) + } + fn pursue_target_noop() -> Weight { + (3_468_000 as Weight) + .saturating_add(T::DbWeight::get().reads(1 as Weight)) + } + fn pursue_target_per_item(b: u32, ) -> Weight { + (65_792_000 as Weight) + // Standard Error: 2_000 + .saturating_add((11_402_000 as Weight).saturating_mul(b as Weight)) + .saturating_add(T::DbWeight::get().reads(3 as Weight)) + .saturating_add(T::DbWeight::get().writes(3 as Weight)) + .saturating_add(T::DbWeight::get().writes((1 as Weight).saturating_mul(b as Weight))) + } + fn pursue_target_per_queue(q: u32, ) -> Weight { + (32_391_000 as Weight) + // Standard Error: 7_000 + .saturating_add((18_500_000 as Weight).saturating_mul(q as Weight)) + .saturating_add(T::DbWeight::get().reads(2 as Weight)) + .saturating_add(T::DbWeight::get().reads((1 as Weight).saturating_mul(q as Weight))) + .saturating_add(T::DbWeight::get().writes(2 as Weight)) + .saturating_add(T::DbWeight::get().writes((2 as Weight).saturating_mul(q as Weight))) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + fn place_bid(l: u32, ) -> Weight { + (79_274_000 as Weight) + // Standard Error: 0 + .saturating_add((289_000 as Weight).saturating_mul(l as Weight)) + .saturating_add(RocksDbWeight::get().reads(2 as Weight)) + .saturating_add(RocksDbWeight::get().writes(2 as Weight)) + } + fn place_bid_max() -> Weight { + (297_825_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(2 as Weight)) + .saturating_add(RocksDbWeight::get().writes(2 as Weight)) + } + fn retract_bid(l: u32, ) -> Weight { + (79_731_000 as Weight) + // Standard Error: 0 + .saturating_add((231_000 as Weight).saturating_mul(l as Weight)) + .saturating_add(RocksDbWeight::get().reads(2 as Weight)) + .saturating_add(RocksDbWeight::get().writes(2 as Weight)) + } + fn set_target() -> Weight { + (6_113_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(1 as Weight)) + .saturating_add(RocksDbWeight::get().writes(1 as Weight)) + } + fn thaw() -> Weight { + (74_792_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(2 as Weight)) + .saturating_add(RocksDbWeight::get().writes(2 as Weight)) + } + fn pursue_target_noop() -> Weight { + (3_468_000 as Weight) + .saturating_add(RocksDbWeight::get().reads(1 as Weight)) + } + fn pursue_target_per_item(b: u32, ) -> Weight { + (65_792_000 as Weight) + // Standard Error: 2_000 + .saturating_add((11_402_000 as Weight).saturating_mul(b as Weight)) + .saturating_add(RocksDbWeight::get().reads(3 as Weight)) + .saturating_add(RocksDbWeight::get().writes(3 as Weight)) + .saturating_add(RocksDbWeight::get().writes((1 as Weight).saturating_mul(b as Weight))) + } + fn pursue_target_per_queue(q: u32, ) -> Weight { + (32_391_000 as Weight) + // Standard Error: 7_000 + .saturating_add((18_500_000 as Weight).saturating_mul(q as Weight)) + .saturating_add(RocksDbWeight::get().reads(2 as Weight)) + .saturating_add(RocksDbWeight::get().reads((1 as Weight).saturating_mul(q as Weight))) + .saturating_add(RocksDbWeight::get().writes(2 as Weight)) + .saturating_add(RocksDbWeight::get().writes((2 as Weight).saturating_mul(q as Weight))) + } +} diff --git a/primitives/arithmetic/src/per_things.rs b/primitives/arithmetic/src/per_things.rs index caaa4c33cd431..319666747b15d 100644 --- a/primitives/arithmetic/src/per_things.rs +++ b/primitives/arithmetic/src/per_things.rs @@ -80,6 +80,11 @@ pub trait PerThing: Self::from_rational_approximation::(p * p, q * q) } + /// Return the part left when `self` is saturating-subtracted from `Self::one()`. + fn left_from_one(self) -> Self { + Self::one().saturating_sub(self) + } + /// Multiplication that always rounds down to a whole number. The standard `Mul` rounds to the /// nearest whole number. ///