From 15bd969c890643480f6d3258d19b1c7a474a05e0 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Sun, 7 Sep 2025 16:28:56 +0200 Subject: [PATCH 1/7] Fixed pricing implementation --- pallets/market/src/dynamic_pricing.rs | 97 +++++++++++++++++++++++++++ pallets/market/src/lib.rs | 78 ++++++++++++--------- 2 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 pallets/market/src/dynamic_pricing.rs diff --git a/pallets/market/src/dynamic_pricing.rs b/pallets/market/src/dynamic_pricing.rs new file mode 100644 index 0000000..d61507a --- /dev/null +++ b/pallets/market/src/dynamic_pricing.rs @@ -0,0 +1,97 @@ +// This file is part of RegionX. +// +// RegionX 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. +// +// RegionX 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 RegionX. If not, see . + +use crate::*; +use crate::frame_system::ensure_signed; + +pub struct DynamicPricing(PhantomData); + +impl Market for DynamicPricing { + type PriceData = BalanceOf; + + fn list_region( + who: T::AccountId, + region_id: RegionId, + timeslice_price: Self::PriceData, + sale_recipient: Option, + ) -> DispatchResult { + ensure!(Listings::::get(region_id).is_none(), Error::::AlreadyListed); + + let region = T::Regions::region(®ion_id.into()).ok_or(Error::::UnknownRegion)?; + ensure!(!region.locked, Error::::RegionLocked); + let record = region.record.get().ok_or(Error::::RecordUnavailable)?; + + // It doesn't make sense to list a region that expired. + let current_timeslice = Self::current_timeslice(); + ensure!(record.end > current_timeslice, Error::::RegionExpired); + + T::Regions::lock(®ion_id.into(), Some(who.clone()))?; + + let sale_recipient = sale_recipient.unwrap_or(who.clone()); + Listings::::insert( + region_id, + Listing { + seller: who.clone(), + timeslice_price, + sale_recipient: sale_recipient.clone(), + }, + ); + + Ok(()) + } + + fn unlist_region(origin: OriginFor, region_id: RegionId) -> DispatchResult { + Ok(()) + } + + fn update_region_price( + origin: OriginFor, + region_id: RegionId, + new_timeslice_price: BalanceOf, + ) -> DispatchResult { + Ok(()) + } + + fn purchase_region(region_id: RegionId, max_price: BalanceOf) -> DispatchResult { + Ok(()) + } +} + +impl DynamicPricing { + pub(crate) fn calculate_region_price( + region_id: RegionId, + record: RegionRecordOf, + timeslice_price: BalanceOf, + ) -> BalanceOf { + let current_timeslice = Self::current_timeslice(); + let duration = record.end.saturating_sub(region_id.begin); + + if current_timeslice < region_id.begin { + // The region didn't start yet, so there is no value lost. + let price = timeslice_price.saturating_mul(duration.into()); + + return price; + } + + let remaining_timeslices = record.end.saturating_sub(current_timeslice); + timeslice_price.saturating_mul(remaining_timeslices.into()) + } + + pub(crate) fn current_timeslice() -> Timeslice { + let latest_rc_block = T::RCBlockNumberProvider::current_block_number(); + let timeslice_period = T::TimeslicePeriod::get(); + (latest_rc_block / timeslice_period).saturated_into() + } +} diff --git a/pallets/market/src/lib.rs b/pallets/market/src/lib.rs index f2060d7..9a25226 100644 --- a/pallets/market/src/lib.rs +++ b/pallets/market/src/lib.rs @@ -15,7 +15,11 @@ #![cfg_attr(not(feature = "std"), no_std)] -use frame_support::traits::{fungible::Inspect, tokens::Preservation}; +use frame_support::{ + pallet_prelude::*, + traits::{fungible::Inspect, tokens::Preservation}, +}; +use frame_system::pallet_prelude::OriginFor; use nonfungible_primitives::LockableNonFungible; pub use pallet::*; use pallet_broker::{RegionId, Timeslice}; @@ -26,6 +30,8 @@ use sp_runtime::{traits::BlockNumberProvider, SaturatedConversion, Saturating}; mod types; pub use crate::types::*; +pub mod dynamic_pricing; + #[cfg(test)] mod mock; @@ -45,13 +51,31 @@ pub type BalanceOf = pub type RCBlockNumberOf = <::RCBlockNumberProvider as BlockNumberProvider>::BlockNumber; +pub trait Market { + type PriceData: Parameter; + + fn list_region( + who: T::AccountId, + region_id: RegionId, + price_data: Self::PriceData, + sale_recipient: Option, + ) -> DispatchResult; + + fn unlist_region(origin: OriginFor, region_id: RegionId) -> DispatchResult; + + fn update_region_price( + origin: OriginFor, + region_id: RegionId, + new_timeslice_price: BalanceOf, + ) -> DispatchResult; + + fn purchase_region(region_id: RegionId, max_price: BalanceOf) -> DispatchResult; +} + #[frame_support::pallet] pub mod pallet { use super::*; - use frame_support::{ - pallet_prelude::*, - traits::{fungible::Mutate, nonfungible::Transfer}, - }; + use frame_support::traits::{fungible::Mutate, nonfungible::Transfer}; use frame_system::pallet_prelude::*; #[pallet::config] @@ -63,6 +87,13 @@ pub mod pallet { /// Currency used for purchasing coretime. type Currency: Mutate; + /// Implementation of all the marketplace extrinsics. + /// + /// This is because we want to support two different pricing models: + /// - Fixed pricing + /// - Dynamic pricing: price of an 'active' region decreases over time. + type MarketImpl: Market; + /// Type providing a way of reading, transferring and locking regions. // // The item id is `u128` encoded RegionId. @@ -101,7 +132,7 @@ pub mod pallet { /// The region that got listed on sale. region_id: RegionId, /// The price per timeslice of the listed region. - timeslice_price: BalanceOf, + price_data: >::PriceData, /// The seller of the region. seller: T::AccountId, /// The sale revenue recipient. @@ -162,38 +193,23 @@ pub mod pallet { pub fn list_region( origin: OriginFor, region_id: RegionId, - timeslice_price: BalanceOf, + price_data: >::PriceData, sale_recipient: Option, ) -> DispatchResult { - let who = ensure_signed(origin)?; - - ensure!(Listings::::get(region_id).is_none(), Error::::AlreadyListed); - - let region = T::Regions::region(®ion_id.into()).ok_or(Error::::UnknownRegion)?; - ensure!(!region.locked, Error::::RegionLocked); - let record = region.record.get().ok_or(Error::::RecordUnavailable)?; - - // It doesn't make sense to list a region that expired. - let current_timeslice = Self::current_timeslice(); - ensure!(record.end > current_timeslice, Error::::RegionExpired); - - T::Regions::lock(®ion_id.into(), Some(who.clone()))?; + let who = ensure_signed(origin)?; - let sale_recipient = sale_recipient.unwrap_or(who.clone()); - Listings::::insert( + >::list_region( + who.clone(), region_id, - Listing { - seller: who.clone(), - timeslice_price, - sale_recipient: sale_recipient.clone(), - }, - ); + price_data.clone(), + sale_recipient.clone() + )?; Self::deposit_event(Event::Listed { region_id, - timeslice_price, - seller: who, - sale_recipient, + price_data, + seller: who.clone(), + sale_recipient: sale_recipient.unwrap_or(who.clone()), }); Ok(()) From 84d2aa7f6d704635772bab6a935a0fef12a02a08 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Sun, 7 Sep 2025 20:03:59 +0200 Subject: [PATCH 2/7] update all extrinsics --- pallets/market/src/dynamic_pricing.rs | 77 ++++++++++++++----- pallets/market/src/lib.rs | 105 +++++++------------------- pallets/market/src/mock.rs | 2 + pallets/market/src/tests.rs | 44 +++++------ pallets/market/src/types.rs | 6 +- 5 files changed, 111 insertions(+), 123 deletions(-) diff --git a/pallets/market/src/dynamic_pricing.rs b/pallets/market/src/dynamic_pricing.rs index d61507a..526a306 100644 --- a/pallets/market/src/dynamic_pricing.rs +++ b/pallets/market/src/dynamic_pricing.rs @@ -13,13 +13,13 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . -use crate::*; -use crate::frame_system::ensure_signed; +use crate::{frame_system::ensure_signed, *}; +use polkadot_sdk::frame_support::traits::{fungible::Mutate, nonfungible::Transfer}; pub struct DynamicPricing(PhantomData); -impl Market for DynamicPricing { - type PriceData = BalanceOf; +impl MarketT for DynamicPricing { + type PriceData = BalanceOf; fn list_region( who: T::AccountId, @@ -44,29 +44,72 @@ impl Market for DynamicPricing { region_id, Listing { seller: who.clone(), - timeslice_price, + price_data: timeslice_price, sale_recipient: sale_recipient.clone(), }, ); - Ok(()) - } + Ok(()) + } + + fn unlist_region(who: T::AccountId, region_id: RegionId) -> DispatchResult { + let listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; + let record = T::Regions::record(®ion_id.into()).ok_or(Error::::UnknownRegion)?; + + // If the region expired anyone can remove it from the market. + let current_timeslice = Self::current_timeslice(); + if current_timeslice <= record.end { + ensure!(who == listing.seller, Error::::NotAllowed); + }; + + Listings::::remove(region_id); + T::Regions::unlock(®ion_id.into(), None)?; - fn unlist_region(origin: OriginFor, region_id: RegionId) -> DispatchResult { - Ok(()) - } + Ok(()) + } fn update_region_price( - origin: OriginFor, + who: T::AccountId, region_id: RegionId, - new_timeslice_price: BalanceOf, + new_timeslice_price: Self::PriceData, ) -> DispatchResult { - Ok(()) - } + let mut listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; + let record = T::Regions::record(®ion_id.into()).ok_or(Error::::UnknownRegion)?; + + // Only the seller can update the price + ensure!(who == listing.seller, Error::::NotAllowed); + + let current_timeslice = Self::current_timeslice(); + ensure!(current_timeslice < record.end, Error::::RegionExpired); + + listing.price_data = new_timeslice_price; + Listings::::insert(region_id, listing); - fn purchase_region(region_id: RegionId, max_price: BalanceOf) -> DispatchResult { - Ok(()) - } + Ok(()) + } + + fn purchase_region( + who: T::AccountId, + region_id: RegionId, + max_price: BalanceOf, + ) -> Result, DispatchError> { + let listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; + let record = T::Regions::record(®ion_id.into()).ok_or(Error::::UnknownRegion)?; + + ensure!(who != listing.seller && who != listing.sale_recipient, Error::::NotAllowed); + + let price = Self::calculate_region_price(region_id, record, listing.price_data); + ensure!(price <= max_price, Error::::PriceTooHigh); + T::Currency::transfer(&who, &listing.sale_recipient, price, Preservation::Preserve)?; + + // Remove the region from sale: + Listings::::remove(region_id); + T::Regions::unlock(®ion_id.into(), None)?; + + T::Regions::transfer(®ion_id.into(), &who)?; + + Ok(price) + } } impl DynamicPricing { diff --git a/pallets/market/src/lib.rs b/pallets/market/src/lib.rs index 9a25226..5dc88ba 100644 --- a/pallets/market/src/lib.rs +++ b/pallets/market/src/lib.rs @@ -51,7 +51,7 @@ pub type BalanceOf = pub type RCBlockNumberOf = <::RCBlockNumberProvider as BlockNumberProvider>::BlockNumber; -pub trait Market { +pub trait MarketT { type PriceData: Parameter; fn list_region( @@ -61,15 +61,19 @@ pub trait Market { sale_recipient: Option, ) -> DispatchResult; - fn unlist_region(origin: OriginFor, region_id: RegionId) -> DispatchResult; + fn unlist_region(who: T::AccountId, region_id: RegionId) -> DispatchResult; fn update_region_price( - origin: OriginFor, + who: T::AccountId, region_id: RegionId, - new_timeslice_price: BalanceOf, + new_timeslice_price: Self::PriceData, ) -> DispatchResult; - fn purchase_region(region_id: RegionId, max_price: BalanceOf) -> DispatchResult; + fn purchase_region( + who: T::AccountId, + region_id: RegionId, + max_price: BalanceOf, + ) -> Result, DispatchError>; } #[frame_support::pallet] @@ -92,7 +96,7 @@ pub mod pallet { /// This is because we want to support two different pricing models: /// - Fixed pricing /// - Dynamic pricing: price of an 'active' region decreases over time. - type MarketImpl: Market; + type MarketImpl: MarketT; /// Type providing a way of reading, transferring and locking regions. // @@ -132,7 +136,7 @@ pub mod pallet { /// The region that got listed on sale. region_id: RegionId, /// The price per timeslice of the listed region. - price_data: >::PriceData, + price_data: >::PriceData, /// The seller of the region. seller: T::AccountId, /// The sale revenue recipient. @@ -154,7 +158,7 @@ pub mod pallet { /// The region for which the sale price was updated. region_id: RegionId, /// New timeslice price - new_timeslice_price: BalanceOf, + price_data: >::PriceData, }, } @@ -193,16 +197,16 @@ pub mod pallet { pub fn list_region( origin: OriginFor, region_id: RegionId, - price_data: >::PriceData, + price_data: >::PriceData, sale_recipient: Option, ) -> DispatchResult { - let who = ensure_signed(origin)?; + let who = ensure_signed(origin)?; - >::list_region( + >::list_region( who.clone(), region_id, price_data.clone(), - sale_recipient.clone() + sale_recipient.clone(), )?; Self::deposit_event(Event::Listed { @@ -211,7 +215,6 @@ pub mod pallet { seller: who.clone(), sale_recipient: sale_recipient.unwrap_or(who.clone()), }); - Ok(()) } @@ -224,19 +227,9 @@ pub mod pallet { pub fn unlist_region(origin: OriginFor, region_id: RegionId) -> DispatchResult { let who = ensure_signed(origin)?; - let listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; - let record = T::Regions::record(®ion_id.into()).ok_or(Error::::UnknownRegion)?; - - // If the region expired anyone can remove it from the market. - let current_timeslice = Self::current_timeslice(); - if current_timeslice <= record.end { - ensure!(who == listing.seller, Error::::NotAllowed); - }; + >::unlist_region(who.clone(), region_id)?; - Listings::::remove(region_id); - T::Regions::unlock(®ion_id.into(), None)?; Self::deposit_event(Event::Unlisted { region_id }); - Ok(()) } @@ -250,23 +243,17 @@ pub mod pallet { pub fn update_region_price( origin: OriginFor, region_id: RegionId, - new_timeslice_price: BalanceOf, + price_data: >::PriceData, ) -> DispatchResult { let who = ensure_signed(origin)?; - let mut listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; - let record = T::Regions::record(®ion_id.into()).ok_or(Error::::UnknownRegion)?; - - // Only the seller can update the price - ensure!(who == listing.seller, Error::::NotAllowed); - - let current_timeslice = Self::current_timeslice(); - ensure!(current_timeslice < record.end, Error::::RegionExpired); - - listing.timeslice_price = new_timeslice_price; - Listings::::insert(region_id, listing); + >::update_region_price( + who.clone(), + region_id, + price_data.clone(), + )?; - Self::deposit_event(Event::PriceUpdated { region_id, new_timeslice_price }); + Self::deposit_event(Event::PriceUpdated { region_id, price_data }); Ok(()) } @@ -286,51 +273,11 @@ pub mod pallet { ) -> DispatchResult { let who = ensure_signed(origin)?; - let listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; - let record = T::Regions::record(®ion_id.into()).ok_or(Error::::UnknownRegion)?; - - ensure!(who != listing.seller && who != listing.sale_recipient, Error::::NotAllowed); - - let price = Self::calculate_region_price(region_id, record, listing.timeslice_price); - ensure!(price <= max_price, Error::::PriceTooHigh); - T::Currency::transfer(&who, &listing.sale_recipient, price, Preservation::Preserve)?; - - // Remove the region from sale: - Listings::::remove(region_id); - T::Regions::unlock(®ion_id.into(), None)?; - - T::Regions::transfer(®ion_id.into(), &who)?; + let price = + >::purchase_region(who.clone(), region_id, max_price)?; Self::deposit_event(Event::Purchased { region_id, buyer: who, total_price: price }); - Ok(()) } } - - impl Pallet { - pub(crate) fn calculate_region_price( - region_id: RegionId, - record: RegionRecordOf, - timeslice_price: BalanceOf, - ) -> BalanceOf { - let current_timeslice = Self::current_timeslice(); - let duration = record.end.saturating_sub(region_id.begin); - - if current_timeslice < region_id.begin { - // The region didn't start yet, so there is no value lost. - let price = timeslice_price.saturating_mul(duration.into()); - - return price; - } - - let remaining_timeslices = record.end.saturating_sub(current_timeslice); - timeslice_price.saturating_mul(remaining_timeslices.into()) - } - - pub(crate) fn current_timeslice() -> Timeslice { - let latest_rc_block = T::RCBlockNumberProvider::current_block_number(); - let timeslice_period = T::TimeslicePeriod::get(); - (latest_rc_block / timeslice_period).saturated_into() - } - } } diff --git a/pallets/market/src/mock.rs b/pallets/market/src/mock.rs index 29a763a..e3e69d5 100644 --- a/pallets/market/src/mock.rs +++ b/pallets/market/src/mock.rs @@ -13,6 +13,7 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . +use crate::dynamic_pricing::DynamicPricing; use anyhow; use frame_support::{derive_impl, pallet_prelude::*, parameter_types, traits::Everything}; use frame_system::{config_preludes::TestDefaultConfig, DefaultConfig}; @@ -161,6 +162,7 @@ impl crate::Config for Test { type Regions = Regions; type RCBlockNumberProvider = RelayBlockNumberProvider; type TimeslicePeriod = ConstU64<80>; + type MarketImpl = DynamicPricing; type WeightInfo = (); } diff --git a/pallets/market/src/tests.rs b/pallets/market/src/tests.rs index 3047b02..4445361 100644 --- a/pallets/market/src/tests.rs +++ b/pallets/market/src/tests.rs @@ -13,11 +13,8 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . -use crate::{mock::*, *}; -use frame_support::{ - assert_noop, assert_ok, - traits::{nonfungible::Mutate, Get}, -}; +use crate::{dynamic_pricing::DynamicPricing, mock::*, *}; +use frame_support::{assert_noop, assert_ok, traits::nonfungible::Mutate}; use pallet_broker::{CoreMask, RegionRecord}; use sp_runtime::{DispatchError::Token, TokenError}; @@ -25,7 +22,7 @@ use sp_runtime::{DispatchError::Token, TokenError}; fn calculate_region_price_works() { new_test_ext().execute_with(|| { assert_eq!( - Market::calculate_region_price( + DynamicPricing::::calculate_region_price( RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, 10 // timeslice price @@ -36,7 +33,7 @@ fn calculate_region_price_works() { // Remains same until a timeslice passes: RelayBlockNumber::set(79); assert_eq!( - Market::calculate_region_price( + DynamicPricing::::calculate_region_price( RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, 10 // timeslice price @@ -48,7 +45,7 @@ fn calculate_region_price_works() { // Reduced by one after a timeslice elapses: assert_eq!( - Market::calculate_region_price( + DynamicPricing::::calculate_region_price( RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, 10 // timeslice price @@ -59,7 +56,7 @@ fn calculate_region_price_works() { RelayBlockNumber::set(8 * 80); // Expired region has no value: assert_eq!( - Market::calculate_region_price( + DynamicPricing::::calculate_region_price( RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, 10 // timeslice price @@ -80,7 +77,7 @@ fn list_region_works() { assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; - let timeslice: u64 = ::TimeslicePeriod::get(); + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); let price = 1_000_000; let recipient = 1; @@ -114,14 +111,14 @@ fn list_region_works() { // Check storage items assert_eq!( Market::listings(region_id), - Some(Listing { seller, timeslice_price: price, sale_recipient: recipient }) + Some(Listing { seller, price_data: price, sale_recipient: recipient }) ); assert!(Regions::regions(region_id).unwrap().locked); // Check events System::assert_last_event( - Event::Listed { region_id, timeslice_price: price, seller, sale_recipient: recipient } + Event::Listed { region_id, price_data: price, seller, sale_recipient: recipient } .into(), ); }); @@ -147,7 +144,7 @@ fn unlist_region_works() { assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); assert_eq!( Market::listings(region_id), - Some(Listing { seller, timeslice_price: price, sale_recipient: seller }) + Some(Listing { seller, price_data: price, sale_recipient: seller }) ); // Failure: NotAllowed @@ -178,7 +175,7 @@ fn unlist_expired_region_works() { assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(seller), paid: None }; - let timeslice: u64 = ::TimeslicePeriod::get(); + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); let price = 1_000_000; assert_ok!(Regions::set_record(region_id, record.clone())); @@ -189,7 +186,7 @@ fn unlist_expired_region_works() { assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); assert_eq!( Market::listings(region_id), - Some(Listing { seller, timeslice_price: price, sale_recipient: seller }) + Some(Listing { seller, price_data: price, sale_recipient: seller }) ); RelayBlockNumber::set(9 * timeslice); @@ -216,7 +213,7 @@ fn update_region_price_works() { assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; - let timeslice: u64 = ::TimeslicePeriod::get(); + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); let price = 1_000_000; let recipient = 1; let new_timeslice_price = 2_000_000; @@ -251,16 +248,12 @@ fn update_region_price_works() { // Check storage assert_eq!( Market::listings(region_id), - Some(Listing { - seller, - timeslice_price: new_timeslice_price, - sale_recipient: recipient - }) + Some(Listing { seller, price_data: new_timeslice_price, sale_recipient: recipient }) ); // Check events System::assert_last_event( - Event::::PriceUpdated { region_id, new_timeslice_price }.into(), + Event::::PriceUpdated { region_id, price_data: new_timeslice_price }.into(), ); }); } @@ -275,7 +268,7 @@ fn purchase_region_works() { assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; - let timeslice: u64 = ::TimeslicePeriod::get(); + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); let timeslice_price = 1_000_000; let recipient = 1; @@ -334,7 +327,10 @@ fn purchase_region_works() { RelayBlockNumber::set(4 * timeslice); let price = 4 * timeslice_price; - assert_eq!(Market::calculate_region_price(region_id, record, timeslice_price), price); + assert_eq!( + DynamicPricing::::calculate_region_price(region_id, record, timeslice_price), + price + ); assert_ok!(Market::purchase_region( RuntimeOrigin::signed(buyer), region_id, diff --git a/pallets/market/src/types.rs b/pallets/market/src/types.rs index b96524a..73009a9 100644 --- a/pallets/market/src/types.rs +++ b/pallets/market/src/types.rs @@ -21,11 +21,11 @@ pub type RegionRecordOf = /// The information we store about a region that got listed on sale. #[derive(Encode, Decode, Debug, Clone, PartialEq, Eq, TypeInfo, MaxEncodedLen)] -pub struct Listing { +pub struct Listing { /// The `AccountId` selling the region. pub seller: AccountId, - /// The price per a single timeslice. - pub timeslice_price: Balance, + /// Price data of the listing. + pub price_data: PriceData, /// The `AccountId` receiving the payment from the sale. /// /// This will usually be the seller account. From ebe76777fcfe8d695f7df91da19ab0eaa47b6183 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Sun, 7 Sep 2025 20:11:22 +0200 Subject: [PATCH 3/7] fixed pricing --- pallets/market/src/fixed_pricing.rs | 101 ++++++++++++++++++++++++++++ pallets/market/src/lib.rs | 1 + 2 files changed, 102 insertions(+) create mode 100644 pallets/market/src/fixed_pricing.rs diff --git a/pallets/market/src/fixed_pricing.rs b/pallets/market/src/fixed_pricing.rs new file mode 100644 index 0000000..fbeda6f --- /dev/null +++ b/pallets/market/src/fixed_pricing.rs @@ -0,0 +1,101 @@ +// This file is part of RegionX. +// +// RegionX 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. +// +// RegionX 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 RegionX. If not, see . + +use crate::{frame_system::ensure_signed, *}; +use polkadot_sdk::frame_support::traits::{fungible::Mutate, nonfungible::Transfer}; + +pub struct FixedPricing(PhantomData); + +impl MarketT for FixedPricing { + type PriceData = BalanceOf; + + fn list_region( + who: T::AccountId, + region_id: RegionId, + price: Self::PriceData, + sale_recipient: Option, + ) -> DispatchResult { + ensure!(Listings::::get(region_id).is_none(), Error::::AlreadyListed); + + let region = T::Regions::region(®ion_id.into()).ok_or(Error::::UnknownRegion)?; + + T::Regions::lock(®ion_id.into(), Some(who.clone()))?; + + let sale_recipient = sale_recipient.unwrap_or(who.clone()); + Listings::::insert( + region_id, + Listing { + seller: who.clone(), + price_data: price, + sale_recipient: sale_recipient.clone(), + }, + ); + + Ok(()) + } + + fn unlist_region(who: T::AccountId, region_id: RegionId) -> DispatchResult { + let listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; + + ensure!(who == listing.seller, Error::::NotAllowed); + + Listings::::remove(region_id); + T::Regions::unlock(®ion_id.into(), None)?; + + Ok(()) + } + + fn update_region_price( + who: T::AccountId, + region_id: RegionId, + new_price: Self::PriceData, + ) -> DispatchResult { + let mut listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; + + // Only the seller can update the price + ensure!(who == listing.seller, Error::::NotAllowed); + + listing.price_data = new_price; + Listings::::insert(region_id, listing); + + Ok(()) + } + + fn purchase_region( + who: T::AccountId, + region_id: RegionId, + max_price: BalanceOf, + ) -> Result, DispatchError> { + let listing = Listings::::get(region_id).ok_or(Error::::NotListed)?; + + ensure!(who != listing.seller && who != listing.sale_recipient, Error::::NotAllowed); + + ensure!(listing.price_data <= max_price, Error::::PriceTooHigh); + T::Currency::transfer( + &who, + &listing.sale_recipient, + listing.price_data, + Preservation::Preserve, + )?; + + // Remove the region from sale: + Listings::::remove(region_id); + T::Regions::unlock(®ion_id.into(), None)?; + + T::Regions::transfer(®ion_id.into(), &who)?; + + Ok(listing.price_data) + } +} diff --git a/pallets/market/src/lib.rs b/pallets/market/src/lib.rs index 5dc88ba..d612ec4 100644 --- a/pallets/market/src/lib.rs +++ b/pallets/market/src/lib.rs @@ -31,6 +31,7 @@ mod types; pub use crate::types::*; pub mod dynamic_pricing; +pub mod fixed_pricing; #[cfg(test)] mod mock; From 19cc783f934bbe81dda484938557a4061ebfe426 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Mon, 8 Sep 2025 10:13:01 +0200 Subject: [PATCH 4/7] testing wip --- pallets/market/Cargo.toml | 1 + pallets/market/src/dynamic_pricing.rs | 2 +- pallets/market/src/fixed_pricing.rs | 4 +- pallets/market/src/mock.rs | 7 + pallets/market/src/tests.rs | 948 +++++++++++++++++--------- 5 files changed, 626 insertions(+), 336 deletions(-) diff --git a/pallets/market/Cargo.toml b/pallets/market/Cargo.toml index 0b2d675..a3b33c3 100644 --- a/pallets/market/Cargo.toml +++ b/pallets/market/Cargo.toml @@ -55,3 +55,4 @@ std = [ "polkadot-sdk/std", ] try-runtime = [ "polkadot-sdk/try-runtime" ] +dynamic-pricing = [] diff --git a/pallets/market/src/dynamic_pricing.rs b/pallets/market/src/dynamic_pricing.rs index 526a306..e7d98d5 100644 --- a/pallets/market/src/dynamic_pricing.rs +++ b/pallets/market/src/dynamic_pricing.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . -use crate::{frame_system::ensure_signed, *}; +use crate::*; use polkadot_sdk::frame_support::traits::{fungible::Mutate, nonfungible::Transfer}; pub struct DynamicPricing(PhantomData); diff --git a/pallets/market/src/fixed_pricing.rs b/pallets/market/src/fixed_pricing.rs index fbeda6f..e24af34 100644 --- a/pallets/market/src/fixed_pricing.rs +++ b/pallets/market/src/fixed_pricing.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . -use crate::{frame_system::ensure_signed, *}; +use crate::*; use polkadot_sdk::frame_support::traits::{fungible::Mutate, nonfungible::Transfer}; pub struct FixedPricing(PhantomData); @@ -29,7 +29,7 @@ impl MarketT for FixedPricing { ) -> DispatchResult { ensure!(Listings::::get(region_id).is_none(), Error::::AlreadyListed); - let region = T::Regions::region(®ion_id.into()).ok_or(Error::::UnknownRegion)?; + let _region = T::Regions::region(®ion_id.into()).ok_or(Error::::UnknownRegion)?; T::Regions::lock(®ion_id.into(), Some(who.clone()))?; diff --git a/pallets/market/src/mock.rs b/pallets/market/src/mock.rs index e3e69d5..b691bf7 100644 --- a/pallets/market/src/mock.rs +++ b/pallets/market/src/mock.rs @@ -13,7 +13,11 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . +#[cfg(feature = "dynamic-pricing")] use crate::dynamic_pricing::DynamicPricing; +#[cfg(not(feature = "dynamic-pricing"))] +use crate::fixed_pricing::FixedPricing; + use anyhow; use frame_support::{derive_impl, pallet_prelude::*, parameter_types, traits::Everything}; use frame_system::{config_preludes::TestDefaultConfig, DefaultConfig}; @@ -162,7 +166,10 @@ impl crate::Config for Test { type Regions = Regions; type RCBlockNumberProvider = RelayBlockNumberProvider; type TimeslicePeriod = ConstU64<80>; + #[cfg(feature = "dynamic-pricing")] type MarketImpl = DynamicPricing; + #[cfg(not(feature = "dynamic-pricing"))] + type MarketImpl = FixedPricing; type WeightInfo = (); } diff --git a/pallets/market/src/tests.rs b/pallets/market/src/tests.rs index 4445361..9160158 100644 --- a/pallets/market/src/tests.rs +++ b/pallets/market/src/tests.rs @@ -13,342 +13,624 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . -use crate::{dynamic_pricing::DynamicPricing, mock::*, *}; +use crate::{mock::*, *}; use frame_support::{assert_noop, assert_ok, traits::nonfungible::Mutate}; -use pallet_broker::{CoreMask, RegionRecord}; +use pallet_broker::CoreMask; use sp_runtime::{DispatchError::Token, TokenError}; -#[test] -fn calculate_region_price_works() { - new_test_ext().execute_with(|| { - assert_eq!( - DynamicPricing::::calculate_region_price( - RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, - RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, - 10 // timeslice price - ), - 80 // 8 * 10 - ); - - // Remains same until a timeslice passes: - RelayBlockNumber::set(79); - assert_eq!( - DynamicPricing::::calculate_region_price( - RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, - RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, - 10 // timeslice price - ), - 80 // 8 * 10 - ); - - RelayBlockNumber::set(80); - - // Reduced by one after a timeslice elapses: - assert_eq!( - DynamicPricing::::calculate_region_price( - RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, - RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, - 10 // timeslice price - ), - 70 // 7 * 10 - ); - - RelayBlockNumber::set(8 * 80); - // Expired region has no value: - assert_eq!( - DynamicPricing::::calculate_region_price( - RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, - RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, - 10 // timeslice price - ), - 0 - ); - }); +#[cfg(not(feature = "dynamic-pricing"))] +mod fixed_pricing_tests { + use super::*; + + #[test] + fn list_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert!(Regions::regions(®ion_id).is_none()); + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + assert!(Regions::regions(®ion_id).is_some()); + + let price = 1_000_000_000; + let recipient = 1; + + // Failure: Unknown region + + let mut fake_region = region_id; + fake_region.core = 42; + + assert_noop!( + Market::list_region(signer.clone(), fake_region, price, None), + Error::::UnknownRegion + ); + + // Should be working + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(recipient))); + + // Failure: Already listed + assert_noop!( + Market::list_region(signer, region_id, price, None), + Error::::AlreadyListed + ); + + // Check storage items + assert_eq!( + Market::listings(region_id), + Some(Listing { seller, price_data: price, sale_recipient: recipient }) + ); + + assert!(Regions::regions(region_id).unwrap().locked); + + // Check events + System::assert_last_event( + Event::Listed { region_id, price_data: price, seller, sale_recipient: recipient } + .into(), + ); + }); + } + + #[test] + fn unlist_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let price = 1_000_000_000; + + // Failure: NotListed + assert_noop!( + Market::unlist_region(signer.clone(), region_id), + Error::::NotListed + ); + + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); + assert_eq!( + Market::listings(region_id), + Some(Listing { seller, price_data: price, sale_recipient: seller }) + ); + + // Failure: NotAllowed + assert_noop!( + Market::unlist_region(RuntimeOrigin::signed(3), region_id), + Error::::NotAllowed + ); + + // Should be working now. + assert_ok!(Market::unlist_region(signer, region_id)); + + // Check storage items + assert!(Market::listings(region_id).is_none()); + assert!(Regions::regions(region_id).unwrap().locked == false); + + // Check events + System::assert_last_event(Event::Unlisted { region_id }.into()) + }); + } + + #[test] + fn update_region_price_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let price = 1_000_000_000; + let recipient = 1; + let new_price = 2_000_000_000; + + // Failure: NotListed + assert_noop!( + Market::update_region_price(signer.clone(), region_id, new_price), + Error::::NotListed + ); + + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(recipient))); + + // Failure: NotAllowed - only the seller can update the price + assert_noop!( + Market::update_region_price( + RuntimeOrigin::signed(3), + region_id, + new_price + ), + Error::::NotAllowed + ); + + // Should be working now + assert_ok!(Market::update_region_price(signer, region_id, new_price)); + + // Check storage + assert_eq!( + Market::listings(region_id), + Some(Listing { + seller, + price_data: new_price, + sale_recipient: recipient + }) + ); + + // Check events + System::assert_last_event( + Event::::PriceUpdated { region_id, price_data: new_price }.into(), + ); + }); + } + + #[test] + fn purchase_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let buyer = 420; + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let price = 1_000_000_000; + let recipient = 1; + + // Failure: NotListed + assert_noop!( + Market::purchase_region( + RuntimeOrigin::signed(seller), + region_id, + price + ), + Error::::NotListed + ); + + assert_ok!(Market::list_region( + RuntimeOrigin::signed(seller), + region_id, + price, + Some(recipient) + )); + + // Failure: NotAllowed + assert_noop!( + Market::purchase_region(RuntimeOrigin::signed(seller), region_id, price), + Error::::NotAllowed + ); + assert_noop!( + Market::purchase_region( + RuntimeOrigin::signed(recipient), + region_id, + price + ), + Error::::NotAllowed + ); + + // Failure: PriceTooHigh + assert_noop!( + Market::purchase_region(RuntimeOrigin::signed(buyer), region_id, price - 1), + Error::::PriceTooHigh + ); + + // Failure: Insufficient Balance + let balance_buyer_old = Balances::free_balance(buyer); + println!("{:?}", balance_buyer_old); + assert_ok!(Balances::force_set_balance( + RuntimeOrigin::root(), + buyer, + price - 100, + )); + assert_noop!( + Market::purchase_region( + RuntimeOrigin::signed(buyer), + region_id, + price + ), + Token(TokenError::FundsUnavailable) + ); + assert_ok!(Balances::transfer_keep_alive( + RuntimeOrigin::signed(seller), + buyer, + 100 + )); + + // Should be working + let balance_recipient_old = Balances::free_balance(recipient); + let balance_buyer_old = Balances::free_balance(buyer); + + assert_ok!(Market::purchase_region( + RuntimeOrigin::signed(buyer), + region_id, + price + )); + + // Check storage items + assert!(Market::listings(region_id).is_none()); + assert!(Regions::regions(region_id).unwrap().locked == false); + + // Check events + System::assert_last_event( + Event::Purchased { region_id, buyer, total_price: price }.into(), + ); + + // Check account balances + let balance_recipient = Balances::free_balance(recipient); + assert_eq!(balance_recipient, balance_recipient_old + price); + + let balance_buyer = Balances::free_balance(buyer); + assert_eq!(balance_buyer.saturating_add(price), balance_buyer_old); + }); + } } -#[test] -fn list_region_works() { - new_test_ext().execute_with(|| { - let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; - let seller = 2; - let signer = RuntimeOrigin::signed(seller); - - assert!(Regions::regions(®ion_id).is_none()); - assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); - - let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; - let timeslice: u64 = <::TimeslicePeriod as Get>::get(); - let price = 1_000_000; - let recipient = 1; - - // Failure: Unknown region - - assert_noop!( - Market::list_region(signer.clone(), region_id, price, None), - Error::::RecordUnavailable - ); - - assert_ok!(Regions::set_record(region_id, record.clone())); - - // Failure: Region expired - RelayBlockNumber::set(10 * timeslice); - - assert_noop!( - Market::list_region(signer.clone(), region_id, price, None), - Error::::RegionExpired - ); - - // Should be working - RelayBlockNumber::set(1 * timeslice); - assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(recipient))); - - // Failure: Already listed - assert_noop!( - Market::list_region(signer, region_id, price, None), - Error::::AlreadyListed - ); - - // Check storage items - assert_eq!( - Market::listings(region_id), - Some(Listing { seller, price_data: price, sale_recipient: recipient }) - ); - - assert!(Regions::regions(region_id).unwrap().locked); - - // Check events - System::assert_last_event( - Event::Listed { region_id, price_data: price, seller, sale_recipient: recipient } - .into(), - ); - }); -} - -#[test] -fn unlist_region_works() { - new_test_ext().execute_with(|| { - let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; - let seller = 2; - let signer = RuntimeOrigin::signed(seller); - - assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); - - let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(seller), paid: None }; - let price = 1_000_000; - - assert_ok!(Regions::set_record(region_id, record.clone())); - - // Failure: NotListed - assert_noop!(Market::unlist_region(signer.clone(), region_id), Error::::NotListed); - - assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); - assert_eq!( - Market::listings(region_id), - Some(Listing { seller, price_data: price, sale_recipient: seller }) - ); - - // Failure: NotAllowed - assert_noop!( - Market::unlist_region(RuntimeOrigin::signed(3), region_id), - Error::::NotAllowed - ); - - // Should be working now. - assert_ok!(Market::unlist_region(signer, region_id)); - - // Check storage items - assert!(Market::listings(region_id).is_none()); - assert!(Regions::regions(region_id).unwrap().locked == false); - - // Check events - System::assert_last_event(Event::Unlisted { region_id }.into()) - }); -} - -#[test] -fn unlist_expired_region_works() { - new_test_ext().execute_with(|| { - let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; - let seller = 2; - let signer = RuntimeOrigin::signed(seller); - - assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); - - let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(seller), paid: None }; - let timeslice: u64 = <::TimeslicePeriod as Get>::get(); - let price = 1_000_000; - - assert_ok!(Regions::set_record(region_id, record.clone())); - - // Failure: NotListed - assert_noop!(Market::unlist_region(signer.clone(), region_id), Error::::NotListed); - - assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); - assert_eq!( - Market::listings(region_id), - Some(Listing { seller, price_data: price, sale_recipient: seller }) - ); - - RelayBlockNumber::set(9 * timeslice); - - // Anyone can unlist an expired region. - assert_ok!(Market::unlist_region(RuntimeOrigin::signed(3), region_id)); - - // Check events - System::assert_last_event(Event::Unlisted { region_id }.into()); - - // Check storage items - assert!(Market::listings(region_id).is_none()); - assert!(Regions::regions(region_id).unwrap().locked == false); - }); -} - -#[test] -fn update_region_price_works() { - new_test_ext().execute_with(|| { - let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; - let seller = 2; - let signer = RuntimeOrigin::signed(seller); - - assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); - - let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; - let timeslice: u64 = <::TimeslicePeriod as Get>::get(); - let price = 1_000_000; - let recipient = 1; - let new_timeslice_price = 2_000_000; - - assert_ok!(Regions::set_record(region_id, record.clone())); - - // Failure: NotListed - assert_noop!( - Market::update_region_price(signer.clone(), region_id, new_timeslice_price), - Error::::NotListed - ); - - assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(recipient))); - - // Failure: NotAllowed - only the seller can update the price - assert_noop!( - Market::update_region_price(RuntimeOrigin::signed(3), region_id, new_timeslice_price), - Error::::NotAllowed - ); - - // Failure: RegionExpired - RelayBlockNumber::set(10 * timeslice); - assert_noop!( - Market::update_region_price(signer.clone(), region_id, new_timeslice_price), - Error::::RegionExpired - ); - - // Should be working now - RelayBlockNumber::set(2 * timeslice); - assert_ok!(Market::update_region_price(signer, region_id, new_timeslice_price)); - - // Check storage - assert_eq!( - Market::listings(region_id), - Some(Listing { seller, price_data: new_timeslice_price, sale_recipient: recipient }) - ); - - // Check events - System::assert_last_event( - Event::::PriceUpdated { region_id, price_data: new_timeslice_price }.into(), - ); - }); -} - -#[test] -fn purchase_region_works() { - new_test_ext().execute_with(|| { - let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; - let seller = 2; - let buyer = 3; - - assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); - - let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; - let timeslice: u64 = <::TimeslicePeriod as Get>::get(); - let timeslice_price = 1_000_000; - let recipient = 1; - - assert_ok!(Regions::set_record(region_id, record.clone())); - - // Failure: NotListed - assert_noop!( - Market::purchase_region(RuntimeOrigin::signed(seller), region_id, 1 * timeslice_price), - Error::::NotListed - ); - - assert_ok!(Market::list_region( - RuntimeOrigin::signed(seller), - region_id, - timeslice_price, - Some(recipient) - )); - - // Failure: NotAllowed - assert_noop!( - Market::purchase_region(RuntimeOrigin::signed(seller), region_id, timeslice_price), - Error::::NotAllowed - ); - assert_noop!( - Market::purchase_region(RuntimeOrigin::signed(recipient), region_id, timeslice_price), - Error::::NotAllowed - ); - - // Failure: PriceTooHigh - RelayBlockNumber::set(timeslice); - assert_noop!( - Market::purchase_region(RuntimeOrigin::signed(buyer), region_id, timeslice_price), - Error::::PriceTooHigh - ); - - // Failure: Insufficient Balance - let balance_buyer_old = Balances::free_balance(buyer); - assert_ok!(Balances::transfer_keep_alive( - RuntimeOrigin::signed(buyer), - seller, - balance_buyer_old.saturating_sub(3 * timeslice_price), - )); - assert_noop!( - Market::purchase_region(RuntimeOrigin::signed(buyer), region_id, 8 * timeslice_price), - Token(TokenError::FundsUnavailable) - ); - assert_ok!(Balances::transfer_keep_alive( - RuntimeOrigin::signed(seller), - buyer, - 2 * timeslice_price - )); - - // Should be working - let balance_recipient_old = Balances::free_balance(recipient); - let balance_buyer_old = Balances::free_balance(buyer); - - RelayBlockNumber::set(4 * timeslice); - let price = 4 * timeslice_price; - assert_eq!( - DynamicPricing::::calculate_region_price(region_id, record, timeslice_price), - price - ); - assert_ok!(Market::purchase_region( - RuntimeOrigin::signed(buyer), - region_id, - 5 * timeslice_price - )); - - // Check storage items - assert!(Market::listings(region_id).is_none()); - assert!(Regions::regions(region_id).unwrap().locked == false); - - // Check events - System::assert_last_event(Event::Purchased { region_id, buyer, total_price: price }.into()); - - // Check account balances - let balance_recipient = Balances::free_balance(recipient); - assert_eq!(balance_recipient, balance_recipient_old + price); - - let balance_buyer = Balances::free_balance(buyer); - assert_eq!(balance_buyer.saturating_add(price), balance_buyer_old); - }); +#[cfg(feature = "dynamic-pricing")] +mod dynamic_pricing_tests { + use super::*; + use dynamic_pricing::DynamicPricing; + use pallet_broker::RegionRecord; + + #[test] + fn calculate_region_price_works() { + new_test_ext().execute_with(|| { + assert_eq!( + DynamicPricing::::calculate_region_price( + RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, + RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, + 10 // timeslice price + ), + 80 // 8 * 10 + ); + + // Remains same until a timeslice passes: + RelayBlockNumber::set(79); + assert_eq!( + DynamicPricing::::calculate_region_price( + RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, + RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, + 10 // timeslice price + ), + 80 // 8 * 10 + ); + + RelayBlockNumber::set(80); + + // Reduced by one after a timeslice elapses: + assert_eq!( + DynamicPricing::::calculate_region_price( + RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, + RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, + 10 // timeslice price + ), + 70 // 7 * 10 + ); + + RelayBlockNumber::set(8 * 80); + // Expired region has no value: + assert_eq!( + DynamicPricing::::calculate_region_price( + RegionId { begin: 0, core: 0, mask: CoreMask::complete() }, + RegionRecordOf:: { end: 8, owner: Some(1), paid: None }, + 10 // timeslice price + ), + 0 + ); + }); + } + + #[test] + fn list_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert!(Regions::regions(®ion_id).is_none()); + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); + let price = 1_000_000; + let recipient = 1; + + // Failure: Unknown region + + assert_noop!( + Market::list_region(signer.clone(), region_id, price, None), + Error::::RecordUnavailable + ); + + assert_ok!(Regions::set_record(region_id, record.clone())); + + // Failure: Region expired + RelayBlockNumber::set(10 * timeslice); + + assert_noop!( + Market::list_region(signer.clone(), region_id, price, None), + Error::::RegionExpired + ); + + // Should be working + RelayBlockNumber::set(1 * timeslice); + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(recipient))); + + // Failure: Already listed + assert_noop!( + Market::list_region(signer, region_id, price, None), + Error::::AlreadyListed + ); + + // Check storage items + assert_eq!( + Market::listings(region_id), + Some(Listing { seller, price_data: price, sale_recipient: recipient }) + ); + + assert!(Regions::regions(region_id).unwrap().locked); + + // Check events + System::assert_last_event( + Event::Listed { region_id, price_data: price, seller, sale_recipient: recipient } + .into(), + ); + }); + } + + #[test] + fn unlist_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let record: RegionRecordOf = + RegionRecord { end: 8, owner: Some(seller), paid: None }; + let price = 1_000_000; + + assert_ok!(Regions::set_record(region_id, record.clone())); + + // Failure: NotListed + assert_noop!( + Market::unlist_region(signer.clone(), region_id), + Error::::NotListed + ); + + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); + assert_eq!( + Market::listings(region_id), + Some(Listing { seller, price_data: price, sale_recipient: seller }) + ); + + // Failure: NotAllowed + assert_noop!( + Market::unlist_region(RuntimeOrigin::signed(3), region_id), + Error::::NotAllowed + ); + + // Should be working now. + assert_ok!(Market::unlist_region(signer, region_id)); + + // Check storage items + assert!(Market::listings(region_id).is_none()); + assert!(Regions::regions(region_id).unwrap().locked == false); + + // Check events + System::assert_last_event(Event::Unlisted { region_id }.into()) + }); + } + + #[test] + fn unlist_expired_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let record: RegionRecordOf = + RegionRecord { end: 8, owner: Some(seller), paid: None }; + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); + let price = 1_000_000; + + assert_ok!(Regions::set_record(region_id, record.clone())); + + // Failure: NotListed + assert_noop!( + Market::unlist_region(signer.clone(), region_id), + Error::::NotListed + ); + + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(seller))); + assert_eq!( + Market::listings(region_id), + Some(Listing { seller, price_data: price, sale_recipient: seller }) + ); + + RelayBlockNumber::set(9 * timeslice); + + // Anyone can unlist an expired region. + assert_ok!(Market::unlist_region(RuntimeOrigin::signed(3), region_id)); + + // Check events + System::assert_last_event(Event::Unlisted { region_id }.into()); + + // Check storage items + assert!(Market::listings(region_id).is_none()); + assert!(Regions::regions(region_id).unwrap().locked == false); + }); + } + + #[test] + fn update_region_price_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let signer = RuntimeOrigin::signed(seller); + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); + let price = 1_000_000; + let recipient = 1; + let new_timeslice_price = 2_000_000; + + assert_ok!(Regions::set_record(region_id, record.clone())); + + // Failure: NotListed + assert_noop!( + Market::update_region_price(signer.clone(), region_id, new_timeslice_price), + Error::::NotListed + ); + + assert_ok!(Market::list_region(signer.clone(), region_id, price, Some(recipient))); + + // Failure: NotAllowed - only the seller can update the price + assert_noop!( + Market::update_region_price( + RuntimeOrigin::signed(3), + region_id, + new_timeslice_price + ), + Error::::NotAllowed + ); + + // Failure: RegionExpired + RelayBlockNumber::set(10 * timeslice); + assert_noop!( + Market::update_region_price(signer.clone(), region_id, new_timeslice_price), + Error::::RegionExpired + ); + + // Should be working now + RelayBlockNumber::set(2 * timeslice); + assert_ok!(Market::update_region_price(signer, region_id, new_timeslice_price)); + + // Check storage + assert_eq!( + Market::listings(region_id), + Some(Listing { + seller, + price_data: new_timeslice_price, + sale_recipient: recipient + }) + ); + + // Check events + System::assert_last_event( + Event::::PriceUpdated { region_id, price_data: new_timeslice_price }.into(), + ); + }); + } + + #[test] + fn purchase_region_works() { + new_test_ext().execute_with(|| { + let region_id = RegionId { begin: 0, core: 0, mask: CoreMask::complete() }; + let seller = 2; + let buyer = 3; + + assert_ok!(Regions::mint_into(®ion_id.into(), &seller)); + + let record: RegionRecordOf = RegionRecord { end: 8, owner: Some(1), paid: None }; + let timeslice: u64 = <::TimeslicePeriod as Get>::get(); + let timeslice_price = 1_000_000; + let recipient = 1; + + assert_ok!(Regions::set_record(region_id, record.clone())); + + // Failure: NotListed + assert_noop!( + Market::purchase_region( + RuntimeOrigin::signed(seller), + region_id, + 1 * timeslice_price + ), + Error::::NotListed + ); + + assert_ok!(Market::list_region( + RuntimeOrigin::signed(seller), + region_id, + timeslice_price, + Some(recipient) + )); + + // Failure: NotAllowed + assert_noop!( + Market::purchase_region(RuntimeOrigin::signed(seller), region_id, timeslice_price), + Error::::NotAllowed + ); + assert_noop!( + Market::purchase_region( + RuntimeOrigin::signed(recipient), + region_id, + timeslice_price + ), + Error::::NotAllowed + ); + + // Failure: PriceTooHigh + RelayBlockNumber::set(timeslice); + assert_noop!( + Market::purchase_region(RuntimeOrigin::signed(buyer), region_id, timeslice_price), + Error::::PriceTooHigh + ); + + // Failure: Insufficient Balance + let balance_buyer_old = Balances::free_balance(buyer); + assert_ok!(Balances::transfer_keep_alive( + RuntimeOrigin::signed(buyer), + seller, + balance_buyer_old.saturating_sub(3 * timeslice_price), + )); + assert_noop!( + Market::purchase_region( + RuntimeOrigin::signed(buyer), + region_id, + 8 * timeslice_price + ), + Token(TokenError::FundsUnavailable) + ); + assert_ok!(Balances::transfer_keep_alive( + RuntimeOrigin::signed(seller), + buyer, + 2 * timeslice_price + )); + + // Should be working + let balance_recipient_old = Balances::free_balance(recipient); + let balance_buyer_old = Balances::free_balance(buyer); + + RelayBlockNumber::set(4 * timeslice); + let price = 4 * timeslice_price; + assert_eq!( + DynamicPricing::::calculate_region_price(region_id, record, timeslice_price), + price + ); + assert_ok!(Market::purchase_region( + RuntimeOrigin::signed(buyer), + region_id, + 5 * timeslice_price + )); + + // Check storage items + assert!(Market::listings(region_id).is_none()); + assert!(Regions::regions(region_id).unwrap().locked == false); + + // Check events + System::assert_last_event( + Event::Purchased { region_id, buyer, total_price: price }.into(), + ); + + // Check account balances + let balance_recipient = Balances::free_balance(recipient); + assert_eq!(balance_recipient, balance_recipient_old + price); + + let balance_buyer = Balances::free_balance(buyer); + assert_eq!(balance_buyer.saturating_add(price), balance_buyer_old); + }); + } } From f0bad4872de67d6f303cbf3156279e270f3233d1 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Mon, 8 Sep 2025 10:16:14 +0200 Subject: [PATCH 5/7] fix test & fmt --- pallets/market/src/tests.rs | 50 ++++++------------------------------- 1 file changed, 8 insertions(+), 42 deletions(-) diff --git a/pallets/market/src/tests.rs b/pallets/market/src/tests.rs index 9160158..97dd271 100644 --- a/pallets/market/src/tests.rs +++ b/pallets/market/src/tests.rs @@ -135,11 +135,7 @@ mod fixed_pricing_tests { // Failure: NotAllowed - only the seller can update the price assert_noop!( - Market::update_region_price( - RuntimeOrigin::signed(3), - region_id, - new_price - ), + Market::update_region_price(RuntimeOrigin::signed(3), region_id, new_price), Error::::NotAllowed ); @@ -149,11 +145,7 @@ mod fixed_pricing_tests { // Check storage assert_eq!( Market::listings(region_id), - Some(Listing { - seller, - price_data: new_price, - sale_recipient: recipient - }) + Some(Listing { seller, price_data: new_price, sale_recipient: recipient }) ); // Check events @@ -177,11 +169,7 @@ mod fixed_pricing_tests { // Failure: NotListed assert_noop!( - Market::purchase_region( - RuntimeOrigin::signed(seller), - region_id, - price - ), + Market::purchase_region(RuntimeOrigin::signed(seller), region_id, price), Error::::NotListed ); @@ -198,11 +186,7 @@ mod fixed_pricing_tests { Error::::NotAllowed ); assert_noop!( - Market::purchase_region( - RuntimeOrigin::signed(recipient), - region_id, - price - ), + Market::purchase_region(RuntimeOrigin::signed(recipient), region_id, price), Error::::NotAllowed ); @@ -213,36 +197,18 @@ mod fixed_pricing_tests { ); // Failure: Insufficient Balance - let balance_buyer_old = Balances::free_balance(buyer); - println!("{:?}", balance_buyer_old); - assert_ok!(Balances::force_set_balance( - RuntimeOrigin::root(), - buyer, - price - 100, - )); + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), buyer, price - 100,)); assert_noop!( - Market::purchase_region( - RuntimeOrigin::signed(buyer), - region_id, - price - ), + Market::purchase_region(RuntimeOrigin::signed(buyer), region_id, price), Token(TokenError::FundsUnavailable) ); - assert_ok!(Balances::transfer_keep_alive( - RuntimeOrigin::signed(seller), - buyer, - 100 - )); + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), buyer, price + 100)); // Should be working let balance_recipient_old = Balances::free_balance(recipient); let balance_buyer_old = Balances::free_balance(buyer); - assert_ok!(Market::purchase_region( - RuntimeOrigin::signed(buyer), - region_id, - price - )); + assert_ok!(Market::purchase_region(RuntimeOrigin::signed(buyer), region_id, price)); // Check storage items assert!(Market::listings(region_id).is_none()); From 2811ec437fe363c48fbcc3cb2834d103c2f75878 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Mon, 8 Sep 2025 12:27:52 +0200 Subject: [PATCH 6/7] fix benchmark --- pallets/market/src/benchmarking.rs | 21 +++++++++++++-------- pallets/market/src/lib.rs | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pallets/market/src/benchmarking.rs b/pallets/market/src/benchmarking.rs index 237b9f1..e81dc26 100644 --- a/pallets/market/src/benchmarking.rs +++ b/pallets/market/src/benchmarking.rs @@ -43,14 +43,14 @@ mod benchmarks { RegionRecord { end: 8, owner: Some(caller.clone()), paid: None }; T::Regions::create_region(region_id, record, caller.clone())?; - let timeslice_price: BalanceOf = 1_000u32.into(); + let price_data = 1_000u32; #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), region_id, timeslice_price, None); + _(RawOrigin::Signed(caller.clone()), region_id, price_data.into(), None); assert_last_event::( Event::Listed { region_id, - timeslice_price, + price_data: price_data.into(), seller: caller.clone(), sale_recipient: caller, } @@ -69,11 +69,11 @@ mod benchmarks { RegionRecord { end: 8, owner: Some(caller.clone()), paid: None }; T::Regions::create_region(region_id, record, caller.clone())?; - let timeslice_price: BalanceOf = 1_000u32.into(); + let price_data: >::PriceData = 1_000u32.into(); crate::Pallet::::list_region( RawOrigin::Signed(caller.clone()).into(), region_id, - timeslice_price, + price_data, None, )?; @@ -101,11 +101,13 @@ mod benchmarks { None, )?; - let new_timeslice_price = 2_000u32.into(); + let new_price_data: >::PriceData = 2_000u32.into(); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), region_id, new_timeslice_price); + _(RawOrigin::Signed(caller.clone()), region_id, new_price_data.clone()); - assert_last_event::(Event::PriceUpdated { region_id, new_timeslice_price }.into()); + assert_last_event::( + Event::PriceUpdated { region_id, price_data: new_price_data }.into(), + ); Ok(()) } @@ -129,7 +131,10 @@ mod benchmarks { )?; ::Currency::set_balance(&caller.clone(), u32::MAX.into()); + #[cfg(feature = "dynamic-pricing")] let max_price = 8000u32.into(); + #[cfg(not(feature = "dynamic-pricing"))] + let max_price = 1000u32.into(); #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), region_id, max_price); diff --git a/pallets/market/src/lib.rs b/pallets/market/src/lib.rs index d612ec4..0c984b6 100644 --- a/pallets/market/src/lib.rs +++ b/pallets/market/src/lib.rs @@ -53,7 +53,7 @@ pub type RCBlockNumberOf = <::RCBlockNumberProvider as BlockNumberProvider>::BlockNumber; pub trait MarketT { - type PriceData: Parameter; + type PriceData: Parameter + From; fn list_region( who: T::AccountId, From 61daa0ca5271d3478b822c01d2fb005d5bcc8b54 Mon Sep 17 00:00:00 2001 From: Szegoo Date: Mon, 8 Sep 2025 12:46:33 +0200 Subject: [PATCH 7/7] fix runtime build --- pallets/market/src/dynamic_pricing.rs | 2 ++ pallets/market/src/lib.rs | 6 ++++-- runtime/kusama/src/lib.rs | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pallets/market/src/dynamic_pricing.rs b/pallets/market/src/dynamic_pricing.rs index e7d98d5..6b6a503 100644 --- a/pallets/market/src/dynamic_pricing.rs +++ b/pallets/market/src/dynamic_pricing.rs @@ -14,7 +14,9 @@ // along with RegionX. If not, see . use crate::*; +use pallet_broker::Timeslice; use polkadot_sdk::frame_support::traits::{fungible::Mutate, nonfungible::Transfer}; +use sp_runtime::traits::{SaturatedConversion, Saturating}; pub struct DynamicPricing(PhantomData); diff --git a/pallets/market/src/lib.rs b/pallets/market/src/lib.rs index 0c984b6..c974434 100644 --- a/pallets/market/src/lib.rs +++ b/pallets/market/src/lib.rs @@ -22,15 +22,17 @@ use frame_support::{ use frame_system::pallet_prelude::OriginFor; use nonfungible_primitives::LockableNonFungible; pub use pallet::*; -use pallet_broker::{RegionId, Timeslice}; +use pallet_broker::RegionId; use polkadot_sdk::*; use region_primitives::{RegionFactory, RegionInspect}; -use sp_runtime::{traits::BlockNumberProvider, SaturatedConversion, Saturating}; +use sp_runtime::traits::BlockNumberProvider; mod types; pub use crate::types::*; +#[cfg(feature = "dynamic-pricing")] pub mod dynamic_pricing; +#[cfg(not(feature = "dynamic-pricing"))] pub mod fixed_pricing; #[cfg(test)] diff --git a/runtime/kusama/src/lib.rs b/runtime/kusama/src/lib.rs index 8ac51ec..823e51b 100644 --- a/runtime/kusama/src/lib.rs +++ b/runtime/kusama/src/lib.rs @@ -648,12 +648,15 @@ impl pallet_scheduler::Config for Runtime { type Preimages = Preimage; } +use pallet_market::fixed_pricing::FixedPricing; + impl pallet_market::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Currency = Balances; type Regions = Regions; type RCBlockNumberProvider = RelaychainDataProvider; type TimeslicePeriod = ConstU32<80>; + type MarketImpl = FixedPricing; type WeightInfo = weights::pallet_market::WeightInfo; }