diff --git a/Cargo.toml b/Cargo.toml index 18465ec..7c4cabd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,9 +23,11 @@ members = [ "frame/cosmos/x/wasm", "frame/cosmos/x/wasm/types", "frame/multimap", + "frame/rewards", "frame/solana", "frame/solana/runtime-api", "frame/wtema", + "primitives/arithmetic", "primitives/babel", "primitives/consensus", # dummy "primitives/consensus/pow", @@ -33,6 +35,7 @@ members = [ "primitives/ethereum", "primitives/multimap", "primitives/nostr", + "primitives/rewards", "primitives/runtime", "primitives/solana", "runtime/common", @@ -159,6 +162,7 @@ wat = "1.0" # substrate sc-client-api = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2409" } sc-consensus = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2409" } +frame-benchmarking = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2409", default-features = false } frame-support = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2409", default-features = false } frame-system = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2409", default-features = false } pallet-assets = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2409", default-features = false } @@ -198,12 +202,14 @@ nc-consensus = { path = "client/consensus" } nc-consensus-pow = { path = "client/consensus/pow" } noir-core-primitives = { path = "core-primitives", default-features = false } noir-runtime-common = { path = "runtime/common", default-features = false } +np-arithmetic = { path = "primitives/arithmetic", default-features = false } np-babel = { path = "primitives/babel", default-features = false } np-consensus-pow = { path = "primitives/consensus/pow", default-features = false } np-cosmos = { path = "primitives/cosmos", default-features = false } np-ethereum = { path = "primitives/ethereum", default-features = false } np-multimap = { path = "primitives/multimap", default-features = false } np-nostr = { path = "primitives/nostr", default-features = false } +np-rewards = { path = "primitives/rewards", default-features = false } np-runtime = { path = "primitives/runtime", default-features = false } np-solana = { path = "primitives/solana", default-features = false } pallet-cosmos = { path = "frame/cosmos", default-features = false } @@ -216,6 +222,7 @@ pallet-cosmos-x-bank-types = { path = "frame/cosmos/x/bank/types", default-featu pallet-cosmos-x-wasm = { path = "frame/cosmos/x/wasm", default-features = false } pallet-cosmos-x-wasm-types = { path = "frame/cosmos/x/wasm/types", default-features = false } pallet-multimap = { path = "frame/multimap", default-features = false } +pallet-rewards = { path = "frame/rewards", default-features = false } pallet-solana = { path = "frame/solana", default-features = false } pallet-wtema = { path = "frame/wtema", default-features = false } diff --git a/frame/rewards/Cargo.toml b/frame/rewards/Cargo.toml new file mode 100644 index 0000000..f84daa5 --- /dev/null +++ b/frame/rewards/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "pallet-rewards" +description = "FRAME rewards for block reward distribution" +license = "GPL-3.0-or-later" +authors = { workspace = true } +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +publish = false + +[dependencies] +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +np-arithmetic = { workspace = true } +np-rewards = { workspace = true } +parity-scale-codec = { workspace = true, features = ["derive"] } +scale-info = { workspace = true, features = ["derive"] } +sp-inherents = { workspace = true } + +[dev-dependencies] +pallet-balances = { workspace = true, default-features = true } +sp-io = { workspace = true, default-features = true } + +[features] +default = ["std"] +std = [ + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "np-arithmetic/std", + "np-rewards/std", + "parity-scale-codec/std", + "scale-info/std", + "sp-inherents/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", +] diff --git a/frame/rewards/src/benchmarking.rs b/frame/rewards/src/benchmarking.rs new file mode 100644 index 0000000..29440a3 --- /dev/null +++ b/frame/rewards/src/benchmarking.rs @@ -0,0 +1,83 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; +use frame_benchmarking::v2::*; +use frame_support::{ + sp_runtime::traits::{Get, One}, + traits::Hooks, +}; +use frame_system::RawOrigin; + +const SEED: u32 = 0; + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn coinbase(n: Linear<1, { T::MaxRewardSplits::get() }>) -> Result<(), BenchmarkError> { + type System = frame_system::Pallet; + + let mut reward = T::EmissionSchedule::get(); + let mut rewards: Vec<(T::AccountId, BalanceOf)> = Vec::new(); + let payout = T::MinPayout::get(); + for index in 1..n { + reward -= payout; + rewards.push((account("miner", index, SEED), payout)); + } + let payout = reward; + rewards.push((account("miner", n, SEED), reward)); + + #[extrinsic_call] + _(RawOrigin::None, rewards.clone()); + + assert_eq!(Processed::::get(), true); + assert_eq!(Rewards::::get(System::::block_number()), rewards); + assert_eq!(RewardLocks::::get(account::("miner", n, SEED)), Some(payout)); + + Ok(()) + } + + #[benchmark] + fn on_finalize(n: Linear<1, { T::MaxRewardSplits::get() }>) { + type Rewards = Pallet; + type System = frame_system::Pallet; + + let number = System::::block_number() + One::one(); + let mut reward = T::EmissionSchedule::get(); + let mut rewards: Vec<(T::AccountId, BalanceOf)> = Vec::new(); + let payout = T::MinPayout::get(); + for index in 1..n { + reward -= payout; + rewards.push((account("miner", index, SEED), payout)); + } + rewards.push((account("miner", n, SEED), reward)); + Rewards::::insert_coinbase(number, rewards); + + #[block] + { + Rewards::::on_finalize(number); + Rewards::::on_initialize(number + T::MaturationTime::get()); + } + } + + impl_benchmark_test_suite!(Rewards, crate::mock::new_test_ext(), crate::mock::Text,); +} diff --git a/frame/rewards/src/lib.rs b/frame/rewards/src/lib.rs new file mode 100644 index 0000000..7511258 --- /dev/null +++ b/frame/rewards/src/lib.rs @@ -0,0 +1,296 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Rewards pallet for block reward distribution. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +mod benchmarking; +mod mock; +mod tests; +pub mod weights; +pub use weights::WeightInfo; + +pub use pallet::*; + +use alloc::collections::BTreeMap; +use frame_support::traits::{Currency, LockIdentifier, LockableCurrency, WithdrawReasons}; +use np_arithmetic::traits::{AtLeast32BitUnsigned, CheckedAdd, SaturatingMulDiv, Zero}; +use np_rewards::{split_reward, InherentError, InherentType, INHERENT_IDENTIFIER}; +use parity_scale_codec::FullCodec; +use sp_inherents::{InherentData, InherentIdentifier}; + +const LOCK_IDENTIFIER: LockIdentifier = *b"rewards_"; + +pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use alloc::vec::Vec; + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Coin emission schedule. + type EmissionSchedule: Get>; + + /// Currency type of this pallet. + type Currency: LockableCurrency; + + /// Minimum payout amount. + /// + /// This must be greater or equal than existential deposit. + type MinPayout: Get>; + + /// Required period that newly minted coins become spendable. + #[pallet::constant] + type MaturationTime: Get>; + + /// Maximum number of reward splits. + #[pallet::constant] + type MaxRewardSplits: Get; + + /// Miner's contribution to generate the proof of the block. + type Share: FullCodec + Copy + AtLeast32BitUnsigned; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + } + + #[pallet::error] + pub enum Error { + /// Invalid reward amount. + InvalidReward, + /// Coinbase contains too many reward splits. + TooManyRewardSplits, + } + + #[pallet::storage] + pub type Processed = StorageValue<_, bool, ValueQuery>; + + #[pallet::storage] + #[pallet::getter(fn rewards)] + pub type Rewards = StorageMap< + _, + Twox64Concat, + BlockNumberFor, + BoundedVec<(T::AccountId, BalanceOf), T::MaxRewardSplits>, + ValueQuery, + >; + + #[pallet::storage] + #[pallet::getter(fn reward_locks)] + pub type RewardLocks = StorageMap<_, Blake2_128Concat, T::AccountId, BalanceOf>; + + #[pallet::call] + impl Pallet { + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::coinbase(rewards.len() as u32))] + pub fn coinbase( + origin: OriginFor, + rewards: Vec<(T::AccountId, BalanceOf)>, + ) -> DispatchResult { + ensure_none(origin)?; + ensure!(!Processed::::exists(), "multiple coinbase not allowed"); + ensure!( + rewards.len() <= T::MaxRewardSplits::get() as usize, + Error::::TooManyRewardSplits + ); + + let reward = T::EmissionSchedule::get(); + let mut reward_given = BalanceOf::::zero(); + for (dest, value) in &rewards { + drop(T::Currency::deposit_creating(dest, *value)); + reward_given += *value; + + RewardLocks::::mutate(dest, |lock| { + let new_lock = match lock.take() { + Some(lock) => lock + *value, + None => *value, + }; + T::Currency::set_lock( + LOCK_IDENTIFIER, + dest, + new_lock, + WithdrawReasons::except(WithdrawReasons::TRANSACTION_PAYMENT), + ); + *lock = Some(new_lock); + }); + } + ensure!(reward_given == reward, Error::::InvalidReward); + + Rewards::::insert( + frame_system::Pallet::::block_number(), + BoundedVec::<_, T::MaxRewardSplits>::try_from(rewards).unwrap(), + ); + + Processed::::put(true); + Ok(()) + } + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(number: BlockNumberFor) -> Weight { + let reward_splits = if number > T::MaturationTime::get() { + { + let unlocked_number = number - T::MaturationTime::get(); + + let rewards = Rewards::::take(unlocked_number); + let reward_splits = rewards.len() as u64; + + for (dest, value) in rewards { + RewardLocks::::mutate(&dest, |lock| { + let locked = lock.unwrap(); + if locked > value { + T::Currency::set_lock( + LOCK_IDENTIFIER, + &dest, + locked - value, + WithdrawReasons::except(WithdrawReasons::TRANSACTION_PAYMENT), + ); + *lock = Some(locked - value); + } else { + T::Currency::remove_lock(LOCK_IDENTIFIER, &dest); + *lock = None; + } + }); + } + reward_splits + } + } else { + 0 + }; + + T::WeightInfo::on_finalize(reward_splits as u32) + } + + fn on_finalize(_: BlockNumberFor) { + assert!(Processed::::take(), "coinbase must be processed"); + } + } + + #[pallet::inherent] + impl ProvideInherent for Pallet + where + BalanceOf: SaturatingMulDiv, + { + type Call = Call; + type Error = InherentError; + const INHERENT_IDENTIFIER: InherentIdentifier = INHERENT_IDENTIFIER; + + fn create_inherent(data: &InherentData) -> Option { + let mut shares = data + .get_data::>(&INHERENT_IDENTIFIER) + .expect("Rewards inherent data not correctly encoded") + .expect("Rewards inherent data must be provided"); + + let reward = T::EmissionSchedule::get(); + + // Prune zero shares and ensure the number of shares is within the limit. + shares.retain(|_, share| !share.is_zero()); + if shares.is_empty() || shares.len() > T::MaxRewardSplits::get() as usize { + return None; + } + + let mut rewards = split_reward(reward, shares.clone())?; + + // Prune rewards that are below the minimum payout. + rewards.retain(|(_, value)| *value >= T::MinPayout::get()); + + let rewards = if rewards.len() != shares.len() { + // Redistribute the reward to the remaining accounts. + shares.retain(|acc, _| rewards.iter().any(|(a, _)| a == acc)); + split_reward(reward, shares)? + } else { + rewards + }; + + if rewards + .iter() + .try_fold(BalanceOf::::zero(), |sum, (_, value)| sum.checked_add(value))? != + reward + { + return None + } + + Some(Call::coinbase { rewards }) + } + + fn check_inherent(call: &Self::Call, _data: &InherentData) -> Result<(), Self::Error> { + if let Call::coinbase { rewards } = call { + let expected_reward = T::EmissionSchedule::get(); + let reward = rewards + .iter() + .try_fold(BalanceOf::::zero(), |sum, (_, value)| sum.checked_add(value)) + .ok_or(InherentError::InvalidReward)?; + if reward != expected_reward { + return Err(InherentError::InvalidReward); + } + if BTreeMap::from_iter(rewards.iter().cloned()).len() != rewards.len() { + return Err(InherentError::DuplicateBeneficiary); + } + } + + Ok(()) + } + + fn is_inherent(call: &Self::Call) -> bool { + matches!(call, Call::coinbase { .. }) + } + } + + impl Pallet { + /// Pushes the coinbase rewards. Only use for tests. + #[cfg(any(feature = "runtime-benchmarks", feature = "std"))] + pub fn insert_coinbase( + number: BlockNumberFor, + rewards: Vec<(T::AccountId, BalanceOf)>, + ) { + for (dest, value) in &rewards { + drop(T::Currency::deposit_creating(dest, *value)); + RewardLocks::::mutate(dest, |lock| { + let new_lock = match lock.take() { + Some(lock) => lock + *value, + None => *value, + }; + T::Currency::set_lock( + LOCK_IDENTIFIER, + dest, + new_lock, + WithdrawReasons::except(WithdrawReasons::TRANSACTION_PAYMENT), + ); + *lock = Some(new_lock); + }); + } + Rewards::::insert( + number, + BoundedVec::<_, T::MaxRewardSplits>::try_from(rewards).unwrap(), + ); + Processed::::put(true); + } + } +} diff --git a/frame/rewards/src/mock.rs b/frame/rewards/src/mock.rs new file mode 100644 index 0000000..7f57f11 --- /dev/null +++ b/frame/rewards/src/mock.rs @@ -0,0 +1,81 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#![cfg(test)] + +use crate as pallet_rewards; + +use super::*; +use frame_support::{ + derive_impl, + sp_runtime::BuildStorage, + traits::{ConstU32, ConstU64}, +}; +use sp_io::TestExternalities; + +#[frame_support::runtime] +mod runtime { + #[runtime::runtime] + #[runtime::derive( + RuntimeCall, + RuntimeEvent, + RuntimeError, + RuntimeHoldReason, + RuntimeFreezeReason, + RuntimeOrigin, + RuntimeTask + )] + pub struct Test; + + #[runtime::pallet_index(0)] + pub type System = frame_system; + + #[runtime::pallet_index(1)] + pub type Rewards = pallet_rewards; + + #[runtime::pallet_index(2)] + pub type Balances = pallet_balances; +} + +type Block = frame_system::mocking::MockBlock; + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type AccountData = pallet_balances::AccountData; +} + +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] +impl pallet_balances::Config for Test { + type AccountStore = System; +} + +impl Config for Test { + type EmissionSchedule = ConstU64<100>; + type Currency = Balances; + type MinPayout = ConstU64<1>; + type MaturationTime = ConstU64<1>; + type MaxRewardSplits = ConstU32<100>; + type Share = u128; + type WeightInfo = (); +} + +pub fn new_test_ext() -> TestExternalities { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + TestExternalities::new(t) +} diff --git a/frame/rewards/src/tests.rs b/frame/rewards/src/tests.rs new file mode 100644 index 0000000..1f0a9d0 --- /dev/null +++ b/frame/rewards/src/tests.rs @@ -0,0 +1,42 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#![cfg(test)] + +use super::{mock::*, Error}; +use frame_support::{assert_noop, assert_ok}; + +#[test] +fn coinbase_should_work() { + new_test_ext().execute_with(|| { + assert_noop!( + Rewards::coinbase(RuntimeOrigin::none(), vec![(0, 80), (1, 21)]), + Error::::InvalidReward + ); + assert_ok!(Rewards::coinbase(RuntimeOrigin::none(), vec![(0, 80), (1, 20)])); + }); +} + +#[test] +#[should_panic(expected = "multiple coinbase not allowed")] +fn multiple_coinbase_should_fail() { + new_test_ext().execute_with(|| { + Rewards::insert_coinbase(0, vec![(0, 100)]); + assert_ok!(Rewards::coinbase(RuntimeOrigin::none(), vec![(0, 100)])); + }); +} diff --git a/frame/rewards/src/weights.rs b/frame/rewards/src/weights.rs new file mode 100644 index 0000000..8be4b92 --- /dev/null +++ b/frame/rewards/src/weights.rs @@ -0,0 +1,173 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Autogenerated weights for `pallet_rewards` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 +//! DATE: 2024-04-18, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `benchmarks`, CPU: `AMD Ryzen 9 7950X 16-Core Processor` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 + +// Executed Command: +// ./target/release/noir +// benchmark +// pallet +// --pallet +// pallet_rewards +// --extrinsic +// * +// --output +// weights.rs +// --default-pov-mode +// measured + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] + +use core::marker::PhantomData; +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; + +pub trait WeightInfo { + fn coinbase(n: u32) -> Weight; + fn on_finalize(n: u32) -> Weight; +} + +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight +where + T: frame_system::Config, +{ + /// Storage: `Rewards::Processed` (r:1 w:1) + /// Proof: `Rewards::Processed` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `Measured`) + /// Storage: `System::Account` (r:8192 w:8192) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `Measured`) + /// Storage: `Rewards::RewardLocks` (r:8192 w:8192) + /// Proof: `Rewards::RewardLocks` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `Measured`) + /// Storage: `Balances::Locks` (r:8192 w:8192) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `Measured`) + /// Storage: `Balances::Freezes` (r:8192 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(185), added: 2660, mode: `Measured`) + /// Storage: `Rewards::Rewards` (r:0 w:1) + /// Proof: `Rewards::Rewards` (`max_values`: None, `max_size`: Some(393230), added: 395705, mode: `Measured`) + /// The range of component `n` is `[1, 8192]`. + fn coinbase(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `6` + // Estimated: `1491 + n * (2475 ±0)` + // Minimum execution time: 34_385_000 picoseconds. + Weight::from_parts(34_775_000, 0) + .saturating_add(Weight::from_parts(0, 1491)) + // Standard Error: 29_018 + .saturating_add(Weight::from_parts(26_542_025, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().reads((4_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(2)) + .saturating_add(T::DbWeight::get().writes((3_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 2475).saturating_mul(n.into())) + } + /// Storage: `Rewards::Processed` (r:1 w:1) + /// Proof: `Rewards::Processed` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `Measured`) + /// Storage: `Rewards::Rewards` (r:1 w:1) + /// Proof: `Rewards::Rewards` (`max_values`: None, `max_size`: Some(393230), added: 395705, mode: `Measured`) + /// Storage: `Rewards::RewardLocks` (r:8192 w:8192) + /// Proof: `Rewards::RewardLocks` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `Measured`) + /// Storage: `Balances::Locks` (r:8192 w:8192) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `Measured`) + /// Storage: `Balances::Freezes` (r:8192 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(185), added: 2660, mode: `Measured`) + /// Storage: `System::Account` (r:8192 w:8192) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `Measured`) + /// The range of component `n` is `[1, 8192]`. + fn on_finalize(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `144 + n * (333 ±0)` + // Estimated: `3670 + n * (2809 ±0)` + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(21_420_000, 0) + .saturating_add(Weight::from_parts(0, 3670)) + // Standard Error: 30_046 + .saturating_add(Weight::from_parts(22_389_152, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().reads((4_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(2)) + .saturating_add(T::DbWeight::get().writes((3_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 2809).saturating_mul(n.into())) + } +} + +impl WeightInfo for () { + /// Storage: `Rewards::Processed` (r:1 w:1) + /// Proof: `Rewards::Processed` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `Measured`) + /// Storage: `System::Account` (r:8192 w:8192) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `Measured`) + /// Storage: `Rewards::RewardLocks` (r:8192 w:8192) + /// Proof: `Rewards::RewardLocks` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `Measured`) + /// Storage: `Balances::Locks` (r:8192 w:8192) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `Measured`) + /// Storage: `Balances::Freezes` (r:8192 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(185), added: 2660, mode: `Measured`) + /// Storage: `Rewards::Rewards` (r:0 w:1) + /// Proof: `Rewards::Rewards` (`max_values`: None, `max_size`: Some(393230), added: 395705, mode: `Measured`) + /// The range of component `n` is `[1, 8192]`. + fn coinbase(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `6` + // Estimated: `1491 + n * (2475 ±0)` + // Minimum execution time: 34_385_000 picoseconds. + Weight::from_parts(34_775_000, 0) + .saturating_add(Weight::from_parts(0, 1491)) + // Standard Error: 29_018 + .saturating_add(Weight::from_parts(26_542_025, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(1)) + .saturating_add(RocksDbWeight::get().reads((4_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(2)) + .saturating_add(RocksDbWeight::get().writes((3_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 2475).saturating_mul(n.into())) + } + /// Storage: `Rewards::Processed` (r:1 w:1) + /// Proof: `Rewards::Processed` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `Measured`) + /// Storage: `Rewards::Rewards` (r:1 w:1) + /// Proof: `Rewards::Rewards` (`max_values`: None, `max_size`: Some(393230), added: 395705, mode: `Measured`) + /// Storage: `Rewards::RewardLocks` (r:8192 w:8192) + /// Proof: `Rewards::RewardLocks` (`max_values`: None, `max_size`: Some(64), added: 2539, mode: `Measured`) + /// Storage: `Balances::Locks` (r:8192 w:8192) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `Measured`) + /// Storage: `Balances::Freezes` (r:8192 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(185), added: 2660, mode: `Measured`) + /// Storage: `System::Account` (r:8192 w:8192) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `Measured`) + /// The range of component `n` is `[1, 8192]`. + fn on_finalize(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `144 + n * (333 ±0)` + // Estimated: `3670 + n * (2809 ±0)` + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(21_420_000, 0) + .saturating_add(Weight::from_parts(0, 3670)) + // Standard Error: 30_046 + .saturating_add(Weight::from_parts(22_389_152, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(2)) + .saturating_add(RocksDbWeight::get().reads((4_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(2)) + .saturating_add(RocksDbWeight::get().writes((3_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 2809).saturating_mul(n.into())) + } +} diff --git a/primitives/arithmetic/Cargo.toml b/primitives/arithmetic/Cargo.toml new file mode 100644 index 0000000..ca93b79 --- /dev/null +++ b/primitives/arithmetic/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "np-arithmetic" +description = "Noir arithmetic primitives" +license = "Apache-2.0" +authors = { workspace = true } +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +publish = false + +[dependencies] +sp-arithmetic = { workspace = true } +sp-core = { workspace = true } + +[features] +default = ["std"] +std = [ + "sp-arithmetic/std", + "sp-core/std", +] diff --git a/primitives/arithmetic/src/lib.rs b/primitives/arithmetic/src/lib.rs new file mode 100644 index 0000000..83dec6e --- /dev/null +++ b/primitives/arithmetic/src/lib.rs @@ -0,0 +1,23 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. 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. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use sp_arithmetic::*; +pub use sp_core::{U256, U512}; + +pub mod traits; diff --git a/primitives/arithmetic/src/traits.rs b/primitives/arithmetic/src/traits.rs new file mode 100644 index 0000000..48dee92 --- /dev/null +++ b/primitives/arithmetic/src/traits.rs @@ -0,0 +1,180 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. 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. + +pub use sp_arithmetic::traits::*; + +use core::ops::{Div, Mul}; +use sp_core::{U256, U512}; + +/// Trait for multiplication and division with saturating. +pub trait SaturatingMulDiv { + /// Calculates `(self * num) / denom` with saturating. + fn saturating_mul_div(self, num: Rhs, denom: Rhs) -> Self; +} + +type Promoted = >::Output; +type WidenPromoted = as Widen>::Output; + +impl SaturatingMulDiv for T +where + T: Promotion + UpperBounded, + Promoted: Widen, + WidenPromoted: + Mul> + Div>, +{ + fn saturating_mul_div(self, num: Rhs, denom: Rhs) -> Self { + let this = >::from(self).widen(); + let num = >::from(num).widen(); + let denom = >::from(denom).widen(); + + match (this * num / denom).try_into() { + Ok(v) => v.try_into().unwrap_or(T::max_value()), + Err(_) => T::max_value(), + } + } +} + +/// Workaround for the lack of impl Bounded for U256. +trait UpperBounded { + fn max_value() -> Self; +} + +macro_rules! impl_upper_bounded { + ($t:ty) => { + impl UpperBounded for $t { + fn max_value() -> Self { + <$t>::MAX + } + } + }; +} + +impl_upper_bounded!(u8); +impl_upper_bounded!(u16); +impl_upper_bounded!(u32); +impl_upper_bounded!(u64); +impl_upper_bounded!(u128); +impl_upper_bounded!(U256); + +/// Trait for promoting type to larger one between two types. +trait Promotion: Sized { + type Output: From + From + TryInto; +} + +impl Promotion for T { + type Output = T; +} + +macro_rules! impl_promotion { + ($t:ty, $u:ty, $v: ty) => { + impl Promotion<$u> for $t { + type Output = $v; + } + }; + ($t:ty, {$($u:ty),*}, {$($v:ty),*}) => { + $(impl_promotion!($t, $u, $t);)* + $(impl_promotion!($t, $v, $v);)* + }; +} + +impl_promotion!(u8, {}, {u16, u32, u64, u128, U256}); +impl_promotion!(u16, {u8}, {u32, u64, u128, U256}); +impl_promotion!(u32, {u8, u16}, {u64, u128, U256}); +impl_promotion!(u64, {u8, u16, u32}, {u128, U256}); +impl_promotion!(u128, {u8, u16, u32, u64}, {U256}); +impl_promotion!(U256, {u8, u16, u32, u64, u128}, {}); + +/// Trait for widening type. +trait Widen: Sized { + type Output: From + TryInto; + + /// Widen a value to a larger type. + fn widen(self) -> Self::Output { + Self::Output::from(self) + } +} +impl Widen for u8 { + type Output = u16; +} +impl Widen for u16 { + type Output = u32; +} +impl Widen for u32 { + type Output = u64; +} +impl Widen for u64 { + type Output = u128; +} +impl Widen for u128 { + type Output = U256; +} +impl Widen for U256 { + type Output = U512; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct CustomNumeric(pub u8); + + impl From for U256 { + fn from(value: CustomNumeric) -> Self { + U256::from(value.0) + } + } + impl TryFrom for CustomNumeric { + type Error = &'static str; + fn try_from(value: U256) -> Result { + value.try_into().map(CustomNumeric) + } + } + impl UpperBounded for CustomNumeric { + fn max_value() -> Self { + CustomNumeric(u8::max_value()) + } + } + impl core::ops::Mul for CustomNumeric { + type Output = Self; + fn mul(self, rhs: Self) -> Self { + CustomNumeric(self.0.wrapping_mul(rhs.0)) + } + } + impl core::ops::Div for CustomNumeric { + type Output = Self; + fn div(self, rhs: Self) -> Self { + CustomNumeric(self.0.wrapping_div(rhs.0)) + } + } + impl Promotion for CustomNumeric { + type Output = U256; + } + impl Promotion for U256 { + type Output = U256; + } + + #[test] + fn saturating_mul_div_with_custom_type() { + // custom lhs + assert_eq!(127, CustomNumeric(254).saturating_mul_div(U256::from(2), U256::from(4)).0); + + // custom rhs + let m = CustomNumeric(254); + let d = CustomNumeric(4); + assert_eq!(U256::from(127), U256::from(2).saturating_mul_div(m, d)); + } +} diff --git a/primitives/rewards/Cargo.toml b/primitives/rewards/Cargo.toml new file mode 100644 index 0000000..6dd0968 --- /dev/null +++ b/primitives/rewards/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "np-rewards" +description = "Noir primitive types for rewards distribution" +license = "Apache-2.0" +authors = { workspace = true } +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +publish = false + +[dependencies] +async-trait = { workspace = true, optional = true } +np-arithmetic = { workspace = true } +parity-scale-codec = { workspace = true } +sp-inherents = { workspace = true } +sp-runtime = { workspace = true } +thiserror = { workspace = true, optional = true } + +[features] +default = ["std"] +std = [ + "async-trait", + "np-arithmetic/std", + "parity-scale-codec/std", + "sp-inherents/std", + "sp-runtime/std", + "thiserror" +] diff --git a/primitives/rewards/src/inherents.rs b/primitives/rewards/src/inherents.rs new file mode 100644 index 0000000..9398918 --- /dev/null +++ b/primitives/rewards/src/inherents.rs @@ -0,0 +1,83 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. 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. + +use alloc::collections::BTreeMap; +use parity_scale_codec::Encode; +use sp_inherents::{self, InherentData, InherentIdentifier, IsFatalError}; +use sp_runtime::RuntimeDebug; +#[cfg(feature = "std")] +use {core::marker::PhantomData, parity_scale_codec::Decode, sp_runtime::traits::One}; + +pub const INHERENT_IDENTIFIER: InherentIdentifier = *b"rewards_"; + +#[derive(Encode, RuntimeDebug)] +#[cfg_attr(feature = "std", derive(Decode, thiserror::Error))] +pub enum InherentError { + /// Invalid reward amount. + #[cfg_attr(feature = "std", error("Invalid reward amount"))] + InvalidReward, + /// Duplicate reward beneficiary. + #[cfg_attr(feature = "std", error("Duplicate reward beneficiary"))] + DuplicateBeneficiary, +} + +impl IsFatalError for InherentError { + fn is_fatal_error(&self) -> bool { + true + } +} + +pub type InherentType = BTreeMap; + +#[cfg(feature = "std")] +pub struct InherentDataProvider { + pub author: AccountId, + _marker: PhantomData, +} + +#[cfg(feature = "std")] +impl InherentDataProvider { + pub fn new(author: AccountId) -> Self { + Self { author, _marker: PhantomData } + } +} + +#[cfg(feature = "std")] +#[async_trait::async_trait] +impl sp_inherents::InherentDataProvider for InherentDataProvider +where + AccountId: Clone + Ord + Encode + Send + Sync, + Share: One + Encode + Send + Sync, +{ + async fn provide_inherent_data( + &self, + inherent_data: &mut InherentData, + ) -> Result<(), sp_inherents::Error> { + inherent_data.put_data( + INHERENT_IDENTIFIER, + &InherentType::from([(self.author.clone(), Share::one())]), + ) + } + + async fn try_handle_error( + &self, + _identifier: &InherentIdentifier, + _error: &[u8], + ) -> Option> { + None + } +} diff --git a/primitives/rewards/src/lib.rs b/primitives/rewards/src/lib.rs new file mode 100644 index 0000000..2f7eec4 --- /dev/null +++ b/primitives/rewards/src/lib.rs @@ -0,0 +1,100 @@ +// This file is part of Noir. + +// Copyright (C) Haderech Pte. 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. + +//! Noir primitive types for rewards distribution. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub mod inherents; +pub use inherents::*; + +use alloc::{collections::BTreeMap, vec::Vec}; +use np_arithmetic::traits::{BaseArithmetic, SaturatingMulDiv}; + +/// Distributes the total reward according to the weight of each account. +pub fn split_reward( + reward: Balance, + shares: BTreeMap, +) -> Option> +where + Balance: BaseArithmetic + SaturatingMulDiv + Copy, + Share: BaseArithmetic + Copy, +{ + let total_weight = shares + .values() + .try_fold(Share::zero(), |sum, weight| sum.checked_add(weight)) + .filter(|sum| !sum.is_zero())?; + + let mut rewards = Vec::new(); + let mut reward_given = Balance::zero(); + let mut cumulative_weight = Share::zero(); + + for (dest, weight) in shares { + cumulative_weight += weight; + let next_value = reward.saturating_mul_div(cumulative_weight, total_weight); + rewards.push((dest, next_value - reward_given)); + reward_given = next_value; + } + + Some(rewards) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_reward_should_works() { + // normal case + { + let shares = BTreeMap::<_, u32>::from([(1, 2), (2, 3), (3, 5)]); + let reward = shares.values().sum(); + assert_eq!(Some(vec![(1, 2), (2, 3), (3, 5)]), split_reward(reward, shares)); + + let shares = BTreeMap::<_, u8>::from([(1, 2), (2, 3), (3, 5)]); + let reward = 10u32; + assert_eq!(Some(vec![(1, 2), (2, 3), (3, 5)]), split_reward(reward, shares)); + + let shares = BTreeMap::<_, u32>::from([(1, 2), (2, 3), (3, 5)]); + let reward = 10u8; + assert_eq!(Some(vec![(1, 2), (2, 3), (3, 5)]), split_reward(reward, shares)); + } + + // zero total weight + { + let shares = BTreeMap::<_, u32>::from([(1, 0), (2, 0), (3, 0)]); + let reward = 10u32; + assert_eq!(None, split_reward(reward, shares)); + } + + // total weight overflow + { + let shares = BTreeMap::from([(1, u32::MAX), (2, u32::MAX), (3, u32::MAX)]); + let reward = 10u32; + assert_eq!(None, split_reward(reward, shares)); + } + + // multiplication overflow + { + let shares = BTreeMap::<_, u32>::from([(1, 5), (2, 5), (3, 0)]); + let reward = 255u8; + assert_eq!(Some(vec![(1, 127), (2, 128), (3, 0)]), split_reward(reward, shares)); + } + } +}