Skip to content

Commit

Permalink
Market: region unlist (#19)
Browse files Browse the repository at this point in the history
* init coretime-market

* add storage

* cross-contract calling

* list_region

* init e2e tests

* add enumeration

* XcRegions: fix remove

* deposit docs

* current_timeslice

* list_region untested

* purchase_region implemented

* fix

* calculate_region_price wip

* fix calculate_region_price

* current_timeslice | wip

* use sp-arithmetic

* fix attempt

* introduce reference points

* init e2e js tests

* constructors work

* extrinsic calls work

* listing works

* listing fully tested

* init purchase tests

* use block-number provider

* tests passing

* more tests

* check events

* timeslice based pricing

* e2e tests passing

* fixes

* remove artifacts & types

* remove unused

* clippy

* Market: unlist region

* add e2e test

* config

* fix tests
  • Loading branch information
Szegoo authored Feb 5, 2024
1 parent 6772945 commit 26e4a3e
Show file tree
Hide file tree
Showing 7 changed files with 395 additions and 62 deletions.
152 changes: 109 additions & 43 deletions contracts/coretime_market/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ mod types;

#[openbrush::contract(env = environment::ExtendedEnvironment)]
pub mod coretime_market {
use crate::types::{Listing, MarketError};
use crate::types::{Config, Listing, MarketError};
use block_number_extension::BlockNumberProviderExtension;
use environment::ExtendedEnvironment;
use ink::{
Expand All @@ -49,7 +49,7 @@ pub mod coretime_market {
};
use openbrush::{contracts::traits::psp34::Id, storage::Mapping, traits::Storage};
use primitives::{
coretime::{RawRegionId, Region, Timeslice, CORE_MASK_BIT_LEN, TIMESLICE_PERIOD},
coretime::{RawRegionId, Region, Timeslice, CORE_MASK_BIT_LEN},
ensure, Version,
};
use sp_arithmetic::{traits::SaturatedConversion, FixedPointNumber, FixedU128};
Expand All @@ -58,20 +58,13 @@ pub mod coretime_market {
#[ink(storage)]
#[derive(Storage)]
pub struct CoretimeMarket {
/// A mapping that holds information about each region listed for sale.
/// A mapping that holds information about each region listed on sale.
pub listings: Mapping<RawRegionId, Listing>,
/// A vector containing all the region ids of regions listed on sale.
///
/// TODO: incentivize the removal of expired regions.
/// A vector containing all the regions listed on sale.
pub listed_regions: Vec<RawRegionId>,
/// The `AccountId` of the xc-regions contract.
///
/// Set on contract initialization. Can't be changed afterwards.
pub xc_regions_contract: AccountId,
/// The deposit required to list a region on sale.
///
/// Set on contract initialization. Can't be changed afterwards.
pub listing_deposit: Balance,
/// The configuration of the market. Set on contract initialization. Can't be changed
/// afterwards.
pub config: Config,
}

#[ink(event)]
Expand All @@ -89,6 +82,15 @@ pub mod coretime_market {
pub(crate) metadata_version: Version,
}

#[ink(event)]
pub struct RegionUnlisted {
/// The identifier of the region that got listed on sale.
#[ink(topic)]
pub(crate) region_id: RawRegionId,
/// The account that removed the region from sale.
pub(crate) caller: AccountId,
}

#[ink(event)]
pub struct RegionPurchased {
/// The identifier of the region that got listed on sale.
Expand All @@ -102,18 +104,21 @@ pub mod coretime_market {

impl CoretimeMarket {
#[ink(constructor)]
pub fn new(xc_regions_contract: AccountId, listing_deposit: Balance) -> Self {
pub fn new(
xc_regions_contract: AccountId,
listing_deposit: Balance,
timeslice_period: BlockNumber,
) -> Self {
Self {
listings: Default::default(),
listed_regions: Default::default(),
xc_regions_contract,
listing_deposit,
config: Config { xc_regions_contract, listing_deposit, timeslice_period },
}
}

#[ink(message)]
pub fn xc_regions_contract(&self) -> AccountId {
self.xc_regions_contract
self.config.xc_regions_contract
}

#[ink(message)]
Expand All @@ -131,8 +136,9 @@ pub mod coretime_market {
pub fn region_price(&self, id: Id) -> Result<Balance, MarketError> {
let Id::U128(region_id) = id else { return Err(MarketError::InvalidRegionId) };

let metadata = RegionMetadataRef::get_metadata(&self.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;
let metadata =
RegionMetadataRef::get_metadata(&self.config.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;
let listing = self.listings.get(&region_id).ok_or(MarketError::RegionNotListed)?;

self.calculate_region_price(metadata.region, listing)
Expand Down Expand Up @@ -167,22 +173,28 @@ pub mod coretime_market {
let Id::U128(region_id) = id else { return Err(MarketError::InvalidRegionId) };

// Ensure that the region exists and its metadata is set.
let metadata = RegionMetadataRef::get_metadata(&self.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;
let metadata =
RegionMetadataRef::get_metadata(&self.config.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;

let current_timeslice = self.current_timeslice();

// It doesn't make sense to list a region that expired.
ensure!(metadata.region.end > current_timeslice, MarketError::RegionExpired);

ensure!(
self.env().transferred_value() == self.listing_deposit,
self.env().transferred_value() == self.config.listing_deposit,
MarketError::MissingDeposit
);

// Transfer the region to the market.
PSP34Ref::transfer(&self.xc_regions_contract, market, id.clone(), Default::default())
.map_err(MarketError::XcRegionsPsp34Error)?;
PSP34Ref::transfer(
&self.config.xc_regions_contract,
market,
id.clone(),
Default::default(),
)
.map_err(MarketError::XcRegionsPsp34Error)?;

let sale_recepient = sale_recepient.unwrap_or(caller);

Expand Down Expand Up @@ -213,9 +225,49 @@ pub mod coretime_market {
/// ## Arguments:
/// - `region_id`: The `u128` encoded identifier of the region that the caller intends to
/// unlist from sale.
///
/// In case the region is expired, this is callable by anyone and the caller will receive
/// the listing deposit as a reward.
#[ink(message)]
pub fn unlist_region(&self, _region_id: RawRegionId) -> Result<(), MarketError> {
todo!()
pub fn unlist_region(&mut self, id: Id) -> Result<(), MarketError> {
let caller = self.env().caller();

let Id::U128(region_id) = id else { return Err(MarketError::InvalidRegionId) };

let listing = self.listings.get(&region_id).ok_or(MarketError::RegionNotListed)?;
let metadata =
RegionMetadataRef::get_metadata(&self.config.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;

let current_timeslice = self.current_timeslice();

// If the region is expired this is callable by anyone, otherwise only the seller can
// unlist the region from the market.
ensure!(
caller == listing.seller || current_timeslice > metadata.region.end,
MarketError::NotAllowed
);

// Transfer the region to the seller.
PSP34Ref::transfer(
&self.config.xc_regions_contract,
listing.seller,
id.clone(),
Default::default(),
)
.map_err(MarketError::XcRegionsPsp34Error)?;

// Remove the region from sale:
self.remove_from_sale(region_id)?;

// Reward the caller with listing deposit.
self.env()
.transfer(caller, self.config.listing_deposit)
.map_err(|_| MarketError::TransferFailed)?;

self.emit_event(RegionUnlisted { region_id, caller });

Ok(())
}

/// A function for updating a listed region's bit price.
Expand Down Expand Up @@ -254,28 +306,26 @@ pub mod coretime_market {
let Id::U128(region_id) = id else { return Err(MarketError::InvalidRegionId) };
let listing = self.listings.get(&region_id).ok_or(MarketError::RegionNotListed)?;

let metadata = RegionMetadataRef::get_metadata(&self.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;
let metadata =
RegionMetadataRef::get_metadata(&self.config.xc_regions_contract, region_id)
.map_err(MarketError::XcRegionsMetadataError)?;

let price = self.calculate_region_price(metadata.region, listing.clone())?;
ensure!(transferred_value >= price, MarketError::InsufficientFunds);

ensure!(listing.metadata_version == metadata_version, MarketError::MetadataNotMatching);

// Transfer the region to the buyer.
PSP34Ref::transfer(&self.xc_regions_contract, caller, id.clone(), Default::default())
.map_err(MarketError::XcRegionsPsp34Error)?;
PSP34Ref::transfer(
&self.config.xc_regions_contract,
caller,
id.clone(),
Default::default(),
)
.map_err(MarketError::XcRegionsPsp34Error)?;

// Remove the region from sale:

let region_index = self
.listed_regions
.iter()
.position(|r| *r == region_id)
.ok_or(MarketError::RegionNotListed)?;

self.listed_regions.remove(region_index);
self.listings.remove(&region_id);
self.remove_from_sale(region_id)?;

// Transfer the tokens to the sale recipient.
self.env()
Expand Down Expand Up @@ -320,17 +370,31 @@ pub mod coretime_market {
Ok(price)
}

// Remove a region from sale
fn remove_from_sale(&mut self, region_id: RawRegionId) -> Result<(), MarketError> {
let region_index = self
.listed_regions
.iter()
.position(|r| *r == region_id)
.ok_or(MarketError::RegionNotListed)?;

self.listed_regions.remove(region_index);
self.listings.remove(&region_id);

Ok(())
}

#[cfg(not(test))]
pub(crate) fn current_timeslice(&self) -> Timeslice {
let latest_rc_block =
self.env().extension().relay_chain_block_number().unwrap_or_default();
(latest_rc_block / TIMESLICE_PERIOD).saturated_into()
(latest_rc_block / self.config.timeslice_period).saturated_into()
}

#[cfg(test)]
pub(crate) fn current_timeslice(&self) -> Timeslice {
let latest_block = self.env().block_number();
(latest_block / TIMESLICE_PERIOD).saturated_into()
(latest_block / self.config.timeslice_period).saturated_into()
}

fn emit_event<Event: Into<<CoretimeMarket as ContractEventBase>::Type>>(&self, e: Event) {
Expand All @@ -346,6 +410,7 @@ pub mod coretime_market {
use super::*;
use environment::ExtendedEnvironment;
use ink_e2e::MessageBuilder;
use primitives::coretime::TIMESLICE_PERIOD;
use xc_regions::xc_regions::XcRegionsRef;

type E2EResult<T> = Result<T, Box<dyn std::error::Error>>;
Expand All @@ -361,7 +426,8 @@ pub mod coretime_market {
.expect("instantiate failed")
.account_id;

let constructor = CoretimeMarketRef::new(xc_regions_acc_id, REQUIRED_DEPOSIT);
let constructor =
CoretimeMarketRef::new(xc_regions_acc_id, REQUIRED_DEPOSIT, TIMESLICE_PERIOD);
let market_acc_id = client
.instantiate("coretime-market", &ink_e2e::alice(), constructor, 0, None)
.await
Expand Down
2 changes: 1 addition & 1 deletion contracts/coretime_market/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use primitives::coretime::{CoreMask, Region, Timeslice, TIMESLICE_PERIOD};
fn calculate_region_price_works() {
let DefaultAccounts::<DefaultEnvironment> { charlie, .. } = get_default_accounts();

let market = CoretimeMarket::new(charlie, 0);
let market = CoretimeMarket::new(charlie, 0, TIMESLICE_PERIOD);
// Works for regions which haven't yet started.

// complete coremask, so 80 active bits.
Expand Down
20 changes: 19 additions & 1 deletion contracts/coretime_market/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,25 @@
// You should have received a copy of the GNU General Public License
// along with RegionX. If not, see <https://www.gnu.org/licenses/>.

use openbrush::{contracts::traits::psp34::PSP34Error, traits::AccountId};
use openbrush::{
contracts::traits::psp34::PSP34Error,
traits::{AccountId, BlockNumber},
};
use primitives::{Balance, Version};
use xc_regions::types::XcRegionsError;

/// The configuration of the coretime market
#[derive(scale::Decode, scale::Encode, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
pub struct Config {
/// The `AccountId` of the xc-regions contract.
pub xc_regions_contract: AccountId,
/// The deposit required to list a region on sale.
pub listing_deposit: Balance,
/// The duration of a timeslice in block numbers.
pub timeslice_period: BlockNumber,
}

#[derive(scale::Decode, scale::Encode, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum MarketError {
Expand All @@ -36,6 +51,8 @@ pub enum MarketError {
MetadataNotMatching,
/// Failed to transfer the tokens to the seller.
TransferFailed,
/// The caller tried to perform an operation that they have no permission for.
NotAllowed,
/// An error occured when calling the xc-regions contract through the psp34 interface.
XcRegionsPsp34Error(PSP34Error),
/// An error occured when calling the xc-regions contract through the metadata interface.
Expand All @@ -53,6 +70,7 @@ impl core::fmt::Display for MarketError {
MarketError::InsufficientFunds => write!(f, "InsufficientFunds"),
MarketError::MetadataNotMatching => write!(f, "MetadataNotMatching"),
MarketError::TransferFailed => write!(f, "TransferFailed"),
MarketError::NotAllowed => write!(f, "NotAllowed"),
MarketError::XcRegionsPsp34Error(e) => write!(f, "{:?}", e),
MarketError::XcRegionsMetadataError(e) => write!(f, "{}", e),
}
Expand Down
13 changes: 7 additions & 6 deletions tests/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import { Region } from 'coretime-utils';
const REGION_COLLECTION_ID = 42;

export async function createRegionCollection(api: ApiPromise, caller: KeyringPair): Promise<void> {
console.log(`Creating the region collection`);

const createCollectionCall = api.tx.uniques.create(REGION_COLLECTION_ID, caller.address);

const callTx = async (resolve: () => void, reject: ({ reason }) => void) => {
Expand Down Expand Up @@ -49,8 +47,6 @@ export async function mintRegion(
caller: KeyringPair,
region: Region,
): Promise<void> {
console.log(`Minting a region`);

const rawRegionId = region.getEncodedRegionId(api);
const mintCall = api.tx.uniques.mint(REGION_COLLECTION_ID, rawRegionId, caller.address);

Expand All @@ -76,8 +72,6 @@ export async function approveTransfer(
region: Region,
delegate: string,
): Promise<void> {
console.log(`Approving region to ${delegate}`);

const rawRegionId = region.getEncodedRegionId(api);
const approveCall = api.tx.uniques.approveTransfer(REGION_COLLECTION_ID, rawRegionId, delegate);

Expand Down Expand Up @@ -129,6 +123,13 @@ export async function balanceOf(api: ApiPromise, acc: string): Promise<number> {
return parseHNString(account.data.free);
}

export async function getBlockNumber(api: ApiPromise): Promise<number> {
const num = (await api.query.system.number()).toHuman();
return parseHNString(num.toString());
}

export function parseHNString(str: string): number {
return parseInt(str.replace(/,/g, ''));
}

export const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
Loading

0 comments on commit 26e4a3e

Please sign in to comment.