diff --git a/contracts/coretime_market/src/lib.rs b/contracts/coretime_market/src/lib.rs index d9703d1..752afb4 100755 --- a/contracts/coretime_market/src/lib.rs +++ b/contracts/coretime_market/src/lib.rs @@ -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::{ @@ -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}; @@ -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, - /// 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, - /// 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)] @@ -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. @@ -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)] @@ -131,8 +136,9 @@ pub mod coretime_market { pub fn region_price(&self, id: Id) -> Result { 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(®ion_id).ok_or(MarketError::RegionNotListed)?; self.calculate_region_price(metadata.region, listing) @@ -167,8 +173,9 @@ 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(); @@ -176,13 +183,18 @@ pub mod coretime_market { 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); @@ -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(®ion_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. @@ -254,8 +306,9 @@ pub mod coretime_market { let Id::U128(region_id) = id else { return Err(MarketError::InvalidRegionId) }; let listing = self.listings.get(®ion_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); @@ -263,19 +316,16 @@ pub mod coretime_market { 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(®ion_id); + self.remove_from_sale(region_id)?; // Transfer the tokens to the sale recipient. self.env() @@ -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(®ion_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::Type>>(&self, e: Event) { @@ -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 = Result>; @@ -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 diff --git a/contracts/coretime_market/src/tests.rs b/contracts/coretime_market/src/tests.rs index ebafd46..d760286 100644 --- a/contracts/coretime_market/src/tests.rs +++ b/contracts/coretime_market/src/tests.rs @@ -10,7 +10,7 @@ use primitives::coretime::{CoreMask, Region, Timeslice, TIMESLICE_PERIOD}; fn calculate_region_price_works() { let DefaultAccounts:: { 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. diff --git a/contracts/coretime_market/src/types.rs b/contracts/coretime_market/src/types.rs index 3000f00..5ff62f4 100644 --- a/contracts/coretime_market/src/types.rs +++ b/contracts/coretime_market/src/types.rs @@ -13,10 +13,25 @@ // You should have received a copy of the GNU General Public License // along with RegionX. If not, see . -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 { @@ -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. @@ -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), } diff --git a/tests/common.ts b/tests/common.ts index 19c2285..a9f7f7e 100644 --- a/tests/common.ts +++ b/tests/common.ts @@ -9,8 +9,6 @@ import { Region } from 'coretime-utils'; const REGION_COLLECTION_ID = 42; export async function createRegionCollection(api: ApiPromise, caller: KeyringPair): Promise { - 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) => { @@ -49,8 +47,6 @@ export async function mintRegion( caller: KeyringPair, region: Region, ): Promise { - console.log(`Minting a region`); - const rawRegionId = region.getEncodedRegionId(api); const mintCall = api.tx.uniques.mint(REGION_COLLECTION_ID, rawRegionId, caller.address); @@ -76,8 +72,6 @@ export async function approveTransfer( region: Region, delegate: string, ): Promise { - console.log(`Approving region to ${delegate}`); - const rawRegionId = region.getEncodedRegionId(api); const approveCall = api.tx.uniques.approveTransfer(REGION_COLLECTION_ID, rawRegionId, delegate); @@ -129,6 +123,13 @@ export async function balanceOf(api: ApiPromise, acc: string): Promise { return parseHNString(account.data.free); } +export async function getBlockNumber(api: ApiPromise): Promise { + 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((resolve) => setTimeout(resolve, ms)); diff --git a/tests/market/list.test.ts b/tests/market/list.test.ts index d2147d2..50c4379 100644 --- a/tests/market/list.test.ts +++ b/tests/market/list.test.ts @@ -10,17 +10,21 @@ import { CoreMask, Id, Region, RegionId, RegionRecord } from 'coretime-utils'; import { MarketErrorBuilder, PSP34ErrorBuilder } from '../../types/types-returns/coretime_market'; import { approveTransfer, + balanceOf, createRegionCollection, expectEvent, expectOnSale, initRegion, mintRegion, + wait, } from '../common'; use(chaiAsPromised); const REGION_COLLECTION_ID = 42; const LISTING_DEPOIST = 100; +// In reality this is 80, however we use 8 for testing. +const TIMESLICE_PERIOD = 8; const wsProvider = new WsProvider('ws://127.0.0.1:9944'); // Create a keyring instance @@ -43,7 +47,7 @@ describe('Coretime market listing', () => { const marketFactory = new Market_Factory(api, alice); market = new Market( - (await marketFactory.new(xcRegions.address, LISTING_DEPOIST)).address, + (await marketFactory.new(xcRegions.address, LISTING_DEPOIST, TIMESLICE_PERIOD)).address, alice, api, ); @@ -74,6 +78,8 @@ describe('Coretime market listing', () => { await xcRegions.withSigner(alice).tx.approve(market.address, id, true); + const aliceBalance = await balanceOf(api, alice.address); + const timeslicePrice = 50; const result = await market .withSigner(alice) @@ -91,6 +97,7 @@ describe('Coretime market listing', () => { timeslicePrice * (region.getEnd() - region.getBegin()), ); expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.deep.equal(market.address); + expect(await balanceOf(api, alice.address)).to.be.lessThan(aliceBalance - LISTING_DEPOIST); }); it('Listing requires listing deposit', async () => { @@ -115,8 +122,10 @@ describe('Coretime market listing', () => { await xcRegions.withSigner(alice).tx.approve(market.address, id, true); const timeslicePrice = 50; - const result = market.withSigner(alice).query.listRegion(id, timeslicePrice, alice.address); - expect((await result).value.unwrap().err).to.deep.equal(MarketErrorBuilder.MissingDeposit()); + const result = await market + .withSigner(alice) + .query.listRegion(id, timeslicePrice, alice.address); + expect(result.value.unwrap().err).to.deep.equal(MarketErrorBuilder.MissingDeposit()); }); it('Listing requires region to be approved to the market', async () => { @@ -169,12 +178,13 @@ describe('Coretime market listing', () => { const id: any = api.createType('Id', { U128: region.getEncodedRegionId(api) }); await xcRegions.withSigner(alice).tx.approve(market.address, id, true); - setTimeout(async () => { - const timeslicePrice = 50; - const result = await market - .withSigner(alice) - .query.listRegion(id, timeslicePrice, alice.address, { value: LISTING_DEPOIST }); - expect(result.value.unwrap().err).to.deep.equal(MarketErrorBuilder.RegionExpired()); - }, 6000); + // Wait for the region to expire. + await wait(2000 * TIMESLICE_PERIOD); + + const timeslicePrice = 50; + const result = await market + .withSigner(alice) + .query.listRegion(id, timeslicePrice, alice.address, { value: LISTING_DEPOIST }); + expect(result.value.unwrap().err).to.deep.equal(MarketErrorBuilder.RegionExpired()); }); }); diff --git a/tests/market/purchase.test.ts b/tests/market/purchase.test.ts index 975e24d..b9866fe 100644 --- a/tests/market/purchase.test.ts +++ b/tests/market/purchase.test.ts @@ -22,6 +22,8 @@ use(chaiAsPromised); const REGION_COLLECTION_ID = 42; const LISTING_DEPOIST = 0; +// In reality this is 80, however we use 8 for testing. +const TIMESLICE_PERIOD = 8; const wsProvider = new WsProvider('ws://127.0.0.1:9944'); // Create a keyring instance @@ -48,7 +50,7 @@ describe('Coretime market purchases', () => { const marketFactory = new Market_Factory(api, alice); market = new Market( - (await marketFactory.new(xcRegions.address, LISTING_DEPOIST)).address, + (await marketFactory.new(xcRegions.address, LISTING_DEPOIST, TIMESLICE_PERIOD)).address, alice, api, ); @@ -111,6 +113,10 @@ describe('Coretime market purchases', () => { ); // Alice's balance is increased. expect(await balanceOf(api, alice.address)).to.be.greaterThan(aliceBalance); + + // Ensure the region is removed from sale: + expect(market.query.listedRegions()).to.eventually.be.equal([]); + expect((await market.query.listedRegion(id)).value.unwrap().ok).to.be.equal(null); }); it('Purchasing fails when insufficient value is sent', async () => { @@ -225,5 +231,9 @@ describe('Coretime market purchases', () => { expect(await balanceOf(api, charlie.address)).to.be.equal( charlieBalance + timeslicePrice * (region.getEnd() - region.getBegin()), ); + + // Ensure the region is removed from sale: + expect(market.query.listedRegions()).to.eventually.be.equal([]); + expect((await market.query.listedRegion(id)).value.unwrap().ok).to.be.equal(null); }); }); diff --git a/tests/market/unlist.test.ts b/tests/market/unlist.test.ts new file mode 100644 index 0000000..390a8e3 --- /dev/null +++ b/tests/market/unlist.test.ts @@ -0,0 +1,228 @@ +import { ApiPromise, Keyring, WsProvider } from '@polkadot/api'; +import { expect, use } from 'chai'; +import { KeyringPair } from '@polkadot/keyring/types'; +import XcRegions_Factory from '../../types/constructors/xc_regions'; +import Market_Factory from '../../types/constructors/coretime_market'; +import XcRegions from '../../types/contracts/xc_regions'; +import Market from '../../types/contracts/coretime_market'; +import chaiAsPromised from 'chai-as-promised'; +import { CoreMask, Id, Region, RegionId, RegionRecord } from 'coretime-utils'; +import { + approveTransfer, + balanceOf, + createRegionCollection, + expectEvent, + expectOnSale, + getBlockNumber, + initRegion, + mintRegion, + wait, +} from '../common'; +import { MarketErrorBuilder } from '../../types/types-returns/coretime_market'; + +use(chaiAsPromised); + +const REGION_COLLECTION_ID = 42; +const LISTING_DEPOIST = 5 * Math.pow(10, 15); +// In reality this is 80, however we use 8 for testing. +const TIMESLICE_PERIOD = 8; + +const wsProvider = new WsProvider('ws://127.0.0.1:9944'); +// Create a keyring instance +const keyring = new Keyring({ type: 'sr25519', ss58Format: 5 }); + +describe('Coretime market unlisting', () => { + let api: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + + let xcRegions: XcRegions; + let market: Market; + + beforeEach(async function (): Promise { + api = await ApiPromise.create({ provider: wsProvider, noInitWarn: true, types: { Id } }); + + alice = keyring.addFromUri('//Alice'); + bob = keyring.addFromUri('//Bob'); + + const xcRegionsFactory = new XcRegions_Factory(api, alice); + xcRegions = new XcRegions((await xcRegionsFactory.new()).address, alice, api); + + const marketFactory = new Market_Factory(api, alice); + market = new Market( + (await marketFactory.new(xcRegions.address, LISTING_DEPOIST, TIMESLICE_PERIOD)).address, + alice, + api, + ); + + if (!(await api.query.uniques.class(REGION_COLLECTION_ID)).toHuman()) { + await createRegionCollection(api, alice); + } + }); + + it('Unlisting works', async () => { + const regionId: RegionId = { + begin: 30, + core: 20, + mask: CoreMask.completeMask(), + }; + const regionRecord: RegionRecord = { + end: 60, + owner: alice.address, + paid: null, + }; + const region = new Region(regionId, regionRecord); + + await mintRegion(api, alice, region); + await approveTransfer(api, alice, region, xcRegions.address); + + await initRegion(api, xcRegions, alice, region); + + const id: any = api.createType('Id', { U128: region.getEncodedRegionId(api) }); + await xcRegions.withSigner(alice).tx.approve(market.address, id, true); + + const aliceBalance = await balanceOf(api, alice.address); + + const timeslicePrice = 5 * Math.pow(10, 12); + await market + .withSigner(alice) + .tx.listRegion(id, timeslicePrice, alice.address, { value: LISTING_DEPOIST }); + + await expectOnSale(market, id, alice, timeslicePrice); + expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.deep.equal(market.address); + + expect(await balanceOf(api, alice.address)).to.be.lessThan(aliceBalance - LISTING_DEPOIST); + + const result = await market.withSigner(alice).tx.unlistRegion(id); + expectEvent(result, 'RegionUnlisted', { + regionId: id.toPrimitive().u128, + caller: alice.address, + }); + + // Ensure the region is removed from sale: + expect(market.query.listedRegions()).to.eventually.be.equal([]); + expect((await market.query.listedRegion(id)).value.unwrap().ok).to.be.equal(null); + + // Alice receives the region back: + expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.be.equal(alice.address); + }); + + it('Unlisting not listed region fails', async () => { + const regionId: RegionId = { + begin: 30, + core: 21, + mask: CoreMask.completeMask(), + }; + const regionRecord: RegionRecord = { + end: 60, + owner: alice.address, + paid: null, + }; + const region = new Region(regionId, regionRecord); + + await mintRegion(api, alice, region); + await approveTransfer(api, alice, region, xcRegions.address); + + await initRegion(api, xcRegions, alice, region); + + const id: any = api.createType('Id', { U128: region.getEncodedRegionId(api) }); + await xcRegions.withSigner(alice).tx.approve(market.address, id, true); + + const result = await market.withSigner(alice).query.unlistRegion(id); + expect(result.value.unwrap().err).to.deep.equal(MarketErrorBuilder.RegionNotListed()); + }); + + it('Only owner can unlist unexpired region', async () => { + const regionId: RegionId = { + begin: 30, + core: 22, + mask: CoreMask.completeMask(), + }; + const regionRecord: RegionRecord = { + end: 60, + owner: alice.address, + paid: null, + }; + const region = new Region(regionId, regionRecord); + + await mintRegion(api, alice, region); + await approveTransfer(api, alice, region, xcRegions.address); + + await initRegion(api, xcRegions, alice, region); + + const id: any = api.createType('Id', { U128: region.getEncodedRegionId(api) }); + await xcRegions.withSigner(alice).tx.approve(market.address, id, true); + + const timeslicePrice = 5 * Math.pow(10, 12); + await market + .withSigner(alice) + .tx.listRegion(id, timeslicePrice, alice.address, { value: LISTING_DEPOIST }); + + await expectOnSale(market, id, alice, timeslicePrice); + expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.deep.equal(market.address); + + const bobUnlistResult = await market.withSigner(bob).query.unlistRegion(id); + expect(bobUnlistResult.value.unwrap().err).to.deep.equal(MarketErrorBuilder.NotAllowed()); + + const aliceUnlistResult = await market.withSigner(alice).tx.unlistRegion(id); + expectEvent(aliceUnlistResult, 'RegionUnlisted', { + regionId: id.toPrimitive().u128, + caller: alice.address, + }); + + // Ensure the region is removed from sale: + expect(market.query.listedRegions()).to.eventually.be.equal([]); + expect((await market.query.listedRegion(id)).value.unwrap().ok).to.be.equal(null); + + // Alice receives the region back: + expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.be.equal(alice.address); + }); + + it('Anyone can unlist an expired region', async () => { + const regionId: RegionId = { + begin: 0, + core: 23, + mask: CoreMask.completeMask(), + }; + const regionRecord: RegionRecord = { + end: 5, + owner: alice.address, + paid: null, + }; + const region = new Region(regionId, regionRecord); + + await mintRegion(api, alice, region); + await approveTransfer(api, alice, region, xcRegions.address); + + await initRegion(api, xcRegions, alice, region); + + const id: any = api.createType('Id', { U128: region.getEncodedRegionId(api) }); + await xcRegions.withSigner(alice).tx.approve(market.address, id, true); + + const timeslicePrice = 5 * Math.pow(10, 12); + await market + .withSigner(alice) + .tx.listRegion(id, timeslicePrice, alice.address, { value: LISTING_DEPOIST }); + + await expectOnSale(market, id, alice, timeslicePrice); + expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.deep.equal(market.address); + + // Wait for the region to expire. + await wait(2000 * region.getEnd() * TIMESLICE_PERIOD); + + const result = await market.withSigner(bob).tx.unlistRegion(id); + expectEvent(result, 'RegionUnlisted', { + regionId: id.toPrimitive().u128, + caller: bob.address, + }); + + // Ensure the region is removed from sale: + expect(market.query.listedRegions()).to.eventually.be.equal([]); + expect((await market.query.listedRegion(id)).value.unwrap().ok).to.be.equal(null); + + // Alice receives the region back: + expect((await xcRegions.query.ownerOf(id)).value.unwrap()).to.be.equal(alice.address); + + // TODO: should ideally ensure that bob received the reward. + }); +});