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),
+ ),);
+ });
+}