diff --git a/kiosk-marketplace/.gitignore b/kiosk-marketplace/.gitignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/kiosk-marketplace/.gitignore @@ -0,0 +1 @@ +build diff --git a/kiosk-marketplace/Move.lock b/kiosk-marketplace/Move.lock new file mode 100644 index 0000000..a1d11ef --- /dev/null +++ b/kiosk-marketplace/Move.lock @@ -0,0 +1,36 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 0 +manifest_digest = "0B26186432FECAA89587FCB88D7D71F62A085F4EE2FE186CBB6DEE4752AF791B" +deps_digest = "3C4103934B1E040BB6B23F1D610B4EF9F2F1166A50A104EADCF77467C004C600" + +dependencies = [ + { name = "Kiosk" }, + { name = "Sui" }, +] + +[[move.package]] +name = "Kiosk" +source = { local = "../kiosk" } + +dependencies = [ + { name = "Sui" }, +] + +[[move.package]] +name = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "framework/testnet", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +name = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "framework/testnet", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { name = "MoveStdlib" }, +] + +[move.toolchain-version] +compiler-version = "1.21.0" +edition = "legacy" +flavor = "sui" diff --git a/kiosk-marketplace/Move.toml b/kiosk-marketplace/Move.toml new file mode 100644 index 0000000..48c6ff3 --- /dev/null +++ b/kiosk-marketplace/Move.toml @@ -0,0 +1,39 @@ +[package] +name = "KioskMarketplace" +edition = "2024.alpha" + +# edition = "2024.alpha" # To use the Move 2024 edition, currently in alpha +# license = "" # e.g., "MIT", "GPL", "Apache 2.0" +# authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"] + +[dependencies] +Sui = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" } +Kiosk = { local = "../kiosk" } + +# For remote import, use the `{ git = "...", subdir = "...", rev = "..." }`. +# Revision can be a branch, a tag, and a commit hash. +# MyRemotePackage = { git = "https://some.remote/host.git", subdir = "remote/path", rev = "main" } + +# For local dependencies use `local = path`. Path is relative to the package root +# Local = { local = "../path/to" } + +# To resolve a version conflict and force a specific version for dependency +# override use `override = true` +# Override = { local = "../conflicting/version", override = true } + +[addresses] +mkt = "0x0" + +# Named addresses will be accessible in Move as `@name`. They're also exported: +# for example, `std = "0x1"` is exported by the Standard Library. +# alice = "0xA11CE" + +[dev-dependencies] +# The dev-dependencies section allows overriding dependencies for `--test` and +# `--dev` modes. You can introduce test-only dependencies here. +# Local = { local = "../path/to/dev-build" } + +[dev-addresses] +# The dev-addresses section allows overwriting named addresses for the `--test` +# and `--dev` modes. +# alice = "0xB0B" diff --git a/kiosk-marketplace/sources/adapter.move b/kiosk-marketplace/sources/adapter.move new file mode 100644 index 0000000..220b08d --- /dev/null +++ b/kiosk-marketplace/sources/adapter.move @@ -0,0 +1,122 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// The best practical approach to trading on marketplaces and favoring their +/// fees and conditions is issuing an additional `TransferRequest` (eg `Market`). +/// To achieve that, the `adapter` module provides a wrapper around the `PurchaseCap` +/// which adds an extra `Market` type parameter and forces the trade transaction +/// sender to satisfy the `TransferPolicy` requirements. +/// +/// Unlike `PurchaseCap` purpose of which is to be as compatible as possible, +/// `MarketPurchaseCap` - the wrapper - only comes with a `store` to reduce the +/// amount of scenarios when it is transferred by accident or sent to an address +/// or object. +/// +/// Notes: +/// - The Adapter intentionally does not have any errors built-in and the error +/// handling needs to be implemented in the extension utilizing the Marketplace +/// Adapter. +module mkt::adapter { + use sui::transfer_policy::{Self as policy, TransferRequest}; + use sui::kiosk::{Kiosk, KioskOwnerCap, PurchaseCap}; + use sui::tx_context::TxContext; + use sui::object::ID; + use sui::coin::Coin; + use sui::sui::SUI; + + friend mkt::collection_bidding; + friend mkt::fixed_trading; + friend mkt::single_bid; + + /// The `NoMarket` type is used to provide a default `Market` type parameter + /// for a scenario when the `MarketplaceAdapter` is not used and extensions + /// maintain uniformity of emitted events. NoMarket = no marketplace. + public struct NoMarket {} + + /// The `MarketPurchaseCap` wraps the `PurchaseCap` and forces the unlocking + /// party to satisfy the `TransferPolicy` requirements. + public struct MarketPurchaseCap has store { + purchase_cap: PurchaseCap + } + + /// Create a new `PurchaseCap` and wrap it into the `MarketPurchaseCap`. + public(friend) fun new( + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, + item_id: ID, + min_price: u64, + ctx: &mut TxContext + ): MarketPurchaseCap { + let purchase_cap = kiosk.list_with_purchase_cap( + cap, item_id, min_price, ctx + ); + + MarketPurchaseCap { purchase_cap } + } + + /// Return the `MarketPurchaseCap` to the `Kiosk`. Similar to how the + /// `PurchaseCap` can be returned at any moment. But it can't be unwrapped + /// into the `PurchaseCap` because that would allow cheating on a `Market`. + public(friend) fun return_cap( + kiosk: &mut Kiosk, + cap: MarketPurchaseCap, + _ctx: &mut TxContext + ) { + let MarketPurchaseCap { purchase_cap } = cap; + kiosk.return_purchase_cap(purchase_cap); + } + + /// Use the `MarketPurchaseCap` to purchase an item from the `Kiosk`. Unlike + /// the default flow, this function adds a `TransferRequest` which + /// forces the unlocking party to satisfy the `TransferPolicy` + public(friend) fun purchase( + kiosk: &mut Kiosk, + cap: MarketPurchaseCap, + coin: Coin, + _ctx: &mut TxContext + ): (T, TransferRequest, TransferRequest) { + let MarketPurchaseCap { purchase_cap } = cap; + let (item, request) = kiosk.purchase_with_cap(purchase_cap, coin); + let market_request = policy::new_request( + policy::item(&request), + policy::paid(&request), + policy::from(&request), + ); + + (item, request, market_request) + } + + /// Purchase an item listed with "NoMarket" policy. This function ignores + /// the `Market` type parameter and returns only a `TransferRequest`. + public(friend) fun purchase_no_market( + kiosk: &mut Kiosk, + cap: MarketPurchaseCap, + coin: Coin, + _ctx: &mut TxContext + ): (T, TransferRequest) { + let MarketPurchaseCap { purchase_cap } = cap; + kiosk.purchase_with_cap(purchase_cap, coin) + } + + // === Getters === + + /// Handy wrapper to read the `kiosk` field of the inner `PurchaseCap` + public(friend) fun kiosk(self: &MarketPurchaseCap): ID { + self.purchase_cap.purchase_cap_kiosk() + } + + /// Handy wrapper to read the `item` field of the inner `PurchaseCap` + public(friend) fun item(self: &MarketPurchaseCap): ID { + self.purchase_cap.purchase_cap_item() + } + + /// Handy wrapper to read the `min_price` field of the inner `PurchaseCap` + public(friend) fun min_price(self: &MarketPurchaseCap): u64 { + self.purchase_cap.purchase_cap_min_price() + } + + // === Test === + + #[test_only] friend mkt::adapter_tests; + #[test_only] friend mkt::fixed_trading_tests; +} diff --git a/kiosk-marketplace/sources/collection_bidding.move b/kiosk-marketplace/sources/collection_bidding.move new file mode 100644 index 0000000..c8a0d47 --- /dev/null +++ b/kiosk-marketplace/sources/collection_bidding.move @@ -0,0 +1,230 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Implements Collection Bidding. Currently it's a Marketplace-only functionality. +/// +/// Flow: +/// 1. The bidder chooses a marketplace and calls `place_bids` with the amount +/// of coins they want to bid. +/// 2. The seller accepts the bid using `accept_market_bid`. The bid is taken +/// by the seller and the item is placed in the buyer's (bidder's) Kiosk. +/// 3. The seller resolves the requests for `Market` and creator. +module mkt::collection_bidding { + use std::type_name; + use sui::kiosk::{Self, Kiosk, KioskOwnerCap}; + use sui::tx_context::TxContext; + use sui::coin::{Self, Coin}; + use sui::transfer_policy::{ + TransferPolicy, + TransferRequest, + }; + use sui::sui::SUI; + use sui::object::{Self, ID}; + use sui::event; + use sui::pay; + + use kiosk::personal_kiosk; + use mkt::adapter::{Self as mkt, NoMarket}; + use mkt::extension as ext; + + /// Trying to perform an action in another user's Kiosk. + const ENotAuthorized: u64 = 0; + /// Trying to accept the bid in a disabled extension. + const EExtensionDisabled: u64 = 1; + /// Trying to accept a bid using a wrong function. + const EIncorrectMarketArg: u64 = 2; + /// Trying to accept a bid that does not exist. + const EBidNotFound: u64 = 3; + /// Trying to place a bid with no coins. + const ENoCoinsPassed: u64 = 4; + /// Trying to access the extension without installing it. + const EExtensionNotInstalled: u64 = 5; + /// Trying to accept a bid that doesn't match the seller's expectation. + const EBidDoesntMatchExpected: u64 = 6; + /// The bid is not in the correct order ASC: last one is the most expensive. + const EIncorrectOrder: u64 = 7; + /// The order_id doesn't match the bid. + const EOrderMismatch: u64 = 8; + + /// A key for Extension storage - a single bid on an item of type `T` on a `Market`. + public struct Bid has copy, store, drop {} + + /// A struct that holds the `bids` and the `order_id`. + public struct Bids has store { + /// A vector of coins that represent the bids. + bids: vector>, + /// A unique identifier for the bid. + order_id: address, + } + + // === Events === + + /// An event that is emitted when a new bid is placed. + public struct NewBid has copy, drop { + kiosk_id: ID, + order_id: address, + bids: vector, + is_personal: bool, + } + + /// An event that is emitted when a bid is accepted. + public struct BidAccepted has copy, drop { + seller_kiosk_id: ID, + buyer_kiosk_id: ID, + order_id: address, + item_id: ID, + amount: u64, + } + + /// An event that is emitted when a bid is canceled. + public struct BidCanceled has copy, drop { + order_id: address, + kiosk_id: ID, + kiosk_owner: address, + } + + // === Bidding logic === + + /// Place a bid on any item in a collection (`T`). We do not assert that all + /// the values in the `place_bids` are identical, the amounts are emitted + /// in the event, the order is reversed. + /// + /// Use `sui::pay::split_n` to prepare the Coins for the bid. + public fun place_bids( + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, + bids: vector>, + ctx: &mut TxContext + ): address { + assert!(bids.length() > 0, ENoCoinsPassed); + assert!(kiosk::has_access(kiosk, cap), ENotAuthorized); + assert!(ext::is_installed(kiosk), EExtensionNotInstalled); + + let order_id = ctx.fresh_object_address(); + let mut amounts = vector[]; + let (mut i, count, mut prev) = (0, bids.length(), 0); + while (i < count) { + let amount = bids.borrow(i).value(); + assert!(amount >= prev && amount > 0, EIncorrectOrder); + amounts.push_back(bids.borrow(i).value()); + prev = amount; + i = i + 1; + }; + + event::emit(NewBid { + order_id, + bids: amounts, + kiosk_id: object::id(kiosk), + is_personal: personal_kiosk::is_personal(kiosk) + }); + + ext::storage_mut(kiosk).add(Bid {}, Bids { bids, order_id }); + order_id + } + + /// Cancel all bids, return the funds to the owner. + public fun cancel_all( + kiosk: &mut Kiosk, cap: &KioskOwnerCap, ctx: &mut TxContext + ): Coin { + assert!(ext::is_installed(kiosk), EExtensionNotInstalled); + assert!(kiosk.has_access(cap), ENotAuthorized); + + let Bids { bids, order_id } = ext::storage_mut(kiosk) + .remove(Bid {}); + + event::emit(BidCanceled { + order_id, + kiosk_id: object::id(kiosk), + kiosk_owner: personal_kiosk::owner(kiosk) + }); + + let mut total = coin::zero(ctx); + pay::join_vec(&mut total, bids); + total + } + + /// Accept the bid and make a purchase on in the `Kiosk`. + /// + /// 1. The seller creates a `MarketPurchaseCap` using the Marketplace adapter, + /// and passes the Cap to this function. The `min_price` value is the expectation + /// of the seller. It protects them from race conditions in case the next bid + /// is smaller than the current one and someone frontrunned the seller. + /// See `EBidDoesntMatchExpectation` for more details on this scenario. + /// + /// 2. The `bid` is taken from the `source` Kiosk's extension storage and is + /// used to purchase the item with the `MarketPurchaseCap`. Proceeds go to + /// the `destination` Kiosk, as this Kiosk offers the `T`. + /// + /// 3. The item is placed in the `destination` Kiosk using the `place` or `lock` + /// functions (see `PERMISSIONS`). The extension must be installed and enabled + /// for this to work. + public fun accept_market_bid( + buyer: &mut Kiosk, + seller: &mut Kiosk, + seller_cap: &KioskOwnerCap, + policy: &TransferPolicy, + item_id: ID, + // for race conditions protection + bid_order_id: address, + min_bid_amount: u64, + // keeping these arguments for extendability + _lock: bool, + ctx: &mut TxContext + ): (TransferRequest, TransferRequest) { + assert!(ext::is_enabled(buyer), EExtensionNotInstalled); + assert!(ext::is_enabled(buyer), EExtensionDisabled); + assert!(seller.has_access(seller_cap), ENotAuthorized); + assert!(type_name::get() != type_name::get(), EIncorrectMarketArg); + + let storage = ext::storage_mut(buyer); + assert!(storage.contains(Bid {}), EBidNotFound); + + // Take 1 Coin from the bag - this is our bid (bids can't be empty, we + // make sure of it). + let Bids { bids, order_id } = storage.borrow_mut(Bid {}); + let order_id = *order_id; + let bid = bids.pop_back(); + let left = bids.length(); + + assert!(order_id == &bid_order_id, EOrderMismatch); + assert!(bid.value() >= min_bid_amount, EBidDoesntMatchExpected); + + // If there are no bids left, remove the bag and the key from the storage. + if (left == 0) { + let Bids { order_id: _, bids } = storage.remove(Bid {}); + bids.destroy_empty(); + }; + + let amount = bid.value(); + + // assert!(mkt::kiosk(&mkt_cap) == object::id(seller), EIncorrectKiosk); + // assert!(mkt::min_price(&mkt_cap) <= amount, EBidDoesntMatchExpectation); + + // Perform the purchase operation in the seller's Kiosk using the `Bid`. + let mkt_cap = mkt::new(seller, seller_cap, item_id, amount, ctx); + let (item, request, market_request) = mkt::purchase(seller, mkt_cap, bid, ctx); + + event::emit(BidAccepted { + amount, + order_id, + item_id: object::id(&item), + buyer_kiosk_id: object::id(buyer), + seller_kiosk_id: object::id(seller), + }); + + // Place or lock the item in the `source` Kiosk. + ext::place_or_lock(buyer, item, policy); + + (request, market_request) + } + + // === Getters === + + /// Number of bids on an item of type `T` on a `Market` in a `Kiosk`. + public fun bid_count(kiosk: &Kiosk): u64 { + let Bids { bids, order_id: _ } = ext::storage(kiosk) + .borrow(Bid {}); + + bids.length() + } +} diff --git a/kiosk-marketplace/sources/extension.move b/kiosk-marketplace/sources/extension.move new file mode 100644 index 0000000..1ad1bdd --- /dev/null +++ b/kiosk-marketplace/sources/extension.move @@ -0,0 +1,88 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// A single extension module for the package. Eliminates the need for separate +/// extension installation and management in every package. +module mkt::extension { + use std::type_name; + use sui::transfer_policy::{Self as policy, TransferPolicy}; + use sui::kiosk::{Kiosk, KioskOwnerCap}; + use sui::kiosk_extension as ext; + use sui::tx_context::TxContext; + use sui::bag::Bag; + use sui::vec_set; + + use kiosk::kiosk_lock_rule::Rule as LockRule; + use kiosk::personal_kiosk; + + friend mkt::collection_bidding; + friend mkt::fixed_trading; + friend mkt::single_bid; + + /// Extension can't be installed in a non-personal Kiosk. + const ENotPersonal: u64 = 0; + + /// The extension Witness. + public struct Extension has drop {} + + /// Place and Lock permissions. + const PERMISSIONS: u128 = 3; + + /// Install the Marketplace Extension into the Kiosk. + public fun add(kiosk: &mut Kiosk, cap: &KioskOwnerCap, ctx: &mut TxContext) { + assert!(personal_kiosk::is_personal(kiosk), ENotPersonal); + ext::add(Extension {}, kiosk, cap, PERMISSIONS, ctx) + } + + /// Check if the extension is installed. + public fun is_installed(kiosk: &Kiosk): bool { + ext::is_installed(kiosk) + } + + /// Check if the extension is enabled. + public fun is_enabled(kiosk: &Kiosk): bool { + ext::is_enabled(kiosk) + } + + // === Friend only === + + /// Place the item into the Kiosk. + public(friend) fun place( + kiosk: &mut Kiosk, item: T, policy: &TransferPolicy + ) { + ext::place(Extension {}, kiosk, item, policy) + } + + /// Lock the item in the Kiosk. + public(friend) fun lock( + kiosk: &mut Kiosk, item: T, policy: &TransferPolicy + ) { + ext::lock(Extension {}, kiosk, item, policy) + } + + /// Get the reference to the extension storage. + public(friend) fun storage(kiosk: &Kiosk): &Bag { + ext::storage(Extension {}, kiosk) + } + + /// Get the mutable reference to the extension storage. + public(friend) fun storage_mut(kiosk: &mut Kiosk): &mut Bag { + ext::storage_mut(Extension {}, kiosk) + } + + /// Place or Lock the item into the Kiosk, based on the policy. + public(friend) fun place_or_lock( + kiosk: &mut Kiosk, item: T, policy: &TransferPolicy + ) { + let should_lock = vec_set::contains( + policy::rules(policy), + &type_name::get() + ); + + if (should_lock) { + lock(kiosk, item, policy) + } else { + place(kiosk, item, policy) + }; + } +} diff --git a/kiosk-marketplace/sources/fixed_trading.move b/kiosk-marketplace/sources/fixed_trading.move new file mode 100644 index 0000000..e1570b2 --- /dev/null +++ b/kiosk-marketplace/sources/fixed_trading.move @@ -0,0 +1,158 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// This extension implements the default list-purchase flow but for a specific +/// market (using the Marketplace Adapter). +/// +/// Consists of 3 functions: +/// - list +/// - delist +/// - purchase +module mkt::fixed_trading { + use sui::kiosk::{Kiosk, KioskOwnerCap}; + use sui::transfer_policy::TransferRequest; + use sui::tx_context::TxContext; + use sui::object::{Self, ID}; + use sui::coin::Coin; + use sui::sui::SUI; + use sui::event; + + use kiosk::personal_kiosk; + use mkt::adapter::{Self as mkt, MarketPurchaseCap}; + use mkt::extension as ext; + + /// For when the caller is not the owner of the Kiosk. + const ENotOwner: u64 = 0; + /// Trying to purchase or delist an item that is not listed. + const ENotListed: u64 = 1; + /// The payment is not enough to purchase the item. + const EIncorrectAmount: u64 = 2; + + public struct Listing has store { + market_cap: MarketPurchaseCap, + order_id: address, + } + + // === Events === + + /// An item has been listed on a Marketplace. + public struct ItemListed has copy, drop { + kiosk_id: ID, + item_id: ID, + price: u64, + order_id: address, + } + + /// An item has been delisted from a Marketplace. + public struct ItemDelisted has copy, drop { + kiosk_id: ID, + item_id: ID, + order_id: address, + } + + /// An item has been purchased from a Marketplace. + public struct ItemPurchased has copy, drop { + kiosk_id: ID, + item_id: ID, + order_id: address, + /// The seller address if the Kiosk is personal. + seller: address, + } + + // === Trading Functions === + + /// List an item on a specified Marketplace. + public fun list( + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, + item_id: ID, + price: u64, + ctx: &mut TxContext + ): address { + assert!(kiosk.has_access(cap), ENotOwner); + + let order_id = ctx.fresh_object_address(); + let market_cap = mkt::new(kiosk, cap, item_id, price, ctx); + + ext::storage_mut(kiosk).add(item_id, Listing { + market_cap, + order_id, + }); + + event::emit(ItemListed { + kiosk_id: object::id(kiosk), + order_id, + item_id, + price, + }); + + order_id + } + + /// Delist an item from a specified Marketplace. + public fun delist( + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, + item_id: ID, + ctx: &mut TxContext + ) { + assert!(kiosk.has_access(cap), ENotOwner); + assert!(kiosk.is_listed(item_id), ENotListed); + + let Listing { market_cap, order_id } = ext::storage_mut(kiosk).remove(item_id); + mkt::return_cap(kiosk, market_cap, ctx); + + event::emit(ItemDelisted { + kiosk_id: object::id(kiosk), + order_id, + item_id + }); + } + + /// Purchase an item from a specified Marketplace. + public fun purchase( + kiosk: &mut Kiosk, + item_id: ID, + list_order_id: address, + payment: Coin, + ctx: &mut TxContext + ): (T, TransferRequest, TransferRequest) { + assert!(kiosk.is_listed(item_id), ENotListed); + + let Listing { + market_cap, + order_id + } = ext::storage_mut(kiosk).remove(item_id); + + assert!(payment.value() == market_cap.min_price(), EIncorrectAmount); + assert!(order_id == list_order_id, EIncorrectAmount); + + event::emit(ItemPurchased { + seller: personal_kiosk::owner(kiosk), + kiosk_id: object::id(kiosk), + order_id, + item_id + }); + + mkt::purchase(kiosk, market_cap, payment, ctx) + } + + // === Getters === + + use fun is_listed as Kiosk.is_listed; + + /// Check if an item is currently listed on a specified Marketplace. + public fun is_listed(kiosk: &Kiosk, item_id: ID): bool { + ext::storage(kiosk).contains_with_type>(item_id) + } + + /// Get the price of a currently listed item from a specified Marketplace. + public fun price(kiosk: &Kiosk, item_id: ID): u64 { + let Listing { + market_cap, + order_id: _ + } = ext::storage(kiosk).borrow(item_id); + + market_cap.min_price() + } +} diff --git a/kiosk-marketplace/sources/single_bid.move b/kiosk-marketplace/sources/single_bid.move new file mode 100644 index 0000000..57bb116 --- /dev/null +++ b/kiosk-marketplace/sources/single_bid.move @@ -0,0 +1,165 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// A module for placing and managing single bids in a Kiosk. A single bid is +/// a bid for a specific item. The module provides functions to place, accept, and +/// cancel a bid. +/// +/// Flow: +/// 1. A buyer places a bid for an item with a specified `ID`. +/// 2. A seller accepts the bid and sells the item to the buyer in a single action. +/// 3. The seller resolves the requests for `Market` and creator. +module mkt::single_bid { + use sui::kiosk::{Kiosk, KioskOwnerCap}; + use sui::transfer_policy::{TransferPolicy, TransferRequest}; + use sui::tx_context::TxContext; + use sui::object::{Self, ID}; + use sui::coin::{Self, Coin}; + use sui::balance::Balance; + use sui::sui::SUI; + use sui::event; + + use mkt::extension as ext; + use mkt::adapter as mkt; + + /// Not the kiosk owner. + const ENotAuthorized: u64 = 0; + /// Item is not found in the kiosk. + const EItemNotFound: u64 = 1; + /// Item is already listed in the kiosk. + const EAlreadyListed: u64 = 2; + /// Extension is not installed in the kiosk. + const EExtensionNotInstalled: u64 = 3; + /// No bid found for the item. + const ENoBid: u64 = 4; + /// Order ID mismatch + const EOrderMismatch: u64 = 5; + + /// The dynamic field key for the Bid. + public struct Bid has copy, store, drop { item_id: ID } + + public struct PlacedBid has store { + bid: Balance, + order_id: address, + } + + // === Events === + + /// Event emitted when a bid is placed. + public struct NewBid has copy, drop { + kiosk_id: ID, + item_id: ID, + bid: u64, + order_id: address, + } + + /// Event emitted when a bid is accepted. + public struct BidAccepted has copy, drop { + kiosk_id: ID, + item_id: ID, + bid: u64, + order_id: address, + } + + /// Event emitted when a bid is cancelled. + public struct BidCancelled has copy, drop { + kiosk_id: ID, + item_id: ID, + bid: u64, + order_id: address, + } + + // === Bidding Logic === + + /// Place a single bid for an item with a specified `ID`. + public fun place( + kiosk: &mut Kiosk, + cap: &KioskOwnerCap, + bid: Coin, + item_id: ID, + ctx: &mut TxContext + ): address { + assert!(kiosk.has_access(cap), ENotAuthorized); + assert!(ext::is_installed(kiosk), EExtensionNotInstalled); + + let order_id = ctx.fresh_object_address(); + + event::emit(NewBid { + kiosk_id: object::id(kiosk), + bid: bid.value(), + order_id, + item_id, + }); + + ext::storage_mut(kiosk).add( + Bid { item_id }, + PlacedBid { bid: bid.into_balance(), order_id } + ); + + order_id + } + + /// Accept a single bid for an item with a specified `ID`. For that the + /// seller lists and sells the item to the buyer in a single action. The + /// requests are issued and need to be resolved by the seller. + public fun accept( + buyer: &mut Kiosk, + seller: &mut Kiosk, + seller_cap: &KioskOwnerCap, + policy: &TransferPolicy, + item_id: ID, + bid_order_id: address, + _lock: bool, + ctx: &mut TxContext + ): (TransferRequest, TransferRequest) { + assert!(ext::is_enabled(buyer), EExtensionNotInstalled); + assert!(ext::storage(buyer).contains(Bid { item_id }), ENoBid); + assert!(seller.has_item(item_id), EItemNotFound); + assert!(!seller.is_listed(item_id), EAlreadyListed); + + let PlacedBid { + bid, order_id + } = ext::storage_mut(buyer).remove(Bid { item_id }); + + assert!(order_id == bid_order_id, EOrderMismatch); + + let amount = bid.value(); + let mkt_cap = mkt::new(seller, seller_cap, item_id, amount, ctx); + let (item, req, mkt_req) = mkt::purchase(seller, mkt_cap, coin::from_balance(bid, ctx), ctx); + + event::emit(BidAccepted { + kiosk_id: object::id(buyer), + bid: amount, + order_id, + item_id, + }); + + ext::place_or_lock(buyer, item, policy); + (req, mkt_req) + } + + /// Cancel a single bid for an item with a specified `ID`. + public fun cancel( + kiosk: &mut Kiosk, + kiosk_cap: &KioskOwnerCap, + item_id: ID, + ctx: &mut TxContext + ): Coin { + assert!(kiosk.has_access(kiosk_cap), ENotAuthorized); + assert!(ext::is_installed(kiosk), EExtensionNotInstalled); + assert!(ext::storage(kiosk).contains(Bid { item_id }), ENoBid); + + let PlacedBid { + order_id, bid, + } = ext::storage_mut(kiosk).remove(Bid { item_id }); + + event::emit(BidCancelled { + kiosk_id: object::id(kiosk), + bid: bid.value(), + order_id, + item_id, + }); + + coin::from_balance(bid, ctx) + } +} diff --git a/kiosk-marketplace/tests/adapter_tests.move b/kiosk-marketplace/tests/adapter_tests.move new file mode 100644 index 0000000..3ddec07 --- /dev/null +++ b/kiosk-marketplace/tests/adapter_tests.move @@ -0,0 +1,83 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +/// Tests for the marketplace adapter. +module mkt::adapter_tests { + use sui::coin; + use sui::kiosk; + use sui::object; + use sui::sui::SUI; + use sui::transfer_policy as policy; + use sui::kiosk_test_utils::{Self as test, Asset}; + + use mkt::adapter as mkt; + + /// The Marketplace witness. + public struct MyMarket has drop {} + + /// The witness to use in tests. + public struct OTW has drop {} + + // Performs a test of the `new` and `return_cap` functions. Not supposed to + // abort, and there's only so many scenarios where it can fail due to strict + // type requirements. + #[test] fun test_new_return_flow() { + let ctx = &mut test::ctx(); + let (mut kiosk, kiosk_cap) = test::get_kiosk(ctx); + let (asset, asset_id) = test::get_asset(ctx); + + kiosk::place(&mut kiosk, &kiosk_cap, asset); + + let mkt_cap = mkt::new( + &mut kiosk, &kiosk_cap, asset_id, 100000, ctx + ); + + assert!(mkt_cap.item() == asset_id, 0); + assert!(mkt_cap.min_price() == 100000, 1); + assert!(mkt_cap.kiosk() == object::id(&kiosk), 2); + + mkt::return_cap(&mut kiosk, mkt_cap, ctx); + + let asset = kiosk::take(&mut kiosk, &kiosk_cap, asset_id); + test::return_kiosk(kiosk, kiosk_cap, ctx); + test::return_assets(vector[ asset ]); + } + + // Perform a `purchase` using the `MarketPurchaseCap`. Type constraints make + // it impossible to cheat and pass another Cap. So the number of potential + // fail scenarios is limited and already covered by the base Kiosk + #[test] fun test_new_purchase_flow() { + let ctx = &mut test::ctx(); + let (mut kiosk, kiosk_cap) = test::get_kiosk(ctx); + let (asset, asset_id) = test::get_asset(ctx); + + kiosk.place(&kiosk_cap, asset); + + // Lock an item in the Marketplace + let mkt_cap = mkt::new( + &mut kiosk, &kiosk_cap, asset_id, 100000, ctx + ); + + // Mint a Coin and make a purchase + let coin = coin::mint_for_testing(100000, ctx); + let (item, req, mkt_req) = mkt::purchase( + &mut kiosk, mkt_cap, coin, ctx + ); + + // Get Policy for the Asset, use it and clean up. + let (policy, policy_cap) = test::get_policy(ctx); + policy.confirm_request(req); + test::return_policy(policy, policy_cap, ctx); + + // Get Policy for the Marketplace, use it and clean up. + let (policy, policy_cap) = policy::new_for_testing(ctx); + policy.confirm_request(mkt_req); + policy.destroy_and_withdraw(policy_cap, ctx) + .destroy_zero(); + + // Now deal with the item and with the Kiosk. + test::return_assets(vector[ item ]); + test::return_kiosk(kiosk, kiosk_cap, ctx); + } +} diff --git a/kiosk-marketplace/tests/collection_bidding_tests.move b/kiosk-marketplace/tests/collection_bidding_tests.move new file mode 100644 index 0000000..c33e2b0 --- /dev/null +++ b/kiosk-marketplace/tests/collection_bidding_tests.move @@ -0,0 +1,89 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +module mkt::collection_bidding_tests { + use sui::test_utils; + use sui::kiosk_test_utils::Asset; + + use mkt::collection_bidding::{Self as bidding}; + use mkt::test_utils as test; + + /// The Marketplace witness. + public struct MyMarket has drop {} + + #[test] + fun test_simple_bid() { + let mut test = test::new(); + let ctx = &mut test.next_tx(@0x1); + let (mut buyer_kiosk, buyer_cap, _) = test.kiosk(ctx); + + // place bids on an Asset: 100 MIST + let order_id = bidding::place_bids( + &mut buyer_kiosk, + buyer_cap.borrow(), + vector[ + test.mint_sui(300, ctx), + test.mint_sui(400, ctx) + ], + ctx + ); + + // prepare the seller Kiosk + let ctx = &mut test.next_tx(@0x2); + let (mut seller_kiosk, seller_cap, asset_id) = test.kiosk(ctx); + let asset_policy = test.policy(ctx); + let mkt_policy = test.policy(ctx); + + // take the bid and perform the purchase (400 SUI) + let (asset_request, mkt_request) = bidding::accept_market_bid( + &mut buyer_kiosk, + &mut seller_kiosk, + seller_cap.borrow(), + &asset_policy, + asset_id, + order_id, + 400, + false, + ctx + ); + + asset_policy.confirm_request(asset_request); + mkt_policy.confirm_request(mkt_request); + + assert!(buyer_kiosk.has_item(asset_id), 0); + assert!(!seller_kiosk.has_item(asset_id), 1); + assert!(seller_kiosk.profits_amount() == 400, 2); + + // do it all over again + let (asset, asset_id) = test.asset(ctx); + seller_kiosk.place(seller_cap.borrow(), asset); + + // second bid (smaller) + let (asset_request, mkt_request) = bidding::accept_market_bid( + &mut buyer_kiosk, + &mut seller_kiosk, + seller_cap.borrow(), + &asset_policy, + asset_id, + order_id, + 300, + false, + ctx + ); + + asset_policy.confirm_request(asset_request); + mkt_policy.confirm_request(mkt_request); + + assert!(buyer_kiosk.has_item(asset_id), 3); + assert!(!seller_kiosk.has_item(asset_id), 4); + assert!(seller_kiosk.profits_amount() == 700, 5); + + test_utils::destroy(seller_kiosk); + test_utils::destroy(buyer_kiosk); + test_utils::destroy(seller_cap); + test_utils::destroy(buyer_cap); + + test.destroy(asset_policy).destroy(mkt_policy); + } +} diff --git a/kiosk-marketplace/tests/test_utils.move b/kiosk-marketplace/tests/test_utils.move new file mode 100644 index 0000000..b945e2c --- /dev/null +++ b/kiosk-marketplace/tests/test_utils.move @@ -0,0 +1,57 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +module mkt::test_utils { + use sui::object::ID; + use sui::sui::SUI; + use sui::coin::{Self, Coin}; + use sui::kiosk::{Self, Kiosk}; + use sui::kiosk_test_utils::{Self as test, Asset}; + use sui::transfer_policy::{Self, TransferPolicy}; + use kiosk::personal_kiosk::{Self, PersonalKioskCap}; + + public struct TestRunner has drop { seq: u64 } + + public fun new(): TestRunner { + TestRunner { seq: 1 } + } + + public fun next_tx(self: &mut TestRunner, sender: address): TxContext { + self.seq = self.seq + 1; + tx_context::new_from_hint( + sender, + self.seq, + 0, 0, 0 + ) + } + + public fun kiosk(_self: &TestRunner, ctx: &mut TxContext): (Kiosk, PersonalKioskCap, ID) { + let (mut kiosk, cap) = kiosk::new(ctx); + let (asset, asset_id) = test::get_asset(ctx); + + kiosk.place(&cap, asset); + let cap = personal_kiosk::new(&mut kiosk, cap, ctx); + mkt::extension::add(&mut kiosk, cap.borrow(), ctx); + (kiosk, cap, asset_id) + } + + public fun policy(self: &TestRunner, ctx: &mut TxContext): TransferPolicy { + let (policy, cap) = transfer_policy::new_for_testing(ctx); + self.destroy(cap); + policy + } + + public fun asset(_self: &TestRunner, ctx: &mut TxContext): (Asset, ID) { + test::get_asset(ctx) + } + + public fun mint_sui(_self: &mut TestRunner, amount: u64, ctx: &mut TxContext): Coin { + coin::mint_for_testing(amount, ctx) + } + + public fun destroy(self: &TestRunner, t: T): &TestRunner { + sui::test_utils::destroy(t); + self + } +} diff --git a/kiosk-marketplace/tests/trading_ext_tests.move b/kiosk-marketplace/tests/trading_ext_tests.move new file mode 100644 index 0000000..9648781 --- /dev/null +++ b/kiosk-marketplace/tests/trading_ext_tests.move @@ -0,0 +1,65 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +/// Tests for the marketplace `marketplace_trading_ext`. +module mkt::fixed_trading_tests { + use sui::test_utils::destroy; + use sui::kiosk_test_utils::Asset; + + use mkt::test_utils as test; + use mkt::fixed_trading as ext; + + const PRICE: u64 = 100_000; + + /// Marketplace type. + public struct MyMarket has drop {} + + #[test] fun test_list_and_delist() { + let mut test = test::new(); + let ctx = &mut test.next_tx(@0x1); + let (mut kiosk, cap, asset_id) = test.kiosk(ctx); + + let _order_id = ext::list(&mut kiosk, cap.borrow(), asset_id, PRICE, ctx); + + assert!(ext::is_listed(&kiosk, asset_id), 0); + assert!(ext::price(&kiosk, asset_id) == PRICE, 1); + + ext::delist(&mut kiosk, cap.borrow(), asset_id, ctx); + + let asset: Asset = kiosk.take(cap.borrow(), asset_id); + + destroy(kiosk); + destroy(asset); + destroy(cap); + } + + #[test] fun test_list_and_purchase() { + let mut test = test::new(); + let ctx = &mut test.next_tx(@0x1); + let (mut kiosk, cap, asset_id) = test.kiosk(ctx); + + let order_id = ext::list( + &mut kiosk, cap.borrow(), asset_id, PRICE, ctx + ); + + let coin = test.mint_sui(PRICE, ctx); + let (item, req, mkt_req) = ext::purchase( + &mut kiosk, asset_id, order_id, coin, ctx + ); + + // Resolve creator's Policy + let policy = test.policy(ctx); + policy.confirm_request(req); + test.destroy(policy); + + // Resolve marketplace's Policy + let policy = test.policy(ctx); + policy.confirm_request(mkt_req); + test.destroy(policy); + + test.destroy(kiosk) + .destroy(item) + .destroy(cap); + } +} diff --git a/kiosk/Move.lock b/kiosk/Move.lock index 83be0be..e958ff4 100644 --- a/kiosk/Move.lock +++ b/kiosk/Move.lock @@ -2,8 +2,8 @@ [move] version = 0 -manifest_digest = "A62499C4014A0043E14032FCB71E9E52900167A18DDF760AC2C025FD3F369799" -deps_digest = "112928C94A84031C09CD9B9D1D44B149B73FC0EEA5FA8D8B2D7CA4D91936335A" +manifest_digest = "B2C32FD994631CF15A3F152AB4239BC2BE09438EDBE6DDD90B754AA1AB73DA66" +deps_digest = "F8BBB0CCB2491CA29A3DF03D6F92277A4F3574266507ACD77214D37ECA3F3082" dependencies = [ { name = "Sui" }, diff --git a/kiosk/Move.toml b/kiosk/Move.toml index 6412209..5568972 100644 --- a/kiosk/Move.toml +++ b/kiosk/Move.toml @@ -1,10 +1,11 @@ [package] name = "Kiosk" version = "0.0.1" -published-at = "0x34cc6762780f4f6f153c924c0680cfe2a1fb4601e7d33cc28a92297b62de1e0e" +# published-at = "0x34cc6762780f4f6f153c924c0680cfe2a1fb4601e7d33cc28a92297b62de1e0e" [dependencies] Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/mainnet" } [addresses] -kiosk = "0x434b5bd8f6a7b05fede0ff46c6e511d71ea326ed38056e3bcd681d2d7c2a7879" +# kiosk = "0x434b5bd8f6a7b05fede0ff46c6e511d71ea326ed38056e3bcd681d2d7c2a7879" +kiosk = "0x0" diff --git a/kiosk/sources/extensions/personal_kiosk.move b/kiosk/sources/extensions/personal_kiosk.move index b9ce890..fe139d5 100644 --- a/kiosk/sources/extensions/personal_kiosk.move +++ b/kiosk/sources/extensions/personal_kiosk.move @@ -19,7 +19,7 @@ module kiosk::personal_kiosk { const EIncorrectOwnedObject: u64 = 1; /// Trying to get the owner of a non-personal Kiosk. const EKioskNotOwned: u64 = 2; - /// Trying to make a someone else's Kiosk "personal". + /// Trying to make someone else's Kiosk "personal". const EWrongKiosk: u64 = 3; /// A key-only wrapper for the KioskOwnerCap. Makes sure that the Kiosk can @@ -42,7 +42,7 @@ module kiosk::personal_kiosk { /// The default setup for the PersonalKioskCap. entry fun default(kiosk: &mut Kiosk, cap: KioskOwnerCap, ctx: &mut TxContext) { - transfer_to_sender(new(kiosk, cap, ctx), ctx); + transfer_to_sender(new(kiosk, cap, ctx), ctx) } /// Wrap the KioskOwnerCap making the Kiosk "owned" and non-transferable. @@ -73,7 +73,7 @@ module kiosk::personal_kiosk { // wrap the Cap in the `PersonalKioskCap` PersonalKioskCap { id: object::new(ctx), - cap: option::some(cap) + cap: option::some(cap), } } diff --git a/kiosk/sources/rules/floor_price_rule.move b/kiosk/sources/rules/floor_price_rule.move index 7ce376a..2baf317 100644 --- a/kiosk/sources/rules/floor_price_rule.move +++ b/kiosk/sources/rules/floor_price_rule.move @@ -44,7 +44,7 @@ module kiosk::floor_price_rule { /// Buyer action: Prove that the amount is higher or equal to the floor_price. public fun prove( - policy: &mut TransferPolicy, + policy: &TransferPolicy, request: &mut TransferRequest ) { let config: &Config = policy::get_rule(Rule {}, policy); diff --git a/kiosk/sources/rules/royalty_rule.move b/kiosk/sources/rules/royalty_rule.move index 39c54cd..737b5e3 100644 --- a/kiosk/sources/rules/royalty_rule.move +++ b/kiosk/sources/rules/royalty_rule.move @@ -63,7 +63,7 @@ module kiosk::royalty_rule { /// Creator action: Add the Royalty Rule for the `T`. /// Pass in the `TransferPolicy`, `TransferPolicyCap` and the configuration /// for the policy: `amount_bp` and `min_amount`. - public fun add( + public fun add( policy: &mut TransferPolicy, cap: &TransferPolicyCap, amount_bp: u16, @@ -74,7 +74,7 @@ module kiosk::royalty_rule { } /// Buyer action: Pay the royalty fee for the transfer. - public fun pay( + public fun pay( policy: &mut TransferPolicy, request: &mut TransferRequest, payment: Coin @@ -90,7 +90,7 @@ module kiosk::royalty_rule { /// Helper function to calculate the amount to be paid for the transfer. /// Can be used dry-runned to estimate the fee amount based on the Kiosk listing price. - public fun fee_amount(policy: &TransferPolicy, paid: u64): u64 { + public fun fee_amount(policy: &TransferPolicy, paid: u64): u64 { let config: &Config = policy::get_rule(Rule {}, policy); let amount = (((paid as u128) * (config.amount_bp as u128) / 10_000) as u64); diff --git a/kiosk/sources/rules/witness_rule.move b/kiosk/sources/rules/witness_rule.move index e5e9168..119e543 100644 --- a/kiosk/sources/rules/witness_rule.move +++ b/kiosk/sources/rules/witness_rule.move @@ -31,7 +31,7 @@ module kiosk::witness_rule { /// Creator action: adds the Rule. /// Requires a "Proof" witness confirmation on every transfer. - public fun add( + public fun add( policy: &mut TransferPolicy, cap: &TransferPolicyCap ) { @@ -40,7 +40,7 @@ module kiosk::witness_rule { /// Buyer action: follow the policy. /// Present the required "Proof" instance to get a receipt. - public fun prove( + public fun prove( _proof: Proof, policy: &TransferPolicy, request: &mut TransferRequest diff --git a/kiosk/sources/utils/compliance.move b/kiosk/sources/utils/compliance.move new file mode 100644 index 0000000..eee46d6 --- /dev/null +++ b/kiosk/sources/utils/compliance.move @@ -0,0 +1,50 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// This module provides a single function to comply with the given policy and +/// complete all the required Rules in a single call. +module kiosk::ruleset { + use sui::transfer_policy::{Self as policy, TransferRequest, TransferPolicy}; + use sui::kiosk::{Self, Kiosk, KioskOwnerCap}; + use sui::tx_context::TxContext; + use sui::coin::{Self, Coin}; + use sui::sui::SUI; + + use kiosk::kiosk_lock_rule::{Self, Rule as LockRule}; + use kiosk::floor_price_rule::{Self, Rule as FloorPriceRule}; + use kiosk::personal_kiosk_rule::{Self, Rule as PersonalKioskRule}; + use kiosk::royalty_rule::{Self, Rule as RoyaltyRule}; + + public fun complete( + policy: &mut TransferPolicy, + request: TransferRequest, + kiosk: &mut Kiosk, + kiosk_cap: &KioskOwnerCap, + item: T, + payment: &mut Coin, + ctx: &mut TxContext, + ) { + if (policy::has_rule(policy)) { + personal_kiosk_rule::prove(kiosk, &mut request); + }; + + if (policy::has_rule(policy)) { + let amount = royalty_rule::fee_amount(policy, policy::paid(&request)); + let royalty = coin::split(payment, amount, ctx); + royalty_rule::pay(policy, &mut request, royalty); + }; + + if (policy::has_rule(policy)) { + floor_price_rule::prove(policy, &mut request); + }; + + if (policy::has_rule(policy)) { + kiosk::lock(kiosk, kiosk_cap, policy, item); + kiosk_lock_rule::prove(&mut request, kiosk); + } else { + kiosk::place(kiosk, kiosk_cap, item); + }; + + policy::confirm_request(policy, request); + } +}