diff --git a/Move.toml b/Move.toml index f4655a8..65b2af7 100644 --- a/Move.toml +++ b/Move.toml @@ -1,11 +1,9 @@ [package] -name = "Syrup" +name = "LiquidityLayer" version = "0.4.0" [dependencies] -NftProtocol = { git = "https://github.com/Origin-Byte/nft-protocol", rev = "0.3.0" } Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework", rev = "devnet-0.9.0" } [addresses] -syrup = "0x0" -# nft_protocol = "0xe6d6f01725d469b7b04d9905e6b3151e8c4264cb" +liquidity_layer = "0x0" diff --git a/README.md b/README.md index 0d3f872..49d3f83 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ - Sui v0.9.0 +Working title: _syrup_ (to be changed) + NFT liquidity layer is a suite of modules which aim to bring NFT trading across marketplaces to a single point, thereby concentrating the liquidity into a common interface. @@ -18,16 +20,12 @@ An ask is one NFT with a min price condition. `Ask` is an object which is associated with a single NFT. When `Ask` is created, we transfer the ownership of the NFT to this -new object. -When an `Ask` is matched with a bid, we transfer the ownership of the -`ask` object to the bid owner (buyer). -The buyer can then claim the NFT via `claim_nft` endpoint. +new object. To be more precise, we transfer the `safe::TransferCap`. One can: - create a new orderbook between a given collection and a BID token (witness pattern protected) -- collect fees (witness pattern protected) - set publicly accessible actions to be witness protected - open a new BID - cancel an existing BID they own diff --git a/examples/custom_collection.move b/examples/custom_collection.move deleted file mode 100644 index 8a06580..0000000 --- a/examples/custom_collection.move +++ /dev/null @@ -1,59 +0,0 @@ -module syrup::custom_collection { - //! A simple showcase how to participate in the liquidity layer as a custom - //! collection implementor. - //! - //! The common use case to trade art NFTs would be implemented by the - //! `StdCollection` of the OriginByte nft protocol (will be a standalone - //! contract so no circular dependencies.) - //! - //! In a nutshell, the `StdCollection` implements a default desired behavior - //! for creators to list their collections in the liquidity layer. - //! - //! It would implements royalty distribution based on a business logic which - //! makes most sense for art NFTs. - - use syrup::orderbook::{Self, Orderbook}; - use std::fixed_point32; - use sui::object::{Self, UID}; - use sui::balance; - use sui::coin; - use sui::transfer::{Self, transfer}; - use sui::sui::SUI; - use sui::tx_context::{Self, TxContext}; - - // TODO: remove `store` once new version of nft-protocol lands - struct Witness has drop, store {} - - struct MyCollection has key, store { - id: UID, - admin: address, - } - - public entry fun create_orderbook( - my_collection: &MyCollection, - ctx: &mut TxContext, - ) { - assert!(tx_context::sender(ctx) == my_collection.admin, 0); - - let royalty = fixed_point32::create_from_rational(1, 100); // 1% - - transfer::share_object(orderbook::create( - Witness {}, - object::id(my_collection), - royalty, - ctx, - )); - } - - public entry fun collect_royalties( - my_collection: &MyCollection, - ob: &mut Orderbook, - ctx: &mut TxContext, - ) { - assert!(tx_context::sender(ctx) == my_collection.admin, 0); - - let balance = orderbook::fees_balance(Witness {}, ob); - let total = balance::value(balance); - transfer(coin::take(balance, total, ctx), my_collection.admin); - } -} diff --git a/examples/name_service.move b/examples/name_service.move deleted file mode 100644 index dbaabc9..0000000 --- a/examples/name_service.move +++ /dev/null @@ -1,82 +0,0 @@ -module syrup::name_service { - //! This example showcases custom implementation for logic creating an ask. - //! - //! Now, marketplaces and wallets have two options: implement creating ask - //! for Sui NS (via the standard e.g.) or decide to only enable bids and - //! buying of name addreses on their platform, but skip selling. - //! - //! We name this "name_service" because one of the teams building a NS on - //! Sui wanted to make trading of expired NFTs impossible. - - use nft_protocol::collection::{Self, Collection}; - use nft_protocol::nft::NftOwned; - use syrup::orderbook::{Self, Orderbook}; - use std::fixed_point32; - use std::option::Option; - use std::string::String; - use sui::object::{Self, ID, UID}; - use sui::transfer; - use sui::tx_context::{Self, TxContext}; - - // witness pattern - // TODO: remove `store` once new version of nft-protocol lands - struct NS has drop, store {} - - // user NFT data - struct NSNftMetadata has key, store { - id: UID, - // points to `NSDomain` - domain: ID, - // some expiry info which will be used in `fun is_expired` - expires_at: u64, - } - - // holds global information about the NS collection, such as tld, registered - // domains etc - struct NSCollMetadata has store { - admin: address, - registered_domains: vector, - } - - // info specific to a domain that's global, owned e.g. by the collection - // object or shared and only accessible by some authority - struct NSDomain has key { - id: UID, - name: String, - current_nft: Option, - } - - fun is_expired(_nft: &NftOwned): bool { - // TODO: implement logic for determining if the domain is expired - - false - } - - /// Joins the NFT liquidity layer but restricts asks such that they can only - /// be called via this contract. - public entry fun create_orderbook( - col: &Collection, - ctx: &mut TxContext, - ) { - let sender = tx_context::sender(ctx); - assert!(collection::metadata(col).admin == sender, 0); - - let fee = fixed_point32::create_from_rational(1, 100); // 1% - - let ob = orderbook::create(NS {}, object::id(col), fee, ctx); - orderbook::toggle_protection_on_create_ask(NS {}, &mut ob); - - transfer::share_object(ob); - } - - /// The NFT can only be listed for sale if the domain is not expired. - public entry fun create_ask( - book: &mut Orderbook, - requsted_tokens: u64, - nft: NftOwned, - ctx: &mut TxContext, - ) { - assert!(!is_expired(&nft), 0); - orderbook::create_ask_protected(NS {}, book, requsted_tokens, nft, ctx) - } -} diff --git a/sources/bidding.move b/sources/bidding.move new file mode 100644 index 0000000..dadcdb4 --- /dev/null +++ b/sources/bidding.move @@ -0,0 +1,147 @@ +module liquidity_layer::bidding { + use liquidity_layer::collection::{Self, TradeReceipt, TradingWhitelist}; + use liquidity_layer::err; + use liquidity_layer::safe::{Self, Safe, TransferCap}; + use std::option::{Self, Option}; + use sui::balance::{Self, Balance}; + use sui::coin::{Self, Coin}; + use sui::object::{Self, ID, UID}; + use sui::transfer::{transfer, share_object}; + use sui::tx_context::{Self, TxContext}; + + struct Bidding has key { + id: UID, + } + + struct Bid has key { + id: UID, + nft: ID, + buyer: address, + offer: Balance, + commission: Option>, + } + + /// Enables collection of wallet/marketplace collection for buying NFTs. + /// 1. user bids via wallet to buy NFT for `p`, wallet wants fee `f` + /// 2. when executed, `p` goes to seller and `f` goes to wallet. + /// + /// + /// TODO: deduplicate with OB + struct BidCommission has store { + /// This is given to the facilitator of the trade. + cut: Balance, + /// A new `Coin` object is created and sent to this address. + beneficiary: address, + } + + /// TODO: deduplicate with OB + struct AskCommission has store, drop { + /// How many tokens of the transferred amount should go to the party + /// which holds the private key of `beneficiary` address. + /// + /// Always less than ask price. + cut: u64, + /// A new `Coin` object is created and sent to this address. + beneficiary: address, + } + + public entry fun create_bid( + _nft: address, + _amount: u64, + _wallet: &mut Coin, + _ctx: &mut TxContext, + ) { + abort(0) + } + + // TODO: opt commission + public entry fun sell_nft( + contract: &Bidding, + bid: &mut Bid, + nft_cap: TransferCap, + safe: &mut Safe, + whitelist: &TradingWhitelist, + ctx: &mut TxContext, + ) { + assert!( + object::id(safe) == safe::transfer_cap_safe_id(&nft_cap), + err::nft_collection_mismatch(), + ); + assert!( + bid.nft == safe::transfer_cap_nft_id(&nft_cap), + err::nft_collection_mismatch(), + ); + + let trade = collection::begin_nft_trade(&contract.id, ctx); + let ask_commission = option::none(); + pay_for_nft( + &mut trade, + &mut bid.offer, + bid.buyer, + &mut ask_commission, + ctx, + ); + option::destroy_none(ask_commission); + + let nft = safe::trade_nft(nft_cap, trade, whitelist, safe); + transfer(nft, bid.buyer); + + transfer_bid_commission(&mut bid.commission, ctx); + } + + // TODO: dedup + fun pay_for_nft( + trade: &mut TradeReceipt, + paid: &mut Balance, + buyer: address, + maybe_commission: &mut Option, + ctx: &mut TxContext, + ) { + let amount = balance::value(paid); + + if (option::is_some(maybe_commission)) { + // the `p`aid amount for the NFT and the commission `c`ut + + let AskCommission { + cut, beneficiary, + } = option::extract(maybe_commission); + + // `p` - `c` goes to seller + collection::add_nft_payment( + trade, + balance::split(paid, amount - cut), + buyer, + ctx, + ); + // `c` goes to the marketplace + collection::add_nft_payment( + trade, + balance::split(paid, cut), + beneficiary, + ctx, + ); + } else { + // no commission, all `p` goes to seller + + collection::add_nft_payment( + trade, + balance::split(paid, amount), + buyer, + ctx, + ); + }; + } + + // TODO: dedup + fun transfer_bid_commission( + commission: &mut Option>, + ctx: &mut TxContext, + ) { + if (option::is_some(commission)) { + let BidCommission { beneficiary, cut } = + option::extract(commission); + + transfer(coin::from_balance(cut, ctx), beneficiary); + }; + } +} diff --git a/sources/collection.move b/sources/collection.move new file mode 100644 index 0000000..5bfaeb0 --- /dev/null +++ b/sources/collection.move @@ -0,0 +1,107 @@ +//! Taken from https://github.com/MystenLabs/sui/pull/4887/files#diff-4097e0ffb7703cda3da51b586eba7657dfc6bfe919ca2b1be060aca6d71e8cd2 +//! and modified. +//! +//! This will eventually be part of a different package. + +module liquidity_layer::collection { + use sui::balance::Balance; + use sui::vec_set::{Self, VecSet}; + use std::vector; + use sui::object::{Self, ID, UID}; + use sui::transfer::transfer_to_object; + use sui::tx_context::TxContext; + use std::option::{Self, Option}; + + struct Collection has key, store { + id: UID, + } + + struct TradingWhitelist has key { + id: UID, + authority: address, + /// If None, then there's no whitelist and everyone is allowed. + /// Otherwise the ID must be in the vec set. + /// + /// Then we assert that the source from `TradeReceipt` is included in + /// this set. + entities: Option>, + } + + struct TradeReceipt has key, store { + id: UID, + /// ID of the source entity which began the trade. + source: ID, + /// Each trade is done with one of more payments. + /// + /// IDs of `TradePayment` child objects of this receipt. + payments: vector, + } + + struct TradePayment has key { + id: UID, + amount: Balance, + /// The address where the amount should be transferred to. + /// This could be either the payment for the seller or a marketplace's + /// commision. + beneficiary: address, + } + + /// Resolve the trade with [`safe::trade_nft`] + /// + /// Settlement + /// + /// The assumption here is that getting a reference to a UID can be only + /// done from within the contract that created an entity. Therefore, this + /// is kind of similar to a witness pattern but works with UID instead. + public fun begin_nft_trade( + source: &UID, + ctx: &mut TxContext, + ): TradeReceipt { + TradeReceipt { + id: object::new(ctx), + source: object::uid_to_inner(source), + payments: vector::empty(), + } + } + + public fun add_nft_payment( + trade: &mut TradeReceipt, + amount: Balance, + beneficiary: address, + ctx: &mut TxContext, + ) { + let payment = TradePayment { + id: object::new(ctx), + amount, + beneficiary, + }; + vector::push_back(&mut trade.payments, object::id(&payment)); + transfer_to_object(payment, trade); + } + + public fun extract_next_nft_payment( + _witness: W, + _trade: TradeReceipt, + ): TradePayment { + // TODO: wait for feature which enables us to reach child objects + // dynamically https://github.com/MystenLabs/sui/issues/4203 + abort(0) + } + + public fun has_some_nft_payment(trade: &TradeReceipt): bool { + !vector::is_empty(&trade.payments) + } + + public fun is_trade_source_whitelisted( + trade: &TradeReceipt, + whitelist: &TradingWhitelist, + ): bool { + if (option::is_none(&whitelist.entities)) { + return true + }; + + let entities = option::borrow(&whitelist.entities); + + vec_set::contains(entities, &trade.source) + } +} diff --git a/sources/crit_bit.move b/sources/crit_bit.move index 4df815b..93ab3e9 100644 --- a/sources/crit_bit.move +++ b/sources/crit_bit.move @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copied from: https://github.com/econia-labs/econia/blob/main/src/move/econia/sources/CritBit.move +// TODO: try again to use movemate /// # Module-level documentation sections /// @@ -401,7 +402,7 @@ /// /// --- /// -module syrup::crit_bit { +module liquidity_layer::crit_bit { use std::vector::{ borrow as v_b, borrow_mut as v_b_m, diff --git a/sources/err.move b/sources/err.move index bbe360a..603ac06 100644 --- a/sources/err.move +++ b/sources/err.move @@ -1,4 +1,4 @@ -module syrup::err { +module liquidity_layer::err { //! Exports error functions. All errors in this smart contract have a prefix //! which distinguishes them from errors in other packages. diff --git a/sources/foo_nft.move b/sources/foo_nft.move new file mode 100644 index 0000000..fedcab8 --- /dev/null +++ b/sources/foo_nft.move @@ -0,0 +1,20 @@ +module liquidity_layer::foo_nft { + use sui::object::UID; + use liquidity_layer::collection::{TradeReceipt}; + use liquidity_layer::safe::Safe; + + struct Witness {} + + /// The NFT itself. + struct Foo has key, store { + id: UID, + // ... + } + + public fun collect_royalty( + _receipt: TradeReceipt, + _safe: &mut Safe, + ) { + abort(0) + } +} diff --git a/sources/orderbook.move b/sources/orderbook.move index 4920bd6..35c10d6 100644 --- a/sources/orderbook.move +++ b/sources/orderbook.move @@ -1,44 +1,41 @@ -module syrup::orderbook { +module liquidity_layer::orderbook { //! Orderbook where bids are fungible tokens and asks are NFTs. //! A bid is a request to buy one NFT from a specific collection. //! An ask is one NFT with a min price condition. //! //! One can - //! - create a new orderbook between a given collection and a bid token - //! (witness pattern protected); - //! - collect fees (witness pattern protected); + //! - create a new orderbook between a given collection and a bid token; //! - set publicly accessible actions to be witness protected; //! - open a new bid; //! - cancel an existing bid they own; //! - offer an NFT if collection matches OB collection; //! - cancel an existing NFT offer; - //! - instantly buy an specific NFT. + //! - instantly buy a specific NFT; + //! - open bids and asks with a commission on behalf of a user. - // TODO: collect fees on trade + // TODO: protocol toll + // TODO: eviction of lowest bid/highest ask on OOM - use nft_protocol::nft::{Self, NftOwned}; - use syrup::err; - use std::fixed_point32::FixedPoint32; + use liquidity_layer::collection::{Self, TradeReceipt, TradingWhitelist}; + use liquidity_layer::crit_bit::{Self, CB as CBTree}; + use liquidity_layer::err; + use liquidity_layer::safe::{Self, Safe, ExclusiveTransferCap}; + use std::option::{Self, Option}; use std::vector; use sui::balance::{Self, Balance}; use sui::coin::{Self, Coin}; use sui::object::{Self, ID, UID}; - use sui::transfer::{transfer, transfer_to_object}; + use sui::transfer::{transfer, share_object}; use sui::tx_context::{Self, TxContext}; - use syrup::crit_bit::{Self, CB as CBTree}; /// A critbit order book implementation. Contains two ordered trees: /// 1. bids ASC /// 2. asks DESC - struct Orderbook has key { + struct Orderbook has key { id: UID, - /// Only NFTs belonging to this collection can be traded. - collection: ID, /// Actions which have a flag set to true can only be called via a /// witness protected implementation. protected_actions: WitnessProtectedActions, - /// Collects fees in fungible tokens. - fees: Fees, /// An ask order stores an NFT to be traded. The price associated with /// such an order is saying: /// @@ -51,17 +48,6 @@ module syrup::orderbook { bids: CBTree>>, } - struct Fees has store { - /// All fees during trading are collected here and the witness protected - /// method [`collect_fees`] is implemented by downstream packages. - /// - /// For example, in case of standard art collections, these fees - /// represent royalties. - uncollected: Balance, - /// in iterval `[0; 1)` - fee: FixedPoint32, - } - /// The contract which creates the orderbook can restrict specific actions /// to be only callable with a witness pattern and not via the entry point /// function. @@ -88,12 +74,25 @@ module syrup::orderbook { } /// An offer for a single NFT in a collection. - struct Bid has store { + struct Bid has store { /// How many "T"okens are being offered by the order issuer for one NFT. - offer: Balance, + offer: Balance, /// The address of the user who created this bid and who will receive an /// NFT in exchange for their tokens. owner: address, + /// If the NFT is offered via a marketplace or a wallet, the + /// faciliatator can optionally set how many tokens they want to claim + /// on top of the offer. + commission: Option>, + } + /// Enables collection of wallet/marketplace collection for buying NFTs. + /// 1. user bids via wallet to buy NFT for `p`, wallet wants fee `f` + /// 2. when executed, `p` goes to seller and `f` goes to wallet + struct BidCommission has store { + /// This is given to the facilitator of the trade. + cut: Balance, + /// A new `Coin` object is created and sent to this address. + beneficiary: address, } /// Object which is associated with a single NFT. @@ -107,10 +106,50 @@ module syrup::orderbook { id: UID, /// How many tokens does the seller want for their NFT in exchange. price: u64, - /// The pointer to the offered NFT. - nft: ID, + /// Capability to get an NFT from a safe. + nft_cap: ExclusiveTransferCap, /// Who owns the NFT. owner: address, + /// If the NFT is offered via a marketplace or a wallet, the + /// faciliatator can optionally set how many tokens they want to claim + /// from the price of the NFT for themselves as a commission. + commission: Option, + } + /// Enables collection of wallet/marketplace collection for listing an NFT. + /// 1. user lists NFT via wallet for price `p`, wallet requests fee `f` + /// 2. when executed, `p - f` goes to user and `f` goes to wallet + struct AskCommission has store, drop { + /// How many tokens of the transferred amount should go to the party + /// which holds the private key of `beneficiary` address. + /// + /// Always less than ask price. + cut: u64, + /// A new `Coin` object is created and sent to this address. + beneficiary: address, + } + + /// We cannot just give the `ExclusiveTransferCap` to the buyer. They could + /// decide to burn the NFT with their funds by never going to the + /// `Safe` object and finishing the trade, but rather keeping the NFT's + /// `ExlusiveTransferCap`. + /// + /// Therefore `TradeIntermediate` is made a share object and can be called + /// permissionlessly. + struct TradeIntermediate has key { + id: UID, + /// in option bcs we want to extract it but cannot destroy shared obj + /// in Sui yet + /// + /// https://github.com/MystenLabs/sui/issues/2083 + nft_cap: Option, + /// in option bcs we want to extract it but cannot destroy shared obj + /// in Sui yet + /// + /// https://github.com/MystenLabs/sui/issues/2083 + trade_receipt: Option>, + buyer: address, + paid: Balance, + commission: Option, } /// How many (`price`) fungible tokens should be taken from sender's wallet @@ -120,29 +159,59 @@ module syrup::orderbook { /// If the `price` is higher than the lowest ask requested price, then we /// execute a trade straight away. Otherwise we add the bid to the /// orderbook's state. - public entry fun create_bid( - book: &mut Orderbook, + public entry fun create_bid( + book: &mut Orderbook, + price: u64, + wallet: &mut Coin, + ctx: &mut TxContext, + ) { + assert!(book.protected_actions.create_bid, err::action_not_public()); + create_bid_(book, price, option::none(), wallet, ctx) + } + public fun create_bid_protected( + _witness: W, + book: &mut Orderbook, + price: u64, + wallet: &mut Coin, + ctx: &mut TxContext, + ) { + create_bid_(book, price, option::none(), wallet, ctx) + } + public entry fun create_bid_with_commission( + book: &mut Orderbook, price: u64, + beneficiary: address, + commission_ft: u64, wallet: &mut Coin, ctx: &mut TxContext, ) { assert!(book.protected_actions.create_bid, err::action_not_public()); - create_bid_(book, price, wallet, ctx) + let commission = BidCommission { + beneficiary, + cut: balance::split(coin::balance_mut(wallet), commission_ft), + }; + create_bid_(book, price, option::some(commission), wallet, ctx) } - public fun create_bid_protected( - _witness: C, - book: &mut Orderbook, + public fun create_bid_with_commission_protected( + _witness: W, + book: &mut Orderbook, price: u64, + beneficiary: address, + commission_ft: u64, wallet: &mut Coin, ctx: &mut TxContext, ) { - create_bid_(book, price, wallet, ctx) + let commission = BidCommission { + beneficiary, + cut: balance::split(coin::balance_mut(wallet), commission_ft), + }; + create_bid_(book, price, option::some(commission), wallet, ctx) } /// Cancel a bid owned by the sender at given price. If there are two bids /// with the same price, the one created later is cancelled. - public entry fun cancel_bid( - book: &mut Orderbook, + public entry fun cancel_bid( + book: &mut Orderbook, requested_bid_offer_to_cancel: u64, wallet: &mut Coin, ctx: &mut TxContext, @@ -150,9 +219,9 @@ module syrup::orderbook { assert!(book.protected_actions.cancel_bid, err::action_not_public()); cancel_bid_(book, requested_bid_offer_to_cancel, wallet, ctx) } - public fun cancel_bid_protected( - _witness: C, - book: &mut Orderbook, + public fun cancel_bid_protected( + _witness: W, + book: &mut Orderbook, requested_bid_offer_to_cancel: u64, wallet: &mut Coin, ctx: &mut TxContext, @@ -164,23 +233,81 @@ module syrup::orderbook { /// there exists a bid with higher offer than `requsted_tokens`, then trade /// is immeidately executed. Otherwise the NFT is transferred to a newly /// created ask object and the object is inserted to the orderbook. - public entry fun create_ask( - book: &mut Orderbook, + public entry fun create_ask( + book: &mut Orderbook, requsted_tokens: u64, - nft: NftOwned, + nft_cap: ExclusiveTransferCap, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { assert!(book.protected_actions.create_ask, err::action_not_public()); - create_ask_(book, requsted_tokens, nft, ctx) + create_ask_( + book, requsted_tokens, option::none(), nft_cap, safe, whitelist, ctx + ) } - public fun create_ask_protected( - _witness: C, - book: &mut Orderbook, + public fun create_ask_protected( + _witness: W, + book: &mut Orderbook, requsted_tokens: u64, - nft: NftOwned, + nft_cap: ExclusiveTransferCap, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { - create_ask_(book, requsted_tokens, nft, ctx) + create_ask_( + book, requsted_tokens, option::none(), nft_cap, safe, whitelist, ctx + ) + } + public entry fun create_ask_with_commission( + book: &mut Orderbook, + requsted_tokens: u64, + nft_cap: ExclusiveTransferCap, + beneficiary: address, + commission: u64, + safe: &mut Safe, + whitelist: &TradingWhitelist, + ctx: &mut TxContext, + ) { + assert!(book.protected_actions.create_ask, err::action_not_public()); + let commission = AskCommission { + cut: commission, + beneficiary, + }; + create_ask_( + book, + requsted_tokens, + option::some(commission), + nft_cap, + safe, + whitelist, + ctx, + ) + } + public fun create_ask_with_commission_protected( + _witness: W, + book: &mut Orderbook, + requsted_tokens: u64, + nft_cap: ExclusiveTransferCap, + beneficiary: address, + commission: u64, + safe: &mut Safe, + whitelist: &TradingWhitelist, + ctx: &mut TxContext, + ) { + let commission = AskCommission { + cut: commission, + beneficiary, + }; + create_ask_( + book, + requsted_tokens, + option::some(commission), + nft_cap, + safe, + whitelist, + ctx, + ) } /// We could remove the NFT requested price from the argument, but then the @@ -188,58 +315,65 @@ module syrup::orderbook { /// /// This API might be improved in future as we use a different data /// structure for the orderbook. - public entry fun cancel_ask( - book: &mut Orderbook, - requested_price_to_cancel: u64, + public entry fun cancel_ask( + book: &mut Orderbook, + nft_price: u64, nft_id: ID, ctx: &mut TxContext, ) { assert!(book.protected_actions.cancel_ask, err::action_not_public()); - cancel_ask_(book, requested_price_to_cancel, nft_id, ctx) + cancel_ask_(book, nft_price, nft_id, ctx) } - public entry fun cancel_ask_protected( - _witness: C, - book: &mut Orderbook, - requested_price_to_cancel: u64, + public entry fun cancel_ask_protected( + _witness: W, + book: &mut Orderbook, + nft_price: u64, nft_id: ID, ctx: &mut TxContext, ) { - cancel_ask_(book, requested_price_to_cancel, nft_id, ctx) + cancel_ask_(book, nft_price, nft_id, ctx) } /// Buys a specific NFT from the orderbook. This is an atypical OB API as /// with fungible tokens, you just want to get the cheapest ask. /// However, with NFTs, you might want to get a specific one. - public entry fun buy_nft( - book: &mut Orderbook, + public entry fun buy_nft( + book: &mut Orderbook, nft_id: ID, price: u64, wallet: &mut Coin, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { assert!(book.protected_actions.buy_nft, err::action_not_public()); - buy_nft_(book, nft_id, price, wallet, ctx) + buy_nft_(book, nft_id, price, wallet, safe, whitelist, ctx) } - public entry fun buy_nft_protected( - _witness: C, - book: &mut Orderbook, + public entry fun buy_nft_protected( + _witness: W, + book: &mut Orderbook, nft_id: ID, price: u64, wallet: &mut Coin, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { - buy_nft_(book, nft_id, price, wallet, ctx) - } - - /// After a bid is matched with an ask, the buyer can claim the NFT via this - /// method, because they will have received the ownership of the - /// corresponding [`Ask`] object. - public entry fun claim_nft( - ask: Ask, - nft: NftOwned, + buy_nft_(book, nft_id, price, wallet, safe, whitelist, ctx) + } + + /// When a bid is created and there's an ask with a lower price, then the + /// trade cannot be resolved immiedately. + /// That's because we don't know the `Safe` ID up front in OB. + /// Therefore, the tx creates `TradeIntermediate` which then has to be + /// permission-lessly resolved via this endpoint. + public entry fun finish_trade( + trade: &mut TradeIntermediate, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { - claim_nft_(ask, nft, ctx) + finish_trade_(trade, safe, whitelist, ctx) } /// `C`ollection kind of NFTs to be traded, and `F`ungible `T`oken to be @@ -251,68 +385,57 @@ module syrup::orderbook { /// To implement specific logic in your smart contract, you can toggle the /// protection on specific actions. That will make them only accessible via /// witness protected methods. - public fun create( - _witness: C, - collection: ID, - fee: FixedPoint32, + public entry fun create( ctx: &mut TxContext, - ): Orderbook { - create_(collection, fee, no_protection(), ctx) + ) { + let ob = create_(no_protection(), ctx); + share_object(ob); + } + public fun create_protected( + _witness: W, + ctx: &mut TxContext, + ): Orderbook { + create_(no_protection(), ctx) } - public fun toggle_protection_on_buy_nft( - _witness: C, - book: &mut Orderbook, + public fun toggle_protection_on_buy_nft( + _witness: W, + book: &mut Orderbook, ) { book.protected_actions.buy_nft = !book.protected_actions.buy_nft; } - public fun toggle_protection_on_cancel_ask( - _witness: C, - book: &mut Orderbook, + public fun toggle_protection_on_cancel_ask( + _witness: W, + book: &mut Orderbook, ) { book.protected_actions.cancel_ask = !book.protected_actions.cancel_ask; } - public fun toggle_protection_on_cancel_bid( - _witness: C, - book: &mut Orderbook, + public fun toggle_protection_on_cancel_bid( + _witness: W, + book: &mut Orderbook, ) { book.protected_actions.cancel_bid = !book.protected_actions.cancel_bid; } - public fun toggle_protection_on_create_ask( - _witness: C, - book: &mut Orderbook, + public fun toggle_protection_on_create_ask( + _witness: W, + book: &mut Orderbook, ) { book.protected_actions.create_ask = !book.protected_actions.create_ask; } - public fun toggle_protection_on_create_bid( - _witness: C, - book: &mut Orderbook, + public fun toggle_protection_on_create_bid( + _witness: W, + book: &mut Orderbook, ) { book.protected_actions.create_bid = !book.protected_actions.create_bid; } - /// The contract which instantiated the OB implements logic for distibuting - /// the fee based on its requirements. - public fun fees_balance( - _witness: C, - orderbook: &mut Orderbook, - ): &mut Balance { - fees_balance_(orderbook) - } - - public fun collection_id( - book: &Orderbook, - ): ID { - book.collection - } - - public fun borrow_bids( - book: &Orderbook, + public fun borrow_bids( + book: &Orderbook, ): &CBTree>> { &book.bids } @@ -325,8 +448,8 @@ module syrup::orderbook { bid.owner } - public fun borrow_asks( - book: &Orderbook, + public fun borrow_asks( + book: &Orderbook, ): &CBTree> { &book.asks } @@ -335,45 +458,32 @@ module syrup::orderbook { ask.price } - public fun ask_nft_id(ask: &Ask): ID { - ask.nft + public fun ask_nft(ask: &Ask): &ExclusiveTransferCap { + &ask.nft_cap } public fun ask_owner(ask: &Ask): address { ask.owner } - fun create_( - collection: ID, - fee: FixedPoint32, + fun create_( protected_actions: WitnessProtectedActions, ctx: &mut TxContext, - ): Orderbook { + ): Orderbook { let id = object::new(ctx); - let fees = Fees { - uncollected: balance::zero(), - fee, - }; - Orderbook { + Orderbook { id, - collection, - fees, protected_actions, asks: crit_bit::empty(), bids: crit_bit::empty(), } } - fun fees_balance_( - orderbook: &mut Orderbook, - ): &mut Balance { - &mut orderbook.fees.uncollected - } - - fun create_bid_( - book: &mut Orderbook, + fun create_bid_( + book: &mut Orderbook, price: u64, + bid_commission: Option>, wallet: &mut Coin, ctx: &mut TxContext, ) { @@ -389,7 +499,8 @@ module syrup::orderbook { crit_bit::min_key(asks) <= price; if (can_be_filled) { - let lowest_ask_price = crit_bit::min_key(asks); // TODO: recomputed + // OPTIMIZE: this is being recomputed + let lowest_ask_price = crit_bit::min_key(asks); let price_level = crit_bit::borrow_mut(asks, lowest_ask_price); let ask = vector::remove( @@ -402,10 +513,34 @@ module syrup::orderbook { vector::destroy_empty(crit_bit::pop(asks, lowest_ask_price)); }; - transfer(coin::from_balance(bid_offer, ctx), ask.owner); - transfer(ask, buyer); + let Ask { + id, + price: _, + owner: _, + nft_cap, + commission: ask_commission, + } = ask; + object::delete(id); + + // see also `finish_trade` entry point + share_object(TradeIntermediate { + id: object::new(ctx), + nft_cap: option::some(nft_cap), + commission: ask_commission, + buyer, + trade_receipt: option::some( + collection::begin_nft_trade(&book.id, ctx), + ), + paid: bid_offer, + }); + + transfer_bid_commission(bid_commission, ctx); } else { - let order = Bid { offer: bid_offer, owner: buyer }; + let order = Bid { + offer: bid_offer, + owner: buyer, + commission: bid_commission, + }; if (crit_bit::has_key(&book.bids, price)) { vector::push_back( @@ -422,8 +557,8 @@ module syrup::orderbook { } } - fun cancel_bid_( - book: &mut Orderbook, + fun cancel_bid_( + book: &mut Orderbook, requested_bid_offer_to_cancel: u64, wallet: &mut Coin, ctx: &mut TxContext, @@ -453,22 +588,36 @@ module syrup::orderbook { assert!(index < bids_count, err::order_owner_must_be_sender()); - let Bid { offer, owner: _owner } = vector::remove(price_level, index); + let Bid { offer, owner: _owner, commission } = + vector::remove(price_level, index); balance::join( coin::balance_mut(wallet), offer, ); + + if (option::is_some(&commission)) { + let BidCommission { cut, beneficiary: _ } = + option::extract(&mut commission); + balance::join( + coin::balance_mut(wallet), + cut, + ); + }; + option::destroy_none(commission); } - fun create_ask_( - book: &mut Orderbook, + fun create_ask_( + book: &mut Orderbook, price: u64, - nft: NftOwned, + ask_commission: Option, + nft_cap: ExclusiveTransferCap, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { assert!( - nft::collection_id(&nft) == book.collection, + object::id(safe) == safe::exclusive_transfer_cap_safe_id(&nft_cap), err::nft_collection_mismatch(), ); @@ -477,7 +626,7 @@ module syrup::orderbook { let bids = &mut book.bids; let can_be_filled = !crit_bit::is_empty(bids) && - crit_bit::max_key(bids) <= price; + crit_bit::max_key(bids) >= price; if (can_be_filled) { let highest_bid_price = crit_bit::max_key(bids); @@ -496,21 +645,33 @@ module syrup::orderbook { let Bid { owner: buyer, offer: bid_offer, + commission: bid_commission, } = bid; - // transfer FT to NFT owner - transfer(coin::from_balance(bid_offer, ctx), seller); - // transfer NFT to FT owner - transfer(nft, buyer); // TODO: use the nft transfer fn + + let trade = collection::begin_nft_trade(&book.id, ctx); + pay_for_nft( + &mut trade, + &mut bid_offer, + buyer, + &mut ask_commission, + ctx, + ); + option::destroy_none(ask_commission); + balance::destroy_zero(bid_offer); + + let nft = safe::trade_nft_exclusive(nft_cap, trade, whitelist, safe); + transfer(nft, buyer); + + transfer_bid_commission(bid_commission, ctx); } else { let id = object::new(ctx); let ask = Ask { id, price, - nft: object::id(&nft), owner: seller, + nft_cap, + commission: ask_commission, }; - // the NFT is now owned by the Ask object - transfer_to_object(nft, &mut ask); // store the Ask object if (crit_bit::has_key(&book.asks, price)) { vector::push_back( @@ -527,74 +688,106 @@ module syrup::orderbook { } } - fun cancel_ask_( - book: &mut Orderbook, - requested_price_to_cancel: u64, + fun cancel_ask_( + book: &mut Orderbook, + nft_price: u64, nft_id: ID, ctx: &mut TxContext, ) { let sender = tx_context::sender(ctx); - let ask = remove_ask( + let Ask { + owner, + id, + price: _, + nft_cap, + commission: _, + } = remove_ask( &mut book.asks, - requested_price_to_cancel, + nft_price, nft_id, ); - assert!(ask.owner != sender, err::order_owner_must_be_sender()); + assert!(owner != sender, err::order_owner_must_be_sender()); - // TODO: figure out whether we can provide NftOwned here and do the - // transfer without the intermediary step - transfer(ask, sender); + object::delete(id); + transfer(nft_cap, sender); } - fun buy_nft_( - book: &mut Orderbook, + fun buy_nft_( + book: &mut Orderbook, nft_id: ID, price: u64, wallet: &mut Coin, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { let buyer = tx_context::sender(ctx); - let ask = remove_ask( + let Ask { + id, + nft_cap, + owner: _, + price: _, + commission: maybe_commission, + } = remove_ask( &mut book.asks, price, nft_id, ); + object::delete(id); + + let bid_offer = balance::split(coin::balance_mut(wallet), price); - // pay the NFT owner - coin::split_and_transfer(wallet, price, ask.owner, ctx); + let trade = collection::begin_nft_trade(&book.id, ctx); + pay_for_nft( + &mut trade, + &mut bid_offer, + buyer, + &mut maybe_commission, + ctx, + ); + option::destroy_none(maybe_commission); + balance::destroy_zero(bid_offer); - // TODO: figure out whether we can provide NftOwned here and do the - // transfer without the intermediary step - transfer(ask, buyer); + let nft = safe::trade_nft_exclusive(nft_cap, trade, whitelist, safe); + transfer(nft, buyer); } - /// The NFT is owned by the ask object, the ownership is transferred in - /// the [`create_ask`] function. - /// Here, we destruct the ask and give the ownership of the NFT to the owner - /// of the ask object. - /// To become an owner of an ask object, one has to create a bid which is - /// filled. - fun claim_nft_( - ask: Ask, - nft: NftOwned, + fun finish_trade_( + trade: &mut TradeIntermediate, + safe: &mut Safe, + whitelist: &TradingWhitelist, ctx: &mut TxContext, ) { - let sender = tx_context::sender(ctx); + let TradeIntermediate { + id: _, + nft_cap, + trade_receipt, + paid, + buyer, + commission: maybe_commission, + } = trade; + + let trade = option::extract(trade_receipt); + let nft_cap = option::extract(nft_cap); - let Ask { - nft: nft_id, - id: ask_id, - price: _, - owner: _, - } = ask; + assert!( + object::id(safe) == safe::exclusive_transfer_cap_safe_id(&nft_cap), + err::nft_collection_mismatch(), + ); - assert!(nft_id == object::id(&nft), err::nft_id_mismatch()); + pay_for_nft( + &mut trade, + paid, + *buyer, + maybe_commission, + ctx, + ); - object::delete(ask_id); - transfer(nft, sender); // TODO: transfer must be done by the nft module + let nft = safe::trade_nft_exclusive(nft_cap, trade, whitelist, safe); + transfer(nft, *buyer); } /// Finds an ask of a given NFT advertized for the given price. Removes it @@ -612,18 +805,73 @@ module syrup::orderbook { while (asks_count > index) { let ask = vector::borrow(price_level, index); // on the same price level, we search for the specified NFT - if (nft_id == ask.nft) { + if (nft_id == safe::exclusive_transfer_cap_nft_id(&ask.nft_cap)) { break }; index = index + 1; }; - assert!(index < asks_count, err::order_owner_must_be_sender()); + assert!(index < asks_count, err::order_does_not_exist()); vector::remove(price_level, index) } + fun pay_for_nft( + trade: &mut TradeReceipt, + paid: &mut Balance, + buyer: address, + maybe_commission: &mut Option, + ctx: &mut TxContext, + ) { + let amount = balance::value(paid); + + if (option::is_some(maybe_commission)) { + // the `p`aid amount for the NFT and the commission `c`ut + + let AskCommission { + cut, beneficiary, + } = option::extract(maybe_commission); + + // `p` - `c` goes to seller + collection::add_nft_payment( + trade, + balance::split(paid, amount - cut), + buyer, + ctx, + ); + // `c` goes to the marketplace + collection::add_nft_payment( + trade, + balance::split(paid, cut), + beneficiary, + ctx, + ); + } else { + // no commission, all `p` goes to seller + + collection::add_nft_payment( + trade, + balance::split(paid, amount), + buyer, + ctx, + ); + }; + } + + fun transfer_bid_commission( + commission: Option>, + ctx: &mut TxContext, + ) { + if (option::is_some(&commission)) { + let BidCommission { beneficiary, cut } = + option::extract(&mut commission); + + transfer(coin::from_balance(cut, ctx), beneficiary); + }; + option::destroy_none(commission); + } + fun no_protection(): WitnessProtectedActions { WitnessProtectedActions { buy_nft: false, diff --git a/sources/safe.move b/sources/safe.move new file mode 100644 index 0000000..87b589f --- /dev/null +++ b/sources/safe.move @@ -0,0 +1,97 @@ +//! Taken from https://github.com/MystenLabs/sui/pull/4887/files#diff-96b0dc07cabd79292618c993cd473c43ca81cd2f742266014967cdea1a7c6186 +//! and modified. +//! +//! This will eventually be part of a different package. + +module liquidity_layer::safe { + use sui::object::{ID, UID}; + use sui::transfer::transfer_to_object; + use liquidity_layer::collection::{Self, TradeReceipt, TradingWhitelist}; + + /// A shared object for storing NFT's of type `T`, owned by the holder of a unique `OwnerCap`. + /// Permissions to allow others to list NFT's can be granted via TransferCap's and BorrowCap's + struct Safe has key { + id: UID, + owner: address, + // ... contains the fields from MR + } + + /// A unique capability held by the owner of a particular `Safe`. + /// The holder can issue and revoke `TransferCap`'s and `BorrowCap`'s. + /// Can be used an arbitrary number of times + struct OwnerCap has key, store { + id: UID, + /// The ID of the safe that this capability grants permissions to + safe_id: ID, + } + + /// Gives the holder permission to transfer the nft with id `nft_id` out of + /// the safe with id `safe_id`. Can only be used once. + struct TransferCap has key, store { + id: UID, + safe_id: ID, + nft_id: ID, + } + + // TODO: should this be a separate type? it's more explicit, but need to + // duplicate fns. we could also have it as a flag on `TransferCap` + struct ExclusiveTransferCap has key, store { + id: UID, + safe_id: ID, + nft_id: ID, + } + + /// Produce a `TransferCap` for the NFT with `id` in `safe`. + /// This `TransferCap` can be (e.g.) used to list the NFT on a marketplace. + public fun sell_nft(_owner_cap: &OwnerCap, _id: ID, _safe: &mut T): TransferCap { + abort(0) + } + + public fun trade_nft( + _cap: TransferCap, + _trade: TradeReceipt, + _whitelist: &TradingWhitelist, + _safe: &mut Safe, + ): C { + abort(0) + } + + public fun trade_nft_exclusive( + _cap: ExclusiveTransferCap, + trade: TradeReceipt, + whitelist: &TradingWhitelist, + safe: &mut Safe, + ): C { + // we cannot know whether the trade payment was honest or whether there + // was a side payment, but at least we know that the payment was + // considered and therefore if a contract wanted to avoid royalties, + // they'd have to be _explicitly_ malicious + assert!(collection::has_some_nft_payment(&trade), 0); + + assert!(collection::is_trade_source_whitelisted(&trade, whitelist), 0); + + transfer_to_object(trade, safe); + + abort(0) + } + + public fun safe_owner(safe: &Safe): address { + safe.owner + } + + public fun exclusive_transfer_cap_safe_id(cap: &ExclusiveTransferCap): ID { + cap.safe_id + } + + public fun exclusive_transfer_cap_nft_id(cap: &ExclusiveTransferCap): ID { + cap.nft_id + } + + public fun transfer_cap_safe_id(cap: &TransferCap): ID { + cap.safe_id + } + + public fun transfer_cap_nft_id(cap: &TransferCap): ID { + cap.nft_id + } +}