diff --git a/Cargo.lock b/Cargo.lock index 228c6cff0..bfbb5cbe8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6900,6 +6900,23 @@ dependencies = [ "sp-weights", ] +[[package]] +name = "pallet-services-payment" +version = "0.1.0" +dependencies = [ + "cumulus-primitives-core", + "frame-support", + "frame-system", + "log", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "serde", + "sp-core", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-session" version = "4.0.0-dev" diff --git a/pallets/services-payment/Cargo.toml b/pallets/services-payment/Cargo.toml new file mode 100644 index 000000000..c3399a169 --- /dev/null +++ b/pallets/services-payment/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "pallet-services-payment" +authors = [] +description = "Services payment pallet" +edition = "2021" +publish = false +version = "0.1.0" + +[package.metadata.docs.rs] +targets = [ "x86_64-unknown-linux-gnu" ] + +[dependencies] +frame-support = { workspace = true } +frame-system = { workspace = true } +log = { workspace = true } +parity-scale-codec = { workspace = true, features = [ "derive", "max-encoded-len" ] } +scale-info = { workspace = true } +serde = { workspace = true, optional = true, features = [ "derive" ] } + +cumulus-primitives-core = { workspace = true } + +[dev-dependencies] +sp-runtime = { workspace = true } +sp-core = { workspace = true } +sp-io = { workspace = true } +pallet-balances = { workspace = true } + +[features] +default = [ "std" ] +std = [ + "cumulus-primitives-core/std", + "frame-support/std", + "frame-system/std", + "pallet-balances/std", + "scale-info/std", +] +try-runtime = [ "frame-support/try-runtime" ] diff --git a/pallets/services-payment/src/lib.rs b/pallets/services-payment/src/lib.rs new file mode 100644 index 000000000..5979c218c --- /dev/null +++ b/pallets/services-payment/src/lib.rs @@ -0,0 +1,221 @@ +// Copyright (C) Moondance Labs Ltd. +// This file is part of Tanssi. + +// Tanssi 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. + +// Tanssi 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 Tanssi. If not, see + +//! # Author Noting Pallet +//! +//! This pallet notes the author of the different containerChains that have registered: +//! +//! The set of container chains is retrieved thanks to the GetContainerChains trait +//! For each containerChain, we inspect the Header stored in the relayChain as +//! a generic header. This is the first requirement for containerChains. +//! +//! The second requirement is that an Aura digest with the slot number for the containerChains +//! needs to exist +//! +//! Using those two requirements we can select who the author was based on the collators assigned +//! to that containerChain, by simply assigning the slot position. + +//! # Services Payment pallet +//! +//! This pallet allows for block creation services to be paid for by a +//! containerChain. + +#![cfg_attr(not(feature = "std"), no_std)] + +use { + cumulus_primitives_core::ParaId, + frame_support::{ + pallet_prelude::*, + sp_runtime::{traits::Zero, Saturating}, + traits::Currency, + }, + frame_system::pallet_prelude::*, +}; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod test; + +pub use pallet::*; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// Handler for fees + type OnChargeForBlockCredit: OnChargeForBlockCredit; + /// Currency type for fee payment + type Currency: Currency; + /// Provider of a block cost which can adjust from block to block + type ProvideBlockProductionCost: ProvideBlockProductionCost; + /// The maximum number of credits that can be accumulated + type MaxCreditsStored: Get; + } + + #[pallet::error] + pub enum Error { + InsufficientFundsToPurchaseCredits, + InsufficientCredits, + CreditPriceTooExpensive, + } + + #[pallet::pallet] + pub struct Pallet(PhantomData); + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + CreditsPurchased { + para_id: ParaId, + payer: T::AccountId, + fee: BalanceOf, + credits_purchased: T::BlockNumber, + credits_remaining: T::BlockNumber, + }, + CreditBurned { + para_id: ParaId, + credits_remaining: T::BlockNumber, + }, + } + + #[pallet::storage] + #[pallet::getter(fn collator_commission)] + pub type BlockProductionCredits = + StorageMap<_, Blake2_128Concat, ParaId, T::BlockNumber, OptionQuery>; + + #[pallet::call] + impl Pallet + where + BalanceOf: From>, + { + #[pallet::call_index(0)] + #[pallet::weight(0)] // TODO + pub fn purchase_credits( + origin: OriginFor, + para_id: ParaId, + credits: T::BlockNumber, + max_price_per_credit: Option>, + ) -> DispatchResultWithPostInfo { + let account = ensure_signed(origin)?; + + let existing_credits = + BlockProductionCredits::::get(para_id).unwrap_or(T::BlockNumber::zero()); + let credits_purchasable = T::MaxCreditsStored::get().saturating_sub(existing_credits); + let actual_credits_purchased = credits.min(credits_purchasable); + + let updated_credits = existing_credits.saturating_add(actual_credits_purchased); + + // get the current per-credit cost of a block + let (block_cost, _weight) = T::ProvideBlockProductionCost::block_cost(¶_id); + if let Some(max_price_per_credit) = max_price_per_credit { + ensure!( + block_cost <= max_price_per_credit, + Error::::CreditPriceTooExpensive, + ); + } + + let total_fee = block_cost.saturating_mul(actual_credits_purchased.into()); + + T::OnChargeForBlockCredit::charge_credits( + &account, + ¶_id, + actual_credits_purchased, + total_fee, + )?; + + BlockProductionCredits::::insert(para_id, updated_credits); + + Self::deposit_event(Event::::CreditsPurchased { + para_id, + payer: account, + fee: total_fee, + credits_purchased: actual_credits_purchased, + credits_remaining: updated_credits, + }); + + Ok(().into()) + } + } + + impl Pallet { + /// Burn a credit for the given para. Deducts one credit if possible, errors otherwise. + pub fn burn_credit_for_para(para_id: &ParaId) -> DispatchResultWithPostInfo { + let existing_credits = + BlockProductionCredits::::get(para_id).unwrap_or(T::BlockNumber::zero()); + + ensure!( + existing_credits >= 1u32.into(), + Error::::InsufficientCredits, + ); + + let updated_credits = existing_credits.saturating_sub(1u32.into()); + BlockProductionCredits::::insert(para_id, updated_credits); + + Self::deposit_event(Event::::CreditBurned { + para_id: *para_id, + credits_remaining: updated_credits, + }); + + Ok(().into()) + } + } + + #[pallet::genesis_config] + pub struct GenesisConfig { + _phantom: PhantomData, + } + + #[cfg(feature = "std")] + impl Default for GenesisConfig { + fn default() -> Self { + Self { + _phantom: Default::default(), + } + } + } + + #[pallet::genesis_build] + impl GenesisBuild for GenesisConfig { + fn build(&self) {} + } +} + +/// Balance used by this pallet +pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +/// Handler for fee charging. This will be invoked when fees need to be deducted from the fee +/// account for a given paraId. +pub trait OnChargeForBlockCredit { + fn charge_credits( + payer: &T::AccountId, + para_id: &ParaId, + credits: T::BlockNumber, + fee: BalanceOf, + ) -> Result<(), Error>; +} + +/// Returns the cost for a given block credit at the current time. This can be a complex operation, +/// so it also returns the weight it consumes. (TODO: or just rely on benchmarking) +pub trait ProvideBlockProductionCost { + fn block_cost(para_id: &ParaId) -> (BalanceOf, Weight); +} diff --git a/pallets/services-payment/src/mock.rs b/pallets/services-payment/src/mock.rs new file mode 100644 index 000000000..8d5aee644 --- /dev/null +++ b/pallets/services-payment/src/mock.rs @@ -0,0 +1,194 @@ +// Copyright (C) Moondance Labs Ltd. +// This file is part of Tanssi. + +// Tanssi 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. + +// Tanssi 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 Tanssi. If not, see + +//! # Author Noting Pallet +//! +//! This pallet notes the author of the different containerChains that have registered: +//! +//! The set of container chains is retrieved thanks to the GetContainerChains trait +//! For each containerChain, we inspect the Header stored in the relayChain as +//! a generic header. This is the first requirement for containerChains. +//! +//! The second requirement is that an Aura digest with the slot number for the containerChains +//! needs to exist +//! +//! Using those two requirements we can select who the author was based on the collators assigned +//! to that containerChain, by simply assigning the slot position. + +use frame_support::traits::{Currency, WithdrawReasons}; + +use { + crate::{self as payment_services_pallet, OnChargeForBlockCredit, ProvideBlockProductionCost}, + cumulus_primitives_core::ParaId, + frame_support::{ + pallet_prelude::*, + parameter_types, + traits::{tokens::ExistenceRequirement, ConstU32, ConstU64, Everything}, + }, + sp_core::H256, + sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + }, +}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; +type AccountId = u64; +type Balance = u128; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + PaymentServices: payment_services_pallet::{Pallet, Call, Config, Storage, Event} + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +parameter_types! { + pub const ExistentialDeposit: u128 = 1; +} + +impl pallet_balances::Config for Test { + type MaxReserves = (); + type ReserveIdentifier = [u8; 4]; + type MaxLocks = (); + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); +} + +parameter_types! { + pub const MaxCreditsStored: u64 = 5; +} + +impl payment_services_pallet::Config for Test { + type RuntimeEvent = RuntimeEvent; + type OnChargeForBlockCredit = ChargeForBlockCredit; + type Currency = Balances; + type ProvideBlockProductionCost = BlockProductionCost; + type MaxCreditsStored = MaxCreditsStored; +} + +pub struct ChargeForBlockCredit(PhantomData); +impl OnChargeForBlockCredit for ChargeForBlockCredit { + fn charge_credits( + payer: &u64, + _para_id: &ParaId, + _credits: u64, + fee: u128, + ) -> Result<(), payment_services_pallet::Error> { + use frame_support::traits::tokens::imbalance::Imbalance; + + let result = Balances::withdraw( + &*payer, + fee, + WithdrawReasons::FEE, + ExistenceRequirement::AllowDeath, + ); + let imbalance = result + .map_err(|_| payment_services_pallet::Error::InsufficientFundsToPurchaseCredits)?; + + if imbalance.peek() != fee { + panic!("withdrawn balance incorrect"); + } + + Ok(()) + } +} + +pub(crate) const FIXED_BLOCK_PRODUCTION_COST: u128 = 100; + +pub struct BlockProductionCost(PhantomData); +impl ProvideBlockProductionCost for BlockProductionCost { + fn block_cost(_para_id: &ParaId) -> (u128, Weight) { + (FIXED_BLOCK_PRODUCTION_COST, Weight::zero()) + } +} + +#[derive(Default)] +pub struct ExtBuilder { + balances: Vec<(AccountId, Balance)>, +} + +impl ExtBuilder { + pub fn with_balances(mut self, balances: Vec<(AccountId, Balance)>) -> Self { + self.balances = balances; + self + } + + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + pallet_balances::GenesisConfig:: { + balances: self.balances, + } + .assimilate_storage(&mut t) + .unwrap(); + + t.into() + } +} + +pub(crate) fn events() -> Vec> { + System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let RuntimeEvent::PaymentServices(inner) = e { + Some(inner) + } else { + None + } + }) + .collect::>() +} diff --git a/pallets/services-payment/src/test.rs b/pallets/services-payment/src/test.rs new file mode 100644 index 000000000..7fe95be52 --- /dev/null +++ b/pallets/services-payment/src/test.rs @@ -0,0 +1,310 @@ +// Copyright (C) Moondance Labs Ltd. +// This file is part of Tanssi. + +// Tanssi 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. + +// Tanssi 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 Tanssi. If not, see + +//! # Author Noting Pallet +//! +//! This pallet notes the author of the different containerChains that have registered: +//! +//! The set of container chains is retrieved thanks to the GetContainerChains trait +//! For each containerChain, we inspect the Header stored in the relayChain as +//! a generic header. This is the first requirement for containerChains. +//! +//! The second requirement is that an Aura digest with the slot number for the containerChains +//! needs to exist +//! +//! Using those two requirements we can select who the author was based on the collators assigned +//! to that containerChain, by simply assigning the slot position. + +use { + crate::{mock::*, pallet as payment_services_pallet, BlockProductionCredits}, + frame_support::{assert_err, assert_ok}, +}; + +const ALICE: u64 = 1; + +#[test] +fn purchase_credits_works() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + System::set_block_number(1); + + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + 1.into(), + MaxCreditsStored::get(), + None, + ),); + + assert_eq!( + events(), + vec![payment_services_pallet::Event::CreditsPurchased { + para_id: 1.into(), + payer: ALICE, + fee: 500, + credits_purchased: MaxCreditsStored::get(), + credits_remaining: MaxCreditsStored::get(), + }] + ); + }); +} + +#[test] +fn purchase_credits_purchases_zero_when_max_already_stored() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + System::set_block_number(1); + + let para_id = 1.into(); + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + para_id, + MaxCreditsStored::get(), + None, + ),); + + assert_eq!( + >::get(para_id), + Some(MaxCreditsStored::get()) + ); + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + para_id, + 1, + None + ),); + assert_eq!( + >::get(para_id), + Some(MaxCreditsStored::get()) + ); + + // should have two purchase events (one with MaxCreditsStored, then one with zero) + assert_eq!( + events(), + vec![ + payment_services_pallet::Event::CreditsPurchased { + para_id, + payer: ALICE, + fee: 500, + credits_purchased: MaxCreditsStored::get(), + credits_remaining: MaxCreditsStored::get(), + }, + payment_services_pallet::Event::CreditsPurchased { + para_id, + payer: ALICE, + fee: 0, + credits_purchased: 0, + credits_remaining: MaxCreditsStored::get(), + }, + ] + ); + }); +} + +#[test] +fn purchase_credits_purchases_max_possible_when_cant_purchase_all_requested() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + System::set_block_number(1); + + let para_id = 1.into(); + let amount_purchased = 1u64; + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + para_id, + amount_purchased, + None, + )); + + let purchasable = MaxCreditsStored::get() - amount_purchased; + assert_eq!(purchasable, 4); + + assert_eq!( + >::get(para_id), + Some(amount_purchased) + ); + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + para_id, + MaxCreditsStored::get(), + None, + ),); + assert_eq!( + >::get(para_id), + Some(MaxCreditsStored::get()) + ); + + // should have two purchase events (one with amount_purchased, then with purchasable) + assert_eq!( + events(), + vec![ + payment_services_pallet::Event::CreditsPurchased { + para_id, + payer: ALICE, + fee: 100, + credits_purchased: amount_purchased, + credits_remaining: amount_purchased, + }, + payment_services_pallet::Event::CreditsPurchased { + para_id, + payer: ALICE, + fee: 400, + credits_purchased: purchasable, + credits_remaining: MaxCreditsStored::get(), + }, + ] + ); + }); +} + +#[test] +fn purchase_credits_fails_with_insufficient_balance() { + ExtBuilder::default().build().execute_with(|| { + // really what we're testing is that purchase_credits fails when OnChargeForBlockCredits does + assert_err!( + PaymentServices::purchase_credits(RuntimeOrigin::signed(ALICE), 1.into(), 1, None), + payment_services_pallet::Error::::InsufficientFundsToPurchaseCredits, + ); + }); +} + +#[test] +fn burn_credit_fails_with_no_credits() { + ExtBuilder::default().build().execute_with(|| { + assert_err!( + PaymentServices::burn_credit_for_para(&1u32.into()), + payment_services_pallet::Error::::InsufficientCredits, + ); + }); +} + +#[test] +fn burn_credit_works() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + let para_id = 1.into(); + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + para_id, + 1u64, + None, + ),); + + // should succeed and burn one + assert_eq!(>::get(para_id), Some(1u64)); + assert_ok!(PaymentServices::burn_credit_for_para(¶_id)); + assert_eq!(>::get(para_id), Some(0u64)); + + // now should fail + assert_err!( + PaymentServices::burn_credit_for_para(¶_id), + payment_services_pallet::Error::::InsufficientCredits, + ); + }); +} + +#[test] +fn burn_credit_fails_for_wrong_para() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + let para_id = 1.into(); + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + para_id, + 1u64, + None, + ),); + + // fails for wrong para + let wrong_para_id = 2.into(); + assert_err!( + PaymentServices::burn_credit_for_para(&wrong_para_id), + payment_services_pallet::Error::::InsufficientCredits, + ); + }); +} + +#[test] +fn buy_credits_no_limit_works() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + 1.into(), + 1u64, + None, + )); + }); +} + +#[test] +fn buy_credits_too_expensive_fails() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + assert_err!( + PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + 1.into(), + 1u64, + Some(FIXED_BLOCK_PRODUCTION_COST - 1), + ), + payment_services_pallet::Error::::CreditPriceTooExpensive, + ); + }); +} + +#[test] +fn buy_credits_exact_price_limit_works() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + 1.into(), + 1u64, + Some(FIXED_BLOCK_PRODUCTION_COST), + ),); + }); +} + +#[test] +fn buy_credits_limit_exceeds_price_works() { + ExtBuilder::default() + .with_balances([(ALICE, 1_000)].into()) + .build() + .execute_with(|| { + assert_ok!(PaymentServices::purchase_credits( + RuntimeOrigin::signed(ALICE), + 1.into(), + 1u64, + Some(FIXED_BLOCK_PRODUCTION_COST + 1), + ),); + }); +}